From 502bbd87f3d7b91717559a644aa4e4bd706c09a6 Mon Sep 17 00:00:00 2001 From: phg Date: Sat, 16 Aug 2025 16:52:53 +0200 Subject: [PATCH 1/9] Refactor hosts TUI application: remove main entry point, enhance modal styles, and unify button classes - Deleted the main entry point file for the hosts application. - Updated the Add Entry Modal to improve section titles and input styles. - Refactored HostsManagerApp layout to use common pane styles for left and right sections. - Enhanced Config, Delete Confirmation, and Help modals with consistent button labels and styles. - Improved Password Modal layout and removed unnecessary parameters. - Consolidated common CSS styles for buttons, inputs, and sections to ensure consistent styling across the application. --- src/hosts/main_backup.py | 1007 -------------------- src/hosts/main_new.py | 17 - src/hosts/tui/add_entry_modal.py | 31 +- src/hosts/tui/app.py | 4 +- src/hosts/tui/config_modal.py | 4 +- src/hosts/tui/delete_confirmation_modal.py | 6 +- src/hosts/tui/help_modal.py | 141 ++- src/hosts/tui/keybindings.py | 2 +- src/hosts/tui/password_modal.py | 29 +- src/hosts/tui/styles.py | 132 +-- 10 files changed, 155 insertions(+), 1218 deletions(-) delete mode 100644 src/hosts/main_backup.py delete mode 100644 src/hosts/main_new.py diff --git a/src/hosts/main_backup.py b/src/hosts/main_backup.py deleted file mode 100644 index dac9024..0000000 --- a/src/hosts/main_backup.py +++ /dev/null @@ -1,1007 +0,0 @@ -""" -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 deleted file mode 100644 index ffd6850..0000000 --- a/src/hosts/main_new.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -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 35612cd..bee3f06 100644 --- a/src/hosts/tui/add_entry_modal.py +++ b/src/hosts/tui/add_entry_modal.py @@ -36,47 +36,48 @@ class AddEntryModal(ModalScreen): with Vertical(classes="add-entry-container"): yield Static("Add New Host Entry", classes="add-entry-title") - with Vertical(classes="add-entry-section"): - yield Label("IP Address:") + with Vertical(classes="default-section") as ip_address: + ip_address.border_title = "IP Address" yield Input( placeholder="e.g., 192.168.1.1 or 2001:db8::1", id="ip-address-input", - classes="add-entry-input", + classes="default-input", ) yield Static("", id="ip-error", classes="validation-error") - with Vertical(classes="add-entry-section"): - yield Label("Hostnames (comma-separated):") + with Vertical(classes="default-section") as hostnames: + hostnames.border_title = "Hostnames" yield Input( placeholder="e.g., example.com, www.example.com", id="hostnames-input", - classes="add-entry-input", + classes="default-input", ) yield Static("", id="hostnames-error", classes="validation-error") - with Vertical(classes="add-entry-section"): - yield Label("Comment (optional):") + with Vertical(classes="default-section") as comment: + comment.border_title = "Comment (optional)" yield Input( placeholder="e.g., Development server", id="comment-input", - classes="add-entry-input", + classes="default-input", ) - with Vertical(classes="add-entry-section"): - yield Checkbox("Active (enabled)", value=True, id="active-checkbox") + with Vertical(classes="default-section") as active: + active.border_title = "Activate Entry" + yield Checkbox("Active", value=True, id="active-checkbox", classes="default-checkbox") with Horizontal(classes="button-row"): yield Button( - "Add Entry", + "Add Entry (CTRL+S)", variant="primary", id="add-button", - classes="add-entry-button", + classes="default-button", ) yield Button( - "Cancel", + "Cancel (ESC)", variant="default", id="cancel-button", - classes="add-entry-button", + classes="default-button", ) def on_mount(self) -> None: diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 5c8679e..265af1f 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -82,12 +82,12 @@ class HostsManagerApp(App): with Horizontal(classes="hosts-container"): # Left pane - entries table - with Vertical(classes="left-pane") as left_pane: + with Vertical(classes="common-pane 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="right-pane") as right_pane: + with Vertical(classes="common-pane right-pane") as right_pane: right_pane.border_title = "Entry Details" yield DataTable( id="entry-details-table", diff --git a/src/hosts/tui/config_modal.py b/src/hosts/tui/config_modal.py index 3f12dbd..ed30834 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", + "Cancel (ESC)", variant="default", id="cancel-button", - classes="config-button", + classes="default-button", ) def on_button_pressed(self, event: Button.Pressed) -> None: diff --git a/src/hosts/tui/delete_confirmation_modal.py b/src/hosts/tui/delete_confirmation_modal.py index bc99152..8efe907 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="delete-button", + classes="default-button", ) yield Button( - "Cancel", + "Cancel (ESC)", variant="default", id="cancel-button", - classes="delete-button", + classes="default-button", ) def on_mount(self) -> None: diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py index 4e1bbc5..88ee3c9 100644 --- a/src/hosts/tui/help_modal.py +++ b/src/hosts/tui/help_modal.py @@ -32,85 +32,84 @@ class HelpModal(ModalScreen): 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") + # 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", - ) + # 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", - ) + # 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", - ) + # 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", - ) + # 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", - ) + # 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" + "Close", variant="primary", id="close-button", classes="default-button" ) def on_mount(self) -> None: diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 50994e5..d33b04f 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -11,7 +11,7 @@ from textual.binding import Binding HOSTS_MANAGER_BINDINGS = [ Binding("q", "quit", "Quit"), Binding("r", "reload", "Reload"), - Binding("h", "help", "Help"), + Binding("question_mark", "help", "Help", key_display="?"), Binding("i", "sort_by_ip", "Sort by IP"), Binding("n", "sort_by_hostname", "Sort by Hostname"), Binding("c", "config", "Config"), diff --git a/src/hosts/tui/password_modal.py b/src/hosts/tui/password_modal.py index f4098c1..c28466c 100644 --- a/src/hosts/tui/password_modal.py +++ b/src/hosts/tui/password_modal.py @@ -27,38 +27,27 @@ class PasswordModal(ModalScreen): Binding("enter", "submit", "Submit"), ] - def __init__(self, message: str = "Enter your password for sudo access:"): + def __init__(self): 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") - yield Input( - placeholder="Password", - password=True, - id="password-input", - classes="password-input", - ) + 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", + ) # 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 7b913a4..713be67 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -5,8 +5,48 @@ 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 = """ +HOSTS_MANAGER_CSS = ( + COMMON_CSS + +""" .search-container { border: round $primary; height: 3; @@ -16,28 +56,27 @@ 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 { @@ -128,18 +167,7 @@ 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 = ( @@ -187,10 +215,6 @@ HelpModal { text-style: bold; color: $accent; } - -.help-button { - min-width: 10; -} """ ) @@ -204,7 +228,7 @@ AddEntryModal { .add-entry-container { width: 80; - height: 25; + height: 26; background: $surface; border: thick $primary; padding: 1; @@ -217,25 +241,6 @@ 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; @@ -254,7 +259,7 @@ DeleteConfirmationModal { .delete-container { width: 60; - height: 15; + height: 16; background: $surface; border: thick $error; padding: 1; @@ -278,16 +283,6 @@ DeleteConfirmationModal { color: $primary; margin: 1 0; } - -.button-row { - margin-top: 2; - align: center middle; -} - -.delete-button { - margin: 0 1; - min-width: 10; -} """ ) @@ -301,7 +296,7 @@ PasswordModal { .password-container { width: 60; - height: 12; + height: 11; background: $surface; border: thick $primary; padding: 1; @@ -320,15 +315,6 @@ PasswordModal { margin-bottom: 1; } -.password-input { - margin: 1 0; -} - -.password-button { - margin: 0 1; - min-width: 10; -} - .error-message { color: $error; text-align: center; @@ -367,16 +353,6 @@ ConfigModal { .config-option { margin: 0 2; } - -.button-row { - margin-top: 2; - align: center middle; -} - -.config-button { - margin: 0 1; - min-width: 10; -} """ ) @@ -413,9 +389,5 @@ SaveConfirmationModal { margin: 0 1; min-width: 12; } - -.save-confirmation-button:focus { - border: thick $accent; -} """ ) From 50628d78b79feec726694fd3321ee1dd72383868 Mon Sep 17 00:00:00 2001 From: phg Date: Sat, 16 Aug 2025 17:57:35 +0200 Subject: [PATCH 2/9] Refactor hosts TUI application: remove HelpModal, update help action, and clean up related code --- src/hosts/tui/add_entry_modal.py | 9 ++- src/hosts/tui/app.py | 13 +++- src/hosts/tui/help_modal.py | 127 ------------------------------- src/hosts/tui/keybindings.py | 32 ++++---- src/hosts/tui/password_modal.py | 2 +- src/hosts/tui/styles.py | 50 +----------- tests/test_main.py | 11 +-- 7 files changed, 38 insertions(+), 206 deletions(-) delete mode 100644 src/hosts/tui/help_modal.py diff --git a/src/hosts/tui/add_entry_modal.py b/src/hosts/tui/add_entry_modal.py index bee3f06..fe89920 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, Label +from textual.widgets import Static, Button, Input, Checkbox from textual.screen import ModalScreen from textual.binding import Binding @@ -64,7 +64,12 @@ class AddEntryModal(ModalScreen): with Vertical(classes="default-section") as active: active.border_title = "Activate Entry" - yield Checkbox("Active", value=True, id="active-checkbox", classes="default-checkbox") + yield Checkbox( + "Active", + value=True, + id="active-checkbox", + classes="default-checkbox", + ) with Horizontal(classes="button-row"): yield Button( diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 265af1f..003727c 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -18,7 +18,6 @@ from .config_modal import ConfigModal from .password_modal import PasswordModal from .add_entry_modal import AddEntryModal from .delete_confirmation_modal import DeleteConfirmationModal -from .help_modal import HelpModal from .styles import HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS from .table_handler import TableHandler @@ -35,9 +34,12 @@ 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) @@ -248,8 +250,13 @@ class HostsManagerApp(App): self.update_status("Hosts file reloaded") def action_help(self) -> None: - """Show help modal.""" - self.push_screen(HelpModal()) + """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 def action_config(self) -> None: """Show configuration modal.""" diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py deleted file mode 100644 index 88ee3c9..0000000 --- a/src/hosts/tui/help_modal.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -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") - - # 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="default-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 d33b04f..a616e00 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -10,21 +10,21 @@ from textual.binding import Binding # Key bindings for the hosts manager application HOSTS_MANAGER_BINDINGS = [ Binding("q", "quit", "Quit"), - Binding("r", "reload", "Reload"), - Binding("question_mark", "help", "Help", key_display="?"), - 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), + Binding("r", "reload", "Reload hosts file"), + Binding("question_mark", "help", "Show help", True, key_display="?"), + Binding("i", "sort_by_ip", "Sort by IP address"), + Binding("n", "sort_by_hostname", "Sort by hostname"), + Binding("c", "config", "Configuration"), + Binding("ctrl+e", "toggle_edit_mode", "Toggle edit mode"), + Binding("a", "add_entry", "Add new entry", show=False), + Binding("d", "delete_entry", "Delete entry", show=False), + Binding("e", "edit_entry", "Edit entry", show=False), + Binding("space", "toggle_entry", "Toggle active/inactive", 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), ("ctrl+c", "quit", "Quit"), ] diff --git a/src/hosts/tui/password_modal.py b/src/hosts/tui/password_modal.py index c28466c..d445e66 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, Horizontal +from textual.containers import Vertical from textual.widgets import Static, Button, Input from textual.screen import ModalScreen from textual.binding import Binding diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index 713be67..2ddc2bb 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -46,7 +46,7 @@ COMMON_CSS = """ # CSS styles for the hosts manager application HOSTS_MANAGER_CSS = ( COMMON_CSS - +""" + + """ .search-container { border: round $primary; height: 3; @@ -169,54 +169,6 @@ Header.-tall { """ ) -# 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; -} -""" -) # Add Entry Modal CSS ADD_ENTRY_MODAL_CSS = ( diff --git a/tests/test_main.py b/tests/test_main.py index aca63f8..b75c39a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -324,17 +324,12 @@ class TestHostsManagerApp: patch("hosts.tui.app.Config", return_value=mock_config), ): app = HostsManagerApp() - app.push_screen = Mock() + app.action_show_help_panel = Mock() app.action_help() - # 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) + # Should call the built-in help action + app.action_show_help_panel.assert_called_once() def test_action_config(self): """Test config action opens modal.""" From 8d3d1e7c11ffde350d54711e388c0c08ba80df17 Mon Sep 17 00:00:00 2001 From: phg Date: Sat, 16 Aug 2025 20:58:41 +0200 Subject: [PATCH 3/9] Refactor hosts TUI application: replace Footer with CustomFooter, implement footer setup and status updates, and enhance styling for improved user experience --- src/hosts/tui/app.py | 41 +++++++++- src/hosts/tui/custom_footer.py | 137 +++++++++++++++++++++++++++++++++ src/hosts/tui/styles.py | 44 +++++++++++ tests/test_main.py | 2 +- 4 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 src/hosts/tui/custom_footer.py diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 003727c..cd1a6f1 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, Footer, Static, DataTable, Input, Checkbox, Label +from textual.widgets import Header, Static, DataTable, Input, Checkbox, Label from textual.reactive import reactive from ..core.parser import HostsParser @@ -18,6 +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 .styles import HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS from .table_handler import TableHandler @@ -71,7 +72,7 @@ class HostsManagerApp(App): def compose(self) -> ComposeResult: """Create child widgets for the app.""" yield Header() - yield Footer() + yield CustomFooter(id="custom-footer") # Search bar above the panes with Horizontal(classes="search-container") as search_container: @@ -116,6 +117,7 @@ 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.""" @@ -135,6 +137,38 @@ 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.""" + try: + footer = self.query_one("#custom-footer", CustomFooter) + + # Left section - common actions + footer.add_left_item("q: Quit") + footer.add_left_item("r: Reload") + footer.add_left_item("?: Help") + + # Right section - sort and edit actions + footer.add_right_item("i: Sort IP") + footer.add_right_item("n: Sort Host") + footer.add_right_item("Ctrl+E: Edit Mode") + + # 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: @@ -162,6 +196,9 @@ class HostsManagerApp(App): # Format: "29 entries (6 active) | Read-only mode" self.sub_title = f"{entry_count} entries ({active_count} active) | {mode}" + # Also update the footer status + self._update_footer_status() + def _clear_status_message(self) -> None: """Clear the temporary status message.""" try: diff --git a/src/hosts/tui/custom_footer.py b/src/hosts/tui/custom_footer.py new file mode 100644 index 0000000..255e885 --- /dev/null +++ b/src/hosts/tui/custom_footer.py @@ -0,0 +1,137 @@ +""" +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 + + +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%; + } + + .footer-left { + width: auto; + text-align: left; + text-style: dim; + } + + .footer-spacer { + width: 1fr; + } + + .footer-right { + width: auto; + text-align: right; + text-style: dim; + } + + .footer-separator { + width: auto; + color: $primary; + text-style: dim; + } + + .footer-status { + width: auto; + text-align: right; + color: $accent; + text-style: bold; + } + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._left_items = [] + self._right_items = [] + 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, item: str) -> None: + """Add an item to the left section.""" + self._left_items.append(item) + self._update_left_section() + + def add_right_item(self, item: str) -> None: + """Add an item to the right section.""" + self._right_items.append(item) + self._update_right_section() + + 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) + left_static.update(" ".join(self._left_items)) + 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) + right_static.update(" ".join(self._right_items)) + 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/styles.py b/src/hosts/tui/styles.py index 2ddc2bb..551d4a4 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -166,6 +166,50 @@ Header { Header.-tall { height: 1; /* Fix tall header also to height 1 */ } + +/* Custom Footer Styling */ +CustomFooter { + background: $surface; + color: $text; + dock: bottom; + height: 1; + padding: 0 1; + width: 100%; +} + +CustomFooter > Horizontal { + height: 1; + width: 100%; +} + +.footer-left { + width: auto; + text-align: left; + text-style: dim; +} + +.footer-spacer { + width: 1fr; +} + +.footer-right { + width: auto; + text-align: right; + text-style: dim; +} + +.footer-separator { + width: auto; + color: $primary; + text-style: dim; +} + +.footer-status { + width: auto; + text-align: right; + color: $accent; + text-style: bold; +} """ ) diff --git a/tests/test_main.py b/tests/test_main.py index b75c39a..6dcfc68 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -537,7 +537,7 @@ class TestHostsManagerApp: assert "q" in binding_keys assert "r" in binding_keys - assert "h" in binding_keys + assert "question_mark" in binding_keys # Help binding (? key) assert "i" in binding_keys assert "n" in binding_keys assert "c" in binding_keys From e8faa48e22f483b560faa1f0543cf9ff7f99557c Mon Sep 17 00:00:00 2001 From: phg Date: Sat, 16 Aug 2025 21:15:25 +0200 Subject: [PATCH 4/9] Refactor CustomFooter styles: enhance alignment and height properties for improved layout consistency across the TUI application. --- src/hosts/tui/custom_footer.py | 10 ++++++++++ src/hosts/tui/styles.py | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/hosts/tui/custom_footer.py b/src/hosts/tui/custom_footer.py index 255e885..00aa63e 100644 --- a/src/hosts/tui/custom_footer.py +++ b/src/hosts/tui/custom_footer.py @@ -33,28 +33,36 @@ class CustomFooter(Widget): 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 { @@ -62,6 +70,8 @@ class CustomFooter(Widget): text-align: right; color: $accent; text-style: bold; + height: 1; + content-align: right middle; } """ diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index 551d4a4..495e8e1 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -180,28 +180,36 @@ CustomFooter { 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 { @@ -209,6 +217,8 @@ CustomFooter > Horizontal { text-align: right; color: $accent; text-style: bold; + height: 1; + content-align: right middle; } """ ) From c941a4ba2408a384cd7b22a55093ce25e62440e2 Mon Sep 17 00:00:00 2001 From: phg Date: Sat, 16 Aug 2025 21:23:30 +0200 Subject: [PATCH 5/9] Refactor HostsManagerApp and keybindings: update footer item arrangement for better usability and modify key bindings to hide unused keys for a cleaner interface. --- src/hosts/tui/app.py | 22 +++++++++------------- src/hosts/tui/keybindings.py | 14 +++++++------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index cd1a6f1..deb5b1b 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -53,7 +53,6 @@ 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() @@ -143,14 +142,16 @@ class HostsManagerApp(App): footer = self.query_one("#custom-footer", CustomFooter) # Left section - common actions - footer.add_left_item("q: Quit") - footer.add_left_item("r: Reload") - footer.add_left_item("?: Help") + footer.add_right_item("^e: Edit Mode") + footer.add_right_item("c: Config") + footer.add_right_item("?: Help") + footer.add_right_item("q: Quit") # Right section - sort and edit actions - footer.add_right_item("i: Sort IP") - footer.add_right_item("n: Sort Host") - footer.add_right_item("Ctrl+E: Edit Mode") + footer.add_left_item("i: Sort IP") + footer.add_left_item("n: Sort Host") + footer.add_left_item("r: Reload") + # Status section will be updated by update_status self._update_footer_status() @@ -194,9 +195,7 @@ class HostsManagerApp(App): 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}" - - # Also update the footer status + # Update the footer status self._update_footer_status() def _clear_status_message(self) -> None: @@ -325,7 +324,6 @@ 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}") @@ -334,7 +332,6 @@ 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 @@ -355,7 +352,6 @@ 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/keybindings.py b/src/hosts/tui/keybindings.py index a616e00..0741978 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -9,13 +9,13 @@ from textual.binding import Binding # Key bindings for the hosts manager application HOSTS_MANAGER_BINDINGS = [ - Binding("q", "quit", "Quit"), - Binding("r", "reload", "Reload hosts file"), - Binding("question_mark", "help", "Show help", True, key_display="?"), - Binding("i", "sort_by_ip", "Sort by IP address"), - Binding("n", "sort_by_hostname", "Sort by hostname"), - Binding("c", "config", "Configuration"), - Binding("ctrl+e", "toggle_edit_mode", "Toggle edit mode"), + Binding("q", "quit", "Quit", show=False), + Binding("r", "reload", "Reload hosts file", show=False), + Binding("question_mark", "help", "Show help", True, key_display="?", show=False), + Binding("i", "sort_by_ip", "Sort by IP address", show=False), + Binding("n", "sort_by_hostname", "Sort by hostname", show=False), + Binding("c", "config", "Configuration", show=False), + Binding("ctrl+e", "toggle_edit_mode", "Toggle edit mode", show=False), Binding("a", "add_entry", "Add new entry", show=False), Binding("d", "delete_entry", "Delete entry", show=False), Binding("e", "edit_entry", "Edit entry", show=False), From 49dd015d53c090fbf2857296ee3fd0787c3acbca Mon Sep 17 00:00:00 2001 From: phg Date: Sat, 16 Aug 2025 21:56:46 +0200 Subject: [PATCH 6/9] Refactor keybindings: update visibility of key bindings for improved usability and consistency in the hosts manager application. --- src/hosts/tui/keybindings.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 0741978..5768fe4 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -9,13 +9,13 @@ from textual.binding import Binding # Key bindings for the hosts manager application HOSTS_MANAGER_BINDINGS = [ - Binding("q", "quit", "Quit", show=False), - Binding("r", "reload", "Reload hosts file", show=False), - Binding("question_mark", "help", "Show help", True, key_display="?", show=False), - Binding("i", "sort_by_ip", "Sort by IP address", show=False), - Binding("n", "sort_by_hostname", "Sort by hostname", show=False), - Binding("c", "config", "Configuration", show=False), - Binding("ctrl+e", "toggle_edit_mode", "Toggle edit mode", show=False), + Binding("q", "quit", "Quit", show=True, id="right:quit"), + Binding("r", "reload", "Reload hosts file", show=True, id="right:reload"), + Binding("question_mark", "help", "Show help", show=True, key_display="?", id="right:help"), + Binding("i", "sort_by_ip", "Sort by IP address", show=True, id="left:quit"), + Binding("n", "sort_by_hostname", "Sort by hostname", show=True, id="left:quit"), + Binding("c", "config", "Configuration", show=True), + Binding("ctrl+e", "toggle_edit_mode", "Toggle edit mode", show=True), Binding("a", "add_entry", "Add new entry", show=False), Binding("d", "delete_entry", "Delete entry", show=False), Binding("e", "edit_entry", "Edit entry", show=False), From 3f0892fb7b35d6c99805432e752c3729d6a12063 Mon Sep 17 00:00:00 2001 From: phg Date: Sat, 16 Aug 2025 22:23:39 +0200 Subject: [PATCH 7/9] Refactor footer setup: enhance footer item management based on keybindings for improved usability and clarity. --- src/hosts/tui/app.py | 42 ++++++++++++++++++++++-------------- src/hosts/tui/keybindings.py | 29 ++++++++++++++++--------- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index deb5b1b..c903be0 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -137,21 +137,36 @@ class HostsManagerApp(App): self.update_status(f"❌ Error loading hosts file: {e}") def _setup_footer(self) -> None: - """Setup the footer with initial content.""" + """Setup the footer with initial content based on keybindings.""" try: footer = self.query_one("#custom-footer", CustomFooter) - # Left section - common actions - footer.add_right_item("^e: Edit Mode") - footer.add_right_item("c: Config") - footer.add_right_item("?: Help") - footer.add_right_item("q: Quit") + # Clear existing items + footer.clear_left_items() + footer.clear_right_items() - # Right section - sort and edit actions - footer.add_left_item("i: Sort IP") - footer.add_left_item("n: Sort Host") - footer.add_left_item("r: Reload") - + # Process keybindings and add to appropriate sections + for binding in self.BINDINGS: + # Only show bindings marked with show=True + if hasattr(binding, "show") and 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 + + # Format the item + item = f"{key_display}: {description}" + + # Determine positioning from id attribute + binding_id = getattr(binding, "id", None) + if binding_id and binding_id.startswith("left:"): + footer.add_left_item(item) + elif binding_id and binding_id.startswith("right:"): + footer.add_right_item(item) + else: + # Default to right if no specific positioning + footer.add_right_item(item) # Status section will be updated by update_status self._update_footer_status() @@ -190,11 +205,6 @@ class HostsManagerApp(App): pass # Always update the header subtitle with current 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" # Update the footer status self._update_footer_status() diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 5768fe4..c84d63d 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -9,17 +9,26 @@ 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=True, id="right:reload"), - Binding("question_mark", "help", "Show help", show=True, key_display="?", id="right:help"), - Binding("i", "sort_by_ip", "Sort by IP address", show=True, id="left:quit"), - Binding("n", "sort_by_hostname", "Sort by hostname", show=True, id="left:quit"), - Binding("c", "config", "Configuration", show=True), - Binding("ctrl+e", "toggle_edit_mode", "Toggle edit mode", show=True), - Binding("a", "add_entry", "Add new entry", show=False), - Binding("d", "delete_entry", "Delete entry", show=False), - Binding("e", "edit_entry", "Edit entry", show=False), - Binding("space", "toggle_entry", "Toggle active/inactive", show=False), + 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), From 6107b43ac5e3542939616d1089812de18ec95ffa Mon Sep 17 00:00:00 2001 From: phg Date: Sat, 16 Aug 2025 22:37:29 +0200 Subject: [PATCH 8/9] Refactor keybindings and footer management: streamline keybinding processing and enhance footer item display with key-description pairs for improved clarity and usability. --- src/hosts/tui/app.py | 15 ++-- src/hosts/tui/custom_footer.py | 122 +++++++++++++++++++++++++++++---- src/hosts/tui/keybindings.py | 20 ++++-- 3 files changed, 133 insertions(+), 24 deletions(-) diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index c903be0..f2de666 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -147,26 +147,27 @@ class HostsManagerApp(App): # 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 hasattr(binding, "show") and binding.show: + 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 - # Format the item - item = f"{key_display}: {description}" - # Determine positioning from id attribute binding_id = getattr(binding, "id", None) if binding_id and binding_id.startswith("left:"): - footer.add_left_item(item) + footer.add_left_item(key_display, description) elif binding_id and binding_id.startswith("right:"): - footer.add_right_item(item) + footer.add_right_item(key_display, description) else: # Default to right if no specific positioning - footer.add_right_item(item) + footer.add_right_item(key_display, description) # Status section will be updated by update_status self._update_footer_status() diff --git a/src/hosts/tui/custom_footer.py b/src/hosts/tui/custom_footer.py index 00aa63e..a2f46ad 100644 --- a/src/hosts/tui/custom_footer.py +++ b/src/hosts/tui/custom_footer.py @@ -11,6 +11,42 @@ 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): @@ -39,7 +75,6 @@ class CustomFooter(Widget): .footer-left { width: auto; text-align: left; - text-style: dim; height: 1; content-align: left middle; } @@ -52,7 +87,6 @@ class CustomFooter(Widget): .footer-right { width: auto; text-align: right; - text-style: dim; height: 1; content-align: right middle; } @@ -73,12 +107,25 @@ class CustomFooter(Widget): 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 = [] - self._right_items = [] + self._left_items = [] # List of FooterKey widgets + self._right_items = [] # List of FooterKey widgets self._status_text = "" def compose(self) -> ComposeResult: @@ -90,16 +137,43 @@ class CustomFooter(Widget): yield Static(" │ ", id="footer-separator", classes="footer-separator") yield Static("", id="footer-status", classes="footer-status") - def add_left_item(self, item: str) -> None: - """Add an item to the left section.""" - self._left_items.append(item) + 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, item: str) -> None: - """Add an item to the right section.""" - self._right_items.append(item) + 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() @@ -119,7 +193,19 @@ class CustomFooter(Widget): """Update the left section display.""" try: left_static = self.query_one("#footer-left", Static) - left_static.update(" ".join(self._left_items)) + 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 @@ -127,7 +213,19 @@ class CustomFooter(Widget): """Update the right section display.""" try: right_static = self.query_one("#footer-right", Static) - right_static.update(" ".join(self._right_items)) + 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 diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index c84d63d..d455379 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -12,8 +12,20 @@ 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( + "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", @@ -26,9 +38,7 @@ HOSTS_MANAGER_BINDINGS = [ 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("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), From 89df22f4e34cf9232d500342a11bde87c4ee049b Mon Sep 17 00:00:00 2001 From: phg Date: Sat, 16 Aug 2025 22:44:47 +0200 Subject: [PATCH 9/9] Enhance entry deletion process: ensure immediate file save after deletion and restore entry on save failure for improved reliability. --- src/hosts/core/manager.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/hosts/core/manager.py b/src/hosts/core/manager.py index 5bbd280..9e49fc5 100644 --- a/src/hosts/core/manager.py +++ b/src/hosts/core/manager.py @@ -331,7 +331,16 @@ class HostsManager: # Remove the entry deleted_entry = hosts_file.entries.pop(index) - return True, f"Entry deleted: {deleted_entry.canonical_hostname}" + 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}" except Exception as e: return False, f"Error deleting entry: {e}"