Skip to content

Commit 5f7ce5e

Browse files
authored
Merge pull request #887 from amnweb/refactor/tray-menu
refactor(tray): replace QMenu with native Win32 popup menu
2 parents 884c5e7 + f05952d commit 5f7ce5e

2 files changed

Lines changed: 122 additions & 142 deletions

File tree

src/core/tray.py

Lines changed: 116 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
1+
import ctypes
12
import logging
23
import os
34
import shutil
45
import subprocess
56
import threading
67

7-
from PyQt6.QtCore import QEvent, QSize, Qt, pyqtSignal
8-
from PyQt6.QtGui import QColor, QCursor, QIcon, QPainter, QPixmap
9-
from PyQt6.QtWidgets import QMenu, QSystemTrayIcon
8+
import win32con
9+
import win32gui
10+
from PyQt6.QtCore import QSize, Qt, pyqtSignal
11+
from PyQt6.QtGui import QColor, QIcon, QPainter, QPixmap
12+
from PyQt6.QtWidgets import QSystemTrayIcon
1013

1114
from core.bar_manager import BarManager
1215
from core.ui.views.about import AboutDialog
1316
from core.utils.controller import exit_application, reload_application
1417
from core.utils.shell_utils import shell_open
1518
from core.utils.update_service import register_update_callback
16-
from core.utils.win32.utils import apply_qmenu_style, disable_autostart, enable_autostart, is_autostart_enabled
19+
from core.utils.win32.utils import disable_autostart, enable_autostart, is_autostart_enabled
1720
from settings import (
1821
APP_NAME,
1922
DEFAULT_CONFIG_DIRECTORY,
@@ -42,35 +45,26 @@ def __init__(self, bar_manager: BarManager):
4245
self._update_signal.connect(self._set_update_badge)
4346
register_update_callback(self._update_signal.emit)
4447

45-
def eventFilter(self, obj, event):
46-
if event.type() == QEvent.Type.MouseButtonPress:
47-
if self.menu and self.menu.isVisible():
48-
global_pos = event.globalPosition().toPoint()
49-
all_menus = [self.menu]
50-
all_menus += [act.menu() for act in self.menu.actions() if act.menu() and act.menu().isVisible()]
51-
if not any(m.geometry().contains(global_pos) for m in all_menus):
52-
self.menu.hide()
53-
self.menu.deleteLater()
54-
return True
55-
return super().eventFilter(obj, event)
56-
5748
def _on_tray_activated(self, reason):
5849
if reason == QSystemTrayIcon.ActivationReason.Context:
59-
self._load_context_menu()
60-
self.menu.popup(QCursor.pos())
61-
self.menu.activateWindow()
50+
self._show_context_menu()
6251

6352
def _load_config(self):
6453
config = self._bar_manager.config
54+
self.komorebi_enabled = False
55+
self.glazewm_enabled = False
56+
6557
if config and config.komorebi:
6658
self.komorebi_start = config.komorebi.start_command
6759
self.komorebi_stop = config.komorebi.stop_command
6860
self.komorebi_reload = config.komorebi.reload_command
61+
self.komorebi_enabled = any([self.komorebi_start, self.komorebi_stop, self.komorebi_reload])
6962

7063
if config and config.glazewm:
7164
self.glazewm_start = config.glazewm.start_command
7265
self.glazewm_stop = config.glazewm.stop_command
7366
self.glazewm_reload = config.glazewm.reload_command
67+
self.glazewm_enabled = any([self.glazewm_start, self.glazewm_stop, self.glazewm_reload])
7468

7569
def _load_favicon(self):
7670
# Get the current directory of the script
@@ -89,120 +83,109 @@ def _set_update_badge(self, release_info=None):
8983
painter.end()
9084
self.setIcon(QIcon(base))
9185
self.setToolTip("Update available")
92-
self._update_available = True
93-
94-
def _load_context_menu(self):
95-
self.menu = QMenu()
96-
self.menu.setWindowModality(Qt.WindowModality.WindowModal)
97-
apply_qmenu_style(self.menu)
98-
style_sheet = """
99-
QMenu {
100-
background-color: #202020;
101-
color: #ffffff;
102-
border:1px solid #303030;
103-
padding:5px 0;
104-
margin:0;
105-
border-radius:8px
106-
}
107-
QMenu::item {
108-
margin:0 4px;
109-
padding: 4px 24px 5px 24px;
110-
border-radius: 4px;
111-
font-size: 11px;
112-
font-weight: 600;
113-
font-family: 'Segoe UI';
114-
}
115-
QMenu::item:selected {
116-
background-color: #333333;
117-
}
118-
QMenu::separator {
119-
height: 1px;
120-
background: #404040;
121-
margin: 4px 8px;
122-
}
123-
QMenu::right-arrow {
124-
width: 8px;
125-
height: 8px;
126-
padding-right:24px;
127-
}
128-
"""
129-
self.menu.setStyleSheet(style_sheet)
130-
131-
if self._update_available:
132-
update_action = self.menu.addAction("Update Available")
133-
update_action.triggered.connect(self._open_update_dialog)
134-
self.menu.addSeparator()
135-
136-
open_config_action = self.menu.addAction("Open Config")
137-
open_config_action.triggered.connect(self._open_config)
138-
if os.path.exists(THEME_EXE_PATH):
139-
yasb_themes_action = self.menu.addAction("Get Themes")
140-
yasb_themes_action.triggered.connect(lambda: os.startfile(THEME_EXE_PATH))
141-
142-
reload_action = self.menu.addAction("Reload YASB")
143-
reload_action.triggered.connect(self._reload_application)
144-
self.reload_action = reload_action
145-
146-
self.menu.addSeparator()
147-
if self.is_wm_installed("komorebi"):
148-
komorebi_menu = self.menu.addMenu("Komorebi")
149-
start_komorebi = komorebi_menu.addAction("Start Komorebi")
150-
start_komorebi.triggered.connect(
151-
lambda checked=False, wm="Komorebi", cmd=self.komorebi_start: self._run_wm_command(wm, cmd)
152-
)
153-
154-
stop_komorebi = komorebi_menu.addAction("Stop Komorebi")
155-
stop_komorebi.triggered.connect(
156-
lambda checked=False, wm="Komorebi", cmd=self.komorebi_stop: self._run_wm_command(wm, cmd)
157-
)
15886

159-
reload_komorebi = komorebi_menu.addAction("Reload Komorebi")
160-
reload_komorebi.triggered.connect(
161-
lambda checked=False, wm="Komorebi", cmd=self.komorebi_reload: self._run_wm_command(wm, cmd)
162-
)
163-
164-
apply_qmenu_style(komorebi_menu)
165-
166-
self.menu.addSeparator()
167-
168-
if self.is_wm_installed("glazewm"):
169-
glazewm_menu = self.menu.addMenu("Glazewm")
170-
start_glazewm = glazewm_menu.addAction("Start Glazewm")
171-
start_glazewm.triggered.connect(
172-
lambda checked=False, wm="Glazewm", cmd=self.glazewm_start: self._run_wm_command(wm, cmd)
173-
)
174-
175-
stop_glazewm = glazewm_menu.addAction("Stop Glazewm")
176-
stop_glazewm.triggered.connect(
177-
lambda checked=False, wm="Glazewm", cmd=self.glazewm_stop: self._run_wm_command(wm, cmd)
178-
)
179-
180-
reload_glazewm = glazewm_menu.addAction("Reload Glazewm")
181-
reload_glazewm.triggered.connect(
182-
lambda checked=False, wm="Glazewm", cmd=self.glazewm_reload: self._run_wm_command(wm, cmd)
87+
def _try_enable_dark_menu(self, hwnd):
88+
try:
89+
uxtheme = ctypes.WinDLL("uxtheme.dll")
90+
uxtheme[135](1) # undocumented SetPreferredAppMode(AllowDark)
91+
uxtheme[133](hwnd, True) # undocumented AllowDarkModeForWindow
92+
uxtheme[136]() # undocumented FlushMenuThemes
93+
except Exception:
94+
logging.debug("Native dark tray menu unavailable", exc_info=True)
95+
96+
def _show_context_menu(self):
97+
"""Builds and shows the system tray context menu with dynamic options based on configuration and state."""
98+
hmenu = None
99+
selected_action = None
100+
try:
101+
hmenu = win32gui.CreatePopupMenu()
102+
actions = {}
103+
cmd_id = [1]
104+
105+
def add_item(menu, label, action):
106+
win32gui.AppendMenu(menu, win32con.MF_STRING, cmd_id[0], label)
107+
actions[cmd_id[0]] = action
108+
cmd_id[0] += 1
109+
110+
def add_sep(menu):
111+
win32gui.AppendMenu(menu, win32con.MF_SEPARATOR, 0, "")
112+
113+
if self._update_available:
114+
add_item(hmenu, "Update Available", self._open_update_dialog)
115+
add_sep(hmenu)
116+
117+
add_item(hmenu, "Open Config", self._open_config)
118+
if os.path.exists(THEME_EXE_PATH):
119+
add_item(hmenu, "Get Themes", lambda: os.startfile(THEME_EXE_PATH))
120+
add_item(hmenu, "Reload YASB", self._reload_application)
121+
add_sep(hmenu)
122+
123+
if self.komorebi_enabled and self.is_wm_installed("komorebi"):
124+
km_sub = win32gui.CreatePopupMenu()
125+
if self.komorebi_start:
126+
add_item(km_sub, "Start Komorebi", lambda: self._run_wm_command("Komorebi", self.komorebi_start))
127+
if self.komorebi_stop:
128+
add_item(km_sub, "Stop Komorebi", lambda: self._run_wm_command("Komorebi", self.komorebi_stop))
129+
if self.komorebi_reload:
130+
add_item(km_sub, "Reload Komorebi", lambda: self._run_wm_command("Komorebi", self.komorebi_reload))
131+
win32gui.AppendMenu(hmenu, win32con.MF_POPUP, km_sub, "Komorebi")
132+
add_sep(hmenu)
133+
134+
if self.glazewm_enabled and self.is_wm_installed("glazewm"):
135+
gw_sub = win32gui.CreatePopupMenu()
136+
if self.glazewm_start:
137+
add_item(gw_sub, "Start GlazeWM", lambda: self._run_wm_command("Glazewm", self.glazewm_start))
138+
if self.glazewm_stop:
139+
add_item(gw_sub, "Stop GlazeWM", lambda: self._run_wm_command("Glazewm", self.glazewm_stop))
140+
if self.glazewm_reload:
141+
add_item(gw_sub, "Reload GlazeWM", lambda: self._run_wm_command("Glazewm", self.glazewm_reload))
142+
win32gui.AppendMenu(hmenu, win32con.MF_POPUP, gw_sub, "GlazeWM")
143+
add_sep(hmenu)
144+
145+
if AUTOSTART_FILE:
146+
if self._check_startup():
147+
add_item(hmenu, "Disable Autostart", self._disable_startup)
148+
else:
149+
add_item(hmenu, "Enable Autostart", self._enable_startup)
150+
151+
add_item(hmenu, "Help", lambda: self._open_in_browser(GITHUB_WIKI_URL))
152+
add_item(hmenu, "About", self._show_about_dialog)
153+
add_sep(hmenu)
154+
add_item(hmenu, "Exit", self._exit_application)
155+
156+
bars = self._bar_manager.bars
157+
hwnd = int(bars[0].winId()) if bars else win32gui.GetDesktopWindow()
158+
x, y = win32gui.GetCursorPos()
159+
self._try_enable_dark_menu(hwnd)
160+
try:
161+
win32gui.SetForegroundWindow(hwnd)
162+
except Exception:
163+
logging.debug("Failed to set tray menu owner as foreground window", exc_info=True)
164+
cmd = win32gui.TrackPopupMenu(
165+
hmenu,
166+
win32con.TPM_LEFTALIGN | win32con.TPM_RETURNCMD | win32con.TPM_NONOTIFY,
167+
x,
168+
y,
169+
0,
170+
hwnd,
171+
None,
183172
)
184-
185-
apply_qmenu_style(glazewm_menu)
186-
187-
self.menu.addSeparator()
188-
189-
if AUTOSTART_FILE:
190-
if self._chek_startup():
191-
disable_startup_action = self.menu.addAction("Disable Autostart")
192-
disable_startup_action.triggered.connect(self._disable_startup)
193-
else:
194-
enable_startup_action = self.menu.addAction("Enable Autostart")
195-
enable_startup_action.triggered.connect(self._enable_startup)
196-
197-
help_action = self.menu.addAction("Help")
198-
help_action.triggered.connect(lambda: self._open_in_browser(GITHUB_WIKI_URL))
199-
200-
about_action = self.menu.addAction("About")
201-
about_action.triggered.connect(self._show_about_dialog)
202-
203-
self.menu.addSeparator()
204-
exit_action = self.menu.addAction("Exit")
205-
exit_action.triggered.connect(self._exit_application)
173+
selected_action = actions.get(cmd)
174+
try:
175+
win32gui.PostMessage(hwnd, win32con.WM_NULL, 0, 0)
176+
except Exception:
177+
logging.debug("Failed to post tray menu cleanup message", exc_info=True)
178+
except Exception:
179+
logging.exception("Failed to show context menu")
180+
finally:
181+
if hmenu:
182+
win32gui.DestroyMenu(hmenu)
183+
184+
if selected_action:
185+
try:
186+
selected_action()
187+
except Exception:
188+
logging.exception("Tray menu action failed")
206189

207190
def is_wm_installed(self, wm) -> bool:
208191
try:
@@ -218,11 +201,8 @@ def _enable_startup(self):
218201
def _disable_startup(self):
219202
disable_autostart(APP_NAME)
220203

221-
def _chek_startup(self):
222-
if is_autostart_enabled(APP_NAME):
223-
return True
224-
else:
225-
return False
204+
def _check_startup(self) -> bool:
205+
return bool(is_autostart_enabled(APP_NAME))
226206

227207
def _open_config(self):
228208
try:

src/core/validation/config.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55

66

77
class KomorebiConfig(CustomBaseModel):
8-
start_command: str | None = "komorebic start --whkd"
9-
stop_command: str | None = "komorebic stop --whkd"
10-
reload_command: str | None = "komorebic reload-configuration"
8+
start_command: str | None = None
9+
stop_command: str | None = None
10+
reload_command: str | None = None
1111

1212

1313
class GlazeWMConfig(CustomBaseModel):
14-
start_command: str | None = "glazewm.exe start"
15-
stop_command: str | None = "glazewm.exe command wm-exit"
16-
reload_command: str | None = "glazewm.exe command wm-exit && glazewm.exe start"
14+
start_command: str | None = None
15+
stop_command: str | None = None
16+
reload_command: str | None = None
1717

1818

1919
class YasbConfig(CustomBaseModel):

0 commit comments

Comments
 (0)