Add pystray tray applet wiring and [tray] extra
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2fce7d412a
commit
1451cf7d58
4 changed files with 149 additions and 0 deletions
8
enodia_sentinel/tray/__main__.py
Normal file
8
enodia_sentinel/tray/__main__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""`python -m enodia_sentinel.tray` entry point."""
|
||||
import sys
|
||||
|
||||
from .app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
109
enodia_sentinel/tray/app.py
Normal file
109
enodia_sentinel/tray/app.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""System-tray applet wiring. Imports pystray/PIL (the [tray] extra).
|
||||
|
||||
This module is the GUI shell only. All testable logic lives in state.py,
|
||||
actions.py, and notify.py, which import no GUI libraries.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pystray
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from ..config import Config
|
||||
from . import actions, notify
|
||||
from .state import TrayState, parse_status
|
||||
|
||||
REFRESH_SECONDS = 15
|
||||
|
||||
_COLORS = {
|
||||
"green": (46, 160, 67),
|
||||
"amber": (210, 153, 34),
|
||||
"grey": (110, 110, 110),
|
||||
}
|
||||
|
||||
|
||||
def _make_image(color: str) -> "Image.Image":
|
||||
rgb = _COLORS.get(color, _COLORS["grey"])
|
||||
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.ellipse((8, 8, 56, 56), fill=rgb)
|
||||
return img
|
||||
|
||||
|
||||
class TrayApp:
|
||||
def __init__(self, cfg: Config):
|
||||
self.cfg = cfg
|
||||
self.state: TrayState = parse_status(None)
|
||||
self.icon = pystray.Icon(
|
||||
"enodia-sentinel",
|
||||
icon=_make_image(self.state.color),
|
||||
title=self.state.tooltip,
|
||||
menu=self._menu(),
|
||||
)
|
||||
|
||||
def _menu(self) -> "pystray.Menu":
|
||||
return pystray.Menu(
|
||||
pystray.MenuItem(lambda _i: self.state.summary, None, enabled=False),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Open dashboard", self._on_open),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Start daemon", self._on_start),
|
||||
pystray.MenuItem("Stop daemon", self._on_stop),
|
||||
pystray.MenuItem("Restart daemon", self._on_restart),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Run quick check", self._on_check),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Quit", self._on_quit),
|
||||
)
|
||||
|
||||
def _async(self, fn) -> None:
|
||||
threading.Thread(target=fn, daemon=True).start()
|
||||
|
||||
def _do(self, action) -> None:
|
||||
res = action()
|
||||
notify.notify("Enodia Sentinel", res.message)
|
||||
self.refresh()
|
||||
|
||||
def _on_open(self, _icon=None, _item=None):
|
||||
self._async(lambda: self._do(lambda: actions.open_dashboard(self.cfg)))
|
||||
|
||||
def _on_start(self, _icon=None, _item=None):
|
||||
self._async(lambda: self._do(actions.start_daemon))
|
||||
|
||||
def _on_stop(self, _icon=None, _item=None):
|
||||
self._async(lambda: self._do(actions.stop_daemon))
|
||||
|
||||
def _on_restart(self, _icon=None, _item=None):
|
||||
self._async(lambda: self._do(actions.restart_daemon))
|
||||
|
||||
def _on_check(self, _icon=None, _item=None):
|
||||
self._async(lambda: self._do(actions.run_check))
|
||||
|
||||
def _on_quit(self, _icon=None, _item=None):
|
||||
self.icon.stop()
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.state = parse_status(actions.fetch_status())
|
||||
self.icon.icon = _make_image(self.state.color)
|
||||
self.icon.title = self.state.tooltip
|
||||
self.icon.update_menu()
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
while True:
|
||||
self.refresh()
|
||||
time.sleep(REFRESH_SECONDS)
|
||||
|
||||
def run(self) -> None:
|
||||
def _setup(icon):
|
||||
icon.visible = True
|
||||
threading.Thread(target=self._poll_loop, daemon=True).start()
|
||||
self.icon.run(setup=_setup)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
cfg = Config.load()
|
||||
TrayApp(cfg).run()
|
||||
return 0
|
||||
|
|
@ -23,9 +23,11 @@ dependencies = []
|
|||
|
||||
[project.scripts]
|
||||
enodia-sentinel = "enodia_sentinel.cli:main"
|
||||
enodia-sentinel-tray = "enodia_sentinel.tray.app:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest"]
|
||||
tray = ["pystray", "Pillow"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["enodia_sentinel*"]
|
||||
|
|
|
|||
30
tests/test_tray_imports.py
Normal file
30
tests/test_tray_imports.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""The tray logic modules must not import GUI libs; pyproject wires the extra."""
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TRAY = ROOT / "enodia_sentinel" / "tray"
|
||||
|
||||
|
||||
class TestTrayBoundaries(unittest.TestCase):
|
||||
def test_logic_modules_have_no_gui_imports(self):
|
||||
for name in ("state.py", "actions.py", "notify.py"):
|
||||
text = (TRAY / name).read_text()
|
||||
self.assertNotIn("pystray", text, f"{name} must not import pystray")
|
||||
self.assertNotIn("PIL", text, f"{name} must not import PIL")
|
||||
|
||||
def test_logic_modules_import_without_gui_deps(self):
|
||||
# These imports must succeed even though pystray/Pillow may be absent.
|
||||
from enodia_sentinel.tray import actions, notify, state # noqa: F401
|
||||
|
||||
def test_pyproject_declares_tray_extra_and_script(self):
|
||||
data = tomllib.loads((ROOT / "pyproject.toml").read_text())
|
||||
self.assertIn("tray", data["project"]["optional-dependencies"])
|
||||
self.assertEqual(
|
||||
data["project"]["scripts"]["enodia-sentinel-tray"],
|
||||
"enodia_sentinel.tray.app:main",
|
||||
)
|
||||
# Core runtime deps stay empty.
|
||||
self.assertEqual(data["project"]["dependencies"], [])
|
||||
Loading…
Add table
Add a link
Reference in a new issue