From d143f5382579fb5d2d5ee15ee509b1babaa2ed0f Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:55:33 -0500 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Extract=20shared=20row?= =?UTF-8?q?-aware=20focus-nav=20helpers=20Refs=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/ui/controls/light_screen.py | 38 ++------------ src/hatty/ui/controls/media_player_screen.py | 39 ++------------ src/hatty/ui/focus_nav.py | 55 ++++++++++++++++++++ tests/test_light_control.py | 4 +- 4 files changed, 66 insertions(+), 70 deletions(-) create mode 100644 src/hatty/ui/focus_nav.py diff --git a/src/hatty/ui/controls/light_screen.py b/src/hatty/ui/controls/light_screen.py index 89adfda..7a0784f 100644 --- a/src/hatty/ui/controls/light_screen.py +++ b/src/hatty/ui/controls/light_screen.py @@ -31,7 +31,6 @@ from textual.containers import Container, Horizontal, VerticalScroll from textual.screen import ModalScreen from textual.timer import Timer -from textual.widget import Widget from textual.widgets import Button, Footer, Input, Label, OptionList, Static, TabbedContent, TabPane, Tabs from textual.widgets.option_list import Option from textual_colorpicker import ColorPicker @@ -39,6 +38,7 @@ 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 +from hatty.ui.focus_nav import enclosing_row, focus_within_row, nav_focus from hatty.ui.popup_base import PopupScreen if TYPE_CHECKING: @@ -426,30 +426,6 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.action_open_color_picker() event.stop() - def _enclosing_row(self, widget: Widget | None) -> Widget | None: - """The `#color_swatches`/`#white_presets` Horizontal row containing `widget`, if any.""" - if widget is None: - return None - for node in widget.ancestors_with_self: - if isinstance(node, Widget) and node.id in _BUTTON_ROW_IDS: - return node - return None - - def _focus_within_row(self, row: Widget, focused: Widget, step: int) -> None: - buttons = list(row.query(Button)) - if not buttons: - return - index = next((i for i, button in enumerate(buttons) if button is focused), 0) - buttons[(index + step) % len(buttons)].focus() - - def _focus_out_of_row(self, row: Widget, step: int) -> None: - """Move focus in `step`'s direction, skipping the whole row as one unit.""" - step_focus = self.focus_next if step > 0 else self.focus_previous - for _ in range(len(self.focus_chain)): - landed = step_focus() - if landed is None or row not in landed.ancestors_with_self: - return - def on_key(self, event: events.Key) -> None: # Left/right walk the focus chain unless a slider/input owns them — or the tab # bar, where they natively switch panes (issue #88). Up/down are handled as @@ -459,9 +435,9 @@ def on_key(self, event: events.Key) -> None: if isinstance(focused, (Input, PercentageSlider, KelvinSlider, Tabs)): return if event.key in ("left", "right"): - row = self._enclosing_row(focused) + row = enclosing_row(focused, _BUTTON_ROW_IDS) if row is not None and focused is not None: - self._focus_within_row(row, focused, 1 if event.key == "right" else -1) + focus_within_row(row, focused, 1 if event.key == "right" else -1) elif event.key == "left": self.focus_previous() else: @@ -499,13 +475,7 @@ def action_show_help(self) -> None: def action_nav_focus(self, direction: int) -> None: # A focused slider (or any single widget) just steps one at a time; a row # (swatches/presets) is skipped as a whole block (issue #286). - row = self._enclosing_row(self.focused) - if row is not None: - self._focus_out_of_row(row, direction) - elif direction > 0: - self.focus_next() - else: - self.focus_previous() + nav_focus(self, _BUTTON_ROW_IDS, direction) def action_toggle_power(self) -> None: entity = self.app.find_entity(self._entity_id) or self._entity diff --git a/src/hatty/ui/controls/media_player_screen.py b/src/hatty/ui/controls/media_player_screen.py index b7bce3e..dceff2e 100644 --- a/src/hatty/ui/controls/media_player_screen.py +++ b/src/hatty/ui/controls/media_player_screen.py @@ -29,13 +29,13 @@ from textual.containers import Horizontal, VerticalScroll from textual.screen import ModalScreen from textual.timer import Timer -from textual.widget import Widget from textual.widgets import Button, Footer, Label, OptionList, Select, Static from hatty.const import media_supports 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 +from hatty.ui.focus_nav import enclosing_row, focus_within_row, nav_focus if TYPE_CHECKING: from hatty.main import HACLI @@ -305,9 +305,9 @@ def on_key(self, event: events.Key) -> None: if isinstance(focused, (PercentageSlider, OptionList)): return if event.key in ("left", "right"): - row = self._enclosing_row(focused) + row = enclosing_row(focused, _BUTTON_ROW_IDS) if row is not None and focused is not None: - self._focus_within_row(row, focused, 1 if event.key == "right" else -1) + focus_within_row(row, focused, 1 if event.key == "right" else -1) elif event.key == "left": self.focus_previous() else: @@ -338,38 +338,7 @@ def action_nav_focus(self, direction: int) -> None: # A focused slider (or any other single widget) just steps one at a time; a # button row (transport/toggle) is skipped as a whole block (mirrors # light_screen.py's #286 pattern). - row = self._enclosing_row(self.focused) - if row is not None: - self._focus_out_of_row(row, direction) - elif direction > 0: - self.focus_next() - else: - self.focus_previous() - - def _enclosing_row(self, widget: Widget | None) -> Widget | None: - """The `#transport_buttons`/`#toggle_buttons` Horizontal row containing `widget`, if any.""" - if widget is None: - return None - for node in widget.ancestors_with_self: - if isinstance(node, Widget) and node.id in _BUTTON_ROW_IDS: - return node - return None - - def _focus_within_row(self, row: Widget, focused: Widget, step: int) -> None: - """Cycle focus among `row`'s buttons, wrapping at the ends.""" - buttons = list(row.query(Button)) - if not buttons: - return - index = next((i for i, button in enumerate(buttons) if button is focused), 0) - buttons[(index + step) % len(buttons)].focus() - - def _focus_out_of_row(self, row: Widget, step: int) -> None: - """Move focus in `step`'s direction, skipping the whole row as one unit.""" - step_focus = self.focus_next if step > 0 else self.focus_previous - for _ in range(len(self.focus_chain)): - landed = step_focus() - if landed is None or row not in landed.ancestors_with_self: - return + nav_focus(self, _BUTTON_ROW_IDS, direction) def action_toggle_play_pause(self) -> None: if not self.supports_play_pause: diff --git a/src/hatty/ui/focus_nav.py b/src/hatty/ui/focus_nav.py new file mode 100644 index 0000000..1813019 --- /dev/null +++ b/src/hatty/ui/focus_nav.py @@ -0,0 +1,55 @@ +# hatty — MIT License. See LICENSE file for details. +"""Shared row-aware focus navigation for modal screens with button rows (issue #36). + +The convention (established by `light_screen.py`/`media_player_screen.py`, issues #88/#286): +left/right cycle within a `Horizontal` button row (wrapping) but hand off to a focused +widget's own native handling (a slider, an `Input`, an expanded `Select`'s overlay) when one +of those owns focus instead; up/down are a priority `Binding` that always steps focus one +field at a time, treating a button row as a single stop rather than one button at a time — +`check_action` is how each screen releases `nav_focus` while a widget with its own up/down +cursor (an `OptionList`, a `DataTable`, a `ListView`) is focused. +""" + +from textual.screen import Screen +from textual.widget import Widget +from textual.widgets import Button + + +def enclosing_row(widget: Widget | None, row_ids: tuple[str, ...]) -> Widget | None: + """The `row_ids`-tagged `Horizontal` containing `widget`, if any.""" + if widget is None: + return None + for node in widget.ancestors_with_self: + if isinstance(node, Widget) and node.id in row_ids: + return node + return None + + +def focus_within_row(row: Widget, focused: Widget, step: int) -> None: + """Cycle focus among `row`'s buttons, wrapping at the ends.""" + buttons = list(row.query(Button)) + if not buttons: + return + index = next((i for i, button in enumerate(buttons) if button is focused), 0) + buttons[(index + step) % len(buttons)].focus() + + +def focus_out_of_row(screen: Screen, row: Widget, step: int) -> None: + """Move focus in `step`'s direction, skipping the whole row as one unit.""" + step_focus = screen.focus_next if step > 0 else screen.focus_previous + for _ in range(len(screen.focus_chain)): + landed = step_focus() + if landed is None or row not in landed.ancestors_with_self: + return + + +def nav_focus(screen: Screen, row_ids: tuple[str, ...], direction: int) -> None: + """The shared `action_nav_focus` body: a focused row is skipped as a whole block; + anything else just steps one field at a time.""" + row = enclosing_row(screen.focused, row_ids) + if row is not None: + focus_out_of_row(screen, row, direction) + elif direction > 0: + screen.focus_next() + else: + screen.focus_previous() diff --git a/tests/test_light_control.py b/tests/test_light_control.py index 3f501ce..7cd614c 100644 --- a/tests/test_light_control.py +++ b/tests/test_light_control.py @@ -2,9 +2,11 @@ from textual.color import Color from textual.widgets import Button, Input, Static, TabbedContent, Tabs +from hatty.ui.controls import light_screen from hatty.ui.controls.kelvin_slider import KelvinSlider from hatty.ui.controls.light_screen import LightControlScreen, hsv_to_rgb from hatty.ui.controls.percentage_slider import PercentageSlider +from hatty.ui.focus_nav import enclosing_row from hatty.ui.help_popup import HelpPopup from tests.conftest import make_config @@ -430,7 +432,7 @@ async def test_up_down_leave_the_color_swatch_row(make_app): await pilot.pause() await pilot.press("up") await pilot.pause() - assert screen._enclosing_row(app.focused) is None + assert enclosing_row(app.focused, light_screen._BUTTON_ROW_IDS) is None assert isinstance(app.focused, Tabs) screen.query_one("#btn_swatch_green", Button).focus() From b503552a90c9f40e115b8be946c727b04a4e42ac Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:07:18 -0500 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20Add=20=E2=86=91/=E2=86=93=20fie?= =?UTF-8?q?ld=20navigation=20to=20the=20slot=20assignment=20popup=20Refs?= =?UTF-8?q?=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/ui/dashboard/slot_popup.py | 80 +++++++++++-------- src/hatty/ui/focus_nav.py | 6 +- tests/test_dashboard_slots.py | 111 +++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 35 deletions(-) diff --git a/src/hatty/ui/dashboard/slot_popup.py b/src/hatty/ui/dashboard/slot_popup.py index 5de0991..e4e1a55 100644 --- a/src/hatty/ui/dashboard/slot_popup.py +++ b/src/hatty/ui/dashboard/slot_popup.py @@ -29,6 +29,14 @@ in a fixed pane to the right of the main column, shown only when the terminal is wide enough to fit both side by side (`preview_fits`, issue #11); on narrow terminals it's hidden outright rather than shuffled by step or type. + +`up`/`down` are a priority binding (issue #36) that always steps focus one +field at a time, mirroring `light_screen.py`/`media_player_screen.py` — the +entity table, the selected-entities list, and an open type dropdown keep +their own up/down row cursor instead (released via `check_action`). +`left`/`right` cycle within `#type_step_buttons` when one of its buttons is +focused, else step focus like up/down; a focused `Input` or open dropdown +keeps its own native left/right. """ from typing import TYPE_CHECKING, cast @@ -38,12 +46,13 @@ 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, Select +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.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 +from hatty.ui.focus_nav import enclosing_row, focus_within_row, nav_focus from hatty.ui.popup_base import PopupScreen from hatty.ui.search_input import SearchInput @@ -73,6 +82,11 @@ class DashboardSlotPopup(PopupScreen): AUTO_FOCUS = "#widget_type_select" + # The only row where left/right should cycle within it (wrapping) and up/down + # should jump out as a block, rather than stepping through each button (issue #36, + # mirrors light_screen.py/media_player_screen.py's _BUTTON_ROW_IDS convention). + BUTTON_ROW_IDS = ("type_step_buttons",) + BINDINGS = [ ("escape", "cancel", "Cancel"), Binding("q", "cancel", "Cancel", show=False), @@ -84,6 +98,11 @@ class DashboardSlotPopup(PopupScreen): 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), ] DEFAULT_CSS = """ @@ -505,47 +524,42 @@ def on_select_changed(self, event: Select.Changed) -> None: self._update_mode_visibility() self._rebuild_preview() + def check_action(self, action: str, parameters: tuple) -> bool | None: + if action == "nav_focus": + # Let the entity table, the selected-entities list, and an open type + # dropdown's overlay keep their own up/down row cursor. + return not isinstance(self.focused, (DataTable, ListView, OptionList)) + return True + + def action_nav_focus(self, direction: int) -> None: + nav_focus(self, self.BUTTON_ROW_IDS, direction) + def on_key(self, event: Key) -> None: - if self._step == "type": - self._handle_type_step_key(event) - elif self._step == "entity" and self._is_multi_add(): - focused = self.focused + focused = self.focused + # Multi-add's search <-> Done shortcut (issue #254): the entity table below + # keeps its own row cursor, so down can't reach Done from the search box. + if self._step == "entity" and self._is_multi_add(): if event.key == "right" and focused is self.query_one("#entity_search_input"): self.set_focus(self.query_one("#btn_panel_done")) event.prevent_default() + return elif event.key == "left" and focused is self.query_one("#btn_panel_done"): self.set_focus(self.query_one("#entity_search_input")) event.prevent_default() - - def _handle_type_step_key(self, event: Key) -> None: - select = self.query_one("#widget_type_select") - next_button = self.query_one("#btn_next_step") - focused = self.focused - # No third stop once "Pick Entity First" is gone: fill mode never - # composes it, and entity-first's revisited type step hides it. - entity_first_button = None if self._fill_mode or self._entity_first else self.query_one("#btn_entity_first") - if entity_first_button is None: - if event.key == "right" and focused is select: - self.set_focus(next_button) - event.prevent_default() - elif event.key == "left" and focused is next_button: - self.set_focus(select) - event.prevent_default() + return + # A focused Input or an open type dropdown's overlay keep their own native + # left/right (cursor movement, option highlight); everything else either + # cycles within its enclosing button row or steps focus by one field. + if event.key not in ("left", "right") or isinstance(focused, (Input, OptionList)): return - if event.key == "right": - if focused is select: - self.set_focus(next_button) - event.prevent_default() - elif focused is next_button: - self.set_focus(entity_first_button) - event.prevent_default() + row = enclosing_row(focused, self.BUTTON_ROW_IDS) + if row is not None and focused is not None: + focus_within_row(row, focused, 1 if event.key == "right" else -1) elif event.key == "left": - if focused is entity_first_button: - self.set_focus(next_button) - event.prevent_default() - elif focused is next_button: - self.set_focus(select) - event.prevent_default() + self.focus_previous() + else: + self.focus_next() + event.stop() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn_next_step": diff --git a/src/hatty/ui/focus_nav.py b/src/hatty/ui/focus_nav.py index 1813019..a3ec027 100644 --- a/src/hatty/ui/focus_nav.py +++ b/src/hatty/ui/focus_nav.py @@ -26,8 +26,10 @@ def enclosing_row(widget: Widget | None, row_ids: tuple[str, ...]) -> Widget | N def focus_within_row(row: Widget, focused: Widget, step: int) -> None: - """Cycle focus among `row`'s buttons, wrapping at the ends.""" - buttons = list(row.query(Button)) + """Cycle focus among `row`'s buttons, wrapping at the ends. Skips buttons + hidden via `.display` — light_screen/media_player_screen's rows never hide + a composed button, but DashboardSlotPopup's `#btn_entity_first` can be.""" + buttons = [button for button in row.query(Button) if button.display] if not buttons: return index = next((i for i, button in enumerate(buttons) if button is focused), 0) diff --git a/tests/test_dashboard_slots.py b/tests/test_dashboard_slots.py index 76ad825..d8768b3 100644 --- a/tests/test_dashboard_slots.py +++ b/tests/test_dashboard_slots.py @@ -554,3 +554,114 @@ async def test_show_last_changed_checkbox_prefilled_on_reopen(make_app): await pilot.pause() assert popup.query_one("#show_last_changed_check", Checkbox).value is True + + +async def test_slot_popup_down_from_type_select_reaches_next_button(make_app, open_dashboard): + app = make_app() + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("E") # edit mode + await pilot.press("a") + await pilot.pause() + popup = app.screen + assert isinstance(popup, DashboardSlotPopup) + assert popup.focused is popup.query_one("#widget_type_select", Select) + + await pilot.press("down") + await pilot.pause() + assert popup.focused is popup.query_one("#btn_next_step", Button) + + +async def test_slot_popup_left_right_cycle_within_type_step_buttons(make_app, open_dashboard): + app = make_app() + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("E") # edit mode + await pilot.press("a") + await pilot.pause() + popup = app.screen + assert isinstance(popup, DashboardSlotPopup) + + popup.set_focus(popup.query_one("#btn_next_step", Button)) + await pilot.pause() + + await pilot.press("right") + await pilot.pause() + assert popup.focused is popup.query_one("#btn_entity_first", Button) + + # Wraps back within the row rather than leaving it (issue #36). + await pilot.press("right") + await pilot.pause() + assert popup.focused is popup.query_one("#btn_next_step", Button) + + +async def test_slot_popup_up_from_type_step_buttons_returns_to_select(make_app, open_dashboard): + app = make_app() + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("E") # edit mode + await pilot.press("a") + await pilot.pause() + popup = app.screen + assert isinstance(popup, DashboardSlotPopup) + + popup.set_focus(popup.query_one("#btn_entity_first", Button)) + await pilot.pause() + + await pilot.press("up") + await pilot.pause() + assert popup.focused is popup.query_one("#widget_type_select", Select) + + +async def test_slot_popup_down_walks_from_search_to_entity_table(make_app, open_dashboard): + app = make_app() + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("E") # edit mode + await pilot.press("a") + await pilot.pause() + popup = app.screen + assert isinstance(popup, DashboardSlotPopup) + + popup.query_one("#widget_type_select", Select).value = "sensor" + await pilot.pause() + popup.query_one("#btn_next_step", Button).press() + await pilot.pause() + popup.query_one("#entity_search_input", SearchInput).focus() + await pilot.pause() + + # "sensor" isn't a gauge, so #gauge_bounds_row is hidden and skipped. + await pilot.press("down") + await pilot.pause() + assert popup.focused is popup.query_one("#show_last_changed_check", Checkbox) + + await pilot.press("down") + await pilot.pause() + assert popup.focused is popup.query_one("#entity_picker_table", EntitiesTable) + + +async def test_slot_popup_up_down_move_gauge_cursor_within_entity_table(make_app, open_dashboard): + # The entity table keeps its own up/down row cursor rather than losing + # focus (issue #36) — check_action releases nav_focus while it's focused. + app = make_app() + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("E") # edit mode + await pilot.press("a") + await pilot.pause() + popup = app.screen + assert isinstance(popup, DashboardSlotPopup) + + popup.query_one("#widget_type_select", Select).value = "sensor" + await pilot.pause() + popup.query_one("#btn_next_step", Button).press() + await pilot.pause() + table = popup.query_one("#entity_picker_table", EntitiesTable) + table.focus() + await pilot.pause() + start_row = table.cursor_coordinate.row + + await pilot.press("down") + await pilot.pause() + assert popup.focused is table + assert table.cursor_coordinate.row == start_row + 1 From 57212f8970451abb0863e07c8c0ee40e889390b4 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:51:22 -0500 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8E=A8=20Centre=20the=20dashboard=20s?= =?UTF-8?q?lot=20assignment=20popup=20dialog=20Refs=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hatty/ui/dashboard/slot_popup.py | 14 +++++++-- tests/test_dashboard_slots.py | 41 +++++++++++++++++++++++++- tests/test_media_player_control.py | 6 ++-- tests/unit/test_slot_preview_layout.py | 8 ++++- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/hatty/ui/dashboard/slot_popup.py b/src/hatty/ui/dashboard/slot_popup.py index e4e1a55..f2ce0d1 100644 --- a/src/hatty/ui/dashboard/slot_popup.py +++ b/src/hatty/ui/dashboard/slot_popup.py @@ -107,10 +107,14 @@ class DashboardSlotPopup(PopupScreen): DEFAULT_CSS = """ #dashboard_slot_container { - width: auto; + /* Width is set explicitly in Python (_apply_preview_visibility, issue #36): + `auto` doesn't work here — the Footer() composed inside this container is + full-width, so `auto` resolves to the whole screen instead of the content's + actual width, leaving the dialog stuck against the left edge uncentred. */ max-width: 100%; } #slot_body { + width: auto; height: auto; } #slot_main { @@ -383,7 +387,13 @@ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None self._preview_timer = self.set_timer(0.3, lambda: self._rebuild_preview(entity_id)) def _apply_preview_visibility(self) -> None: - self.query_one("#widget_preview").display = preview_fits(self.app.size.width) + # Ties the dialog's width to the same show/hide decision (issue #36): with + # #dashboard_slot_container's width no longer `auto` (see DEFAULT_CSS), this is + # what centres it instead of stretching it to the terminal's full width. + show_preview = preview_fits(self.app.size.width) + self.query_one("#widget_preview").display = show_preview + width = MAIN_WIDTH + PREVIEW_GAP + PREVIEW_WIDTH + POPUP_CHROME if show_preview else MAIN_WIDTH + POPUP_CHROME + self.query_one("#dashboard_slot_container").styles.width = width def on_resize(self, event) -> None: # Terminal resized while the popup is open (issue #11) — re-decide diff --git a/tests/test_dashboard_slots.py b/tests/test_dashboard_slots.py index d8768b3..9fe52c6 100644 --- a/tests/test_dashboard_slots.py +++ b/tests/test_dashboard_slots.py @@ -3,7 +3,7 @@ from textual.widgets import Button, Checkbox, ListView, Select, Static from hatty.ui.dashboard.screen import DashboardScreen, DashboardSlotWidget -from hatty.ui.dashboard.slot_popup import DashboardSlotPopup +from hatty.ui.dashboard.slot_popup import MAIN_WIDTH, POPUP_CHROME, PREVIEW_GAP, PREVIEW_WIDTH, DashboardSlotPopup from hatty.ui.dashboard.widgets.graph import GraphSlotWidget from hatty.ui.dashboard.widgets.panel import PanelSlotWidget from hatty.ui.dashboard.widgets.text import TextSlotWidget @@ -665,3 +665,42 @@ async def test_slot_popup_up_down_move_gauge_cursor_within_entity_table(make_app await pilot.pause() assert popup.focused is table assert table.cursor_coordinate.row == start_row + 1 + + +async def test_slot_popup_is_centred_with_preview_shown(make_app, open_dashboard): + # Regression for issue #36: #dashboard_slot_container used to stretch to the + # terminal's full width (an inner Footer() defeats `width: auto`), leaving the + # dialog stuck against the left edge instead of centred. + app = make_app() + async with app.run_test(size=(120, 40)) as pilot: + await open_dashboard(pilot) + await pilot.press("E") # edit mode + await pilot.press("a") + await pilot.pause() + popup = app.screen + assert isinstance(popup, DashboardSlotPopup) + assert popup.query_one("#widget_preview").display is True + + region = popup.query_one("#dashboard_slot_container").region + assert region.width == MAIN_WIDTH + PREVIEW_GAP + PREVIEW_WIDTH + POPUP_CHROME + left_margin = region.x + right_margin = 120 - (region.x + region.width) + assert abs(left_margin - right_margin) <= 1 + + +async def test_slot_popup_is_centred_with_preview_hidden(make_app, open_dashboard): + app = make_app() + async with app.run_test(size=(80, 24)) as pilot: + await open_dashboard(pilot) + await pilot.press("E") # edit mode + await pilot.press("a") + await pilot.pause() + popup = app.screen + assert isinstance(popup, DashboardSlotPopup) + assert popup.query_one("#widget_preview").display is False + + region = popup.query_one("#dashboard_slot_container").region + assert region.width == MAIN_WIDTH + POPUP_CHROME + left_margin = region.x + right_margin = 80 - (region.x + region.width) + assert abs(left_margin - right_margin) <= 1 diff --git a/tests/test_media_player_control.py b/tests/test_media_player_control.py index 292b898..3528b47 100644 --- a/tests/test_media_player_control.py +++ b/tests/test_media_player_control.py @@ -2,8 +2,10 @@ from textual.widgets import Button, OptionList, Select, Static from hatty.const import MEDIA_FEAT +from hatty.ui.controls import media_player_screen from hatty.ui.controls.media_player_screen import MediaPlayerControlScreen from hatty.ui.controls.percentage_slider import PercentageSlider +from hatty.ui.focus_nav import enclosing_row from hatty.ui.help_popup import HelpPopup from tests.conftest import make_config @@ -382,7 +384,7 @@ async def test_down_skips_rest_of_transport_row_as_a_block(make_app): await pilot.press("down") await pilot.pause() - assert screen._enclosing_row(app.focused) is None + assert enclosing_row(app.focused, media_player_screen._BUTTON_ROW_IDS) is None assert app.focused is screen.query_one("#field_source", Select) @@ -400,7 +402,7 @@ async def test_down_enters_toggle_row_at_shuffle_then_skips_out_on_repeat(make_a await pilot.press("down") # already inside the row -> skip the whole block await pilot.pause() - assert screen._enclosing_row(app.focused) is None + assert enclosing_row(app.focused, media_player_screen._BUTTON_ROW_IDS) is None async def test_up_from_toggle_row_reaches_field_sound_mode(make_app): diff --git a/tests/unit/test_slot_preview_layout.py b/tests/unit/test_slot_preview_layout.py index 4a71504..7caadac 100644 --- a/tests/unit/test_slot_preview_layout.py +++ b/tests/unit/test_slot_preview_layout.py @@ -1,5 +1,5 @@ # hatty — MIT License. See LICENSE file for details. -from hatty.ui.dashboard.slot_popup import preview_fits +from hatty.ui.dashboard.slot_popup import MAIN_WIDTH, POPUP_CHROME, PREVIEW_GAP, PREVIEW_WIDTH, preview_fits def test_preview_hidden_just_below_threshold(): @@ -16,3 +16,9 @@ def test_preview_hidden_at_zero_width(): def test_preview_shown_comfortably_wide(): assert preview_fits(200) is True + + +def test_preview_fits_threshold_matches_the_widened_dialog_width(): + # _apply_preview_visibility (issue #36) sizes #dashboard_slot_container from these + # same constants — preview_fits's threshold is exactly that widened width. + assert MAIN_WIDTH + PREVIEW_GAP + PREVIEW_WIDTH + POPUP_CHROME == 107