diff --git a/src/hatty/controllers/logbook.py b/src/hatty/controllers/logbook.py index f7d5939..7e3aa6a 100644 --- a/src/hatty/controllers/logbook.py +++ b/src/hatty/controllers/logbook.py @@ -22,7 +22,12 @@ 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. +exactly once, for the option actually applied (unless called quiet=True). + +A cursor_option-backed session tracks the table cursor live: HACLI debounces +DataTable.CellHighlighted into follow_cursor, which quietly re-applies the +active option when the resolved scope actually changed. A maximized panel +opts out — the table isn't what's focused there. """ import asyncio @@ -64,11 +69,14 @@ class LogScope: @dataclass(frozen=True) class LogScopeOption: """One row of the `v` scope popup (`LogScopePopup`, `ui/log_scope_popup.py`). - `resolve` is pure — see the module docstring.""" + `resolve` is pure — see the module docstring. `follows_cursor` marks a + cursor_option so LogbookController.follow_cursor knows to re-resolve it + as the table selection moves.""" id: str label: str resolve: Callable[[], LogScope | None] + follows_cursor: bool = False @dataclass @@ -213,7 +221,7 @@ def _resolve() -> "LogScope | None": 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) + return LogScopeOption(option_id, label, _resolve, follows_cursor=True) # ── applying a scope ───────────────────────────────────────────────────── @@ -223,12 +231,13 @@ def resolved_options(self, host: LogHost) -> list[tuple[LogScopeOption, "LogScop return [] return [(option, option.resolve()) for option in session.options] - def apply_option(self, host: LogHost, option_id: str) -> None: + def apply_option(self, host: LogHost, option_id: str, *, quiet: bool = False) -> 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.""" + get surfaced as a notification, unless quiet=True (follow_cursor's + silent re-apply as the table selection moves).""" session = self.session_for(host) if session is None: return @@ -238,11 +247,12 @@ def apply_option(self, host: LogHost, option_id: str) -> None: 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) + if not quiet: + 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) @@ -255,6 +265,31 @@ def handle_scope_popup_result(self, host: LogHost, result: "str | None") -> None if result is not None: self.apply_option(host, result) + def follow_cursor(self, host: LogHost) -> None: + """Re-point a cursor-scoped session at the table's new selection. + No-op for base scopes, for a maximized panel (the log list owns + focus there, not the table), and when the cursor resolves to the + same scope already applied (moving between siblings of one device + under cursor_device).""" + session = self.session_for(host) + if session is None: + return + option = next((o for o in session.options if o.id == session.option_id), None) + if option is None or not option.follows_cursor: + return + if session.panel().has_class("-maximized"): + return + scope = option.resolve() + if scope is None: + return + if ( + scope.entity_ids == session.query_ids + and scope.device_ids == session.device_ids + and scope.title == session.title_base + ): + return + self.apply_option(host, session.option_id, quiet=True) + # ── window / paging ────────────────────────────────────────────────────── def reload(self, host: LogHost) -> None: diff --git a/src/hatty/main.py b/src/hatty/main.py index 18d25ae..aa4836f 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -160,6 +160,7 @@ def __init__(self, config_path: str | None = None, demo: bool = False): self._update_pending = False self.pending_call_status: dict[str, str] = {} self._pending_call_timers: dict[str, Timer] = {} + self._log_cursor_timer: Timer | None = None # Fire-and-forget tasks hold a reference here so asyncio can't GC them # mid-flight; done tasks remove themselves. self._bg_tasks: set[asyncio.Task] = set() @@ -821,6 +822,8 @@ def action_maximize_log(self) -> None: _LOG_HINT = "v scope · f maximize · ←/→ older/newer · T timeframe · a/i close" _LOG_HINT_MAXIMIZED = "↑/↓ select · f exit · ←/→ older/newer · T timeframe" + # Coalesces held arrow-key repeats before a cursor-scoped log refetches + resubscribes. + _LOG_CURSOR_DEBOUNCE = 0.3 def _graph_entity_ids(self) -> list[str]: """The graphed entity plus its `+` comparison lines, primary first.""" @@ -932,18 +935,23 @@ def action_toggle_entity_log(self) -> None: 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: + if event.data_table.id != "entities_table": return - entity_id = event.cell_key.row_key.value - if not entity_id or entity_id == self._detail_entity_id: + if not entity_id: return - entity = self.find_entity(entity_id) - if not entity: + if self._detail_entity_id is not None: + if entity_id != self._detail_entity_id: + entity = self.find_entity(entity_id) + if entity: + self.graph_ctl.follow_cursor(entity_id, entity) return - self.graph_ctl.follow_cursor(entity_id, entity) + # A cursor-scoped log refetches + resubscribes, so coalesce held arrow keys. + if self._log_cursor_timer is not None: + self._log_cursor_timer.stop() + self._log_cursor_timer = self.set_timer(self._LOG_CURSOR_DEBOUNCE, lambda: self.log_ctl.follow_cursor(self)) def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: # Enter on the entities table toggles the selected entity (mirrors the dashboard's Enter). diff --git a/tests/test_activity_log.py b/tests/test_activity_log.py index e45f115..761ced8 100644 --- a/tests/test_activity_log.py +++ b/tests/test_activity_log.py @@ -406,6 +406,124 @@ async def test_snapping_back_to_live_resubscribes_the_stream(make_app): assert app.client.logbook_subscription_id is not None +# Cursor-scoped log follows the table cursor: moving the highlighted row +# re-resolves the "cursor"/"cursor_device" options in place, without the +# user having to re-press `v` (issue #38 follow-up). +# +# With NO_LIST_CONFIG + sample_entities, alphabetical sort by friendly name: +# Row 0: switch.fan (Fan Switch, no device) +# Row 1: light.kitchen_light (Kitchen Light, dev_abc) +# Row 2: light.living_room_lamp (Living Room Lamp, dev_abc) +# Row 3: sensor.temperature (Temperature Sensor, dev_xyz) + + +async def test_cursor_scoped_log_follows_the_table_cursor(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(1, 0) # light.kitchen_light + await pilot.pause() + await pilot.press("a") + await pilot.pause() + app.log_ctl.apply_option(app, "cursor") + await pilot.pause() + + table.cursor_coordinate = Coordinate(3, 0) # sensor.temperature + await pilot.pause(app._LOG_CURSOR_DEBOUNCE + 0.1) + + 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 app.client.logbook_calls[-1][0] == ["sensor.temperature"] + + +async def test_cursor_scoped_log_resubscribes_when_live(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(1, 0) # light.kitchen_light + await pilot.pause() + await pilot.press("a") + await pilot.pause() + app.log_ctl.apply_option(app, "cursor") + await pilot.pause() + + table.cursor_coordinate = Coordinate(3, 0) # sensor.temperature + await pilot.pause(app._LOG_CURSOR_DEBOUNCE + 0.1) + + assert app.client.subscribe_logbook_calls[-1][0] == ["sensor.temperature"] + + +async def test_cursor_scope_skips_reload_when_row_is_unchanged(make_app, sample_entities, sample_registry): + """Moving the cursor across columns within the same row re-highlights the + same entity_id — the resolved scope is identical, so follow_cursor's + dedupe skips the otherwise-redundant clear+refetch+resubscribe.""" + 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(1, 0) # light.kitchen_light + await pilot.pause() + await pilot.press("a") + await pilot.pause() + app.log_ctl.apply_option(app, "cursor_device") + await pilot.pause() + calls_before = len(app.client.logbook_calls) + + table.cursor_coordinate = Coordinate(1, 1) # same row, next column + await pilot.pause(app._LOG_CURSOR_DEBOUNCE + 0.1) + + assert len(app.client.logbook_calls) == calls_before + + +async def test_base_scoped_log_ignores_cursor_movement(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(1, 0) + await pilot.pause() + await pilot.press("a") + await pilot.pause() + title_before = str( + app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content + ) + calls_before = len(app.client.logbook_calls) + + table.cursor_coordinate = Coordinate(3, 0) + await pilot.pause(app._LOG_CURSOR_DEBOUNCE + 0.1) + + title_after = str( + app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content + ) + assert title_after == title_before + assert len(app.client.logbook_calls) == calls_before + + +async def test_maximized_cursor_scoped_log_ignores_cursor_movement(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(1, 0) # light.kitchen_light + await pilot.pause() + await pilot.press("a") + await pilot.pause() + app.log_ctl.apply_option(app, "cursor") + await pilot.pause() + await pilot.press("f") + await pilot.pause() + calls_before = len(app.client.logbook_calls) + + table.cursor_coordinate = Coordinate(3, 0) # sensor.temperature + await pilot.pause(app._LOG_CURSOR_DEBOUNCE + 0.1) + + assert len(app.client.logbook_calls) == calls_before + assert app.log_ctl.session_for(app).entity_ids == {"light.kitchen_light"} + + async def test_reconnect_resubscribes_the_open_live_log(make_app): app = make_app() async with app.run_test() as pilot: diff --git a/tests/unit/test_logbook_controller.py b/tests/unit/test_logbook_controller.py index 8d542ad..ba0e816 100644 --- a/tests/unit/test_logbook_controller.py +++ b/tests/unit/test_logbook_controller.py @@ -330,6 +330,104 @@ async def test_apply_option_resubscribes_with_the_new_scope(): assert app.client.subscribe_calls[-1] == (["light.b"], []) +# ── follow_cursor ──────────────────────────────────────────────────────────── + + +async def test_follow_cursor_noops_for_a_base_scope(): + 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() + calls_before = len(app.client.logbook_calls) + + ctl.follow_cursor(host) + await app.run_spawned() + + assert len(app.client.logbook_calls) == calls_before + + +async def test_follow_cursor_reapplies_a_cursor_scope_that_moved(): + ctl, app = _controller() + host = _StubHost() + app.all_entities = [ + {"entity_id": "light.a", "attributes": {"friendly_name": "Lamp A"}}, + {"entity_id": "light.b", "attributes": {"friendly_name": "Lamp B"}}, + ] + selected = "light.a" + options = [ctl.cursor_option("cursor", lambda: selected, with_device=False)] + ctl.open(host, options=options, option_id="cursor", hint="") + await app.run_spawned() + assert app.client.logbook_calls[-1][0] == ["light.a"] + + selected = "light.b" + ctl.follow_cursor(host) + await app.run_spawned() + + assert app.client.logbook_calls[-1][0] == ["light.b"] + assert host.panel_widget.title == "Activity Log — Lamp B" + + +async def test_follow_cursor_skips_reload_when_scope_is_unchanged(): + ctl, app = _controller() + host = _StubHost() + app.all_entities = [{"entity_id": "light.a", "attributes": {"friendly_name": "Lamp A"}}] + options = [ctl.cursor_option("cursor", lambda: "light.a", with_device=False)] + ctl.open(host, options=options, option_id="cursor", hint="") + await app.run_spawned() + calls_before = len(app.client.logbook_calls) + cleared_before = host.panel_widget.cleared + + ctl.follow_cursor(host) + await app.run_spawned() + + assert len(app.client.logbook_calls) == calls_before + assert host.panel_widget.cleared == cleared_before + + +async def test_follow_cursor_is_quiet_about_no_device_notice(): + ctl, app = _controller() + host = _StubHost() + app.all_entities = [ + {"entity_id": "switch.fan", "attributes": {}}, + {"entity_id": "light.a", "attributes": {}}, + ] + app.entity_registry = [{"entity_id": "light.a", "device_id": "dev_1"}] + selected = "light.a" + options = [ctl.cursor_option("cursor_device", lambda: selected, with_device=True)] + ctl.open(host, options=options, option_id="cursor_device", hint="") + await app.run_spawned() + assert app.notifications == [] + + selected = "switch.fan" # no device -> would notify "No device found" if not quiet + ctl.follow_cursor(host) + await app.run_spawned() + + assert app.notifications == [] + assert app.client.logbook_calls[-1][0] == ["switch.fan"] + + +async def test_follow_cursor_noops_while_maximized(): + ctl, app = _controller() + host = _StubHost() + app.all_entities = [ + {"entity_id": "light.a", "attributes": {"friendly_name": "Lamp A"}}, + {"entity_id": "light.b", "attributes": {"friendly_name": "Lamp B"}}, + ] + selected = "light.a" + options = [ctl.cursor_option("cursor", lambda: selected, with_device=False)] + ctl.open(host, options=options, option_id="cursor", hint="") + await app.run_spawned() + host.panel_widget.add_class("-maximized") + calls_before = len(app.client.logbook_calls) + + selected = "light.b" + ctl.follow_cursor(host) + await app.run_spawned() + + assert len(app.client.logbook_calls) == calls_before + + # ── resolved_options / handle_scope_popup_result (the `v` scope popup) ─────