diff --git a/CLAUDE.md b/CLAUDE.md index 60b2c0e..b4c99a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,10 +39,16 @@ taking an injected app reference: `controllers/lists.py` (`app.list_ctl`), `dash (`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). **`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(...)`). +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). 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(...)`). **Two-tier config persistence.** `config.yaml` is lean — connection settings and display preferences only. The user-data collections (`lists`, `entity_names`, `dashboards`, `saved_graphs`, @@ -65,7 +71,7 @@ params typed as `Entity` (not bare `dict`) and read `total=False` fields via `.g - `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 four controllers above. +- `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. diff --git a/config.example.yaml b/config.example.yaml index b82089d..c6adaf6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -12,3 +12,8 @@ theme: null graph_type: line graph_hours: 4 log_hours: 24 +# Custom keybindings, also editable from Configuration > Keybindings in the app. +# Maps a keybinding id to a Textual key string; omitted ids keep their default. +# keybindings: +# log.toggle: "A" +# nav.back: "backspace" diff --git a/src/hatty/config.py b/src/hatty/config.py index eeb79cf..f0a0710 100644 --- a/src/hatty/config.py +++ b/src/hatty/config.py @@ -13,6 +13,7 @@ CONFIG_KEY_GRAPH_HOURS, CONFIG_KEY_GRAPH_TYPE, CONFIG_KEY_HOME_ASSISTANT, + CONFIG_KEY_KEYBINDINGS, CONFIG_KEY_LISTS, CONFIG_KEY_LOG_HOURS, CONFIG_KEY_MANUAL_LISTS, @@ -80,6 +81,7 @@ def default_config() -> dict: CONFIG_KEY_NOTIFICATIONS: dict(DEFAULT_NOTIFICATIONS), CONFIG_KEY_TERMINAL_TITLE_ENABLED: True, CONFIG_KEY_TERMINAL_TITLE: DEFAULT_TERMINAL_TITLE, + CONFIG_KEY_KEYBINDINGS: {}, } diff --git a/src/hatty/const.py b/src/hatty/const.py index 9192e17..7f1fe6c 100644 --- a/src/hatty/const.py +++ b/src/hatty/const.py @@ -121,6 +121,7 @@ def binary_state_label(state: str, device_class: str) -> str: return off_label return state + # Dashboard slot widget types offered by DashboardSlotPopup ("split" is not # assignable — split slots are created via SplitSlotPopup). WIDGET_TYPES = [ @@ -169,6 +170,11 @@ 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. +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. @@ -194,6 +200,7 @@ def binary_state_label(state: str, device_class: str) -> str: CONFIG_KEY_NOTIFY_LISTS = "notify_lists" CONFIG_KEY_TERMINAL_TITLE_ENABLED = "terminal_title_enabled" CONFIG_KEY_TERMINAL_TITLE = "terminal_title" +CONFIG_KEY_KEYBINDINGS = "keybindings" # Fallback/default value for the "terminal_title" config key (issue: set tmux # title to hatty or pref). diff --git a/src/hatty/controllers/keybindings.py b/src/hatty/controllers/keybindings.py new file mode 100644 index 0000000..d99b9a8 --- /dev/null +++ b/src/hatty/controllers/keybindings.py @@ -0,0 +1,1548 @@ +# hatty — MIT License. See LICENSE file for details. +"""The single source of truth for every keybinding in hatty — backs the +Configuration > Keybindings category's custom-keybindings support — and the +app.keys_ctl controller that turns a user's overrides into a live Textual +keymap. + +Two halves, like controllers/logbook.py pairs pure helpers with a stateful +controller: + + - **Pure registry** (`KeySpec`, `REGISTRY`, `BY_ID`, `BY_SCOPE`, `SCOPES`, + `bindings_for`, `sanitize`, `resolve_keymap`, `validate`, `rebindable`). + `REGISTRY` is imported at *class-definition* time by every screen — + `BINDINGS = bindings_for("dashboard")` runs while `ui/dashboard/screen.py` + is first imported — so this half must stay as cycle-safe as const.py/ + types.py: no imports from hatty.ui or hatty.main, only textual.binding and + hatty.const. `display_key` is the one exception, and it lazy-imports its + dependency for exactly that reason. + + - **`KeybindingController`** (`app.keys_ctl`), which owns the *live* + overrides and pushes them onto the running App via `App.set_keymap`. + +REGISTRY reproduces the 224 bindings that used to be hand-written across 25 +screen modules (see the migration guard in tests/unit/test_keybindings.py, +checked against the pre-migration golden snapshot at +tests/unit/binding_snapshot.json) — one KeySpec per original Binding/tuple +entry, tagged with an `id` and the `scope` (screen) it belongs to. A single +`id` may label several KeySpec rows across different scopes when they are the +same conceptual action and should move together on rebind (e.g. `nav.back` +covers every screen's "leave/cancel" escape binding); every other row gets an +id unique to its own scope so it is never accidentally caught by another +id's rebind. + +Only entries with a non-None `section` are user-rebindable via the config +screen's Keybindings category (`rebindable()`); everything else keeps its +default forever but still needs an id — see the next paragraph for why. + +Three Textual behaviours (verified against textual 8.2.8's +`BindingsMap.apply_keymap`, textual/binding.py) shape this design: + +1. Rebinding a key deletes *every* binding under the old key in that binding + chain node, id'd or not, then re-adds only the ids present in the keymap + passed to `apply_keymap`. `DashboardScreen` binds `a` to both + `edit_slot` (Edit mode) and `toggle_activity_log` (Use mode); if only one + had an id, the other would silently vanish the moment the id'd one moved. + So *every* row gets an id, and `resolve_keymap()`/`KeybindingController. + keymap()` always return the *complete* id -> key mapping (default or + override) — never a delta — so every row in a touched scope survives the + round trip. +2. Several bindings legitimately sharing one id (the `nav.back` case, or + GraphPreviewScreen's three `escape` rows) make `apply_keymap` report a + `clashed_bindings` false positive even when nothing is actually + conflicting — see `HACLI.handle_bindings_clash`, which is a documented + no-op for this reason; this module's own `validate()` is what gives the + config UI a real conflict check. +3. `Binding.with_key()` (used by `KeybindingController.static_bindings`) is a + `dataclasses.replace`, so `priority=`/`show=` survive rekeying for free. +""" + +from typing import NamedTuple, cast + +from textual.binding import Binding, BindingType + +from hatty.const import CONFIG_KEY_KEYBINDINGS, FAST_PAGE_MULTIPLIER + + +class KeySpec(NamedTuple): + id: str + """Keymap id. Shared across rows (possibly in different scopes) that must + move together when rebound; otherwise unique to this row.""" + scope: str + """Which BINDINGS list (screen) this row belongs to — see SCOPES.""" + key: str + """Default Textual key string.""" + action: str + description: str + show: bool = True + priority: bool = False + section: str | None = None + """Config-UI grouping label. None = not user-rebindable.""" + label: str | None = None + """Config-UI display label for a rebindable row; falls back to + `description` when None (auto-generated, non-curated rows never set this, + 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). +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). +REGISTRY: tuple[KeySpec, ...] = ( + KeySpec( + id="nav.search", + scope="app", + key="/", + action="toggle_search", + description="Search", + section="Navigation", + label="Search", + ), + KeySpec( + id="entity.expand", + scope="app", + key="e", + action="expand_entity", + description="Controls", + section="Entities & lists", + label="Controls", + ), + KeySpec( + id="entity.toggle_list_membership", + scope="app", + key="space", + action="toggle_list_membership", + description="In List", + section="Entities & lists", + label="In List", + ), + KeySpec( + id="entity.move_up", + scope="app", + key="shift+up", + action="move_entity_in_list(-1)", + description="Move Up", + show=False, + section="Entities & lists", + label="Move Up", + ), + KeySpec( + id="entity.move_down", + scope="app", + key="shift+down", + action="move_entity_in_list(1)", + description="Move Down", + show=False, + section="Entities & lists", + label="Move Down", + ), + KeySpec( + id="entity.sort", + scope="app", + key="o", + action="toggle_list_sort", + description="Sort Order", + show=False, + section="Entities & lists", + label="Sort Order", + ), + KeySpec( + id="entity.lock", + scope="app", + key="L", + action="toggle_list_lock", + description="Lock List", + show=False, + section="Entities & lists", + label="Lock List", + ), + KeySpec( + id="entity.rename", + scope="app", + key="r", + action="rename_entity", + description="Rename", + show=False, + section="Entities & lists", + label="Rename", + ), + KeySpec( + id="entity.undo", + scope="app", + key="u", + action="undo", + description="Undo", + show=False, + section="Entities & lists", + label="Undo", + ), + KeySpec( + id="entity.redo", + scope="app", + key="ctrl+r", + action="redo", + description="Redo", + show=False, + section="Entities & lists", + label="Redo", + ), + KeySpec( + id="entity.lists", + scope="app", + key="l", + action="show_list_selection_popup", + description="Lists", + show=False, + section="Entities & lists", + label="Lists", + ), + KeySpec( + id="entity.columns", + scope="app", + key="c", + action="show_column_config", + description="Columns", + show=False, + section="Entities & lists", + label="Columns", + ), + KeySpec( + id="log.toggle", + scope="app", + key="a", + action="toggle_activity_log", + description="Activity Log", + show=False, + section="Activity log", + label="Toggle", + ), + KeySpec( + id="log.entity", + scope="app", + key="i", + action="toggle_entity_log", + description="Entity Log", + show=False, + section="Activity log", + label="Entity Log", + ), + KeySpec( + id="log.scope", + scope="app", + key="v", + action="show_log_scope", + description="Log Scope", + show=False, + section="Activity log", + label="Scope", + ), + KeySpec( + id="log.maximize", + scope="app", + key="f", + action="maximize_log", + description="Maximize Log", + show=False, + section="Activity log", + label="Maximize", + ), + KeySpec( + id="log.older", + scope="app", + key="left", + action="log_older", + description="Older Events", + show=False, + priority=True, + section="Activity log", + label="Older Events", + ), + KeySpec( + id="log.newer", + scope="app", + key="right", + action="log_newer", + description="Newer Events", + show=False, + priority=True, + section="Activity log", + label="Newer Events", + ), + KeySpec( + id="graph.toggle", + scope="app", + key="g", + action="toggle_graph", + description="Graph", + show=False, + section="Graph", + label="Toggle", + ), + KeySpec( + id="graph.fullscreen", + scope="app", + key="G", + action="graph_fullscreen", + description="Full Graph", + show=False, + section="Graph", + label="Fullscreen", + ), + KeySpec( + id="graph.compare", + scope="app", + key="+", + action="add_to_graph", + description="Compare", + show=False, + section="Graph", + label="Compare", + ), + KeySpec( + id="nav.dashboard", + scope="app", + key="d", + action="show_dashboard", + description="Dashboard", + show=False, + section="Navigation", + label="Dashboard", + ), + KeySpec( + id="nav.device_tree", + scope="app", + key="D", + action="show_device_tree", + description="Device Tree", + show=False, + section="Navigation", + label="Device Tree", + ), + KeySpec( + id="nav.saved_graphs", + scope="app", + key="s", + action="show_saved_graphs_popup", + description="Saved Graphs", + show=False, + section="Navigation", + label="Saved Graphs", + ), + KeySpec( + id="graph.cycle_type", + scope="app", + key="t", + action="cycle_graph_type", + description="Graph Type", + section="Graph", + label="Cycle Type", + ), + KeySpec( + id="graph.duration", + scope="app", + key="T", + action="show_graph_duration", + description="Duration", + show=False, + section="Graph", + label="Duration", + ), + KeySpec( + id="nav.search_next", + scope="app", + key="n", + action="search_next", + description="Next Match", + show=False, + section="Navigation", + label="Next Match", + ), + KeySpec( + id="nav.search_prev", + scope="app", + key="N", + action="search_prev", + description="Prev Match", + show=False, + section="Navigation", + label="Prev Match", + ), + KeySpec( + id="nav.help", + scope="app", + key="question_mark", + action="show_help", + description="Help", + section="Navigation", + label="Help", + ), + KeySpec( + id="nav.back", + scope="app", + key="escape", + action="go_back", + description="Back/Clear", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="app.quit", scope="app", key="ctrl+q", action="quit", description="Quit", show=False), + KeySpec( + id="column_config.save_and_close", + scope="column_config", + key="escape", + action="save_and_close", + description="Save & Close", + ), + KeySpec( + id="column_config.save_and_close__q", + scope="column_config", + key="q", + action="save_and_close", + description="Save & Close", + show=False, + ), + KeySpec( + id="column_config.save_and_close__enter", + scope="column_config", + key="enter", + action="save_and_close", + description="Save & Close", + priority=True, + ), + KeySpec( + id="column_config.move_up", + scope="column_config", + key="shift+up", + action="move_up", + description="Move Up", + priority=True, + ), + KeySpec( + id="column_config.move_down", + scope="column_config", + key="shift+down", + action="move_down", + description="Move Down", + priority=True, + ), + KeySpec(id="confirm.confirm", scope="confirm", key="y", action="confirm", description="Yes"), + KeySpec(id="confirm.cancel", scope="confirm", key="n", action="cancel", description="No"), + KeySpec( + id="nav.back", + scope="confirm", + key="escape", + action="cancel", + description="No", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="confirm.cancel__q", scope="confirm", key="q", action="cancel", description="No", show=False), + KeySpec( + id="nav.back", + scope="control_popup", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="control_popup.cancel", scope="control_popup", key="q", action="cancel", description="Cancel", show=False + ), + KeySpec(id="control_popup.save", scope="control_popup", key="enter", action="save", description="Save"), + KeySpec( + id="nav.back", + scope="entity_picker", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="entity_picker.cancel", scope="entity_picker", key="q", action="cancel", description="Cancel", show=False + ), + KeySpec( + id="nav.back", + scope="color_picker", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="color_picker.cancel", scope="color_picker", key="q", action="cancel", description="Cancel", show=False), + KeySpec( + id="nav.back", + scope="light", + key="escape", + action="close", + description="Close", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="light.close", scope="light", key="q", action="close", description="Close", show=False), + KeySpec( + id="light.toggle_power", scope="light", key="space", action="toggle_power", description="On/Off", priority=True + ), + KeySpec(id="light.white_preset", scope="light", key="1", action="white_preset(0)", description="Warm", show=False), + KeySpec( + id="light.white_preset__2", scope="light", key="2", action="white_preset(1)", description="Neutral", show=False + ), + KeySpec( + id="light.white_preset__3", scope="light", key="3", action="white_preset(2)", description="Cool", show=False + ), + KeySpec( + id="light.open_color_picker", + scope="light", + key="p", + action="open_color_picker", + description="Pick Color", + show=False, + ), + KeySpec(id="light.cycle_tab", scope="light", key="t", action="cycle_tab", description="Next Tab"), + KeySpec( + id="light.nav_focus", + scope="light", + key="up", + action="nav_focus(-1)", + description="Focus Up", + show=False, + priority=True, + ), + KeySpec( + id="light.nav_focus__down", + scope="light", + key="down", + action="nav_focus(1)", + description="Focus Down", + show=False, + priority=True, + ), + KeySpec( + id="nav.help", + scope="light", + key="question_mark", + action="show_help", + description="Help", + section="Navigation", + label="Help", + ), + KeySpec( + id="nav.back", + scope="media_player", + key="escape", + action="close", + description="Close", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="media_player.close", scope="media_player", key="q", action="close", description="Close", show=False), + KeySpec( + id="media_player.toggle_play_pause", + scope="media_player", + key="space", + action="toggle_play_pause", + description="Play/Pause", + priority=True, + ), + KeySpec( + id="media_player.stop_playback", + scope="media_player", + key="s", + action="stop_playback", + description="Stop", + show=False, + ), + KeySpec( + id="media_player.nav_focus", + scope="media_player", + key="up", + action="nav_focus(-1)", + description="Focus Up", + show=False, + priority=True, + ), + KeySpec( + id="media_player.nav_focus__down", + scope="media_player", + key="down", + action="nav_focus(1)", + description="Focus Down", + show=False, + priority=True, + ), + KeySpec( + id="nav.help", + scope="media_player", + key="question_mark", + action="show_help", + description="Help", + section="Navigation", + label="Help", + ), + KeySpec( + id="nav.back", + scope="panel_manage", + key="escape", + action="done", + description="Done", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="panel_manage.done", scope="panel_manage", key="q", action="done", description="Done", show=False), + KeySpec(id="panel_manage.move", scope="panel_manage", key="shift+up", action="move(-1)", description="Move up"), + KeySpec( + id="panel_manage.move__shift_down", + scope="panel_manage", + key="shift+down", + action="move(1)", + description="Move down", + ), + KeySpec(id="panel_manage.remove", scope="panel_manage", key="delete", action="remove", description="Remove"), + KeySpec(id="panel_manage.remove__x", scope="panel_manage", key="x", action="remove", description="Remove"), + KeySpec(id="panel_manage.add", scope="panel_manage", key="a", action="add", description="Add"), + KeySpec( + id="dashboard.move_cursor", + scope="dashboard", + key="up", + action="move_cursor(-1, 0)", + description="Up", + show=False, + ), + KeySpec( + id="dashboard.move_cursor__down", + scope="dashboard", + key="down", + action="move_cursor(1, 0)", + description="Down", + show=False, + ), + KeySpec( + id="dashboard.move_cursor__left", + scope="dashboard", + key="left", + action="move_cursor(0, -1)", + description="Left", + show=False, + ), + KeySpec( + id="dashboard.move_cursor__right", + scope="dashboard", + key="right", + action="move_cursor(0, 1)", + description="Right", + show=False, + ), + KeySpec(id="dashboard.toggle_slot", scope="dashboard", key="enter", action="toggle_slot", description="Toggle"), + KeySpec(id="dashboard.expand_slot", scope="dashboard", key="e", action="expand_slot", description="Controls"), + KeySpec(id="dashboard.enter_edit", scope="dashboard", key="E", action="enter_edit", description="Edit"), + KeySpec( + id="dashboard.rename_slot_entity", scope="dashboard", key="r", action="rename_slot_entity", description="Rename" + ), + KeySpec(id="dashboard.edit_slot", scope="dashboard", key="a", action="edit_slot", description="Assign"), + KeySpec(id="dashboard.clear_slot", scope="dashboard", key="delete", action="clear_slot", description="Clear Slot"), + KeySpec(id="dashboard.grab_move", scope="dashboard", key="enter", action="grab_move", description="Move"), + KeySpec( + id="dashboard.resize_slot", + scope="dashboard", + key="ctrl+right", + action="resize_slot(0, 1)", + description="Wider", + show=False, + ), + KeySpec( + id="dashboard.resize_slot__ctrl_left", + scope="dashboard", + key="ctrl+left", + action="resize_slot(0, -1)", + description="Narrower", + show=False, + ), + KeySpec( + id="dashboard.resize_slot__ctrl_down", + scope="dashboard", + key="ctrl+down", + action="resize_slot(1, 0)", + description="Taller", + show=False, + ), + KeySpec( + id="dashboard.resize_slot__ctrl_up", + scope="dashboard", + key="ctrl+up", + action="resize_slot(-1, 0)", + description="Shorter", + show=False, + ), + KeySpec(id="dashboard.split_slot", scope="dashboard", key="s", action="split_slot", description="Split"), + KeySpec( + id="dashboard.unsplit_slot", + scope="dashboard", + key="u", + action="unsplit_slot", + description="Unsplit", + show=False, + ), + KeySpec(id="dashboard.fill_split", scope="dashboard", key="f", action="fill_split", description="Fill"), + KeySpec( + id="log.toggle", + scope="dashboard", + key="a", + action="toggle_activity_log", + description="Activity Log", + show=False, + section="Activity log", + label="Toggle", + ), + KeySpec( + id="log.scope", + scope="dashboard", + key="v", + action="show_log_scope", + description="Log Scope", + show=False, + section="Activity log", + label="Scope", + ), + KeySpec( + id="log.maximize", + scope="dashboard", + key="f", + action="maximize_log", + description="Maximize Log", + show=False, + section="Activity log", + label="Maximize", + ), + KeySpec( + id="dashboard.log_older", + scope="dashboard", + key="left_square_bracket", + action="log_older", + description="Older Events", + show=False, + ), + KeySpec( + id="dashboard.log_newer", + scope="dashboard", + key="right_square_bracket", + action="log_newer", + description="Newer Events", + show=False, + ), + KeySpec( + id="dashboard.show_list_popup", + scope="dashboard", + key="l", + action="show_list_popup", + description="Back to List", + show=False, + ), + KeySpec( + id="dashboard.manage_dashboards", + scope="dashboard", + key="d", + action="manage_dashboards", + description="Dashboards", + ), + KeySpec( + id="nav.device_tree", + scope="dashboard", + key="D", + action="show_device_tree", + description="Device Tree", + section="Navigation", + label="Device Tree", + ), + KeySpec( + id="graph.cycle_type", + scope="dashboard", + key="t", + action="cycle_graph_type", + description="Graph Type", + show=False, + section="Graph", + label="Cycle Type", + ), + KeySpec( + id="graph.fullscreen", + scope="dashboard", + key="G", + action="graph_fullscreen", + description="Full Graph", + section="Graph", + label="Fullscreen", + ), + KeySpec( + id="nav.help", + scope="dashboard", + key="question_mark", + action="show_help", + description="Help", + section="Navigation", + label="Help", + ), + KeySpec( + id="nav.back", + scope="dashboard", + key="escape", + action="go_back", + description="Back", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="dashboard_select.delete_dashboard", + scope="dashboard_select", + key="delete", + action="delete_dashboard", + description="Delete", + ), + KeySpec( + id="dashboard_select.edit_dashboard", + scope="dashboard_select", + key="e", + action="edit_dashboard", + description="Edit", + ), + KeySpec( + id="dashboard_select.set_default", + scope="dashboard_select", + key="d", + action="set_default", + description="Set as Default", + ), + KeySpec( + id="dashboard_select.export_dashboard", + scope="dashboard_select", + key="x", + action="export_dashboard", + description="Export", + ), + KeySpec( + id="dashboard_select.import_dashboard", + scope="dashboard_select", + key="i", + action="import_dashboard", + description="Import", + ), + KeySpec( + id="nav.back", + scope="dashboard_select", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="dashboard_select.cancel", + scope="dashboard_select", + key="q", + action="cancel", + description="Cancel", + show=False, + ), + KeySpec( + id="dashboard_select.move_up", + scope="dashboard_select", + key="shift+up", + action="move_up", + description="Move Up", + priority=True, + ), + KeySpec( + id="dashboard_select.move_down", + scope="dashboard_select", + key="shift+down", + action="move_down", + description="Move Down", + priority=True, + ), + KeySpec( + id="nav.back", + scope="slot_popup", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="slot_popup.cancel", scope="slot_popup", key="q", action="cancel", description="Cancel", show=False), + KeySpec( + id="slot_popup.reorder_selected", + scope="slot_popup", + key="shift+up", + action="reorder_selected(-1)", + description="Move Up", + show=False, + ), + KeySpec( + id="slot_popup.reorder_selected__shift_down", + scope="slot_popup", + key="shift+down", + action="reorder_selected(1)", + description="Move Down", + show=False, + ), + KeySpec( + id="slot_popup.remove_selected", + scope="slot_popup", + key="delete", + action="remove_selected", + description="Remove", + show=False, + ), + KeySpec( + id="slot_popup.nav_focus", + scope="slot_popup", + key="up", + action="nav_focus(-1)", + description="Focus Up", + show=False, + priority=True, + ), + KeySpec( + id="slot_popup.nav_focus__down", + scope="slot_popup", + key="down", + action="nav_focus(1)", + description="Focus Down", + show=False, + priority=True, + ), + KeySpec(id="split_slot.split", scope="split_slot", key="v", action="split('v')", description="Left/Right"), + KeySpec(id="split_slot.split__h", scope="split_slot", key="h", action="split('h')", description="Top/Bottom"), + KeySpec(id="split_slot.split__q", scope="split_slot", key="q", action="split('quad')", description="Quarters"), + KeySpec( + id="nav.back", + scope="split_slot", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="nav.back", + scope="area_name", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="area_name.cancel", scope="area_name", key="q", action="cancel", description="Cancel", show=False), + KeySpec( + id="nav.back", + scope="area_picker", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="area_picker.cancel", scope="area_picker", key="q", action="cancel", description="Cancel", show=False), + KeySpec( + id="nav.back", + scope="device_info", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="device_info.cancel", scope="device_info", key="i", action="cancel", description="Close"), + KeySpec( + id="device_info.cancel__q", scope="device_info", key="q", action="cancel", description="Cancel", show=False + ), + KeySpec( + id="nav.search", + scope="tree", + key="/", + action="toggle_search", + description="Search", + section="Navigation", + label="Search", + ), + KeySpec( + id="entity.expand", + scope="tree", + key="e", + action="expand_entity", + description="Controls", + section="Entities & lists", + label="Controls", + ), + KeySpec( + id="graph.fullscreen", + scope="tree", + key="G", + action="graph_fullscreen", + description="Graph", + section="Graph", + label="Fullscreen", + ), + KeySpec(id="tree.cycle_mode", scope="tree", key="v", action="cycle_mode", description="View"), + KeySpec(id="tree.move_device", scope="tree", key="m", action="move_device", description="Move to Area"), + KeySpec(id="tree.device_info", scope="tree", key="i", action="device_info", description="Info"), + KeySpec(id="tree.create_area", scope="tree", key="a", action="create_area", description="New Area"), + KeySpec(id="tree.rename", scope="tree", key="r", action="rename", description="Rename"), + KeySpec(id="tree.jump_to_list", scope="tree", key="l", action="jump_to_list", description="Lists"), + KeySpec(id="tree.open_dashboard", scope="tree", key="d", action="open_dashboard", description="Dashboard"), + KeySpec( + id="tree.area_to_dashboard", scope="tree", key="n", action="area_to_dashboard", description="New Dashboard" + ), + KeySpec(id="tree.collapse_all", scope="tree", key="x", action="collapse_all", description="Collapse All"), + KeySpec(id="tree.expand_all", scope="tree", key="X", action="expand_all", description="Expand All"), + KeySpec( + id="nav.help", + scope="tree", + key="question_mark", + action="show_help", + description="Help", + section="Navigation", + label="Help", + ), + KeySpec( + id="entity.toggle_list_membership", + scope="tree", + key="space", + action="toggle_list_membership", + description="List", + priority=True, + section="Entities & lists", + label="In List", + ), + KeySpec( + id="tree.cycle_scope", scope="tree", key="ctrl+s", action="cycle_scope", description="Scope", priority=True + ), + KeySpec( + id="nav.back", + scope="tree", + key="escape", + action="go_back", + description="Back", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="nav.back", + scope="graph_color", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="graph_color.cancel", scope="graph_color", key="q", action="cancel", description="Cancel", show=False), + KeySpec( + id="nav.back", + scope="graph_duration", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="graph_duration.cancel", scope="graph_duration", key="q", action="cancel", description="Cancel", show=False + ), + KeySpec( + id="graph_duration.confirm", + scope="graph_duration", + key="enter", + action="confirm", + description="Select", + priority=True, + ), + KeySpec(id="graph.cycle_plot_type", scope="graph", key="t", action="cycle_plot_type", description="Graph Type"), + KeySpec(id="graph.scroll_back", scope="graph", key="left", action="scroll_back", description="Older"), + KeySpec(id="graph.cursor_prev", scope="graph", key="left", action="cursor_prev", description="Prev Sample"), + KeySpec(id="graph.scroll_forward", scope="graph", key="right", action="scroll_forward", description="Newer"), + KeySpec(id="graph.cursor_next", scope="graph", key="right", action="cursor_next", description="Next Sample"), + KeySpec( + id="graph.scroll_back_fast", + scope="graph", + key="shift+left", + action="scroll_back_fast", + description=f"Older ×{FAST_PAGE_MULTIPLIER}", + ), + KeySpec( + id="graph.cursor_prev_fast", + scope="graph", + key="shift+left", + action="cursor_prev_fast", + description="Prev Sample ×10%", + ), + KeySpec( + id="graph.scroll_forward_fast", + scope="graph", + key="shift+right", + action="scroll_forward_fast", + description=f"Newer ×{FAST_PAGE_MULTIPLIER}", + ), + KeySpec( + id="graph.cursor_next_fast", + scope="graph", + key="shift+right", + action="cursor_next_fast", + description="Next Sample ×10%", + ), + KeySpec(id="graph.zoom_in", scope="graph", key="plus", action="zoom_in", description="Zoom In"), + KeySpec(id="graph.zoom_out", scope="graph", key="minus", action="zoom_out", description="Zoom Out"), + KeySpec(id="graph.snap_live", scope="graph", key="home", action="snap_live", description="Now"), + KeySpec(id="graph.cursor_home", scope="graph", key="home", action="cursor_home", description="Oldest Sample"), + KeySpec(id="graph.cursor_end", scope="graph", key="end", action="cursor_end", description="Newest Sample"), + KeySpec( + id="graph.toggle_cursor_mode", scope="graph", key="enter", action="toggle_cursor_mode", description="Inspect" + ), + KeySpec( + id="graph.exit_cursor_mode", scope="graph", key="enter", action="exit_cursor_mode", description="Exit Inspect" + ), + KeySpec(id="graph.save_graph", scope="graph", key="S", action="save_graph", description="Save As"), + KeySpec(id="graph.update_graph", scope="graph", key="u", action="update_graph", description="Update"), + KeySpec( + id="graph.next_entity", scope="graph", key="tab", action="next_entity", description="Next Line", show=False + ), + KeySpec(id="graph.cycle_color", scope="graph", key="c", action="cycle_color", description="Color"), + KeySpec(id="graph.pick_color", scope="graph", key="C", action="pick_color", description="Color Picker"), + KeySpec( + id="graph.show_list_popup", + scope="graph", + key="l", + action="show_list_popup", + description="Back to List", + show=False, + ), + KeySpec( + id="log.toggle", + scope="graph", + key="a", + action="toggle_event_log", + description="Activity Log", + section="Activity log", + label="Toggle", + ), + KeySpec( + id="log.scope", + scope="graph", + key="v", + action="show_log_scope", + description="Log View", + section="Activity log", + label="Scope", + ), + KeySpec( + id="log.maximize", + scope="graph", + key="f", + action="maximize_log", + description="Maximize Log", + show=False, + section="Activity log", + label="Maximize", + ), + KeySpec(id="graph.show_help", scope="graph", key="question_mark", action="show_help", description="Help"), + KeySpec( + id="nav.back", + scope="graph", + key="escape", + action="exit_cursor_mode", + description="Exit Inspect", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="nav.back", + scope="graph", + key="escape", + action="close_event_log", + description="Close Log", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="nav.back", + scope="graph", + key="escape", + action="go_back", + description="Back", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="graph.exit_cursor_mode__q", + scope="graph", + key="q", + action="exit_cursor_mode", + description="Exit Inspect", + show=False, + ), + KeySpec( + id="graph.close_event_log", + scope="graph", + key="q", + action="close_event_log", + description="Close Log", + show=False, + ), + KeySpec(id="graph.go_back", scope="graph", key="q", action="go_back", description="Back", show=False), + KeySpec( + id="nav.back", + scope="save_graph_name", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="save_graph_name.cancel", scope="save_graph_name", key="q", action="cancel", description="Cancel", show=False + ), + KeySpec( + id="saved_graphs_popup.rename_graph", + scope="saved_graphs_popup", + key="r", + action="rename_graph", + description="Rename", + ), + KeySpec( + id="saved_graphs_popup.delete_graph", + scope="saved_graphs_popup", + key="delete", + action="delete_graph", + description="Delete", + ), + KeySpec( + id="nav.back", + scope="saved_graphs_popup", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="saved_graphs_popup.cancel", + scope="saved_graphs_popup", + key="q", + action="cancel", + description="Cancel", + show=False, + ), + KeySpec(id="help_popup.prev_page", scope="help_popup", key="left", action="prev_page", description="Prev Page"), + KeySpec(id="help_popup.next_page", scope="help_popup", key="right", action="next_page", description="Next Page"), + KeySpec(id="help_popup.focus_filter", scope="help_popup", key="/", action="focus_filter", description="Search"), + KeySpec(id="help_popup.toggle_all", scope="help_popup", key="a", action="toggle_all", description="Show All"), + KeySpec( + id="nav.back", + scope="help_popup", + key="escape", + action="dismiss", + description="Close", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="help_popup.dismiss", + scope="help_popup", + key="question_mark", + action="dismiss", + description="Close", + show=False, + ), + KeySpec(id="help_popup.dismiss__q", scope="help_popup", key="q", action="dismiss", description="Close", show=False), + KeySpec( + id="list_popup.delete_list", scope="list_popup", key="delete", action="delete_list", description="Delete List" + ), + KeySpec(id="list_popup.rename_list", scope="list_popup", key="r", action="rename_list", description="Rename"), + KeySpec( + id="list_popup.set_default", scope="list_popup", key="d", action="set_default", description="Set as Default" + ), + KeySpec(id="list_popup.toggle_notify", scope="list_popup", key="n", action="toggle_notify", description="Notify"), + KeySpec( + id="list_popup.view_as_dashboard", + scope="list_popup", + key="v", + action="view_as_dashboard", + description="View as Dashboard", + ), + KeySpec( + id="nav.back", + scope="list_popup", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="list_popup.cancel", scope="list_popup", key="q", action="cancel", description="Cancel", show=False), + KeySpec( + id="nav.search", + scope="list_popup", + key="/", + action="toggle_search", + description="Search", + section="Navigation", + label="Search", + ), + KeySpec( + id="list_popup.move_up", + scope="list_popup", + key="shift+up", + action="move_up", + description="Move Up", + priority=True, + ), + KeySpec( + id="list_popup.move_down", + scope="list_popup", + key="shift+down", + action="move_down", + description="Move Down", + priority=True, + ), + KeySpec( + id="nav.back", + scope="log_scope_popup", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="log_scope_popup.cancel", scope="log_scope_popup", key="q", action="cancel", description="Cancel", show=False + ), + KeySpec( + id="onboarding.test_connection", + scope="onboarding", + key="ctrl+t", + action="test_connection", + description="Test Connection", + ), + KeySpec(id="onboarding.save", scope="onboarding", key="ctrl+s", action="save", description="Save & Connect"), + KeySpec( + id="onboarding.toggle_token", + scope="onboarding", + key="ctrl+v", + action="toggle_token", + description="Show/Hide Token", + ), + KeySpec( + id="nav.back", + scope="onboarding", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec( + id="nav.back", + scope="rename_popup", + key="escape", + action="cancel", + description="Cancel", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="rename_popup.cancel", scope="rename_popup", key="q", action="cancel", description="Cancel", show=False), + KeySpec( + id="rename_popup.save_local", scope="rename_popup", key="enter", action="save_local", description="Save Locally" + ), + KeySpec( + id="search_input.toggle_mode", + scope="search_input", + key="tab", + action="toggle_mode", + description="Toggle filter/jump", + show=False, + ), + KeySpec( + id="nav.back", + scope="weather", + key="escape", + action="go_back", + description="Back", + section="Navigation", + label="Back / Cancel", + ), + KeySpec(id="weather.cycle_type", scope="weather", key="t", action="cycle_type", description="Switch type"), + KeySpec( + id="nav.help", + scope="weather", + key="question_mark", + action="show_help", + description="Help", + section="Navigation", + label="Help", + ), +) + +SCOPES: tuple[str, ...] = tuple(dict.fromkeys(spec.scope for spec in REGISTRY)) + +BY_SCOPE: dict[str, tuple[KeySpec, ...]] = { + scope: tuple(spec for spec in REGISTRY if spec.scope == scope) for scope in SCOPES +} + +_by_id: dict[str, list[KeySpec]] = {} +for _spec in REGISTRY: + _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. +TWINS: dict[str, frozenset[str]] = {} +for _scope_specs in BY_SCOPE.values(): + _by_key: dict[str, set[str]] = {} + for _spec in _scope_specs: + _by_key.setdefault(_spec.key, set()).add(_spec.id) + for _ids in _by_key.values(): + if len(_ids) > 1: + for _id in _ids: + TWINS[_id] = TWINS.get(_id, frozenset()) | (_ids - {_id}) + + +def _binding_list(*scopes: str) -> list[Binding]: + return [ + Binding(spec.key, spec.action, spec.description, show=spec.show, priority=spec.priority, id=spec.id) + for scope in scopes + for spec in BY_SCOPE.get(scope, ()) + ] + + +def bindings_for(*scopes: str) -> list[BindingType]: + """The default (unrebound) `Binding` list for one or more scopes, in + registry order — the replacement for a screen's old literal `BINDINGS`. + Rebinding is applied later, per-node, by `App.set_keymap` (see + `KeybindingController.apply`); this always returns defaults. Return type + is `list[BindingType]` (not `list[Binding]`) only so it matches + `DOMNode.BINDINGS`'s own invariant `list[BindingType]` declaration and + `BINDINGS = bindings_for(...)` type-checks — every element actually + constructed is a `Binding` (see `_binding_list`, used internally where + that concrete type matters).""" + return cast(list[BindingType], _binding_list(*scopes)) + + +def display_key(key: str) -> str: + """Friendly display text for a raw Textual key string, e.g. "escape" -> + "Esc". Lazily imports help_popup — the one place this module reaches past + const.py, since it's never needed at the class-definition time that makes + the rest of this module import-cycle-sensitive (see module docstring).""" + from hatty.ui.help_popup import display_key as _display_key + + return _display_key(key) + + +def sanitize(overrides: object) -> dict[str, str]: + """Drop anything from a raw (possibly hand-edited) config value that isn't + a valid, non-reserved override for a known id, and drop no-op overrides + that just restate the default — so a stale/corrupt YAML can never break + startup, and "keybindings:" in config.yaml only ever lists real + customizations.""" + if not isinstance(overrides, dict): + return {} + clean: dict[str, str] = {} + for spec_id, key in overrides.items(): + if not isinstance(spec_id, str) or spec_id not in BY_ID: + continue + if not isinstance(key, str) or not key or key in RESERVED_KEYS: + continue + if key == BY_ID[spec_id][0].key: + continue + clean[spec_id] = key + return clean + + +def resolve_keymap(overrides: dict[str, str]) -> dict[str, str]: + """The complete id -> key mapping: every registered id, override or + default. Must be complete, never a delta (gotcha #1 in the module + docstring) — this is what gets handed to `App.set_keymap`.""" + return {spec_id: overrides.get(spec_id, specs[0].key) for spec_id, specs in BY_ID.items()} + + +def validate(spec_id: str, key: str, overrides: dict[str, str]) -> str | None: + """None if `key` is free to become spec_id's binding under `overrides`; + otherwise an error naming the action that already owns it in a scope + spec_id also occupies. Pure — the config screen calls this against its own + uncommitted working copy of overrides before accepting a capture.""" + if spec_id not in BY_ID: + raise KeyError(spec_id) + if key in RESERVED_KEYS: + return f"{display_key(key)} is reserved" + resolved = resolve_keymap({**overrides, spec_id: key}) + target_scopes = {spec.scope for spec in BY_ID[spec_id]} + twins = TWINS.get(spec_id, frozenset()) + for scope in target_scopes: + for spec in BY_SCOPE[scope]: + if spec.id == spec_id or spec.id in twins: + continue + if resolved[spec.id] == key: + return f"{display_key(key)} is already used by {spec.label or spec.description}" + return None + + +def rebindable() -> list[tuple[str, list[KeySpec]]]: + """Curated ids grouped by section (Navigation / Entities & lists / + Activity log / Graph), one representative KeySpec per id — the listing for + the config screen's Keybindings category.""" + seen: set[str] = set() + buckets: dict[str, list[KeySpec]] = {section: [] for section in SECTION_ORDER} + for spec in REGISTRY: + if spec.section is None or spec.id in seen: + continue + seen.add(spec.id) + buckets[spec.section].append(spec) + return [(section, buckets[section]) for section in SECTION_ORDER if buckets[section]] + + +class KeybindingController: + """Owns the live keybinding overrides and pushes the resulting keymap onto + the running App. `apply()` is called from HACLI._apply_config (boot, demo, + and the post-onboarding restart) and again from _on_config_saved so a + rebind in the config screen's Keybindings category takes effect without a + restart.""" + + def __init__(self, app) -> None: + self._app = app + self.overrides: dict[str, str] = {} + + def apply(self, cfg: dict) -> None: + """Sanitize cfg[keybindings], store the result, and push the keymap + onto the app. Also normalizes cfg in place so a save right afterwards + writes back only the sanitized overrides.""" + self.overrides = sanitize(cfg.get(CONFIG_KEY_KEYBINDINGS) or {}) + cfg[CONFIG_KEY_KEYBINDINGS] = dict(self.overrides) + self._app.set_keymap(resolve_keymap(self.overrides)) + + def key_for(self, spec_id: str) -> str: + specs = BY_ID.get(spec_id) + if not specs: + raise KeyError(spec_id) + return self.overrides.get(spec_id, specs[0].key) + + def display(self, spec_id: str) -> str: + return display_key(self.key_for(spec_id)) + + def static_bindings(self, scope: str) -> list[Binding]: + """`bindings_for(scope)` with the live keymap applied — for the help + screen's pages for a screen that isn't the currently active one, whose + `active_bindings` Textual can't give us directly.""" + keymap = resolve_keymap(self.overrides) + return [binding.with_key(keymap[binding.id]) if binding.id else binding for binding in _binding_list(scope)] diff --git a/src/hatty/main.py b/src/hatty/main.py index 6585baa..88e81b3 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -5,6 +5,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.coordinate import Coordinate +from textual.dom import DOMNode from textual.timer import Timer from textual.widgets import DataTable, Footer, Header @@ -41,6 +42,7 @@ from hatty.controllers.connection import ConnectionController from hatty.controllers.dashboards import DashboardController from hatty.controllers.graphs import GraphController, _trim_history # noqa: F401 (_trim_history re-exported for tests) +from hatty.controllers.keybindings import KeybindingController, bindings_for from hatty.controllers.lists import ListController from hatty.controllers.logbook import LogbookController, LogScopeOption from hatty.controllers.notifications import NotificationController @@ -88,39 +90,7 @@ class HACLI(App): PENDING_TIMEOUT_SECONDS = 10 # class attribute so tests can override it per-instance - BINDINGS = [ - Binding("/", "toggle_search", "Search"), - Binding("e", "expand_entity", "Controls"), - Binding("space", "toggle_list_membership", "In List"), - Binding("shift+up", "move_entity_in_list(-1)", "Move Up", show=False), - Binding("shift+down", "move_entity_in_list(1)", "Move Down", show=False), - Binding("o", "toggle_list_sort", "Sort Order", show=False), - Binding("L", "toggle_list_lock", "Lock List", show=False), - Binding("r", "rename_entity", "Rename", show=False), - Binding("u", "undo", "Undo", show=False), - Binding("ctrl+r", "redo", "Redo", show=False), - Binding("l", "show_list_selection_popup", "Lists", show=False), - Binding("c", "show_column_config", "Columns", show=False), - Binding("a", "toggle_activity_log", "Activity Log", show=False), - Binding("i", "toggle_entity_log", "Entity Log", show=False), - Binding("v", "show_log_scope", "Log Scope", show=False), - Binding("f", "maximize_log", "Maximize Log", show=False), - Binding("left", "log_older", "Older Events", show=False, priority=True), - Binding("right", "log_newer", "Newer Events", show=False, priority=True), - Binding("g", "toggle_graph", "Graph", show=False), - Binding("G", "graph_fullscreen", "Full Graph", show=False), - Binding("+", "add_to_graph", "Compare", show=False), - Binding("d", "show_dashboard", "Dashboard", show=False), - Binding("D", "show_device_tree", "Device Tree", show=False), - Binding("s", "show_saved_graphs_popup", "Saved Graphs", show=False), - Binding("t", "cycle_graph_type", "Graph Type"), - Binding("T", "show_graph_duration", "Duration", show=False), - Binding("n", "search_next", "Next Match", show=False), - Binding("N", "search_prev", "Prev Match", show=False), - Binding("question_mark", "show_help", "Help"), - Binding("escape", "go_back", "Back/Clear"), - Binding("ctrl+q", "quit", "Quit", show=False), - ] + BINDINGS = bindings_for("app") def __init__(self, config_path: str | None = None, demo: bool = False): super().__init__() @@ -143,6 +113,7 @@ def __init__(self, config_path: str | None = None, demo: bool = False): self.conn_ctl = ConnectionController(self) self.notify_ctl = NotificationController(self) self.log_ctl = LogbookController(self) + self.keys_ctl = KeybindingController(self) self.all_entities: list = [] self.entity_registry: list = [] @@ -344,6 +315,7 @@ def _apply_config(self, cfg: dict) -> None: self.theme = saved_theme self._apply_terminal_title(cfg) + self.keys_ctl.apply(cfg) self.query_one("#detail_panel", EntityDetailPanel).apply_saved_graph_type(cfg.get(CONFIG_KEY_GRAPH_TYPE)) @@ -627,6 +599,18 @@ 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. + scope_of = { + None: "app", + DashboardScreen: "dashboard", + DeviceTreeScreen: "tree", + GraphPreviewScreen: "graph", + LightControlScreen: "light", + MediaPlayerControlScreen: "media_player", + } + def active_entries() -> list[tuple[str, str, str]]: return [ (active.binding.key, active.binding.description, action_name(active.binding.action)) @@ -640,9 +624,15 @@ def page_rows(screen_cls: type | None, is_active: bool) -> list[tuple[str, str]] # regardless of which mode is active — its help page groups both modes' # bindings side by side instead of only showing whichever is live (#7). if screen_cls is not None and getattr(screen_cls, "HELP_ALL_MODES", False): - rows = sectioned_rows(binding_entries(screen_cls.BINDINGS), screen_cls.HELP_SECTIONS) + rows = sectioned_rows( + binding_entries(self.keys_ctl.static_bindings(scope_of[screen_cls])), screen_cls.HELP_SECTIONS + ) allowed = screen_cls.ALLOWED_APP_ACTIONS - app_rows = [(key, desc) for key, desc, action in binding_entries(self.BINDINGS) if action in allowed] + app_rows = [ + (key, desc) + for key, desc, action in binding_entries(self.keys_ctl.static_bindings("app")) + if action in allowed + ] if app_rows: rows = [*rows, ("", "From anywhere"), *app_rows] return rows @@ -650,7 +640,7 @@ def page_rows(screen_cls: type | None, is_active: bool) -> list[tuple[str, str]] if is_active: entries = active_entries() else: - entries = binding_entries(self.BINDINGS if screen_cls is None else screen_cls.BINDINGS) + entries = binding_entries(self.keys_ctl.static_bindings(scope_of[screen_cls])) sections = getattr(screen_cls, "HELP_SECTIONS", None) if screen_cls is not None else None if sections: @@ -816,8 +806,23 @@ def action_maximize_log(self) -> None: if not maximizing: self.query_one("#entities_table", EntitiesTable).focus() - _LOG_HINT = "v scope · f maximize · ←/→ older/newer · T timeframe · a/i close" - _LOG_HINT_MAXIMIZED = "↑/↓ select · f exit · ←/→ older/newer · T timeframe" + @property + def _LOG_HINT(self) -> str: + d = self.keys_ctl.display + return ( + f"{d('log.scope')} scope · {d('log.maximize')} maximize · " + f"{d('log.older')}/{d('log.newer')} older/newer · {d('graph.duration')} timeframe · " + f"{d('log.toggle')}/{d('log.entity')} close" + ) + + @property + def _LOG_HINT_MAXIMIZED(self) -> str: + d = self.keys_ctl.display + return ( + f"↑/↓ select · {d('log.maximize')} exit · " + f"{d('log.older')}/{d('log.newer')} older/newer · {d('graph.duration')} timeframe" + ) + # Coalesces held arrow-key repeats before a cursor-scoped log refetches + resubscribes. _LOG_CURSOR_DEBOUNCE = 0.3 @@ -1114,6 +1119,16 @@ async def _save_config_async(self) -> None: self.log.error(f"Error saving collections to storage: {e}") self.notify(f"Error saving data: {e}", title="Save Error", severity="error") + def handle_bindings_clash(self, clashed_bindings: set[Binding], node: DOMNode) -> None: + """No-op override. Several ids in controllers/keybindings.py's REGISTRY + deliberately share one binding id across multiple rows on the same + screen (e.g. `nav.back`'s three GraphPreviewScreen `escape` rows) so + they move together on rebind — Textual's `apply_keymap` reports that + as a `clashed_bindings` false positive even when nothing is actually + conflicting (see keybindings.py's module docstring, gotcha #2). Real + conflicts are caught earlier, in `keybindings.validate()`, when the + config screen's Keybindings category takes the new key.""" + # ── Navigation / back ──────────────────────────────────────────────────── def check_action(self, action: str, parameters: tuple) -> bool | None: @@ -1561,6 +1576,7 @@ def _on_config_saved(self, result: dict | None) -> None: self.app_config = result self.columns = result.get(CONFIG_KEY_COLUMNS, list(DEFAULT_COLUMNS)) self.entity_names = result.get(CONFIG_KEY_ENTITY_NAMES, {}) + self.keys_ctl.apply(result) self.set_title_based_on_focused_ui() new_graph_type = result.get(CONFIG_KEY_GRAPH_TYPE) self.query_one("#detail_panel", EntityDetailPanel).apply_saved_graph_type(new_graph_type) diff --git a/src/hatty/ui/activity_log_panel.py b/src/hatty/ui/activity_log_panel.py index a56fac8..43dfdfc 100644 --- a/src/hatty/ui/activity_log_panel.py +++ b/src/hatty/ui/activity_log_panel.py @@ -57,6 +57,7 @@ from collections import deque +from rich.markup import escape from textual import events from textual.app import ComposeResult from textual.containers import Vertical, VerticalScroll @@ -182,7 +183,11 @@ def set_title(self, text: str) -> None: self.query_one("#log_title", Label).update(text) def set_hint(self, text: str) -> None: - self.query_one("#log_hint", Label).update(text) + # 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. "[" + "/" + "]" -> "[/]"). + self.query_one("#log_hint", Label).update(escape(text)) @property def title_text(self) -> str: diff --git a/src/hatty/ui/column_config_popup.py b/src/hatty/ui/column_config_popup.py index 223df5c..47752de 100644 --- a/src/hatty/ui/column_config_popup.py +++ b/src/hatty/ui/column_config_popup.py @@ -1,10 +1,10 @@ # hatty — MIT License. See LICENSE file for details. from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container from textual.widgets import Footer, Label, SelectionList from textual.widgets.selection_list import Selection +from hatty.controllers.keybindings import bindings_for from hatty.ui.entity_table import COLUMNS from hatty.ui.popup_base import PopupScreen @@ -12,13 +12,7 @@ class ColumnConfigPopup(PopupScreen): AUTO_FOCUS = "#column_selection" - BINDINGS = [ - ("escape", "save_and_close", "Save & Close"), - Binding("q", "save_and_close", "Save & Close", show=False), - Binding("enter", "save_and_close", "Save & Close", priority=True), - Binding("shift+up", "move_up", "Move Up", priority=True), - Binding("shift+down", "move_down", "Move Down", priority=True), - ] + BINDINGS = bindings_for("column_config") DEFAULT_CSS = """ #column_config_container Label { diff --git a/src/hatty/ui/config_screen.py b/src/hatty/ui/config_screen.py index d98b30a..fc56c5f 100644 --- a/src/hatty/ui/config_screen.py +++ b/src/hatty/ui/config_screen.py @@ -11,6 +11,7 @@ from textual.widgets import ( Button, ContentSwitcher, + DataTable, Footer, Header, Input, @@ -33,6 +34,7 @@ CONFIG_KEY_GRAPH_HOURS, CONFIG_KEY_GRAPH_TYPE, CONFIG_KEY_HOME_ASSISTANT, + CONFIG_KEY_KEYBINDINGS, CONFIG_KEY_LISTS, CONFIG_KEY_NOTIFICATIONS, CONFIG_KEY_SAVED_GRAPHS, @@ -45,8 +47,10 @@ DEFAULT_NOTIFICATIONS, DEFAULT_TERMINAL_TITLE, ) +from hatty.controllers import keybindings as keybindings_module from hatty.controllers.notifications import send_test_ntfy from hatty.ui.entity_table import COLUMNS +from hatty.ui.key_capture_popup import KeyCapturePopup if TYPE_CHECKING: from hatty.main import HACLI @@ -85,8 +89,13 @@ ("Appearance", "cat_appearance", "Theme, graph defaults, visible columns", "#cfg_theme"), ("Notifications", "cat_notifications", "Toast/beep/desktop/ntfy alerts", "#cfg_notify"), ("Data & Collections", "cat_data", "Lists, name overrides, dashboards, saved graphs", "#cat_data"), + ("Keybindings", "cat_keybindings", "Rebind keys for navigation, lists, log and graph", "#cfg_keys"), ] +# Row key prefix for a non-interactive section-header row in #cfg_keys, so +# on_data_table_row_selected can tell it apart from a real rebindable id. +_KEYS_SECTION_ROW_PREFIX = "section:" + class ConfigScreen(Screen): app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect @@ -185,6 +194,12 @@ def __init__(self, raw_config: dict, config_path: str | None): self._raw_config = raw_config 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. + self._keybindings: dict[str, str] = dict(keybindings_module.sanitize(raw_config.get(CONFIG_KEY_KEYBINDINGS))) @staticmethod def _lists_summary(lists: dict) -> str | Table: @@ -350,8 +365,63 @@ def compose(self) -> ComposeResult: yield Static("No saved graphs defined.", classes="read-only-note") yield Label("Use [bold]s[/bold] to manage saved graphs", classes="read-only-note", markup=True) + with VerticalScroll(id="cat_keybindings", classes="config-pane"): + yield Label( + "Select a row and press the new key. Bold keys differ from their default.", + classes="read-only-note", + ) + yield DataTable(id="cfg_keys", cursor_type="row") + yield Button("Reset all to defaults", id="cfg_keys_reset") + yield Footer() + def on_mount(self) -> None: + self._populate_keybindings_table() + + def _populate_keybindings_table(self) -> None: + """(Re)render #cfg_keys from self._keybindings — called on mount and + after every capture/reset so the "Key" column always reflects the + working (unsaved) overrides.""" + table = self.query_one("#cfg_keys", DataTable) + table.clear(columns=True) + table.add_columns("Action", "Key", "Default") + for section, specs in keybindings_module.rebindable(): + table.add_row(f"[bold]{section}[/bold]", "", "", key=f"{_KEYS_SECTION_ROW_PREFIX}{section}") + for spec in specs: + current = self._keybindings.get(spec.id, spec.key) + current_text = keybindings_module.display_key(current) + if current != spec.key: + current_text = f"[bold]{current_text}[/bold]" + table.add_row( + spec.label or spec.description, + current_text, + keybindings_module.display_key(spec.key), + key=spec.id, + ) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if event.data_table.id != "cfg_keys": + return + spec_id = event.row_key.value + if spec_id is None or spec_id.startswith(_KEYS_SECTION_ROW_PREFIX): + return + spec = keybindings_module.BY_ID[spec_id][0] + current = self._keybindings.get(spec_id, spec.key) + self.app.push_screen( + KeyCapturePopup(spec, current, self._keybindings), + lambda new_key, spec_id=spec_id: self._on_key_captured(spec_id, new_key), + ) + + def _on_key_captured(self, spec_id: str, new_key: str | None) -> None: + if new_key is None: + return + default_key = keybindings_module.BY_ID[spec_id][0].key + if new_key == default_key: + self._keybindings.pop(spec_id, None) + else: + self._keybindings[spec_id] = new_key + self._populate_keybindings_table() + def show_category(self, pane_id: str) -> None: """Drill into a category pane ("cat_menu" to go back to the top-level menu), updating the breadcrumb and focusing that pane's primary field.""" @@ -389,6 +459,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.action_stop_watching_all() elif event.button.id == "cfg_ntfy_test": self.action_test_ntfy() + elif event.button.id == "cfg_keys_reset": + self._keybindings = {} + self._populate_keybindings_table() def action_stop_watching_all(self) -> None: # A live write (like the l/d/s popups edit their own collections directly) @@ -488,6 +561,7 @@ def action_save_and_close(self) -> None: new_config[CONFIG_KEY_TERMINAL_TITLE] = title_text new_config[CONFIG_KEY_COLUMNS] = columns if columns else existing new_config[CONFIG_KEY_NOTIFICATIONS] = notifications + new_config[CONFIG_KEY_KEYBINDINGS] = dict(self._keybindings) # Collections live in SQLite; this screen only edits connection settings + # display preferences, so keep them out of the lean YAML it writes. The diff --git a/src/hatty/ui/confirm_popup.py b/src/hatty/ui/confirm_popup.py index bc486bc..c4f107b 100644 --- a/src/hatty/ui/confirm_popup.py +++ b/src/hatty/ui/confirm_popup.py @@ -1,19 +1,21 @@ # hatty — MIT License. See LICENSE file for details. +from typing import TYPE_CHECKING + from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container from textual.widgets import Footer, Label +from hatty.controllers.keybindings import bindings_for from hatty.ui.popup_base import PopupScreen +if TYPE_CHECKING: + from hatty.main import HACLI + class ConfirmPopup(PopupScreen[bool]): - BINDINGS = [ - ("y", "confirm", "Yes"), - ("n", "cancel", "No"), - ("escape", "cancel", "No"), - Binding("q", "cancel", "No", show=False), - ] + app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect + + BINDINGS = bindings_for("confirm") DEFAULT_CSS = """ #confirm_container { @@ -34,7 +36,8 @@ def __init__(self, message: str): def compose(self) -> ComposeResult: with Container(id="confirm_container", classes="popup-container"): yield Label(self._message, id="confirm_message") - yield Label("[y] Yes [n / Esc] No", id="confirm_hint") + back = self.app.keys_ctl.display("nav.back") + yield Label(f"[y] Yes [n / {back}] No", id="confirm_hint") yield Footer() def action_confirm(self) -> None: diff --git a/src/hatty/ui/controls/control_popup.py b/src/hatty/ui/controls/control_popup.py index e8fc43c..dc89ca7 100644 --- a/src/hatty/ui/controls/control_popup.py +++ b/src/hatty/ui/controls/control_popup.py @@ -3,11 +3,11 @@ from textual import events from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Horizontal, VerticalScroll from textual.widgets import Button, Footer, Input, Label, Select from hatty.const import NUMERIC_INPUT_TYPES +from hatty.controllers.keybindings import bindings_for if TYPE_CHECKING: from textual.widgets._input import InputType @@ -19,11 +19,7 @@ class EntityControlPopup(PopupScreen): - BINDINGS = [ - Binding("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - Binding("enter", "save", "Save"), - ] + BINDINGS = bindings_for("control_popup") DEFAULT_CSS = """ #control_container { diff --git a/src/hatty/ui/controls/entity_picker_modal.py b/src/hatty/ui/controls/entity_picker_modal.py index 4e01cc5..418eff6 100644 --- a/src/hatty/ui/controls/entity_picker_modal.py +++ b/src/hatty/ui/controls/entity_picker_modal.py @@ -1,9 +1,9 @@ # hatty — MIT License. See LICENSE file for details. from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container from textual.widgets import DataTable, Footer, Label +from hatty.controllers.keybindings import bindings_for from hatty.ui.entity_table import EntitiesTable, entity_matches from hatty.ui.popup_base import PopupScreen from hatty.ui.search_input import SearchInput @@ -16,10 +16,7 @@ class EntityPickerModal(PopupScreen): EntitiesTable (same widget as the main list) is reused for its row rendering. """ - BINDINGS = [ - ("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - ] + BINDINGS = bindings_for("entity_picker") DEFAULT_CSS = """ #entity_picker_container { diff --git a/src/hatty/ui/controls/light_screen.py b/src/hatty/ui/controls/light_screen.py index 7a0784f..9c29e86 100644 --- a/src/hatty/ui/controls/light_screen.py +++ b/src/hatty/ui/controls/light_screen.py @@ -26,7 +26,6 @@ from textual import events from textual.app import ComposeResult -from textual.binding import Binding from textual.color import Color from textual.containers import Container, Horizontal, VerticalScroll from textual.screen import ModalScreen @@ -35,6 +34,7 @@ from textual.widgets.option_list import Option from textual_colorpicker import ColorPicker +from hatty.controllers.keybindings import bindings_for from hatty.ui.controls.kelvin_slider import KelvinSlider from hatty.ui.controls.percentage_slider import PercentageSlider from hatty.ui.entity_table import get_display_name @@ -89,10 +89,7 @@ def hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]: class ColorPickerModal(PopupScreen): - BINDINGS = [ - Binding("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - ] + BINDINGS = bindings_for("color_picker") # Centering + the panel box come from PopupScreen's `.popup-container`; only the # auto width (to fit the ColorPicker) and the button row differ. @@ -135,27 +132,14 @@ class LightControlScreen(ModalScreen): app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect - BINDINGS = [ - Binding("escape", "close", "Close"), - Binding("q", "close", "Close", show=False), - # priority so a focused Button doesn't swallow space (buttons still work via enter); - # check_action releases it while the effect filter Input is focused. - Binding("space", "toggle_power", "On/Off", priority=True), - Binding("1", "white_preset(0)", "Warm", show=False), - Binding("2", "white_preset(1)", "Neutral", show=False), - Binding("3", "white_preset(2)", "Cool", show=False), - Binding("p", "open_color_picker", "Pick Color", show=False), - # One key cycles White/Color/Effects; check_action releases it while - # typing in the effect filter (issue #88). - Binding("t", "cycle_tab", "Next Tab"), - # Priority so up/down 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. - Binding("up", "nav_focus(-1)", "Focus Up", show=False, priority=True), - Binding("down", "nav_focus(1)", "Focus Down", show=False, priority=True), - Binding("question_mark", "show_help", "Help"), - ] + # 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. + BINDINGS = bindings_for("light") DEFAULT_CSS = """ LightControlScreen { diff --git a/src/hatty/ui/controls/media_player_screen.py b/src/hatty/ui/controls/media_player_screen.py index dceff2e..0fd2c3f 100644 --- a/src/hatty/ui/controls/media_player_screen.py +++ b/src/hatty/ui/controls/media_player_screen.py @@ -25,13 +25,13 @@ from textual import events from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Horizontal, VerticalScroll from textual.screen import ModalScreen from textual.timer import Timer from textual.widgets import Button, Footer, Label, OptionList, Select, Static from hatty.const import media_supports +from hatty.controllers.keybindings import bindings_for from hatty.ui.controls.light_screen import DEBOUNCE_SECONDS from hatty.ui.controls.percentage_slider import PercentageSlider from hatty.ui.entity_table import get_display_name @@ -62,20 +62,13 @@ class MediaPlayerControlScreen(ModalScreen): app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect - BINDINGS = [ - Binding("escape", "close", "Close"), - Binding("q", "close", "Close", show=False), - # priority so a focused Button doesn't swallow space (buttons still work via enter). - Binding("space", "toggle_play_pause", "Play/Pause", priority=True), - Binding("s", "stop_playback", "Stop", show=False), - # Priority so up/down 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. - Binding("up", "nav_focus(-1)", "Focus Up", show=False, priority=True), - Binding("down", "nav_focus(1)", "Focus Down", show=False, priority=True), - Binding("question_mark", "show_help", "Help"), - ] + # 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. + BINDINGS = bindings_for("media_player") DEFAULT_CSS = """ MediaPlayerControlScreen { diff --git a/src/hatty/ui/dashboard/panel_manage_popup.py b/src/hatty/ui/dashboard/panel_manage_popup.py index d1bba92..aaa2da5 100644 --- a/src/hatty/ui/dashboard/panel_manage_popup.py +++ b/src/hatty/ui/dashboard/panel_manage_popup.py @@ -2,11 +2,11 @@ from typing import TYPE_CHECKING from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container from textual.widgets import Footer, Label, OptionList from textual.widgets.option_list import Option +from hatty.controllers.keybindings import bindings_for from hatty.ui.controls.entity_picker_modal import EntityPickerModal from hatty.ui.entity_table import get_display_name from hatty.ui.popup_base import PopupScreen @@ -23,15 +23,7 @@ class PanelManagePopup(PopupScreen): app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect parent: "HACLI" # this popup's parent is always the app - BINDINGS = [ - Binding("escape", "done", "Done"), - Binding("q", "done", "Done", show=False), - Binding("shift+up", "move(-1)", "Move up"), - Binding("shift+down", "move(1)", "Move down"), - Binding("delete", "remove", "Remove"), - Binding("x", "remove", "Remove"), - Binding("a", "add", "Add"), - ] + BINDINGS = bindings_for("panel_manage") DEFAULT_CSS = """ #panel_manage_container { diff --git a/src/hatty/ui/dashboard/screen.py b/src/hatty/ui/dashboard/screen.py index a85ea93..a8de61d 100644 --- a/src/hatty/ui/dashboard/screen.py +++ b/src/hatty/ui/dashboard/screen.py @@ -45,7 +45,6 @@ from typing import TYPE_CHECKING from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container, Grid, VerticalScroll from textual.reactive import reactive from textual.screen import Screen @@ -55,6 +54,7 @@ from textual_fspicker import FileOpen, FileSave, Filters from hatty.const import CONFIG_KEY_GRAPH_TYPE +from hatty.controllers.keybindings import bindings_for from hatty.ui.activity_log_panel import ActivityLogPanel from hatty.ui.confirm_popup import ConfirmPopup from hatty.ui.dashboard.cursor import GridCursor @@ -205,8 +205,22 @@ class DashboardScreen(Screen): # log_window/log_title_suffix hooks live below with the rest of the log actions. LOG_PANEL_ID: str = "dashboard_log_panel" LOG_SUPPORTS_LIVE: bool = True - _LOG_HINT = "v scope · f maximize · [ / ] older/newer · a close" - _LOG_HINT_MAXIMIZED = "↑/↓ select · f exit · ←/→ older/newer · a close" + + @property + def _LOG_HINT(self) -> str: + d = self.app.keys_ctl.display + return ( + f"{d('log.scope')} scope · {d('log.maximize')} maximize · " + f"{d('dashboard.log_older')}/{d('dashboard.log_newer')} older/newer · {d('log.toggle')} close" + ) + + @property + def _LOG_HINT_MAXIMIZED(self) -> str: + d = self.app.keys_ctl.display + return ( + f"↑/↓ select · {d('log.maximize')} exit · " + f"{d('dashboard.log_older')}/{d('dashboard.log_newer')} older/newer · {d('log.toggle')} close" + ) IDLE_TIMEOUT: float = 5.0 @@ -215,43 +229,7 @@ class DashboardScreen(Screen): # scrolls instead of squishing every cell below readability. CELL_MIN_HEIGHT: int = 8 - BINDINGS = [ - Binding("up", "move_cursor(-1, 0)", "Up", show=False), - Binding("down", "move_cursor(1, 0)", "Down", show=False), - Binding("left", "move_cursor(0, -1)", "Left", show=False), - Binding("right", "move_cursor(0, 1)", "Right", show=False), - # Use mode - Binding("enter", "toggle_slot", "Toggle"), - Binding("e", "expand_slot", "Controls"), - Binding("E", "enter_edit", "Edit"), - Binding("r", "rename_slot_entity", "Rename"), - # Edit mode - Binding("a", "edit_slot", "Assign"), - Binding("delete", "clear_slot", "Clear Slot"), - Binding("enter", "grab_move", "Move"), - Binding("ctrl+right", "resize_slot(0, 1)", "Wider", show=False), - Binding("ctrl+left", "resize_slot(0, -1)", "Narrower", show=False), - Binding("ctrl+down", "resize_slot(1, 0)", "Taller", show=False), - Binding("ctrl+up", "resize_slot(-1, 0)", "Shorter", show=False), - Binding("s", "split_slot", "Split"), - Binding("u", "unsplit_slot", "Unsplit", show=False), - Binding("f", "fill_split", "Fill"), - # Activity log (Use mode only; `a`/`f` double up with Edit mode above, - # gated apart by check_action like enter's toggle_slot/grab_move split) - Binding("a", "toggle_activity_log", "Activity Log", show=False), - Binding("v", "show_log_scope", "Log Scope", show=False), - Binding("f", "maximize_log", "Maximize Log", show=False), - Binding("left_square_bracket", "log_older", "Older Events", show=False), - Binding("right_square_bracket", "log_newer", "Newer Events", show=False), - # Both modes - Binding("l", "show_list_popup", "Back to List", show=False), - Binding("d", "manage_dashboards", "Dashboards"), - Binding("D", "show_device_tree", "Device Tree"), - Binding("t", "cycle_graph_type", "Graph Type", show=False), - Binding("G", "graph_fullscreen", "Full Graph"), - Binding("question_mark", "show_help", "Help"), - Binding("escape", "go_back", "Back"), - ] + 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 @@ -448,9 +426,14 @@ 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). + back = self.app.keys_ctl.display("nav.back") if self._widget_active: banner.add_class("-mode-widget") - banner.update("WIDGET · interacting — ↑↓: adjust Esc: exit") + banner.update(f"WIDGET · interacting — ↑↓: adjust {back}: exit") elif not self.edit_mode: banner.update("USE · operate widgets — Enter: use e: edit") elif self._grabbed is not None: @@ -460,7 +443,7 @@ def _update_mode_banner(self) -> None: banner.add_class("-mode-edit") banner.update( "EDIT · arrange — a: assign (enters a split) Del: clear Enter: move" - " Ctrl+arrows: resize s/u: split/unsplit f: fill r: manage panel Esc: done" + f" Ctrl+arrows: resize s/u: split/unsplit f: fill r: manage panel {back}: done" ) def _reset_idle_timer(self) -> None: diff --git a/src/hatty/ui/dashboard/selection_popup.py b/src/hatty/ui/dashboard/selection_popup.py index 252cb85..531fbdf 100644 --- a/src/hatty/ui/dashboard/selection_popup.py +++ b/src/hatty/ui/dashboard/selection_popup.py @@ -2,10 +2,10 @@ from typing import TYPE_CHECKING from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container from textual.widgets import Footer, Input, Label, ListView +from hatty.controllers.keybindings import bindings_for from hatty.ui.popup_base import ListPopup if TYPE_CHECKING: @@ -15,17 +15,7 @@ class DashboardSelectionPopup(ListPopup): parent: "HACLI" # this popup's parent is always the app (annotation only, no runtime effect) - BINDINGS = [ - ("delete", "delete_dashboard", "Delete"), - ("e", "edit_dashboard", "Edit"), - ("d", "set_default", "Set as Default"), - ("x", "export_dashboard", "Export"), - ("i", "import_dashboard", "Import"), - ("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - Binding("shift+up", "move_up", "Move Up", priority=True), - Binding("shift+down", "move_down", "Move Down", priority=True), - ] + BINDINGS = bindings_for("dashboard_select") DEFAULT_CSS = """ #dashboard_selection_container Input { diff --git a/src/hatty/ui/dashboard/slot_popup.py b/src/hatty/ui/dashboard/slot_popup.py index f2ce0d1..e3c0559 100644 --- a/src/hatty/ui/dashboard/slot_popup.py +++ b/src/hatty/ui/dashboard/slot_popup.py @@ -42,13 +42,13 @@ from typing import TYPE_CHECKING, cast from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container, Horizontal, Vertical from textual.events import Key from textual.timer import Timer from textual.widgets import Button, Checkbox, DataTable, Footer, Input, Label, ListItem, ListView, OptionList, Select from hatty.const import LAST_CHANGED_WIDGET_TYPES, WIDGET_TYPES +from hatty.controllers.keybindings import bindings_for from hatty.ui.dashboard.widget_match import compatible_widget_types, entity_matches_widget_type from hatty.ui.dashboard.widgets.base import build_slot_content from hatty.ui.entity_table import EntitiesTable, entity_matches, get_display_name @@ -87,23 +87,16 @@ class DashboardSlotPopup(PopupScreen): # mirrors light_screen.py/media_player_screen.py's _BUTTON_ROW_IDS convention). BUTTON_ROW_IDS = ("type_step_buttons",) - BINDINGS = [ - ("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - # Panel/fill's accumulated-entities box (issue #254): reorder and remove - # only apply while that box is focused (guarded in the actions below), so - # these 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. - Binding("shift+up", "reorder_selected(-1)", "Move Up", show=False), - Binding("shift+down", "reorder_selected(1)", "Move Down", show=False), - Binding("delete", "remove_selected", "Remove", show=False), - # Priority so up/down 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. - Binding("up", "nav_focus(-1)", "Focus Up", show=False, priority=True), - Binding("down", "nav_focus(1)", "Focus Down", show=False, priority=True), - ] + # 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. + BINDINGS = bindings_for("slot_popup") DEFAULT_CSS = """ #dashboard_slot_container { diff --git a/src/hatty/ui/dashboard/split_slot_popup.py b/src/hatty/ui/dashboard/split_slot_popup.py index ba4855e..9f20356 100644 --- a/src/hatty/ui/dashboard/split_slot_popup.py +++ b/src/hatty/ui/dashboard/split_slot_popup.py @@ -1,21 +1,24 @@ # hatty — MIT License. See LICENSE file for details. +from typing import TYPE_CHECKING + from textual.app import ComposeResult from textual.containers import Container from textual.widgets import Footer, Label +from hatty.controllers.keybindings import bindings_for from hatty.ui.popup_base import PopupScreen +if TYPE_CHECKING: + from hatty.main import HACLI + class SplitSlotPopup(PopupScreen): """Ask how to split the selected pane. Dismisses with 'v' (left/right), 'h' (top/bottom), 'quad' (quarters), or None on cancel.""" - BINDINGS = [ - ("v", "split('v')", "Left/Right"), - ("h", "split('h')", "Top/Bottom"), - ("q", "split('quad')", "Quarters"), - ("escape", "cancel", "Cancel"), - ] + app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect + + BINDINGS = bindings_for("split_slot") DEFAULT_CSS = """ #split_title { @@ -30,7 +33,8 @@ class SplitSlotPopup(PopupScreen): def compose(self) -> ComposeResult: with Container(id="split_container", classes="popup-container"): yield Label("Split this pane into smaller sections", id="split_title") - yield Label("[v] Left / Right [h] Top / Bottom [q] Quarters [Esc] Cancel", id="split_hint") + back = self.app.keys_ctl.display("nav.back") + yield Label(f"[v] Left / Right [h] Top / Bottom [q] Quarters [{back}] Cancel", id="split_hint") yield Footer() def action_split(self, direction: str) -> None: diff --git a/src/hatty/ui/device_tree_screen.py b/src/hatty/ui/device_tree_screen.py index 43b050e..ac8c1f0 100644 --- a/src/hatty/ui/device_tree_screen.py +++ b/src/hatty/ui/device_tree_screen.py @@ -32,12 +32,12 @@ from rich.text import Text from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container from textual.screen import Screen from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, Static, Tree from hatty.const import CONFIG_KEY_GRAPH_TYPE +from hatty.controllers.keybindings import bindings_for from hatty.ui.entity_table import entity_matches, get_display_name, is_dead from hatty.ui.popup_base import PopupScreen from hatty.ui.search_input import SearchInput @@ -290,10 +290,7 @@ class AreaNamePopup(PopupScreen): """Single-Input name prompt for creating or renaming an area (issue #146). Dismisses with the trimmed name, or `None` when empty/cancelled.""" - BINDINGS = [ - ("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - ] + BINDINGS = bindings_for("area_name") DEFAULT_CSS = """ AreaNamePopup #area_name_container { @@ -327,10 +324,7 @@ class AreaPickerPopup(PopupScreen): """Pick a target area for a device. Dismisses with `{"area_id": }` (None clears the assignment) or `None` when cancelled.""" - BINDINGS = [ - ("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - ] + BINDINGS = bindings_for("area_picker") DEFAULT_CSS = """ AreaPickerPopup #area_picker_container { @@ -373,11 +367,7 @@ class DeviceInfoPopup(PopupScreen): """Read-only device (or entity) info panel (issue #151). Constructed from an already-resolved title + `(label, value)` rows so it stays pure/testable.""" - BINDINGS = [ - ("escape", "cancel", "Cancel"), - ("i", "cancel", "Close"), - Binding("q", "cancel", "Cancel", show=False), - ] + BINDINGS = bindings_for("device_info") DEFAULT_CSS = """ DeviceInfoPopup #device_info_container { @@ -407,29 +397,11 @@ def action_cancel(self) -> None: class DeviceTreeScreen(Screen): app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect - BINDINGS = [ - Binding("/", "toggle_search", "Search"), - Binding("e", "expand_entity", "Controls"), - Binding("G", "graph_fullscreen", "Graph"), - Binding("v", "cycle_mode", "View"), - Binding("m", "move_device", "Move to Area"), - Binding("i", "device_info", "Info"), - Binding("a", "create_area", "New Area"), - Binding("r", "rename", "Rename"), - Binding("l", "jump_to_list", "Lists"), - Binding("d", "open_dashboard", "Dashboard"), - Binding("n", "area_to_dashboard", "New Dashboard"), - Binding("x", "collapse_all", "Collapse All"), - Binding("X", "expand_all", "Expand All"), - Binding("question_mark", "show_help", "Help"), - # Priority: the focused Tree binds space to toggle_node; the screen - # action forwards non-entity nodes there so containers still fold. - Binding("space", "toggle_list_membership", "List", priority=True), - # 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). - Binding("ctrl+s", "cycle_scope", "Scope", priority=True), - Binding("escape", "go_back", "Back"), - ] + # 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). + BINDINGS = bindings_for("tree") _MODES = ("device", "area", "integration") # Scopes offered per view (ctrl+s cycles only within the current view's @@ -508,7 +480,8 @@ def _status_text(self) -> str: return text def _search_placeholder(self) -> str: - return f"Filter [{self._scope}]... (Enter to apply, ctrl+s: scope, Esc to clear)" + back = self.app.keys_ctl.display("nav.back") + return f"Filter [{self._scope}]... (Enter to apply, ctrl+s: scope, {back} to clear)" def _entity_matcher(self): term = self._filter diff --git a/src/hatty/ui/graph/color_popup.py b/src/hatty/ui/graph/color_popup.py index 5e19333..6bfd436 100644 --- a/src/hatty/ui/graph/color_popup.py +++ b/src/hatty/ui/graph/color_popup.py @@ -1,10 +1,10 @@ # hatty — MIT License. See LICENSE file for details. from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container from textual.widgets import Footer, Label, OptionList from textual.widgets.option_list import Option +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 @@ -77,10 +77,7 @@ def swatch_markup(plotext_color: str) -> str: class GraphColorPopup(PopupScreen): """Pick a plotext color for one graph line; dismisses with the color name or None.""" - BINDINGS = [ - Binding("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - ] + BINDINGS = bindings_for("graph_color") DEFAULT_CSS = """ #graph_color_container { diff --git a/src/hatty/ui/graph/duration_popup.py b/src/hatty/ui/graph/duration_popup.py index db36c98..b1303ab 100644 --- a/src/hatty/ui/graph/duration_popup.py +++ b/src/hatty/ui/graph/duration_popup.py @@ -1,9 +1,9 @@ # hatty — MIT License. See LICENSE file for details. from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container, Horizontal from textual.widgets import Footer, Input, Label, RadioButton, RadioSet +from hatty.controllers.keybindings import bindings_for from hatty.ui.popup_base import PopupScreen _DURATION_OPTIONS = [ @@ -28,11 +28,7 @@ def _split_hours(hours: float) -> tuple[str, str]: class GraphDurationPopup(PopupScreen): AUTO_FOCUS = "RadioSet" - BINDINGS = [ - ("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - Binding("enter", "confirm", "Select", priority=True), - ] + BINDINGS = bindings_for("graph_duration") DEFAULT_CSS = """ #duration_container { diff --git a/src/hatty/ui/graph/preview_screen.py b/src/hatty/ui/graph/preview_screen.py index d7a6e72..1c30ce0 100644 --- a/src/hatty/ui/graph/preview_screen.py +++ b/src/hatty/ui/graph/preview_screen.py @@ -54,11 +54,12 @@ from rich.text import Text from textual.app import ComposeResult -from textual.binding import Binding from textual.screen import Screen from textual.widgets import Footer, Label from textual_plotext import PlotextPlot +from hatty.const import FAST_PAGE_MULTIPLIER +from hatty.controllers.keybindings import bindings_for from hatty.ui.activity_log_panel import ActivityLogPanel from hatty.ui.entity_table import entity_title, entity_unit, get_display_name from hatty.ui.graph.binary_history import binary_stats, value_to_state @@ -98,9 +99,6 @@ def _color_hint(color: str) -> str: return f"{swatch_markup(color)} [tab: next line · c/C: {color}]" -_FAST_PAGE_MULTIPLIER = 6 - - class GraphPreviewScreen(Screen): app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect @@ -123,40 +121,7 @@ class GraphPreviewScreen(Screen): } """ - BINDINGS = [ - Binding("t", "cycle_plot_type", "Graph Type"), - Binding("left", "scroll_back", "Older"), - Binding("left", "cursor_prev", "Prev Sample"), - Binding("right", "scroll_forward", "Newer"), - Binding("right", "cursor_next", "Next Sample"), - Binding("shift+left", "scroll_back_fast", f"Older ×{_FAST_PAGE_MULTIPLIER}"), - Binding("shift+left", "cursor_prev_fast", "Prev Sample ×10%"), - Binding("shift+right", "scroll_forward_fast", f"Newer ×{_FAST_PAGE_MULTIPLIER}"), - Binding("shift+right", "cursor_next_fast", "Next Sample ×10%"), - Binding("plus", "zoom_in", "Zoom In"), - Binding("minus", "zoom_out", "Zoom Out"), - Binding("home", "snap_live", "Now"), - Binding("home", "cursor_home", "Oldest Sample"), - Binding("end", "cursor_end", "Newest Sample"), - Binding("enter", "toggle_cursor_mode", "Inspect"), - Binding("enter", "exit_cursor_mode", "Exit Inspect"), - Binding("S", "save_graph", "Save As"), - Binding("u", "update_graph", "Update"), - Binding("tab", "next_entity", "Next Line", show=False), - Binding("c", "cycle_color", "Color"), - Binding("C", "pick_color", "Color Picker"), - Binding("l", "show_list_popup", "Back to List", show=False), - Binding("a", "toggle_event_log", "Activity Log"), - Binding("v", "show_log_scope", "Log View"), - Binding("f", "maximize_log", "Maximize Log", show=False), - Binding("question_mark", "show_help", "Help"), - Binding("escape", "exit_cursor_mode", "Exit Inspect"), - Binding("escape", "close_event_log", "Close Log"), - Binding("escape", "go_back", "Back"), - Binding("q", "exit_cursor_mode", "Exit Inspect", show=False), - Binding("q", "close_event_log", "Close Log", show=False), - Binding("q", "go_back", "Back", show=False), - ] + 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 @@ -421,10 +386,10 @@ def action_scroll_forward(self) -> None: self._page_forward(self._window_hours() / 2) def action_scroll_back_fast(self) -> None: - self._page_back(self._window_hours() * _FAST_PAGE_MULTIPLIER) + self._page_back(self._window_hours() * FAST_PAGE_MULTIPLIER) def action_scroll_forward_fast(self) -> None: - self._page_forward(self._window_hours() * _FAST_PAGE_MULTIPLIER) + self._page_forward(self._window_hours() * FAST_PAGE_MULTIPLIER) def action_cursor_prev(self) -> None: self._move_cursor(-1) @@ -448,8 +413,8 @@ def _plot_width(self) -> int: def _fast_cursor_stride(self) -> int: """Cursor-mode fast step: at least 10% of the plotted samples so ~10 fast presses cross a dense (multi-thousand-point) window, never below the - familiar small-plot step of _FAST_PAGE_MULTIPLIER.""" - return max(_FAST_PAGE_MULTIPLIER, round(len(self._data) * 0.10)) + familiar small-plot step of FAST_PAGE_MULTIPLIER.""" + return max(FAST_PAGE_MULTIPLIER, round(len(self._data) * 0.10)) def _page_back(self, hours: float) -> None: self._window.page_back(hours, datetime.now(timezone.utc)) @@ -766,8 +731,15 @@ def _close_event_log(self) -> None: self.app.log_ctl.close(self) self._redraw() - _LOG_HINT = "v scope · f max · a close · ←/→ page with the graph" - _LOG_HINT_MAXIMIZED = "↑/↓ select · f exit · a close · ←/→ page with the graph" + @property + def _LOG_HINT(self) -> str: + d = self.app.keys_ctl.display + return f"{d('log.scope')} scope · {d('log.maximize')} max · {d('log.toggle')} close · ←/→ page with the graph" + + @property + def _LOG_HINT_MAXIMIZED(self) -> str: + d = self.app.keys_ctl.display + return f"↑/↓ select · {d('log.maximize')} exit · {d('log.toggle')} close · ←/→ page with the graph" def action_close_event_log(self) -> None: """escape/q — a further escape/toggle closes; a maximized panel gets diff --git a/src/hatty/ui/graph/saved_graphs_popup.py b/src/hatty/ui/graph/saved_graphs_popup.py index b042bcf..87496f2 100644 --- a/src/hatty/ui/graph/saved_graphs_popup.py +++ b/src/hatty/ui/graph/saved_graphs_popup.py @@ -2,10 +2,10 @@ from typing import TYPE_CHECKING from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container from textual.widgets import Footer, Input, Label, ListView +from hatty.controllers.keybindings import bindings_for from hatty.ui.popup_base import ListPopup, PopupScreen if TYPE_CHECKING: @@ -13,10 +13,7 @@ class SaveGraphNamePopup(PopupScreen): - BINDINGS = [ - ("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - ] + BINDINGS = bindings_for("save_graph_name") def __init__(self, initial_name: str | None = None): super().__init__(id="save_graph_name_popup") @@ -42,12 +39,7 @@ def action_cancel(self) -> None: class SavedGraphsPopup(ListPopup): parent: "HACLI" # this popup's parent is always the app (annotation only, no runtime effect) - BINDINGS = [ - ("r", "rename_graph", "Rename"), - ("delete", "delete_graph", "Delete"), - ("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - ] + BINDINGS = bindings_for("saved_graphs_popup") DEFAULT_CSS = """ #saved_graphs_list { diff --git a/src/hatty/ui/help_popup.py b/src/hatty/ui/help_popup.py index 48fcb3b..ea0de53 100644 --- a/src/hatty/ui/help_popup.py +++ b/src/hatty/ui/help_popup.py @@ -14,15 +14,20 @@ class attr (a title -> action-names mapping); `GraphPreviewScreen` additionally """ from collections.abc import Iterable, Sequence +from typing import TYPE_CHECKING from rich.table import Table from textual.app import ComposeResult -from textual.binding import Binding +from textual.binding import Binding, BindingType from textual.containers import Container, VerticalScroll from textual.widgets import Footer, Input, Label, Static +from hatty.controllers.keybindings import bindings_for from hatty.ui.popup_base import PopupScreen +if TYPE_CHECKING: + from hatty.main import HACLI + # Textual key name -> friendly display, for the few that don't read well raw. KEY_DISPLAY = { "question_mark": "?", @@ -37,10 +42,10 @@ class attr (a title -> action-names mapping); `GraphPreviewScreen` additionally "minus": "-", "space": "Space", "tab": "Tab", + "left_square_bracket": "[", + "right_square_bracket": "]", } -HINT_TEXT = "←/→ pages · / search all · a show all · Esc close" - def display_key(key: str) -> str: return KEY_DISPLAY.get(key, key) @@ -65,36 +70,32 @@ def action_name(action: str) -> str: return action.split("(", 1)[0] -def binding_entries(bindings: Iterable[Binding | tuple]) -> list[tuple[str, str, str]]: - """Convert a screen's BINDINGS (Binding objects, or the odd plain tuple) into - deduped (key, description, action_name) triples — the static-page counterpart - to the (key, description) pairs `active_bindings` already yields for the live - page, with the action name kept alongside so `sectioned_rows` can group them.""" - - def _entry(entry: Binding | tuple) -> tuple[str, str, str]: - if isinstance(entry, Binding): - return entry.key, entry.description, action_name(entry.action) - return ( - entry[0], - entry[2] if len(entry) > 2 else "", - action_name(entry[1] if len(entry) > 1 else ""), - ) - +def binding_entries(bindings: Iterable[BindingType]) -> list[tuple[str, str, str]]: + """Convert a screen's BINDINGS into deduped (key, description, action_name) + triples — the static-page counterpart to the (key, description) pairs + `active_bindings` already yields for the live page, with the action name + kept alongside so `sectioned_rows` can group them. Parameter is typed + `Iterable[BindingType]` only because that's `DOMNode.BINDINGS`'s own + declared type; every BINDINGS list is actually a plain `Binding` list since + the keybinding-registry migration (no more bare tuples anywhere), so + non-Binding entries are skipped defensively rather than parsed.""" seen: set[tuple[str, str]] = set() result: list[tuple[str, str, str]] = [] for entry in bindings: - key, description, action = _entry(entry) + if not isinstance(entry, Binding): + continue + key, description = entry.key, entry.description if not description or (key, description) in seen: continue seen.add((key, description)) - result.append((key, description, action)) + result.append((key, description, action_name(entry.action))) return result -def binding_rows(bindings: Iterable[Binding | tuple]) -> list[tuple[str, str]]: - """Convert a screen's BINDINGS (Binding objects, or the odd plain tuple) into - deduped (key, description) rows — the static-page counterpart to the - (key, description) pairs `active_bindings` already yields for the live page.""" +def binding_rows(bindings: Iterable[BindingType]) -> list[tuple[str, str]]: + """Convert a screen's BINDINGS into deduped (key, description) rows — the + static-page counterpart to the (key, description) pairs `active_bindings` + already yields for the live page.""" return [(key, description) for key, description, _ in binding_entries(bindings)] @@ -147,15 +148,9 @@ def filter_pages( class HelpPopup(PopupScreen): - BINDINGS = [ - Binding("left", "prev_page", "Prev Page"), - Binding("right", "next_page", "Next Page"), - Binding("/", "focus_filter", "Search"), - Binding("a", "toggle_all", "Show All"), - Binding("escape", "dismiss", "Close"), - Binding("question_mark", "dismiss", "Close", show=False), - Binding("q", "dismiss", "Close", show=False), - ] + app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect + + BINDINGS = bindings_for("help_popup") DEFAULT_CSS = """ #help_container { @@ -188,10 +183,16 @@ def __init__(self, pages: list[tuple[str, list[tuple[str, str]]]], active_index: # that inspect `app.screen._binding_rows` keep working unchanged. 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. + return f"←/→ pages · / search all · a show all · {self.app.keys_ctl.display('nav.back')} close" + def compose(self) -> ComposeResult: with Container(id="help_container", classes="popup-container"): yield Label(id="help_title") - yield Label(HINT_TEXT, id="help_hint") + 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 diff --git a/src/hatty/ui/key_capture_popup.py b/src/hatty/ui/key_capture_popup.py new file mode 100644 index 0000000..d3a3cb4 --- /dev/null +++ b/src/hatty/ui/key_capture_popup.py @@ -0,0 +1,78 @@ +# hatty — MIT License. See LICENSE file for details. +"""Key-capture popup for the config screen's Keybindings category: shows the +action being rebound and its current key, then records the *next* raw +keypress instead of routing it through the binding system — the same +swallow-everything `on_key` pattern as `splash_screen.py`, which is what lets +`escape` itself (nav.back's own default) be captured as a new key rather than +dismissing the popup. + +`ctrl+c` is the fixed cancel key (dismiss with `None`, no change) and `delete` +resets to the id's registry default — both still go through `validate()` so a +default that now collides with another rebind is reported the same as any +other candidate. Every other key is validated against the *working* overrides +dict the config screen passes in (its uncommitted edits, not yet saved) via +`keybindings.validate`; a conflict is shown inline and the popup stays open +for another attempt.""" + +from textual import events +from textual.app import ComposeResult +from textual.containers import Container +from textual.widgets import Label, Static + +from hatty.controllers.keybindings import KeySpec, display_key, validate +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. +_CANCEL_KEY = "ctrl+c" +_RESET_KEY = "delete" + + +class KeyCapturePopup(PopupScreen[str | None]): + DEFAULT_CSS = """ + KeyCapturePopup .popup-container { + width: 60; + } + KeyCapturePopup #key_capture_prompt { + margin-top: 1; + text-style: bold; + } + KeyCapturePopup #key_capture_hint { + color: $text-muted; + margin-top: 1; + } + KeyCapturePopup #key_capture_error { + color: $error; + margin-top: 1; + } + """ + + def __init__(self, spec: KeySpec, current_key: str, overrides: dict[str, str]) -> None: + super().__init__() + self._spec = spec + self._current_key = current_key + self._overrides = overrides + + def compose(self) -> ComposeResult: + with Container(classes="popup-container"): + yield Label(f"Rebind: {self._spec.label or self._spec.description}", classes="popup-title") + yield Static(f"Current: {display_key(self._current_key)}", id="key_capture_current") + yield Static("Press the new key…", id="key_capture_prompt") + yield Static(f"{_CANCEL_KEY} cancel · {_RESET_KEY} reset to default", id="key_capture_hint") + yield Static("", id="key_capture_error") + + def on_key(self, event: events.Key) -> None: + event.stop() + event.prevent_default() + + if event.key == _CANCEL_KEY: + self.dismiss(None) + return + + key = self._spec.key if event.key == _RESET_KEY else event.key + error = validate(self._spec.id, key, self._overrides) + if error: + self.query_one("#key_capture_error", Static).update(error) + return + self.dismiss(key) diff --git a/src/hatty/ui/list_selection_popup.py b/src/hatty/ui/list_selection_popup.py index d647028..9871102 100644 --- a/src/hatty/ui/list_selection_popup.py +++ b/src/hatty/ui/list_selection_popup.py @@ -2,10 +2,10 @@ from typing import TYPE_CHECKING from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container from textual.widgets import Footer, Input, Label, ListView +from hatty.controllers.keybindings import bindings_for from hatty.ui.popup_base import ListPopup from hatty.ui.search_input import SearchInput @@ -17,18 +17,7 @@ class ListSelectionPopup(ListPopup): app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect parent: "HACLI" # this popup's parent is always the app - BINDINGS = [ - ("delete", "delete_list", "Delete List"), - ("r", "rename_list", "Rename"), - ("d", "set_default", "Set as Default"), - ("n", "toggle_notify", "Notify"), - ("v", "view_as_dashboard", "View as Dashboard"), - ("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - ("/", "toggle_search", "Search"), - Binding("shift+up", "move_up", "Move Up", priority=True), - Binding("shift+down", "move_down", "Move Down", priority=True), - ] + BINDINGS = bindings_for("list_popup") DEFAULT_CSS = """ ListSelectionPopup #list_selection_container { diff --git a/src/hatty/ui/log_scope_popup.py b/src/hatty/ui/log_scope_popup.py index f40408e..d826b0a 100644 --- a/src/hatty/ui/log_scope_popup.py +++ b/src/hatty/ui/log_scope_popup.py @@ -17,11 +17,11 @@ rather than re-derived here).""" from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container, VerticalScroll from textual.widgets import Footer, Label, OptionList, Static from textual.widgets.option_list import Option +from hatty.controllers.keybindings import bindings_for from hatty.controllers.logbook import LogScope, LogScopeOption from hatty.ui.popup_base import PopupScreen @@ -49,10 +49,7 @@ class LogScopePopup(PopupScreen[str | None]): } """ - BINDINGS = [ - Binding("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - ] + BINDINGS = bindings_for("log_scope_popup") def __init__( self, diff --git a/src/hatty/ui/onboarding_screen.py b/src/hatty/ui/onboarding_screen.py index 513b7cf..8c882bf 100644 --- a/src/hatty/ui/onboarding_screen.py +++ b/src/hatty/ui/onboarding_screen.py @@ -1,11 +1,11 @@ # hatty — MIT License. See LICENSE file for details. from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Horizontal, VerticalScroll from textual.screen import Screen from textual.widgets import Button, Footer, Input, Label from hatty.client import probe_connection +from hatty.controllers.keybindings import bindings_for class OnboardingScreen(Screen): @@ -17,12 +17,7 @@ class OnboardingScreen(Screen): parse — that decision lives in main.py via config.needs_onboarding. """ - BINDINGS = [ - Binding("ctrl+t", "test_connection", "Test Connection"), - Binding("ctrl+s", "save", "Save & Connect"), - Binding("ctrl+v", "toggle_token", "Show/Hide Token"), - Binding("escape", "cancel", "Cancel"), - ] + BINDINGS = bindings_for("onboarding") DEFAULT_CSS = """ OnboardingScreen { diff --git a/src/hatty/ui/rename_entity_popup.py b/src/hatty/ui/rename_entity_popup.py index c6bd5d4..9aa48d2 100644 --- a/src/hatty/ui/rename_entity_popup.py +++ b/src/hatty/ui/rename_entity_popup.py @@ -1,19 +1,15 @@ # hatty — MIT License. See LICENSE file for details. from textual import events from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container, Horizontal from textual.widgets import Button, Footer, Input, Label +from hatty.controllers.keybindings import bindings_for from hatty.ui.popup_base import PopupScreen class RenameEntityPopup(PopupScreen): - BINDINGS = [ - Binding("escape", "cancel", "Cancel"), - Binding("q", "cancel", "Cancel", show=False), - Binding("enter", "save_local", "Save Locally"), - ] + BINDINGS = bindings_for("rename_popup") DEFAULT_CSS = """ #rename_container { diff --git a/src/hatty/ui/search_input.py b/src/hatty/ui/search_input.py index b145839..2a0a6cf 100644 --- a/src/hatty/ui/search_input.py +++ b/src/hatty/ui/search_input.py @@ -1,13 +1,24 @@ # hatty — MIT License. See LICENSE file for details. -from textual.binding import Binding +from typing import TYPE_CHECKING + from textual.message import Message from textual.widgets import Input +from hatty.controllers.keybindings import bindings_for + +if TYPE_CHECKING: + from hatty.main import HACLI + +# Static fallbacks — used only in __init__, before the widget is mounted and +# self.app is available; overwritten by the dynamic (keymap-aware) versions +# below the moment either placeholder is next set. FILTER_PLACEHOLDER = "Filter all entities... (Enter to apply, Tab: jump in current view, Esc to cancel)" VI_PLACEHOLDER = "Jump in current view... (Enter/Esc to close, n/N after, Tab: filter all entities)" class SearchInput(Input): + app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect + DEFAULT_CSS = """ SearchInput.-active { border: heavy $accent; @@ -18,9 +29,7 @@ class SearchInput(Input): } """ - BINDINGS = [ - Binding("tab", "toggle_mode", "Toggle filter/jump", show=False), - ] + BINDINGS = bindings_for("search_input") class SearchSubmitted(Message): def __init__(self, value: str) -> None: @@ -43,21 +52,36 @@ def on_input_submitted(self, event: Input.Submitted) -> None: def on_input_changed(self, event: Input.Changed) -> None: self.post_message(self.SearchChanged(event.value)) + def _filter_placeholder(self) -> str: + d = self.app.keys_ctl.display + return ( + f"Filter all entities... (Enter to apply, {d('search_input.toggle_mode')}: " + f"jump in current view, {d('nav.back')} to cancel)" + ) + + def _vi_placeholder(self) -> str: + d = self.app.keys_ctl.display + return ( + f"Jump in current view... (Enter/{d('nav.back')} to close, " + f"{d('nav.search_next')}/{d('nav.search_prev')} after, {d('search_input.toggle_mode')}: " + f"filter all entities)" + ) + def action_toggle_mode(self) -> None: self.vi_mode = not self.vi_mode if self.vi_mode: self.add_class("-vi-mode") - self.placeholder = VI_PLACEHOLDER + self.placeholder = self._vi_placeholder() else: self.remove_class("-vi-mode") - self.placeholder = FILTER_PLACEHOLDER + self.placeholder = self._filter_placeholder() self.post_message(self.SearchChanged(self.value)) def action_focus_display(self) -> None: self.value = "" self.vi_mode = False self.remove_class("-vi-mode") - self.placeholder = FILTER_PLACEHOLDER + self.placeholder = self._filter_placeholder() self.display = True self.add_class("-active") self.focus() diff --git a/src/hatty/ui/weather_forecast_screen.py b/src/hatty/ui/weather_forecast_screen.py index 113e86f..0d31194 100644 --- a/src/hatty/ui/weather_forecast_screen.py +++ b/src/hatty/ui/weather_forecast_screen.py @@ -22,12 +22,12 @@ from typing import TYPE_CHECKING from textual.app import ComposeResult -from textual.binding import Binding from textual.containers import Container, Horizontal, Vertical from textual.screen import Screen from textual.widgets import Footer, Label, Static, Tab, Tabs from hatty.const import supported_forecast_types +from hatty.controllers.keybindings import bindings_for from hatty.ui.dashboard.widgets.visuals import build_forecast_columns from hatty.ui.entity_table import get_display_name @@ -96,11 +96,7 @@ class WeatherForecastScreen(Screen): } """ - BINDINGS = [ - Binding("escape", "go_back", "Back"), - Binding("t", "cycle_type", "Switch type"), - Binding("question_mark", "show_help", "Help"), - ] + BINDINGS = bindings_for("weather") HELP_TITLE = "Weather Forecast" diff --git a/tests/test_keybindings_config.py b/tests/test_keybindings_config.py new file mode 100644 index 0000000..bbf305d --- /dev/null +++ b/tests/test_keybindings_config.py @@ -0,0 +1,146 @@ +# hatty — MIT License. See LICENSE file for details. +"""Acceptance tests for the config screen's Keybindings category (issue #50): +selecting a row and pressing a key rebinds it, conflicts are blocked with an +inline error, and a save takes effect live without a restart.""" + +from textual.widgets import Button, DataTable, Static + +from hatty.controllers import keybindings as kb +from hatty.ui.config_screen import ConfigScreen +from hatty.ui.key_capture_popup import KeyCapturePopup +from tests.conftest import make_config + + +async def _open_keybindings(app, pilot): + await pilot.pause() + app.action_show_config() + await pilot.pause() + assert isinstance(app.screen, ConfigScreen) + app.screen.show_category("cat_keybindings") + await pilot.pause() + return app.screen + + +async def test_config_screen_boots_with_an_override_applied(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data={**make_config(), "keybindings": {"log.toggle": "A"}}) + async with app.run_test() as pilot: + await pilot.pause() + assert "A" in app.screen.active_bindings + assert app.screen.active_bindings["A"].binding.action == "toggle_activity_log" + assert "a" not in app.screen.active_bindings + + +async def test_rebind_via_capture_popup_and_save_takes_effect_live(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=make_config()) + async with app.run_test() as pilot: + screen = await _open_keybindings(app, pilot) + + table = screen.query_one("#cfg_keys", DataTable) + row = table.get_row_index("log.toggle") + table.move_cursor(row=row) + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert isinstance(app.screen, KeyCapturePopup) + await pilot.press("A") + await pilot.pause() + + # back on the config screen, the table reflects the new key + assert isinstance(app.screen, ConfigScreen) + assert screen._keybindings["log.toggle"] == "A" + + await pilot.press("ctrl+s") + await pilot.pause() + + assert app.app_config["keybindings"] == {"log.toggle": "A"} + assert "A" in app.screen.active_bindings + assert "a" not in app.screen.active_bindings + + +async def test_conflicting_key_keeps_popup_open_with_an_error(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=make_config()) + async with app.run_test() as pilot: + screen = await _open_keybindings(app, pilot) + + table = screen.query_one("#cfg_keys", DataTable) + table.move_cursor(row=table.get_row_index("log.toggle")) + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + popup = app.screen + assert isinstance(popup, KeyCapturePopup) + # "v" is log.scope's default key, in the same scopes as log.toggle. + await pilot.press("v") + await pilot.pause() + + assert app.screen is popup + error_text = str(popup.query_one("#key_capture_error", Static)._Static__content) + assert "Scope" in error_text + + # cancel out cleanly + await pilot.press("ctrl+c") + await pilot.pause() + assert isinstance(app.screen, ConfigScreen) + assert "log.toggle" not in screen._keybindings + + +async def test_reserved_key_keeps_popup_open_with_an_error(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=make_config()) + async with app.run_test() as pilot: + screen = await _open_keybindings(app, pilot) + + table = screen.query_one("#cfg_keys", DataTable) + table.move_cursor(row=table.get_row_index("log.toggle")) + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + popup = app.screen + assert isinstance(popup, KeyCapturePopup) + await pilot.press("ctrl+q") + await pilot.pause() + assert app.screen is popup + assert "reserved" in str(popup.query_one("#key_capture_error", Static)._Static__content) + + +async def test_delete_resets_to_default_in_capture_popup(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data={**make_config(), "keybindings": {"log.toggle": "A"}}) + async with app.run_test() as pilot: + screen = await _open_keybindings(app, pilot) + assert screen._keybindings == {"log.toggle": "A"} + + table = screen.query_one("#cfg_keys", DataTable) + table.move_cursor(row=table.get_row_index("log.toggle")) + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert isinstance(app.screen, KeyCapturePopup) + await pilot.press("delete") + await pilot.pause() + + assert isinstance(app.screen, ConfigScreen) + assert "log.toggle" not in screen._keybindings + + +async def test_reset_all_button_clears_every_override(make_app, sample_entities): + app = make_app( + entities=sample_entities, + config_data={**make_config(), "keybindings": {"log.toggle": "A", "nav.back": "backspace"}}, + ) + async with app.run_test() as pilot: + screen = await _open_keybindings(app, pilot) + assert screen._keybindings == {"log.toggle": "A", "nav.back": "backspace"} + + screen.query_one("#cfg_keys_reset", Button).press() + await pilot.pause() + + assert screen._keybindings == {} + + await pilot.press("ctrl+s") + await pilot.pause() + assert app.app_config["keybindings"] == {} + assert kb.resolve_keymap({})["log.toggle"] == "a" + assert "a" in app.screen.active_bindings diff --git a/tests/unit/binding_snapshot.json b/tests/unit/binding_snapshot.json new file mode 100644 index 0000000..c27f8d6 --- /dev/null +++ b/tests/unit/binding_snapshot.json @@ -0,0 +1,1726 @@ +[ + { + "file": "src/hatty/main.py", + "class": "HACLI", + "lineno": 91, + "count": 31, + "entries": [ + { + "form": "Binding", + "key": "/", + "action": "toggle_search", + "description": "Search" + }, + { + "form": "Binding", + "key": "e", + "action": "expand_entity", + "description": "Controls" + }, + { + "form": "Binding", + "key": "space", + "action": "toggle_list_membership", + "description": "In List" + }, + { + "form": "Binding", + "key": "shift+up", + "action": "move_entity_in_list(-1)", + "description": "Move Up", + "show": false + }, + { + "form": "Binding", + "key": "shift+down", + "action": "move_entity_in_list(1)", + "description": "Move Down", + "show": false + }, + { + "form": "Binding", + "key": "o", + "action": "toggle_list_sort", + "description": "Sort Order", + "show": false + }, + { + "form": "Binding", + "key": "L", + "action": "toggle_list_lock", + "description": "Lock List", + "show": false + }, + { + "form": "Binding", + "key": "r", + "action": "rename_entity", + "description": "Rename", + "show": false + }, + { + "form": "Binding", + "key": "u", + "action": "undo", + "description": "Undo", + "show": false + }, + { + "form": "Binding", + "key": "ctrl+r", + "action": "redo", + "description": "Redo", + "show": false + }, + { + "form": "Binding", + "key": "l", + "action": "show_list_selection_popup", + "description": "Lists", + "show": false + }, + { + "form": "Binding", + "key": "c", + "action": "show_column_config", + "description": "Columns", + "show": false + }, + { + "form": "Binding", + "key": "a", + "action": "toggle_activity_log", + "description": "Activity Log", + "show": false + }, + { + "form": "Binding", + "key": "i", + "action": "toggle_entity_log", + "description": "Entity Log", + "show": false + }, + { + "form": "Binding", + "key": "v", + "action": "show_log_scope", + "description": "Log Scope", + "show": false + }, + { + "form": "Binding", + "key": "f", + "action": "maximize_log", + "description": "Maximize Log", + "show": false + }, + { + "form": "Binding", + "key": "left", + "action": "log_older", + "description": "Older Events", + "show": false, + "priority": true + }, + { + "form": "Binding", + "key": "right", + "action": "log_newer", + "description": "Newer Events", + "show": false, + "priority": true + }, + { + "form": "Binding", + "key": "g", + "action": "toggle_graph", + "description": "Graph", + "show": false + }, + { + "form": "Binding", + "key": "G", + "action": "graph_fullscreen", + "description": "Full Graph", + "show": false + }, + { + "form": "Binding", + "key": "+", + "action": "add_to_graph", + "description": "Compare", + "show": false + }, + { + "form": "Binding", + "key": "d", + "action": "show_dashboard", + "description": "Dashboard", + "show": false + }, + { + "form": "Binding", + "key": "D", + "action": "show_device_tree", + "description": "Device Tree", + "show": false + }, + { + "form": "Binding", + "key": "s", + "action": "show_saved_graphs_popup", + "description": "Saved Graphs", + "show": false + }, + { + "form": "Binding", + "key": "t", + "action": "cycle_graph_type", + "description": "Graph Type" + }, + { + "form": "Binding", + "key": "T", + "action": "show_graph_duration", + "description": "Duration", + "show": false + }, + { + "form": "Binding", + "key": "n", + "action": "search_next", + "description": "Next Match", + "show": false + }, + { + "form": "Binding", + "key": "N", + "action": "search_prev", + "description": "Prev Match", + "show": false + }, + { + "form": "Binding", + "key": "question_mark", + "action": "show_help", + "description": "Help" + }, + { + "form": "Binding", + "key": "escape", + "action": "go_back", + "description": "Back/Clear" + }, + { + "form": "Binding", + "key": "ctrl+q", + "action": "quit", + "description": "Quit", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/column_config_popup.py", + "class": "ColumnConfigPopup", + "lineno": 15, + "count": 5, + "entries": [ + { + "form": "tuple", + "key": "escape", + "action": "save_and_close", + "description": "Save & Close", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "save_and_close", + "description": "Save & Close", + "show": false + }, + { + "form": "Binding", + "key": "enter", + "action": "save_and_close", + "description": "Save & Close", + "priority": true + }, + { + "form": "Binding", + "key": "shift+up", + "action": "move_up", + "description": "Move Up", + "priority": true + }, + { + "form": "Binding", + "key": "shift+down", + "action": "move_down", + "description": "Move Down", + "priority": true + } + ] + }, + { + "file": "src/hatty/ui/config_screen.py", + "class": "ConfigScreen", + "lineno": 100, + "count": 5, + "entries": [ + { + "form": "Binding", + "key": "ctrl+s", + "action": "save_and_close", + "description": "Save" + }, + { + "form": "Binding", + "key": "escape", + "action": "cancel", + "description": "Back/Cancel" + }, + { + "form": "Binding", + "key": "ctrl+o", + "action": "open_in_editor", + "description": "Editor" + }, + { + "form": "Binding", + "key": "ctrl+v", + "action": "toggle_token", + "description": "Show/Hide Token" + }, + { + "form": "Binding", + "key": "question_mark", + "action": "show_help", + "description": "Help" + } + ] + }, + { + "file": "src/hatty/ui/confirm_popup.py", + "class": "ConfirmPopup", + "lineno": 11, + "count": 4, + "entries": [ + { + "form": "tuple", + "key": "y", + "action": "confirm", + "description": "Yes", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "n", + "action": "cancel", + "description": "No", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "No", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "No", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/controls/control_popup.py", + "class": "EntityControlPopup", + "lineno": 22, + "count": 3, + "entries": [ + { + "form": "Binding", + "key": "escape", + "action": "cancel", + "description": "Cancel" + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + }, + { + "form": "Binding", + "key": "enter", + "action": "save", + "description": "Save" + } + ] + }, + { + "file": "src/hatty/ui/controls/entity_picker_modal.py", + "class": "EntityPickerModal", + "lineno": 19, + "count": 2, + "entries": [ + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/controls/light_screen.py", + "class": "ColorPickerModal", + "lineno": 92, + "count": 2, + "entries": [ + { + "form": "Binding", + "key": "escape", + "action": "cancel", + "description": "Cancel" + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/controls/light_screen.py", + "class": "LightControlScreen", + "lineno": 138, + "count": 11, + "entries": [ + { + "form": "Binding", + "key": "escape", + "action": "close", + "description": "Close" + }, + { + "form": "Binding", + "key": "q", + "action": "close", + "description": "Close", + "show": false + }, + { + "form": "Binding", + "key": "space", + "action": "toggle_power", + "description": "On/Off", + "priority": true + }, + { + "form": "Binding", + "key": "1", + "action": "white_preset(0)", + "description": "Warm", + "show": false + }, + { + "form": "Binding", + "key": "2", + "action": "white_preset(1)", + "description": "Neutral", + "show": false + }, + { + "form": "Binding", + "key": "3", + "action": "white_preset(2)", + "description": "Cool", + "show": false + }, + { + "form": "Binding", + "key": "p", + "action": "open_color_picker", + "description": "Pick Color", + "show": false + }, + { + "form": "Binding", + "key": "t", + "action": "cycle_tab", + "description": "Next Tab" + }, + { + "form": "Binding", + "key": "up", + "action": "nav_focus(-1)", + "description": "Focus Up", + "show": false, + "priority": true + }, + { + "form": "Binding", + "key": "down", + "action": "nav_focus(1)", + "description": "Focus Down", + "show": false, + "priority": true + }, + { + "form": "Binding", + "key": "question_mark", + "action": "show_help", + "description": "Help" + } + ] + }, + { + "file": "src/hatty/ui/controls/media_player_screen.py", + "class": "MediaPlayerControlScreen", + "lineno": 65, + "count": 7, + "entries": [ + { + "form": "Binding", + "key": "escape", + "action": "close", + "description": "Close" + }, + { + "form": "Binding", + "key": "q", + "action": "close", + "description": "Close", + "show": false + }, + { + "form": "Binding", + "key": "space", + "action": "toggle_play_pause", + "description": "Play/Pause", + "priority": true + }, + { + "form": "Binding", + "key": "s", + "action": "stop_playback", + "description": "Stop", + "show": false + }, + { + "form": "Binding", + "key": "up", + "action": "nav_focus(-1)", + "description": "Focus Up", + "show": false, + "priority": true + }, + { + "form": "Binding", + "key": "down", + "action": "nav_focus(1)", + "description": "Focus Down", + "show": false, + "priority": true + }, + { + "form": "Binding", + "key": "question_mark", + "action": "show_help", + "description": "Help" + } + ] + }, + { + "file": "src/hatty/ui/dashboard/panel_manage_popup.py", + "class": "PanelManagePopup", + "lineno": 26, + "count": 7, + "entries": [ + { + "form": "Binding", + "key": "escape", + "action": "done", + "description": "Done" + }, + { + "form": "Binding", + "key": "q", + "action": "done", + "description": "Done", + "show": false + }, + { + "form": "Binding", + "key": "shift+up", + "action": "move(-1)", + "description": "Move up" + }, + { + "form": "Binding", + "key": "shift+down", + "action": "move(1)", + "description": "Move down" + }, + { + "form": "Binding", + "key": "delete", + "action": "remove", + "description": "Remove" + }, + { + "form": "Binding", + "key": "x", + "action": "remove", + "description": "Remove" + }, + { + "form": "Binding", + "key": "a", + "action": "add", + "description": "Add" + } + ] + }, + { + "file": "src/hatty/ui/dashboard/screen.py", + "class": "DashboardScreen", + "lineno": 218, + "count": 30, + "entries": [ + { + "form": "Binding", + "key": "up", + "action": "move_cursor(-1, 0)", + "description": "Up", + "show": false + }, + { + "form": "Binding", + "key": "down", + "action": "move_cursor(1, 0)", + "description": "Down", + "show": false + }, + { + "form": "Binding", + "key": "left", + "action": "move_cursor(0, -1)", + "description": "Left", + "show": false + }, + { + "form": "Binding", + "key": "right", + "action": "move_cursor(0, 1)", + "description": "Right", + "show": false + }, + { + "form": "Binding", + "key": "enter", + "action": "toggle_slot", + "description": "Toggle" + }, + { + "form": "Binding", + "key": "e", + "action": "expand_slot", + "description": "Controls" + }, + { + "form": "Binding", + "key": "E", + "action": "enter_edit", + "description": "Edit" + }, + { + "form": "Binding", + "key": "r", + "action": "rename_slot_entity", + "description": "Rename" + }, + { + "form": "Binding", + "key": "a", + "action": "edit_slot", + "description": "Assign" + }, + { + "form": "Binding", + "key": "delete", + "action": "clear_slot", + "description": "Clear Slot" + }, + { + "form": "Binding", + "key": "enter", + "action": "grab_move", + "description": "Move" + }, + { + "form": "Binding", + "key": "ctrl+right", + "action": "resize_slot(0, 1)", + "description": "Wider", + "show": false + }, + { + "form": "Binding", + "key": "ctrl+left", + "action": "resize_slot(0, -1)", + "description": "Narrower", + "show": false + }, + { + "form": "Binding", + "key": "ctrl+down", + "action": "resize_slot(1, 0)", + "description": "Taller", + "show": false + }, + { + "form": "Binding", + "key": "ctrl+up", + "action": "resize_slot(-1, 0)", + "description": "Shorter", + "show": false + }, + { + "form": "Binding", + "key": "s", + "action": "split_slot", + "description": "Split" + }, + { + "form": "Binding", + "key": "u", + "action": "unsplit_slot", + "description": "Unsplit", + "show": false + }, + { + "form": "Binding", + "key": "f", + "action": "fill_split", + "description": "Fill" + }, + { + "form": "Binding", + "key": "a", + "action": "toggle_activity_log", + "description": "Activity Log", + "show": false + }, + { + "form": "Binding", + "key": "v", + "action": "show_log_scope", + "description": "Log Scope", + "show": false + }, + { + "form": "Binding", + "key": "f", + "action": "maximize_log", + "description": "Maximize Log", + "show": false + }, + { + "form": "Binding", + "key": "left_square_bracket", + "action": "log_older", + "description": "Older Events", + "show": false + }, + { + "form": "Binding", + "key": "right_square_bracket", + "action": "log_newer", + "description": "Newer Events", + "show": false + }, + { + "form": "Binding", + "key": "l", + "action": "show_list_popup", + "description": "Back to List", + "show": false + }, + { + "form": "Binding", + "key": "d", + "action": "manage_dashboards", + "description": "Dashboards" + }, + { + "form": "Binding", + "key": "D", + "action": "show_device_tree", + "description": "Device Tree" + }, + { + "form": "Binding", + "key": "t", + "action": "cycle_graph_type", + "description": "Graph Type", + "show": false + }, + { + "form": "Binding", + "key": "G", + "action": "graph_fullscreen", + "description": "Full Graph" + }, + { + "form": "Binding", + "key": "question_mark", + "action": "show_help", + "description": "Help" + }, + { + "form": "Binding", + "key": "escape", + "action": "go_back", + "description": "Back" + } + ] + }, + { + "file": "src/hatty/ui/dashboard/selection_popup.py", + "class": "DashboardSelectionPopup", + "lineno": 18, + "count": 9, + "entries": [ + { + "form": "tuple", + "key": "delete", + "action": "delete_dashboard", + "description": "Delete", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "e", + "action": "edit_dashboard", + "description": "Edit", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "d", + "action": "set_default", + "description": "Set as Default", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "x", + "action": "export_dashboard", + "description": "Export", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "i", + "action": "import_dashboard", + "description": "Import", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + }, + { + "form": "Binding", + "key": "shift+up", + "action": "move_up", + "description": "Move Up", + "priority": true + }, + { + "form": "Binding", + "key": "shift+down", + "action": "move_down", + "description": "Move Down", + "priority": true + } + ] + }, + { + "file": "src/hatty/ui/dashboard/slot_popup.py", + "class": "DashboardSlotPopup", + "lineno": 90, + "count": 7, + "entries": [ + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + }, + { + "form": "Binding", + "key": "shift+up", + "action": "reorder_selected(-1)", + "description": "Move Up", + "show": false + }, + { + "form": "Binding", + "key": "shift+down", + "action": "reorder_selected(1)", + "description": "Move Down", + "show": false + }, + { + "form": "Binding", + "key": "delete", + "action": "remove_selected", + "description": "Remove", + "show": false + }, + { + "form": "Binding", + "key": "up", + "action": "nav_focus(-1)", + "description": "Focus Up", + "show": false, + "priority": true + }, + { + "form": "Binding", + "key": "down", + "action": "nav_focus(1)", + "description": "Focus Down", + "show": false, + "priority": true + } + ] + }, + { + "file": "src/hatty/ui/dashboard/split_slot_popup.py", + "class": "SplitSlotPopup", + "lineno": 13, + "count": 4, + "entries": [ + { + "form": "tuple", + "key": "v", + "action": "split('v')", + "description": "Left/Right", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "h", + "action": "split('h')", + "description": "Top/Bottom", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "q", + "action": "split('quad')", + "description": "Quarters", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + } + ] + }, + { + "file": "src/hatty/ui/device_tree_screen.py", + "class": "AreaNamePopup", + "lineno": 293, + "count": 2, + "entries": [ + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/device_tree_screen.py", + "class": "AreaPickerPopup", + "lineno": 330, + "count": 2, + "entries": [ + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/device_tree_screen.py", + "class": "DeviceInfoPopup", + "lineno": 376, + "count": 3, + "entries": [ + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "i", + "action": "cancel", + "description": "Close", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/device_tree_screen.py", + "class": "DeviceTreeScreen", + "lineno": 410, + "count": 17, + "entries": [ + { + "form": "Binding", + "key": "/", + "action": "toggle_search", + "description": "Search" + }, + { + "form": "Binding", + "key": "e", + "action": "expand_entity", + "description": "Controls" + }, + { + "form": "Binding", + "key": "G", + "action": "graph_fullscreen", + "description": "Graph" + }, + { + "form": "Binding", + "key": "v", + "action": "cycle_mode", + "description": "View" + }, + { + "form": "Binding", + "key": "m", + "action": "move_device", + "description": "Move to Area" + }, + { + "form": "Binding", + "key": "i", + "action": "device_info", + "description": "Info" + }, + { + "form": "Binding", + "key": "a", + "action": "create_area", + "description": "New Area" + }, + { + "form": "Binding", + "key": "r", + "action": "rename", + "description": "Rename" + }, + { + "form": "Binding", + "key": "l", + "action": "jump_to_list", + "description": "Lists" + }, + { + "form": "Binding", + "key": "d", + "action": "open_dashboard", + "description": "Dashboard" + }, + { + "form": "Binding", + "key": "n", + "action": "area_to_dashboard", + "description": "New Dashboard" + }, + { + "form": "Binding", + "key": "x", + "action": "collapse_all", + "description": "Collapse All" + }, + { + "form": "Binding", + "key": "X", + "action": "expand_all", + "description": "Expand All" + }, + { + "form": "Binding", + "key": "question_mark", + "action": "show_help", + "description": "Help" + }, + { + "form": "Binding", + "key": "space", + "action": "toggle_list_membership", + "description": "List", + "priority": true + }, + { + "form": "Binding", + "key": "ctrl+s", + "action": "cycle_scope", + "description": "Scope", + "priority": true + }, + { + "form": "Binding", + "key": "escape", + "action": "go_back", + "description": "Back" + } + ] + }, + { + "file": "src/hatty/ui/graph/color_popup.py", + "class": "GraphColorPopup", + "lineno": 80, + "count": 2, + "entries": [ + { + "form": "Binding", + "key": "escape", + "action": "cancel", + "description": "Cancel" + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/graph/duration_popup.py", + "class": "GraphDurationPopup", + "lineno": 31, + "count": 3, + "entries": [ + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + }, + { + "form": "Binding", + "key": "enter", + "action": "confirm", + "description": "Select", + "priority": true + } + ] + }, + { + "file": "src/hatty/ui/graph/preview_screen.py", + "class": "GraphPreviewScreen", + "lineno": 126, + "count": 32, + "entries": [ + { + "form": "Binding", + "key": "t", + "action": "cycle_plot_type", + "description": "Graph Type" + }, + { + "form": "Binding", + "key": "left", + "action": "scroll_back", + "description": "Older" + }, + { + "form": "Binding", + "key": "left", + "action": "cursor_prev", + "description": "Prev Sample" + }, + { + "form": "Binding", + "key": "right", + "action": "scroll_forward", + "description": "Newer" + }, + { + "form": "Binding", + "key": "right", + "action": "cursor_next", + "description": "Next Sample" + }, + { + "form": "Binding", + "key": "shift+left", + "action": "scroll_back_fast", + "description": { + "__expr__": "f\"Older \u00d7{_FAST_PAGE_MULTIPLIER}\"" + } + }, + { + "form": "Binding", + "key": "shift+left", + "action": "cursor_prev_fast", + "description": "Prev Sample \u00d710%" + }, + { + "form": "Binding", + "key": "shift+right", + "action": "scroll_forward_fast", + "description": { + "__expr__": "f\"Newer \u00d7{_FAST_PAGE_MULTIPLIER}\"" + } + }, + { + "form": "Binding", + "key": "shift+right", + "action": "cursor_next_fast", + "description": "Next Sample \u00d710%" + }, + { + "form": "Binding", + "key": "plus", + "action": "zoom_in", + "description": "Zoom In" + }, + { + "form": "Binding", + "key": "minus", + "action": "zoom_out", + "description": "Zoom Out" + }, + { + "form": "Binding", + "key": "home", + "action": "snap_live", + "description": "Now" + }, + { + "form": "Binding", + "key": "home", + "action": "cursor_home", + "description": "Oldest Sample" + }, + { + "form": "Binding", + "key": "end", + "action": "cursor_end", + "description": "Newest Sample" + }, + { + "form": "Binding", + "key": "enter", + "action": "toggle_cursor_mode", + "description": "Inspect" + }, + { + "form": "Binding", + "key": "enter", + "action": "exit_cursor_mode", + "description": "Exit Inspect" + }, + { + "form": "Binding", + "key": "S", + "action": "save_graph", + "description": "Save As" + }, + { + "form": "Binding", + "key": "u", + "action": "update_graph", + "description": "Update" + }, + { + "form": "Binding", + "key": "tab", + "action": "next_entity", + "description": "Next Line", + "show": false + }, + { + "form": "Binding", + "key": "c", + "action": "cycle_color", + "description": "Color" + }, + { + "form": "Binding", + "key": "C", + "action": "pick_color", + "description": "Color Picker" + }, + { + "form": "Binding", + "key": "l", + "action": "show_list_popup", + "description": "Back to List", + "show": false + }, + { + "form": "Binding", + "key": "a", + "action": "toggle_event_log", + "description": "Activity Log" + }, + { + "form": "Binding", + "key": "v", + "action": "show_log_scope", + "description": "Log View" + }, + { + "form": "Binding", + "key": "f", + "action": "maximize_log", + "description": "Maximize Log", + "show": false + }, + { + "form": "Binding", + "key": "question_mark", + "action": "show_help", + "description": "Help" + }, + { + "form": "Binding", + "key": "escape", + "action": "exit_cursor_mode", + "description": "Exit Inspect" + }, + { + "form": "Binding", + "key": "escape", + "action": "close_event_log", + "description": "Close Log" + }, + { + "form": "Binding", + "key": "escape", + "action": "go_back", + "description": "Back" + }, + { + "form": "Binding", + "key": "q", + "action": "exit_cursor_mode", + "description": "Exit Inspect", + "show": false + }, + { + "form": "Binding", + "key": "q", + "action": "close_event_log", + "description": "Close Log", + "show": false + }, + { + "form": "Binding", + "key": "q", + "action": "go_back", + "description": "Back", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/graph/saved_graphs_popup.py", + "class": "SaveGraphNamePopup", + "lineno": 16, + "count": 2, + "entries": [ + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/graph/saved_graphs_popup.py", + "class": "SavedGraphsPopup", + "lineno": 45, + "count": 4, + "entries": [ + { + "form": "tuple", + "key": "r", + "action": "rename_graph", + "description": "Rename", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "delete", + "action": "delete_graph", + "description": "Delete", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/help_popup.py", + "class": "HelpPopup", + "lineno": 150, + "count": 7, + "entries": [ + { + "form": "Binding", + "key": "left", + "action": "prev_page", + "description": "Prev Page" + }, + { + "form": "Binding", + "key": "right", + "action": "next_page", + "description": "Next Page" + }, + { + "form": "Binding", + "key": "/", + "action": "focus_filter", + "description": "Search" + }, + { + "form": "Binding", + "key": "a", + "action": "toggle_all", + "description": "Show All" + }, + { + "form": "Binding", + "key": "escape", + "action": "dismiss", + "description": "Close" + }, + { + "form": "Binding", + "key": "question_mark", + "action": "dismiss", + "description": "Close", + "show": false + }, + { + "form": "Binding", + "key": "q", + "action": "dismiss", + "description": "Close", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/list_selection_popup.py", + "class": "ListSelectionPopup", + "lineno": 20, + "count": 10, + "entries": [ + { + "form": "tuple", + "key": "delete", + "action": "delete_list", + "description": "Delete List", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "r", + "action": "rename_list", + "description": "Rename", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "d", + "action": "set_default", + "description": "Set as Default", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "n", + "action": "toggle_notify", + "description": "Notify", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "v", + "action": "view_as_dashboard", + "description": "View as Dashboard", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "escape", + "action": "cancel", + "description": "Cancel", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + }, + { + "form": "tuple", + "key": "/", + "action": "toggle_search", + "description": "Search", + "__tuple_form__": true + }, + { + "form": "Binding", + "key": "shift+up", + "action": "move_up", + "description": "Move Up", + "priority": true + }, + { + "form": "Binding", + "key": "shift+down", + "action": "move_down", + "description": "Move Down", + "priority": true + } + ] + }, + { + "file": "src/hatty/ui/log_scope_popup.py", + "class": "LogScopePopup", + "lineno": 52, + "count": 2, + "entries": [ + { + "form": "Binding", + "key": "escape", + "action": "cancel", + "description": "Cancel" + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/onboarding_screen.py", + "class": "OnboardingScreen", + "lineno": 20, + "count": 4, + "entries": [ + { + "form": "Binding", + "key": "ctrl+t", + "action": "test_connection", + "description": "Test Connection" + }, + { + "form": "Binding", + "key": "ctrl+s", + "action": "save", + "description": "Save & Connect" + }, + { + "form": "Binding", + "key": "ctrl+v", + "action": "toggle_token", + "description": "Show/Hide Token" + }, + { + "form": "Binding", + "key": "escape", + "action": "cancel", + "description": "Cancel" + } + ] + }, + { + "file": "src/hatty/ui/rename_entity_popup.py", + "class": "RenameEntityPopup", + "lineno": 12, + "count": 3, + "entries": [ + { + "form": "Binding", + "key": "escape", + "action": "cancel", + "description": "Cancel" + }, + { + "form": "Binding", + "key": "q", + "action": "cancel", + "description": "Cancel", + "show": false + }, + { + "form": "Binding", + "key": "enter", + "action": "save_local", + "description": "Save Locally" + } + ] + }, + { + "file": "src/hatty/ui/search_input.py", + "class": "SearchInput", + "lineno": 21, + "count": 1, + "entries": [ + { + "form": "Binding", + "key": "tab", + "action": "toggle_mode", + "description": "Toggle filter/jump", + "show": false + } + ] + }, + { + "file": "src/hatty/ui/weather_forecast_screen.py", + "class": "WeatherForecastScreen", + "lineno": 99, + "count": 3, + "entries": [ + { + "form": "Binding", + "key": "escape", + "action": "go_back", + "description": "Back" + }, + { + "form": "Binding", + "key": "t", + "action": "cycle_type", + "description": "Switch type" + }, + { + "form": "Binding", + "key": "question_mark", + "action": "show_help", + "description": "Help" + } + ] + } +] diff --git a/tests/unit/test_keybindings.py b/tests/unit/test_keybindings.py new file mode 100644 index 0000000..adec1f8 --- /dev/null +++ b/tests/unit/test_keybindings.py @@ -0,0 +1,292 @@ +# hatty — MIT License. See LICENSE file for details. +"""Unit tests for the keybinding registry (controllers/keybindings.py). + +The migration-fidelity test is the safety net for issue #50's refactor: every +screen's old literal `BINDINGS` list became `bindings_for(scope)`, generated +from `tests/unit/binding_snapshot.json` — a golden snapshot of every +`BINDINGS` block captured *before* the migration. This test asserts +`bindings_for(scope)` reproduces that snapshot exactly, field for field, in +order, for every migrated scope, so a dropped `priority=True` or a typo'd +description fails loudly instead of silently changing app behavior. +""" + +import json +from pathlib import Path + +import pytest +from textual.binding import Binding + +from hatty.controllers import keybindings as kb + +SNAPSHOT_PATH = Path(__file__).parent / "binding_snapshot.json" + +# class name -> registry scope key. ConfigScreen is intentionally absent: its +# BINDINGS stay a hand-written literal (see keybindings.py's module docstring) +# so the config screen can never be rebound into being unreachable. +SCOPE_MAP = { + "HACLI": "app", + "ColumnConfigPopup": "column_config", + "ConfirmPopup": "confirm", + "EntityControlPopup": "control_popup", + "EntityPickerModal": "entity_picker", + "ColorPickerModal": "color_picker", + "LightControlScreen": "light", + "MediaPlayerControlScreen": "media_player", + "PanelManagePopup": "panel_manage", + "DashboardScreen": "dashboard", + "DashboardSelectionPopup": "dashboard_select", + "DashboardSlotPopup": "slot_popup", + "SplitSlotPopup": "split_slot", + "AreaNamePopup": "area_name", + "AreaPickerPopup": "area_picker", + "DeviceInfoPopup": "device_info", + "DeviceTreeScreen": "tree", + "GraphColorPopup": "graph_color", + "GraphDurationPopup": "graph_duration", + "GraphPreviewScreen": "graph", + "SaveGraphNamePopup": "save_graph_name", + "SavedGraphsPopup": "saved_graphs_popup", + "HelpPopup": "help_popup", + "ListSelectionPopup": "list_popup", + "LogScopePopup": "log_scope_popup", + "OnboardingScreen": "onboarding", + "RenameEntityPopup": "rename_popup", + "SearchInput": "search_input", + "WeatherForecastScreen": "weather", +} + + +def _snapshot_blocks(): + return json.loads(SNAPSHOT_PATH.read_text()) + + +def _expected_row(entry: dict) -> tuple: + desc = entry["description"] + if isinstance(desc, dict) and "__expr__" in desc: + # The two GraphPreviewScreen fast-page descriptions are f-strings + # over FAST_PAGE_MULTIPLIER in the original source. + from hatty.const import FAST_PAGE_MULTIPLIER + + desc = f"Older ×{FAST_PAGE_MULTIPLIER}" if "Older" in desc["__expr__"] else f"Newer ×{FAST_PAGE_MULTIPLIER}" + show = True if entry.get("show") is None else entry["show"] + priority = False if entry.get("priority") is None else entry["priority"] + return (entry["key"], entry["action"], desc, show, priority) + + +def _actual_row(binding: Binding) -> tuple: + return (binding.key, binding.action, binding.description, binding.show, binding.priority) + + +@pytest.mark.parametrize("block", _snapshot_blocks(), ids=lambda b: f"{b['class']}") +def test_bindings_for_reproduces_snapshot(block): + scope = SCOPE_MAP.get(block["class"]) + if scope is None: + pytest.skip(f"{block['class']} keeps a literal BINDINGS list, not migrated") + expected = [_expected_row(e) for e in block["entries"]] + # _binding_list (not the public bindings_for) so `b` is statically a + # Binding, matching what this codebase actually constructs at every call + # site — bindings_for's wider BindingType return only exists to satisfy + # Textual's invariant `list[BindingType]` BINDINGS declaration. + actual = [_actual_row(b) for b in kb._binding_list(scope)] + assert actual == expected + + +def test_snapshot_scope_map_is_exhaustive(): + """Every BINDINGS block in the golden snapshot is either mapped to a + registry scope or explicitly (ConfigScreen) excluded — so a class renamed + or added later can't silently fall out of coverage.""" + classes = {block["class"] for block in _snapshot_blocks()} + assert classes - set(SCOPE_MAP) == {"ConfigScreen"} + + +def test_all_scopes_covered(): + assert set(kb.SCOPES) == set(SCOPE_MAP.values()) + + +# ── Registry invariants ────────────────────────────────────────────────────── + + +def test_ids_have_a_consistent_default_key_across_scopes(): + """resolve_keymap()/KeybindingController.key_for() both pick BY_ID[id][0].key + as *the* default — every row sharing an id must agree on it, or a shared + id (e.g. nav.back) would resolve differently depending on which row was + registered first.""" + for spec_id, specs in kb.BY_ID.items(): + keys = {spec.key for spec in specs} + assert len(keys) == 1, f"{spec_id} has inconsistent default keys: {keys}" + + +def test_duplicate_ids_within_a_scope_are_only_deliberate_twins(): + """An id repeating within one scope is fine *only* when it's the + deliberate "move together" pattern (e.g. GraphPreviewScreen's three + `escape` rows all sharing `nav.back`) — each repeat must be a distinct + action, never the same (key, action) pair registered twice.""" + for scope, specs in kb.BY_SCOPE.items(): + seen: dict[str, set[str]] = {} + for spec in specs: + actions = seen.setdefault(spec.id, set()) + assert spec.action not in actions, f"{spec.id} repeats action {spec.action!r} in scope {scope!r}" + actions.add(spec.action) + + +def test_curated_ids_are_unique_and_labeled(): + sections = kb.rebindable() + all_ids = [spec.id for _section, specs in sections for spec in specs] + assert len(all_ids) == len(set(all_ids)) + for section, specs in sections: + assert section in kb.SECTION_ORDER + for spec in specs: + assert spec.label + + +def test_reserved_keys_never_default_for_any_id(): + for spec in kb.REGISTRY: + if spec.id != "app.quit": + assert spec.key not in kb.RESERVED_KEYS + + +# ── resolve_keymap / sanitize / validate ───────────────────────────────────── + + +def test_resolve_keymap_is_complete(): + keymap = kb.resolve_keymap({}) + assert set(keymap) == set(kb.BY_ID) + assert keymap["log.toggle"] == "a" + + +def test_resolve_keymap_applies_override(): + keymap = kb.resolve_keymap({"log.toggle": "A"}) + assert keymap["log.toggle"] == "A" + # untouched ids keep their default + assert keymap["nav.back"] == "escape" + + +def test_sanitize_drops_unknown_ids(): + assert kb.sanitize({"nonexistent.id": "x"}) == {} + + +def test_sanitize_drops_reserved_and_empty_keys(): + assert kb.sanitize({"log.toggle": "ctrl+q"}) == {} + assert kb.sanitize({"log.toggle": ""}) == {} + + +def test_sanitize_drops_noop_overrides_restating_the_default(): + assert kb.sanitize({"log.toggle": "a"}) == {} + + +def test_sanitize_ignores_non_dict_input(): + assert kb.sanitize(None) == {} + assert kb.sanitize(["a", "b"]) == {} + + +def test_validate_allows_a_free_key(): + assert kb.validate("log.toggle", "A", {}) is None + + +def test_validate_blocks_reserved_key(): + assert kb.validate("log.toggle", "ctrl+q", {}) is not None + + +def test_validate_blocks_conflict_in_a_shared_scope(): + # log.toggle and log.scope both live in {app, dashboard, graph}; log.scope + # defaults to "v" — reassigning log.toggle to "v" collides in all three. + error = kb.validate("log.toggle", "v", {}) + assert error is not None + assert "Scope" in error + + +def test_validate_allows_resetting_to_default_despite_a_baseline_twin_overlap(): + # dashboard.edit_slot (Edit-mode "a", never rebindable) and log.toggle + # (Use-mode "a", curated) share "a" by default in the dashboard scope — + # a deliberate, check_action-gated overlap (TWINS), not a real conflict. + # Resetting log.toggle back to "a" must not trip validate() against it. + assert kb.validate("log.toggle", "a", {"log.toggle": "A"}) is None + + +def test_twins_pairs_ids_sharing_a_scope_and_key_by_default(): + assert "dashboard.edit_slot" in kb.TWINS.get("log.toggle", frozenset()) + assert "log.toggle" in kb.TWINS.get("dashboard.edit_slot", frozenset()) + # nav.back's three GraphPreviewScreen rows all share one *id*, not three + # distinct ids colliding on a key — never a TWINS entry. + assert "nav.back" not in kb.TWINS + + +def test_validate_allows_reusing_a_key_in_a_disjoint_scope(): + # dashboard.rename_slot_entity ("r") only lives in the dashboard scope; + # entity.rename ("r", app-only) rebinding elsewhere doesn't touch it, and + # rebinding entity.rename to some other dashboard-only key is fine since + # entity.rename never appears in the dashboard scope. + assert kb.validate("entity.rename", "E", {}) is None + + +def test_validate_considers_hypothetical_override_not_just_current(): + # Rebind log.scope away from "v" first; now "v" should be free for + # log.toggle when validating against that hypothetical state. + overrides = {"log.scope": "V"} + assert kb.validate("log.toggle", "v", overrides) is None + + +def test_validate_unknown_id_raises(): + with pytest.raises(KeyError): + kb.validate("nonexistent.id", "x", {}) + + +# ── bindings_for / display_key ─────────────────────────────────────────────── + + +def test_bindings_for_unknown_scope_is_empty(): + assert kb.bindings_for("nonexistent-scope") == [] + + +def test_bindings_for_multiple_scopes_concatenates_in_order(): + combined = kb._binding_list("confirm", "control_popup") + assert [b.key for b in combined[:4]] == ["y", "n", "escape", "q"] + + +def test_display_key_uses_help_popup_mapping(): + assert kb.display_key("escape") == "Esc" + assert kb.display_key("a") == "a" + + +# ── KeybindingController ───────────────────────────────────────────────────── + + +class _FakeApp: + def __init__(self): + self.keymap = None + + def set_keymap(self, keymap): + self.keymap = keymap + + +def test_controller_apply_sanitizes_and_pushes_keymap(): + app = _FakeApp() + ctl = kb.KeybindingController(app) + cfg = {"keybindings": {"log.toggle": "A", "bogus.id": "z"}} + ctl.apply(cfg) + assert ctl.overrides == {"log.toggle": "A"} + assert cfg["keybindings"] == {"log.toggle": "A"} + assert app.keymap == kb.resolve_keymap({"log.toggle": "A"}) + + +def test_controller_key_for_and_display(): + app = _FakeApp() + ctl = kb.KeybindingController(app) + ctl.apply({"keybindings": {"log.toggle": "A"}}) + assert ctl.key_for("log.toggle") == "A" + assert ctl.key_for("nav.back") == "escape" + assert ctl.display("nav.back") == "Esc" + + +def test_controller_static_bindings_reflects_overrides(): + app = _FakeApp() + ctl = kb.KeybindingController(app) + ctl.apply({"keybindings": {"log.toggle": "A"}}) + dashboard_bindings = ctl.static_bindings("dashboard") + log_toggle = next(b for b in dashboard_bindings if b.id == "log.toggle") + assert log_toggle.key == "A" + # a sibling binding at the *old* default key must be untouched — this is + # the gotcha #1 regression: dashboard's Edit-mode "a" (edit_slot) must + # survive log.toggle moving off of "a". + edit_slot = next(b for b in dashboard_bindings if b.id == "dashboard.edit_slot") + assert edit_slot.key == "a"