From 5722e86878a806829e8047f2622796198707f830 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:04:32 -0500 Subject: [PATCH 1/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Extract=20LogbookContr?= =?UTF-8?q?oller,=20unifying=20the=20log=20state=20machine=20Refs=20#38?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/controllers/connection.py | 47 +- src/hatty/controllers/logbook.py | 545 ++++++++++++++++++++++ src/hatty/main.py | 418 +++-------------- src/hatty/ui/graph/preview_screen.py | 110 ++--- tests/test_activity_log.py | 30 +- tests/test_device_log.py | 31 +- tests/test_device_log_list.py | 14 +- tests/test_log_scope_cycle.py | 17 +- tests/unit/test_connection_controller.py | 46 +- tests/unit/test_logbook_controller.py | 568 +++++++++++++++++++++++ 10 files changed, 1277 insertions(+), 549 deletions(-) create mode 100644 src/hatty/controllers/logbook.py create mode 100644 tests/unit/test_logbook_controller.py diff --git a/src/hatty/controllers/connection.py b/src/hatty/controllers/connection.py index 7e87fc6..75cf077 100644 --- a/src/hatty/controllers/connection.py +++ b/src/hatty/controllers/connection.py @@ -9,12 +9,6 @@ on ``HACLI``; this controller reaches it through the app reference. """ -from datetime import datetime, timezone - -from hatty.logbook import normalize_entry -from hatty.ui.activity_log_panel import ActivityLogPanel -from hatty.ui.entity_table import get_display_name - class ConnectionController: """Owns the HA websocket message pump. UI plumbing (notify, sub_title, @@ -72,10 +66,7 @@ def _on_ha_connected(self, msg: dict) -> None: app.notify("Reconnected to Home Assistant.", title="Reconnected", severity="information") # The logbook/event_stream subscription (if any) died with the old # socket — re-arm it so a live-open log doesn't go silent post-reconnect. - if app._log_end is None: - log_panel = app.query_one("#activity_log_panel", ActivityLogPanel) - if log_panel.has_class("-visible"): - app.spawn(app.client.subscribe_logbook(app._log_query_ids, app._log_device_ids)) + app.log_ctl.resubscribe_after_reconnect() # Warn once per run when the token travels over cleartext http:// (issue #158). if not self.http_warned and (app.ha_url or "").lower().startswith("http://"): self.http_warned = True @@ -229,27 +220,7 @@ def _handle_event_message(self, msg: dict) -> None: # While a logbook/event_stream subscription is active, it already # carries this same state change (plus device events state_changed # can never see) — appending here too would double the line (issue #19). - if ( - app._log_entity_ids - and entity_id in app._log_entity_ids - and app._log_end is None - and app.client.logbook_subscription_id is None - ): - log_panel = app.query_one("#activity_log_panel", ActivityLogPanel) - if log_panel.has_class("-visible"): - device_class = new_state.get("attributes", {}).get("device_class") or "" - raw = { - "when": datetime.now(timezone.utc).isoformat(), - "state": new_state.get("state", ""), - "entity_id": entity_id, - "name": get_display_name(new_state), - } - # name is always set above, so entity_names/device_names can stay - # empty — resolve_name short-circuits on it (issue #25's transport - # consistency: this now shares format_log_line/state_detail with - # the fetched path instead of writing a raw, unlabeled string). - entry = normalize_entry(raw, {}, {}, {entity_id: device_class}) - app.call_later(log_panel.add_log_entry, entry) + app.log_ctl.handle_state_change(entity_id, new_state) app._clear_pending_call(entity_id) if app._detail_entity_id == entity_id: app.call_later(app.graph_ctl.refresh_detail_panel) @@ -261,15 +232,5 @@ def _handle_event_message(self, msg: dict) -> None: def _handle_logbook_stream_message(self, msg: dict) -> None: """Live logbook/event_stream frames (issue #19) — device-scoped events (a zha_event button press, a ping) never fire state_changed, so this is - the only way they can appear without reloading the log. The panel's own - dedupe (ActivityLogPanel.add_log_entry) absorbs the boundary overlap - between the fetched window and the first live push.""" - app = self._app - raw_entries = msg.get("event", {}).get("events") or [] - if not raw_entries: - return - log_panel = app.query_one("#activity_log_panel", ActivityLogPanel) - if not log_panel.has_class("-visible") or app._log_end is not None: - return - for entry in app.normalize_log_entries(raw_entries): - app.call_later(log_panel.add_log_entry, entry) + the only way they can appear without reloading the log.""" + self._app.log_ctl.handle_stream_frame(msg.get("event", {}).get("events") or []) diff --git a/src/hatty/controllers/logbook.py b/src/hatty/controllers/logbook.py new file mode 100644 index 0000000..2bfe054 --- /dev/null +++ b/src/hatty/controllers/logbook.py @@ -0,0 +1,545 @@ +# hatty — MIT License. See LICENSE file for details. +"""Shared activity-log state machine for both log hosts (HACLI's docked panel +and GraphPreviewScreen's fullscreen-graph panel), extracted so issue #28 (a +third, device-tree-scoped host) is a matter of wiring, not another copy of +this file (issue #38). + +One LogbookController (app.log_ctl) holds a LogSession per open host, keyed +by id(host) — not a single global session, since the main screen's log and a +pushed GraphPreviewScreen's log can both be `-visible` at once (opening a +fullscreen graph via `G` does not close the main log; only the docked-panel +toggle does that mutual-exclusion dance). HAClient has exactly one +logbook_subscription_id, though, so a *live* WS subscription is a singleton +resource — `live_session()` picks the one session (if any) allowed to hold +it, unambiguous by construction since only one host is live-capable. + +Only HACLI is live-capable (LOG_SUPPORTS_LIVE = True). GraphPreviewScreen +stays fetch-only on purpose: its plot event marks are driven by the entries +list `load()` hands back via `host.on_log_entries`, and a live append +wouldn't route through that — subscribing would silently desync the marks +from the list. + +LogScopeOption.resolve is pure (never notifies) so every option can be +resolved just to preview it (the `v` scope popup, issue #38) without side +effects; apply_option is the only place that surfaces cap/no-device notices, +exactly once, for the option actually applied. +""" + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Protocol + +from hatty.logbook import LogEntry, entry_when_iso, is_continuous_sensor, normalize_entries, normalize_entry +from hatty.types import Entity +from hatty.ui.activity_log_panel import ActivityLogPanel +from hatty.ui.device_tree_screen import device_display_name +from hatty.ui.entity_table import get_display_name + +# A device log covering a whole list can expand to many sibling entities; cap +# the set so a single logbook GET's entity= param can't blow up. +_DEVICE_LOG_MAX_ENTITIES = 200 +# Every device_id widens the WS logbook query's event-type set (HA's +# async_determine_event_types), making device count the expensive axis — cap +# it independently of the entity cap above. +_DEVICE_LOG_MAX_DEVICES = 50 + + +@dataclass(frozen=True) +class LogScope: + """What one resolved LogScopeOption logs: the wire ids, the panel title, + and (when capped, or when a cursor_device option's entity has no device) + the facts a caller needs to notify/annotate — resolve() itself never + does, so it's safe to call for every option just to preview it.""" + + entity_ids: list[str] + device_ids: list[str] + title: str + entity_total: int = 0 # pre-cap entity count; 0 when not capped + device_total: int = 0 # pre-cap device count; 0 when not capped + no_device: bool = False # a cursor_device option whose entity has no device + + +@dataclass(frozen=True) +class LogScopeOption: + """One row of the `v` scope popup (or, pre-#38's popup, one step of the + blind cycle). `resolve` is pure — see the module docstring.""" + + id: str + label: str + resolve: Callable[[], LogScope | None] + + +@dataclass +class LogSession: + host: "LogHost" + panel_id: str + supports_live: bool + options: list[LogScopeOption] + option_id: str + query_ids: list[str] + device_ids: list[str] + entity_ids: set[str] + title_base: str + end: "datetime | None" = None + generation: int = 0 + + def panel(self) -> ActivityLogPanel: + return self.host.query_one(f"#{self.panel_id}", ActivityLogPanel) + + def is_visible(self) -> bool: + return self.panel().has_class("-visible") + + +class LogHost(Protocol): + """The per-host hooks LogbookController needs — implemented by HACLI and + GraphPreviewScreen. Documentation-grade typing only, like the other + controllers' untyped `app` reference.""" + + LOG_PANEL_ID: str + LOG_SUPPORTS_LIVE: bool + + def query_one(self, selector: str, expect_type: type) -> ActivityLogPanel: ... + def log_window(self, session: LogSession) -> "tuple[float, datetime | None]": ... + def log_title_suffix(self, session: LogSession) -> str: ... + def on_log_entries(self, entries: list[LogEntry]) -> None: ... + + +class LogbookController: + """Owns every open log session's scope/paging/fetch/subscription state. + Widget lookups go through each session's host; the live WS subscription + and registries are reached through app.client / app.entity_registry etc. + — app is always the HACLI instance (this controller lives at app.log_ctl), + regardless of which host (HACLI itself, or a pushed GraphPreviewScreen) + a given session belongs to.""" + + def __init__(self, app) -> None: + self._app = app + self._sessions: dict[int, LogSession] = {} + + # ── session lifecycle ──────────────────────────────────────────────────── + + def open(self, host: LogHost, *, options: list[LogScopeOption], option_id: str, hint: str) -> LogSession: + session = LogSession( + host=host, + panel_id=host.LOG_PANEL_ID, + supports_live=host.LOG_SUPPORTS_LIVE, + options=options, + option_id=option_id, + query_ids=[], + device_ids=[], + entity_ids=set(), + title_base="", + ) + self._sessions[id(host)] = session + panel = session.panel() + panel.set_hint(hint) + panel.remove_class("-maximized") + panel.add_class("-visible") + self._app.refresh_bindings() + self.apply_option(host, option_id) + return session + + def close(self, host: LogHost) -> None: + session = self._sessions.pop(id(host), None) + if session is None: + return + panel = session.panel() + panel.remove_class("-visible") + panel.remove_class("-maximized") + if session.supports_live: + self._app.spawn(self._app.client.unsubscribe_logbook()) + self._app.refresh_bindings() + + def session_for(self, host: LogHost) -> "LogSession | None": + return self._sessions.get(id(host)) + + def is_open(self, host: LogHost) -> bool: + return id(host) in self._sessions + + def paged_back(self, host: LogHost) -> bool: + session = self.session_for(host) + return session is not None and session.end is not None + + # ── scope option factories ─────────────────────────────────────────────── + + def base_option( + self, option_id: str, label: str, entity_ids: list[str], *, with_devices: bool + ) -> LogScopeOption: + """A fixed base entity set — the table's active list/all-entities + snapshot, or an i/graph-opened entity set. Captured by closure at + build time since the base doesn't change while the panel stays open + (only cursor_option below re-derives on every call).""" + + def _resolve() -> "LogScope | None": + if not entity_ids: + return None + if with_devices: + device_ids = self._device_ids_for_entities(entity_ids) + capped_entities, capped_devices, entity_total, device_total = self._cap(entity_ids, device_ids) + title = self._device_log_title("Device Log", label, capped_devices) + return LogScope(capped_entities, capped_devices, title, entity_total, device_total) + return LogScope(list(entity_ids), [], f"Activity Log — {label}") + + return LogScopeOption(option_id, label, _resolve) + + def cursor_option( + self, option_id: str, selected_entity_id: Callable[[], "str | None"], *, with_device: bool + ) -> LogScopeOption: + """The table's currently-selected row — re-resolved on every call + (not captured at build time), since the cursor can move while the + panel stays open.""" + + def _resolve() -> "LogScope | None": + entity_id = selected_entity_id() + if not entity_id: + return None + if with_device: + entity_ids, cursor_label, device_id = self._get_device_entity_ids(entity_id) + capped_entities, capped_devices, entity_total, device_total = self._cap( + entity_ids, [device_id] if device_id else [] + ) + return LogScope( + capped_entities, + capped_devices, + f"Device Log — {cursor_label}", + entity_total, + device_total, + no_device=device_id is None, + ) + entity = self._app.find_entity(entity_id) + cursor_label = get_display_name(entity) if entity else entity_id + return LogScope([entity_id], [], f"Activity Log — {cursor_label}") + + label = "Selected entity's device" if with_device else "Selected entity" + return LogScopeOption(option_id, label, _resolve) + + # ── applying a scope ───────────────────────────────────────────────────── + + def resolved_options(self, host: LogHost) -> list[tuple[LogScopeOption, "LogScope | None"]]: + session = self.session_for(host) + if session is None: + return [] + return [(option, option.resolve()) for option in session.options] + + def apply_option(self, host: LogHost, option_id: str) -> None: + """Resolve `option_id` and point the session at it — clears + + retitles + refetches + resyncs the subscription, leaving the paged + window and maximized state alone (a scope change in place, not a + reopen). The only place a resolved LogScope's cap/no-device facts + get surfaced as a notification.""" + session = self.session_for(host) + if session is None: + return + option = next((o for o in session.options if o.id == option_id), None) + if option is None: + return + scope = option.resolve() + if scope is None: + return + if scope.no_device: + self._app.notify( + "No device found for the selected entity. Showing single entity log.", title="Device Log" + ) + self._notify_caps(scope.entity_total, scope.device_total) + session.option_id = option_id + session.entity_ids = set(scope.entity_ids) + session.query_ids = list(scope.entity_ids) + session.device_ids = list(scope.device_ids) + session.title_base = scope.title + session.panel().clear() + self.reload(host) + + def handle_scope_popup_result(self, host: LogHost, result: "str | None") -> None: + if result is not None: + self.apply_option(host, result) + + def next_option_id(self, host: LogHost) -> "str | None": + """The next option in the cycle that actually resolves right now, + wrapping — temporary, used only while `v` is still a blind cycle + (pre-scope-popup, issue #38); deleted once LogScopePopup lands.""" + session = self.session_for(host) + if session is None or not session.options: + return None + ids = [o.id for o in session.options] + index = ids.index(session.option_id) if session.option_id in ids else -1 + for step in range(1, len(ids) + 1): + option = session.options[(index + step) % len(ids)] + if option.resolve() is not None: + return option.id + return None + + # ── window / paging ────────────────────────────────────────────────────── + + def reload(self, host: LogHost) -> None: + session = self.session_for(host) + if session is None: + return + session.generation += 1 + session.panel().set_title(session.title_base + host.log_title_suffix(session)) + self._app.spawn(self.load(session)) + self._app.spawn(self.resync_subscription()) + + async def load(self, session: LogSession) -> None: + generation = session.generation + host = session.host + hours, end = host.log_window(session) + entries = await self.fetch_entries(session.query_ids, hours=hours, end=end, device_ids=session.device_ids) + panel = session.panel() + if not panel.has_class("-visible") or session.generation != generation: + return + if entries is None: + self._app.notify( + "Failed to load activity log from Home Assistant.", title="Activity Log", severity="error" + ) + normalized: list[LogEntry] = [] + else: + normalized = self.normalize(entries) + panel.load_history(normalized) + host.on_log_entries(normalized) + + def page(self, host: LogHost, direction: int) -> None: + """direction<0 pages older, >0 pages newer (snapping back to live at + or past "now"). Only ever called for HACLI — GraphPreviewScreen pages + its own GraphWindow instead.""" + session = self.session_for(host) + if session is None: + return + now = datetime.now(timezone.utc) + span = timedelta(hours=self._app.log_hours) + if direction < 0: + session.end = (session.end or now) - span + else: + if session.end is None: + return + new_end = session.end + span + session.end = None if new_end >= now else new_end + self.reload(host) + + def range_suffix(self, session: LogSession) -> str: + """`(last Xh)` while live, or the paged-back window's full start–end + range — mirrors the fullscreen graph's window-status suffix.""" + from hatty.ui.graph.plot_time import ts_to_full + + if session.end is None: + return f" (last {self._format_log_hours(self._app.log_hours)})" + end = session.end + start = end - timedelta(hours=self._app.log_hours) + return f" ({ts_to_full(start.isoformat())} – {ts_to_full(end.isoformat())})" + + # ── fetch / normalize ──────────────────────────────────────────────────── + + def display_names(self) -> tuple[dict[str, str], dict[str, str]]: + """entity_id -> display name, device_id -> display name — the name + maps LogScopePopup's preview (issue #38) and normalize() both need, + built once so there's one precedence chain for both.""" + entity_names = {e["entity_id"]: get_display_name(e) for e in self._app.all_entities if e.get("entity_id")} + for reg in self._app.entity_registry: + entity_id = reg.get("entity_id") + if entity_id and entity_id not in entity_names: + entity_names[entity_id] = reg.get("name") or reg.get("original_name") or entity_id + device_names = {d["id"]: device_display_name(d) for d in self._app.device_registry if d.get("id")} + return entity_names, device_names + + def normalize(self, raw: list[dict]) -> list[LogEntry]: + """Raw REST/WS logbook entries -> the single shape the log panel and + the graph's event marks consume. WS entries have an epoch `when` and + no `name` on state entries (issue #17) — this resolves both.""" + entity_names, device_names = self.display_names() + device_classes = { + e["entity_id"]: e.get("attributes", {}).get("device_class") or "" + for e in self._app.all_entities + if e.get("entity_id") + } + units = { + e["entity_id"]: e.get("attributes", {}).get("unit_of_measurement") or "" + for e in self._app.all_entities + if e.get("entity_id") + } + for reg in self._app.entity_registry: + entity_id = reg.get("entity_id") + if entity_id and not device_classes.get(entity_id): + device_classes[entity_id] = reg.get("device_class") or reg.get("original_device_class") or "" + return normalize_entries(raw, entity_names, device_names, device_classes, units) + + def _continuous_log_ids(self, entity_ids: list[str]) -> list[str]: + """The subset of entity_ids that are continuous sensors (issue #29) + — HA's logbook silently excludes these, so fetch_entries fills the + gap with history-derived entries. Order-preserving.""" + result = [] + for entity_id in entity_ids: + entity = self._app.find_entity(entity_id) + if entity and is_continuous_sensor(entity_id, entity.get("attributes", {})): + result.append(entity_id) + return result + + async def fetch_entries( + self, + entity_ids: list[str], + hours: float, + end: "datetime | None" = None, + device_ids: "list[str] | None" = None, + ) -> "list[dict] | None": + """The one seam both log hosts call instead of client.fetch_logbook + directly — merges in history-derived entries for continuous sensors + (issue #29), which HA's own logbook never returns. Failure semantics + of the base fetch are preserved: a None here still means "ask HA + failed", not "nothing to show".""" + entries = await self._app.client.fetch_logbook(entity_ids, hours=hours, end=end, device_ids=device_ids) + continuous_ids = self._continuous_log_ids(entity_ids) + if not continuous_ids: + return entries + + semaphore = asyncio.Semaphore(8) + + async def _fetch(entity_id: str) -> list[dict]: + async with semaphore: + result = await self._app.client.fetch_state_log(entity_id, hours=hours, end=end) + return result or [] + + synthesized: list[dict] = [] + for rows in await asyncio.gather(*(_fetch(eid) for eid in continuous_ids)): + synthesized.extend(rows) + if not synthesized: + return entries + + merged = list(entries or []) + synthesized + merged.sort(key=lambda e: entry_when_iso(e.get("when"))) + return merged + + # ── device-scope helpers ───────────────────────────────────────────────── + + def _get_device_entity_ids(self, entity_id: str) -> "tuple[list[str], str, str | None]": + """Sibling entity_ids sharing entity_id's device, its display label, + and the device_id itself (None when the entity has no device — the + WS logbook query then falls back to entity-only scope).""" + entity = self._app.find_entity(entity_id) + label = get_display_name(entity) if entity else entity_id + + reg_entry = next((e for e in self._app.entity_registry if e.get("entity_id") == entity_id), None) + device_id = reg_entry.get("device_id") if reg_entry else None + + if not device_id: + return ([entity_id], label, None) + + siblings = [ + e["entity_id"] for e in self._app.entity_registry if e.get("device_id") == device_id and e.get("entity_id") + ] + if not siblings: + siblings = [entity_id] + + return (siblings, label, device_id) + + def _device_ids_for_entities(self, entity_ids: list[str]) -> list[str]: + """Distinct device_ids backing any of entity_ids, order-preserving — + used by both hosts' device-scoped views.""" + reg_device = {e.get("entity_id"): e.get("device_id") for e in self._app.entity_registry} + device_ids: list[str] = [] + seen: set[str] = set() + for entity_id in entity_ids: + device_id = reg_device.get(entity_id) + if device_id and device_id not in seen: + seen.add(device_id) + device_ids.append(device_id) + return device_ids + + @staticmethod + def _device_log_title(prefix: str, label: str, device_ids: list[str]) -> str: + suffix = f" ({len(device_ids)} devices)" if len(device_ids) > 1 else "" + return f"{prefix} — {label}{suffix}" + + @staticmethod + def _cap(entity_ids: list[str], device_ids: list[str]) -> tuple[list[str], list[str], int, int]: + entity_total = len(entity_ids) if len(entity_ids) > _DEVICE_LOG_MAX_ENTITIES else 0 + device_total = len(device_ids) if len(device_ids) > _DEVICE_LOG_MAX_DEVICES else 0 + return entity_ids[:_DEVICE_LOG_MAX_ENTITIES], device_ids[:_DEVICE_LOG_MAX_DEVICES], entity_total, device_total + + def _notify_caps(self, entity_total: int, device_total: int) -> None: + if entity_total: + self._app.notify( + f"Showing device log for the first {_DEVICE_LOG_MAX_ENTITIES} entities.", title="Device Log" + ) + if device_total: + self._app.notify( + f"Showing device log for the first {_DEVICE_LOG_MAX_DEVICES} devices.", title="Device Log" + ) + + @staticmethod + def _format_log_hours(hours: float) -> str: + return f"{int(hours)}h" if hours == int(hours) else f"{hours:.1f}h" + + # ── live subscription + streamed frames ────────────────────────────────── + + def live_session(self) -> "LogSession | None": + """The one session that may own the WS subscription: live-capable + host, window anchored to now, panel actually visible. At most one + exists (GraphPreviewScreen is fetch-only), so no stack/priority is + needed to pick among sessions.""" + for session in self._sessions.values(): + if session.supports_live and session.end is None and session.is_visible(): + return session + return None + + async def resync_subscription(self) -> None: + """Realign the live logbook/event_stream subscription with whichever + session is currently live (issue #19) — called on every open, page, + scope change, and timeframe change, so a stale subscription never + survives. Always unsubscribes first: the real client allocates a + fresh WS id per subscribe, so an old one would otherwise leak + server-side.""" + await self._app.client.unsubscribe_logbook() + session = self.live_session() + if session is not None: + await self._app.client.subscribe_logbook(session.query_ids, session.device_ids) + + def resubscribe_after_reconnect(self) -> None: + """The logbook/event_stream subscription (if any) died with the old + socket — re-arm it so a live-open log doesn't go silent post- + reconnect. No unsubscribe first: client.connect() already reset + logbook_subscription_id to None for the new socket.""" + session = self.live_session() + if session is not None: + self._app.spawn(self._app.client.subscribe_logbook(session.query_ids, session.device_ids)) + + def handle_stream_frame(self, raw_entries: list[dict]) -> None: + """Live logbook/event_stream frames (issue #19) — device-scoped + events (a zha_event button press, a ping) never fire state_changed, + so this is the only way they can appear without reloading the log. + The panel's own dedupe (ActivityLogPanel.add_log_entry) absorbs the + boundary overlap between the fetched window and the first live + push.""" + if not raw_entries: + return + session = self.live_session() + if session is None: + return + panel = session.panel() + for entry in self.normalize(raw_entries): + self._app.call_later(panel.add_log_entry, entry) + + def handle_state_change(self, entity_id: str, new_state: Entity) -> None: + """The state_changed fallback (issue #19) — while a logbook/ + event_stream subscription is active, it already carries this same + state change (plus device events state_changed can never see), so + appending here too would double the line; only fires when no + subscription is live for the session that would want this entity.""" + session = self.live_session() + if session is None or entity_id not in session.entity_ids: + return + if self._app.client.logbook_subscription_id is not None: + return + panel = session.panel() + device_class = new_state.get("attributes", {}).get("device_class") or "" + raw = { + "when": datetime.now(timezone.utc).isoformat(), + "state": new_state.get("state", ""), + "entity_id": entity_id, + "name": get_display_name(new_state), + } + # name is always set above, so entity_names/device_names can stay + # empty — resolve_name short-circuits on it (issue #25's transport + # consistency: this shares format_log_line/state_detail with the + # fetched path instead of writing a raw, unlabeled string). + entry = normalize_entry(raw, {}, {}, {entity_id: device_class}) + self._app.call_later(panel.add_log_entry, entry) diff --git a/src/hatty/main.py b/src/hatty/main.py index a8ed363..9765874 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -1,6 +1,6 @@ # hatty — MIT License. See LICENSE file for details. import asyncio -from datetime import datetime, timedelta, timezone +from datetime import datetime from textual.app import App, ComposeResult from textual.binding import Binding @@ -42,8 +42,9 @@ from hatty.controllers.dashboards import DashboardController from hatty.controllers.graphs import GraphController, _trim_history # noqa: F401 (_trim_history re-exported for tests) from hatty.controllers.lists import ListController +from hatty.controllers.logbook import LogbookController from hatty.controllers.notifications import NotificationController -from hatty.logbook import LogEntry, entry_when_iso, is_continuous_sensor, normalize_entries +from hatty.logbook import LogEntry from hatty.service_calls import _CONTROL_SERVICE_BUILDERS from hatty.types import Entity from hatty.ui.activity_log_panel import ActivityLogPanel @@ -52,7 +53,6 @@ from hatty.ui.confirm_popup import ConfirmPopup from hatty.ui.controls.control_popup import EntityControlPopup from hatty.ui.dashboard.screen import DashboardScreen -from hatty.ui.device_tree_screen import device_display_name from hatty.ui.entity_table import EntitiesTable, entity_matches, get_display_name from hatty.ui.graph.entity_detail import EntityDetailPanel from hatty.ui.help_popup import HelpPopup @@ -145,6 +145,7 @@ def __init__(self, config_path: str | None = None, demo: bool = False): self.graph_ctl = GraphController(self) self.conn_ctl = ConnectionController(self) self.notify_ctl = NotificationController(self) + self.log_ctl = LogbookController(self) self.all_entities: list = [] self.entity_registry: list = [] @@ -158,19 +159,6 @@ def __init__(self, config_path: str | None = None, demo: bool = False): self.columns = list(DEFAULT_COLUMNS) self.ha_url = "" self.current_view = "entities" - self._log_entity_ids: set[str] = set() - self._log_query_ids: list[str] = [] - self._log_device_ids: list[str] = [] - self._log_generation: int = 0 - # The `v`-cycled scope: _log_base is where the log was opened from - # (a fixed entity set, or the entity table), _log_base_ids/_log_base_label - # are a snapshot taken at open time, and _log_view is the current step. - self._log_base: str = self._LOG_BASE_TABLE - self._log_base_ids: list[str] = [] - self._log_base_label: str = "" - self._log_view: str = "base" - self._log_title_base: str = "" - self._log_end: datetime | None = None self._update_pending = False self.pending_call_status: dict[str, str] = {} self._pending_call_timers: dict[str, Timer] = {} @@ -205,6 +193,19 @@ def graph_hours(self) -> float: def log_hours(self) -> float: return self.app_config.get(CONFIG_KEY_LOG_HOURS, DEFAULT_LOG_HOURS) + # ── LogHost hooks (LogbookController) — see controllers/logbook.py ────── + LOG_PANEL_ID: str = "activity_log_panel" + LOG_SUPPORTS_LIVE: bool = True + + def log_window(self, session) -> tuple[float, "datetime | None"]: + return self.log_hours, session.end + + def log_title_suffix(self, session) -> str: + return self.log_ctl.range_suffix(session) + + def on_log_entries(self, entries: list[LogEntry]) -> None: + pass + # ── Domain state lives on the controllers; these proxies preserve the app's # historical surface — screens and tests read *and assign* these directly. # Each is a real property, so assignment still routes to the controller. ── @@ -767,8 +768,8 @@ def action_toggle_graph(self) -> None: self.notify("No graph available for this entity type.", severity="warning") return - if self.query_one("#activity_log_panel", ActivityLogPanel).has_class("-visible"): - self._close_log_panel() + if self.log_ctl.is_open(self): + self.log_ctl.close(self) self.graph_ctl.open_graph_for(entity_id, entity) @@ -810,31 +811,18 @@ async def _load_and_refresh() -> None: self.spawn(_load_and_refresh()) - def _close_log_panel(self) -> None: - log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) - log_panel.remove_class("-visible") - log_panel.remove_class("-maximized") - self._log_entity_ids.clear() - self._log_query_ids = [] - self._log_device_ids = [] - self._log_base_ids = [] - self._log_view = "base" - self._log_end = None - self.spawn(self.client.unsubscribe_logbook()) - self.refresh_bindings() - def action_maximize_log(self) -> None: - log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) - if not log_panel.has_class("-visible"): + if not self.log_ctl.is_open(self): return + log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) log_panel.set_maximized(not log_panel.has_class("-maximized")) def action_show_log_entries(self) -> None: """`V` — browse the open log's retained entries and read a truncated line's full text (issue #23).""" - log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) - if not log_panel.has_class("-visible"): + if not self.log_ctl.is_open(self): return + log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) entries = log_panel.entries if not entries: self.notify("No activity log entries to show.", title="Activity Log") @@ -843,42 +831,6 @@ def action_show_log_entries(self) -> None: _LOG_HINT = "v scope · f maximize · V full text · ←/→ older/newer · T timeframe · a/i close" - # `v`'s activity-log scope cycle (issue #27, mirroring the fullscreen - # graph's `v` from issue #21). _log_base names where the open log's base - # entity set came from: a fixed set (the inline graph's lines, or `i`'s - # single entity) only ever widens through the first two views; the - # entity table (an active list, or the no-list "first 50" fallback) also - # offers the two cursor-scoped views. - _LOG_BASE_ENTITIES = "entities" - _LOG_BASE_TABLE = "table" - _LOG_VIEWS_ENTITIES = ("base", "base_devices") - _LOG_VIEWS_TABLE = (*_LOG_VIEWS_ENTITIES, "cursor", "cursor_device") - _LOG_VIEW_TITLES = { - "base": "Activity Log", - "base_devices": "Device Log", - "cursor": "Activity Log", - "cursor_device": "Device Log", - } - - @staticmethod - def _format_log_hours(hours: float) -> str: - return f"{int(hours)}h" if hours == int(hours) else f"{hours:.1f}h" - - def _log_range_suffix(self) -> str: - """`(last Xh)` while live, or the paged-back window's full start–end - range — mirrors the fullscreen graph's window-status suffix.""" - from hatty.ui.graph.plot_time import ts_to_full - - if self._log_end is None: - return f" (last {self._format_log_hours(self.log_hours)})" - end = self._log_end - start = end - timedelta(hours=self.log_hours) - return f" ({ts_to_full(start.isoformat())} – {ts_to_full(end.isoformat())})" - - def _set_log_title(self) -> None: - log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) - log_panel.set_title(self._log_title_base + self._log_range_suffix()) - def _graph_entity_ids(self) -> list[str]: """The graphed entity plus its `+` comparison lines, primary first.""" entity_id = self._detail_entity_id @@ -899,192 +851,25 @@ def _log_label_for_ids(self, entity_ids: list[str]) -> str: label += f" +{len(entity_ids) - 1} more" return label - def _set_log_scope(self, entity_ids: list[str], title: str, device_ids: list[str] | None = None) -> None: - """Point the open panel at a new scope and refetch, leaving the paged - window (_log_end) and the maximized state alone — `v` cycles scope in - place, unlike _open_log_panel, which (re)opens from scratch.""" - self._log_entity_ids = set(entity_ids) - self._log_query_ids = list(entity_ids) - self._log_device_ids = list(device_ids) if device_ids else [] - self._log_title_base = title - self.query_one("#activity_log_panel", ActivityLogPanel).clear() - self._reload_log() - - def _open_log_panel( - self, - entity_ids: list[str], - title: str, - device_ids: list[str] | None = None, - *, - base: str = _LOG_BASE_TABLE, - base_ids: list[str] | None = None, - base_label: str = "", - ) -> None: - log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) - self._log_end = None - self._log_base = base - self._log_base_ids = list(base_ids) if base_ids is not None else list(entity_ids) - self._log_base_label = base_label - self._log_view = "base" - log_panel.set_hint(self._LOG_HINT) - log_panel.remove_class("-maximized") - log_panel.add_class("-visible") - self.refresh_bindings() - self._set_log_scope(entity_ids, title, device_ids) - - def _reload_log(self) -> None: - self._log_generation += 1 - current_gen = self._log_generation - self._set_log_title() - self.spawn(self._load_activity_log(self._log_query_ids, current_gen, self._log_device_ids)) - self.spawn(self._resync_log_subscription()) - - async def _resync_log_subscription(self) -> None: - """Realign the live logbook/event_stream subscription with the panel's - current scope/live-ness (issue #19) — called on every open, page, and - timeframe change, so a stale subscription never survives a scope - change. Always unsubscribes first: the real client allocates a fresh - WS id per subscribe, so an old one would otherwise leak server-side.""" - await self.client.unsubscribe_logbook() - if self._log_end is None: - panel = self.query_one("#activity_log_panel", ActivityLogPanel) - if panel.has_class("-visible"): - await self.client.subscribe_logbook(self._log_query_ids, self._log_device_ids) - def action_log_older(self) -> None: - now = datetime.now(timezone.utc) - self._log_end = (self._log_end or now) - timedelta(hours=self.log_hours) - self._reload_log() + self.log_ctl.page(self, -1) def action_log_newer(self) -> None: - if self._log_end is None: - return - now = datetime.now(timezone.utc) - new_end = self._log_end + timedelta(hours=self.log_hours) - self._log_end = None if new_end >= now else new_end - self._reload_log() - - # A device log covering a whole list can expand to many sibling entities; cap - # the set so a single logbook GET's entity= param can't blow up. - _DEVICE_LOG_MAX_ENTITIES = 200 - # Every device_id widens the WS logbook query's event-type set (HA's - # async_determine_event_types), making device count the expensive axis — - # cap it independently of the entity cap above. - _DEVICE_LOG_MAX_DEVICES = 50 - - def _cap_log_scope(self, entity_ids: list[str], device_ids: list[str]) -> tuple[list[str], list[str]]: - """Truncate a widened (entity_ids, device_ids) pair to the caps above, - notifying once per truncation — shared by every device-scoped view.""" - if len(entity_ids) > self._DEVICE_LOG_MAX_ENTITIES: - entity_ids = entity_ids[: self._DEVICE_LOG_MAX_ENTITIES] - self.notify( - f"Showing device log for the first {self._DEVICE_LOG_MAX_ENTITIES} entities.", - title="Device Log", - ) - if len(device_ids) > self._DEVICE_LOG_MAX_DEVICES: - device_ids = device_ids[: self._DEVICE_LOG_MAX_DEVICES] - self.notify( - f"Showing device log for the first {self._DEVICE_LOG_MAX_DEVICES} devices.", - title="Device Log", - ) - return entity_ids, device_ids - - def _get_device_entity_ids(self, entity_id: str) -> tuple[list[str], str, str | None]: - """Sibling entity_ids sharing entity_id's device, its display label, - and the device_id itself (None when the entity has no device — the - WS logbook query then falls back to entity-only scope).""" - entity = self.find_entity(entity_id) - label = get_display_name(entity) if entity else entity_id - - reg_entry = next((e for e in self.entity_registry if e.get("entity_id") == entity_id), None) - device_id = reg_entry.get("device_id") if reg_entry else None - - if not device_id: - return ([entity_id], label, None) - - siblings = [ - e["entity_id"] for e in self.entity_registry if e.get("device_id") == device_id and e.get("entity_id") - ] - if not siblings: - siblings = [entity_id] - - return (siblings, label, device_id) - - def _device_ids_for_entities(self, entity_ids: list[str]) -> list[str]: - """Distinct device_ids backing any of entity_ids, order-preserving — - used by both surfaces' `v`-cycled device-scoped event log views - (issue #18, #21, #27).""" - reg_device = {e.get("entity_id"): e.get("device_id") for e in self.entity_registry} - device_ids: list[str] = [] - seen: set[str] = set() - for entity_id in entity_ids: - device_id = reg_device.get(entity_id) - if device_id and device_id not in seen: - seen.add(device_id) - device_ids.append(device_id) - return device_ids - - def _device_log_title(self, prefix: str, label: str, device_ids: list[str]) -> str: - suffix = f" ({len(device_ids)} devices)" if len(device_ids) > 1 else "" - return f"{prefix} — {label}{suffix}" - - def _log_views(self) -> tuple[str, ...]: - """The `v` cycle for the open log's base — the table base additionally - offers the two cursor-scoped views (issue #27).""" - return self._LOG_VIEWS_TABLE if self._log_base == self._LOG_BASE_TABLE else self._LOG_VIEWS_ENTITIES - - def _log_view_scope(self, view: str) -> tuple[list[str], list[str], str] | None: - """(entity_ids, device_ids, title) for one step of the `v` cycle, or - None if it can't resolve right now (a cursor view with no selected - row) — action_cycle_log_scope skips over a None step.""" - base_ids = list(self._log_base_ids) - label = self._log_base_label - title_prefix = self._LOG_VIEW_TITLES[view] - - if view == "base": - return base_ids, [], f"{title_prefix} — {label}" - if view == "base_devices": - device_ids = self._device_ids_for_entities(base_ids) - entity_ids, device_ids = self._cap_log_scope(base_ids, device_ids) - return entity_ids, device_ids, self._device_log_title(title_prefix, label, device_ids) - - entity_id = self._selected_entity_id() - if not entity_id: - return None - if view == "cursor": - entity = self.find_entity(entity_id) - cursor_label = get_display_name(entity) if entity else entity_id - return [entity_id], [], f"{title_prefix} — {cursor_label}" - if view == "cursor_device": - entity_ids, cursor_label, device_id = self._get_device_entity_ids(entity_id) - if not device_id: - self.notify(f"No device found for {entity_id}. Showing single entity log.", title="Device Log") - return entity_ids, [device_id] if device_id else [], f"{title_prefix} — {cursor_label}" - return None + self.log_ctl.page(self, 1) def action_cycle_log_scope(self) -> None: """`v` — advance the open log's scope one step, wrapping (issue #27, mirroring the fullscreen graph's `v`, issue #21). A scope change, not - a reopen: the paged window and the maximized state survive. Views + a reopen: the paged window and the maximized state survive. Options that can't resolve right now are skipped. check_action gates this off while the log is closed.""" - views = self._log_views() - index = views.index(self._log_view) - for step in range(1, len(views) + 1): - view = views[(index + step) % len(views)] - scope = self._log_view_scope(view) - if scope is None: - continue - entity_ids, device_ids, title = scope - self._log_view = view - self._set_log_scope(entity_ids, title, device_ids) - return + next_id = self.log_ctl.next_option_id(self) + if next_id is not None: + self.log_ctl.apply_option(self, next_id) def action_toggle_activity_log(self) -> None: - log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) - - if log_panel.has_class("-visible"): - self._close_log_panel() + if self.log_ctl.is_open(self): + self.log_ctl.close(self) return graph_ids = self._open_graph_log_ids() @@ -1093,19 +878,16 @@ def action_toggle_activity_log(self) -> None: if graph_ids: label = self._log_label_for_ids(graph_ids) - self._open_log_panel( - graph_ids, - f"Activity Log — {label}", - base=self._LOG_BASE_ENTITIES, - base_ids=graph_ids, - base_label=label, - ) + options = [ + self.log_ctl.base_option("entities", label, graph_ids, with_devices=False), + self.log_ctl.base_option("entities_devices", label, graph_ids, with_devices=True), + ] + self.log_ctl.open(self, options=options, option_id="entities", hint=self._LOG_HINT) return if self.current_list_name: entity_ids = list(self.entity_lists.get(self.current_list_name, [])) base_label = self.current_list_name - title = f"Activity Log — {base_label}" else: entity_ids = [e["entity_id"] for e in self.all_entities] if len(entity_ids) > 50: @@ -1115,19 +897,22 @@ def action_toggle_activity_log(self) -> None: title="Activity Log", ) base_label = "All Entities" - title = "Activity Log — All Entities" if entity_ids else "Activity Log" if not entity_ids: self.notify("No entities to log. Select a list or add entities.", severity="warning") return - self._open_log_panel(entity_ids, title, base=self._LOG_BASE_TABLE, base_label=base_label) + options = [ + self.log_ctl.base_option("list", base_label, entity_ids, with_devices=False), + self.log_ctl.base_option("list_devices", base_label, entity_ids, with_devices=True), + self.log_ctl.cursor_option("cursor", self._selected_entity_id, with_device=False), + self.log_ctl.cursor_option("cursor_device", self._selected_entity_id, with_device=True), + ] + self.log_ctl.open(self, options=options, option_id="list", hint=self._LOG_HINT) def action_toggle_entity_log(self) -> None: - log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) - - if log_panel.has_class("-visible"): - self._close_log_panel() + if self.log_ctl.is_open(self): + self.log_ctl.close(self) return graph_ids = self._open_graph_log_ids() @@ -1142,13 +927,11 @@ def action_toggle_entity_log(self) -> None: entity = self.find_entity(entity_id) label = get_display_name(entity) if entity else entity_id - self._open_log_panel( - [entity_id], - f"Activity Log — {label}", - base=self._LOG_BASE_ENTITIES, - base_ids=[entity_id], - base_label=label, - ) + options = [ + self.log_ctl.base_option("entities", label, [entity_id], with_devices=False), + self.log_ctl.base_option("entities_devices", label, [entity_id], with_devices=True), + ] + self.log_ctl.open(self, options=options, option_id="entities", hint=self._LOG_HINT) def on_data_table_cell_highlighted(self, event: DataTable.CellHighlighted) -> None: if self._detail_entity_id is None: @@ -1174,90 +957,6 @@ def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: if entity_id: self.toggle_or_open_controls(entity_id) - def normalize_log_entries(self, raw: list[dict]) -> list[LogEntry]: - """Raw REST/WS logbook entries -> the single shape the log panel and - the graph's event marks consume. WS entries have an epoch `when` and - no `name` on state entries (issue #17) — this resolves both.""" - entity_names = {e["entity_id"]: get_display_name(e) for e in self.all_entities if e.get("entity_id")} - device_classes = { - e["entity_id"]: e.get("attributes", {}).get("device_class") or "" - for e in self.all_entities - if e.get("entity_id") - } - units = { - e["entity_id"]: e.get("attributes", {}).get("unit_of_measurement") or "" - for e in self.all_entities - if e.get("entity_id") - } - for reg in self.entity_registry: - entity_id = reg.get("entity_id") - if entity_id and entity_id not in entity_names: - entity_names[entity_id] = reg.get("name") or reg.get("original_name") or entity_id - if entity_id and not device_classes.get(entity_id): - device_classes[entity_id] = reg.get("device_class") or reg.get("original_device_class") or "" - device_names = {d["id"]: device_display_name(d) for d in self.device_registry if d.get("id")} - return normalize_entries(raw, entity_names, device_names, device_classes, units) - - def _continuous_log_ids(self, entity_ids: list[str]) -> list[str]: - """The subset of entity_ids that are continuous sensors (issue #29) - — HA's logbook silently excludes these, so fetch_log_entries fills - the gap with history-derived entries. Order-preserving.""" - result = [] - for entity_id in entity_ids: - entity = self.find_entity(entity_id) - if entity and is_continuous_sensor(entity_id, entity.get("attributes", {})): - result.append(entity_id) - return result - - async def fetch_log_entries( - self, - entity_ids: list[str], - hours: float, - end: datetime | None = None, - device_ids: list[str] | None = None, - ) -> list[dict] | None: - """The one seam both log surfaces call instead of client.fetch_logbook - directly — merges in history-derived entries for continuous sensors - (issue #29), which HA's own logbook never returns. Failure semantics - of the base fetch are preserved: a None here still means "ask HA - failed", not "nothing to show".""" - entries = await self.client.fetch_logbook(entity_ids, hours=hours, end=end, device_ids=device_ids) - continuous_ids = self._continuous_log_ids(entity_ids) - if not continuous_ids: - return entries - - semaphore = asyncio.Semaphore(8) - - async def _fetch(entity_id: str) -> list[dict]: - async with semaphore: - result = await self.client.fetch_state_log(entity_id, hours=hours, end=end) - return result or [] - - synthesized: list[dict] = [] - for rows in await asyncio.gather(*(_fetch(eid) for eid in continuous_ids)): - synthesized.extend(rows) - if not synthesized: - return entries - - merged = list(entries or []) + synthesized - merged.sort(key=lambda e: entry_when_iso(e.get("when"))) - return merged - - async def _load_activity_log( - self, entity_ids: list[str], generation: int, device_ids: list[str] | None = None - ) -> None: - entries = await self.fetch_log_entries( - entity_ids, hours=self.log_hours, end=self._log_end, device_ids=device_ids - ) - panel = self.query_one("#activity_log_panel", ActivityLogPanel) - if not panel.has_class("-visible") or self._log_generation != generation: - return - if entries is None: - self.notify("Failed to load activity log from Home Assistant.", title="Activity Log", severity="error") - panel.load_history([]) - else: - panel.load_history(self.normalize_log_entries(entries)) - def action_cycle_graph_type(self) -> None: panel = self.query_one("#detail_panel", EntityDetailPanel) panel.cycle_graph_type() @@ -1271,7 +970,7 @@ def action_show_graph_duration(self) -> None: # The two panels are mutually exclusive (opening either closes the # other), so `T` unambiguously targets whichever is open — the # activity log's timeframe when it's visible, the graph's otherwise. - if self.query_one("#activity_log_panel", ActivityLogPanel).has_class("-visible"): + if self.log_ctl.is_open(self): self._show_log_duration_popup() return @@ -1296,7 +995,7 @@ def callback(hours: float | None) -> None: return self.app_config[CONFIG_KEY_LOG_HOURS] = hours self.persist() - self._reload_log() + self.log_ctl.reload(self) self.push_screen(GraphDurationPopup(current, title="Activity Log Timeframe"), callback) @@ -1444,17 +1143,10 @@ def check_action(self, action: str, parameters: tuple) -> bool | None: elif action == "add_to_graph": panel = self.query_one("#detail_panel", EntityDetailPanel) return panel.has_class("-visible") - elif action == "maximize_log": - return self.query_one("#activity_log_panel", ActivityLogPanel).has_class("-visible") - elif action == "show_log_entries": - return self.query_one("#activity_log_panel", ActivityLogPanel).has_class("-visible") - elif action == "cycle_log_scope": - return self.query_one("#activity_log_panel", ActivityLogPanel).has_class("-visible") - elif action == "log_older": - return self.query_one("#activity_log_panel", ActivityLogPanel).has_class("-visible") + elif action in ("maximize_log", "show_log_entries", "cycle_log_scope", "log_older"): + return self.log_ctl.is_open(self) elif action == "log_newer": - log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) - return log_panel.has_class("-visible") and self._log_end is not None + return self.log_ctl.is_open(self) and self.log_ctl.paged_back(self) elif action == "toggle_graph": panel = self.query_one("#detail_panel", EntityDetailPanel) if panel.has_class("-visible"): diff --git a/src/hatty/ui/graph/preview_screen.py b/src/hatty/ui/graph/preview_screen.py index 6581cb7..b538413 100644 --- a/src/hatty/ui/graph/preview_screen.py +++ b/src/hatty/ui/graph/preview_screen.py @@ -220,15 +220,9 @@ class GraphPreviewScreen(Screen): ("Other", frozenset({"show_list_popup", "show_help", "go_back"})), ) - # The fullscreen graph's log scopes, cycled by `v` (issue #21) — widening - # from just the plotted entities, to their devices' events too. HACLI has - # its own same-named _log_view (issue #27) for the main screen's log — a - # different state machine, no relation to this class's. - _LOG_VIEWS = ("entity", "device") - _LOG_VIEW_TITLES = { - "entity": "Activity Log", - "device": "Device Log", - } + # LogHost hooks (LogbookController, issue #38) — see controllers/logbook.py. + LOG_PANEL_ID: str = "preview_log_panel" + LOG_SUPPORTS_LIVE: bool = False def __init__( self, @@ -264,10 +258,6 @@ def __init__( self._cursor_mode = False self._cursor_index = 0 self._events: list[LogEntry] = [] - # Which of _LOG_VIEWS the open log panel/worker is fetching for - # (issue #21); reset to "entity" on every open, advanced by `v`, - # read by _log_scope. - self._log_view = "entity" # Delegating properties over the pure GraphWindow, so existing reads/writes # of these attrs across the screen and tests keep working unchanged. @@ -329,10 +319,17 @@ def check_action(self, action: str, parameters: tuple) -> bool | None: return True def _log_visible(self) -> bool: - try: - return self.query_one("#preview_log_panel", ActivityLogPanel).has_class("-visible") - except Exception: - return False + return self.app.log_ctl.is_open(self) + + def log_window(self, session) -> "tuple[float, datetime]": + return self._window_hours(), self._window_end or datetime.now(timezone.utc) + + def log_title_suffix(self, session) -> str: + return "" + + def on_log_entries(self, entries: list[LogEntry]) -> None: + self._events = entries + self._redraw() def compose(self) -> ComposeResult: yield Label("", id="preview_title") @@ -795,10 +792,8 @@ def action_show_help(self) -> None: self.app.action_show_help() def _close_event_log(self) -> None: - log_panel = self.query_one("#preview_log_panel", ActivityLogPanel) - log_panel.remove_class("-visible") + self.app.log_ctl.close(self) self._redraw() - self.refresh_bindings() def action_close_event_log(self) -> None: """escape/q — a further escape/toggle closes; a maximized panel gets @@ -834,71 +829,36 @@ def _redraw(self) -> None: entity = self.app.find_entity(self._entity_id) self._update_display(entity) - def _event_log_title(self) -> str: - entity = self.app.find_entity(self._entity_id) - label = get_display_name(entity) if entity else self._entity_id - if len(self._entity_ids) > 1: - label += f" +{len(self._entity_ids) - 1} more" - return f"{self._LOG_VIEW_TITLES[self._log_view]} — {label}" - - def _log_scope(self) -> tuple[list[str], list[str] | None]: - """entity_ids/device_ids to fetch for the current _log_view — the - plotted entities alone, or widened to their devices' events too - (issue #18).""" - if self._log_view == "entity": - return self._entity_ids, None - return self._entity_ids, self.app._device_ids_for_entities(self._entity_ids) - - def _reload_event_log(self) -> None: - """Shared by opening and by `v` cycling — clear, retitle, refetch.""" - log_panel = self.query_one("#preview_log_panel", ActivityLogPanel) - log_panel.set_title(self._event_log_title()) - log_panel.clear() - self.run_worker(self._load_events(), exclusive=True, group="events") - self.refresh_bindings() - def _open_event_log(self) -> None: - log_panel = self.query_one("#preview_log_panel", ActivityLogPanel) - self._log_view = "entity" - log_panel.set_hint("v view · f max · V full text · a close · ←/→ page with the graph") - log_panel.set_maximized(False) - log_panel.add_class("-visible") - self._reload_event_log() + label = self.app._log_label_for_ids(self._entity_ids) + options = [ + self.app.log_ctl.base_option("entities", label, self._entity_ids, with_devices=False), + self.app.log_ctl.base_option("entities_devices", label, self._entity_ids, with_devices=True), + ] + self.app.log_ctl.open( + self, + options=options, + option_id="entities", + hint="v view · f max · V full text · a close · ←/→ page with the graph", + ) def action_toggle_event_log(self) -> None: - log_panel = self.query_one("#preview_log_panel", ActivityLogPanel) - if log_panel.has_class("-visible"): + if self.app.log_ctl.is_open(self): self._close_event_log() return self._open_event_log() def action_cycle_log_view(self) -> None: - """`v` — advance through _LOG_VIEWS, wrapping (issue #21). A no-op - while the log is closed (gated by check_action).""" - index = self._LOG_VIEWS.index(self._log_view) - self._log_view = self._LOG_VIEWS[(index + 1) % len(self._LOG_VIEWS)] - self._reload_event_log() + """`v` — advance through the scope options, wrapping (issue #21). A + no-op while the log is closed (gated by check_action).""" + next_id = self.app.log_ctl.next_option_id(self) + if next_id is not None: + self.app.log_ctl.apply_option(self, next_id) async def _refresh_events_if_open(self) -> None: - if self.query_one("#preview_log_panel", ActivityLogPanel).has_class("-visible"): - await self._load_events() - - async def _load_events(self) -> None: - end = self._window_end or datetime.now(timezone.utc) - hours = self._window_hours() - entity_ids, device_ids = self._log_scope() - entries = await self.app.fetch_log_entries(entity_ids, hours=hours, end=end, device_ids=device_ids) - log_panel = self.query_one("#preview_log_panel", ActivityLogPanel) - if not log_panel.has_class("-visible"): - return # closed while the fetch was in flight - if entries is None: - self.notify("Failed to load activity log from Home Assistant.", title="Activity Log", severity="error") - self._events = [] - log_panel.load_history([]) - else: - self._events = self.app.normalize_log_entries(entries) - log_panel.load_history(self._events) - self._redraw() + session = self.app.log_ctl.session_for(self) + if session is not None: + await self.app.log_ctl.load(session) def action_show_list_popup(self) -> None: # Mirror DashboardScreen: dismiss the fullscreen graph and jump straight back diff --git a/tests/test_activity_log.py b/tests/test_activity_log.py index f8debe2..f07033e 100644 --- a/tests/test_activity_log.py +++ b/tests/test_activity_log.py @@ -218,7 +218,7 @@ async def test_a_scopes_to_graphed_entity_over_list_scope(make_app, sample_entit await pilot.press("a") await pilot.pause() - assert app._log_entity_ids == {"sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} title = str(app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content) assert "Temperature Sensor" in title assert "my_list" not in title @@ -239,7 +239,7 @@ async def test_a_includes_comparison_entities_when_graphed(make_app): await pilot.press("a") await pilot.pause() - assert app._log_entity_ids == {"sensor.temperature", "sensor.humidity"} + assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature", "sensor.humidity"} assert app.client.logbook_calls[-1][0] == ["sensor.temperature", "sensor.humidity"] title = str(app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content) assert "Temperature Sensor" in title @@ -258,7 +258,7 @@ async def test_i_scopes_to_graphed_entity_when_graph_panel_open(make_app, sample await pilot.press("i") await pilot.pause() - assert app._log_entity_ids == {"sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} title = str(app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content) assert "my_list" not in title @@ -274,7 +274,7 @@ async def test_i_opens_single_entity_activity_log_and_i_again_closes_it(make_app await pilot.pause() panel = app.query_one("#activity_log_panel", ActivityLogPanel) assert panel.has_class("-visible") - assert app._log_entity_ids == {"sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} title = str(panel.query_one("#log_title", Label).content) assert "Temperature Sensor" in title @@ -310,15 +310,15 @@ async def test_left_arrow_pages_log_older_when_open(make_app): await pilot.pause() await pilot.press("a") await pilot.pause() - assert app._log_end is None + assert app.log_ctl.session_for(app).end is None await pilot.press("left") await pilot.pause() - assert app._log_end is not None + assert app.log_ctl.session_for(app).end is not None last_call = app.client.logbook_calls[-1] assert last_call[1] == app.log_hours # hours - assert last_call[2] == app._log_end # end + assert last_call[2] == app.log_ctl.session_for(app).end # end async def test_left_arrow_is_inert_while_log_closed(make_app): @@ -327,7 +327,7 @@ async def test_left_arrow_is_inert_while_log_closed(make_app): await pilot.pause() await pilot.press("left") await pilot.pause() - assert app._log_end is None + assert app.log_ctl.session_for(app) is None assert not app.query_one("#activity_log_panel", ActivityLogPanel).has_class("-visible") @@ -340,14 +340,14 @@ async def test_right_arrow_pages_log_newer(make_app): await pilot.press("left") await pilot.press("left") await pilot.pause() - paged_back_end = app._log_end + paged_back_end = app.log_ctl.session_for(app).end assert paged_back_end is not None await pilot.press("right") await pilot.pause() - assert app._log_end is not None - assert app._log_end > paged_back_end + assert app.log_ctl.session_for(app).end is not None + assert app.log_ctl.session_for(app).end > paged_back_end async def test_right_arrow_snaps_back_to_live(make_app): @@ -358,12 +358,12 @@ async def test_right_arrow_snaps_back_to_live(make_app): await pilot.pause() # Far enough in the past that one log_hours step forward can't reach it # by accident, but close enough that the next step clears "now". - app._log_end = datetime.now(timezone.utc) - timedelta(hours=1) + app.log_ctl.session_for(app).end = datetime.now(timezone.utc) - timedelta(hours=1) await pilot.press("right") await pilot.pause() - assert app._log_end is None + assert app.log_ctl.session_for(app).end is None async def test_paging_older_unsubscribes_the_stream(make_app): @@ -385,13 +385,13 @@ async def test_snapping_back_to_live_resubscribes_the_stream(make_app): await pilot.pause() await pilot.press("a") await pilot.pause() - app._log_end = datetime.now(timezone.utc) - timedelta(hours=1) + app.log_ctl.session_for(app).end = datetime.now(timezone.utc) - timedelta(hours=1) app.client.logbook_subscription_id = None await pilot.press("right") await pilot.pause() - assert app._log_end is None + assert app.log_ctl.session_for(app).end is None assert app.client.logbook_subscription_id is not None diff --git a/tests/test_device_log.py b/tests/test_device_log.py index 49064a6..78f3f09 100644 --- a/tests/test_device_log.py +++ b/tests/test_device_log.py @@ -34,7 +34,7 @@ async def test_i_v_advances_to_device_view_and_sends_the_entitys_device_id( assert "Device Log" in title assert "Living Room Lamp" in title # This view widens the event-type query, not the entity set. - assert app._log_entity_ids == {"light.living_room_lamp"} + assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp"} # issue #17: the device view is the one scope that queries device-scoped events. assert app.client.logbook_calls[-1][3] == ["dev_abc"] @@ -129,28 +129,7 @@ async def test_device_log_fallback_when_no_device_id(make_app, sample_entities, await pilot.pause() panel = app.query_one("#activity_log_panel", ActivityLogPanel) assert panel.has_class("-visible") - assert app._log_entity_ids == {"switch.fan"} - - -async def test_get_device_entity_ids_returns_siblings(make_app, sample_entities, sample_registry): - app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - entity_ids, label, device_id = app._get_device_entity_ids("light.living_room_lamp") - assert device_id == "dev_abc" - assert set(entity_ids) == {"light.living_room_lamp", "light.kitchen_light"} - assert "Living Room Lamp" in label - - -async def test_get_device_entity_ids_fallback_empty_device_id(make_app, sample_entities): - registry_with_empty = [ - {"entity_id": "light.living_room_lamp", "device_id": ""}, - ] - app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=registry_with_empty) - async with app.run_test(): - entity_ids, label, device_id = app._get_device_entity_ids("light.living_room_lamp") - assert device_id is None - assert entity_ids == ["light.living_room_lamp"] + assert app.log_ctl.session_for(app).entity_ids == {"switch.fan"} async def test_v_is_a_noop_when_no_entities(make_app): @@ -172,7 +151,7 @@ async def test_v_opens_device_log_for_entity_with_different_device(make_app, sam await pilot.pause() await pilot.press("i") await pilot.pause() - assert app._log_entity_ids == {"sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} async def test_v_scopes_to_graphed_entity_and_wraps_after_two_views(make_app, sample_entities, sample_registry): @@ -198,11 +177,11 @@ async def test_v_scopes_to_graphed_entity_and_wraps_after_two_views(make_app, sa await pilot.press("a") await pilot.pause() - assert app._log_entity_ids == {"sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} await pilot.press("v") await pilot.pause() - assert app._log_entity_ids == {"sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} title = str(app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content) assert "Device Log" in title assert "Temperature Sensor" in title diff --git a/tests/test_device_log_list.py b/tests/test_device_log_list.py index f3aa0d5..b875690 100644 --- a/tests/test_device_log_list.py +++ b/tests/test_device_log_list.py @@ -34,7 +34,7 @@ async def test_v_adds_the_lists_device_ids_without_expanding_siblings(make_app, await pilot.pause() panel = app.query_one("#activity_log_panel", ActivityLogPanel) assert panel.has_class("-visible") - assert app._log_entity_ids == {"light.living_room_lamp"} + assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp"} assert app.client.logbook_calls[-1][3] == ["dev_abc"] @@ -63,7 +63,7 @@ async def test_v_passes_through_entity_without_device(make_app, sample_entities, await pilot.pause() await pilot.press("v") await pilot.pause() - assert app._log_entity_ids == {"switch.fan"} + assert app.log_ctl.session_for(app).entity_ids == {"switch.fan"} async def test_v_sends_every_device_id_over_the_list(make_app, sample_entities, sample_registry): @@ -92,17 +92,17 @@ async def test_v_cycles_the_list_base_through_four_scopes_and_wraps(make_app, sa await pilot.pause() panel = app.query_one("#activity_log_panel", ActivityLogPanel) assert panel.has_class("-visible") - assert app._log_entity_ids == {"light.living_room_lamp", "sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp", "sensor.temperature"} assert app.client.logbook_calls[-1][3] == [] await pilot.press("v") # 2: list entities' devices await pilot.pause() - assert app._log_entity_ids == {"light.living_room_lamp", "sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp", "sensor.temperature"} assert set(app.client.logbook_calls[-1][3]) == {"dev_abc", "dev_xyz"} await pilot.press("v") # 3: the cursor entity alone await pilot.pause() - assert app._log_entity_ids == {"sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} assert app.client.logbook_calls[-1][3] == [] title = str(panel.query_one("#log_title", Label).content) assert title.startswith("Activity Log — Temperature Sensor") @@ -110,13 +110,13 @@ async def test_v_cycles_the_list_base_through_four_scopes_and_wraps(make_app, sa await pilot.press("v") # 4: the cursor entity's device await pilot.pause() assert app.client.logbook_calls[-1][3] == ["dev_xyz"] - assert app._log_entity_ids == {"sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} title = str(panel.query_one("#log_title", Label).content) assert "devices)" not in title # a single device never shows the count suffix await pilot.press("v") # wraps back to the plain list scope await pilot.pause() - assert app._log_entity_ids == {"light.living_room_lamp", "sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp", "sensor.temperature"} assert app.client.logbook_calls[-1][3] == [] assert panel.has_class("-visible") # the cycle never closes the panel diff --git a/tests/test_log_scope_cycle.py b/tests/test_log_scope_cycle.py index 9509276..5fc0a61 100644 --- a/tests/test_log_scope_cycle.py +++ b/tests/test_log_scope_cycle.py @@ -5,6 +5,7 @@ from textual.widgets import Label, Log +import hatty.controllers.logbook as logbook_module from hatty.ui.activity_log_panel import ActivityLogPanel from tests.conftest import make_config @@ -28,13 +29,13 @@ async def test_v_preserves_the_paged_window(make_app, sample_entities, sample_re await pilot.pause() await pilot.press("left") await pilot.pause() - paged_end = app._log_end + paged_end = app.log_ctl.session_for(app).end assert paged_end is not None await pilot.press("v") await pilot.pause() - assert app._log_end == paged_end + assert app.log_ctl.session_for(app).end == paged_end last_call = app.client.logbook_calls[-1] assert last_call[2] == paged_end # end @@ -105,7 +106,7 @@ async def test_v_v_v_retargets_the_live_append_filter(make_app, sample_entities, await pilot.pause() await pilot.press("v") # cursor_device: sibling kitchen_light now in scope await pilot.pause() - assert app._log_entity_ids == {"light.living_room_lamp", "light.kitchen_light"} + assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp", "light.kitchen_light"} app.client.logbook_subscription_id = None log_widget = app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_widget", Log) @@ -171,13 +172,13 @@ async def test_v_skips_the_cursor_views_when_no_row_is_selected(make_app, sample assert panel.has_class("-visible") title = str(panel.query_one("#log_title", Label).content) assert title.startswith("Activity Log — my_list") - assert app._log_entity_ids == {"light.living_room_lamp", "sensor.temperature"} + assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp", "sensor.temperature"} -async def test_v_caps_the_widened_scope(make_app, sample_entities, sample_registry): +async def test_v_caps_the_widened_scope(make_app, sample_entities, sample_registry, monkeypatch): config = _list_config(["light.living_room_lamp", "sensor.temperature"]) app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) - app._DEVICE_LOG_MAX_DEVICES = 1 + monkeypatch.setattr(logbook_module, "_DEVICE_LOG_MAX_DEVICES", 1) async with app.run_test() as pilot: await pilot.pause() await pilot.press("a") @@ -207,11 +208,11 @@ async def test_v_walks_the_fixed_base_through_two_views_and_wraps(make_app, samp title = str(panel.query_one("#log_title", Label).content) assert title.startswith("Device Log") assert app.client.logbook_calls[-1][3] == ["dev_abc"] - assert app._log_entity_ids == {"light.living_room_lamp"} + assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp"} await pilot.press("v") # wraps — a fixed base has no cursor views await pilot.pause() title = str(panel.query_one("#log_title", Label).content) assert title.startswith("Activity Log") assert app.client.logbook_calls[-1][3] == [] - assert app._log_entity_ids == {"light.living_room_lamp"} + assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp"} diff --git a/tests/unit/test_connection_controller.py b/tests/unit/test_connection_controller.py index d1641a1..8febfb4 100644 --- a/tests/unit/test_connection_controller.py +++ b/tests/unit/test_connection_controller.py @@ -47,12 +47,24 @@ def subscribe_logbook(self, entity_ids, device_ids=None): return None -class _StubLogPanel: - """No log panel is ever mounted in this pump-only test file — always - reports "closed" so ha_connected's resubscribe check is a no-op here.""" +class _StubLogCtl: + """Records the LogbookController calls ConnectionController drives — + no session is ever open in this pump-only test file, so these are just + call recorders, not a functioning controller.""" - def has_class(self, name): - return False + def __init__(self): + self.reconnect_resubscribes = 0 + self.state_changes = [] + self.stream_frames = [] + + def resubscribe_after_reconnect(self): + self.reconnect_resubscribes += 1 + + def handle_state_change(self, entity_id, new_state): + self.state_changes.append((entity_id, new_state)) + + def handle_stream_frame(self, raw_entries): + self.stream_frames.append(raw_entries) class _StubApp: @@ -64,16 +76,13 @@ def __init__(self, ha_url=""): self.client = _StubClient() self.graph_ctl = _StubGraphCtl() self.notify_ctl = _StubNotifyCtl() + self.log_ctl = _StubLogCtl() self.all_entities = [] self.entity_registry = [] self.device_registry = [] self.area_registry = [] self.entity_names = {} self.sub_title = "" - self._log_entity_ids = set() - self._log_query_ids = [] - self._log_device_ids = [] - self._log_end = None self._detail_entity_id = None self.notifications = [] self.spawned = [] @@ -101,9 +110,6 @@ def set_title_based_on_focused_ui(self): def _splash_screen(self): return None - def query_one(self, selector, widget_type=None): - return _StubLogPanel() - def _dismiss_splash(self): self.splash_dismissals += 1 @@ -222,6 +228,13 @@ def test_connected_fetches_registries(): assert len(app.spawned) == 3 +def test_connected_asks_log_ctl_to_resubscribe(): + app = _StubApp() + ctl = _ctl(app) + ctl.handle_ha_message({"type": "ha_connected", "attempt": 1}) + assert app.log_ctl.reconnect_resubscribes == 1 + + def test_cleartext_http_warning_fires_once(): app = _StubApp(ha_url="http://homeassistant.local:8123") ctl = _ctl(app) @@ -287,6 +300,15 @@ def test_event_upserts_entity_and_clears_pending(): assert app.cleared_pending == ["switch.fan"] assert app.graph_ctl.recorded == [new_state] assert app.refreshed_tree_entities == ["switch.fan"] + assert app.log_ctl.state_changes == [("switch.fan", new_state)] + + +def test_logbook_stream_event_routes_to_log_ctl(): + app = _StubApp() + ctl = _ctl(app) + entries = [{"when": "2024-01-15T10:30:00+00:00", "state": "on", "entity_id": "light.kitchen"}] + ctl.handle_ha_message({"type": "event", "event": {"events": entries}}) + assert app.log_ctl.stream_frames == [entries] def test_event_without_new_state_is_ignored(): diff --git a/tests/unit/test_logbook_controller.py b/tests/unit/test_logbook_controller.py new file mode 100644 index 0000000..e18675e --- /dev/null +++ b/tests/unit/test_logbook_controller.py @@ -0,0 +1,568 @@ +# hatty — MIT License. See LICENSE file for details. +"""Unit tests for LogbookController: scope resolution, caps, paging, the live +subscription singleton, and the state_changed/stream fallbacks — extracted +from HACLI/GraphPreviewScreen (issue #38). Pilot-free: hosts and the client +are stubbed, so these run without booting the Textual app.""" + +import asyncio +from datetime import datetime, timedelta, timezone + +import hatty.controllers.logbook as logbook_module +from hatty.controllers.logbook import LogbookController, LogScope, LogSession + + +class _StubClient: + def __init__(self): + self.logbook_subscription_id = None + self.logbook_calls = [] + self.state_log_calls = [] + self.subscribe_calls = [] + self.unsubscribe_calls = 0 + self._logbook_data: list[dict] = [] + self._state_log_data: dict[str, list[dict]] = {} + self._next_id = 1 + + async def fetch_logbook(self, entity_ids, hours=24, end=None, device_ids=None): + self.logbook_calls.append((list(entity_ids), hours, end, list(device_ids or []))) + return list(self._logbook_data) + + async def fetch_state_log(self, entity_id, hours=24, end=None): + self.state_log_calls.append((entity_id, hours, end)) + return list(self._state_log_data.get(entity_id, [])) + + async def subscribe_logbook(self, entity_ids, device_ids=None): + self.subscribe_calls.append((list(entity_ids), list(device_ids or []))) + self.logbook_subscription_id = self._next_id + self._next_id += 1 + return self.logbook_subscription_id + + async def unsubscribe_logbook(self): + # Mirrors FakeHAClient/HAClient: a no-op when nothing is subscribed, + # so a counting test can't be fooled by an extra call. + if self.logbook_subscription_id is None: + return + self.unsubscribe_calls += 1 + self.logbook_subscription_id = None + + +class _StubPanel: + def __init__(self): + self.classes: set[str] = set() + self.title = "" + self.hint = "" + self.history = None + self.cleared = 0 + self.entries_added: list = [] + + def set_hint(self, text): + self.hint = text + + def set_title(self, text): + self.title = text + + def add_class(self, name): + self.classes.add(name) + + def remove_class(self, name): + self.classes.discard(name) + + def has_class(self, name): + return name in self.classes + + def clear(self): + self.cleared += 1 + + def load_history(self, entries): + self.history = entries + + def add_log_entry(self, entry): + self.entries_added.append(entry) + + +class _StubHost: + LOG_PANEL_ID = "stub_panel" + LOG_SUPPORTS_LIVE = True + + def __init__(self): + self.panel_widget = _StubPanel() + self.entries_seen: list = [] + + def query_one(self, selector, widget_type=None): + return self.panel_widget + + def log_window(self, session): + return 24.0, session.end + + def log_title_suffix(self, session): + return "" + + def on_log_entries(self, entries): + self.entries_seen.append(entries) + + +class _StubFetchOnlyHost(_StubHost): + """Mirrors GraphPreviewScreen: no live subscription.""" + + LOG_PANEL_ID = "stub_fetch_only_panel" + LOG_SUPPORTS_LIVE = False + + +class _StubApp: + def __init__(self): + self.all_entities: list = [] + self.entity_registry: list = [] + self.device_registry: list = [] + self.log_hours = 24.0 + self.client = _StubClient() + self.notifications: list = [] + self.bindings_refreshes = 0 + self.spawned: list = [] + + def find_entity(self, entity_id): + return next((e for e in self.all_entities if e["entity_id"] == entity_id), None) + + def notify(self, message, **kwargs): + self.notifications.append((message, kwargs)) + + def call_later(self, fn, *args): + fn(*args) + + def refresh_bindings(self): + self.bindings_refreshes += 1 + + def spawn(self, coro): + task = asyncio.ensure_future(coro) + self.spawned.append(task) + return task + + async def run_spawned(self) -> None: + while self.spawned: + await self.spawned.pop(0) + + +def _controller() -> tuple[LogbookController, _StubApp]: + app = _StubApp() + return LogbookController(app), app + + +# ── base_option ────────────────────────────────────────────────────────────── + + +def test_base_option_resolves_plain_scope(): + ctl, app = _controller() + option = ctl.base_option("list", "my_list", ["light.a", "light.b"], with_devices=False) + scope = option.resolve() + assert scope == LogScope(["light.a", "light.b"], [], "Activity Log — my_list") + + +def test_base_option_resolves_none_for_empty_base(): + ctl, app = _controller() + option = ctl.base_option("list", "my_list", [], with_devices=False) + assert option.resolve() is None + + +def test_base_option_with_devices_widens_and_titles(): + ctl, app = _controller() + app.entity_registry = [ + {"entity_id": "light.a", "device_id": "dev_1"}, + {"entity_id": "light.b", "device_id": "dev_2"}, + ] + option = ctl.base_option("list_devices", "my_list", ["light.a", "light.b"], with_devices=True) + scope = option.resolve() + assert scope.entity_ids == ["light.a", "light.b"] + assert scope.device_ids == ["dev_1", "dev_2"] + assert scope.title == "Device Log — my_list (2 devices)" + + +def test_base_option_with_devices_single_device_no_count_suffix(): + ctl, app = _controller() + app.entity_registry = [{"entity_id": "light.a", "device_id": "dev_1"}] + option = ctl.base_option("list_devices", "my_list", ["light.a"], with_devices=True) + scope = option.resolve() + assert scope.title == "Device Log — my_list" + + +# ── cursor_option ──────────────────────────────────────────────────────────── + + +def test_cursor_option_resolves_none_without_a_selection(): + ctl, app = _controller() + option = ctl.cursor_option("cursor", lambda: None, with_device=False) + assert option.resolve() is None + + +def test_cursor_option_resolves_selected_entity(): + ctl, app = _controller() + app.all_entities = [{"entity_id": "light.a", "attributes": {"friendly_name": "Lamp"}}] + option = ctl.cursor_option("cursor", lambda: "light.a", with_device=False) + scope = option.resolve() + assert scope == LogScope(["light.a"], [], "Activity Log — Lamp") + + +def test_cursor_option_with_device_resolves_siblings(): + ctl, app = _controller() + app.all_entities = [{"entity_id": "light.a", "attributes": {"friendly_name": "Lamp"}}] + app.entity_registry = [ + {"entity_id": "light.a", "device_id": "dev_1"}, + {"entity_id": "light.b", "device_id": "dev_1"}, + ] + option = ctl.cursor_option("cursor_device", lambda: "light.a", with_device=True) + scope = option.resolve() + assert set(scope.entity_ids) == {"light.a", "light.b"} + assert scope.device_ids == ["dev_1"] + assert scope.no_device is False + + +def test_cursor_option_with_device_flags_no_device_without_notifying(): + ctl, app = _controller() + app.all_entities = [{"entity_id": "switch.fan", "attributes": {}}] + option = ctl.cursor_option("cursor_device", lambda: "switch.fan", with_device=True) + scope = option.resolve() + assert scope.entity_ids == ["switch.fan"] + assert scope.device_ids == [] + assert scope.no_device is True + # resolve() is pure — no toast until something actually applies this option. + assert app.notifications == [] + + +# ── caps ───────────────────────────────────────────────────────────────────── + + +def test_base_option_caps_widened_entities_and_devices(monkeypatch): + ctl, app = _controller() + monkeypatch.setattr(logbook_module, "_DEVICE_LOG_MAX_ENTITIES", 2) + monkeypatch.setattr(logbook_module, "_DEVICE_LOG_MAX_DEVICES", 1) + app.entity_registry = [ + {"entity_id": "light.a", "device_id": "dev_1"}, + {"entity_id": "light.b", "device_id": "dev_2"}, + {"entity_id": "light.c", "device_id": "dev_3"}, + ] + option = ctl.base_option("list_devices", "my_list", ["light.a", "light.b", "light.c"], with_devices=True) + scope = option.resolve() + assert scope.entity_ids == ["light.a", "light.b", "light.c"][:2] + assert scope.device_ids == ["dev_1"] + assert scope.entity_total == 3 + assert scope.device_total == 3 + # resolve() itself never notifies — only apply_option does. + assert app.notifications == [] + + +def test_cursor_device_option_is_capped_too(monkeypatch): + """Unlike the pre-#38 behaviour, cursor_device now goes through the same + caps as every other device-widened option.""" + ctl, app = _controller() + monkeypatch.setattr(logbook_module, "_DEVICE_LOG_MAX_ENTITIES", 1) + app.all_entities = [{"entity_id": "light.a", "attributes": {}}] + app.entity_registry = [ + {"entity_id": "light.a", "device_id": "dev_1"}, + {"entity_id": "light.b", "device_id": "dev_1"}, + ] + option = ctl.cursor_option("cursor_device", lambda: "light.a", with_device=True) + scope = option.resolve() + assert len(scope.entity_ids) == 1 + assert scope.entity_total == 2 + + +# ── apply_option ───────────────────────────────────────────────────────────── + + +async def test_apply_option_clears_retitles_and_fetches(): + ctl, app = _controller() + host = _StubHost() + app.client._logbook_data = [{"when": "2024-01-15T10:00:00+00:00", "state": "on", "entity_id": "light.a"}] + options = [ctl.base_option("list", "my_list", ["light.a"], with_devices=False)] + ctl.open(host, options=options, option_id="list", hint="hint text") + await app.run_spawned() + + assert host.panel_widget.has_class("-visible") + assert host.panel_widget.hint == "hint text" + assert host.panel_widget.cleared >= 1 + assert host.panel_widget.title == "Activity Log — my_list" + assert host.panel_widget.history is not None + assert app.client.logbook_calls[-1][0] == ["light.a"] + + +async def test_apply_option_notifies_caps_exactly_once(monkeypatch): + ctl, app = _controller() + host = _StubHost() + monkeypatch.setattr(logbook_module, "_DEVICE_LOG_MAX_DEVICES", 1) + app.entity_registry = [ + {"entity_id": "light.a", "device_id": "dev_1"}, + {"entity_id": "light.b", "device_id": "dev_2"}, + ] + options = [ + ctl.base_option("list", "my_list", ["light.a", "light.b"], with_devices=False), + ctl.base_option("list_devices", "my_list", ["light.a", "light.b"], with_devices=True), + ] + ctl.open(host, options=options, option_id="list", hint="") + await app.run_spawned() + assert app.notifications == [] + + ctl.apply_option(host, "list_devices") + await app.run_spawned() + device_cap_toasts = [n for n in app.notifications if "devices" in n[0]] + assert len(device_cap_toasts) == 1 + + +async def test_apply_option_notifies_no_device_found(): + ctl, app = _controller() + host = _StubHost() + app.all_entities = [{"entity_id": "switch.fan", "attributes": {}}] + options = [ctl.cursor_option("cursor_device", lambda: "switch.fan", with_device=True)] + ctl.open(host, options=options, option_id="cursor_device", hint="") + await app.run_spawned() + assert any("No device found" in n[0] for n in app.notifications) + + +async def test_apply_option_resubscribes_with_the_new_scope(): + ctl, app = _controller() + host = _StubHost() + options = [ + ctl.base_option("a", "a", ["light.a"], with_devices=False), + ctl.base_option("b", "b", ["light.b"], with_devices=False), + ] + ctl.open(host, options=options, option_id="a", hint="") + await app.run_spawned() + assert app.client.subscribe_calls[-1] == (["light.a"], []) + + ctl.apply_option(host, "b") + await app.run_spawned() + assert app.client.subscribe_calls[-1] == (["light.b"], []) + + +# ── next_option_id (the pre-popup blind cycle) ────────────────────────────── + + +def test_next_option_id_skips_unresolvable_options(): + ctl, app = _controller() + host = _StubHost() + options = [ + ctl.base_option("list", "my_list", ["light.a"], with_devices=False), + ctl.base_option("list_devices", "my_list", ["light.a"], with_devices=True), + ctl.cursor_option("cursor", lambda: None, with_device=False), + ctl.cursor_option("cursor_device", lambda: None, with_device=True), + ] + # Built directly rather than via open() — open() would spawn a fetch we don't need here. + session = LogSession( + host=host, + panel_id=host.LOG_PANEL_ID, + supports_live=host.LOG_SUPPORTS_LIVE, + options=options, + option_id="list", + query_ids=["light.a"], + device_ids=[], + entity_ids={"light.a"}, + title_base="Activity Log — my_list", + ) + ctl._sessions[id(host)] = session + assert ctl.next_option_id(host) == "list_devices" + + +# ── paging ─────────────────────────────────────────────────────────────────── + + +async def test_page_older_sets_a_window_end(): + ctl, app = _controller() + host = _StubHost() + options = [ctl.base_option("list", "my_list", ["light.a"], with_devices=False)] + ctl.open(host, options=options, option_id="list", hint="") + await app.run_spawned() + + before = datetime.now(timezone.utc) + ctl.page(host, -1) + await app.run_spawned() + after = datetime.now(timezone.utc) + session = ctl.session_for(host) + assert session.end is not None + assert before - timedelta(hours=app.log_hours) <= session.end <= after - timedelta(hours=app.log_hours) + + +async def test_page_newer_is_a_noop_while_live(): + ctl, app = _controller() + host = _StubHost() + options = [ctl.base_option("list", "my_list", ["light.a"], with_devices=False)] + ctl.open(host, options=options, option_id="list", hint="") + await app.run_spawned() + + ctl.page(host, 1) + assert ctl.session_for(host).end is None + + +async def test_page_newer_snaps_back_to_live_past_now(): + ctl, app = _controller() + host = _StubHost() + options = [ctl.base_option("list", "my_list", ["light.a"], with_devices=False)] + ctl.open(host, options=options, option_id="list", hint="") + await app.run_spawned() + ctl.session_for(host).end = datetime.now(timezone.utc) - timedelta(hours=1) + + ctl.page(host, 1) + await app.run_spawned() + assert ctl.session_for(host).end is None + + +# ── live_session ───────────────────────────────────────────────────────────── + + +async def test_live_session_requires_live_capable_and_visible_and_not_paged(): + ctl, app = _controller() + live_host = _StubHost() + fetch_only_host = _StubFetchOnlyHost() + options = [ctl.base_option("a", "a", ["light.a"], with_devices=False)] + + assert ctl.live_session() is None + + ctl.open(fetch_only_host, options=list(options), option_id="a", hint="") + await app.run_spawned() + assert ctl.live_session() is None # fetch-only host never counts + + ctl.open(live_host, options=list(options), option_id="a", hint="") + await app.run_spawned() + assert ctl.live_session() is ctl.session_for(live_host) + + ctl.session_for(live_host).end = datetime.now(timezone.utc) + assert ctl.live_session() is None # paged back + + ctl.session_for(live_host).end = None + live_host.panel_widget.remove_class("-visible") + assert ctl.live_session() is None # closed + + +# ── resync_subscription / resubscribe_after_reconnect ─────────────────────── + + +async def test_resync_subscription_unsubscribes_then_resubscribes(): + ctl, app = _controller() + host = _StubHost() + options = [ctl.base_option("a", "a", ["light.a"], with_devices=False)] + ctl.open(host, options=options, option_id="a", hint="") + await app.run_spawned() + assert app.client.subscribe_calls == [(["light.a"], [])] + + await ctl.resync_subscription() + assert app.client.unsubscribe_calls == 1 + assert app.client.subscribe_calls[-1] == (["light.a"], []) + + +async def test_resubscribe_after_reconnect_does_not_unsubscribe_first(): + ctl, app = _controller() + host = _StubHost() + options = [ctl.base_option("a", "a", ["light.a"], with_devices=False)] + ctl.open(host, options=options, option_id="a", hint="") + await app.run_spawned() + app.client.unsubscribe_calls = 0 + + ctl.resubscribe_after_reconnect() + await app.run_spawned() + assert app.client.unsubscribe_calls == 0 + assert len(app.client.subscribe_calls) == 2 + + +def test_resubscribe_after_reconnect_noop_when_nothing_live(): + ctl, app = _controller() + ctl.resubscribe_after_reconnect() + assert app.spawned == [] + + +# ── fetch_entries ──────────────────────────────────────────────────────────── + + +async def test_fetch_entries_passes_through_without_continuous_sensors(): + ctl, app = _controller() + app.client._logbook_data = [{"when": "x"}] + result = await ctl.fetch_entries(["light.a"], hours=24) + assert result == [{"when": "x"}] + assert app.client.state_log_calls == [] + + +async def test_fetch_entries_merges_and_sorts_continuous_sensor_history(): + ctl, app = _controller() + app.all_entities = [ + {"entity_id": "sensor.temp", "attributes": {"unit_of_measurement": "°C", "state_class": "measurement"}} + ] + app.client._logbook_data = [{"when": "2024-01-15T10:02:00+00:00", "entity_id": "light.a", "state": "on"}] + app.client._state_log_data = {"sensor.temp": [{"when": "2024-01-15T10:00:00+00:00", "state": "21.0"}]} + result = await ctl.fetch_entries(["light.a", "sensor.temp"], hours=24) + assert [e["when"] for e in result] == ["2024-01-15T10:00:00+00:00", "2024-01-15T10:02:00+00:00"] + + +async def test_fetch_entries_returns_none_on_base_failure_without_synthesized_rows(): + ctl, app = _controller() + + async def _fail(*args, **kwargs): + return None + + app.client.fetch_logbook = _fail + result = await ctl.fetch_entries(["light.a"], hours=24) + assert result is None + + +# ── load: the generation guard ─────────────────────────────────────────────── + + +async def test_load_drops_a_stale_result(): + ctl, app = _controller() + host = _StubHost() + options = [ctl.base_option("a", "a", ["light.a"], with_devices=False)] + session = ctl.open(host, options=options, option_id="a", hint="") + await app.run_spawned() + + stale_load = ctl.load(session) + session.generation += 1 # a newer reload started before the stale one resolves + await stale_load + # The stale load must not have clobbered the panel with its (older) result. + assert host.panel_widget.history is not None # still whatever the fresh load left + + +async def test_load_drops_result_when_panel_closed_midflight(): + ctl, app = _controller() + host = _StubHost() + options = [ctl.base_option("a", "a", ["light.a"], with_devices=False)] + session = ctl.open(host, options=options, option_id="a", hint="") + await app.run_spawned() + host.panel_widget.remove_class("-visible") + + host.panel_widget.history = "sentinel" + await ctl.load(session) + assert host.panel_widget.history == "sentinel" # untouched + + +# ── handle_stream_frame / handle_state_change ─────────────────────────────── + + +async def test_handle_stream_frame_appends_to_the_live_session(): + ctl, app = _controller() + host = _StubHost() + options = [ctl.base_option("a", "a", ["light.a"], with_devices=False)] + ctl.open(host, options=options, option_id="a", hint="") + await app.run_spawned() + + ctl.handle_stream_frame([{"when": "2024-01-15T10:00:00+00:00", "state": "on", "entity_id": "light.a"}]) + assert len(host.panel_widget.entries_added) == 1 + + +def test_handle_stream_frame_noop_with_no_live_session(): + ctl, app = _controller() + ctl.handle_stream_frame([{"when": "x"}]) + assert app.notifications == [] # nothing blew up, nothing happened + + +async def test_handle_state_change_appends_only_when_in_scope_and_unsubscribed(): + ctl, app = _controller() + host = _StubHost() + options = [ctl.base_option("a", "a", ["light.a"], with_devices=False)] + ctl.open(host, options=options, option_id="a", hint="") + await app.run_spawned() + assert app.client.logbook_subscription_id is not None + + # Subscription still active — the stream already carries this, so no append. + ctl.handle_state_change("light.a", {"state": "on", "attributes": {}}) + assert host.panel_widget.entries_added == [] + + app.client.logbook_subscription_id = None + ctl.handle_state_change("light.a", {"state": "on", "attributes": {}}) + assert len(host.panel_widget.entries_added) == 1 + + # Out-of-scope entity: still filtered even with the stream down. + ctl.handle_state_change("light.b", {"state": "on", "attributes": {}}) + assert len(host.panel_widget.entries_added) == 1 From f3fc7883df737b70dd471237f9e1c8df56e59900 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:34:16 -0500 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20Make=20the=20maximized=20activi?= =?UTF-8?q?ty=20log=20an=20interactive,=20selectable=20list=20Refs=20#38?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/main.py | 9 +- src/hatty/ui/activity_log_panel.py | 161 ++++++++++++++++++++++++--- src/hatty/ui/graph/preview_screen.py | 26 +++-- tests/test_graph_log_maximize.py | 80 ++++++++++++- tests/test_log_maximize.py | 129 ++++++++++++++++++++- 5 files changed, 370 insertions(+), 35 deletions(-) diff --git a/src/hatty/main.py b/src/hatty/main.py index 9765874..c682b2e 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -815,7 +815,11 @@ def action_maximize_log(self) -> None: if not self.log_ctl.is_open(self): return log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) - log_panel.set_maximized(not log_panel.has_class("-maximized")) + maximizing = not log_panel.has_class("-maximized") + log_panel.set_hint(self._LOG_HINT_MAXIMIZED if maximizing else self._LOG_HINT) + log_panel.set_maximized(maximizing) + if not maximizing: + self.query_one("#entities_table", EntitiesTable).focus() def action_show_log_entries(self) -> None: """`V` — browse the open log's retained entries and read a @@ -830,6 +834,7 @@ def action_show_log_entries(self) -> None: self.push_screen(LogEntryPopup(entries, log_panel.title_text)) _LOG_HINT = "v scope · f maximize · V full text · ←/→ older/newer · T timeframe · a/i close" + _LOG_HINT_MAXIMIZED = "↑/↓ select · f exit · ←/→ older/newer · T timeframe" def _graph_entity_ids(self) -> list[str]: """The graphed entity plus its `+` comparison lines, primary first.""" @@ -1169,7 +1174,9 @@ def action_go_back(self) -> None: log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) if log_panel.has_class("-maximized"): # First escape restores the normal-width panel; a further escape/toggle closes it. + log_panel.set_hint(self._LOG_HINT) log_panel.set_maximized(False) + self.query_one("#entities_table", EntitiesTable).focus() return search_input = self.query_one("#search_input", SearchInput) diff --git a/src/hatty/ui/activity_log_panel.py b/src/hatty/ui/activity_log_panel.py index 406b01e..989fa81 100644 --- a/src/hatty/ui/activity_log_panel.py +++ b/src/hatty/ui/activity_log_panel.py @@ -6,10 +6,16 @@ and on the fullscreen graph screen (`a` opens it, `v` cycles its scope, issue #21; its events additionally marked on the plot). -The panel itself is dumb — a title, a scrolling `Log`, and a bottom hint line -(`set_hint`) the host screen fills in with its own keys, since the two hosts -offer different actions around it. Scope, time-window paging and live-append -all live on the host; maximizing goes through `set_maximized` here. +The panel itself is dumb — a title, a bottom hint line (`set_hint`) the host +screen fills in with its own keys (since the two hosts offer different +actions around it), and two mutually-exclusive bodies toggled by +`set_maximized` (issue #38): a passive, non-focusable `Log` ticker while +docked, and a focusable, selectable `LogOptionList` + inline detail region +while maximized — replacing the old `LogEntryPopup`, whose only job was +showing one entry's untruncated text. Both bodies are always mounted (CSS +`display` toggles which shows) so a live append can keep the ticker correct +even while the selectable list is what's on screen. Scope, time-window +paging and live-append all live on the host. `load_history` renders normalized `LogEntry`s (see `hatty.logbook`) — both the REST and WS logbook transports get unified to that shape before reaching this @@ -19,6 +25,8 @@ logbook/event_stream) — it dedupes against the last few entries rendered, since a live push can legitimately overlap the last entry `load_history` already drew (the window fetch and the stream subscription have no shared cursor). +Appending to the selectable list never moves the current selection, so a live +push while maximized doesn't yank the highlight away from what's being read. The panel retains its rendered entries (`_entries`, capped in lockstep with the `Log`'s own `max_lines`) so it can re-truncate them to the true width @@ -27,22 +35,21 @@ and `set_maximized`'s explicit follow-up call are the two triggers (issue #22: the old code baked truncation width into each line at write time and never revisited it, so maximizing did nothing for already-written lines). -Re-render always scrolls to the newest line — there's no cursor to preserve, -since the log stays outside the focus chain (see below). - -The same retained `_entries` (via the `entries` property, alongside -`title_text`) also back `LogEntryPopup` (issue #23, `ui/log_entry_popup.py`, -opened with `V` by each host) — a browse popup for reading a truncated -line's full, untruncated text.""" +The two bodies track their rendered width independently (`_rendered_width` / +`_options_rendered_width`) so toggling `-maximized` back and forth never +skips a needed re-render. Loading a fresh history (a scope/page change) +always resets the selectable list's highlight to the newest entry; a live +append leaves it where it is.""" from collections import deque from textual import events from textual.app import ComposeResult +from textual.containers import Vertical, VerticalScroll from textual.widget import Widget -from textual.widgets import Label, Log +from textual.widgets import Label, Log, OptionList, Static -from hatty.logbook import LogEntry, format_log_line +from hatty.logbook import LogEntry, format_log_detail, format_log_line # How many recently-rendered entries add_log_entry checks against — only the # fetch/stream boundary can overlap, so a handful of slots is ample. @@ -53,6 +60,21 @@ _MAX_LOG_LINES = 2000 +class LogOptionList(OptionList): + """The maximized panel's selectable list. Deaf to every OptionList + binding except cursor_up/cursor_down (issue #38) — a falsy check_action + makes Textual's binding resolution fall through to the next namespace in + the chain, so left/right/enter/home/end/pageup/pagedown keep reaching + the *host's* own bindings (paging, inspect mode, …) even while this list + is focused, instead of being swallowed by OptionList's native scrolling/ + selection keys.""" + + def check_action(self, action: str, parameters: tuple) -> bool | None: + if action in ("scroll_left", "scroll_right", "select", "first", "last", "page_up", "page_down"): + return False + return True + + class ActivityLogPanel(Widget): DEFAULT_CSS = """ ActivityLogPanel { @@ -78,6 +100,24 @@ class ActivityLogPanel(Widget): height: 1fr; overflow-x: hidden; } + ActivityLogPanel #log_browser { + display: none; + } + ActivityLogPanel.-maximized #log_widget { + display: none; + } + ActivityLogPanel.-maximized #log_browser { + display: block; + height: 1fr; + } + ActivityLogPanel #log_options { + height: 1fr; + } + ActivityLogPanel #log_detail_scroll { + height: auto; + max-height: 10; + border-top: solid $accent; + } ActivityLogPanel #log_hint { dock: bottom; height: 1; @@ -90,6 +130,7 @@ def __init__(self, *args, **kwargs) -> None: self._recent_keys: deque[tuple[str, str, str]] = deque(maxlen=_DEDUPE_WINDOW) self._entries: deque[LogEntry] = deque(maxlen=_MAX_LOG_LINES) self._rendered_width = 0 + self._options_rendered_width = 0 self._title = "" def compose(self) -> ComposeResult: @@ -104,6 +145,22 @@ def compose(self) -> ComposeResult: # take keyboard focus, so keep it out of the focus chain entirely. log.can_focus = False yield log + with Vertical(id="log_browser"): + # Same can_focus=False trick as the Log above, and for the same + # reason: while docked (not maximized) this must be invisible to + # auto-focus even though it's still mounted. set_maximized flips + # can_focus on/off in lockstep with the -maximized class. + options = LogOptionList(id="log_options", markup=False) + options.can_focus = False + yield options + # VerticalScroll defaults can_focus=True (unlike Vertical above) — + # without this it, not the OptionList, is what app-wide AUTO_FOCUS + # ("*") lands on first, since it's earlier/equally eligible in the + # DOM and never otherwise receives an explicit .focus() call. + detail_scroll = VerticalScroll(id="log_detail_scroll") + detail_scroll.can_focus = False + with detail_scroll: + yield Static(id="log_detail", markup=False) yield Label("", id="log_hint") def set_title(self, text: str) -> None: @@ -135,6 +192,42 @@ def _line_width(self) -> int: log = self.query_one("#log_widget", Log) return max(20, log.scrollable_content_region.width or self.content_size.width or 50) + def _options_width(self) -> int: + options = self.query_one("#log_options", OptionList) + return max(20, options.scrollable_content_region.width or options.content_size.width or 50) + + def _render_detail(self, index: int | None) -> None: + detail = self.query_one("#log_detail", Static) + if not self._entries: + detail.update("(no history available)") + elif index is None: + detail.update("") + else: + detail.update(format_log_detail(self._entries[index])) + + def _render_options(self, *, keep_highlighted: bool) -> None: + """Rebuild the maximized panel's selectable list at the current + width. `keep_highlighted=True` (a resize) tries to preserve the + current selection; `False` (a fresh load_history or an entry into + maximized mode) always lands on the newest entry.""" + options = self.query_one("#log_options", OptionList) + previous = options.highlighted if keep_highlighted else None + width = self._options_width() + options.clear_options() + if not self._entries: + self._options_rendered_width = width + self._render_detail(None) + return + options.add_options(format_log_line(entry, width) for entry in self._entries) + self._options_rendered_width = width + if previous is not None and previous < len(self._entries): + options.highlighted = previous + else: + options.highlighted = len(self._entries) - 1 + + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + self._render_detail(event.option_index) + def load_history(self, entries: list[LogEntry]) -> None: log = self.query_one("#log_widget", Log) log.clear() @@ -144,16 +237,23 @@ def load_history(self, entries: list[LogEntry]) -> None: self._entries.extend(entries) if not entries: log.write_line("(no history available)") + self._rendered_width = 0 + if self.has_class("-maximized"): + self._render_options(keep_highlighted=False) return width = self._line_width() log.write_lines([format_log_line(entry, width) for entry in entries]) self._rendered_width = width + if self.has_class("-maximized"): + self._render_options(keep_highlighted=False) def add_log_entry(self, entry: LogEntry) -> None: """Live-append a single normalized entry (a logbook/event_stream push) — reuses format_log_line so a device event gets the same ⚡ form and width truncation as the initial load. Skips an entry already rendered - in the last _DEDUPE_WINDOW (the fetch/stream boundary can overlap).""" + in the last _DEDUPE_WINDOW (the fetch/stream boundary can overlap). + Appending to the selectable list never moves its highlighted index, + so a live push while maximized can't yank the selection away.""" key = self._dedupe_key(entry) if key in self._recent_keys: return @@ -162,15 +262,27 @@ def add_log_entry(self, entry: LogEntry) -> None: width = self._line_width() self.query_one("#log_widget", Log).write_line(format_log_line(entry, width)) self._rendered_width = width + if self.has_class("-maximized"): + options = self.query_one("#log_options", OptionList) + options_width = self._options_width() + options.add_option(format_log_line(entry, options_width)) + self._options_rendered_width = options_width def _reflow_lines(self) -> None: """Re-truncate every retained entry to the current width — the response to a resize (`-visible`/`-maximized` toggling). A no-op while empty (nothing to re-truncate; re-deriving the placeholder here would flash it mid-fetch, since opening clears before the load - completes) or when the width hasn't actually changed.""" + completes), and re-renders whichever of the two bodies is currently + displayed, skipping the other (it'll catch up next time it's shown, + via load_history/set_maximized rather than this resize path).""" if not self._entries: return + if self.has_class("-maximized"): + if self._options_width() == self._options_rendered_width: + return + self._render_options(keep_highlighted=True) + return width = self._line_width() if width == self._rendered_width: return @@ -183,15 +295,30 @@ def on_resize(self, event: events.Resize) -> None: self._reflow_lines() def set_maximized(self, maximized: bool) -> None: - self.set_class(maximized, "-maximized") + options = self.query_one("#log_options", OptionList) + if maximized: + self.set_class(True, "-maximized") + self._render_options(keep_highlighted=False) + options.can_focus = True + options.focus() + else: + # can_focus must drop before the class does — Textual's auto-focus + # rescans regardless of `display`, so a focused, still-focusable + # OptionList behind a display:none body would keep intercepting + # keys the host's own bindings expect (see the compose() comment). + options.can_focus = False + self.set_class(False, "-maximized") # Belt-and-braces: on_resize normally handles this already, but - # call_after_refresh (post-layout) + the _rendered_width guard make + # call_after_refresh (post-layout) + the rendered-width guards make # this a free no-op when it did, and a correct fallback when a # class-driven resize doesn't queue for some reason. self.call_after_refresh(self._reflow_lines) def clear(self) -> None: self.query_one("#log_widget", Log).clear() + self.query_one("#log_options", OptionList).clear_options() + self.query_one("#log_detail", Static).update("") self._recent_keys.clear() self._entries.clear() self._rendered_width = 0 + self._options_rendered_width = 0 diff --git a/src/hatty/ui/graph/preview_screen.py b/src/hatty/ui/graph/preview_screen.py index b538413..176a7f8 100644 --- a/src/hatty/ui/graph/preview_screen.py +++ b/src/hatty/ui/graph/preview_screen.py @@ -795,19 +795,36 @@ def _close_event_log(self) -> None: self.app.log_ctl.close(self) self._redraw() + _LOG_HINT = "v view · f max · V full text · a close · ←/→ page with the graph" + _LOG_HINT_MAXIMIZED = "↑/↓ select · f exit · a close · ←/→ page with the graph" + def action_close_event_log(self) -> None: """escape/q — a further escape/toggle closes; a maximized panel gets un-maximized first, mirroring the main screen's action_go_back. `a`/`A` (action_toggle_event_log) close outright instead, bypassing this.""" log_panel = self.query_one("#preview_log_panel", ActivityLogPanel) if log_panel.has_class("-maximized"): + log_panel.set_hint(self._LOG_HINT) log_panel.set_maximized(False) + # The screen itself isn't focusable, so self.focus() would no-op — + # explicitly blur (Screen.set_focus(widget) is a no-op unless the + # target is focusable, and there's no natural "home" widget here + # the way the main table is for HACLI). + self.set_focus(None) return self._close_event_log() def action_maximize_log(self) -> None: log_panel = self.query_one("#preview_log_panel", ActivityLogPanel) - log_panel.set_maximized(not log_panel.has_class("-maximized")) + maximizing = not log_panel.has_class("-maximized") + log_panel.set_hint(self._LOG_HINT_MAXIMIZED if maximizing else self._LOG_HINT) + log_panel.set_maximized(maximizing) + if not maximizing: + # The screen itself isn't focusable, so self.focus() would no-op — + # explicitly blur (Screen.set_focus(widget) is a no-op unless the + # target is focusable, and there's no natural "home" widget here + # the way the main table is for HACLI). + self.set_focus(None) def action_show_log_entries(self) -> None: """`V` — browse the open log's retained entries and read a @@ -835,12 +852,7 @@ def _open_event_log(self) -> None: self.app.log_ctl.base_option("entities", label, self._entity_ids, with_devices=False), self.app.log_ctl.base_option("entities_devices", label, self._entity_ids, with_devices=True), ] - self.app.log_ctl.open( - self, - options=options, - option_id="entities", - hint="v view · f max · V full text · a close · ←/→ page with the graph", - ) + self.app.log_ctl.open(self, options=options, option_id="entities", hint=self._LOG_HINT) def action_toggle_event_log(self) -> None: if self.app.log_ctl.is_open(self): diff --git a/tests/test_graph_log_maximize.py b/tests/test_graph_log_maximize.py index aafaede..ec3e416 100644 --- a/tests/test_graph_log_maximize.py +++ b/tests/test_graph_log_maximize.py @@ -2,11 +2,14 @@ """`f` maximizes the fullscreen graph's activity log (issue #22) — a graph-screen analogue of the main table's maximize, with its own two-step escape (un-maximize first, close on a further press) while `a`/`A` still -close outright from either state.""" +close outright from either state. Issue #38 turns the maximized state into +a genuinely interactive, selectable list — see LogOptionList.check_action +for how `left`/`right`/`enter` still reach the graph's own paging/inspect- +mode bindings even while that list is focused.""" -from textual.widgets import Log +from textual.widgets import Log, OptionList, Static -from hatty.ui.activity_log_panel import ActivityLogPanel +from hatty.ui.activity_log_panel import ActivityLogPanel, LogOptionList from hatty.ui.graph.preview_screen import GraphPreviewScreen from tests.conftest import NO_LIST_CONFIG from tests.test_graph_event_log import _open_preview_on_temperature @@ -125,5 +128,74 @@ async def test_maximize_reflows_a_truncated_line_wider(make_app, sample_entities await pilot.press("f") await pilot.pause() - maximized_line = next(line for line in log_widget.lines if "AAA" in line) + options = preview.query_one("#preview_log_panel", ActivityLogPanel).query_one("#log_options", OptionList) + maximized_line = str(options.get_option_at_index(0).prompt) assert len(maximized_line) > len(windowed_line) + + +async def test_f_focuses_the_option_list_and_shows_detail(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG) + async with app.run_test() as pilot: + await pilot.pause() + app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} + app.client._logbook_data = [{"when": "2024-01-01T11:30:00+00:00", "name": "Front Door", "state": "on"}] + preview = await _open_preview_on_temperature(pilot, app) + + await pilot.press("a", "f") + await pilot.pause() + assert isinstance(app.focused, LogOptionList) + panel = preview.query_one("#preview_log_panel", ActivityLogPanel) + detail = panel.query_one("#log_detail", Static) + assert "Front Door" in str(detail.content) + + +async def test_left_right_page_the_graph_while_the_option_list_is_focused(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG) + async with app.run_test() as pilot: + await pilot.pause() + app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} + await _open_preview_on_temperature(pilot, app) + + await pilot.press("a", "f") + await pilot.pause() + assert isinstance(app.focused, LogOptionList) + calls_before = len(app.client.logbook_calls) + + await pilot.press("left") + await pilot.pause() + # left paged the graph window (and refetched the log for it), not the list. + assert len(app.client.logbook_calls) > calls_before + + +async def test_enter_still_toggles_inspect_mode_while_the_option_list_is_focused(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG) + async with app.run_test() as pilot: + await pilot.pause() + app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} + preview = await _open_preview_on_temperature(pilot, app) + + await pilot.press("a", "f") + await pilot.pause() + assert isinstance(app.focused, LogOptionList) + + await pilot.press("enter") + await pilot.pause() + assert preview._cursor_mode is True + + +async def test_unmaximize_blurs_the_option_list(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG) + async with app.run_test() as pilot: + await pilot.pause() + app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} + preview = await _open_preview_on_temperature(pilot, app) + + await pilot.press("a", "f") + await pilot.pause() + assert isinstance(app.focused, LogOptionList) + + await pilot.press("f") + await pilot.pause() + assert not isinstance(app.focused, LogOptionList) + options = preview.query_one("#preview_log_panel", ActivityLogPanel).query_one("#log_options", LogOptionList) + assert options.can_focus is False diff --git a/tests/test_log_maximize.py b/tests/test_log_maximize.py index f5068d3..7391224 100644 --- a/tests/test_log_maximize.py +++ b/tests/test_log_maximize.py @@ -1,9 +1,11 @@ # hatty — MIT License. See LICENSE file for details. -"""Maximize toggle for the activity/device log panel (issue #70).""" +"""Maximize toggle for the activity/device log panel (issue #70), and its +issue #38 upgrade into a genuinely interactive, selectable list with an +inline detail region.""" -from textual.widgets import Log +from textual.widgets import Log, OptionList, Static -from hatty.ui.activity_log_panel import ActivityLogPanel +from hatty.ui.activity_log_panel import ActivityLogPanel, LogOptionList from tests.conftest import NO_LIST_CONFIG @@ -57,8 +59,9 @@ async def test_escape_unmaximizes_before_closing(make_app): async def test_maximize_reflows_a_truncated_line_wider(make_app): - """Maximizing must re-truncate already-written lines at the new width, - not just widen the panel around stale strings (issue #22).""" + """Maximizing swaps to the full-width selectable list, whose rows are + truncated at the new (wider) width rather than the docked ticker's + stale, narrower strings (issue #22, extended by #38's selectable list).""" app = make_app(config_data=NO_LIST_CONFIG) async with app.run_test() as pilot: await pilot.pause() @@ -71,7 +74,8 @@ async def test_maximize_reflows_a_truncated_line_wider(make_app): await pilot.press("f") await pilot.pause() - maximized_line = next(line for line in log_widget.lines if "AAA" in line) + options = app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_options", OptionList) + maximized_line = str(options.get_option_at_index(0).prompt) assert not maximized_line.endswith("…") assert len(maximized_line) > len(windowed_line) @@ -94,6 +98,119 @@ async def test_maximize_with_empty_log_keeps_the_placeholder(make_app): await pilot.press("f") await pilot.pause() assert list(log_widget.lines) == ["(no history available)"] + panel = app.query_one("#activity_log_panel", ActivityLogPanel) + options = panel.query_one("#log_options", OptionList) + assert options.option_count == 0 + detail = panel.query_one("#log_detail", Static) + assert str(detail.content) == "(no history available)" + + +async def test_f_focuses_the_option_list_and_shows_the_newest_entrys_detail(make_app): + app = make_app(config_data=NO_LIST_CONFIG) + async with app.run_test() as pilot: + await pilot.pause() + app.client._logbook_data = [ + {"when": "2024-01-15T10:30:00+00:00", "name": "Living Room Lamp", "state": "on"}, + {"when": "2024-01-15T10:31:00+00:00", "name": "Kitchen Light", "state": "off"}, + ] + await pilot.press("a") + await pilot.pause() + + await pilot.press("f") + await pilot.pause() + panel = app.query_one("#activity_log_panel", ActivityLogPanel) + options = panel.query_one("#log_options", LogOptionList) + assert isinstance(app.focused, LogOptionList) + assert options.highlighted == options.option_count - 1 + detail = panel.query_one("#log_detail", Static) + assert "Kitchen Light" in str(detail.content) + assert "2024-01-15" in str(detail.content) + + +async def test_up_moves_selection_and_updates_the_detail_region(make_app): + app = make_app(config_data=NO_LIST_CONFIG) + async with app.run_test() as pilot: + await pilot.pause() + app.client._logbook_data = [ + {"when": "2024-01-15T10:30:00+00:00", "name": "Living Room Lamp", "state": "on"}, + {"when": "2024-01-15T10:31:00+00:00", "name": "Kitchen Light", "state": "off"}, + ] + await pilot.press("a") + await pilot.press("f") + await pilot.pause() + + await pilot.press("up") + await pilot.pause() + panel = app.query_one("#activity_log_panel", ActivityLogPanel) + options = panel.query_one("#log_options", OptionList) + assert options.highlighted == 0 + detail = panel.query_one("#log_detail", Static) + assert "Living Room Lamp" in str(detail.content) + + +async def test_left_right_still_page_while_the_option_list_is_focused(make_app): + app = make_app(config_data=NO_LIST_CONFIG) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("a") + await pilot.press("f") + await pilot.pause() + assert isinstance(app.focused, LogOptionList) + + await pilot.press("left") + await pilot.pause() + assert app.log_ctl.session_for(app).end is not None + + +async def test_unmaximize_blurs_and_refocuses_the_table(make_app): + app = make_app(config_data=NO_LIST_CONFIG) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("a") + await pilot.press("f") + await pilot.pause() + assert isinstance(app.focused, LogOptionList) + + await pilot.press("f") + await pilot.pause() + assert app.query_one("#entities_table").has_focus + options = app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_options", LogOptionList) + assert options.can_focus is False + + +async def test_live_append_while_maximized_preserves_the_selection(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG) + async with app.run_test() as pilot: + await pilot.pause() + app.client._logbook_data = [ + {"when": "2024-01-15T10:30:00+00:00", "name": "Living Room Lamp", "state": "on"}, + {"when": "2024-01-15T10:31:00+00:00", "name": "Kitchen Light", "state": "off"}, + ] + await pilot.press("a") + await pilot.press("f") + await pilot.pause() + await pilot.press("up") # select the older (first) entry + await pilot.pause() + + panel = app.query_one("#activity_log_panel", ActivityLogPanel) + options = panel.query_one("#log_options", OptionList) + assert options.highlighted == 0 + + app.client.logbook_subscription_id = None # exercise the state_changed fallback append + app.client.inject_state_change( + { + "entity_id": "switch.fan", + "state": "on", + "attributes": {"friendly_name": "Fan Switch"}, + "last_changed": "2024-01-15T10:32:00.000000+00:00", + } + ) + await pilot.pause() + + assert options.option_count == 3 + assert options.highlighted == 0 # unmoved by the append + detail = panel.query_one("#log_detail", Static) + assert "Living Room Lamp" in str(detail.content) async def test_reopening_log_is_not_maximized(make_app): From 6796a044fd892718a9974704bca67a291a92cc10 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:03:23 -0500 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=A8=20Replace=20the=20blind=20`v`=20s?= =?UTF-8?q?cope=20cycle=20with=20a=20preview-then-commit=20popup=20Refs=20?= =?UTF-8?q?#38?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/controllers/logbook.py | 19 +- src/hatty/main.py | 29 +- src/hatty/ui/graph/preview_screen.py | 30 +- src/hatty/ui/log_scope_popup.py | 136 +++++++++ tests/test_activity_log.py | 13 +- tests/test_demo_mode.py | 11 +- tests/test_device_log.py | 104 ++----- tests/test_device_log_list.py | 148 ---------- tests/test_graph.py | 2 +- tests/test_graph_event_log.py | 73 +---- ...scope_cycle.py => test_log_scope_popup.py} | 261 ++++++++++-------- tests/unit/test_logbook_controller.py | 44 ++- 12 files changed, 414 insertions(+), 456 deletions(-) create mode 100644 src/hatty/ui/log_scope_popup.py delete mode 100644 tests/test_device_log_list.py rename tests/{test_log_scope_cycle.py => test_log_scope_popup.py} (51%) diff --git a/src/hatty/controllers/logbook.py b/src/hatty/controllers/logbook.py index 2bfe054..f7d5939 100644 --- a/src/hatty/controllers/logbook.py +++ b/src/hatty/controllers/logbook.py @@ -63,8 +63,8 @@ class LogScope: @dataclass(frozen=True) class LogScopeOption: - """One row of the `v` scope popup (or, pre-#38's popup, one step of the - blind cycle). `resolve` is pure — see the module docstring.""" + """One row of the `v` scope popup (`LogScopePopup`, `ui/log_scope_popup.py`). + `resolve` is pure — see the module docstring.""" id: str label: str @@ -255,21 +255,6 @@ def handle_scope_popup_result(self, host: LogHost, result: "str | None") -> None if result is not None: self.apply_option(host, result) - def next_option_id(self, host: LogHost) -> "str | None": - """The next option in the cycle that actually resolves right now, - wrapping — temporary, used only while `v` is still a blind cycle - (pre-scope-popup, issue #38); deleted once LogScopePopup lands.""" - session = self.session_for(host) - if session is None or not session.options: - return None - ids = [o.id for o in session.options] - index = ids.index(session.option_id) if session.option_id in ids else -1 - for step in range(1, len(ids) + 1): - option = session.options[(index + step) % len(ids)] - if option.resolve() is not None: - return option.id - return None - # ── window / paging ────────────────────────────────────────────────────── def reload(self, host: LogHost) -> None: diff --git a/src/hatty/main.py b/src/hatty/main.py index c682b2e..86c7a5c 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -105,7 +105,7 @@ class HACLI(App): Binding("c", "show_column_config", "Columns", show=False), Binding("a", "toggle_activity_log", "Activity Log", show=False), Binding("i", "toggle_entity_log", "Entity Log", show=False), - Binding("v", "cycle_log_scope", "Log Scope", show=False), + Binding("v", "show_log_scope", "Log Scope", show=False), Binding("f", "maximize_log", "Maximize Log", show=False), Binding("V", "show_log_entries", "Log Entry Text", show=False), Binding("left", "log_older", "Older Events", show=False, priority=True), @@ -862,15 +862,22 @@ def action_log_older(self) -> None: def action_log_newer(self) -> None: self.log_ctl.page(self, 1) - def action_cycle_log_scope(self) -> None: - """`v` — advance the open log's scope one step, wrapping (issue #27, - mirroring the fullscreen graph's `v`, issue #21). A scope change, not - a reopen: the paged window and the maximized state survive. Options - that can't resolve right now are skipped. check_action gates this - off while the log is closed.""" - next_id = self.log_ctl.next_option_id(self) - if next_id is not None: - self.log_ctl.apply_option(self, next_id) + def action_show_log_scope(self) -> None: + """`v` — preview and pick the open log's scope (issue #38, replacing + the old blind cycle from issue #27). check_action gates this off + while the log is closed.""" + from hatty.ui.log_scope_popup import LogScopePopup + + session = self.log_ctl.session_for(self) + if session is None: + return + entity_names, device_names = self.log_ctl.display_names() + resolved = self.log_ctl.resolved_options(self) + + def callback(result: str | None) -> None: + self.log_ctl.handle_scope_popup_result(self, result) + + self.push_screen(LogScopePopup(resolved, session.option_id, entity_names, device_names), callback) def action_toggle_activity_log(self) -> None: if self.log_ctl.is_open(self): @@ -1148,7 +1155,7 @@ def check_action(self, action: str, parameters: tuple) -> bool | None: elif action == "add_to_graph": panel = self.query_one("#detail_panel", EntityDetailPanel) return panel.has_class("-visible") - elif action in ("maximize_log", "show_log_entries", "cycle_log_scope", "log_older"): + elif action in ("maximize_log", "show_log_entries", "show_log_scope", "log_older"): return self.log_ctl.is_open(self) elif action == "log_newer": return self.log_ctl.is_open(self) and self.log_ctl.paged_back(self) diff --git a/src/hatty/ui/graph/preview_screen.py b/src/hatty/ui/graph/preview_screen.py index 176a7f8..1022615 100644 --- a/src/hatty/ui/graph/preview_screen.py +++ b/src/hatty/ui/graph/preview_screen.py @@ -152,7 +152,7 @@ class GraphPreviewScreen(Screen): Binding("C", "pick_color", "Color Picker"), Binding("l", "show_list_popup", "Back to List", show=False), Binding("a", "toggle_event_log", "Activity Log"), - Binding("v", "cycle_log_view", "Log View"), + Binding("v", "show_log_scope", "Log View"), Binding("f", "maximize_log", "Maximize Log", show=False), Binding("V", "show_log_entries", "Full Text", show=False), Binding("question_mark", "show_help", "Help"), @@ -214,7 +214,7 @@ class GraphPreviewScreen(Screen): ( "Activity log", frozenset( - {"toggle_event_log", "cycle_log_view", "maximize_log", "show_log_entries", "close_event_log"} + {"toggle_event_log", "show_log_scope", "maximize_log", "show_log_entries", "close_event_log"} ), ), ("Other", frozenset({"show_list_popup", "show_help", "go_back"})), @@ -312,7 +312,7 @@ def check_action(self, action: str, parameters: tuple) -> bool | None: return self._log_visible() if action == "show_log_entries": return self._log_visible() - if action == "cycle_log_view": + if action == "show_log_scope": return self._log_visible() if action == "go_back": return not self._cursor_mode and not self._log_visible() @@ -795,7 +795,7 @@ def _close_event_log(self) -> None: self.app.log_ctl.close(self) self._redraw() - _LOG_HINT = "v view · f max · V full text · a close · ←/→ page with the graph" + _LOG_HINT = "v scope · f max · V full text · a close · ←/→ page with the graph" _LOG_HINT_MAXIMIZED = "↑/↓ select · f exit · a close · ←/→ page with the graph" def action_close_event_log(self) -> None: @@ -860,12 +860,22 @@ def action_toggle_event_log(self) -> None: return self._open_event_log() - def action_cycle_log_view(self) -> None: - """`v` — advance through the scope options, wrapping (issue #21). A - no-op while the log is closed (gated by check_action).""" - next_id = self.app.log_ctl.next_option_id(self) - if next_id is not None: - self.app.log_ctl.apply_option(self, next_id) + def action_show_log_scope(self) -> None: + """`v` — preview and pick the open log's scope (issue #38, replacing + the old blind cycle from issue #21). A no-op while the log is closed + (gated by check_action).""" + from hatty.ui.log_scope_popup import LogScopePopup + + session = self.app.log_ctl.session_for(self) + if session is None: + return + entity_names, device_names = self.app.log_ctl.display_names() + resolved = self.app.log_ctl.resolved_options(self) + + def callback(result: str | None) -> None: + self.app.log_ctl.handle_scope_popup_result(self, result) + + self.app.push_screen(LogScopePopup(resolved, session.option_id, entity_names, device_names), callback) async def _refresh_events_if_open(self) -> None: session = self.app.log_ctl.session_for(self) diff --git a/src/hatty/ui/log_scope_popup.py b/src/hatty/ui/log_scope_popup.py new file mode 100644 index 0000000..f40408e --- /dev/null +++ b/src/hatty/ui/log_scope_popup.py @@ -0,0 +1,136 @@ +# hatty — MIT License. See LICENSE file for details. +"""`v` — preview-then-commit popup for the activity log's scope (issue #38), +replacing the old blind cycle. Lists every `LogScopeOption` the open session +offers (unresolvable ones, e.g. a cursor-scoped option with no selected row, +are simply omitted); highlighting a row resolves it — `LogbookController. +resolve` is pure, so this is side-effect-free — and renders the entities/ +devices it would log into the preview pane below, with a summary line +noting any 200-entity/50-device cap. `Enter` applies the highlighted option +(`LogbookController.apply_option` — closes the popup, clears the panel, +refetches, and resyncs the live subscription); `Escape`/`q` cancel, leaving +the current scope untouched. + +Scopes are resolved eagerly, once, in `__init__`, over every option — cheap: +a handful of registry passes bounded by the same caps that bound the fetch +itself. Entity/device *name* rendering is lazy, done per highlight, reusing +`LogbookController.display_names()`'s single precedence chain (passed in +rather than re-derived here).""" + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, VerticalScroll +from textual.widgets import Footer, Label, OptionList, Static +from textual.widgets.option_list import Option + +from hatty.controllers.logbook import LogScope, LogScopeOption +from hatty.ui.popup_base import PopupScreen + + +class LogScopePopup(PopupScreen[str | None]): + DEFAULT_CSS = """ + LogScopePopup .popup-container { + width: 72; + height: 80%; + max-height: 30; + } + LogScopePopup #log_scope_options { + height: auto; + max-height: 6; + } + LogScopePopup #log_scope_summary { + margin-top: 1; + color: $text-muted; + } + LogScopePopup #log_scope_preview { + height: 1fr; + border-top: solid $accent; + margin-top: 1; + padding-top: 1; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("q", "cancel", "Cancel", show=False), + ] + + def __init__( + self, + resolved: list[tuple[LogScopeOption, "LogScope | None"]], + active_option_id: str, + entity_names: dict[str, str], + device_names: dict[str, str], + ) -> None: + super().__init__() + # Options that can't resolve right now (a cursor-scoped option with + # no selected row) never show up as a row at all. + self._resolved: list[tuple[LogScopeOption, LogScope]] = [ + (option, scope) for option, scope in resolved if scope is not None + ] + self._active_option_id = active_option_id + self._entity_names = entity_names + self._device_names = device_names + + def compose(self) -> ComposeResult: + with Container(id="log_scope_container", classes="popup-container"): + yield Label("Activity Log Scope", classes="popup-title") + yield OptionList(id="log_scope_options", markup=False) + yield Label("", id="log_scope_summary") + with VerticalScroll(id="log_scope_preview"): + yield Static(id="log_scope_preview_body", markup=False) + yield Footer() + + def on_mount(self) -> None: + options = self.query_one("#log_scope_options", OptionList) + for option, scope in self._resolved: + options.add_option(Option(self._row_label(option, scope), id=option.id)) + options.focus() + index = next((i for i, (o, _) in enumerate(self._resolved) if o.id == self._active_option_id), 0) + options.highlighted = index + self._render_preview(index) + + @staticmethod + def _row_label(option: LogScopeOption, scope: LogScope) -> str: + if scope.device_ids or scope.device_total: + count = scope.device_total or len(scope.device_ids) + noun = "device" if count == 1 else "devices" + else: + count = scope.entity_total or len(scope.entity_ids) + noun = "entity" if count == 1 else "entities" + return f"{option.label} ({count} {noun})" + + def _render_preview(self, index: int) -> None: + _option, scope = self._resolved[index] + lines: list[str] = [] + if scope.device_ids: + lines.append(f"Devices ({len(scope.device_ids)})") + for device_id in scope.device_ids: + lines.append(f" {self._device_names.get(device_id, device_id)}") + lines.append("") + lines.append(f"Entities ({len(scope.entity_ids)})") + for entity_id in scope.entity_ids: + lines.append(f" {self._entity_names.get(entity_id, entity_id)} {entity_id}") + self.query_one("#log_scope_preview_body", Static).update("\n".join(lines)) + + summary = self.query_one("#log_scope_summary", Label) + if scope.entity_total: + summary.update(f"Showing the first {len(scope.entity_ids)} of {scope.entity_total} entities") + elif scope.device_total: + summary.update(f"Showing the first {len(scope.device_ids)} of {scope.device_total} devices") + else: + parts = [f"{len(scope.entity_ids)} entities"] + if scope.device_ids: + parts.append(f"{len(scope.device_ids)} devices") + summary.update(" · ".join(parts)) + + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option_index is not None: + self._render_preview(event.option_index) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + if event.option_index is not None: + option, _scope = self._resolved[event.option_index] + self.dismiss(option.id) + + def action_cancel(self) -> None: + self.dismiss(None) diff --git a/tests/test_activity_log.py b/tests/test_activity_log.py index f07033e..e45f115 100644 --- a/tests/test_activity_log.py +++ b/tests/test_activity_log.py @@ -8,7 +8,18 @@ from hatty.ui.entity_table import EntitiesTable from hatty.ui.graph.duration_popup import GraphDurationPopup from hatty.ui.graph.entity_detail import EntityDetailPanel -from tests.conftest import NO_LIST_CONFIG +from tests.conftest import NO_LIST_CONFIG, make_config + + +async def test_a_with_an_empty_active_list_notifies_and_stays_hidden(make_app, sample_entities): + config = {**make_config(), "lists": {"my_list": []}, "default_list": "my_list"} + app = make_app(entities=sample_entities, config_data=config) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("a") + await pilot.pause() + panel = app.query_one("#activity_log_panel", ActivityLogPanel) + assert not panel.has_class("-visible") async def test_a_opens_activity_log_panel_and_a_again_closes_it(make_app): diff --git a/tests/test_demo_mode.py b/tests/test_demo_mode.py index b4348f7..fb6d7a9 100644 --- a/tests/test_demo_mode.py +++ b/tests/test_demo_mode.py @@ -111,9 +111,10 @@ async def test_demo_mode_serves_devices_and_areas_and_populates_tree(): async def test_demo_mode_device_log_shows_a_device_event(): - """`i` then `v` on the demo Zigbee button's battery entity surfaces its - device events (issue #17) — proof --demo exercises the same WS-shaped - normalization path a real logbook/get_events response would.""" + """`i` then `v` (the scope popup, issue #38) on the demo Zigbee button's + battery entity surfaces its device events (issue #17) — proof --demo + exercises the same WS-shaped normalization path a real logbook/ + get_events response would.""" app = HACLI(demo=True) async with app.run_test() as pilot: await pilot.pause() @@ -130,7 +131,9 @@ async def test_demo_mode_device_log_shows_a_device_event(): await pilot.pause() await pilot.press("i") # opens the entity-scoped view await pilot.pause() - await pilot.press("v") # advances to the device view, adding device_ids + await pilot.press("v") # opens the scope popup + await pilot.pause() + await pilot.press("down", "enter") # picks the device-widened option, adding device_ids await pilot.pause() panel = app.query_one("#activity_log_panel", ActivityLogPanel) diff --git a/tests/test_device_log.py b/tests/test_device_log.py index 78f3f09..8e713c2 100644 --- a/tests/test_device_log.py +++ b/tests/test_device_log.py @@ -1,9 +1,13 @@ # hatty — MIT License. See LICENSE file for details. +"""Device-scoped activity log on the main screen (issue #18), reached via +the `v` scope popup (issue #38, replacing the old blind cycle from #27).""" + from textual.coordinate import Coordinate -from textual.widgets import Label, Log +from textual.widgets import Label from hatty.ui.activity_log_panel import ActivityLogPanel -from tests.conftest import NO_LIST_CONFIG +from tests.conftest import NO_LIST_CONFIG, notified +from tests.test_log_scope_popup import _pick_via_popup # sample_registry fixture is shared from tests/conftest.py. @@ -14,9 +18,7 @@ # Row 3: sensor.temperature (Temperature Sensor) -async def test_i_v_advances_to_device_view_and_sends_the_entitys_device_id( - make_app, sample_entities, sample_registry -): +async def test_i_v_applies_device_scope_via_popup(make_app, sample_entities, sample_registry): app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) async with app.run_test() as pilot: await pilot.pause() @@ -25,8 +27,8 @@ async def test_i_v_advances_to_device_view_and_sends_the_entitys_device_id( await pilot.pause() await pilot.press("i") await pilot.pause() - await pilot.press("v") - await pilot.pause() + + await _pick_via_popup(pilot, 1) # entities_devices panel = app.query_one("#activity_log_panel", ActivityLogPanel) assert panel.has_class("-visible") @@ -39,20 +41,6 @@ async def test_i_v_advances_to_device_view_and_sends_the_entitys_device_id( assert app.client.logbook_calls[-1][3] == ["dev_abc"] -async def test_v_sends_no_device_id_when_entity_has_no_device(make_app, sample_entities, sample_registry): - app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - table = app.query_one("EntitiesTable") - table.cursor_coordinate = Coordinate(0, 0) # switch.fan (no device_id) - await pilot.pause() - await pilot.press("i") - await pilot.pause() - await pilot.press("v") - await pilot.pause() - assert app.client.logbook_calls[-1][3] == [] - - async def test_capital_a_is_no_longer_bound(make_app, sample_entities, sample_registry): app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) async with app.run_test() as pilot: @@ -75,8 +63,7 @@ async def test_a_closes_device_log_view(make_app, sample_entities, sample_regist await pilot.pause() await pilot.press("i") await pilot.pause() - await pilot.press("v") - await pilot.pause() + await _pick_via_popup(pilot, 1) # entities_devices panel = app.query_one("#activity_log_panel", ActivityLogPanel) assert panel.has_class("-visible") await pilot.press("a") @@ -84,40 +71,6 @@ async def test_a_closes_device_log_view(make_app, sample_entities, sample_regist assert not panel.has_class("-visible") -async def test_device_log_live_update_from_sibling(make_app, sample_entities, sample_registry): - app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - table = app.query_one("EntitiesTable") - table.cursor_coordinate = Coordinate(2, 0) # light.living_room_lamp - await pilot.pause() - await pilot.press("a") - await pilot.pause() - await pilot.press("v") # base_devices - await pilot.pause() - await pilot.press("v") # cursor - await pilot.pause() - await pilot.press("v") # cursor_device: sibling kitchen_light is now in scope - await pilot.pause() - # Opening a live log auto-subscribes to logbook/event_stream (issue #19); - # the raw state_changed append is then the fallback path, so simulate it - # not being active here to keep testing the pre-#19 append mechanism. - app.client.logbook_subscription_id = None - log_widget = app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_widget", Log) - count_before = log_widget.line_count - - app.client.inject_state_change( - { - "entity_id": "light.kitchen_light", - "state": "on", - "attributes": {"friendly_name": "Kitchen Light"}, - "last_changed": "2024-01-15T10:32:00.000000+00:00", - } - ) - await pilot.pause() - assert log_widget.line_count == count_before + 1 - - async def test_device_log_fallback_when_no_device_id(make_app, sample_entities, sample_registry): app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) async with app.run_test() as pilot: @@ -132,35 +85,27 @@ async def test_device_log_fallback_when_no_device_id(make_app, sample_entities, assert app.log_ctl.session_for(app).entity_ids == {"switch.fan"} -async def test_v_is_a_noop_when_no_entities(make_app): - app = make_app(entities=[], config_data=NO_LIST_CONFIG) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("v") - await pilot.pause() - panel = app.query_one("#activity_log_panel", ActivityLogPanel) - assert not panel.has_class("-visible") - - -async def test_v_opens_device_log_for_entity_with_different_device(make_app, sample_entities, sample_registry): +async def test_cursor_device_with_no_device_notifies_and_omits_device_id(make_app, sample_entities, sample_registry): app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) async with app.run_test() as pilot: await pilot.pause() table = app.query_one("EntitiesTable") - table.cursor_coordinate = Coordinate(3, 0) # sensor.temperature (dev_xyz, solo) + table.cursor_coordinate = Coordinate(0, 0) # switch.fan (no device_id) await pilot.pause() - await pilot.press("i") + await pilot.press("a") await pilot.pause() - assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} + + await _pick_via_popup(pilot, 3) # cursor_device + + assert app.client.logbook_calls[-1][3] == [] + assert notified(app, title="Device Log", message_contains="No device found") -async def test_v_scopes_to_graphed_entity_and_wraps_after_two_views(make_app, sample_entities, sample_registry): +async def test_v_scopes_to_graphed_entitys_device_over_the_lists_devices(make_app, sample_entities, sample_registry): """A graphed entity's device takes priority over expanding the whole active list's devices (issue #14) — sensor.temperature (dev_xyz, solo) graphed while `my_list` (light.living_room_lamp + sensor.temperature, - spanning dev_abc and dev_xyz) is active should log only dev_xyz. A fixed - (graph-based) scope offers no cursor views, so `v` wraps after 2 presses, - unlike the 4-view table-base cycle (issue #27).""" + spanning dev_abc and dev_xyz) is active should log only dev_xyz.""" config = { "home_assistant": {"url": "http://fake.ha.local:8123", "token": "fake_token_abc"}, "default_list": "my_list", @@ -179,15 +124,10 @@ async def test_v_scopes_to_graphed_entity_and_wraps_after_two_views(make_app, sa await pilot.pause() assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} - await pilot.press("v") - await pilot.pause() + await _pick_via_popup(pilot, 1) # entities_devices + assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} title = str(app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content) assert "Device Log" in title assert "Temperature Sensor" in title assert "devices)" not in title # a single device never shows the count suffix - - await pilot.press("v") # wraps back to the plain entity view - await pilot.pause() - title = str(app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content) - assert title.startswith("Activity Log") diff --git a/tests/test_device_log_list.py b/tests/test_device_log_list.py deleted file mode 100644 index b875690..0000000 --- a/tests/test_device_log_list.py +++ /dev/null @@ -1,148 +0,0 @@ -# hatty — MIT License. See LICENSE file for details. -"""`v` widens the activity log over a whole list: its entities' devices -(issue #69), then narrows to the cursor's entity and its device, then wraps -(issue #27).""" - -from textual.widgets import Label - -from hatty.ui.activity_log_panel import ActivityLogPanel -from tests.conftest import make_config - -# sample_registry fixture is shared from tests/conftest.py. - - -def _list_config(list_entities): - return { - **make_config(), - "lists": {"my_list": list_entities}, - "default_list": "my_list", - } - - -async def test_v_adds_the_lists_device_ids_without_expanding_siblings(make_app, sample_entities, sample_registry): - # List has one light; its device dev_abc also owns kitchen_light, but the - # "base_devices" view (v once) only widens the event-type query, not the - # entity set. - config = _list_config(["light.living_room_lamp"]) - app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - assert app.current_list_name == "my_list" - await pilot.press("a") - await pilot.pause() - await pilot.press("v") - await pilot.pause() - panel = app.query_one("#activity_log_panel", ActivityLogPanel) - assert panel.has_class("-visible") - assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp"} - assert app.client.logbook_calls[-1][3] == ["dev_abc"] - - -async def test_v_title_shows_list_and_device_count(make_app, sample_entities, sample_registry): - config = _list_config(["light.living_room_lamp", "sensor.temperature"]) - app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("a") - await pilot.pause() - await pilot.press("v") - await pilot.pause() - panel = app.query_one("#activity_log_panel", ActivityLogPanel) - title = str(panel.query_one("#log_title", Label).content) - assert "Device Log" in title - assert "my_list" in title - assert "2 devices" in title - - -async def test_v_passes_through_entity_without_device(make_app, sample_entities, sample_registry): - config = _list_config(["switch.fan"]) # no device_id in registry - app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("a") - await pilot.pause() - await pilot.press("v") - await pilot.pause() - assert app.log_ctl.session_for(app).entity_ids == {"switch.fan"} - - -async def test_v_sends_every_device_id_over_the_list(make_app, sample_entities, sample_registry): - config = _list_config(["light.living_room_lamp", "sensor.temperature"]) - app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("a") - await pilot.pause() - await pilot.press("v") - await pilot.pause() - assert set(app.client.logbook_calls[-1][3]) == {"dev_abc", "dev_xyz"} - - -async def test_v_cycles_the_list_base_through_four_scopes_and_wraps(make_app, sample_entities, sample_registry): - # Row 0 after sort: light.kitchen_light (dev_abc); row 1: light.living_room_lamp (dev_abc). - config = _list_config(["light.living_room_lamp", "sensor.temperature"]) - app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - table = app.query_one("EntitiesTable") - table.jump_cursor_to_row_key("sensor.temperature") - await pilot.pause() - - await pilot.press("a") # 1: list entities only - await pilot.pause() - panel = app.query_one("#activity_log_panel", ActivityLogPanel) - assert panel.has_class("-visible") - assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp", "sensor.temperature"} - assert app.client.logbook_calls[-1][3] == [] - - await pilot.press("v") # 2: list entities' devices - await pilot.pause() - assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp", "sensor.temperature"} - assert set(app.client.logbook_calls[-1][3]) == {"dev_abc", "dev_xyz"} - - await pilot.press("v") # 3: the cursor entity alone - await pilot.pause() - assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} - assert app.client.logbook_calls[-1][3] == [] - title = str(panel.query_one("#log_title", Label).content) - assert title.startswith("Activity Log — Temperature Sensor") - - await pilot.press("v") # 4: the cursor entity's device - await pilot.pause() - assert app.client.logbook_calls[-1][3] == ["dev_xyz"] - assert app.log_ctl.session_for(app).entity_ids == {"sensor.temperature"} - title = str(panel.query_one("#log_title", Label).content) - assert "devices)" not in title # a single device never shows the count suffix - - await pilot.press("v") # wraps back to the plain list scope - await pilot.pause() - assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp", "sensor.temperature"} - assert app.client.logbook_calls[-1][3] == [] - assert panel.has_class("-visible") # the cycle never closes the panel - - -async def test_left_arrow_paging_preserves_device_scope(make_app, sample_entities, sample_registry): - config = _list_config(["light.living_room_lamp"]) - app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("a") - await pilot.pause() - await pilot.press("v") - await pilot.pause() - assert app.client.logbook_calls[-1][3] == ["dev_abc"] - - await pilot.press("left") - await pilot.pause() - assert app.client.logbook_calls[-1][3] == ["dev_abc"] - - -async def test_device_log_empty_list_notifies_and_stays_hidden(make_app, sample_entities, sample_registry): - config = _list_config([]) - app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("a") - await pilot.pause() - panel = app.query_one("#activity_log_panel", ActivityLogPanel) - assert not panel.has_class("-visible") diff --git a/tests/test_graph.py b/tests/test_graph.py index d39ed10..45cb8a8 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -458,7 +458,7 @@ async def test_stray_list_bindings_disabled_on_fullscreen_graph(make_app, sample "rename_entity", "show_column_config", "toggle_activity_log", - "cycle_log_scope", + "show_log_scope", "toggle_graph", "add_to_graph", ): diff --git a/tests/test_graph_event_log.py b/tests/test_graph_event_log.py index aa6deaa..61cb65a 100644 --- a/tests/test_graph_event_log.py +++ b/tests/test_graph_event_log.py @@ -3,9 +3,12 @@ window follows the graph's own paging so opening the log shows events for whatever's currently plotted, and closing/escaping tears it back down. -Also `v`'s log-view cycle (issue #21): entity-only, then entity + the -plotted entities' devices' events (issue #18) — with device events drawn in -a distinct color.""" +`v`'s scope popup (issue #38, replacing the old blind cycle from #21) is +covered in test_log_scope_popup.py; this file keeps only the plot-mark +color assertions, which depend on the device-widened scope but aren't +really about the popup itself — entity-only vs. entity + the plotted +entities' devices' events (issue #18), with device events drawn in a +distinct color.""" from textual.coordinate import Coordinate from textual.widgets import Label, Log @@ -108,55 +111,13 @@ async def test_a_sends_no_device_ids(make_app, sample_entities, sample_registry) assert app.client.logbook_calls[-1][3] == [] -async def test_v_advances_to_device_view_and_sends_the_entitys_device_id(make_app, sample_entities, sample_registry): - app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} - await _open_preview_on_temperature(pilot, app) - - await pilot.press("a", "v") - await pilot.pause() - assert app.client.logbook_calls[-1][3] == ["dev_xyz"] - - -async def test_device_view_title_says_device_log(make_app, sample_entities, sample_registry): - app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} - preview = await _open_preview_on_temperature(pilot, app) - - await pilot.press("a", "v") - await pilot.pause() - log_panel = preview.query_one("#preview_log_panel", ActivityLogPanel) - title = str(log_panel.query_one("#log_title", Label).content) - assert "Device Log" in title - assert "Temperature Sensor" in title - - -async def test_v_wraps_back_to_entity_view(make_app, sample_entities, sample_registry): - app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} - preview = await _open_preview_on_temperature(pilot, app) - - await pilot.press("a", "v", "v") - await pilot.pause() - assert app.client.logbook_calls[-1][3] == [] - log_panel = preview.query_one("#preview_log_panel", ActivityLogPanel) - title = str(log_panel.query_one("#log_title", Label).content) - assert "Activity Log" in title - - async def test_v_is_a_noop_when_log_closed(make_app, sample_entities): app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG) async with app.run_test() as pilot: await pilot.pause() app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} preview = await _open_preview_on_temperature(pilot, app) - assert preview.check_action("cycle_log_view", ()) is False + assert preview.check_action("show_log_scope", ()) is False await pilot.press("v") await pilot.pause() @@ -180,20 +141,6 @@ async def test_capital_a_does_nothing_on_the_graph_screen(make_app, sample_entit assert not log_panel.has_class("-visible") -async def test_a_closes_from_any_view(make_app, sample_entities, sample_registry): - app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} - preview = await _open_preview_on_temperature(pilot, app) - - await pilot.press("a", "v", "a") - await pilot.pause() - - log_panel = preview.query_one("#preview_log_panel", ActivityLogPanel) - assert not log_panel.has_class("-visible") - - async def test_device_scoped_event_renders_and_marks_the_plot_in_cyan( make_app, sample_entities, sample_registry, monkeypatch ): @@ -220,6 +167,8 @@ async def test_device_scoped_event_renders_and_marks_the_plot_in_cyan( await pilot.press("a", "v") await pilot.pause() + await pilot.press("down", "enter") + await pilot.pause() assert any(e["kind"] == "event" for e in preview._events) log_panel = preview.query_one("#preview_log_panel", ActivityLogPanel) @@ -260,7 +209,9 @@ async def test_neither_view_ever_marks_orange(make_app, sample_entities, sample_ assert colors_with_marks == {"magenta"} calls.clear() - await pilot.press("v") # device view + await pilot.press("v") # opens the scope popup + await pilot.pause() + await pilot.press("down", "enter") # picks the device-widened option await pilot.pause() colors_with_marks = {color for color, count in calls if count > 0} assert "orange" not in colors_with_marks diff --git a/tests/test_log_scope_cycle.py b/tests/test_log_scope_popup.py similarity index 51% rename from tests/test_log_scope_cycle.py rename to tests/test_log_scope_popup.py index 5fc0a61..60a30a0 100644 --- a/tests/test_log_scope_cycle.py +++ b/tests/test_log_scope_popup.py @@ -1,13 +1,16 @@ # hatty — MIT License. See LICENSE file for details. -"""`v` cycles the main screen's activity log scope in place (issue #27), -mirroring the fullscreen graph's `v` (issue #21) — a scope change, not a -reopen, so the paged window and the maximized state survive it.""" +"""`v` opens a preview-then-commit popup for the activity log's scope +(issue #38), replacing the old blind cycle (issue #27, mirrored on the +fullscreen graph by issue #21) — a scope change in place, not a reopen, so +the paged window and the maximized state survive committing a new one.""" -from textual.widgets import Label, Log +from textual.widgets import Label, Log, OptionList, Static import hatty.controllers.logbook as logbook_module from hatty.ui.activity_log_panel import ActivityLogPanel -from tests.conftest import make_config +from hatty.ui.log_scope_popup import LogScopePopup +from tests.conftest import make_config, notified +from tests.test_graph_event_log import _open_preview_on_temperature # sample_registry fixture is shared from tests/conftest.py. @@ -20,42 +23,141 @@ def _list_config(list_entities): } -async def test_v_preserves_the_paged_window(make_app, sample_entities, sample_registry): - config = _list_config(["light.living_room_lamp"]) +async def _pick_via_popup(pilot, index: int) -> None: + """Open the popup (assumes it's not already open), jump to the row at + `index`, and commit it.""" + await pilot.press("v") + await pilot.pause() + await pilot.press("home") + for _ in range(index): + await pilot.press("down") + await pilot.press("enter") + await pilot.pause() + + +async def test_v_opens_the_popup_with_four_options_for_a_table_base(make_app, sample_entities, sample_registry): + config = _list_config(["light.living_room_lamp", "sensor.temperature"]) + app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("a", "v") + await pilot.pause() + assert isinstance(app.screen, LogScopePopup) + options = app.screen.query_one("#log_scope_options", OptionList) + assert options.option_count == 4 + + +async def test_v_offers_two_options_for_a_fixed_entity_base(make_app, sample_entities, sample_registry): + app = make_app(entities=sample_entities, config_data=make_config(lists={}), registry=sample_registry) + async with app.run_test() as pilot: + await pilot.pause() + table = app.query_one("EntitiesTable") + table.jump_cursor_to_row_key("light.living_room_lamp") + await pilot.pause() + await pilot.press("i", "v") + await pilot.pause() + assert isinstance(app.screen, LogScopePopup) + options = app.screen.query_one("#log_scope_options", OptionList) + assert options.option_count == 2 + + +async def test_v_offers_two_options_on_the_graph_screen(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=make_config(lists={})) + async with app.run_test() as pilot: + await pilot.pause() + app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} + await _open_preview_on_temperature(pilot, app) + await pilot.press("a", "v") + await pilot.pause() + assert isinstance(app.screen, LogScopePopup) + options = app.screen.query_one("#log_scope_options", OptionList) + assert options.option_count == 2 + + +async def test_cursor_options_omitted_when_no_row_is_selected(make_app, sample_entities, sample_registry): + config = _list_config(["light.living_room_lamp", "sensor.temperature"]) app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) async with app.run_test() as pilot: await pilot.pause() await pilot.press("a") await pilot.pause() - await pilot.press("left") + + app.search_term = "zzz_no_match" + app._update_entities_display() await pilot.pause() - paged_end = app.log_ctl.session_for(app).end - assert paged_end is not None await pilot.press("v") await pilot.pause() + options = app.screen.query_one("#log_scope_options", OptionList) + assert options.option_count == 2 # cursor / cursor_device both unresolvable - assert app.log_ctl.session_for(app).end == paged_end - last_call = app.client.logbook_calls[-1] - assert last_call[2] == paged_end # end +async def test_highlighting_previews_entity_and_device_names(make_app, sample_entities, sample_registry): + config = _list_config(["light.living_room_lamp", "sensor.temperature"]) + devices = [{"id": "dev_abc", "name": "Living Room Hub"}, {"id": "dev_xyz", "name": "Temperature Hub"}] + app = make_app(entities=sample_entities, config_data=config, registry=sample_registry, devices=devices) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("a", "v") + await pilot.pause() -async def test_v_preserves_the_maximized_panel(make_app, sample_entities, sample_registry): - config = _list_config(["light.living_room_lamp"]) + preview = app.screen.query_one("#log_scope_preview_body", Static) + assert "Living Room Lamp" in str(preview.content) + assert "Temperature Sensor" in str(preview.content) + + await pilot.press("down") # list_devices + await pilot.pause() + assert "Living Room Hub" in str(preview.content) # devices are shown by name, not id + summary = app.screen.query_one("#log_scope_summary", Label) + assert "device" in str(summary.content) + + +async def test_cap_notice_in_summary_and_toast_fires_only_on_apply( + make_app, sample_entities, sample_registry, monkeypatch +): + config = _list_config(["light.living_room_lamp", "sensor.temperature"]) + app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) + monkeypatch.setattr(logbook_module, "_DEVICE_LOG_MAX_DEVICES", 1) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("a", "v") + await pilot.pause() + + await pilot.press("down") # list_devices — widened to 2 devices, capped to 1 + await pilot.pause() + summary = app.screen.query_one("#log_scope_summary", Label) + assert "first 1 of 2 devices" in str(summary.content) + assert not notified(app, title="Device Log") # highlighting alone must not toast + + await pilot.press("enter") + await pilot.pause() + assert app.client.logbook_calls[-1][3] == ["dev_abc"] + assert notified(app, title="Device Log") + + +async def test_enter_applies_and_preserves_paged_window_and_maximized(make_app, sample_entities, sample_registry): + config = _list_config(["light.living_room_lamp", "sensor.temperature"]) app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) async with app.run_test() as pilot: await pilot.pause() await pilot.press("a") await pilot.pause() - await pilot.press("f") + await pilot.press("left") # page back + await pilot.press("f") # maximize await pilot.pause() + paged_end = app.log_ctl.session_for(app).end + assert paged_end is not None panel = app.query_one("#activity_log_panel", ActivityLogPanel) assert panel.has_class("-maximized") - await pilot.press("v") - await pilot.pause() + await _pick_via_popup(pilot, 1) # list_devices + assert app.log_ctl.session_for(app).option_id == "list_devices" + assert app.log_ctl.session_for(app).end == paged_end assert panel.has_class("-maximized") + last_call = app.client.logbook_calls[-1] + assert last_call[2] == paged_end + assert last_call[3] == ["dev_abc", "dev_xyz"] async def test_v_resubscribes_the_live_stream_with_the_new_scope(make_app, sample_entities, sample_registry): @@ -67,30 +169,45 @@ async def test_v_resubscribes_the_live_stream_with_the_new_scope(make_app, sampl await pilot.pause() assert app.client.subscribe_logbook_calls[-1] == (["light.living_room_lamp"], []) - await pilot.press("v") - await pilot.pause() + await _pick_via_popup(pilot, 1) # list_devices assert app.client.subscribe_logbook_calls[-1] == (["light.living_room_lamp"], ["dev_abc"]) -async def test_v_stays_unsubscribed_while_paged_back(make_app, sample_entities, sample_registry): - config = _list_config(["light.living_room_lamp"]) +async def test_escape_cancels_leaving_scope_untouched(make_app, sample_entities, sample_registry): + config = _list_config(["light.living_room_lamp", "sensor.temperature"]) app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) async with app.run_test() as pilot: await pilot.pause() await pilot.press("a") await pilot.pause() - await pilot.press("left") - await pilot.pause() - assert app.client.logbook_subscription_id is None + calls_before = len(app.client.logbook_calls) await pilot.press("v") await pilot.pause() + await pilot.press("down", "down") # move around, but never commit + await pilot.pause() + + await pilot.press("escape") + await pilot.pause() + assert app.log_ctl.session_for(app).option_id == "list" + assert len(app.client.logbook_calls) == calls_before + panel = app.query_one("#activity_log_panel", ActivityLogPanel) + assert panel.has_class("-visible") # back on the main screen, log untouched + - assert app.client.logbook_subscription_id is None +async def test_v_is_a_noop_while_the_log_is_closed(make_app): + app = make_app() + async with app.run_test() as pilot: + await pilot.pause() + assert app.check_action("show_log_scope", ()) is False + + await pilot.press("v") + await pilot.pause() + assert not app.query_one("#activity_log_panel", ActivityLogPanel).has_class("-visible") -async def test_v_v_v_retargets_the_live_append_filter(make_app, sample_entities, sample_registry): +async def test_reaching_cursor_device_retargets_the_live_append_filter(make_app, sample_entities, sample_registry): config = _list_config(["light.living_room_lamp", "sensor.temperature"]) app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) async with app.run_test() as pilot: @@ -100,12 +217,8 @@ async def test_v_v_v_retargets_the_live_append_filter(make_app, sample_entities, await pilot.pause() await pilot.press("a") await pilot.pause() - await pilot.press("v") # base_devices - await pilot.pause() - await pilot.press("v") # cursor: narrows to living_room_lamp alone - await pilot.pause() - await pilot.press("v") # cursor_device: sibling kitchen_light now in scope - await pilot.pause() + + await _pick_via_popup(pilot, 3) # cursor_device assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp", "light.kitchen_light"} app.client.logbook_subscription_id = None @@ -123,8 +236,6 @@ async def test_v_v_v_retargets_the_live_append_filter(make_app, sample_entities, await pilot.pause() assert log_widget.line_count == count_before + 1 - # An entity outside every widened view (not in the list, no shared device) - # is still filtered out. count_before = log_widget.line_count app.client.inject_state_change( { @@ -136,83 +247,3 @@ async def test_v_v_v_retargets_the_live_append_filter(make_app, sample_entities, ) await pilot.pause() assert log_widget.line_count == count_before - - -async def test_v_is_a_noop_while_the_log_is_closed(make_app): - app = make_app() - async with app.run_test() as pilot: - await pilot.pause() - assert app.check_action("cycle_log_scope", ()) is False - - await pilot.press("v") - await pilot.pause() - assert not app.query_one("#activity_log_panel", ActivityLogPanel).has_class("-visible") - - -async def test_v_skips_the_cursor_views_when_no_row_is_selected(make_app, sample_entities, sample_registry): - config = _list_config(["light.living_room_lamp", "sensor.temperature"]) - app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("a") - await pilot.pause() - - # Filter the table down to no rows, so _selected_entity_id() has nothing - # to resolve for the cursor-scoped views. - app.search_term = "zzz_no_match" - app._update_entities_display() - await pilot.pause() - - await pilot.press("v") # base_devices - await pilot.pause() - await pilot.press("v") # cursor / cursor_device would resolve to nothing — skip straight back to base - await pilot.pause() - - panel = app.query_one("#activity_log_panel", ActivityLogPanel) - assert panel.has_class("-visible") - title = str(panel.query_one("#log_title", Label).content) - assert title.startswith("Activity Log — my_list") - assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp", "sensor.temperature"} - - -async def test_v_caps_the_widened_scope(make_app, sample_entities, sample_registry, monkeypatch): - config = _list_config(["light.living_room_lamp", "sensor.temperature"]) - app = make_app(entities=sample_entities, config_data=config, registry=sample_registry) - monkeypatch.setattr(logbook_module, "_DEVICE_LOG_MAX_DEVICES", 1) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("a") - await pilot.pause() - await pilot.press("v") - await pilot.pause() - - assert len(app.client.logbook_calls[-1][3]) == 1 - - -async def test_v_walks_the_fixed_base_through_two_views_and_wraps(make_app, sample_entities, sample_registry): - app = make_app(entities=sample_entities, config_data=make_config(lists={}), registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - table = app.query_one("EntitiesTable") - table.jump_cursor_to_row_key("light.living_room_lamp") - await pilot.pause() - await pilot.press("i") - await pilot.pause() - panel = app.query_one("#activity_log_panel", ActivityLogPanel) - title = str(panel.query_one("#log_title", Label).content) - assert title.startswith("Activity Log") - assert app.client.logbook_calls[-1][3] == [] - - await pilot.press("v") - await pilot.pause() - title = str(panel.query_one("#log_title", Label).content) - assert title.startswith("Device Log") - assert app.client.logbook_calls[-1][3] == ["dev_abc"] - assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp"} - - await pilot.press("v") # wraps — a fixed base has no cursor views - await pilot.pause() - title = str(panel.query_one("#log_title", Label).content) - assert title.startswith("Activity Log") - assert app.client.logbook_calls[-1][3] == [] - assert app.log_ctl.session_for(app).entity_ids == {"light.living_room_lamp"} diff --git a/tests/unit/test_logbook_controller.py b/tests/unit/test_logbook_controller.py index e18675e..8d542ad 100644 --- a/tests/unit/test_logbook_controller.py +++ b/tests/unit/test_logbook_controller.py @@ -330,19 +330,16 @@ async def test_apply_option_resubscribes_with_the_new_scope(): assert app.client.subscribe_calls[-1] == (["light.b"], []) -# ── next_option_id (the pre-popup blind cycle) ────────────────────────────── +# ── resolved_options / handle_scope_popup_result (the `v` scope popup) ───── -def test_next_option_id_skips_unresolvable_options(): +def test_resolved_options_includes_unresolvable_as_none(): ctl, app = _controller() host = _StubHost() options = [ ctl.base_option("list", "my_list", ["light.a"], with_devices=False), - ctl.base_option("list_devices", "my_list", ["light.a"], with_devices=True), ctl.cursor_option("cursor", lambda: None, with_device=False), - ctl.cursor_option("cursor_device", lambda: None, with_device=True), ] - # Built directly rather than via open() — open() would spawn a fetch we don't need here. session = LogSession( host=host, panel_id=host.LOG_PANEL_ID, @@ -355,7 +352,42 @@ def test_next_option_id_skips_unresolvable_options(): title_base="Activity Log — my_list", ) ctl._sessions[id(host)] = session - assert ctl.next_option_id(host) == "list_devices" + resolved = ctl.resolved_options(host) + assert [scope is not None for _option, scope in resolved] == [True, False] + + +def test_resolved_options_empty_without_a_session(): + ctl, app = _controller() + assert ctl.resolved_options(_StubHost()) == [] + + +async def test_handle_scope_popup_result_applies_the_chosen_option(): + ctl, app = _controller() + host = _StubHost() + options = [ + ctl.base_option("a", "a", ["light.a"], with_devices=False), + ctl.base_option("b", "b", ["light.b"], with_devices=False), + ] + ctl.open(host, options=options, option_id="a", hint="") + await app.run_spawned() + + ctl.handle_scope_popup_result(host, "b") + await app.run_spawned() + assert ctl.session_for(host).option_id == "b" + + +async def test_handle_scope_popup_result_none_leaves_scope_untouched(): + ctl, app = _controller() + host = _StubHost() + options = [ + ctl.base_option("a", "a", ["light.a"], with_devices=False), + ctl.base_option("b", "b", ["light.b"], with_devices=False), + ] + ctl.open(host, options=options, option_id="a", hint="") + await app.run_spawned() + + ctl.handle_scope_popup_result(host, None) + assert ctl.session_for(host).option_id == "a" # ── paging ─────────────────────────────────────────────────────────────────── From ff24394f824163520c85c8ecc9482a13e2c10b69 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:21:16 -0500 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=94=A5=20Delete=20LogEntryPopup,=20su?= =?UTF-8?q?perseded=20by=20the=20maximized=20selectable=20list=20Refs=20#3?= =?UTF-8?q?8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/logbook.py | 8 +- src/hatty/main.py | 18 +---- src/hatty/ui/activity_log_panel.py | 12 +-- src/hatty/ui/graph/preview_screen.py | 37 +++------- src/hatty/ui/log_entry_popup.py | 106 --------------------------- tests/test_log_entry_popup.py | 105 -------------------------- tests/unit/test_log_detail_format.py | 5 +- 7 files changed, 25 insertions(+), 266 deletions(-) delete mode 100644 src/hatty/ui/log_entry_popup.py delete mode 100644 tests/test_log_entry_popup.py diff --git a/src/hatty/logbook.py b/src/hatty/logbook.py index 54822e9..9c6b96c 100644 --- a/src/hatty/logbook.py +++ b/src/hatty/logbook.py @@ -192,9 +192,11 @@ def format_log_datetime(iso_str: str) -> str: def format_log_detail(entry: LogEntry) -> str: - """The untruncated block a LogEntryPopup shows for one selected entry - (issue #23) — unlike format_log_line, never budgets or truncates, so the - full name/detail survive regardless of panel width. One field per line: + """The untruncated block ActivityLogPanel's maximized detail region shows + for the selected entry (issue #23, moved from the now-deleted + LogEntryPopup by issue #38) — unlike format_log_line, never budgets or + truncates, so the full name/detail survive regardless of panel width. + One field per line: timestamp, name (⚡-prefixed for an event, matching format_log_line's form), detail, then entity_id when the entry carries one (device-scoped events don't).""" diff --git a/src/hatty/main.py b/src/hatty/main.py index 86c7a5c..18d25ae 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -57,7 +57,6 @@ from hatty.ui.graph.entity_detail import EntityDetailPanel from hatty.ui.help_popup import HelpPopup from hatty.ui.list_selection_popup import ListSelectionPopup -from hatty.ui.log_entry_popup import LogEntryPopup from hatty.ui.rename_entity_popup import RenameEntityPopup from hatty.ui.search_input import SearchInput @@ -107,7 +106,6 @@ class HACLI(App): Binding("i", "toggle_entity_log", "Entity Log", show=False), Binding("v", "show_log_scope", "Log Scope", show=False), Binding("f", "maximize_log", "Maximize Log", show=False), - Binding("V", "show_log_entries", "Log Entry Text", show=False), Binding("left", "log_older", "Older Events", show=False, priority=True), Binding("right", "log_newer", "Newer Events", show=False, priority=True), Binding("g", "toggle_graph", "Graph", show=False), @@ -821,19 +819,7 @@ def action_maximize_log(self) -> None: if not maximizing: self.query_one("#entities_table", EntitiesTable).focus() - def action_show_log_entries(self) -> None: - """`V` — browse the open log's retained entries and read a - truncated line's full text (issue #23).""" - if not self.log_ctl.is_open(self): - return - log_panel = self.query_one("#activity_log_panel", ActivityLogPanel) - entries = log_panel.entries - if not entries: - self.notify("No activity log entries to show.", title="Activity Log") - return - self.push_screen(LogEntryPopup(entries, log_panel.title_text)) - - _LOG_HINT = "v scope · f maximize · V full text · ←/→ older/newer · T timeframe · a/i close" + _LOG_HINT = "v scope · f maximize · ←/→ older/newer · T timeframe · a/i close" _LOG_HINT_MAXIMIZED = "↑/↓ select · f exit · ←/→ older/newer · T timeframe" def _graph_entity_ids(self) -> list[str]: @@ -1155,7 +1141,7 @@ def check_action(self, action: str, parameters: tuple) -> bool | None: elif action == "add_to_graph": panel = self.query_one("#detail_panel", EntityDetailPanel) return panel.has_class("-visible") - elif action in ("maximize_log", "show_log_entries", "show_log_scope", "log_older"): + elif action in ("maximize_log", "show_log_scope", "log_older"): return self.log_ctl.is_open(self) elif action == "log_newer": return self.log_ctl.is_open(self) and self.log_ctl.paged_back(self) diff --git a/src/hatty/ui/activity_log_panel.py b/src/hatty/ui/activity_log_panel.py index 989fa81..08dc155 100644 --- a/src/hatty/ui/activity_log_panel.py +++ b/src/hatty/ui/activity_log_panel.py @@ -2,9 +2,9 @@ """The activity log side panel: a docked, togglable log of Home Assistant logbook entries, hosted both on the main entity table (`a`/`i` open it — list or single-entity scope; scoped to the graphed entity/entities instead -when the inline graph panel is open — and `v` cycles the scope, issue #27) -and on the fullscreen graph screen (`a` opens it, `v` cycles its scope, -issue #21; its events additionally marked on the plot). +when the inline graph panel is open — and `v` opens a scope popup, issue +#38) and on the fullscreen graph screen (`a` opens it, `v` opens the same +popup, issue #21; its events additionally marked on the plot). The panel itself is dumb — a title, a bottom hint line (`set_hint`) the host screen fills in with its own keys (since the two hosts offer different @@ -174,12 +174,6 @@ def set_hint(self, text: str) -> None: def title_text(self) -> str: return self._title - @property - def entries(self) -> list[LogEntry]: - """A snapshot of the retained entries, newest last — what - LogEntryPopup (issue #23) browses.""" - return list(self._entries) - @staticmethod def _dedupe_key(entry: LogEntry) -> tuple[str, str, str]: return (entry["when"], entry["name"], entry["detail"]) diff --git a/src/hatty/ui/graph/preview_screen.py b/src/hatty/ui/graph/preview_screen.py index 1022615..f030dde 100644 --- a/src/hatty/ui/graph/preview_screen.py +++ b/src/hatty/ui/graph/preview_screen.py @@ -30,16 +30,17 @@ `a` toggles a docked activity log for the plotted entities (issue #2), fetched for the same window the graph is currently showing; paging/zooming the graph (`left`/`right`/`shift+left`/`shift+right`/`+`/`-`/`home`) refetches -it to match. `v` cycles its scope through `_LOG_VIEWS` (issue #21): the -plotted entities alone, then their devices' events too (issue #18, e.g. a -zha_event button press). `f` maximizes it to the full screen width (issue -#22); `V` opens a browse popup (`LogEntryPopup`, issue #23) over its -retained entries for reading a truncated line's full text; `a` always -closes outright even while maximized, while `escape`/`q` restore the normal -width first and only close on a further press. While open, each logged -event is additionally marked on the plot itself -(`plot_render.render_event_marks`) — numeric and binary graphs only; climate -graphs still show the log list but skip the marks. +it to match. `v` opens a preview-then-commit scope popup (`LogScopePopup`, +issue #38, replacing the old blind cycle from #21): the plotted entities +alone, or widened to their devices' events too (issue #18, e.g. a zha_event +button press). `f` maximizes the panel to the full screen width and turns it +into a selectable list with an inline untruncated detail region (issue #22, +upgraded by #38 — no separate browse popup anymore); `a` always closes +outright even while maximized, while `escape`/`q` restore the normal width +first and only close on a further press. While open, each logged event is +additionally marked on the plot itself (`plot_render.render_event_marks`) — +numeric and binary graphs only; climate graphs still show the log list but +skip the marks. `ALLOWED_APP_ACTIONS` is this screen's carve-out from `HACLI.check_action`'s "pushed screen" lockdown — only the app-level keys that still do something on @@ -82,7 +83,6 @@ from hatty.ui.graph.plot_time import secs_since from hatty.ui.graph.plot_time import ts_to_full as _ts_to_full from hatty.ui.graph.window import GraphWindow -from hatty.ui.log_entry_popup import LogEntryPopup if TYPE_CHECKING: from hatty.main import HACLI @@ -154,7 +154,6 @@ class GraphPreviewScreen(Screen): Binding("a", "toggle_event_log", "Activity Log"), Binding("v", "show_log_scope", "Log View"), Binding("f", "maximize_log", "Maximize Log", show=False), - Binding("V", "show_log_entries", "Full Text", show=False), Binding("question_mark", "show_help", "Help"), Binding("escape", "exit_cursor_mode", "Exit Inspect"), Binding("escape", "close_event_log", "Close Log"), @@ -214,7 +213,7 @@ class GraphPreviewScreen(Screen): ( "Activity log", frozenset( - {"toggle_event_log", "show_log_scope", "maximize_log", "show_log_entries", "close_event_log"} + {"toggle_event_log", "show_log_scope", "maximize_log", "close_event_log"} ), ), ("Other", frozenset({"show_list_popup", "show_help", "go_back"})), @@ -310,8 +309,6 @@ def check_action(self, action: str, parameters: tuple) -> bool | None: return not self._cursor_mode and self._log_visible() if action == "maximize_log": return self._log_visible() - if action == "show_log_entries": - return self._log_visible() if action == "show_log_scope": return self._log_visible() if action == "go_back": @@ -826,16 +823,6 @@ def action_maximize_log(self) -> None: # the way the main table is for HACLI). self.set_focus(None) - def action_show_log_entries(self) -> None: - """`V` — browse the open log's retained entries and read a - truncated line's full text (issue #23).""" - log_panel = self.query_one("#preview_log_panel", ActivityLogPanel) - entries = log_panel.entries - if not entries: - self.notify("No activity log entries to show.", title="Activity Log") - return - self.app.push_screen(LogEntryPopup(entries, log_panel.title_text)) - def action_go_back(self) -> None: self.dismiss() diff --git a/src/hatty/ui/log_entry_popup.py b/src/hatty/ui/log_entry_popup.py deleted file mode 100644 index 56a8c4d..0000000 --- a/src/hatty/ui/log_entry_popup.py +++ /dev/null @@ -1,106 +0,0 @@ -# hatty — MIT License. See LICENSE file for details. -"""Browse popup for reading a truncated activity-log line's full text (issue -#23), opened with `V` from either ActivityLogPanel host (the main table and -the fullscreen graph). Takes a snapshot of the panel's retained entries -(`ActivityLogPanel.entries`) — it doesn't stay live against further -appends/scope changes, matching every other popup's fire-and-forget-a-list -shape (ListSelectionPopup, GraphDurationPopup, ...). - -Rows reuse `format_log_line` (the same truncated form the panel itself -shows) so the list reads like a zoomed-out mirror of the panel; the detail -pane below tracks the highlighted row via `format_log_detail`, which never -truncates. `OptionList`/`Static` are both constructed with `markup=False` — -a raw log line's `[HH:MM:SS]` prefix would otherwise parse as (invalid) -console markup. - -Row width is re-measured and rebuilt on resize, the same width-tracking -pattern as ActivityLogPanel._reflow_lines (issue #22) — necessary here too -since the popup, unlike the docked panel, doesn't have a fixed width.""" - -from textual import events -from textual.app import ComposeResult -from textual.binding import Binding -from textual.containers import Container, VerticalScroll -from textual.widgets import Footer, Label, OptionList, Static - -from hatty.logbook import LogEntry, format_log_detail, format_log_line -from hatty.ui.popup_base import PopupScreen - - -class LogEntryPopup(PopupScreen): - DEFAULT_CSS = """ - LogEntryPopup .popup-container { - width: 90%; - height: 80%; - max-width: 100; - max-height: 40; - } - LogEntryPopup #log_entry_list { - height: 1fr; - } - LogEntryPopup #log_entry_detail_scroll { - height: auto; - max-height: 10; - border-top: solid $accent; - margin-top: 1; - padding-top: 1; - } - """ - - BINDINGS = [ - Binding("escape", "close", "Close"), - Binding("q", "close", "Close", show=False), - Binding("V", "close", "Close", show=False), - Binding("enter", "close", "Close", show=False), - ] - - def __init__(self, entries: list[LogEntry], title: str) -> None: - super().__init__() - self._entries = entries - self._title = title - self._rendered_width = 0 - - def compose(self) -> ComposeResult: - with Container(classes="popup-container"): - yield Label(self._title, classes="popup-title") - yield OptionList(id="log_entry_list", markup=False) - with VerticalScroll(id="log_entry_detail_scroll"): - yield Static(id="log_entry_detail", markup=False) - yield Footer() - - def on_mount(self) -> None: - self._render_options() - options = self.query_one("#log_entry_list", OptionList) - options.focus() - if self._entries: - options.highlighted = len(self._entries) - 1 - - def _line_width(self) -> int: - options = self.query_one("#log_entry_list", OptionList) - return max(20, options.scrollable_content_region.width or options.content_size.width or 50) - - def _render_options(self) -> None: - options = self.query_one("#log_entry_list", OptionList) - width = self._line_width() - highlighted = options.highlighted - options.clear_options() - options.add_options(format_log_line(entry, width) for entry in self._entries) - self._rendered_width = width - if highlighted is not None and highlighted < len(self._entries): - options.highlighted = highlighted - - def on_resize(self, event: events.Resize) -> None: - if not self._entries: - return - if self._line_width() == self._rendered_width: - return - self._render_options() - - def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: - if event.option_index is None: - return - entry = self._entries[event.option_index] - self.query_one("#log_entry_detail", Static).update(format_log_detail(entry)) - - def action_close(self) -> None: - self.dismiss(None) diff --git a/tests/test_log_entry_popup.py b/tests/test_log_entry_popup.py deleted file mode 100644 index c521c53..0000000 --- a/tests/test_log_entry_popup.py +++ /dev/null @@ -1,105 +0,0 @@ -# hatty — MIT License. See LICENSE file for details. -"""`V` browses the open activity log's retained entries in a popup and shows -a selected entry's full, untruncated text (issue #23) — from both hosts of -ActivityLogPanel: the main table's docked panel and the fullscreen graph's.""" - -from textual.widgets import OptionList, Static - -from hatty.ui.activity_log_panel import ActivityLogPanel -from hatty.ui.log_entry_popup import LogEntryPopup -from tests.conftest import NO_LIST_CONFIG -from tests.test_graph_event_log import _open_preview_on_temperature - -_LONG_NAME = "A Very Long Entity Name That Goes On And On And On And On And On" - - -async def test_v_opens_log_entry_popup_with_all_entries(make_app): - app = make_app() - async with app.run_test() as pilot: - await pilot.pause() - app.client._logbook_data = [ - {"when": "2024-01-15T10:29:58+00:00", "name": "Front Door", "state": "on"}, - {"when": "2024-01-15T10:30:00+00:00", "name": _LONG_NAME, "state": "on"}, - ] - await pilot.press("a") - await pilot.pause() - - await pilot.press("V") - await pilot.pause() - - assert isinstance(app.screen, LogEntryPopup) - options = app.screen.query_one("#log_entry_list", OptionList) - assert options.option_count == 2 - assert options.highlighted == 1 # newest preselected - - -async def test_detail_pane_shows_full_untruncated_text(make_app): - app = make_app() - async with app.run_test() as pilot: - await pilot.pause() - app.client._logbook_data = [{"when": "2024-01-15T10:30:00+00:00", "name": _LONG_NAME, "state": "on"}] - await pilot.press("a") - await pilot.pause() - - panel = app.query_one("#activity_log_panel", ActivityLogPanel) - # The panel's own truncated line must not contain the full name. - assert not any(_LONG_NAME in line for line in panel.query_one("#log_widget").lines) - - await pilot.press("V") - await pilot.pause() - - detail = str(app.screen.query_one("#log_entry_detail", Static).content) - assert _LONG_NAME in detail - assert "on" in detail - - -async def test_escape_closes_popup_and_leaves_panel_visible(make_app): - app = make_app() - async with app.run_test() as pilot: - await pilot.pause() - app.client._logbook_data = [{"when": "2024-01-15T10:30:00+00:00", "name": "Front Door", "state": "on"}] - await pilot.press("a") - await pilot.pause() - await pilot.press("V") - await pilot.pause() - assert isinstance(app.screen, LogEntryPopup) - - await pilot.press("escape") - await pilot.pause() - - assert not isinstance(app.screen, LogEntryPopup) - assert app.query_one("#activity_log_panel", ActivityLogPanel).has_class("-visible") - - -async def test_v_is_a_noop_when_log_closed(make_app): - app = make_app(config_data=NO_LIST_CONFIG) - async with app.run_test() as pilot: - await pilot.pause() - assert app.check_action("show_log_entries", ()) is False - - await pilot.press("V") - await pilot.pause() - - assert not isinstance(app.screen, LogEntryPopup) - - -async def test_v_opens_log_entry_popup_from_fullscreen_graph(make_app, sample_entities): - app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG) - async with app.run_test() as pilot: - await pilot.pause() - app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} - preview = await _open_preview_on_temperature(pilot, app) - app.client._logbook_data = [{"when": "2024-01-15T10:30:00+00:00", "name": _LONG_NAME, "state": "on"}] - - assert preview.check_action("show_log_entries", ()) is False - - await pilot.press("a") - await pilot.pause() - assert preview.check_action("show_log_entries", ()) is True - - await pilot.press("V") - await pilot.pause() - - assert isinstance(app.screen, LogEntryPopup) - detail = str(app.screen.query_one("#log_entry_detail", Static).content) - assert _LONG_NAME in detail diff --git a/tests/unit/test_log_detail_format.py b/tests/unit/test_log_detail_format.py index 2ee0476..567f69f 100644 --- a/tests/unit/test_log_detail_format.py +++ b/tests/unit/test_log_detail_format.py @@ -1,7 +1,8 @@ # hatty — MIT License. See LICENSE file for details. """format_log_detail/format_log_datetime (issue #23): the untruncated -per-field text a LogEntryPopup shows for a selected entry — mirrors -test_log_line_format.py's style for format_log_line.""" +per-field text ActivityLogPanel's maximized detail region shows for the +selected entry (issue #38) — mirrors test_log_line_format.py's style for +format_log_line.""" from hatty.logbook import LogEntry, format_log_datetime, format_log_detail, format_log_time From f2832a6d8fe3eebc81a49174bfb05cd414aefc3d Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:34:51 -0500 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20Document=20log=5Fctl=20in=20?= =?UTF-8?q?CLAUDE.md,=20fix=20stale=20graph-log=20hint=20text=20Refs=20#38?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 6 ++++-- src/hatty/ui/graph/preview_screen.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0b40c22..a3eb8e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,10 +34,12 @@ User Keybindings → HACLI (main.py) → HAClient (client.py) ↔ Home Assistant _update_entities_display() → EntitiesTable ``` -Domain state lives on four controllers instantiated in `HACLI.__init__`, each holding one slice and +Domain state lives on controllers instantiated in `HACLI.__init__`, each holding one slice and taking an injected app reference: `controllers/lists.py` (`app.list_ctl`), `dashboards.py` (`app.dash_ctl`), `graphs.py` (`app.graph_ctl`), `connection.py` (`app.conn_ctl` — the HA websocket -message pump, `handle_ha_message`/`_HA_MESSAGE_HANDLERS`). **`HACLI` keeps its old attribute surface +message pump, `handle_ha_message`/`_HA_MESSAGE_HANDLERS`), `notifications.py` (`app.notify_ctl`), +`logbook.py` (`app.log_ctl` — the activity log's scope/paging/fetch/subscription state machine, +shared by `HACLI`'s docked panel and `GraphPreviewScreen`'s). **`HACLI` keeps its old attribute surface via property pairs** (`app.dashboards`, `app.current_list_name`, `app._detail_entity_id`, …) so screens and tests read/assign through the app unchanged; new UI code should call controllers directly instead (`self.app.dash_ctl.set_slot(...)`). diff --git a/src/hatty/ui/graph/preview_screen.py b/src/hatty/ui/graph/preview_screen.py index f030dde..763f303 100644 --- a/src/hatty/ui/graph/preview_screen.py +++ b/src/hatty/ui/graph/preview_screen.py @@ -792,7 +792,7 @@ def _close_event_log(self) -> None: self.app.log_ctl.close(self) self._redraw() - _LOG_HINT = "v scope · f max · V full text · a close · ←/→ page with the graph" + _LOG_HINT = "v scope · f max · a close · ←/→ page with the graph" _LOG_HINT_MAXIMIZED = "↑/↓ select · f exit · a close · ←/→ page with the graph" def action_close_event_log(self) -> None: