From cdcc3a9347a0dc88471e776ef2ab5acd65dbc37c Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:01:59 -0500 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=93=9D=20Condense=20verbose=20comment?= =?UTF-8?q?s=20in=20main.py=20and=20const.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/const.py | 52 ++++++++++-------------- src/hatty/main.py | 99 ++++++++++++++++++---------------------------- 2 files changed, 59 insertions(+), 92 deletions(-) diff --git a/src/hatty/const.py b/src/hatty/const.py index 7348907..46f0dcf 100644 --- a/src/hatty/const.py +++ b/src/hatty/const.py @@ -6,16 +6,13 @@ must not import anything from the app so it stays cycle-safe. """ -# Domains whose entities can be flipped with a plain homeassistant.toggle-style -# turn_on/turn_off pair (enter on the entities table). "media_player" is also here, -# but its enter behavior is media_play_pause, not turn_on/turn_off — see the -# media_player carve-out at the top of HACLI.toggle_entity. +# Domains flippable with a plain turn_on/turn_off pair (enter on the entities +# table). "media_player" is here too, but its enter behavior is media_play_pause +# — see the carve-out at the top of HACLI.toggle_entity. TOGGLABLE_DOMAINS = {"switch", "light", "fan", "media_player"} -# Domains with an attribute-editing UI. "light" and "media_player" are routed to -# their own dedicated live-apply screens (ui/controls/light_screen.py, -# ui/controls/media_player_screen.py) by main.py; the EntityControlPopup handles -# the remaining simple field-based domains. +# Domains with an attribute-editing UI. "light"/"media_player" route to their own +# live-apply screens (ui/controls/); EntityControlPopup handles the rest. CONTROLLABLE_DOMAINS = {"light", "fan", "climate", "cover", "input_number", "lock", "media_player"} # Home Assistant MediaPlayerEntityFeature bitmask (only the flags we gate on). @@ -44,8 +41,7 @@ def media_supports(features: int | None, flag: str) -> bool: # Home Assistant WeatherEntityFeature bitmask — which weather.get_forecasts -# `type` values (used verbatim as the service call's "type" field) an entity -# supports. Order here doubles as the preferred default when several are set. +# `type` values an entity supports; order doubles as the preferred default. WEATHER_FEAT = { "forecast_daily": 1, "forecast_hourly": 2, @@ -140,9 +136,9 @@ def binary_state_label(state: str, device_class: str) -> str: "panel", ] -# widget_type -> domain its entity picker should be restricted to; absent = unrestricted (panel), -# "graph"/"gauge" are handled separately since they filter by numeric state rather than domain. -# Every new WIDGET_TYPES entry must get a mapping here or an explicit carve-out above. +# widget_type -> domain its entity picker restricts to; absent = unrestricted (panel). +# "graph"/"gauge" filter by numeric state instead, so they're not mapped here. Every +# new WIDGET_TYPES entry needs a mapping here or an explicit carve-out above. WIDGET_TYPE_DOMAINS = { "switch": "switch", "light": "light", @@ -156,9 +152,8 @@ def binary_state_label(state: str, device_class: str) -> str: "weather": "weather", } -# Widget types that can carry the per-slot "show_last_changed" option: every -# single-entity widget. "graph" already plots a time axis; "panel"/"split" hold -# many entities, so there is no single last_changed to show. +# Widget types that can carry "show_last_changed": every single-entity widget. +# "graph" plots its own time axis; "panel"/"split" hold many entities, no single one. LAST_CHANGED_WIDGET_TYPES = frozenset(WIDGET_TYPES) - {"graph", "panel"} # Entity table columns shown when the config carries no "columns" key. @@ -170,16 +165,14 @@ def binary_state_label(state: str, device_class: str) -> str: # Fallback for the global "log_hours" config value (the activity log's window size). DEFAULT_LOG_HOURS = 24 -# GraphPreviewScreen's shift+left/shift+right "fast page" multiplier over the -# normal left/right page. Lives here (not preview_screen.py) so the keybinding -# registry can reference it in a binding description without an import cycle. +# GraphPreviewScreen's shift+left/right "fast page" multiplier. Lives here (not +# preview_screen.py) so the keybinding registry can reference it without a cycle. FAST_PAGE_MULTIPLIER = 6 # Canonical names for the top-level app_config keys, so a rename is one edit and a -# typo is a NameError instead of a silent None. config.default_config() and -# storage.PERSISTED reference these, keeping them the single literal definition. -# NOTE: "graph_type"/"hours" also appear as keys *inside* saved-graph entry dicts -# (a different namespace — storage.py, controllers/graphs.py); do NOT reuse +# typo is a NameError instead of a silent None (config.default_config() and +# storage.PERSISTED reference these). NOTE: "graph_type"/"hours" also appear as +# keys *inside* saved-graph entry dicts (a different namespace) — don't reuse # CONFIG_KEY_GRAPH_TYPE there. CONFIG_KEY_HOME_ASSISTANT = "home_assistant" CONFIG_KEY_URL = "url" @@ -203,13 +196,11 @@ def binary_state_label(state: str, device_class: str) -> str: CONFIG_KEY_KEYBINDINGS = "keybindings" CONFIG_KEY_BACKUP = "backup" -# Fallback/default value for the "terminal_title" config key (issue: set tmux -# title to hatty or pref). +# Fallback for the "terminal_title" config key. DEFAULT_TERMINAL_TITLE = "hatty" -# Legacy reserved list name (issue #224). No longer special — any list can be -# designated a notification source via `notify_lists` (issue #24) — kept only as -# the name storage.migrate_reserved_notify_list looks for on a pre-#24 DB. +# Legacy reserved list name (#224). No longer special — any list can be a +# notification source via `notify_lists` (#24); kept only for migration lookup. NOTIFY_LIST_NAME = "\U0001f514 Notifications" # Default notification preferences (config key "notifications"), merged over by @@ -228,9 +219,8 @@ def binary_state_label(state: str, device_class: str) -> str: } # Default Backup & Sync preferences (config key "backup"), merged over by -# BackupController whenever a config predates a given key. "sections" is -# spelled out literally (matching backup.SECTIONS) rather than imported, so -# const.py stays free of imports from the rest of the app. +# BackupController for a config that predates a key. "sections" is spelled out +# literally (matching backup.SECTIONS) so const.py stays import-free. DEFAULT_BACKUP = { "path": "", # export directory; "" = feature idle "sections": ["lists", "dashboards", "saved_graphs", "entity_names", "settings", "keybindings"], diff --git a/src/hatty/main.py b/src/hatty/main.py index 47cac8b..a0c7c46 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -62,10 +62,8 @@ from hatty.ui.rename_entity_popup import RenameEntityPopup from hatty.ui.search_input import SearchInput -# In --demo mode the seeded DemoHAClient answers get_states almost instantly, -# so the splash would otherwise flash for a fraction of a second — never long -# enough to be seen (e.g. in the recorded demo screencast). Hold it visible for -# a moment on that first auto-dismiss only; real-HA boot is unaffected. +# --demo answers get_states almost instantly, so the splash would otherwise +# flash for a fraction of a second (invisible in a recorded screencast). DEMO_SPLASH_SECONDS = 2.5 @@ -136,9 +134,8 @@ def __init__(self, config_path: str | None = None, demo: bool = False): # 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() - # Guards the exit-time git sync against running twice: action_quit is - # the primary path, _on_exit_app is a backstop for any exit() call - # that bypasses it. + # Guards the exit-time git sync against running twice (action_quit is + # the primary path, _on_exit_app a backstop for any exit() call). self._exit_sync_done = False # Config key -> the app attribute that is its in-memory working copy, derived @@ -197,9 +194,8 @@ def log_window(self, session) -> tuple[float, "datetime | None"]: def log_title_suffix(self, session) -> str: return self.log_ctl.range_suffix(session) - # ── 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. ── + # ── Domain state lives on the controllers; these proxies preserve the app's old + # attribute surface — screens/tests read/assign directly, routed through the controller. ── # List state (ListController) entity_lists = _controller_proxy("list_ctl", "entity_lists") @@ -377,9 +373,8 @@ def _apply_config(self, cfg: dict) -> None: self._apply_terminal_title(cfg) self.keys_ctl.apply(cfg) self.backup_ctl.apply(cfg) - # Only at boot/restart (this method's three call sites), never on a - # plain reconnect from the config screen — pull_on_start() is itself a - # no-op in demo mode and when the pref is off. + # Only at boot/restart, never on a plain reconnect from the config screen — + # pull_on_start() is itself a no-op in demo mode and when the pref is off. self.spawn(self.backup_ctl.pull_on_start()) self.query_one("#detail_panel", EntityDetailPanel).apply_saved_graph_type(cfg.get(CONFIG_KEY_GRAPH_TYPE)) @@ -441,10 +436,8 @@ def _dismiss_splash(self) -> None: splash = self._splash_screen() if splash is None: return - # Demo mode: hold the splash up for a moment on its first auto-dismiss - # (see DEMO_SPLASH_SECONDS) so it's actually visible instead of a - # single-frame flash. A keypress (SplashScreen.on_key) can still skip - # it early — the deferred call below just no-ops if that happened. + # Demo mode: hold the splash up once (DEMO_SPLASH_SECONDS) so it's actually + # visible; a keypress can still dismiss early, in which case this no-ops. if self._demo and not self._demo_splash_held: self._demo_splash_held = True self.set_timer(DEMO_SPLASH_SECONDS, self._dismiss_splash) @@ -509,9 +502,8 @@ def action_palette_switch_list(self) -> None: def action_show_list_selection_popup(self) -> None: if self.current_list_name is not None and self.search_term: - # A list is already active but hidden behind a search filter — - # just clear the search and return to it, rather than making the - # user re-pick the same list from the popup (issue #211). + # A list is already active but hidden behind a search filter — clear the + # search and return to it rather than re-picking it from the popup (#211). self.pop_to_base_screen() self.search_term = "" self.set_title_based_on_focused_ui() @@ -590,10 +582,8 @@ def action_toggle_list_membership(self) -> None: action = "remove" if entity_id in current_list else "add" list_name = self.current_list_name - # Removing while viewing a locked list requires an unlock confirmation - # (issue #214) — but only in pure list-view; an active search is the - # "filter list for adding items" path the issue exempts, since there - # every displayed entity isn't necessarily a list member. + # Removing from a locked list needs an unlock confirmation (#214), but not + # during an active search — that's "filter to add items", not list-view. if action == "remove" and not self.search_term and self.list_ctl.is_locked(list_name): def _unlock_and_remove(confirmed: bool | None, _name: str = list_name, _eid: str = entity_id) -> None: @@ -689,9 +679,8 @@ def callback(result) -> None: # ── Help ────────────────────────────────────────────────────────────────── - # Textual DataTable bindings that leak into the Main page's active_bindings - # via the focused EntitiesTable but mean nothing as hatty keybindings (issue - # #7) — ↑/↓/PgUp/PgDn/Ctrl+Home/Ctrl+End stay, they're genuinely useful. + # Textual DataTable bindings that leak into active_bindings via the focused + # EntitiesTable but aren't real hatty keybindings (#7); arrows/paging stay. _HELP_HIDDEN_ACTIONS = frozenset({"cursor_left", "cursor_right", "select_cursor", "scroll_home", "scroll_end"}) def action_show_help(self) -> None: @@ -701,9 +690,8 @@ def action_show_help(self) -> None: from hatty.ui.graph.preview_screen import GraphPreviewScreen from hatty.ui.help_popup import action_name, binding_entries, sectioned_rows - # screen_cls -> its controllers/keybindings.py registry scope, so an - # inactive page's static rows go through keys_ctl.static_bindings and - # reflect the live keymap rather than the class's hard-coded defaults. + # screen_cls -> its keybindings.py registry scope, so an inactive page's + # rows reflect the live keymap rather than the class's hard-coded defaults. scope_of = { None: "app", DashboardScreen: "dashboard", @@ -721,10 +709,9 @@ def active_entries() -> list[tuple[str, str, str]]: ] def page_rows(screen_cls: type | None, is_active: bool) -> list[tuple[str, str]]: - # A screen opting into HELP_ALL_MODES (GraphPreviewScreen) always builds - # from its full static BINDINGS plus an app-level "From anywhere" section, - # regardless of which mode is active — its help page groups both modes' - # bindings side by side instead of only showing whichever is live (#7). + # HELP_ALL_MODES (GraphPreviewScreen) always builds from full static + # BINDINGS plus an app-level "From anywhere" section, so both modes' + # bindings show side by side instead of only whichever is live (#7). if screen_cls is not None and getattr(screen_cls, "HELP_ALL_MODES", False): rows = sectioned_rows( binding_entries(self.keys_ctl.static_bindings(scope_of[screen_cls])), screen_cls.HELP_SECTIONS @@ -773,9 +760,8 @@ def page_rows(screen_cls: type | None, is_active: bool) -> list[tuple[str, str]] matched_known_screen = True pages.append((title, page_rows(screen_cls, is_active))) - # A pushed screen that isn't one of the six above (Weather Forecast, - # Config, …) used to silently show the unrelated Main page instead of - # its own keys (issue #7) — give it a leading page of its own instead. + # A pushed screen outside the six above used to silently show the unrelated + # Main page instead of its own keys (#7) — give it a leading page instead. if not matched_known_screen and self.screen is not self.screen_stack[0]: screen_type = type(self.screen) title = getattr(screen_type, "HELP_TITLE", screen_type.__name__) @@ -1104,9 +1090,8 @@ def action_cycle_graph_type(self) -> None: def action_show_graph_duration(self) -> None: from hatty.ui.graph.duration_popup import GraphDurationPopup - # 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. + # The two panels are mutually exclusive, so `T` unambiguously targets + # whichever is open — the log's timeframe when visible, else the graph's. if self.log_ctl.is_open(self): self._show_log_duration_popup() return @@ -1270,11 +1255,9 @@ def handle_bindings_clash(self, clashed_bindings: set[Binding], node: DOMNode) - # ── Navigation / back ──────────────────────────────────────────────────── def check_action(self, action: str, parameters: tuple) -> bool | None: - # The command palette is a cross-screen affordance (Dashboard/Lists/ - # Configuration/Setup wizard, per HACommandProvider) and must stay - # reachable no matter which screen is on top — Dashboard and List are - # both primary displays a user switches between via the palette, not - # a base view with secondary screens bolted on (#9). + # The command palette is a cross-screen affordance and must stay reachable + # no matter which screen is on top — Dashboard/List are both primary + # displays switched via the palette, not secondary screens bolted on (#9). if action == "command_palette": return True if isinstance(self.screen, DashboardScreen): @@ -1287,10 +1270,8 @@ def check_action(self, action: str, parameters: tuple) -> bool | None: if isinstance(self.screen, GraphPreviewScreen): return action in GraphPreviewScreen.ALLOWED_APP_ACTIONS - # Any other pushed screen (ConfigScreen, LightControlScreen, popups, …) - # must not leak main-table bindings to the hidden base table (#187), but - # Textual's own tab focus navigation (app.focus_next/previous) operates on - # the pushed screen and must stay live so Tab works inside it (#202). + # Any other pushed screen must not leak main-table bindings to the hidden + # base table (#187), but focus_next/previous must stay live for Tab (#202). if self.screen is not self.screen_stack[0] and not isinstance( self.screen, (DashboardScreen, DeviceTreeScreen, GraphPreviewScreen) ): @@ -1310,10 +1291,8 @@ def check_action(self, action: str, parameters: tuple) -> bool | None: return False domain = entity_id.split(".")[0] # "weather" is neither controllable nor graphable but still routes to - # WeatherForecastScreen in open_entity_controls (issue #275) — without - # this carve-out the binding (and its footer hint) never appears on the - # main table for a weather entity, even though the dashboard/device tree - # paths reach the same screen fine via their own check_action (#283). + # WeatherForecastScreen (#275); without this carve-out the binding never + # appears on the main table, though dashboard/device tree reach it (#283). return domain in CONTROLLABLE_DOMAINS or domain == "weather" or self.graph_ctl.is_graphable(entity) elif action == "cycle_graph_type": return self._detail_entity_id is not None @@ -1458,8 +1437,8 @@ def _cycle_vi_search(self, direction: int) -> None: # ── HA message handling ────────────────────────────────────────────────── def handle_ha_message(self, msg: dict) -> None: - # Public message-callback seam: the client factory is handed this bound - # method; the pump itself lives on the ConnectionController. + # Message-callback seam: the client factory is handed this bound method; + # the pump itself lives on the ConnectionController. self.conn_ctl.handle_ha_message(msg) def _apply_name_override(self, entity: Entity) -> None: @@ -1617,9 +1596,8 @@ def callback(result: dict | None) -> None: self.push_screen(EntityControlPopup(entity), callback) elif domain == "weather": - # Fullscreen forecast view (issue #275); a weather entity's state is a - # condition slug, not numeric, so it's neither togglable nor graphable - # and would otherwise dead-end here. + # Fullscreen forecast view (#275) — a weather entity's state is a + # condition slug, so it's neither togglable nor graphable elsewhere. from hatty.ui.weather_forecast_screen import WeatherForecastScreen self.push_screen(WeatherForecastScreen(entity)) @@ -1698,9 +1676,8 @@ def _dispatch_rename_to_ha(self, entity_id: str, name: str | None) -> None: # ── Configuration screen ───────────────────────────────────────────────── def action_show_config(self) -> None: - # Pass the live in-memory config: the YAML no longer carries collections - # (they're in SQLite now), so a fresh load_config would show empty - # lists/dashboards/graphs and its save would drop them. + # Pass the live in-memory config: collections live in SQLite now, so a + # fresh load_config would show empty lists/dashboards/graphs and drop them. self.push_screen(ConfigScreen(dict(self.app_config), self.config_path), self._on_config_saved) def action_show_onboarding(self) -> None: From b96cf4e4efdd83fe265a9e46356f5e8701e83ee0 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:06:19 -0500 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=93=9D=20Condense=20verbose=20comment?= =?UTF-8?q?s=20in=20ui/dashboard/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/ui/dashboard/screen.py | 75 +++++++++-------------- src/hatty/ui/dashboard/slot_popup.py | 67 +++++++++----------- src/hatty/ui/dashboard/widgets/base.py | 10 ++- src/hatty/ui/dashboard/widgets/text.py | 5 +- src/hatty/ui/dashboard/widgets/visuals.py | 13 ++-- 5 files changed, 67 insertions(+), 103 deletions(-) diff --git a/src/hatty/ui/dashboard/screen.py b/src/hatty/ui/dashboard/screen.py index a8de61d..5acb857 100644 --- a/src/hatty/ui/dashboard/screen.py +++ b/src/hatty/ui/dashboard/screen.py @@ -224,17 +224,14 @@ def _LOG_HINT_MAXIMIZED(self) -> str: IDLE_TIMEOUT: float = 5.0 - # Minimum rows a single grid cell gets: when the dashboard has too many rows - # to fit the viewport at this height, the grid overflows and the container - # scrolls instead of squishing every cell below readability. + # Minimum height a grid cell gets before the container scrolls instead of + # squishing cells below readability. CELL_MIN_HEIGHT: int = 8 BINDINGS = bindings_for("dashboard") - # Groups the help page under the same Use/Edit split the bindings above are - # already commented with (issue #7); unlike GraphPreviewScreen this page - # still switches between active-mode and full-static rows in the usual way - # (main.action_show_help) — only the grouping is new. + # Groups the help page under the same Use/Edit split as the bindings above + # (#7); unlike GraphPreviewScreen it still switches active/static rows normally. HELP_SECTIONS = ( ("Use mode", USE_ONLY_ACTIONS), ("Edit mode", EDIT_ONLY_ACTIONS), @@ -297,10 +294,8 @@ def _LOG_HINT_MAXIMIZED(self) -> str: def __init__(self): super().__init__() - # Grid navigation lives on a pure GridCursor (path + step/clamp/descend - # math). _cursor_path / cursor_row / cursor_col stay as delegating - # properties over it so the rest of the screen and the tests read/assign - # them unchanged. + # Grid navigation lives on a pure GridCursor; _cursor_path/cursor_row/ + # cursor_col delegate to it so the rest of the screen reads/assigns unchanged. self._cursor = GridCursor() self._grabbed: tuple[int, int] | None = None # Anchor of the split the grab started in (None = top level); a grab can @@ -367,15 +362,13 @@ def on_unmount(self) -> None: if self._log_cursor_timer is not None: self._log_cursor_timer.stop() self._log_cursor_timer = None - # A session is only ever removed by close() — leaving this out would let a - # dismissed screen's live-capable session linger and (via id() reuse) risk - # aliasing a later host. + # A session is only ever removed by close() — without this a dismissed + # screen's live session could linger and, via id() reuse, alias a later host. self.app.log_ctl.close(self) def on_screen_resume(self, event) -> None: - # Re-point the singleton WS logbook subscription at this screen's log - # (if any) now that it's the one on top — e.g. popping back here from a - # pushed GraphPreviewScreen. + # Re-point the singleton WS logbook subscription at this screen's log (if + # any) now that it's on top again, e.g. popping back from GraphPreviewScreen. if self.app.log_ctl.is_open(self): self.app.spawn(self.app.log_ctl.resync_subscription()) @@ -426,10 +419,8 @@ def _update_mode_banner(self) -> None: banner = self.query_one("#dashboard_mode_banner", Static) for cls in ("-mode-edit", "-mode-grab", "-mode-widget"): banner.remove_class(cls) - # Esc is the only key in these banners that tracks the live keymap - # (nav.back, curated/rebindable) — every other key here belongs to a - # fixed, non-rebindable dashboard-local binding (or, for "r: manage - # panel", a raw on_key handler with no registry id at all). + # Esc is the only key here that tracks the live keymap (nav.back); every + # other key is a fixed, non-rebindable dashboard-local binding. back = self.app.keys_ctl.display("nav.back") if self._widget_active: banner.add_class("-mode-widget") @@ -587,9 +578,8 @@ def _move_selection(self, d_row: int, d_col: int) -> None: if self._widget_active: self._exit_widget() rows, cols, slots = self._cursor.active_grid_ctx(self._current_dashboard()) - # The step-past-footprint clamp math lives on the cursor; it returns False - # when the footprint runs into the edge (nothing to move to), else settles - # the new position and we refresh the DOM highlight + scroll here. + # The step-past-footprint clamp lives on the cursor; it returns False when + # the edge blocks the move, else settles the position — highlight/scroll here. if not self._cursor.move(d_row, d_col, rows, cols, slots): return self._apply_cursor_highlight() @@ -640,16 +630,14 @@ def _sync_split_selection(self, split: SplitSlotWidget) -> None: split._selected = None def action_move_cursor(self, d_row: int, d_col: int) -> None: - # A maximized log hides the grid and gives the entry list focus (which - # itself handles up/down); left/right fall through here to page the - # log instead of moving a cursor over a grid the user can't see. + # A maximized log hides the grid and gives the entry list focus (up/down); + # left/right fall through here to page the log instead. if self._log_maximized(): if d_col: self.app.log_ctl.page(self, d_col) return - # Widget interaction (thermostat setpoint, panel cursor) only happens after - # explicitly entering the widget with Enter/s — arrows always navigate the grid - # otherwise. Edit mode always navigates. + # Widget interaction (setpoint, panel cursor, …) only happens after entering + # the widget with Enter/s; otherwise arrows navigate the grid. Edit mode always navigates. if self._widget_active and d_col == 0 and d_row != 0: widget = self._content_widget_at_cursor() if isinstance(widget, ThermostatSlotWidget): @@ -664,8 +652,7 @@ def action_move_cursor(self, d_row: int, d_col: int) -> None: if isinstance(widget, MediaPlayerSlotWidget): widget.adjust_volume(-d_row) return - # An active media_player widget also repurposes left/right for track skip - # instead of grid navigation. + # An active media_player widget repurposes left/right for track skip instead. if self._widget_active and d_row == 0 and d_col != 0: widget = self._content_widget_at_cursor() if isinstance(widget, MediaPlayerSlotWidget): @@ -779,9 +766,8 @@ def action_unsplit_slot(self) -> None: self.render_dashboard() def action_fill_split(self) -> None: - # Quick-fill (issue #218): always targets the top-level pane under the - # cursor, even when currently descended into a split — a child cell - # can't itself become a split (one level max). + # Quick-fill (#218) always targets the top-level pane under the cursor — a + # child cell can't itself become a split (one level max). row, col = self._top_cell() def callback(result: dict | None) -> None: @@ -796,13 +782,10 @@ def callback(result: dict | None) -> None: self.app.push_screen(DashboardSlotPopup(None, fill_mode=True), callback) def action_grab_move(self) -> None: - # Two-step move: Enter grabs the occupied cell at the cursor, then Enter on a - # destination swaps the two cells' contents (Enter on the grabbed cell cancels). - # Works across grids too (issue #220) — a: descends into a split while - # grabbing, escape ascends without releasing — so a widget can be carried - # into or out of a split; dropping on an occupied cell swaps across grids, - # dropping on empty just moves. A split itself can never land in a child - # grid (no nesting), refused by the controller. + # Two-step move: Enter grabs the cell at the cursor, Enter on a destination + # swaps contents (Enter on the grabbed cell cancels). Works across grids too + # (#220) — a widget can be carried into/out of a split via a/escape while + # grabbed; a split itself can never land in a child grid (refused by the controller). grab_parent = self._split_anchor() if self._in_split() else None cursor_cell = self._cursor_path[-1] if self._grabbed is None: @@ -1170,11 +1153,9 @@ def _schedule_log_follow(self) -> None: ) def action_go_back(self) -> None: - # Esc backs out one level: un-maximize the log, exit active widget, drop - # a grabbed widget, ascend out of a split, leave Edit mode, close an open - # log, then dismiss the screen. While a widget is grabbed, esc ascends - # out of a split first (carrying the grab along — issue #220) and only - # releases the grab once back at the top level. + # Esc backs out one level: un-maximize log, exit widget, drop grab, ascend + # out of a split, leave Edit mode, close log, then dismiss. A grabbed widget + # ascends out of a split first, carrying the grab along (#220). if self._log_maximized(): self.action_maximize_log() return diff --git a/src/hatty/ui/dashboard/slot_popup.py b/src/hatty/ui/dashboard/slot_popup.py index e3c0559..35cc392 100644 --- a/src/hatty/ui/dashboard/slot_popup.py +++ b/src/hatty/ui/dashboard/slot_popup.py @@ -62,9 +62,8 @@ NO_ENTITY_LABEL = "(no entity)" NO_ENTITY_ROW = {"entity_id": "", "attributes": {"friendly_name": NO_ENTITY_LABEL}, "state": ""} -# Layout budget for the side-by-side preview (issue #11): #slot_main stays a -# fixed 70 cols regardless, so the preview only shows once the terminal can -# fit it alongside that column plus the popup's own border/padding chrome. +# Layout budget for the side-by-side preview (#11): #slot_main stays a fixed 70 +# cols, so the preview only shows once the terminal can fit it alongside that plus chrome. MAIN_WIDTH = 70 PREVIEW_WIDTH = 30 PREVIEW_GAP = 1 # #widget_preview's margin-left @@ -82,20 +81,16 @@ class DashboardSlotPopup(PopupScreen): AUTO_FOCUS = "#widget_type_select" - # The only row where left/right should cycle within it (wrapping) and up/down - # should jump out as a block, rather than stepping through each button (issue #36, - # mirrors light_screen.py/media_player_screen.py's _BUTTON_ROW_IDS convention). + # The only row where left/right cycles within it (wrapping) and up/down jumps + # out as a block, rather than stepping button by button (#36, _BUTTON_ROW_IDS convention). BUTTON_ROW_IDS = ("type_step_buttons",) - # Panel/fill's accumulated-entities box (issue #254): reorder and remove - # only apply while that box is focused (guarded in the actions below), so - # those are plain (non-priority) bindings — a priority binding on "delete" - # would intercept it ahead of Input's own delete_right while the search - # box is focused, breaking forward-delete text editing there. up/down are - # priority so they always move focus instead of being swallowed by the - # entity table's/selected-list's own cursor; check_action releases it - # while those (or an open type dropdown) are focused so their cursor - # keeps working. + # Panel/fill's accumulated-entities box (#254): reorder/remove only apply while + # it's focused (guarded below), so those are non-priority bindings — a priority + # "delete" would break Input's delete_right while the search box is focused. + # up/down are priority so they move focus instead of being swallowed by the + # entity table's/list's own cursor; check_action releases them while those + # (or an open type dropdown) are focused so that cursor keeps working. BINDINGS = bindings_for("slot_popup") DEFAULT_CSS = """ @@ -181,16 +176,15 @@ def __init__(self, slot: dict | None, fill_mode: bool = False): self._preview_timer: Timer | None = None def _type_choices(self) -> list[str]: - # Fill mode packs one widget per entity into a fresh split; a "panel" is - # itself a multi-entity container, so it doesn't make sense as the fill type. + # Fill mode packs one widget per entity into a fresh split; "panel" (itself + # multi-entity) doesn't make sense as the fill type. if self._fill_mode: return [wt for wt in WIDGET_TYPES if wt != "panel"] return WIDGET_TYPES def compose(self) -> ComposeResult: - # Fall back to the default for types the popup can't assign (a split - # pane's "split", or anything unrecognized) — the Select would raise - # InvalidSelectValueError on a value outside WIDGET_TYPES. + # Fall back to the default for types the popup can't assign ("split", or + # anything unrecognized) — Select raises on a value outside WIDGET_TYPES. type_choices = self._type_choices() current_type = self._slot["widget_type"] if self._slot else type_choices[0] if current_type not in type_choices: @@ -324,9 +318,8 @@ def _relevant_entities(self) -> list[dict]: def _update_entity_table(self) -> None: term = self.query_one("#entity_search_input", SearchInput).value.strip().lower() - # Entity-first's entity step runs before any type is chosen, so it - # browses every entity unfiltered — and skips the "no entity" row, - # since picking a real entity is the whole point of that order. + # Entity-first's entity step runs before any type is chosen, so it browses + # every entity unfiltered, skipping "no entity" (the whole point of that order). if self._entity_first: candidates = list(self.parent.all_entities) else: @@ -369,9 +362,8 @@ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: self._submit(entity_id) def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: - # Debounced (issue #3): GraphSlotWidget refetches history on every - # mount with no cache short-circuit, so previewing on every arrow key - # would hit HA once per keystroke. + # Debounced (#3): GraphSlotWidget refetches history on every mount with no + # cache short-circuit, so previewing on every arrow key would hit HA per keystroke. if event.data_table.id != "entity_picker_table" or self._step != "entity": return entity_id = event.row_key.value or None @@ -380,17 +372,16 @@ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None self._preview_timer = self.set_timer(0.3, lambda: self._rebuild_preview(entity_id)) def _apply_preview_visibility(self) -> None: - # Ties the dialog's width to the same show/hide decision (issue #36): with - # #dashboard_slot_container's width no longer `auto` (see DEFAULT_CSS), this is - # what centres it instead of stretching it to the terminal's full width. + # Ties the dialog's width to the same show/hide decision (#36): since + # #dashboard_slot_container's width isn't `auto`, this centres it instead of stretching. show_preview = preview_fits(self.app.size.width) self.query_one("#widget_preview").display = show_preview width = MAIN_WIDTH + PREVIEW_GAP + PREVIEW_WIDTH + POPUP_CHROME if show_preview else MAIN_WIDTH + POPUP_CHROME self.query_one("#dashboard_slot_container").styles.width = width def on_resize(self, event) -> None: - # Terminal resized while the popup is open (issue #11) — re-decide - # whether the side-by-side preview fits and repaint it. + # Terminal resized while the popup is open (#11) — re-decide whether the + # preview fits and repaint it. if self.is_mounted: self._apply_preview_visibility() self._rebuild_preview() @@ -418,8 +409,8 @@ def _submit(self, entity_id: str | None) -> None: widget_type = self.query_one("#widget_type_select", Select).value result = {"widget_type": widget_type, "entity_id": entity_id} if widget_type == "gauge": - # Blank inputs mean "auto" (entity min/max attrs, else 0-100); the keys - # are only present when overridden so other slots' config shape is unchanged. + # Blank means "auto" (entity min/max attrs, else 0-100); keys are only + # present when overridden so other slots' config shape is unchanged. for key, input_id in (("gauge_min", "#gauge_min_input"), ("gauge_max", "#gauge_max_input")): raw = self.query_one(input_id, Input).value.strip() if raw: @@ -520,9 +511,8 @@ def on_select_changed(self, event: Select.Changed) -> None: return if self._step == "type": self.set_focus(self.query_one("#btn_next_step")) - # Entity-first's revisited type step keeps the Select interactive - # (unlike type-first's fixed-type entity step), so switching to/from - # "gauge" here needs to toggle the bounds row live. + # Entity-first's revisited type step keeps the Select interactive, so + # switching to/from "gauge" here needs to toggle the bounds row live. if self._is_final_step(): self._update_mode_visibility() self._rebuild_preview() @@ -550,9 +540,8 @@ def on_key(self, event: Key) -> None: self.set_focus(self.query_one("#entity_search_input")) event.prevent_default() return - # A focused Input or an open type dropdown's overlay keep their own native - # left/right (cursor movement, option highlight); everything else either - # cycles within its enclosing button row or steps focus by one field. + # A focused Input or open dropdown overlay keeps its own native left/right; + # everything else cycles within its button row or steps focus by one field. if event.key not in ("left", "right") or isinstance(focused, (Input, OptionList)): return row = enclosing_row(focused, self.BUTTON_ROW_IDS) diff --git a/src/hatty/ui/dashboard/widgets/base.py b/src/hatty/ui/dashboard/widgets/base.py index 42e4d47..eba0ee1 100644 --- a/src/hatty/ui/dashboard/widgets/base.py +++ b/src/hatty/ui/dashboard/widgets/base.py @@ -12,9 +12,8 @@ if TYPE_CHECKING: from hatty.main import HACLI -# How often an opted-in slot re-renders its "Nm ago" text on its own — nothing -# else on the dashboard ticks on a clock, so slots showing elapsed time own a -# small timer of their own (issue #33). +# How often an opted-in slot re-renders its "Nm ago" text — nothing else on the +# dashboard ticks on a clock, so these own a small timer of their own (#33). ELAPSED_TICK_SECONDS = 30 @@ -88,9 +87,8 @@ def _render_empty(self) -> None: self.query_one("#slot_name", Label).update("No entity") -# widget_type -> factory(slot) -> Widget. The imports stay inside the -# factories: the widget modules import EntitySlotWidget from this module, so a -# module-level import here would be circular. +# widget_type -> factory(slot) -> Widget. Imports stay inside the factories: the +# widget modules import EntitySlotWidget from here, so a module-level import would cycle. def _wants_elapsed(slot: dict) -> bool: diff --git a/src/hatty/ui/dashboard/widgets/text.py b/src/hatty/ui/dashboard/widgets/text.py index e8aec14..6afeeef 100644 --- a/src/hatty/ui/dashboard/widgets/text.py +++ b/src/hatty/ui/dashboard/widgets/text.py @@ -38,9 +38,8 @@ def _render_entity(self, entity: Entity, pending: str | None) -> None: # Opportunistic: only shown when the graph store already has history loaded. arrow = trend_arrow(list(self.app.entity_history.get(self.entity_id, []))) - # Build a markup-safe Text: the HA-derived state/unit are appended as plain - # runs (no markup parsing, #157); only the unit carries an app-chosen dim - # style, applied as a span rather than "[dim]…[/dim]" markup. + # Build a markup-safe Text: state/unit are appended as plain runs (no markup + # parsing, #157); only the unit gets a dim style, applied as a span. value = Text() if icon: value.append(f"{icon} ") diff --git a/src/hatty/ui/dashboard/widgets/visuals.py b/src/hatty/ui/dashboard/widgets/visuals.py index f657285..c746d67 100644 --- a/src/hatty/ui/dashboard/widgets/visuals.py +++ b/src/hatty/ui/dashboard/widgets/visuals.py @@ -51,9 +51,8 @@ def trend_arrow(history: list[tuple[str, float]]) -> str: return "→" -# hvac_action -> icon/word/CSS class for climate widgets. "idle" carries no -# class, staying the default muted color; unmapped actions (off, fan, drying, -# unreported) fall back to showing the plain HVAC mode instead. +# hvac_action -> icon/word/CSS class. "idle" carries no class (default muted +# color); unmapped actions fall back to showing the plain HVAC mode. HVAC_ACTION_ICONS = {"heating": "🔥", "cooling": "❄", "idle": "•"} HVAC_ACTION_WORDS = {"heating": "Heating", "cooling": "Cooling", "idle": "Idle"} HVAC_ACTION_CLASSES = {"heating": "-heating", "cooling": "-cooling"} @@ -74,9 +73,8 @@ def trend_arrow(history: list[tuple[str, float]]) -> str: # domain -> representative glyph for the entity table's opt-in "Icon" column -# (#217). "lock" matches the padlock-with-key glyph used for the locked state -# elsewhere (dashboard/widgets/lock.py, dashboard/widgets/binary_sensor.py, -# controls/control_popup.py) so the domain icon and the locked-state icon agree. +# (#217). "lock" matches the padlock glyph used for the locked state elsewhere, +# so the domain icon and locked-state icon agree. DOMAIN_GLYPHS = { "light": "💡", "switch": "🔌", @@ -109,8 +107,7 @@ def trend_arrow(history: list[tuple[str, float]]) -> str: # HA weather.* condition -> multi-line ASCII art (wego/wttr.in idiom). Near-identical -# conditions deliberately share one block (both lightning variants, both windy variants, -# both "rainy" flavors) rather than drawing 15 distinct scenes. +# conditions deliberately share one block rather than drawing 15 distinct scenes. _ART_SUNNY = """\ \\ / .-. From 2ca57f5af59dea2489e3f4647b0e884916563404 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:19:05 -0500 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=93=9D=20Condense=20verbose=20comment?= =?UTF-8?q?s=20in=20the=20rest=20of=20ui/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/ui/activity_log_panel.py | 53 +++++++----------- src/hatty/ui/config_screen.py | 59 ++++++++------------ src/hatty/ui/controls/light_screen.py | 21 +++---- src/hatty/ui/controls/media_player_screen.py | 30 ++++------ src/hatty/ui/device_tree_screen.py | 27 ++++----- src/hatty/ui/entity_table.py | 13 ++--- src/hatty/ui/exit_sync_screen.py | 6 +- src/hatty/ui/graph/color_popup.py | 24 ++++---- src/hatty/ui/graph/entity_detail.py | 5 +- src/hatty/ui/graph/preview_screen.py | 42 +++++--------- src/hatty/ui/help_popup.py | 10 ++-- src/hatty/ui/key_capture_popup.py | 3 +- src/hatty/ui/list_selection_popup.py | 5 +- src/hatty/ui/weather_forecast_screen.py | 6 +- 14 files changed, 117 insertions(+), 187 deletions(-) diff --git a/src/hatty/ui/activity_log_panel.py b/src/hatty/ui/activity_log_panel.py index 43dfdfc..f1c3672 100644 --- a/src/hatty/ui/activity_log_panel.py +++ b/src/hatty/ui/activity_log_panel.py @@ -151,27 +151,22 @@ def __init__(self, *args, **kwargs) -> None: def compose(self) -> ComposeResult: yield Label("Activity Log", id="log_title") log = Log(max_lines=_MAX_LOG_LINES, id="log_widget", auto_scroll=True) - # Log/ScrollableContainer defaults to can_focus=True with its own - # left/right/home/end scroll bindings — a host screen's auto-focus - # (Textual scans descendants regardless of `display`, so even hidden - # counts) would land here and swallow those keys before the host's - # own paging bindings ever see them (the fullscreen graph's `left`/ - # `right` page the window, not this log). The log is never meant to - # take keyboard focus, so keep it out of the focus chain entirely. + # Log defaults to can_focus=True with its own scroll bindings — a host + # screen's auto-focus (which scans descendants even when hidden) would land + # here and swallow keys the host's own paging bindings expect. 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. + # Same can_focus=False trick as the Log above: while docked this must be + # invisible to auto-focus though still mounted. set_maximized flips + # can_focus 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. + # without this, app-wide AUTO_FOCUS ("*") would land here instead of + # the OptionList, being earlier/equally eligible in the DOM. detail_scroll = VerticalScroll(id="log_detail_scroll") detail_scroll.can_focus = False with detail_scroll: @@ -183,10 +178,9 @@ def set_title(self, text: str) -> None: self.query_one("#log_title", Label).update(text) def set_hint(self, text: str) -> None: - # Escaped: hint text is assembled from live keybinding display strings - # that can include literal "["/"]" (e.g. the bracket keys), which - # Rich's markup parser can misread as a style tag once adjacent - # fragments combine (e.g. "[" + "/" + "]" -> "[/]"). + # Escaped: hint text is assembled from keybinding display strings that can + # include literal "["/"]" (e.g. bracket keys), which Rich's markup parser + # can misread as a style tag once adjacent fragments combine. self.query_one("#log_hint", Label).update(escape(text)) @property @@ -198,10 +192,9 @@ def _dedupe_key(entry: LogEntry) -> tuple[str, str, str]: return (entry["when"], entry["name"], entry["detail"]) def _line_width(self) -> int: - # The scrollbar-aware width: Log's CSS is `overflow: scroll`, so its - # vertical scrollbar is always shown, and content_size doesn't - # subtract it — measuring the true scrollable region is what keeps a - # written "…" from landing behind the scrollbar (issue #22). + # Log's CSS is `overflow: scroll`, so its scrollbar is always shown and + # content_size doesn't subtract it — measure the true scrollable region so + # a written "…" doesn't land behind the scrollbar (#22). log = self.query_one("#log_widget", Log) return max(20, log.scrollable_content_region.width or self.content_size.width or 50) @@ -314,9 +307,8 @@ def _reflow_lines(self) -> None: if width == self._rendered_width: return log = self.query_one("#log_widget", Log) - # format_log_line always yields exactly one line per entry, so the - # rewritten content has the same line count — scroll position stays - # meaningful across the clear()/write_lines below. + # format_log_line yields exactly one line per entry, so the rewritten + # content has the same line count — scroll position stays meaningful. at_tail = log.is_vertical_scroll_end prior_y = log.scroll_offset.y log.clear() @@ -339,15 +331,12 @@ def set_maximized(self, maximized: bool) -> None: 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). + # rescans regardless of `display`, so a still-focusable OptionList + # behind a hidden body would keep intercepting the host's keys. 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 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. + # Belt-and-braces: on_resize normally handles this already; the + # rendered-width guards make this a free no-op when it did. self.call_after_refresh(self._reflow_lines) def clear(self) -> None: diff --git a/src/hatty/ui/config_screen.py b/src/hatty/ui/config_screen.py index 5a268d9..21ff59e 100644 --- a/src/hatty/ui/config_screen.py +++ b/src/hatty/ui/config_screen.py @@ -88,9 +88,8 @@ ("Highlight changed entity", "highlight"), ] -# Backup & Sync git toggles, shown as a SelectionList mirroring the -# notification channel toggles above. Keys match DEFAULT_BACKUP exactly minus -# "path"/"sections", which get their own widgets. +# Backup & Sync git toggles, mirroring the notification toggles above. Keys match +# DEFAULT_BACKUP exactly minus "path"/"sections", which get their own widgets. _BACKUP_GIT_TOGGLES = [ ("Enable git", "git_enabled"), ("Pull on start", "pull_on_start"), @@ -100,9 +99,8 @@ ("Rebase on pull (instead of fast-forward only)", "pull_rebase"), ] -# Top-level category menu (issue #252). Each entry is (display name, pane id, -# one-line hint, first-focus widget id within the pane). "cat_menu" itself is -# the ContentSwitcher's built-in first pane and isn't listed here. +# Top-level category menu (#252): (display name, pane id, hint, first-focus +# widget id). "cat_menu" itself is the built-in first pane, not listed here. _CATEGORIES = [ ("Home Assistant", "cat_home_assistant", "Connection URL, access token", "#cfg_url"), ("Appearance", "cat_appearance", "Theme, graph defaults, visible columns", "#cfg_theme"), @@ -122,18 +120,15 @@ class ConfigScreen(Screen): AUTO_FOCUS = "#cfg_category_list" - # All bindings are modifier-prefixed (mirroring OnboardingScreen). A bare - # single-letter binding here would hijack keystrokes the moment focus tabs - # onto a non-Input widget (Select/SelectionList/Button) — e.g. a stray "s" - # would fire save_and_close and dismiss the screen (issue #192). + # All bindings are modifier-prefixed (mirroring OnboardingScreen) — a bare + # letter would hijack keystrokes once focus tabs onto a non-Input widget (#192). BINDINGS = [ Binding("ctrl+s", "save_and_close", "Save"), Binding("escape", "cancel", "Back/Cancel"), Binding("ctrl+o", "open_in_editor", "Editor"), Binding("ctrl+v", "toggle_token", "Show/Hide Token"), - # "?" is punctuation, not a bare letter, so it doesn't hijack typing the - # way the comment above warns about; a focused Input still consumes it - # as a keystroke rather than letting it reach this binding. + # "?" is punctuation, not a bare letter, so it doesn't hijack typing — a + # focused Input still consumes it as a keystroke first. Binding("question_mark", "show_help", "Help"), ] @@ -239,10 +234,8 @@ def __init__(self, raw_config: dict, config_path: str | None): self._config_path = config_path self._token_visible = False # Working copy of keybinding overrides, edited live by KeyCapturePopup - # (unlike every other field here, which is read straight off its widget - # at Save time) so the table can re-render its "Key" column immediately - # after each rebind, and so keybindings.validate() sees in-progress - # edits from earlier in the same session. + # (unlike every other field, read off its widget at Save time) so the table + # re-renders "Key" immediately and validate() sees in-progress edits. self._keybindings: dict[str, str] = dict(keybindings_module.sanitize(raw_config.get(CONFIG_KEY_KEYBINDINGS))) @staticmethod @@ -267,7 +260,7 @@ def compose(self) -> ComposeResult: current_theme = self._raw_config.get(CONFIG_KEY_THEME) or "" current_graph_type = self._raw_config.get(CONFIG_KEY_GRAPH_TYPE) or "line" # Guard against a stale persisted type no longer in the option list (e.g. the - # removed "bar" mode) — Select raises InvalidSelectValueError on an unknown value. + # removed "bar" mode) — Select raises on an unknown value. if current_graph_type not in {value for _, value in _GRAPH_OPTIONS}: current_graph_type = "line" current_graph_hours = self._raw_config.get(CONFIG_KEY_GRAPH_HOURS, DEFAULT_GRAPH_HOURS) @@ -524,9 +517,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == "cfg_save": self.action_save_and_close() elif event.button.id == "cfg_cancel": - # Unconditional dismiss (unlike the back-aware escape binding below) — - # the on-screen Cancel button means "abandon changes and close", not - # "go back a level", even when pressed from inside a category pane. + # Unconditional dismiss (unlike the back-aware escape below) — the + # Cancel button means "abandon and close", not "go back a level". self.dismiss(None) elif event.button.id == "cfg_notify_clear": self.action_stop_watching_all() @@ -551,9 +543,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.action_backup_status_check() def action_stop_watching_all(self) -> None: - # A live write (like the l/d/s popups edit their own collections directly) - # rather than something staged until Save — there's no "undo" expected here. - # Only the notify_lists designation is cleared; list contents are untouched. + # A live write, not staged until Save (no "undo" expected here) — only the + # notify_lists designation is cleared; list contents are untouched. self.app.notify_ctl.stop_watching_all() self.query_one("#cfg_notify_count", Static).update("0 entities watched.") summary = self.query_one("#cfg_notify_lists_summary", Static) @@ -588,7 +579,7 @@ def _set_ntfy_status(self, text: str, ok: bool | None = None) -> None: def action_test_ntfy(self) -> None: # Uses the currently entered (unsaved) fields, like action_test_connection, - # so the user can verify ntfy config before committing to Save (issue #248). + # so the user can verify ntfy config before committing to Save (#248). prefs = { "ntfy_url": self.query_one("#cfg_ntfy_url", Input).value.strip(), "ntfy_topic": self.query_one("#cfg_ntfy_topic", Input).value.strip(), @@ -604,8 +595,7 @@ async def _do_test_ntfy(self, prefs: dict) -> None: # ── Backup & Sync ───────────────────────────────────────────────────────── # Every action here acts on the currently entered (unsaved) path/sections/git - # toggles, the action_test_connection/action_test_ntfy precedent — not on - # self.app.backup_ctl.prefs, which only reflects the last *saved* config. + # toggles, not self.app.backup_ctl.prefs (only the last *saved* config). def _backup_path(self) -> str: return self.query_one("#cfg_backup_path", Input).value.strip() @@ -643,10 +633,8 @@ def action_backup_export(self) -> None: self.run_worker(self._do_backup_export(path, sections), exclusive=True) async def _do_backup_export(self, path: str, sections: list[str]) -> None: - # Not asyncio.to_thread: export_now/import_now (via the object - # controllers' import_from_payload) call app.persist(), which does - # asyncio.create_task() — that needs the app's own running loop, which - # a thread-pool worker thread doesn't have. + # Not asyncio.to_thread: export_now/import_now call app.persist(), which + # does asyncio.create_task() — a thread-pool worker has no running loop for that. ok, msg = self.app.backup_ctl.export_now(path, sections) self._set_backup_status(msg, ok=ok) @@ -772,9 +760,8 @@ def action_save_and_close(self) -> None: **self._backup_git_prefs(), } - # Collections live in SQLite; this screen only edits connection settings + - # display preferences, so keep them out of the lean YAML it writes. The - # dismissed dict still carries them so the app's in-memory state is intact. + # Collections live in SQLite; keep them out of the lean YAML this screen + # writes. The dismissed dict still carries them so in-memory state is intact. to_save = {k: v for k, v in new_config.items() if k not in storage_module.COLLECTION_KEYS} try: @@ -786,8 +773,8 @@ def action_save_and_close(self) -> None: self.dismiss(new_config) def action_cancel(self) -> None: - # Back-aware (issue #252): escape/Cancel from inside a category returns - # to the top-level menu first; only dismisses the screen from there. + # Back-aware (#252): escape/Cancel from inside a category returns to the + # top-level menu first; only dismisses the screen from there. switcher = self.query_one("#cfg_switcher", ContentSwitcher) if switcher.current != "cat_menu": self.show_category("cat_menu") diff --git a/src/hatty/ui/controls/light_screen.py b/src/hatty/ui/controls/light_screen.py index 9c29e86..128a4e2 100644 --- a/src/hatty/ui/controls/light_screen.py +++ b/src/hatty/ui/controls/light_screen.py @@ -132,13 +132,10 @@ class LightControlScreen(ModalScreen): app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect - # space is priority so a focused Button doesn't swallow it (buttons still work - # via enter); check_action releases it while the effect filter Input is - # focused. up/down are priority so they always move focus instead of being - # swallowed by a focused slider's own value-adjust handling (issue #286) — - # left/right still adjust the slider in place. check_action releases it - # while the effects OptionList is focused, so its own up/down cursor - # movement keeps working. + # space is priority so a focused Button doesn't swallow it (released while the + # effect filter Input is focused). up/down are priority so they move focus + # instead of being swallowed by a slider's value-adjust (#286) — left/right + # still adjust in place; released while the effects OptionList is focused. BINDINGS = bindings_for("light") DEFAULT_CSS = """ @@ -411,10 +408,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: event.stop() def on_key(self, event: events.Key) -> None: - # Left/right walk the focus chain unless a slider/input owns them — or the tab - # bar, where they natively switch panes (issue #88). Up/down are handled as - # priority Bindings instead (see nav_focus) so they always move focus rather - # than being swallowed by a focused slider's own value-adjust handling. + # Left/right walk the focus chain unless a slider/input/tab bar owns them + # (#88). Up/down are priority Bindings instead (see nav_focus). focused = self.focused if isinstance(focused, (Input, PercentageSlider, KelvinSlider, Tabs)): return @@ -457,8 +452,8 @@ def action_show_help(self) -> None: self.app.action_show_help() def action_nav_focus(self, direction: int) -> None: - # A focused slider (or any single widget) just steps one at a time; a row - # (swatches/presets) is skipped as a whole block (issue #286). + # A focused slider (or any single widget) steps one at a time; a row + # (swatches/presets) is skipped as a whole block (#286). nav_focus(self, _BUTTON_ROW_IDS, direction) def action_toggle_power(self) -> None: diff --git a/src/hatty/ui/controls/media_player_screen.py b/src/hatty/ui/controls/media_player_screen.py index 0fd2c3f..0ce3696 100644 --- a/src/hatty/ui/controls/media_player_screen.py +++ b/src/hatty/ui/controls/media_player_screen.py @@ -43,11 +43,9 @@ _STATE_ICONS = {"playing": "▶", "paused": "⏸", "idle": "⏹", "off": "⏹", "buffering": "⏳", "on": "▶"} -# Horizontal button rows where left/right should cycle within the row (wrapping) and -# up/down should jump out of the row as a block instead of stepping through each button -# individually (mirrors light_screen.py's #88/#286 patterns). Buttons without a dedicated -# hotkey (shuffle/repeat) are only reachable this way; the rest (transport) also have -# space/s/Enter shortcuts. +# Horizontal button rows where left/right cycle within the row and up/down jump out +# as a block (mirrors light_screen.py's #88/#286 patterns). Shuffle/repeat have no +# dedicated hotkey and are only reachable this way; transport also has space/s/Enter. _BUTTON_ROW_IDS = ("transport_buttons", "toggle_buttons") # HA repeat modes, in the order the repeat button cycles through them. @@ -62,12 +60,10 @@ class MediaPlayerControlScreen(ModalScreen): app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect - # space is priority so a focused Button doesn't swallow it (buttons still work - # via enter). up/down are priority so they always move focus instead of being - # swallowed by a focused volume slider's own value-adjust handling (issue - # #291, mirrors light_screen's #286 fix) — left/right still adjust the slider - # in place. check_action releases it while a Select's overlay is focused, so - # its own up/down keeps working. + # space is priority so a focused Button doesn't swallow it. up/down are priority + # so they move focus instead of being swallowed by the volume slider's value-adjust + # (#291, mirrors light_screen's #286) — left/right still adjust in place; released + # while a Select's overlay is focused, so its own up/down keeps working. BINDINGS = bindings_for("media_player") DEFAULT_CSS = """ @@ -290,10 +286,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self._cycle_repeat() def on_key(self, event: events.Key) -> None: - # Left/right walk the focus chain (row-aware — see _focus_within_row) unless the - # volume slider or a Select's expanded overlay owns them (mirrors light_screen.py's - # on_key hijack, issue #88). Up/down are handled as priority Bindings instead (see - # nav_focus) so they always move focus rather than being swallowed by the slider. + # Left/right walk the focus chain (row-aware) unless the volume slider or a + # Select's overlay owns them (mirrors light_screen.py, #88). Up/down are + # priority Bindings instead (see nav_focus). focused = self.focused if isinstance(focused, (PercentageSlider, OptionList)): return @@ -328,9 +323,8 @@ def action_show_help(self) -> None: self.app.action_show_help() def action_nav_focus(self, direction: int) -> None: - # A focused slider (or any other single widget) just steps one at a time; a - # button row (transport/toggle) is skipped as a whole block (mirrors - # light_screen.py's #286 pattern). + # A focused slider steps one at a time; a button row (transport/toggle) is + # skipped as a whole block (mirrors light_screen.py's #286 pattern). nav_focus(self, _BUTTON_ROW_IDS, direction) def action_toggle_play_pause(self) -> None: diff --git a/src/hatty/ui/device_tree_screen.py b/src/hatty/ui/device_tree_screen.py index ac8c1f0..a7c0415 100644 --- a/src/hatty/ui/device_tree_screen.py +++ b/src/hatty/ui/device_tree_screen.py @@ -216,9 +216,8 @@ def build_integration_groups(entity_registry: list, device_registry: list) -> li def _label_matches(term: str, label: str) -> bool: - # Mirrors entity_matches' skip-word semantics (issue #241): every - # whitespace-separated word of the term just has to appear somewhere in the - # label, in any order, so "living rm" matches an area labeled "Living Room". + # Mirrors entity_matches' skip-word semantics (#241): every word of the term + # just has to appear somewhere in the label, in any order. haystack = label.lower() return all(word in haystack for word in term.split()) @@ -397,16 +396,13 @@ def action_cancel(self) -> None: class DeviceTreeScreen(Screen): app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect - # Space is priority: the focused Tree binds space to toggle_node; the - # screen action forwards non-entity nodes there so containers still fold. - # ctrl+s is priority so it fires while the search Input is focused (ctrl+s - # isn't consumed by Input; tab is already taken by SearchInput.toggle_mode). + # Space is priority: the focused Tree binds it to toggle_node, forwarded here + # for non-entity nodes. ctrl+s is priority so it fires while search is focused. BINDINGS = bindings_for("tree") _MODES = ("device", "area", "integration") - # Scopes offered per view (ctrl+s cycles only within the current view's - # tuple, so it never lands on a scope that matches nothing in this grouping). - # The first entry is that view's default, applied on a view switch (#206). + # Scopes offered per view; ctrl+s cycles only within the current tuple. The + # first entry is that view's default, applied on a view switch (#206). _VIEW_SCOPES = { "device": ("device", "all", "entity"), "area": ("area", "all", "device", "entity"), @@ -428,11 +424,9 @@ def __init__(self, initial_entity_id: str | None = None): self._mode = "device" self._filter = "" self._scope = self._VIEW_SCOPES[self._mode][0] - # entity_id -> its leaf nodes (an entity can appear once, but keep a list - # to stay robust to duplicate placements). + # entity_id -> its leaf nodes (a list to stay robust to duplicate placements). self._entity_nodes: dict[str, list] = {} - # device_id -> its device nodes, same shape, for cursor-follow across - # grouping-mode switches (issue #153). + # device_id -> its device nodes, for cursor-follow across mode switches (#153). self._device_nodes: dict[str, list] = {} # Entity to cursor after the first build (table -> tree, issue #153). self._initial_entity_id = initial_entity_id @@ -695,9 +689,8 @@ def action_expand_entity(self) -> None: self.app.open_entity_controls(entity_id, fullscreen_graph_fallback=True) def action_jump_to_list(self) -> None: - # Dismiss the tree first so list state never changes behind it, then - # reuse the app's jump-or-pick logic (jumps to the last-shown/default - # list, only opening the picker when no list exists yet). + # Dismiss the tree first so list state never changes behind it, then reuse + # the app's jump-or-pick logic (picker only opens if no list exists yet). self.app.pop_to_base_screen() self.app.action_show_list_selection_popup() diff --git a/src/hatty/ui/entity_table.py b/src/hatty/ui/entity_table.py index 9f94b23..da31dd7 100644 --- a/src/hatty/ui/entity_table.py +++ b/src/hatty/ui/entity_table.py @@ -45,9 +45,8 @@ def _is_in_list(entity: Entity, lists, current_list_name) -> bool: def entity_matches(entity: Entity, term: str) -> bool: - # Multi-word terms match "skip words" (issue #241): each whitespace-separated - # word just has to appear somewhere in the combined haystack, in any order, so - # "living lamp" matches "Living Room Lamp" without needing "room" typed too. + # Multi-word terms match "skip words" (#241): each word just has to appear + # somewhere in the haystack, in any order — "living lamp" matches "Living Room Lamp". haystack = " ".join( ( str(entity.get("entity_id", "")), @@ -59,11 +58,9 @@ def entity_matches(entity: Entity, term: str) -> bool: def apply_pending_suffix(value: str | Text, pending: str | None) -> Text: - # Always return a Text (not a raw str): a raw str would be parsed as Rich - # markup by Textual, so an HA-derived state like "[red]" restyles the UI and a - # bare "[" crashes rendering with MarkupError (#157). The Text constructor does - # not parse markup, so a plain-str base is escaped; a Text base (a caller that - # pre-styled its content, e.g. the sensor widget's dimmed unit) is preserved. + # Always return a Text: a raw str would be parsed as Rich markup by Textual, so + # an HA-derived state like "[red]" restyles the UI or crashes rendering (#157). + # A plain-str base gets escaped; an already-styled Text base is preserved. base = value if isinstance(value, Text) else Text(str(value)) if pending == "pending": out = base.copy() diff --git a/src/hatty/ui/exit_sync_screen.py b/src/hatty/ui/exit_sync_screen.py index 71d7cff..9e400b1 100644 --- a/src/hatty/ui/exit_sync_screen.py +++ b/src/hatty/ui/exit_sync_screen.py @@ -102,10 +102,8 @@ async def _sync(self) -> None: try: self._set_status("Saving…") await self.app.drain_bg_tasks(timeout=5.0) - # sync_on_exit calls this back before each of its own phases - # (Exporting…/Committing…/Pushing…), so the overlay always shows - # what's actually happening instead of one static message for - # however long the whole thing takes. + # sync_on_exit calls this back before each phase (Exporting/Committing/ + # Pushing), so the overlay shows what's actually happening, not one static message. ok, msg = await self.app.backup_ctl.sync_on_exit(status=self._set_status) if msg: self._set_status(msg, final=True) diff --git a/src/hatty/ui/graph/color_popup.py b/src/hatty/ui/graph/color_popup.py index 6bfd436..4a5fdf7 100644 --- a/src/hatty/ui/graph/color_popup.py +++ b/src/hatty/ui/graph/color_popup.py @@ -7,21 +7,18 @@ from hatty.controllers.keybindings import bindings_for from hatty.ui.popup_base import PopupScreen -# plotext named color -> ANSI 0-15 code. plotext resolves its named colors -# through the terminal's own palette (its "orange" is ANSI 3, which most themes -# render as yellow), so the picker swatch must go through the same codes or it -# lies about what the plotted line will look like. Imported from plotext's -# private _utility so the swatch can never drift from the plotter; falls back to -# an empty map (uncolored swatches) if that internal module ever moves. +# plotext named color -> ANSI 0-15 code. plotext resolves colors through the +# terminal's own palette, so the swatch must go through the same codes or it lies +# about the plotted line. Imported from plotext's private _utility so it can +# never drift; falls back to an empty map if that internal module moves. try: from plotext._utility import color_codes as _PLOTEXT_COLOR_CODES except ImportError: # pragma: no cover - defensive against plotext internals moving _PLOTEXT_COLOR_CODES = {} -# ANSI 0-15 -> Textual markup color name. Textual's `ansi_*` colors render -# terminal-native (through the same 16-color palette plotext emits to), so a -# swatch tagged this way and a plotext line share one palette on every theme. -# (Rich's `[color(N)]` syntax is NOT valid in Textual's markup dialect.) +# ANSI 0-15 -> Textual markup color name. `ansi_*` renders terminal-native +# (same 16-color palette plotext emits to), so a swatch and a plotted line +# share one palette on every theme. (Rich's `[color(N)]` isn't valid here.) _ANSI_MARKUP = { 0: "ansi_black", 1: "ansi_red", @@ -41,10 +38,9 @@ 15: "ansi_bright_white", } -# Every plotext named color offered by the picker; the `+` suffix is plotext's -# bright variant. Superset of graph_preview_screen's default cycling palette. -# Order is deliberate (base hues, then bright variants) and index-sensitive for -# tests; every entry must be a key of _PLOTEXT_COLOR_CODES (guarded by a test). +# Every plotext named color offered by the picker (`+` = plotext's bright variant). +# Order is deliberate and index-sensitive for tests; every entry must be a key +# of _PLOTEXT_COLOR_CODES (guarded by a test). ALL_PLOT_COLORS = [ "blue", "red", diff --git a/src/hatty/ui/graph/entity_detail.py b/src/hatty/ui/graph/entity_detail.py index 55532c4..be048a4 100644 --- a/src/hatty/ui/graph/entity_detail.py +++ b/src/hatty/ui/graph/entity_detail.py @@ -178,9 +178,8 @@ def _render_binary_mode(self) -> None: return now_iso = datetime.now().astimezone().isoformat() - # render_binary drops numeric companions (they can't sit on the 0/1 axis; - # plotext crashes on a labelled series entirely outside ylim) and extends - # each trace to "now". + # render_binary drops numeric companions (can't sit on the 0/1 axis; plotext + # crashes on a labelled series entirely outside ylim) and extends to "now". extras = [ (get_display_name(extra_entity) if extra_entity else eid, extra_hist, None) for eid, (extra_entity, extra_hist) in self._extra_histories.items() diff --git a/src/hatty/ui/graph/preview_screen.py b/src/hatty/ui/graph/preview_screen.py index 1c30ce0..f041c19 100644 --- a/src/hatty/ui/graph/preview_screen.py +++ b/src/hatty/ui/graph/preview_screen.py @@ -124,18 +124,14 @@ class GraphPreviewScreen(Screen): BINDINGS = bindings_for("graph") # App-level actions that still make sense on top of this screen — everything - # else in HACLI.BINDINGS is main-table-only and is denied by HACLI.check_action - # (issue #7: `n`/`N` search, `L`/`u`/`ctrl+r` list editing, `G` full-graph-again, - # etc. used to leak through and clutter the Graph help page). + # else in HACLI.BINDINGS is main-table-only and denied by HACLI.check_action (#7). ALLOWED_APP_ACTIONS = frozenset( {"show_dashboard", "show_device_tree", "show_saved_graphs_popup", "show_graph_duration", "quit"} ) - # This screen's help page is always built from the full static BINDINGS - # rather than whichever mode happens to be active (main.action_show_help), - # grouped into these sections — otherwise the inspect-mode twin of every - # paging key would never appear on the page unless help was opened from - # inside inspect mode (issue #7). + # This screen's help page always builds from the full static BINDINGS rather + # than whichever mode is active — else the inspect-mode twin of every paging + # key would only appear if help was opened from inside inspect mode (#7). HELP_ALL_MODES = True HELP_SECTIONS = ( ( @@ -179,7 +175,7 @@ class GraphPreviewScreen(Screen): ("Other", frozenset({"show_list_popup", "show_help", "go_back"})), ) - # LogHost hooks (LogbookController, issue #38) — see controllers/logbook.py. + # LogHost hooks (#38) — see controllers/logbook.py. LOG_PANEL_ID: str = "preview_log_panel" LOG_SUPPORTS_LIVE: bool = False @@ -200,9 +196,8 @@ def __init__( ) self._data: list[tuple[str, float]] = [] self._all_data: dict[str, list] = {} - # Zoom/scroll/live-anchor state lives on a pure GraphWindow; the three - # legacy attrs below are delegating properties over it so the rest of the - # screen (and the tests) read/assign them unchanged. + # Zoom/scroll/live-anchor state lives on a pure GraphWindow; the legacy + # attrs below delegate to it so the rest of the screen reads/assigns unchanged. self._window = GraphWindow() self._is_climate = self._entity_id.split(".")[0] == "climate" # Comparison mixing is blocked upstream, so the primary decides for all lines. @@ -323,10 +318,9 @@ async def _load_and_render(self, preserve_zoom: bool = False) -> None: self._all_data = {} for eid in self._entity_ids: values = await self.app.graph_ctl.history_fetcher(eid)(eid, hours=self._local_hours, end=now) - # A wide-window REST fetch can fail or come back empty for a - # single entity (HAClient swallows errors as None); don't blank - # an otherwise-good line — fall back to the in-memory store's - # recent buffer so it stays visible instead of vanishing (#179). + # A wide-window REST fetch can fail or come back empty for a single + # entity; fall back to the in-memory store's recent buffer instead + # of blanking an otherwise-good line (#179). self._all_data[eid] = values or list(self.app.entity_history.get(eid, [])) else: for eid in self._entity_ids: @@ -482,8 +476,7 @@ def refresh_live_data(self, entity_id: str, history: list[tuple[str, float]]) -> return if self._local_hours is not None and self._local_hours > self.app.graph_hours: # Zoomed out past the store: `history` is only its recent tail, so a - # wholesale replace would shrink the held wide window. Append just the - # newest sample instead. + # wholesale replace would shrink the held wide window — append just the newest sample. buf = self._all_data.get(entity_id) if buf is not None and history and (not buf or history[-1][0] > buf[-1][0]): buf.append(history[-1]) @@ -750,9 +743,7 @@ def action_close_event_log(self) -> None: 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). + # explicitly blur instead (there's no natural "home" widget here). self.set_focus(None) return self._close_event_log() @@ -764,9 +755,7 @@ def action_maximize_log(self) -> None: 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). + # explicitly blur instead (there's no natural "home" widget here). self.set_focus(None) def action_go_back(self) -> None: @@ -816,9 +805,8 @@ async def _refresh_events_if_open(self) -> 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 - # to the last-shown (or default) list; only fall back to the full picker when - # there is no list to return to. + # Mirror DashboardScreen: dismiss the graph and jump to the last-shown (or + # default) list, falling back to the full picker only if none exists. from hatty.ui.list_selection_popup import ListSelectionPopup target = self.app.list_ctl.jump_target() diff --git a/src/hatty/ui/help_popup.py b/src/hatty/ui/help_popup.py index ea0de53..b51d76e 100644 --- a/src/hatty/ui/help_popup.py +++ b/src/hatty/ui/help_popup.py @@ -184,9 +184,8 @@ def __init__(self, pages: list[tuple[str, list[tuple[str, str]]]], active_index: self._binding_rows = pages[active_index][1] if pages else [] def _hint_text(self) -> str: - # left/right/"/"/a are HelpPopup's own fixed (non-rebindable) keys; - # only Esc close tracks the live nav.back key, since HelpPopup's escape - # binding shares that id with every other screen's back/cancel key. + # left/right/"/"/a are HelpPopup's own fixed keys; only Esc close tracks + # the live nav.back key, since it shares that id with every other screen. return f"←/→ pages · / search all · a show all · {self.app.keys_ctl.display('nav.back')} close" def compose(self) -> ComposeResult: @@ -195,9 +194,8 @@ def compose(self) -> ComposeResult: yield Label(self._hint_text(), id="help_hint") filter_input = Input(placeholder="Search all pages...", id="help_filter") filter_input.display = False - # Screen auto-focus scans descendants regardless of `display`, so an - # unfocusable-until-shown Input keeps /, a, left/right reaching the - # screen's own bindings instead of being swallowed as text entry. + # Screen auto-focus scans descendants regardless of `display`, so keeping + # this unfocusable until shown lets /, a, left/right reach the screen's bindings. filter_input.can_focus = False yield filter_input with VerticalScroll(id="help_body"): diff --git a/src/hatty/ui/key_capture_popup.py b/src/hatty/ui/key_capture_popup.py index d3a3cb4..7b3137b 100644 --- a/src/hatty/ui/key_capture_popup.py +++ b/src/hatty/ui/key_capture_popup.py @@ -23,8 +23,7 @@ from hatty.ui.popup_base import PopupScreen # Keys that never reach validate() as a candidate — ctrl+c is the fixed cancel -# key here (mirroring RESERVED_KEYS' reasoning: the popup must always have a -# way out), delete is the reset-to-default shortcut. +# key (the popup must always have a way out), delete resets to default. _CANCEL_KEY = "ctrl+c" _RESET_KEY = "delete" diff --git a/src/hatty/ui/list_selection_popup.py b/src/hatty/ui/list_selection_popup.py index 9589238..188cbb8 100644 --- a/src/hatty/ui/list_selection_popup.py +++ b/src/hatty/ui/list_selection_popup.py @@ -161,9 +161,8 @@ def action_toggle_notify(self) -> None: self._relabel(self._names, self.parent.default_list_name, markers=self._notify_markers()) def _move(self, delta: int) -> None: - # Mirrors ColumnConfigPopup's Shift+up/down reorder (issue #212). The - # synthetic "View All" row at index 0 isn't part of list_names and can't - # be reordered; a search filter shows a subset, so order is ambiguous. + # Mirrors ColumnConfigPopup's Shift+up/down reorder (#212). The synthetic + # "View All" row can't be reordered; a search filter makes order ambiguous. if self.search_term: self.app.notify("Clear the search to reorder lists.", severity="warning") return diff --git a/src/hatty/ui/weather_forecast_screen.py b/src/hatty/ui/weather_forecast_screen.py index 0d31194..2503c96 100644 --- a/src/hatty/ui/weather_forecast_screen.py +++ b/src/hatty/ui/weather_forecast_screen.py @@ -105,10 +105,8 @@ def __init__(self, entity: "Entity") -> None: self._entity = entity self._entity_id = entity.get("entity_id", "") self._attrs = entity.get("attributes", {}) - # Which weather.get_forecasts `type`s to offer: whatever the entity's - # supported_features bitmask advertises, or a bare ["daily"] guess - # (still worth trying, then falling back to the attribute) when the - # entity carries no supported_features at all. + # Which weather.get_forecasts `type`s to offer: whatever supported_features + # advertises, or a bare ["daily"] guess when the entity carries none. self._types = supported_forecast_types(self._attrs.get("supported_features")) or ["daily"] self._type_index = 0 self._forecast: list[dict] | None = None From bcd304563e60cb9a27c0a9a5562413b4ba849c25 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:19:10 -0500 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=93=9D=20Condense=20verbose=20comment?= =?UTF-8?q?s=20in=20client/storage/controllers/demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/client.py | 15 +++++--------- src/hatty/config.py | 7 +++---- src/hatty/controllers/backup.py | 10 ++++------ src/hatty/controllers/connection.py | 28 +++++++++++---------------- src/hatty/controllers/dashboards.py | 5 ++--- src/hatty/controllers/graphs.py | 10 ++++------ src/hatty/controllers/keybindings.py | 27 +++++++++++--------------- src/hatty/controllers/lists.py | 17 +++++++--------- src/hatty/controllers/logbook.py | 17 +++++++--------- src/hatty/demo/__init__.py | 5 ++--- src/hatty/demo/demo_client.py | 13 +++++-------- src/hatty/demo/demo_data.py | 29 ++++++++++++---------------- src/hatty/git_sync.py | 7 +++---- src/hatty/storage.py | 21 ++++++++------------ 14 files changed, 84 insertions(+), 127 deletions(-) diff --git a/src/hatty/client.py b/src/hatty/client.py index 263dbe9..93dd575 100644 --- a/src/hatty/client.py +++ b/src/hatty/client.py @@ -10,22 +10,17 @@ RECONNECT_DELAY = 5 MAX_RECONNECT_DELAY = 60 -# WS ping keepalive interval (seconds). Without this, a silently-dropped -# network (e.g. WiFi turned off, no TCP FIN/RST) leaves `ws.receive()` -# blocked forever — neither `ha_disconnect` nor `ha_connect_failed` is ever -# emitted, so the UI shows stale state indefinitely (issue #250). With a -# heartbeat, aiohttp pings the server and raises a timeout when pongs stop -# arriving, which flows through listen()'s except-Exception path instead. +# WS ping keepalive (seconds): without it a silently-dropped network leaves +# `ws.receive()` blocked forever and the UI stuck on stale state (issue #250). WS_HEARTBEAT = 30 # How long an awaited WS request (`_request`) waits for its `result` frame # before giving up — see fetch_logbook's WS-first/REST-fallback split (issue #17). WS_REQUEST_TIMEOUT = 10 -# Sentinel distinguishing "argument omitted" from an explicit None (which is a -# meaningful value — clearing a device's area or reverting its user-set name). -# Shared so the stand-in clients import the *same* object: the parity test in -# tests/test_fake_client_parity.py compares parameter defaults by identity. +# Sentinel distinguishing "argument omitted" from an explicit None (a meaningful +# value — clearing a device's area). Shared so stand-in clients import the same +# object: test_fake_client_parity.py compares parameter defaults by identity. _UNSET = object() diff --git a/src/hatty/config.py b/src/hatty/config.py index da14dec..2828f9a 100644 --- a/src/hatty/config.py +++ b/src/hatty/config.py @@ -144,10 +144,9 @@ def save_config(config: dict, config_path: str | None = None) -> None: if not path: raise ValueError("Configuration file path not found, cannot save config.") - # The config holds the long-lived HA token in cleartext, so keep the dir and - # file private (issue #156). mkdir's mode= is masked by umask, so chmod it - # explicitly; write the file via os.open with 0o600 (no world-readable window - # for a fresh file) and chmod afterward to tighten any pre-existing config. + # The config holds the HA token in cleartext, so keep dir/file private (#156). + # mkdir's mode= is masked by umask, so chmod explicitly; os.open with 0o600 + # avoids a world-readable window for a fresh file. path.parent.mkdir(parents=True, exist_ok=True) os.chmod(path.parent, 0o700) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) diff --git a/src/hatty/controllers/backup.py b/src/hatty/controllers/backup.py index 46c0561..5c46c11 100644 --- a/src/hatty/controllers/backup.py +++ b/src/hatty/controllers/backup.py @@ -214,12 +214,10 @@ async def pull_on_start(self) -> None: async def sync_on_exit( self, status: Callable[[str], None] | None = None, timeout: float = 75.0 ) -> tuple[bool, str]: - # 75s: room for git_sync's own NETWORK_TIMEOUT (60s) on the push plus a - # buffer for the local commit and export, as a belt-and-suspenders cap - # so a stalled network can't hang the exit-sync overlay indefinitely. - # `status`, if given, is called before each phase — ExitSyncScreen - # passes its own label so a slow push doesn't look identical to a - # slow commit (issue: show what's happening during a slow exit). + # 75s: room for git_sync's NETWORK_TIMEOUT (60s) plus a buffer for commit/ + # export, so a stalled network can't hang the exit-sync overlay indefinitely. + # `status`, if given, is called before each phase so a slow push doesn't + # look identical to a slow commit. if not self.exit_sync_pending(): return True, "" path = self.prefs.get("path") or "" diff --git a/src/hatty/controllers/connection.py b/src/hatty/controllers/connection.py index 576dd3e..5d69bb1 100644 --- a/src/hatty/controllers/connection.py +++ b/src/hatty/controllers/connection.py @@ -41,12 +41,10 @@ def _on_ha_result(self, msg: dict) -> None: self._handle_failed_result_message(msg) def _on_ha_event(self, msg: dict) -> None: - # logbook/event_stream frames and state_changed frames share the outer - # {"type": "event", "event": {...}} envelope; distinguished by shape — - # a stream frame's `event` carries an `events` list, not `event_type`/ - # `data` (issue #19). Dispatching on id isn't safe here: send_json's - # await point means a fast HA can deliver the first push before - # subscribe_logbook() has returned the id to store. + # logbook/event_stream and state_changed frames share the outer envelope; + # distinguished by shape — a stream frame's `event` carries `events`, not + # `event_type`/`data` (#19). Dispatching on id isn't safe: a fast HA can + # deliver the first push before subscribe_logbook() returns the id to store. if "events" in msg.get("event", {}): self._handle_logbook_stream_message(msg) else: @@ -56,9 +54,8 @@ def _on_ha_connected(self, msg: dict) -> None: app = self._app self._connected = True app.set_title_based_on_focused_ui() - # (Re)load the entity/device/area registries on every (re)connect — they - # aren't part of the state stream, so a reconnect would otherwise keep - # stale names/areas. + # (Re)load the registries on every (re)connect — they aren't part of the + # state stream, so a reconnect would otherwise keep stale names/areas. app.spawn(app.client.fetch_entity_registry()) app.spawn(app.client.fetch_device_registry()) app.spawn(app.client.fetch_area_registry()) @@ -81,10 +78,9 @@ def _on_ha_connect_failed(self, msg: dict) -> None: sub_title = f"Home Assistant unreachable — retry {attempt} pending…" splash_status = f"Unreachable — retry {attempt} pending…" if self._connected: - # The connected→disconnected edge (issue #243): a drop that surfaces as an - # exception rather than a clean close. Re-show the splash (over whatever - # screen is up) with its status baked in via the constructor — it isn't - # mounted yet, so update_status() can't be used here. + # Connected→disconnected edge (#243): a drop that surfaces as an exception + # rather than a clean close. Re-show the splash with status baked into + # the constructor — it isn't mounted yet, so update_status() can't be used. self._connected = False self._app._show_splash(splash_status) self._app.sub_title = sub_title @@ -217,10 +213,8 @@ def _handle_event_message(self, msg: dict) -> None: app.graph_ctl.record_state(new_state) app.notify_ctl.handle_state_change(entity_id, old_state, new_state) - # While a logbook/event_stream subscription is active, it already - # carries this same state change for most entities (issue #19) — - # except continuous sensors, which HA's stream excludes just like - # its logbook fetch does (issue #50); log_ctl decides who needs this. + # A live logbook/event_stream subscription already carries this state change + # for most entities (#19), except continuous sensors (#50); log_ctl decides. app.log_ctl.handle_state_change(entity_id, new_state, old_state) app._clear_pending_call(entity_id) if app._detail_entity_id == entity_id: diff --git a/src/hatty/controllers/dashboards.py b/src/hatty/controllers/dashboards.py index 49022c6..0030182 100644 --- a/src/hatty/controllers/dashboards.py +++ b/src/hatty/controllers/dashboards.py @@ -309,9 +309,8 @@ def clear_slot(self, dashboard_name: str, row: int, col: int, parent: tuple[int, def swap_slots( self, dashboard_name: str, r1: int, c1: int, r2: int, c2: int, parent: tuple[int, int] | None = None ) -> bool: - # Move/swap a widget between two cells of the same grid (top-level or one - # split's child grid): reassign each present slot's row/col. Handles - # occupied↔occupied and occupied↔empty (a missing slot is simply absent). + # Move/swap a widget between two cells of the same grid: reassign each + # present slot's row/col (handles occupied↔occupied and occupied↔empty). # Both slots must fit at their new anchors (span-aware); returns False if not. ctx = self.grid_ctx(dashboard_name, parent) if ctx is None: diff --git a/src/hatty/controllers/graphs.py b/src/hatty/controllers/graphs.py index 4572195..a7ca4ee 100644 --- a/src/hatty/controllers/graphs.py +++ b/src/hatty/controllers/graphs.py @@ -124,8 +124,7 @@ def open_graph_for(self, entity_id: str, entity: Entity) -> None: self.detail_entity_id = entity_id self._panel().add_class("-visible") # Open in the configured default graph type, mirroring the fullscreen graph - # (initial_graph_type) and dashboard Graph widgets; None falls back to the - # sparkline Max summary, so the historic default is unchanged. + # and dashboard Graph widgets; None falls back to the sparkline Max summary. self._panel().apply_saved_graph_type(self._app.app_config.get(CONFIG_KEY_GRAPH_TYPE)) self.render_detail(entity_id, entity) self._spawn_history_load(entity_id) @@ -320,10 +319,9 @@ def handle_saved_graphs_popup_action(self, result: dict) -> None: return from hatty.ui.graph.preview_screen import GraphPreviewScreen - # Replace an already-open fullscreen graph in place instead of stacking a - # second one (the SavedGraphsPopup has already dismissed, so app.screen is - # the old graph screen here). Pop before overwriting graph_hours so the old - # screen never reloads against the new window on its way out. + # Replace an already-open fullscreen graph instead of stacking a second one + # (app.screen is the old graph screen). Pop before overwriting graph_hours + # so the old screen never reloads against the new window on its way out. if isinstance(app.screen, GraphPreviewScreen): app.pop_screen() app.app_config[CONFIG_KEY_GRAPH_HOURS] = saved.get("hours", DEFAULT_GRAPH_HOURS) diff --git a/src/hatty/controllers/keybindings.py b/src/hatty/controllers/keybindings.py index 0828bfb..df0581d 100644 --- a/src/hatty/controllers/keybindings.py +++ b/src/hatty/controllers/keybindings.py @@ -83,19 +83,16 @@ class KeySpec(NamedTuple): since they're never shown).""" -# Never assignable to any action: ctrl+q is the unconditional quit escape -# hatch, ctrl+p opens Textual's command palette, ctrl+c is the terminal's own -# interrupt (also KeyCapturePopup's cancel key). +# Never assignable: ctrl+q is the unconditional quit hatch, ctrl+p opens the +# command palette, ctrl+c is the terminal interrupt (also KeyCapturePopup's cancel). RESERVED_KEYS = frozenset({"ctrl+q", "ctrl+p", "ctrl+c"}) SECTION_ORDER = ("Navigation", "Entities & lists", "Activity log", "Graph") -# One row per original Binding/tuple entry across every migrated screen -# (config_screen.py is deliberately excluded — its own bindings stay fixed so -# the config screen can never be rebound into being unreachable). Grouped by -# scope in file order; within a scope, order matches the screen's original -# BINDINGS list exactly (guarded by tests/unit/test_keybindings.py against -# tests/unit/binding_snapshot.json). +# One row per original Binding/tuple across every migrated screen (config_screen.py +# excluded — its bindings stay fixed so it can never be rebound unreachable). +# Grouped by scope in file order, matching each screen's original BINDINGS list +# (guarded by test_keybindings.py against binding_snapshot.json). REGISTRY: tuple[KeySpec, ...] = ( KeySpec( id="nav.search", @@ -1429,13 +1426,11 @@ class KeySpec(NamedTuple): _by_id.setdefault(_spec.id, []).append(_spec) BY_ID: dict[str, tuple[KeySpec, ...]] = {spec_id: tuple(specs) for spec_id, specs in _by_id.items()} -# Distinct ids that default to the *same* key in the *same* scope — the -# mode-gated twins check_action already keeps mutually exclusive (e.g. -# dashboard's Use-mode `log.toggle` and Edit-mode `dashboard.edit_slot`, both -# "a" by default). validate() must never flag these against each other: only -# one side of most such pairs is even curated/rebindable, and the other stays -# permanently pinned at that shared default, so the overlap is the accepted -# baseline, not a conflict to report. +# Distinct ids that default to the *same* key in the *same* scope — mode-gated +# twins check_action already keeps mutually exclusive (e.g. dashboard's Use-mode +# `log.toggle` and Edit-mode `dashboard.edit_slot`, both "a" by default). +# validate() must never flag these against each other: only one side of most such +# pairs is curated/rebindable, the other stays pinned at that shared default. TWINS: dict[str, frozenset[str]] = {} for _scope_specs in BY_SCOPE.values(): _by_key: dict[str, set[str]] = {} diff --git a/src/hatty/controllers/lists.py b/src/hatty/controllers/lists.py index 83158f5..39e0c2f 100644 --- a/src/hatty/controllers/lists.py +++ b/src/hatty/controllers/lists.py @@ -23,10 +23,9 @@ def __init__(self, app) -> None: self.manual_lists: set[str] = set() self.undo_stack: list[dict] = [] self.redo_stack: list[dict] = [] - # Transient (never persisted, issue #214): the one list currently unlocked - # for removal, if any. Every list starts locked; entering a list via - # `select_or_create` re-locks it, so unlocking never survives a switch - # away and back, let alone a restart. + # Transient (never persisted, #214): the one list currently unlocked for + # removal. Every list starts locked; select_or_create re-locks it, so + # unlocking never survives a switch away and back. self.unlocked_list: str | None = None def jump_target(self) -> str | None: @@ -180,10 +179,8 @@ def select_or_create(self, list_name: str) -> None: if list_name not in self.list_names: self.list_names.append(list_name) self.entity_lists[list_name] = [] - # An active free-text search would otherwise keep winning over the - # list just selected (search_term takes priority in - # _currently_displayed_entities), leaving the table stuck showing - # stale search results instead of the list (issue #211). + # An active free-text search takes priority in _currently_displayed_entities + # and would otherwise keep showing stale results instead of the list (#211). self._app.search_term = "" # Every (re)entry into a list starts locked (issue #214) — unlocking # never survives switching away, even back to the same list. @@ -205,8 +202,8 @@ def lock(self, list_name: str) -> None: self.unlocked_list = None def apply_membership(self, list_name: str, entity_id: str, action: str) -> None: - # `setdefault` re-creates a deleted list on undo rather than erroring; deleting a list - # is out of scope for undo/redo, so this is an accepted edge case, not a bug. + # `setdefault` re-creates a deleted list on undo rather than erroring; deleting + # a list is out of scope for undo/redo, an accepted edge case, not a bug. current_list = self.entity_lists.setdefault(list_name, []) if action == "add" and entity_id not in current_list: current_list.append(entity_id) diff --git a/src/hatty/controllers/logbook.py b/src/hatty/controllers/logbook.py index ad44377..8b7121b 100644 --- a/src/hatty/controllers/logbook.py +++ b/src/hatty/controllers/logbook.py @@ -58,9 +58,8 @@ # 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. +# Every device_id widens the WS logbook query's event-type set, making device +# count the expensive axis — cap it independently of the entity cap above. _DEVICE_LOG_MAX_DEVICES = 50 @@ -168,9 +167,8 @@ def close(self, host: LogHost) -> None: try: panel = session.panel() except NoMatches: - # The host is mid-teardown (e.g. a screen's on_unmount closing its - # own session so it can't linger — see LogHost's docstring) and its - # children, including the panel, are already gone. Nothing left to + # The host is mid-teardown (its own on_unmount closing this session so + # it can't linger) and its children are already gone — nothing left to # un-visible/un-maximize; still resync the subscription below. panel = None if panel is not None: @@ -613,9 +611,8 @@ def handle_state_change(self, entity_id: str, new_state: Entity, old_state: "Ent "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). + # name is always set above, so entity_names/device_names can stay empty — + # resolve_name short-circuits on it, sharing format_log_line/state_detail + # with the fetched path instead of writing a raw, unlabeled string (#25). entry = normalize_entry(raw, {}, {}, {entity_id: device_class}, {entity_id: unit}) self._app.call_later(panel.add_log_entry, entry) diff --git a/src/hatty/demo/__init__.py b/src/hatty/demo/__init__.py index 75b7316..76fd51b 100644 --- a/src/hatty/demo/__init__.py +++ b/src/hatty/demo/__init__.py @@ -23,8 +23,7 @@ def demo_config() -> dict: cfg["home_assistant"] = {"url": "demo://home-assistant", "token": "demo"} cfg["graph_type"] = "line" # dashboard Graph widgets render as a line, not the block sparkline cfg.update(demo_collections()) - # Enable change-alert notifications (issue #224) — the "Security" list in - # demo_collections() is pre-designated (issue #24) so it shows up already in - # use — ntfy stays off since demo mode is fully offline. + # Enable change-alert notifications (#224) — the "Security" list is + # pre-designated (#24) so it shows up already in use; ntfy stays off (offline). cfg[const.CONFIG_KEY_NOTIFICATIONS] = {**const.DEFAULT_NOTIFICATIONS} return cfg diff --git a/src/hatty/demo/demo_client.py b/src/hatty/demo/demo_client.py index 5fdf472..60cb88e 100644 --- a/src/hatty/demo/demo_client.py +++ b/src/hatty/demo/demo_client.py @@ -77,10 +77,9 @@ async def call_service(self, domain: str, service: str, service_data: dict[str, entity = self._entities.get(entity_id) if entity is None: return - # Snapshot the pre-mutation state (a shallow copy would still share the - # nested `attributes` dict `_apply_service` mutates in place) so the - # echoed event carries a real old_state — issue #224's change alerts - # need one to tell "state changed" from "first seen". + # Snapshot pre-mutation (a shallow copy would still share the nested + # `attributes` dict `_apply_service` mutates) so the echoed event carries a + # real old_state — #224's change alerts need one to detect "first seen". old_state = {**entity, "attributes": dict(entity.get("attributes", {}))} _apply_service(entity, domain, service, service_data) self._emit_state(entity, old_state) @@ -94,8 +93,7 @@ async def update_entity_registry(self, entity_id: str, name: str | None): async def update_device_registry(self, device_id: str, area_id=_UNSET, name_by_user=_UNSET): # Mutate the in-memory device, then ack like the real client — main.py's - # handler re-fetches the device registry (served above with the new area - # or name) and rebuilds the tree, so the move/rename is interactive in demo. + # handler re-fetches the registry and rebuilds the tree, staying interactive. for device in self._devices: if device.get("id") == device_id: if area_id is not _UNSET: @@ -107,8 +105,7 @@ async def update_device_registry(self, device_id: str, area_id=_UNSET, name_by_u async def create_area(self, name: str): # Append to the in-memory areas, then ack like the real client — main.py's - # handler re-fetches the area registry (served with the new area), so the - # create is interactive in demo. + # handler re-fetches the registry, so the create stays interactive. area_id = "area_" + "_".join(name.lower().split()) self._areas.append({"area_id": area_id, "name": name}) self._result("create_area", None) diff --git a/src/hatty/demo/demo_data.py b/src/hatty/demo/demo_data.py index 37d58f0..eeb2ec2 100644 --- a/src/hatty/demo/demo_data.py +++ b/src/hatty/demo/demo_data.py @@ -90,10 +90,9 @@ def e(entity_id: str, state: str, attributes: dict, changed_min_ago: int = 0) -> e("binary_sensor.smoke_detector", "off", {"friendly_name": "Smoke Detector", "device_class": "smoke"}), e("binary_sensor.washing_machine", "on", {"friendly_name": "Washing Machine", "device_class": "running"}, changed_min_ago=22), - # A Zigbee button has no meaningful state beyond its battery — but the - # device log (`i` then `v`) is reached from an entity row, so it needs - # one to be reachable at all. Its interest is its device events (issue #17): - # button presses never show up as a state change. + # A Zigbee button has no meaningful state beyond its battery, but the device + # log needs one to be reachable from an entity row; its interest is its + # device events (#17) — button presses never show up as a state change. e("sensor.living_room_button_battery", "87", {"friendly_name": "Living Room Button Battery", "unit_of_measurement": "%", "device_class": "battery"}), # ── Covers ── @@ -103,8 +102,7 @@ def e(entity_id: str, state: str, attributes: dict, changed_min_ago: int = 0) -> e("lock.front_door", "locked", {"friendly_name": "Front Door Lock"}, changed_min_ago=240), # ── Media player ── # supported_features 384447 = every MediaPlayerEntityFeature bit this app - # controls (const.MEDIA_FEAT's values summed); not imported here to keep - # this module free of app imports. + # controls; not imported here to keep this module free of app imports. e("media_player.living_room_speaker", "playing", {"friendly_name": "Living Room Speaker", "supported_features": 384447, "volume_level": 0.4, "is_volume_muted": False, @@ -120,10 +118,9 @@ def e(entity_id: str, state: str, attributes: dict, changed_min_ago: int = 0) -> e("input_number.thermostat_offset", "1.5", {"friendly_name": "Thermostat Offset", "min": -5, "max": 5, "step": 0.5, "unit_of_measurement": "°C"}), # ── Weather ── - # supported_features 7 = FORECAST_DAILY(1) | FORECAST_HOURLY(2) | FORECAST_TWICE_DAILY(4), - # so the demo entity exercises all three weather.get_forecasts types (issue #283) — - # see demo_forecast() below for the per-type payloads; the inline "forecast" attribute - # here is the legacy daily shape, kept as the fallback path's demo data. + # supported_features 7 = DAILY(1) | HOURLY(2) | TWICE_DAILY(4), so this exercises + # all three weather.get_forecasts types (#283) — see demo_forecast() for the + # per-type payloads; the inline "forecast" attribute is the legacy fallback shape. e("weather.home", "partlycloudy", {"friendly_name": "Home Weather", "supported_features": 7, "temperature": 18.4, "temperature_unit": "°C", @@ -143,10 +140,9 @@ def e(entity_id: str, state: str, attributes: dict, changed_min_ago: int = 0) -> ] -# Per-type weather.get_forecasts payloads (issue #283), keyed by entity_id then -# forecast type — served by DemoHAClient.fetch_forecast so --demo exercises the -# same fetch-and-switch path a real HA instance does, rather than only ever -# reading the legacy inline "forecast" attribute. +# Per-type weather.get_forecasts payloads (#283), keyed by entity_id then forecast +# type — served by fetch_forecast so --demo exercises the same fetch-and-switch +# path real HA does, instead of only ever reading the legacy inline attribute. _WEATHER_FORECASTS: dict[str, dict[str, list[dict]]] = { "weather.home": { "daily": [ @@ -417,9 +413,8 @@ def demo_climate_history( return pts -# device_id -> plausible zha_event types (issue #17) — a button's presses and -# a door sensor's connectivity pings never show up as a state change, so these -# are the demo's proof that the device log (`v`) surfaces more than entities do. +# device_id -> plausible zha_event types (#17) — button presses and door-sensor +# pings never show as a state change; proof the device log surfaces more than entities do. _DEMO_DEVICE_EVENTS: dict[str, list[str]] = { "dev_lr_button": ["remote_button_short_press", "remote_button_double_press", "remote_button_long_press"], "dev_front_door": ["device_offline", "device_online"], diff --git a/src/hatty/git_sync.py b/src/hatty/git_sync.py index 356b2ad..97899fe 100644 --- a/src/hatty/git_sync.py +++ b/src/hatty/git_sync.py @@ -28,10 +28,9 @@ _RC_NO_GIT = -101 _RC_TIMEOUT = -102 -# Applied to every invocation. core.editor=true makes any editor launch exit 0 -# instantly instead of blocking on a TTY; commit.gpgsign=false stops a -# passphrase prompt from hanging the TUI; gc.auto=0 keeps a commit from -# triggering a slow background gc the first time timing matters. +# Applied to every invocation: core.editor=true makes an editor launch exit 0 +# instantly instead of blocking on a TTY; commit.gpgsign=false stops a passphrase +# prompt from hanging the TUI; gc.auto=0 avoids a slow background gc mid-timing. _GLOBAL_FLAGS = [ "-c", "core.editor=true", diff --git a/src/hatty/storage.py b/src/hatty/storage.py index 8878904..340742f 100644 --- a/src/hatty/storage.py +++ b/src/hatty/storage.py @@ -89,13 +89,11 @@ def _dump_json(value) -> str | None: ); """ -# Single source of truth for every persisted config key: key -> (app attribute -# holding its in-memory working copy, destination). SQLite keys are the growing -# user-data collections this module owns; YAML keys (just "columns") are display -# preferences that stay in the lean config.yaml. HACLI derives _PERSIST_ATTRS and -# _collections_snapshot from this table, and COLLECTION_KEYS below is the sqlite -# slice — a test in tests/unit/test_storage.py guards that they stay in sync so a -# new collection can't be half-registered (issue #168). +# Single source of truth for every persisted config key: key -> (in-memory app +# attribute, destination). SQLite keys are this module's user-data collections; +# YAML keys ("columns") are lean display preferences. HACLI derives +# _PERSIST_ATTRS/_collections_snapshot from this; COLLECTION_KEYS below is the +# sqlite slice, kept in sync by a test in test_storage.py (issue #168). PERSISTED = { CONFIG_KEY_LISTS: ("entity_lists", "sqlite"), CONFIG_KEY_DEFAULT_LIST: ("default_list_name", "sqlite"), @@ -116,12 +114,9 @@ class Storage: def __init__(self, db_path: str | Path): self.db_path = str(db_path) self._conn: sqlite3.Connection | None = None - # A single sqlite3 connection is shared across threads (saves run in - # asyncio.to_thread workers, close() runs on the app's main thread). - # check_same_thread=False disables sqlite's own guard, so we serialize - # every access to the connection ourselves — two concurrent save_all - # calls, or a close() racing an in-flight save, are otherwise a C-level - # crash rather than a clean error. + # One connection shared across threads (saves in to_thread workers, close() + # on the main thread): check_same_thread=False disables sqlite's guard, so + # this lock serializes access — a concurrent save/close is a C-level crash otherwise. self._lock = threading.Lock() @property From 399e61b4b7322a7207a017032b5bc8670c7b085b Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:40:28 -0500 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9B=20Honor=20the=20default=20dash?= =?UTF-8?q?board=20when=20opening=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/command_provider.py | 2 +- src/hatty/controllers/dashboards.py | 16 +++++++-- src/hatty/main.py | 11 ++++--- src/hatty/ui/dashboard/screen.py | 1 + tests/test_config_persistence.py | 22 +++++++++++++ tests/test_dashboard_crud.py | 33 +++++++++++++++++++ tests/unit/test_dashboards_controller.py | 41 ++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/hatty/command_provider.py b/src/hatty/command_provider.py index 6bc6b39..7cff042 100644 --- a/src/hatty/command_provider.py +++ b/src/hatty/command_provider.py @@ -23,7 +23,7 @@ def _candidates(self) -> list[tuple[str, str, IgnoreReturnCallbackType]]: ("Configuration", "Edit hatty settings", app.action_show_config), ] result.append(("Lists", "Switch to your last-used or default list", app.action_palette_switch_list)) - result.append(("Dashboard", "Open your last-used or default dashboard", app.action_show_dashboard)) + result.append(("Dashboard", "Open your default dashboard", app.action_show_dashboard)) result.append(("Setup wizard", "Re-enter the Home Assistant URL and token", app.action_show_onboarding)) return result diff --git a/src/hatty/controllers/dashboards.py b/src/hatty/controllers/dashboards.py index 0030182..93955c5 100644 --- a/src/hatty/controllers/dashboards.py +++ b/src/hatty/controllers/dashboards.py @@ -41,10 +41,20 @@ def switch(self, name: str) -> None: if name in self.dashboards: self.current_dashboard_name = name + def open_target(self) -> str | None: + """The dashboard to open: the default, falling back to the last viewed, + then the first. A configured default always wins.""" + if self.default_dashboard_name in self.dashboards: + return self.default_dashboard_name + if self.current_dashboard_name in self.dashboards: + return self.current_dashboard_name + return self.dashboard_names[0] if self.dashboard_names else None + def set_default(self, name: str) -> None: if name not in self.dashboards: return self.default_dashboard_name = name + self.current_dashboard_name = name self._app.persist("default_dashboard") self._app.notify(f"'{name}' set as default dashboard.", title="Default Dashboard Set") @@ -98,10 +108,10 @@ def delete(self, name: str) -> None: return del self.dashboards[name] self.dashboard_names.remove(name) - if self.current_dashboard_name == name: - self.current_dashboard_name = self.dashboard_names[0] if self.default_dashboard_name == name: self.default_dashboard_name = None + if self.current_dashboard_name == name: + self.current_dashboard_name = self.open_target() self._app.persist("dashboards", "default_dashboard") self._app.notify(f"Dashboard '{name}' deleted.", title="Dashboard Deleted") @@ -217,7 +227,7 @@ def cleanup_temp_dashboard(self, name: str) -> None: self.dashboard_names.remove(name) self.temp_dashboard_names.discard(name) if self.current_dashboard_name == name: - self.current_dashboard_name = self.dashboard_names[0] if self.dashboard_names else None + self.current_dashboard_name = self.open_target() # ── Slot editing ───────────────────────────────────────────────────────── diff --git a/src/hatty/main.py b/src/hatty/main.py index a0c7c46..569ab23 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -297,10 +297,12 @@ async def _on_exit_app(self) -> None: if self._exit_sync_done or self._demo: return self._exit_sync_done = True + # Let queued saves land before on_unmount closes the DB — a save racing + # the close silently no-ops (storage.save_all). + await self.drain_bg_tasks(timeout=5.0) if not self.backup_ctl.exit_sync_pending(): return try: - await self.drain_bg_tasks(timeout=5.0) await self.backup_ctl.sync_on_exit() except Exception as e: self.log.error(f"git sync on exit failed: {e}") @@ -777,10 +779,9 @@ def page_rows(screen_cls: type | None, is_active: bool) -> list[tuple[str, str]] def action_show_dashboard(self) -> None: if not self.dashboard_names: - self.dash_ctl.create("Main", rows=3, cols=3) - elif self.current_dashboard_name not in self.dashboards: - target = self.default_dashboard_name if self.default_dashboard_name in self.dashboards else None - self.current_dashboard_name = target or self.dashboard_names[0] + self.dash_ctl.create("Main", rows=3, cols=3) # create() sets current itself + elif target := self.dash_ctl.open_target(): + self.current_dashboard_name = target self.pop_to_base_screen() self.push_screen(DashboardScreen()) diff --git a/src/hatty/ui/dashboard/screen.py b/src/hatty/ui/dashboard/screen.py index 5acb857..732addc 100644 --- a/src/hatty/ui/dashboard/screen.py +++ b/src/hatty/ui/dashboard/screen.py @@ -967,6 +967,7 @@ def callback(result: dict | None) -> None: self.render_dashboard() elif action == "set_default": self.app.dash_ctl.set_default(result["name"]) + self._reset_cursor() self.render_dashboard() elif action == "resize": self.app.dash_ctl.resize(result["name"], result["rows"], result["cols"]) diff --git a/tests/test_config_persistence.py b/tests/test_config_persistence.py index f4d0245..1d42332 100644 --- a/tests/test_config_persistence.py +++ b/tests/test_config_persistence.py @@ -67,3 +67,25 @@ async def test_set_default_saves_to_config_file(make_app, sample_entities): saved = app.storage.load_all() assert saved["default_list"] == "list_a" + + +async def test_set_default_dashboard_saves_to_storage(make_app, open_dashboard): + config_data = { + **make_config(), + "lists": {}, + "dashboards": { + "Main": {"rows": 3, "cols": 3, "slots": []}, + "Office": {"rows": 2, "cols": 2, "slots": []}, + }, + } + app = make_app(config_data=config_data) + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("d") + await pilot.pause() + await pilot.press("down") + await pilot.press("d") + await pilot.pause() + + saved = app.storage.load_all() + assert saved["default_dashboard"] == "Office" diff --git a/tests/test_dashboard_crud.py b/tests/test_dashboard_crud.py index e16a477..3754608 100644 --- a/tests/test_dashboard_crud.py +++ b/tests/test_dashboard_crud.py @@ -243,6 +243,8 @@ async def test_set_default_dashboard_via_popup(make_app, open_dashboard): await pilot.press("d") await pilot.pause() assert app.default_dashboard_name == "Office" + # setting the default switches to it right away, like lists do + assert app.current_dashboard_name == "Office" # the default is marked with a trailing '*' in the popup list await pilot.press("d") @@ -251,6 +253,37 @@ async def test_set_default_dashboard_via_popup(make_app, open_dashboard): assert "Office*" in labels +async def test_default_dashboard_wins_over_last_viewed(make_app, open_dashboard): + config_data = { + **make_config(), + "lists": {}, + "dashboards": { + "Main": {"rows": 3, "cols": 3, "slots": []}, + "Office": {"rows": 2, "cols": 2, "slots": []}, + }, + } + app = make_app(config_data=config_data) + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("d") + await pilot.pause() + await pilot.press("down") + await pilot.press("d") # set "Office" as default + await pilot.pause() + + # view "Main" instead, then leave and come back: the default still wins + app.dash_ctl.switch("Main") + await pilot.press("escape") + await pilot.pause() + await pilot.press("y") # confirm "Leave dashboard?" + await pilot.pause() + await pilot.press("d") + await pilot.pause() + + assert isinstance(app.screen, DashboardScreen) + assert app.current_dashboard_name == "Office" + + async def test_rename_default_dashboard_updates_default(make_app, open_dashboard): config_data = { **make_config(), diff --git a/tests/unit/test_dashboards_controller.py b/tests/unit/test_dashboards_controller.py index a377933..82c2e23 100644 --- a/tests/unit/test_dashboards_controller.py +++ b/tests/unit/test_dashboards_controller.py @@ -126,6 +126,47 @@ def test_set_default_guarded_by_membership(): assert ctl.default_dashboard_name == "A" +def test_set_default_switches_to_it(): + ctl = _controller() + ctl.create("A", 2, 2) + ctl.create("B", 2, 2) + ctl.set_default("A") + assert ctl.current_dashboard_name == "A" + + +# ── open_target ─────────────────────────────────────────────────────────────── + + +def test_open_target_prefers_the_default_over_the_last_viewed(): + ctl = _controller() + ctl.create("A", 2, 2) + ctl.create("B", 2, 2) + ctl.default_dashboard_name = "A" + ctl.current_dashboard_name = "B" + assert ctl.open_target() == "A" + + +def test_open_target_falls_back_to_current_then_first(): + ctl = _controller() + ctl.create("A", 2, 2) + ctl.create("B", 2, 2) + ctl.current_dashboard_name = "B" + assert ctl.open_target() == "B" + ctl.current_dashboard_name = "Ghost" + assert ctl.open_target() == "A" + + +def test_open_target_ignores_a_stale_default(): + ctl = _controller() + ctl.create("A", 2, 2) + ctl.default_dashboard_name = "Ghost" + assert ctl.open_target() == "A" + + +def test_open_target_without_dashboards_is_none(): + assert _controller().open_target() is None + + # ── delete ──────────────────────────────────────────────────────────────────── From bc4df91fc02432f84cb4448f64b08474d0b5733e Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:49:35 -0500 Subject: [PATCH 6/6] Condense CLAUDE.md, pointing at module docstrings --- CLAUDE.md | 112 +++++++++++++++++------------------------------------- 1 file changed, 35 insertions(+), 77 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 27f172f..7230ba4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ to disk). Nothing is published to PyPI yet. hatty is an independent reimplementation of `../ha-cli`'s BUILD_PLAN.md spec — same conventions, separate codebase, not a port of ha-cli's source. -## Architecture +## Architecture & layout ``` User Keybindings → HACLI (main.py) → HAClient (client.py) ↔ Home Assistant WebSocket @@ -34,75 +34,36 @@ User Keybindings → HACLI (main.py) → HAClient (client.py) ↔ Home Assistant _update_entities_display() → EntitiesTable ``` -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`), `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, `GraphPreviewScreen`'s, and `DashboardScreen`'s), `keybindings.py` -(`app.keys_ctl` — owns the user's keybinding overrides and pushes the resulting keymap onto the -running app via `App.set_keymap`; every screen's `BINDINGS` is `bindings_for(scope)` from this -module's `REGISTRY`, the single source of truth for all ~220 bindings in the app, rebindable from -Configuration ▸ Keybindings), `backup.py` (`app.backup_ctl` — Backup & Sync: owns the export-scope -and git prefs, drives `backup.py`/`git_sync.py` against the app's live collections, and fires -pull-on-start / the exit-time commit-and-push). Like `const.py`/`types.py`, `keybindings.py`'s -registry half is cycle-safe (no `hatty.ui`/`hatty.main` imports) since it's imported at -class-definition time by every screen module. **`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(...)`). - -**Single-object export/import.** Lists, dashboards, and saved graphs each have a matching pair of -controller methods — `to_export_payload(name)` / `import_from_payload(payload)` on -`ListController`/`DashboardController`/`GraphController` — producing one small versioned JSON file -per object (`{"hatty_list": 1, ...}` / `{"hatty_dashboard": 1, ...}` / `{"hatty_graph": 1, ...}`), -reachable from each object's popup (`x`/`i`). `src/hatty/backup.py`'s directory export (Configuration -▸ Backup & Sync) is built entirely out of these same payloads — one file per object under -`lists/`/`dashboards/`/`graphs/` plus a handful of whole-collection files (`entity_names.json`, -`settings.json`, `keybindings.json`) and a `hatty-backup.json` manifest — so a file written by one -path is always readable by the other, and dropping a hand-exported object into the backup directory -just works. `src/hatty/git_sync.py` is a separate, git-agnostic layer that shells out to the `git` -CLI (hardened against credential prompts and hangs — see its module docstring) to optionally treat -that directory as a repo; neither module imports the other's caller, `controllers/backup.py` wires -them together. - -**Two-tier config persistence.** `config.yaml` is lean — connection settings and display -preferences only. The user-data collections (`lists`, `entity_names`, `dashboards`, `saved_graphs`, -`manual_lists`, `default_list`, `default_dashboard` — the exact set is `storage.COLLECTION_KEYS`) -live in SQLite (`src/hatty/storage.py`, `Storage`) at `/hatty.db`. SQLite is -authoritative: on boot the DB's collections are loaded back over the YAML config, and every save -strips collection keys from the YAML while writing them to the DB in one transaction. See -`storage.py`'s module docstring for the collection shapes. - -**Test/demo injection seam**: `HACLI._client_factory` is where the test suite's `FakeHAClient` and -`--demo`'s `DemoHAClient` both replace the real `HAClient` — `DemoHAClient` is signature-parity-tested -against it. - -Entity dicts follow the `Entity`/`EntityAttributes` TypedDicts in `src/hatty/types.py`; pass entity -params typed as `Entity` (not bare `dict`) and read `total=False` fields via `.get(...)`. -`const.py`/`types.py` import nothing from the app, so they stay cycle-safe. - -## Layout - -- `src/hatty/main.py` — the `HACLI` Textual app: keybindings, message routing, entity-table state, - cross-cutting plumbing (`spawn(coro)` for tracked fire-and-forget tasks — never bare - `asyncio.create_task`; `persist(*keys)` to mirror + save a collection). -- `src/hatty/controllers/` — the controllers above. -- `src/hatty/client.py` — `HAClient`: websocket auth/requests, REST history/logbook fetchers (swallow - errors, return `None`). -- `src/hatty/config.py` / `storage.py` — YAML config and SQLite collection persistence. -- `src/hatty/const.py` / `types.py` / `service_calls.py` — shared constants, entity TypedDicts, and - the pure per-domain functions that build `call_service` data for entity controls. -- `src/hatty/backup.py` — Backup & Sync's directory export/import: builds/writes/reads the JSON - files described above, no git involved. -- `src/hatty/git_sync.py` — the git CLI layer for Backup & Sync: init/commit/pull/push over the - export directory, every invocation non-interactive and time-bounded. -- `src/hatty/ui/` — screens and popups, one module per surface (entity table, dashboard grid + - widgets, device/area tree, graph panel/fullscreen/preview, per-domain control screens, config, - onboarding). Each module's own docstring is the source of truth for its behavior — read the file - before describing it. -- `src/hatty/ui/popup_base.py` — shared modal scaffolding (`PopupScreen`, `ListPopup`); new popups - should subclass these rather than hand-rolling styling. +Controllers (`src/hatty/controllers/`, instantiated in `HACLI.__init__`; see each docstring): +`lists.py` (`app.list_ctl`, list state), `dashboards.py` (`app.dash_ctl`, dashboard grid/slots), +`graphs.py` (`app.graph_ctl`, history/detail/saved graphs), `connection.py` (`app.conn_ctl`, HA +websocket pump), `notifications.py` (`app.notify_ctl`, change alerts), `logbook.py` (`app.log_ctl`, +shared activity-log state), `keybindings.py` (`app.keys_ctl`, overrides), `backup.py` +(`app.backup_ctl`, Backup & Sync prefs). +`app.keys_ctl` pushes overrides via `App.set_keymap`; every screen's `BINDINGS = bindings_for(scope)` +from `REGISTRY` (single source for ~220 bindings), whose registry half is cycle-safe — see its +docstring. + +`HACLI` keeps its old attribute surface via `_controller_proxy` property pairs (`app.dashboards`, +`app.current_list_name`, `app._detail_entity_id`, …); new code calls controllers directly +(`self.app.dash_ctl.set_slot(...)`). **Injection seam**: `HACLI._client_factory` swaps in +`FakeHAClient`/`DemoHAClient`. + +Lists/dashboards/graphs each expose `to_export_payload`/`import_from_payload`; `backup.py`'s +directory export reuses those payloads and `git_sync.py` optionally treats it as a git repo — see +their docstrings. `config.yaml` stays lean; user-data collections (`storage.COLLECTION_KEYS`) live +in SQLite, authoritative over the YAML — see `storage.py`. Entity dicts follow the +`Entity`/`EntityAttributes` TypedDicts in `types.py`; read `total=False` fields via `.get(...)`; +`const.py`/`types.py` import nothing from the app (cycle-safe). + +- `src/hatty/main.py` — `spawn(coro)` for tracked fire-and-forget (never bare + `asyncio.create_task`); `persist(*keys)` to mirror + save a collection. +- `src/hatty/client.py` — `HAClient`: websocket auth/requests, REST history/logbook fetchers. +- `src/hatty/config.py`/`storage.py` — YAML config + SQLite; `const.py`/`types.py`/ + `service_calls.py` — constants, TypedDicts, `call_service` builders. +- `src/hatty/backup.py`/`git_sync.py` — directory export/import + git layer (above). +- `src/hatty/ui/` — one module per surface; module docstrings are the source of truth (read before + describing). `ui/popup_base.py` — subclass `PopupScreen`/`ListPopup`, don't hand-roll styling. ## Conventions @@ -110,14 +71,11 @@ params typed as `Entity` (not bare `dict`) and read `total=False` fields via `.g license header `# hatty — MIT License. See LICENSE file for details.` as the first line (or right after a `#!` shebang). - `uv run pyright` runs in CI (`.gitea/workflows/test.yml`) and must pass before pushing, alongside - `uv run ruff check .`. It's `basic` mode over `src/hatty` with every basic-mode category enabled, - including `reportOptionalMemberAccess`/`reportAttributeAccessIssue`/`reportArgumentType` — don't - write code that fires any of them. + `uv run ruff check .`. It's `basic` mode with every category enabled — see `[tool.pyright]` in + `pyproject.toml`; don't write code that fires any pyright diagnostic. - Every popup/widget uses inline `DEFAULT_CSS`, no external stylesheets. -- Commit messages: concise, usually a single line. -- When implementing a plan with multiple milestones: commit (and push, if asked) after each - milestone once its tests pass, and run the full `pytest` suite after the final milestone before - reporting the plan complete. +- Commit messages: concise, usually a single line; after each milestone of a plan, commit (and push + if asked) once its tests pass, and run the full `pytest` suite after the final milestone. ## Testing