From 0b7b52521f32eaa24ad3def77085705082537646 Mon Sep 17 00:00:00 2001 From: phg Date: Fri, 4 Sep 2026 17:15:39 +0000 Subject: [PATCH] feat #14: Implement design guide (#15) Reviewed-on: https://git.s1q.dev/phg/hosts/pulls/15 Co-authored-by: phg Co-committed-by: phg --- AGENTS.md | 2 + CONTRIBUTING.md | 5 + docs/design-guide.md | 404 ++++++++++++++++ src/hosts/tui/add_entry_modal.py | 50 +- src/hosts/tui/app.py | 508 ++++++++++++++------- src/hosts/tui/config_modal.py | 8 +- src/hosts/tui/delete_confirmation_modal.py | 15 +- src/hosts/tui/details_handler.py | 224 +++------ src/hosts/tui/edit_handler.py | 14 +- src/hosts/tui/filter_modal.py | 455 +++++++++--------- src/hosts/tui/help_modal.py | 66 +++ src/hosts/tui/keybindings.py | 2 +- src/hosts/tui/save_confirmation_modal.py | 20 +- src/hosts/tui/styles.py | 174 ++++++- src/hosts/tui/table_handler.py | 55 +-- tests/test_add_entry_modal.py | 16 +- tests/test_app.py | 152 +++++- tests/test_main.py | 50 +- tests/test_save_confirmation_modal.py | 10 +- 19 files changed, 1507 insertions(+), 723 deletions(-) create mode 100644 docs/design-guide.md create mode 100644 src/hosts/tui/help_modal.py diff --git a/AGENTS.md b/AGENTS.md index dde8956..b0c89ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,8 @@ is reachable in the current application. privileged mode, sudo handling, system-file writes, or backups. - Read [ADR 0002](docs/adr/0002-normalize-hosts-serialization.md) before changing parsing, serialization, comment placement, or formatting preservation. +- Read the [TUI design guide](docs/design-guide.md) before changing layouts, + components, interaction states, user-visible copy, or terminal-size behavior. ## Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b386303..d3c56be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,9 @@ Core code that is not reachable through the TUI is not yet a supported product feature. Do not advertise it in user-facing documentation until users can actually invoke it. +Read the [TUI design guide](docs/design-guide.md) before changing layouts, +components, interaction states, user-visible copy, or terminal-size behavior. + ## Domain language and decisions Read [CONTEXT.md](CONTEXT.md) before naming user-visible concepts. Use its @@ -111,6 +114,8 @@ Documentation is part of the behavior contract: persistence, recovery, configuration, or troubleshooting change. - Update [CONTEXT.md](CONTEXT.md) when the canonical domain language changes; keep it a glossary rather than an architecture guide or progress log. +- Update the [TUI design guide](docs/design-guide.md) when an intentional UI + pattern should become the new project-wide standard. - Update ADR 0002 and the README if serialization stops being normalized or becomes byte-preserving. diff --git a/docs/design-guide.md b/docs/design-guide.md new file mode 100644 index 0000000..ae3eabf --- /dev/null +++ b/docs/design-guide.md @@ -0,0 +1,404 @@ +# TUI design guide + +This guide is the normative design contract for the `hosts` terminal user +interface. It evolves the application's existing dark, keyboard-first, +master-detail interface into a consistent system without replacing its visual +identity. + +The words **must**, **should**, and **may** distinguish requirements, +recommendations, and optional choices. The guide describes the target state; +current deviations are implementation work, not exceptions or user-reachable +features. User-facing documentation must continue to describe only behavior +that the application currently provides. + +## Design character + +`hosts` is a calm, trustworthy operations console. It should feel dense and +capable without feeling busy, playful, or alarming during ordinary use. + +Every screen follows these principles: + +1. **Information before decoration.** Structure, alignment, and concise text do + more work than borders, color, or ornament. +2. **Safety remains visible.** Mode, protection, failures, and consequences are + explicit and persist for as long as they matter. +3. **The keyboard path is complete.** Every workflow works without a mouse; + natural mouse support remains available where Textual provides it. +4. **State has more than one signal.** Color may reinforce a state but never + carries essential meaning alone. +5. **The terminal is user-owned.** The interface does not assume a particular + font, truecolor renderer, glyph width outside predictable one-cell symbols, + or a large viewport. + +## Viewports and composition + +The interface has three viewport modes: + +| Terminal size | Contract | +| --- | --- | +| At least 120 columns by 40 rows | Design target. Show the full 60/40 master-detail workspace. | +| At least 100 columns by 30 rows | Minimum usable size. Keep the 60/40 workspace, compact the details, and abbreviate contextual controls without hiding required actions. | +| Below 100 columns or below 30 rows | Replace the workspace with a minimum-size presentation. Do not silently clip, overlap, or squeeze controls into unusable shapes. | + +The target layout is: + +```text +┌──────────────────────────── hosts ─────────────────────────────┐ +│ message rail: transient result or persistent problem │ +├─ Search ────────────────────────────────────────────────────────┤ +│ query Filters: 2 │ +├─ Host Entries (60%) ───────────────┬─ Entry Details (40%) ─────┤ +│ state IP address hostname DNS │ IP address value │ +│ ... │ Hostnames value │ +│ │ DNS details value │ +│ │ Comment — │ +├────────────────────────────────────┴────────────────────────────┤ +│ contextual keys count · filters · READ-ONLY │ +└─────────────────────────────────────────────────────────────────┘ +``` + +At the minimum usable size, preserve the same regions but use compact +label-value rows in Entry Details and shorter footer descriptions. If either +dimension falls below the minimum, render a stable presentation such as: + +```text +┌──────────────────── hosts ────────────────────┐ +│ Terminal too small │ +│ │ +│ hosts requires at least 100 columns × 30 rows │ +│ Current size: 86 × 24 │ +│ │ +│ q Quit │ +└───────────────────────────────────────────────┘ +``` + +The minimum-size presentation must not permit a hidden or ambiguous mutation. +It may retain safe global actions such as Quit. + +### Regions + +- The header identifies the application and exposes safety-critical mode when + Privileged Mode is active. +- The message rail occupies one reserved row below the header. It never covers + the workspace. +- Search remains one row above the master-detail workspace. +- Host Entries and Entry Details use an approximate 60/40 split. Their minimum + content widths take priority over an exact percentage. +- The footer shows contextual keys on the left and durable state on the right. + It must not rely on horizontal scrolling. + +## Theme and color + +The application owns a semantic `hosts-dark` theme rather than inheriting +unspecified library defaults. Its initial palette preserves the current +Textual-dark appearance: + +| Token | Initial dark value | Purpose | +| --- | --- | --- | +| `background` | `#121212` | Screen background | +| `surface` | `#1E1E1E` | Inputs and alternating table rows | +| `panel` | `#242F38` | Header, footer, and elevated containers | +| `foreground` | `#E0E0E0` | Primary text | +| `primary` | `#0178D4` | Structure, focus, and selection | +| `secondary` | `#004578` | Secondary structure | +| `accent` | `#FFA62B` | Key hints and exceptional emphasis | +| `success` | `#4EBF71` | Successful outcomes and confirmed healthy state | +| `warning` | `#FFA62B` | Caution, inactivity, and Privileged Mode emphasis | +| `error` | `#BA3C5B` | Failures and destructive actions | + +Components must consume semantic tokens such as `$primary`, `$success`, and +`$text-muted`; they must not embed named Rich colors such as `green` or +`yellow`. Accent and warning happen to share an initial value but remain +separate roles. + +Dark is the default appearance. A future light theme may choose different +values, but it must preserve the same semantic roles, hierarchy, and state +redundancy. Text should approximate a contrast ratio of at least 4.5:1 where +the renderer permits controlled color. Focus and meaningful large text should +approximate at least 3:1 against adjacent colors. + +Use chromatic color sparingly: + +- Primary blue describes structure, focus, and selection. +- Green describes a successful result or active mapping, never a generic + decoration. +- Amber describes caution, inactivity, or elevated privilege. +- Red describes failure or destructive consequence. +- Muted foreground describes secondary information, not information required + to finish a workflow. + +## Text, symbols, and density + +The terminal controls the font. Assume a legible monospace font and make no +layout or branding decision that depends on a particular typeface. + +- Use bold for headings, keys, and the strongest current emphasis. +- Use dim text for supporting information. +- Use italics only as reinforcement; some terminals do not distinguish them. +- Use sentence case for headings, labels, buttons, and messages. +- Use one-cell spacing increments. +- Use **compact** density for tables, details, footers, and status areas. +- Use **comfortable** density for forms and modals so fields remain easy to + navigate and errors have room to appear. + +Predictable one-cell Unicode symbols such as `✓`, `×`, and `!` may reinforce a +state. Emoji are not structural UI: their width, color, and availability vary +too widely. Essential meaning must survive if a symbol is absent or rendered +poorly. Details, help, and messages therefore use words in addition to any +marker. + +## Borders and hierarchy + +Rounded single-line borders define major regions: Search, Host Entries, and +Entry Details. Nested borders should not make every value compete with its +parent region. Within a region, prefer alignment, whitespace, text weight, and +subtle surface changes. + +Inputs receive an input treatment only while they are editable. Read-only +values are label-value text, not disabled inputs. Thick borders are reserved +for modals; the error color is reserved for destructive confirmation and +failure states. + +Good: + +```text +╭─ Entry Details ─────────────────────╮ +│ IP address 192.0.2.10 │ +│ Hostnames api.local, api │ +│ Comment — │ +│ State ✓ Active │ +╰─────────────────────────────────────╯ +``` + +Avoid: + +```text +╭─ Entry Details ─────────────────────╮ +│ ╭─ IP Address ────────────────────╮ │ +│ │ disabled input │ │ +│ ╰────────────────────────────────╯ │ +│ ╭─ Comment ───────────────────────╮ │ +│ │ disabled input │ │ +│ ╰────────────────────────────────╯ │ +╰─────────────────────────────────────╯ +``` + +## State grammar + +Each interaction state has a distinct visual channel: + +| State | Required treatment | +| --- | --- | +| Focus | Bright primary border or cursor that remains visible independently of selection | +| Selection | Contrasting row background; moving focus away must not erase which Host Entry is selected | +| Active Entry | Stable marker plus normal or success-toned text | +| Inactive Entry | Different marker plus warning-toned or muted text; italics may reinforce it | +| Default Entry | Explicit protected marker plus muted treatment and an explanation in Entry Details | +| Disabled action | Muted but still visible when hiding it would make a restriction mysterious | +| Work in progress | Text such as `Resolving…` plus an optional one-cell activity marker | +| Failure | Error token plus an actionable textual message | +| Privileged Mode | Persistent `PRIVILEGED` wording plus restrained warning emphasis | + +Selection, focus, activity, and protection are independent. A selected +Inactive Entry must still look selected; a focused Default Entry must still +look protected. + +## Core components + +### Search and filters + +The top Search field performs immediate text filtering. `Ctrl+F` opens advanced +criteria. Both surfaces edit one coherent filter state and must show the same +search term. + +When non-text criteria are active, the workspace exposes a compact indicator, +such as `Filters: 2`. A no-results state says that filters matched no Host +Entries and presents the shortcut for changing or clearing them. + +Placeholders may provide examples, but never replace a persistent label when a +field's meaning would otherwise disappear after typing. + +### Host Entries table + +The table is a scan surface, not a compressed details view. Preserve columns +under horizontal pressure in this order: + +1. state; +2. IP address; +3. Canonical Hostname; +4. DNS status. + +Never truncate an IP address. Long hostnames may end in an ellipsis; Entry +Details shows their complete values. Column widths remain stable while the +selection moves. DNS status uses a short, predictable marker in the table and +complete wording in Entry Details. + +### Entry Details + +Display inspection data as compact aligned label-value rows. Ordinary +IP-address Host Entries do not show empty DNS Name, DNS Status, or Last Resolved +rows. DNS Entry fields appear only when they apply. Use `—` for an absent +optional value such as a comment. + +When no Host Entry is selected, say so rather than showing a field-shaped empty +screen. When no entries exist or no filter results match, name the condition +and offer the relevant next action. + +### Entry Editor and forms + +Forms use persistent labels, consistent required-field markers, and a visual +order that matches their Tab order. Validation appears directly beneath the +responsible field, retains the user's input, and explains how to correct the +value. + +The primary action is visually prominent and last in keyboard order. Cancel is +always available and non-destructive. Arrow keys operate grouped controls such +as radio buttons; Tab and Shift+Tab move between fields and action groups. + +### Modals + +Modals are centered, bounded by the viewport, and share this structure: + +1. concise title; +2. explanation or form content; +3. inline validation or consequence text; +4. action row. + +Neutral and complex modals use the primary border. Only destructive +confirmation uses the error border. `Escape` cancels without mutation. Initial +focus lands on the safest sensible action; destructive confirmation never +defaults to the destructive button. + +### Footer and help + +The footer is contextual. It shows the small set of actions most relevant to +the current state rather than the complete binding catalog. Descriptions may +shorten in compact mode, but the keys and durable state remain visible. The +right side reports entry count, active filters, and either `READ-ONLY` or +`PRIVILEGED`. + +`?` opens a dedicated overlay or screen instead of docking a panel that shrinks +the workspace. Group help under General, Navigation, Filtering, and Privileged +Mode. `?` and `Escape` close it and restore previous focus. + +## Feedback and safety + +The reserved message rail reports action results without covering content or +moving the master-detail workspace. + +- Routine success and informational messages may expire. +- Errors, warnings, and unresolved safety conditions persist until the user + acknowledges them or a later result supersedes them. +- Durable state such as mode and active filters belongs in the footer. +- An error says what happened, whether state changed, and what the user can do + next. + +Use the canonical terms from `CONTEXT.md`: **Read-only Mode**, **Privileged +Mode**, and **Entry Editor**. `Edit Mode` must not name Privileged Mode. + +Entering Privileged Mode does not recolor the entire interface. A persistent, +warning-toned `PRIVILEGED` indicator communicates elevated capability without +normalizing alarm colors. Mutating actions become available contextually. + +Default Entries have a visible protected marker and an explanation in Entry +Details. When a protected mutation is unavailable, keep the action visible if +hiding it would make the restriction unclear, and explain the restriction in +the message rail or help. + +Confirm destructive operations and attempts to leave an Entry Editor with +unsaved changes. Name the affected Host Entry and initially focus the safest +sensible action. Routine navigation and inspection do not require +confirmation. + +DNS resolution and other asynchronous work keep the interface responsive. +Show `Resolving…` inline for the affected DNS Entry. Completion or failure must +not move selection or steal focus unexpectedly. + +Motion is optional, brief, and nonessential. It must not delay input, hide +state, or be necessary to understand an outcome. + +## Input and focus + +Every workflow must be keyboard-complete. Mouse support should work where +Textual provides it naturally, but no action may depend on hover or a pointer. + +- Tab order follows visual reading order. +- Arrow keys operate tables and controls that conventionally use them. +- `Escape` backs out without mutation or opens the applicable unsaved-change + confirmation. +- Closing a modal or help restores focus to the control that opened it. +- Application shortcuts must not fire while a user is typing into a field + unless the shortcut is a conventional editing or escape command. +- Focus remains visible in dark, light, 256-color, and degraded-color output. + +## Writing + +English is the canonical interface language. Use concise, direct verbs and the +domain language in `CONTEXT.md`. Write complete strings and avoid layout +assumptions that make later localization needlessly difficult; an +internationalization architecture is outside the current contract. + +Good and avoid: + +| Good | Avoid | Reason | +| --- | --- | --- | +| `Enter Privileged Mode` | `Toggle edit mode` | Names the resulting safety state | +| `Delete Host Entry` | `Delete Entry!` | Uses the canonical concept without decorative alarm | +| `Save failed; previous state restored. Check write access and try again.` | `❌ Save error` | States outcome, data safety, and recovery | +| `No Host Entries match the current filters.` | `Nothing here` | Names the condition | +| `—` | `N/A` | Represents an absent optional value without implying an error | + +## Accessibility and terminal compatibility + +The supported visual baseline is a common 256-color terminal. Truecolor may +improve fidelity. Monochrome or degraded-color output may lose polish but must +retain all essential meaning through wording, markers, position, and text +style. + +Do not depend on: + +- color as the only state signal; +- italics, dim text, or animation as the only distinction; +- emoji or ambiguous-width glyphs; +- a particular terminal font; +- mouse hover; +- content hidden outside the supported viewport without a visible way to reach + it. + +## Verification + +Changes to the TUI must preserve behavior and essential visibility at 120×40 +and 100×30. Tests should use Textual's test harness to cover: + +- keyboard-complete workflows; +- visual-order focus traversal and focus restoration; +- selection remaining distinct from focus and entry state; +- mode, protection, validation, progress, and failure wording; +- conditional detail fields and empty states; +- absence of clipped or inaccessible essential controls at supported sizes. + +Test structure and semantics rather than terminal pixels or exact glyph +rendering. Significant visual changes also receive an `agent-tui` inspection at +both supported sizes. Screenshots are illustrative evidence, not golden output. + +Before review, check: + +- Does the change preserve the calm operations-console character? +- Are all states legible without color? +- Is the keyboard path complete and ordered like the screen? +- Are canonical Hosts Management terms used? +- Does feedback remain visible for as long as it matters? +- Are 120×40 and 100×30 both usable without essential clipping? +- Does the full key reference remain reachable through `?`? + +## Evolving the guide + +This contract applies to the whole TUI, including existing screens. Track +current deviations in Forgejo rather than weakening the rules with permanent +exceptions. + +An intentional departure must be explained in the relevant change. Update +this guide when the departure should become the new standard. Add an ADR only +when the decision is consequential, surprising, difficult to reverse, and the +result of a real trade-off. + diff --git a/src/hosts/tui/add_entry_modal.py b/src/hosts/tui/add_entry_modal.py index c776757..467c14f 100644 --- a/src/hosts/tui/add_entry_modal.py +++ b/src/hosts/tui/add_entry_modal.py @@ -45,7 +45,17 @@ class AddEntryModal(ModalScreen[HostEntry | None]): ) yield RadioButton("DNS Name Entry", id="dns-entry-radio") - # IP Address Section + # Hostnames Section + with Vertical(classes="default-section") as hostnames: + hostnames.border_title = "Hostnames" + yield Input( + placeholder="e.g., example.com, www.example.com", + id="hostnames-input", + classes="default-input", + ) + yield Static("", id="hostnames-error", classes="validation-error") + + # Address sections follow Hostnames in both visual and Tab order. with Vertical(classes="default-section", id="ip-section") as ip_address: ip_address.border_title = "IP Address" yield Input( @@ -55,7 +65,6 @@ class AddEntryModal(ModalScreen[HostEntry | None]): ) yield Static("", id="ip-error", classes="validation-error") - # DNS Name Section (initially hidden) with Vertical( classes="default-section hidden", id="dns-section" ) as dns_name: @@ -67,16 +76,6 @@ class AddEntryModal(ModalScreen[HostEntry | None]): ) yield Static("", id="dns-error", classes="validation-error") - # Hostnames Section - with Vertical(classes="default-section") as hostnames: - hostnames.border_title = "Hostnames" - yield Input( - placeholder="e.g., example.com, www.example.com", - id="hostnames-input", - classes="default-input", - ) - yield Static("", id="hostnames-error", classes="validation-error") - # Comment Section with Vertical(classes="default-section") as comment: comment.border_title = "Comment (optional)" @@ -99,22 +98,21 @@ class AddEntryModal(ModalScreen[HostEntry | None]): # Buttons with Horizontal(classes="button-row"): yield Button( - "Add Entry (CTRL+S)", - variant="primary", - id="add-button", - classes="default-button", - ) - yield Button( - "Cancel (ESC)", + "Cancel", variant="default", id="cancel-button", classes="default-button", ) + yield Button( + "Add Host Entry", + variant="primary", + id="add-button", + classes="default-button", + ) def on_mount(self) -> None: - """Focus IP address input when modal opens.""" - ip_input = self.query_one("#entry-type-radio", RadioSet) - ip_input.focus() + """Focus the entry type choice when the modal opens.""" + self.query_one("#entry-type-radio", RadioSet).focus() def on_radio_set_changed(self, event: RadioSet.Changed) -> None: """Handle entry type radio button changes.""" @@ -135,9 +133,7 @@ class AddEntryModal(ModalScreen[HostEntry | None]): if isinstance(active_section, Vertical): active_section.border_title = "Activate Entry" - # Focus IP input - ip_input = self.query_one("#ip-address-input", Input) - ip_input.focus() + self.query_one("#hostnames-input", Input).focus() elif pressed_radio and pressed_radio.id == "dns-entry-radio": # Show DNS section, hide IP section ip_section = self.query_one("#ip-section") @@ -155,9 +151,7 @@ class AddEntryModal(ModalScreen[HostEntry | None]): "Activate Entry (DNS entries activate after resolution)" ) - # Focus DNS input - dns_input = self.query_one("#dns-name-input", Input) - dns_input.focus() + self.query_one("#hostnames-input", Input).focus() def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 4a669bd..7656d60 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -31,8 +31,9 @@ from .config_modal import ConfigModal from .add_entry_modal import AddEntryModal from .delete_confirmation_modal import DeleteConfirmationModal from .filter_modal import FilterModal +from .help_modal import HelpModal from .custom_footer import CustomFooter -from .styles import HOSTS_MANAGER_CSS +from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS from .table_handler import TableHandler from .details_handler import DetailsHandler @@ -60,8 +61,6 @@ class HostsManagerApp(App): CSS = HOSTS_MANAGER_CSS BINDINGS = HOSTS_MANAGER_BINDINGS - help_visible = False - # Reactive attributes hosts_file: reactive[HostsFile] = reactive(HostsFile()) selected_entry_index: reactive[int] = reactive(0) @@ -74,6 +73,8 @@ class HostsManagerApp(App): def __init__(self): super().__init__() self.title = "/etc/hosts Manager" + self.register_theme(HOSTS_DARK_THEME) + self.theme = "hosts-dark" # Initialize core components self.parser = HostsParser() @@ -99,166 +100,212 @@ class HostsManagerApp(App): # State for edit mode self.original_entry_values = None + self._status_timer = None + self._viewport_too_small = False def compose(self) -> ComposeResult: """Create child widgets for the app.""" yield Header() yield CustomFooter(id="custom-footer") + yield Static("", id="message-rail") + yield Static("", id="minimum-size", classes="hidden") - # Search bar above the panes - with Horizontal(classes="search-container") as search_container: - search_container.border_title = "Search" - yield Input( - placeholder="Filter by hostname, IP address, or comment...", - id="search-input", - classes="search-input", - ) + with Vertical(id="workspace"): + # Search remains visible above the master-detail workspace. + with Horizontal(classes="search-container") as search_container: + search_container.border_title = "Search" + yield Input( + placeholder="Filter by hostname, IP address, or comment...", + id="search-input", + classes="search-input", + ) + yield Static("", id="filter-summary") - with Horizontal(classes="hosts-container"): - # Left pane - entries table - with Vertical(classes="common-pane left-pane") as left_pane: - left_pane.border_title = "Host Entries" - yield DataTable(id="entries-table") + with Horizontal(classes="hosts-container"): + # Left pane - entries table + with Vertical(classes="common-pane left-pane") as left_pane: + left_pane.border_title = "Host Entries" + yield DataTable(id="entries-table") - # Right pane - entry details or edit form - with Vertical(classes="common-pane right-pane") as right_pane: - right_pane.border_title = "Entry Details" + # Right pane - entry details or edit form + with Vertical(classes="common-pane right-pane") as right_pane: + right_pane.border_title = "Entry Details" - # Details display form (disabled inputs) - with Vertical(id="entry-details-display", classes="entry-form"): - with Vertical( - classes="default-section section-no-top-margin" - ) as ip_address: - ip_address.border_title = "IP Address" - yield Input( - placeholder="No entry selected", - id="details-ip-input", - disabled=True, - classes="default-input", + # Inspection values deliberately use text, not disabled inputs. + with Vertical(id="entry-details-display", classes="entry-form"): + yield Static( + "No Host Entry selected.", + id="details-empty-state", + classes="empty-state", ) - - with Vertical(classes="default-section") as hostnames: - hostnames.border_title = "Hostnames (comma-separated)" - yield Input( - placeholder="No entry selected", - id="details-hostname-input", - disabled=True, - classes="default-input", - ) - - with Vertical(classes="default-section") as dns_name: - dns_name.border_title = "DNS Name" - yield Input( - placeholder="No DNS name", - id="details-dns-name-input", - disabled=True, - classes="default-input", - ) - - with Vertical(classes="default-section") as dns_status: - dns_status.border_title = "DNS Status" - yield Input( - placeholder="No DNS status", - id="details-dns-status-input", - disabled=True, - classes="default-input", - ) - - with Vertical(classes="default-section") as dns_resolved: - dns_resolved.border_title = "Last Resolved" - yield Input( - placeholder="Not resolved yet", - id="details-dns-resolved-input", - disabled=True, - classes="default-input", - ) - - with Vertical(classes="default-section") as comment: - comment.border_title = "Comment:" - yield Input( - placeholder="No entry selected", - id="details-comment-input", - disabled=True, - classes="default-input", - ) - - with Vertical(classes="default-section") as active: - active.border_title = "Active" - yield Checkbox( - "Active", - id="details-active-checkbox", - disabled=True, - classes="default-checkbox", - ) - - # Edit form (initially hidden) - with Vertical(id="entry-edit-form", classes="entry-form hidden"): - # Entry Type Selection - with Vertical( - classes="default-flex-section section-no-top-margin" - ) as entry_type: - entry_type.border_title = "Entry Type" - with RadioSet( - id="edit-entry-type-radio", classes="default-radio-set" + with Vertical( + id="details-content", classes="detail-rows hidden" ): - yield RadioButton( - "IP Address Entry", value=True, id="edit-ip-entry-radio" - ) - yield RadioButton( - "DNS Name Entry", id="edit-dns-entry-radio" + with Horizontal(classes="detail-row"): + yield Static("IP address", classes="detail-label") + yield Static( + "", id="details-ip-input", classes="detail-value" + ) + with Horizontal(classes="detail-row"): + yield Static("Hostnames", classes="detail-label") + yield Static( + "", + id="details-hostname-input", + classes="detail-value", + ) + with Horizontal(classes="detail-row"): + yield Static("State", classes="detail-label") + yield Static( + "", + id="details-active-checkbox", + classes="detail-value", + ) + with Vertical(id="details-dns-rows", classes="hidden"): + with Horizontal(classes="detail-row"): + yield Static("DNS name", classes="detail-label") + yield Static( + "", + id="details-dns-name-input", + classes="detail-value", + ) + with Horizontal(classes="detail-row"): + yield Static("DNS status", classes="detail-label") + yield Static( + "", + id="details-dns-status-input", + classes="detail-value", + ) + with Horizontal(classes="detail-row"): + yield Static( + "Last resolved", classes="detail-label" + ) + yield Static( + "", + id="details-dns-resolved-input", + classes="detail-value", + ) + with Horizontal(classes="detail-row"): + yield Static("Comment", classes="detail-label") + yield Static( + "", + id="details-comment-input", + classes="detail-value", + ) + yield Static( + "", + id="details-default-notice", + classes="detail-note hidden", ) - # IP Address Section - with Vertical( - classes="default-section", id="edit-ip-section" - ) as ip_address: - ip_address.border_title = "IP Address" - yield Input( - placeholder="Enter IP address", - id="ip-input", - classes="default-input", - ) + # Edit form (initially hidden) + with Vertical(id="entry-edit-form", classes="entry-form hidden"): + with Vertical( + classes="default-flex-section section-no-top-margin" + ) as entry_type: + entry_type.border_title = "Entry Type" + with RadioSet( + id="edit-entry-type-radio", classes="default-radio-set" + ): + yield RadioButton( + "IP Address Entry", + value=True, + id="edit-ip-entry-radio", + ) + yield RadioButton( + "DNS Name Entry", id="edit-dns-entry-radio" + ) - # DNS Name Section (initially hidden) - with Vertical( - classes="default-section hidden", id="edit-dns-section" - ) as dns_name: - dns_name.border_title = "DNS Name (to resolve)" - yield Input( - placeholder="e.g., example.com", - id="dns-name-input", - classes="default-input", - ) + with Vertical(classes="default-section") as hostnames: + hostnames.border_title = "Hostnames (comma-separated)" + yield Input( + placeholder="Enter hostnames", + id="hostname-input", + classes="default-input", + ) - with Vertical(classes="default-section") as hostnames: - hostnames.border_title = "Hostnames (comma-separated)" - yield Input( - placeholder="Enter hostnames", - id="hostname-input", - classes="default-input", - ) + with Vertical( + classes="default-section", id="edit-ip-section" + ) as ip_address: + ip_address.border_title = "IP Address" + yield Input( + placeholder="Enter IP address", + id="ip-input", + classes="default-input", + ) - with Vertical(classes="default-section") as comment: - comment.border_title = "Comment:" - yield Input( - placeholder="Enter comment (optional)", - id="comment-input", - classes="default-input", - ) + with Vertical( + classes="default-section hidden", id="edit-dns-section" + ) as dns_name: + dns_name.border_title = "DNS Name (to resolve)" + yield Input( + placeholder="e.g., example.com", + id="dns-name-input", + classes="default-input", + ) - with Vertical(classes="default-section") as active: - active.border_title = "Active" - yield Checkbox( - "Active", id="active-checkbox", classes="default-checkbox" - ) + with Vertical(classes="default-section") as comment: + comment.border_title = "Comment:" + yield Input( + placeholder="Enter comment (optional)", + id="comment-input", + classes="default-input", + ) - # Status bar for error/temporary messages (overlay, doesn't affect layout) - yield Static("", id="status-bar", classes="status-bar hidden") + with Vertical(classes="default-section") as active: + active.border_title = "Active" + yield Checkbox( + "Active", + id="active-checkbox", + classes="default-checkbox", + ) def on_ready(self) -> None: """Called when the app is ready.""" self.load_hosts_file() self._setup_footer() + self._update_viewport() + + def on_resize(self) -> None: + """Switch to a safe, explicit presentation below the supported viewport.""" + self._update_viewport() + + def _update_viewport(self) -> None: + try: + too_small = self.size.width < 100 or self.size.height < 30 + self._viewport_too_small = too_small + workspace = self.query_one("#workspace") + minimum_size = self.query_one("#minimum-size", Static) + footer = self.query_one("#custom-footer", CustomFooter) + if too_small: + workspace.add_class("hidden") + minimum_size.update( + "Terminal too small\n\nhosts requires at least 100 columns × 30 rows\n" + f"Current size: {self.size.width} × {self.size.height}\n\nq Quit" + ) + minimum_size.remove_class("hidden") + minimum_size.add_class("visible") + footer.add_class("hidden") + else: + workspace.remove_class("hidden") + minimum_size.add_class("hidden") + minimum_size.remove_class("visible") + footer.remove_class("hidden") + self.set_class( + self.size.width < 120 or self.size.height < 40, "compact-layout" + ) + self._setup_footer() + except Exception: + # The widgets are not mounted during early application startup. + pass + + def _allow_mutation_action(self) -> bool: + """Keep every mutation unreachable while the workspace is replaced.""" + if not self._viewport_too_small: + return True + self.update_status( + "Cannot mutate while the terminal is too small. Resize to at least 100 columns × 30 rows." + ) + return False def load_hosts_file(self) -> None: """Load the hosts file and populate the table.""" @@ -276,7 +323,7 @@ class HostsManagerApp(App): self.table_handler.restore_cursor_position(previous_entry) self.update_status() except Exception as e: - self.update_status(f"❌ Error loading hosts file: {e}") + self.update_status(f"Error loading hosts file: {e}") def _setup_footer(self) -> None: """Setup the footer with initial content based on keybindings.""" @@ -287,19 +334,45 @@ class HostsManagerApp(App): footer.clear_left_items() footer.clear_right_items() - # Process keybindings and add to appropriate sections + # The footer is a contextual reminder, not a complete binding catalogue. + # The dedicated help overlay remains the reachable source of all keys. + visible_actions = { + "new_entry", + "edit_entry", + "toggle_edit_mode", + "show_filters", + "help", + "quit", + } + if self.size.width < 120 or self.size.height < 40: + visible_actions = { + "toggle_edit_mode", + "show_filters", + "help", + "quit", + } + for binding in self.BINDINGS: # Skip tuple-style bindings and only process Binding objects if not isinstance(binding, Binding): continue # Only show bindings marked with show=True - if binding.show: + if binding.show and binding.action in visible_actions: # Get the display key key_display = getattr(binding, "key_display", None) or binding.key - # Get the description - description = binding.description or binding.action + descriptions = { + "new_entry": "New", + "edit_entry": "Edit", + "toggle_edit_mode": "Mode", + "show_filters": "Filters", + "help": "Help", + "quit": "Quit", + } + description = descriptions.get( + binding.action, binding.description or binding.action + ) # Determine positioning from id attribute binding_id = getattr(binding, "id", None) @@ -320,44 +393,98 @@ class HostsManagerApp(App): """Update the footer status section.""" try: footer = self.query_one("#custom-footer", CustomFooter) - mode = "Edit" if self.edit_mode else "Read-only" + mode = "PRIVILEGED" if self.edit_mode else "READ-ONLY" entry_count = len(self.hosts_file.entries) active_count = len(self.hosts_file.get_active_entries()) - - status = f"{entry_count} entries ({active_count} active) | {mode}" + filter_count = self._active_filter_count() + status = ( + f"{entry_count} entries ({active_count} active) · " + f"Filters: {filter_count} · {mode}" + ) footer.set_status(status) + self.query_one("#filter-summary", Static).update( + f"Filters: {filter_count}" if filter_count else "" + ) except Exception: pass # Footer not ready yet + def _active_filter_count(self) -> int: + """Count the active filter groups for the durable workspace summary.""" + options = self.current_filter_options + return sum( + ( + bool(options.search_term), + options.active_only + or options.inactive_only + or not (options.show_active and options.show_inactive), + options.dns_only + or options.ip_only + or not (options.show_dns_entries and options.show_ip_entries), + options.mismatch_only + or options.resolved_only + or not all( + ( + options.show_resolved, + options.show_unresolved, + options.show_resolving, + options.show_failed, + options.show_mismatched, + ) + ), + ) + ) + def update_status(self, message: str = "") -> None: - """Update the header subtitle and status bar with status information.""" + """Update the reserved message rail and durable footer state.""" if message: - # Show temporary message in the status bar try: - status_bar = self.query_one("#status-bar", Static) - status_bar.update(message) - status_bar.remove_class("hidden") - - if message.startswith("❌"): - # Auto-clear error message after 5 seconds - self.set_timer(5.0, lambda: self._clear_status_message()) + if self._status_timer is not None: + self._status_timer.stop() + self._status_timer = None + rail = self.query_one("#message-rail", Static) + normalized = ( + message.lstrip("✓×!· ") + .replace("❌", "×") + .replace("✅", "✓") + .replace("🔄", "!") + .replace("⚠️", "!") + ) + normalized = normalized.replace("Edit mode", "Privileged Mode").replace( + "edit mode", "Privileged Mode" + ) + rail.update(normalized) + rail.remove_class("message-error") + rail.remove_class("message-warning") + persistent = any( + word in normalized.lower() + for word in ( + "error", + "failed", + "cannot", + "not granted", + "read-only", + ) + ) + if persistent: + rail.add_class("message-error") + elif normalized.startswith("!"): + rail.add_class("message-warning") else: - # Auto-clear regular message after 3 seconds - self.set_timer(3.0, lambda: self._clear_status_message()) + self._status_timer = self.set_timer( + 3.0, lambda: self._clear_status_message() + ) except Exception: - # Fallback if status bar not found (during initialization) pass - - # Always update the header subtitle with current status - # Update the footer status self._update_footer_status() def _clear_status_message(self) -> None: """Clear the temporary status message.""" try: - status_bar = self.query_one("#status-bar", Static) - status_bar.update("") - status_bar.add_class("hidden") + rail = self.query_one("#message-rail", Static) + rail.update("") + rail.remove_class("message-error") + rail.remove_class("message-warning") + self._status_timer = None except Exception: pass @@ -370,6 +497,13 @@ class HostsManagerApp(App): def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool: """Persist a mutation, restoring its complete pre-action state on failure.""" + if not self._allow_mutation_action(): + self.hosts_file = snapshot.manager_state.hosts_file + self.manager.undo_redo_history = snapshot.manager_state.undo_redo_history + self.selected_entry_index = snapshot.selected_entry_index + self.table_handler.populate_entries_table() + self.details_handler.update_entry_details() + return False save_success, save_message, hosts_file = self.manager.save_mutation( self.hosts_file, snapshot.manager_state ) @@ -496,13 +630,9 @@ class HostsManagerApp(App): self.update_status("Hosts file reloaded") def action_help(self) -> None: - """Show help panel.""" - if self.help_visible: - self.action_hide_help_panel() - self.help_visible = False - else: - self.action_show_help_panel() - self.help_visible = True + """Open the keyboard reference without reducing workspace width.""" + if not self.screen_stack or not isinstance(self.screen, HelpModal): + self.push_screen(HelpModal()) def action_config(self) -> None: """Show configuration modal.""" @@ -517,6 +647,8 @@ class HostsManagerApp(App): def action_show_filters(self) -> None: """Open the advanced filter controls and apply their returned options.""" + if isinstance(self.screen, FilterModal): + return def handle_filter_result(filter_options: FilterOptions | None) -> None: if filter_options is None: @@ -564,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() @@ -626,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." @@ -665,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: @@ -677,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." @@ -736,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." @@ -792,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 @@ -819,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 @@ -846,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." @@ -940,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." @@ -1134,6 +1292,10 @@ class HostsManagerApp(App): """Update the edit form with current entry values.""" self.details_handler.update_edit_form() + def watch_edit_mode(self, edit_mode: bool) -> None: + """Keep elevated capability explicit without changing the full palette.""" + self.sub_title = "PRIVILEGED" if edit_mode else "" + def watch_entry_edit_mode(self, entry_edit_mode: bool) -> None: """Update the right pane border title when entry edit mode changes.""" try: diff --git a/src/hosts/tui/config_modal.py b/src/hosts/tui/config_modal.py index d1f00b6..1b6d346 100644 --- a/src/hosts/tui/config_modal.py +++ b/src/hosts/tui/config_modal.py @@ -48,14 +48,14 @@ class ConfigModal(ModalScreen[bool]): with Horizontal(classes="button-row"): yield Button( - "Save", variant="primary", id="save-button", classes="config-button" - ) - yield Button( - "Cancel (ESC)", + "Cancel", variant="default", id="cancel-button", classes="default-button", ) + yield Button( + "Save", variant="primary", id="save-button", classes="config-button" + ) def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" diff --git a/src/hosts/tui/delete_confirmation_modal.py b/src/hosts/tui/delete_confirmation_modal.py index dff896d..d180c65 100644 --- a/src/hosts/tui/delete_confirmation_modal.py +++ b/src/hosts/tui/delete_confirmation_modal.py @@ -25,7 +25,6 @@ class DeleteConfirmationModal(ModalScreen[bool]): BINDINGS = [ Binding("escape", "cancel", "Cancel"), - Binding("enter", "confirm", "Delete"), ] def __init__(self, entry: HostEntry): @@ -35,7 +34,7 @@ class DeleteConfirmationModal(ModalScreen[bool]): def compose(self) -> ComposeResult: """Create the delete confirmation modal layout.""" with Vertical(classes="delete-container"): - yield Static("Delete Entry", classes="delete-title") + yield Static("Delete Host Entry", classes="delete-title") yield Static( "Are you sure you want to delete this entry?", classes="delete-message" @@ -52,15 +51,15 @@ class DeleteConfirmationModal(ModalScreen[bool]): with Horizontal(classes="button-row"): yield Button( - "Delete", - variant="error", - id="delete-button", + "Cancel", + variant="default", + id="cancel-button", classes="default-button", ) yield Button( - "Cancel (ESC)", - variant="default", - id="cancel-button", + "Delete Host Entry", + variant="error", + id="delete-button", classes="default-button", ) diff --git a/src/hosts/tui/details_handler.py b/src/hosts/tui/details_handler.py index 6a2b63b..87408a4 100644 --- a/src/hosts/tui/details_handler.py +++ b/src/hosts/tui/details_handler.py @@ -1,113 +1,113 @@ -""" -Details pane management for the hosts TUI application. +"""Read-only Entry Details and editable Entry Editor coordination.""" -This module handles the display and updating of entry details -and edit forms in the right pane. -""" - -from textual.widgets import Input, Checkbox +from textual.widgets import Checkbox, Input, Static class DetailsHandler: - """Handles all details pane operations for the hosts manager.""" + """Render selected Host Entry data without making inspection look editable.""" def __init__(self, app): - """Initialize the details handler with reference to the main app.""" self.app = app def update_entry_details(self) -> None: - """Update the right pane with selected entry details.""" if self.app.entry_edit_mode: self.update_edit_form() else: self.update_details_display() + def _set_detail(self, detail_id: str, value: str) -> None: + self.app.query_one(f"#{detail_id}", Static).update(value) + + def _show_empty_state(self, message: str) -> None: + self.app.query_one("#details-empty-state", Static).update(message) + self.app.query_one("#details-empty-state", Static).remove_class("hidden") + self.app.query_one("#details-content").add_class("hidden") + self.app.query_one("#details-dns-rows").add_class("hidden") + self.app.query_one("#details-default-notice").add_class("hidden") + def update_details_display(self) -> None: - """Update the details display using disabled Input widgets.""" + """Show compact label-value rows for the currently selected Host Entry.""" details_display = self.app.query_one("#entry-details-display") edit_form = self.app.query_one("#entry-edit-form") - - # Show details display, hide edit form details_display.remove_class("hidden") edit_form.add_class("hidden") - # Get the input widgets - ip_input = self.app.query_one("#details-ip-input", Input) - hostname_input = self.app.query_one("#details-hostname-input", Input) - comment_input = self.app.query_one("#details-comment-input", Input) - active_checkbox = self.app.query_one("#details-active-checkbox", Checkbox) - if not self.app.hosts_file.entries: - # Show empty message - ip_input.value = "" - ip_input.placeholder = "No entries loaded" - hostname_input.value = "" - hostname_input.placeholder = "No entries loaded" - comment_input.value = "" - comment_input.placeholder = "No entries loaded" - active_checkbox.value = False + self._show_empty_state("No Host Entries are loaded.") return - # Get visible entries to check if we need to adjust selection visible_entries = self.app.table_handler.get_visible_entries() if not visible_entries: - ip_input.value = "" - ip_input.placeholder = "No visible entries" - hostname_input.value = "" - hostname_input.placeholder = "No visible entries" - comment_input.value = "" - comment_input.placeholder = "No visible entries" - active_checkbox.value = False + self._show_empty_state( + "No Host Entries match the current filters. Press Ctrl+F to change them." + ) return - # If default entries are hidden and selected_entry_index points to a hidden entry, - # we need to find the corresponding visible entry - show_defaults = self.app.config.should_show_default_entries() - if not show_defaults: - # Check if the currently selected entry is a default entry (hidden) - if ( - self.app.selected_entry_index < len(self.app.hosts_file.entries) - and self.app.hosts_file.entries[ - self.app.selected_entry_index - ].is_default_entry() - ): - # The selected entry is hidden, so we should show the first visible entry instead - if visible_entries: - # Find the first visible entry in the hosts file - for i, entry in enumerate(self.app.hosts_file.entries): - if not entry.is_default_entry(): - self.app.selected_entry_index = i - break - if self.app.selected_entry_index >= len(self.app.hosts_file.entries): self.app.selected_entry_index = 0 - entry = self.app.hosts_file.entries[self.app.selected_entry_index] - # Update the input widgets with entry data - ip_input.value = entry.ip_address - ip_input.placeholder = "" - hostname_input.value = ", ".join(entry.hostnames) - hostname_input.placeholder = "" - comment_input.value = entry.comment or "" - comment_input.placeholder = "No comment" - active_checkbox.value = entry.is_active + self.app.query_one("#details-empty-state", Static).add_class("hidden") + self.app.query_one("#details-content").remove_class("hidden") + self._set_detail("details-ip-input", entry.ip_address) + self._set_detail("details-hostname-input", ", ".join(entry.hostnames)) + self._set_detail("details-comment-input", entry.comment or "—") + self._set_detail( + "details-active-checkbox", "✓ Active" if entry.is_active else "· Inactive" + ) - # For default entries, show warning in placeholder text + default_notice = self.app.query_one("#details-default-notice", Static) if entry.is_default_entry(): - ip_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" - hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" - comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" + entry_state = "✓ Active" if entry.is_active else "· Inactive" + self._set_detail( + "details-active-checkbox", f"■ Default (protected) · {entry_state}" + ) + default_notice.update( + "Protected Default Entry. This operating-system mapping cannot be changed." + ) + default_notice.remove_class("hidden") + else: + default_notice.add_class("hidden") - # Update DNS information if present - self._update_dns_information(entry) + dns_rows = self.app.query_one("#details-dns-rows") + if entry.has_dns_name(): + dns_rows.remove_class("hidden") + self._set_detail("details-dns-name-input", entry.dns_name or "—") + self._set_detail("details-dns-status-input", self._dns_status_text(entry)) + self._set_detail( + "details-dns-resolved-input", + entry.last_resolved.strftime("%Y-%m-%d %H:%M:%S") + if entry.last_resolved + else "Not resolved yet", + ) + else: + dns_rows.add_class("hidden") + + @staticmethod + def _dns_status_text(entry) -> str: + labels = { + "not_resolved": "· Not resolved", + "resolving": "! Resolving…", + "resolved": "✓ Resolved", + "failed": "× Resolution failed", + "match": "✓ IP matches DNS", + "mismatch": "! IP differs from DNS", + } + status = labels.get(entry.dns_resolution_status, entry.dns_resolution_status) + if not status: + return "· Not resolved" + if entry.resolved_ip and entry.dns_resolution_status in { + "resolved", + "match", + "mismatch", + }: + return f"{status} ({entry.resolved_ip})" + return status def update_edit_form(self) -> None: - """Update the edit form with current entry values.""" + """Populate the separate editable Entry Editor form.""" details_display = self.app.query_one("#entry-details-display") edit_form = self.app.query_one("#entry-edit-form") - - # Hide details display, show edit form details_display.add_class("hidden") edit_form.remove_class("hidden") @@ -117,80 +117,8 @@ class DetailsHandler: return entry = self.app.hosts_file.entries[self.app.selected_entry_index] - - # Update form fields with current entry values - ip_input = self.app.query_one("#ip-input", Input) - hostname_input = self.app.query_one("#hostname-input", Input) - comment_input = self.app.query_one("#comment-input", Input) - active_checkbox = self.app.query_one("#active-checkbox", Checkbox) - - ip_input.value = entry.ip_address - hostname_input.value = ", ".join(entry.hostnames) - comment_input.value = entry.comment or "" - active_checkbox.value = entry.is_active - - # Initialize radio button state and field visibility + self.app.query_one("#ip-input", Input).value = entry.ip_address + self.app.query_one("#hostname-input", Input).value = ", ".join(entry.hostnames) + self.app.query_one("#comment-input", Input).value = entry.comment or "" + self.app.query_one("#active-checkbox", Checkbox).value = entry.is_active self.app.edit_handler.populate_edit_form_with_type_detection() - - def _update_dns_information(self, entry) -> None: - """Update DNS information display for the selected entry.""" - try: - # Get the three separate DNS input fields - dns_name_input = self.app.query_one("#details-dns-name-input", Input) - dns_status_input = self.app.query_one("#details-dns-status-input", Input) - dns_resolved_input = self.app.query_one( - "#details-dns-resolved-input", Input - ) - - if not entry.has_dns_name(): - # Clear all DNS fields if no DNS information - dns_name_input.value = "" - dns_name_input.placeholder = "No DNS name" - dns_status_input.value = "" - dns_status_input.placeholder = "No DNS status" - dns_resolved_input.value = "" - dns_resolved_input.placeholder = "Not resolved yet" - return - - # Update DNS Name field - dns_name_input.value = entry.dns_name or "" - dns_name_input.placeholder = "" if entry.dns_name else "No DNS name" - - # Update DNS Status field - if entry.dns_resolution_status: - status_text = { - "not_resolved": "Not resolved", - "resolving": "Resolving...", - "resolved": "Resolved", - "failed": "Resolution failed", - "match": "IP matches DNS", - "mismatch": "IP differs from DNS", - }.get(entry.dns_resolution_status, entry.dns_resolution_status) - - # Add resolved IP to status if available - if entry.resolved_ip and entry.dns_resolution_status in [ - "resolved", - "match", - "mismatch", - ]: - status_text += f" ({entry.resolved_ip})" - - dns_status_input.value = status_text - dns_status_input.placeholder = "" - else: - dns_status_input.value = "" - dns_status_input.placeholder = "No DNS status" - - # Update Last Resolved field - if entry.last_resolved: - time_str = entry.last_resolved.strftime("%H:%M:%S") - date_str = entry.last_resolved.strftime("%Y-%m-%d") - dns_resolved_input.value = f"{date_str} {time_str}" - dns_resolved_input.placeholder = "" - else: - dns_resolved_input.value = "" - dns_resolved_input.placeholder = "Not resolved yet" - - except Exception: - # DNS widgets not present yet, silently ignore - pass diff --git a/src/hosts/tui/edit_handler.py b/src/hosts/tui/edit_handler.py index ff8b505..5e94121 100644 --- a/src/hosts/tui/edit_handler.py +++ b/src/hosts/tui/edit_handler.py @@ -425,9 +425,9 @@ class EditHandler: active_checkbox = self.app.query_one("#active-checkbox", Checkbox) # Build field list based on current entry type - fields = [radio_set] + fields = [radio_set, hostname_input] - # Add IP or DNS field based on visibility + # The address field follows Hostnames in the visual editor. try: ip_section = self.app.query_one("#edit-ip-section") if not ip_section.has_class("hidden"): @@ -444,8 +444,7 @@ class EditHandler: except Exception: pass - # Add remaining fields - fields.extend([hostname_input, comment_input, active_checkbox]) + fields.extend([comment_input, active_checkbox]) # Find currently focused field and move to next for i, field in enumerate(fields): @@ -471,9 +470,9 @@ class EditHandler: active_checkbox = self.app.query_one("#active-checkbox", Checkbox) # Build field list based on current entry type - fields = [radio_set] + fields = [radio_set, hostname_input] - # Add IP or DNS field based on visibility + # The address field follows Hostnames in the visual editor. try: ip_section = self.app.query_one("#edit-ip-section") if not ip_section.has_class("hidden"): @@ -490,8 +489,7 @@ class EditHandler: except Exception: pass - # Add remaining fields - fields.extend([hostname_input, comment_input, active_checkbox]) + fields.extend([comment_input, active_checkbox]) # Find currently focused field and move to previous for i, field in enumerate(fields): diff --git a/src/hosts/tui/filter_modal.py b/src/hosts/tui/filter_modal.py index 47ef632..32c7823 100644 --- a/src/hosts/tui/filter_modal.py +++ b/src/hosts/tui/filter_modal.py @@ -6,17 +6,15 @@ filtering options including status, type, resolution status, and search filterin """ from textual.app import ComposeResult -from textual.containers import Grid, Horizontal, Container +from textual.containers import Horizontal, Vertical, VerticalScroll from textual.widgets import ( Static, Button, Checkbox, Input, Select, - Label, RadioSet, RadioButton, - Collapsible, ) from textual.screen import ModalScreen from textual import on @@ -24,131 +22,19 @@ from textual.binding import Binding from typing import Optional, Dict, List from ..core.filters import FilterOptions, EntryFilter +from .styles import FILTER_MODAL_CSS class FilterModal(ModalScreen[Optional[FilterOptions]]): """Advanced filtering configuration modal.""" - BINDINGS = [Binding("escape", "cancel", "Cancel")] + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("tab", "focus_next", show=False), + Binding("shift+tab", "focus_previous", show=False), + ] - DEFAULT_CSS = """ - FilterModal { - align: center middle; - } - - #filter-dialog { - grid-size: 1; - grid-gutter: 1 2; - grid-rows: auto 1fr auto; - padding: 0 1; - width: 80; - height: auto; - border: thick $background 80%; - background: $surface; - max-height: 90%; - } - - #filter-header { - dock: top; - width: 1fr; - height: 3; - content-align: center middle; - text-style: bold; - background: $primary; - color: $text; - } - - #filter-content { - layout: vertical; - overflow-y: auto; - height: auto; - max-height: 70vh; - padding: 1; - } - - #filter-actions { - dock: bottom; - layout: horizontal; - width: 1fr; - height: 3; - align: center middle; - padding: 0 1; - background: $panel; - } - - .filter-section { - margin: 1 0; - padding: 1; - border: round $primary 20%; - background: $panel; - } - - .filter-section-title { - text-style: bold; - color: $primary; - margin-bottom: 1; - } - - .filter-checkboxes { - layout: vertical; - margin: 0 2; - } - - .filter-radios { - layout: vertical; - margin: 0 2; - } - - .filter-input-row { - layout: horizontal; - margin: 0 2; - height: 3; - align: left middle; - } - - .filter-input-label { - width: 20; - content-align: left middle; - margin-right: 1; - } - - .filter-input { - width: 30; - } - - .preset-row { - layout: horizontal; - margin: 1 2; - height: 3; - align: left middle; - } - - .preset-select { - width: 30; - margin-right: 2; - } - - Button { - margin: 0 1; - min-width: 12; - } - - Checkbox { - margin: 0 1; - } - - RadioButton { - margin: 0 1; - } - - .count-display { - text-style: italic; - color: $text-muted; - content-align: center middle; - height: 1; - margin: 1 0; - } - """ + CSS = FILTER_MODAL_CSS def __init__( self, @@ -171,133 +57,140 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): def compose(self) -> ComposeResult: """Compose the filter modal interface.""" - with Grid(id="filter-dialog"): - yield Static("Advanced Filtering", id="filter-header") + with VerticalScroll(classes="filter-container"): + yield Static("Advanced Filtering", classes="filter-title") + yield Static("", id="count-display", classes="filter-count") - with Container(id="filter-content"): - # Filter presets section - with Collapsible(title="Filter Presets", collapsed=False): - with Container(classes="filter-section"): - with Horizontal(classes="preset-row"): - yield Label("Preset:", classes="filter-input-label") - yield self._create_preset_select() - yield Button("Load", id="load-preset", variant="primary") - yield Button("Save", id="save-preset") - yield Button("Delete", id="delete-preset", variant="error") + with Vertical(classes="default-flex-section") as presets: + presets.border_title = "Presets" + with Horizontal(classes="filter-preset-row"): + yield self._create_preset_select() + yield Button( + "Load", id="load-preset", variant="primary", compact=True + ) + yield Button("Save", id="save-preset", compact=True) + yield Button( + "Delete", + id="delete-preset", + variant="error", + compact=True, + ) - # Status filtering section - with Collapsible(title="Status Filtering", collapsed=False): - with Container(classes="filter-section"): - yield Static("Status Filtering", classes="filter-section-title") - with RadioSet(id="status-filter-type"): - yield RadioButton("Show All", id="status-all") - yield RadioButton("Active Only", id="status-active") - yield RadioButton("Inactive Only", id="status-inactive") - yield RadioButton("Custom", id="status-custom") + with Vertical(classes="default-flex-section") as status: + status.border_title = "Entry status" + with RadioSet(id="status-filter-type", classes="default-radio-set"): + yield RadioButton("Show all", id="status-all") + yield RadioButton("Active only", id="status-active") + yield RadioButton("Inactive only", id="status-inactive") + yield RadioButton("Custom", id="status-custom") + with Vertical( + classes="filter-custom-options", id="status-custom-options" + ): + yield Checkbox( + "Show active entries", + value=True, + id="show-active", + compact=True, + ) + yield Checkbox( + "Show inactive entries", + value=True, + id="show-inactive", + compact=True, + ) - with Container( - classes="filter-checkboxes", id="status-custom-options" - ): - yield Checkbox( - "Show Active Entries", value=True, id="show-active" - ) - yield Checkbox( - "Show Inactive Entries", value=True, id="show-inactive" - ) + with Vertical(classes="default-flex-section") as entry_type: + entry_type.border_title = "Entry type" + with RadioSet(id="type-filter-type", classes="default-radio-set"): + yield RadioButton("Show all", id="type-all") + yield RadioButton("DNS entries only", id="type-dns") + yield RadioButton("IP entries only", id="type-ip") + yield RadioButton("Custom", id="type-custom") + with Vertical( + classes="filter-custom-options", id="type-custom-options" + ): + yield Checkbox( + "Show DNS entries", value=True, id="show-dns", compact=True + ) + yield Checkbox( + "Show IP entries", value=True, id="show-ip", compact=True + ) - # DNS type filtering section - with Collapsible(title="Entry Type Filtering", collapsed=False): - with Container(classes="filter-section"): - yield Static( - "Entry Type Filtering", classes="filter-section-title" - ) - with RadioSet(id="type-filter-type"): - yield RadioButton("Show All", id="type-all") - yield RadioButton("DNS Entries Only", id="type-dns") - yield RadioButton("IP Entries Only", id="type-ip") - yield RadioButton("Custom", id="type-custom") + with Vertical(classes="default-flex-section") as resolution: + resolution.border_title = "DNS resolution" + with RadioSet(id="resolution-filter-type", classes="default-radio-set"): + yield RadioButton("Show all", id="resolution-all") + yield RadioButton("Resolved only", id="resolution-resolved") + yield RadioButton("Mismatches only", id="resolution-mismatch") + yield RadioButton("Custom", id="resolution-custom") + with Vertical( + classes="filter-custom-options", id="resolution-custom-options" + ): + yield Checkbox( + "Show resolved", value=True, id="show-resolved", compact=True + ) + yield Checkbox( + "Show unresolved", + value=True, + id="show-unresolved", + compact=True, + ) + yield Checkbox( + "Show resolving", + value=True, + id="show-resolving", + compact=True, + ) + yield Checkbox( + "Show failed", value=True, id="show-failed", compact=True + ) + yield Checkbox( + "Show mismatched", + value=True, + id="show-mismatched", + compact=True, + ) - with Container( - classes="filter-checkboxes", id="type-custom-options" - ): - yield Checkbox( - "Show DNS Entries", value=True, id="show-dns" - ) - yield Checkbox("Show IP Entries", value=True, id="show-ip") + with Vertical(classes="default-flex-section") as search: + search.border_title = "Search" + yield Input( + placeholder="Hostname, IP address, or comment", + value=self.current_options.search_term or "", + id="search-term", + classes="filter-search-input", + ) + with Vertical(classes="filter-custom-options"): + yield Checkbox( + "Search hostnames", + value=True, + id="search-hostnames", + compact=True, + ) + yield Checkbox( + "Search comments", + value=True, + id="search-comments", + compact=True, + ) + yield Checkbox( + "Search IP addresses", + value=True, + id="search-ips", + compact=True, + ) + yield Checkbox( + "Case sensitive", + value=False, + id="search-case-sensitive", + compact=True, + ) - # DNS resolution status filtering section - with Collapsible(title="Resolution Status Filtering", collapsed=False): - with Container(classes="filter-section"): - yield Static( - "Resolution Status Filtering", - classes="filter-section-title", - ) - with RadioSet(id="resolution-filter-type"): - yield RadioButton("Show All", id="resolution-all") - yield RadioButton( - "Resolved Only", - id="resolution-resolved", - ) - yield RadioButton( - "Mismatches Only", - id="resolution-mismatch", - ) - yield RadioButton("Custom", id="resolution-custom") - - with Container( - classes="filter-checkboxes", id="resolution-custom-options" - ): - yield Checkbox( - "Show Resolved", value=True, id="show-resolved" - ) - yield Checkbox( - "Show Unresolved", value=True, id="show-unresolved" - ) - yield Checkbox( - "Show Resolving", value=True, id="show-resolving" - ) - yield Checkbox("Show Failed", value=True, id="show-failed") - yield Checkbox( - "Show Mismatched", value=True, id="show-mismatched" - ) - - # Search filtering section - with Collapsible(title="Search Filtering", collapsed=True): - with Container(classes="filter-section"): - yield Static("Search Filtering", classes="filter-section-title") - - with Horizontal(classes="filter-input-row"): - yield Label("Search term:", classes="filter-input-label") - yield Input( - placeholder="Enter search term...", - value=self.current_options.search_term or "", - id="search-term", - classes="filter-input", - ) - - with Container(classes="filter-checkboxes"): - yield Checkbox( - "Search in hostnames", value=True, id="search-hostnames" - ) - yield Checkbox( - "Search in comments", value=True, id="search-comments" - ) - yield Checkbox( - "Search in IP addresses", value=True, id="search-ips" - ) - yield Checkbox( - "Case sensitive", - value=False, - id="search-case-sensitive", - ) - - # Entry count display - yield Static("", id="count-display", classes="count-display") - - with Horizontal(id="filter-actions"): - yield Button("Apply", id="apply", variant="primary") - yield Button("Reset", id="reset") - yield Button("Cancel", id="cancel") + 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" + ) def _create_preset_select(self) -> Select: """Create the preset picker without selecting a preset by default.""" @@ -307,18 +200,90 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]): preset_options, value=self.current_options.preset_name, id="preset-select", - classes="preset-select", + classes="filter-preset-select", + compact=True, ) return Select( preset_options, id="preset-select", - classes="preset-select", + classes="filter-preset-select", + compact=True, ) def on_mount(self) -> None: """Initialize the modal with current options.""" self._update_ui_from_options() self._update_count_display() + self.query_one("#preset-select", Select).focus() + + def _focusable_control_ids(self) -> List[str]: + """Return the modal's tab order, omitting hidden custom controls.""" + control_ids = [ + "preset-select", + "load-preset", + "save-preset", + "delete-preset", + "status-filter-type", + ] + if self.query_one("#status-custom", RadioButton).value: + control_ids.extend(("show-active", "show-inactive")) + + control_ids.append("type-filter-type") + if self.query_one("#type-custom", RadioButton).value: + control_ids.extend(("show-dns", "show-ip")) + + control_ids.append("resolution-filter-type") + if self.query_one("#resolution-custom", RadioButton).value: + control_ids.extend( + ( + "show-resolved", + "show-unresolved", + "show-resolving", + "show-failed", + "show-mismatched", + ) + ) + + control_ids.extend( + ( + "search-term", + "search-hostnames", + "search-comments", + "search-ips", + "search-case-sensitive", + "cancel", + "reset", + "apply", + ) + ) + return control_ids + + def action_focus_next(self) -> None: + """Move focus in form order, including controls below the scroll position.""" + self._move_focus_in_form(1) + + def action_focus_previous(self) -> None: + """Move focus backward in form order, including controls below the scroll position.""" + self._move_focus_in_form(-1) + + def _move_focus_in_form(self, direction: int) -> None: + """Focus the next or previous currently-visible form control.""" + control_ids = self._focusable_control_ids() + focused_id = ( + str(self.focused.id) + if self.focused is not None and self.focused.id is not None + else None + ) + if focused_id is None: + focused_index = -1 if direction > 0 else 0 + else: + try: + focused_index = control_ids.index(focused_id) + except ValueError: + focused_index = -1 if direction > 0 else 0 + + next_id = control_ids[(focused_index + direction) % len(control_ids)] + self.query_one(f"#{next_id}").focus() def _update_ui_from_options(self) -> None: """Update UI controls to reflect current options.""" diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py new file mode 100644 index 0000000..72dd7ab --- /dev/null +++ b/src/hosts/tui/help_modal.py @@ -0,0 +1,66 @@ +"""Dedicated keyboard reference overlay.""" + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Static + + +class HelpModal(ModalScreen[None]): + """Show the complete binding reference without shrinking the workspace.""" + + BINDINGS = [ + Binding("escape", "close", "Close"), + Binding("question_mark", "close", "Close"), + ] + + CSS = """ + HelpModal { align: center middle; } + #help-container { + width: 72; + height: auto; + max-height: 90%; + background: $surface; + border: thick $primary; + padding: 1 2; + } + .help-title { text-style: bold; color: $primary; text-align: center; } + .help-section { margin-top: 1; text-style: bold; } + .help-copy { color: $text-muted; } + #help-close { margin-top: 1; width: 12; } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="help-container"): + yield Static("Keyboard help", classes="help-title") + yield Static("General", classes="help-section") + yield Static( + "q Quit ? Close help c Configuration", classes="help-copy" + ) + yield Static("Navigation", classes="help-section") + yield Static( + "↑/↓ Select Host Entry i Sort IP h Sort hostname", + classes="help-copy", + ) + yield Static("Filtering", classes="help-section") + yield Static( + "Type in Search for immediate filtering Ctrl+F Advanced filters", + classes="help-copy", + ) + yield Static("Privileged Mode", classes="help-section") + yield Static( + "Ctrl+E Enter or leave Privileged Mode n New e Edit d Delete", + classes="help-copy", + ) + yield Button("Close", id="help-close", variant="primary") + + def on_mount(self) -> None: + self.query_one("#help-close", Button).focus() + + def action_close(self) -> None: + self.dismiss(None) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "help-close": + self.action_close() diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 0279815..9d7dc51 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -22,7 +22,7 @@ HOSTS_MANAGER_BINDINGS = [ Binding( "ctrl+e", "toggle_edit_mode", - "Toggle edit mode", + "Privileged Mode", show=True, id="left:toggle_edit_mode", ), diff --git a/src/hosts/tui/save_confirmation_modal.py b/src/hosts/tui/save_confirmation_modal.py index c7c0b14..3b5a36f 100644 --- a/src/hosts/tui/save_confirmation_modal.py +++ b/src/hosts/tui/save_confirmation_modal.py @@ -40,29 +40,27 @@ class SaveConfirmationModal(ModalScreen[str]): with Horizontal(classes="button-row"): yield Button( - "Save (S)", - variant="primary", - id="save-button", + "Cancel", + variant="default", + id="cancel-button", classes="save-confirmation-button", ) yield Button( - "Discard (D)", + "Discard", variant="default", id="discard-button", classes="save-confirmation-button", ) yield Button( - "Cancel (ESC)", - variant="default", - id="cancel-button", + "Save", + variant="primary", + id="save-button", classes="save-confirmation-button", ) def on_mount(self) -> None: - """Called when the modal is mounted. Set focus to the first button.""" - # Focus on the Save button by default - save_button = self.query_one("#save-button", Button) - save_button.focus() + """Start at the non-destructive Cancel action.""" + self.query_one("#cancel-button", Button).focus() def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index 60c4bea..d927a46 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -5,6 +5,24 @@ This module contains all CSS definitions for consistent styling across the application. """ +from textual.theme import Theme + + +HOSTS_DARK_THEME = Theme( + name="hosts-dark", + primary="#0178D4", + secondary="#004578", + warning="#FFA62B", + error="#BA3C5B", + success="#4EBF71", + accent="#FFA62B", + foreground="#E0E0E0", + background="#121212", + surface="#1E1E1E", + panel="#242F38", + dark=True, +) + # Common CSS classes shared across components COMMON_CSS = """ .default-button { @@ -71,17 +89,66 @@ 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; border: none; } +#filter-summary { + width: auto; + height: 1; + padding: 0 1; + content-align: right middle; + color: $text-muted; +} + .hosts-container { height: 1fr; margin-top: 0; } +.compact-layout .entry-form { + padding: 0 1; +} + .common-pane { border: round $primary; margin: 0; @@ -127,24 +194,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 +235,7 @@ Header.-tall { /* Custom Footer Styling */ CustomFooter { - background: $surface; + background: $panel; color: $text; dock: bottom; height: 1; @@ -313,6 +391,68 @@ ConfigModal { """ ) +# Advanced Filter Modal CSS +FILTER_MODAL_CSS = ( + COMMON_CSS + + """ +FilterModal { + align: center middle; +} + +.filter-container { + width: 80; + height: 28; + background: $surface; + border: thick $primary; + padding: 1; +} + +.filter-title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; +} + +.filter-count { + height: 1; + text-align: center; + color: $text-muted; + text-style: italic; + margin-bottom: 1; +} + +.filter-preset-row { + height: 1; + align: left middle; +} + +.filter-preset-select { + width: 1fr; + margin-right: 1; +} + +.filter-custom-options { + height: auto; + margin: 0 2; +} + +.filter-search-input { + height: 1; + width: 1fr; + margin: 0 2; + border: none; +} + +.filter-actions { + dock: bottom; + height: 3; + background: $surface; + align: center middle; +} +""" +) + # Save Confirmation Modal CSS SAVE_CONFIRMATION_MODAL_CSS = ( COMMON_CSS diff --git a/src/hosts/tui/table_handler.py b/src/hosts/tui/table_handler.py index 769fe79..1ec9da8 100644 --- a/src/hosts/tui/table_handler.py +++ b/src/hosts/tui/table_handler.py @@ -168,8 +168,8 @@ class TableHandler: arrow = "↑" if self.app.sort_ascending else "↓" hostname_label = f"{arrow} Canonical Hostname" - # Add columns with proper labels (Active, IP, Hostname, DNS) - table.add_columns(active_label, ip_label, hostname_label, dns_label) + # Keep the canonical hostname beside state for fast table scanning. + table.add_columns(active_label, hostname_label, ip_label, dns_label) # Get visible entries (after filtering) visible_entries = self.get_visible_entries() @@ -185,25 +185,21 @@ class TableHandler: # Get DNS status indicator dns_text = self._get_dns_status_indicator(entry) - # Add row with styling based on active status and default entry status + # Markers and wording keep each state understandable without colour. if is_default: - # Default entries are always shown in dim grey regardless of active status - active_text = Text("✓" if entry.is_active else "", style="dim white") - ip_text = Text(entry.ip_address, style="dim white") - hostname_text = Text(canonical_hostname, style="dim white") - table.add_row(active_text, ip_text, hostname_text, dns_text) + entry_state = "✓ Active" if entry.is_active else "· Inactive" + active_text = Text(f"■ Default · {entry_state}", style="dim") + ip_text = Text(entry.ip_address, style="dim") + hostname_text = Text(canonical_hostname, style="dim") elif entry.is_active: - # Active entries in green with checkmark - active_text = Text("✓", style="bold green") - ip_text = Text(entry.ip_address, style="bold green") - hostname_text = Text(canonical_hostname, style="bold green") - table.add_row(active_text, ip_text, hostname_text, dns_text) + active_text = Text("✓ Active") + ip_text = entry.ip_address + hostname_text = canonical_hostname else: - # Inactive entries in dim yellow with italic (no checkmark) - active_text = Text("", style="dim yellow italic") - ip_text = Text(entry.ip_address, style="dim yellow italic") - hostname_text = Text(canonical_hostname, style="dim yellow italic") - table.add_row(active_text, ip_text, hostname_text, dns_text) + active_text = Text("· Inactive", style="dim") + ip_text = Text(entry.ip_address, style="dim") + hostname_text = Text(canonical_hostname, style="dim") + table.add_row(active_text, hostname_text, ip_text, dns_text) def restore_cursor_position(self, previous_entry) -> None: """Restore cursor position after reload, maintaining selection if possible.""" @@ -265,7 +261,7 @@ class TableHandler: """Get DNS name and status indicator for an entry.""" # If entry has no DNS name configured, show empty if not entry.has_dns_name(): - return Text("", style="dim white") + return Text("") # Start with the DNS name dns_display = entry.dns_name @@ -274,28 +270,21 @@ class TableHandler: dns_status = entry.dns_resolution_status or "not_resolved" if dns_status == "not_resolved": - status_icon = "⏳" - style = "dim yellow" + status_icon = "· Pending" elif dns_status == "resolving": - status_icon = "🔄" - style = "yellow" + status_icon = "! Resolving" elif dns_status == "resolved": - status_icon = "✅" - style = "green" + status_icon = "✓ Resolved" elif dns_status == "match": - status_icon = "✅" - style = "bold green" + status_icon = "✓ Matches" elif dns_status == "mismatch": - status_icon = "⚠️" - style = "red" + status_icon = "! Mismatch" elif dns_status == "failed": - status_icon = "❌" - style = "red" + status_icon = "× Failed" else: status_icon = "" - style = "dim white" - return Text(f"{status_icon} {dns_display}", style=style) + return Text(f"{status_icon} {dns_display}".strip()) def sort_entries_by_hostname(self) -> None: """Sort entries by canonical hostname.""" diff --git a/tests/test_add_entry_modal.py b/tests/test_add_entry_modal.py index 63c901e..313a0e1 100644 --- a/tests/test_add_entry_modal.py +++ b/tests/test_add_entry_modal.py @@ -193,15 +193,15 @@ class TestAddEntryModalRadioButtonLogic: # Mock the query_one method for sections and inputs mock_ip_section = Mock() mock_dns_section = Mock() - mock_ip_input = Mock(spec=Input) + mock_hostname_input = Mock(spec=Input) def mock_query_one(selector, widget_type=None): if selector == "#ip-section": return mock_ip_section elif selector == "#dns-section": return mock_dns_section - elif selector == "#ip-address-input": - return mock_ip_input + elif selector == "#hostnames-input": + return mock_hostname_input return Mock() self.modal.query_one = Mock(side_effect=mock_query_one) @@ -225,22 +225,22 @@ class TestAddEntryModalRadioButtonLogic: # Verify IP section is shown and DNS section is hidden mock_ip_section.remove_class.assert_called_with("hidden") mock_dns_section.add_class.assert_called_with("hidden") - mock_ip_input.focus.assert_called_once() + mock_hostname_input.focus.assert_called_once() def test_radio_button_change_to_dns_entry(self): """Test radio button change to DNS entry mode.""" # Mock the query_one method for sections and inputs mock_ip_section = Mock() mock_dns_section = Mock() - mock_dns_input = Mock(spec=Input) + mock_hostname_input = Mock(spec=Input) def mock_query_one(selector, widget_type=None): if selector == "#ip-section": return mock_ip_section elif selector == "#dns-section": return mock_dns_section - elif selector == "#dns-name-input": - return mock_dns_input + elif selector == "#hostnames-input": + return mock_hostname_input return Mock() self.modal.query_one = Mock(side_effect=mock_query_one) @@ -264,7 +264,7 @@ class TestAddEntryModalRadioButtonLogic: # Verify DNS section is shown and IP section is hidden mock_ip_section.add_class.assert_called_with("hidden") mock_dns_section.remove_class.assert_called_with("hidden") - mock_dns_input.focus.assert_called_once() + mock_hostname_input.focus.assert_called_once() class TestAddEntryModalSaveLogic: diff --git a/tests/test_app.py b/tests/test_app.py index c7bc2d1..d717165 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,13 +5,14 @@ from unittest.mock import Mock, patch import pytest from textual.app import SuspendNotSupported -from textual.widgets import HelpPanel, RadioButton, Static +from textual.widgets import Input, RadioButton, Static from src.hosts.core.filters import FilterOptions from src.hosts.core.models import HostEntry, HostsFile from src.hosts.tui.app import HostsManagerApp from src.hosts.tui.custom_footer import CustomFooter from src.hosts.tui.filter_modal import FilterModal +from src.hosts.tui.help_modal import HelpModal @contextmanager @@ -45,16 +46,13 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter(): async with app.run_test() as pilot: footer = app.query_one("#custom-footer", CustomFooter) footer_text = footer.query_one("#footer-right", Static).render() - assert "ctrl+f Filter entries" in str(footer_text) + assert "ctrl+f Filters" in str(footer_text) app.action_help() await pilot.pause() - assert isinstance(app.query_one(HelpPanel), HelpPanel) - assert any( - binding.action == "show_filters" and binding.description == "Filter entries" - for _, binding, _, _ in app.screen.active_bindings.values() - ) - app.action_help() + assert isinstance(app.screen, HelpModal) + assert app.screen.query_one("#help-close") is not None + await pilot.press("escape") await pilot.pause() await pilot.press("ctrl+f") @@ -72,6 +70,71 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter(): ] +@pytest.mark.asyncio +async def test_filter_shortcut_does_not_stack_filter_modals(): + """Repeated filter shortcuts keep the existing filter modal in focus.""" + app = app_with_filterable_entries() + + async with app.run_test() as pilot: + await pilot.press("ctrl+f") + await pilot.pause() + + await pilot.press("ctrl+f") + await pilot.press("ctrl+f") + await pilot.pause() + + assert isinstance(app.screen, FilterModal) + 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.""" + app = app_with_filterable_entries() + + async with app.run_test() as pilot: + await pilot.press("ctrl+f") + await pilot.pause() + + assert app.focused is app.screen.query_one("#preset-select") + for expected_id in ( + "load-preset", + "save-preset", + "delete-preset", + "status-filter-type", + "type-filter-type", + "resolution-filter-type", + "search-term", + "search-hostnames", + "search-comments", + "search-ips", + "search-case-sensitive", + "cancel", + "reset", + "apply", + ): + await pilot.press("tab") + assert app.focused is app.screen.query_one(f"#{expected_id}") + + @pytest.mark.asyncio async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters(): """Reset clears the form on Apply, while Cancel leaves the applied filter alone.""" @@ -103,6 +166,79 @@ async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters( ] +@pytest.mark.asyncio +async def test_workspace_uses_read_only_detail_rows_and_hides_irrelevant_dns_fields(): + """An IP Host Entry has compact text details instead of disabled controls.""" + app = app_with_filterable_entries() + + async with app.run_test(size=(120, 40)) as pilot: + app.table_handler.populate_entries_table() + app.details_handler.update_entry_details() + await pilot.pause() + + details = app.query_one("#entry-details-display") + assert not list(details.query(Input)) + assert "192.0.2.1" in str(app.query_one("#details-ip-input", Static).render()) + assert "Active" in str( + app.query_one("#details-active-checkbox", Static).render() + ) + assert app.query_one("#details-dns-rows").has_class("hidden") + + +@pytest.mark.asyncio +async def test_workspace_exposes_dns_and_protection_details_and_durable_state(): + """DNS and Default Entry states remain explicit independently of colour.""" + app = app_with_filterable_entries() + app.hosts_file.entries[0].dns_name = "active.example" + app.hosts_file.entries[0].dns_resolution_status = "resolved" + app.hosts_file.entries[0].resolved_ip = "192.0.2.1" + app.current_filter_options.active_only = True + app.current_filter_options.show_inactive = False + + async with app.run_test(size=(100, 30)) as pilot: + app.table_handler.populate_entries_table() + app.details_handler.update_entry_details() + app._update_footer_status() + await pilot.pause() + + assert not app.query_one("#details-dns-rows").has_class("hidden") + assert "Resolved" in str( + app.query_one("#details-dns-status-input", Static).render() + ) + footer = app.query_one("#custom-footer", CustomFooter) + assert "Filters: 1" in footer._status_text + assert "READ-ONLY" in footer._status_text + assert app.has_class("compact-layout") + + +@pytest.mark.asyncio +async def test_small_viewport_replaces_workspace_with_an_explicit_safe_message(): + """A viewport below the contract cannot expose clipped mutating controls.""" + app = app_with_filterable_entries() + + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + + assert not app.query_one("#workspace").display + minimum_size = app.query_one("#minimum-size", Static) + assert "requires at least 100 columns × 30 rows" in str(minimum_size.render()) + + +@pytest.mark.asyncio +async def test_small_viewport_blocks_hidden_mutation_shortcuts(): + """A minimum-size presentation cannot open a hidden editor or privilege flow.""" + app = app_with_filterable_entries() + app.push_screen = Mock() + app.manager = Mock() + + async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("n", "ctrl+e") + await pilot.pause() + + app.push_screen.assert_not_called() + app.manager.enter_edit_mode.assert_not_called() + + class TestPrivilegedModeAuthorization: """Test the user-visible privileged-mode authorization flow.""" diff --git a/tests/test_main.py b/tests/test_main.py index 27e0f54..601939a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -102,7 +102,7 @@ class TestHostsManagerApp: # Should handle error gracefully app.update_status.assert_called_with( - "❌ Error loading hosts file: Hosts file not found" + "Error loading hosts file: Hosts file not found" ) def test_load_hosts_file_permission_error(self): @@ -122,7 +122,7 @@ class TestHostsManagerApp: # Should handle error gracefully app.update_status.assert_called_with( - "❌ Error loading hosts file: Permission denied" + "Error loading hosts file: Permission denied" ) def test_populate_entries_table_logic(self): @@ -207,13 +207,15 @@ class TestHostsManagerApp: app.update_entry_details() - # Verify input widgets were updated with entry data + # Verify read-only detail rows were updated with entry data. mock_details_display.remove_class.assert_called_with("hidden") mock_edit_form.add_class.assert_called_with("hidden") - assert mock_ip_input.value == "127.0.0.1" - assert mock_hostname_input.value == "localhost, local" - assert mock_comment_input.value == "Test comment" - assert mock_active_checkbox.value + mock_ip_input.update.assert_called_with("127.0.0.1") + mock_hostname_input.update.assert_called_with("localhost, local") + mock_comment_input.update.assert_called_with("Test comment") + mock_active_checkbox.update.assert_called_with( + "■ Default (protected) · ✓ Active" + ) def test_update_entry_details_no_entries(self): """Test updating entry details with no entries.""" @@ -233,6 +235,7 @@ class TestHostsManagerApp: mock_hostname_input = Mock() mock_comment_input = Mock() mock_active_checkbox = Mock() + mock_empty_state = Mock() def mock_query_one(selector, widget_type=None): if selector == "#entry-details-display": @@ -247,6 +250,8 @@ class TestHostsManagerApp: return mock_comment_input elif selector == "#details-active-checkbox": return mock_active_checkbox + elif selector == "#details-empty-state": + return mock_empty_state return Mock() cast(Any, app).query_one = mock_query_one @@ -254,16 +259,10 @@ class TestHostsManagerApp: app.update_entry_details() - # Verify widgets show empty state placeholders + # Verify the detail pane names the empty condition. mock_details_display.remove_class.assert_called_with("hidden") mock_edit_form.add_class.assert_called_with("hidden") - assert mock_ip_input.value == "" - assert mock_ip_input.placeholder == "No entries loaded" - assert mock_hostname_input.value == "" - assert mock_hostname_input.placeholder == "No entries loaded" - assert mock_comment_input.value == "" - assert mock_comment_input.placeholder == "No entries loaded" - assert not mock_active_checkbox.value + mock_empty_state.update.assert_called_with("No Host Entries are loaded.") def test_update_status_default(self): """Test status bar update with default information.""" @@ -301,7 +300,7 @@ class TestHostsManagerApp: # Verify footer status was updated mock_footer.set_status.assert_called_once() status_call = mock_footer.set_status.call_args[0][0] - assert "Read-only" in status_call + assert "READ-ONLY" in status_call assert "2 entries" in status_call assert "1 active" in status_call @@ -318,12 +317,12 @@ class TestHostsManagerApp: # Mock set_timer and query_one to avoid event loop and UI issues app.set_timer = Mock() - mock_status_bar = Mock() + mock_message_rail = Mock() mock_footer = Mock() def mock_query_one(selector, widget_type=None): - if selector == "#status-bar": - return mock_status_bar + if selector == "#message-rail": + return mock_message_rail elif selector == "#custom-footer": return mock_footer return Mock() @@ -343,14 +342,13 @@ class TestHostsManagerApp: app.update_status("Custom status message") - # Verify status bar was updated with custom message - mock_status_bar.update.assert_called_with("Custom status message") - mock_status_bar.remove_class.assert_called_with("hidden") + # Verify the reserved message rail was updated with the message. + mock_message_rail.update.assert_called_with("Custom status message") # Verify footer status was updated with current status (not the custom message) mock_footer.set_status.assert_called_once() footer_status = mock_footer.set_status.call_args[0][0] assert "2 entries" in footer_status - assert "Read-only" in footer_status + assert "READ-ONLY" in footer_status # Verify timer was set for auto-clearing app.set_timer.assert_called_once() @@ -382,12 +380,12 @@ class TestHostsManagerApp: patch("hosts.tui.app.Config", return_value=mock_config), ): app = HostsManagerApp() - app.action_show_help_panel = Mock() + app.push_screen = Mock() app.action_help() - # Should call the built-in help action - app.action_show_help_panel.assert_called_once() + # Help is a dedicated overlay, not a docked panel. + app.push_screen.assert_called_once() def test_action_config(self): """Test config action opens modal.""" diff --git a/tests/test_save_confirmation_modal.py b/tests/test_save_confirmation_modal.py index cd1bd52..b7cbefe 100644 --- a/tests/test_save_confirmation_modal.py +++ b/tests/test_save_confirmation_modal.py @@ -57,15 +57,15 @@ class TestSaveConfirmationModal: @patch.object(SaveConfirmationModal, "query_one") def test_on_mount_sets_focus(self, mock_query_one): - """Test that on_mount sets focus to the save button.""" + """Test that on_mount sets focus to the safe cancel button.""" modal = SaveConfirmationModal() - mock_save_button = Mock() - mock_query_one.return_value = mock_save_button + mock_cancel_button = Mock() + mock_query_one.return_value = mock_cancel_button modal.on_mount() - mock_query_one.assert_called_once_with("#save-button", Button) - mock_save_button.focus.assert_called_once() + mock_query_one.assert_called_once_with("#cancel-button", Button) + mock_cancel_button.focus.assert_called_once() class TestSaveConfirmationIntegration: