Skip to content

Commit 32d4093

Browse files
committed
flutter_tray_icon_example: popup mode, and the Omarchy theme's colour for Auto
Properties -> Popup (or TRAY_POPUP=1) makes the window behave like a tray utility's: no title bar, a click on the icon shows it or hides it, losing the focus hides it. On Wayland the compositor places it; the README gives the Hyprland window rule that puts it under the icon, and tools/gui/flutter_tray_popup_test_hyprland.py checks the whole sequence with a real click in Omarchy's bar. On Omarchy the Auto icon colour is the desktop theme's foreground and follows a theme switch. tools/gui/README.md also lists core's window_shadow_remap_linux_test.
1 parent 67bf57d commit 32d4093

7 files changed

Lines changed: 685 additions & 7 deletions

File tree

‎examples/flutter_tray_icon_example/README.md‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,46 @@ What a platform cannot do is not offered, and its checklist items step aside:
5353

5454
macOS shows tray images as 18 pt templates, so there the colour row only changes the
5555
preview's alpha shape and the menu bar picks the tint; resolution still matters (1x / 2x
56-
/ 3x pixels for the same 18 pt).
56+
/ 3x pixels for the same 18 pt). On Omarchy the *Auto* colour is the desktop theme's
57+
foreground, like the bar's own icons, and follows a theme switch; since the asset
58+
still is a fixed white image, an icon showing it switches to the drawn star there.
59+
60+
## Popup mode
61+
62+
*Properties → Popup* (or `TRAY_POPUP=1` in the environment) makes the window behave like
63+
a tray utility's: no title bar, a click on the icon shows it, a click while it shows
64+
hides it, and losing the focus hides it (`WindowBlurredEvent` → `hide()`). Every show is
65+
a fresh `show()` after a `hide()`, not a raise.
66+
67+
Where the window appears is the platform's business. On macOS and Windows *Window to
68+
icon* places it next to the icon from `getBounds`. On Wayland the app can neither read
69+
the icon's position nor place its own window, so the compositor has to do it. On
70+
Hyprland (0.56+, Lua config) a window rule evaluated when the window maps does — the
71+
cursor is still on the icon at that moment:
72+
73+
```lua
74+
-- ~/.config/hypr/hyprland.lua: right edge 20 px right of the click, top 20 px below it
75+
hl.window_rule({
76+
match = { class = "^com\\.example\\.tray_icon_example$" },
77+
float = true,
78+
pin = true,
79+
no_anim = true,
80+
move = { "(cursor_x-window_w+20)", "(cursor_y+20)" },
81+
})
82+
```
83+
84+
The example sizes the window itself (400 × 640), so the rule needs no `size`. Static
85+
rules run once per map, which is why popup mode hides the window instead of
86+
lowering it. `tools/gui/flutter_tray_popup_test_hyprland.py` installs this rule for a run
87+
and checks the whole sequence with a real click in Omarchy's bar.
88+
89+
What it looks like there: about 150 ms from the click to the first frame, which is
90+
already the final layout. With a title bar it would re-lay out once more: Hyprland
91+
reports every toplevel as tiled and maximized, floating or not, so that it draws no
92+
client-side shadow, and GTK maps a decorated window with its CSD shadow margins first
93+
and drops them when those states arrive. Without the title bar core suppresses GTK's
94+
decoration margin as well, and never adds its own shadow gutter before the compositor
95+
has answered (`core/tests/window_shadow_remap_linux_test.cpp`).
5796

5897
## Files
5998

‎examples/flutter_tray_icon_example/lib/checklist.dart‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ class Checklist extends ChangeNotifier {
7171
CheckItem('m.still', 'Icon swaps: asset, drawn, base64', manual: true),
7272
CheckItem('m.animation', 'Tray animation matches preview', manual: true),
7373
CheckItem('m.three', 'Three icons animate at once', manual: true),
74+
CheckItem(
75+
'm.popup',
76+
'Popup: a click shows the window, losing focus hides it',
77+
manual: true,
78+
),
7479
if (!Platform.isWindows)
7580
CheckItem(
7681
'm.title',

‎examples/flutter_tray_icon_example/lib/tabs/animate_tab.dart‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,9 @@ class _AnimateTabState extends State<AnimateTab>
137137
for (final MapEntry(:key, :value) in _colors.entries)
138138
OptionChip(
139139
label: key,
140-
selected:
141-
animator.color == (value ?? TrayController.autoColor),
140+
selected: animator.color == (value ?? controller.autoColor),
142141
onTap: () =>
143-
controller.setColor(value ?? TrayController.autoColor),
142+
controller.setColor(value ?? controller.autoColor),
144143
),
145144
if (Platform.isMacOS) const Hint('macOS tints it itself'),
146145
],

‎examples/flutter_tray_icon_example/lib/tabs/properties_tab.dart‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ class PropertiesTab extends StatelessWidget {
118118
),
119119
],
120120
),
121+
OptionRow(
122+
label: 'Popup',
123+
children: [
124+
OptionChip(
125+
label: 'Off',
126+
selected: !controller.popupMode,
127+
onTap: () => controller.setPopupMode(false),
128+
),
129+
OptionChip(
130+
label: 'Click shows, blur hides',
131+
selected: controller.popupMode,
132+
onTap: () => controller.setPopupMode(true),
133+
),
134+
],
135+
),
121136
OptionRow(
122137
label: 'Menu',
123138
children: [

‎examples/flutter_tray_icon_example/lib/tray_controller.dart‎

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33
import 'dart:io';
44
import 'dart:ui' as ui;
55

6+
import 'package:dazzui_host/dazzui_host.dart' show OmarchyTheme;
67
import 'package:flutter/widgets.dart';
78
import 'package:nativeapi/nativeapi.dart' as na;
89
import 'package:flutter/services.dart';
@@ -58,7 +59,15 @@ class TrayController extends ChangeNotifier {
5859
} else {
5960
checklist.fail(Checklist.supported, 'false');
6061
}
62+
_omarchy = OmarchyTheme.start();
63+
_omarchy?.addListener(_onThemeChanged);
6164
addIcon();
65+
_windowListener = WindowManager.instance.addListener(_onWindowEvent);
66+
if (_popupMode) {
67+
WidgetsBinding.instance.addPostFrameCallback(
68+
(_) => _applyPopupWindowStyle(),
69+
);
70+
}
6271
_sceneTimer = Timer.periodic(
6372
const Duration(milliseconds: 250),
6473
(_) => _driveScenes(),
@@ -81,6 +90,16 @@ class TrayController extends ChangeNotifier {
8190
MenuBackend get menuBackend => _menuBackend;
8291
MenuBackend _menuBackend = MenuBackend.native;
8392

93+
/// Popup behaviour, the way a tray utility has it: a click on the icon shows
94+
/// the window (and hides it again), losing the focus hides it. `TRAY_POPUP=1`
95+
/// in the environment turns it on from the start, for scripts. Where the
96+
/// window ends up is the platform's business: on Wayland the app cannot place
97+
/// it, a compositor rule can (see the example's README for Hyprland).
98+
bool get popupMode => _popupMode;
99+
bool _popupMode = Platform.environment['TRAY_POPUP'] == '1';
100+
late final ListenerId _windowListener;
101+
DateTime _popupHiddenAt = DateTime(0);
102+
84103
int _nextNumber = 1;
85104
late final Timer _sceneTimer;
86105

@@ -101,9 +120,14 @@ class TrayController extends ChangeNotifier {
101120
static bool get openMenuSupported => !Platform.isLinux;
102121

103122
/// Icon colour when the user picks "Auto". On macOS that means a template
104-
/// image, which the menu bar tints itself; elsewhere the tray is usually dark.
105-
static Color get autoColor =>
106-
Platform.isMacOS ? const Color(0xFF000000) : const Color(0xFFFFFFFF);
123+
/// image, which the menu bar tints itself. On Omarchy it is the desktop
124+
/// theme's foreground, light or dark, like the bar's own icons; elsewhere the
125+
/// tray is usually dark, so white.
126+
Color get autoColor => _autoColor;
127+
Color _autoColor = Platform.isMacOS
128+
? const Color(0xFF000000)
129+
: const Color(0xFFFFFFFF);
130+
OmarchyTheme? _omarchy;
107131

108132
// ---------------------------------------------------------------------
109133
// Icons
@@ -238,10 +262,34 @@ class TrayController extends ChangeNotifier {
238262

239263
@override
240264
void dispose() {
265+
WindowManager.instance.removeListener(_windowListener);
266+
_omarchy?.dispose();
241267
disposeIcons();
242268
super.dispose();
243269
}
244270

271+
/// The desktop theme changed (or was first read): icons on the "Auto" colour
272+
/// take its foreground. The asset still is a fixed white image, which a
273+
/// light bar would swallow, so an icon showing it switches to the drawn one.
274+
void _onThemeChanged() {
275+
final foreground = _omarchy?.data?.vars.colorContent;
276+
if (foreground == null || foreground == _autoColor) return;
277+
final previous = _autoColor;
278+
_autoColor = foreground;
279+
for (final entry in entries) {
280+
if (entry.animator.color != previous) continue;
281+
entry.animator.setColor(foreground);
282+
final still = entry.still;
283+
if (still == StillIcon.asset) {
284+
setStill(StillIcon.drawn, entry, true);
285+
} else if (still != null) {
286+
setStill(still, entry, true);
287+
}
288+
}
289+
_log('auto colour ← ${_omarchy?.name ?? 'theme'} foreground');
290+
notifyListeners();
291+
}
292+
245293
void _refreshManager() {
246294
managerCount = TrayManager.instance.getAll().length;
247295
}
@@ -528,6 +576,68 @@ class TrayController extends ChangeNotifier {
528576
notifyListeners();
529577
}
530578

579+
// ---------------------------------------------------------------------
580+
// Popup: click shows, blur hides
581+
// ---------------------------------------------------------------------
582+
583+
void setPopupMode(bool on) {
584+
if (_popupMode == on) return;
585+
_popupMode = on;
586+
_applyPopupWindowStyle();
587+
_log('popup mode ← ${on ? 'on' : 'off'}');
588+
notifyListeners();
589+
}
590+
591+
/// A popup has no title bar. On Linux that also keeps every show a single
592+
/// layout: a decorated GTK window maps with its CSD shadow margins and drops
593+
/// them when the compositor's states arrive, and Flutter re-lays out at each
594+
/// size - a visible "grow in". Without the header bar there is nothing to
595+
/// drop.
596+
void _applyPopupWindowStyle() {
597+
final window = WindowManager.instance.getCurrent();
598+
if (window == null) return;
599+
window.titleBarStyle = _popupMode
600+
? TitleBarStyle.hidden
601+
: TitleBarStyle.normal;
602+
}
603+
604+
void _onWindowEvent(WindowEvent event) {
605+
if (!_popupMode || event is! WindowBlurredEvent) return;
606+
final window = WindowManager.instance.getCurrent();
607+
if (window == null || event.windowId != window.id || !window.isVisible) {
608+
return;
609+
}
610+
window.hide();
611+
_popupHiddenAt = DateTime.now();
612+
_popup('hidden (blur)');
613+
}
614+
615+
/// A click on the icon shows the window, or hides it when it is showing. A
616+
/// click that arrives right after a blur hid the window is the same gesture
617+
/// (the press took the focus away first), so it leaves the window hidden.
618+
void _togglePopup() {
619+
final window = WindowManager.instance.getCurrent();
620+
if (window == null) return;
621+
if (window.isVisible) {
622+
window.hide();
623+
_popupHiddenAt = DateTime.now();
624+
_popup('hidden (click)');
625+
} else if (DateTime.now().difference(_popupHiddenAt) <
626+
const Duration(milliseconds: 400)) {
627+
_popup('stays hidden');
628+
} else {
629+
window.show();
630+
window.focus();
631+
_popup('shown');
632+
}
633+
}
634+
635+
// Also on stdout, where a script can read it without the UI probe.
636+
void _popup(String what) {
637+
_log('popup $what');
638+
debugPrint('[popup] $what');
639+
}
640+
531641
// ---------------------------------------------------------------------
532642
// Scenes: one click, a complete picture for the camera
533643
// ---------------------------------------------------------------------
@@ -612,6 +722,7 @@ class TrayController extends ChangeNotifier {
612722
checklist.count(Checklist.clicked);
613723
_noteTrayEvent(ContextMenuTrigger.clicked);
614724
_event('Clicked', entry);
725+
if (_popupMode) _togglePopup();
615726
} else if (event is TrayIconRightClickedEvent) {
616727
entry.rightClicks++;
617728
checklist.count(Checklist.rightClicked);

‎tools/gui/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ runner) lives in [`.agents/skills/`](../../.agents/skills); read
3030
| `core_drag_drop_test.py` / `.ps1` | core `drag_drop_example` (C++) | macOS / Windows | `DragSource` → `DropTarget` across two windows: enter / move / drop events, dropped file path and text, drop position in content coordinates, source reports `copy`; a drag released where nothing accepts it reports exit and `none` |
3131
| `flutter_drag_drop_test.py` / `.ps1` | `drag_drop_example` | macOS / Windows | `DragOutArea` → `DropRegion` in one window: a file and a text drop arrive, the source sees `copy`, the highlight clears, a second drag works after the first |
3232
| `flutter_tray_icon_test.py` / `.ps1` / `_linux.py` | `tray_icon_example` | macOS / Windows (WinUI 3 and Native menu backends) / Linux | animated tray icons: frames really are rendered and pushed (counter, 30 and 60 fps, no dropped frames, Pause / Step / Resume, 3x resolution), a live widget captured into frames, the Download scene driving the title, three icons animating at once; title / tooltip / visibility / trigger / bounds read back from the native getters; the menu opened from code shows its items and states, an item click arrives, `closeContextMenu` closes it; "Window to icon" puts the window below the icon on macOS and above it on Windows, centred on it; the example's own checklist has no unexpected failures. Windows tray icons have no title, so the title checks become one no-op check there. On Linux the icon is a StatusNotifierItem: no bounds and no menu opened from code, so the test checks the example does not offer them; the frame pipeline is asserted at 10 fps (what 30 fps gives on the host is printed), and the icons are counted where the shell counts them: `RegisteredStatusNotifierItems` lists one, then three, then one again after two are removed. Not covered: clicks on the tray icon itself — the input driver refuses the menu-bar layer, those stay manual items of the example's Checklist tab |
33+
| `flutter_tray_popup_test_hyprland.py` | `tray_icon_example` | Linux (Hyprland, the Omarchy laptop) | the example's popup mode (`TRAY_POPUP=1`, or Properties → Popup) together with a Hyprland window rule placed by `cursor_x`/`cursor_y`: a real left click on the tray icon in the bar (located by diffing two `grim` captures of the bar, pressed through `wlpointer`, the kit's zwlr_virtual_pointer tool) maps the window floating and pinned with its right edge 20 px right of the click and its top 20 px below it, under the bar, focused; focusing another window hides it (`[popup] hidden (blur)` on stdout); `Activate` over D-Bus with the cursor in the middle of the screen maps it there (the rule is re-evaluated on every map because the example hides instead of lowering); a click while it shows hides it. The rule is installed into `~/.config/hypr/hyprland.lua` for the run (a marked `require` line plus a file next to it) and removed again, both checked with `hyprctl configerrors`. Needs the item pinned in Omarchy's tray (new items sit in a collapsed drawer) and a Lua-config Hyprland (0.56+: `hyprctl dispatch` takes `hl.dsp.*` calls) |
34+
| `core/tests/window_shadow_remap_linux_test` (in core) | core hidden-title-bar shadow | Linux (Hyprland; any compositor) | no-input regression, run floating (`hl.window_rule` for its class on Hyprland): a window whose title bar is hidden keeps its content size across hide and show whatever xdg states the compositor sends - Hyprland reports every window tiled and maximized, and GDK takes no resize while they stand, so core must not add its shadow gutter before the compositor has answered. Three timings of hiding the title bar; exact content size once settled, no gutter band and no growth across shows in all of them |
3335
| `core_tray_icon_events_test_linux.sh` | core `tray_icon_example` (C++) | Linux | the tray icon's menu export and events, driven over D-Bus the way the shell drives a StatusNotifierItem (the input driver may not press the top bar): with the `RightClicked` trigger the menu is exported (`Menu`, its items in `GetLayout`) without `ItemIsMenu`; `WindowId` is an int32, as gnome-shell reads it; the host's dbusmenu events come back as menu events (`opened` / `closed` on the root once each however often they come, `clicked` on an item); from a host other than gnome-shell `Activate` is a click, a second one within the double-click time a double click (a third starts a new pair, two slow ones are two clicks), `ContextMenu` a right click; `SecondaryActivate` and `Scroll` report nothing yet. Not covered: gnome-shell's own `Activate` (a double click) and a real host's menu, which only real clicks in the top bar show — checked by hand on GNOME 46 and in KDE Plasma 6 |
3436
| `flutter_launch_at_startup_test.py` | `launch_at_startup`'s own example, in a leanflutter checkout (`$LEANFLUTTER_DIR`, default `~/Projects/leanflutter`; see `common.py`) | macOS | the 0.5.x compatible API on nativeapi: Enable registers an `SMAppService` login item, `isEnabled` reads it back, Disable removes it, and `setup(args:)` neither throws nor leaves an entry behind. Always leaves the login item off, including on a failure. Written but not yet run — the display was asleep; the same sequence was verified headlessly instead |
3537
| `flutter_detachable_window_and_browser_tabs_demo.py` / `.ps1` | both Flutter examples | macOS / Windows | the demo video scenarios; also the only coverage of `browser_tabs_example` (reorder, tear off, merge, move by the strip; its log ends with the preserved page state, no PASS/FAIL checks) |
@@ -96,6 +98,8 @@ $R linux desktop tools/gui/flutter_floating_toolbar_test_linux.py 120 # no inp
9698
$R linux desktop tools/gui/flutter_tray_icon_test_linux.py 300 # example built in the host's checkout
9799
$R linux run tools/gui/build_core_example_linux.sh tray_icon_example
98100
$R linux desktop tools/gui/core_tray_icon_events_test_linux.sh 60
101+
$R omarchy setup # Hyprland laptop: pushes wlpointer's sources with the kit
102+
$R omarchy desktop tools/gui/flutter_tray_popup_test_hyprland.py 240 # example built in the host's staged workspace
99103
$R linux desktop tools/gui/flutter_window_shape_demo_linux.py 240 # dry run: no recorder
100104
.agents/skills/record-demo/scripts/record_remote_linux.sh linux \
101105
tools/gui/flutter_window_shape_demo_linux.py tools/gui/output # the take

0 commit comments

Comments
 (0)