Enhance filter modal: prevent stacking and improve tab navigation

This commit is contained in:
Philip Henning 2026-09-04 15:52:12 +02:00
parent 9f8e9c3415
commit 167a6596b2
4 changed files with 318 additions and 245 deletions

View file

@ -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:

View file

@ -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")
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")
yield Button("Save", id="save-preset")
yield Button("Delete", id="delete-preset", variant="error")
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")
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 Container(
classes="filter-checkboxes", id="status-custom-options"
with Vertical(
classes="filter-custom-options", id="status-custom-options"
):
yield Checkbox(
"Show Active Entries", value=True, id="show-active"
"Show active entries",
value=True,
id="show-active",
compact=True,
)
yield Checkbox(
"Show Inactive Entries", value=True, id="show-inactive"
"Show inactive entries",
value=True,
id="show-inactive",
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")
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 Container(
classes="filter-checkboxes", id="type-custom-options"
with Vertical(
classes="filter-custom-options", id="type-custom-options"
):
yield Checkbox(
"Show DNS Entries", value=True, id="show-dns"
"Show DNS entries", value=True, id="show-dns", compact=True
)
yield Checkbox(
"Show IP entries", value=True, id="show-ip", compact=True
)
yield Checkbox("Show IP Entries", value=True, id="show-ip")
# 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",
)
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 Container(
classes="filter-checkboxes", id="resolution-custom-options"
with Vertical(
classes="filter-custom-options", id="resolution-custom-options"
):
yield Checkbox(
"Show Resolved", value=True, id="show-resolved"
"Show resolved", value=True, id="show-resolved", compact=True
)
yield Checkbox(
"Show Unresolved", value=True, id="show-unresolved"
"Show unresolved",
value=True,
id="show-unresolved",
compact=True,
)
yield Checkbox(
"Show Resolving", value=True, id="show-resolving"
"Show resolving",
value=True,
id="show-resolving",
compact=True,
)
yield Checkbox("Show Failed", value=True, id="show-failed")
yield Checkbox(
"Show Mismatched", value=True, id="show-mismatched"
"Show failed", value=True, id="show-failed", compact=True
)
yield Checkbox(
"Show mismatched",
value=True,
id="show-mismatched",
compact=True,
)
# 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")
with Vertical(classes="default-flex-section") as search:
search.border_title = "Search"
yield Input(
placeholder="Enter search term...",
placeholder="Hostname, IP address, or comment",
value=self.current_options.search_term or "",
id="search-term",
classes="filter-input",
classes="filter-search-input",
)
with Container(classes="filter-checkboxes"):
with Vertical(classes="filter-custom-options"):
yield Checkbox(
"Search in hostnames", value=True, id="search-hostnames"
"Search hostnames",
value=True,
id="search-hostnames",
compact=True,
)
yield Checkbox(
"Search in comments", value=True, id="search-comments"
"Search comments",
value=True,
id="search-comments",
compact=True,
)
yield Checkbox(
"Search in IP addresses", value=True, id="search-ips"
"Search IP addresses",
value=True,
id="search-ips",
compact=True,
)
yield Checkbox(
"Case sensitive",
value=False,
id="search-case-sensitive",
compact=True,
)
# 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."""

View file

@ -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

View file

@ -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."""