diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 4a669bd..e383b6d 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -517,6 +517,8 @@ class HostsManagerApp(App): def action_show_filters(self) -> None: """Open the advanced filter controls and apply their returned options.""" + if isinstance(self.screen, FilterModal): + return def handle_filter_result(filter_options: FilterOptions | None) -> None: if filter_options is None: diff --git a/src/hosts/tui/filter_modal.py b/src/hosts/tui/filter_modal.py index 47ef632..f60a393 100644 --- a/src/hosts/tui/filter_modal.py +++ b/src/hosts/tui/filter_modal.py @@ -6,17 +6,15 @@ filtering options including status, type, resolution status, and search filterin """ from textual.app import ComposeResult -from textual.containers import Grid, Horizontal, Container +from textual.containers import Horizontal, Vertical, VerticalScroll from textual.widgets import ( Static, Button, Checkbox, Input, Select, - Label, RadioSet, RadioButton, - Collapsible, ) from textual.screen import ModalScreen from textual import on @@ -24,131 +22,19 @@ from textual.binding import Binding from typing import Optional, Dict, List from ..core.filters import FilterOptions, EntryFilter +from .styles import FILTER_MODAL_CSS class FilterModal(ModalScreen[Optional[FilterOptions]]): """Advanced filtering configuration modal.""" - BINDINGS = [Binding("escape", "cancel", "Cancel")] + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("tab", "focus_next", show=False), + Binding("shift+tab", "focus_previous", show=False), + ] - DEFAULT_CSS = """ - FilterModal { - align: center middle; - } - - #filter-dialog { - grid-size: 1; - grid-gutter: 1 2; - grid-rows: auto 1fr auto; - padding: 0 1; - width: 80; - height: auto; - border: thick $background 80%; - background: $surface; - max-height: 90%; - } - - #filter-header { - dock: top; - width: 1fr; - height: 3; - content-align: center middle; - text-style: bold; - background: $primary; - color: $text; - } - - #filter-content { - layout: vertical; - overflow-y: auto; - height: auto; - max-height: 70vh; - padding: 1; - } - - #filter-actions { - dock: bottom; - layout: horizontal; - width: 1fr; - height: 3; - align: center middle; - padding: 0 1; - background: $panel; - } - - .filter-section { - margin: 1 0; - padding: 1; - border: round $primary 20%; - background: $panel; - } - - .filter-section-title { - text-style: bold; - color: $primary; - margin-bottom: 1; - } - - .filter-checkboxes { - layout: vertical; - margin: 0 2; - } - - .filter-radios { - layout: vertical; - margin: 0 2; - } - - .filter-input-row { - layout: horizontal; - margin: 0 2; - height: 3; - align: left middle; - } - - .filter-input-label { - width: 20; - content-align: left middle; - margin-right: 1; - } - - .filter-input { - width: 30; - } - - .preset-row { - layout: horizontal; - margin: 1 2; - height: 3; - align: left middle; - } - - .preset-select { - width: 30; - margin-right: 2; - } - - Button { - margin: 0 1; - min-width: 12; - } - - Checkbox { - margin: 0 1; - } - - RadioButton { - margin: 0 1; - } - - .count-display { - text-style: italic; - color: $text-muted; - content-align: center middle; - height: 1; - margin: 1 0; - } - """ + CSS = FILTER_MODAL_CSS def __init__( self, @@ -171,133 +57,140 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): def compose(self) -> ComposeResult: """Compose the filter modal interface.""" - with Grid(id="filter-dialog"): - yield Static("Advanced Filtering", id="filter-header") + with VerticalScroll(classes="filter-container"): + yield Static("Advanced Filtering", classes="filter-title") + yield Static("", id="count-display", classes="filter-count") - with Container(id="filter-content"): - # Filter presets section - with Collapsible(title="Filter Presets", collapsed=False): - with Container(classes="filter-section"): - with Horizontal(classes="preset-row"): - yield Label("Preset:", classes="filter-input-label") - yield self._create_preset_select() - yield Button("Load", id="load-preset", variant="primary") - yield Button("Save", id="save-preset") - yield Button("Delete", id="delete-preset", variant="error") + with Vertical(classes="default-flex-section") as presets: + presets.border_title = "Presets" + with Horizontal(classes="filter-preset-row"): + yield self._create_preset_select() + yield Button( + "Load", id="load-preset", variant="primary", compact=True + ) + yield Button("Save", id="save-preset", compact=True) + yield Button( + "Delete", + id="delete-preset", + variant="error", + compact=True, + ) - # Status filtering section - with Collapsible(title="Status Filtering", collapsed=False): - with Container(classes="filter-section"): - yield Static("Status Filtering", classes="filter-section-title") - with RadioSet(id="status-filter-type"): - yield RadioButton("Show All", id="status-all") - yield RadioButton("Active Only", id="status-active") - yield RadioButton("Inactive Only", id="status-inactive") - yield RadioButton("Custom", id="status-custom") + with Vertical(classes="default-flex-section") as status: + status.border_title = "Entry status" + with RadioSet(id="status-filter-type", classes="default-radio-set"): + yield RadioButton("Show all", id="status-all") + yield RadioButton("Active only", id="status-active") + yield RadioButton("Inactive only", id="status-inactive") + yield RadioButton("Custom", id="status-custom") + with Vertical( + classes="filter-custom-options", id="status-custom-options" + ): + yield Checkbox( + "Show active entries", + value=True, + id="show-active", + compact=True, + ) + yield Checkbox( + "Show inactive entries", + value=True, + id="show-inactive", + compact=True, + ) - with Container( - classes="filter-checkboxes", id="status-custom-options" - ): - yield Checkbox( - "Show Active Entries", value=True, id="show-active" - ) - yield Checkbox( - "Show Inactive Entries", value=True, id="show-inactive" - ) + with Vertical(classes="default-flex-section") as entry_type: + entry_type.border_title = "Entry type" + with RadioSet(id="type-filter-type", classes="default-radio-set"): + yield RadioButton("Show all", id="type-all") + yield RadioButton("DNS entries only", id="type-dns") + yield RadioButton("IP entries only", id="type-ip") + yield RadioButton("Custom", id="type-custom") + with Vertical( + classes="filter-custom-options", id="type-custom-options" + ): + yield Checkbox( + "Show DNS entries", value=True, id="show-dns", compact=True + ) + yield Checkbox( + "Show IP entries", value=True, id="show-ip", compact=True + ) - # DNS type filtering section - with Collapsible(title="Entry Type Filtering", collapsed=False): - with Container(classes="filter-section"): - yield Static( - "Entry Type Filtering", classes="filter-section-title" - ) - with RadioSet(id="type-filter-type"): - yield RadioButton("Show All", id="type-all") - yield RadioButton("DNS Entries Only", id="type-dns") - yield RadioButton("IP Entries Only", id="type-ip") - yield RadioButton("Custom", id="type-custom") + with Vertical(classes="default-flex-section") as resolution: + resolution.border_title = "DNS resolution" + with RadioSet(id="resolution-filter-type", classes="default-radio-set"): + yield RadioButton("Show all", id="resolution-all") + yield RadioButton("Resolved only", id="resolution-resolved") + yield RadioButton("Mismatches only", id="resolution-mismatch") + yield RadioButton("Custom", id="resolution-custom") + with Vertical( + classes="filter-custom-options", id="resolution-custom-options" + ): + yield Checkbox( + "Show resolved", value=True, id="show-resolved", compact=True + ) + yield Checkbox( + "Show unresolved", + value=True, + id="show-unresolved", + compact=True, + ) + yield Checkbox( + "Show resolving", + value=True, + id="show-resolving", + compact=True, + ) + yield Checkbox( + "Show failed", value=True, id="show-failed", compact=True + ) + yield Checkbox( + "Show mismatched", + value=True, + id="show-mismatched", + compact=True, + ) - with Container( - classes="filter-checkboxes", id="type-custom-options" - ): - yield Checkbox( - "Show DNS Entries", value=True, id="show-dns" - ) - yield Checkbox("Show IP Entries", value=True, id="show-ip") + with Vertical(classes="default-flex-section") as search: + search.border_title = "Search" + yield Input( + placeholder="Hostname, IP address, or comment", + value=self.current_options.search_term or "", + id="search-term", + classes="filter-search-input", + ) + with Vertical(classes="filter-custom-options"): + yield Checkbox( + "Search hostnames", + value=True, + id="search-hostnames", + compact=True, + ) + yield Checkbox( + "Search comments", + value=True, + id="search-comments", + compact=True, + ) + yield Checkbox( + "Search IP addresses", + value=True, + id="search-ips", + compact=True, + ) + yield Checkbox( + "Case sensitive", + value=False, + id="search-case-sensitive", + compact=True, + ) - # DNS resolution status filtering section - with Collapsible(title="Resolution Status Filtering", collapsed=False): - with Container(classes="filter-section"): - yield Static( - "Resolution Status Filtering", - classes="filter-section-title", - ) - with RadioSet(id="resolution-filter-type"): - yield RadioButton("Show All", id="resolution-all") - yield RadioButton( - "Resolved Only", - id="resolution-resolved", - ) - yield RadioButton( - "Mismatches Only", - id="resolution-mismatch", - ) - yield RadioButton("Custom", id="resolution-custom") - - with Container( - classes="filter-checkboxes", id="resolution-custom-options" - ): - yield Checkbox( - "Show Resolved", value=True, id="show-resolved" - ) - yield Checkbox( - "Show Unresolved", value=True, id="show-unresolved" - ) - yield Checkbox( - "Show Resolving", value=True, id="show-resolving" - ) - yield Checkbox("Show Failed", value=True, id="show-failed") - yield Checkbox( - "Show Mismatched", value=True, id="show-mismatched" - ) - - # Search filtering section - with Collapsible(title="Search Filtering", collapsed=True): - with Container(classes="filter-section"): - yield Static("Search Filtering", classes="filter-section-title") - - with Horizontal(classes="filter-input-row"): - yield Label("Search term:", classes="filter-input-label") - yield Input( - placeholder="Enter search term...", - value=self.current_options.search_term or "", - id="search-term", - classes="filter-input", - ) - - with Container(classes="filter-checkboxes"): - yield Checkbox( - "Search in hostnames", value=True, id="search-hostnames" - ) - yield Checkbox( - "Search in comments", value=True, id="search-comments" - ) - yield Checkbox( - "Search in IP addresses", value=True, id="search-ips" - ) - yield Checkbox( - "Case sensitive", - value=False, - id="search-case-sensitive", - ) - - # Entry count display - yield Static("", id="count-display", classes="count-display") - - with Horizontal(id="filter-actions"): - yield Button("Apply", id="apply", variant="primary") - yield Button("Reset", id="reset") - yield Button("Cancel", id="cancel") + with Horizontal(classes="filter-actions"): + yield Button( + "Apply", id="apply", variant="primary", classes="default-button" + ) + yield Button("Reset", id="reset", classes="default-button") + yield Button("Cancel (ESC)", id="cancel", classes="default-button") def _create_preset_select(self) -> Select: """Create the preset picker without selecting a preset by default.""" @@ -307,18 +200,90 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): preset_options, value=self.current_options.preset_name, id="preset-select", - classes="preset-select", + classes="filter-preset-select", + compact=True, ) return Select( preset_options, id="preset-select", - classes="preset-select", + classes="filter-preset-select", + compact=True, ) def on_mount(self) -> None: """Initialize the modal with current options.""" self._update_ui_from_options() self._update_count_display() + self.query_one("#preset-select", Select).focus() + + def _focusable_control_ids(self) -> List[str]: + """Return the modal's tab order, omitting hidden custom controls.""" + control_ids = [ + "preset-select", + "load-preset", + "save-preset", + "delete-preset", + "status-filter-type", + ] + if self.query_one("#status-custom", RadioButton).value: + control_ids.extend(("show-active", "show-inactive")) + + control_ids.append("type-filter-type") + if self.query_one("#type-custom", RadioButton).value: + control_ids.extend(("show-dns", "show-ip")) + + control_ids.append("resolution-filter-type") + if self.query_one("#resolution-custom", RadioButton).value: + control_ids.extend( + ( + "show-resolved", + "show-unresolved", + "show-resolving", + "show-failed", + "show-mismatched", + ) + ) + + control_ids.extend( + ( + "search-term", + "search-hostnames", + "search-comments", + "search-ips", + "search-case-sensitive", + "apply", + "reset", + "cancel", + ) + ) + return control_ids + + def action_focus_next(self) -> None: + """Move focus in form order, including controls below the scroll position.""" + self._move_focus_in_form(1) + + def action_focus_previous(self) -> None: + """Move focus backward in form order, including controls below the scroll position.""" + self._move_focus_in_form(-1) + + def _move_focus_in_form(self, direction: int) -> None: + """Focus the next or previous currently-visible form control.""" + control_ids = self._focusable_control_ids() + focused_id = ( + str(self.focused.id) + if self.focused is not None and self.focused.id is not None + else None + ) + if focused_id is None: + focused_index = -1 if direction > 0 else 0 + else: + try: + focused_index = control_ids.index(focused_id) + except ValueError: + focused_index = -1 if direction > 0 else 0 + + next_id = control_ids[(focused_index + direction) % len(control_ids)] + self.query_one(f"#{next_id}").focus() def _update_ui_from_options(self) -> None: """Update UI controls to reflect current options.""" diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index 60c4bea..fa1da14 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -313,6 +313,67 @@ ConfigModal { """ ) +# Advanced Filter Modal CSS +FILTER_MODAL_CSS = ( + COMMON_CSS + + """ +FilterModal { + align: center middle; +} + +.filter-container { + width: 80; + height: 28; + background: $surface; + border: thick $primary; + padding: 1; +} + +.filter-title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; +} + +.filter-count { + height: 1; + text-align: center; + color: $text-muted; + text-style: italic; + margin-bottom: 1; +} + +.filter-preset-row { + height: 1; + align: left middle; +} + +.filter-preset-select { + width: 1fr; + margin-right: 1; +} + +.filter-custom-options { + height: auto; + margin: 0 2; +} + +.filter-search-input { + height: 3; + width: 1fr; + margin: 0 2; +} + +.filter-actions { + dock: bottom; + height: 3; + background: $surface; + align: center middle; +} +""" +) + # Save Confirmation Modal CSS SAVE_CONFIRMATION_MODAL_CSS = ( COMMON_CSS diff --git a/tests/test_app.py b/tests/test_app.py index c7bc2d1..808b2f9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -72,6 +72,51 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter(): ] +@pytest.mark.asyncio +async def test_filter_shortcut_does_not_stack_filter_modals(): + """Repeated filter shortcuts keep the existing filter modal in focus.""" + app = app_with_filterable_entries() + + async with app.run_test() as pilot: + await pilot.press("ctrl+f") + await pilot.pause() + + await pilot.press("ctrl+f") + await pilot.press("ctrl+f") + await pilot.pause() + + assert isinstance(app.screen, FilterModal) + assert sum(isinstance(screen, FilterModal) for screen in app.screen_stack) == 1 + + +@pytest.mark.asyncio +async def test_filter_modal_tabs_through_controls_in_visual_order(): + """The modal starts at Presets and tabs through the visible controls in order.""" + app = app_with_filterable_entries() + + async with app.run_test() as pilot: + await pilot.press("ctrl+f") + await pilot.pause() + + assert app.focused is app.screen.query_one("#preset-select") + for expected_id in ( + "load-preset", + "save-preset", + "delete-preset", + "status-filter-type", + "type-filter-type", + "resolution-filter-type", + "search-term", + "search-hostnames", + "search-comments", + "search-ips", + "search-case-sensitive", + "apply", + ): + await pilot.press("tab") + assert app.focused is app.screen.query_one(f"#{expected_id}") + + @pytest.mark.asyncio async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters(): """Reset clears the form on Apply, while Cancel leaves the applied filter alone."""