Skip to content

fix: do not activate a disabled toggle from a tray icon click [patch] - #9

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/trayapp-7-disabled-toggle-icon-click
Sep 24, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/trayapp-7-disabled-toggle-icon-click

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #7.

OnTrayIconClicked took the first TrayToggleItem in the menu and called Activate on it without reading IsEnabled:

if (model is TrayToggleItem)
{
    Activate(model);
    return;
}

The menu path never needed that check because the toolkit supplies it: BuildNativeMenu wires native.Click += …, and a greyed-out NativeMenuItem does not raise Click. The tray icon is the one path from a click to the tool's setter with nothing in the way, so a tool's isEnabled predicate was honoured in the menu and silently ignored on the icon — the entry showed unavailable while a left-click ran it anyway.

Measured: with the guards reverted, TrayIconClick_WithTheToggleDisabled_DoesNotRunTheToolsSetter records the setter running once against an expected zero.

The change

Two guards, both small:

  1. OnTrayIconClicked — if (model is TrayToggleItem { IsEnabled: true }), exactly as the issue proposes.
  2. Activate — returns early for a disabled item. This is the defence-in-depth the issue raises as optional, and it earns its place on a case the toolkit gating cannot cover: a native item is greyed out as of the last refresh, and nothing repaints a menu the user already has open, so a tool whose state changes between the paint and the click still reaches Activate. IsEnabled is the value the paint used, so the guard agrees with what the user saw rather than re-reading the predicate and disagreeing with the screen.

Both OnTrayIconClicked and Activate are internal, joining NativeMenu and HasStarted, so they can be driven with no display — which is how the rest of this class is already tested. Neither is public, so the package surface is unchanged.

TrayMenu.Activate is deliberately not touched: its bool return documents "the item's action completed without throwing", and making it also mean "was disabled" would conflate two different answers in a public API.

One thing worth your call

The guard sits inside the loop, per the issue's suggested line, so the click now activates the first enabled toggle. With two toggles where the first is disabled, that means the icon flips the second one rather than doing nothing. For the single-toggle case they are identical. If you would rather the fast path stay pinned to "the first switch" and do nothing when it is unavailable, that is moving the return out one level — say the word and I will change it.

Quit is unaffected

Worth confirming, since the Activate guard applies to commands too: TrayMenu constructs QuitItem with getEnabled: null, so its IsEnabled is always true. TrayIconClick_WithNoToggleAtAll_DoesNothing pins that a toggle-free menu has nothing picked for it.

Tests

Five added to TrayApplicationTests, which previously only covered menu projection and disposal. Verified by reverting each guard in turn and re-running: three fail against the old implementation.

Test Old implementation
TrayIconClick_WithTheToggleDisabled_DoesNotRunTheToolsSetter fails — setter ran once, expected zero
TrayIconClick_BecomingEnabledAgain_RunsTheToolsSetter fails — setter ran twice, expected once
Activate_WithTheItemDisabled_DoesNotRunTheToolsSetter fails without the Activate guard — setter ran once, expected zero
TrayIconClick_WithTheToggleEnabled_RunsTheToolsSetter passes — guards the fast path still working
TrayIconClick_WithNoToggleAtAll_DoesNothing passes — regression guard

Each test calls menu.Refresh() before clicking. IsEnabled is a cache of the last refresh and defaults to true, so without it the disabled test would have passed for the wrong reason.

Activate_WithTheItemDisabled_… was added after SonarCloud failed the gate at 66.7% coverage on new code: every other test drives OnTrayIconClicked, which skips a disabled toggle before Activate is reached, so the backstop was never executed. The alternative was deleting the guard to raise the number, which would have traded the fix for the metric. NativeMenuItem exposes Click with no way to raise it, so the native path cannot be driven from a test — hence calling Activate directly. Checked against a local cobertura report that every executable line this PR adds is now hit; the two uncovered lines remaining in the file (OnQuitRequested, OnExit) are pre-existing and untouched by this diff.

Full suite: 112 passed, 0 failed, 1 skipped (the pre-existing non-Linux DesktopSession case). Solution builds with 0 warnings, 0 errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_018VSTy8Ye7JXqeFqvhrmnRt

OnTrayIconClicked found the first TrayToggleItem in the menu and called
Activate unconditionally, never reading model.IsEnabled. The native menu
path is gated for free — the toolkit greys a disabled NativeMenuItem out
and will not raise Click for it — so the tray icon was the one path from
a click to the tool's setter with nothing in the way.

A tool registering a toggle with an isEnabled predicate therefore saw its
setter run on a left click while the menu showed the same action greyed
out, invoking logic the tool had explicitly gated off.

The click handler now skips disabled toggles. Activate also returns early
for a disabled item, which covers the gap the native gating leaves: an
item is greyed out as of the last refresh, and a tool's state can change
between painting the menu and the user clicking it. IsEnabled is the
value the paint used, so this agrees with what the user saw.

OnTrayIconClicked becomes internal so it can be driven with no display,
as the rest of this class's tests already are.

Fixes #7

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VSTy8Ye7JXqeFqvhrmnRt
SonarCloud failed the quality gate at 66.7% coverage on new code. The
uncovered line was the early return in Activate: every test drove
OnTrayIconClicked, which skips a disabled toggle before Activate is
reached, so the backstop behind the native menu was never executed.

The honest fix is to test it rather than delete it. The guard covers the
gap native greying cannot - an item greyed out as of the last refresh,
with a click already on its way - and dropping a correct check to raise a
coverage number would trade the fix for the metric.

NativeMenuItem exposes Click with no way to raise it, so the native path
cannot be driven from a test at all. Activate becomes internal, as
OnTrayIconClicked already is and for the same reason, and the new test
calls it with an item the tool has since disabled.

Verified the test fails without the guard (setter runs once against an
expected zero), and against a local cobertura report that every
executable line this PR adds is now hit. The two remaining uncovered
lines in the file, OnQuitRequested and OnExit, are pre-existing and
untouched by this diff.

113 tests: 112 pass, 1 pre-existing skip. Solution builds with 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VSTy8Ye7JXqeFqvhrmnRt
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 0ae333c into main Sep 24, 2026
14 checks passed
@matt-edmondson
matt-edmondson deleted the claude/trayapp-7-disabled-toggle-icon-click branch September 24, 2026 00:47
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.

Left-clicking the tray icon activates a toggle even when it's disabled

2 participants