aurora8ab0f85
aurora: nothing os redesign, phase 2 (floating bar, calendar, edge osd, notification island)
Bar is a floating pill: dotted-ring mark (runner; live while recording/listening, flashes on new notifications), workspace dots, Doto clock, battery capsule. Calendar card, right-edge Glyph-strip OSD, notification island with hover hold. Tray menus need UseQApplication.
+586 −227
12 files changed
- M .config/quickshell/aurora/Bar.qml +140 −39
- M .config/quickshell/aurora/BarPill.qml +21 −27
- M .config/quickshell/aurora/CalPopup.qml +68 −61
- M .config/quickshell/aurora/NotifPopups.qml +232 −14
- M .config/quickshell/aurora/Notifs.qml +7 −0
- M .config/quickshell/aurora/Osd.qml +75 −42
- M .config/quickshell/aurora/SetAppearance.qml +1 −1
- M .config/quickshell/aurora/Settings.qml +3 −1
- M .config/quickshell/aurora/Theme.qml +3 −1
- M .config/quickshell/aurora/Tray.qml +11 −14
- M .config/quickshell/aurora/Workspaces.qml +23 −27
- M .config/quickshell/aurora/shell.qml +2 −0
| @@ -6,6 +6,12 @@ import Quickshell.Hyprland | |||
| 6 | 6 | import Quickshell.Services.Pipewire | |
| 7 | 7 | import Quickshell.Services.UPower | |
| 8 | 8 | ||
| 9 | + | // The bar: a floating pill at the top of every monitor. | |
| 10 | + | // Left: the mark (a dotted ring: click = runner; its centre turns the live colour while something records or listens, | |
| 11 | + | // the ring lights up in a wave on a new notification), this monitor's workspaces as dots, the cat. | |
| 12 | + | // Centre: the active window (focused monitor only). Right: tray, recording, privacy, caffeine/airplane, headset, | |
| 13 | + | // volume, net + bt, battery, clock (click = calendar). Clicks open the same panel tabs as always. | |
| 14 | + | // After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), bar/Bar.qml. | |
| 9 | 15 | PanelWindow { | |
| 10 | 16 | id: bar | |
| 11 | 17 | required property var modelData | |
| @@ -14,8 +20,9 @@ PanelWindow { | |||
| 14 | 20 | signal toggleOverlay(string name, var scr) | |
| 15 | 21 | ||
| 16 | 22 | anchors { top: true; left: true; right: true } | |
| 17 | − | implicitHeight: Theme.barHeight | |
| 18 | − | color: Theme.barBg | |
| 23 | + | implicitHeight: Theme.barHeight // the pill + the gap above it; also the exclusive zone | |
| 24 | + | color: "transparent" | |
| 25 | + | mask: Region { item: pill } | |
| 19 | 26 | WlrLayershell.namespace: "aurora-bar" | |
| 20 | 27 | ||
| 21 | 28 | SystemClock { id: clock; precision: SystemClock.Minutes } | |
| @@ -33,8 +40,7 @@ PanelWindow { | |||
| 33 | 40 | readonly property bool hasBat: bat !== null && bat.isPresent | |
| 34 | 41 | readonly property real batPct: hasBat ? (bat.percentage > 1 ? bat.percentage / 100 : bat.percentage) : 0 | |
| 35 | 42 | readonly property bool charging: hasBat && bat.state === UPowerDeviceState.Charging | |
| 36 | − | readonly property bool plugged: hasBat && bat.state === UPowerDeviceState.FullyCharged | |
| 37 | − | readonly property bool batLow: hasBat && batPct <= 0.15 && !charging | |
| 43 | + | readonly property bool batLow: hasBat && batPct <= Settings.batLow / 100 && !charging | |
| 38 | 44 | readonly property var adapter: Bluetooth.defaultAdapter | |
| 39 | 45 | ||
| 40 | 46 | // which Hyprland monitor this bar belongs to, and whether it has focus | |
| @@ -59,39 +65,88 @@ PanelWindow { | |||
| 59 | 65 | readonly property var headset: findHeadset() | |
| 60 | 66 | readonly property bool btOn: adapter !== null && adapter.enabled | |
| 61 | 67 | readonly property bool btLink: btOn && btConnected() | |
| 68 | + | // something records or listens: the mark's centre goes live | |
| 69 | + | readonly property bool live: Rec.busy || Privacy.mic || Privacy.cam || Privacy.screen | |
| 62 | 70 | ||
| 63 | − | Item { | |
| 64 | − | anchors.fill: parent | |
| 71 | + | // a new toast: the mark's ring lights up for 3s (error colour if critical) | |
| 72 | + | property bool flash: false | |
| 73 | + | property bool flashCrit: false | |
| 74 | + | Timer { id: flashT; interval: 3000; onTriggered: bar.flash = false } | |
| 75 | + | Connections { | |
| 76 | + | target: Notifs | |
| 77 | + | function onArrived(n, critical) { bar.flashCrit = critical; bar.flash = true; flashT.restart() } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | Rectangle { | |
| 81 | + | id: pill | |
| 82 | + | x: Theme.barGap | |
| 83 | + | y: Theme.barGap | |
| 84 | + | width: parent.width - 2 * Theme.barGap | |
| 85 | + | height: Theme.barSize | |
| 86 | + | radius: height / 2 | |
| 87 | + | color: Qt.rgba(Theme.s1.r, Theme.s1.g, Theme.s1.b, Theme.barAlpha) | |
| 88 | + | border.width: 1 | |
| 89 | + | border.color: Theme.s2 | |
| 65 | 90 | ||
| 66 | 91 | // ---------------- left ---------------- | |
| 67 | 92 | Row { | |
| 68 | 93 | anchors.left: parent.left | |
| 94 | + | anchors.leftMargin: Math.round(Theme.barSize * 0.25) | |
| 69 | 95 | height: parent.height | |
| 70 | − | spacing: 10 | |
| 96 | + | spacing: 14 | |
| 97 | + | ||
| 98 | + | Item { | |
| 99 | + | id: mark | |
| 100 | + | readonly property real size: Math.round(Theme.barSize * 0.55) | |
| 101 | + | width: size | |
| 102 | + | height: size | |
| 103 | + | anchors.verticalCenter: parent.verticalCenter | |
| 104 | + | Ring { | |
| 105 | + | anchors.fill: parent | |
| 106 | + | anchors.margins: 1.5 | |
| 107 | + | n: 16 | |
| 108 | + | dot: 2.6 | |
| 109 | + | on: bar.flash ? (bar.flashCrit ? Theme.err : Theme.fill) : Theme.fg | |
| 110 | + | off: Theme.s3 | |
| 111 | + | } | |
| 112 | + | Rectangle { | |
| 113 | + | anchors.centerIn: parent | |
| 114 | + | width: Math.round(mark.size * 0.32) | |
| 115 | + | height: width | |
| 116 | + | radius: width / 2 | |
| 117 | + | color: bar.live ? Theme.live : Theme.fg | |
| 118 | + | } | |
| 119 | + | MouseArea { | |
| 120 | + | anchors.fill: parent | |
| 121 | + | anchors.margins: -5 | |
| 122 | + | cursorShape: Qt.PointingHandCursor | |
| 123 | + | onClicked: bar.toggleOverlay("runner", bar.modelData) | |
| 124 | + | } | |
| 125 | + | } | |
| 71 | 126 | Workspaces { height: parent.height; monitor: bar.hmon } | |
| 72 | 127 | CatLoop { height: parent.height } | |
| 73 | 128 | } | |
| 74 | 129 | ||
| 75 | 130 | // ---------------- center ---------------- | |
| 76 | − | Text { | |
| 131 | + | Lb { | |
| 77 | 132 | anchors.centerIn: parent | |
| 78 | 133 | width: Math.min(implicitWidth, parent.width * 0.32) | |
| 79 | − | elide: Text.ElideRight | |
| 80 | 134 | horizontalAlignment: Text.AlignHCenter | |
| 81 | − | readonly property string title: ToplevelManager.activeToplevel ? ToplevelManager.activeToplevel.title : "" | |
| 82 | − | visible: bar.monFocused && title !== "" // active window is global: show it on the focused monitor only | |
| 83 | − | text: Theme.iArch + " " + title | |
| 84 | − | color: Theme.fg | |
| 85 | − | font.family: Theme.font | |
| 86 | − | font.pixelSize: Theme.fontSize | |
| 87 | − | font.bold: true | |
| 135 | + | elide: Text.ElideRight | |
| 136 | + | readonly property var active: ToplevelManager.activeToplevel | |
| 137 | + | readonly property string app: active && active.appId ? String(active.appId).split(".").pop() : "" | |
| 138 | + | visible: bar.monFocused && active !== null && active.title !== "" // the active window is global: focused monitor only | |
| 139 | + | text: active ? (app !== "" ? app + " · " + active.title : active.title) : "" | |
| 140 | + | px: 11 | |
| 141 | + | color: Theme.fgDim | |
| 88 | 142 | } | |
| 89 | 143 | ||
| 90 | 144 | // ---------------- right ---------------- | |
| 91 | 145 | Row { | |
| 92 | 146 | anchors.right: parent.right | |
| 147 | + | anchors.rightMargin: 6 | |
| 93 | 148 | height: parent.height | |
| 94 | − | spacing: 10 | |
| 149 | + | spacing: 0 | |
| 95 | 150 | ||
| 96 | 151 | Tray { show: bar.monFocused } | |
| 97 | 152 | ||
| @@ -99,8 +154,7 @@ PanelWindow { | |||
| 99 | 154 | visible: Rec.busy | |
| 100 | 155 | icon: Theme.iStop | |
| 101 | 156 | text: Rec.running ? "rec " + Theme.fmtClock(Rec.elapsed) : "select area" | |
| 102 | − | tint: Theme.err | |
| 103 | − | underline: Theme.err | |
| 157 | + | tint: Theme.live | |
| 104 | 158 | onActivated: Rec.stop() | |
| 105 | 159 | } | |
| 106 | 160 | ||
| @@ -109,23 +163,20 @@ PanelWindow { | |||
| 109 | 163 | visible: bar.monFocused && Privacy.mic | |
| 110 | 164 | icon: bar.mic && bar.mic.muted ? Theme.iMicOff : Theme.iMic | |
| 111 | 165 | text: Privacy.micApps.join(", ") | |
| 112 | − | tint: bar.mic && bar.mic.muted ? Theme.dim : Theme.err | |
| 113 | − | underline: Theme.err | |
| 166 | + | tint: bar.mic && bar.mic.muted ? Theme.grey : Theme.live | |
| 114 | 167 | onActivated: if (bar.mic) bar.mic.muted = !bar.mic.muted | |
| 115 | 168 | } | |
| 116 | 169 | BarPill { | |
| 117 | 170 | visible: bar.monFocused && Privacy.cam | |
| 118 | 171 | icon: Theme.iWebcam | |
| 119 | 172 | text: Privacy.camApps.join(", ") | |
| 120 | − | tint: Theme.err | |
| 121 | − | underline: Theme.err | |
| 173 | + | tint: Theme.live | |
| 122 | 174 | } | |
| 123 | 175 | BarPill { | |
| 124 | 176 | visible: bar.monFocused && Privacy.screen | |
| 125 | 177 | icon: Theme.iCast | |
| 126 | 178 | text: Privacy.shareApps.join(", ") | |
| 127 | − | tint: Theme.err | |
| 128 | − | underline: Theme.err | |
| 179 | + | tint: Theme.live | |
| 129 | 180 | } | |
| 130 | 181 | ||
| 131 | 182 | BarPill { | |
| @@ -146,12 +197,15 @@ PanelWindow { | |||
| 146 | 197 | visible: bar.audio !== null | |
| 147 | 198 | icon: Theme.volIcon(bar.audio ? bar.audio.volume : 0, bar.audio ? bar.audio.muted : true) | |
| 148 | 199 | text: (bar.audio ? Math.round(bar.audio.volume * 100) : 0) + "%" | |
| 149 | − | + (bar.mic && bar.mic.muted ? " " + Theme.iMicOff : "") | |
| 200 | + | tint: bar.audio && bar.audio.muted ? Theme.grey : Theme.fg | |
| 201 | + | icon2: bar.mic && bar.mic.muted ? Theme.iMicOff : "" | |
| 202 | + | tint2: Theme.grey | |
| 150 | 203 | onActivated: button => { | |
| 151 | 204 | if (button === Qt.RightButton && bar.audio) bar.audio.muted = !bar.audio.muted | |
| 152 | 205 | else if (button === Qt.MiddleButton && bar.mic) bar.mic.muted = !bar.mic.muted | |
| 153 | 206 | else bar.togglePanel("audio", bar.modelData) | |
| 154 | 207 | } | |
| 208 | + | onActivated2: button => { if (bar.mic) bar.mic.muted = !bar.mic.muted } | |
| 155 | 209 | onScrolled: dir => { | |
| 156 | 210 | if (bar.audio) bar.audio.volume = Math.max(0, Math.min(1, bar.audio.volume + dir * 0.02)) | |
| 157 | 211 | } | |
| @@ -161,31 +215,78 @@ PanelWindow { | |||
| 161 | 215 | BarPill { | |
| 162 | 216 | icon: Net.wired && !(Net.wifiEnabled && Net.connected !== "") ? Net.wiredIcon | |
| 163 | 217 | : (Net.wifiEnabled ? Theme.wifiIcon(Net.connected !== "" ? Net.activeSignal : 0) : Theme.iWifiOff) | |
| 164 | − | tint: Net.portal ? Theme.err : ((Net.wired || (Net.wifiEnabled && Net.connected !== "")) ? Theme.fg : Theme.dim) | |
| 218 | + | tint: Net.portal ? Theme.live : ((Net.wired || (Net.wifiEnabled && Net.connected !== "")) ? Theme.fg : Theme.grey) | |
| 165 | 219 | icon2: bar.adapter === null ? "" : (!bar.btOn ? Theme.iBtOff : (bar.btLink ? Theme.iBtConn : Theme.iBt)) | |
| 166 | − | tint2: bar.btLink ? Theme.fg : Theme.dim | |
| 220 | + | tint2: bar.btLink ? Theme.fg : Theme.grey | |
| 167 | 221 | onActivated: bar.togglePanel("net", bar.modelData) | |
| 168 | 222 | onActivated2: bar.togglePanel("bt", bar.modelData) | |
| 169 | 223 | } | |
| 170 | 224 | ||
| 225 | + | // battery: a capsule that fills up, the error colour when low, a bolt + breathing fill while charging | |
| 171 | 226 | BarPill { | |
| 227 | + | id: batt | |
| 172 | 228 | visible: bar.hasBat | |
| 173 | − | icon: Theme.batIcon(bar.batPct, bar.charging, bar.plugged) | |
| 174 | − | text: Math.round(bar.batPct * 100) + "%" | |
| 175 | − | tint: bar.batLow ? Theme.err : Theme.fg | |
| 176 | − | underline: bar.batLow ? Theme.err : Theme.accent | |
| 229 | + | icon: bar.charging ? Theme.iBolt : "" | |
| 177 | 230 | onActivated: bar.togglePanel("sys", bar.modelData) | |
| 231 | + | // breathing at 20 steps a second: an Animation would redraw the bar on every vsync while charging | |
| 232 | + | property real phase: 0 | |
| 233 | + | readonly property real pulse: bar.charging ? 0.675 + 0.325 * Math.cos(phase * Math.PI / 1.1) : 1 | |
| 234 | + | Timer { | |
| 235 | + | running: bar.charging && bar.visible | |
| 236 | + | interval: 50 | |
| 237 | + | repeat: true | |
| 238 | + | onTriggered: batt.phase = (batt.phase + 0.05) % 2.2 | |
| 239 | + | onRunningChanged: batt.phase = 0 | |
| 240 | + | } | |
| 241 | + | Rectangle { | |
| 242 | + | anchors.verticalCenter: parent.verticalCenter | |
| 243 | + | width: 26 | |
| 244 | + | height: 13 | |
| 245 | + | radius: height / 2 | |
| 246 | + | color: "transparent" | |
| 247 | + | border.width: 1.5 | |
| 248 | + | border.color: bar.batLow ? Theme.err : Theme.grey | |
| 249 | + | Rectangle { | |
| 250 | + | x: 3 | |
| 251 | + | anchors.verticalCenter: parent.verticalCenter | |
| 252 | + | width: Math.max(3, (parent.width - 6) * bar.batPct) | |
| 253 | + | height: parent.height - 6 | |
| 254 | + | radius: height / 2 | |
| 255 | + | color: bar.batLow ? Theme.err : Theme.fill | |
| 256 | + | opacity: batt.pulse | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | Lb { | |
| 260 | + | anchors.verticalCenter: parent.verticalCenter | |
| 261 | + | text: Math.round(bar.batPct * 100) | |
| 262 | + | px: 11 | |
| 263 | + | color: bar.batLow ? Theme.err : Theme.fg | |
| 264 | + | } | |
| 178 | 265 | } | |
| 179 | 266 | ||
| 267 | + | // clock: Doto from bar size 40 up (Doto is unreadable smaller), Space Mono below that | |
| 180 | 268 | BarPill { | |
| 181 | − | icon: Theme.iCal | |
| 182 | − | text: Qt.formatDateTime(clock.date, "dd MMM yyyy") | |
| 183 | − | onActivated: bar.toggleOverlay("cal", bar.modelData) | |
| 184 | − | } | |
| 185 | − | BarPill { | |
| 186 | − | icon: Theme.iClock | |
| 187 | − | text: Qt.formatDateTime(clock.date, "HH:mm") | |
| 188 | 269 | onActivated: bar.toggleOverlay("cal", bar.modelData) | |
| 270 | + | Num { | |
| 271 | + | visible: Theme.barSize >= 40 | |
| 272 | + | anchors.verticalCenter: parent.verticalCenter | |
| 273 | + | text: Theme.hh(clock.date) + ":" + Qt.formatDateTime(clock.date, "mm") | |
| 274 | + | font.pixelSize: 26 | |
| 275 | + | } | |
| 276 | + | Text { | |
| 277 | + | visible: Theme.barSize < 40 | |
| 278 | + | anchors.verticalCenter: parent.verticalCenter | |
| 279 | + | text: Theme.hh(clock.date) + ":" + Qt.formatDateTime(clock.date, "mm") | |
| 280 | + | font.family: Theme.mono | |
| 281 | + | font.pixelSize: 14 | |
| 282 | + | font.bold: true | |
| 283 | + | color: Theme.fg | |
| 284 | + | } | |
| 285 | + | Lb { | |
| 286 | + | anchors.verticalCenter: parent.verticalCenter | |
| 287 | + | text: Qt.locale("en_US").toString(clock.date, "ddd d") | |
| 288 | + | px: 10 | |
| 289 | + | } | |
| 189 | 290 | } | |
| 190 | 291 | } | |
| 191 | 292 | } | |
| @@ -1,68 +1,62 @@ | |||
| 1 | 1 | import QtQuick | |
| 2 | 2 | ||
| 3 | − | // A waybar-style block: flat, 12px padding, 1px accent underline. | |
| 3 | + | // One block of the bar: icon, caps label, optional second icon with its own click (activated2), anything else declared | |
| 4 | + | // inside goes after them (the battery capsule, the clock). Hover = a rounded highlight. | |
| 4 | 5 | Item { | |
| 5 | 6 | id: p | |
| 6 | 7 | property string icon: "" | |
| 7 | 8 | property string text: "" | |
| 8 | 9 | property color tint: Theme.fg | |
| 9 | − | property color underline: Theme.accent | |
| 10 | − | // optional second icon in the same block, with its own click (activated2) | |
| 10 | + | property color underline: "transparent" // old style, unused | |
| 11 | 11 | property string icon2: "" | |
| 12 | 12 | property color tint2: Theme.fg | |
| 13 | + | default property alias extra: row.data | |
| 13 | 14 | signal activated(int button) | |
| 14 | 15 | signal activated2(int button) | |
| 15 | 16 | signal scrolled(int dir) | |
| 16 | 17 | ||
| 17 | − | implicitWidth: row.implicitWidth + 24 | |
| 18 | − | implicitHeight: parent ? parent.height : Theme.barHeight | |
| 18 | + | implicitWidth: row.implicitWidth + 22 | |
| 19 | + | implicitHeight: parent ? parent.height : Theme.barSize | |
| 19 | 20 | ||
| 20 | 21 | Rectangle { | |
| 21 | 22 | anchors.fill: parent | |
| 22 | − | color: m.containsMouse ? Theme.surface : "transparent" | |
| 23 | − | } | |
| 24 | − | Rectangle { | |
| 25 | − | anchors.left: parent.left | |
| 26 | − | anchors.right: parent.right | |
| 27 | − | anchors.bottom: parent.bottom | |
| 28 | − | height: 1 | |
| 29 | − | color: p.underline | |
| 23 | + | anchors.topMargin: 5 | |
| 24 | + | anchors.bottomMargin: 5 | |
| 25 | + | radius: height / 2 | |
| 26 | + | color: m.containsMouse ? Theme.s2 : "transparent" | |
| 30 | 27 | } | |
| 31 | 28 | Row { | |
| 32 | 29 | id: row | |
| 33 | 30 | anchors.centerIn: parent | |
| 34 | − | spacing: 6 | |
| 35 | − | Text { | |
| 31 | + | spacing: 7 | |
| 32 | + | Icon { | |
| 36 | 33 | visible: p.icon !== "" | |
| 34 | + | anchors.verticalCenter: parent.verticalCenter | |
| 37 | 35 | text: p.icon | |
| 36 | + | font.pixelSize: 16 | |
| 38 | 37 | color: p.tint | |
| 39 | − | font.family: Theme.font | |
| 40 | − | font.pixelSize: Theme.fontSize + 1 | |
| 41 | − | anchors.verticalCenter: parent.verticalCenter | |
| 42 | 38 | } | |
| 43 | − | Text { | |
| 39 | + | Lb { | |
| 44 | 40 | visible: p.text !== "" | |
| 41 | + | anchors.verticalCenter: parent.verticalCenter | |
| 45 | 42 | text: p.text | |
| 43 | + | px: 11 | |
| 46 | 44 | color: p.tint | |
| 47 | − | font.family: Theme.font | |
| 48 | − | font.pixelSize: Theme.fontSize | |
| 49 | − | font.bold: true | |
| 50 | − | anchors.verticalCenter: parent.verticalCenter | |
| 51 | 45 | } | |
| 52 | − | Text { | |
| 46 | + | Icon { | |
| 53 | 47 | id: second | |
| 54 | 48 | visible: p.icon2 !== "" | |
| 49 | + | anchors.verticalCenter: parent.verticalCenter | |
| 55 | 50 | text: p.icon2 | |
| 51 | + | font.pixelSize: 16 | |
| 56 | 52 | color: p.tint2 | |
| 57 | − | font.family: Theme.font | |
| 58 | − | font.pixelSize: Theme.fontSize + 1 | |
| 59 | − | anchors.verticalCenter: parent.verticalCenter | |
| 60 | 53 | } | |
| 61 | 54 | } | |
| 62 | 55 | MouseArea { | |
| 63 | 56 | id: m | |
| 64 | 57 | anchors.fill: parent | |
| 65 | 58 | hoverEnabled: true | |
| 59 | + | cursorShape: Qt.PointingHandCursor | |
| 66 | 60 | acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton | |
| 67 | 61 | onClicked: mouse => { | |
| 68 | 62 | // split at the middle of the gap before the second icon | |
| @@ -4,19 +4,25 @@ import Quickshell | |||
| 4 | 4 | import Quickshell.Wayland | |
| 5 | 5 | import Quickshell.Hyprland | |
| 6 | 6 | ||
| 7 | − | // Month calendar under the clock pills. Monday first, ISO week numbers, scroll / arrows change month. | |
| 7 | + | // Month calendar under the bar clock: Doto month, Monday first, ISO week numbers, today in a primary circle. | |
| 8 | + | // Scroll / arrows change the month, up / down the year, space = back to today, Esc closes. | |
| 9 | + | // After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), cal/Cal.qml. | |
| 8 | 10 | PanelWindow { | |
| 9 | 11 | id: win | |
| 10 | 12 | property bool opened: false | |
| 11 | 13 | signal closeRequested() | |
| 12 | 14 | ||
| 13 | − | visible: opened | |
| 15 | + | property real t: opened ? 1 : 0 | |
| 16 | + | Behavior on t { NumberAnimation { duration: Theme.t; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.glide } } | |
| 17 | + | ||
| 18 | + | visible: t > 0 | |
| 14 | 19 | anchors { top: true; right: true } | |
| 15 | 20 | margins { top: 6; right: 8 } | |
| 16 | − | implicitWidth: 300 | |
| 17 | − | implicitHeight: col.implicitHeight + 24 | |
| 21 | + | implicitWidth: card.width | |
| 22 | + | implicitHeight: card.height + 20 // room for the slide | |
| 18 | 23 | exclusiveZone: 0 | |
| 19 | 24 | color: "transparent" | |
| 25 | + | mask: Region { item: card } | |
| 20 | 26 | WlrLayershell.layer: WlrLayer.Overlay | |
| 21 | 27 | WlrLayershell.namespace: "aurora-calendar" | |
| 22 | 28 | WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand | |
| @@ -27,7 +33,8 @@ PanelWindow { | |||
| 27 | 33 | onCleared: win.closeRequested() | |
| 28 | 34 | } | |
| 29 | 35 | ||
| 30 | − | SystemClock { id: clock; precision: SystemClock.Seconds } | |
| 36 | + | SystemClock { id: clock; precision: SystemClock.Minutes } | |
| 37 | + | readonly property var en: Qt.locale("en_US") | |
| 31 | 38 | ||
| 32 | 39 | property int year: 0 | |
| 33 | 40 | property int month: 0 // 0..11 | |
| @@ -62,10 +69,15 @@ PanelWindow { | |||
| 62 | 69 | readonly property var now: clock.date | |
| 63 | 70 | ||
| 64 | 71 | Rectangle { | |
| 65 | − | anchors.fill: parent | |
| 66 | − | color: Theme.bgA | |
| 67 | − | border.width: 2 | |
| 68 | − | border.color: Theme.accent | |
| 72 | + | id: card | |
| 73 | + | y: -16 * (1 - win.t) | |
| 74 | + | opacity: win.t | |
| 75 | + | width: 384 | |
| 76 | + | height: col.implicitHeight + 44 | |
| 77 | + | radius: 36 | |
| 78 | + | color: Qt.rgba(Theme.s1.r, Theme.s1.g, Theme.s1.b, Theme.panelAlpha) | |
| 79 | + | border.width: 1 | |
| 80 | + | border.color: Theme.s2 | |
| 69 | 81 | focus: true | |
| 70 | 82 | Keys.onEscapePressed: win.closeRequested() | |
| 71 | 83 | Keys.onLeftPressed: win.shift(-1) | |
| @@ -81,75 +93,63 @@ PanelWindow { | |||
| 81 | 93 | ||
| 82 | 94 | ColumnLayout { | |
| 83 | 95 | id: col | |
| 84 | − | anchors.left: parent.left | |
| 85 | − | anchors.right: parent.right | |
| 86 | − | anchors.top: parent.top | |
| 87 | − | anchors.margins: 12 | |
| 88 | − | spacing: 8 | |
| 96 | + | x: 22 | |
| 97 | + | y: 22 | |
| 98 | + | width: parent.width - 44 | |
| 99 | + | spacing: 14 | |
| 89 | 100 | ||
| 90 | − | // big clock | |
| 91 | − | Text { | |
| 92 | − | text: Qt.formatDateTime(win.now, "HH:mm:ss") | |
| 93 | − | color: Theme.fg | |
| 94 | − | font.family: Theme.font | |
| 95 | − | font.pixelSize: 28 | |
| 96 | − | font.bold: true | |
| 97 | − | } | |
| 98 | − | Text { | |
| 99 | − | text: Qt.formatDateTime(win.now, "dddd, d MMMM yyyy") + " · w" + win.isoWeek(win.now) | |
| 100 | − | color: Theme.dim | |
| 101 | − | font.family: Theme.font | |
| 102 | − | font.pixelSize: Theme.fontSize - 1 | |
| 101 | + | Lb { | |
| 102 | + | Layout.leftMargin: 6 | |
| 103 | + | px: 10 | |
| 104 | + | text: win.en.toString(win.now, "dddd · d MMMM yyyy") + " · week " + win.isoWeek(win.now) | |
| 103 | 105 | } | |
| 104 | 106 | ||
| 105 | − | Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: Theme.line } | |
| 106 | − | ||
| 107 | 107 | RowLayout { | |
| 108 | 108 | Layout.fillWidth: true | |
| 109 | − | IconBtn { icon: Theme.iPrev; implicitHeight: 24; onClicked: win.shift(-1) } | |
| 110 | − | Text { | |
| 111 | − | Layout.fillWidth: true | |
| 112 | − | horizontalAlignment: Text.AlignHCenter | |
| 113 | − | text: Qt.formatDate(new Date(win.year, win.month, 1), "MMMM yyyy").toUpperCase() | |
| 114 | − | color: Theme.accent | |
| 115 | − | font.family: Theme.font | |
| 116 | − | font.pixelSize: Theme.fontSize | |
| 117 | − | font.bold: true | |
| 118 | − | font.letterSpacing: 1.5 | |
| 109 | + | Layout.leftMargin: 6 | |
| 110 | + | spacing: 12 | |
| 111 | + | Num { | |
| 112 | + | text: win.en.standaloneMonthName(win.month, Locale.ShortFormat) | |
| 113 | + | font.pixelSize: 44 | |
| 119 | 114 | MouseArea { | |
| 120 | 115 | anchors.fill: parent | |
| 121 | 116 | cursorShape: Qt.PointingHandCursor | |
| 122 | 117 | onClicked: win.today() | |
| 123 | 118 | } | |
| 124 | 119 | } | |
| 125 | − | IconBtn { icon: Theme.iNext; implicitHeight: 24; onClicked: win.shift(1) } | |
| 120 | + | Lb { | |
| 121 | + | Layout.alignment: Qt.AlignVCenter | |
| 122 | + | text: win.year | |
| 123 | + | } | |
| 124 | + | Item { Layout.fillWidth: true } | |
| 125 | + | IconBtn { implicitHeight: 40; icon: Theme.iChevronLeft; onClicked: win.shift(-1) } | |
| 126 | + | IconBtn { implicitHeight: 40; icon: Theme.iChevron; onClicked: win.shift(1) } | |
| 126 | 127 | } | |
| 127 | 128 | ||
| 128 | − | GridLayout { | |
| 129 | + | Grid { | |
| 130 | + | id: grid | |
| 129 | 131 | Layout.fillWidth: true | |
| 132 | + | Layout.preferredWidth: 1 | |
| 130 | 133 | columns: 8 | |
| 131 | − | columnSpacing: 0 | |
| 132 | − | rowSpacing: 0 | |
| 134 | + | readonly property real cw: width / 8 | |
| 133 | 135 | ||
| 134 | 136 | Repeater { | |
| 135 | − | model: ["wk", "mo", "tu", "we", "th", "fr", "sa", "su"] | |
| 136 | − | delegate: Text { | |
| 137 | + | model: ["wk", "m", "t", "w", "t", "f", "s", "s"] | |
| 138 | + | delegate: Lb { | |
| 137 | 139 | required property var modelData | |
| 138 | 140 | required property int index | |
| 139 | − | Layout.fillWidth: true | |
| 140 | − | Layout.preferredHeight: 24 | |
| 141 | + | width: grid.cw | |
| 142 | + | height: 28 | |
| 141 | 143 | horizontalAlignment: Text.AlignHCenter | |
| 142 | 144 | verticalAlignment: Text.AlignVCenter | |
| 143 | 145 | text: modelData | |
| 144 | − | color: index === 0 ? Theme.line : (index >= 6 ? Theme.accentDim : Theme.dim) | |
| 145 | − | font.family: Theme.font | |
| 146 | − | font.pixelSize: Theme.fontSize - 2 | |
| 146 | + | px: 10 | |
| 147 | + | color: index === 0 ? Theme.mute : Theme.grey | |
| 147 | 148 | } | |
| 148 | 149 | } | |
| 149 | − | ||
| 150 | 150 | Repeater { | |
| 151 | 151 | model: 48 | |
| 152 | − | delegate: Rectangle { | |
| 152 | + | delegate: Item { | |
| 153 | 153 | id: cell | |
| 154 | 154 | required property int index | |
| 155 | 155 | readonly property bool isWeek: index % 8 === 0 | |
| @@ -158,18 +158,25 @@ PanelWindow { | |||
| 158 | 158 | readonly property bool inMonth: d.getMonth() === win.month | |
| 159 | 159 | readonly property bool isToday: !isWeek && d.toDateString() === win.now.toDateString() | |
| 160 | 160 | readonly property bool weekend: (index % 8) >= 6 | |
| 161 | − | Layout.fillWidth: true | |
| 162 | − | Layout.preferredHeight: 28 | |
| 163 | − | color: isToday ? Theme.accent : "transparent" | |
| 161 | + | width: grid.cw | |
| 162 | + | height: 40 | |
| 163 | + | Rectangle { | |
| 164 | + | visible: cell.isToday | |
| 165 | + | anchors.centerIn: parent | |
| 166 | + | width: 34 | |
| 167 | + | height: 34 | |
| 168 | + | radius: 17 | |
| 169 | + | color: Theme.fill | |
| 170 | + | } | |
| 164 | 171 | Text { | |
| 165 | 172 | anchors.centerIn: parent | |
| 166 | 173 | text: cell.isWeek ? win.isoWeek(cell.d) : cell.d.getDate() | |
| 167 | − | color: cell.isToday ? Theme.bg | |
| 168 | − | : cell.isWeek ? Theme.line | |
| 169 | − | : !cell.inMonth ? Theme.surfaceHi | |
| 170 | − | : cell.weekend ? Theme.accent : Theme.fg | |
| 171 | − | font.family: Theme.font | |
| 172 | − | font.pixelSize: cell.isWeek ? Theme.fontSize - 2 : Theme.fontSize | |
| 174 | + | color: cell.isToday ? Theme.fillFg | |
| 175 | + | : cell.isWeek ? Theme.mute | |
| 176 | + | : !cell.inMonth ? Theme.mute | |
| 177 | + | : cell.weekend ? Theme.grey : Theme.fg | |
| 178 | + | font.family: Theme.mono | |
| 179 | + | font.pixelSize: cell.isWeek ? 10 : 13 | |
| 173 | 180 | font.bold: cell.isToday | |
| 174 | 181 | } | |
| 175 | 182 | } | |
| @@ -1,33 +1,251 @@ | |||
| 1 | 1 | import QtQuick | |
| 2 | − | import QtQuick.Layouts | |
| 2 | + | import QtQuick.Effects | |
| 3 | 3 | import Quickshell | |
| 4 | 4 | import Quickshell.Wayland | |
| 5 | + | import Quickshell.Services.Notifications | |
| 5 | 6 | ||
| 7 | + | // Toasts as an island at the top centre, under the bar: up to 4 cards, newest on top. Each drops in as a small pill and | |
| 8 | + | // grows into its card; leaving, it shrinks back and the ones below slide up. Hovering holds them all, click = the app's | |
| 9 | + | // default action, the × on hover only drops the toast (the notification stays in the history), other actions are pills. | |
| 10 | + | // After glyph-rice's island (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), glyph/Glyph.qml. | |
| 6 | 11 | PanelWindow { | |
| 7 | 12 | id: win | |
| 8 | 13 | property bool suppressed: false // hidden while the control panel is open | |
| 14 | + | readonly property int cardW: 520 | |
| 9 | 15 | ||
| 10 | − | visible: Notifs.popups.length > 0 && !suppressed | |
| 11 | − | anchors { top: true; right: true } | |
| 12 | − | margins { top: 6; right: 8 } | |
| 13 | − | implicitWidth: 360 | |
| 14 | − | implicitHeight: col.implicitHeight | |
| 16 | + | visible: stack.count > 0 && !suppressed | |
| 17 | + | anchors.top: true | |
| 18 | + | margins.top: 6 | |
| 19 | + | implicitWidth: cardW | |
| 20 | + | implicitHeight: 4 * 160 + 20 // fixed; only the cards take input (mask) | |
| 15 | 21 | exclusiveZone: 0 | |
| 16 | 22 | color: "transparent" | |
| 23 | + | mask: Region { item: col } | |
| 17 | 24 | WlrLayershell.layer: WlrLayer.Overlay | |
| 18 | 25 | WlrLayershell.namespace: "aurora-notifs" | |
| 19 | 26 | WlrLayershell.keyboardFocus: WlrKeyboardFocus.None | |
| 20 | 27 | ||
| 21 | − | ColumnLayout { | |
| 28 | + | // The island's own model: Notifs.popups is a new array on every change, which would restart every card's animation. | |
| 29 | + | // Entries are snapshots (a notification can close while its card animates out); refs keeps the live object. | |
| 30 | + | ListModel { id: stack } | |
| 31 | + | property var refs: ({}) | |
| 32 | + | ||
| 33 | + | function imgOf(n) { | |
| 34 | + | if (n.image && n.image !== "") return n.image | |
| 35 | + | if (n.appIcon && n.appIcon !== "") return Quickshell.iconPath(n.appIcon, true) | |
| 36 | + | return "" | |
| 37 | + | } | |
| 38 | + | function sync() { | |
| 39 | + | var want = {}, l = Notifs.popups | |
| 40 | + | for (var i = 0; i < l.length; i++) want[String(l[i].n.id)] = l[i].n | |
| 41 | + | for (var j = 0; j < stack.count; j++) { | |
| 42 | + | var e = stack.get(j) | |
| 43 | + | if (!e.leaving && !want[e.key]) stack.setProperty(j, "leaving", true) | |
| 44 | + | } | |
| 45 | + | for (var k in want) { | |
| 46 | + | var known = false | |
| 47 | + | for (var m = 0; m < stack.count; m++) if (stack.get(m).key === k) { known = true; break } | |
| 48 | + | if (known) continue | |
| 49 | + | var n = want[k] | |
| 50 | + | refs[k] = n | |
| 51 | + | stack.insert(0, { key: k, app: n.appName || "app", summary: n.summary || "", body: n.body || "", | |
| 52 | + | icon: imgOf(n), photo: !!n.image && n.image !== "", | |
| 53 | + | crit: n.urgency === NotificationUrgency.Critical, leaving: false }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | function forget(key) { | |
| 57 | + | delete refs[key] | |
| 58 | + | for (var i = 0; i < stack.count; i++) if (stack.get(i).key === key) { stack.remove(i); return } | |
| 59 | + | } | |
| 60 | + | Connections { | |
| 61 | + | target: Notifs | |
| 62 | + | function onPopupsChanged() { win.sync() } | |
| 63 | + | } | |
| 64 | + | Component.onCompleted: sync() | |
| 65 | + | ||
| 66 | + | // hovering the island holds every toast | |
| 67 | + | Binding { target: Notifs; property: "hold"; value: hov.hovered } | |
| 68 | + | ||
| 69 | + | Column { | |
| 22 | 70 | id: col | |
| 23 | − | width: 360 | |
| 24 | − | spacing: 6 | |
| 71 | + | width: win.cardW | |
| 72 | + | HoverHandler { id: hov } | |
| 73 | + | ||
| 25 | 74 | Repeater { | |
| 26 | − | model: Notifs.popups | |
| 27 | − | delegate: NotifCard { | |
| 28 | − | required property var modelData | |
| 29 | − | n: modelData.n | |
| 30 | − | popup: true | |
| 75 | + | model: stack | |
| 76 | + | delegate: Item { | |
| 77 | + | id: c | |
| 78 | + | required property int index | |
| 79 | + | required property string key | |
| 80 | + | required property string app | |
| 81 | + | required property string summary | |
| 82 | + | required property string body | |
| 83 | + | required property string icon | |
| 84 | + | required property bool photo | |
| 85 | + | required property bool crit | |
| 86 | + | required property bool leaving | |
| 87 | + | ||
| 88 | + | readonly property var n: win.refs[key] || null | |
| 89 | + | // other actions as pills (the default one is the click); read once, the notification may close later | |
| 90 | + | readonly property var buttons: { | |
| 91 | + | var out = [], a = n ? n.actions : [] | |
| 92 | + | for (var i = 0; i < a.length; i++) if (a[i].identifier !== "default" && a[i].text !== "") out.push(a[i]) | |
| 93 | + | return out | |
| 94 | + | } | |
| 95 | + | readonly property var defaultAction: { | |
| 96 | + | var a = n ? n.actions : [] | |
| 97 | + | for (var i = 0; i < a.length; i++) if (a[i].identifier === "default") return a[i] | |
| 98 | + | return null | |
| 99 | + | } | |
| 100 | + | // invoke() can close the notification: drop by key afterwards, never touch c.n again | |
| 101 | + | function run(action) { | |
| 102 | + | var k = key | |
| 103 | + | try { if (action) action.invoke() } catch (e) {} | |
| 104 | + | var nn = win.refs[k] | |
| 105 | + | if (nn) Notifs.dropPopup(nn) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // p 0..1 linear; each phase gets its own ease-out-quart (like glide) | |
| 109 | + | property real p: 0 | |
| 110 | + | Component.onCompleted: p = Qt.binding(() => leaving ? 0 : 1) | |
| 111 | + | Behavior on p { enabled: Theme.motion; NumberAnimation { duration: c.leaving ? 450 : 700 } } | |
| 112 | + | onPChanged: if (leaving && p <= 0) Qt.callLater(win.forget, key) | |
| 113 | + | onLeavingChanged: if (leaving && !Theme.motion) Qt.callLater(win.forget, key) | |
| 114 | + | function ph(a, b) { var x = Math.max(0, Math.min(1, (p - a) / (b - a))); return 1 - Math.pow(1 - x, 4) } | |
| 115 | + | readonly property real drop: ph(0, 0.3) // the pill drops in | |
| 116 | + | readonly property real grow: ph(0.25, 0.8) // and spreads into the card | |
| 117 | + | readonly property real full: Math.max(86, txt.implicitHeight + 32) | |
| 118 | + | ||
| 119 | + | width: win.cardW | |
| 120 | + | height: (40 + (full - 40) * grow + 10) * drop // its slot grows and collapses with it | |
| 121 | + | ||
| 122 | + | Rectangle { | |
| 123 | + | id: card | |
| 124 | + | width: 140 + (win.cardW - 140) * c.grow | |
| 125 | + | height: 40 + (c.full - 40) * c.grow | |
| 126 | + | x: (win.cardW - width) / 2 | |
| 127 | + | y: -24 * (1 - c.drop) | |
| 128 | + | opacity: c.drop | |
| 129 | + | radius: Math.min(34, height / 2) | |
| 130 | + | clip: true | |
| 131 | + | color: Theme.s1 | |
| 132 | + | border.width: 1 | |
| 133 | + | border.color: c.crit ? Theme.err : Theme.grey | |
| 134 | + | ||
| 135 | + | MouseArea { | |
| 136 | + | id: ma | |
| 137 | + | anchors.fill: parent | |
| 138 | + | hoverEnabled: true | |
| 139 | + | cursorShape: c.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor | |
| 140 | + | onClicked: c.run(c.defaultAction) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // the content at full size in the middle: text doesn't reflow while the card grows | |
| 144 | + | Item { | |
| 145 | + | anchors.centerIn: parent | |
| 146 | + | width: win.cardW | |
| 147 | + | height: c.full | |
| 148 | + | opacity: c.ph(0.7, 1) | |
| 149 | + | ||
| 150 | + | Rectangle { | |
| 151 | + | id: ic | |
| 152 | + | x: 16 | |
| 153 | + | anchors.verticalCenter: parent.verticalCenter | |
| 154 | + | width: 54 | |
| 155 | + | height: 54 | |
| 156 | + | radius: 27 | |
| 157 | + | color: Theme.s2 | |
| 158 | + | Icon { anchors.centerIn: parent; visible: !img.visible; text: Theme.iBell; font.pixelSize: 22 } | |
| 159 | + | Image { | |
| 160 | + | id: img | |
| 161 | + | anchors.centerIn: parent | |
| 162 | + | width: c.photo && Settings.appIcons === "dots" ? 54 : 30 | |
| 163 | + | height: width | |
| 164 | + | visible: status === Image.Ready | |
| 165 | + | source: c.icon | |
| 166 | + | sourceSize: Qt.size(width * 2, height * 2) | |
| 167 | + | fillMode: Image.PreserveAspectCrop | |
| 168 | + | asynchronous: true | |
| 169 | + | layer.enabled: Settings.appIcons !== "colour" | |
| 170 | + | layer.effect: Settings.appIcons === "mono" ? monoFx : dotFx | |
| 171 | + | } | |
| 172 | + | Component { id: dotFx; DotMask { step: c.photo ? 3 : 2; circle: c.photo } } | |
| 173 | + | Component { id: monoFx; MultiEffect { saturation: -1 } } | |
| 174 | + | } | |
| 175 | + | Column { | |
| 176 | + | id: txt | |
| 177 | + | anchors.left: ic.right | |
| 178 | + | anchors.leftMargin: 16 | |
| 179 | + | anchors.right: parent.right | |
| 180 | + | anchors.rightMargin: 48 | |
| 181 | + | anchors.verticalCenter: parent.verticalCenter | |
| 182 | + | spacing: 2 | |
| 183 | + | Lb { width: parent.width; elide: Text.ElideRight; px: 10; text: c.app + " · now" } | |
| 184 | + | Text { | |
| 185 | + | width: parent.width | |
| 186 | + | elide: Text.ElideRight | |
| 187 | + | text: c.summary | |
| 188 | + | font.family: Theme.ui | |
| 189 | + | font.pixelSize: 17 | |
| 190 | + | font.weight: Font.DemiBold | |
| 191 | + | color: Theme.fg | |
| 192 | + | } | |
| 193 | + | Text { | |
| 194 | + | width: parent.width | |
| 195 | + | visible: c.body !== "" | |
| 196 | + | text: c.body | |
| 197 | + | textFormat: Text.StyledText | |
| 198 | + | wrapMode: Text.Wrap | |
| 199 | + | maximumLineCount: 2 | |
| 200 | + | elide: Text.ElideRight | |
| 201 | + | font.family: Theme.ui | |
| 202 | + | font.pixelSize: 14 | |
| 203 | + | color: Theme.fgDim | |
| 204 | + | linkColor: Theme.fill | |
| 205 | + | } | |
| 206 | + | Flow { | |
| 207 | + | visible: c.buttons.length > 0 | |
| 208 | + | width: parent.width | |
| 209 | + | topPadding: 8 | |
| 210 | + | spacing: 6 | |
| 211 | + | Repeater { | |
| 212 | + | model: c.buttons | |
| 213 | + | delegate: Chip { | |
| 214 | + | required property var modelData | |
| 215 | + | text: modelData.text | |
| 216 | + | filled: true | |
| 217 | + | px: 10 | |
| 218 | + | onClicked: c.run(modelData) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | // the newest one carries the live dot; on hover the dot makes room for × | |
| 224 | + | Rectangle { | |
| 225 | + | visible: c.index === 0 && !ma.containsMouse && !closeBtn.hovered | |
| 226 | + | anchors.right: parent.right | |
| 227 | + | anchors.rightMargin: 22 | |
| 228 | + | anchors.verticalCenter: parent.verticalCenter | |
| 229 | + | width: 10 | |
| 230 | + | height: 10 | |
| 231 | + | radius: 5 | |
| 232 | + | color: Theme.live | |
| 233 | + | } | |
| 234 | + | IconBtn { | |
| 235 | + | id: closeBtn | |
| 236 | + | readonly property bool hovered: hh.hovered | |
| 237 | + | visible: ma.containsMouse || hovered | |
| 238 | + | anchors.right: parent.right | |
| 239 | + | anchors.rightMargin: 12 | |
| 240 | + | anchors.verticalCenter: parent.verticalCenter | |
| 241 | + | implicitHeight: 30 | |
| 242 | + | icon: Theme.iClose | |
| 243 | + | iconSize: 13 | |
| 244 | + | onClicked: c.run(null) | |
| 245 | + | HoverHandler { id: hh } | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | } | |
| 31 | 249 | } | |
| 32 | 250 | } | |
| 33 | 251 | } | |
| @@ -8,6 +8,8 @@ import Quickshell.Services.Notifications | |||
| 8 | 8 | Singleton { | |
| 9 | 9 | id: root | |
| 10 | 10 | property bool dnd: false | |
| 11 | + | property bool hold: false // the pointer is on the toasts: none expires | |
| 12 | + | signal arrived(var n, bool critical) // a toast was shown (the bar mark flashes) | |
| 11 | 13 | ||
| 12 | 14 | // sound played on every toast (silenced by do-not-disturb, except critical ones) | |
| 13 | 15 | property bool soundEnabled: Settings.notifSound | |
| @@ -54,6 +56,7 @@ Singleton { | |||
| 54 | 56 | var next = popups.concat([{ n: n, until: Date.now() + ms }]) | |
| 55 | 57 | popups = next.slice(-4) | |
| 56 | 58 | n.closed.connect(function () { root.dropPopup(n) }) | |
| 59 | + | arrived(n, n.urgency === NotificationUrgency.Critical) | |
| 57 | 60 | } | |
| 58 | 61 | ||
| 59 | 62 | NotificationServer { | |
| @@ -82,6 +85,10 @@ Singleton { | |||
| 82 | 85 | running: root.popups.length > 0 | |
| 83 | 86 | repeat: true | |
| 84 | 87 | onTriggered: { | |
| 88 | + | if (root.hold) { // hovered: push every deadline back instead | |
| 89 | + | for (var i = 0; i < root.popups.length; i++) root.popups[i].until += 500 | |
| 90 | + | return | |
| 91 | + | } | |
| 85 | 92 | var now = Date.now() | |
| 86 | 93 | var keep = root.popups.filter(function (p) { return p.until > now }) | |
| 87 | 94 | if (keep.length !== root.popups.length) root.popups = keep | |
| @@ -1,11 +1,13 @@ | |||
| 1 | 1 | import QtQuick | |
| 2 | + | import QtQuick.Effects | |
| 2 | 3 | import Quickshell | |
| 3 | 4 | import Quickshell.Wayland | |
| 4 | 5 | import Quickshell.Services.Pipewire | |
| 5 | 6 | ||
| 6 | − | // Volume / mic / brightness popup: bottom-centre, click-through, auto-hides. | |
| 7 | − | // Volume + mute changes are picked up natively from Pipewire (any source: keys, wpctl, panel). | |
| 8 | − | // Brightness is pushed in via show() from the `bright` IPC call. | |
| 7 | + | // Volume / mic / brightness on the right screen edge, like the Glyph strips on the phone: 20 segments fill bottom-up | |
| 8 | + | // (the lit ones glow), a capsule next to them shows the icon and the value. Click-through, fades out after | |
| 9 | + | // Settings.osdTimeout. Volume + mute come natively from Pipewire (keys, wpctl, panel); brightness is pushed in with | |
| 10 | + | // show() from the `bright` IPC call. After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), glyph/Glyph.qml. | |
| 9 | 11 | PanelWindow { | |
| 10 | 12 | id: win | |
| 11 | 13 | property bool suppressed: false // hidden while the control panel is open | |
| @@ -19,16 +21,18 @@ PanelWindow { | |||
| 19 | 21 | win.icon = icon | |
| 20 | 22 | win.value = value | |
| 21 | 23 | win.muted = muted | |
| 22 | − | win.valueText = valueText !== undefined ? valueText : Math.round(value * 100) + "%" | |
| 24 | + | win.valueText = valueText !== undefined ? valueText : String(Math.round(value * 100)) | |
| 23 | 25 | shown = true | |
| 24 | 26 | hide.restart() | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | − | visible: shown && !suppressed | |
| 28 | − | anchors.bottom: true | |
| 29 | − | margins.bottom: 80 | |
| 30 | − | implicitWidth: 280 | |
| 31 | − | implicitHeight: 40 | |
| 29 | + | property real t: shown && !suppressed ? 1 : 0 | |
| 30 | + | Behavior on t { NumberAnimation { duration: Theme.t; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.glide } } | |
| 31 | + | ||
| 32 | + | visible: t > 0 | |
| 33 | + | anchors.right: true // vertically centred on the right edge | |
| 34 | + | implicitWidth: 150 | |
| 35 | + | implicitHeight: 520 | |
| 32 | 36 | exclusiveZone: 0 | |
| 33 | 37 | color: "transparent" | |
| 34 | 38 | mask: Region {} | |
| @@ -64,46 +68,75 @@ PanelWindow { | |||
| 64 | 68 | function onMutedChanged() { | |
| 65 | 69 | var a = win.source.audio | |
| 66 | 70 | if (!win.armed) return | |
| 67 | − | win.show(a.muted ? Theme.iMicOff : Theme.iMic, a.volume, a.muted, a.muted ? "muted" : "on") | |
| 71 | + | win.show(a.muted ? Theme.iMicOff : Theme.iMic, a.muted ? 0 : a.volume, a.muted, a.muted ? "off" : "on") | |
| 68 | 72 | } | |
| 69 | 73 | } | |
| 70 | 74 | ||
| 71 | 75 | // ---- ui ---- | |
| 72 | − | Rectangle { | |
| 76 | + | Item { | |
| 73 | 77 | anchors.fill: parent | |
| 74 | − | color: Theme.bgA | |
| 75 | − | border.width: 1 | |
| 76 | − | border.color: Theme.accent | |
| 77 | − | ||
| 78 | − | Row { | |
| 79 | − | anchors.fill: parent | |
| 80 | − | anchors.leftMargin: 12 | |
| 81 | − | anchors.rightMargin: 12 | |
| 82 | − | spacing: 10 | |
| 78 | + | opacity: win.t | |
| 79 | + | transform: Translate { x: 16 * (1 - win.t) } | |
| 83 | 80 | ||
| 84 | − | Text { | |
| 85 | − | anchors.verticalCenter: parent.verticalCenter | |
| 86 | − | width: 20 | |
| 87 | − | text: win.icon | |
| 88 | − | color: win.muted ? Theme.dim : Theme.fg | |
| 89 | − | font.family: Theme.font | |
| 90 | − | font.pixelSize: 16 | |
| 91 | − | } | |
| 92 | − | BarSlider { | |
| 93 | − | anchors.verticalCenter: parent.verticalCenter | |
| 94 | − | width: parent.width - 20 - 44 - parent.spacing * 2 | |
| 95 | − | value: win.value | |
| 96 | − | muted: win.muted | |
| 81 | + | // the strip: unlit segments, then the lit ones on top with the glow (the glow would outline unlit ones too) | |
| 82 | + | Seg { | |
| 83 | + | id: strip | |
| 84 | + | x: parent.width - 14 | |
| 85 | + | y: 10 | |
| 86 | + | width: 5 | |
| 87 | + | height: parent.height - 20 | |
| 88 | + | n: 20 | |
| 89 | + | vertical: true | |
| 90 | + | f: 0 | |
| 91 | + | } | |
| 92 | + | Seg { | |
| 93 | + | x: strip.x | |
| 94 | + | y: strip.y | |
| 95 | + | width: strip.width | |
| 96 | + | height: strip.height | |
| 97 | + | n: 20 | |
| 98 | + | vertical: true | |
| 99 | + | f: win.value | |
| 100 | + | on: win.muted ? Theme.grey : Theme.fill | |
| 101 | + | off: "transparent" | |
| 102 | + | layer.enabled: true | |
| 103 | + | layer.effect: MultiEffect { | |
| 104 | + | shadowEnabled: true | |
| 105 | + | shadowColor: Theme.glow | |
| 106 | + | shadowBlur: 0.7 | |
| 107 | + | blurMax: 20 | |
| 108 | + | shadowHorizontalOffset: 0 | |
| 109 | + | shadowVerticalOffset: 0 | |
| 97 | 110 | } | |
| 98 | − | Text { | |
| 99 | − | anchors.verticalCenter: parent.verticalCenter | |
| 100 | − | width: 44 | |
| 101 | − | horizontalAlignment: Text.AlignRight | |
| 102 | − | text: win.valueText | |
| 103 | − | color: win.muted ? Theme.dim : Theme.fg | |
| 104 | − | font.family: Theme.font | |
| 105 | − | font.pixelSize: Theme.fontSize | |
| 106 | − | font.bold: true | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // the value next to it: icon over the number | |
| 114 | + | Rectangle { | |
| 115 | + | anchors.right: parent.right | |
| 116 | + | anchors.rightMargin: 32 | |
| 117 | + | anchors.verticalCenter: parent.verticalCenter | |
| 118 | + | width: Math.max(58, box.implicitWidth + 24) | |
| 119 | + | height: box.implicitHeight + 36 | |
| 120 | + | radius: width / 2 | |
| 121 | + | color: Theme.s1 | |
| 122 | + | border.width: 1 | |
| 123 | + | border.color: Theme.s2 | |
| 124 | + | Column { | |
| 125 | + | id: box | |
| 126 | + | anchors.centerIn: parent | |
| 127 | + | spacing: 12 | |
| 128 | + | Icon { | |
| 129 | + | anchors.horizontalCenter: parent.horizontalCenter | |
| 130 | + | text: win.icon | |
| 131 | + | font.pixelSize: 20 | |
| 132 | + | color: win.muted ? Theme.grey : Theme.fg | |
| 133 | + | } | |
| 134 | + | Num { | |
| 135 | + | anchors.horizontalCenter: parent.horizontalCenter | |
| 136 | + | text: win.valueText | |
| 137 | + | font.pixelSize: 30 | |
| 138 | + | color: win.muted ? Theme.grey : Theme.fg | |
| 139 | + | } | |
| 107 | 140 | } | |
| 108 | 141 | } | |
| 109 | 142 | } | |
| @@ -91,7 +91,7 @@ Flickable { | |||
| 91 | 91 | SectionLabel { text: "sizes" } | |
| 92 | 92 | Knob { label: "font size"; from: 9; to: 18; value: Settings.fontSize; onMoved: v => Settings.fontSize = v } | |
| 93 | 93 | Knob { label: "workspace icons"; from: 8; to: 18; value: Settings.wsFontSize; onMoved: v => Settings.wsFontSize = v } | |
| 94 | − | Knob { label: "bar height"; from: 16; to: 36; value: Settings.barHeight; onMoved: v => Settings.barHeight = v } | |
| 94 | + | Knob { label: "bar height"; from: 32; to: 56; value: Settings.barSize; onMoved: v => Settings.barSize = v } | |
| 95 | 95 | ||
| 96 | 96 | SectionLabel { text: "transparency" } | |
| 97 | 97 | Knob { label: "bar opacity"; from: 0.3; to: 1; decimals: 2; value: Settings.barAlpha; onMoved: v => Settings.barAlpha = v } | |
| @@ -18,7 +18,7 @@ Singleton { | |||
| 18 | 18 | idleDim: 150, idleOff: 330, idleLock: 600, idleSuspend: 0, charTone: 0, portalAutoOpen: true, | |
| 19 | 19 | chargeMode: "off", batLow: 15, batCritical: 5, batCriticalAction: "none", | |
| 20 | 20 | privacyIndicators: true, powerAuto: false, powerAc: "balanced", powerBat: "saver", | |
| 21 | − | motion: true, clock24: true, appIcons: "dots" | |
| 21 | + | motion: true, clock24: true, appIcons: "dots", barSize: 40 | |
| 22 | 22 | }) | |
| 23 | 23 | ||
| 24 | 24 | property alias fontSize: a.fontSize | |
| @@ -54,6 +54,7 @@ Singleton { | |||
| 54 | 54 | property alias motion: a.motion | |
| 55 | 55 | property alias clock24: a.clock24 | |
| 56 | 56 | property alias appIcons: a.appIcons | |
| 57 | + | property alias barSize: a.barSize | |
| 57 | 58 | ||
| 58 | 59 | function reset() { | |
| 59 | 60 | for (var k in defaults) a[k] = defaults[k] | |
| @@ -111,6 +112,7 @@ Singleton { | |||
| 111 | 112 | property bool motion: true // shell animations (Theme.t) | |
| 112 | 113 | property bool clock24: true | |
| 113 | 114 | property string appIcons: "dots" // dots | mono | colour: how app icons are drawn | |
| 115 | + | property int barSize: 40 // height of the bar pill (Doto clock from 40 up) | |
| 114 | 116 | } | |
| 115 | 117 | } | |
| 116 | 118 | // first run: create the file once the directory exists | |
| @@ -19,7 +19,9 @@ Singleton { | |||
| 19 | 19 | readonly property int fontSize: Settings.fontSize | |
| 20 | 20 | readonly property int wsFontSize: Settings.wsFontSize // workspace icons only; everything else uses fontSize | |
| 21 | 21 | readonly property real panelAlpha: Settings.panelAlpha | |
| 22 | − | readonly property int barHeight: Settings.barHeight | |
| 22 | + | readonly property int barSize: Math.max(32, Settings.barSize) // the bar pill | |
| 23 | + | readonly property int barGap: 6 // pill to the screen edge | |
| 24 | + | readonly property int barHeight: barSize + barGap // what the bar reserves at the top | |
| 23 | 25 | readonly property real barAlpha: Settings.barAlpha | |
| 24 | 26 | ||
| 25 | 27 | // ---- motion: ease-out-quart without overshoot (glyph's "glide"); Settings.motion off = instant ---- | |
| @@ -1,43 +1,40 @@ | |||
| 1 | 1 | import QtQuick | |
| 2 | + | import QtQuick.Effects | |
| 2 | 3 | import Quickshell | |
| 3 | 4 | import Quickshell.Services.SystemTray | |
| 4 | 5 | ||
| 6 | + | // Tray icons in the bar; monochrome unless Settings.appIcons is "colour" (dots are too coarse at this size). | |
| 5 | 7 | Item { | |
| 6 | 8 | id: tray | |
| 7 | 9 | property bool show: true | |
| 8 | 10 | visible: show && SystemTray.items.values.length > 0 | |
| 9 | − | implicitWidth: row.implicitWidth + 24 | |
| 10 | − | implicitHeight: parent ? parent.height : Theme.barHeight | |
| 11 | + | implicitWidth: row.implicitWidth + 22 | |
| 12 | + | implicitHeight: parent ? parent.height : Theme.barSize | |
| 11 | 13 | ||
| 12 | − | Rectangle { | |
| 13 | − | anchors.left: parent.left | |
| 14 | − | anchors.right: parent.right | |
| 15 | − | anchors.bottom: parent.bottom | |
| 16 | − | height: 1 | |
| 17 | − | color: Theme.dim | |
| 18 | − | } | |
| 19 | 14 | Row { | |
| 20 | 15 | id: row | |
| 21 | 16 | anchors.centerIn: parent | |
| 22 | − | spacing: 10 | |
| 17 | + | spacing: 12 | |
| 23 | 18 | Repeater { | |
| 24 | 19 | model: SystemTray.items | |
| 25 | 20 | delegate: Item { | |
| 26 | 21 | id: it | |
| 27 | 22 | required property var modelData | |
| 28 | − | width: 14 | |
| 29 | − | height: 14 | |
| 23 | + | width: 16 | |
| 24 | + | height: 16 | |
| 30 | 25 | anchors.verticalCenter: parent.verticalCenter | |
| 31 | 26 | ||
| 32 | 27 | function openMenu() { | |
| 33 | − | var p = it.mapToItem(null, 0, it.height + 4) | |
| 28 | + | var p = it.mapToItem(null, 0, it.height + 10) | |
| 34 | 29 | modelData.display(QsWindow.window, p.x, p.y) | |
| 35 | 30 | } | |
| 36 | 31 | Image { | |
| 37 | 32 | anchors.fill: parent | |
| 38 | 33 | source: it.modelData.icon | |
| 39 | − | sourceSize: Qt.size(28, 28) | |
| 34 | + | sourceSize: Qt.size(32, 32) | |
| 40 | 35 | smooth: true | |
| 36 | + | layer.enabled: Settings.appIcons !== "colour" | |
| 37 | + | layer.effect: MultiEffect { saturation: -1; brightness: 0.1 } | |
| 41 | 38 | } | |
| 42 | 39 | MouseArea { | |
| 43 | 40 | anchors.fill: parent | |
| @@ -1,13 +1,14 @@ | |||
| 1 | 1 | import QtQuick | |
| 2 | 2 | import Quickshell.Hyprland | |
| 3 | 3 | ||
| 4 | − | // Per-monitor workspaces, dwm / split-monitor-workspaces style. | |
| 5 | − | // Each monitor owns a block of 10 ids (monitor 1: 1-10, monitor 2: 11-20, ...). | |
| 6 | − | // The block is derived from the monitor's own active workspace, so it works with or | |
| 7 | − | // without the plugin. Icons repeat every 10, like your waybar format-icons. | |
| 4 | + | // This monitor's 10 workspaces as dots (split-monitor-workspaces: monitor 1 owns 1-10, monitor 2 11-20, ...; the block | |
| 5 | + | // comes from the monitor's own active workspace, so it works without the plugin too). | |
| 6 | + | // Active = a wide pill (primary on the focused monitor, grey on the others), occupied = grey dot, empty = dim dot, | |
| 7 | + | // urgent = the live colour. Click = go there, wheel = the next / previous occupied one. | |
| 8 | + | // After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), bar/Bar.qml. | |
| 8 | 9 | Row { | |
| 9 | 10 | id: ws | |
| 10 | − | spacing: 0 | |
| 11 | + | spacing: 6 | |
| 11 | 12 | ||
| 12 | 13 | property var monitor: null // HyprlandMonitor of the bar this sits in | |
| 13 | 14 | ||
| @@ -40,36 +41,31 @@ Row { | |||
| 40 | 41 | readonly property bool active: ws.activeWs !== null && ws.activeWs.id === modelData | |
| 41 | 42 | readonly property bool occupied: obj !== null && obj.toplevels.values.length > 0 | |
| 42 | 43 | readonly property bool urgent: obj !== null && obj.urgent | |
| 43 | − | // active on the focused monitor = filled; active on another monitor = accent text + thick underline | |
| 44 | − | readonly property bool lit: (active && ws.monFocused) || m.containsMouse | |
| 45 | − | readonly property bool dimActive: active && !ws.monFocused && !m.containsMouse | |
| 46 | 44 | ||
| 47 | − | width: 30 | |
| 45 | + | width: dot.width | |
| 48 | 46 | height: ws.height | |
| 49 | 47 | ||
| 50 | 48 | Rectangle { | |
| 51 | − | anchors.fill: parent | |
| 52 | − | color: b.lit ? Theme.accent : "transparent" | |
| 53 | − | } | |
| 54 | − | Rectangle { // occupied marker (1px), or active-on-unfocused-monitor marker (2px) | |
| 55 | − | visible: (b.occupied && !b.lit) || b.dimActive | |
| 56 | − | anchors.left: parent.left | |
| 57 | − | anchors.right: parent.right | |
| 58 | − | anchors.bottom: parent.bottom | |
| 59 | − | height: b.dimActive ? 2 : 1 | |
| 60 | − | color: b.dimActive ? Theme.accent : Theme.cursorC | |
| 61 | − | } | |
| 62 | − | Text { | |
| 63 | − | anchors.centerIn: parent | |
| 64 | − | text: Theme.wsIcon(b.modelData) | |
| 65 | − | color: b.lit ? Theme.bg : (b.dimActive ? Theme.accent : (b.urgent ? Theme.err : Theme.fg)) | |
| 66 | − | opacity: (b.occupied || b.active || b.lit || b.urgent) ? 1 : 0.4 | |
| 67 | − | font.family: Theme.font | |
| 68 | − | font.pixelSize: Theme.wsFontSize | |
| 49 | + | id: dot | |
| 50 | + | anchors.verticalCenter: parent.verticalCenter | |
| 51 | + | width: b.active ? 26 : 8 | |
| 52 | + | height: 8 | |
| 53 | + | radius: 4 | |
| 54 | + | color: b.urgent && !b.active ? Theme.live | |
| 55 | + | : b.active ? (ws.monFocused ? Theme.fill : Theme.grey) | |
| 56 | + | : m.containsMouse ? Theme.fg | |
| 57 | + | : b.occupied ? Theme.grey : Theme.s3 | |
| 58 | + | Behavior on width { | |
| 59 | + | enabled: Theme.motion | |
| 60 | + | NumberAnimation { duration: Theme.t; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.glide } | |
| 61 | + | } | |
| 62 | + | Behavior on color { enabled: Theme.motion; ColorAnimation { duration: Theme.t } } | |
| 69 | 63 | } | |
| 70 | 64 | MouseArea { | |
| 71 | 65 | id: m | |
| 72 | 66 | anchors.fill: parent | |
| 67 | + | anchors.leftMargin: -3 | |
| 68 | + | anchors.rightMargin: -3 | |
| 73 | 69 | hoverEnabled: true | |
| 74 | 70 | cursorShape: Qt.PointingHandCursor | |
| 75 | 71 | onClicked: Hypr.workspace(b.modelData) | |
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //@ pragma UseQApplication | |
| 2 | + | // ^ tray menus (SystemTray display()) are platform menus: they need a QApplication | |
| 1 | 3 | //@ pragma IconTheme Papirus-Dark | |
| 2 | 4 | // ^ same as gtk-3.0: notify-send -i names (drive-removable-media, network-wireless, ...) resolve through it | |
| 3 | 5 | import QtQuick | |