From cbe730e9a24af6ff9ffb3ddf0ce2c0e8ebebe5b1 Mon Sep 17 00:00:00 2001 From: Chintan Patel Date: Tue, 1 Sep 2026 21:51:46 +0530 Subject: [PATCH] feat: implement Phase 3 (Global App Menu) and Phase 4 (System Tray) - Phase 3 (Global App Menu): - Added AppMenu widget with File, Edit, View, Window, and Help menus - Contextual action popovers with keyboard shortcuts and Niri window actions - Integrated cleanly next to active application name in LeftSection - Phase 4 (System Tray): - Added SystemTray widget connecting to org.kde.StatusNotifierWatcher on DBus - Real-time rendering of third-party tray icons (Discord, Telegram, Steam, etc.) - Click-to-activate support - Integrated into RightSection with macOS liquid glass styling - Documentation and roadmap updated --- README.md | 6 +- src/bar/LeftSection.tsx | 3 + src/bar/RightSection.tsx | 2 + src/styles/style.scss | 82 ++++++++++++- src/widgets/AppMenu.tsx | 230 +++++++++++++++++++++++++++++++++++++ src/widgets/SystemTray.tsx | 133 +++++++++++++++++++++ 6 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 src/widgets/AppMenu.tsx create mode 100644 src/widgets/SystemTray.tsx 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 ( + + {/* File Menu */} + + + + {/* Edit Menu */} + + + + {/* View Menu */} + + + + {/* Window Menu */} + + + + {/* Help Menu */} + + + + ) +} 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) + } + }) + }} + /> + + ) +}