diff --git a/src/hosts/tui/add_entry_modal.py b/src/hosts/tui/add_entry_modal.py index 35612cd..d5b1de7 100644 --- a/src/hosts/tui/add_entry_modal.py +++ b/src/hosts/tui/add_entry_modal.py @@ -11,7 +11,6 @@ from textual.screen import ModalScreen from textual.binding import Binding from ..core.models import HostEntry -from .styles import ADD_ENTRY_MODAL_CSS class AddEntryModal(ModalScreen): @@ -21,7 +20,51 @@ class AddEntryModal(ModalScreen): Provides a floating window with input fields for creating new entries. """ - CSS = ADD_ENTRY_MODAL_CSS + CSS = """ + AddEntryModal { + align: center middle; + } + + .add-entry-container { + width: 80; + height: 25; + background: $surface; + border: thick $primary; + padding: 1; + } + + .add-entry-title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + + .add-entry-section { + margin: 1 0; + } + + .add-entry-input { + margin: 0 2; + width: 1fr; + } + + .button-row { + margin-top: 2; + align: center middle; + } + + .add-entry-button { + margin: 0 1; + min-width: 10; + } + + .validation-error { + color: $error; + margin: 0 2; + text-style: italic; + } + """ BINDINGS = [ Binding("escape", "cancel", "Cancel"), diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 5c8679e..f5de683 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -460,6 +460,12 @@ class HostsManagerApp(App): self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation) + def action_search(self) -> None: + """Focus the search bar for filtering entries.""" + search_input = self.query_one("#search-input", Input) + search_input.focus() + self.update_status("Use the search bar to filter entries") + def action_quit(self) -> None: """Quit the application.""" self.navigation_handler.quit_application() diff --git a/src/hosts/tui/config_modal.py b/src/hosts/tui/config_modal.py index 3f12dbd..618853c 100644 --- a/src/hosts/tui/config_modal.py +++ b/src/hosts/tui/config_modal.py @@ -11,7 +11,6 @@ from textual.screen import ModalScreen from textual.binding import Binding from ..core.config import Config -from .styles import CONFIG_MODAL_CSS class ConfigModal(ModalScreen): @@ -21,7 +20,44 @@ class ConfigModal(ModalScreen): Provides a floating window with configuration options. """ - CSS = CONFIG_MODAL_CSS + CSS = """ + ConfigModal { + align: center middle; + } + + .config-container { + width: 80; + height: 20; + background: $surface; + border: thick $primary; + padding: 1; + } + + .config-title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + + .config-section { + margin: 1 0; + } + + .config-option { + margin: 0 2; + } + + .button-row { + margin-top: 2; + align: center middle; + } + + .config-button { + margin: 0 1; + min-width: 10; + } + """ BINDINGS = [ Binding("escape", "cancel", "Cancel"), diff --git a/src/hosts/tui/delete_confirmation_modal.py b/src/hosts/tui/delete_confirmation_modal.py index bc99152..8e1f4d5 100644 --- a/src/hosts/tui/delete_confirmation_modal.py +++ b/src/hosts/tui/delete_confirmation_modal.py @@ -11,7 +11,6 @@ from textual.screen import ModalScreen from textual.binding import Binding from ..core.models import HostEntry -from .styles import DELETE_CONFIRMATION_MODAL_CSS class DeleteConfirmationModal(ModalScreen): @@ -21,7 +20,48 @@ class DeleteConfirmationModal(ModalScreen): Provides a confirmation dialog before deleting host entries. """ - CSS = DELETE_CONFIRMATION_MODAL_CSS + CSS = """ + DeleteConfirmationModal { + align: center middle; + } + + .delete-container { + width: 60; + height: 15; + background: $surface; + border: thick $error; + padding: 1; + } + + .delete-title { + text-align: center; + text-style: bold; + color: $error; + margin-bottom: 1; + } + + .delete-message { + text-align: center; + margin: 1 0; + } + + .entry-info { + text-align: center; + text-style: bold; + color: $primary; + margin: 1 0; + } + + .button-row { + margin-top: 2; + align: center middle; + } + + .delete-button { + margin: 0 1; + min-width: 10; + } + """ BINDINGS = [ Binding("escape", "cancel", "Cancel"), diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py index 4e1bbc5..3c018d7 100644 --- a/src/hosts/tui/help_modal.py +++ b/src/hosts/tui/help_modal.py @@ -10,8 +10,6 @@ from textual.widgets import Static, Button from textual.screen import ModalScreen from textual.binding import Binding -from .styles import HELP_MODAL_CSS - class HelpModal(ModalScreen): """ @@ -20,7 +18,59 @@ class HelpModal(ModalScreen): Provides comprehensive help information for using the application. """ - CSS = HELP_MODAL_CSS + CSS = """ + HelpModal { + align: center middle; + } + + .help-container { + width: 90; + height: 40; + background: $surface; + border: thick $primary; + padding: 1; + } + + .help-title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + + .help-content { + height: 35; + margin: 1 0; + } + + .help-section { + margin-bottom: 1; + } + + .help-section-title { + text-style: bold; + color: $primary; + margin-bottom: 0; + } + + .help-item { + margin: 0 2; + } + + .keyboard-shortcut { + text-style: bold; + color: $accent; + } + + .button-row { + margin-top: 1; + align: center middle; + } + + .help-button { + min-width: 10; + } + """ BINDINGS = [ Binding("escape", "close", "Close"), @@ -44,7 +94,7 @@ class HelpModal(ModalScreen): with Vertical(classes="help-section"): yield Static("Main Commands", classes="help-section-title") yield Static( - "[bold]r[/bold] Reload [bold]h[/bold] Help [bold]c[/bold] Config [bold]q[/bold] Quit", + "[bold]r[/bold] Reload [bold]h[/bold] Help [bold]c[/bold] Config [bold]Ctrl+F[/bold] Search [bold]q[/bold] Quit", classes="help-item", ) yield Static( @@ -84,7 +134,7 @@ class HelpModal(ModalScreen): "Special Dialog Commands", classes="help-section-title" ) yield Static( - "[bold]s[/bold] Save changes [bold]d[/bold] Discard changes", + "[bold]F3[/bold] Search in search dialog [bold]s[/bold] Save changes [bold]d[/bold] Discard changes", classes="help-item", ) diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 50994e5..e333d73 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -15,6 +15,7 @@ HOSTS_MANAGER_BINDINGS = [ Binding("i", "sort_by_ip", "Sort by IP"), Binding("n", "sort_by_hostname", "Sort by Hostname"), Binding("c", "config", "Config"), + Binding("ctrl+f", "search", "Focus Search"), Binding("ctrl+e", "toggle_edit_mode", "Edit Mode"), Binding("a", "add_entry", "Add Entry", show=False), Binding("d", "delete_entry", "Delete Entry", show=False), diff --git a/src/hosts/tui/password_modal.py b/src/hosts/tui/password_modal.py index f4098c1..fc6b189 100644 --- a/src/hosts/tui/password_modal.py +++ b/src/hosts/tui/password_modal.py @@ -10,8 +10,6 @@ from textual.widgets import Static, Button, Input from textual.screen import ModalScreen from textual.binding import Binding -from .styles import PASSWORD_MODAL_CSS - class PasswordModal(ModalScreen): """ @@ -20,7 +18,52 @@ class PasswordModal(ModalScreen): Provides a floating window for entering sudo password with proper masking. """ - CSS = PASSWORD_MODAL_CSS + CSS = """ + PasswordModal { + align: center middle; + } + + .password-container { + width: 60; + height: 12; + background: $surface; + border: thick $primary; + padding: 1; + } + + .password-title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + + .password-message { + text-align: center; + color: $text; + margin-bottom: 1; + } + + .password-input { + margin: 1 0; + } + + .button-row { + margin-top: 1; + align: center middle; + } + + .password-button { + margin: 0 1; + min-width: 10; + } + + .error-message { + color: $error; + text-align: center; + margin: 1 0; + } + """ BINDINGS = [ Binding("escape", "cancel", "Cancel"), diff --git a/src/hosts/tui/save_confirmation_modal.py b/src/hosts/tui/save_confirmation_modal.py index 8b9f1cf..a49f8f1 100644 --- a/src/hosts/tui/save_confirmation_modal.py +++ b/src/hosts/tui/save_confirmation_modal.py @@ -10,8 +10,6 @@ from textual.widgets import Static, Button, Label from textual.screen import ModalScreen from textual.binding import Binding -from .styles import SAVE_CONFIRMATION_MODAL_CSS - class SaveConfirmationModal(ModalScreen): """ @@ -20,7 +18,45 @@ class SaveConfirmationModal(ModalScreen): Provides a confirmation dialog asking whether to save or discard changes. """ - CSS = SAVE_CONFIRMATION_MODAL_CSS + CSS = """ + SaveConfirmationModal { + align: center middle; + } + + .save-confirmation-container { + width: 60; + height: 15; + background: $surface; + border: thick $primary; + padding: 1; + } + + .save-confirmation-title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + + .save-confirmation-message { + text-align: center; + margin-bottom: 2; + color: $text; + } + + .button-row { + align: center middle; + } + + .save-confirmation-button { + margin: 0 1; + min-width: 12; + } + + .save-confirmation-button:focus { + border: thick $accent; + } + """ BINDINGS = [ Binding("escape", "cancel", "Cancel"), diff --git a/src/hosts/tui/search_modal.py b/src/hosts/tui/search_modal.py new file mode 100644 index 0000000..88ba7fe --- /dev/null +++ b/src/hosts/tui/search_modal.py @@ -0,0 +1,278 @@ +""" +Search modal window for the hosts TUI application. + +This module provides a floating search window for finding entries by hostname or IP address. +""" + +from textual.app import ComposeResult +from textual.containers import Vertical, Horizontal +from textual.widgets import Static, Button, Input, DataTable, Label +from textual.screen import ModalScreen +from textual.binding import Binding + +from ..core.models import HostEntry + + +class SearchModal(ModalScreen): + """ + Modal screen for searching host entries. + + Provides a search interface and displays matching results. + """ + + CSS = """ + SearchModal { + align: center middle; + } + + .search-container { + width: 90; + height: 30; + background: $surface; + border: thick $primary; + padding: 1; + } + + .search-title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + + .search-section { + margin: 1 0; + } + + .search-input { + margin: 0 2; + width: 1fr; + } + + .results-section { + margin: 1 0; + height: 15; + } + + .search-results { + margin: 0 2; + height: 13; + } + + .button-row { + margin-top: 1; + align: center middle; + } + + .search-button { + margin: 0 1; + min-width: 10; + } + + .results-info { + margin: 0 2; + color: $text-muted; + text-style: italic; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("enter", "select", "Select"), + Binding("f3", "search", "Search"), + ] + + def __init__(self, entries): + super().__init__() + self.entries = entries + self.search_results = [] + + def compose(self) -> ComposeResult: + """Create the search modal layout.""" + with Vertical(classes="search-container"): + yield Static("Search Host Entries", classes="search-title") + + with Vertical(classes="search-section"): + yield Label("Search term (hostname or IP address):") + yield Input( + placeholder="e.g., example.com or 192.168.1.1", + id="search-input", + classes="search-input", + ) + + with Vertical(classes="results-section"): + yield Static("Search Results:", classes="results-info") + yield DataTable( + id="search-results-table", + classes="search-results", + show_header=True, + zebra_stripes=True, + ) + + with Horizontal(classes="button-row"): + yield Button( + "Search", + variant="primary", + id="search-button", + classes="search-button", + ) + yield Button( + "Select", + variant="success", + id="select-button", + classes="search-button", + ) + yield Button( + "Close", + variant="default", + id="close-button", + classes="search-button", + ) + + def on_mount(self) -> None: + """Initialize the search results table and focus search input.""" + # Set up the results table + results_table = self.query_one("#search-results-table", DataTable) + results_table.add_column("IP Address", key="ip") + results_table.add_column("Canonical Hostname", key="hostname") + results_table.add_column("Status", key="status") + results_table.add_column("Comment", key="comment") + + # Focus search input + search_input = self.query_one("#search-input", Input) + search_input.focus() + + # Disable select button initially + select_button = self.query_one("#select-button", Button) + select_button.disabled = True + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "search-button": + self.action_search() + elif event.button.id == "select-button": + self.action_select() + elif event.button.id == "close-button": + self.action_cancel() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle enter key in search input.""" + if event.input.id == "search-input": + self.action_search() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle row selection in results table.""" + if event.data_table.id == "search-results-table": + # Enable select button when a row is selected + select_button = self.query_one("#select-button", Button) + select_button.disabled = False + + def action_search(self) -> None: + """Perform search based on input.""" + search_input = self.query_one("#search-input", Input) + search_term = search_input.value.strip().lower() + + if not search_term: + self._update_results_info("Please enter a search term") + return + + # Perform search + self.search_results = self._search_entries(search_term) + + # Update results table + results_table = self.query_one("#search-results-table", DataTable) + results_table.clear() + + if not self.search_results: + self._update_results_info(f"No entries found matching '{search_term}'") + select_button = self.query_one("#select-button", Button) + select_button.disabled = True + return + + # Add results to table + for entry in self.search_results: + status = "✓ Active" if entry.is_active else "✗ Inactive" + comment = entry.comment or "" + results_table.add_row( + entry.ip_address, + entry.canonical_hostname, + status, + comment, + key=str(id(entry)), + ) + + self._update_results_info(f"Found {len(self.search_results)} matching entries") + + def action_select(self) -> None: + """Select the currently highlighted entry and close modal.""" + results_table = self.query_one("#search-results-table", DataTable) + + if results_table.cursor_row is None or not self.search_results: + return + + # Get the selected entry + cursor_row = results_table.cursor_row + if 0 <= cursor_row < len(self.search_results): + selected_entry = self.search_results[cursor_row] + # Find the original index of this entry + original_index = self._find_entry_index(selected_entry) + self.dismiss(original_index) + else: + self.dismiss(None) + + def action_cancel(self) -> None: + """Cancel search and close modal.""" + self.dismiss(None) + + def _search_entries(self, search_term: str) -> list[HostEntry]: + """ + Search entries by hostname or IP address. + + Args: + search_term: The search term to match against + + Returns: + List of matching entries + """ + results = [] + + for entry in self.entries: + # Search in IP address + if search_term in entry.ip_address.lower(): + results.append(entry) + continue + + # Search in hostnames + for hostname in entry.hostnames: + if search_term in hostname.lower(): + results.append(entry) + break + else: + # Search in comment + if entry.comment and search_term in entry.comment.lower(): + results.append(entry) + + return results + + def _find_entry_index(self, target_entry: HostEntry) -> int: + """ + Find the index of an entry in the original entries list. + + Args: + target_entry: Entry to find + + Returns: + Index of the entry, or -1 if not found + """ + for i, entry in enumerate(self.entries): + if entry is target_entry: + return i + return -1 + + def _update_results_info(self, message: str) -> None: + """Update the results info label.""" + try: + results_info = self.query_one(".results-info", Static) + results_info.update(message) + except Exception: + pass diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index 7b913a4..ec61e37 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -128,294 +128,3 @@ Header.-tall { height: 1; /* Fix tall header also to height 1 */ } """ - -# Common CSS classes shared across components -COMMON_CSS = """ -.button-row { - margin-top: 1; - align: center middle; -} - -.hidden { - display: none; -} -""" - -# Help Modal CSS -HELP_MODAL_CSS = ( - COMMON_CSS - + """ -HelpModal { - align: center middle; -} - -.help-container { - width: 90; - height: 40; - background: $surface; - border: thick $primary; - padding: 1; -} - -.help-title { - text-align: center; - text-style: bold; - color: $primary; - margin-bottom: 1; -} - -.help-content { - height: 35; - margin: 1 0; -} - -.help-section { - margin-bottom: 1; -} - -.help-section-title { - text-style: bold; - color: $primary; - margin-bottom: 0; -} - -.help-item { - margin: 0 2; -} - -.keyboard-shortcut { - text-style: bold; - color: $accent; -} - -.help-button { - min-width: 10; -} -""" -) - -# Add Entry Modal CSS -ADD_ENTRY_MODAL_CSS = ( - COMMON_CSS - + """ -AddEntryModal { - align: center middle; -} - -.add-entry-container { - width: 80; - height: 25; - background: $surface; - border: thick $primary; - padding: 1; -} - -.add-entry-title { - text-align: center; - text-style: bold; - color: $primary; - margin-bottom: 1; -} - -.add-entry-section { - margin: 1 0; -} - -.add-entry-input { - margin: 0 2; - width: 1fr; -} - -.button-row { - margin-top: 2; - align: center middle; -} - -.add-entry-button { - margin: 0 1; - min-width: 10; -} - -.validation-error { - color: $error; - margin: 0 2; - text-style: italic; -} -""" -) - -# Delete Confirmation Modal CSS -DELETE_CONFIRMATION_MODAL_CSS = ( - COMMON_CSS - + """ -DeleteConfirmationModal { - align: center middle; -} - -.delete-container { - width: 60; - height: 15; - background: $surface; - border: thick $error; - padding: 1; -} - -.delete-title { - text-align: center; - text-style: bold; - color: $error; - margin-bottom: 1; -} - -.delete-message { - text-align: center; - margin: 1 0; -} - -.entry-info { - text-align: center; - text-style: bold; - color: $primary; - margin: 1 0; -} - -.button-row { - margin-top: 2; - align: center middle; -} - -.delete-button { - margin: 0 1; - min-width: 10; -} -""" -) - -# Password Modal CSS -PASSWORD_MODAL_CSS = ( - COMMON_CSS - + """ -PasswordModal { - align: center middle; -} - -.password-container { - width: 60; - height: 12; - background: $surface; - border: thick $primary; - padding: 1; -} - -.password-title { - text-align: center; - text-style: bold; - color: $primary; - margin-bottom: 1; -} - -.password-message { - text-align: center; - color: $text; - margin-bottom: 1; -} - -.password-input { - margin: 1 0; -} - -.password-button { - margin: 0 1; - min-width: 10; -} - -.error-message { - color: $error; - text-align: center; - margin: 1 0; -} -""" -) - -# Config Modal CSS -CONFIG_MODAL_CSS = ( - COMMON_CSS - + """ -ConfigModal { - align: center middle; -} - -.config-container { - width: 80; - height: 20; - background: $surface; - border: thick $primary; - padding: 1; -} - -.config-title { - text-align: center; - text-style: bold; - color: $primary; - margin-bottom: 1; -} - -.config-section { - margin: 1 0; -} - -.config-option { - margin: 0 2; -} - -.button-row { - margin-top: 2; - align: center middle; -} - -.config-button { - margin: 0 1; - min-width: 10; -} -""" -) - -# Save Confirmation Modal CSS -SAVE_CONFIRMATION_MODAL_CSS = ( - COMMON_CSS - + """ -SaveConfirmationModal { - align: center middle; -} - -.save-confirmation-container { - width: 60; - height: 15; - background: $surface; - border: thick $primary; - padding: 1; -} - -.save-confirmation-title { - text-align: center; - text-style: bold; - color: $primary; - margin-bottom: 1; -} - -.save-confirmation-message { - text-align: center; - margin-bottom: 2; - color: $text; -} - -.save-confirmation-button { - margin: 0 1; - min-width: 12; -} - -.save-confirmation-button:focus { - border: thick $accent; -} -""" -)