Files
arthur-os/default/quickshell/omarchy-shell/plugins/notifications/components/NotificationCard.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

258 lines
8.9 KiB
QML

// Notification card. Pure presentational — no service, Notification, or
// ListModel references. The popup container drives lifetime; the history
// panel drives static rendering. Both use the same component.
import QtQuick
import QtQuick.Layouts
import qs.Commons
Rectangle {
id: root
property string app: ""
property string appIcon: ""
property string summary: ""
property string body: ""
property string image: ""
// Nerd Font glyph rendered in the icon slot when no real icon is set.
// Used by omarchy-notification-send so user-action toasts (`Silenced
// notifications` etc.) show their bell/lock/etc. glyph without leaking
// into the summary text.
property string glyph: ""
// NotificationUrgency: Low=0, Normal=1, Critical=2 (upstream).
property int urgency: 1
property double timestamp: 0
property int cornerRadius: 10
property real progress: 1.0
property bool showProgress: false
// Container can override the theme defaults per-card. Defaults bind to
// the live Color.* tokens, so when the container leaves them alone the
// card still tracks the theme.
property color borderColorOverride: Color.border
property color backgroundColorOverride: Color.background
property color textColorOverride: Color.foreground
property color countdownColorOverride: Color.accent
// System font from shell.json bar.fontFamily, injected by the container.
property string fontFamily: ""
readonly property bool hovered: hoverTracker.hovered
signal closeRequested()
signal cardClicked()
signal imageClicked()
// Media mode = the notification carries a real screenshot or screen
// recording preview. Quickshell normalizes file paths from `-i` and the
// `image-path` hint into `image://icon//<absolute path>` (double slash
// marks an absolute filesystem path vs a themed icon name like
// `image://icon/firefox`).
function _imageFilePath(s) {
if (!s) return ""
if (s.indexOf("image://icon//") === 0) return s.substring("image://icon/".length)
if (s.indexOf("file://") === 0) return decodeURIComponent(s.substring(7))
return ""
}
function _isMediaFile(path) {
if (!path) return false
var lower = path.toLowerCase()
return lower.endsWith(".png") || lower.endsWith(".jpg") ||
lower.endsWith(".jpeg") || lower.endsWith(".webp") ||
lower.endsWith(".gif")
}
readonly property string mediaImageSource: {
if (_isMediaFile(_imageFilePath(image))) return image
if (_isMediaFile(_imageFilePath(appIcon))) return appIcon
return ""
}
readonly property bool mediaMode: mediaImageSource.length > 0
// Use only what the notification explicitly carries — no themed-icon
// theme-lookup fallback because Quickshell's icon image provider returns
// a placeholder for missing names (rather than erroring), which means
// we'd render Qt's pink "broken image" pattern for any unknown app.
// Apps that send their own icon via `image` (image-data hint) or
// `appIcon` (-i flag) still get one.
readonly property string smallIconSource: image.length > 0 ? image : appIcon
readonly property bool hasGlyph: glyph.length > 0
readonly property bool hasSmallIcon: !mediaMode && (smallIconSource.length > 0 || hasGlyph)
readonly property color dimColor: Qt.darker(textColorOverride, 1.4)
readonly property color bodyColor: Qt.darker(textColorOverride, 1.15)
readonly property color hoverColor: Qt.rgba(textColorOverride.r, textColorOverride.g, textColorOverride.b, 0.14)
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : countdownColorOverride)
function sanitizeBody(s) {
return String(s).replace(/<img[^>]*>/gi, "")
}
implicitWidth: 380
// Add 2 * border.width so mainColumn (inset by border.width on top/left/right)
// doesn't push content under the bottom edge. The bottom edge is also inset
// for symmetry except when the progress bar replaces it.
implicitHeight: mainColumn.implicitHeight + border.width * 2 + (showProgress ? 3 : 0)
radius: cornerRadius
color: backgroundColorOverride
border.color: urgency === 2 ? Color.urgent : borderColorOverride
border.width: 2
clip: true
HoverHandler { id: hoverTracker }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.cardClicked()
}
ColumnLayout {
id: mainColumn
// Inset by the card border so the hero image (and the text row) don't
// paint over the card's outer border. Without this the left/right/top
// border is invisible under the image.
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: root.border.width
anchors.leftMargin: root.border.width
anchors.rightMargin: root.border.width
spacing: 0
// Hero image strip (media notifications only). PreserveAspectCrop so
// the preview looks like a clean banner without dark letterboxing.
Item {
Layout.fillWidth: true
Layout.preferredHeight: 140
visible: root.mediaMode
clip: true
Image {
anchors.fill: parent
source: root.mediaImageSource
fillMode: Image.PreserveAspectCrop
sourceSize.width: width > 0 ? width * Screen.devicePixelRatio : 0
sourceSize.height: height > 0 ? height * Screen.devicePixelRatio : 0
asynchronous: true
smooth: true
cache: false
}
// Bottom divider matching the card border so the screenshot is
// visually framed on every side (card border wraps top/left/right;
// this line completes the bottom).
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: root.border.width
color: root.urgency === 2 ? Color.urgent : root.borderColorOverride
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.imageClicked()
}
}
// Text content. Always rendered — for media notifications this carries
// the summary/body ("Screenshot saved" etc) under the hero image.
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 12
Layout.rightMargin: 12
Layout.topMargin: 10
Layout.bottomMargin: 10
spacing: 10
Item {
id: smallIconSlot
Layout.preferredWidth: 32
Layout.preferredHeight: 32
Layout.alignment: Qt.AlignVCenter
// Hide the slot when the icon failed to resolve (themed-icon name
// not in the user's icon theme) AND we don't have a glyph fallback
// — prevents rendering Qt's pink broken-image placeholder.
visible: root.hasSmallIcon && (root.hasGlyph || smallIconImage.status !== Image.Error)
Image {
id: smallIconImage
anchors.fill: parent
source: root.smallIconSource
sourceSize.width: 32 * Screen.devicePixelRatio
sourceSize.height: 32 * Screen.devicePixelRatio
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
visible: !root.hasGlyph || smallIconImage.status === Image.Ready
}
// Glyph fallback (Nerd Font character) when no image icon is
// available. Used by omarchy-notification-send's `-g` flag.
Text {
anchors.centerIn: parent
visible: root.hasGlyph && smallIconImage.status !== Image.Ready
text: root.glyph
color: root.textColorOverride
font.family: root.fontFamily
font.pixelSize: 18
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: 2
Text {
Layout.fillWidth: true
visible: root.summary.length > 0
text: root.summary
font.family: root.fontFamily
color: root.textColorOverride
font.pixelSize: 13
font.bold: true
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 2
}
Text {
Layout.fillWidth: true
Layout.topMargin: 2
visible: root.body.length > 0
text: root.sanitizeBody(root.body)
textFormat: Text.StyledText
font.family: root.fontFamily
color: root.bodyColor
font.pixelSize: 12
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 3
}
}
}
}
// Progress bar at the bottom edge. Stays visible while the container has
// a finite lifetime; freezes (doesn't decrement) when hover pauses the
// tick from the container side.
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 3
color: root.borderColorOverride
visible: root.showProgress
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, root.progress))
color: root.accentColor
}
}
}