From 563ec7c705a284d0b60b53100dc9547cc0761b5d Mon Sep 17 00:00:00 2001 From: phg Date: Fri, 4 Sep 2026 18:30:53 +0200 Subject: [PATCH] Migrate TUI to design guide --- src/hosts/tui/add_entry_modal.py | 12 +- src/hosts/tui/app.py | 506 ++++++++++++++------- src/hosts/tui/config_modal.py | 8 +- src/hosts/tui/delete_confirmation_modal.py | 15 +- src/hosts/tui/details_handler.py | 224 ++++----- src/hosts/tui/filter_modal.py | 8 +- src/hosts/tui/help_modal.py | 66 +++ src/hosts/tui/keybindings.py | 2 +- src/hosts/tui/save_confirmation_modal.py | 20 +- src/hosts/tui/styles.py | 104 ++++- src/hosts/tui/table_handler.py | 51 +-- tests/test_app.py | 107 ++++- tests/test_main.py | 50 +- tests/test_save_confirmation_modal.py | 10 +- 14 files changed, 741 insertions(+), 442 deletions(-) create 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 c776757..21188b0 100644 --- a/src/hosts/tui/add_entry_modal.py +++ b/src/hosts/tui/add_entry_modal.py @@ -99,15 +99,15 @@ class AddEntryModal(ModalScreen[HostEntry | None]): # Buttons with Horizontal(classes="button-row"): yield Button( - "Add Entry (CTRL+S)", - variant="primary", - id="add-button", + "Cancel", + variant="default", + id="cancel-button", classes="default-button", ) yield Button( - "Cancel (ESC)", - variant="default", - id="cancel-button", + "Add Host Entry", + variant="primary", + id="add-button", classes="default-button", ) diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index e383b6d..09d6013 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -31,8 +31,9 @@ from .config_modal import ConfigModal from .add_entry_modal import AddEntryModal from .delete_confirmation_modal import DeleteConfirmationModal from .filter_modal import FilterModal +from .help_modal import HelpModal from .custom_footer import CustomFooter -from .styles import HOSTS_MANAGER_CSS +from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS from .table_handler import TableHandler from .details_handler import DetailsHandler @@ -60,8 +61,6 @@ class HostsManagerApp(App): 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) @@ -74,6 +73,8 @@ class HostsManagerApp(App): def __init__(self): super().__init__() self.title = "/etc/hosts Manager" + self.register_theme(HOSTS_DARK_THEME) + self.theme = "hosts-dark" # Initialize core components self.parser = HostsParser() @@ -99,166 +100,212 @@ class HostsManagerApp(App): # State for edit mode self.original_entry_values = None + self._status_timer = None + self._viewport_too_small = False def compose(self) -> ComposeResult: """Create child widgets for the app.""" yield Header() yield CustomFooter(id="custom-footer") + yield Static("", id="message-rail") + yield Static("", id="minimum-size", classes="hidden") - # Search bar above the panes - with Horizontal(classes="search-container") as search_container: - search_container.border_title = "Search" - yield Input( - placeholder="Filter by hostname, IP address, or comment...", - id="search-input", - classes="search-input", - ) + with Vertical(id="workspace"): + # Search remains visible above the master-detail workspace. + with Horizontal(classes="search-container") as search_container: + search_container.border_title = "Search" + yield Input( + placeholder="Filter by hostname, IP address, or comment...", + id="search-input", + classes="search-input", + ) + yield Static("", id="filter-summary") - with Horizontal(classes="hosts-container"): - # Left pane - entries table - with Vertical(classes="common-pane left-pane") as left_pane: - left_pane.border_title = "Host Entries" - yield DataTable(id="entries-table") + with Horizontal(classes="hosts-container"): + # Left pane - entries table + 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="common-pane right-pane") as right_pane: - right_pane.border_title = "Entry Details" + # Right pane - entry details or edit form + with Vertical(classes="common-pane right-pane") as right_pane: + right_pane.border_title = "Entry Details" - # Details display form (disabled inputs) - with Vertical(id="entry-details-display", classes="entry-form"): - with Vertical( - classes="default-section section-no-top-margin" - ) as ip_address: - ip_address.border_title = "IP Address" - yield Input( - placeholder="No entry selected", - id="details-ip-input", - disabled=True, - classes="default-input", + # Inspection values deliberately use text, not disabled inputs. + with Vertical(id="entry-details-display", classes="entry-form"): + yield Static( + "No Host Entry selected.", + id="details-empty-state", + classes="empty-state", ) - - with Vertical(classes="default-section") as hostnames: - hostnames.border_title = "Hostnames (comma-separated)" - yield Input( - placeholder="No entry selected", - id="details-hostname-input", - disabled=True, - classes="default-input", - ) - - with Vertical(classes="default-section") as dns_name: - dns_name.border_title = "DNS Name" - yield Input( - placeholder="No DNS name", - id="details-dns-name-input", - disabled=True, - classes="default-input", - ) - - with Vertical(classes="default-section") as dns_status: - dns_status.border_title = "DNS Status" - yield Input( - placeholder="No DNS status", - id="details-dns-status-input", - disabled=True, - classes="default-input", - ) - - with Vertical(classes="default-section") as dns_resolved: - dns_resolved.border_title = "Last Resolved" - yield Input( - placeholder="Not resolved yet", - id="details-dns-resolved-input", - disabled=True, - classes="default-input", - ) - - with Vertical(classes="default-section") as comment: - comment.border_title = "Comment:" - yield Input( - placeholder="No entry selected", - id="details-comment-input", - disabled=True, - classes="default-input", - ) - - with Vertical(classes="default-section") as active: - active.border_title = "Active" - yield Checkbox( - "Active", - id="details-active-checkbox", - disabled=True, - classes="default-checkbox", - ) - - # Edit form (initially hidden) - with Vertical(id="entry-edit-form", classes="entry-form hidden"): - # Entry Type Selection - with Vertical( - classes="default-flex-section section-no-top-margin" - ) as entry_type: - entry_type.border_title = "Entry Type" - with RadioSet( - id="edit-entry-type-radio", classes="default-radio-set" + with Vertical( + id="details-content", classes="detail-rows hidden" ): - yield RadioButton( - "IP Address Entry", value=True, id="edit-ip-entry-radio" - ) - yield RadioButton( - "DNS Name Entry", id="edit-dns-entry-radio" + with Horizontal(classes="detail-row"): + yield Static("IP address", classes="detail-label") + yield Static( + "", id="details-ip-input", classes="detail-value" + ) + with Horizontal(classes="detail-row"): + yield Static("Hostnames", classes="detail-label") + yield Static( + "", + id="details-hostname-input", + classes="detail-value", + ) + with Horizontal(classes="detail-row"): + yield Static("State", classes="detail-label") + yield Static( + "", + id="details-active-checkbox", + classes="detail-value", + ) + with Vertical(id="details-dns-rows", classes="hidden"): + with Horizontal(classes="detail-row"): + yield Static("DNS name", classes="detail-label") + yield Static( + "", + id="details-dns-name-input", + classes="detail-value", + ) + with Horizontal(classes="detail-row"): + yield Static("DNS status", classes="detail-label") + yield Static( + "", + id="details-dns-status-input", + classes="detail-value", + ) + with Horizontal(classes="detail-row"): + yield Static( + "Last resolved", classes="detail-label" + ) + yield Static( + "", + id="details-dns-resolved-input", + classes="detail-value", + ) + with Horizontal(classes="detail-row"): + yield Static("Comment", classes="detail-label") + yield Static( + "", + id="details-comment-input", + classes="detail-value", + ) + yield Static( + "", + id="details-default-notice", + classes="detail-note hidden", ) - # IP Address Section - with Vertical( - classes="default-section", id="edit-ip-section" - ) as ip_address: - ip_address.border_title = "IP Address" - yield Input( - placeholder="Enter IP address", - id="ip-input", - classes="default-input", - ) + # Edit form (initially hidden) + with Vertical(id="entry-edit-form", classes="entry-form hidden"): + with Vertical( + classes="default-flex-section section-no-top-margin" + ) as entry_type: + entry_type.border_title = "Entry Type" + with RadioSet( + id="edit-entry-type-radio", classes="default-radio-set" + ): + yield RadioButton( + "IP Address Entry", + value=True, + id="edit-ip-entry-radio", + ) + yield RadioButton( + "DNS Name Entry", id="edit-dns-entry-radio" + ) - # DNS Name Section (initially hidden) - with Vertical( - classes="default-section hidden", id="edit-dns-section" - ) as dns_name: - dns_name.border_title = "DNS Name (to resolve)" - yield Input( - placeholder="e.g., example.com", - id="dns-name-input", - classes="default-input", - ) + with Vertical( + classes="default-section", id="edit-ip-section" + ) as ip_address: + ip_address.border_title = "IP Address" + yield Input( + placeholder="Enter IP address", + id="ip-input", + classes="default-input", + ) - with Vertical(classes="default-section") as hostnames: - hostnames.border_title = "Hostnames (comma-separated)" - yield Input( - placeholder="Enter hostnames", - id="hostname-input", - classes="default-input", - ) + with Vertical( + classes="default-section hidden", id="edit-dns-section" + ) as dns_name: + dns_name.border_title = "DNS Name (to resolve)" + yield Input( + placeholder="e.g., example.com", + id="dns-name-input", + classes="default-input", + ) - with Vertical(classes="default-section") as comment: - comment.border_title = "Comment:" - yield Input( - placeholder="Enter comment (optional)", - id="comment-input", - classes="default-input", - ) + with Vertical(classes="default-section") as hostnames: + hostnames.border_title = "Hostnames (comma-separated)" + yield Input( + placeholder="Enter hostnames", + id="hostname-input", + classes="default-input", + ) - with Vertical(classes="default-section") as active: - active.border_title = "Active" - yield Checkbox( - "Active", id="active-checkbox", classes="default-checkbox" - ) + with Vertical(classes="default-section") as comment: + comment.border_title = "Comment:" + yield Input( + placeholder="Enter comment (optional)", + id="comment-input", + classes="default-input", + ) - # Status bar for error/temporary messages (overlay, doesn't affect layout) - yield Static("", id="status-bar", classes="status-bar hidden") + with Vertical(classes="default-section") as active: + active.border_title = "Active" + yield Checkbox( + "Active", + id="active-checkbox", + classes="default-checkbox", + ) def on_ready(self) -> None: """Called when the app is ready.""" self.load_hosts_file() self._setup_footer() + self._update_viewport() + + def on_resize(self) -> None: + """Switch to a safe, explicit presentation below the supported viewport.""" + self._update_viewport() + + def _update_viewport(self) -> None: + try: + too_small = self.size.width < 100 or self.size.height < 30 + self._viewport_too_small = too_small + workspace = self.query_one("#workspace") + minimum_size = self.query_one("#minimum-size", Static) + footer = self.query_one("#custom-footer", CustomFooter) + if too_small: + workspace.add_class("hidden") + minimum_size.update( + "Terminal too small\n\nhosts requires at least 100 columns × 30 rows\n" + f"Current size: {self.size.width} × {self.size.height}\n\nq Quit" + ) + minimum_size.remove_class("hidden") + minimum_size.add_class("visible") + footer.add_class("hidden") + else: + workspace.remove_class("hidden") + minimum_size.add_class("hidden") + minimum_size.remove_class("visible") + footer.remove_class("hidden") + self.set_class( + self.size.width < 120 or self.size.height < 40, "compact-layout" + ) + self._setup_footer() + except Exception: + # The widgets are not mounted during early application startup. + pass + + def _allow_mutation_action(self) -> bool: + """Keep every mutation unreachable while the workspace is replaced.""" + if not self._viewport_too_small: + return True + self.update_status( + "Cannot mutate while the terminal is too small. Resize to at least 100 columns × 30 rows." + ) + return False def load_hosts_file(self) -> None: """Load the hosts file and populate the table.""" @@ -276,7 +323,7 @@ class HostsManagerApp(App): self.table_handler.restore_cursor_position(previous_entry) self.update_status() except Exception as e: - self.update_status(f"❌ Error loading hosts file: {e}") + self.update_status(f"Error loading hosts file: {e}") def _setup_footer(self) -> None: """Setup the footer with initial content based on keybindings.""" @@ -287,19 +334,45 @@ class HostsManagerApp(App): footer.clear_left_items() footer.clear_right_items() - # Process keybindings and add to appropriate sections + # The footer is a contextual reminder, not a complete binding catalogue. + # The dedicated help overlay remains the reachable source of all keys. + visible_actions = { + "new_entry", + "edit_entry", + "toggle_edit_mode", + "show_filters", + "help", + "quit", + } + if self.size.width < 120 or self.size.height < 40: + visible_actions = { + "toggle_edit_mode", + "show_filters", + "help", + "quit", + } + for binding in self.BINDINGS: # Skip tuple-style bindings and only process Binding objects if not isinstance(binding, Binding): continue # Only show bindings marked with show=True - if binding.show: + if binding.show and binding.action in visible_actions: # Get the display key key_display = getattr(binding, "key_display", None) or binding.key - # Get the description - description = binding.description or binding.action + descriptions = { + "new_entry": "New", + "edit_entry": "Edit", + "toggle_edit_mode": "Mode", + "show_filters": "Filters", + "help": "Help", + "quit": "Quit", + } + description = descriptions.get( + binding.action, binding.description or binding.action + ) # Determine positioning from id attribute binding_id = getattr(binding, "id", None) @@ -320,44 +393,98 @@ class HostsManagerApp(App): """Update the footer status section.""" try: footer = self.query_one("#custom-footer", CustomFooter) - mode = "Edit" if self.edit_mode else "Read-only" + mode = "PRIVILEGED" 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}" + filter_count = self._active_filter_count() + status = ( + f"{entry_count} entries ({active_count} active) · " + f"Filters: {filter_count} · {mode}" + ) footer.set_status(status) + self.query_one("#filter-summary", Static).update( + f"Filters: {filter_count}" if filter_count else "" + ) except Exception: pass # Footer not ready yet + def _active_filter_count(self) -> int: + """Count the active filter groups for the durable workspace summary.""" + options = self.current_filter_options + return sum( + ( + bool(options.search_term), + options.active_only + or options.inactive_only + or not (options.show_active and options.show_inactive), + options.dns_only + or options.ip_only + or not (options.show_dns_entries and options.show_ip_entries), + options.mismatch_only + or options.resolved_only + or not all( + ( + options.show_resolved, + options.show_unresolved, + options.show_resolving, + options.show_failed, + options.show_mismatched, + ) + ), + ) + ) + def update_status(self, message: str = "") -> None: - """Update the header subtitle and status bar with status information.""" + """Update the reserved message rail and durable footer state.""" if message: - # Show temporary message in the status bar try: - status_bar = self.query_one("#status-bar", Static) - status_bar.update(message) - status_bar.remove_class("hidden") - - if message.startswith("❌"): - # Auto-clear error message after 5 seconds - self.set_timer(5.0, lambda: self._clear_status_message()) + if self._status_timer is not None: + self._status_timer.stop() + self._status_timer = None + rail = self.query_one("#message-rail", Static) + normalized = ( + message.lstrip("✓×!· ") + .replace("❌", "×") + .replace("✅", "✓") + .replace("🔄", "!") + .replace("⚠️", "!") + ) + normalized = normalized.replace("Edit mode", "Privileged Mode").replace( + "edit mode", "Privileged Mode" + ) + rail.update(normalized) + rail.remove_class("message-error") + rail.remove_class("message-warning") + persistent = any( + word in normalized.lower() + for word in ( + "error", + "failed", + "cannot", + "not granted", + "read-only", + ) + ) + if persistent: + rail.add_class("message-error") + elif normalized.startswith("!"): + rail.add_class("message-warning") else: - # Auto-clear regular message after 3 seconds - self.set_timer(3.0, lambda: self._clear_status_message()) + self._status_timer = self.set_timer( + 3.0, lambda: self._clear_status_message() + ) except Exception: - # Fallback if status bar not found (during initialization) pass - - # Always update the header subtitle with current status - # Update the footer status self._update_footer_status() def _clear_status_message(self) -> None: """Clear the temporary status message.""" try: - status_bar = self.query_one("#status-bar", Static) - status_bar.update("") - status_bar.add_class("hidden") + rail = self.query_one("#message-rail", Static) + rail.update("") + rail.remove_class("message-error") + rail.remove_class("message-warning") + self._status_timer = None except Exception: pass @@ -370,6 +497,13 @@ class HostsManagerApp(App): def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool: """Persist a mutation, restoring its complete pre-action state on failure.""" + if not self._allow_mutation_action(): + self.hosts_file = snapshot.manager_state.hosts_file + self.manager.undo_redo_history = snapshot.manager_state.undo_redo_history + self.selected_entry_index = snapshot.selected_entry_index + self.table_handler.populate_entries_table() + self.details_handler.update_entry_details() + return False save_success, save_message, hosts_file = self.manager.save_mutation( self.hosts_file, snapshot.manager_state ) @@ -496,13 +630,9 @@ 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 + """Open the keyboard reference without reducing workspace width.""" + if not self.screen_stack or not isinstance(self.screen, HelpModal): + self.push_screen(HelpModal()) def action_config(self) -> None: """Show configuration modal.""" @@ -566,6 +696,8 @@ class HostsManagerApp(App): def action_toggle_edit_mode(self) -> None: """Toggle between read-only and edit mode.""" + if not self._allow_mutation_action(): + return if self.edit_mode: # Exit edit mode success, message = self.manager.exit_edit_mode() @@ -628,6 +760,8 @@ class HostsManagerApp(App): def action_edit_entry(self) -> None: """Enter edit mode for the selected entry.""" + if not self._allow_mutation_action(): + return if not self.edit_mode: self.update_status( "❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." @@ -667,6 +801,8 @@ class HostsManagerApp(App): def action_exit_edit_entry(self) -> None: """Exit entry edit mode and return focus to the entries table.""" + if not self._allow_mutation_action(): + return self.edit_handler.exit_edit_entry_with_confirmation() def action_next_field(self) -> None: @@ -679,22 +815,32 @@ class HostsManagerApp(App): def action_toggle_entry(self) -> None: """Toggle the active state of the selected entry.""" + if not self._allow_mutation_action(): + return self.navigation_handler.toggle_entry() def action_move_entry_up(self) -> None: """Move the selected entry up in the list.""" + if not self._allow_mutation_action(): + return self.navigation_handler.move_entry_up() def action_move_entry_down(self) -> None: """Move the selected entry down in the list.""" + if not self._allow_mutation_action(): + return self.navigation_handler.move_entry_down() def action_save_file(self) -> None: """Save the hosts file to disk.""" + if not self._allow_mutation_action(): + return self.navigation_handler.save_hosts_file() def action_add_entry(self) -> None: """Show the add entry modal.""" + if not self._allow_mutation_action(): + return if not self.edit_mode: self.update_status( "❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." @@ -738,6 +884,8 @@ class HostsManagerApp(App): def action_delete_entry(self) -> None: """Show the delete confirmation modal for the selected entry.""" + if not self._allow_mutation_action(): + return if not self.edit_mode: self.update_status( "❌ Cannot delete entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." @@ -794,6 +942,8 @@ class HostsManagerApp(App): def action_undo(self) -> None: """Undo the last operation.""" + if not self._allow_mutation_action(): + return if not self.edit_mode: self.update_status("❌ Cannot undo: Application is in read-only mode") return @@ -821,6 +971,8 @@ class HostsManagerApp(App): def action_redo(self) -> None: """Redo the last undone operation.""" + if not self._allow_mutation_action(): + return if not self.edit_mode: self.update_status("❌ Cannot redo: Application is in read-only mode") return @@ -848,6 +1000,8 @@ class HostsManagerApp(App): def action_refresh_dns(self) -> None: """Manually refresh DNS resolution for all entries.""" + if not self._allow_mutation_action(): + return if not self.edit_mode: self.update_status( "❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." @@ -942,6 +1096,8 @@ class HostsManagerApp(App): def action_update_single_dns(self) -> None: """Manually refresh DNS resolution for the currently selected entry.""" + if not self._allow_mutation_action(): + return if not self.edit_mode: self.update_status( "❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." @@ -1136,6 +1292,10 @@ class HostsManagerApp(App): """Update the edit form with current entry values.""" self.details_handler.update_edit_form() + def watch_edit_mode(self, edit_mode: bool) -> None: + """Keep elevated capability explicit without changing the full palette.""" + self.sub_title = "PRIVILEGED" if edit_mode else "" + def watch_entry_edit_mode(self, entry_edit_mode: bool) -> None: """Update the right pane border title when entry edit mode changes.""" try: diff --git a/src/hosts/tui/config_modal.py b/src/hosts/tui/config_modal.py index d1f00b6..1b6d346 100644 --- a/src/hosts/tui/config_modal.py +++ b/src/hosts/tui/config_modal.py @@ -48,14 +48,14 @@ class ConfigModal(ModalScreen[bool]): with Horizontal(classes="button-row"): yield Button( - "Save", variant="primary", id="save-button", classes="config-button" - ) - yield Button( - "Cancel (ESC)", + "Cancel", variant="default", id="cancel-button", classes="default-button", ) + yield Button( + "Save", variant="primary", id="save-button", classes="config-button" + ) def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" diff --git a/src/hosts/tui/delete_confirmation_modal.py b/src/hosts/tui/delete_confirmation_modal.py index dff896d..d180c65 100644 --- a/src/hosts/tui/delete_confirmation_modal.py +++ b/src/hosts/tui/delete_confirmation_modal.py @@ -25,7 +25,6 @@ class DeleteConfirmationModal(ModalScreen[bool]): BINDINGS = [ Binding("escape", "cancel", "Cancel"), - Binding("enter", "confirm", "Delete"), ] def __init__(self, entry: HostEntry): @@ -35,7 +34,7 @@ class DeleteConfirmationModal(ModalScreen[bool]): def compose(self) -> ComposeResult: """Create the delete confirmation modal layout.""" with Vertical(classes="delete-container"): - yield Static("Delete Entry", classes="delete-title") + yield Static("Delete Host Entry", classes="delete-title") yield Static( "Are you sure you want to delete this entry?", classes="delete-message" @@ -52,15 +51,15 @@ class DeleteConfirmationModal(ModalScreen[bool]): with Horizontal(classes="button-row"): yield Button( - "Delete", - variant="error", - id="delete-button", + "Cancel", + variant="default", + id="cancel-button", classes="default-button", ) yield Button( - "Cancel (ESC)", - variant="default", - id="cancel-button", + "Delete Host Entry", + variant="error", + id="delete-button", classes="default-button", ) diff --git a/src/hosts/tui/details_handler.py b/src/hosts/tui/details_handler.py index 6a2b63b..87408a4 100644 --- a/src/hosts/tui/details_handler.py +++ b/src/hosts/tui/details_handler.py @@ -1,113 +1,113 @@ -""" -Details pane management for the hosts TUI application. +"""Read-only Entry Details and editable Entry Editor coordination.""" -This module handles the display and updating of entry details -and edit forms in the right pane. -""" - -from textual.widgets import Input, Checkbox +from textual.widgets import Checkbox, Input, Static class DetailsHandler: - """Handles all details pane operations for the hosts manager.""" + """Render selected Host Entry data without making inspection look editable.""" def __init__(self, app): - """Initialize the details handler with reference to the main app.""" self.app = app def update_entry_details(self) -> None: - """Update the right pane with selected entry details.""" if self.app.entry_edit_mode: self.update_edit_form() else: self.update_details_display() + def _set_detail(self, detail_id: str, value: str) -> None: + self.app.query_one(f"#{detail_id}", Static).update(value) + + def _show_empty_state(self, message: str) -> None: + self.app.query_one("#details-empty-state", Static).update(message) + self.app.query_one("#details-empty-state", Static).remove_class("hidden") + self.app.query_one("#details-content").add_class("hidden") + self.app.query_one("#details-dns-rows").add_class("hidden") + self.app.query_one("#details-default-notice").add_class("hidden") + def update_details_display(self) -> None: - """Update the details display using disabled Input widgets.""" + """Show compact label-value rows for the currently selected Host Entry.""" details_display = self.app.query_one("#entry-details-display") edit_form = self.app.query_one("#entry-edit-form") - - # Show details display, hide edit form details_display.remove_class("hidden") edit_form.add_class("hidden") - # Get the input widgets - ip_input = self.app.query_one("#details-ip-input", Input) - hostname_input = self.app.query_one("#details-hostname-input", Input) - comment_input = self.app.query_one("#details-comment-input", Input) - active_checkbox = self.app.query_one("#details-active-checkbox", Checkbox) - if not self.app.hosts_file.entries: - # Show empty message - ip_input.value = "" - ip_input.placeholder = "No entries loaded" - hostname_input.value = "" - hostname_input.placeholder = "No entries loaded" - comment_input.value = "" - comment_input.placeholder = "No entries loaded" - active_checkbox.value = False + self._show_empty_state("No Host Entries are loaded.") return - # Get visible entries to check if we need to adjust selection visible_entries = self.app.table_handler.get_visible_entries() if not visible_entries: - ip_input.value = "" - ip_input.placeholder = "No visible entries" - hostname_input.value = "" - hostname_input.placeholder = "No visible entries" - comment_input.value = "" - comment_input.placeholder = "No visible entries" - active_checkbox.value = False + self._show_empty_state( + "No Host Entries match the current filters. Press Ctrl+F to change them." + ) 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.app.config.should_show_default_entries() - if not show_defaults: - # Check if the currently selected entry is a default entry (hidden) - if ( - self.app.selected_entry_index < len(self.app.hosts_file.entries) - and self.app.hosts_file.entries[ - self.app.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.app.hosts_file.entries): - if not entry.is_default_entry(): - self.app.selected_entry_index = i - break - if self.app.selected_entry_index >= len(self.app.hosts_file.entries): self.app.selected_entry_index = 0 - entry = self.app.hosts_file.entries[self.app.selected_entry_index] - # Update the input widgets with entry data - ip_input.value = entry.ip_address - ip_input.placeholder = "" - hostname_input.value = ", ".join(entry.hostnames) - hostname_input.placeholder = "" - comment_input.value = entry.comment or "" - comment_input.placeholder = "No comment" - active_checkbox.value = entry.is_active + self.app.query_one("#details-empty-state", Static).add_class("hidden") + self.app.query_one("#details-content").remove_class("hidden") + self._set_detail("details-ip-input", entry.ip_address) + self._set_detail("details-hostname-input", ", ".join(entry.hostnames)) + self._set_detail("details-comment-input", entry.comment or "—") + self._set_detail( + "details-active-checkbox", "✓ Active" if entry.is_active else "· Inactive" + ) - # For default entries, show warning in placeholder text + default_notice = self.app.query_one("#details-default-notice", Static) if entry.is_default_entry(): - ip_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" - hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" - comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" + entry_state = "✓ Active" if entry.is_active else "· Inactive" + self._set_detail( + "details-active-checkbox", f"■ Default (protected) · {entry_state}" + ) + default_notice.update( + "Protected Default Entry. This operating-system mapping cannot be changed." + ) + default_notice.remove_class("hidden") + else: + default_notice.add_class("hidden") - # Update DNS information if present - self._update_dns_information(entry) + dns_rows = self.app.query_one("#details-dns-rows") + if entry.has_dns_name(): + dns_rows.remove_class("hidden") + self._set_detail("details-dns-name-input", entry.dns_name or "—") + self._set_detail("details-dns-status-input", self._dns_status_text(entry)) + self._set_detail( + "details-dns-resolved-input", + entry.last_resolved.strftime("%Y-%m-%d %H:%M:%S") + if entry.last_resolved + else "Not resolved yet", + ) + else: + dns_rows.add_class("hidden") + + @staticmethod + def _dns_status_text(entry) -> str: + labels = { + "not_resolved": "· Not resolved", + "resolving": "! Resolving…", + "resolved": "✓ Resolved", + "failed": "× Resolution failed", + "match": "✓ IP matches DNS", + "mismatch": "! IP differs from DNS", + } + status = labels.get(entry.dns_resolution_status, entry.dns_resolution_status) + if not status: + return "· Not resolved" + if entry.resolved_ip and entry.dns_resolution_status in { + "resolved", + "match", + "mismatch", + }: + return f"{status} ({entry.resolved_ip})" + return status def update_edit_form(self) -> None: - """Update the edit form with current entry values.""" + """Populate the separate editable Entry Editor form.""" details_display = self.app.query_one("#entry-details-display") edit_form = self.app.query_one("#entry-edit-form") - - # Hide details display, show edit form details_display.add_class("hidden") edit_form.remove_class("hidden") @@ -117,80 +117,8 @@ class DetailsHandler: return entry = self.app.hosts_file.entries[self.app.selected_entry_index] - - # Update form fields with current entry values - ip_input = self.app.query_one("#ip-input", Input) - hostname_input = self.app.query_one("#hostname-input", Input) - comment_input = self.app.query_one("#comment-input", Input) - active_checkbox = self.app.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 - - # Initialize radio button state and field visibility + self.app.query_one("#ip-input", Input).value = entry.ip_address + self.app.query_one("#hostname-input", Input).value = ", ".join(entry.hostnames) + self.app.query_one("#comment-input", Input).value = entry.comment or "" + self.app.query_one("#active-checkbox", Checkbox).value = entry.is_active self.app.edit_handler.populate_edit_form_with_type_detection() - - def _update_dns_information(self, entry) -> None: - """Update DNS information display for the selected entry.""" - try: - # Get the three separate DNS input fields - dns_name_input = self.app.query_one("#details-dns-name-input", Input) - dns_status_input = self.app.query_one("#details-dns-status-input", Input) - dns_resolved_input = self.app.query_one( - "#details-dns-resolved-input", Input - ) - - if not entry.has_dns_name(): - # Clear all DNS fields if no DNS information - dns_name_input.value = "" - dns_name_input.placeholder = "No DNS name" - dns_status_input.value = "" - dns_status_input.placeholder = "No DNS status" - dns_resolved_input.value = "" - dns_resolved_input.placeholder = "Not resolved yet" - return - - # Update DNS Name field - dns_name_input.value = entry.dns_name or "" - dns_name_input.placeholder = "" if entry.dns_name else "No DNS name" - - # Update DNS Status field - if entry.dns_resolution_status: - status_text = { - "not_resolved": "Not resolved", - "resolving": "Resolving...", - "resolved": "Resolved", - "failed": "Resolution failed", - "match": "IP matches DNS", - "mismatch": "IP differs from DNS", - }.get(entry.dns_resolution_status, entry.dns_resolution_status) - - # Add resolved IP to status if available - if entry.resolved_ip and entry.dns_resolution_status in [ - "resolved", - "match", - "mismatch", - ]: - status_text += f" ({entry.resolved_ip})" - - dns_status_input.value = status_text - dns_status_input.placeholder = "" - else: - dns_status_input.value = "" - dns_status_input.placeholder = "No DNS status" - - # Update Last Resolved field - if entry.last_resolved: - time_str = entry.last_resolved.strftime("%H:%M:%S") - date_str = entry.last_resolved.strftime("%Y-%m-%d") - dns_resolved_input.value = f"{date_str} {time_str}" - dns_resolved_input.placeholder = "" - else: - dns_resolved_input.value = "" - dns_resolved_input.placeholder = "Not resolved yet" - - except Exception: - # DNS widgets not present yet, silently ignore - pass diff --git a/src/hosts/tui/filter_modal.py b/src/hosts/tui/filter_modal.py index f60a393..32c7823 100644 --- a/src/hosts/tui/filter_modal.py +++ b/src/hosts/tui/filter_modal.py @@ -186,11 +186,11 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): ) with Horizontal(classes="filter-actions"): + yield Button("Cancel", id="cancel", classes="default-button") + yield Button("Reset", id="reset", classes="default-button") yield Button( "Apply", id="apply", variant="primary", classes="default-button" ) - yield Button("Reset", id="reset", classes="default-button") - yield Button("Cancel (ESC)", id="cancel", classes="default-button") def _create_preset_select(self) -> Select: """Create the preset picker without selecting a preset by default.""" @@ -251,9 +251,9 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): "search-comments", "search-ips", "search-case-sensitive", - "apply", - "reset", "cancel", + "reset", + "apply", ) ) return control_ids diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py new file mode 100644 index 0000000..72dd7ab --- /dev/null +++ b/src/hosts/tui/help_modal.py @@ -0,0 +1,66 @@ +"""Dedicated keyboard reference overlay.""" + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Static + + +class HelpModal(ModalScreen[None]): + """Show the complete binding reference without shrinking the workspace.""" + + BINDINGS = [ + Binding("escape", "close", "Close"), + Binding("question_mark", "close", "Close"), + ] + + CSS = """ + HelpModal { align: center middle; } + #help-container { + width: 72; + height: auto; + max-height: 90%; + background: $surface; + border: thick $primary; + padding: 1 2; + } + .help-title { text-style: bold; color: $primary; text-align: center; } + .help-section { margin-top: 1; text-style: bold; } + .help-copy { color: $text-muted; } + #help-close { margin-top: 1; width: 12; } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="help-container"): + yield Static("Keyboard help", classes="help-title") + yield Static("General", classes="help-section") + yield Static( + "q Quit ? Close help c Configuration", classes="help-copy" + ) + yield Static("Navigation", classes="help-section") + yield Static( + "↑/↓ Select Host Entry i Sort IP h Sort hostname", + classes="help-copy", + ) + yield Static("Filtering", classes="help-section") + yield Static( + "Type in Search for immediate filtering Ctrl+F Advanced filters", + classes="help-copy", + ) + yield Static("Privileged Mode", classes="help-section") + yield Static( + "Ctrl+E Enter or leave Privileged Mode n New e Edit d Delete", + classes="help-copy", + ) + yield Button("Close", id="help-close", variant="primary") + + def on_mount(self) -> None: + self.query_one("#help-close", Button).focus() + + def action_close(self) -> None: + self.dismiss(None) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "help-close": + self.action_close() diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 0279815..9d7dc51 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -22,7 +22,7 @@ HOSTS_MANAGER_BINDINGS = [ Binding( "ctrl+e", "toggle_edit_mode", - "Toggle edit mode", + "Privileged Mode", show=True, id="left:toggle_edit_mode", ), diff --git a/src/hosts/tui/save_confirmation_modal.py b/src/hosts/tui/save_confirmation_modal.py index c7c0b14..3b5a36f 100644 --- a/src/hosts/tui/save_confirmation_modal.py +++ b/src/hosts/tui/save_confirmation_modal.py @@ -40,29 +40,27 @@ class SaveConfirmationModal(ModalScreen[str]): with Horizontal(classes="button-row"): yield Button( - "Save (S)", - variant="primary", - id="save-button", + "Cancel", + variant="default", + id="cancel-button", classes="save-confirmation-button", ) yield Button( - "Discard (D)", + "Discard", variant="default", id="discard-button", classes="save-confirmation-button", ) yield Button( - "Cancel (ESC)", - variant="default", - id="cancel-button", + "Save", + variant="primary", + id="save-button", classes="save-confirmation-button", ) def on_mount(self) -> None: - """Called when the modal is mounted. Set focus to the first button.""" - # Focus on the Save button by default - save_button = self.query_one("#save-button", Button) - save_button.focus() + """Start at the non-destructive Cancel action.""" + self.query_one("#cancel-button", Button).focus() def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index fa1da14..f37bdeb 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -5,6 +5,24 @@ This module contains all CSS definitions for consistent styling across the application. """ +from textual.theme import Theme + + +HOSTS_DARK_THEME = Theme( + name="hosts-dark", + primary="#0178D4", + secondary="#004578", + warning="#FFA62B", + error="#BA3C5B", + success="#4EBF71", + accent="#FFA62B", + foreground="#E0E0E0", + background="#121212", + surface="#1E1E1E", + panel="#242F38", + dark=True, +) + # Common CSS classes shared across components COMMON_CSS = """ .default-button { @@ -71,6 +89,43 @@ HOSTS_MANAGER_CSS = ( margin-bottom: 0; } +#message-rail { + height: 1; + background: $panel; + color: $text; + padding: 0 1; +} + +#message-rail.message-error { + color: $error; +} + +#message-rail.message-warning { + color: $warning; +} + +#minimum-size { + display: none; + height: 1fr; + content-align: center middle; + text-align: center; + padding: 1 2; + border: round $warning; + color: $text; +} + +#workspace.hidden, +#minimum-size.hidden, +.detail-rows.hidden, +#details-dns-rows.hidden, +#details-default-notice.hidden { + display: none; +} + +#minimum-size.visible { + display: block; +} + .search-input { height: 1fr; width: 1fr; @@ -82,6 +137,10 @@ HOSTS_MANAGER_CSS = ( margin-top: 0; } +.compact-layout .entry-form { + padding: 0 1; +} + .common-pane { border: round $primary; margin: 0; @@ -127,24 +186,35 @@ HOSTS_MANAGER_CSS = ( display: none; } -.status-bar { - height: 1; - width: 100%; - background: $error; - color: $text; - content-align: center middle; - layer: overlay; - dock: top; - offset-y: 1; -} - -.status-bar.hidden { - display: none; -} - .entry-form { + height: 1fr; + padding: 1 2; +} + +.detail-row { + height: 1; +} + +.detail-label { + width: 14; + color: $text-muted; +} + +.detail-value { + width: 1fr; + color: $text; +} + +.detail-note { height: auto; - padding: 1; + color: $warning; + margin-top: 1; +} + +.empty-state { + height: 1fr; + content-align: center middle; + color: $text-muted; } Header { @@ -157,7 +227,7 @@ Header.-tall { /* Custom Footer Styling */ CustomFooter { - background: $surface; + background: $panel; color: $text; dock: bottom; height: 1; diff --git a/src/hosts/tui/table_handler.py b/src/hosts/tui/table_handler.py index 769fe79..bd2c391 100644 --- a/src/hosts/tui/table_handler.py +++ b/src/hosts/tui/table_handler.py @@ -185,25 +185,21 @@ class TableHandler: # Get DNS status indicator dns_text = self._get_dns_status_indicator(entry) - # Add row with styling based on active status and default entry status + # Markers and wording keep each state understandable without colour. 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, dns_text) + entry_state = "✓ Active" if entry.is_active else "· Inactive" + active_text = Text(f"■ Default · {entry_state}", style="dim") + ip_text = Text(entry.ip_address, style="dim") + hostname_text = Text(canonical_hostname, style="dim") 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, dns_text) + active_text = Text("✓ Active") + ip_text = entry.ip_address + hostname_text = canonical_hostname 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, dns_text) + active_text = Text("· Inactive", style="dim") + ip_text = Text(entry.ip_address, style="dim") + hostname_text = Text(canonical_hostname, style="dim") + table.add_row(active_text, ip_text, hostname_text, dns_text) def restore_cursor_position(self, previous_entry) -> None: """Restore cursor position after reload, maintaining selection if possible.""" @@ -265,7 +261,7 @@ class TableHandler: """Get DNS name and status indicator for an entry.""" # If entry has no DNS name configured, show empty if not entry.has_dns_name(): - return Text("", style="dim white") + return Text("") # Start with the DNS name dns_display = entry.dns_name @@ -274,28 +270,21 @@ class TableHandler: dns_status = entry.dns_resolution_status or "not_resolved" if dns_status == "not_resolved": - status_icon = "⏳" - style = "dim yellow" + status_icon = "· Pending" elif dns_status == "resolving": - status_icon = "🔄" - style = "yellow" + status_icon = "! Resolving" elif dns_status == "resolved": - status_icon = "✅" - style = "green" + status_icon = "✓ Resolved" elif dns_status == "match": - status_icon = "✅" - style = "bold green" + status_icon = "✓ Matches" elif dns_status == "mismatch": - status_icon = "⚠️" - style = "red" + status_icon = "! Mismatch" elif dns_status == "failed": - status_icon = "❌" - style = "red" + status_icon = "× Failed" else: status_icon = "" - style = "dim white" - return Text(f"{status_icon} {dns_display}", style=style) + return Text(f"{status_icon} {dns_display}".strip()) def sort_entries_by_hostname(self) -> None: """Sort entries by canonical hostname.""" diff --git a/tests/test_app.py b/tests/test_app.py index 808b2f9..d717165 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,13 +5,14 @@ from unittest.mock import Mock, patch import pytest from textual.app import SuspendNotSupported -from textual.widgets import HelpPanel, RadioButton, Static +from textual.widgets import Input, RadioButton, Static from src.hosts.core.filters import FilterOptions from src.hosts.core.models import HostEntry, HostsFile from src.hosts.tui.app import HostsManagerApp from src.hosts.tui.custom_footer import CustomFooter from src.hosts.tui.filter_modal import FilterModal +from src.hosts.tui.help_modal import HelpModal @contextmanager @@ -45,16 +46,13 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter(): async with app.run_test() as pilot: footer = app.query_one("#custom-footer", CustomFooter) footer_text = footer.query_one("#footer-right", Static).render() - assert "ctrl+f Filter entries" in str(footer_text) + assert "ctrl+f Filters" in str(footer_text) app.action_help() await pilot.pause() - assert isinstance(app.query_one(HelpPanel), HelpPanel) - assert any( - binding.action == "show_filters" and binding.description == "Filter entries" - for _, binding, _, _ in app.screen.active_bindings.values() - ) - app.action_help() + assert isinstance(app.screen, HelpModal) + assert app.screen.query_one("#help-close") is not None + await pilot.press("escape") await pilot.pause() await pilot.press("ctrl+f") @@ -89,6 +87,24 @@ async def test_filter_shortcut_does_not_stack_filter_modals(): assert sum(isinstance(screen, FilterModal) for screen in app.screen_stack) == 1 +@pytest.mark.asyncio +async def test_help_overlay_closes_to_the_control_that_opened_it(): + """Help is an overlay and returns keyboard focus to its opener.""" + app = app_with_filterable_entries() + + async with app.run_test(size=(120, 40)) as pilot: + search = app.query_one("#search-input", Input) + search.focus() + app.action_help() + await pilot.pause() + + assert isinstance(app.screen, HelpModal) + await pilot.press("escape") + await pilot.pause() + + assert app.focused is search + + @pytest.mark.asyncio async def test_filter_modal_tabs_through_controls_in_visual_order(): """The modal starts at Presets and tabs through the visible controls in order.""" @@ -111,6 +127,8 @@ async def test_filter_modal_tabs_through_controls_in_visual_order(): "search-comments", "search-ips", "search-case-sensitive", + "cancel", + "reset", "apply", ): await pilot.press("tab") @@ -148,6 +166,79 @@ async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters( ] +@pytest.mark.asyncio +async def test_workspace_uses_read_only_detail_rows_and_hides_irrelevant_dns_fields(): + """An IP Host Entry has compact text details instead of disabled controls.""" + app = app_with_filterable_entries() + + async with app.run_test(size=(120, 40)) as pilot: + app.table_handler.populate_entries_table() + app.details_handler.update_entry_details() + await pilot.pause() + + details = app.query_one("#entry-details-display") + assert not list(details.query(Input)) + assert "192.0.2.1" in str(app.query_one("#details-ip-input", Static).render()) + assert "Active" in str( + app.query_one("#details-active-checkbox", Static).render() + ) + assert app.query_one("#details-dns-rows").has_class("hidden") + + +@pytest.mark.asyncio +async def test_workspace_exposes_dns_and_protection_details_and_durable_state(): + """DNS and Default Entry states remain explicit independently of colour.""" + app = app_with_filterable_entries() + app.hosts_file.entries[0].dns_name = "active.example" + app.hosts_file.entries[0].dns_resolution_status = "resolved" + app.hosts_file.entries[0].resolved_ip = "192.0.2.1" + app.current_filter_options.active_only = True + app.current_filter_options.show_inactive = False + + async with app.run_test(size=(100, 30)) as pilot: + app.table_handler.populate_entries_table() + app.details_handler.update_entry_details() + app._update_footer_status() + await pilot.pause() + + assert not app.query_one("#details-dns-rows").has_class("hidden") + assert "Resolved" in str( + app.query_one("#details-dns-status-input", Static).render() + ) + footer = app.query_one("#custom-footer", CustomFooter) + assert "Filters: 1" in footer._status_text + assert "READ-ONLY" in footer._status_text + assert app.has_class("compact-layout") + + +@pytest.mark.asyncio +async def test_small_viewport_replaces_workspace_with_an_explicit_safe_message(): + """A viewport below the contract cannot expose clipped mutating controls.""" + app = app_with_filterable_entries() + + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + + assert not app.query_one("#workspace").display + minimum_size = app.query_one("#minimum-size", Static) + assert "requires at least 100 columns × 30 rows" in str(minimum_size.render()) + + +@pytest.mark.asyncio +async def test_small_viewport_blocks_hidden_mutation_shortcuts(): + """A minimum-size presentation cannot open a hidden editor or privilege flow.""" + app = app_with_filterable_entries() + app.push_screen = Mock() + app.manager = Mock() + + async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("n", "ctrl+e") + await pilot.pause() + + app.push_screen.assert_not_called() + app.manager.enter_edit_mode.assert_not_called() + + class TestPrivilegedModeAuthorization: """Test the user-visible privileged-mode authorization flow.""" diff --git a/tests/test_main.py b/tests/test_main.py index 27e0f54..601939a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -102,7 +102,7 @@ class TestHostsManagerApp: # Should handle error gracefully app.update_status.assert_called_with( - "❌ Error loading hosts file: Hosts file not found" + "Error loading hosts file: Hosts file not found" ) def test_load_hosts_file_permission_error(self): @@ -122,7 +122,7 @@ class TestHostsManagerApp: # Should handle error gracefully app.update_status.assert_called_with( - "❌ Error loading hosts file: Permission denied" + "Error loading hosts file: Permission denied" ) def test_populate_entries_table_logic(self): @@ -207,13 +207,15 @@ class TestHostsManagerApp: app.update_entry_details() - # Verify input widgets were updated with entry data + # Verify read-only detail rows were updated with entry data. mock_details_display.remove_class.assert_called_with("hidden") mock_edit_form.add_class.assert_called_with("hidden") - assert mock_ip_input.value == "127.0.0.1" - assert mock_hostname_input.value == "localhost, local" - assert mock_comment_input.value == "Test comment" - assert mock_active_checkbox.value + mock_ip_input.update.assert_called_with("127.0.0.1") + mock_hostname_input.update.assert_called_with("localhost, local") + mock_comment_input.update.assert_called_with("Test comment") + mock_active_checkbox.update.assert_called_with( + "■ Default (protected) · ✓ Active" + ) def test_update_entry_details_no_entries(self): """Test updating entry details with no entries.""" @@ -233,6 +235,7 @@ class TestHostsManagerApp: mock_hostname_input = Mock() mock_comment_input = Mock() mock_active_checkbox = Mock() + mock_empty_state = Mock() def mock_query_one(selector, widget_type=None): if selector == "#entry-details-display": @@ -247,6 +250,8 @@ class TestHostsManagerApp: return mock_comment_input elif selector == "#details-active-checkbox": return mock_active_checkbox + elif selector == "#details-empty-state": + return mock_empty_state return Mock() cast(Any, app).query_one = mock_query_one @@ -254,16 +259,10 @@ class TestHostsManagerApp: app.update_entry_details() - # Verify widgets show empty state placeholders + # Verify the detail pane names the empty condition. mock_details_display.remove_class.assert_called_with("hidden") mock_edit_form.add_class.assert_called_with("hidden") - assert mock_ip_input.value == "" - assert mock_ip_input.placeholder == "No entries loaded" - assert mock_hostname_input.value == "" - assert mock_hostname_input.placeholder == "No entries loaded" - assert mock_comment_input.value == "" - assert mock_comment_input.placeholder == "No entries loaded" - assert not mock_active_checkbox.value + mock_empty_state.update.assert_called_with("No Host Entries are loaded.") def test_update_status_default(self): """Test status bar update with default information.""" @@ -301,7 +300,7 @@ class TestHostsManagerApp: # Verify footer status was updated mock_footer.set_status.assert_called_once() status_call = mock_footer.set_status.call_args[0][0] - assert "Read-only" in status_call + assert "READ-ONLY" in status_call assert "2 entries" in status_call assert "1 active" in status_call @@ -318,12 +317,12 @@ class TestHostsManagerApp: # Mock set_timer and query_one to avoid event loop and UI issues app.set_timer = Mock() - mock_status_bar = Mock() + mock_message_rail = Mock() mock_footer = Mock() def mock_query_one(selector, widget_type=None): - if selector == "#status-bar": - return mock_status_bar + if selector == "#message-rail": + return mock_message_rail elif selector == "#custom-footer": return mock_footer return Mock() @@ -343,14 +342,13 @@ class TestHostsManagerApp: app.update_status("Custom status message") - # Verify status bar was updated with custom message - mock_status_bar.update.assert_called_with("Custom status message") - mock_status_bar.remove_class.assert_called_with("hidden") + # Verify the reserved message rail was updated with the message. + mock_message_rail.update.assert_called_with("Custom status message") # Verify footer status was updated with current status (not the custom message) mock_footer.set_status.assert_called_once() footer_status = mock_footer.set_status.call_args[0][0] assert "2 entries" in footer_status - assert "Read-only" in footer_status + assert "READ-ONLY" in footer_status # Verify timer was set for auto-clearing app.set_timer.assert_called_once() @@ -382,12 +380,12 @@ 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() + # Help is a dedicated overlay, not a docked panel. + app.push_screen.assert_called_once() def test_action_config(self): """Test config action opens modal.""" diff --git a/tests/test_save_confirmation_modal.py b/tests/test_save_confirmation_modal.py index cd1bd52..b7cbefe 100644 --- a/tests/test_save_confirmation_modal.py +++ b/tests/test_save_confirmation_modal.py @@ -57,15 +57,15 @@ class TestSaveConfirmationModal: @patch.object(SaveConfirmationModal, "query_one") def test_on_mount_sets_focus(self, mock_query_one): - """Test that on_mount sets focus to the save button.""" + """Test that on_mount sets focus to the safe cancel button.""" modal = SaveConfirmationModal() - mock_save_button = Mock() - mock_query_one.return_value = mock_save_button + mock_cancel_button = Mock() + mock_query_one.return_value = mock_cancel_button modal.on_mount() - mock_query_one.assert_called_once_with("#save-button", Button) - mock_save_button.focus.assert_called_once() + mock_query_one.assert_called_once_with("#cancel-button", Button) + mock_cancel_button.focus.assert_called_once() class TestSaveConfirmationIntegration: