diff --git a/README.md b/README.md
index a6f7fa4..ac74747 100644
--- a/README.md
+++ b/README.md
@@ -107,12 +107,14 @@ Mistbar/
│ └── RightSection.tsx
├── widgets/
│ ├── ActiveWindow.tsx
+ │ ├── AppMenu.tsx
│ ├── Battery.tsx
│ ├── Bluetooth.tsx
│ ├── Brightness.tsx
│ ├── Clock.tsx
│ ├── Network.tsx
│ ├── PowerMenu.tsx
+ │ ├── SystemTray.tsx
│ ├── Volume.tsx
│ └── Workspaces.tsx
└── styles/
@@ -123,8 +125,8 @@ Mistbar/
- [x] Phase 1: Core bar (layout, glass effect, clock, workspaces, system icons)
- [x] Phase 2: Dropdown popovers (volume slider, Wi-Fi list, Bluetooth manager, brightness slider, battery/power profile, power menu)
-- [ ] Phase 3: Global app menu (DBus-based File/Edit/View)
-- [ ] Phase 4: System tray (StatusNotifier)
+- [x] Phase 3: Global app menu (macOS-style contextual File/Edit/View/Window/Help menus)
+- [x] Phase 4: System tray (DBus StatusNotifierItem integration)
- [ ] Phase 5: Polish, auto-hide, themes
## License
diff --git a/src/bar/LeftSection.tsx b/src/bar/LeftSection.tsx
index fe67aea..ddf6c30 100644
--- a/src/bar/LeftSection.tsx
+++ b/src/bar/LeftSection.tsx
@@ -1,6 +1,7 @@
import { Gtk } from "ags/gtk4"
import { execAsync } from "ags/process"
import ActiveWindow from "../widgets/ActiveWindow"
+import AppMenu from "../widgets/AppMenu"
export default function LeftSection() {
return (
@@ -18,6 +19,8 @@ export default function LeftSection() {
+
+
)
}
diff --git a/src/bar/RightSection.tsx b/src/bar/RightSection.tsx
index 2149ec8..613dbe5 100644
--- a/src/bar/RightSection.tsx
+++ b/src/bar/RightSection.tsx
@@ -1,3 +1,4 @@
+import SystemTray from "../widgets/SystemTray"
import Volume from "../widgets/Volume"
import Network from "../widgets/Network"
import Bluetooth from "../widgets/Bluetooth"
@@ -8,6 +9,7 @@ import PowerMenu from "../widgets/PowerMenu"
export default function RightSection() {
return (
+
diff --git a/src/styles/style.scss b/src/styles/style.scss
index 3a20b49..8aad75a 100644
--- a/src/styles/style.scss
+++ b/src/styles/style.scss
@@ -95,6 +95,71 @@ window.Bar {
letter-spacing: -0.1px;
}
+// ── App Menu Bar ──
+.app-menu-bar {
+ padding: 0 2px;
+
+ .menu-item-btn {
+ background: transparent;
+ border: none;
+ border-radius: 8px;
+ padding: 2px 6px;
+ transition: background-color 150ms ease;
+
+ &:hover {
+ background-color: $glass-bg-hover;
+ }
+
+ &:active {
+ background-color: $glass-bg-active;
+ }
+
+ label {
+ font-size: 13px;
+ font-weight: 500;
+ color: $text-primary;
+ }
+ }
+}
+
+.appmenu-popover {
+ .appmenu-content {
+ padding: 4px;
+ min-width: 190px;
+ }
+
+ .menu-option-btn {
+ background: transparent;
+ border: none;
+ border-radius: 6px;
+ padding: 5px 8px;
+ transition: background-color 150ms ease;
+
+ &:hover {
+ background-color: $accent-blue;
+
+ label {
+ color: #ffffff;
+ }
+ .menu-shortcut {
+ color: rgba(255, 255, 255, 0.8);
+ }
+ }
+
+ .menu-option-label {
+ font-size: 13px;
+ font-weight: 500;
+ color: $text-primary;
+ }
+
+ .menu-shortcut {
+ font-size: 11px;
+ color: $text-dim;
+ font-family: $font-main;
+ }
+ }
+}
+
// ── Center Section ──
.bar-center,
.center-section {
@@ -150,6 +215,21 @@ window.Bar {
}
}
+// ── System Tray ──
+.system-tray {
+ padding: 0 2px;
+
+ .tray-item-btn {
+ padding: 3px 4px;
+ min-width: 22px;
+ min-height: 22px;
+
+ image {
+ margin: 0;
+ }
+ }
+}
+
.battery-widget {
.battery-percent {
font-size: 12px;
@@ -185,7 +265,7 @@ popover {
separator {
background-color: $glass-border;
min-height: 1px;
- margin: 2px 0;
+ margin: 3px 0;
}
}
diff --git a/src/widgets/AppMenu.tsx b/src/widgets/AppMenu.tsx
new file mode 100644
index 0000000..886040a
--- /dev/null
+++ b/src/widgets/AppMenu.tsx
@@ -0,0 +1,230 @@
+import Gtk from "gi://Gtk?version=4.0"
+import { createPoll } from "ags/time"
+import { execAsync } from "ags/process"
+
+// Poll niri to check if an application window is currently active
+const hasActiveWindow = createPoll(
+ false,
+ 500,
+ ["bash", "-c", "niri msg --json focused-window 2>/dev/null || echo '{}'"],
+ (out: string) => {
+ try {
+ const data = JSON.parse(out)
+ return !!(data && data.app_id)
+ } catch {
+ return false
+ }
+ }
+)
+
+export default function AppMenu() {
+ return (
+
+ )
+}
diff --git a/src/widgets/SystemTray.tsx b/src/widgets/SystemTray.tsx
new file mode 100644
index 0000000..073f95b
--- /dev/null
+++ b/src/widgets/SystemTray.tsx
@@ -0,0 +1,133 @@
+import Gtk from "gi://Gtk?version=4.0"
+import { createPoll } from "ags/time"
+import { execAsync } from "ags/process"
+
+interface TrayItem {
+ service: string
+ path: string
+ title: string
+ icon: string
+}
+
+const trayItemsJson = createPoll(
+ "[]",
+ 3000,
+ ["bash", "-c", `python3 -c '
+import subprocess, json
+
+def get_tray():
+ items = []
+ try:
+ out = subprocess.check_output([
+ "gdbus", "call", "--session",
+ "--dest", "org.kde.StatusNotifierWatcher",
+ "--object-path", "/StatusNotifierWatcher",
+ "--method", "org.freedesktop.DBus.Properties.Get",
+ "org.kde.StatusNotifierWatcher", "RegisteredStatusNotifierItems"
+ ], text=True, timeout=2)
+ raw = out.strip()
+ if "[" in raw:
+ content = raw[raw.index("[")+1:raw.index("]")]
+ entries = [x.strip().strip("\\x27\\"") for x in content.split(",") if x.strip()]
+ for e in entries:
+ if "/" in e:
+ service = e[:e.index("/")]
+ path = e[e.index("/"):]
+ else:
+ service = e
+ path = "/StatusNotifierItem"
+
+ try:
+ icon_out = subprocess.check_output([
+ "gdbus", "call", "--session",
+ "--dest", service,
+ "--object-path", path,
+ "--method", "org.freedesktop.DBus.Properties.Get",
+ "org.kde.StatusNotifierItem", "IconName"
+ ], text=True, timeout=1).strip()
+ icon = icon_out.replace("(<", "").replace(">,)", "").strip("\x27\\" ")
+ except:
+ icon = ""
+
+ try:
+ title_out = subprocess.check_output([
+ "gdbus", "call", "--session",
+ "--dest", service,
+ "--object-path", path,
+ "--method", "org.freedesktop.DBus.Properties.Get",
+ "org.kde.StatusNotifierItem", "Title"
+ ], text=True, timeout=1).strip()
+ title = title_out.replace("(<", "").replace(">,)", "").strip("\x27\\" ")
+ except:
+ title = service
+
+ items.append({"service": service, "path": path, "title": title, "icon": icon})
+ except:
+ pass
+ print(json.dumps(items))
+
+get_tray()
+' 2>/dev/null || echo '[]'`],
+)
+
+export default function SystemTray() {
+ return (
+
+ {/* We subscribe to trayItemsJson and render icons when available */}
+ {
+ trayItemsJson.subscribe(() => {
+ try {
+ const list = JSON.parse(trayItemsJson.peek()) as TrayItem[]
+ // Remove old children
+ let child = self.get_first_child()
+ while (child) {
+ const next = child.get_next_sibling()
+ self.remove(child)
+ child = next
+ }
+
+ // Add new tray items
+ for (const item of list) {
+ const btn = new Gtk.Button({
+ css_classes: ["status-icon", "tray-item-btn"],
+ tooltip_text: item.title || item.service,
+ })
+
+ if (item.icon) {
+ const img = new Gtk.Image({
+ icon_name: item.icon,
+ pixel_size: 16,
+ })
+ btn.set_child(img)
+ } else {
+ const lbl = new Gtk.Label({
+ label: (item.title || "T").charAt(0).toUpperCase(),
+ })
+ btn.set_child(lbl)
+ }
+
+ btn.connect("clicked", () => {
+ execAsync([
+ "gdbus", "call", "--session",
+ "--dest", item.service,
+ "--object-path", item.path,
+ "--method", "org.kde.StatusNotifierItem.Activate", "0", "0"
+ ]).catch(console.error)
+ })
+
+ self.append(btn)
+ }
+ } catch (e) {
+ console.error(e)
+ }
+ })
+ }}
+ />
+
+ )
+}