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]
|
[project.scripts]
|
||||||
enodia-sentinel = "enodia_sentinel.cli:main"
|
enodia-sentinel = "enodia_sentinel.cli:main"
|
||||||
|
enodia-sentinel-tray = "enodia_sentinel.tray.app:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest"]
|
dev = ["pytest"]
|
||||||
|
tray = ["pystray", "Pillow"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["enodia_sentinel*"]
|
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