Files
Nix-Vibe/home-manager/modules/quickshell-shell.qml
T
petere eb08cd4282 Nix-Vibe public snapshot (squashed history)
Current state of main at 0240060 feat(hp-laptop): install TeleportFling from its flake. History intentionally
collapsed to a single commit; this repo mirrors only the latest state.
2026-09-19 13:53:39 +01:00

6694 lines
212 KiB
QML

//@ pragma UseQApplication
// ~/.config/quickshell/shell.qml
// QuickShell desktop shell for Hyprland (Nix-Vibe hp-laptop).
//
// "Sci-fi dark glass + vivid neon" theme:
// - frosted-glass pill bar (blur via Hyprland layerrule, namespace qs-neon-bar)
// - workspace pills with neon bloom on the active one
// - icon status row: battery (UPower), volume (PipeWire), brightness
// (brightnessctl), Wi-Fi (Networking); scroll volume/brightness chips
// - notification center (native NotificationServer): toasts + history/DND/clear
// - workspace overview (SUPER+W): lite mission control, jump with a click
// - Orbitron display font, Symbols Nerd Font icons
// - HUD corner brackets per monitor with a slow pulse
// - animated accent sweep along the bar's bottom edge
// QuickShell live-reloads this file on save.
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Networking
import Quickshell.Services.Mpris
import Quickshell.Services.Notifications
import Quickshell.Services.Pipewire
import Quickshell.Services.UPower
import Quickshell.Services.SystemTray
import Quickshell.Wayland
import QtQuick
import QtQuick.Layouts
ShellRoot {
id: root
// Keep-awake: while true the compositor is told the session must not go
// idle, so hypridle's lock/blank/suspend listeners never fire.
// Session-lifetime only (resets on logout) — it's meant as a temporary hold.
property bool keepAwake: false
// ---------- system stats (qs-stats probe) ----------
// Parsed JSON from the stats probe; refreshed on the slow bar timer and
// fast while the popup is open.
property var stats: null
readonly property string statsScript: "/home/petere/.local/bin/qs-stats"
// Pinned flake.lock info (nixpkgs node) injected at build time by hyprland.nix.
readonly property var flakeInfo: @FLAKEINFO@
// Build-time theme colors injected by hyprland.nix from the shared palette
// (home-manager/modules/palette.nix). At startup these may be repainted by a
// wallpaper-driven palette (~/.cache/quickshell/theme.json, see
// applyRuntimeTheme/themeOverrideProc below).
readonly property var themeColors: @THEMECOLORS@
// helpers for the chip/popup display values
function statsHot() {
if (root.stats === null || root.stats.temps === undefined || root.stats.temps.length === 0)
return 0;
let hot = 0;
for (const t of root.stats.temps)
hot = Math.max(hot, t.temp);
return hot;
}
function statsAgeDays() {
if (root.flakeInfo === null || root.flakeInfo.lastModified === undefined)
return -1;
return Math.max(0, Math.floor((Date.now() / 1000 - root.flakeInfo.lastModified) / 86400));
}
function statsRev() {
if (root.flakeInfo === null || root.flakeInfo.rev === undefined)
return "";
return root.flakeInfo.rev.substring(0, 8);
}
// ---------- autostart app manager ----------
property var appsEntries: []
property bool appsAddOpen: false
property string appsNewName: ""
property string appsNewCmd: ""
// ---------- battery charge limit (gear quick-settings panel) ----------
// Backend `battery-charge-limit` (NixOS hardware/battery-limit modules) is
// provided by both the HP module (acpi_call SBCO/SBCC) and the ThinkPad
// module (native charge_control_start/end_threshold sysfs). The on/off
// switch refreshes the persisted state file via `status` and toggles via
// `sudo <script> on|off`.
property bool chargeLimitActive: false
property bool chargeLimitBusy: false
readonly property string chargeLimitScript: "/run/current-system/sw/bin/battery-charge-limit"
// ============================ Theme ============================
QtObject {
id: theme
// Surfaces (translucent over the Hyprland layer blur). Mutable so a
// wallpaper-driven palette can repaint them at startup.
property color glass: root.themeColors.glass
property color glassPanel: root.themeColors.glassPanel
property color surface: root.themeColors.surface
property color line: root.themeColors.line
// Text
property color text: root.themeColors.text
property color muted: root.themeColors.muted
property color ink: root.themeColors.ink
// Neon accents
property color neon: root.themeColors.neon
property color magenta: root.themeColors.magenta
property color violet: root.themeColors.violet
property color danger: root.themeColors.danger
property color cyan: root.themeColors.cyan
property color blue: root.themeColors.blue
// Fonts
readonly property string displayFont: "Orbitron"
readonly property string iconFont: "Symbols Nerd Font"
readonly property int fontSize: 12
// Metrics
readonly property int barHeight: 38
readonly property int radius: 12
readonly property bool hudFrame: true // toggle monitor corner brackets
}
// Apply a wallpaper-driven palette (qs-theme / matugen). Expects a JSON
// object with hex '#rgb' values for surface/ink/text/muted/line/neon/
// violet/magenta/danger. Translucent panels are re-derived from `surface`.
function applyRuntimeTheme(j) {
if (j === null || typeof j !== "object") return;
const set = (name, val) => {
if (val !== undefined && val !== null) theme[name] = val;
};
set("surface", j.surface);
set("ink", j.ink);
set("text", j.text);
set("muted", j.muted);
set("line", j.line);
set("neon", j.neon);
set("violet", j.violet);
set("magenta", j.magenta);
set("danger", j.danger);
if (j.surface !== undefined) {
theme.glass = Qt.rgba(theme.surface.r, theme.surface.g, theme.surface.b, 0.55);
theme.glassPanel = Qt.rgba(theme.surface.r, theme.surface.g, theme.surface.b, 0.76);
}
}
// Watches the wallpaper-driven palette (written by `qs-theme` via matugen
// to ~/.cache/quickshell/theme.json) and repaints the theme on any change,
// including at startup. No-op if the file doesn't exist yet.
FileView {
id: themeFile
path: "file:///home/petere/.cache/quickshell/theme.json"
watchChanges: true
onLoaded: {
try {
root.applyRuntimeTheme(JSON.parse(text()));
} catch (e) { }
}
onFileChanged: themeFile.reload()
}
// ============================ HUD frame ============================
// Transparent background-layer panel per monitor: glowing corner brackets
// with a slow pulse. Sits below windows; top brackets clear of the bar.
Variants {
model: Quickshell.screens
PanelWindow {
id: hud
required property var modelData
screen: modelData
anchors {
top: true
bottom: true
left: true
right: true
}
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.namespace: "qs-hud"
exclusiveZone: 0
implicitWidth: modelData.width
implicitHeight: modelData.height
color: "transparent"
visible: theme.hudFrame
// model rows: [xFrac, yFrac, dirX, dirY]
Repeater {
model: [
[0, 0, 1, 1],
[1, 0, -1, 1],
[0, 1, 1, -1],
[1, 1, -1, -1]
]
Item {
required property var modelData
width: 36
height: 36
x: modelData[0] === 0 ? 6 : hud.width - width - 6
y: modelData[1] === 0 ? 6 + theme.barHeight + 2 : hud.height - height - 6
// L-shaped bracket: horizontal leg
Rectangle {
width: parent.width
height: 2
radius: 1
color: theme.neon
anchors.top: modelData[1] === 0 ? parent.top : undefined
anchors.bottom: modelData[1] === 1 ? parent.bottom : undefined
anchors.left: modelData[2] === 1 ? parent.left : undefined
anchors.right: modelData[2] === -1 ? parent.right : undefined
}
// vertical leg
Rectangle {
width: 2
height: parent.height
radius: 1
color: theme.neon
anchors.left: modelData[2] === 1 ? parent.left : undefined
anchors.right: modelData[2] === -1 ? parent.right : undefined
anchors.top: modelData[1] === 0 ? parent.top : undefined
anchors.bottom: modelData[1] === 1 ? parent.bottom : undefined
}
SequentialAnimation on opacity {
loops: Animation.Infinite
running: true
NumberAnimation {
from: 0.35
to: 0.8
duration: 1800
easing.type: Easing.InOutSine
}
NumberAnimation {
from: 0.8
to: 0.35
duration: 1800
easing.type: Easing.InOutSine
}
}
}
}
}
}
// ============================ Top bar ============================
// Dynamic island: a centered floating pill that tucks up to the screen
// top edge (only a peek strip stays visible), slides in fully on hover or
// event (workspace switch / notification / media change — see
// root.islandRaise), and auto-retracts after 10s of no interaction.
Variants {
model: Quickshell.screens
PanelWindow {
id: bar
required property var modelData
screen: modelData
// island geometry: centered pill, tucked up when hidden
readonly property int islandWidth: Math.min(1160, modelData.width - 64)
readonly property int peekPixels: 12 // strip left visible when tucked
// (must stay > barInner.bottomMargin so the rounded glass edge +
// cyan hairline remain visible as a hover target)
// Hover tracking: a counter of active hover sources (peek-area MouseArea
// + every chip MouseArea in the bar). The hovered flag stays true while
// the cursor is anywhere inside the bar, preventing premature collapse
// when moving from the peek strip onto a chip.
property int hoverCount: 0
readonly property bool hovered: bar.hoverCount > 0
property bool anyPopupVisible: quickPopup.visible || volPopup.visible
|| netPopup.visible || btPanel.visible || notifPopup.visible || statsPopup.visible
|| root.calOpen || root.wsOpen || root.helpOpen || root.clipOpen || root.wallOpen || root.btOpen
property bool revealed: bar.hovered || root.islandRevealed || bar.anyPopupVisible
property real topMargin: bar.revealed ? 0 : -(theme.barHeight - bar.peekPixels)
anchors {
top: true
left: true
}
implicitWidth: bar.islandWidth
implicitHeight: theme.barHeight
exclusiveZone: 0
margins.top: bar.topMargin
margins.left: (modelData.width - bar.islandWidth) / 2
Behavior on topMargin {
NumberAnimation {
duration: 260
easing.type: Easing.OutCubic
}
}
color: "transparent"
WlrLayershell.namespace: "qs-neon-bar"
// Hover detection for the whole island (clicks still fall through to
// the chips below because acceptedButtons is NoButton).
MouseArea {
id: islandHover
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
}
// Inhibit session idle while keep-awake is on (compositors treat
// PanelWindows as important, so this blocks lock/blank/suspend).
IdleInhibitor {
window: bar
enabled: root.keepAwake
}
// ---------- Wi-Fi (native Networking over NetworkManager DBus) ----------
readonly property WifiDevice wifiDev: {
for (const d of Networking.devices.values) {
if (d.type === DeviceType.Wifi)
return d;
}
return null;
}
readonly property string connectedSsid: {
if (wifiDev === null || !wifiDev.connected)
return "";
for (const n of wifiDev.networks.values) {
if (n.connected)
return n.name;
}
return "";
}
// ---------- Battery (UPower display device or real laptop battery) ----------
readonly property var battery: {
const dd = UPower.displayDevice;
if (dd.ready && dd.isLaptopBattery)
return dd;
const b = UPower.devices.values.find(d => d.isLaptopBattery);
return b === undefined ? null : b;
}
readonly property bool batteryCharging: bar.battery !== null && bar.battery.state === UPowerDeviceState.Charging
// ---------- Volume (PipeWire default sink) ----------
readonly property var sink: Pipewire.defaultAudioSink
readonly property real sinkVolume: bar.sink !== null && bar.sink.audio !== null ? bar.sink.audio.volume : 0
readonly property bool sinkMuted: bar.sink !== null && bar.sink.audio !== null ? bar.sink.audio.muted : false
// PwNodeAudio.volume/muted are only valid while the node is tracked.
PwObjectTracker {
objects: bar.sink === null ? [] : [bar.sink]
}
// Symbols Nerd Font (FontAwesome set, BMP) — battery/lock/volume/wifi.
// NOTE: QuickShell UPowerDevice.percentage is a 0..1 fraction (it
// divides UPower's 0-100 by 100), despite the docs' wording.
function batteryIcon(pct) {
if (pct >= 0.9)
return "\uf240";
if (pct >= 0.66)
return "\uf241";
if (pct >= 0.4)
return "\uf242";
if (pct >= 0.15)
return "\uf243";
return "\uf244";
}
function volumeIcon(vol, muted) {
if (muted || vol <= 0.005)
return "\uf026"; // volume-off
if (vol >= 0.55)
return "\uf028"; // volume-up
return "\uf027"; // volume-down
}
// ---------- frosted glass body ----------
Rectangle {
id: barInner
anchors {
fill: parent
leftMargin: 8
rightMargin: 8
bottomMargin: 5
}
radius: theme.radius
color: theme.glass
border.color: theme.line
border.width: 1
clip: true
// static bottom hairline: cyan -> violet gradient
Rectangle {
anchors {
bottom: parent.bottom
left: parent.left
right: parent.right
margins: theme.radius
}
height: 1
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0
color: "#0000e5ff"
}
GradientStop {
position: 0.5
color: "#8000e5ff"
}
GradientStop {
position: 1
color: "#007c4dff"
}
}
}
// animated accent sweep along the bottom edge
Rectangle {
id: barSweep
width: 180
height: 2
radius: 1
y: barInner.height - 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0
color: "#0000e5ff"
}
GradientStop {
position: 0.5
color: "#b300e5ff"
}
GradientStop {
position: 1
color: "#00ff2e97"
}
}
SequentialAnimation on x {
loops: Animation.Infinite
running: true
NumberAnimation {
from: 0
to: barInner.width - 180
duration: 9000
easing.type: Easing.InOutSine
}
NumberAnimation {
from: barInner.width - 180
to: 0
duration: 9000
easing.type: Easing.InOutSine
}
}
}
}
RowLayout {
anchors {
fill: barInner
leftMargin: 10
rightMargin: 10
}
spacing: 12
// ---------- Left: workspace pills ----------
Row {
Layout.alignment: Qt.AlignVCenter
spacing: 5
Repeater {
model: Hyprland.workspaces
Rectangle {
id: ws
required property var modelData
// Hide empty/transient workspaces (e.g. a closed scratchpad) so
// the bar doesn't keep showing their pills after they close.
visible: modelData.windows > 0
height: 24
radius: 8
color: modelData.focused
? theme.neon
: (modelData.active ? theme.line : theme.surface)
Behavior on color { ColorAnimation { duration: 200 } }
// pill width hugs the text ("1" is narrow, "special:term" is wide)
implicitWidth: wsText.implicitWidth + 16
// neon bloom behind the active pill (stacked translucent halos
// — they can spread beyond the pill, unlike a layer blur)
Repeater {
model: ws.modelData.focused ? [4, 2] : []
Rectangle {
required property int index
anchors.centerIn: parent
width: ws.width + modelData
height: ws.height + modelData
radius: 8 + modelData / 2
color: theme.neon
opacity: index === 0 ? 0.14 : 0.22
Behavior on opacity { NumberAnimation { duration: 200 } }
}
}
Text {
id: wsText
anchors.centerIn: parent
text: ws.modelData.name || ws.modelData.id
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
color: ws.modelData.focused ? theme.ink : theme.muted
Behavior on color { ColorAnimation { duration: 200 } }
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: ws.modelData.activate()
}
// Reveal the island bar on workspace switch.
Connections {
target: ws.modelData
function onFocusedChanged() {
if (ws.modelData.focused && root.osdArmed)
root.islandRaise();
}
}
}
}
}
// ---------- Center: focused window title ----------
Text {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
horizontalAlignment: Text.AlignHCenter
text: Hyprland.activeToplevel ? Hyprland.activeToplevel.title : ""
color: theme.muted
font.pixelSize: theme.fontSize
elide: Text.ElideRight
}
// ---------- Right: status cluster + clock ----------
Row {
Layout.alignment: Qt.AlignVCenter
spacing: 12
// quick-settings (gear)
Rectangle {
id: gearButton
anchors.verticalCenter: parent.verticalCenter
width: 26
height: 24
radius: 8
color: quickPopup.visible ? theme.surface : "transparent"
border.color: quickPopup.visible ? theme.neon : "transparent"
border.width: 1
Text {
anchors.centerIn: parent
text: "\uf013"
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: theme.text
SequentialAnimation on rotation {
loops: Animation.Infinite
running: quickPopup.visible
NumberAnimation {
to: 360
duration: 4000
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: quickPopup.visible = !quickPopup.visible
}
}
// now playing (MPRIS): click toggles play/pause
Text {
id: mediaText
anchors.verticalCenter: parent.verticalCenter
visible: root.mediaPlayer !== null && root.mediaPlayer.trackTitle !== ""
width: Math.min(implicitWidth, 260)
text: (root.mediaPlayer !== null && root.mediaPlayer.isPlaying ? "\uf04c " : "\uf04b ")
+ (root.mediaPlayer !== null ? root.mediaPlayer.trackTitle : "")
+ (root.mediaPlayer !== null && root.mediaPlayer.trackArtist !== "" ? " \u2014 " + root.mediaPlayer.trackArtist : "")
font.pixelSize: theme.fontSize - 1
color: root.mediaPlayer !== null && root.mediaPlayer.isPlaying ? theme.neon : theme.muted
elide: Text.ElideRight
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: {
if (root.mediaPlayer !== null && root.mediaPlayer.canTogglePlaying)
root.mediaPlayer.togglePlaying();
}
}
}
// volume (click to open inline slider popup)
Rectangle {
id: volButton
anchors.verticalCenter: parent.verticalCenter
implicitWidth: volText.implicitWidth + 14
height: 24
radius: 8
color: volPopup.visible ? theme.surface : "transparent"
border.color: volPopup.visible ? theme.neon : "transparent"
border.width: 1
visible: bar.sink !== null
Text {
id: volText
anchors.centerIn: parent
text: bar.volumeIcon(bar.sinkVolume, bar.sinkMuted) + " " + Math.round(bar.sinkVolume * 100) + "%"
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: bar.sinkMuted ? theme.muted : theme.text
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: volPopup.visible = !volPopup.visible
onWheel: event => {
// scroll to nudge volume by one 5% step (OSD follows
// automatically via the shellVol change tracking).
// Touchpads report pixelDelta with a zero angleDelta, so use
// whichever delta is non-zero for the direction.
if (bar.sink === null || bar.sink.audio === null)
return;
const dy = event.angleDelta.y !== 0 ? event.angleDelta.y : event.pixelDelta.y;
const step = dy > 0 ? 0.05 : -0.05;
bar.sink.audio.muted = false;
bar.sink.audio.volume = Math.max(0, Math.min(1, bar.sink.audio.volume + step));
}
}
}
// brightness (scroll to adjust + OSD, click opens quick settings)
Rectangle {
id: brightButton
anchors.verticalCenter: parent.verticalCenter
implicitWidth: brightText.implicitWidth + 14
height: 24
radius: 8
color: "transparent"
border.color: "transparent"
border.width: 1
Text {
id: brightText
anchors.centerIn: parent
text: "\uf185 " + (root.brightNow >= 0 ? root.brightNow + "%" : "--")
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: theme.text
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: quickPopup.visible = !quickPopup.visible
onWheel: event => {
if (root.brightSetBusy)
return;
// touchpads: pixelDelta direction, angleDelta may be zero
const dy = event.angleDelta.y !== 0 ? event.angleDelta.y : event.pixelDelta.y;
const step = dy > 0 ? 5 : -5;
const next = Math.max(0, Math.min(100, (root.brightNow >= 0 ? root.brightNow : 50) + step));
root.brightNow = next;
root.brightSetBusy = true;
shellBrightSet.exec(["brightnessctl", "s", next + "%"]);
osdWin.showBright();
}
}
}
// Wi-Fi (opens menu)
Rectangle {
id: wifiButton
anchors.verticalCenter: parent.verticalCenter
implicitWidth: wifiRow.implicitWidth + 14
height: 24
radius: 8
color: netPopup.visible ? theme.surface : "transparent"
border.color: netPopup.visible ? theme.neon : "transparent"
border.width: 1
Row {
id: wifiRow
anchors.centerIn: parent
spacing: 4
Text {
anchors.verticalCenter: parent.verticalCenter
text: "\uf1eb"
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: !Networking.wifiEnabled ? theme.muted
: (bar.connectedSsid !== "" ? theme.neon : theme.text)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: bar.connectedSsid !== "" ? bar.connectedSsid : (Networking.wifiEnabled ? "Wi-Fi" : "off")
font.pixelSize: theme.fontSize - 1
color: bar.connectedSsid !== "" ? theme.text : theme.muted
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: netPopup.visible = !netPopup.visible
}
}
// Bluetooth (opens menu)
Rectangle {
id: btButton
anchors.verticalCenter: parent.verticalCenter
implicitWidth: btRow.implicitWidth + 14
height: 24
radius: 8
color: btPanel.visible ? theme.surface : "transparent"
border.color: btPanel.visible ? theme.neon : "transparent"
border.width: 1
Row {
id: btRow
anchors.centerIn: parent
spacing: 4
Text {
anchors.verticalCenter: parent.verticalCenter
text: "\uf294"
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: root.btPowered === null || root.btPowered === false ? theme.muted
: (root.btConnected > 0 ? theme.neon : theme.text)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.btPowered === false || root.btPowered === null
? "off"
: (root.btConnected > 0 ? root.btConnected + " devices" : "Bluetooth")
font.pixelSize: theme.fontSize - 1
color: root.btPowered === false || root.btPowered === null ? theme.muted : theme.text
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: {
netPopup.visible = false;
root.btOpen = !root.btOpen;
}
}
}
// keep-awake indicator (state lives in the quick-settings panel)
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.keepAwake
text: "\uf0f4" // coffee
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: theme.magenta
}
// on-screen keyboard / tablet-mode (click toggles wvkbd). Icon + color
// follow the state file written by the hyprland-tablet daemon.
Rectangle {
id: oskButton
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 26
height: 24
radius: 8
color: root.oskActive ? theme.surface : "transparent"
border.color: root.oskActive ? theme.neon : "transparent"
border.width: 1
Text {
anchors.centerIn: parent
text: root.tabletActive ? "\uf108" /* tablet */ : "\uf11c" /* keyboard */
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: root.oskActive ? theme.neon
: (root.tabletActive ? theme.magenta : theme.muted)
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: root.oskToggle()
}
}
// notification center (bell)
Rectangle {
id: notifButton
anchors.verticalCenter: parent.verticalCenter
width: 26
height: 24
radius: 8
color: notifPopup.visible ? theme.surface : "transparent"
border.color: notifPopup.visible ? theme.neon : "transparent"
border.width: 1
Text {
anchors.centerIn: parent
text: root.notifDnd ? "\uf2ed" : "\uf0f3" // bell-slash / bell
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: root.notifDnd ? theme.muted
: (root.notifUnseen > 0 ? theme.magenta : theme.text)
}
// unseen-dot badge (cleared when the center opens)
Rectangle {
visible: root.notifUnseen > 0 && !root.notifOpen
anchors {
top: parent.top
right: parent.right
}
width: 6
height: 6
radius: 3
color: theme.magenta
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: root.notifOpen = !root.notifOpen
}
}
// system tray (StatusNotifier): per-app icons + DBusMenu context menus
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 4
visible: trayList.count > 0
Repeater {
id: trayList
model: SystemTray.items
Item {
id: trayItem
required property var modelData
property bool hovered: false
width: 26
height: 24
Rectangle {
anchors.fill: parent
radius: 8
color: trayItem.hovered ? theme.surface : "transparent"
border.color: trayMenu.visible ? theme.neon : "transparent"
border.width: 1
}
Image {
id: trayIcon
anchors.centerIn: parent
width: 18
height: 18
source: trayItem.modelData.icon
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
}
// fallback: first letter when the icon can't be resolved
Text {
anchors.centerIn: parent
visible: trayIcon.status !== Image.Ready
text: trayItem.modelData.title !== "" ? trayItem.modelData.title.charAt(0).toUpperCase() : "?"
font.family: theme.displayFont
font.pixelSize: 11
font.bold: true
color: theme.muted
}
// right-click context menu (the app's DBusMenu)
QsMenuAnchor {
id: trayMenu
menu: trayItem.modelData.menu
anchor.window: bar
anchor.rect: {
const pos = trayItem.mapToItem(bar.contentItem, 0, 0);
return Qt.rect(pos.x, pos.y, trayItem.width, trayItem.height);
}
anchor.edges: Edges.Bottom | Edges.Left
}
MouseArea {
id: trayArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onEntered: { trayItem.hovered = true; bar.hoverCount++ }
onExited: { trayItem.hovered = false; bar.hoverCount-- }
onWheel: event => {
trayItem.modelData.scroll(
event.angleDelta.y > 0 ? 1 : -1,
(event.modifiers & Qt.ShiftModifier) !== 0
);
}
onClicked: event => {
if (event.button === Qt.RightButton) {
if (trayItem.modelData.hasMenu)
trayMenu.open();
else
trayItem.modelData.secondaryActivate();
} else if (event.button === Qt.MiddleButton) {
trayItem.modelData.secondaryActivate();
} else if (event.button === Qt.LeftButton) {
trayItem.modelData.activate();
}
}
}
}
}
}
// system stats (CPU/temp; quick probe refresh — click opens details)
Rectangle {
id: statsButton
anchors.verticalCenter: parent.verticalCenter
implicitWidth: statsRow.implicitWidth + 14
height: 24
radius: 8
color: statsPopup.visible ? theme.surface : "transparent"
border.color: statsPopup.visible ? theme.neon : "transparent"
border.width: 1
Row {
id: statsRow
anchors.centerIn: parent
spacing: 4
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.statsHot() >= 80 ? "\uf2c6" : "\uf2c7" // thermometer-full / half
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: root.statsHot() >= 80 ? theme.danger
: (root.statsHot() >= 65 ? theme.magenta : theme.neon)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.stats === null ? "--" : (Math.round(root.stats.cpu) + "%")
font.family: theme.displayFont
font.pixelSize: 9
color: root.stats === null ? theme.muted
: (root.stats.cpu >= 85 ? theme.danger : theme.text)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.stats === null ? "" : (Math.round(root.statsHot()) + "\u00b0")
font.pixelSize: theme.fontSize - 1
color: theme.muted
}
// separator
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 12
color: theme.line
}
// battery (UPower): icon + %, magenta when charging, danger <15%
Text {
anchors.verticalCenter: parent.verticalCenter
visible: bar.battery !== null
text: bar.battery === null ? "" : (bar.batteryCharging ? "\uf0e7" : bar.batteryIcon(bar.battery.percentage))
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: bar.battery === null ? theme.muted
: (bar.batteryCharging ? theme.magenta
: (bar.battery.percentage < 0.15 ? theme.danger : theme.text))
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: bar.battery !== null
text: bar.battery === null ? "" : Math.round(bar.battery.percentage * 100) + "%"
font.family: theme.displayFont
font.pixelSize: 9
color: bar.battery === null ? theme.muted
: (bar.battery.percentage < 0.15 ? theme.danger : theme.muted)
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: {
netPopup.visible = false;
quickPopup.visible = false;
statsPopup.visible = !statsPopup.visible;
}
}
}
// clock (Orbitron, neon cyan) — click opens the calendar+weather popup
Rectangle {
id: clockButton
anchors.verticalCenter: parent.verticalCenter
implicitWidth: clockRow.implicitWidth + 16
height: 24
radius: 8
color: root.calOpen ? theme.surface : "transparent"
border.color: root.calOpen ? theme.neon : "transparent"
border.width: 1
Row {
id: clockRow
anchors.centerIn: parent
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDateTime(timeSource.date, "HH:mm")
font.family: theme.displayFont
font.pixelSize: 13
font.bold: true
color: theme.neon
}
Text {
anchors.verticalCenter: parent.verticalCenter
color: theme.muted
font.pixelSize: 8
text: Qt.formatDateTime(timeSource.date, "ddd d MMM")
}
}
SystemClock {
id: timeSource
precision: SystemClock.Seconds
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: bar.hoverCount++
onExited: bar.hoverCount--
onClicked: {
netPopup.visible = false;
quickPopup.visible = false;
root.btOpen = false;
root.notifOpen = false;
root.wsOpen = false;
root.calOpen = !root.calOpen;
}
}
}
}
}
// ================= Quick settings =================
// Volume (PipeWire), screen brightness (brightnessctl), session actions.
PopupWindow {
id: quickPopup
implicitWidth: 300
implicitHeight: quickColumn.implicitHeight + 20
visible: false
grabFocus: true
color: "transparent"
property int brightNow: -1 // cached percent from the last query
property var actions: [
{
"label": "LOCK",
"icon": "\uf023",
"cmd": ["hyprlock"],
"accent": theme.neon
},
{
"label": "EXIT",
"icon": "\uf08b",
"cmd": ["hyprctl", "dispatch", "exit"],
"accent": theme.muted
},
{
"label": "REBOOT",
"icon": "\uf021",
"cmd": ["sudo", "systemctl", "reboot"],
"accent": theme.violet
},
{
"label": "POWER",
"icon": "\uf011",
"cmd": ["sudo", "systemctl", "poweroff"],
"accent": theme.danger
}
]
anchor {
item: gearButton
edges: Edges.Bottom
}
// refresh backlight + volume + autostart list state on open
onVisibleChanged: {
if (visible) {
brightQuery.running = true;
volSlider.value = bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume);
root.appsRefresh();
root.chargeLimitRefresh();
}
}
// `brightnessctl g` + `brightnessctl m` in one go: "<cur> <max>"
Process {
id: brightQuery
command: ["sh", "-c", "echo $(brightnessctl g) $(brightnessctl m)"]
running: false
stdout: SplitParser {
onRead: line => {
const parts = line.trim().split(/\s+/).map(Number);
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1]) && parts[1] > 0) {
brightSlider.value = Math.max(0, Math.min(1, parts[0] / parts[1]));
quickPopup.brightNow = Math.round(brightSlider.value * 100);
}
}
}
}
// throttled setter: skip while a previous request is still running
Process {
id: brightSet
running: false
}
Rectangle {
anchors.fill: parent
radius: theme.radius
color: theme.glassPanel
border.color: theme.neon
border.width: 1
}
ColumnLayout {
id: quickColumn
anchors {
fill: parent
margins: 10
}
spacing: 10
Text {
text: "SYSTEM // QUICK CONTROL"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
Layout.fillWidth: true
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
// volume
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: bar.volumeIcon(bar.sinkVolume, bar.sinkMuted)
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: bar.sinkMuted ? theme.muted : theme.text
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: {
if (bar.sink !== null && bar.sink.audio !== null)
bar.sink.audio.muted = !bar.sink.audio.muted;
}
}
}
SciSlider {
id: volSlider
Layout.fillWidth: true
value: bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume)
onMoved: v => {
if (bar.sink !== null && bar.sink.audio !== null) {
bar.sink.audio.muted = false;
bar.sink.audio.volume = v;
}
}
}
Text {
text: bar.sinkMuted ? "MUTE" : Math.round(volSlider.value * 100) + "%"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.preferredWidth: 44
horizontalAlignment: Text.AlignRight
}
}
// brightness
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "\uf185" // sun
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: theme.text
}
SciSlider {
id: brightSlider
Layout.fillWidth: true
accent: theme.magenta
value: 0.5
onMoved: v => {
quickPopup.brightNow = Math.round(v * 100);
if (!brightSet.running)
brightSet.exec(["brightnessctl", "s", quickPopup.brightNow + "%"]);
}
}
Text {
text: quickPopup.brightNow >= 0 ? quickPopup.brightNow + "%" : "--"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.preferredWidth: 44
horizontalAlignment: Text.AlignRight
}
}
// power profile (power-profiles-daemon via Quickshell.UPower)
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "PWR"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
}
Repeater {
model: [
{
"label": "SAVER",
"profile": PowerProfile.PowerSaver,
"enabled": true
},
{
"label": "BALANCED",
"profile": PowerProfile.Balanced,
"enabled": true
},
{
"label": "PERF",
"profile": PowerProfile.Performance,
"enabled": PowerProfiles.hasPerformanceProfile
}
].filter(m => m.enabled)
Rectangle {
id: pfPill
required property var modelData
implicitWidth: pfText.implicitWidth + 16
implicitHeight: 22
radius: 6
color: PowerProfiles.profile === modelData.profile ? theme.neon : "transparent"
border.color: PowerProfiles.profile === modelData.profile ? theme.neon : theme.line
border.width: 1
Behavior on color { ColorAnimation { duration: 150 } }
Text {
id: pfText
anchors.centerIn: parent
text: pfPill.modelData.label
font.family: theme.displayFont
font.pixelSize: 8
color: PowerProfiles.profile === pfPill.modelData.profile ? theme.ink : theme.muted
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: PowerProfiles.profile = pfPill.modelData.profile
}
}
}
}
// keep awake: hold off lock/screen-off/suspend (idle inhibitor)
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "\uf0f4" // coffee
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: root.keepAwake ? theme.magenta : theme.text
}
Text {
text: "KEEP AWAKE"
color: root.keepAwake ? theme.magenta : theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.fillWidth: true
}
Rectangle {
implicitWidth: 38
implicitHeight: 20
radius: 10
color: root.keepAwake ? theme.magenta : theme.line
Rectangle {
anchors.verticalCenter: parent.verticalCenter
x: root.keepAwake ? 20 : 2
width: 16
height: 16
radius: 8
color: theme.ink
Behavior on x {
NumberAnimation {
duration: 140
easing.type: Easing.InOutQuad
}
}
}
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.keepAwake = !root.keepAwake
}
}
}
// charge limit: stop charging at 80% (battery health); on = limit on
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "\uf240" // battery-3/4
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: root.chargeLimitActive ? theme.neon : theme.text
}
Text {
text: "CHARGE LIMIT"
color: root.chargeLimitActive ? theme.neon : theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.fillWidth: true
}
Text {
text: Math.round(bar.battery ? bar.battery.percentage * 100 : 0) + "%"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
visible: root.chargeLimitActive
}
Rectangle {
implicitWidth: 38
implicitHeight: 20
radius: 10
color: root.chargeLimitActive ? theme.neon : theme.line
Rectangle {
anchors.verticalCenter: parent.verticalCenter
x: root.chargeLimitActive ? 20 : 2
width: 16
height: 16
radius: 8
color: theme.ink
Behavior on x {
NumberAnimation {
duration: 140
easing.type: Easing.InOutQuad
}
}
}
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.chargeLimitToggle()
}
}
}
// --- autostart apps: pick which installed apps start at login ---
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 4
spacing: 8
Text {
text: "\uf135" // rocket
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: theme.neon
}
Text {
text: "APPS ON LOGIN"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 9
Layout.fillWidth: true
}
Rectangle {
implicitWidth: 20
implicitHeight: 20
radius: 4
color: root.appsAddOpen ? theme.neon : "transparent"
border.color: theme.neon
border.width: 1
Text {
anchors.centerIn: parent
text: "\uf067" // plus
font.family: theme.iconFont
font.pixelSize: 10
color: root.appsAddOpen ? theme.ink : theme.neon
}
MouseArea {
anchors.fill: parent
anchors.margins: -2
cursorShape: Qt.PointingHandCursor
onClicked: {
root.appsAddOpen = !root.appsAddOpen;
if (!root.appsAddOpen) {
root.appsNewName = "";
root.appsNewCmd = "";
}
}
}
}
}
// add form (name + command), collapsed by default
ColumnLayout {
visible: root.appsAddOpen
Layout.fillWidth: true
spacing: 4
RowLayout {
Layout.fillWidth: true
spacing: 4
Text {
text: "NAME"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
Layout.preferredWidth: 36
}
Rectangle {
Layout.fillWidth: true
height: 24
radius: 6
color: theme.surface
border.color: theme.line
border.width: 1
TextInput {
anchors {
fill: parent
leftMargin: 6
rightMargin: 6
}
verticalAlignment: Text.AlignVCenter
color: theme.text
font.pixelSize: 11
selectByMouse: true
onTextChanged: root.appsNewName = text
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 4
Text {
text: "CMD"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
Layout.preferredWidth: 36
}
Rectangle {
Layout.fillWidth: true
height: 24
radius: 6
color: theme.surface
border.color: theme.line
border.width: 1
TextInput {
anchors {
fill: parent
leftMargin: 6
rightMargin: 6
}
verticalAlignment: Text.AlignVCenter
color: theme.text
font.family: theme.displayFont
font.pixelSize: 11
selectByMouse: true
onTextChanged: root.appsNewCmd = text
}
}
Rectangle {
implicitWidth: 34
height: 24
radius: 6
color: (root.appsNewName && root.appsNewCmd) ? theme.neon : theme.surface
border.color: theme.neon
border.width: 1
Text {
anchors.centerIn: parent
text: "ADD"
font.family: theme.displayFont
font.pixelSize: 8
color: (root.appsNewName && root.appsNewCmd) ? theme.ink : theme.neon
}
MouseArea {
anchors.fill: parent
anchors.margins: -2
cursorShape: Qt.PointingHandCursor
enabled: root.appsNewName && root.appsNewCmd
onClicked: {
root.appsAdd(root.appsNewName, root.appsNewCmd);
root.appsNewName = "";
root.appsNewCmd = "";
root.appsAddOpen = false;
}
}
}
}
}
ListView {
id: appsListView
Layout.fillWidth: true
Layout.preferredHeight: Math.min(appsListView.contentHeight, 140)
Layout.topMargin: 4
clip: true
boundsBehavior: Flickable.StopAtBounds
spacing: 2
model: root.appsEntries
delegate: Rectangle {
id: appsRow
required property var modelData
width: appsListView.width
height: 32
radius: 6
color: appsRowMa.containsMouse ? theme.surface : "transparent"
Text {
anchors {
left: parent.left
verticalCenter: parent.verticalCenter
leftMargin: 8
}
text: appsRow.modelData.name
color: theme.text
font.family: theme.displayFont
font.pixelSize: 11
}
// remove (user-added entries only)
Text {
visible: appsRow.modelData.source === "user"
anchors {
right: appsRowTog.left
verticalCenter: parent.verticalCenter
rightMargin: 6
}
text: "\uf00d" // times
font.family: theme.iconFont
font.pixelSize: 12
color: theme.danger
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.appsRemove(appsRow.modelData.name)
}
}
// toggle knob (keep-awake style)
Rectangle {
id: appsRowTog
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
rightMargin: 8
}
implicitWidth: 38
implicitHeight: 20
radius: 10
color: appsRow.modelData.enabled ? theme.neon : theme.line
Rectangle {
anchors.verticalCenter: parent.verticalCenter
x: appsRow.modelData.enabled ? 20 : 2
width: 16
height: 16
radius: 8
color: theme.ink
Behavior on x {
NumberAnimation {
duration: 140
easing.type: Easing.InOutQuad
}
}
}
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.appsToggle(appsRow.modelData.name)
}
}
MouseArea {
id: appsRowMa
anchors.fill: parent
hoverEnabled: true
z: -1
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
// action buttons: LOCK / EXIT / REBOOT / POWER
Row {
Layout.fillWidth: true
spacing: 8
Repeater {
model: quickPopup.actions
Rectangle {
required property var modelData
width: (quickColumn.width - 3 * 8) / 4
height: 48
radius: 8
color: actMa.containsMouse ? theme.surface : "transparent"
border.color: modelData.accent
border.width: actMa.containsMouse ? 2 : 1
Column {
anchors.centerIn: parent
spacing: 2
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.icon
font.family: theme.iconFont
font.pixelSize: theme.fontSize + 2
color: modelData.accent
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.label
font.family: theme.displayFont
font.pixelSize: 8
color: theme.muted
}
}
MouseArea {
id: actMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Quickshell.execDetached(modelData.cmd)
}
}
}
}
}
// keep the volume slider in sync with app/media-key changes
Connections {
target: bar
function onSinkVolumeChanged() {
if (!volSlider.pressed && !bar.sinkMuted)
volSlider.value = Math.min(1, bar.sinkVolume);
}
function onSinkMutedChanged() {
if (!volSlider.pressed)
volSlider.value = bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume);
}
}
}
// ================= System stats =================
// CPU/RAM/swap/disk/temps + pinned-flake badge. Popup-only full probe
// keeps the CPU sample off the always-on chip.
PopupWindow {
id: statsPopup
implicitWidth: 340
implicitHeight: statsColumn.implicitHeight + 24
visible: false
grabFocus: true
color: "transparent"
anchor {
item: statsButton
edges: Edges.Bottom
}
onVisibleChanged: {
if (visible) {
statsProbe.exec(["sh", "-c", root.statsScript]);
statsPopupTimer.running = true;
} else {
statsPopupTimer.running = false;
}
}
Rectangle {
anchors {
fill: parent
topMargin: 4
}
radius: theme.radius
color: theme.glassPanel
border.color: theme.neon
border.width: 1
}
ColumnLayout {
id: statsColumn
anchors {
fill: parent
margins: 10
}
spacing: 8
// Header
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "SYSTEM"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 11
font.bold: true
Layout.fillWidth: true
}
Text {
text: root.stats === null ? "…" : (root.stats.freq + " GHz")
color: theme.muted
font.pixelSize: 10
}
}
// CPU
RowLayout {
Layout.fillWidth: true
spacing: 6
Text {
text: "CPU"
color: theme.text
font.pixelSize: 10
Layout.preferredWidth: 34
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 6
radius: 3
color: theme.line
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 1
anchors.top: parent.top
anchors.topMargin: 1
anchors.bottom: parent.bottom
anchors.bottomMargin: 1
width: (parent.width - 2) * (root.stats === null ? 0 : Math.min(1, root.stats.cpu / 100))
radius: 2
color: root.stats === null ? theme.muted
: (root.stats.cpu >= 85 ? theme.danger
: (root.stats.cpu >= 60 ? theme.magenta : theme.neon))
}
}
Text {
text: root.stats === null ? "--" : (root.stats.cpu + "%")
color: theme.text
font.family: theme.displayFont
font.pixelSize: 9
Layout.preferredWidth: 40
horizontalAlignment: Text.AlignRight
}
}
// RAM
RowLayout {
Layout.fillWidth: true
spacing: 6
Text {
text: "RAM"
color: theme.text
font.pixelSize: 10
Layout.preferredWidth: 34
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 6
radius: 3
color: theme.line
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 1
anchors.top: parent.top
anchors.topMargin: 1
anchors.bottom: parent.bottom
anchors.bottomMargin: 1
width: (parent.width - 2) * (root.stats === null ? 0 : Math.min(1, root.stats.ram.pct / 100))
radius: 2
color: root.stats === null ? theme.muted
: (root.stats.ram.pct >= 85 ? theme.danger
: (root.stats.ram.pct >= 65 ? theme.magenta : theme.violet))
}
}
Text {
text: root.stats === null ? "--" : (root.stats.ram.used + "G / " + root.stats.ram.total + "G")
color: theme.text
font.pixelSize: 9
font.family: theme.displayFont
Layout.preferredWidth: 92
horizontalAlignment: Text.AlignRight
}
}
// Swap (only when nonzero)
RowLayout {
Layout.fillWidth: true
spacing: 6
visible: root.stats !== null && root.stats.swap.total > 0
Text {
text: "SWAP"
color: theme.text
font.pixelSize: 10
Layout.preferredWidth: 34
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 6
radius: 3
color: theme.line
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 1
anchors.top: parent.top
anchors.topMargin: 1
anchors.bottom: parent.bottom
anchors.bottomMargin: 1
width: (parent.width - 2) * (root.stats === null ? 0 : Math.min(1, root.stats.swap.pct / 100))
radius: 2
color: theme.muted
}
}
Text {
text: root.stats === null ? "--" : (root.stats.swap.used + "G / " + root.stats.swap.total + "G")
color: theme.text
font.pixelSize: 9
font.family: theme.displayFont
Layout.preferredWidth: 92
horizontalAlignment: Text.AlignRight
}
}
// Disk root
RowLayout {
Layout.fillWidth: true
spacing: 6
Text {
text: "DISK"
color: theme.text
font.pixelSize: 10
Layout.preferredWidth: 34
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 6
radius: 3
color: theme.line
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 1
anchors.top: parent.top
anchors.topMargin: 1
anchors.bottom: parent.bottom
anchors.bottomMargin: 1
width: (parent.width - 2) * (root.stats === null ? 0 : Math.min(1, root.stats.disk.pct / 100))
radius: 2
color: root.stats !== null && root.stats.disk.pct >= 90 ? theme.danger
: (root.stats !== null && root.stats.disk.pct >= 75 ? theme.magenta : theme.neon)
}
}
Text {
text: root.stats === null ? "--" : (root.stats.disk.used + "G / " + root.stats.disk.total + "G")
color: theme.text
font.pixelSize: 9
font.family: theme.displayFont
Layout.preferredWidth: 92
horizontalAlignment: Text.AlignRight
}
}
// Temperatures
Flow {
Layout.fillWidth: true
spacing: 4
Repeater {
model: root.stats === null ? [] : root.stats.temps
Rectangle {
required property var modelData
implicitWidth: rowText.implicitWidth + 10
implicitHeight: 18
radius: 4
color: modelData.temp >= 80 ? "#3df7768e" : (modelData.temp >= 65 ? "#3fff2e97" : "#38a9b1d6")
Text {
id: rowText
anchors.centerIn: parent
text: modelData.name + " " + Math.round(modelData.temp) + "\u00b0"
font.pixelSize: 8
font.family: theme.displayFont
color: modelData.temp >= 65 ? theme.ink : (modelData.temp >= 45 ? theme.text : theme.muted)
}
}
}
}
// Load average
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "LOAD"
color: theme.text
font.pixelSize: 10
Layout.preferredWidth: 34
}
Text {
text: root.stats === null || root.stats.load.length === 0 ? "--" : root.stats.load.join(" ")
color: theme.muted
font.pixelSize: 9
font.family: theme.displayFont
horizontalAlignment: Text.AlignRight
Layout.fillWidth: true
}
Text {
text: root.stats === null ? "" : (Math.round(root.stats.uptime / 3600) + "h up")
color: theme.muted
font.pixelSize: 9
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
// Pinned flake badge: rev + date + staleness + copy-update-cmd
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "\uf120" // terminal
font.family: theme.iconFont
font.pixelSize: 12
color: theme.muted
}
ColumnLayout {
spacing: 2
Layout.fillWidth: true
Text {
text: "NIXPKGS"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 9
font.bold: true
}
Text {
text: root.statsRev() + " \u00b7 " + (root.statsAgeDays() < 0 ? "--" : root.statsAgeDays() + "d old")
color: theme.muted
font.pixelSize: 9
}
}
Rectangle {
Layout.preferredWidth: 116
Layout.preferredHeight: 26
radius: 6
color: copyMa.containsMouse ? theme.surface : "transparent"
border.color: theme.violet
border.width: copyMa.containsMouse ? 2 : 1
Text {
id: flakeCopyLabel
anchors.centerIn: parent
text: "\uf0c1 UPDATE CMD"
font.family: theme.displayFont
font.pixelSize: 7
color: theme.violet
}
MouseArea {
id: copyMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.flakeCopy()
}
}
}
}
}
// ================= Wi-Fi menu =================
PopupWindow {
id: netPopup
implicitWidth: 320
implicitHeight: netColumn.implicitHeight + 24
visible: false
grabFocus: true
color: "transparent"
// Secured network awaiting a password (null = prompt hidden)
property var pendingNetwork: null
// Error text from the last failed connect attempt ("" = no error)
property string errText: ""
anchor {
item: wifiButton
edges: Edges.Bottom
}
// Keep the NetworkManager scan list live only while the menu is open.
onVisibleChanged: {
if (bar.wifiDev !== null)
bar.wifiDev.scannerEnabled = visible;
if (!visible) {
netPopup.pendingNetwork = null;
netPopup.errText = "";
pskInput.text = "";
}
}
// Password flow for networks NetworkManager has never seen:
// Quickshell's connectWithPsk() creates an incomplete profile (no
// key-mgmt) for new networks and only logs DBus errors, so connect
// via nmcli instead — it builds a full profile and its stderr is
// surfaced in the menu. (Saved/open networks still use the native
// connect() path in the row click handler.)
Process {
id: wifiConnectProc
running: false
stdout: StdioCollector {
onStreamFinished: netPopup.errText = ""
}
stderr: StdioCollector {
onStreamFinished: netPopup.errText = this.text.trim()
}
onExited: (code) => {
if (code === 0) {
netPopup.pendingNetwork = null;
netPopup.errText = "";
pskInput.text = "";
} else if (netPopup.errText === "") {
netPopup.errText = "Connection failed (nmcli exit " + code + ")";
}
}
}
Rectangle {
anchors {
fill: parent
topMargin: 4
}
radius: theme.radius
color: theme.glassPanel
border.color: theme.neon
border.width: 1
}
ColumnLayout {
id: netColumn
anchors {
top: parent.top
left: parent.left
right: parent.right
topMargin: 16
leftMargin: 8
rightMargin: 8
bottomMargin: 8
}
spacing: 6
// Header: title + Wi-Fi radio toggle
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "WI-FI"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 11
font.bold: true
Layout.fillWidth: true
}
Rectangle {
implicitWidth: 38
implicitHeight: 20
radius: 10
color: Networking.wifiEnabled ? theme.neon : theme.line
Rectangle {
anchors.verticalCenter: parent.verticalCenter
x: Networking.wifiEnabled ? 20 : 2
width: 16
height: 16
radius: 8
color: theme.ink
Behavior on x {
NumberAnimation {
duration: 140
easing.type: Easing.InOutQuad
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: Networking.wifiEnabled = !Networking.wifiEnabled
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
// Empty / scanning placeholder
Text {
visible: bar.wifiDev === null || bar.wifiDev.networks.count === 0
text: bar.wifiDev === null
? "No Wi-Fi adapter"
: (bar.wifiDev.scannerEnabled ? "Scanning…" : "No networks found")
color: theme.muted
font.pixelSize: 12
}
// Network rows (live ObjectModel from NetworkManager)
Repeater {
model: bar.wifiDev !== null ? bar.wifiDev.networks : null
Rectangle {
id: netRow
required property WifiNetwork modelData
Layout.fillWidth: true
implicitHeight: 32
radius: 6
color: netRowMa.containsMouse ? theme.surface : "transparent"
Text {
anchors {
left: parent.left
verticalCenter: parent.verticalCenter
leftMargin: 6
}
text: (netRow.modelData.security !== WifiSecurityType.None ? "\uf023 " : "") + netRow.modelData.name
color: netRow.modelData.connected ? theme.neon : theme.text
font.pixelSize: 12
}
Text {
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
rightMargin: 6
}
text: netRow.modelData.stateChanging
? "…"
: (netRow.modelData.connected ? "LINK" : Math.round(netRow.modelData.signalStrength * 100) + "%")
color: netRow.modelData.connected ? theme.neon : theme.muted
font.family: theme.displayFont
font.pixelSize: 9
}
MouseArea {
id: netRowMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (netRow.modelData.connected) {
netRow.modelData.disconnect();
return;
}
netPopup.pendingNetwork = null;
netPopup.errText = "";
pskInput.text = "";
if (netRow.modelData.known || netRow.modelData.security === WifiSecurityType.None) {
netRow.modelData.connect();
} else {
netPopup.pendingNetwork = netRow.modelData;
pskInput.forceActiveFocus();
}
}
}
// If connect() fails because NetworkManager has no stored
// secret, fall back to the inline password prompt.
Connections {
target: netRow.modelData
function onConnectionFailed(reason) {
if (reason === ConnectionFailReason.NoSecrets) {
netPopup.pendingNetwork = netRow.modelData;
pskInput.forceActiveFocus();
}
}
}
}
}
// Inline password prompt
ColumnLayout {
id: pskColumn
Layout.fillWidth: true
Layout.topMargin: 4
visible: netPopup.pendingNetwork !== null
spacing: 6
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
Text {
text: "PASSWORD // " + (netPopup.pendingNetwork !== null ? netPopup.pendingNetwork.name : "")
color: theme.magenta
font.family: theme.displayFont
font.pixelSize: 9
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 26
radius: 6
color: theme.surface
border.color: theme.neon
border.width: pskInput.activeFocus ? 2 : 1
TextInput {
id: pskInput
anchors {
fill: parent
margins: 5
}
clip: true
color: theme.text
echoMode: TextInput.Password
font.pixelSize: 12
onAccepted: pskColumn.submit()
}
}
RowLayout {
spacing: 8
Rectangle {
implicitWidth: connectText.implicitWidth + 16
implicitHeight: 24
radius: 6
color: connectMa.containsMouse ? theme.line : theme.neon
Text {
id: connectText
anchors.centerIn: parent
text: "CONNECT"
color: theme.ink
font.family: theme.displayFont
font.pixelSize: 9
font.bold: true
}
MouseArea {
id: connectMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: pskColumn.submit()
}
}
Rectangle {
implicitWidth: cancelText.implicitWidth + 16
implicitHeight: 24
radius: 6
color: cancelMa.containsMouse ? theme.surface : "transparent"
border.color: theme.line
Text {
id: cancelText
anchors.centerIn: parent
text: "CANCEL"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
}
MouseArea {
id: cancelMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
netPopup.pendingNetwork = null;
netPopup.errText = "";
pskInput.text = "";
}
}
}
Text {
visible: wifiConnectProc.running
text: "LINKING…"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 9
}
}
Text {
visible: netPopup.errText !== ""
Layout.fillWidth: true
text: netPopup.errText
color: theme.danger
font.pixelSize: 11
wrapMode: Text.Wrap
}
function submit() {
if (netPopup.pendingNetwork === null || pskInput.text === "")
return;
netPopup.errText = "";
wifiConnectProc.exec([
"nmcli", "device", "wifi", "connect",
netPopup.pendingNetwork.name,
"password", pskInput.text
]);
}
}
Item {
Layout.preferredHeight: 2
}
}
}
// ================= Bluetooth menu =================
PanelWindow {
id: btPanel
screen: modelData
anchors {
top: true
right: true
}
exclusiveZone: 0
margins.top: theme.barHeight + 8
// align right edge with the island's right edge, not the screen's
// (bar is a centered pill now, so screen-right no longer matches it)
margins.right: (modelData.width - bar.islandWidth) / 2 + 210
focusable: true
visible: root.btOpen && modelData === Quickshell.screens[0]
implicitWidth: 340
implicitHeight: Math.min(btColumn.implicitHeight + 24, modelData.height - 140)
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-bt"
onVisibleChanged: {
if (visible) {
root.btError = "";
root.btRefresh();
}
}
HyprlandFocusGrab {
windows: [btPanel]
active: btPanel.visible
onCleared: root.btOpen = false
}
Rectangle {
anchors.fill: parent
radius: theme.radius
color: theme.glassPanel
border.color: theme.neon
border.width: 1
}
ColumnLayout {
id: btColumn
anchors {
fill: parent
margins: 8
}
spacing: 6
// Header: title + power toggle
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "BLUETOOTH"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 11
font.bold: true
Layout.fillWidth: true
}
Rectangle {
implicitWidth: 38
implicitHeight: 20
radius: 10
color: root.btPowered === true ? theme.neon : theme.line
Rectangle {
anchors.verticalCenter: parent.verticalCenter
x: root.btPowered === true ? 20 : 2
width: 16
height: 16
radius: 8
color: theme.ink
Behavior on x {
NumberAnimation {
duration: 140
easing.type: Easing.InOutQuad
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.btRun(["power", root.btPowered === true ? "off" : "on"])
}
}
}
// Subline: adapter status + scan action
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
Layout.fillWidth: true
text: root.btPowered === null
? "No Bluetooth adapter"
: (root.btPowered === true
? (root.btAdapterName !== "" ? root.btAdapterName : "adapter ready")
: "adapter off")
color: theme.muted
font.pixelSize: 11
elide: Text.ElideRight
}
Rectangle {
implicitWidth: scanText.implicitWidth + 16
implicitHeight: 22
radius: 5
visible: root.btPowered === true && !root.btBusy
color: scanMa.containsMouse ? theme.line : "transparent"
border.color: theme.neon
border.width: 1
Text {
id: scanText
anchors.centerIn: parent
text: root.btDiscovering ? "SCANNING…" : "SCAN"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 9
font.bold: true
}
MouseArea {
id: scanMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.btScan()
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
// Busy / error line
Text {
visible: root.btBusy || root.btError !== ""
Layout.fillWidth: true
text: root.btBusy ? "WORKING…" : root.btError
color: root.btBusy ? theme.neon : theme.danger
font.family: theme.displayFont
font.pixelSize: 10
wrapMode: Text.Wrap
}
// Empty state
Text {
visible: root.btPowered === true && root.btDevices.length === 0 && !root.btBusy && root.btError === ""
Layout.fillWidth: true
text: root.btDiscovering ? "Scanning…" : "No devices — try SCAN"
color: theme.muted
font.pixelSize: 12
}
// Device rows
Repeater {
model: root.btDevices
Rectangle {
id: btRow
required property var modelData
Layout.fillWidth: true
implicitHeight: 32
radius: 6
color: btRowMa.containsMouse ? theme.surface : "transparent"
Row {
anchors {
left: parent.left
verticalCenter: parent.verticalCenter
leftMargin: 6
}
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
text: btRow.modelData.icon === "audio-headset" || btRow.modelData.icon === "audio-card"
? "\uf025"
: (btRow.modelData.icon === "computer" ? "\uf109"
: (btRow.modelData.icon === "input-keyboard" ? "\uf11c"
: (btRow.modelData.icon === "input-mouse" ? "\uf8cc"
: (btRow.modelData.icon === "phone" ? "\uf10b" : "\uf294"))))
font.family: theme.iconFont
font.pixelSize: 13
color: btRow.modelData.connected ? theme.neon : theme.muted
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: btRow.modelData.name !== "" ? btRow.modelData.name : btRow.modelData.mac
color: btRow.modelData.connected ? theme.neon : theme.text
font.pixelSize: 12
elide: Text.ElideRight
}
}
Text {
anchors {
right: forgetBtn.left
verticalCenter: parent.verticalCenter
rightMargin: 6
}
text: btRow.modelData.connected
? "LINK"
: (btRow.modelData.paired
? (btRow.modelData.battery !== null ? btRow.modelData.battery + "%" : "PAIRED")
: "")
color: btRow.modelData.connected ? theme.neon : theme.muted
font.family: theme.displayFont
font.pixelSize: 9
}
// forget (unpair) — topmost, stops row click propagation
Rectangle {
id: forgetBtn
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
rightMargin: 4
}
width: 20
height: 20
radius: 4
visible: btRow.modelData.paired || btRow.modelData.connected
color: forgetMa.containsMouse ? theme.danger : "transparent"
Text {
anchors.centerIn: parent
text: "\uf2ed"
font.family: theme.iconFont
font.pixelSize: 10
color: theme.muted
}
MouseArea {
id: forgetMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.btRun(["remove", btRow.modelData.mac])
}
}
MouseArea {
id: btRowMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.btAct(btRow.modelData.mac)
}
}
}
Item {
Layout.preferredHeight: 2
}
}
}
// ================= Volume popup =================
PopupWindow {
id: volPopup
implicitWidth: 220
implicitHeight: volPopColumn.implicitHeight + 20
visible: false
grabFocus: true
color: "transparent"
anchor {
item: volButton
edges: Edges.Bottom
}
onVisibleChanged: {
if (visible)
volPopSlider.value = bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume);
}
Rectangle {
anchors.fill: parent
radius: theme.radius
color: theme.glassPanel
border.color: theme.neon
border.width: 1
}
ColumnLayout {
id: volPopColumn
anchors {
fill: parent
margins: 10
}
spacing: 10
Text {
text: "AUDIO // VOLUME"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
Layout.fillWidth: true
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: bar.volumeIcon(bar.sinkVolume, bar.sinkMuted)
font.family: theme.iconFont
font.pixelSize: theme.fontSize
color: bar.sinkMuted ? theme.muted : theme.text
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: {
if (bar.sink !== null && bar.sink.audio !== null)
bar.sink.audio.muted = !bar.sink.audio.muted;
}
}
}
SciSlider {
id: volPopSlider
Layout.fillWidth: true
value: bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume)
onMoved: v => {
if (bar.sink !== null && bar.sink.audio !== null) {
bar.sink.audio.muted = false;
bar.sink.audio.volume = v;
}
}
}
Text {
text: bar.sinkMuted ? "MUTE" : Math.round(volPopSlider.value * 100) + "%"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.preferredWidth: 44
horizontalAlignment: Text.AlignRight
}
}
}
// keep the volume slider in sync with app/media-key changes
Connections {
target: bar
function onSinkVolumeChanged() {
if (!volPopSlider.pressed && !bar.sinkMuted)
volPopSlider.value = Math.min(1, bar.sinkVolume);
}
function onSinkMutedChanged() {
if (!volPopSlider.pressed)
volPopSlider.value = bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume);
}
}
}
// ================= Clipboard picker (cliphist) =================
// One panel per bar instance, but only ever shown on the primary
// screen (the cliphist data + list live at ShellRoot scope).
// NB: deliberately a PanelWindow (layer surface) not a PopupWindow
// (xdg-popup): xdg-popups opened from a keybind have no input serial
// and never receive clicks or key events. A layer surface takes input
// normally and is first-class for HyprlandFocusGrab.
PanelWindow {
id: clipPopup
screen: modelData
// top-left anchors; horizontal centering computed via margins.left
anchors {
top: true
left: true
}
exclusiveZone: 0
margins.top: theme.barHeight + 8
margins.left: (modelData.width - clipPopup.implicitWidth) / 2
// required or the panel never receives keyboard events (1-9 / Esc)
focusable: true
implicitWidth: 440
implicitHeight: clipCol.implicitHeight + 24
visible: root.clipOpen && modelData === Quickshell.screens[0]
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-clipboard"
// pull keyboard focus onto the key-handler item whenever the picker
// opens (needed for 1-9 / Esc to reach the Keys handlers)
onVisibleChanged: if (visible)
clipKeys.forceActiveFocus()
HyprlandFocusGrab {
windows: [clipPopup]
active: clipPopup.visible
onCleared: {
console.log("clip: grab cleared");
root.clipOpen = false;
}
}
Rectangle {
anchors.fill: parent
radius: theme.radius
color: theme.glassPanel
border.color: theme.neon
border.width: 1
}
ColumnLayout {
id: clipCol
anchors {
fill: parent
margins: 12
}
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "CLIPBOARD"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
Layout.fillWidth: true
}
Text {
text: root.clipEntries.length + " ITEMS"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
Text {
visible: root.clipEntries.length === 0
text: "Clipboard history is empty"
color: theme.muted
font.pixelSize: 12
}
ListView {
id: clipListView
Layout.fillWidth: true
Layout.preferredHeight: Math.min(clipListView.contentHeight, 340)
visible: root.clipEntries.length > 0
clip: true
boundsBehavior: Flickable.StopAtBounds
spacing: 2
model: root.clipEntries
delegate: Rectangle {
id: clipRow
required property var modelData
required property int index
width: clipListView.width
height: 38
radius: 6
color: clipRowMa.containsMouse ? theme.surface : "transparent"
Text {
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
leftMargin: 8
rightMargin: 8
}
text: (clipRow.index + 1) + ". " + clipRow.modelData.text
color: theme.text
font.pixelSize: 12
elide: Text.ElideRight
}
MouseArea {
id: clipRowMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
console.log("clip: row clicked " + clipRow.index);
root.clipPick(clipRow.modelData.hash);
}
}
}
}
Text {
Layout.fillWidth: true
text: "1-9 pick + paste // click to restore // esc closes"
color: theme.muted
font.pixelSize: 8
horizontalAlignment: Text.AlignHCenter
}
}
// key handler surface: the ListView is click-only
Item {
id: clipKeys
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.clipOpen = false
Keys.onPressed: event => {
if (event.key >= Qt.Key_1 && event.key <= Qt.Key_9) {
const i = event.key - Qt.Key_1;
if (i < root.clipEntries.length)
root.clipPick(root.clipEntries[i].hash);
}
}
}
}
// ================= Keybind cheatsheet (SUPER+CTRL+H) =================
// Data comes from keybinds.json generated by Home Manager (single
// source of truth, kept in sync with the hl.bind()s in hyprland.nix).
PanelWindow {
id: helpPopup
screen: modelData
anchors {
top: true
left: true
}
exclusiveZone: 0
margins.top: theme.barHeight + 8
margins.left: (modelData.width - helpPopup.implicitWidth) / 2
focusable: true
visible: root.helpOpen && modelData === Quickshell.screens[0]
implicitWidth: 640
implicitHeight: Math.max(240, Math.min(helpCol.implicitHeight + 100, modelData.height - 140))
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-help"
onVisibleChanged: if (visible)
helpKeys.forceActiveFocus()
HyprlandFocusGrab {
windows: [helpPopup]
active: helpPopup.visible
onCleared: root.helpOpen = false
}
Rectangle {
anchors.fill: parent
radius: theme.radius
color: theme.glassPanel
border.color: theme.neon
border.width: 1
}
ColumnLayout {
anchors {
fill: parent
margins: 12
}
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "SYSTEM // KEYBINDS"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
Layout.fillWidth: true
}
Text {
text: "esc / click away closes"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
Flickable {
id: helpFlick
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
boundsBehavior: Flickable.StopAtBounds
contentHeight: helpCol.implicitHeight
ColumnLayout {
id: helpCol
width: helpFlick.width
spacing: 10
Repeater {
model: root.helpSections
ColumnLayout {
id: helpSection
required property var modelData
Layout.fillWidth: true
spacing: 2
Text {
text: helpSection.modelData.cat
color: theme.magenta
font.family: theme.displayFont
font.pixelSize: 9
font.bold: true
Layout.fillWidth: true
}
Repeater {
model: helpSection.modelData.items
RowLayout {
id: helpRow
required property var modelData
Layout.fillWidth: true
Layout.leftMargin: 8
spacing: 10
Text {
Layout.preferredWidth: 230
text: helpRow.modelData.keys
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 9
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: helpRow.modelData.desc
color: theme.text
font.pixelSize: 11
elide: Text.ElideRight
}
}
}
}
}
}
}
}
Item {
id: helpKeys
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.helpOpen = false
}
}
// ================= Wallpaper picker (SUPER+CTRL+A) =================
// Thumbnail grid of ~/Pictures/wallpapers (hyprpaper set). Clicking a
// card applies it via `hyprctl hyprpaper` and persists the choice; the
// active entry keeps a magenta frame.
PanelWindow {
id: wallPopup
screen: modelData
anchors {
top: true
left: true
}
exclusiveZone: 0
margins.top: theme.barHeight + 8
margins.left: (modelData.width - wallPopup.implicitWidth) / 2
focusable: true
visible: root.wallOpen && modelData === Quickshell.screens[0]
implicitWidth: 620
implicitHeight: 58 + wallPopup.wallListH
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-wall"
// Grid keyboard cursor: arrows move the highlight AND live-apply the
// wallpaper (thumbnails = instant preview). Cols must match the popup's
// wallCols at its 620px width (2 columns of 262 + 12 spacing).
readonly property int wallCols: Math.max(1, Math.floor((wallPopup.width - 20) / (wallCardW + 12)))
readonly property int wallCardW: 262
readonly property int wallCardH: 150
readonly property int wallRows: Math.max(1, Math.ceil(root.wallList.length / wallPopup.wallCols))
readonly property int wallListH: Math.min(wallPopup.wallRows * (wallCardH + 10), Math.max(160, modelData.height - 300))
onVisibleChanged: if (visible) {
root.wallIndex = -1;
root.wallSelPath = "";
root.wallRefresh();
wallKeys.forceActiveFocus();
}
HyprlandFocusGrab {
windows: [wallPopup]
active: wallPopup.visible
onCleared: root.wallOpen = false
}
Rectangle {
anchors.fill: parent
radius: theme.radius
color: theme.glassPanel
border.color: theme.neon
border.width: 1
}
ColumnLayout {
anchors {
fill: parent
margins: 12
}
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "WALLPAPERS"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
Layout.fillWidth: true
}
Text {
text: root.wallList.length + " in ~/Pictures/wallpapers"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
}
Rectangle {
width: paletteBtn.width + 12
height: 20
radius: 6
color: themeProc.generating ? theme.neon : theme.surface
border.color: themeProc.generating ? theme.ink : theme.neon
border.width: 1
visible: root.wallSelPath !== ""
Text {
id: paletteBtn
anchors.centerIn: parent
text: themeProc.generating ? "..." : "PALETTE"
color: themeProc.generating ? theme.ink : theme.neon
font.pixelSize: 8
font.bold: true
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.wallSelPath && !themeProc.generating) {
themeProc.generating = true;
themeProc.exec([root.qsThemeScript, root.wallSelPath]);
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
Flickable {
id: wallFlick
Layout.fillWidth: true
Layout.preferredHeight: wallPopup.wallListH
clip: true
boundsBehavior: Flickable.StopAtBounds
contentHeight: wallGrid.implicitHeight + 4
contentY: {
// keep the keyboard cursor's row in view
if (root.wallIndex < 0)
return 0;
const row = Math.floor(root.wallIndex / wallPopup.wallCols);
const maxY = Math.max(0, wallGrid.implicitHeight + 4 - wallFlick.height);
return Math.max(0, Math.min(row * (wallPopup.wallCardH + 10), maxY));
}
Grid {
id: wallGrid
columns: wallPopup.wallCols
columnSpacing: 12
rowSpacing: 10
Repeater {
model: root.wallList
Rectangle {
id: wallCard
required property var modelData
width: wallPopup.wallCardW
height: wallPopup.wallCardH
radius: theme.radius
readonly property bool selected: wallCard.modelData.path === root.wallSelPath
color: wallCard.selected
? theme.surface
: (wallCard.modelData.active ? theme.surface : "#101322")
border.color: wallCard.selected
? theme.neon
: (wallCard.hovered
? theme.neon
: (wallCard.modelData.active ? theme.magenta : theme.line))
border.width: wallCard.selected || wallCard.modelData.active ? 2 : 1
property bool hovered: false
Behavior on border.color { ColorAnimation { duration: 120 } }
Behavior on color { ColorAnimation { duration: 120 } }
ColumnLayout {
anchors {
fill: parent
margins: 7
}
spacing: 6
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 108
radius: 7
clip: true
color: theme.surface
Image {
anchors.fill: parent
source: "file://" + wallCard.modelData.path
fillMode: Image.PreserveAspectCrop
smooth: true
cache: false
}
}
Text {
Layout.fillWidth: true
text: wallCard.modelData.name
color: wallCard.modelData.active ? theme.neon : theme.text
font.pixelSize: 10
elide: Text.ElideRight
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
wallCard.hovered = true;
root.wallSelPath = wallCard.modelData.path; // highlight follows the pointer
}
onExited: wallCard.hovered = false
onClicked: {
root.wallSelPath = wallCard.modelData.path;
root.wallPick(wallCard.modelData.path);
}
}
}
}
}
}
Text {
Layout.fillWidth: true
text: "arrow keys preview live // click applies // esc closes"
color: theme.muted
font.pixelSize: 8
horizontalAlignment: Text.AlignHCenter
}
}
Item {
id: wallKeys
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.wallOpen = false
Keys.onLeftPressed: root.wallMove(-1, 0)
Keys.onRightPressed: root.wallMove(1, 0)
Keys.onUpPressed: root.wallMove(0, -1)
Keys.onDownPressed: root.wallMove(0, 1)
Keys.onReturnPressed: root.wallOpen = false
Keys.onEnterPressed: root.wallOpen = false
}
}
// ================= Notification toasts (top-right) =================
// Live notifications render here for ~5s (mako->QuickShell migration);
// seen toasts fall through to the center history. Non-focusable layer
// surface that never takes keyboard focus.
PanelWindow {
id: toastLayer
screen: modelData
anchors {
top: true
right: true
}
exclusiveZone: 0
margins.top: theme.barHeight + 8
margins.right: 12
focusable: false
visible: root.toastItems.length > 0 && modelData === Quickshell.screens[0]
implicitWidth: 380
implicitHeight: toastCol.implicitHeight
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-toasts"
Column {
id: toastCol
width: parent.width
spacing: 8
Repeater {
model: root.toastItems
Rectangle {
id: toast
required property var modelData
width: toastCol.width
height: tInner.implicitHeight + 20
radius: theme.radius
color: theme.glassPanel
border.color: root.notifAccent(toast.modelData.urgency)
border.width: 1
ColumnLayout {
id: tInner
anchors {
fill: parent
margins: 10
}
spacing: 6
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
text: toast.modelData.appName !== "" ? toast.modelData.appName : "Unknown"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
elide: Text.ElideRight
}
Text {
Layout.alignment: Qt.AlignVCenter
text: "\uf00d" // close
font.family: theme.iconFont
font.pixelSize: 10
color: theme.muted
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.notifDismiss(toast.modelData)
}
}
}
Text {
Layout.fillWidth: true
text: toast.modelData.summary !== "" ? toast.modelData.summary : toast.modelData.body
color: theme.text
font.pixelSize: 12
font.bold: true
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: toast.modelData.body !== "" && toast.modelData.summary !== ""
text: toast.modelData.body
color: theme.muted
font.pixelSize: 11
textFormat: Text.PlainText
wrapMode: Text.Wrap
elide: Text.ElideRight
maximumLineCount: 3
}
// screenshot preview (qs-shot posts image-path hint → image://qsimage/…)
Image {
Layout.fillWidth: true
Layout.preferredHeight: 140
visible: toast.modelData.image !== ""
source: toast.modelData.image
fillMode: Image.PreserveAspectCrop
clip: true
smooth: true
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 2
visible: toast.modelData.actions.length > 0
spacing: 6
Repeater {
model: toast.modelData.actions
Rectangle {
id: toastAct
required property var modelData
implicitWidth: toastActText.implicitWidth + 14
implicitHeight: 20
radius: 5
color: "transparent"
border.color: theme.line
border.width: 1
Text {
id: toastActText
anchors.centerIn: parent
text: toastAct.modelData.text
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 9
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.calNotifAction(toast.modelData, toastAct.modelData)
}
}
}
}
}
Timer {
interval: Math.max(4, toast.modelData.expireTimeout) * 1000
running: true
repeat: false
onTriggered: root.toastRemove(toast.modelData)
}
// toast vanishes too when the notification is closed anywhere
Connections {
target: toast.modelData
function onClosed(reason) {
root.toastRemove(toast.modelData);
}
}
}
}
}
}
// ================= Notification center (bell / SUPER+CTRL+N) =================
PanelWindow {
id: notifPopup
screen: modelData
anchors {
top: true
right: true
}
exclusiveZone: 0
margins.top: theme.barHeight + 8
margins.right: (modelData.width - bar.islandWidth) / 2 + 12
focusable: true
visible: root.notifOpen && modelData === Quickshell.screens[0]
implicitWidth: 390
implicitHeight: Math.min(notifCol.implicitHeight + 24, modelData.height - 160)
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-notifications"
onVisibleChanged: if (visible) {
root.notifUnseen = 0;
root.toastItems = []; // seen toasts now live in the center
notifKeys.forceActiveFocus();
}
HyprlandFocusGrab {
windows: [notifPopup]
active: notifPopup.visible
onCleared: root.notifOpen = false
}
Rectangle {
anchors.fill: parent
radius: theme.radius
color: theme.glassPanel
border.color: theme.neon
border.width: 1
}
ColumnLayout {
id: notifCol
anchors {
fill: parent
margins: 12
}
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "NOTIFICATIONS"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
Layout.fillWidth: true
}
Text {
text: notifServer.trackedNotifications.values.length + " IN HISTORY"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Rectangle {
id: dndPill
implicitWidth: dndText.implicitWidth + 16
implicitHeight: 22
radius: 6
color: root.notifDnd ? theme.neon : "transparent"
border.color: root.notifDnd ? theme.neon : theme.line
border.width: 1
Behavior on color {
ColorAnimation {
duration: 150
}
}
Text {
id: dndText
anchors.centerIn: parent
text: root.notifDnd ? "DND ON" : "DND OFF"
font.family: theme.displayFont
font.pixelSize: 8
color: root.notifDnd ? theme.ink : theme.muted
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.notifDnd = !root.notifDnd
}
}
Item {
Layout.fillWidth: true
}
Text {
text: "CLEAR"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: root.notifClear()
}
}
}
ListView {
id: notifList
Layout.fillWidth: true
Layout.preferredHeight: Math.min(notifList.contentHeight, 360)
visible: notifServer.trackedNotifications.values.length > 0
clip: true
boundsBehavior: Flickable.StopAtBounds
spacing: 6
// newest notifications at the top (array model is append-ordered,
// so show a reversed copy; no ListView.reverse in this env)
model: notifServer.trackedNotifications.values.slice().reverse()
delegate: Rectangle {
id: notifRow
required property var modelData
width: notifList.width
height: nCol.implicitHeight + 18
radius: 6
color: nRowMa.containsMouse ? theme.surface : "transparent"
// left urgency accent strip
Rectangle {
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
leftMargin: 4
topMargin: 6
bottomMargin: 6
}
width: 3
radius: 2
color: root.notifAccent(notifRow.modelData.urgency)
}
ColumnLayout {
id: nCol
anchors {
fill: parent
margins: 9
}
spacing: 4
RowLayout {
Layout.fillWidth: true
spacing: 6
// app letter chip tinted by urgency
Rectangle {
Layout.alignment: Qt.AlignVCenter
width: 18
height: 18
radius: 5
color: root.notifAccent(notifRow.modelData.urgency)
Text {
anchors.centerIn: parent
text: (notifRow.modelData.appName !== "" ? notifRow.modelData.appName : "?")[0].toUpperCase()
color: theme.ink
font.family: theme.displayFont
font.pixelSize: 9
font.bold: true
}
}
Text {
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
text: notifRow.modelData.appName !== "" ? notifRow.modelData.appName : "Unknown"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
elide: Text.ElideRight
}
Text {
Layout.alignment: Qt.AlignVCenter
text: "\uf00d" // close
font.family: theme.iconFont
font.pixelSize: 9
color: theme.muted
MouseArea {
anchors.fill: parent
anchors.margins: -5
cursorShape: Qt.PointingHandCursor
onClicked: root.notifDismiss(notifRow.modelData)
}
}
}
Text {
Layout.fillWidth: true
visible: notifRow.modelData.summary !== ""
text: notifRow.modelData.summary
color: theme.text
font.pixelSize: 12
font.bold: true
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: notifRow.modelData.body !== ""
text: notifRow.modelData.body
color: theme.muted
font.pixelSize: 11
textFormat: Text.PlainText
wrapMode: Text.Wrap
elide: Text.ElideRight
maximumLineCount: 4
}
// screenshot thumbnail (history copy of the live toast preview)
Image {
Layout.fillWidth: true
Layout.preferredHeight: 120
visible: notifRow.modelData.image !== ""
source: notifRow.modelData.image
fillMode: Image.PreserveAspectCrop
clip: true
smooth: true
}
RowLayout {
id: nActs
Layout.fillWidth: true
Layout.topMargin: 2
visible: notifRow.modelData.actions.length > 0
spacing: 6
Repeater {
model: notifRow.modelData.actions
Rectangle {
id: nAct
required property var modelData
implicitWidth: nActText.implicitWidth + 14
implicitHeight: 20
radius: 5
color: "transparent"
border.color: theme.line
border.width: 1
Text {
id: nActText
anchors.centerIn: parent
text: nAct.modelData.text
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 9
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.calNotifAction(notifRow.modelData, nAct.modelData)
}
}
}
}
}
MouseArea {
id: nRowMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
}
}
}
Text {
Layout.fillWidth: true
visible: notifServer.trackedNotifications.values.length === 0
text: "No notifications yet"
color: theme.muted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
}
Text {
Layout.fillWidth: true
text: "esc / click away closes // DND hides toasts, history kept"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
horizontalAlignment: Text.AlignHCenter
}
}
Item {
id: notifKeys
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.notifOpen = false
}
}
// ================= Calendar + weather (clock click / SUPER+CTRL+C) =================
// Month grid + agenda fed by the qs-cal sync pipeline (Nextcloud CalDAV
// via vdirsyncer+khal -> ~/.cache/quickshell-cal/events.json; weather via
// Open-Meteo). YEARS read caches instantly via `qs-cal-sync read`.
PanelWindow {
id: calPopup
screen: modelData
anchors {
top: true
right: true
}
exclusiveZone: 0
margins.top: theme.barHeight + 8
margins.right: (modelData.width - bar.islandWidth) / 2 + 12
focusable: true
visible: root.calOpen && modelData === Quickshell.screens[0]
implicitWidth: 660
implicitHeight: Math.min(calBody.implicitHeight + 24, modelData.height - 140)
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-cal"
onVisibleChanged: {
if (visible) {
root.calResetToday();
root.calRead();
calKeys.forceActiveFocus();
}
}
HyprlandFocusGrab {
windows: [calPopup]
active: calPopup.visible
onCleared: root.calOpen = false
}
Rectangle {
anchors.fill: parent
radius: theme.radius
color: theme.glassPanel
border.color: theme.neon
border.width: 1
}
ColumnLayout {
id: calBody
anchors {
fill: parent
margins: 12
}
spacing: 10
// ---------- header ----------
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "CALENDAR"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 12
font.bold: true
}
Text {
text: root.calFetchedEvents !== "" ? "SYNC " + root.calFetchedEvents.slice(0, 16).replace("T", " ") : "SYNC —"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 10
}
// sync-health badge: "!" triangle when the last sync or a push
// (new event / edit / delete) failed or is stale.
Text {
visible: root.calSyncOk && root.calSyncOk.ok === false
text: "\uf071" // exclamation-triangle
font.family: theme.iconFont
font.pixelSize: 11
color: theme.danger
Text {
anchors.top: parent.top
anchors.topMargin: 16
anchors.right: parent.right
width: 190
horizontalAlignment: Text.AlignRight
visible: parent.visible && caleErrHover.containsMouse
text: "Last sync/push failed:\n" + (root.calSyncOk ? root.calSyncOk.error : "")
color: theme.danger
font.family: theme.displayFont
font.pixelSize: 8
wrapMode: Text.Wrap
}
MouseArea {
id: caleErrHover
anchors.fill: parent
anchors.margins: -4
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.calRefresh()
}
}
Item {
Layout.fillWidth: true
}
Text {
text: "\uf0d9" // angle-left
font.family: theme.iconFont
font.pixelSize: 13
color: theme.muted
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: root.calShiftMonth(-1)
}
}
Text {
text: Qt.formatDate(root.calView, "MMMM yyyy").toUpperCase()
color: theme.text
font.family: theme.displayFont
font.pixelSize: 11
}
Text {
text: "\uf0da" // angle-right
font.family: theme.iconFont
font.pixelSize: 13
color: theme.muted
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: root.calShiftMonth(1)
}
}
Text {
text: "TODAY"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 10
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.calResetToday()
}
}
Rectangle {
Layout.preferredWidth: 1
Layout.fillHeight: true
color: theme.line
}
Text {
text: "\uf055" // plus-circle
font.family: theme.iconFont
font.pixelSize: 13
color: root.calAdding ? theme.neon : theme.muted
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.calAdding)
root.calAdding = false;
else
root.calStartAdd();
}
}
}
Text {
text: "\uf01e" // refresh
font.family: theme.iconFont
font.pixelSize: 13
color: theme.muted
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: root.calRefresh()
}
}
Text {
text: "\uf00d" // close
font.family: theme.iconFont
font.pixelSize: 13
color: theme.muted
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: root.calOpen = false
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
// ---------- new-event form ----------
ColumnLayout {
Layout.fillWidth: true
visible: root.calAdding
spacing: 8
Text {
text: root.calEditMode ? "EDIT EVENT" : "NEW EVENT"
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 11
font.bold: true
}
// calendar picker (Flow so long calendar names wrap onto a second row;
// each pill sizes itself from the name so the row stays readable)
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
model: root.calCals
Rectangle {
required property string modelData
readonly property bool active: modelData === root.calNewCal
width: Math.max(44, modelData.length * 6.2 + 30)
height: 24
radius: 6
color: active ? Qt.rgba(0, 229, 255, 0.12) : theme.surface
border.color: active ? theme.neon : theme.line
border.width: 1
Rectangle {
width: 8
height: 8
radius: 4
color: root.calCalColor(modelData)
anchors.left: parent.left
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: calPickTxt
anchors.left: parent.left
anchors.leftMargin: 18
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: modelData
color: active ? theme.neon : theme.text
font.family: theme.displayFont
font.pixelSize: 10
elide: Text.ElideRight
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.calNewCal = modelData
}
}
}
}
// title
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "TITLE"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
Layout.fillWidth: true
height: 32
radius: 6
color: theme.surface
border.color: theme.line
border.width: 1
TextInput {
id: calNewTitleInput
anchors {
fill: parent
leftMargin: 8
rightMargin: 8
}
verticalAlignment: Text.AlignVCenter
color: theme.text
font.pixelSize: 13
selectByMouse: true
text: root.calNewTitle
onTextChanged: root.calNewTitle = text
}
}
}
// date + all-day + reminder
RowLayout {
Layout.fillWidth: true
spacing: 8
Rectangle {
Layout.preferredWidth: 130
height: 28
radius: 6
color: theme.surface
border.color: theme.line
border.width: 1
TextInput {
id: calNewDateInput
anchors {
fill: parent
leftMargin: 8
rightMargin: 8
}
verticalAlignment: Text.AlignVCenter
color: theme.text
font.family: theme.displayFont
font.pixelSize: 11
selectByMouse: true
text: root.calNewDate
onTextChanged: root.calNewDate = text
}
}
Rectangle {
implicitWidth: 74
height: 28
radius: 6
color: root.calNewAllDay ? theme.violet : theme.surface
border.color: root.calNewAllDay ? theme.violet : theme.line
border.width: 1
Text {
anchors.centerIn: parent
text: "ALL DAY"
color: root.calNewAllDay ? theme.ink : theme.muted
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.calNewAllDay = !root.calNewAllDay
}
}
Item { Layout.fillWidth: true }
Text {
text: "REMIND"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.alignment: Qt.AlignVCenter
}
Repeater {
model: [[0, "OFF"], [5, "5m"], [10, "10m"], [15, "15m"], [30, "30m"], [60, "1h"]]
delegate: Rectangle {
required property var modelData
readonly property int val: modelData[0]
implicitWidth: 26
height: 22
radius: 6
color: root.calNewRem === val ? theme.neon : theme.surface
border.color: root.calNewRem === val ? theme.neon : theme.line
border.width: 1
Text {
anchors.centerIn: parent
text: modelData[1]
color: root.calNewRem === val ? theme.ink : theme.muted
font.family: theme.displayFont
font.pixelSize: 9
font.bold: root.calNewRem === val
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.calNewRem = val
}
}
}
}
// times + location (hidden for all-day)
RowLayout {
Layout.fillWidth: true
visible: !root.calNewAllDay
spacing: 8
Text {
text: "FROM"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
Layout.preferredWidth: 74
height: 28
radius: 6
color: theme.surface
border.color: theme.line
border.width: 1
TextInput {
id: calNewStartInput
anchors {
fill: parent
leftMargin: 8
rightMargin: 8
}
verticalAlignment: Text.AlignVCenter
color: theme.text
font.family: theme.displayFont
font.pixelSize: 11
selectByMouse: true
text: root.calNewStart
onTextChanged: root.calNewStart = text
}
}
Text {
text: "TO"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
Layout.preferredWidth: 74
height: 28
radius: 6
color: theme.surface
border.color: theme.line
border.width: 1
TextInput {
id: calNewEndInput
anchors {
fill: parent
leftMargin: 8
rightMargin: 8
}
verticalAlignment: Text.AlignVCenter
color: theme.text
font.family: theme.displayFont
font.pixelSize: 11
selectByMouse: true
text: root.calNewEnd
onTextChanged: root.calNewEnd = text
}
}
Text {
text: "LOC"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
Layout.fillWidth: true
height: 28
radius: 6
color: theme.surface
border.color: theme.line
border.width: 1
TextInput {
anchors {
fill: parent
leftMargin: 8
rightMargin: 8
}
verticalAlignment: Text.AlignVCenter
color: theme.text
font.pixelSize: 11
selectByMouse: true
text: root.calNewLoc
onTextChanged: root.calNewLoc = text
}
}
}
// repeat selector
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "REPEAT"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
Layout.alignment: Qt.AlignVCenter
}
Repeater {
model: [["NONE", "NONE"], ["DAILY", "DAILY"], ["WEEKLY", "WEEKLY"], ["MONTHLY", "MONTHLY"]]
delegate: Rectangle {
required property var modelData
readonly property string val: modelData[0]
implicitWidth: 66
height: 24
radius: 6
color: root.calNewRep === val ? theme.violet : theme.surface
border.color: root.calNewRep === val ? theme.violet : theme.line
border.width: 1
Text {
anchors.centerIn: parent
text: modelData[1]
color: root.calNewRep === val ? theme.ink : theme.muted
font.family: theme.displayFont
font.pixelSize: 9
font.bold: root.calNewRep === val
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.calNewRep = val
}
}
}
Text {
visible: root.calEditMode && root.calEditEv && root.calEditEv.rep
text: "NOTE: repeat edits apply to the whole series"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
Layout.alignment: Qt.AlignVCenter
}
}
// save / cancel / (edit-mode) delete
RowLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignRight
spacing: 10
Text {
text: "CANCEL"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 10
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.calDiscardEdit()
}
}
Rectangle {
visible: root.calEditMode
implicitWidth: 70
height: 30
radius: 6
color: theme.danger
Text {
anchors.centerIn: parent
text: "DELETE"
color: theme.ink
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.calDeleteEv()
}
}
Rectangle {
implicitWidth: 62
height: 30
radius: 6
color: theme.neon
Text {
anchors.centerIn: parent
text: "SAVE"
color: theme.ink
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.calCommitSave()
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
}
// ---------- month grid + agenda ----------
RowLayout {
Layout.fillWidth: true
spacing: 12
ColumnLayout {
Layout.preferredWidth: 322
Layout.alignment: Qt.AlignLeft
spacing: 4
RowLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
spacing: 0
Repeater {
model: ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
delegate: Text {
required property string modelData
Layout.preferredWidth: calGrid.cellWidth
horizontalAlignment: Text.AlignHCenter
text: modelData
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 10
}
}
}
GridView {
id: calGrid
Layout.preferredWidth: 322
Layout.preferredHeight: 228
cellWidth: 46
cellHeight: 38
interactive: false
clip: false
focus: false
model: ScriptModel {
values: root.calBuildMonth()
}
delegate: Rectangle {
required property var modelData
readonly property var c: modelData
width: 46
height: 38
color: "transparent"
Rectangle {
width: 42
height: 36
anchors {
top: parent.top
topMargin: 1
left: parent.left
leftMargin: 2
}
radius: 6
color: c.m
? (c.today ? Qt.rgba(0, 229, 255, 0.12) : "transparent")
: "transparent"
border.color: c.sel ? theme.magenta : (c.today ? theme.neon : "transparent")
border.width: c.sel ? 2 : 1
Text {
anchors.centerIn: parent
text: c.n
color: c.sel ? theme.magenta : (c.today ? theme.neon : (c.m ? theme.text : theme.muted))
font.family: theme.displayFont
font.pixelSize: 13
font.bold: c.sel || c.today
}
Rectangle {
visible: c.has
anchors {
bottom: parent.bottom
bottomMargin: 3
horizontalCenter: parent.horizontalCenter
}
width: 4
height: 4
radius: 2
color: theme.magenta
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
const d = new Date(c.ms);
d.setHours(0, 0, 0, 0);
root.calSel = d;
root.calView = new Date(d.getFullYear(), d.getMonth(), 1);
}
}
}
}
}
// small legend
RowLayout {
spacing: 10
RowLayout {
spacing: 5
Rectangle { width: 6; height: 6; radius: 3; color: theme.magenta }
Text { text: "has events"; color: theme.muted; font.family: theme.displayFont; font.pixelSize: 10 }
}
RowLayout {
spacing: 5
Rectangle { width: 10; height: 10; radius: 5; color: "transparent"; border.color: theme.neon; border.width: 1 }
Text { text: "today"; color: theme.muted; font.family: theme.displayFont; font.pixelSize: 10 }
}
RowLayout {
spacing: 5
Rectangle { width: 10; height: 10; radius: 5; color: "transparent"; border.color: theme.magenta; border.width: 2 }
Text { text: "selected"; color: theme.muted; font.family: theme.displayFont; font.pixelSize: 10 }
}
}
}
Rectangle {
Layout.preferredWidth: 1
Layout.fillHeight: true
color: theme.line
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 6
RowLayout {
Layout.fillWidth: true
Text {
text: "AGENDA · " + Qt.formatDate(root.calSel, "ddd d MMM").toUpperCase()
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 11
font.bold: true
Layout.fillWidth: true
}
Text {
text: root.calSelEventsCount() + " EVENTS"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
}
}
Repeater {
model: root.calSelEvents()
MouseArea {
required property var modelData
readonly property var ev: modelData
Layout.fillWidth: true
Layout.preferredHeight: 30
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: root.calStartEdit(modelData)
Rectangle {
anchors.fill: parent
radius: 6
color: parent.containsMouse ? Qt.rgba(0, 229, 255, 0.08) : "transparent"
}
RowLayout {
anchors {
fill: parent
leftMargin: 6
rightMargin: 6
}
spacing: 8
Text {
text: ev.allDay ? "ALL DAY" : ev.t
color: ev.allDay ? theme.violet : theme.neon
font.family: theme.displayFont
font.pixelSize: 11
font.bold: !ev.allDay
Layout.preferredWidth: 62
}
Rectangle {
width: 6
height: 6
radius: 3
Layout.alignment: Qt.AlignVCenter
color: root.calCalColor(ev.cal)
}
Text {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: ev.title + (ev.rep ? " \uf2f1" : "")
color: theme.text
font.pixelSize: 13
elide: Text.ElideRight
}
Text {
visible: parent.parent.containsMouse
text: "\uf044" // edit
color: theme.muted
font.family: theme.iconFont
font.pixelSize: 10
Layout.alignment: Qt.AlignVCenter
}
}
}
}
Text {
Layout.fillWidth: true
visible: root.calSelEventsCount() === 0
text: root.calEvents.length === 0
? "No events in cache.\nLink a Nextcloud calendar in secrets.yaml\n(hp-laptop/nextcloud-cal-env) and restart qs-cal-sync."
: "Nothing scheduled."
color: theme.muted
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
}
Item { Layout.fillHeight: true }
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
// ---------- weather ----------
RowLayout {
Layout.fillWidth: true
spacing: 12
ColumnLayout {
Layout.preferredWidth: 160
spacing: 2
RowLayout {
spacing: 10
Text {
text: root.calCurTemp()
color: theme.neon
font.family: theme.displayFont
font.pixelSize: 30
font.bold: true
}
ColumnLayout {
spacing: 0
Text {
text: root.calWmoLabel(root.calCurCode())
color: theme.text
font.family: theme.displayFont
font.pixelSize: 11
}
Text {
text: root.calWeather && root.calWeather.daily && root.calWeather.daily.length > 0
? "H " + root.calTemp(root.calWeather.daily[0].tmax) + " L " + root.calTemp(root.calWeather.daily[0].tmin)
: "—"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 10
}
Text {
text: root.calWeather && root.calWeather.current
? "FEELS " + root.calTemp(root.calWeather.current.feels) + " HUM " + (root.calWeather.current.hum != null ? Math.round(root.calWeather.current.hum) + "%" : "—")
: ""
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
}
Text {
visible: root.calWeather && root.calWeather.current && root.calWeather.current.wind != null
text: "WIND " + Math.round(root.calWeather.current.wind) + " KM/H"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
}
}
}
RowLayout {
spacing: 4
Text {
text: root.calLoc ? String(root.calLoc.name).toUpperCase() : "SET LOCATION"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 10
elide: Text.ElideRight
}
Text {
text: "\uf040" // pencil
font.family: theme.iconFont
font.pixelSize: 13
color: theme.neon
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: {
root.calEditingLoc = !root.calEditingLoc;
if (root.calEditingLoc && locInput.text === "")
locInput.text = "Search city or lat,lon";
}
}
}
}
}
Item { Layout.fillWidth: true }
ColumnLayout {
Layout.fillWidth: true
spacing: 6
// hourly trace for the rest of today (starts at current hour)
RowLayout {
Layout.fillWidth: true
spacing: 5
Repeater {
model: root.calHourlySlice()
ColumnLayout {
required property var modelData
readonly property var hr: modelData
width: 52
spacing: 1
Text {
Layout.alignment: Qt.AlignHCenter
text: hr.h
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
}
Text {
Layout.alignment: Qt.AlignHCenter
text: root.calTemp(hr.t)
color: root.calWmoColor(hr.code)
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
}
Text {
Layout.alignment: Qt.AlignHCenter
visible: hr.pop != null && hr.pop > 0
text: "☔" + Math.round(hr.pop) + "%"
color: hr.pop >= 60 ? theme.danger : (hr.pop >= 30 ? theme.violet : theme.muted)
font.family: theme.displayFont
font.pixelSize: 8
}
}
}
}
// next 7 days, two rows of 4 + 3
Repeater {
model: [0, 4]
RowLayout {
required property var modelData
readonly property int start: modelData
Layout.fillWidth: true
spacing: 5
Repeater {
model: root.calWeather && root.calWeather.daily
? root.calWeather.daily.slice(start + 1, start + 5) : []
ColumnLayout {
required property var modelData
readonly property var day: modelData
width: 96
spacing: 1
Text {
Layout.alignment: Qt.AlignHCenter
text: root.calWeekday(day.d).toUpperCase()
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
}
Text {
Layout.alignment: Qt.AlignHCenter
text: root.calWmoLabel(day.code)
color: root.calWmoColor(day.code)
font.family: theme.displayFont
font.pixelSize: 8
}
Text {
Layout.alignment: Qt.AlignHCenter
text: root.calTemp(day.tmax) + "/" + root.calTemp(day.tmin)
color: theme.text
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
}
Text {
Layout.alignment: Qt.AlignHCenter
visible: day.pop != null && day.pop > 0
text: "☔" + Math.round(day.pop) + "%"
color: day.pop >= 60 ? theme.danger : (day.pop >= 30 ? theme.violet : theme.muted)
font.family: theme.displayFont
font.pixelSize: 8
}
}
}
}
}
}
}
// ---------- location editor ----------
RowLayout {
Layout.fillWidth: true
visible: root.calEditingLoc
spacing: 8
Rectangle {
Layout.fillWidth: true
height: 30
radius: 6
color: theme.surface
border.color: theme.line
border.width: 1
TextInput {
id: locInput
anchors {
fill: parent
leftMargin: 8
rightMargin: 8
}
verticalAlignment: Text.AlignVCenter
color: theme.text
font.pixelSize: 13
selectByMouse: true
}
}
Rectangle {
implicitWidth: 52
height: 30
radius: 6
color: theme.neon
Text {
anchors.centerIn: parent
text: "SAVE"
color: theme.ink
font.family: theme.displayFont
font.pixelSize: 10
font.bold: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
const q = locInput.text.trim();
if (q !== "" && q !== "Search city or lat,lon") {
root.calRun("setloc", q);
root.calEditingLoc = false;
}
}
}
}
Text {
text: "CANCEL"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 10
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.calEditingLoc = false
}
}
}
Text {
Layout.fillWidth: true
Layout.topMargin: -4
text: root.calWeather && root.calWeather.fetched
? "weather for " + root.calWeather.name.toUpperCase() + " · " + root.calWeather.fetched.slice(0, 16).replace("T", " ")
: "weather: click the pencil to set a location"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 8
horizontalAlignment: Text.AlignHCenter
}
}
Item {
id: calKeys
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.calOpen = false
Keys.onLeftPressed: root.calSelMove(-1, 0)
Keys.onRightPressed: root.calSelMove(1, 0)
Keys.onUpPressed: root.calSelMove(0, -1)
Keys.onDownPressed: root.calSelMove(0, 1)
Keys.onReturnPressed: root.calResetToday()
Keys.onSpacePressed: root.calResetToday()
}
}
// ================= Workspace overview (SUPER+W) =================
// "Mission control" built in the shell rather than a compositor plugin:
// fullscreen scrim + one card per workspace. Keyboard-driven (arrows,
// enter, 1-9) or click to jump. QuickShell can't capture live frames, so
// windows are title lists — hyprspace is parked until its nixpkgs
// package builds again (AnimationManager.hpp moved upstream).
PanelWindow {
id: wsPopup
screen: modelData
anchors {
top: true
left: true
right: true
bottom: true
}
exclusiveZone: 0
margins.top: theme.barHeight + 16
margins.left: 24
margins.right: 24
margins.bottom: 24
focusable: true
visible: root.wsOpen && modelData === Quickshell.screens[0]
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-workspaces"
HyprlandFocusGrab {
windows: [wsPopup]
active: wsPopup.visible
}
onVisibleChanged: if (visible) {
root.wsIndex = root.focusedWorkspaceIndex();
wsKeys.forceActiveFocus();
}
// translucent veil behind the panel frame (dim the desktop)
Rectangle {
anchors.fill: parent
color: "#070a15"
opacity: 0.72
radius: theme.radius + 8
border.color: theme.neon
border.width: 1
Rectangle {
anchors {
fill: parent
margins: 4
}
radius: theme.radius + 4
color: "transparent"
border.color: theme.line
border.width: 1
}
}
ColumnLayout {
anchors {
fill: parent
margins: 28
}
spacing: 14
RowLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "WORKSPACES"
font.family: theme.displayFont
font.pixelSize: 13
font.bold: true
color: theme.neon
}
Text {
Layout.alignment: Qt.AlignRight
text: "arrows + enter // click // 1-9 direct // esc"
color: theme.muted
font.family: theme.displayFont
font.pixelSize: 9
}
}
Grid {
id: wsGrid
Layout.fillWidth: true
Layout.fillHeight: true
columns: Math.max(1, Math.min(4, Math.round(wsPopup.width / 440)))
columnSpacing: 16
rowSpacing: 16
readonly property int cols: columns
readonly property real cardW: (wsGrid.width - (cols - 1) * columnSpacing) / cols
Repeater {
model: Hyprland.workspaces
Rectangle {
id: wsCard
required property var modelData
// Repeater does not inject `index` for ObjectModel models in
// this build, so selection is derived by object identity with
// the workspace currently at root.wsIndex.
readonly property bool selected: modelData === Hyprland.workspaces.values[root.wsIndex]
property var titles: root.wsTitles(modelData)
property int winCount: (modelData.windows == null ? 0 : modelData.windows)
width: wsGrid.cardW
height: 212
radius: theme.radius
color: selected
? theme.surface
: Qt.rgba(11 / 255, 16 / 255, 25 / 255, 0.72)
border.color: selected
? theme.magenta
: (modelData.focused
? theme.neon
: (modelData.active ? theme.line : "#1c1f2b66"))
border.width: selected ? 2 : 1
Behavior on border.color { ColorAnimation { duration: 120 } }
Behavior on color { ColorAnimation { duration: 120 } }
ColumnLayout {
anchors {
fill: parent
margins: 14
}
spacing: 7
RowLayout {
Layout.fillWidth: true
spacing: 10
Text {
text: modelData.id > 0 ? modelData.id : (modelData.name || modelData.id)
color: modelData.focused ? theme.neon : (selected ? theme.magenta : theme.muted)
font.family: theme.displayFont
font.pixelSize: 20
font.bold: true
}
Text {
Layout.fillWidth: true
text: modelData.id > 0 ? ("workspace " + modelData.id) : (modelData.name || "workspace")
color: selected ? theme.text : theme.muted
font.family: theme.displayFont
font.pixelSize: 11
elide: Text.ElideRight
}
Text {
Layout.alignment: Qt.AlignRight
text: (winCount > 0 ? wsCard.titles.length + "/" + winCount + " wins" : "empty")
color: winCount > 0 ? theme.muted : theme.magenta
font.family: theme.displayFont
font.pixelSize: 9
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: theme.line
}
Repeater {
model: wsCard.titles
Text {
Layout.fillWidth: true
required property var modelData
text: " \uE0B7 " + modelData
color: modelData === undefined ? theme.text : theme.text
font.pixelSize: 11
elide: Text.ElideRight
}
}
Text {
Layout.fillWidth: true
visible: winCount > wsCard.titles.length
text: " \uf0a1 " + (winCount - wsCard.titles.length) + " more..."
color: theme.muted
font.pixelSize: 10
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.wsIndex = Hyprland.workspaces.values.indexOf(modelData)
onClicked: {
wsCard.modelData.activate()
root.wsOpen = false
}
}
}
}
}
}
Item {
id: wsKeys
anchors.fill: parent
focus: true
function wsJump(): void {
const vals = Hyprland.workspaces.values;
if (root.wsIndex >= 0 && root.wsIndex < vals.length) {
vals[root.wsIndex].activate();
root.wsOpen = false;
}
}
Keys.onEscapePressed: root.wsOpen = false
Keys.onReturnPressed: wsKeys.wsJump()
Keys.onSpacePressed: wsKeys.wsJump()
Keys.onLeftPressed: root.wsIndex = Math.max(0, root.wsIndex - 1)
Keys.onRightPressed: root.wsIndex = Math.min(Hyprland.workspaces.values.length - 1, root.wsIndex + 1)
Keys.onUpPressed: root.wsIndex = Math.max(0, root.wsIndex - wsGrid.columns)
Keys.onDownPressed: root.wsIndex = Math.min(Hyprland.workspaces.values.length - 1, root.wsIndex + wsGrid.columns)
Keys.onDigit1Pressed: wsKeyJump(1)
Keys.onDigit2Pressed: wsKeyJump(2)
Keys.onDigit3Pressed: wsKeyJump(3)
Keys.onDigit4Pressed: wsKeyJump(4)
Keys.onDigit5Pressed: wsKeyJump(5)
Keys.onDigit6Pressed: wsKeyJump(6)
Keys.onDigit7Pressed: wsKeyJump(7)
Keys.onDigit8Pressed: wsKeyJump(8)
Keys.onDigit9Pressed: wsKeyJump(9)
Keys.onDigit0Pressed: wsKeyJump(10)
function wsKeyJump(id: int): void {
const vals = Hyprland.workspaces.values;
for (let i = 0; i < vals.length; i++) {
if (vals[i].id === id) {
root.wsIndex = i;
vals[i].activate();
root.wsOpen = false;
return;
}
}
}
}
}
}
}
// ================= Shell services (single-instance) =================
// Everything below lives at ShellRoot scope so it is NOT duplicated per
// screen by the Variants above.
// OSD startup guard: skip the initial volume-binding evaluation so the
// overlay doesn't flash on login.
property bool osdArmed: false
Timer {
interval: 1500
running: true
onTriggered: root.osdArmed = true
}
// ---------- dynamic-island bar state ----------
// Event-driven reveal (workspace switch, notification, media change).
// Hover-driven reveal lives per-bar (bar.hovered).
property bool islandRevealed: false
Timer {
id: islandHideTimer
interval: 10000
onTriggered: root.islandRevealed = false
}
function islandRaise() {
root.islandRevealed = true;
islandHideTimer.restart();
}
// PipeWire sink tracked at shell scope to drive the volume OSD (the bar
// keeps its own per-screen copy for display).
readonly property var shellSink: Pipewire.defaultAudioSink
readonly property real shellVol: root.shellSink !== null && root.shellSink.audio !== null ? root.shellSink.audio.volume : 0
readonly property bool shellMuted: root.shellSink !== null && root.shellSink.audio !== null ? root.shellSink.audio.muted : false
PwObjectTracker {
objects: root.shellSink === null ? [] : [root.shellSink]
}
// ---------- screen brightness (brightnessctl, shell scope) ----------
// Single source of truth shared by the bar brightness chip, the OSD and
// the quick-settings slider. `brightnessctl g/m` in one go: "<cur> <max>".
// (named shellBright* to avoid shadowing the per-popup brightSet/brightQuery)
property int brightNow: -1 // cached percent from the last query
property bool brightSetBusy: false
Process {
id: shellBrightProbe
command: ["sh", "-c", "echo $(brightnessctl g) $(brightnessctl m)"]
running: false
stdout: SplitParser {
onRead: line => {
const parts = line.trim().split(/\s+/).map(Number);
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1]) && parts[1] > 0)
root.brightNow = Math.round((parts[0] / parts[1]) * 100);
}
}
}
// throttled setter: skip while a previous request is still running
Process {
id: shellBrightSet
running: false
onExited: {
root.brightSetBusy = false;
shellBrightProbe.running = true; // refresh chiplets + OSD fraction
}
}
// keep the chip fresh when the panel keys / other tools change brightness
Timer {
interval: 30000
running: true
repeat: true
triggeredOnStart: true
onTriggered: shellBrightProbe.running = true
}
Connections {
target: root
function onShellVolChanged() {
osdWin.showVol();
}
function onShellMutedChanged() {
osdWin.showVol();
}
function onMediaPlayerChanged() {
if (!root.osdArmed) return;
root.islandRaise();
}
}
// MPRIS active player: first playing one, else the first registered.
readonly property MprisPlayer mediaPlayer: {
const ps = Mpris.players.values;
for (const p of ps) {
if (p.isPlaying)
return p;
}
return ps.length > 0 ? ps[0] : null;
}
// ---------- keybind cheatsheet ----------
// helpSections is loaded from keybinds.json, which Home Manager generates
// next to shell.qml; watchChanges keeps it fresh across rebuilds.
property bool helpOpen: false
property var helpSections: []
FileView {
id: helpFile
path: "file://" + Quickshell.shellDir + "/keybinds.json"
watchChanges: true
onLoaded: {
try {
root.helpSections = JSON.parse(text());
} catch (e) {
console.log("keybinds.json parse failed: " + e);
}
}
onFileChanged: helpFile.reload()
}
// ---------- system stats ------------------------------
// One probe (~250ms idle CPU sample) powers both the always-on chip and the
// popup. The chip polls slowly (15s) to stay battery-cheap; the popup bumps
// to a fast 3s refresh (with a real CPU%) while open.
Process {
id: statsProbe
running: false
stdout: StdioCollector {
onStreamFinished: {
try {
root.stats = JSON.parse(this.text);
} catch (e) {
console.log("qs-stats parse failed: " + e);
}
}
}
}
Timer {
id: statsTimer
interval: 15000
running: true
repeat: true
triggeredOnStart: true
onTriggered: statsProbe.exec(["sh", "-c", root.statsScript])
}
Timer {
id: statsPopupTimer
interval: 3000
running: false
repeat: true
onTriggered: statsProbe.exec(["sh", "-c", root.statsScript])
}
// Update command shown by the flake badge (copied to clipboard on click).
readonly property string flakeUpdateCmd: "cd ~/Nix-Vibe && nix flake update && sudo nixos-rebuild switch --flake .#x1carbon"
Process {
id: flakeCopyProc
}
function flakeCopy() {
flakeCopyProc.exec(["sh", "-c", "printf '%s' '" + root.flakeUpdateCmd + "' | wl-copy"]);
flakeCopyLabel.text = "\uf00c COPIED";
flakeResetTimer.start();
}
Timer {
id: flakeResetTimer
interval: 1800
onTriggered: flakeCopyLabel.text = "\uf0c1 COPY UPDATE CMD";
}
// ---------- clipboard history state (cliphist via the HM service) ----------
property bool clipOpen: false
property var clipEntries: []
Process {
id: clipList
stdout: StdioCollector {
onStreamFinished: {
const out = [];
for (const l of text.split("\n")) {
if (l.length === 0)
continue;
const tab = l.indexOf("\t");
if (tab > 0)
out.push({ "hash": l.slice(0, tab), "text": l.slice(tab + 1) });
else
out.push({ "hash": l, "text": l });
}
root.clipEntries = out;
}
}
}
Process {
id: clipCopy
}
function clipPick(hash) {
console.log("clipPick: " + hash);
clipCopy.exec(["sh", "-c", "printf %s '" + hash + "' | cliphist decode | wl-copy"]);
root.clipOpen = false;
clipPasteTimer.restart();
}
Timer {
id: clipPasteTimer
interval: 120
onTriggered: {
// window selector resolved at dispatch time: prefer the app that was
// focused when the picker OPENED (captured below), else whatever is
// active now
const wsel = clipPaste.targetAddr !== "" ? "address:" + clipPaste.targetAddr : "active";
console.log("clip paste -> " + wsel);
clipPaste.exec(["hyprctl", "dispatch", "hl.dsp.send_shortcut({mods = \"CTRL\", key = \"V\", window = \"" + wsel + "\"})"]);
}
}
Process {
id: clipPaste
property string targetAddr: ""
running: false
}
// authoritative active-window query at picker-open time (Hyprland's
// activeToplevel property proved unreliable from the IpcHandler scope)
Process {
id: clipAddrProbe
command: ["sh", "-c", "hyprctl activewindow | head -1 | cut -d' ' -f2 | sed 's/^/0x/'"]
running: false
stdout: StdioCollector {
onStreamFinished: {
const a = text.trim();
if (a !== "")
clipPaste.targetAddr = a;
}
}
}
function clipRefresh() {
clipList.exec(["sh", "-c", "cliphist list | head -n 30"]);
}
Connections {
target: root
function onClipOpenChanged() {
if (root.clipOpen) {
clipAddrProbe.running = true;
root.clipRefresh();
}
}
}
// ---------- notification center (native Quickshell NotificationServer) ----------
// Replaces mako. One server per process (takes the freedesktop.org
// Notifications DBus name). Toasts show live, tracked notifications keep
// a history in the center; DND hides toasts but keeps tracking.
property bool notifOpen: false
property bool notifDnd: false
property int notifUnseen: 0
property var toastItems: [] // Notification refs currently rendered as toasts
NotificationServer {
id: notifServer
actionsSupported: true
bodyMarkupSupported: false // we render bodies as PlainText
keepOnReload: true
onNotification: notification => {
if (!notification.transient)
notification.tracked = true;
// after a reload these are re-emitted for history, not new toasts
if (notification.lastGeneration)
return;
if (!root.notifOpen)
root.notifUnseen++;
if (!root.notifOpen && !root.notifDnd)
root.toastPush(notification);
}
}
function toastPush(n) {
root.islandRaise();
root.toastItems = root.toastItems.concat(n);
// cap on-screen toasts like mako's max-visible=5
if (root.toastItems.length > 5)
root.toastItems = root.toastItems.slice(root.toastItems.length - 5);
}
function toastRemove(n) {
root.toastItems = root.toastItems.filter(t => t !== n);
}
function notifDismiss(n) {
n.dismiss(); // closes + tells the app, and drops it from history
}
function notifClear() {
const vals = notifServer.trackedNotifications.values;
const tod = [];
for (let i = 0; i < vals.length; i++)
tod.push(vals[i]);
tod.forEach(nt => nt.dismiss());
root.notifUnseen = 0;
}
function notifAccent(u) {
if (u === NotificationUrgency.Critical)
return theme.danger;
if (u === NotificationUrgency.Low)
return theme.muted;
return theme.neon;
}
// ---------- workspace overview (SUPER+W mission control) ----------
// HyprlandWorkspace only exposes a window *count* on this build, so window
// titles are gathered by filtering the root Hyprland.toplevels object model
// for the workspace id (max 6, then "+N more").
property bool wsOpen: false
property int wsIndex: 0 // keyboard/hover selection within Hyprland.workspaces
function focusedWorkspaceIndex(): int {
const tl = Hyprland.workspaces.values;
for (let i = 0; i < tl.length; i++) {
if (tl[i].focused)
return i;
}
return 0;
}
function wsTitles(w) {
const out = [];
const tl = Hyprland.toplevels.values;
for (let i = 0; i < tl.length; i++) {
if (out.length >= 6)
break;
const t = tl[i];
if (t.workspace != null && t.workspace.id === w.id)
out.push(t.title);
}
return out;
}
// ---------- calendar + weather popup (clock click / SUPER+CTRL+C) ----------
// Data comes from `qs-cal-sync` (home-manager quickshell-cal module):
// qs-cal-sync read -> merge caches to one JSON doc (instant)
// qs-cal-sync sync -> Nextcloud CalDAV sync + weather refresh
// qs-cal-sync setloc <q> -> geocode, rewrite loc.json, refresh + print
// Caches live in ~/.cache/quickshell-cal/events.json + weather.json + loc.json,
// refreshed every 20 min by a home-manager user timer under petere.
property bool calOpen: false
property bool calLoading: false
property bool calEditingLoc: false
property var calEvents: []
property var calWeather: null
property var calLoc: null
property string calFetchedEvents: ""
property var calView: new Date(new Date().getFullYear(), new Date().getMonth(), 1)
property var calSel: (() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; })()
readonly property string calScript: "/home/petere/.local/bin/qs-cal-sync"
// ---------- "new event" form ----------
property var calCals: []
property bool calAdding: false
property string calNewCal: ""
property string calNewTitle: ""
property string calNewDate: ""
property string calNewStart: "09:00"
property string calNewEnd: "10:00"
property string calNewLoc: ""
property bool calNewAllDay: false
property int calNewRem: 0
property string calNewRep: "NONE"
// Editing an existing event (instead of adding): the agenda row click fills
// the form and sets calEditEv with the target's {uid, file, cal} so the save
// path rewrites that .ics instead of creating a new one.
property bool calEditMode: false
property var calEditEv: null
// sync.json health -> "!" badge in the header when a sync/add push failed.
property var calSyncOk: null
// ---------- wallpaper picker (SUPER+CTRL+A) ----------
// Backend `qs-wall` (home-manager hyprland module) lists
// ~/Pictures/wallpapers, applies via `hyprctl hyprpaper`, and persists the
// choice to ~/.cache/quickshell/wallpaper.
property bool wallOpen: false
property var wallList: []
property int wallIndex: -1 // keyboard cursor; -1 = not yet positioned
property string wallSelPath: "" // highlighted card (path-compare; Repeater index is unavailable in this quickshell)
readonly property string wallScript: "/home/petere/.local/bin/qs-wall"
readonly property string qsThemeScript: "/home/petere/.local/bin/qs-theme"
// ---------- autostart app manager (gear quick-settings panel) ----------
// Backend `qs-apps` (home-manager quickshell-apps module) manages a
// user-toggleable list of apps to start at login. State is persisted in
// ~/.cache/quickshell/autostart.json; `qs-apps run` is called at login
// from the Hyprland start handler (hyprland.nix).
readonly property string appsScript: "/home/petere/.local/bin/qs-apps"
// ---------- bluetooth (BT status-bar button + popup) ----------
// Backend `qs-bt` (home-manager hyprland module) wraps bluetoothctl; state is
// re-fetched whenever the popup opens and after every action.
property bool btOpen: false
property bool btBusy: false
property var btPowered: null // null = no adapter fetched, true/false = powered
property bool btDiscovering: false
property string btAdapterName: ""
property string btError: ""
property var btDevices: []
readonly property int btConnected: root.btDevices.filter(d => d.connected).length
readonly property string btScript: "/home/petere/.local/bin/qs-bt"
// tablet-mode state mirrored by the hyprland-tablet daemon (status file):
// tablet = display rotated into tablet orientation, osk = wvkbd running.
property bool oskActive: false
property bool tabletActive: false
readonly property string oskScript: "hyprland-tablet"
function calStartAdd() {
const cals = root.calCals;
root.calNewCal = cals.indexOf("personal") >= 0 ? "personal" : (cals.length > 0 ? cals[0] : "");
root.calNewDate = root.calDayPath(root.calSel);
root.calNewTitle = "";
root.calNewStart = "09:00";
root.calNewEnd = "10:00";
root.calNewLoc = "";
root.calNewAllDay = false;
root.calNewRem = 0;
root.calNewRep = "NONE";
root.calEditMode = false;
root.calEditEv = null;
root.calAdding = true;
calNewTitleInput.forceActiveFocus();
}
// Repeat presets <-> RRULE for the form selector.
function calRepFromRrule(rrule) {
const s = String(rrule || "").toUpperCase();
if (s.indexOf("FREQ=DAILY") >= 0)
return "DAILY";
if (s.indexOf("FREQ=WEEKLY") >= 0)
return "WEEKLY";
if (s.indexOf("FREQ=MONTHLY") >= 0)
return "MONTHLY";
return "NONE";
}
function calStartEdit(ev) {
root.calNewCal = ev.cal;
root.calNewTitle = ev.title;
root.calNewDate = ev.d;
root.calNewStart = ev.t !== "" ? ev.t : "09:00";
root.calNewEnd = ev.e !== "" ? ev.e : "10:00";
root.calNewLoc = ev.loc || "";
root.calNewAllDay = ev.allDay === true;
root.calNewRem = ev.rem || 0;
root.calNewRep = root.calRepFromRrule(ev.rrule);
root.calEditMode = true;
root.calEditEv = ev;
root.calAdding = true;
calNewTitleInput.forceActiveFocus();
}
function calDiscardEdit() {
root.calAdding = false;
root.calEditMode = false;
root.calEditEv = null;
}
function calCommitSave() {
const title = root.calNewTitle.trim();
if (title === "" || root.calNewCal === "") {
console.log("cal save: missing title/calendar");
return;
}
const obj = {
cal: root.calNewCal,
title: title,
date: root.calNewDate,
start: root.calNewStart.trim() || "09:00",
end: root.calNewEnd.trim() || "10:00",
loc: root.calNewLoc.trim(),
allDay: root.calNewAllDay,
reminder: root.calNewRem,
repeat: root.calNewRep,
};
const mode = root.calEditMode && root.calEditEv ? "edit" : "add";
if (root.calEditEv && root.calEditEv.uid) {
obj.uid = root.calEditEv.uid;
obj.file = root.calEditEv.file;
}
root.calAdding = false;
root.calEditMode = false;
root.calEditEv = null;
calAddProc.exec([root.calScript, mode, JSON.stringify(obj)]);
}
function calDeleteEv() {
const ev = root.calEditEv;
if (!ev || !ev.uid)
return;
root.calAdding = false;
root.calEditMode = false;
calAddProc.exec([root.calScript, "delete", JSON.stringify({
cal: ev.cal,
file: ev.file,
uid: ev.uid,
})]);
root.calEditEv = null;
}
function calDayPath(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return y + "-" + m + "-" + day;
}
function calResetToday() {
const d = new Date();
d.setHours(0, 0, 0, 0);
root.calSel = d;
root.calView = new Date(d.getFullYear(), d.getMonth(), 1);
}
function calShiftMonth(delta) {
root.calView = new Date(root.calView.getFullYear(), root.calView.getMonth() + delta, 1);
}
function calSelMove(dx, dy) {
let d = root.calSel != null ? new Date(root.calSel) : new Date();
d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + (7 * dy + dx));
root.calSel = d;
root.calView = new Date(d.getFullYear(), d.getMonth(), 1);
}
function calBuildMonth() {
const view = root.calView;
const first = new Date(view.getFullYear(), view.getMonth(), 1);
const lead = (first.getDay() + 6) % 7; // Monday-first
const today = new Date();
today.setHours(0, 0, 0, 0);
const sel = root.calSel != null ? new Date(root.calSel) : today;
sel.setHours(0, 0, 0, 0);
const start = new Date(first);
start.setDate(first.getDate() - lead);
const cells = [];
const ev = root.calEvents;
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
d.setHours(0, 0, 0, 0);
const path = root.calDayPath(d);
let has = false;
for (let j = 0; j < ev.length; j++) {
if (ev[j].d === path) {
has = true;
break;
}
}
cells.push({
ms: d.getTime(),
n: d.getDate(),
m: d.getMonth() === view.getMonth(),
today: d.getTime() === today.getTime(),
sel: d.getTime() === sel.getTime(),
has: has,
});
}
return cells;
}
function calSelEvents() {
if (root.calSel == null || root.calEvents.length === 0)
return [];
const path = root.calDayPath(root.calSel);
const out = [];
for (let i = 0; i < root.calEvents.length; i++) {
if (out.length >= 6)
break;
if (root.calEvents[i].d === path)
out.push(root.calEvents[i]);
}
return out;
}
function calSelEventsCount() {
if (root.calSel == null)
return 0;
const path = root.calDayPath(root.calSel);
let n = 0;
for (let i = 0; i < root.calEvents.length; i++)
if (root.calEvents[i].d === path)
n++;
return n;
}
function calCalColor(name) {
const palette = ["#ff2e97", "#00e5ff", "#7c4dff", "#9ece6a", "#e0af68", "#f7768e"];
let h = 0;
for (let i = 0; i < name.length; i++)
h = (h + name.charCodeAt(i)) % 9973;
return palette[h % palette.length];
}
function calTemp(v) {
if (v === null || v === undefined || isNaN(v))
return "--";
return Math.round(v) + "\u00b0";
}
function calCurTemp() {
if (root.calWeather && root.calWeather.current)
return root.calTemp(root.calWeather.current.t);
return "--\u00b0";
}
function calCurCode() {
if (root.calWeather && root.calWeather.current)
return root.calWeather.current.code;
return null;
}
// Hourly trace for the rest of today (up to 8 entries from the current hour).
function calHourlySlice() {
const w = root.calWeather;
if (!w || !w.hourly || w.hourly.length === 0)
return [];
const nowH = new Date().getHours();
const out = [];
for (let i = 0; i < w.hourly.length && out.length < 8; i++) {
const hNum = Number(String(w.hourly[i].h).split(":")[0]);
if (!isNaN(hNum) && hNum >= nowH)
out.push(w.hourly[i]);
}
return out;
}
function calWeekday(isodate) {
const p = String(isodate).split("-");
if (p.length !== 3)
return "?";
const d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
return ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"][d.getDay()];
}
function calWmoLabel(code) {
if (code === null || code === undefined || isNaN(code))
return "—";
if (code === 0)
return "CLEAR";
if (code === 1 || code === 2)
return "FEW CLOUDS";
if (code === 3)
return "OVERCAST";
if (code >= 45 && code <= 48)
return "FOG";
if (code >= 51 && code <= 57)
return "DRIZZLE";
if (code >= 61 && code <= 67)
return "RAIN";
if (code >= 71 && code <= 77)
return "SNOW";
if (code >= 80 && code <= 82)
return "SHOWERS";
if (code >= 85 && code <= 86)
return "SNOW SHOWERS";
if (code >= 95)
return "STORM";
return "—";
}
function calWmoColor(code) {
if (code === 0)
return theme.neon;
if (code >= 95)
return theme.danger;
if (code >= 45)
return theme.violet;
return theme.muted;
}
Process {
id: calProc
stdout: StdioCollector {
onStreamFinished: root.calConsume(text)
}
}
Process {
id: calSyncProc
stdout: StdioCollector {
onStreamFinished: root.calRead()
}
}
Process {
id: calAddProc
stdout: StdioCollector {
onStreamFinished: root.calRead()
}
}
Process {
id: calSnoozeProc
stdout: StdioCollector {
onStreamFinished: (text) => {
// best-effort; the backend echoes {ok,key,until}
console.log("cal snooze: " + text.trim());
}
}
}
// screenshot toast actions: shot-open opens the copied file, shot-copy
// re-pushes it to the clipboard (both identifiers carry the saved path)
readonly property string shotScript: "/home/petere/.local/bin/qs-shot"
Process { id: shotRunProc }
function calRun(mode, arg) {
root.calLoading = true;
calProc.exec(arg && arg !== "" ? [root.calScript, mode, arg] : [root.calScript, mode]);
}
function calRead() {
root.calRun("read");
}
function calRefresh() {
root.calLoading = true;
calSyncProc.exec([root.calScript, "sync"]);
}
function calConsume(text) {
root.calLoading = false;
try {
const o = JSON.parse(text.trim());
if (o.events !== undefined)
root.calEvents = o.events;
if (o.weather !== undefined && o.weather !== null)
root.calWeather = o.weather;
if (o.loc !== undefined && o.loc !== null)
root.calLoc = o.loc;
if (o.eventsFetched !== undefined)
root.calFetchedEvents = o.eventsFetched;
if (o.cals !== undefined && o.cals !== null)
root.calCals = o.cals.filter(c => c.indexOf("birthday") === -1);
if (o.sync !== undefined)
root.calSyncOk = o.sync;
} catch (e) {
console.log("cal parse error: " + e);
}
}
// ---------- calendar actions on notification buttons ----------
// The reminder notifier (qs-cal-sync remind) encodes payloads in the action
// *identifier*: cal-open:<date> opens the popup at that day; cal-snooze:<key>
// delays the contiguous reminder by 10 min. Anything else falls through to the
// normal D-Bus action invocation.
function calNotifAction(notification, action) {
const idn = String(action.identifier);
if (idn.indexOf("cal-open:") === 0) {
const parts = idn.slice("cal-open:".length).split("-");
if (parts.length === 3) {
const y = Number(parts[0]);
const m = Number(parts[1]) - 1;
const d = Number(parts[2]);
if (!isNaN(y) && !isNaN(m) && !isNaN(d)) {
const dd = new Date(y, m, d);
dd.setHours(0, 0, 0, 0);
root.calSel = dd;
root.calView = new Date(y, m, 1);
}
}
root.notifOpen = false;
root.calOpen = true;
notification.dismiss();
return;
}
if (idn.indexOf("cal-snooze:") === 0) {
const key = idn.slice("cal-snooze:".length);
const proc = calSnoozeProc;
root.notifDismiss(notification);
proc.exec([root.calScript, "snooze", key, "10"]);
return;
}
// screenshot toast actions: identifier payload is the saved PNG path
if (idn.indexOf("shot-open:") === 0) {
root.notifDismiss(notification);
shotRunProc.exec([root.shotScript, "open", idn.slice("shot-open:".length)]);
return;
}
if (idn.indexOf("shot-copy:") === 0) {
shotRunProc.exec([root.shotScript, "copy", idn.slice("shot-copy:".length)]);
return;
}
action.invoke();
}
// ---------- wallpaper picker plumbing ----------
Process {
id: wallListProc
stdout: StdioCollector {
onStreamFinished: {
try {
root.wallList = JSON.parse(text.trim());
if (root.wallOpen && root.wallSelPath === "") {
const i = root.wallList.findIndex(e => e.active);
root.wallIndex = i >= 0 ? i : 0;
root.wallSelPath = root.wallList.length > 0
? root.wallList[root.wallIndex].path
: "";
}
} catch (e) {
console.log("wall list parse error: " + e);
}
}
}
}
Process {
id: wallSetProc
stdout: StdioCollector {
onStreamFinished: root.wallRefresh()
}
}
Process {
id: themeProc
property bool generating: false
stdout: StdioCollector {
onStreamFinished: themeProc.generating = false
}
}
function wallRefresh() {
wallListProc.exec([root.wallScript, "list"]);
}
// Columns for the cursor math (matches the popup's wallCols at 620px width).
property int wallColsTotal: Math.max(1, Math.floor((620 - 20) / (262 + 12)))
function wallMove(dx, dy) {
const list = root.wallList;
if (list.length === 0)
return;
// linear cursor: left/right step one item, up/down step a full row
const cols = root.wallColsTotal;
const idx = Math.min(list.length - 1, root.wallIndex >= 0 ? root.wallIndex : 0);
const ni = Math.max(0, Math.min(list.length - 1, idx + (dx !== 0 ? dx : dy * cols)));
if (ni === idx)
return;
root.wallIndex = ni;
root.wallSelPath = list[ni].path;
root.wallPick(list[ni].path);
}
function wallPick(path) {
console.log("wall: set " + path);
wallSetProc.exec([root.wallScript, "set", path]);
}
// ---------- autostart app manager plumbing ----------
Process {
id: appsListProc
stdout: StdioCollector {
onStreamFinished: {
try {
const data = JSON.parse(text.trim());
root.appsEntries = data.apps || [];
} catch (e) {
console.log("apps list parse error: " + e);
}
}
}
}
Process {
id: appsSetProc
stdout: StdioCollector {
onStreamFinished: root.appsRefresh()
}
}
function appsRefresh() {
appsListProc.exec([root.appsScript, "list"]);
}
function appsToggle(name) {
appsSetProc.exec([root.appsScript, "toggle", name]);
}
function appsAdd(name, cmd) {
appsSetProc.exec([root.appsScript, "add", name, cmd]);
}
function appsRemove(name) {
appsSetProc.exec([root.appsScript, "remove", name]);
}
// ---------- battery charge limit plumbing ----------
Process {
id: chargeLimitStatusProc
running: false
stdout: StdioCollector {
onStreamFinished: {
const s = text.trim().toLowerCase();
if (s === "on" || s === "off")
root.chargeLimitActive = s === "on";
}
}
}
Process {
id: chargeLimitSetProc
running: false
onExited: {
root.chargeLimitBusy = false;
root.chargeLimitRefresh();
}
}
function chargeLimitRefresh() {
chargeLimitStatusProc.exec([root.chargeLimitScript, "status"]);
}
function chargeLimitToggle() {
if (root.chargeLimitBusy)
return;
root.chargeLimitBusy = true;
chargeLimitSetProc.exec([
"sudo",
root.chargeLimitScript,
root.chargeLimitActive ? "off" : "on",
]);
}
// ---------- bluetooth plumbing ----------
Process {
id: btStatusProc
running: false
stdout: StdioCollector {
onStreamFinished: {
try {
const o = JSON.parse(text.trim());
root.btPowered = o.powered;
root.btDiscovering = o.discovering === true;
root.btAdapterName = o.alias !== "" ? o.alias : o.name;
root.btDevices = o.devices || [];
root.btError = "";
} catch (e) {
console.log("bt status parse error: " + e);
}
}
}
onExited: root.btBusy = false
}
// ---------- tablet-mode / on-screen keyboard plumbing ----------
Process {
id: oskStatusProc
running: false
command: [root.oskScript, "status"]
stdout: StdioCollector {
onStreamFinished: {
try {
const o = JSON.parse(text.trim());
root.tabletActive = o.tablet === true;
root.oskActive = o.osk === true;
} catch (e) {
console.log("osk status parse error: " + e);
}
}
}
}
Process {
id: oskToggleProc
running: false
command: [root.oskScript, "keyboard-toggle"]
onExited: oskStatusTimer.restart()
}
Timer {
id: oskStatusTimer
interval: 2000
running: true
repeat: true
triggeredOnStart: true
onTriggered: oskStatusProc.running = true
}
function oskToggle() {
oskToggleProc.running = true;
}
Process {
id: btActionProc
running: false
stdout: StdioCollector {
onStreamFinished: {
try {
const o = JSON.parse(text.trim());
if (o && o.ok === false && o.error)
root.btError = o.error;
} catch (e) {
}
}
}
onExited: (code) => {
root.btBusy = false;
root.btDiscovering = false;
root.btRefresh();
}
}
function btRefresh() {
if (root.btBusy)
return;
root.btBusy = true;
btStatusProc.exec([root.btScript, "status"]);
}
function btRun(args) {
root.btBusy = true;
btActionProc.exec([root.btScript].concat(args));
}
function btScan() {
root.btBusy = true;
root.btDiscovering = true;
btActionProc.exec([root.btScript, "scan"]);
}
// row click: disconnect if connected, connect if paired, else pair
function btAct(mac) {
const d = root.btDevices.find(x => x.mac === mac);
if (!d)
return;
if (d.connected)
root.btRun(["disconnect", mac]);
else if (d.paired)
root.btRun(["connect", mac]);
else
root.btRun(["pair", mac]);
}
// ---------- IPC endpoints called from Hyprland keybinds ----------
// SUPER+CTRL+V -> qs ipc call clip toggle
// brightness keys -> qs ipc call osd bright (after brightnessctl set)
// NOTE: 0.3 renamed the IpcHandler name property from `topic` to `target`.
IpcHandler {
target: "clip"
function toggle(): void {
console.log("clip toggle, now " + !root.clipOpen);
if (!root.clipOpen)
clipAddrProbe.running = true;
root.clipOpen = !root.clipOpen;
}
// debug hook: qs ipc call clip pick <hash>
function pick(h: string): void {
root.clipPick(h);
}
}
IpcHandler {
target: "help"
function toggle(): void {
root.helpOpen = !root.helpOpen;
}
}
IpcHandler {
target: "osd"
// MUST NOT be named show()/call()/listen()/wait()/prop(): the qs CLI
// parses those tokens as its own subcommands and silently never calls
// the handler (qs ipc call osd show prints a metadata listing instead).
function bright(): void {
osdWin.showBright();
}
}
IpcHandler {
target: "notif"
function toggle(): void {
root.notifOpen = !root.notifOpen;
if (root.notifOpen)
root.notifUnseen = 0;
}
// debug wrapper: qs ipc call notif dnd
function dnd(): void {
root.notifDnd = !root.notifDnd;
console.log("NOTIF dnd now " + root.notifDnd);
}
// debug wrapper: qs ipc call notif state
function state(): void {
console.log("NOTIF state: open=" + root.notifOpen
+ " dnd=" + root.notifDnd
+ " unseen=" + root.notifUnseen
+ " toasts=" + root.toastItems.length
+ " tracked=" + notifServer.trackedNotifications.values.length);
}
}
IpcHandler {
target: "wall"
function toggle(): void {
root.wallOpen = !root.wallOpen;
}
// debug wrapper: qs ipc call wall move right|left|up|down (same code path
// as the arrow keys — handy for remote testing)
function move(dir: string): void {
if (dir === "left")
root.wallMove(-1, 0);
else if (dir === "right")
root.wallMove(1, 0);
else if (dir === "up")
root.wallMove(0, -1);
else if (dir === "down")
root.wallMove(0, 1);
}
// debug wrapper: qs ipc call wall state
function state(): void {
console.log("WALL state: open=" + root.wallOpen + " items=" + root.wallList.length);
}
}
IpcHandler {
target: "ws"
function toggle(): void {
root.wsOpen = !root.wsOpen;
}
// debug wrapper: qs ipc call ws state
function state(): void {
console.log("WS state: open=" + root.wsOpen
+ " workspaces=" + Hyprland.workspaces.values.length
+ " toplevels=" + Hyprland.toplevels.values.length);
}
}
IpcHandler {
target: "osk"
function toggle(): void {
root.oskToggle();
}
// debug wrapper: qs ipc call osk state
function state(): void {
console.log("OSK state: osk=" + root.oskActive + " tablet=" + root.tabletActive);
}
}
IpcHandler {
target: "cal"
function toggle(): void {
root.calOpen = !root.calOpen;
}
function refresh(): void {
root.calRefresh();
}
// debug wrapper: qs ipc call cal state
function state(): void {
console.log("CAL state: open=" + root.calOpen
+ " events=" + root.calEvents.length
+ " weather=" + (root.calWeather ? root.calWeather.name : "none")
+ " loc=" + (root.calLoc ? root.calLoc.name : "none"));
}
}
IpcHandler {
target: "bt"
function toggle(): void {
root.btOpen = !root.btOpen;
}
function refresh(): void {
root.btRefresh();
}
// debug wrappers: qs ipc call bt {power on, scan, pair <mac>, ...}
function power(val: string): void {
root.btRun(["power", val]);
}
function scan(): void {
root.btScan();
}
// debug wrapper: qs ipc call bt state
function state(): void {
console.log("BT state: open=" + root.btOpen
+ " powered=" + root.btPowered
+ " discovering=" + root.btDiscovering
+ " adapter=" + root.btAdapterName
+ " devices=" + root.btDevices.length
+ " connected=" + root.btConnected
+ " busy=" + root.btBusy
+ " error='" + root.btError + "'");
}
}
// ---------- volume / brightness OSD (right-edge neon pill) ----------
PanelWindow {
id: osdWin
// anchored to the first screen (Quickshell.primaryScreen does not exist
// in 0.3.1) — the strip covers the full right edge of that screen only
screen: Quickshell.screens.length > 0 ? Quickshell.screens[0] : null
anchors {
top: true
bottom: true
right: true
}
exclusiveZone: 0
implicitWidth: 64
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "qs-osd"
visible: false
// never steals input from whatever is under the strip
mask: Region {}
property string mode: "volume" // "volume" | "brightness"
property real frac: 0
readonly property int labelPct: Math.round(osdWin.frac * 100)
function showVol() {
if (!root.osdArmed)
return;
osdWin.mode = "volume";
osdWin.frac = root.shellMuted ? 0 : Math.min(1, root.shellVol);
osdWin.reveal();
}
function showBright() {
if (!root.osdArmed)
return;
osdWin.mode = "brightness";
if (root.brightNow >= 0)
osdWin.frac = Math.max(0, Math.min(1, root.brightNow / 100));
osdWin.reveal();
osdBrightProbe.running = true; // reconcile exact fraction
}
function reveal() {
osdWin.visible = true;
osdFade.stop();
osdContent.opacity = 1.0;
osdHideTimer.restart();
}
Timer {
id: osdHideTimer
interval: 1400
onTriggered: osdFade.start()
}
NumberAnimation {
id: osdFade
target: osdContent
property: "opacity"
to: 0.0
duration: 250
onFinished: osdWin.visible = false
}
// `brightnessctl g` + `brightnessctl m` in one go: "<cur> <max>"
Process {
id: osdBrightProbe
command: ["sh", "-c", "echo $(brightnessctl g) $(brightnessctl m)"]
running: false
stdout: SplitParser {
onRead: line => {
const parts = line.trim().split(/\s+/).map(Number);
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1]) && parts[1] > 0)
osdWin.frac = Math.max(0, Math.min(1, parts[0] / parts[1]));
}
}
}
Rectangle {
id: osdContent
anchors {
verticalCenter: parent.verticalCenter
right: parent.right
rightMargin: 10
}
width: 48
height: 190
radius: 14
color: theme.glassPanel
border.color: theme.neon
border.width: 1
ColumnLayout {
anchors {
fill: parent
margins: 8
}
spacing: 6
Text {
Layout.alignment: Qt.AlignHCenter
text: osdWin.mode === "volume"
? (root.shellMuted ? "\uf026" : "\uf028")
: "\uf185" // sun
font.family: theme.iconFont
font.pixelSize: 16
color: osdWin.mode === "brightness" ? theme.magenta : theme.neon
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
// vertical track, fills from the bottom
Rectangle {
id: osdTrack
anchors.centerIn: parent
width: 8
height: parent.height
radius: 4
color: theme.surface
border.color: theme.line
border.width: 1
Rectangle {
anchors {
bottom: parent.bottom
horizontalCenter: parent.horizontalCenter
}
width: parent.width
height: Math.max(4, osdTrack.height * Math.max(0, Math.min(1, osdWin.frac)))
radius: 4
color: osdWin.mode === "brightness" ? theme.magenta : theme.neon
Behavior on height { NumberAnimation { duration: 100 } }
}
}
}
Text {
Layout.alignment: Qt.AlignHCenter
text: osdWin.labelPct + "%"
font.family: theme.displayFont
font.pixelSize: 9
color: theme.muted
}
}
}
}
// ---------- battery low warnings (notify-send -> QuickShell toasts) ----------
property bool battArmed20: true
property bool battArmed5: true
Process {
id: battProc
running: false
}
Timer {
id: battTimer
interval: 30000
repeat: true
running: true
triggeredOnStart: true
onTriggered: {
const dd = UPower.displayDevice;
const b = (dd.ready && dd.isLaptopBattery) ? dd : UPower.devices.values.find(d => d.isLaptopBattery);
if (b === undefined || b === null)
return;
if (b.state === UPowerDeviceState.Charging || b.state === UPowerDeviceState.Full) {
// re-arm on charge so the warnings fire again next discharge
root.battArmed20 = true;
root.battArmed5 = true;
return;
}
const pct = b.percentage; // 0..1 fraction
if (pct <= 0.05 && root.battArmed5) {
root.battArmed5 = false;
root.battArmed20 = false;
battProc.exec(["notify-send", "-u", "critical", "-a", "quickshell", "BATTERY CRITICAL", "SYSTEM // " + Math.round(pct * 100) + "% // CONNECT POWER"]);
} else if (pct <= 0.20 && root.battArmed20) {
root.battArmed20 = false;
battProc.exec(["notify-send", "-u", "normal", "-a", "quickshell", "BATTERY LOW", "SYSTEM // " + Math.round(pct * 100) + "% REMAINING"]);
}
}
}
}