From c10159128433b2e12807f323d1a957395f44f4b7 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Thu, 14 May 2026 14:21:07 -0400 Subject: [PATCH] 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. --- bin/omarchy | 1 - bin/omarchy-capture-screenrecording | 2 +- bin/omarchy-capture-screenshot | 4 +- bin/omarchy-capture-text-extraction | 2 +- bin/omarchy-default-browser | 2 +- bin/omarchy-default-editor | 2 +- bin/omarchy-default-terminal | 2 +- bin/omarchy-font-set | 4 +- bin/omarchy-hyprland-monitor-scaling-set | 2 +- bin/omarchy-hyprland-toggle | 4 +- bin/omarchy-hyprland-workspace-layout-toggle | 2 +- bin/omarchy-launch-screensaver | 2 +- bin/omarchy-notification-dismiss | 9 +- bin/omarchy-notification-send | 16 +- bin/omarchy-restart-mako | 5 - bin/omarchy-style-corners | 3 +- bin/omarchy-style-corners-mako | 38 - bin/omarchy-theme-set | 1 - bin/omarchy-toggle | 4 +- bin/omarchy-toggle-idle | 4 +- bin/omarchy-toggle-nightlight | 4 +- bin/omarchy-toggle-notification-silencing | 16 +- bin/omarchy-voxtype-install | 2 +- bin/omarchy-windows-vm | 2 +- default/hypr/autostart.lua | 1 - default/hypr/bindings/utilities.lua | 14 +- default/mako/core.ini | 38 - default/omarchy-skill/SKILL.md | 5 +- default/omarchy/omarchy-menu.jsonc | 3 +- .../omarchy-shell/Commons/Color.qml | 37 +- .../omarchy-shell/plugins/bar/Bar.qml | 2 +- .../plugins/bar/widgets/controlCenter.qml | 95 +- .../bar/widgets/notificationCenter.qml | 516 ++++++---- .../plugins/notifications/Service.qml | 902 ++++++++++++++++++ .../components/NotificationCard.qml | 257 +++++ .../plugins/notifications/manifest.json | 14 + .../plugins/settings/SettingsPanel.qml | 8 +- default/quickshell/omarchy-shell/shell.qml | 97 ++ default/themed/mako.ini.tpl | 6 - .../indicators/notification-silencing.sh | 3 +- install/config/theme.sh | 3 - install/config/toggles.sh | 1 - install/first-run/install-voxtype.hook | 5 +- install/first-run/welcome.sh | 5 +- install/first-run/wifi.sh | 20 +- install/omarchy-base.packages | 1 - migrations/1778743515.sh | 27 + 47 files changed, 1780 insertions(+), 413 deletions(-) delete mode 100755 bin/omarchy-restart-mako delete mode 100755 bin/omarchy-style-corners-mako delete mode 100644 default/mako/core.ini create mode 100644 default/quickshell/omarchy-shell/plugins/notifications/Service.qml create mode 100644 default/quickshell/omarchy-shell/plugins/notifications/components/NotificationCard.qml create mode 100644 default/quickshell/omarchy-shell/plugins/notifications/manifest.json delete mode 100644 default/themed/mako.ini.tpl create mode 100644 migrations/1778743515.sh diff --git a/bin/omarchy b/bin/omarchy index bcf820aa..f27c9630 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -47,7 +47,6 @@ GROUP_DESCRIPTIONS[hw]="Hardware detection and controls" GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls" GROUP_DESCRIPTIONS[install]="Optional software installers" GROUP_DESCRIPTIONS[launch]="Application launchers" -GROUP_DESCRIPTIONS[mako]="Mako notification controls" GROUP_DESCRIPTIONS[menu]="Omarchy menu commands" GROUP_DESCRIPTIONS[migrate]="Migration runner" GROUP_DESCRIPTIONS[notification]="Notification helpers" diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index 82644e06..90d27279 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -240,7 +240,7 @@ stop_screenrecording() { ffmpeg -y -i "$filename" -ss 00:00:00.1 -vframes 1 -q:v 2 "$preview" -loglevel quiet 2>/dev/null ( - ACTION=$(notify-send "Screen recording saved" "Open with Super + Alt + , (or click this)" -t 10000 -i "${preview:-$filename}" -A "default=open") + ACTION=$(notify-send -a omarchy-action "Screen recording saved" "Open with Super + Alt + , (or click this)" -t 10000 --hint="string:image-path:${preview:-$filename}" -A "default=open") [[ $ACTION == "default" ]] && mpv "$filename" rm -f "$preview" ) & diff --git a/bin/omarchy-capture-screenshot b/bin/omarchy-capture-screenshot index 572330f7..682ae65d 100755 --- a/bin/omarchy-capture-screenshot +++ b/bin/omarchy-capture-screenshot @@ -11,7 +11,7 @@ OUTPUT_DIR="${OMARCHY_SCREENSHOT_DIR:-${XDG_PICTURES_DIR:-$HOME/Pictures}}" if [[ ! -d $OUTPUT_DIR ]]; then mkdir -p "$OUTPUT_DIR" - notify-send "Created screenshot directory: $OUTPUT_DIR" -u normal -t 2000 + notify-send -a omarchy-action "Created screenshot directory: $OUTPUT_DIR" -u normal -t 2000 fi pkill slurp && exit 0 @@ -133,7 +133,7 @@ case "$PROCESSING" in wl-copy <"$FILEPATH" ( - ACTION=$(notify-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" -t 10000 -i "$FILEPATH" -A "default=edit") + ACTION=$(notify-send -a omarchy-action "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" -t 10000 --hint="string:image-path:$FILEPATH" -A "default=edit") [[ $ACTION == "default" ]] && open_editor "$FILEPATH" ) >/dev/null 2>&1 & ;; diff --git a/bin/omarchy-capture-text-extraction b/bin/omarchy-capture-text-extraction index 4b49b16b..6b304da5 100755 --- a/bin/omarchy-capture-text-extraction +++ b/bin/omarchy-capture-text-extraction @@ -23,4 +23,4 @@ TEXT=$(grim -g "$SELECTION" - | tesseract stdin stdout --oem 1 --psm 6 -l "${OMA [[ -z $TEXT ]] && exit 1 printf "%s" "$TEXT" | wl-copy -notify-send "󰴑 Copied text from selection to clipboard" +notify-send -a omarchy-action "󰴑 Copied text from selection to clipboard" diff --git a/bin/omarchy-default-browser b/bin/omarchy-default-browser index b6bbf8cb..68305bd0 100755 --- a/bin/omarchy-default-browser +++ b/bin/omarchy-default-browser @@ -37,4 +37,4 @@ xdg-mime default "$desktop_id" x-scheme-handler/http xdg-mime default "$desktop_id" x-scheme-handler/https xdg-mime default "$desktop_id" text/html -notify-send -u low "$glyph $name is now the default browser" +notify-send -a omarchy-action -u low "$glyph $name is now the default browser" diff --git a/bin/omarchy-default-editor b/bin/omarchy-default-editor index 31846450..f2cb20f7 100755 --- a/bin/omarchy-default-editor +++ b/bin/omarchy-default-editor @@ -27,4 +27,4 @@ esac sed -i "s/^export EDITOR=.*/export EDITOR=$editor/" ~/.config/uwsm/default export EDITOR="$editor" -notify-send -u low "$glyph $name is now the default editor" " Effective after logging out" +notify-send -a omarchy-action -u low "$glyph $name is now the default editor" " Effective after logging out" diff --git a/bin/omarchy-default-terminal b/bin/omarchy-default-terminal index b44b0511..138f35b1 100755 --- a/bin/omarchy-default-terminal +++ b/bin/omarchy-default-terminal @@ -33,4 +33,4 @@ cat >~/.config/xdg-terminals.list <&2 exit 1 @@ -51,7 +51,7 @@ on() { off() { rm -f "$FLAG_FILE" - [[ -n $DISABLED_NOTIFICATION ]] && notify-send -u low "$DISABLED_NOTIFICATION" + [[ -n $DISABLED_NOTIFICATION ]] && notify-send -a omarchy-action -u low "$DISABLED_NOTIFICATION" } toggle() { diff --git a/bin/omarchy-hyprland-workspace-layout-toggle b/bin/omarchy-hyprland-workspace-layout-toggle index 7335ff5d..39360572 100755 --- a/bin/omarchy-hyprland-workspace-layout-toggle +++ b/bin/omarchy-hyprland-workspace-layout-toggle @@ -12,4 +12,4 @@ esac hyprctl eval "hl.workspace_rule({ workspace = \"$ACTIVE_WORKSPACE\", layout = \"$NEW_LAYOUT\" })" >/dev/null 2>&1 || \ hyprctl keyword workspace $ACTIVE_WORKSPACE, layout:$NEW_LAYOUT -notify-send -u low "󱂬 Workspace layout set to $NEW_LAYOUT" +notify-send -a omarchy-action -u low "󱂬 Workspace layout set to $NEW_LAYOUT" diff --git a/bin/omarchy-launch-screensaver b/bin/omarchy-launch-screensaver index 1bf445f7..cee5338b 100755 --- a/bin/omarchy-launch-screensaver +++ b/bin/omarchy-launch-screensaver @@ -47,7 +47,7 @@ for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do hypr_exec "kitty --class=org.omarchy.screensaver --override font_size=18 --override window_padding_width=0 -e omarchy-screensaver" ;; *) - notify-send -u low "✋ Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty" + notify-send -a omarchy-action -u low "✋ Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty" ;; esac done diff --git a/bin/omarchy-notification-dismiss b/bin/omarchy-notification-dismiss index ab515e72..5779dc02 100755 --- a/bin/omarchy-notification-dismiss +++ b/bin/omarchy-notification-dismiss @@ -1,6 +1,6 @@ #!/bin/bash -# omarchy:summary=Dismiss a mako notification on the basis of its summary. Used by the first-run notifications to dismiss them after clicking for action. +# omarchy:summary=Dismiss a notification by summary substring. Used by the first-run notifications to dismiss them after clicking for action. # omarchy:args= if (($# == 0)); then @@ -8,9 +8,4 @@ if (($# == 0)); then exit 1 fi -# Find the first notification whose 'summary' matches the regex in $1 -notification_id=$(makoctl list | grep -F "$1" | head -n1 | sed -E 's/^Notification ([0-9]+):.*/\1/') - -if [[ -n $notification_id ]]; then - makoctl dismiss -n $notification_id -fi +omarchy-shell-ipc notifications dismiss "$1" >/dev/null 2>&1 || true diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index becb6c93..6b1a36e4 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -65,12 +65,18 @@ while (($# > 0)); do fi done -summary="$glyph $headline" -args+=("-u" "$urgency") +# Tag as a user-action toast so it pops through DND. See dnd-fix-plan.md. +args+=("-a" "omarchy-action" "-u" "$urgency") + +# Glyph is now a custom hint the quickshell daemon renders into the icon +# slot, not prepended to the summary. Old mako-era formatting was glyph + 4 +# spaces + headline, which leaves ugly whitespace in the new card design. +if [[ -n $glyph ]]; then + args+=("--hint=string:omarchy-glyph:$glyph") +fi if [[ -n $description ]]; then - description=$(printf '%b' "$description" | sed 's/^/ /') - notify-send "${args[@]}" "$summary" "$description" + notify-send "${args[@]}" "$headline" "$description" else - notify-send "${args[@]}" "$summary" + notify-send "${args[@]}" "$headline" fi diff --git a/bin/omarchy-restart-mako b/bin/omarchy-restart-mako deleted file mode 100755 index 3cb6a6a2..00000000 --- a/bin/omarchy-restart-mako +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Reload mako configuration (used by the Omarchy theme switching). - -makoctl reload diff --git a/bin/omarchy-style-corners b/bin/omarchy-style-corners index 005b90a2..af71bde1 100755 --- a/bin/omarchy-style-corners +++ b/bin/omarchy-style-corners @@ -1,6 +1,6 @@ #!/bin/bash -# omarchy:summary=Set Hyprland, Hyprlock, Mako, Walker, and Quickshell corners to sharp or round +# omarchy:summary=Set Hyprland, Hyprlock, Walker, and Quickshell (bar, menu, notifications) corners to sharp or round # omarchy:args= # omarchy:examples=omarchy style corners round | omarchy style corners sharp @@ -11,7 +11,6 @@ fi omarchy-style-corners-hyprland "$1" omarchy-style-corners-hyprlock "$1" -omarchy-style-corners-mako "$1" omarchy-style-corners-walker "$1" omarchy-style-corners-quickshell "$1" diff --git a/bin/omarchy-style-corners-mako b/bin/omarchy-style-corners-mako deleted file mode 100755 index f3d39ba6..00000000 --- a/bin/omarchy-style-corners-mako +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Set or toggle rounded Mako notification corners -# omarchy:args=[toggle|sharp|round] -# omarchy:examples=omarchy style corners mako toggle | omarchy style corners mako round | omarchy style corners mako sharp - -TOGGLES_CONFIG="$HOME/.local/state/omarchy/toggles/mako.ini" - -set_radius() { - local radius="$1" - - mkdir -p "$(dirname "$TOGGLES_CONFIG")" - touch "$TOGGLES_CONFIG" - - if grep -q '^border-radius=' "$TOGGLES_CONFIG"; then - sed -i "s/^border-radius=.*/border-radius=$radius/" "$TOGGLES_CONFIG" - else - echo "border-radius=$radius" >>"$TOGGLES_CONFIG" - fi -} - -case "${1:-toggle}" in - round) set_radius 6 ;; - sharp) set_radius 0 ;; - toggle) - if [[ -f $TOGGLES_CONFIG ]] && grep -q '^border-radius=6$' "$TOGGLES_CONFIG"; then - set_radius 0 - else - set_radius 6 - fi - ;; - *) - echo "Usage: omarchy-style-corners-mako [toggle|sharp|round]" - exit 1 - ;; -esac - -makoctl reload diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index 8dafed8b..756869f5 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -58,7 +58,6 @@ omarchy-restart-terminal omarchy-restart-hyprctl omarchy-restart-btop omarchy-restart-opencode -omarchy-restart-mako omarchy-restart-helix # Change app-specific themes diff --git a/bin/omarchy-toggle b/bin/omarchy-toggle index f5eb1a3b..bb3e879c 100755 --- a/bin/omarchy-toggle +++ b/bin/omarchy-toggle @@ -19,9 +19,9 @@ FLAG="$HOME/.local/state/omarchy/toggles/$FLAG_NAME" if [[ -f $FLAG ]]; then rm $FLAG - [[ -n $DISABLED_NOTIFICATION ]] && notify-send -u low "$DISABLED_NOTIFICATION" + [[ -n $DISABLED_NOTIFICATION ]] && notify-send -a omarchy-action -u low "$DISABLED_NOTIFICATION" else mkdir -p "$(dirname $FLAG)" touch $FLAG - [[ -n $ENABLED_NOTIFICATION ]] && notify-send -u low "$ENABLED_NOTIFICATION" + [[ -n $ENABLED_NOTIFICATION ]] && notify-send -a omarchy-action -u low "$ENABLED_NOTIFICATION" fi diff --git a/bin/omarchy-toggle-idle b/bin/omarchy-toggle-idle index 83398a33..22b2cf26 100755 --- a/bin/omarchy-toggle-idle +++ b/bin/omarchy-toggle-idle @@ -4,10 +4,10 @@ if pgrep -x hypridle >/dev/null; then pkill -x hypridle - notify-send -u low "󱫖 Stop locking computer when idle" + notify-send -a omarchy-action -u low "󱫖 Stop locking computer when idle" else uwsm-app -- hypridle >/dev/null 2>&1 & - notify-send -u low "󱫖 Now locking computer when idle" + notify-send -a omarchy-action -u low "󱫖 Now locking computer when idle" fi pkill -RTMIN+9 waybar diff --git a/bin/omarchy-toggle-nightlight b/bin/omarchy-toggle-nightlight index c308b757..6085667b 100755 --- a/bin/omarchy-toggle-nightlight +++ b/bin/omarchy-toggle-nightlight @@ -23,10 +23,10 @@ restart_nightlighted_waybar() { if [[ $CURRENT_TEMP == $OFF_TEMP ]]; then hyprctl hyprsunset temperature $ON_TEMP - notify-send -u low " Nightlight screen temperature" + notify-send -a omarchy-action -u low " Nightlight screen temperature" restart_nightlighted_waybar else hyprctl hyprsunset temperature $OFF_TEMP - notify-send -u low " Daylight screen temperature" + notify-send -a omarchy-action -u low " Daylight screen temperature" restart_nightlighted_waybar fi diff --git a/bin/omarchy-toggle-notification-silencing b/bin/omarchy-toggle-notification-silencing index c865b982..c40d6a2b 100755 --- a/bin/omarchy-toggle-notification-silencing +++ b/bin/omarchy-toggle-notification-silencing @@ -2,14 +2,12 @@ # omarchy:summary=Toggle notification do-not-disturb mode -makoctl mode -t do-not-disturb +state=$(omarchy-shell-ipc notifications toggleDnd 2>/dev/null || echo "") -if makoctl mode | grep -q 'do-not-disturb'; then - notify-send -u low "󰂛 Silenced notifications" -else - notify-send -u low "󰂚 Enabled notifications" -fi +case $state in +on) omarchy-notification-send "Silenced notifications" -g 󰂛 -e ;; +off) omarchy-notification-send "Enabled notifications" -g 󰂚 -e ;; +esac -pkill -RTMIN+10 waybar -OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} -quickshell ipc -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" call bar refreshIndicators >/dev/null 2>&1 || true +pkill -RTMIN+10 waybar 2>/dev/null || true +omarchy-shell-ipc bar refreshIndicators >/dev/null 2>&1 || true diff --git a/bin/omarchy-voxtype-install b/bin/omarchy-voxtype-install index c337c756..2faa340f 100755 --- a/bin/omarchy-voxtype-install +++ b/bin/omarchy-voxtype-install @@ -24,5 +24,5 @@ if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then omarchy-restart-waybar fi omarchy-restart-quickshell - notify-send " Voxtype Dictation Ready" "Hold F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000 + notify-send -a omarchy-action " Voxtype Dictation Ready" "Hold F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000 fi diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index ec918875..9c1d6c5d 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -293,7 +293,7 @@ launch_windows() { echo "Starting Windows VM..." # Send desktop notification - notify-send " Starting Windows VM" " This can take 15-30 seconds" -t 15000 + notify-send -a omarchy-action " Starting Windows VM" " This can take 15-30 seconds" -t 15000 if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then echo "❌ Failed to start Windows VM!" diff --git a/default/hypr/autostart.lua b/default/hypr/autostart.lua index e4659402..51cd83c0 100644 --- a/default/hypr/autostart.lua +++ b/default/hypr/autostart.lua @@ -1,6 +1,5 @@ hl.on("hyprland.start", function() hl.exec_cmd("uwsm-app -- hypridle") - hl.exec_cmd("uwsm-app -- mako") hl.exec_cmd("omarchy-restart-quickshell") hl.exec_cmd("uwsm-app -- fcitx5 --disable notificationitem") hl.exec_cmd("uwsm-app -- swaybg -i ~/.config/omarchy/current/background -m fill") diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 87ff6ba7..505bcca9 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -22,11 +22,11 @@ hl.bind("SUPER + BACKSPACE", hl.dsp.exec_cmd("omarchy-hyprland-window-transparen hl.bind("SUPER + SHIFT + BACKSPACE", hl.dsp.exec_cmd("omarchy-hyprland-window-gaps-toggle"), { description = "Toggle window gaps" }) hl.bind("SUPER + CTRL + BACKSPACE", hl.dsp.exec_cmd("omarchy-hyprland-window-single-square-aspect-toggle"), { description = "Toggle single-window square aspect" }) -hl.bind("SUPER + COMMA", hl.dsp.exec_cmd("makoctl dismiss"), { description = "Dismiss last notification" }) -hl.bind("SUPER + SHIFT + COMMA", hl.dsp.exec_cmd("makoctl dismiss --all"), { description = "Dismiss all notifications" }) +hl.bind("SUPER + COMMA", hl.dsp.exec_cmd("omarchy-shell-ipc notifications dismissOne"), { description = "Dismiss last notification" }) +hl.bind("SUPER + SHIFT + COMMA", hl.dsp.exec_cmd("omarchy-shell-ipc notifications dismissAll"), { description = "Dismiss all notifications" }) hl.bind("SUPER + CTRL + COMMA", hl.dsp.exec_cmd("omarchy-toggle-notification-silencing"), { description = "Toggle silencing notifications" }) -hl.bind("SUPER + ALT + COMMA", hl.dsp.exec_cmd("makoctl invoke"), { description = "Invoke last notification" }) -hl.bind("SUPER + SHIFT + ALT + COMMA", hl.dsp.exec_cmd("makoctl restore"), { description = "Restore last notification" }) +hl.bind("SUPER + ALT + COMMA", hl.dsp.exec_cmd("omarchy-shell-ipc notifications invokeLast"), { description = "Invoke last notification" }) +hl.bind("SUPER + SHIFT + ALT + COMMA", hl.dsp.exec_cmd("omarchy-shell-ipc notifications showHistory"), { description = "Open notification history" }) hl.bind("SUPER + CTRL + I", hl.dsp.exec_cmd("omarchy-toggle-idle"), { description = "Toggle locking on idle" }) hl.bind("SUPER + CTRL + N", hl.dsp.exec_cmd("omarchy-toggle-nightlight"), { description = "Toggle nightlight" }) @@ -48,9 +48,9 @@ hl.bind("SUPER + CTRL + R", hl.dsp.exec_cmd("omarchy-menu reminder-set"), { desc hl.bind("SUPER + CTRL + ALT + R", hl.dsp.exec_cmd("omarchy-reminder show"), { description = "Show reminders" }) hl.bind("SUPER + SHIFT + CTRL + R", hl.dsp.exec_cmd("omarchy-reminder clear"), { description = "Clear reminders" }) -hl.bind("SUPER + CTRL + ALT + T", hl.dsp.exec_cmd([[notify-send -u low " $(date +"%A %H:%M · %d %B %Y · Week %V")"]]), { description = "Show time" }) -hl.bind("SUPER + CTRL + ALT + B", hl.dsp.exec_cmd([[notify-send -u low "$(omarchy-battery-status)"]]), { description = "Show battery remaining" }) -hl.bind("SUPER + CTRL + ALT + W", hl.dsp.exec_cmd([[notify-send -u low "$(omarchy-weather-status)"]]), { description = "Show weather" }) +hl.bind("SUPER + CTRL + ALT + T", hl.dsp.exec_cmd([[notify-send -a omarchy-action -u low " $(date +"%A %H:%M · %d %B %Y · Week %V")"]]), { description = "Show time" }) +hl.bind("SUPER + CTRL + ALT + B", hl.dsp.exec_cmd([[notify-send -a omarchy-action -u low "$(omarchy-battery-status)"]]), { description = "Show battery remaining" }) +hl.bind("SUPER + CTRL + ALT + W", hl.dsp.exec_cmd([[notify-send -a omarchy-action -u low "$(omarchy-weather-status)"]]), { description = "Show weather" }) hl.bind("SUPER + CTRL + A", hl.dsp.exec_cmd("omarchy-launch-audio"), { description = "Audio controls" }) hl.bind("SUPER + CTRL + B", hl.dsp.exec_cmd("omarchy-launch-bluetooth"), { description = "Bluetooth controls" }) diff --git a/default/mako/core.ini b/default/mako/core.ini deleted file mode 100644 index d8837b92..00000000 --- a/default/mako/core.ini +++ /dev/null @@ -1,38 +0,0 @@ -anchor=top-right -group-by=app-name,summary,body -default-timeout=5000 -width=420 -outer-margin=20 -padding=10,15 -border-size=2 -max-icon-size=32 -font=sans-serif 14px - -[app-name=Spotify] -invisible=1 - -[mode=do-not-disturb] -invisible=true - -[mode=do-not-disturb app-name=notify-send] -invisible=false - -[urgency=critical] -default-timeout=0 -layer=overlay - -[summary~="Setup Wi-Fi"] -on-button-left=exec sh -c 'omarchy-notification-dismiss "Setup Wi-Fi"; omarchy-launch-wifi' - -[summary~="Update System"] -on-button-left=exec sh -c 'omarchy-notification-dismiss "Update System"; omarchy-launch-floating-terminal-with-presentation omarchy-update' - -[summary~="Learn Keybindings"] -on-button-left=exec sh -c 'omarchy-notification-dismiss "Learn Keybindings"; omarchy-menu-keybindings' - -[summary~="Install Dictation with Voxtype"] -on-button-left=exec sh -c 'omarchy-notification-dismiss "Install Dictation with Voxtype"; omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install' - -[summary~="Screenshot copied & saved"] -max-icon-size=80 -format=%s\n%b diff --git a/default/omarchy-skill/SKILL.md b/default/omarchy-skill/SKILL.md index b843bae3..0beaab5f 100644 --- a/default/omarchy-skill/SKILL.md +++ b/default/omarchy-skill/SKILL.md @@ -3,7 +3,7 @@ name: omarchy description: > REQUIRED for end-user customization of Linux desktop, window manager, or system config. Use when editing ~/.config/hypr/, ~/.config/waybar/, ~/.config/walker/, - ~/.config/alacritty/, ~/.config/foot/, ~/.config/kitty/, ~/.config/ghostty/, ~/.config/mako/, + ~/.config/alacritty/, ~/.config/foot/, ~/.config/kitty/, ~/.config/ghostty/, or ~/.config/omarchy/. Triggers: Hyprland, window rules, animations, keybindings, monitors, gaps, borders, blur, opacity, waybar, walker, terminal config, themes, background, night light, idle, lock screen, screenshots, reminders, layer rules, @@ -23,7 +23,7 @@ It is not for contributing to Omarchy source code. **ALWAYS invoke this skill for end-user requests involving ANY of these:** - Editing ANY file in `~/.config/hypr/` (window rules, animations, keybindings, monitors, etc.) -- Editing ANY file in `~/.config/waybar/`, `~/.config/walker/`, `~/.config/mako/` +- Editing ANY file in `~/.config/waybar/`, `~/.config/walker/` - Editing terminal configs (alacritty, foot, kitty, ghostty) - Editing ANY file in `~/.config/omarchy/` - Window behavior, animations, opacity, blur, gaps, borders @@ -79,7 +79,6 @@ Omarchy is built on: | **Waybar** | Status bar | `~/.config/waybar/` | | **Walker** | App launcher | `~/.config/walker/` | | **Alacritty/Foot/Kitty/Ghostty** | Terminals | `~/.config//` | -| **Mako** | Notifications | `~/.config/mako/` | | **SwayOSD** | On-screen display | `~/.config/swayosd/` | ## Command Discovery diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 53dd8757..17eee1fd 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -77,7 +77,7 @@ "trigger.toggle.screensaver": {"icon":"󱄄","label":"Screensaver","action":"omarchy-toggle-screensaver"}, "trigger.toggle.nightlight": {"icon":"󰔎","label":"Nightlight","keywords":"night light hyprsunset","action":"omarchy-toggle-nightlight"}, "trigger.toggle.idle-lock": {"icon":"󱫖","label":"Idle Lock","keywords":"hypridle","action":"omarchy-toggle-idle"}, - "trigger.toggle.notifications": {"icon":"󰂛","label":"Notifications","keywords":"mako silence","action":"omarchy-toggle-notification-silencing"}, + "trigger.toggle.notifications": {"icon":"󰂛","label":"Notifications","keywords":"silence dnd","action":"omarchy-toggle-notification-silencing"}, "trigger.toggle.top-bar": {"icon":"󰍜","label":"Bar","keywords":"top bar quickshell","action":"omarchy-toggle-bar"}, "trigger.toggle.workspace-layout": {"icon":"󱂬","label":"Workspace Layout","action":"omarchy-hyprland-workspace-layout-toggle"}, "trigger.toggle.window-gaps": {"icon":"","label":"Window Gaps","action":"omarchy-hyprland-window-gaps-toggle"}, @@ -311,7 +311,6 @@ "update.channel.dev": {"icon":"🔴","label":"Dev","keywords":"development","action":"present_terminal 'omarchy-channel-set dev'"}, "update.process.hypridle": {"icon":"","label":"Hypridle","keywords":"restart","action":"omarchy-restart-hypridle"}, "update.process.hyprsunset": {"icon":"","label":"Hyprsunset","keywords":"restart","action":"omarchy-restart-hyprsunset"}, - "update.process.mako": {"icon":"󰎟","label":"Mako","keywords":"restart notifications","action":"omarchy-restart-mako"}, "update.process.swayosd": {"icon":"","label":"Swayosd","keywords":"restart","action":"omarchy-restart-swayosd"}, "update.process.walker": {"icon":"󰌧","label":"Walker","keywords":"restart launcher","action":"omarchy-restart-walker"}, "update.process.quickshell": {"icon":"󰍜","label":"Quickshell","keywords":"restart bar shell","action":"omarchy-restart-quickshell"}, diff --git a/default/quickshell/omarchy-shell/Commons/Color.qml b/default/quickshell/omarchy-shell/Commons/Color.qml index 8b6be52c..f9521c54 100644 --- a/default/quickshell/omarchy-shell/Commons/Color.qml +++ b/default/quickshell/omarchy-shell/Commons/Color.qml @@ -17,6 +17,11 @@ QtObject { property color background: "#101315" property color accent: "#cacccc" property color urgent: "#a55555" + // The theme's Hyprland active border color (`$activeBorderColor` in the + // theme's hyprland.conf). Most themes set this to the accent; some themes + // (e.g. aether, oodle) override it to a neutral. Streamed from the theme + // file alongside colors.toml so notification surfaces stay in sync. + property color border: accent // Noctalia palette tokens. We map them onto our theme colors. readonly property color mPrimary: accent @@ -91,14 +96,32 @@ QtObject { function loadTheme(raw) { var lines = String(raw || "").split("\n") + var foundAccent = false + var color4Value = "" for (var i = 0; i < lines.length; i++) { var match = lines[i].match(/^\s*([A-Za-z0-9_-]+)\s*=\s*["']?(#[0-9A-Fa-f]{6})/) if (!match) continue if (match[1] === "foreground") foreground = match[2] else if (match[1] === "background") background = match[2] - else if (match[1] === "color4" || match[1] === "accent") accent = match[2] - else if (match[1] === "red") urgent = match[2] + // Prefer the explicit `accent` key; only fall back to color4 when the + // theme doesn't define a separate accent. Aether/oodle/etc define both, + // and color4 appears later in the file so the old single-property + // approach was clobbering accent with color4 (#8274fd purple). + else if (match[1] === "accent") { accent = match[2]; foundAccent = true } + else if (match[1] === "color4") color4Value = match[2] + else if (match[1] === "red" || match[1] === "color1") urgent = match[2] } + if (!foundAccent && color4Value.length > 0) accent = color4Value + } + + // Parse `$activeBorderColor = rgb(XXXXXX)` from the theme's hyprland.conf. + function loadHyprlandTheme(raw) { + var match = String(raw || "").match(/\$activeBorderColor\s*=\s*rgba?\(([0-9A-Fa-f]{6,8})\)/) + if (!match) { border = accent; return } + var hex = match[1] + // rgba() in hyprland is AARRGGBB; strip the AA prefix and use the RGB. + if (hex.length === 8) hex = hex.substring(2) + border = "#" + hex } // `omarchy-theme-set` recreates the theme/ directory via rm+mv, which kills @@ -116,6 +139,14 @@ QtObject { path: Quickshell.env("HOME") + "/.config/omarchy/current/theme.name" watchChanges: true printErrors: false - onFileChanged: themeColorsFile.reload() + onFileChanged: { themeColorsFile.reload(); themeHyprlandFile.reload() } + } + property FileView themeHyprlandFile: FileView { + id: themeHyprlandFile + path: Quickshell.env("HOME") + "/.config/omarchy/current/theme/hyprland.conf" + watchChanges: true + printErrors: false + onLoaded: root.loadHyprlandTheme(text()) + onFileChanged: reload() } } diff --git a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml index 3bbda3b6..f4ac8fb4 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml @@ -309,7 +309,7 @@ Item { "networkPanel": { displayName: "Network", description: "Wi-Fi list and connection state", category: "Network", allowMultiple: false }, "bluetoothPanel": { displayName: "Bluetooth", description: "Bluetooth device list with connect/disconnect", category: "Network", allowMultiple: false }, "calendar": { displayName: "Calendar", description: "Clock with month-grid popup", category: "Time", allowMultiple: false, settingsForm: "calendarSettings" }, - "notificationCenter": { displayName: "Notification center", description: "Recent notifications + DND (replaces mako)", category: "Status", allowMultiple: false }, + "notificationCenter": { displayName: "Notification center", description: "Recent notifications + DND", category: "Status", allowMultiple: false }, "systemStats": { displayName: "System stats", description: "CPU icon — hover for graphs, click to open btop", category: "System", allowMultiple: false }, "weatherFlyout": { displayName: "Weather", description: "Weather pill with detail popup", category: "Info", allowMultiple: false }, "idleInhibitor": { displayName: "Keep awake", description: "Toggle idle inhibitor", category: "System", allowMultiple: false }, diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml index 784ee8dc..52379500 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml @@ -1,5 +1,6 @@ import QtQuick import Quickshell +import Quickshell.Bluetooth import Quickshell.Io import Quickshell.Services.Pipewire import Quickshell.Services.UPower @@ -25,15 +26,22 @@ Item { return value === undefined || value === null ? fallback : value } - readonly property var sink: Pipewire.defaultAudioSink - readonly property real currentVolume: sink && sink.audio && isFinite(sink.audio.volume) ? sink.audio.volume : 0 - readonly property bool sinkMuted: sink && sink.audio ? sink.audio.muted : false - PwObjectTracker { objects: root.sink ? [root.sink] : [] } + // Volume controls live in the audioPanel bar widget, so the quick-settings + // popup just hosts brightness + toggles. Bluetooth adapter status is + // exposed via Quickshell.Bluetooth. + readonly property var btAdapter: Bluetooth.defaultAdapter + readonly property bool btEnabled: btAdapter ? btAdapter.enabled : false property int currentBrightness: -1 property int pendingBrightness: -1 - property bool dndActive: false + // DND state is bound straight to the notifications service so toggling + // from the quick-settings tile or the bar widget reflects instantly. + 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 bool dndActive: notificationService ? notificationService.doNotDisturb : false property bool idleInhibited: false property bool nightLightActive: false property bool nightLightAvailable: false @@ -42,16 +50,6 @@ Item { readonly property bool powerProfileAvailable: PowerProfiles.hasPerformanceProfile || PowerProfiles.profile === PowerProfile.PowerSaver || PowerProfiles.profile === PowerProfile.Balanced readonly property int currentProfile: PowerProfiles.profile - function setVolume(value) { - if (!sink || !sink.audio) return - sink.audio.volume = Math.max(0, Math.min(1, value)) - } - - function toggleMute() { - if (!sink || !sink.audio) return - sink.audio.muted = !sink.audio.muted - } - function setBrightness(percent) { var clamped = Math.max(1, Math.min(100, Math.round(percent))) currentBrightness = clamped @@ -61,7 +59,6 @@ Item { function refresh() { if (!brightnessProc.running) brightnessProc.running = true - if (!dndProc.running) dndProc.running = true if (!idleProc.running) idleProc.running = true if (!nightLightProc.running) nightLightProc.running = true if (!themeProc.running) themeProc.running = true @@ -98,15 +95,6 @@ Item { } } - Process { - id: dndProc - command: ["bash", "-lc", "if command -v makoctl >/dev/null && makoctl mode 2>/dev/null | grep -q '^do-not-disturb$'; then echo on; else echo off; fi"] - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: root.dndActive = String(text || "").trim() === "on" - } - } - Process { id: idleProc command: ["bash", "-lc", "if pgrep -x hypridle >/dev/null 2>&1; then echo running; else echo inhibited; fi"] @@ -190,49 +178,12 @@ Item { Column { width: parent.width spacing: 10 - visible: root.sink !== null + visible: root.currentBrightness >= 0 Row { width: parent.width spacing: 8 - Common.PillButton { - iconText: root.sinkMuted ? "󰝟" : "󰕾" - foreground: root.bar.foreground - horizontalPadding: 8 - verticalPadding: 6 - iconSize: 16 - onClicked: root.toggleMute() - } - - Common.Slider { - bar: root.bar - width: parent.width - 90 - value: root.currentVolume - minimum: 0 - maximum: 1 - step: 0.05 - anchors.verticalCenter: parent.verticalCenter - onMoved: function(v) { root.setVolume(v) } - onReleased: function(v) { root.setVolume(v) } - } - - Text { - text: Math.round(root.currentVolume * 100) + "%" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - width: 32 - horizontalAlignment: Text.AlignRight - } - } - - Row { - width: parent.width - spacing: 8 - visible: root.currentBrightness >= 0 - Common.PillButton { iconText: "󰃠" foreground: root.bar.foreground @@ -284,7 +235,10 @@ Item { title: "Do Not Disturb" subtitle: root.dndActive ? "On" : "Off" active: root.dndActive - onClicked: { root.run("omarchy-toggle-notification-silencing"); dndProc.running = true } + onClicked: { + if (!root.notificationService) return + root.notificationService.setDoNotDisturb(!root.notificationService.doNotDisturb) + } } Tile { @@ -308,10 +262,15 @@ Item { Tile { width: (tileGrid.width - tileGrid.columnSpacing) / 2 - glyph: "󰔎" - title: "Theme" - subtitle: root.themeName || "—" - onClicked: { root.run("omarchy-menu themes"); root.popupOpen = false } + glyph: root.btEnabled ? "󰂯" : "󰂲" + title: "Bluetooth" + subtitle: !root.btAdapter ? "—" : (root.btEnabled ? "On" : "Off") + active: root.btEnabled + tileEnabled: root.btAdapter !== null + onClicked: { + if (!root.btAdapter) return + root.btAdapter.enabled = !root.btAdapter.enabled + } } } diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/notificationCenter.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/notificationCenter.qml index 669894d4..5800c3d7 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/notificationCenter.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/notificationCenter.qml @@ -1,6 +1,7 @@ import QtQuick +import QtQuick.Layouts import Quickshell -import Quickshell.Services.Notifications +import qs.Commons import "../common" as Common Item { @@ -11,80 +12,46 @@ Item { property var settings: ({}) property bool popupOpen: false - function closePopout() { popupOpen = false } - property var stored: [] - property bool dnd: false - readonly property int count: stored.length + // 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 (count > 0) return "󱅫" + if (pendingCount > 0) return "󱅫" return "󰂚" } - property bool replaceMako: settings && settings.replaceMako === true - - Loader { - active: root.replaceMako - sourceComponent: serverComponent - } - - Component { - id: serverComponent - - NotificationServer { - id: server - keepOnReload: false - bodySupported: true - actionsSupported: true - imageSupported: true - - onNotification: function(notification) { - if (root.dnd) { - notification.expire() - return - } - notification.tracked = true - var snapshot = { - id: notification.id, - app: notification.appName, - summary: notification.summary, - body: notification.body, - time: new Date(), - ref: notification - } - var next = root.stored.slice() - next.unshift(snapshot) - if (next.length > 30) next.pop() - root.stored = next - } - } - } - - function dismiss(index) { - var item = stored[index] - if (item && item.ref && !item.ref.closed) item.ref.dismiss() - var next = stored.slice() - next.splice(index, 1) - stored = next - } - - function clearAll() { - for (var i = 0; i < stored.length; i++) { - if (stored[i].ref && !stored[i].ref.closed) stored[i].ref.dismiss() - } - stored = [] - } - - function relativeTime(date) { - if (!date) return "" - var diff = (Date.now() - date.getTime()) / 1000 - if (diff < 60) return "just now" - if (diff < 3600) return Math.floor(diff / 60) + "m" - if (diff < 86400) return Math.floor(diff / 3600) + "h" - return Math.floor(diff / 86400) + "d" - } + // 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 @@ -94,148 +61,315 @@ Item { anchors.fill: parent bar: root.bar text: root.icon - active: root.count > 0 && !root.dnd - tooltipText: root.dnd ? "Do Not Disturb" : (root.count > 0 ? root.count + " notifications" : "No notifications") + 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) root.dnd = !root.dnd - else root.popupOpen = !root.popupOpen + 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 - owner: root bar: root.bar + owner: root open: root.popupOpen - contentWidth: 340 - contentHeight: Math.min(420, listColumn.implicitHeight + 60) + contentWidth: 440 + contentHeight: 540 - Column { - id: listColumn + ColumnLayout { anchors.fill: parent - spacing: 8 + spacing: 10 - Row { - width: parent.width + // ----------------------------------------- header + RowLayout { + Layout.fillWidth: true spacing: 8 Text { text: "Notifications" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 13 + font.family: root.bar ? root.bar.fontFamily : "" + color: root.colForeground + font.pixelSize: 14 font.bold: true - anchors.verticalCenter: parent.verticalCenter } - Item { width: parent.width - 220; height: 1 } + Item { Layout.fillWidth: true } - Common.PillButton { - iconText: root.dnd ? "󰂛" : "" - text: root.dnd ? "DND" : "" - foreground: root.bar.foreground - horizontalPadding: 8 - verticalPadding: 4 - active: root.dnd - onClicked: root.dnd = !root.dnd - } + 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 - Common.PillButton { - iconText: "󰎟" - foreground: root.bar.foreground - horizontalPadding: 8 - verticalPadding: 4 - enabled: root.count > 0 - opacity: enabled ? 1 : 0.4 - onClicked: root.clearAll() + 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) + } } } - Flickable { - width: parent.width - height: Math.min(320, contentHeight) - contentHeight: feedColumn.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds + // ----------------------------------------- tabs + RowLayout { + Layout.fillWidth: true + spacing: 0 - Column { - id: feedColumn - width: parent.width - spacing: 4 + 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 - Repeater { - model: root.stored + 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 { - required property var modelData - required property int index + 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 + } - width: feedColumn.width - radius: 4 - color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.06) - implicitHeight: notifContent.implicitHeight + 16 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.activeTab = modelData.key + } + } + } + } - Column { - id: notifContent + // ----------------------------------------- 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 - anchors.margins: 8 - spacing: 2 + 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 + } + } - Row { - width: parent.width + ColumnLayout { + Layout.fillWidth: true + spacing: 2 - Text { - text: modelData ? (modelData.app || "App") : "" - color: Qt.darker(root.bar.foreground, 1.4) - font.family: root.bar.fontFamily - font.pixelSize: 10 - font.bold: true - elide: Text.ElideRight - width: parent.width - timeText.implicitWidth - dismissBtn.width - 12 - } + 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 { - id: timeText - text: modelData ? root.relativeTime(modelData.time) : "" - color: Qt.darker(root.bar.foreground, 1.6) - font.family: root.bar.fontFamily - font.pixelSize: 10 - } + Text { + Layout.fillWidth: true + visible: rowCard.body.length > 0 + text: String(rowCard.body).replace(/]*>/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 + } + } - Item { width: 6; height: 1 } + Rectangle { + Layout.preferredWidth: 18 + Layout.preferredHeight: 18 + Layout.alignment: Qt.AlignVCenter + radius: Math.min(4, root.cardRadius) + color: rowCloseArea.containsMouse ? root.colBorder : "transparent" - Common.PillButton { - id: dismissBtn - iconText: "󰅖" - foreground: root.bar.foreground - horizontalPadding: 4 - verticalPadding: 0 - iconSize: 10 - onClicked: root.dismiss(index) - } - } + Text { + anchors.centerIn: parent + text: "✕" + font.family: root.bar ? root.bar.fontFamily : "" + color: root.colDim + font.pixelSize: 11 + } - Text { - text: modelData ? (modelData.summary || "") : "" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 11 - font.bold: true - wrapMode: Text.WordWrap - width: parent.width - } - - Text { - visible: modelData && modelData.body !== "" - text: modelData ? (modelData.body || "") : "" - color: Qt.darker(root.bar.foreground, 1.2) - font.family: root.bar.fontFamily - font.pixelSize: 10 - wrapMode: Text.WordWrap - width: parent.width - maximumLineCount: 3 - elide: Text.ElideRight + 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) } } } @@ -243,12 +377,36 @@ Item { } } - Text { - visible: root.stored.length === 0 - text: root.dnd ? "Do Not Disturb is on" : "Nothing new" - color: Qt.darker(root.bar.foreground, 1.5) - font.family: root.bar.fontFamily - font.pixelSize: 11 + // ----------------------------------------- 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 + } + } } } } diff --git a/default/quickshell/omarchy-shell/plugins/notifications/Service.qml b/default/quickshell/omarchy-shell/plugins/notifications/Service.qml new file mode 100644 index 00000000..80b9c34c --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/notifications/Service.qml @@ -0,0 +1,902 @@ +// Notification service. Adapted from noctalia-shell (MIT) and +// DankMaterialShell (MIT). Original implementations: +// https://github.com/noctalia-dev/noctalia-shell +// https://github.com/AvengeMedia/DankMaterialShell + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Services.Notifications +import qs.Commons + +import "components" + +Item { + id: service + + // Injected by omarchy-shell (the first-party service loader). + property string omarchyPath: "" + property var shell: null + property var manifest: null + + readonly property string home: Quickshell.env("HOME") + readonly property string cacheDir: home + "/.cache/omarchy/" + readonly property string historyPath: cacheDir + "notifications.json" + readonly property string imageCacheDir: cacheDir + "notification-images/" + readonly property string styleStatePath: home + "/.local/state/omarchy/toggles/quickshell-menu.json" + // Optional per-theme override file. Themes that want notification colors + // to diverge from the rest of the palette can ship this file. Schema: + // { + // "borderColor": "#hex", + // "backgroundColor": "#hex", + // "textColor": "#hex", + // "countdownColor": "#hex" // progress bar at bottom of popup + // } + // All keys optional; unset keys fall back to the live Color.* tokens. + readonly property string themeOverridePath: home + "/.config/omarchy/current/theme/notifications.json" + + // Corner radius is shared with omarchy-shell menu and settings panel — + // `omarchy style corners ` writes this file once and every + // surface reads it. Default 10 keeps the popup looking like it did pre-toggle. + property int cornerRadius: 10 + + // Surfaces anchor relative to the omarchy bar so popups and history land + // alongside the other shell panels rather than on top of the bar itself. + // Falls back to the bar's default size (26 horizontal / 28 vertical) when + // shell.bar isn't reachable so the popup never lands on top of the bar. + readonly property string barPosition: shell && shell.barConfig ? String(shell.barConfig.position || "top") : "top" + readonly property bool barVertical: barPosition === "left" || barPosition === "right" + readonly property int defaultBarSize: barVertical ? 28 : 26 + readonly property int liveBarSize: shell && shell.bar && !shell.bar.barHidden ? Math.max(0, shell.bar.barSize) : defaultBarSize + readonly property int barClearance: liveBarSize + 12 + + function loadStyleState(raw) { + try { + var parsed = JSON.parse(raw || "{}") + var n = Number(parsed.radius) + cornerRadius = isFinite(n) && n >= 0 ? n : 10 + } catch (e) { + cornerRadius = 10 + } + } + + FileView { + path: service.styleStatePath + watchChanges: true + printErrors: false + onLoaded: service.loadStyleState(text()) + onFileChanged: reload() + } + + // ----------------------------------------------- per-theme color overrides + + property string overrideBorder: "" + property string overrideBackground: "" + property string overrideText: "" + property string overrideCountdown: "" + + readonly property color effectiveBorder: overrideBorder.length > 0 ? overrideBorder : Color.border + readonly property color effectiveBackground: overrideBackground.length > 0 ? overrideBackground : Color.background + readonly property color effectiveText: overrideText.length > 0 ? overrideText : Color.foreground + readonly property color effectiveCountdown: overrideCountdown.length > 0 ? overrideCountdown : Color.accent + + function loadThemeOverride(raw) { + try { + var parsed = JSON.parse(raw || "{}") + overrideBorder = typeof parsed.borderColor === "string" ? parsed.borderColor : "" + overrideBackground = typeof parsed.backgroundColor === "string" ? parsed.backgroundColor : "" + overrideText = typeof parsed.textColor === "string" ? parsed.textColor : "" + overrideCountdown = typeof parsed.countdownColor === "string" ? parsed.countdownColor : "" + } catch (e) { + overrideBorder = "" + overrideBackground = "" + overrideText = "" + overrideCountdown = "" + } + } + + FileView { + path: service.themeOverridePath + watchChanges: true + printErrors: false + onLoaded: service.loadThemeOverride(text()) + onLoadFailed: service.loadThemeOverride("") + onFileChanged: reload() + } + + // Fired by IPC (`omarchy-shell-ipc notifications showHistory`) so the + // bar widget can drop its PopupCard from the same anchor a click would. + signal historyOpenRequested() + + // PersistentProperties handles in-process QML reloads. The on-disk + // notifications.json file is the cross-restart backstop — its `dnd` key + // is hydrated into persisted.doNotDisturb on startup and written back via + // the same debounced save timer used for history entries. + PersistentProperties { + id: persisted + reloadableId: "omarchy-notifications" + property bool doNotDisturb: false + onDoNotDisturbChanged: { + // Suppress the write that load-time hydration would otherwise trigger. + if (service._hydrating) return + service.scheduleHistorySave() + } + } + + // Guards onDoNotDisturbChanged while we're hydrating from disk so the + // hydration assignment doesn't immediately schedule a write-back. + property bool _hydrating: false + + readonly property alias doNotDisturb: persisted.doNotDisturb + + function setDoNotDisturb(value) { + persisted.doNotDisturb = !!value + } + + // popupModel feeds the on-screen toast stack. + // pendingModel = notifications received but not yet "seen" by the user. + // Anything DND-suppressed lands here and stays there until + // the user reviews it; anything that pops up also lives + // here until the popup dismisses, then moves to pastModel. + // pastModel = notifications the user has already seen on-screen. + // Surfaced under the Past tab in the history panel. + // + // Aliased as properties so the bar widget and HistoryPanel (outside this + // Item's id scope) can bind to them. QML ids aren't visible to external + // consumers without the alias. + property alias popupModel: popupModel + property alias pendingModel: pendingModel + property alias pastModel: pastModel + ListModel { id: popupModel } + ListModel { id: pendingModel } + ListModel { id: pastModel } + + readonly property int historyCap: 100 + + function durationFor(urgency) { + if (urgency === NotificationUrgency.Critical) return 0 + if (urgency === NotificationUrgency.Low) return 3000 + return 6000 + } + + // DND bypass: only let through notifications we trust to be intentional + // and rare. + // - omarchy-action: a user-action confirmation toast ("Theme changed", + // "Screenshot saved"). The user JUST did something — their feedback + // should show. + // - urgency=critical AND app_name=notify-send: bare-CLI emergency alerts + // (omarchy-battery-monitor uses this for low-battery; install-time + // scripts for wifi setup, etc.). Trusted because it's almost always + // omarchy or system shell scripts — chat apps set app_name to + // their brand (Discord/Slack/Vesktop) which falls outside this rule. + function shouldBypassDnd(notification) { + var appName = String(notification.appName || "") + if (appName === "omarchy-action") return true + if (appName === "notify-send" && notification.urgency === NotificationUrgency.Critical) return true + return false + } + + function snapshotOf(notification) { + var glyph = "" + try { + if (notification.hints && typeof notification.hints["omarchy-glyph"] === "string") { + glyph = notification.hints["omarchy-glyph"] + } + } catch (e) { glyph = "" } + return { + id: notification.id, + originalId: notification.id, + app: notification.appName || "", + appIcon: notification.appIcon || "", + summary: notification.summary || "", + body: notification.body || "", + image: notification.image || "", + glyph: glyph, + urgency: notification.urgency, + timestamp: Date.now(), + ref: notification + } + } + + function handleNotification(notification) { + // Without `tracked = true` the Notification object is destroyed as soon + // as this signal handler returns, which would null out the `ref` we just + // captured for the popup card. + notification.tracked = true + var snapshot = snapshotOf(notification) + // History is for notifications from real apps (Slack, Discord, mailer, + // etc.) — things the user might want to look back at. Skip the pending + // / past bookkeeping when: + // - the freedesktop `transient` hint is set ("popup only, don't store") + // - app_name is "notify-send" (the CLI default — means the sender + // didn't bother declaring an identity, so it's almost certainly + // ephemeral test/feedback noise) + // - app_name is "omarchy-action" (omarchy's own user-action + // confirmation toasts — the user just triggered them, they don't + // need to be archived) + var transient = false + try { + transient = !!(notification.hints && notification.hints["transient"]) + } catch (e) { transient = false } + var appName = String(notification.appName || "") + var ephemeralApp = appName === "notify-send" || appName === "omarchy-action" + if (transient || ephemeralApp) { + if (service.doNotDisturb && !shouldBypassDnd(notification)) { + notification.tracked = false + return + } + Qt.callLater(function() { popupModel.insert(0, snapshot) }) + return + } + + // Pending first, unconditionally. DND only suppresses the toast — the + // record still has to land somewhere the user can review later. + addToPending(snapshot) + + // Kick off a copy of any /tmp screenshot into the persistent image cache. + // The cp races the popup; the popup keeps the original path so it always + // renders, and the history row gets rewritten to the cached path once + // cp.exits. + maybeCacheImage(snapshot) + + // DND bypass rules — see ~/Work/omarchy/dnd-fix-plan.md. The pending + // entry already captured this notification above; we just decide here + // whether to also pop a toast. Chat apps abuse urgency=critical to + // force visibility, so critical alone isn't enough — we also require + // the sender to be CLI-style. See shouldBypassDnd(). + if (service.doNotDisturb && !shouldBypassDnd(notification)) { + notification.tracked = false + return + } + + // Qt.callLater avoids "QV4::Object::insertMember" crashes when a + // Repeater is mid-incubation while we mutate its model — see noctalia + // NotificationService.qml ~L307. + Qt.callLater(function() { + popupModel.insert(0, snapshot) + }) + } + + function addToPending(snapshot) { + Qt.callLater(function() { + pendingModel.insert(0, snapshot) + while (pendingModel.count > service.historyCap) { + pendingModel.remove(pendingModel.count - 1) + } + scheduleHistorySave() + }) + } + + // Find a pending entry by its libnotify id and move it to pastModel. Called + // when a popup naturally dismisses (timer expired or user clicked X / the + // default action) — the user is assumed to have seen it. + function markSeenByOriginalId(originalId) { + Qt.callLater(function() { + for (var i = 0; i < pendingModel.count; i++) { + var entry = pendingModel.get(i) + if (!entry || entry.originalId !== originalId) continue + var snapshot = service.snapshotFromRow(entry) + pendingModel.remove(i) + pastModel.insert(0, snapshot) + while (pastModel.count > service.historyCap) { + pastModel.remove(pastModel.count - 1) + } + scheduleHistorySave() + return + } + }) + } + + // Copy a ListModel row into a plain JS object so we can re-insert it into + // a different model without sharing references. + function snapshotFromRow(row) { + return { + id: row.id, + originalId: row.originalId, + app: row.app, + appIcon: row.appIcon, + summary: row.summary, + body: row.body, + image: row.image, + glyph: row.glyph || "", + urgency: row.urgency, + timestamp: row.timestamp + } + } + + function markAllSeen() { + Qt.callLater(function() { + while (pendingModel.count > 0) { + var entry = pendingModel.get(0) + var snapshot = service.snapshotFromRow(entry) + pendingModel.remove(0) + pastModel.insert(0, snapshot) + } + while (pastModel.count > service.historyCap) { + pastModel.remove(pastModel.count - 1) + } + scheduleHistorySave() + }) + } + + function dismissPopup(index) { + if (index < 0 || index >= popupModel.count) return + var entry = popupModel.get(index) + var ref = entry ? entry.ref : null + var originalId = entry ? entry.originalId : -1 + popupModel.remove(index) + if (ref) { + try { + if (ref.tracked) ref.dismiss() + } catch (e) { + // Object already torn down by the server — nothing to dismiss. + } + } + // User (or the lifetime timer) saw the popup — archive it. + if (originalId >= 0) markSeenByOriginalId(originalId) + } + + function clearPopups() { + while (popupModel.count > 0) dismissPopup(0) + } + + function dismissPending(index) { + if (index < 0 || index >= pendingModel.count) return + var entry = pendingModel.get(index) + if (entry) maybeDeleteCachedImage(entry.image) + pendingModel.remove(index) + scheduleHistorySave() + } + + function dismissPast(index) { + if (index < 0 || index >= pastModel.count) return + var entry = pastModel.get(index) + if (entry) maybeDeleteCachedImage(entry.image) + pastModel.remove(index) + scheduleHistorySave() + } + + function clearPending() { + for (var i = 0; i < pendingModel.count; i++) { + var entry = pendingModel.get(i) + if (entry) maybeDeleteCachedImage(entry.image) + } + pendingModel.clear() + scheduleHistorySave() + } + + function clearPast() { + for (var i = 0; i < pastModel.count; i++) { + var entry = pastModel.get(i) + if (entry) maybeDeleteCachedImage(entry.image) + } + pastModel.clear() + scheduleHistorySave() + } + + // Invoke the libnotify "default" action on the popup's underlying + // notification, if it has one, then dismiss. Clients register the default + // action with the canonical identifier "default"; e.g. screenshot toasts + // use `notify-send -A default=Edit` so click-the-card opens the editor. + function invokePopupDefault(index) { + if (index < 0 || index >= popupModel.count) return + var entry = popupModel.get(index) + var ref = entry ? entry.ref : null + var invoked = false + if (ref && ref.actions) { + for (var i = 0; i < ref.actions.length; i++) { + var action = ref.actions[i] + if (action && action.identifier === "default") { + try { action.invoke(); invoked = true } catch (e) { console.warn("invoke default failed:", e) } + break + } + } + } + // Chat apps (Slack, Discord, Vesktop, etc.) rarely register a "default" + // libnotify action — they just expect clicking the notification to + // focus their window. Fall back to focusing the sending app by class so + // that click-to-jump actually works. + if (!invoked) focusApp(entry) + dismissPopup(index) + } + + // Try to focus an existing Hyprland window matching the notification's + // sender. We shell out to a small bash one-liner because Hyprland's class + // matcher is regex-based but its case-sensitivity is implementation- + // defined (std::regex doesn't reliably honor `(?i)`). Easier to query the + // client list ourselves and pick the first match. + function focusApp(entry) { + if (!entry || !entry.app) return + var lower = String(entry.app).toLowerCase() + focusAppProc.command = ["bash", "-lc", + "hyprctl clients -j 2>/dev/null | " + + "jq -r --arg name \"" + lower + "\" " + + "'[.[] | select((.class // \"\") | ascii_downcase | startswith($name))] | first.address // empty' | " + + "xargs -r -I{} hyprctl dispatch focuswindow address:{}"] + focusAppProc.running = true + } + + Process { id: focusAppProc; running: false } + + // Open the popup's large image in the user's default image viewer. + // image:// URIs come from the upstream raw-bytes provider and have no + // on-disk path to hand to xdg-open, so we just dismiss in that case. + function openPopupImage(index) { + if (index < 0 || index >= popupModel.count) return + var entry = popupModel.get(index) + if (!entry) return + var image = String(entry.image || "") + if (image.indexOf("file://") === 0) { + var lower = image.toLowerCase() + if (lower.endsWith(".png") || lower.endsWith(".jpg") || + lower.endsWith(".jpeg") || lower.endsWith(".webp")) { + var path = decodeURIComponent(image.substring(7)) + Quickshell.execDetached(["xdg-open", path]) + } + } + dismissPopup(index) + } + + // ---------------------------------------------------- image cache + // + // Notifications coming from screenshot helpers ship an `image-path` hint + // pointing at /tmp/. We want the history thumbnail to outlive that + // file, so we copy it into a long-lived cache dir on ingress and rewrite + // the history row's `image` to point at the cache once cp finishes. + // image:// (raw-bytes) URIs aren't trivially copyable from QML; document + // and skip them for v1. + + function imageExtension(srcPath) { + var lower = srcPath.toLowerCase() + var dot = lower.lastIndexOf(".") + if (dot < 0) return "png" + var ext = lower.substring(dot + 1) + if (ext.length === 0 || ext.length > 5) return "png" + return ext + } + + function maybeCacheImage(snapshot) { + var image = String(snapshot.image || "") + if (!image) return + // image:// URIs are decoded from raw bytes by Quickshell's image provider. + // We can't copy them out from QML, so let history reference them by URI + // and accept that they disappear with the source notification. + if (image.indexOf("image://") === 0) return + if (image.indexOf("file:///tmp/") !== 0) return + + var srcPath = decodeURIComponent(image.substring(7)) + var ext = imageExtension(srcPath) + var destPath = imageCacheDir + snapshot.timestamp + "-" + snapshot.originalId + "." + ext + var destUri = "file://" + destPath + + imageCacheProc.targetUri = destUri + imageCacheProc.matchOriginalId = snapshot.originalId + imageCacheProc.matchTimestamp = snapshot.timestamp + imageCacheProc.command = ["cp", "-f", srcPath, destPath] + imageCacheProc.running = true + } + + function maybeDeleteCachedImage(image) { + var path = String(image || "") + if (!path) return + if (path.indexOf("file://") !== 0) return + var local = decodeURIComponent(path.substring(7)) + if (local.indexOf(imageCacheDir) !== 0) return + deleteImageProc.command = ["rm", "-f", local] + deleteImageProc.running = true + } + + Process { + id: ensureDirsProc + command: ["mkdir", "-p", service.cacheDir, service.imageCacheDir] + running: false + } + + Process { + id: imageCacheProc + property string targetUri: "" + property int matchOriginalId: -1 + property double matchTimestamp: 0 + onExited: function(exitCode) { + if (exitCode !== 0 || !targetUri) return + // Search both models since a notification may have moved to past + // between when we kicked off the copy and when it finished. + function rewrite(model) { + for (var i = 0; i < model.count; i++) { + var row = model.get(i) + if (row && row.originalId === matchOriginalId && row.timestamp === matchTimestamp) { + model.setProperty(i, "image", targetUri) + return true + } + } + return false + } + if (rewrite(pendingModel) || rewrite(pastModel)) scheduleHistorySave() + } + } + + Process { id: deleteImageProc; running: false } + + // ---------------------------------------------------- history persistence + + FileView { + id: historyFile + path: service.historyPath + watchChanges: false + atomicWrites: true + printErrors: false + onLoaded: service.loadHistory(text()) + // First-run: the file doesn't exist yet. Without this branch, + // `historyLoaded` stays false forever and `scheduleHistorySave` becomes + // a no-op — so the file is never created and history vanishes on + // shell restart. + onLoadFailed: service.loadHistory("") + } + + Timer { + id: historySaveTimer + interval: 200 + repeat: false + onTriggered: service.flushHistory() + } + + // Past is a rolling "recently" window. Sweep every minute and drop + // anything older than 15 minutes so the tab doesn't accumulate forever. + readonly property int pastTtlMs: 15 * 60 * 1000 + + Timer { + id: pastPruneTimer + interval: 60 * 1000 + repeat: true + running: true + triggeredOnStart: true + onTriggered: service.prunePast() + } + + function prunePast() { + if (pastModel.count === 0) return + var cutoff = Date.now() - service.pastTtlMs + var removed = false + for (var i = pastModel.count - 1; i >= 0; i--) { + var entry = pastModel.get(i) + if (entry && entry.timestamp && entry.timestamp < cutoff) { + if (entry.image) maybeDeleteCachedImage(entry.image) + pastModel.remove(i) + removed = true + } + } + if (removed) scheduleHistorySave() + } + + function scheduleHistorySave() { + if (!service.historyLoaded) return + historySaveTimer.restart() + } + + property bool historyLoaded: false + + function loadHistory(raw) { + var text = String(raw || "").trim() + if (!text) { service.historyLoaded = true; return } + try { + var parsed = JSON.parse(text) + if (parsed && typeof parsed.dnd === "boolean") { + service._hydrating = true + persisted.doNotDisturb = parsed.dnd + service._hydrating = false + } + var pending = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : [] + var past = (parsed && Array.isArray(parsed.past)) ? parsed.past : [] + // v1 backwards compat: the old schema had a single `entries` array. + // Treat all of those as past since the user already presumably saw + // them (and DND-suppressed notifications from before the split are + // a rare edge case). + if (parsed && Array.isArray(parsed.entries)) past = past.concat(parsed.entries) + + function entryFor(e) { + return { + id: e.id || 0, + originalId: e.originalId || e.id || 0, + app: e.app || "", + appIcon: e.appIcon || "", + summary: e.summary || "", + body: e.body || "", + image: e.image || "", + glyph: e.glyph || "", + urgency: typeof e.urgency === "number" ? e.urgency : NotificationUrgency.Normal, + timestamp: e.timestamp || 0, + ref: null + } + } + // Newest-first on disk; insert in order so models match. + Qt.callLater(function() { + for (var i = 0; i < pending.length; i++) { + if (!pending[i]) continue + pendingModel.append(entryFor(pending[i])) + if (pendingModel.count > service.historyCap) pendingModel.remove(pendingModel.count - 1) + } + for (var j = 0; j < past.length; j++) { + if (!past[j]) continue + pastModel.append(entryFor(past[j])) + if (pastModel.count > service.historyCap) pastModel.remove(pastModel.count - 1) + } + service.historyLoaded = true + }) + } catch (e) { + console.warn("notifications: history parse failed:", e) + service.historyLoaded = true + } + } + + function flushHistory() { + function dump(model) { + var out = [] + for (var i = 0; i < model.count; i++) { + var r = model.get(i) + if (!r) continue + out.push({ + id: r.id, + originalId: r.originalId, + app: r.app, + appIcon: r.appIcon, + summary: r.summary, + body: r.body, + image: r.image, + glyph: r.glyph || "", + urgency: r.urgency, + timestamp: r.timestamp + }) + } + return out + } + var payload = { + version: 2, + dnd: persisted.doNotDisturb, + pending: dump(pendingModel), + past: dump(pastModel) + } + historyFile.setText(JSON.stringify(payload, null, 2) + "\n") + } + + Component.onCompleted: { + ensureDirsProc.running = true + // Once mkdir has had a tick, load the existing history file. FileView + // surfaces an empty string when the file doesn't exist; loadHistory + // handles that path. + Qt.callLater(function() { historyFile.reload() }) + } + + // ---------------------------------------------------- IPC + + IpcHandler { + target: "notifications" + + function dndState(): string { + return service.doNotDisturb ? "on" : "off" + } + + function toggleDnd(): string { + service.setDoNotDisturb(!service.doNotDisturb) + return dndState() + } + + function setDnd(value: string): string { + var v = String(value || "").toLowerCase() + var on = v === "true" || v === "1" || v === "on" || v === "yes" + service.setDoNotDisturb(on) + return dndState() + } + + function isDnd(): string { + return dndState() + } + + function showHistory(): string { + service.historyOpenRequested() + return "ok" + } + + // `clear` empties the past tab (the "I already saw these" bucket). + function clear(): string { + service.clearPast() + return "ok" + } + + function clearPending(): string { + service.clearPending() + return "ok" + } + + function markAllSeen(): string { + service.markAllSeen() + return "ok" + } + + function dismissAll(): string { + service.clearPopups() + service.clearPending() + service.clearPast() + return "ok" + } + + // dismiss the most recent popup; fall back to the most recent pending + // entry, then past, if no popup is currently showing. + function dismissOne(): string { + if (popupModel.count > 0) { + service.dismissPopup(0) + return "ok" + } + if (pendingModel.count > 0) { + service.dismissPending(0) + return "ok" + } + if (pastModel.count > 0) { + service.dismissPast(0) + return "ok" + } + return "none" + } + + // Fire the default action on the most recent popup, then dismiss it. + function invokeLast(): string { + if (popupModel.count === 0) return "none" + service.invokePopupDefault(0) + return "ok" + } + + function dismiss(summary: string): string { + var needle = String(summary || "") + if (!needle) return "none" + var hit = false + function sweep(model, dismissFn) { + for (var i = model.count - 1; i >= 0; i--) { + var row = model.get(i) + if (row && String(row.summary || "").indexOf(needle) !== -1) { + dismissFn(i) + hit = true + } + } + } + sweep(pendingModel, service.dismissPending) + sweep(pastModel, service.dismissPast) + sweep(popupModel, service.dismissPopup) + return hit ? "ok" : "none" + } + + function ping(): string { return "ok" } + } + + // ---------------------------------------------------- server + + NotificationServer { + id: server + keepOnReload: false + imageSupported: true + actionsSupported: true + bodyMarkupSupported: true + bodyHyperlinksSupported: true + persistenceSupported: true + + onNotification: function(notification) { + service.handleNotification(notification) + } + } + + // -------------------------------------------------------------- popup UI + // + // One PanelWindow per output (Variants on Quickshell.screens) holding the + // stacked toast cards. Layer is Overlay, exclusionMode Ignore, no + // keyboard focus — popups are passive surfaces and must never steal input + // from the focused application. + + Variants { + model: Quickshell.screens + + PanelWindow { + id: popupWindow + required property var modelData + screen: modelData + visible: popupModel.count > 0 + + WlrLayershell.namespace: "omarchy-notifications" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + anchors { + top: service.barPosition !== "bottom" + bottom: service.barPosition === "bottom" + left: service.barPosition === "left" + right: service.barPosition !== "left" + } + margins { + top: service.barPosition === "top" ? service.barClearance + 12 : 20 + bottom: service.barPosition === "bottom" ? service.barClearance + 12 : 20 + left: service.barPosition === "left" ? service.barClearance + 12 : 20 + right: service.barPosition === "right" ? service.barClearance + 12 : 20 + } + + implicitWidth: popupColumn.implicitWidth + implicitHeight: popupColumn.implicitHeight + + ColumnLayout { + id: popupColumn + anchors.right: parent.right + anchors.top: parent.top + spacing: 8 + + Repeater { + model: popupModel + + // The delegate is a slot Item that owns lifetime state (timer, + // progress, paused). The actual visuals live in NotificationCard, + // which the history panel also reuses. + delegate: Item { + id: cardSlot + 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 string glyph + required property int urgency + required property double timestamp + + // Each card sizes itself based on mode (text vs media); the slot + // tracks the card so the column auto-fits to whichever is widest. + Layout.preferredWidth: card.implicitWidth + Layout.alignment: Qt.AlignRight + implicitHeight: card.implicitHeight + + readonly property real lifetime: service.durationFor(cardSlot.urgency) + property real progress: 1.0 + readonly property bool ticking: cardSlot.lifetime > 0 && !card.hovered + + Timer { + interval: 50 + repeat: true + running: cardSlot.ticking + onTriggered: { + if (cardSlot.lifetime <= 0) return + cardSlot.progress -= 50.0 / cardSlot.lifetime + if (cardSlot.progress <= 0) { + cardSlot.progress = 0 + service.dismissPopup(cardSlot.index) + } + } + } + + NotificationCard { + id: card + anchors.right: parent.right + app: cardSlot.app + appIcon: cardSlot.appIcon + summary: cardSlot.summary + body: cardSlot.body + image: cardSlot.image + urgency: cardSlot.urgency + timestamp: cardSlot.timestamp + cornerRadius: service.cornerRadius + borderColorOverride: service.effectiveBorder + backgroundColorOverride: service.effectiveBackground + textColorOverride: service.effectiveText + countdownColorOverride: service.effectiveCountdown + fontFamily: service.shell && service.shell.bar ? service.shell.bar.fontFamily : "" + glyph: cardSlot.glyph + progress: cardSlot.progress + showProgress: cardSlot.lifetime > 0 + + onCloseRequested: service.dismissPopup(cardSlot.index) + onCardClicked: service.invokePopupDefault(cardSlot.index) + onImageClicked: service.openPopupImage(cardSlot.index) + } + } + } + } + } + } +} diff --git a/default/quickshell/omarchy-shell/plugins/notifications/components/NotificationCard.qml b/default/quickshell/omarchy-shell/plugins/notifications/components/NotificationCard.qml new file mode 100644 index 00000000..559f2b72 --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/notifications/components/NotificationCard.qml @@ -0,0 +1,257 @@ +// 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//` (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(/]*>/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 + } + } +} diff --git a/default/quickshell/omarchy-shell/plugins/notifications/manifest.json b/default/quickshell/omarchy-shell/plugins/notifications/manifest.json new file mode 100644 index 00000000..9badbb7c --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/notifications/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "omarchy.notifications", + "name": "Notifications", + "version": "1.0.0", + "author": "Omarchy", + "description": "Notification daemon, popups, and history", + "kinds": ["service"], + "activation": "persistent", + "keepLoaded": true, + "entryPoints": { + "service": "Service.qml" + } +} diff --git a/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml b/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml index 83bf8e1a..1a2468df 100644 --- a/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml @@ -385,7 +385,7 @@ Item { "voxtype": { name: "Voxtype", description: "Voxtype dictation state", category: "Status" }, "screenRecording": { name: "Screen recording", description: "Active recording indicator", category: "Status" }, "idle": { name: "Idle (legacy)", description: "Inhibitor indicator", category: "Status" }, - "notifications": { name: "DND (mako)", description: "Notification silencing indicator", category: "Status" }, + "notifications": { name: "DND", description: "Do-not-disturb indicator", category: "Status" }, "tray": { name: "System tray", description: "Status notifier items", category: "Status" }, "bluetooth": { name: "Bluetooth (legacy)", description: "Bluetooth status icon", category: "Network" }, "network": { name: "Network (legacy)", description: "Wi-Fi / ethernet status", category: "Network" }, @@ -1594,8 +1594,8 @@ Item { } Process { id: readDndProc - command: ["bash", "-lc", "makoctl mode 2>/dev/null | grep -q do-not-disturb && echo yes || echo no"] - stdout: SplitParser { onRead: function(line) { dndOn = String(line).trim() === "yes" } } + command: ["bash", "-lc", "omarchy-shell-ipc notifications isDnd 2>/dev/null || echo off"] + stdout: SplitParser { onRead: function(line) { dndOn = String(line).trim() === "on" } } } Process { id: readIdleProc @@ -1694,7 +1694,7 @@ Item { StyleToggleRow { label: "Notifications" - description: dndOn ? "Do-not-disturb is on — Mako is silencing." : "Notifications post normally via Mako." + description: dndOn ? "Do-not-disturb is on — notifications are silenced." : "Notifications post normally." isOn: !dndOn onToggle: runSystem("omarchy-toggle-notification-silencing") } diff --git a/default/quickshell/omarchy-shell/shell.qml b/default/quickshell/omarchy-shell/shell.qml index 76432da8..c5d93c1b 100644 --- a/default/quickshell/omarchy-shell/shell.qml +++ b/default/quickshell/omarchy-shell/shell.qml @@ -158,6 +158,7 @@ ShellRoot { // chains rescan() once the directory exists. We also kick a scan here in // case the user dir already existed at startup. pluginRegistry.rescan() + shell._syncFirstPartyServices() // Wire Noctalia compat singletons. Plugins read state from these globals; // we hand them references to the host so they can route into the right @@ -178,6 +179,10 @@ ShellRoot { persistShellConfig(copy) } + // Exposed as a property so child plugins (notifications, future panels) + // can read barSize/barHidden/position to anchor relative to the bar. + property alias bar: bar + Bar { id: bar omarchyPath: shell.omarchyPath @@ -351,6 +356,94 @@ ShellRoot { function onPluginsChanged() { shell._syncNoctaliaServices() } } + // ---------------------------------------------------- first-party services + // + // Generic loader for any first-party plugin that declares kind "service". + // The notification daemon is the first user; future first-party services + // (background updaters, idle inhibitor, etc.) plug in here. Mirrors + // ensureNoctaliaService but skips the noctalia-specific pluginApi handoff + // and gates on `__isFirstParty` instead of `__noctaliaCompat`. + Item { + id: firstPartyServiceHost + visible: false + } + + property var _firstPartyServices: ({}) + + function firstPartyServiceFor(pluginId) { + return _firstPartyServices[String(pluginId)] || null + } + + function ensureFirstPartyService(pluginId) { + var key = String(pluginId) + if (_firstPartyServices[key]) return _firstPartyServices[key] + var manifest = pluginRegistry && pluginRegistry.installedPlugins + ? pluginRegistry.installedPlugins[key] : null + if (!manifest || !manifest.__isFirstParty) return null + if (manifest.__noctaliaCompat) return null + if (!Array.isArray(manifest.kinds) || manifest.kinds.indexOf("service") === -1) return null + if (!manifest.entryPoints || !manifest.entryPoints.service) return null + var url = pluginRegistry.entryPointUrl(manifest, "service") + if (!url) return null + + var comp = Qt.createComponent(url, Component.PreferSynchronous) + function finalize() { + if (comp.status !== Component.Ready) { + console.warn("first-party service load failed for " + key + ": " + comp.errorString()) + return + } + var inst = comp.createObject(firstPartyServiceHost, { + omarchyPath: shell.omarchyPath, + shell: shell, + manifest: manifest + }) + if (!inst) { + console.warn("first-party service createObject returned null for", key) + return + } + var snext = ({}) + for (var sk in _firstPartyServices) snext[sk] = _firstPartyServices[sk] + snext[key] = inst + _firstPartyServices = snext + } + if (comp.status === Component.Loading) { + comp.statusChanged.connect(finalize) + return null + } + finalize() + return _firstPartyServices[key] || null + } + + function _syncFirstPartyServices() { + if (!pluginRegistry || !pluginRegistry.installedPlugins) return + var plugins = pluginRegistry.installedPlugins + for (var id in plugins) { + var m = plugins[id] + if (!m || !m.__isFirstParty || m.__noctaliaCompat) continue + if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue + if (!m.entryPoints || !m.entryPoints.service) continue + if (!pluginRegistry.isEnabled(id)) continue + if (_firstPartyServices[id]) continue + ensureFirstPartyService(id) + } + // Drop services for plugins that have been disabled or removed. + for (var existingId in _firstPartyServices) { + var stillThere = plugins[existingId] + var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId) + if (stillThere && stillEnabled) continue + var inst = _firstPartyServices[existingId] + if (inst && typeof inst.destroy === "function") inst.destroy() + var next = ({}) + for (var k in _firstPartyServices) if (k !== existingId) next[k] = _firstPartyServices[k] + _firstPartyServices = next + } + } + + Connections { + target: shell.pluginRegistry + function onPluginsChanged() { shell._syncFirstPartyServices() } + } + // Used by Noctalia compat: pluginApi.saveSettings() ends up writing inline // settings to the plugin's entry in shell.json. moduleName is the entry id // (the bare manifest id, e.g. "noctalia.air-quality"); settings is the @@ -564,6 +657,10 @@ ShellRoot { if ("manifest" in item) item.manifest = panelEntry.manifest if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry + // First-party plugins that pair a panel UI with a service entry + // (e.g. omarchy.notifications) read shared state off `service`. We + // hand them the matching service instance if one was loaded. + if ("service" in item) item.service = shell.firstPartyServiceFor(panelEntry.pluginId) // Noctalia panel/overlay/menu plugins expect a pluginApi just like // their bar widgets do. First-party Omarchy plugins don't declare // the property so the `in target` check skips them. diff --git a/default/themed/mako.ini.tpl b/default/themed/mako.ini.tpl deleted file mode 100644 index 061eecd4..00000000 --- a/default/themed/mako.ini.tpl +++ /dev/null @@ -1,6 +0,0 @@ -include=~/.local/share/omarchy/default/mako/core.ini -include=~/.local/state/omarchy/toggles/mako.ini - -text-color={{ foreground }} -border-color={{ accent }} -background-color={{ background }} diff --git a/default/waybar/indicators/notification-silencing.sh b/default/waybar/indicators/notification-silencing.sh index d5a68276..f981b402 100755 --- a/default/waybar/indicators/notification-silencing.sh +++ b/default/waybar/indicators/notification-silencing.sh @@ -1,6 +1,7 @@ #!/bin/bash -if makoctl mode | grep -q 'do-not-disturb'; then +state=$(omarchy-shell-ipc notifications isDnd 2>/dev/null || echo off) +if [[ $state == "on" ]]; then echo '{"text": "󰂛", "tooltip": "Notifications silenced", "class": "active"}' else echo '{"text": ""}' diff --git a/install/config/theme.sh b/install/config/theme.sh index fe7e9112..f276392d 100644 --- a/install/config/theme.sh +++ b/install/config/theme.sh @@ -17,8 +17,5 @@ rm -rf ~/.config/chromium/SingletonLock # otherwise archiso will own the chromiu mkdir -p ~/.config/btop/themes ln -snf ~/.config/omarchy/current/theme/btop.theme ~/.config/btop/themes/current.theme -mkdir -p ~/.config/mako -ln -snf ~/.config/omarchy/current/theme/mako.ini ~/.config/mako/config - # Default Chromium to follow system appearance ("device") instead of dark echo '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | sudo tee /usr/lib/chromium/initial_preferences >/dev/null diff --git a/install/config/toggles.sh b/install/config/toggles.sh index 807c92a8..ace601fa 100644 --- a/install/config/toggles.sh +++ b/install/config/toggles.sh @@ -1,5 +1,4 @@ # Make sure toggles are available mkdir -p ~/.local/state/omarchy/toggles touch ~/.local/state/omarchy/toggles/hyprlock.conf -touch ~/.local/state/omarchy/toggles/mako.ini touch ~/.local/state/omarchy/toggles/walker.css diff --git a/install/first-run/install-voxtype.hook b/install/first-run/install-voxtype.hook index 5513c4e6..8da281b4 100644 --- a/install/first-run/install-voxtype.hook +++ b/install/first-run/install-voxtype.hook @@ -5,4 +5,7 @@ set -e # Remove this hook after use rm -f "$0" -notify-send " Install Dictation with Voxtype" "Click to install voice dictation for Omarchy." -u critical +( + action=$(notify-send " Install Dictation with Voxtype" "Click to install voice dictation for Omarchy." -u critical -A "default=Install") + [[ $action == "default" ]] && omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install +) >/dev/null 2>&1 & diff --git a/install/first-run/welcome.sh b/install/first-run/welcome.sh index 9c5d06af..3680c0e4 100644 --- a/install/first-run/welcome.sh +++ b/install/first-run/welcome.sh @@ -1 +1,4 @@ -notify-send " Learn Keybindings" "Super + K for cheatsheet.\nSuper + Space for application launcher.\nSuper + Alt + Space for Omarchy Menu." -u critical +( + action=$(notify-send " Learn Keybindings" "Super + K for cheatsheet.\nSuper + Space for application launcher.\nSuper + Alt + Space for Omarchy Menu." -u critical -A "default=Open") + [[ $action == "default" ]] && omarchy-menu-keybindings +) >/dev/null 2>&1 & diff --git a/install/first-run/wifi.sh b/install/first-run/wifi.sh index f8d7fc8f..a680b331 100644 --- a/install/first-run/wifi.sh +++ b/install/first-run/wifi.sh @@ -1,6 +1,20 @@ +notify_update() { + ( + action=$(notify-send " Update System" "$1" -u critical -A "default=Update") + [[ $action == "default" ]] && omarchy-launch-floating-terminal-with-presentation omarchy-update + ) >/dev/null 2>&1 & +} + +notify_wifi() { + ( + action=$(notify-send "󰖩 Click to Setup Wi-Fi" "Tab to navigate, Space to select, ? for help." -u critical -A "default=Setup") + [[ $action == "default" ]] && omarchy-launch-wifi + ) >/dev/null 2>&1 & +} + if ! ping -c3 -W1 1.1.1.1 >/dev/null 2>&1; then - notify-send " Update System" "When you have internet, click to update the system." -u critical - notify-send "󰖩 Click to Setup Wi-Fi" "Tab to navigate, Space to select, ? for help." -u critical + notify_update "When you have internet, click to update the system." + notify_wifi else - notify-send " Update System" "Click to update the system." -u critical + notify_update "Click to update the system." fi diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index a84f73b1..770599ea 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -77,7 +77,6 @@ libreoffice-fresh llvm localsend luarocks -mako man-db mariadb-libs mise diff --git a/migrations/1778743515.sh b/migrations/1778743515.sh new file mode 100644 index 00000000..3a7d35a5 --- /dev/null +++ b/migrations/1778743515.sh @@ -0,0 +1,27 @@ +echo "Replace mako with the omarchy-shell notification plugin" + +# Stop any running instance so the new daemon can take the bus name. The +# unit is Type=dbus / BusName=org.freedesktop.Notifications, so until mako +# releases the name our quickshell server can't register. +pkill -x mako 2>/dev/null || true +systemctl --user stop mako.service 2>/dev/null || true + +# Uninstall the package outright. This also deletes +# /usr/lib/systemd/user/mako.service — D-Bus activation has nothing to +# look up once the unit file is gone, so notify-send can never respawn it. +# (`disable` isn't useful here: it only affects boot-time activation, not +# the on-demand D-Bus path that's how mako actually starts.) +if pacman -Qq mako >/dev/null 2>&1; then + sudo pacman -Rns --noconfirm mako >/dev/null 2>&1 || true +fi + +# Old user config dir is no longer read. +[[ -d ~/.config/mako ]] && rm -rf ~/.config/mako + +# Old toggle file is no longer read. +[[ -f ~/.local/state/omarchy/toggles/mako.ini ]] && rm -f ~/.local/state/omarchy/toggles/mako.ini + +# Reload the shell so the new NotificationServer claims the bus name. +if command -v omarchy-restart-quickshell >/dev/null 2>&1; then + omarchy-restart-quickshell >/dev/null 2>&1 || true +fi