feat #14: Implement design guide #15

Merged
phg merged 6 commits from issue-14-tui-design-guide into main 2026-09-04 17:15:40 +00:00
14 changed files with 741 additions and 442 deletions
Showing only changes of commit 563ec7c705 - Show all commits

View file

@ -99,15 +99,15 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
# Buttons # Buttons
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button( yield Button(
"Add Entry (CTRL+S)", "Cancel",
variant="primary", variant="default",
id="add-button", id="cancel-button",
classes="default-button", classes="default-button",
) )
yield Button( yield Button(
"Cancel (ESC)", "Add Host Entry",
variant="default", variant="primary",
id="cancel-button", id="add-button",
classes="default-button", classes="default-button",
) )

View file

@ -31,8 +31,9 @@ from .config_modal import ConfigModal
from .add_entry_modal import AddEntryModal from .add_entry_modal import AddEntryModal
from .delete_confirmation_modal import DeleteConfirmationModal from .delete_confirmation_modal import DeleteConfirmationModal
from .filter_modal import FilterModal from .filter_modal import FilterModal
from .help_modal import HelpModal
from .custom_footer import CustomFooter 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 .keybindings import HOSTS_MANAGER_BINDINGS
from .table_handler import TableHandler from .table_handler import TableHandler
from .details_handler import DetailsHandler from .details_handler import DetailsHandler
@ -60,8 +61,6 @@ class HostsManagerApp(App):
CSS = HOSTS_MANAGER_CSS CSS = HOSTS_MANAGER_CSS
BINDINGS = HOSTS_MANAGER_BINDINGS BINDINGS = HOSTS_MANAGER_BINDINGS
help_visible = False
# Reactive attributes # Reactive attributes
hosts_file: reactive[HostsFile] = reactive(HostsFile()) hosts_file: reactive[HostsFile] = reactive(HostsFile())
selected_entry_index: reactive[int] = reactive(0) selected_entry_index: reactive[int] = reactive(0)
@ -74,6 +73,8 @@ class HostsManagerApp(App):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.title = "/etc/hosts Manager" self.title = "/etc/hosts Manager"
self.register_theme(HOSTS_DARK_THEME)
self.theme = "hosts-dark"
# Initialize core components # Initialize core components
self.parser = HostsParser() self.parser = HostsParser()
@ -99,166 +100,212 @@ class HostsManagerApp(App):
# State for edit mode # State for edit mode
self.original_entry_values = None self.original_entry_values = None
self._status_timer = None
self._viewport_too_small = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create child widgets for the app.""" """Create child widgets for the app."""
yield Header() yield Header()
yield CustomFooter(id="custom-footer") yield CustomFooter(id="custom-footer")
yield Static("", id="message-rail")
yield Static("", id="minimum-size", classes="hidden")
# Search bar above the panes with Vertical(id="workspace"):
with Horizontal(classes="search-container") as search_container: # Search remains visible above the master-detail workspace.
search_container.border_title = "Search" with Horizontal(classes="search-container") as search_container:
yield Input( search_container.border_title = "Search"
placeholder="Filter by hostname, IP address, or comment...", yield Input(
id="search-input", placeholder="Filter by hostname, IP address, or comment...",
classes="search-input", id="search-input",
) classes="search-input",
)
yield Static("", id="filter-summary")
with Horizontal(classes="hosts-container"): with Horizontal(classes="hosts-container"):
# Left pane - entries table # Left pane - entries table
with Vertical(classes="common-pane left-pane") as left_pane: with Vertical(classes="common-pane left-pane") as left_pane:
left_pane.border_title = "Host Entries" left_pane.border_title = "Host Entries"
yield DataTable(id="entries-table") yield DataTable(id="entries-table")
# Right pane - entry details or edit form # Right pane - entry details or edit form
with Vertical(classes="common-pane right-pane") as right_pane: with Vertical(classes="common-pane right-pane") as right_pane:
right_pane.border_title = "Entry Details" right_pane.border_title = "Entry Details"
# Details display form (disabled inputs) # Inspection values deliberately use text, not disabled inputs.
with Vertical(id="entry-details-display", classes="entry-form"): with Vertical(id="entry-details-display", classes="entry-form"):
with Vertical( yield Static(
classes="default-section section-no-top-margin" "No Host Entry selected.",
) as ip_address: id="details-empty-state",
ip_address.border_title = "IP Address" classes="empty-state",
yield Input(
placeholder="No entry selected",
id="details-ip-input",
disabled=True,
classes="default-input",
) )
with Vertical(
with Vertical(classes="default-section") as hostnames: id="details-content", classes="detail-rows hidden"
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"
): ):
yield RadioButton( with Horizontal(classes="detail-row"):
"IP Address Entry", value=True, id="edit-ip-entry-radio" yield Static("IP address", classes="detail-label")
) yield Static(
yield RadioButton( "", id="details-ip-input", classes="detail-value"
"DNS Name Entry", id="edit-dns-entry-radio" )
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 # Edit form (initially hidden)
with Vertical( with Vertical(id="entry-edit-form", classes="entry-form hidden"):
classes="default-section", id="edit-ip-section" with Vertical(
) as ip_address: classes="default-flex-section section-no-top-margin"
ip_address.border_title = "IP Address" ) as entry_type:
yield Input( entry_type.border_title = "Entry Type"
placeholder="Enter IP address", with RadioSet(
id="ip-input", id="edit-entry-type-radio", classes="default-radio-set"
classes="default-input", ):
) 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(
with Vertical( classes="default-section", id="edit-ip-section"
classes="default-section hidden", id="edit-dns-section" ) as ip_address:
) as dns_name: ip_address.border_title = "IP Address"
dns_name.border_title = "DNS Name (to resolve)" yield Input(
yield Input( placeholder="Enter IP address",
placeholder="e.g., example.com", id="ip-input",
id="dns-name-input", classes="default-input",
classes="default-input", )
)
with Vertical(classes="default-section") as hostnames: with Vertical(
hostnames.border_title = "Hostnames (comma-separated)" classes="default-section hidden", id="edit-dns-section"
yield Input( ) as dns_name:
placeholder="Enter hostnames", dns_name.border_title = "DNS Name (to resolve)"
id="hostname-input", yield Input(
classes="default-input", placeholder="e.g., example.com",
) id="dns-name-input",
classes="default-input",
)
with Vertical(classes="default-section") as comment: with Vertical(classes="default-section") as hostnames:
comment.border_title = "Comment:" hostnames.border_title = "Hostnames (comma-separated)"
yield Input( yield Input(
placeholder="Enter comment (optional)", placeholder="Enter hostnames",
id="comment-input", id="hostname-input",
classes="default-input", classes="default-input",
) )
with Vertical(classes="default-section") as active: with Vertical(classes="default-section") as comment:
active.border_title = "Active" comment.border_title = "Comment:"
yield Checkbox( yield Input(
"Active", id="active-checkbox", classes="default-checkbox" placeholder="Enter comment (optional)",
) id="comment-input",
classes="default-input",
)
# Status bar for error/temporary messages (overlay, doesn't affect layout) with Vertical(classes="default-section") as active:
yield Static("", id="status-bar", classes="status-bar hidden") active.border_title = "Active"
yield Checkbox(
"Active",
id="active-checkbox",
classes="default-checkbox",
)
def on_ready(self) -> None: def on_ready(self) -> None:
"""Called when the app is ready.""" """Called when the app is ready."""
self.load_hosts_file() self.load_hosts_file()
self._setup_footer() 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: def load_hosts_file(self) -> None:
"""Load the hosts file and populate the table.""" """Load the hosts file and populate the table."""
@ -276,7 +323,7 @@ class HostsManagerApp(App):
self.table_handler.restore_cursor_position(previous_entry) self.table_handler.restore_cursor_position(previous_entry)
self.update_status() self.update_status()
except Exception as e: 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: def _setup_footer(self) -> None:
"""Setup the footer with initial content based on keybindings.""" """Setup the footer with initial content based on keybindings."""
@ -287,19 +334,45 @@ class HostsManagerApp(App):
footer.clear_left_items() footer.clear_left_items()
footer.clear_right_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: for binding in self.BINDINGS:
# Skip tuple-style bindings and only process Binding objects # Skip tuple-style bindings and only process Binding objects
if not isinstance(binding, Binding): if not isinstance(binding, Binding):
continue continue
# Only show bindings marked with show=True # Only show bindings marked with show=True
if binding.show: if binding.show and binding.action in visible_actions:
# Get the display key # Get the display key
key_display = getattr(binding, "key_display", None) or binding.key key_display = getattr(binding, "key_display", None) or binding.key
# Get the description descriptions = {
description = binding.description or binding.action "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 # Determine positioning from id attribute
binding_id = getattr(binding, "id", None) binding_id = getattr(binding, "id", None)
@ -320,44 +393,98 @@ class HostsManagerApp(App):
"""Update the footer status section.""" """Update the footer status section."""
try: try:
footer = self.query_one("#custom-footer", CustomFooter) 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) entry_count = len(self.hosts_file.entries)
active_count = len(self.hosts_file.get_active_entries()) active_count = len(self.hosts_file.get_active_entries())
filter_count = self._active_filter_count()
status = f"{entry_count} entries ({active_count} active) | {mode}" status = (
f"{entry_count} entries ({active_count} active) · "
f"Filters: {filter_count} · {mode}"
)
footer.set_status(status) footer.set_status(status)
self.query_one("#filter-summary", Static).update(
f"Filters: {filter_count}" if filter_count else ""
)
except Exception: except Exception:
pass # Footer not ready yet 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: 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: if message:
# Show temporary message in the status bar
try: try:
status_bar = self.query_one("#status-bar", Static) if self._status_timer is not None:
status_bar.update(message) self._status_timer.stop()
status_bar.remove_class("hidden") self._status_timer = None
rail = self.query_one("#message-rail", Static)
if message.startswith(""): normalized = (
# Auto-clear error message after 5 seconds message.lstrip("✓×!· ")
self.set_timer(5.0, lambda: self._clear_status_message()) .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: else:
# Auto-clear regular message after 3 seconds self._status_timer = self.set_timer(
self.set_timer(3.0, lambda: self._clear_status_message()) 3.0, lambda: self._clear_status_message()
)
except Exception: except Exception:
# Fallback if status bar not found (during initialization)
pass pass
# Always update the header subtitle with current status
# Update the footer status
self._update_footer_status() self._update_footer_status()
def _clear_status_message(self) -> None: def _clear_status_message(self) -> None:
"""Clear the temporary status message.""" """Clear the temporary status message."""
try: try:
status_bar = self.query_one("#status-bar", Static) rail = self.query_one("#message-rail", Static)
status_bar.update("") rail.update("")
status_bar.add_class("hidden") rail.remove_class("message-error")
rail.remove_class("message-warning")
self._status_timer = None
except Exception: except Exception:
pass pass
@ -370,6 +497,13 @@ class HostsManagerApp(App):
def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool: def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool:
"""Persist a mutation, restoring its complete pre-action state on failure.""" """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( save_success, save_message, hosts_file = self.manager.save_mutation(
self.hosts_file, snapshot.manager_state self.hosts_file, snapshot.manager_state
) )
@ -496,13 +630,9 @@ class HostsManagerApp(App):
self.update_status("Hosts file reloaded") self.update_status("Hosts file reloaded")
def action_help(self) -> None: def action_help(self) -> None:
"""Show help panel.""" """Open the keyboard reference without reducing workspace width."""
if self.help_visible: if not self.screen_stack or not isinstance(self.screen, HelpModal):
self.action_hide_help_panel() self.push_screen(HelpModal())
self.help_visible = False
else:
self.action_show_help_panel()
self.help_visible = True
def action_config(self) -> None: def action_config(self) -> None:
"""Show configuration modal.""" """Show configuration modal."""
@ -566,6 +696,8 @@ class HostsManagerApp(App):
def action_toggle_edit_mode(self) -> None: def action_toggle_edit_mode(self) -> None:
"""Toggle between read-only and edit mode.""" """Toggle between read-only and edit mode."""
if not self._allow_mutation_action():
return
if self.edit_mode: if self.edit_mode:
# Exit edit mode # Exit edit mode
success, message = self.manager.exit_edit_mode() success, message = self.manager.exit_edit_mode()
@ -628,6 +760,8 @@ class HostsManagerApp(App):
def action_edit_entry(self) -> None: def action_edit_entry(self) -> None:
"""Enter edit mode for the selected entry.""" """Enter edit mode for the selected entry."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ 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: def action_exit_edit_entry(self) -> None:
"""Exit entry edit mode and return focus to the entries table.""" """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() self.edit_handler.exit_edit_entry_with_confirmation()
def action_next_field(self) -> None: def action_next_field(self) -> None:
@ -679,22 +815,32 @@ class HostsManagerApp(App):
def action_toggle_entry(self) -> None: def action_toggle_entry(self) -> None:
"""Toggle the active state of the selected entry.""" """Toggle the active state of the selected entry."""
if not self._allow_mutation_action():
return
self.navigation_handler.toggle_entry() self.navigation_handler.toggle_entry()
def action_move_entry_up(self) -> None: def action_move_entry_up(self) -> None:
"""Move the selected entry up in the list.""" """Move the selected entry up in the list."""
if not self._allow_mutation_action():
return
self.navigation_handler.move_entry_up() self.navigation_handler.move_entry_up()
def action_move_entry_down(self) -> None: def action_move_entry_down(self) -> None:
"""Move the selected entry down in the list.""" """Move the selected entry down in the list."""
if not self._allow_mutation_action():
return
self.navigation_handler.move_entry_down() self.navigation_handler.move_entry_down()
def action_save_file(self) -> None: def action_save_file(self) -> None:
"""Save the hosts file to disk.""" """Save the hosts file to disk."""
if not self._allow_mutation_action():
return
self.navigation_handler.save_hosts_file() self.navigation_handler.save_hosts_file()
def action_add_entry(self) -> None: def action_add_entry(self) -> None:
"""Show the add entry modal.""" """Show the add entry modal."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ 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: def action_delete_entry(self) -> None:
"""Show the delete confirmation modal for the selected entry.""" """Show the delete confirmation modal for the selected entry."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot delete entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ 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: def action_undo(self) -> None:
"""Undo the last operation.""" """Undo the last operation."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status("❌ Cannot undo: Application is in read-only mode") self.update_status("❌ Cannot undo: Application is in read-only mode")
return return
@ -821,6 +971,8 @@ class HostsManagerApp(App):
def action_redo(self) -> None: def action_redo(self) -> None:
"""Redo the last undone operation.""" """Redo the last undone operation."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status("❌ Cannot redo: Application is in read-only mode") self.update_status("❌ Cannot redo: Application is in read-only mode")
return return
@ -848,6 +1000,8 @@ class HostsManagerApp(App):
def action_refresh_dns(self) -> None: def action_refresh_dns(self) -> None:
"""Manually refresh DNS resolution for all entries.""" """Manually refresh DNS resolution for all entries."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ 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: def action_update_single_dns(self) -> None:
"""Manually refresh DNS resolution for the currently selected entry.""" """Manually refresh DNS resolution for the currently selected entry."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ 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.""" """Update the edit form with current entry values."""
self.details_handler.update_edit_form() 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: def watch_entry_edit_mode(self, entry_edit_mode: bool) -> None:
"""Update the right pane border title when entry edit mode changes.""" """Update the right pane border title when entry edit mode changes."""
try: try:

View file

@ -48,14 +48,14 @@ class ConfigModal(ModalScreen[bool]):
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button( yield Button(
"Save", variant="primary", id="save-button", classes="config-button" "Cancel",
)
yield Button(
"Cancel (ESC)",
variant="default", variant="default",
id="cancel-button", id="cancel-button",
classes="default-button", classes="default-button",
) )
yield Button(
"Save", variant="primary", id="save-button", classes="config-button"
)
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses.""" """Handle button presses."""

View file

@ -25,7 +25,6 @@ class DeleteConfirmationModal(ModalScreen[bool]):
BINDINGS = [ BINDINGS = [
Binding("escape", "cancel", "Cancel"), Binding("escape", "cancel", "Cancel"),
Binding("enter", "confirm", "Delete"),
] ]
def __init__(self, entry: HostEntry): def __init__(self, entry: HostEntry):
@ -35,7 +34,7 @@ class DeleteConfirmationModal(ModalScreen[bool]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create the delete confirmation modal layout.""" """Create the delete confirmation modal layout."""
with Vertical(classes="delete-container"): with Vertical(classes="delete-container"):
yield Static("Delete Entry", classes="delete-title") yield Static("Delete Host Entry", classes="delete-title")
yield Static( yield Static(
"Are you sure you want to delete this entry?", classes="delete-message" "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"): with Horizontal(classes="button-row"):
yield Button( yield Button(
"Delete", "Cancel",
variant="error", variant="default",
id="delete-button", id="cancel-button",
classes="default-button", classes="default-button",
) )
yield Button( yield Button(
"Cancel (ESC)", "Delete Host Entry",
variant="default", variant="error",
id="cancel-button", id="delete-button",
classes="default-button", classes="default-button",
) )

View file

@ -1,113 +1,113 @@
""" """Read-only Entry Details and editable Entry Editor coordination."""
Details pane management for the hosts TUI application.
This module handles the display and updating of entry details from textual.widgets import Checkbox, Input, Static
and edit forms in the right pane.
"""
from textual.widgets import Input, Checkbox
class DetailsHandler: 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): def __init__(self, app):
"""Initialize the details handler with reference to the main app."""
self.app = app self.app = app
def update_entry_details(self) -> None: def update_entry_details(self) -> None:
"""Update the right pane with selected entry details."""
if self.app.entry_edit_mode: if self.app.entry_edit_mode:
self.update_edit_form() self.update_edit_form()
else: else:
self.update_details_display() 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: 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") details_display = self.app.query_one("#entry-details-display")
edit_form = self.app.query_one("#entry-edit-form") edit_form = self.app.query_one("#entry-edit-form")
# Show details display, hide edit form
details_display.remove_class("hidden") details_display.remove_class("hidden")
edit_form.add_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: if not self.app.hosts_file.entries:
# Show empty message self._show_empty_state("No Host Entries are loaded.")
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
return return
# Get visible entries to check if we need to adjust selection
visible_entries = self.app.table_handler.get_visible_entries() visible_entries = self.app.table_handler.get_visible_entries()
if not visible_entries: if not visible_entries:
ip_input.value = "" self._show_empty_state(
ip_input.placeholder = "No visible entries" "No Host Entries match the current filters. Press Ctrl+F to change them."
hostname_input.value = "" )
hostname_input.placeholder = "No visible entries"
comment_input.value = ""
comment_input.placeholder = "No visible entries"
active_checkbox.value = False
return 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): if self.app.selected_entry_index >= len(self.app.hosts_file.entries):
self.app.selected_entry_index = 0 self.app.selected_entry_index = 0
entry = self.app.hosts_file.entries[self.app.selected_entry_index] entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Update the input widgets with entry data self.app.query_one("#details-empty-state", Static).add_class("hidden")
ip_input.value = entry.ip_address self.app.query_one("#details-content").remove_class("hidden")
ip_input.placeholder = "" self._set_detail("details-ip-input", entry.ip_address)
hostname_input.value = ", ".join(entry.hostnames) self._set_detail("details-hostname-input", ", ".join(entry.hostnames))
hostname_input.placeholder = "" self._set_detail("details-comment-input", entry.comment or "")
comment_input.value = entry.comment or "" self._set_detail(
comment_input.placeholder = "No comment" "details-active-checkbox", "✓ Active" if entry.is_active else "· Inactive"
active_checkbox.value = entry.is_active )
# For default entries, show warning in placeholder text default_notice = self.app.query_one("#details-default-notice", Static)
if entry.is_default_entry(): if entry.is_default_entry():
ip_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" entry_state = "✓ Active" if entry.is_active else "· Inactive"
hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" self._set_detail(
comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" "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 dns_rows = self.app.query_one("#details-dns-rows")
self._update_dns_information(entry) 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: 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") details_display = self.app.query_one("#entry-details-display")
edit_form = self.app.query_one("#entry-edit-form") edit_form = self.app.query_one("#entry-edit-form")
# Hide details display, show edit form
details_display.add_class("hidden") details_display.add_class("hidden")
edit_form.remove_class("hidden") edit_form.remove_class("hidden")
@ -117,80 +117,8 @@ class DetailsHandler:
return return
entry = self.app.hosts_file.entries[self.app.selected_entry_index] entry = self.app.hosts_file.entries[self.app.selected_entry_index]
self.app.query_one("#ip-input", Input).value = entry.ip_address
# Update form fields with current entry values self.app.query_one("#hostname-input", Input).value = ", ".join(entry.hostnames)
ip_input = self.app.query_one("#ip-input", Input) self.app.query_one("#comment-input", Input).value = entry.comment or ""
hostname_input = self.app.query_one("#hostname-input", Input) self.app.query_one("#active-checkbox", Checkbox).value = entry.is_active
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.edit_handler.populate_edit_form_with_type_detection() 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

View file

@ -186,11 +186,11 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
) )
with Horizontal(classes="filter-actions"): with Horizontal(classes="filter-actions"):
yield Button("Cancel", id="cancel", classes="default-button")
yield Button("Reset", id="reset", classes="default-button")
yield Button( yield Button(
"Apply", id="apply", variant="primary", classes="default-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: def _create_preset_select(self) -> Select:
"""Create the preset picker without selecting a preset by default.""" """Create the preset picker without selecting a preset by default."""
@ -251,9 +251,9 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
"search-comments", "search-comments",
"search-ips", "search-ips",
"search-case-sensitive", "search-case-sensitive",
"apply",
"reset",
"cancel", "cancel",
"reset",
"apply",
) )
) )
return control_ids return control_ids

View file

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

View file

@ -22,7 +22,7 @@ HOSTS_MANAGER_BINDINGS = [
Binding( Binding(
"ctrl+e", "ctrl+e",
"toggle_edit_mode", "toggle_edit_mode",
"Toggle edit mode", "Privileged Mode",
show=True, show=True,
id="left:toggle_edit_mode", id="left:toggle_edit_mode",
), ),

View file

@ -40,29 +40,27 @@ class SaveConfirmationModal(ModalScreen[str]):
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button( yield Button(
"Save (S)", "Cancel",
variant="primary", variant="default",
id="save-button", id="cancel-button",
classes="save-confirmation-button", classes="save-confirmation-button",
) )
yield Button( yield Button(
"Discard (D)", "Discard",
variant="default", variant="default",
id="discard-button", id="discard-button",
classes="save-confirmation-button", classes="save-confirmation-button",
) )
yield Button( yield Button(
"Cancel (ESC)", "Save",
variant="default", variant="primary",
id="cancel-button", id="save-button",
classes="save-confirmation-button", classes="save-confirmation-button",
) )
def on_mount(self) -> None: def on_mount(self) -> None:
"""Called when the modal is mounted. Set focus to the first button.""" """Start at the non-destructive Cancel action."""
# Focus on the Save button by default self.query_one("#cancel-button", Button).focus()
save_button = self.query_one("#save-button", Button)
save_button.focus()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses.""" """Handle button presses."""

View file

@ -5,6 +5,24 @@ This module contains all CSS definitions for consistent styling
across the application. 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 classes shared across components
COMMON_CSS = """ COMMON_CSS = """
.default-button { .default-button {
@ -71,6 +89,43 @@ HOSTS_MANAGER_CSS = (
margin-bottom: 0; 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 { .search-input {
height: 1fr; height: 1fr;
width: 1fr; width: 1fr;
@ -82,6 +137,10 @@ HOSTS_MANAGER_CSS = (
margin-top: 0; margin-top: 0;
} }
.compact-layout .entry-form {
padding: 0 1;
}
.common-pane { .common-pane {
border: round $primary; border: round $primary;
margin: 0; margin: 0;
@ -127,24 +186,35 @@ HOSTS_MANAGER_CSS = (
display: none; 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 { .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; height: auto;
padding: 1; color: $warning;
margin-top: 1;
}
.empty-state {
height: 1fr;
content-align: center middle;
color: $text-muted;
} }
Header { Header {
@ -157,7 +227,7 @@ Header.-tall {
/* Custom Footer Styling */ /* Custom Footer Styling */
CustomFooter { CustomFooter {
background: $surface; background: $panel;
color: $text; color: $text;
dock: bottom; dock: bottom;
height: 1; height: 1;

View file

@ -185,25 +185,21 @@ class TableHandler:
# Get DNS status indicator # Get DNS status indicator
dns_text = self._get_dns_status_indicator(entry) 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: if is_default:
# Default entries are always shown in dim grey regardless of active status entry_state = "✓ Active" if entry.is_active else "· Inactive"
active_text = Text("" if entry.is_active else "", style="dim white") active_text = Text(f"■ Default · {entry_state}", style="dim")
ip_text = Text(entry.ip_address, style="dim white") ip_text = Text(entry.ip_address, style="dim")
hostname_text = Text(canonical_hostname, style="dim white") hostname_text = Text(canonical_hostname, style="dim")
table.add_row(active_text, ip_text, hostname_text, dns_text)
elif entry.is_active: elif entry.is_active:
# Active entries in green with checkmark active_text = Text("✓ Active")
active_text = Text("", style="bold green") ip_text = entry.ip_address
ip_text = Text(entry.ip_address, style="bold green") hostname_text = canonical_hostname
hostname_text = Text(canonical_hostname, style="bold green")
table.add_row(active_text, ip_text, hostname_text, dns_text)
else: else:
# Inactive entries in dim yellow with italic (no checkmark) active_text = Text("· Inactive", style="dim")
active_text = Text("", style="dim yellow italic") ip_text = Text(entry.ip_address, style="dim")
ip_text = Text(entry.ip_address, style="dim yellow italic") hostname_text = Text(canonical_hostname, style="dim")
hostname_text = Text(canonical_hostname, style="dim yellow italic") table.add_row(active_text, ip_text, hostname_text, dns_text)
table.add_row(active_text, ip_text, hostname_text, dns_text)
def restore_cursor_position(self, previous_entry) -> None: def restore_cursor_position(self, previous_entry) -> None:
"""Restore cursor position after reload, maintaining selection if possible.""" """Restore cursor position after reload, maintaining selection if possible."""
@ -265,7 +261,7 @@ class TableHandler:
"""Get DNS name and status indicator for an entry.""" """Get DNS name and status indicator for an entry."""
# If entry has no DNS name configured, show empty # If entry has no DNS name configured, show empty
if not entry.has_dns_name(): if not entry.has_dns_name():
return Text("", style="dim white") return Text("")
# Start with the DNS name # Start with the DNS name
dns_display = entry.dns_name dns_display = entry.dns_name
@ -274,28 +270,21 @@ class TableHandler:
dns_status = entry.dns_resolution_status or "not_resolved" dns_status = entry.dns_resolution_status or "not_resolved"
if dns_status == "not_resolved": if dns_status == "not_resolved":
status_icon = "" status_icon = "· Pending"
style = "dim yellow"
elif dns_status == "resolving": elif dns_status == "resolving":
status_icon = "🔄" status_icon = "! Resolving"
style = "yellow"
elif dns_status == "resolved": elif dns_status == "resolved":
status_icon = "" status_icon = "✓ Resolved"
style = "green"
elif dns_status == "match": elif dns_status == "match":
status_icon = "" status_icon = "✓ Matches"
style = "bold green"
elif dns_status == "mismatch": elif dns_status == "mismatch":
status_icon = "⚠️" status_icon = "! Mismatch"
style = "red"
elif dns_status == "failed": elif dns_status == "failed":
status_icon = "" status_icon = "× Failed"
style = "red"
else: else:
status_icon = "" 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: def sort_entries_by_hostname(self) -> None:
"""Sort entries by canonical hostname.""" """Sort entries by canonical hostname."""

View file

@ -5,13 +5,14 @@ from unittest.mock import Mock, patch
import pytest import pytest
from textual.app import SuspendNotSupported 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.filters import FilterOptions
from src.hosts.core.models import HostEntry, HostsFile from src.hosts.core.models import HostEntry, HostsFile
from src.hosts.tui.app import HostsManagerApp from src.hosts.tui.app import HostsManagerApp
from src.hosts.tui.custom_footer import CustomFooter from src.hosts.tui.custom_footer import CustomFooter
from src.hosts.tui.filter_modal import FilterModal from src.hosts.tui.filter_modal import FilterModal
from src.hosts.tui.help_modal import HelpModal
@contextmanager @contextmanager
@ -45,16 +46,13 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter():
async with app.run_test() as pilot: async with app.run_test() as pilot:
footer = app.query_one("#custom-footer", CustomFooter) footer = app.query_one("#custom-footer", CustomFooter)
footer_text = footer.query_one("#footer-right", Static).render() 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() app.action_help()
await pilot.pause() await pilot.pause()
assert isinstance(app.query_one(HelpPanel), HelpPanel) assert isinstance(app.screen, HelpModal)
assert any( assert app.screen.query_one("#help-close") is not None
binding.action == "show_filters" and binding.description == "Filter entries" await pilot.press("escape")
for _, binding, _, _ in app.screen.active_bindings.values()
)
app.action_help()
await pilot.pause() await pilot.pause()
await pilot.press("ctrl+f") 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 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 @pytest.mark.asyncio
async def test_filter_modal_tabs_through_controls_in_visual_order(): async def test_filter_modal_tabs_through_controls_in_visual_order():
"""The modal starts at Presets and tabs through the visible controls in 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-comments",
"search-ips", "search-ips",
"search-case-sensitive", "search-case-sensitive",
"cancel",
"reset",
"apply", "apply",
): ):
await pilot.press("tab") 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: class TestPrivilegedModeAuthorization:
"""Test the user-visible privileged-mode authorization flow.""" """Test the user-visible privileged-mode authorization flow."""

View file

@ -102,7 +102,7 @@ class TestHostsManagerApp:
# Should handle error gracefully # Should handle error gracefully
app.update_status.assert_called_with( 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): def test_load_hosts_file_permission_error(self):
@ -122,7 +122,7 @@ class TestHostsManagerApp:
# Should handle error gracefully # Should handle error gracefully
app.update_status.assert_called_with( 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): def test_populate_entries_table_logic(self):
@ -207,13 +207,15 @@ class TestHostsManagerApp:
app.update_entry_details() 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_details_display.remove_class.assert_called_with("hidden")
mock_edit_form.add_class.assert_called_with("hidden") mock_edit_form.add_class.assert_called_with("hidden")
assert mock_ip_input.value == "127.0.0.1" mock_ip_input.update.assert_called_with("127.0.0.1")
assert mock_hostname_input.value == "localhost, local" mock_hostname_input.update.assert_called_with("localhost, local")
assert mock_comment_input.value == "Test comment" mock_comment_input.update.assert_called_with("Test comment")
assert mock_active_checkbox.value mock_active_checkbox.update.assert_called_with(
"■ Default (protected) · ✓ Active"
)
def test_update_entry_details_no_entries(self): def test_update_entry_details_no_entries(self):
"""Test updating entry details with no entries.""" """Test updating entry details with no entries."""
@ -233,6 +235,7 @@ class TestHostsManagerApp:
mock_hostname_input = Mock() mock_hostname_input = Mock()
mock_comment_input = Mock() mock_comment_input = Mock()
mock_active_checkbox = Mock() mock_active_checkbox = Mock()
mock_empty_state = Mock()
def mock_query_one(selector, widget_type=None): def mock_query_one(selector, widget_type=None):
if selector == "#entry-details-display": if selector == "#entry-details-display":
@ -247,6 +250,8 @@ class TestHostsManagerApp:
return mock_comment_input return mock_comment_input
elif selector == "#details-active-checkbox": elif selector == "#details-active-checkbox":
return mock_active_checkbox return mock_active_checkbox
elif selector == "#details-empty-state":
return mock_empty_state
return Mock() return Mock()
cast(Any, app).query_one = mock_query_one cast(Any, app).query_one = mock_query_one
@ -254,16 +259,10 @@ class TestHostsManagerApp:
app.update_entry_details() 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_details_display.remove_class.assert_called_with("hidden")
mock_edit_form.add_class.assert_called_with("hidden") mock_edit_form.add_class.assert_called_with("hidden")
assert mock_ip_input.value == "" mock_empty_state.update.assert_called_with("No Host Entries are loaded.")
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
def test_update_status_default(self): def test_update_status_default(self):
"""Test status bar update with default information.""" """Test status bar update with default information."""
@ -301,7 +300,7 @@ class TestHostsManagerApp:
# Verify footer status was updated # Verify footer status was updated
mock_footer.set_status.assert_called_once() mock_footer.set_status.assert_called_once()
status_call = mock_footer.set_status.call_args[0][0] 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 "2 entries" in status_call
assert "1 active" 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 # Mock set_timer and query_one to avoid event loop and UI issues
app.set_timer = Mock() app.set_timer = Mock()
mock_status_bar = Mock() mock_message_rail = Mock()
mock_footer = Mock() mock_footer = Mock()
def mock_query_one(selector, widget_type=None): def mock_query_one(selector, widget_type=None):
if selector == "#status-bar": if selector == "#message-rail":
return mock_status_bar return mock_message_rail
elif selector == "#custom-footer": elif selector == "#custom-footer":
return mock_footer return mock_footer
return Mock() return Mock()
@ -343,14 +342,13 @@ class TestHostsManagerApp:
app.update_status("Custom status message") app.update_status("Custom status message")
# Verify status bar was updated with custom message # Verify the reserved message rail was updated with the message.
mock_status_bar.update.assert_called_with("Custom status message") mock_message_rail.update.assert_called_with("Custom status message")
mock_status_bar.remove_class.assert_called_with("hidden")
# Verify footer status was updated with current status (not the custom message) # Verify footer status was updated with current status (not the custom message)
mock_footer.set_status.assert_called_once() mock_footer.set_status.assert_called_once()
footer_status = mock_footer.set_status.call_args[0][0] footer_status = mock_footer.set_status.call_args[0][0]
assert "2 entries" in footer_status 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 # Verify timer was set for auto-clearing
app.set_timer.assert_called_once() app.set_timer.assert_called_once()
@ -382,12 +380,12 @@ class TestHostsManagerApp:
patch("hosts.tui.app.Config", return_value=mock_config), patch("hosts.tui.app.Config", return_value=mock_config),
): ):
app = HostsManagerApp() app = HostsManagerApp()
app.action_show_help_panel = Mock() app.push_screen = Mock()
app.action_help() app.action_help()
# Should call the built-in help action # Help is a dedicated overlay, not a docked panel.
app.action_show_help_panel.assert_called_once() app.push_screen.assert_called_once()
def test_action_config(self): def test_action_config(self):
"""Test config action opens modal.""" """Test config action opens modal."""

View file

@ -57,15 +57,15 @@ class TestSaveConfirmationModal:
@patch.object(SaveConfirmationModal, "query_one") @patch.object(SaveConfirmationModal, "query_one")
def test_on_mount_sets_focus(self, mock_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() modal = SaveConfirmationModal()
mock_save_button = Mock() mock_cancel_button = Mock()
mock_query_one.return_value = mock_save_button mock_query_one.return_value = mock_cancel_button
modal.on_mount() modal.on_mount()
mock_query_one.assert_called_once_with("#save-button", Button) mock_query_one.assert_called_once_with("#cancel-button", Button)
mock_save_button.focus.assert_called_once() mock_cancel_button.focus.assert_called_once()
class TestSaveConfirmationIntegration: class TestSaveConfirmationIntegration: