linux observer
0
fork

Configure Feed

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

tray: add status header and always emit menu booleans

Adds a macOS-style plain-text status header at the top of the tray
menu. One pure helper `_compute_header_label(status, sync_status,
pause_remaining)` drives both the new header and the existing
`_status_item` inside the status submenu, so the two never diverge.
Uses em dash (U+2014) matching existing user-facing strings in
this file.

Fixes a post-pause resume-visibility bug: `MenuItem.get_properties()`
now always emits `enabled` and `visible` as boolean Variants rather
than only when False. Some SNI hosts (e.g. GNOME AppIndicator)
cache these values and do not re-default to True when the key is
absent, so after pause→resume the resume item would stay hidden
despite `LayoutUpdated`. Always emitting makes the spec-correct
boolean state visible on every layout re-read.

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

+153 -19
+3 -4
src/solstone_linux/dbusmenu.py
··· 62 62 props["label"] = Variant("s", self.label) 63 63 if self.icon_name: 64 64 props["icon-name"] = Variant("s", self.icon_name) 65 - if not self.enabled: 66 - props["enabled"] = Variant("b", False) 67 - if not self.visible: 68 - props["visible"] = Variant("b", False) 65 + # Some hosts cache booleans and won't default missing keys back to True. 66 + props["enabled"] = Variant("b", self.enabled) 67 + props["visible"] = Variant("b", self.visible) 69 68 if self.toggle_type: 70 69 props["toggle-type"] = Variant("s", self.toggle_type) 71 70 props["toggle-state"] = Variant("i", self.toggle_state)
+37 -10
src/solstone_linux/tray.py
··· 44 44 SOURCE_DIR = str(Path(__file__).resolve().parent) 45 45 46 46 47 + def _compute_header_label(status, sync_status, pause_remaining) -> str: 48 + if status == "paused": 49 + if pause_remaining and pause_remaining > 0: 50 + mins = pause_remaining // 60 51 + return f"paused ({mins}m remaining)" 52 + return "paused" 53 + if status == "stopped": 54 + return "not running" 55 + if status == "recording": 56 + if sync_status == "offline": 57 + return "observing — offline (recording locally)" 58 + if sync_status in ("syncing", "uploading", "retrying"): 59 + return "observing — syncing" 60 + return "observing — connected" 61 + if status == "idle": 62 + if sync_status == "offline": 63 + return "idle — offline" 64 + if sync_status in ("syncing", "uploading", "retrying"): 65 + return "idle — syncing" 66 + return "idle — connected" 67 + return str(status) 68 + 69 + 47 70 class TrayApp: 48 71 """In-process tray component — exports SNI on the observer's bus.""" 49 72 ··· 64 87 self._last_stats_time = 0.0 65 88 66 89 # Menu item references for dynamic updates 90 + self._status_header: MenuItem = None 67 91 self._status_item: MenuItem = None 68 92 self._sync_item: MenuItem = None 69 93 self._segment_item: MenuItem = None ··· 201 225 202 226 self._update_status(status) 203 227 self._update_sync(sync_status, sync_progress) 228 + self._update_header(pause_remaining) 204 229 self._update_live_stats(segment_timer, pause_remaining) 205 230 self.paused_remaining = pause_remaining 206 231 207 232 def _build_menu(self): 208 233 """Build the full tray menu structure.""" 234 + 235 + self._status_header = MenuItem(label="observing", enabled=False) 209 236 210 237 # ── Status submenu (live data) ── 211 238 self._status_item = MenuItem(label="observing", enabled=False) ··· 316 343 # ── Assemble full menu ── 317 344 self.menu.set_menu( 318 345 [ 319 - status_submenu, 346 + self._status_header, 320 347 separator(), 321 348 self._pause_submenu, 322 349 self._resume_item, 323 350 separator(), 351 + status_submenu, 324 352 open_journal, 325 353 settings_submenu, 326 354 about_submenu, ··· 347 375 # Update tooltip 348 376 self.sni.set_tooltip("solstone observer", self._build_tooltip()) 349 377 350 - # Update status submenu item 351 - labels = { 352 - "recording": "observing", 353 - "paused": "paused", 354 - "idle": "idle (screen inactive)", 355 - "stopped": "not running", 356 - } 357 - self._status_item.label = labels.get(status, status) 358 - 359 378 # Toggle pause/resume 360 379 is_paused = status == "paused" 361 380 self._pause_submenu.visible = not is_paused ··· 375 394 self.sni.set_status("Active") 376 395 377 396 log.info(f"Status -> {status} (icon: {icon})") 397 + 398 + def _update_header(self, pause_remaining: int): 399 + label = _compute_header_label(self.status, self.sync_status, pause_remaining) 400 + if label == self._status_header.label: 401 + return 402 + self._status_header.label = label 403 + self._status_item.label = label 404 + self.menu.update_item(self._status_header) 378 405 379 406 def _update_sync(self, sync_status: str, progress: str): 380 407 """Update sync status display."""
+39
tests/test_dbusmenu.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from dbus_next import Variant 5 + 6 + from solstone_linux.dbusmenu import MenuItem 7 + 8 + 9 + def test_default_emits_enabled_and_visible_true(): 10 + props = MenuItem(label="foo").get_properties() 11 + 12 + assert props["enabled"] == Variant("b", True) 13 + assert props["visible"] == Variant("b", True) 14 + 15 + 16 + def test_explicit_false_emits_false(): 17 + props = MenuItem(label="x", enabled=False, visible=False).get_properties() 18 + 19 + assert props["enabled"] == Variant("b", False) 20 + assert props["visible"] == Variant("b", False) 21 + 22 + 23 + def test_toggle_true_after_false_still_emits(): 24 + item = MenuItem(label="x", enabled=False, visible=False) 25 + item.enabled = True 26 + item.visible = True 27 + 28 + props = item.get_properties() 29 + 30 + assert props["enabled"] == Variant("b", True) 31 + assert props["visible"] == Variant("b", True) 32 + 33 + 34 + def test_other_keys_still_conditional(): 35 + props = MenuItem().get_properties() 36 + 37 + assert "icon-name" not in props 38 + assert "toggle-type" not in props 39 + assert "children-display" not in props
+74 -5
tests/test_tray.py
··· 4 4 import time 5 5 from pathlib import Path 6 6 from unittest.mock import MagicMock 7 + from unittest.mock import patch 8 + 9 + import pytest 7 10 8 11 from solstone_linux.config import Config 9 12 from solstone_linux.dbusmenu import MenuItem, separator 10 - from solstone_linux.tray import AGENT_INSTRUCTIONS, ICONS, SOURCE_DIR, TrayApp 13 + from solstone_linux.tray import ( 14 + AGENT_INSTRUCTIONS, 15 + ICONS, 16 + SOURCE_DIR, 17 + TrayApp, 18 + _compute_header_label, 19 + ) 11 20 12 21 13 22 def _make_app(tmp_path=None): ··· 54 63 assert app._pause_submenu.children_display == "submenu" 55 64 assert len(app._pause_submenu.children) == 4 56 65 assert app._resume_item.visible is False 66 + assert app.menu._root.children[0] is app._status_header 57 67 assert app.menu._root.children[1].item_type == separator().item_type 58 - assert len(app.menu._root.children) == 10 68 + assert len(app.menu._root.children) == 11 59 69 60 70 61 71 class TestUpdateStatus: ··· 69 79 assert app.status == "paused" 70 80 assert app._pause_submenu.visible is False 71 81 assert app._resume_item.visible is True 72 - assert app._status_item.label == "paused" 73 82 assert app.menu.update_item.call_count == 2 74 83 75 84 def test_update_status_idle(self): ··· 78 87 79 88 app._update_status("idle") 80 89 81 - assert app._status_item.label == "idle (screen inactive)" 90 + assert app.status == "idle" 82 91 83 92 def test_update_status_stopped_sets_attention(self): 84 93 app = _make_app() ··· 86 95 87 96 app._update_status("stopped") 88 97 89 - assert app._status_item.label == "not running" 98 + assert app.status == "stopped" 90 99 assert app.sni._status == "NeedsAttention" 91 100 92 101 def test_update_status_recording_uses_error_icon_when_error_set(self): ··· 161 170 app._update_live_stats(245, 0) 162 171 163 172 app.menu.update_item.assert_not_called() 173 + 174 + 175 + class TestHeaderLabel: 176 + def test_header_recording_synced(self): 177 + app = _make_app() 178 + app._build_menu() 179 + 180 + app.update() 181 + 182 + assert app._status_header.label == "observing — connected" 183 + assert app._status_item.label == "observing — connected" 184 + 185 + def test_header_paused_with_timer(self): 186 + app = _make_app() 187 + app._build_menu() 188 + app._observer._paused = True 189 + app._observer._pause_until = 1000.0 190 + 191 + with patch("solstone_linux.tray.time.monotonic", return_value=100.0): 192 + app.update() 193 + 194 + assert app._status_header.label == "paused (15m remaining)" 195 + assert app._status_item.label == "paused (15m remaining)" 196 + 197 + def test_header_recording_offline(self): 198 + app = _make_app() 199 + app._build_menu() 200 + app._observer._sync = MagicMock() 201 + app._observer._sync.sync_status = "offline" 202 + app._observer._sync.sync_progress = "" 203 + 204 + app.update() 205 + 206 + assert app._status_header.label == "observing — offline (recording locally)" 207 + assert app._status_item.label == "observing — offline (recording locally)" 208 + 209 + 210 + class TestComputeHeaderLabel: 211 + @pytest.mark.parametrize( 212 + "status,sync_status,pause_remaining,expected", 213 + [ 214 + ("recording", "synced", 0, "observing — connected"), 215 + ("recording", "syncing", 0, "observing — syncing"), 216 + ("recording", "uploading", 0, "observing — syncing"), 217 + ("recording", "retrying", 0, "observing — syncing"), 218 + ("recording", "offline", 0, "observing — offline (recording locally)"), 219 + ("idle", "synced", 0, "idle — connected"), 220 + ("idle", "syncing", 0, "idle — syncing"), 221 + ("idle", "uploading", 0, "idle — syncing"), 222 + ("idle", "retrying", 0, "idle — syncing"), 223 + ("idle", "offline", 0, "idle — offline"), 224 + ("paused", "synced", 0, "paused"), 225 + ("paused", "synced", 900, "paused (15m remaining)"), 226 + ("paused", "offline", 59, "paused (0m remaining)"), 227 + ("stopped", "synced", 0, "not running"), 228 + ("weird", "synced", 0, "weird"), 229 + ], 230 + ) 231 + def test_compute_header_label(self, status, sync_status, pause_remaining, expected): 232 + assert _compute_header_label(status, sync_status, pause_remaining) == expected 164 233 165 234 166 235 class TestBuildTooltip: