# -*- coding: utf-8 -*- """Иконка в трее Windows: Shell_NotifyIconW без сторонних библиотек. Скрытое окно с собственным message loop живёт в фоновом потоке; колбэки вызываются в этом потоке — UI передаёт сюда только потокобезопасные функции (положить сообщение в очередь Tk). """ import ctypes import threading from ctypes import wintypes user32 = ctypes.windll.user32 shell32 = ctypes.windll.shell32 kernel32 = ctypes.windll.kernel32 WM_APP = 0x8000 WM_TRAY = WM_APP + 1 # колбэк иконки WM_TRAY_ACT = WM_APP + 2 # ЛКМ/даблклик → показать окно WM_TRAY_CMD = WM_APP + 3 # пункт меню (wParam = индекс 1..n) NIM_ADD, NIM_MODIFY, NIM_DELETE = 0, 1, 2 NIF_MESSAGE, NIF_ICON, NIF_TIP, NIF_INFO = 0x1, 0x2, 0x4, 0x10 NIIF_INFO, NIIF_WARNING, NIIF_ERROR = 1, 2, 3 WM_LBUTTONUP, WM_LBUTTONDBLCLK, WM_RBUTTONUP = 0x0202, 0x0203, 0x0205 MF_STRING, MF_SEPARATOR = 0x0, 0x800 TPM_RIGHTBUTTON, TPM_RETURNCMD = 0x2, 0x100 IDI_APPLICATION = 32512 GCLP_HICON = -14 HWND_MESSAGE = wintypes.HWND(-3) WM_NULL = 0 WM_CLOSE = 0x10 WM_DESTROY = 0x02 WNDPROC = ctypes.WINFUNCTYPE(ctypes.c_ssize_t, wintypes.HWND, ctypes.c_uint, wintypes.WPARAM, wintypes.LPARAM) # сигнатуры для x64 (иначе хэндлы/указатели обрезаются до 32 бит) user32.DefWindowProcW.restype = ctypes.c_ssize_t user32.DefWindowProcW.argtypes = [wintypes.HWND, ctypes.c_uint, wintypes.WPARAM, wintypes.LPARAM] user32.CreatePopupMenu.restype = wintypes.HMENU user32.AppendMenuW.argtypes = [wintypes.HMENU, ctypes.c_uint, wintypes.UINT, wintypes.LPCWSTR] user32.TrackPopupMenu.restype = ctypes.c_int user32.TrackPopupMenu.argtypes = [wintypes.HMENU, ctypes.c_uint, ctypes.c_int, ctypes.c_int, ctypes.c_int, wintypes.HWND, ctypes.c_void_p] user32.DestroyMenu.argtypes = [wintypes.HMENU] user32.GetClassLongPtrW.restype = ctypes.c_size_t user32.GetClassLongPtrW.argtypes = [wintypes.HWND, ctypes.c_int] user32.LoadIconW.restype = wintypes.HICON user32.LoadIconW.argtypes = [wintypes.HINSTANCE, ctypes.c_void_p] kernel32.GetModuleHandleW.restype = wintypes.HINSTANCE kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR] shell32.Shell_NotifyIconW.restype = wintypes.BOOL shell32.Shell_NotifyIconW.argtypes = [ctypes.c_uint, ctypes.c_void_p] user32.PeekMessageW.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint] user32.CreateWindowExW.argtypes = [ ctypes.c_uint, wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.c_uint, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wintypes.HWND, wintypes.HMENU, wintypes.HINSTANCE, ctypes.c_void_p] user32.CreateWindowExW.restype = wintypes.HWND user32.GetCursorPos.argtypes = [ctypes.POINTER(wintypes.POINT)] user32.PostMessageW.argtypes = [wintypes.HWND, ctypes.c_uint, wintypes.WPARAM, wintypes.LPARAM] class GUID(ctypes.Structure): _fields_ = [("Data1", ctypes.c_uint32), ("Data2", ctypes.c_uint16), ("Data3", ctypes.c_uint16), ("Data4", ctypes.c_ubyte * 8)] class NIDW(ctypes.Structure): _fields_ = [ ("cbSize", wintypes.DWORD), ("hWnd", wintypes.HWND), ("uID", wintypes.UINT), ("uFlags", wintypes.UINT), ("uCallbackMessage", wintypes.UINT), ("hIcon", wintypes.HICON), ("szTip", wintypes.WCHAR * 128), ("dwState", wintypes.DWORD), ("dwStateMask", wintypes.DWORD), ("szInfo", wintypes.WCHAR * 256), ("uTimeout", wintypes.UINT), ("szInfoTitle", wintypes.WCHAR * 64), ("dwInfoFlags", wintypes.DWORD), ("guidItem", GUID), ("hBalloonIcon", wintypes.HICON), ] class WNDCLASSW(ctypes.Structure): _fields_ = [ ("style", ctypes.c_uint), ("lpfnWndProc", WNDPROC), ("cbClsExtra", ctypes.c_int), ("cbWndExtra", ctypes.c_int), ("hInstance", wintypes.HINSTANCE), ("hIcon", wintypes.HICON), ("hCursor", wintypes.HCURSOR), ("hbrBackground", wintypes.HBRUSH), ("lpszMenuName", wintypes.LPCWSTR), ("lpszClassName", wintypes.LPCWSTR), ] class Tray: """Трей: start() → иконка, notify() → плашка, stop() → убрать.""" def __init__(self, on_activate=None, on_command=None, commands=()): self._on_activate = on_activate or (lambda: None) self._on_command = on_command or (lambda name: None) self._commands = list(commands) self._hwnd = None self._thread = None self._ready = threading.Event() self._stop = threading.Event() self._hicon = None self._wndproc = WNDPROC(self._wnd_proc) # держим живым # ------------------------------------------------------------- поток def start(self, tooltip="", hicon=None): self._hicon = hicon or user32.LoadIconW(None, IDI_APPLICATION) self._tooltip = tooltip or "PixelLauncher" self._thread = threading.Thread(target=self._loop, daemon=True, name="tray") self._thread.start() self._ready.wait(5) def _loop(self): cls = "PixelLauncherTrayClass" wc = WNDCLASSW() wc.lpfnWndProc = self._wndproc wc.hInstance = kernel32.GetModuleHandleW(None) wc.lpszClassName = cls user32.RegisterClassW(ctypes.byref(wc)) hwnd = user32.CreateWindowExW( 0, cls, "PixelLauncher", 0, 0, 0, 0, 0, HWND_MESSAGE, None, wc.hInstance, None) if not hwnd: self._ready.set() return self._hwnd = hwnd self._shell(NIM_ADD, NIF_MESSAGE | NIF_ICON | NIF_TIP, callback=WM_TRAY, tip=self._tooltip, icon=self._hicon) self._ready.set() msg = wintypes.MSG() while not self._stop.is_set(): # PeekMessage, чтобы периодически проверять флаг остановки while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1): user32.TranslateMessage(ctypes.byref(msg)) user32.DispatchMessageW(ctypes.byref(msg)) if msg.message == WM_DESTROY: return if not self._stop.is_set(): # усыпляемся до следующего сообщения user32.WaitMessage() user32.DestroyWindow(hwnd) # ----------------------------------------------------------- колбэки def _wnd_proc(self, hwnd, msg, wparam, lparam): if msg == WM_TRAY: if lparam in (WM_LBUTTONUP, WM_LBUTTONDBLCLK): self._on_activate() elif lparam == WM_RBUTTONUP: self._popup(hwnd) return 0 if msg == WM_TRAY_ACT: self._on_activate() return 0 if msg == WM_TRAY_CMD: if int(wparam) == 100: self._on_command("exit") else: idx = int(wparam) - 2 if 0 <= idx < len(self._commands): self._on_command(self._commands[idx][1]) return 0 if msg == WM_DESTROY: user32.PostQuitMessage(0) return 0 return user32.DefWindowProcW(hwnd, msg, wparam, lparam) def _popup(self, hwnd): hm = user32.CreatePopupMenu() user32.AppendMenuW(hm, MF_STRING, 1, "Показать лаунчер") user32.AppendMenuW(hm, MF_SEPARATOR, 0, None) for i, (label, _name) in enumerate(self._commands): user32.AppendMenuW(hm, MF_STRING, i + 2, label) user32.AppendMenuW(hm, MF_SEPARATOR, 0, None) user32.AppendMenuW(hm, MF_STRING, 100, "Выход") user32.SetForegroundWindow(hwnd) pt = wintypes.POINT() user32.GetCursorPos(ctypes.byref(pt)) cmd = user32.TrackPopupMenu(hm, TPM_RIGHTBUTTON | TPM_RETURNCMD, pt.x, pt.y, 0, hwnd, None) user32.PostMessageW(hwnd, WM_NULL, 0, 0) user32.DestroyMenu(hm) if cmd == 1: user32.PostMessageW(hwnd, WM_TRAY_ACT, 0, 0) elif cmd == 100: user32.PostMessageW(hwnd, WM_TRAY_CMD, 100, 0) elif cmd: user32.PostMessageW(hwnd, WM_TRAY_CMD, cmd, 0) # -------------------------------------------------------------- API def _shell(self, action, flags, callback=0, tip="", icon=None, info="", title="", niif=NIIF_INFO): if not self._hwnd: return nid = NIDW() nid.cbSize = ctypes.sizeof(NIDW) nid.hWnd = self._hwnd nid.uID = 1 nid.uFlags = flags nid.uCallbackMessage = callback nid.hIcon = icon or self._hicon nid.szTip = (tip or "")[:127] nid.szInfo = (info or "")[:255] nid.szInfoTitle = (title or "")[:63] nid.dwInfoFlags = niif shell32.Shell_NotifyIconW(action, ctypes.byref(nid)) def notify(self, title, text, warn=False): """Плашка в трее (из любого потока).""" self._shell(NIM_MODIFY, NIF_INFO, title=str(title)[:63], info=str(text)[:255], niif=NIIF_WARNING if warn else NIIF_INFO) def update_tooltip(self, text): self._shell(NIM_MODIFY, NIF_TIP, tip=str(text)[:127]) def stop(self): """Убирает иконку (можно из любого потока).""" if self._stop.is_set(): return self._stop.set() if self._hwnd: self._shell(NIM_DELETE, 0) user32.PostMessageW(self._hwnd, WM_CLOSE, 0, 0) def window_icon(hwnd): """HICON окна Tk для иконки трея (None — если не вышло).""" try: h = user32.GetClassLongPtrW(hwnd, GCLP_HICON) return h or None except Exception: # noqa: BLE001 return None