Files
arthur-os/default/quickshell/omarchy-shell/plugins/bar/widgets/notificationCenter.qml
T
Ryan Hughes c101591284 Replace mako with quickshell-backed notification daemon
Adds first-party omarchy.notifications service plugin that hosts a
freedesktop notification server and renders popups + a history popup
inside the shell. Uninstalls mako and retargets every helper, keybind,
indicator, and migration entry to the new daemon.

Plugin (default/quickshell/omarchy-shell/plugins/notifications/):
- Service.qml: NotificationServer, popupModel + pendingModel + pastModel
  (two-tier history, see below), DND via PersistentProperties +
  cache-file backstop, image cache for /tmp screenshots, IpcHandler with
  toggleDnd/setDnd/isDnd/showHistory/clear/clearPending/markAllSeen/
  dismissAll/dismissOne/invokeLast/dismiss, per-theme override file
  ~/.config/omarchy/current/theme/notifications.json honoring
  borderColor/backgroundColor/textColor/countdownColor.
- components/NotificationCard.qml: theme-driven card (Color.foreground/
  background/border tokens from Commons/Color.qml), 32x32 icon slot,
  Nerd Font glyph fallback via omarchy-glyph hint, hero image strip
  for screenshot/image-path notifications, hover-pause progress bar,
  uses bar.fontFamily so all surfaces share one font.

Filtering and DND:
- transient hint and CLI-style senders (app_name in notify-send /
  omarchy-action) bypass history but still pop.
- DND only allows omarchy-action toasts and notify-send -u critical
  through; real-app urgency=critical (Discord, Slack, Vesktop) is
  silenced and lands in pending instead.
- Pending vs past split surfaced via tabs in the bar widget popup;
  past tab is auto-pruned at the 15-minute mark.
- Click-to-jump: notifications without a libnotify default action focus
  the matching Hyprland window via class lookup.

Shell host:
- shell.qml: generic first-party service loader (mirrors the existing
  noctalia-compat path) and an alias for the bar so plugins can read
  barSize / barHidden / position for anchoring.
- Commons/Color.qml: parses the theme's hyprland.conf for
  $activeBorderColor so notifications match Hyprland window borders;
  picks the explicit accent= key over the color4= alias.

Bar widget rebase (plugins/bar/widgets/notificationCenter.qml):
- Drops the chunk-1 stub server, binds count/dnd state to the service,
  hosts the history popup via PopupCard so it drops down from the
  notification glyph the same way Quick Settings does.
- Pending/Past tabs, dismiss-individual close X, mark-all-as-seen and
  clear-recent action buttons, theme-driven palette.

Quick Settings rework (plugins/bar/widgets/controlCenter.qml):
- DND tile binds directly to service.doNotDisturb for instant feedback.
- Drops the volume slider (already in audioPanel) and the no-op Theme
  tile; adds a Bluetooth toggle bound to Quickshell.Bluetooth.
- Bigger 44x44 wallet was scaled back to 32x32 for tighter rows.

Notification scripts (bin/omarchy-*):
- omarchy-notification-send: passes glyph as a custom hint instead of
  prepending to the summary; adds -a omarchy-action and -u urgency
  automatically; supports -e/--transient passthrough.
- User-action toasts in the capture / toggle / hyprland / default-*
  scripts and bindings/utilities.lua now tag themselves
  -a omarchy-action so DND treats them as intent-based bypass.
- omarchy-toggle-notification-silencing, omarchy-notification-dismiss,
  default/waybar/indicators/notification-silencing.sh, and the Hyprland
  comma-keybinds all route through omarchy-shell-ipc notifications.
- omarchy-capture-screenshot / -screenrecording set the image-path
  hint properly so the hero-image rendering kicks in.

Mako removal (migrations/1778743515.sh):
- pkill -x mako, systemctl --user stop mako.service, pacman -Rns mako
  (uninstalling deletes /usr/lib/systemd/user/mako.service so D-Bus
  activation can't respawn it). Removes ~/.config/mako/ and the legacy
  toggle file. Restarts quickshell so it claims the bus name.
- Drops mako from install/omarchy-base.packages, autostart.lua,
  install/config/theme.sh + toggles.sh, default/themed/mako.ini.tpl,
  default/mako/, the omarchy-menu Mako restart row, bin/omarchy
  GROUP_DESCRIPTIONS, the settings panel catalogue, and the
  default/omarchy-skill paths table.
- Removed scripts: bin/omarchy-restart-mako,
  bin/omarchy-style-corners-mako.
- bin/omarchy-style-corners summary updated; corner radius for the
  notification card reads ~/.local/state/omarchy/toggles/quickshell-menu.json
  alongside the rest of the shell.
2026-05-14 14:21:07 -04:00

414 lines
14 KiB
QML

import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import "../common" as Common
Item {
id: root
property QtObject bar: null
property string moduleName: "notificationCenter"
property var settings: ({})
property bool popupOpen: false
function closePopout() { popupOpen = false }
// Always default to the pending tab when there's anything unseen, no
// matter how the popup was opened (click, keybind/IPC, or the closePopout
// path). Keeps the spec from drifting based on the user's last manual
// tab selection.
onPopupOpenChanged: {
if (popupOpen) {
activeTab = pendingCount > 0 ? "pending" : "past"
}
}
// Look up the long-running notifications service through the shell host.
readonly property var hostShell: bar && bar.shell ? bar.shell : null
readonly property var notificationService: hostShell && typeof hostShell.firstPartyServiceFor === "function"
? hostShell.firstPartyServiceFor("omarchy.notifications")
: null
readonly property int pendingCount: notificationService ? notificationService.pendingModel.count : 0
readonly property int pastCount: notificationService ? notificationService.pastModel.count : 0
readonly property bool dnd: notificationService ? notificationService.doNotDisturb : false
// Which tab is active in the popup. Auto-selects pending when there's
// something unseen; otherwise opens past.
property string activeTab: "pending"
readonly property string icon: {
if (dnd) return "󰂛"
if (pendingCount > 0) return "󱅫"
return "󰂚"
}
// Theme palette (mirrors HistoryPanel's tokens so the popup matches the
// rest of the notification stack).
readonly property color colForeground: Color.foreground
readonly property color colDim: Qt.darker(Color.foreground, 1.4)
readonly property color colBorder: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.18)
readonly property color colSurface: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.06)
readonly property color colAccent: Color.accent
readonly property int cardRadius: notificationService ? notificationService.cornerRadius : 10
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
Common.WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.icon
active: root.pendingCount > 0 && !root.dnd
tooltipText: root.dnd ? "Do Not Disturb"
: (root.pendingCount > 0 ? root.pendingCount + " pending" : "No notifications")
onPressed: function(b) {
if (b === Qt.RightButton) {
if (root.notificationService) {
root.notificationService.setDoNotDisturb(!root.notificationService.doNotDisturb)
}
} else {
root.popupOpen = !root.popupOpen
}
}
}
// Service-side IPC (omarchy-shell-ipc notifications showHistory) flips
// historyOpenRequested; we toggle our local popup state from here so the
// keybind path lands in the same PopupCard the click path uses.
Connections {
target: root.notificationService
ignoreUnknownSignals: true
function onHistoryOpenRequested() {
root.popupOpen = true
}
}
Common.PopupCard {
id: popup
anchorItem: button
bar: root.bar
owner: root
open: root.popupOpen
contentWidth: 440
contentHeight: 540
ColumnLayout {
anchors.fill: parent
spacing: 10
// ----------------------------------------- header
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "Notifications"
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colForeground
font.pixelSize: 14
font.bold: true
}
Item { Layout.fillWidth: true }
Rectangle {
id: dndPill
Layout.preferredHeight: 24
Layout.preferredWidth: dndLabel.implicitWidth + dndGlyph.implicitWidth + 18
radius: Math.min(12, root.cardRadius + 6)
color: dndOn ? root.colAccent : root.colSurface
border.color: dndOn ? root.colAccent : root.colBorder
border.width: 1
readonly property bool dndOn: !!root.notificationService && root.notificationService.doNotDisturb
Row {
anchors.centerIn: parent
spacing: 4
Text {
id: dndGlyph
text: dndPill.dndOn ? "󰂛" : "󰂚"
font.family: root.bar ? root.bar.fontFamily : ""
color: dndPill.dndOn ? Color.background : root.colDim
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: dndLabel
text: dndPill.dndOn ? "DND on" : "DND off"
font.family: root.bar ? root.bar.fontFamily : ""
color: dndPill.dndOn ? Color.background : root.colDim
font.pixelSize: 10
font.bold: true
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: if (root.notificationService) root.notificationService.setDoNotDisturb(!dndPill.dndOn)
}
}
}
// ----------------------------------------- tabs
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: [
{ key: "pending", label: "Pending", count: root.pendingCount },
{ key: "past", label: "Recently", count: root.pastCount }
]
delegate: Rectangle {
required property var modelData
readonly property bool isActive: root.activeTab === modelData.key
Layout.fillWidth: true
Layout.preferredHeight: 30
color: "transparent"
Text {
anchors.centerIn: parent
text: modelData.label + (modelData.count > 0 ? " " + modelData.count : "")
font.family: root.bar ? root.bar.fontFamily : ""
color: parent.isActive ? root.colForeground : root.colDim
font.pixelSize: 12
font.bold: parent.isActive
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
color: parent.isActive ? root.colAccent : root.colBorder
opacity: parent.isActive ? 1 : 0.4
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.activeTab = modelData.key
}
}
}
}
// ----------------------------------------- action row
RowLayout {
Layout.fillWidth: true
visible: (root.activeTab === "pending" && root.pendingCount > 0)
|| (root.activeTab === "past" && root.pastCount > 0)
spacing: 8
Item { Layout.fillWidth: true }
Rectangle {
Layout.preferredWidth: actionLabel.implicitWidth + 16
Layout.preferredHeight: 22
radius: Math.min(6, root.cardRadius)
color: actionArea.containsMouse ? root.colBorder : "transparent"
border.color: root.colBorder
border.width: 1
Text {
id: actionLabel
anchors.centerIn: parent
text: root.activeTab === "pending" ? "Mark all as seen" : "Clear recent"
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colForeground
font.pixelSize: 10
}
MouseArea {
id: actionArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!root.notificationService) return
if (root.activeTab === "pending") root.notificationService.markAllSeen()
else root.notificationService.clearPast()
}
}
}
}
// ----------------------------------------- list
ListView {
id: listView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: 8
readonly property bool onPending: root.activeTab === "pending"
model: !root.notificationService ? null
: (onPending ? root.notificationService.pendingModel : root.notificationService.pastModel)
visible: count > 0
delegate: Rectangle {
id: rowCard
required property int index
required property string app
required property string appIcon
required property string summary
required property string body
required property string image
required property int urgency
required property double timestamp
readonly property bool hasMedia: image.length > 0 && (
image.indexOf("image://icon//") === 0 || image.indexOf("file://") === 0)
readonly property string smallIconSource: image.length > 0 ? image : appIcon
readonly property bool hasIcon: !hasMedia && smallIconSource.length > 0
width: listView.width
implicitHeight: rowContent.implicitHeight + 20
radius: root.cardRadius
color: "transparent"
border.color: root.colBorder
border.width: 1
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: { /* no-op */ }
}
RowLayout {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 10
Item {
Layout.preferredWidth: 32
Layout.preferredHeight: 32
Layout.alignment: Qt.AlignVCenter
// Hide on icon load failure so unresolved themed-icon names
// don't render Qt's broken-image placeholder.
visible: (rowCard.hasIcon || rowCard.hasMedia) && rowIconImage.status !== Image.Error
Image {
id: rowIconImage
anchors.fill: parent
source: rowCard.hasMedia ? rowCard.image : rowCard.smallIconSource
fillMode: rowCard.hasMedia ? Image.PreserveAspectCrop : Image.PreserveAspectFit
sourceSize.width: 32 * Screen.devicePixelRatio
sourceSize.height: 32 * Screen.devicePixelRatio
asynchronous: true
smooth: true
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
Layout.fillWidth: true
visible: rowCard.summary.length > 0
text: rowCard.summary
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colForeground
font.pixelSize: 13
font.bold: true
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
Layout.fillWidth: true
visible: rowCard.body.length > 0
text: String(rowCard.body).replace(/<img[^>]*>/gi, "")
font.family: root.bar ? root.bar.fontFamily : ""
textFormat: Text.PlainText
color: root.colDim
font.pixelSize: 11
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 2
}
}
Rectangle {
Layout.preferredWidth: 18
Layout.preferredHeight: 18
Layout.alignment: Qt.AlignVCenter
radius: Math.min(4, root.cardRadius)
color: rowCloseArea.containsMouse ? root.colBorder : "transparent"
Text {
anchors.centerIn: parent
text: "✕"
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colDim
font.pixelSize: 11
}
MouseArea {
id: rowCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!root.notificationService) return
if (listView.onPending) root.notificationService.dismissPending(rowCard.index)
else root.notificationService.dismissPast(rowCard.index)
}
}
}
}
}
}
// ----------------------------------------- empty state
Item {
Layout.fillWidth: true
Layout.fillHeight: true
visible: listView.count === 0
ColumnLayout {
anchors.centerIn: parent
spacing: 6
Text {
Layout.alignment: Qt.AlignHCenter
text: "󰂚"
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colBorder
font.pixelSize: 36
}
Text {
Layout.alignment: Qt.AlignHCenter
text: root.activeTab === "pending"
? "Nothing waiting for you"
: "Nothing recent"
font.family: root.bar ? root.bar.fontFamily : ""
? "Nothing waiting for you"
: "No past notifications"
color: root.colDim
font.pixelSize: 12
}
}
}
}
}
}