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.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
) &
|
||||
|
||||
@@ -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 &
|
||||
;;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -33,4 +33,4 @@ cat >~/.config/xdg-terminals.list <<EOF
|
||||
$desktop_id
|
||||
EOF
|
||||
|
||||
notify-send -u low "$glyph $name is now the default terminal"
|
||||
notify-send -a omarchy-action -u low "$glyph $name is now the default terminal"
|
||||
|
||||
@@ -55,11 +55,11 @@ if [[ -n $font_name ]]; then
|
||||
omarchy-restart-swayosd
|
||||
|
||||
if pgrep -x ghostty; then
|
||||
notify-send -u low " You must restart Ghostty to see font change"
|
||||
notify-send -a omarchy-action -u low " You must restart Ghostty to see font change"
|
||||
fi
|
||||
|
||||
if pgrep -x foot; then
|
||||
notify-send -u low " You must restart Foot to see font change"
|
||||
notify-send -a omarchy-action -u low " You must restart Foot to see font change"
|
||||
fi
|
||||
|
||||
omarchy-hook font-set "$font_name"
|
||||
|
||||
@@ -42,4 +42,4 @@ elif [[ -f $MONITOR_LUA ]] && grep -Eq '^hl\.monitor\(\{ output = "", mode = "pr
|
||||
"$MONITOR_LUA"
|
||||
fi
|
||||
|
||||
notify-send -u low " Display scaling set to ${NEW_SCALE}x"
|
||||
notify-send -a omarchy-action -u low " Display scaling set to ${NEW_SCALE}x"
|
||||
|
||||
@@ -42,7 +42,7 @@ on() {
|
||||
if [[ -f $FLAG_SOURCE ]]; then
|
||||
mkdir -p "$(dirname "$FLAG_FILE")"
|
||||
cp "$FLAG_SOURCE" "$FLAG_FILE"
|
||||
[[ -n $ENABLED_NOTIFICATION ]] && notify-send -u low "$ENABLED_NOTIFICATION"
|
||||
[[ -n $ENABLED_NOTIFICATION ]] && notify-send -a omarchy-action -u low "$ENABLED_NOTIFICATION"
|
||||
else
|
||||
echo "Flag not found: $FLAG_NAME" >&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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<summary>
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Reload mako configuration (used by the Omarchy theme switching).
|
||||
|
||||
makoctl reload
|
||||
@@ -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=<sharp|round>
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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=<b>%s</b>\n%b
|
||||
@@ -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/<terminal>/` |
|
||||
| **Mako** | Notifications | `~/.config/mako/` |
|
||||
| **SwayOSD** | On-screen display | `~/.config/swayosd/` |
|
||||
|
||||
## Command Discovery
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/<img[^>]*>/gi, "")
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
textFormat: Text.PlainText
|
||||
color: root.colDim
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 2
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <sharp|round>` 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/<file>. 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+257
@@ -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//<absolute path>` (double slash
|
||||
// marks an absolute filesystem path vs a themed icon name like
|
||||
// `image://icon/firefox`).
|
||||
function _imageFilePath(s) {
|
||||
if (!s) return ""
|
||||
if (s.indexOf("image://icon//") === 0) return s.substring("image://icon/".length)
|
||||
if (s.indexOf("file://") === 0) return decodeURIComponent(s.substring(7))
|
||||
return ""
|
||||
}
|
||||
function _isMediaFile(path) {
|
||||
if (!path) return false
|
||||
var lower = path.toLowerCase()
|
||||
return lower.endsWith(".png") || lower.endsWith(".jpg") ||
|
||||
lower.endsWith(".jpeg") || lower.endsWith(".webp") ||
|
||||
lower.endsWith(".gif")
|
||||
}
|
||||
readonly property string mediaImageSource: {
|
||||
if (_isMediaFile(_imageFilePath(image))) return image
|
||||
if (_isMediaFile(_imageFilePath(appIcon))) return appIcon
|
||||
return ""
|
||||
}
|
||||
readonly property bool mediaMode: mediaImageSource.length > 0
|
||||
// Use only what the notification explicitly carries — no themed-icon
|
||||
// theme-lookup fallback because Quickshell's icon image provider returns
|
||||
// a placeholder for missing names (rather than erroring), which means
|
||||
// we'd render Qt's pink "broken image" pattern for any unknown app.
|
||||
// Apps that send their own icon via `image` (image-data hint) or
|
||||
// `appIcon` (-i flag) still get one.
|
||||
readonly property string smallIconSource: image.length > 0 ? image : appIcon
|
||||
readonly property bool hasGlyph: glyph.length > 0
|
||||
readonly property bool hasSmallIcon: !mediaMode && (smallIconSource.length > 0 || hasGlyph)
|
||||
|
||||
readonly property color dimColor: Qt.darker(textColorOverride, 1.4)
|
||||
readonly property color bodyColor: Qt.darker(textColorOverride, 1.15)
|
||||
readonly property color hoverColor: Qt.rgba(textColorOverride.r, textColorOverride.g, textColorOverride.b, 0.14)
|
||||
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : countdownColorOverride)
|
||||
|
||||
function sanitizeBody(s) {
|
||||
return String(s).replace(/<img[^>]*>/gi, "")
|
||||
}
|
||||
|
||||
implicitWidth: 380
|
||||
// Add 2 * border.width so mainColumn (inset by border.width on top/left/right)
|
||||
// doesn't push content under the bottom edge. The bottom edge is also inset
|
||||
// for symmetry except when the progress bar replaces it.
|
||||
implicitHeight: mainColumn.implicitHeight + border.width * 2 + (showProgress ? 3 : 0)
|
||||
radius: cornerRadius
|
||||
color: backgroundColorOverride
|
||||
border.color: urgency === 2 ? Color.urgent : borderColorOverride
|
||||
border.width: 2
|
||||
clip: true
|
||||
|
||||
HoverHandler { id: hoverTracker }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.cardClicked()
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: mainColumn
|
||||
// Inset by the card border so the hero image (and the text row) don't
|
||||
// paint over the card's outer border. Without this the left/right/top
|
||||
// border is invisible under the image.
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: root.border.width
|
||||
anchors.leftMargin: root.border.width
|
||||
anchors.rightMargin: root.border.width
|
||||
spacing: 0
|
||||
|
||||
// Hero image strip (media notifications only). PreserveAspectCrop so
|
||||
// the preview looks like a clean banner without dark letterboxing.
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 140
|
||||
visible: root.mediaMode
|
||||
clip: true
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: root.mediaImageSource
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
sourceSize.width: width > 0 ? width * Screen.devicePixelRatio : 0
|
||||
sourceSize.height: height > 0 ? height * Screen.devicePixelRatio : 0
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
cache: false
|
||||
}
|
||||
|
||||
// Bottom divider matching the card border so the screenshot is
|
||||
// visually framed on every side (card border wraps top/left/right;
|
||||
// this line completes the bottom).
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: root.border.width
|
||||
color: root.urgency === 2 ? Color.urgent : root.borderColorOverride
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.imageClicked()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Text content. Always rendered — for media notifications this carries
|
||||
// the summary/body ("Screenshot saved" etc) under the hero image.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 12
|
||||
Layout.rightMargin: 12
|
||||
Layout.topMargin: 10
|
||||
Layout.bottomMargin: 10
|
||||
spacing: 10
|
||||
|
||||
Item {
|
||||
id: smallIconSlot
|
||||
Layout.preferredWidth: 32
|
||||
Layout.preferredHeight: 32
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
// Hide the slot when the icon failed to resolve (themed-icon name
|
||||
// not in the user's icon theme) AND we don't have a glyph fallback
|
||||
// — prevents rendering Qt's pink broken-image placeholder.
|
||||
visible: root.hasSmallIcon && (root.hasGlyph || smallIconImage.status !== Image.Error)
|
||||
|
||||
Image {
|
||||
id: smallIconImage
|
||||
anchors.fill: parent
|
||||
source: root.smallIconSource
|
||||
sourceSize.width: 32 * Screen.devicePixelRatio
|
||||
sourceSize.height: 32 * Screen.devicePixelRatio
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
visible: !root.hasGlyph || smallIconImage.status === Image.Ready
|
||||
}
|
||||
|
||||
// Glyph fallback (Nerd Font character) when no image icon is
|
||||
// available. Used by omarchy-notification-send's `-g` flag.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.hasGlyph && smallIconImage.status !== Image.Ready
|
||||
text: root.glyph
|
||||
color: root.textColorOverride
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 18
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.summary.length > 0
|
||||
text: root.summary
|
||||
font.family: root.fontFamily
|
||||
color: root.textColorOverride
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 2
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 2
|
||||
visible: root.body.length > 0
|
||||
text: root.sanitizeBody(root.body)
|
||||
textFormat: Text.StyledText
|
||||
font.family: root.fontFamily
|
||||
color: root.bodyColor
|
||||
font.pixelSize: 12
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar at the bottom edge. Stays visible while the container has
|
||||
// a finite lifetime; freezes (doesn't decrement) when hover pauses the
|
||||
// tick from the container side.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 3
|
||||
color: root.borderColorOverride
|
||||
visible: root.showProgress
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * Math.max(0, Math.min(1, root.progress))
|
||||
color: root.accentColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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": ""}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,7 +77,6 @@ libreoffice-fresh
|
||||
llvm
|
||||
localsend
|
||||
luarocks
|
||||
mako
|
||||
man-db
|
||||
mariadb-libs
|
||||
mise
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user