diff --git a/src/hosts/core/manager.py b/src/hosts/core/manager.py index 9e49fc5..5bbd280 100644 --- a/src/hosts/core/manager.py +++ b/src/hosts/core/manager.py @@ -331,16 +331,7 @@ class HostsManager: # Remove the entry deleted_entry = hosts_file.entries.pop(index) - canonical_hostname = deleted_entry.hostnames[0] if deleted_entry.hostnames else deleted_entry.ip_address - - # Save the file immediately - save_success, save_message = self.save_hosts_file(hosts_file) - if not save_success: - # If save fails, restore the entry - hosts_file.entries.insert(index, deleted_entry) - return False, f"Failed to save after deletion: {save_message}" - - return True, f"Entry deleted: {canonical_hostname}" + return True, f"Entry deleted: {deleted_entry.canonical_hostname}" except Exception as e: return False, f"Error deleting entry: {e}" diff --git a/src/hosts/main_backup.py b/src/hosts/main_backup.py new file mode 100644 index 0000000..dac9024 --- /dev/null +++ b/src/hosts/main_backup.py @@ -0,0 +1,1007 @@ +""" +Main entry point for the hosts TUI application. + +This module contains the main application class and entry point function. +""" + +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Header, Footer, Static, DataTable, Input, Checkbox, Label +from textual.binding import Binding +from textual.reactive import reactive +from rich.text import Text +import ipaddress +import re + +from .core.parser import HostsParser +from .core.models import HostsFile +from .core.config import Config +from .core.manager import HostsManager +from .tui.config_modal import ConfigModal +from .tui.save_confirmation_modal import SaveConfirmationModal + + +class HostsManagerApp(App): + """ + Main application class for the hosts TUI manager. + + Provides a two-pane interface for managing hosts file entries + with read-only mode by default and explicit edit mode. + """ + + CSS = """ + .hosts-container { + height: 1fr; + } + + .left-pane { + width: 60%; + border: round $primary; + margin: 1; + padding: 1; + } + + .right-pane { + width: 40%; + border: round $primary; + margin: 1; + padding: 1; + } + + .entry-active { + color: $success; + } + + .entry-inactive { + color: $warning; + text-style: italic; + } + + .status-bar { + background: $surface; + color: $text; + height: 1; + padding: 0 1; + dock: bottom; + } + + .status-error { + background: $error; + color: $text; + height: 1; + padding: 0 1; + text-style: bold; + dock: bottom; + } + + /* DataTable styling to match background */ + #entries-table { + background: $background; + } + + #entries-table .datatable--header { + background: $surface; + } + + #entries-table .datatable--even-row { + background: $background; + } + + #entries-table .datatable--odd-row { + background: $surface; + } + + /* DataTable row styling - colors are now handled via Rich Text objects */ + + .hidden { + display: none; + } + + #entry-edit-form { + height: auto; + padding: 1; + } + + #entry-edit-form Label { + margin-bottom: 1; + color: $accent; + text-style: bold; + } + + #entry-edit-form Input { + margin-bottom: 1; + } + + #entry-edit-form Checkbox { + margin-bottom: 1; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("r", "reload", "Reload"), + Binding("h", "help", "Help"), + Binding("i", "sort_by_ip", "Sort by IP"), + Binding("n", "sort_by_hostname", "Sort by Hostname"), + Binding("c", "config", "Config"), + Binding("ctrl+e", "toggle_edit_mode", "Edit Mode"), + Binding("e", "edit_entry", "Edit Entry", show=False), + Binding("space", "toggle_entry", "Toggle Entry", show=False), + Binding("ctrl+s", "save_file", "Save", show=False), + Binding("shift+up", "move_entry_up", "Move Up", show=False), + Binding("shift+down", "move_entry_down", "Move Down", show=False), + Binding("escape", "exit_edit_entry", "Exit Edit", show=False), + Binding("tab", "next_field", "Next Field", show=False), + Binding("shift+tab", "prev_field", "Prev Field", show=False), + ("ctrl+c", "quit", "Quit"), + ] + + # Reactive attributes + hosts_file: reactive[HostsFile] = reactive(HostsFile()) + selected_entry_index: reactive[int] = reactive(0) + edit_mode: reactive[bool] = reactive(False) + entry_edit_mode: reactive[bool] = reactive(False) + sort_column: reactive[str] = reactive("") # "ip" or "hostname" + sort_ascending: reactive[bool] = reactive(True) + + def __init__(self): + super().__init__() + self.parser = HostsParser() + self.config = Config() + self.manager = HostsManager() + self.title = "Hosts Manager" + self.sub_title = "Read-only mode" + + # Track original entry values for change detection + self.original_entry_values = None + + def compose(self) -> ComposeResult: + """Create the application layout.""" + yield Header() + + with Vertical(): + with Horizontal(classes="hosts-container"): + left_pane = Vertical(classes="left-pane") + left_pane.border_title = "Hosts Entries" + with left_pane: + yield DataTable(id="entries-table") + yield left_pane + + right_pane = Vertical(classes="right-pane") + right_pane.border_title = "Entry Details" + with right_pane: + yield Static("", id="entry-details") + with Vertical(id="entry-edit-form", classes="hidden"): + yield Label("IP Address:") + yield Input(id="ip-input", placeholder="Enter IP address") + yield Label("Hostname:") + yield Input(id="hostname-input", placeholder="Enter hostname") + yield Label("Comment:") + yield Input( + id="comment-input", placeholder="Enter comment (optional)" + ) + yield Label("Active:") + yield Checkbox(id="active-checkbox", value=True) + yield right_pane + + yield Static("", classes="status-bar", id="status") + + yield Footer() + + def on_ready(self) -> None: + """Initialize the application when ready.""" + self.load_hosts_file() + self.update_status() + + def load_hosts_file(self) -> None: + """Load the hosts file and populate the interface.""" + # Remember current selection for restoration + current_entry = None + if self.hosts_file.entries and self.selected_entry_index < len( + self.hosts_file.entries + ): + current_entry = self.hosts_file.entries[self.selected_entry_index] + + try: + self.hosts_file = self.parser.parse() + self.populate_entries_table() + + # Restore cursor position with a timer to ensure ListView is fully rendered + self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry)) + + self.update_entry_details() + self.log(f"Loaded {len(self.hosts_file.entries)} entries from hosts file") + except FileNotFoundError: + self.log("Hosts file not found") + self.update_status("Error: Hosts file not found") + except PermissionError: + self.log("Permission denied reading hosts file") + self.update_status("Error: Permission denied") + except Exception as e: + self.log(f"Error loading hosts file: {e}") + self.update_status(f"Error: {e}") + + def get_visible_entries(self) -> list: + """Get the list of entries that are visible in the table (after filtering).""" + show_defaults = self.config.should_show_default_entries() + visible_entries = [] + + for entry in self.hosts_file.entries: + canonical_hostname = entry.hostnames[0] if entry.hostnames else "" + # Skip default entries if configured to hide them + if not show_defaults and self.config.is_default_entry( + entry.ip_address, canonical_hostname + ): + continue + visible_entries.append(entry) + + return visible_entries + + def get_first_visible_entry_index(self) -> int: + """Get the index of the first visible entry in the hosts file.""" + show_defaults = self.config.should_show_default_entries() + + for i, entry in enumerate(self.hosts_file.entries): + canonical_hostname = entry.hostnames[0] if entry.hostnames else "" + # Skip default entries if configured to hide them + if not show_defaults and self.config.is_default_entry( + entry.ip_address, canonical_hostname + ): + continue + return i + + # If no visible entries found, return 0 + return 0 + + def display_index_to_actual_index(self, display_index: int) -> int: + """Convert a display table index to the actual hosts file entry index.""" + visible_entries = self.get_visible_entries() + if display_index >= len(visible_entries): + return 0 + + target_entry = visible_entries[display_index] + + # Find this entry in the full hosts file + for i, entry in enumerate(self.hosts_file.entries): + if entry is target_entry: + return i + + return 0 + + def actual_index_to_display_index(self, actual_index: int) -> int: + """Convert an actual hosts file entry index to a display table index.""" + if actual_index >= len(self.hosts_file.entries): + return 0 + + target_entry = self.hosts_file.entries[actual_index] + visible_entries = self.get_visible_entries() + + # Find this entry in the visible entries + for i, entry in enumerate(visible_entries): + if entry is target_entry: + return i + + return 0 + + def populate_entries_table(self) -> None: + """Populate the left pane with hosts entries using DataTable.""" + table = self.query_one("#entries-table", DataTable) + table.clear(columns=True) # Clear both rows and columns + + # Configure DataTable properties + table.zebra_stripes = True + table.cursor_type = "row" + table.show_header = True + + # Create column labels with sort indicators + active_label = "Active" + ip_label = "IP Address" + hostname_label = "Canonical Hostname" + + # Add sort indicators + if self.sort_column == "ip": + arrow = "↑" if self.sort_ascending else "↓" + ip_label = f"{arrow} IP Address" + elif self.sort_column == "hostname": + arrow = "↑" if self.sort_ascending else "↓" + hostname_label = f"{arrow} Canonical Hostname" + + # Add columns with proper labels (Active column first) + table.add_columns(active_label, ip_label, hostname_label) + + # Get visible entries (after filtering) + visible_entries = self.get_visible_entries() + + # Add rows + for entry in visible_entries: + # Get the canonical hostname (first hostname) + canonical_hostname = entry.hostnames[0] if entry.hostnames else "" + + # Check if this is a default system entry + is_default = entry.is_default_entry() + + # Add row with styling based on active status and default entry status + if is_default: + # Default entries are always shown in dim grey regardless of active status + active_text = Text("✓" if entry.is_active else "", style="dim white") + ip_text = Text(entry.ip_address, style="dim white") + hostname_text = Text(canonical_hostname, style="dim white") + table.add_row(active_text, ip_text, hostname_text) + elif entry.is_active: + # Active entries in green with checkmark + active_text = Text("✓", style="bold green") + ip_text = Text(entry.ip_address, style="bold green") + hostname_text = Text(canonical_hostname, style="bold green") + table.add_row(active_text, ip_text, hostname_text) + else: + # Inactive entries in dim yellow with italic (no checkmark) + active_text = Text("", style="dim yellow italic") + ip_text = Text(entry.ip_address, style="dim yellow italic") + hostname_text = Text(canonical_hostname, style="dim yellow italic") + table.add_row(active_text, ip_text, hostname_text) + + def restore_cursor_position(self, previous_entry) -> None: + """Restore cursor position after reload, maintaining selection if possible.""" + if not self.hosts_file.entries: + self.selected_entry_index = 0 + return + + if previous_entry is None: + # No previous selection, start at first visible entry + self.selected_entry_index = self.get_first_visible_entry_index() + else: + # Try to find the same entry in the reloaded file + for i, entry in enumerate(self.hosts_file.entries): + if ( + entry.ip_address == previous_entry.ip_address + and entry.hostnames == previous_entry.hostnames + and entry.comment == previous_entry.comment + ): + self.selected_entry_index = i + break + else: + # Entry not found, default to first visible entry + self.selected_entry_index = self.get_first_visible_entry_index() + + # Update the DataTable cursor position using display index + table = self.query_one("#entries-table", DataTable) + display_index = self.actual_index_to_display_index(self.selected_entry_index) + if table.row_count > 0 and display_index < table.row_count: + # Move cursor to the selected row + table.move_cursor(row=display_index) + table.focus() + # Update the details pane to match the selection + self.update_entry_details() + + def update_entry_details(self) -> None: + """Update the right pane with selected entry details.""" + if self.entry_edit_mode: + self.update_edit_form() + else: + self.update_details_display() + + def update_details_display(self) -> None: + """Update the static details display.""" + details_widget = self.query_one("#entry-details", Static) + edit_form = self.query_one("#entry-edit-form") + + # Show details, hide edit form + details_widget.remove_class("hidden") + edit_form.add_class("hidden") + + if not self.hosts_file.entries: + details_widget.update("No entries loaded") + return + + # Get visible entries to check if we need to adjust selection + visible_entries = self.get_visible_entries() + if not visible_entries: + details_widget.update("No visible entries") + return + + # If default entries are hidden and selected_entry_index points to a hidden entry, + # we need to find the corresponding visible entry + show_defaults = self.config.should_show_default_entries() + if not show_defaults: + # Check if the currently selected entry is a default entry (hidden) + if ( + self.selected_entry_index < len(self.hosts_file.entries) + and self.hosts_file.entries[ + self.selected_entry_index + ].is_default_entry() + ): + # The selected entry is hidden, so we should show the first visible entry instead + if visible_entries: + # Find the first visible entry in the hosts file + for i, entry in enumerate(self.hosts_file.entries): + if not entry.is_default_entry(): + self.selected_entry_index = i + break + + if self.selected_entry_index >= len(self.hosts_file.entries): + self.selected_entry_index = 0 + + entry = self.hosts_file.entries[self.selected_entry_index] + + details_lines = [ + f"IP Address: {entry.ip_address}", + f"Hostnames: {', '.join(entry.hostnames)}", + f"Status: {'Active' if entry.is_active else 'Inactive'}", + ] + + # Add notice for default system entries + if entry.is_default_entry(): + details_lines.append("") + details_lines.append("⚠️ SYSTEM DEFAULT ENTRY") + details_lines.append( + "This is a default system entry and cannot be modified." + ) + + if entry.comment: + details_lines.append(f"Comment: {entry.comment}") + + if entry.dns_name: + details_lines.append(f"DNS Name: {entry.dns_name}") + + details_widget.update("\n".join(details_lines)) + + def update_edit_form(self) -> None: + """Update the edit form with current entry values.""" + details_widget = self.query_one("#entry-details", Static) + edit_form = self.query_one("#entry-edit-form") + + # Hide details, show edit form + details_widget.add_class("hidden") + edit_form.remove_class("hidden") + + if not self.hosts_file.entries or self.selected_entry_index >= len( + self.hosts_file.entries + ): + return + + entry = self.hosts_file.entries[self.selected_entry_index] + + # Update form fields with current entry values + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.query_one("#active-checkbox", Checkbox) + + ip_input.value = entry.ip_address + hostname_input.value = ", ".join(entry.hostnames) + comment_input.value = entry.comment or "" + active_checkbox.value = entry.is_active + + def update_status(self, message: str = "") -> None: + """Update the status bar.""" + status_widget = self.query_one("#status", Static) + + if message: + # Check if this is an error message (starts with ❌) + if message.startswith("❌"): + # Use error styling for error messages + status_widget.remove_class("status-bar") + status_widget.add_class("status-error") + status_widget.update(message) + # Auto-clear error message after 5 seconds + self.set_timer(5.0, lambda: self.update_status()) + else: + # Use normal styling for regular messages + status_widget.remove_class("status-error") + status_widget.add_class("status-bar") + status_widget.update(message) + # Auto-clear regular message after 3 seconds + self.set_timer(3.0, lambda: self.update_status()) + else: + # Reset to normal status display + status_widget.remove_class("status-error") + status_widget.add_class("status-bar") + + mode = "Edit mode" if self.edit_mode else "Read-only mode" + entry_count = len(self.hosts_file.entries) + active_count = len(self.hosts_file.get_active_entries()) + + status_text = f"{mode} | {entry_count} entries ({active_count} active)" + + # Add file info + file_info = self.parser.get_file_info() + if file_info["exists"]: + status_text += f" | {file_info['path']}" + + status_widget.update(status_text) + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + """Handle row highlighting (cursor movement) in the DataTable.""" + if event.data_table.id == "entries-table": + # Convert display index to actual index + self.selected_entry_index = self.display_index_to_actual_index( + event.cursor_row + ) + self.update_entry_details() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle row selection in the DataTable.""" + if event.data_table.id == "entries-table": + # Convert display index to actual index + self.selected_entry_index = self.display_index_to_actual_index( + event.cursor_row + ) + self.update_entry_details() + + def action_reload(self) -> None: + """Reload the hosts file.""" + # Reset sort state on reload + self.sort_column = "" + self.sort_ascending = True + self.load_hosts_file() + self.update_status("Hosts file reloaded") + + def action_help(self) -> None: + """Show help information.""" + # For now, just update the status with help info + self.update_status( + "Help: ↑/↓ Navigate, r Reload, q Quit, h Help, c Config, e Edit" + ) + + def action_config(self) -> None: + """Show configuration modal.""" + + def handle_config_result(config_changed: bool) -> None: + if config_changed: + # Reload the table to apply new filtering + self.populate_entries_table() + self.update_status("Configuration saved") + + self.push_screen(ConfigModal(self.config), handle_config_result) + + def action_sort_by_ip(self) -> None: + """Sort entries by IP address, toggle ascending/descending.""" + # Toggle sort direction if already sorting by IP + if self.sort_column == "ip": + self.sort_ascending = not self.sort_ascending + else: + self.sort_column = "ip" + self.sort_ascending = True + + # Sort the entries using the new method that keeps defaults on top + self.hosts_file.sort_by_ip(self.sort_ascending) + self.populate_entries_table() + + direction = "ascending" if self.sort_ascending else "descending" + self.update_status(f"Sorted by IP address ({direction})") + + def action_sort_by_hostname(self) -> None: + """Sort entries by canonical hostname, toggle ascending/descending.""" + # Toggle sort direction if already sorting by hostname + if self.sort_column == "hostname": + self.sort_ascending = not self.sort_ascending + else: + self.sort_column = "hostname" + self.sort_ascending = True + + # Sort the entries using the new method that keeps defaults on top + self.hosts_file.sort_by_hostname(self.sort_ascending) + self.populate_entries_table() + + direction = "ascending" if self.sort_ascending else "descending" + self.update_status(f"Sorted by hostname ({direction})") + + def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None: + """Handle column header clicks for sorting.""" + if event.data_table.id == "entries-table": + # Check if the column key contains "IP Address" (handles sort indicators) + if "IP Address" in str(event.column_key): + self.action_sort_by_ip() + elif "Canonical Hostname" in str(event.column_key): + self.action_sort_by_hostname() + + def action_toggle_edit_mode(self) -> None: + """Toggle between read-only and edit mode.""" + if self.edit_mode: + # Exit edit mode + success, message = self.manager.exit_edit_mode() + if success: + self.edit_mode = False + self.sub_title = "Read-only mode" + self.update_status(message) + else: + self.update_status(f"Error exiting edit mode: {message}") + else: + # Enter edit mode + success, message = self.manager.enter_edit_mode() + if success: + self.edit_mode = True + self.sub_title = "Edit mode" + self.update_status(message) + else: + self.update_status(f"Error entering edit mode: {message}") + + def action_edit_entry(self) -> None: + """Enter edit mode for the selected entry.""" + if not self.edit_mode: + self.update_status( + "❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." + ) + return + + if not self.hosts_file.entries: + self.update_status("No entries to edit") + return + + if self.selected_entry_index >= len(self.hosts_file.entries): + self.update_status("Invalid entry selected") + return + + entry = self.hosts_file.entries[self.selected_entry_index] + if entry.is_default_entry(): + self.update_status("❌ Cannot edit system default entry") + return + + # Store original values for change detection + self.original_entry_values = { + "ip_address": entry.ip_address, + "hostnames": entry.hostnames.copy(), + "comment": entry.comment, + "is_active": entry.is_active, + } + + self.entry_edit_mode = True + self.update_entry_details() + + # Focus on the IP address input field + ip_input = self.query_one("#ip-input", Input) + ip_input.focus() + + self.update_status("Editing entry - Use Tab/Shift+Tab to navigate, ESC to exit") + + def has_entry_changes(self) -> bool: + """Check if the current entry has been modified from its original values.""" + if not self.original_entry_values or not self.entry_edit_mode: + return False + + # Get current values from form fields + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.query_one("#active-checkbox", Checkbox) + + current_hostnames = [ + h.strip() for h in hostname_input.value.split(",") if h.strip() + ] + current_comment = comment_input.value.strip() or None + + # Compare with original values + return ( + ip_input.value.strip() != self.original_entry_values["ip_address"] + or current_hostnames != self.original_entry_values["hostnames"] + or current_comment != self.original_entry_values["comment"] + or active_checkbox.value != self.original_entry_values["is_active"] + ) + + def action_exit_edit_entry(self) -> None: + """Exit entry edit mode and return focus to the entries table.""" + if not self.entry_edit_mode: + return + + # Check if there are unsaved changes + if self.has_entry_changes(): + # Show save confirmation modal + def handle_save_confirmation(result): + if result == "save": + # Validate and save changes + if self.validate_and_save_entry_changes(): + self.exit_edit_entry_mode() + elif result == "discard": + # Restore original values and exit + self.restore_original_entry_values() + self.exit_edit_entry_mode() + elif result == "cancel": + # Do nothing, stay in edit mode + pass + + self.push_screen(SaveConfirmationModal(), handle_save_confirmation) + else: + # No changes, exit directly + self.exit_edit_entry_mode() + + def exit_edit_entry_mode(self) -> None: + """Helper method to exit entry edit mode and clean up.""" + self.entry_edit_mode = False + self.original_entry_values = None + self.update_entry_details() + + # Return focus to the entries table + table = self.query_one("#entries-table", DataTable) + table.focus() + + self.update_status("Exited entry edit mode") + + def restore_original_entry_values(self) -> None: + """Restore the original values to the form fields.""" + if not self.original_entry_values: + return + + # Update form fields with original values + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.query_one("#active-checkbox", Checkbox) + + ip_input.value = self.original_entry_values["ip_address"] + hostname_input.value = ", ".join(self.original_entry_values["hostnames"]) + comment_input.value = self.original_entry_values["comment"] or "" + active_checkbox.value = self.original_entry_values["is_active"] + + def validate_and_save_entry_changes(self) -> bool: + """Validate current entry values and save if valid.""" + if not self.hosts_file.entries or self.selected_entry_index >= len( + self.hosts_file.entries + ): + return False + + entry = self.hosts_file.entries[self.selected_entry_index] + + # Get values from form fields + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.query_one("#active-checkbox", Checkbox) + + # Validate IP address + try: + ipaddress.ip_address(ip_input.value.strip()) + except ValueError: + self.update_status("❌ Invalid IP address - changes not saved") + return False + + # Validate hostname(s) + hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()] + if not hostnames: + self.update_status( + "❌ At least one hostname is required - changes not saved" + ) + return False + + hostname_pattern = re.compile( + r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$" + ) + + for hostname in hostnames: + if not hostname_pattern.match(hostname): + self.update_status( + f"❌ Invalid hostname: {hostname} - changes not saved" + ) + return False + + # Update the entry + entry.ip_address = ip_input.value.strip() + entry.hostnames = hostnames + entry.comment = comment_input.value.strip() or None + entry.is_active = active_checkbox.value + + # Save to file + success, message = self.manager.save_hosts_file(self.hosts_file) + if success: + # Update the table display + self.populate_entries_table() + # Restore cursor position + table = self.query_one("#entries-table", DataTable) + display_index = self.actual_index_to_display_index( + self.selected_entry_index + ) + if table.row_count > 0 and display_index < table.row_count: + table.move_cursor(row=display_index) + self.update_status("Entry saved successfully") + return True + else: + self.update_status(f"❌ Error saving entry: {message}") + return False + + def action_next_field(self) -> None: + """Move to the next field in edit mode.""" + if not self.entry_edit_mode: + return + + # Get all input fields in order + fields = [ + self.query_one("#ip-input", Input), + self.query_one("#hostname-input", Input), + self.query_one("#comment-input", Input), + self.query_one("#active-checkbox", Checkbox), + ] + + # Find currently focused field and move to next + for i, field in enumerate(fields): + if field.has_focus: + next_field = fields[(i + 1) % len(fields)] + next_field.focus() + break + + def action_prev_field(self) -> None: + """Move to the previous field in edit mode.""" + if not self.entry_edit_mode: + return + + # Get all input fields in order + fields = [ + self.query_one("#ip-input", Input), + self.query_one("#hostname-input", Input), + self.query_one("#comment-input", Input), + self.query_one("#active-checkbox", Checkbox), + ] + + # Find currently focused field and move to previous + for i, field in enumerate(fields): + if field.has_focus: + prev_field = fields[(i - 1) % len(fields)] + prev_field.focus() + break + + def on_key(self, event) -> None: + """Handle key events to override default tab behavior in edit mode.""" + # Only handle custom tab navigation if in entry edit mode AND no modal is open + if self.entry_edit_mode and len(self.screen_stack) == 1 and event.key == "tab": + # Prevent default tab behavior and use our custom navigation + event.prevent_default() + self.action_next_field() + elif ( + self.entry_edit_mode + and len(self.screen_stack) == 1 + and event.key == "shift+tab" + ): + # Prevent default shift+tab behavior and use our custom navigation + event.prevent_default() + self.action_prev_field() + + def on_input_changed(self, event: Input.Changed) -> None: + """Handle input field changes (no auto-save - changes saved on exit).""" + # Input changes are tracked but not automatically saved + # Changes will be validated and saved when exiting edit mode + pass + + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + """Handle checkbox changes (no auto-save - changes saved on exit).""" + # Checkbox changes are tracked but not automatically saved + # Changes will be validated and saved when exiting edit mode + pass + + def action_toggle_entry(self) -> None: + """Toggle the active state of the selected entry.""" + if not self.edit_mode: + self.update_status( + "❌ Cannot toggle entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." + ) + return + + if not self.hosts_file.entries: + self.update_status("No entries to toggle") + return + + # Remember current entry for cursor position restoration + current_entry = self.hosts_file.entries[self.selected_entry_index] + + success, message = self.manager.toggle_entry( + self.hosts_file, self.selected_entry_index + ) + if success: + # Auto-save the changes immediately + save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + if save_success: + self.populate_entries_table() + # Restore cursor position to the same entry + self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry)) + self.update_entry_details() + self.update_status(f"{message} - Changes saved automatically") + else: + self.update_status(f"Entry toggled but save failed: {save_message}") + else: + self.update_status(f"Error toggling entry: {message}") + + def action_move_entry_up(self) -> None: + """Move the selected entry up in the list.""" + if not self.edit_mode: + self.update_status( + "❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." + ) + return + + if not self.hosts_file.entries: + self.update_status("No entries to move") + return + + success, message = self.manager.move_entry_up( + self.hosts_file, self.selected_entry_index + ) + if success: + # Auto-save the changes immediately + save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + if save_success: + # Update the selection index to follow the moved entry + if self.selected_entry_index > 0: + self.selected_entry_index -= 1 + self.populate_entries_table() + # Update the DataTable cursor position to follow the moved entry + table = self.query_one("#entries-table", DataTable) + display_index = self.actual_index_to_display_index( + self.selected_entry_index + ) + if table.row_count > 0 and display_index < table.row_count: + table.move_cursor(row=display_index) + self.update_entry_details() + self.update_status(f"{message} - Changes saved automatically") + else: + self.update_status(f"Entry moved but save failed: {save_message}") + else: + self.update_status(f"Error moving entry: {message}") + + def action_move_entry_down(self) -> None: + """Move the selected entry down in the list.""" + if not self.edit_mode: + self.update_status( + "❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." + ) + return + + if not self.hosts_file.entries: + self.update_status("No entries to move") + return + + success, message = self.manager.move_entry_down( + self.hosts_file, self.selected_entry_index + ) + if success: + # Auto-save the changes immediately + save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + if save_success: + # Update the selection index to follow the moved entry + if self.selected_entry_index < len(self.hosts_file.entries) - 1: + self.selected_entry_index += 1 + self.populate_entries_table() + # Update the DataTable cursor position to follow the moved entry + table = self.query_one("#entries-table", DataTable) + display_index = self.actual_index_to_display_index( + self.selected_entry_index + ) + if table.row_count > 0 and display_index < table.row_count: + table.move_cursor(row=display_index) + self.update_entry_details() + self.update_status(f"{message} - Changes saved automatically") + else: + self.update_status(f"Entry moved but save failed: {save_message}") + else: + self.update_status(f"Error moving entry: {message}") + + def action_save_file(self) -> None: + """Save the hosts file to disk.""" + if not self.edit_mode: + self.update_status( + "❌ Cannot save: Application is in read-only mode. No changes to save." + ) + return + + success, message = self.manager.save_hosts_file(self.hosts_file) + if success: + self.update_status(message) + else: + self.update_status(f"Error saving file: {message}") + + def action_quit(self) -> None: + """Quit the application.""" + # If in entry edit mode, exit it first + if self.entry_edit_mode: + self.action_exit_edit_entry() + + # If in edit mode, exit it first + if self.edit_mode: + self.manager.exit_edit_mode() + self.exit() + + +def main(): + """Main entry point for the hosts application.""" + app = HostsManagerApp() + app.run() + + +if __name__ == "__main__": + main() diff --git a/src/hosts/main_new.py b/src/hosts/main_new.py new file mode 100644 index 0000000..ffd6850 --- /dev/null +++ b/src/hosts/main_new.py @@ -0,0 +1,17 @@ +""" +Main entry point for the hosts TUI application. + +This module contains the main application entry point function. +""" + +from .tui.app import HostsManagerApp + + +def main(): + """Main entry point for the hosts application.""" + app = HostsManagerApp() + app.run() + + +if __name__ == "__main__": + main() diff --git a/src/hosts/tui/add_entry_modal.py b/src/hosts/tui/add_entry_modal.py index fe89920..35612cd 100644 --- a/src/hosts/tui/add_entry_modal.py +++ b/src/hosts/tui/add_entry_modal.py @@ -6,7 +6,7 @@ This module provides a floating modal window for creating new host entries. from textual.app import ComposeResult from textual.containers import Vertical, Horizontal -from textual.widgets import Static, Button, Input, Checkbox +from textual.widgets import Static, Button, Input, Checkbox, Label from textual.screen import ModalScreen from textual.binding import Binding @@ -36,53 +36,47 @@ class AddEntryModal(ModalScreen): with Vertical(classes="add-entry-container"): yield Static("Add New Host Entry", classes="add-entry-title") - with Vertical(classes="default-section") as ip_address: - ip_address.border_title = "IP Address" + with Vertical(classes="add-entry-section"): + yield Label("IP Address:") yield Input( placeholder="e.g., 192.168.1.1 or 2001:db8::1", id="ip-address-input", - classes="default-input", + classes="add-entry-input", ) yield Static("", id="ip-error", classes="validation-error") - with Vertical(classes="default-section") as hostnames: - hostnames.border_title = "Hostnames" + with Vertical(classes="add-entry-section"): + yield Label("Hostnames (comma-separated):") yield Input( placeholder="e.g., example.com, www.example.com", id="hostnames-input", - classes="default-input", + classes="add-entry-input", ) yield Static("", id="hostnames-error", classes="validation-error") - with Vertical(classes="default-section") as comment: - comment.border_title = "Comment (optional)" + with Vertical(classes="add-entry-section"): + yield Label("Comment (optional):") yield Input( placeholder="e.g., Development server", id="comment-input", - classes="default-input", + classes="add-entry-input", ) - with Vertical(classes="default-section") as active: - active.border_title = "Activate Entry" - yield Checkbox( - "Active", - value=True, - id="active-checkbox", - classes="default-checkbox", - ) + with Vertical(classes="add-entry-section"): + yield Checkbox("Active (enabled)", value=True, id="active-checkbox") with Horizontal(classes="button-row"): yield Button( - "Add Entry (CTRL+S)", + "Add Entry", variant="primary", id="add-button", - classes="default-button", + classes="add-entry-button", ) yield Button( - "Cancel (ESC)", + "Cancel", variant="default", id="cancel-button", - classes="default-button", + classes="add-entry-button", ) def on_mount(self) -> None: diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index f2de666..5c8679e 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -7,7 +7,7 @@ all the handlers and provides the primary user interface. from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical -from textual.widgets import Header, Static, DataTable, Input, Checkbox, Label +from textual.widgets import Header, Footer, Static, DataTable, Input, Checkbox, Label from textual.reactive import reactive from ..core.parser import HostsParser @@ -18,7 +18,7 @@ from .config_modal import ConfigModal from .password_modal import PasswordModal from .add_entry_modal import AddEntryModal from .delete_confirmation_modal import DeleteConfirmationModal -from .custom_footer import CustomFooter +from .help_modal import HelpModal from .styles import HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS from .table_handler import TableHandler @@ -35,12 +35,9 @@ class HostsManagerApp(App): with read-only mode by default and explicit edit mode. """ - ENABLE_COMMAND_PALETTE = False CSS = HOSTS_MANAGER_CSS BINDINGS = HOSTS_MANAGER_BINDINGS - help_visible = False - # Reactive attributes hosts_file: reactive[HostsFile] = reactive(HostsFile()) selected_entry_index: reactive[int] = reactive(0) @@ -53,6 +50,7 @@ class HostsManagerApp(App): def __init__(self): super().__init__() self.title = "/etc/hosts Manager" + self.sub_title = "" # Will be set by update_status # Initialize core components self.parser = HostsParser() @@ -71,7 +69,7 @@ class HostsManagerApp(App): def compose(self) -> ComposeResult: """Create child widgets for the app.""" yield Header() - yield CustomFooter(id="custom-footer") + yield Footer() # Search bar above the panes with Horizontal(classes="search-container") as search_container: @@ -84,12 +82,12 @@ class HostsManagerApp(App): with Horizontal(classes="hosts-container"): # Left pane - entries table - with Vertical(classes="common-pane left-pane") as left_pane: + with Vertical(classes="left-pane") as left_pane: left_pane.border_title = "Host Entries" yield DataTable(id="entries-table") # Right pane - entry details or edit form - with Vertical(classes="common-pane right-pane") as right_pane: + with Vertical(classes="right-pane") as right_pane: right_pane.border_title = "Entry Details" yield DataTable( id="entry-details-table", @@ -116,7 +114,6 @@ class HostsManagerApp(App): def on_ready(self) -> None: """Called when the app is ready.""" self.load_hosts_file() - self._setup_footer() def load_hosts_file(self) -> None: """Load the hosts file and populate the table.""" @@ -136,56 +133,6 @@ class HostsManagerApp(App): except Exception as e: self.update_status(f"❌ Error loading hosts file: {e}") - def _setup_footer(self) -> None: - """Setup the footer with initial content based on keybindings.""" - try: - footer = self.query_one("#custom-footer", CustomFooter) - - # Clear existing items - footer.clear_left_items() - footer.clear_right_items() - - # Process keybindings and add to appropriate sections - for binding in self.BINDINGS: - # Skip tuple-style bindings and only process Binding objects - if not hasattr(binding, "show"): - continue - - # Only show bindings marked with show=True - if binding.show: - # Get the display key - key_display = getattr(binding, "key_display", None) or binding.key - - # Get the description - description = binding.description or binding.action - - # Determine positioning from id attribute - binding_id = getattr(binding, "id", None) - if binding_id and binding_id.startswith("left:"): - footer.add_left_item(key_display, description) - elif binding_id and binding_id.startswith("right:"): - footer.add_right_item(key_display, description) - else: - # Default to right if no specific positioning - footer.add_right_item(key_display, description) - - # Status section will be updated by update_status - self._update_footer_status() - except Exception: - pass # Footer not ready yet - - def _update_footer_status(self) -> None: - """Update the footer status section.""" - try: - footer = self.query_one("#custom-footer", CustomFooter) - mode = "Edit" if self.edit_mode else "Read-only" - entry_count = len(self.hosts_file.entries) - active_count = len(self.hosts_file.get_active_entries()) - status = f"{entry_count} entries ({active_count} active) | {mode}" - footer.set_status(status) - except Exception: - pass # Footer not ready yet - def update_status(self, message: str = "") -> None: """Update the header subtitle and status bar with status information.""" if message: @@ -206,8 +153,12 @@ class HostsManagerApp(App): pass # Always update the header subtitle with current status - # Update the footer status - self._update_footer_status() + mode = "Edit mode" if self.edit_mode else "Read-only mode" + entry_count = len(self.hosts_file.entries) + active_count = len(self.hosts_file.get_active_entries()) + + # Format: "29 entries (6 active) | Read-only mode" + self.sub_title = f"{entry_count} entries ({active_count} active) | {mode}" def _clear_status_message(self) -> None: """Clear the temporary status message.""" @@ -297,13 +248,8 @@ class HostsManagerApp(App): self.update_status("Hosts file reloaded") def action_help(self) -> None: - """Show help panel.""" - if self.help_visible: - self.action_hide_help_panel() - self.help_visible = False - else: - self.action_show_help_panel() - self.help_visible = True + """Show help modal.""" + self.push_screen(HelpModal()) def action_config(self) -> None: """Show configuration modal.""" @@ -335,6 +281,7 @@ class HostsManagerApp(App): success, message = self.manager.exit_edit_mode() if success: self.edit_mode = False + self.sub_title = "Read-only mode" self.update_status(message) else: self.update_status(f"Error exiting edit mode: {message}") @@ -343,6 +290,7 @@ class HostsManagerApp(App): success, message = self.manager.enter_edit_mode() if success: self.edit_mode = True + self.sub_title = "Edit mode" self.update_status(message) elif "Password required" in message: # Show password modal @@ -363,6 +311,7 @@ class HostsManagerApp(App): success, message = self.manager.enter_edit_mode(password) if success: self.edit_mode = True + self.sub_title = "Edit mode" self.update_status(message) elif "Incorrect password" in message: # Show error and try again diff --git a/src/hosts/tui/config_modal.py b/src/hosts/tui/config_modal.py index ed30834..3f12dbd 100644 --- a/src/hosts/tui/config_modal.py +++ b/src/hosts/tui/config_modal.py @@ -51,10 +51,10 @@ class ConfigModal(ModalScreen): "Save", variant="primary", id="save-button", classes="config-button" ) yield Button( - "Cancel (ESC)", + "Cancel", variant="default", id="cancel-button", - classes="default-button", + classes="config-button", ) def on_button_pressed(self, event: Button.Pressed) -> None: diff --git a/src/hosts/tui/custom_footer.py b/src/hosts/tui/custom_footer.py deleted file mode 100644 index a2f46ad..0000000 --- a/src/hosts/tui/custom_footer.py +++ /dev/null @@ -1,245 +0,0 @@ -""" -Custom footer widget with three sections: left, right, and status. - -This module provides a custom footer that divides the footer into three sections: -- Left: Items added from the left side of the screen -- Right: Items added from the right side of the screen -- Status: Right edge section separated by a vertical line -""" - -from textual.app import ComposeResult -from textual.containers import Horizontal -from textual.widgets import Static -from textual.widget import Widget -from rich.text import Text - - -class FooterKey(Widget): - """A key/action pair widget for the footer, styled similar to Textual's original FooterKey.""" - - DEFAULT_CSS = """ - FooterKey { - width: auto; - height: 1; - content-align: center middle; - padding: 0 1; - } - - .footer-key--key { - text-style: bold; - color: $text; - } - - .footer-key--description { - color: $text-muted; - } - """ - - def __init__(self, key: str, description: str, **kwargs): - super().__init__(**kwargs) - self.key = key - self.description = description - - def render(self) -> Text: - """Render the key-description pair with proper styling.""" - text = Text() - text.append(f"{self.key}", style="bold") - text.append(" ") - text.append(self.description, style="dim") - return text - - -class CustomFooter(Widget): - """ - A custom footer widget with three sections. - - Layout: [Left items] [spacer] [Right items] | [Status] - """ - - DEFAULT_CSS = """ - CustomFooter { - background: $surface; - color: $text; - dock: bottom; - height: 1; - padding: 0 1; - width: 100%; - } - - CustomFooter > Horizontal { - height: 1; - width: 100%; - align: left middle; - } - - .footer-left { - width: auto; - text-align: left; - height: 1; - content-align: left middle; - } - - .footer-spacer { - width: 1fr; - height: 1; - } - - .footer-right { - width: auto; - text-align: right; - height: 1; - content-align: right middle; - } - - .footer-separator { - width: auto; - color: $primary; - text-style: dim; - height: 1; - content-align: center middle; - } - - .footer-status { - width: auto; - text-align: right; - color: $accent; - text-style: bold; - height: 1; - content-align: right middle; - } - - /* Enhanced styling for footer key components */ - .footer-left .footer-key--key, - .footer-right .footer-key--key { - text-style: bold; - color: $text; - } - - .footer-left .footer-key--description, - .footer-right .footer-key--description { - color: $text-muted; - text-style: dim; - } - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self._left_items = [] # List of FooterKey widgets - self._right_items = [] # List of FooterKey widgets - self._status_text = "" - - def compose(self) -> ComposeResult: - """Create the footer layout.""" - with Horizontal(): - yield Static("", id="footer-left", classes="footer-left") - yield Static("", id="footer-spacer", classes="footer-spacer") - yield Static("", id="footer-right", classes="footer-right") - yield Static(" │ ", id="footer-separator", classes="footer-separator") - yield Static("", id="footer-status", classes="footer-status") - - def add_left_item(self, key: str, description: str) -> None: - """Add a key-description pair to the left section.""" - footer_key = FooterKey(key, description) - self._left_items.append(footer_key) - self._update_left_section() - - def add_right_item(self, key: str, description: str) -> None: - """Add a key-description pair to the right section.""" - footer_key = FooterKey(key, description) - self._right_items.append(footer_key) - self._update_right_section() - - def add_left_item_legacy(self, item: str) -> None: - """Add a legacy item (key: description format) to the left section.""" - if ": " in item: - key, description = item.split(": ", 1) - self.add_left_item(key, description) - else: - self.add_left_item(item, "") - - def add_right_item_legacy(self, item: str) -> None: - """Add a legacy item (key: description format) to the right section.""" - if ": " in item: - key, description = item.split(": ", 1) - self.add_right_item(key, description) - else: - self.add_right_item(item, "") - - # Backward compatibility - temporarily add the old single parameter methods - def add_left_item_old(self, item: str) -> None: - """Backward compatibility method.""" - self.add_left_item_legacy(item) - - def add_right_item_old(self, item: str) -> None: - """Backward compatibility method.""" - self.add_right_item_legacy(item) - - def clear_left_items(self) -> None: - """Clear all items from the left section.""" - self._left_items.clear() - self._update_left_section() - - def clear_right_items(self) -> None: - """Clear all items from the right section.""" - self._right_items.clear() - self._update_right_section() - - def set_status(self, status: str) -> None: - """Set the status text.""" - self._status_text = status - self._update_status_section() - - def _update_left_section(self) -> None: - """Update the left section display.""" - try: - left_static = self.query_one("#footer-left", Static) - if self._left_items: - # Combine all FooterKey renderings with spacing - combined_text = Text() - for i, footer_key in enumerate(self._left_items): - if i > 0: - combined_text.append(" ") # Add spacing between items - # Render individual key-description pair with styling - combined_text.append(footer_key.key, style="bold") - combined_text.append(" ") - combined_text.append(footer_key.description, style="dim") - left_static.update(combined_text) - else: - left_static.update("") - except Exception: - pass # Widget not ready yet - - def _update_right_section(self) -> None: - """Update the right section display.""" - try: - right_static = self.query_one("#footer-right", Static) - if self._right_items: - # Combine all FooterKey renderings with spacing - combined_text = Text() - for i, footer_key in enumerate(self._right_items): - if i > 0: - combined_text.append(" ") # Add spacing between items - # Render individual key-description pair with styling - combined_text.append(footer_key.key, style="bold") - combined_text.append(" ") - combined_text.append(footer_key.description, style="dim") - right_static.update(combined_text) - else: - right_static.update("") - except Exception: - pass # Widget not ready yet - - def _update_status_section(self) -> None: - """Update the status section display.""" - try: - status_static = self.query_one("#footer-status", Static) - status_static.update(self._status_text) - except Exception: - pass # Widget not ready yet - - def on_mount(self) -> None: - """Called when the widget is mounted.""" - # Initialize all sections - self._update_left_section() - self._update_right_section() - self._update_status_section() diff --git a/src/hosts/tui/delete_confirmation_modal.py b/src/hosts/tui/delete_confirmation_modal.py index 8efe907..bc99152 100644 --- a/src/hosts/tui/delete_confirmation_modal.py +++ b/src/hosts/tui/delete_confirmation_modal.py @@ -55,13 +55,13 @@ class DeleteConfirmationModal(ModalScreen): "Delete", variant="error", id="delete-button", - classes="default-button", + classes="delete-button", ) yield Button( - "Cancel (ESC)", + "Cancel", variant="default", id="cancel-button", - classes="default-button", + classes="delete-button", ) def on_mount(self) -> None: diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py new file mode 100644 index 0000000..4e1bbc5 --- /dev/null +++ b/src/hosts/tui/help_modal.py @@ -0,0 +1,128 @@ +""" +Help modal window for the hosts TUI application. + +This module provides a help dialog showing keyboard shortcuts and usage information. +""" + +from textual.app import ComposeResult +from textual.containers import Vertical, Horizontal, ScrollableContainer +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): + """ + Modal screen showing help and keyboard shortcuts. + + Provides comprehensive help information for using the application. + """ + + CSS = HELP_MODAL_CSS + + BINDINGS = [ + Binding("escape", "close", "Close"), + Binding("enter", "close", "Close"), + ] + + def compose(self) -> ComposeResult: + """Create the help modal layout.""" + with Vertical(classes="help-container"): + yield Static("/etc/hosts Manager - Help", classes="help-title") + + with ScrollableContainer(classes="help-content"): + # Navigation section + with Vertical(classes="help-section"): + yield Static("Navigation", classes="help-section-title") + yield Static("↑ ↓ - Navigate entries", classes="help-item") + yield Static("Enter - Select entry", classes="help-item") + yield Static("Tab - Navigate between panes", classes="help-item") + + # Main Commands section + 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", + classes="help-item", + ) + yield Static( + "[bold]i[/bold] Sort by IP [bold]n[/bold] Sort by hostname", + classes="help-item", + ) + + # Edit Mode section + with Vertical(classes="help-section"): + yield Static("Edit Mode Commands", classes="help-section-title") + yield Static( + "[bold]Ctrl+E[/bold] - Toggle edit mode (requires sudo)", + classes="help-item", + ) + yield Static( + "[italic]Edit mode commands:[/italic] [bold]Space[/bold] Toggle [bold]a[/bold] Add [bold]d[/bold] Delete [bold]e[/bold] Edit", + classes="help-item", + ) + yield Static( + "[bold]Shift+↑/↓[/bold] Move entry [bold]Ctrl+S[/bold] Save file", + classes="help-item", + ) + + # Form Navigation section + with Vertical(classes="help-section"): + yield Static( + "Form & Modal Navigation", classes="help-section-title" + ) + yield Static( + "[bold]Tab/Shift+Tab[/bold] Navigate fields [bold]Enter[/bold] Confirm/Save [bold]Escape[/bold] Cancel/Exit", + classes="help-item", + ) + + # Special Commands section + with Vertical(classes="help-section"): + yield Static( + "Special Dialog Commands", classes="help-section-title" + ) + yield Static( + "[bold]s[/bold] Save changes [bold]d[/bold] Discard changes", + classes="help-item", + ) + + # Status and Tips section + with Vertical(classes="help-section"): + yield Static("Entry Status & Tips", classes="help-section-title") + yield Static( + "✓ Active (enabled) ✗ Inactive (commented out)", + classes="help-item", + ) + yield Static( + "• Edit mode commands require [bold]Ctrl+E[/bold] first", + classes="help-item", + ) + yield Static( + "• Search supports partial matches in IP, hostname, or comment", + classes="help-item", + ) + yield Static( + "• Edit mode creates automatic backups • System entries cannot be modified", + classes="help-item", + ) + + with Horizontal(classes="button-row"): + yield Button( + "Close", variant="primary", id="close-button", classes="help-button" + ) + + def on_mount(self) -> None: + """Focus close button when modal opens.""" + close_button = self.query_one("#close-button", Button) + close_button.focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "close-button": + self.action_close() + + def action_close(self) -> None: + """Close the help modal.""" + self.dismiss() diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index d455379..50994e5 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -9,41 +9,22 @@ from textual.binding import Binding # Key bindings for the hosts manager application HOSTS_MANAGER_BINDINGS = [ - Binding("a", "add_entry", "Add new entry", show=True, id="left:add_entry"), - Binding("d", "delete_entry", "Delete entry", show=True, id="left:delete_entry"), - Binding("e", "edit_entry", "Edit entry", show=True, id="left:edit_entry"), - Binding( - "space", - "toggle_entry", - "Toggle active/inactive", - show=True, - id="left:toggle_entry", - ), - Binding( - "ctrl+e", - "toggle_edit_mode", - "Toggle edit mode", - show=True, - id="left:toggle_edit_mode", - ), - Binding("c", "config", "Configuration", show=True, id="right:config"), - Binding( - "question_mark", - "help", - "Show help", - show=True, - key_display="?", - id="right:help", - ), - Binding("q", "quit", "Quit", show=True, id="right:quit"), - Binding("r", "reload", "Reload hosts file", show=False), - Binding("i", "sort_by_ip", "Sort by IP address", show=False), - Binding("h", "sort_by_hostname", "Sort by hostname", show=False), - Binding("ctrl+s", "save_file", "Save hosts file", show=False), - Binding("shift+up", "move_entry_up", "Move entry up", show=False), - Binding("shift+down", "move_entry_down", "Move entry down", show=False), - Binding("escape", "exit_edit_entry", "Exit edit mode", show=False), - Binding("tab", "next_field", "Next field", show=False), - Binding("shift+tab", "prev_field", "Previous field", show=False), + Binding("q", "quit", "Quit"), + Binding("r", "reload", "Reload"), + Binding("h", "help", "Help"), + Binding("i", "sort_by_ip", "Sort by IP"), + Binding("n", "sort_by_hostname", "Sort by Hostname"), + Binding("c", "config", "Config"), + Binding("ctrl+e", "toggle_edit_mode", "Edit Mode"), + Binding("a", "add_entry", "Add Entry", show=False), + Binding("d", "delete_entry", "Delete Entry", show=False), + Binding("e", "edit_entry", "Edit Entry", show=False), + Binding("space", "toggle_entry", "Toggle Entry", show=False), + Binding("ctrl+s", "save_file", "Save", show=False), + Binding("shift+up", "move_entry_up", "Move Up", show=False), + Binding("shift+down", "move_entry_down", "Move Down", show=False), + Binding("escape", "exit_edit_entry", "Exit Edit", show=False), + Binding("tab", "next_field", "Next Field", show=False), + Binding("shift+tab", "prev_field", "Prev Field", show=False), ("ctrl+c", "quit", "Quit"), ] diff --git a/src/hosts/tui/password_modal.py b/src/hosts/tui/password_modal.py index d445e66..f4098c1 100644 --- a/src/hosts/tui/password_modal.py +++ b/src/hosts/tui/password_modal.py @@ -5,7 +5,7 @@ This module provides a secure password input modal for sudo operations. """ from textual.app import ComposeResult -from textual.containers import Vertical +from textual.containers import Vertical, Horizontal from textual.widgets import Static, Button, Input from textual.screen import ModalScreen from textual.binding import Binding @@ -27,27 +27,38 @@ class PasswordModal(ModalScreen): Binding("enter", "submit", "Submit"), ] - def __init__(self): + def __init__(self, message: str = "Enter your password for sudo access:"): super().__init__() + self.message = message self.error_message = "" def compose(self) -> ComposeResult: """Create the password modal layout.""" with Vertical(classes="password-container"): yield Static("Sudo Authentication", classes="password-title") + yield Static(self.message, classes="password-message") - with Vertical(classes="default-section") as password_input: - password_input.border_title = "Enter sudo Password" - yield Input( - placeholder="Password", - password=True, - id="password-input", - classes="default-input", - ) + yield Input( + placeholder="Password", + password=True, + id="password-input", + classes="password-input", + ) # Error message placeholder (initially empty) yield Static("", id="error-message", classes="error-message") + with Horizontal(classes="button-row"): + yield Button( + "OK", variant="primary", id="ok-button", classes="password-button" + ) + yield Button( + "Cancel", + variant="default", + id="cancel-button", + classes="password-button", + ) + def on_mount(self) -> None: """Focus the password input when modal opens.""" password_input = self.query_one("#password-input", Input) diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index 495e8e1..7b913a4 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -5,48 +5,8 @@ This module contains all CSS definitions for consistent styling across the application. """ -# Common CSS classes shared across components -COMMON_CSS = """ -.default-button { - margin: 0 1; - min-width: 10; -} - -.default-checkbox { - height: 1fr; - margin: 0 2; - border: none; -} - -.default-input { - height: 1fr; - width: 1fr; - margin: 0 2; - border: none; -} - -.default-section { - border: round $primary; - height: 3; - padding: 0; - margin: 1 0; -} - -.button-row { - margin-top: 2; - height: 3; - align: center middle; -} - -.hidden { - display: none; -} -""" - # CSS styles for the hosts manager application -HOSTS_MANAGER_CSS = ( - COMMON_CSS - + """ +HOSTS_MANAGER_CSS = """ .search-container { border: round $primary; height: 3; @@ -56,27 +16,28 @@ HOSTS_MANAGER_CSS = ( } .search-input { - height: 1fr; width: 1fr; + height: 1; border: none; } .hosts-container { - height: 1fr; + # height: 1fr; margin-top: 0; } -.common-pane { - border: round $primary; - margin: 0; -} - .left-pane { width: 60%; + border: round $primary; + margin: 0; + padding: 1; } .right-pane { width: 40%; + border: round $primary; + margin: 0; + padding: 1; } .entry-active { @@ -166,64 +127,73 @@ Header { Header.-tall { height: 1; /* Fix tall header also to height 1 */ } +""" -/* Custom Footer Styling */ -CustomFooter { +# 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; - color: $text; - dock: bottom; - height: 1; - padding: 0 1; - width: 100%; + border: thick $primary; + padding: 1; } -CustomFooter > Horizontal { - height: 1; - width: 100%; - align: left middle; -} - -.footer-left { - width: auto; - text-align: left; - text-style: dim; - height: 1; - content-align: left middle; -} - -.footer-spacer { - width: 1fr; - height: 1; -} - -.footer-right { - width: auto; - text-align: right; - text-style: dim; - height: 1; - content-align: right middle; -} - -.footer-separator { - width: auto; - color: $primary; - text-style: dim; - height: 1; - content-align: center middle; -} - -.footer-status { - width: auto; - text-align: right; - color: $accent; +.help-title { + text-align: center; text-style: bold; - height: 1; - content-align: right middle; + 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 @@ -234,7 +204,7 @@ AddEntryModal { .add-entry-container { width: 80; - height: 26; + height: 25; background: $surface; border: thick $primary; padding: 1; @@ -247,6 +217,25 @@ AddEntryModal { 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; @@ -265,7 +254,7 @@ DeleteConfirmationModal { .delete-container { width: 60; - height: 16; + height: 15; background: $surface; border: thick $error; padding: 1; @@ -289,6 +278,16 @@ DeleteConfirmationModal { color: $primary; margin: 1 0; } + +.button-row { + margin-top: 2; + align: center middle; +} + +.delete-button { + margin: 0 1; + min-width: 10; +} """ ) @@ -302,7 +301,7 @@ PasswordModal { .password-container { width: 60; - height: 11; + height: 12; background: $surface; border: thick $primary; padding: 1; @@ -321,6 +320,15 @@ PasswordModal { margin-bottom: 1; } +.password-input { + margin: 1 0; +} + +.password-button { + margin: 0 1; + min-width: 10; +} + .error-message { color: $error; text-align: center; @@ -359,6 +367,16 @@ ConfigModal { .config-option { margin: 0 2; } + +.button-row { + margin-top: 2; + align: center middle; +} + +.config-button { + margin: 0 1; + min-width: 10; +} """ ) @@ -395,5 +413,9 @@ SaveConfirmationModal { margin: 0 1; min-width: 12; } + +.save-confirmation-button:focus { + border: thick $accent; +} """ ) diff --git a/tests/test_main.py b/tests/test_main.py index 6dcfc68..aca63f8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -324,12 +324,17 @@ class TestHostsManagerApp: patch("hosts.tui.app.Config", return_value=mock_config), ): app = HostsManagerApp() - app.action_show_help_panel = Mock() + app.push_screen = Mock() app.action_help() - # Should call the built-in help action - app.action_show_help_panel.assert_called_once() + # Should push the help modal screen + app.push_screen.assert_called_once() + # Verify the modal is a HelpModal instance + from hosts.tui.help_modal import HelpModal + + args = app.push_screen.call_args[0] + assert isinstance(args[0], HelpModal) def test_action_config(self): """Test config action opens modal.""" @@ -537,7 +542,7 @@ class TestHostsManagerApp: assert "q" in binding_keys assert "r" in binding_keys - assert "question_mark" in binding_keys # Help binding (? key) + assert "h" in binding_keys assert "i" in binding_keys assert "n" in binding_keys assert "c" in binding_keys