Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 45 additions & 10 deletions src/hatty/controllers/logbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand Down
20 changes: 14 additions & 6 deletions src/hatty/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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).
Expand Down
118 changes: 118 additions & 0 deletions tests/test_activity_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
98 changes: 98 additions & 0 deletions tests/unit/test_logbook_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) ─────


Expand Down
Loading