Migrate TUI to design guide

This commit is contained in:
Philip Henning 2026-09-04 18:30:53 +02:00
parent 3ce7ba8d5c
commit 563ec7c705
14 changed files with 741 additions and 442 deletions

View file

@ -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",
)

View file

@ -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,13 +100,18 @@ 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 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(
@ -113,6 +119,7 @@ class HostsManagerApp(App):
id="search-input",
classes="search-input",
)
yield Static("", id="filter-summary")
with Horizontal(classes="hosts-container"):
# Left pane - entries table
@ -124,76 +131,74 @@ class HostsManagerApp(App):
with Vertical(classes="common-pane right-pane") as right_pane:
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"):
yield Static(
"No Host Entry selected.",
id="details-empty-state",
classes="empty-state",
)
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",
id="details-content", classes="detail-rows hidden"
):
with Horizontal(classes="detail-row"):
yield Static("IP address", classes="detail-label")
yield Static(
"", id="details-ip-input", classes="detail-value"
)
with Vertical(classes="default-section") as hostnames:
hostnames.border_title = "Hostnames (comma-separated)"
yield Input(
placeholder="No entry selected",
with Horizontal(classes="detail-row"):
yield Static("Hostnames", classes="detail-label")
yield Static(
"",
id="details-hostname-input",
disabled=True,
classes="default-input",
classes="detail-value",
)
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",
with Horizontal(classes="detail-row"):
yield Static("State", classes="detail-label")
yield Static(
"",
id="details-active-checkbox",
disabled=True,
classes="default-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",
)
# 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:
@ -202,13 +207,14 @@ class HostsManagerApp(App):
id="edit-entry-type-radio", classes="default-radio-set"
):
yield RadioButton(
"IP Address Entry", value=True, id="edit-ip-entry-radio"
"IP Address Entry",
value=True,
id="edit-ip-entry-radio",
)
yield RadioButton(
"DNS Name Entry", id="edit-dns-entry-radio"
)
# IP Address Section
with Vertical(
classes="default-section", id="edit-ip-section"
) as ip_address:
@ -219,7 +225,6 @@ class HostsManagerApp(App):
classes="default-input",
)
# DNS Name Section (initially hidden)
with Vertical(
classes="default-section hidden", id="edit-dns-section"
) as dns_name:
@ -249,16 +254,58 @@ class HostsManagerApp(App):
with Vertical(classes="default-section") as active:
active.border_title = "Active"
yield Checkbox(
"Active", id="active-checkbox", classes="default-checkbox"
"Active",
id="active-checkbox",
classes="default-checkbox",
)
# Status bar for error/temporary messages (overlay, doesn't affect layout)
yield Static("", id="status-bar", classes="status-bar hidden")
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:

View file

@ -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."""

View file

@ -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",
)

View file

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

View file

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

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(
"ctrl+e",
"toggle_edit_mode",
"Toggle edit mode",
"Privileged Mode",
show=True,
id="left:toggle_edit_mode",
),

View file

@ -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."""

View file

@ -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;

View file

@ -185,24 +185,20 @@ 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")
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:
@ -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."""

View file

@ -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."""

View file

@ -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."""

View file

@ -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: