Skip to content

Add minimize to system tray - #789

Open
SubliminalsTV wants to merge 3 commits into
WhiteMagic:developfrom
SubliminalsTV:feature/minimize-to-tray
Open

Add minimize to system tray#789
SubliminalsTV wants to merge 3 commits into
WhiteMagic:developfrom
SubliminalsTV:feature/minimize-to-tray

Conversation

@SubliminalsTV

Copy link
Copy Markdown
Contributor

Restores the pre-R14 minimize-to-tray behaviour as an opt-in option.

What

  • New minimize-to-tray option (off by default). When enabled, minimizing the main window hides it to a tray icon instead of the taskbar; clicking the icon restores and focuses it.
  • Tray icon created only when QSystemTrayIcon.isSystemTrayAvailable().
  • Context menu offers profile activate/deactivate (label + icon reflect the live state via Backend.activityChanged) and quit — quit routes through the window's normal close path so the unsaved-changes prompt still fires.

Notes

  • Self-contained in gremlin/ui/tray.py; joystick_gremlin.py only registers the option and creates the icon. No behaviour change when the option is off.
  • The tray icon is always present (when a tray exists) so activate/quit stay reachable while minimized — the option only governs hide-on-minimize. Happy to gate the icon's presence on the option instead if you'd prefer.
  • Reuses existing gfx/icon*.ico assets and Backend signals; nothing added to signal.

@WhiteMagic

Copy link
Copy Markdown
Owner

The issue with all the systray solutions LLM's come up with is that they don't understand Gremlin's constraints well. As a result they always want to use QtWidget's based solutions which will work right now, but once the input viewer is reworked nothing in Gremlin requires QtWidgets and they will be dropped. As such adding new components that requires that won't work. There is a QML system tray icon but that falls back onto QtWidgets, making it not suitable. This effectively leaves a solution that directly uses Window's systray interface via pywin32 which Gremlin requires as a dependency anyway.

@SubliminalsTV
SubliminalsTV force-pushed the feature/minimize-to-tray branch from 76e91ef to d508e36 Compare June 24, 2026 10:11
@SubliminalsTV

Copy link
Copy Markdown
Contributor Author

Reworked to use the Win32 Shell_NotifyIcon API directly via pywin32 (already a Gremlin dependency) instead of QtWidgets — QSystemTrayIcon and the QML tray both pull in QtWidgets, which this avoids entirely.

A hidden helper window handles the icon's mouse/menu messages (its wndproc is driven by Qt's running event loop), and window show/hide still goes through the QML QWindow (QtGui). Verified live: the icon appears, minimize-to-tray hides the window, left-click restores it, and the right-click menu (Show / Activate-Deactivate / Quit) works with the icon reflecting the active state.

@WhiteMagic

WhiteMagic commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Ran it through my usual merge steps, the LLM comes up with these things it thinks may be troublesome:

0. remove() double-deletes the tray icon, and _on_destroy is a confused fallback

remove() calls NIM_DELETE, then DestroyWindow. DestroyWindow sends WM_DESTROY synchronously, which dispatches to _on_destroy, which calls NIM_DELETE again. The second call silently fails (the icon is already gone), but the two methods now share responsibility for the same side-effect. The intent of _on_destroy is clearly a defensive fallback for abnormal window destruction — it should be the only place NIM_DELETE lives, and remove() should just call DestroyWindow.

1. Upgrade to NOTIFYICON_VERSION_4

The current code uses the pre-Vista notification protocol. Calling NIM_SETVERSION after NIM_ADD unlocks: Cursor position packed into wparam (eliminates the GetCursorPos call) NIN_SELECT for keyboard-driven activation (accessibility)
NIN_KEYMAPMENU for keyboard-driven context menu Better multi-monitor menu positioning The catch: with version 4, lparam is (icon_id << 16) | notification_event, so _on_tray_event must extract lparam & 0xFFFF instead of comparing lparam directly — the current code would silently break if you added the version call without that fix.

2. Make the helper window invisible to the shell

The current CreateWindow uses WS_OVERLAPPED (= 0) at zero size, which works but relies on the window being invisible by accident. A zero-size overlapped window can briefly flicker in the taskbar on some DWM configurations. Use reateWindowEx with WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE to explicitly exclude it from the taskbar and Alt+Tab.

3. Add NIF_SHOWTIP to the notification data

NIF_SHOWTIP (added in Vista) opts into the modern rich tooltip and ensures the tip text shows even when the user has balloon tips suppressed by group policy.

Probably giving these files as context to an LLM will make it understand these inputs:

Restores the pre-R14 minimize-to-tray behaviour as an opt-in option. When
'minimize-to-tray' is enabled, minimizing the window hides it to a tray
icon instead of the taskbar; clicking the icon restores it. The tray menu
offers profile activate/deactivate and quit (via the window's close path
so unsaved changes are still flagged), and the icon reflects the active
state.

Implemented directly against the Win32 Shell_NotifyIcon API via pywin32
(already a Gremlin dependency) rather than QtWidgets' QSystemTrayIcon, so
it adds no QtWidgets dependency. Window show/hide goes through the QML
QWindow (QtGui). Self-contained in gremlin/ui/tray.py plus the option
registration and tray creation in joystick_gremlin.py.
- remove() no longer double-deletes the icon: DestroyWindow triggers the
  WM_DESTROY handler, which is now the sole owner of NIM_DELETE
- opt into NOTIFYICON_VERSION_4 (NIM_SETVERSION after NIM_ADD) and mask the
  low word of lparam in the event handler, since v4 packs the icon id into
  the high word; also handle NIN_SELECT / NIN_KEYSELECT / WM_CONTEXTMENU for
  keyboard-driven activation and menu
- create the helper window with WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE so the
  zero-size window can't flicker in the taskbar or steal focus
- add NIF_SHOWTIP so the tooltip shows even when balloon tips are suppressed
The top tray-menu entry now reads Hide/Show depending on whether the
window is currently visible, mirroring the Activate/Deactivate entry, so
the menu can both stash the window into the tray and bring it back.
@SubliminalsTV
SubliminalsTV force-pushed the feature/minimize-to-tray branch from d508e36 to 17e5174 Compare July 19, 2026 17:12
@SubliminalsTV

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — addressed all four in a follow-up commit, rebased on current develop:

  1. remove() now only calls DestroyWindow; the WM_DESTROY handler is the sole owner of NIM_DELETE, so the icon is no longer double-deleted.
  2. Opted into NOTIFYICON_VERSION_4 (NIM_SETVERSION after NIM_ADD) and switched the event handler to lparam & 0xFFFF, since v4 packs the icon id into the high word. Also handle NIN_SELECT / NIN_KEYSELECT / WM_CONTEXTMENU for keyboard-driven activation and menu.
  3. Helper window is now created via CreateWindowEx with WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE so the zero-size window can't flicker in the taskbar or steal focus.
  4. Added NIF_SHOWTIP to the notification data.

pywin32 doesn't expose NIF_SHOWTIP / NOTIFYICON_VERSION_4 / NIN_*, so those are defined locally at their documented Win32 values.

There's also one small enhancement in a separate commit: the top menu entry now toggles Hide/Show based on window visibility (mirroring the Activate/Deactivate entry) so the menu can both stash the window into the tray and bring it back. Happy to drop that commit if you'd rather keep this PR to just the fixes.

Comment thread gremlin/ui/tray.py
@@ -0,0 +1,234 @@
# -*- coding: utf-8; -*-

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to name this system_tray.py, that way it's obvious what this is for.

Comment thread gremlin/ui/tray.py
Comment on lines +20 to +24
# Shell_NotifyIcon values that pywin32 does not expose as constants.
# Opting into version 4 unlocks the modern notification protocol (cursor
# position packed into wparam, keyboard selection events, better multi-monitor
# menu placement); NIF_SHOWTIP opts into the standard tooltip even when the
# user has balloon tips suppressed by policy.

@WhiteMagic WhiteMagic Jul 21, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the kind of comment LLMs like to write. I don't think they are very good, since the LLM always wants to explain its reasoning to the user, which is fair, to let the human agree or disagree. But it has no place in the code. If the reader doesn't understand the meaning, they should read up on it to understand. So here I'd expect a human to write a comment like

# Use notify protocol v4 and force standard tooltip behavior even if OS policies override them.

This is in part personal preference and in part experience from working on large code bases. The code LLMs produce is usually of fair quality but needs massaging to properly adhere to standards and not overlook specific things. The comments is where they fail in my eyes since they don't very well distinguish between "discussion with the user and explaining" and "code comment generation". Also asking them to be "concise and precise" often doesn't produce good comments either sadly. So far I always find it that manually rewriting the comments is required to get a comment that's worth having. I may try to let Claude run over Gremlin's code to build a "write acceptable comments" skill.

The above is more of an explanation of why I probably will "complain" about most comments that feel too "explanatory". I'm fine with those being added as "this is what the code is meant to do" to aid in code review, though.

Comment thread gremlin/ui/tray.py
Comment on lines +42 to +51
Gremlin is moving away from any QtWidgets dependency, so the tray can't be a
QSystemTrayIcon (QtWidgets, as is the QML tray fallback). It talks to the
Windows shell directly instead, via pywin32 which Gremlin already requires.
Window show/hide still goes through the QML window, a QWindow that lives in
QtGui rather than QtWidgets.

When the minimize-to-tray option is enabled, minimizing the main window
hides it into the tray instead of the taskbar; clicking the icon restores
it. The tray menu offers profile activate/deactivate and quit, and the icon
reflects whether a profile is currently active.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Falls under the same not-very-useful comment stuff. Especially the first paragraph is pure history but irrelevant to the code.

Comment thread gremlin/ui/tray.py
Comment on lines +61 to +62
self._config = Configuration()
self._backend = Backend()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both are singletons so can be retrieved at any time on demand. The Backend I'd say is fine to keep here but the Configuration being used just once makes little sense. The "time" required to get it is irrelevant in the scheme of things.

Comment thread gremlin/ui/tray.py
Comment on lines +96 to +101
"""Removes the tray icon and helper window (call on shutdown).

DestroyWindow dispatches WM_DESTROY synchronously, and its handler is
the sole owner of the icon removal, so tearing down the window here is
enough -- deleting the icon as well would double-delete it.
"""

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idem for this comment, it actually raises questions:

  • Is this required to be called? What if one doesn't call it?
  • The comment will end in user facing documentation but exposes implementation detail which is bad.

If the goal is to prevent future us from going in and deleting the icon again, the comment should be inside the code block along the lines of:

# The main window manages the icon resource lifetime.

Though I also don't see any icon resource that could be freed, so unsure if this comment even applies still.

Comment thread gremlin/ui/tray.py
active = self._backend.gremlinActive
return self._active_icon if active else self._idle_icon

def _add_icon(self) -> None:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this more of a _create_icon / _install_icon?

Comment thread gremlin/ui/tray.py
Comment on lines +113 to +116
return win32gui.LoadImage(
0, gremlin.util.resource_path(relative_path), win32con.IMAGE_ICON,
size, size, win32con.LR_LOADFROMFILE
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire code base is now Ruff-conformant, so this likely would reformat to something like:

return win32gui.LoadImage(
    0,
    gremlin.util.resource_path(relative_path),
    win32con.IMAGE_ICON,
    size,
    size,
    win32con.LR_LOADFROMFILE,
)

Comment thread gremlin/ui/tray.py
Comment on lines +110 to +111
def _load_icon(self, relative_path: str) -> int:
"""Loads an .ico at the system tray icon size."""

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I try to document the functions in full, i.e. with args and returns such that if I ever get around to run sphinx over it there is a complete api doc.

Comment thread gremlin/ui/tray.py
Comment on lines +119 to +120
active = self._backend.gremlinActive
return self._active_icon if active else self._idle_icon

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
active = self._backend.gremlinActive
return self._active_icon if active else self._idle_icon
return self._active_icon if self._backend.gremlinActive else self._idle_icon

Comment thread gremlin/ui/tray.py
return self._active_icon if active else self._idle_icon

def _add_icon(self) -> None:
win32gui.Shell_NotifyIcon(win32gui.NIM_ADD, (

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just running ruff format and check over the file should fix all formatting bits.

Comment thread gremlin/ui/tray.py
Comment on lines +129 to +130
# Select the modern notification behaviour. Must follow NIM_ADD; the
# version is carried in the timeout/version field of the data tuple.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd simplify this and put it above the first one. There it makes sense to say "using v4 which first does a before 2 can be done".

Comment thread gremlin/ui/tray.py
Comment on lines +135 to +136
def _refresh_icon(self) -> None:
"""Keeps the icon in sync with the profile activation state."""

@WhiteMagic WhiteMagic Jul 21, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _refresh_icon(self) -> None:
"""Keeps the icon in sync with the profile activation state."""
def _activity_changed_cb(self) -> None:
"""Updates the icon to match Gremlin's activation state."""

Comment thread gremlin/ui/tray.py
Comment on lines +92 to +93
self._window.visibilityChanged.connect(self._on_visibility_changed)
self._backend.activityChanged.connect(self._refresh_icon)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self._window.visibilityChanged.connect(self._on_visibility_changed)
self._backend.activityChanged.connect(self._refresh_icon)
self._window.visibilityChanged.connect(self._visibility_changed_cb)
self._backend.activityChanged.connect(self._activity_changed_cb)

In the Python part of Gremlin I use some_signal_cb for callback functions. The on_some_signal would be mirroring what the QML side does.

Comment thread gremlin/ui/tray.py
Comment on lines +153 to +154
"""Restores on a left click / double click / keyboard selection of the
icon, and shows the context menu on a right click / menu key."""

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Restores on a left click / double click / keyboard selection of the
icon, and shows the context menu on a right click / menu key."""
"""Handles tray icon interactions.
Restores on a left click, double click, or keyboard selection of the icon. Right click or menu key display the context menu."""

Comment thread gremlin/ui/tray.py
Comment on lines +157 to +158
event = lparam & 0xFFFF
if event in (

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a case where extracting a simple operation into a function is a good idea.

  • It's reusable and testable if we need
  • You can put explaining comment there
  • The call site stays clean and easily understandable

Comment thread gremlin/ui/tray.py
self, hwnd: int, msg: int, wparam: int, lparam: int
) -> int:
"""Dispatches a tray menu selection."""
command = wparam & 0xFFFF

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is similar to the above a "magic" parsing which I'd probably factor out into a helper.

You can take a look at these places in Gremlin where I deal a lot with windows types and fucntions to get an idea of what that might look like. I haven't revisited them in some time so there may also be bits I would do differently now.

Comment thread gremlin/ui/tray.py
Comment on lines +185 to +188
if self._window.isVisible():
self._window.hide()
else:
self.restore()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly this works too? But maybe is a bit too "terse"?

self._window.hide() if self._window.isVisible() else self.restore()

Comment thread gremlin/ui/tray.py
Comment on lines +190 to +195
def _on_taskbar_created(
self, hwnd: int, msg: int, wparam: int, lparam: int
) -> int:
"""Re-adds the icon after an Explorer restart."""
self._add_icon()
return 0

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Since this is a callback I'd stick to _taskbar_created_cb.
  • Is this guaranteed to only run on that event to make sure we don't end up adding icon after icon due to some funny issue
    • Or can we check whether the icon still exists?

Comment thread gremlin/ui/tray.py
self._add_icon()
return 0

def _on_destroy(

@WhiteMagic WhiteMagic Jul 21, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Same bit about the name, I'd prefer _destruction/destroy_cb.
  • Is there a need for guards around the actual variables and the call and its return value?

Comment thread gremlin/ui/tray.py
Comment on lines +218 to +220
# TrackPopupMenu needs the owning window in the foreground to dismiss
# correctly, plus a follow-up message after it returns (a documented
# Win32 quirk).

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment to other LLM comments, they are not of good quality.

  • Doesn't say what the purpose is
  • Is verbose for what it says (low signal to noise ratio)
  • Claims it works around some issue that allegedly is documented but doesn't provide a link

Comment thread gremlin/ui/tray.py
win32gui.DestroyMenu(menu)

def _quit(self) -> None:
"""Quits via the window's close path so unsaved changes are flagged."""

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unclear what this quits, i.e. the taskbar or Gremlin. I assume the latter but that doesn't become clear.

Comment thread joystick_gremlin.py
Comment on lines +223 to +232
cfg.register(
"global",
"general",
"minimize-to-tray",
PropertyType.Bool,
False,
"Minimize to the system tray instead of the taskbar.",
{},
True,
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other option we should add together with this is the "close minimizes instead of closing".

Comment thread joystick_gremlin.py
"minimize-to-tray",
PropertyType.Bool,
False,
"Minimize to the system tray instead of the taskbar.",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"Minimize to the system tray instead of the taskbar.",
"Minimize the Gremlin window to the system tray instead of the taskbar.",

These descriptions are user facing documentation, so ideally are not overly terse.

Comment thread joystick_gremlin.py
# Create the system tray icon so the window can hide to and be
# restored from the tray, and tear it down cleanly on exit.
self.tray_icon = gremlin.ui.tray.SystemTrayIcon(self.main_window)
self.aboutToQuit.connect(self.tray_icon.remove)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to group the other aboutToQuit signal connection below, that way they are in one place and it's easy to konw where these are meant to go.

Comment thread joystick_gremlin.py
Comment on lines +461 to +462
# Create the system tray icon so the window can hide to and be
# restored from the tray, and tear it down cleanly on exit.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't provide much value, it's a systray icon it will do what a systray icon is there for :-)

@WhiteMagic

Copy link
Copy Markdown
Owner

I wrote a skill to generate comments (https://github.com/WhiteMagic/JoystickGremlin/tree/develop/.claude/skills/comment-code) it seems to produce passable results, maybe on the "too little comment" side, which honestly is better than ending up with a novel. Model wise this uses Claude with Sonnet 5, so may work better or worse depending on model used I'd bet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants