linux observer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

tray: emit ItemsPropertiesUpdated for property changes (fixes GNOME submenu teardown)

GNOME AppIndicator treats LayoutUpdated as a structural menu change and tears down open submenus while they are animating. For the pause/resume and header label updates, property-only changes need to use ItemsPropertiesUpdated as described by the DBusMenu protocol.

Replace the old update_item path with update_properties, keep LayoutUpdated only for set_menu structural rebuilds, and switch AboutToShow to return False because GetLayout always reads fresh state and there are no pending unsignaled updates.

This supersedes the earlier noblink approach for these three tray call sites by sending precise property updates instead of forcing a structural refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+101 -18
+20 -9
src/solstone_linux/dbusmenu.py
··· 101 101 self._revision += 1 102 102 self.LayoutUpdated(self._revision, 0) 103 103 104 - def update_item(self, item: MenuItem): 105 - """Signal that a menu item's properties changed. 104 + def update_properties(self, item: MenuItem, *names: str): 105 + if not names: 106 + return 106 107 107 - We emit LayoutUpdated rather than ItemsPropertiesUpdated 108 - because it's simpler and universally supported. The tray 109 - host will re-read the layout on next menu open. 110 - """ 111 - self._revision += 1 112 - self.LayoutUpdated(self._revision, 0) 108 + updated = {name: self._property_variant(item, name) for name in names} 109 + self.ItemsPropertiesUpdated([[item.id, updated]], []) 113 110 114 111 def _register_items(self, items: list[MenuItem]): 115 112 for item in items: 116 113 self._items[item.id] = item 117 114 if item.children: 118 115 self._register_items(item.children) 116 + 117 + def _property_variant(self, item: MenuItem, name: str) -> Variant: 118 + if name == "label": 119 + return Variant("s", item.label) 120 + if name == "visible": 121 + return Variant("b", item.visible) 122 + if name == "enabled": 123 + return Variant("b", item.enabled) 124 + if name == "icon-name": 125 + return Variant("s", item.icon_name) 126 + if name == "toggle-state": 127 + return Variant("i", item.toggle_state) 128 + 129 + raise ValueError(f"unsupported menu property: {name}") 119 130 120 131 def _build_layout(self, item: MenuItem, depth: int, props: list[str]): 121 132 """Build the (ia{sv}av) layout tuple for GetLayout.""" ··· 187 198 188 199 @method() 189 200 def AboutToShow(self, item_id: "i") -> "b": 190 - return True # tell host to re-read layout (fresh labels on open) 201 + return False # GetLayout always returns fresh state; no pending unsignaled changes. 191 202 192 203 @method() 193 204 def AboutToShowGroup(self, ids: "ai") -> "aiai":
+3 -3
src/solstone_linux/tray.py
··· 384 384 self._resume_item.label = f"resume ({mins}m remaining)" 385 385 else: 386 386 self._resume_item.label = "resume" 387 - self.menu.update_item(self._pause_submenu) 388 - self.menu.update_item(self._resume_item) 387 + self.menu.update_properties(self._pause_submenu, "visible") 388 + self.menu.update_properties(self._resume_item, "visible", "label") 389 389 390 390 # SNI status 391 391 if status == "stopped" or self.error: ··· 401 401 return 402 402 self._status_header.label = label 403 403 self._status_item.label = label 404 - self.menu.update_item(self._status_header) 404 + self.menu.update_properties(self._status_header, "label") 405 405 406 406 def _update_sync(self, sync_status: str, progress: str): 407 407 """Update sync status display."""
+49 -1
tests/test_dbusmenu.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 + from unittest.mock import MagicMock 5 + 4 6 from dbus_next import Variant 5 7 6 - from solstone_linux.dbusmenu import MenuItem 8 + from solstone_linux.dbusmenu import DBusMenu, MenuItem 7 9 8 10 9 11 def test_default_emits_enabled_and_visible_true(): ··· 37 39 assert "icon-name" not in props 38 40 assert "toggle-type" not in props 39 41 assert "children-display" not in props 42 + 43 + 44 + def test_update_properties_emits_items_properties_updated(): 45 + menu = DBusMenu() 46 + item = MenuItem(label="resume", visible=True) 47 + menu.set_menu([item]) 48 + menu.ItemsPropertiesUpdated = MagicMock() 49 + menu.LayoutUpdated = MagicMock() 50 + revision = menu._revision 51 + item.visible = False 52 + 53 + menu.update_properties(item, "visible") 54 + 55 + menu.ItemsPropertiesUpdated.assert_called_once() 56 + menu.LayoutUpdated.assert_not_called() 57 + assert menu._revision == revision 58 + 59 + updated_props, removed_props = menu.ItemsPropertiesUpdated.call_args.args 60 + assert removed_props == [] 61 + assert len(updated_props) == 1 62 + item_id, props = updated_props[0] 63 + assert item_id == item.id 64 + assert props.keys() == {"visible"} 65 + assert props["visible"].signature == "b" 66 + assert props["visible"].value is False 67 + 68 + 69 + def test_update_properties_noop_when_no_names(): 70 + menu = DBusMenu() 71 + item = MenuItem(label="resume") 72 + menu.set_menu([item]) 73 + menu.ItemsPropertiesUpdated = MagicMock() 74 + menu.LayoutUpdated = MagicMock() 75 + revision = menu._revision 76 + 77 + menu.update_properties(item) 78 + 79 + menu.ItemsPropertiesUpdated.assert_not_called() 80 + menu.LayoutUpdated.assert_not_called() 81 + assert menu._revision == revision 82 + 83 + 84 + def test_about_to_show_returns_false(): 85 + menu = DBusMenu() 86 + 87 + assert DBusMenu.AboutToShow.__wrapped__(menu, 0) is False
+29 -5
tests/test_tray.py
··· 3 3 4 4 import time 5 5 from pathlib import Path 6 + from unittest.mock import call 6 7 from unittest.mock import MagicMock 7 8 from unittest.mock import patch 8 9 ··· 72 73 def test_update_status_paused(self): 73 74 app = _make_app() 74 75 app._build_menu() 75 - app.menu.update_item = MagicMock() 76 + app.menu.update_properties = MagicMock() 76 77 77 78 app._update_status("paused") 78 79 79 80 assert app.status == "paused" 80 81 assert app._pause_submenu.visible is False 81 82 assert app._resume_item.visible is True 82 - assert app.menu.update_item.call_count == 2 83 + assert app.menu.update_properties.call_count >= 2 84 + assert ( 85 + call(app._pause_submenu, "visible") 86 + in app.menu.update_properties.call_args_list 87 + ) 88 + assert ( 89 + call(app._resume_item, "visible", "label") 90 + in app.menu.update_properties.call_args_list 91 + ) 83 92 84 93 def test_update_status_idle(self): 85 94 app = _make_app() ··· 156 165 def test_update_live_stats_skips_unchanged_menu_updates(self): 157 166 app = _make_app() 158 167 app._build_menu() 159 - app.menu.update_item = MagicMock() 168 + app.menu.update_properties = MagicMock() 160 169 app.stats = { 161 170 "captures_today": 5, 162 171 "total_size_mb": 42, ··· 165 174 } 166 175 167 176 app._update_live_stats(245, 0) 168 - app.menu.update_item.reset_mock() 177 + app.menu.update_properties.reset_mock() 169 178 170 179 app._update_live_stats(245, 0) 171 180 172 - app.menu.update_item.assert_not_called() 181 + app.menu.update_properties.assert_not_called() 173 182 174 183 175 184 class TestHeaderLabel: 185 + def test_update_header_emits_label_property_update(self): 186 + app = _make_app() 187 + app._build_menu() 188 + app.menu.update_properties = MagicMock() 189 + app.sync_status = "offline" 190 + 191 + app._update_header(0) 192 + app.menu.update_properties.reset_mock() 193 + 194 + app.sync_status = "synced" 195 + app._update_header(0) 196 + 197 + app.menu.update_properties.assert_called_with(app._status_header, "label") 198 + assert app._status_header.label == "observing — connected" 199 + 176 200 def test_header_recording_synced(self): 177 201 app = _make_app() 178 202 app._build_menu()