feat #14: Implement design guide #15

Merged
phg merged 6 commits from issue-14-tui-design-guide into main 2026-09-04 17:15:40 +00:00
19 changed files with 1507 additions and 723 deletions

View file

@ -13,6 +13,8 @@ is reachable in the current application.
privileged mode, sudo handling, system-file writes, or backups. privileged mode, sudo handling, system-file writes, or backups.
- Read [ADR 0002](docs/adr/0002-normalize-hosts-serialization.md) before changing - Read [ADR 0002](docs/adr/0002-normalize-hosts-serialization.md) before changing
parsing, serialization, comment placement, or formatting preservation. 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 ## Commands

View file

@ -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 feature. Do not advertise it in user-facing documentation until users can
actually invoke it. 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 ## Domain language and decisions
Read [CONTEXT.md](CONTEXT.md) before naming user-visible concepts. Use its 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. persistence, recovery, configuration, or troubleshooting change.
- Update [CONTEXT.md](CONTEXT.md) when the canonical domain language changes; - Update [CONTEXT.md](CONTEXT.md) when the canonical domain language changes;
keep it a glossary rather than an architecture guide or progress log. 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 - Update ADR 0002 and the README if serialization stops being normalized or
becomes byte-preserving. becomes byte-preserving.

404
docs/design-guide.md Normal file
View file

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

View file

@ -45,7 +45,17 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
) )
yield RadioButton("DNS Name Entry", id="dns-entry-radio") 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: with Vertical(classes="default-section", id="ip-section") as ip_address:
ip_address.border_title = "IP Address" ip_address.border_title = "IP Address"
yield Input( yield Input(
@ -55,7 +65,6 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
) )
yield Static("", id="ip-error", classes="validation-error") yield Static("", id="ip-error", classes="validation-error")
# DNS Name Section (initially hidden)
with Vertical( with Vertical(
classes="default-section hidden", id="dns-section" classes="default-section hidden", id="dns-section"
) as dns_name: ) as dns_name:
@ -67,16 +76,6 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
) )
yield Static("", id="dns-error", classes="validation-error") 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 # Comment Section
with Vertical(classes="default-section") as comment: with Vertical(classes="default-section") as comment:
comment.border_title = "Comment (optional)" comment.border_title = "Comment (optional)"
@ -99,22 +98,21 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
# Buttons # Buttons
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button( yield Button(
"Add Entry (CTRL+S)", "Cancel",
variant="primary",
id="add-button",
classes="default-button",
)
yield Button(
"Cancel (ESC)",
variant="default", variant="default",
id="cancel-button", id="cancel-button",
classes="default-button", classes="default-button",
) )
yield Button(
"Add Host Entry",
variant="primary",
id="add-button",
classes="default-button",
)
def on_mount(self) -> None: def on_mount(self) -> None:
"""Focus IP address input when modal opens.""" """Focus the entry type choice when the modal opens."""
ip_input = self.query_one("#entry-type-radio", RadioSet) self.query_one("#entry-type-radio", RadioSet).focus()
ip_input.focus()
def on_radio_set_changed(self, event: RadioSet.Changed) -> None: def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
"""Handle entry type radio button changes.""" """Handle entry type radio button changes."""
@ -135,9 +133,7 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
if isinstance(active_section, Vertical): if isinstance(active_section, Vertical):
active_section.border_title = "Activate Entry" active_section.border_title = "Activate Entry"
# Focus IP input self.query_one("#hostnames-input", Input).focus()
ip_input = self.query_one("#ip-address-input", Input)
ip_input.focus()
elif pressed_radio and pressed_radio.id == "dns-entry-radio": elif pressed_radio and pressed_radio.id == "dns-entry-radio":
# Show DNS section, hide IP section # Show DNS section, hide IP section
ip_section = self.query_one("#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)" "Activate Entry (DNS entries activate after resolution)"
) )
# Focus DNS input self.query_one("#hostnames-input", Input).focus()
dns_input = self.query_one("#dns-name-input", Input)
dns_input.focus()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses.""" """Handle button presses."""

View file

@ -31,8 +31,9 @@ from .config_modal import ConfigModal
from .add_entry_modal import AddEntryModal from .add_entry_modal import AddEntryModal
from .delete_confirmation_modal import DeleteConfirmationModal from .delete_confirmation_modal import DeleteConfirmationModal
from .filter_modal import FilterModal from .filter_modal import FilterModal
from .help_modal import HelpModal
from .custom_footer import CustomFooter from .custom_footer import CustomFooter
from .styles import HOSTS_MANAGER_CSS from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS from .keybindings import HOSTS_MANAGER_BINDINGS
from .table_handler import TableHandler from .table_handler import TableHandler
from .details_handler import DetailsHandler from .details_handler import DetailsHandler
@ -60,8 +61,6 @@ class HostsManagerApp(App):
CSS = HOSTS_MANAGER_CSS CSS = HOSTS_MANAGER_CSS
BINDINGS = HOSTS_MANAGER_BINDINGS BINDINGS = HOSTS_MANAGER_BINDINGS
help_visible = False
# Reactive attributes # Reactive attributes
hosts_file: reactive[HostsFile] = reactive(HostsFile()) hosts_file: reactive[HostsFile] = reactive(HostsFile())
selected_entry_index: reactive[int] = reactive(0) selected_entry_index: reactive[int] = reactive(0)
@ -74,6 +73,8 @@ class HostsManagerApp(App):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.title = "/etc/hosts Manager" self.title = "/etc/hosts Manager"
self.register_theme(HOSTS_DARK_THEME)
self.theme = "hosts-dark"
# Initialize core components # Initialize core components
self.parser = HostsParser() self.parser = HostsParser()
@ -99,166 +100,212 @@ class HostsManagerApp(App):
# State for edit mode # State for edit mode
self.original_entry_values = None self.original_entry_values = None
self._status_timer = None
self._viewport_too_small = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create child widgets for the app.""" """Create child widgets for the app."""
yield Header() yield Header()
yield CustomFooter(id="custom-footer") yield CustomFooter(id="custom-footer")
yield Static("", id="message-rail")
yield Static("", id="minimum-size", classes="hidden")
# Search bar above the panes with Vertical(id="workspace"):
with Horizontal(classes="search-container") as search_container: # Search remains visible above the master-detail workspace.
search_container.border_title = "Search" with Horizontal(classes="search-container") as search_container:
yield Input( search_container.border_title = "Search"
placeholder="Filter by hostname, IP address, or comment...", yield Input(
id="search-input", placeholder="Filter by hostname, IP address, or comment...",
classes="search-input", id="search-input",
) classes="search-input",
)
yield Static("", id="filter-summary")
with Horizontal(classes="hosts-container"): with Horizontal(classes="hosts-container"):
# Left pane - entries table # Left pane - entries table
with Vertical(classes="common-pane left-pane") as left_pane: with Vertical(classes="common-pane left-pane") as left_pane:
left_pane.border_title = "Host Entries" left_pane.border_title = "Host Entries"
yield DataTable(id="entries-table") yield DataTable(id="entries-table")
# Right pane - entry details or edit form # Right pane - entry details or edit form
with Vertical(classes="common-pane right-pane") as right_pane: with Vertical(classes="common-pane right-pane") as right_pane:
right_pane.border_title = "Entry Details" right_pane.border_title = "Entry Details"
# Details display form (disabled inputs) # Inspection values deliberately use text, not disabled inputs.
with Vertical(id="entry-details-display", classes="entry-form"): with Vertical(id="entry-details-display", classes="entry-form"):
with Vertical( yield Static(
classes="default-section section-no-top-margin" "No Host Entry selected.",
) as ip_address: id="details-empty-state",
ip_address.border_title = "IP Address" classes="empty-state",
yield Input(
placeholder="No entry selected",
id="details-ip-input",
disabled=True,
classes="default-input",
) )
with Vertical(
with Vertical(classes="default-section") as hostnames: id="details-content", classes="detail-rows hidden"
hostnames.border_title = "Hostnames (comma-separated)"
yield Input(
placeholder="No entry selected",
id="details-hostname-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as dns_name:
dns_name.border_title = "DNS Name"
yield Input(
placeholder="No DNS name",
id="details-dns-name-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as dns_status:
dns_status.border_title = "DNS Status"
yield Input(
placeholder="No DNS status",
id="details-dns-status-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as dns_resolved:
dns_resolved.border_title = "Last Resolved"
yield Input(
placeholder="Not resolved yet",
id="details-dns-resolved-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as comment:
comment.border_title = "Comment:"
yield Input(
placeholder="No entry selected",
id="details-comment-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as active:
active.border_title = "Active"
yield Checkbox(
"Active",
id="details-active-checkbox",
disabled=True,
classes="default-checkbox",
)
# Edit form (initially hidden)
with Vertical(id="entry-edit-form", classes="entry-form hidden"):
# Entry Type Selection
with Vertical(
classes="default-flex-section section-no-top-margin"
) as entry_type:
entry_type.border_title = "Entry Type"
with RadioSet(
id="edit-entry-type-radio", classes="default-radio-set"
): ):
yield RadioButton( with Horizontal(classes="detail-row"):
"IP Address Entry", value=True, id="edit-ip-entry-radio" yield Static("IP address", classes="detail-label")
) yield Static(
yield RadioButton( "", id="details-ip-input", classes="detail-value"
"DNS Name Entry", id="edit-dns-entry-radio" )
with Horizontal(classes="detail-row"):
yield Static("Hostnames", classes="detail-label")
yield Static(
"",
id="details-hostname-input",
classes="detail-value",
)
with Horizontal(classes="detail-row"):
yield Static("State", classes="detail-label")
yield Static(
"",
id="details-active-checkbox",
classes="detail-value",
)
with Vertical(id="details-dns-rows", classes="hidden"):
with Horizontal(classes="detail-row"):
yield Static("DNS name", classes="detail-label")
yield Static(
"",
id="details-dns-name-input",
classes="detail-value",
)
with Horizontal(classes="detail-row"):
yield Static("DNS status", classes="detail-label")
yield Static(
"",
id="details-dns-status-input",
classes="detail-value",
)
with Horizontal(classes="detail-row"):
yield Static(
"Last resolved", classes="detail-label"
)
yield Static(
"",
id="details-dns-resolved-input",
classes="detail-value",
)
with Horizontal(classes="detail-row"):
yield Static("Comment", classes="detail-label")
yield Static(
"",
id="details-comment-input",
classes="detail-value",
)
yield Static(
"",
id="details-default-notice",
classes="detail-note hidden",
) )
# IP Address Section # Edit form (initially hidden)
with Vertical( with Vertical(id="entry-edit-form", classes="entry-form hidden"):
classes="default-section", id="edit-ip-section" with Vertical(
) as ip_address: classes="default-flex-section section-no-top-margin"
ip_address.border_title = "IP Address" ) as entry_type:
yield Input( entry_type.border_title = "Entry Type"
placeholder="Enter IP address", with RadioSet(
id="ip-input", id="edit-entry-type-radio", classes="default-radio-set"
classes="default-input", ):
) yield RadioButton(
"IP Address Entry",
value=True,
id="edit-ip-entry-radio",
)
yield RadioButton(
"DNS Name Entry", id="edit-dns-entry-radio"
)
# DNS Name Section (initially hidden) with Vertical(classes="default-section") as hostnames:
with Vertical( hostnames.border_title = "Hostnames (comma-separated)"
classes="default-section hidden", id="edit-dns-section" yield Input(
) as dns_name: placeholder="Enter hostnames",
dns_name.border_title = "DNS Name (to resolve)" id="hostname-input",
yield Input( classes="default-input",
placeholder="e.g., example.com", )
id="dns-name-input",
classes="default-input",
)
with Vertical(classes="default-section") as hostnames: with Vertical(
hostnames.border_title = "Hostnames (comma-separated)" classes="default-section", id="edit-ip-section"
yield Input( ) as ip_address:
placeholder="Enter hostnames", ip_address.border_title = "IP Address"
id="hostname-input", yield Input(
classes="default-input", placeholder="Enter IP address",
) id="ip-input",
classes="default-input",
)
with Vertical(classes="default-section") as comment: with Vertical(
comment.border_title = "Comment:" classes="default-section hidden", id="edit-dns-section"
yield Input( ) as dns_name:
placeholder="Enter comment (optional)", dns_name.border_title = "DNS Name (to resolve)"
id="comment-input", yield Input(
classes="default-input", placeholder="e.g., example.com",
) id="dns-name-input",
classes="default-input",
)
with Vertical(classes="default-section") as active: with Vertical(classes="default-section") as comment:
active.border_title = "Active" comment.border_title = "Comment:"
yield Checkbox( yield Input(
"Active", id="active-checkbox", classes="default-checkbox" placeholder="Enter comment (optional)",
) id="comment-input",
classes="default-input",
)
# Status bar for error/temporary messages (overlay, doesn't affect layout) with Vertical(classes="default-section") as active:
yield Static("", id="status-bar", classes="status-bar hidden") active.border_title = "Active"
yield Checkbox(
"Active",
id="active-checkbox",
classes="default-checkbox",
)
def on_ready(self) -> None: def on_ready(self) -> None:
"""Called when the app is ready.""" """Called when the app is ready."""
self.load_hosts_file() self.load_hosts_file()
self._setup_footer() self._setup_footer()
self._update_viewport()
def on_resize(self) -> None:
"""Switch to a safe, explicit presentation below the supported viewport."""
self._update_viewport()
def _update_viewport(self) -> None:
try:
too_small = self.size.width < 100 or self.size.height < 30
self._viewport_too_small = too_small
workspace = self.query_one("#workspace")
minimum_size = self.query_one("#minimum-size", Static)
footer = self.query_one("#custom-footer", CustomFooter)
if too_small:
workspace.add_class("hidden")
minimum_size.update(
"Terminal too small\n\nhosts requires at least 100 columns × 30 rows\n"
f"Current size: {self.size.width} × {self.size.height}\n\nq Quit"
)
minimum_size.remove_class("hidden")
minimum_size.add_class("visible")
footer.add_class("hidden")
else:
workspace.remove_class("hidden")
minimum_size.add_class("hidden")
minimum_size.remove_class("visible")
footer.remove_class("hidden")
self.set_class(
self.size.width < 120 or self.size.height < 40, "compact-layout"
)
self._setup_footer()
except Exception:
# The widgets are not mounted during early application startup.
pass
def _allow_mutation_action(self) -> bool:
"""Keep every mutation unreachable while the workspace is replaced."""
if not self._viewport_too_small:
return True
self.update_status(
"Cannot mutate while the terminal is too small. Resize to at least 100 columns × 30 rows."
)
return False
def load_hosts_file(self) -> None: def load_hosts_file(self) -> None:
"""Load the hosts file and populate the table.""" """Load the hosts file and populate the table."""
@ -276,7 +323,7 @@ class HostsManagerApp(App):
self.table_handler.restore_cursor_position(previous_entry) self.table_handler.restore_cursor_position(previous_entry)
self.update_status() self.update_status()
except Exception as e: except Exception as e:
self.update_status(f"Error loading hosts file: {e}") self.update_status(f"Error loading hosts file: {e}")
def _setup_footer(self) -> None: def _setup_footer(self) -> None:
"""Setup the footer with initial content based on keybindings.""" """Setup the footer with initial content based on keybindings."""
@ -287,19 +334,45 @@ class HostsManagerApp(App):
footer.clear_left_items() footer.clear_left_items()
footer.clear_right_items() footer.clear_right_items()
# Process keybindings and add to appropriate sections # The footer is a contextual reminder, not a complete binding catalogue.
# The dedicated help overlay remains the reachable source of all keys.
visible_actions = {
"new_entry",
"edit_entry",
"toggle_edit_mode",
"show_filters",
"help",
"quit",
}
if self.size.width < 120 or self.size.height < 40:
visible_actions = {
"toggle_edit_mode",
"show_filters",
"help",
"quit",
}
for binding in self.BINDINGS: for binding in self.BINDINGS:
# Skip tuple-style bindings and only process Binding objects # Skip tuple-style bindings and only process Binding objects
if not isinstance(binding, Binding): if not isinstance(binding, Binding):
continue continue
# Only show bindings marked with show=True # Only show bindings marked with show=True
if binding.show: if binding.show and binding.action in visible_actions:
# Get the display key # Get the display key
key_display = getattr(binding, "key_display", None) or binding.key key_display = getattr(binding, "key_display", None) or binding.key
# Get the description descriptions = {
description = binding.description or binding.action "new_entry": "New",
"edit_entry": "Edit",
"toggle_edit_mode": "Mode",
"show_filters": "Filters",
"help": "Help",
"quit": "Quit",
}
description = descriptions.get(
binding.action, binding.description or binding.action
)
# Determine positioning from id attribute # Determine positioning from id attribute
binding_id = getattr(binding, "id", None) binding_id = getattr(binding, "id", None)
@ -320,44 +393,98 @@ class HostsManagerApp(App):
"""Update the footer status section.""" """Update the footer status section."""
try: try:
footer = self.query_one("#custom-footer", CustomFooter) footer = self.query_one("#custom-footer", CustomFooter)
mode = "Edit" if self.edit_mode else "Read-only" mode = "PRIVILEGED" if self.edit_mode else "READ-ONLY"
entry_count = len(self.hosts_file.entries) entry_count = len(self.hosts_file.entries)
active_count = len(self.hosts_file.get_active_entries()) active_count = len(self.hosts_file.get_active_entries())
filter_count = self._active_filter_count()
status = f"{entry_count} entries ({active_count} active) | {mode}" status = (
f"{entry_count} entries ({active_count} active) · "
f"Filters: {filter_count} · {mode}"
)
footer.set_status(status) footer.set_status(status)
self.query_one("#filter-summary", Static).update(
f"Filters: {filter_count}" if filter_count else ""
)
except Exception: except Exception:
pass # Footer not ready yet pass # Footer not ready yet
def _active_filter_count(self) -> int:
"""Count the active filter groups for the durable workspace summary."""
options = self.current_filter_options
return sum(
(
bool(options.search_term),
options.active_only
or options.inactive_only
or not (options.show_active and options.show_inactive),
options.dns_only
or options.ip_only
or not (options.show_dns_entries and options.show_ip_entries),
options.mismatch_only
or options.resolved_only
or not all(
(
options.show_resolved,
options.show_unresolved,
options.show_resolving,
options.show_failed,
options.show_mismatched,
)
),
)
)
def update_status(self, message: str = "") -> None: def update_status(self, message: str = "") -> None:
"""Update the header subtitle and status bar with status information.""" """Update the reserved message rail and durable footer state."""
if message: if message:
# Show temporary message in the status bar
try: try:
status_bar = self.query_one("#status-bar", Static) if self._status_timer is not None:
status_bar.update(message) self._status_timer.stop()
status_bar.remove_class("hidden") self._status_timer = None
rail = self.query_one("#message-rail", Static)
if message.startswith(""): normalized = (
# Auto-clear error message after 5 seconds message.lstrip("✓×!· ")
self.set_timer(5.0, lambda: self._clear_status_message()) .replace("", "×")
.replace("", "")
.replace("🔄", "!")
.replace("⚠️", "!")
)
normalized = normalized.replace("Edit mode", "Privileged Mode").replace(
"edit mode", "Privileged Mode"
)
rail.update(normalized)
rail.remove_class("message-error")
rail.remove_class("message-warning")
persistent = any(
word in normalized.lower()
for word in (
"error",
"failed",
"cannot",
"not granted",
"read-only",
)
)
if persistent:
rail.add_class("message-error")
elif normalized.startswith("!"):
rail.add_class("message-warning")
else: else:
# Auto-clear regular message after 3 seconds self._status_timer = self.set_timer(
self.set_timer(3.0, lambda: self._clear_status_message()) 3.0, lambda: self._clear_status_message()
)
except Exception: except Exception:
# Fallback if status bar not found (during initialization)
pass pass
# Always update the header subtitle with current status
# Update the footer status
self._update_footer_status() self._update_footer_status()
def _clear_status_message(self) -> None: def _clear_status_message(self) -> None:
"""Clear the temporary status message.""" """Clear the temporary status message."""
try: try:
status_bar = self.query_one("#status-bar", Static) rail = self.query_one("#message-rail", Static)
status_bar.update("") rail.update("")
status_bar.add_class("hidden") rail.remove_class("message-error")
rail.remove_class("message-warning")
self._status_timer = None
except Exception: except Exception:
pass pass
@ -370,6 +497,13 @@ class HostsManagerApp(App):
def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool: def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool:
"""Persist a mutation, restoring its complete pre-action state on failure.""" """Persist a mutation, restoring its complete pre-action state on failure."""
if not self._allow_mutation_action():
self.hosts_file = snapshot.manager_state.hosts_file
self.manager.undo_redo_history = snapshot.manager_state.undo_redo_history
self.selected_entry_index = snapshot.selected_entry_index
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
return False
save_success, save_message, hosts_file = self.manager.save_mutation( save_success, save_message, hosts_file = self.manager.save_mutation(
self.hosts_file, snapshot.manager_state self.hosts_file, snapshot.manager_state
) )
@ -496,13 +630,9 @@ class HostsManagerApp(App):
self.update_status("Hosts file reloaded") self.update_status("Hosts file reloaded")
def action_help(self) -> None: def action_help(self) -> None:
"""Show help panel.""" """Open the keyboard reference without reducing workspace width."""
if self.help_visible: if not self.screen_stack or not isinstance(self.screen, HelpModal):
self.action_hide_help_panel() self.push_screen(HelpModal())
self.help_visible = False
else:
self.action_show_help_panel()
self.help_visible = True
def action_config(self) -> None: def action_config(self) -> None:
"""Show configuration modal.""" """Show configuration modal."""
@ -517,6 +647,8 @@ class HostsManagerApp(App):
def action_show_filters(self) -> None: def action_show_filters(self) -> None:
"""Open the advanced filter controls and apply their returned options.""" """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: def handle_filter_result(filter_options: FilterOptions | None) -> None:
if filter_options is None: if filter_options is None:
@ -564,6 +696,8 @@ class HostsManagerApp(App):
def action_toggle_edit_mode(self) -> None: def action_toggle_edit_mode(self) -> None:
"""Toggle between read-only and edit mode.""" """Toggle between read-only and edit mode."""
if not self._allow_mutation_action():
return
if self.edit_mode: if self.edit_mode:
# Exit edit mode # Exit edit mode
success, message = self.manager.exit_edit_mode() success, message = self.manager.exit_edit_mode()
@ -626,6 +760,8 @@ class HostsManagerApp(App):
def action_edit_entry(self) -> None: def action_edit_entry(self) -> None:
"""Enter edit mode for the selected entry.""" """Enter edit mode for the selected entry."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
@ -665,6 +801,8 @@ class HostsManagerApp(App):
def action_exit_edit_entry(self) -> None: def action_exit_edit_entry(self) -> None:
"""Exit entry edit mode and return focus to the entries table.""" """Exit entry edit mode and return focus to the entries table."""
if not self._allow_mutation_action():
return
self.edit_handler.exit_edit_entry_with_confirmation() self.edit_handler.exit_edit_entry_with_confirmation()
def action_next_field(self) -> None: def action_next_field(self) -> None:
@ -677,22 +815,32 @@ class HostsManagerApp(App):
def action_toggle_entry(self) -> None: def action_toggle_entry(self) -> None:
"""Toggle the active state of the selected entry.""" """Toggle the active state of the selected entry."""
if not self._allow_mutation_action():
return
self.navigation_handler.toggle_entry() self.navigation_handler.toggle_entry()
def action_move_entry_up(self) -> None: def action_move_entry_up(self) -> None:
"""Move the selected entry up in the list.""" """Move the selected entry up in the list."""
if not self._allow_mutation_action():
return
self.navigation_handler.move_entry_up() self.navigation_handler.move_entry_up()
def action_move_entry_down(self) -> None: def action_move_entry_down(self) -> None:
"""Move the selected entry down in the list.""" """Move the selected entry down in the list."""
if not self._allow_mutation_action():
return
self.navigation_handler.move_entry_down() self.navigation_handler.move_entry_down()
def action_save_file(self) -> None: def action_save_file(self) -> None:
"""Save the hosts file to disk.""" """Save the hosts file to disk."""
if not self._allow_mutation_action():
return
self.navigation_handler.save_hosts_file() self.navigation_handler.save_hosts_file()
def action_add_entry(self) -> None: def action_add_entry(self) -> None:
"""Show the add entry modal.""" """Show the add entry modal."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
@ -736,6 +884,8 @@ class HostsManagerApp(App):
def action_delete_entry(self) -> None: def action_delete_entry(self) -> None:
"""Show the delete confirmation modal for the selected entry.""" """Show the delete confirmation modal for the selected entry."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot delete entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ Cannot delete entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
@ -792,6 +942,8 @@ class HostsManagerApp(App):
def action_undo(self) -> None: def action_undo(self) -> None:
"""Undo the last operation.""" """Undo the last operation."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status("❌ Cannot undo: Application is in read-only mode") self.update_status("❌ Cannot undo: Application is in read-only mode")
return return
@ -819,6 +971,8 @@ class HostsManagerApp(App):
def action_redo(self) -> None: def action_redo(self) -> None:
"""Redo the last undone operation.""" """Redo the last undone operation."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status("❌ Cannot redo: Application is in read-only mode") self.update_status("❌ Cannot redo: Application is in read-only mode")
return return
@ -846,6 +1000,8 @@ class HostsManagerApp(App):
def action_refresh_dns(self) -> None: def action_refresh_dns(self) -> None:
"""Manually refresh DNS resolution for all entries.""" """Manually refresh DNS resolution for all entries."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
@ -940,6 +1096,8 @@ class HostsManagerApp(App):
def action_update_single_dns(self) -> None: def action_update_single_dns(self) -> None:
"""Manually refresh DNS resolution for the currently selected entry.""" """Manually refresh DNS resolution for the currently selected entry."""
if not self._allow_mutation_action():
return
if not self.edit_mode: if not self.edit_mode:
self.update_status( self.update_status(
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode." "❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
@ -1134,6 +1292,10 @@ class HostsManagerApp(App):
"""Update the edit form with current entry values.""" """Update the edit form with current entry values."""
self.details_handler.update_edit_form() self.details_handler.update_edit_form()
def watch_edit_mode(self, edit_mode: bool) -> None:
"""Keep elevated capability explicit without changing the full palette."""
self.sub_title = "PRIVILEGED" if edit_mode else ""
def watch_entry_edit_mode(self, entry_edit_mode: bool) -> None: def watch_entry_edit_mode(self, entry_edit_mode: bool) -> None:
"""Update the right pane border title when entry edit mode changes.""" """Update the right pane border title when entry edit mode changes."""
try: try:

View file

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

View file

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

View file

@ -1,113 +1,113 @@
""" """Read-only Entry Details and editable Entry Editor coordination."""
Details pane management for the hosts TUI application.
This module handles the display and updating of entry details from textual.widgets import Checkbox, Input, Static
and edit forms in the right pane.
"""
from textual.widgets import Input, Checkbox
class DetailsHandler: class DetailsHandler:
"""Handles all details pane operations for the hosts manager.""" """Render selected Host Entry data without making inspection look editable."""
def __init__(self, app): def __init__(self, app):
"""Initialize the details handler with reference to the main app."""
self.app = app self.app = app
def update_entry_details(self) -> None: def update_entry_details(self) -> None:
"""Update the right pane with selected entry details."""
if self.app.entry_edit_mode: if self.app.entry_edit_mode:
self.update_edit_form() self.update_edit_form()
else: else:
self.update_details_display() self.update_details_display()
def _set_detail(self, detail_id: str, value: str) -> None:
self.app.query_one(f"#{detail_id}", Static).update(value)
def _show_empty_state(self, message: str) -> None:
self.app.query_one("#details-empty-state", Static).update(message)
self.app.query_one("#details-empty-state", Static).remove_class("hidden")
self.app.query_one("#details-content").add_class("hidden")
self.app.query_one("#details-dns-rows").add_class("hidden")
self.app.query_one("#details-default-notice").add_class("hidden")
def update_details_display(self) -> None: def update_details_display(self) -> None:
"""Update the details display using disabled Input widgets.""" """Show compact label-value rows for the currently selected Host Entry."""
details_display = self.app.query_one("#entry-details-display") details_display = self.app.query_one("#entry-details-display")
edit_form = self.app.query_one("#entry-edit-form") edit_form = self.app.query_one("#entry-edit-form")
# Show details display, hide edit form
details_display.remove_class("hidden") details_display.remove_class("hidden")
edit_form.add_class("hidden") edit_form.add_class("hidden")
# Get the input widgets
ip_input = self.app.query_one("#details-ip-input", Input)
hostname_input = self.app.query_one("#details-hostname-input", Input)
comment_input = self.app.query_one("#details-comment-input", Input)
active_checkbox = self.app.query_one("#details-active-checkbox", Checkbox)
if not self.app.hosts_file.entries: if not self.app.hosts_file.entries:
# Show empty message self._show_empty_state("No Host Entries are loaded.")
ip_input.value = ""
ip_input.placeholder = "No entries loaded"
hostname_input.value = ""
hostname_input.placeholder = "No entries loaded"
comment_input.value = ""
comment_input.placeholder = "No entries loaded"
active_checkbox.value = False
return return
# Get visible entries to check if we need to adjust selection
visible_entries = self.app.table_handler.get_visible_entries() visible_entries = self.app.table_handler.get_visible_entries()
if not visible_entries: if not visible_entries:
ip_input.value = "" self._show_empty_state(
ip_input.placeholder = "No visible entries" "No Host Entries match the current filters. Press Ctrl+F to change them."
hostname_input.value = "" )
hostname_input.placeholder = "No visible entries"
comment_input.value = ""
comment_input.placeholder = "No visible entries"
active_checkbox.value = False
return return
# If default entries are hidden and selected_entry_index points to a hidden entry,
# we need to find the corresponding visible entry
show_defaults = self.app.config.should_show_default_entries()
if not show_defaults:
# Check if the currently selected entry is a default entry (hidden)
if (
self.app.selected_entry_index < len(self.app.hosts_file.entries)
and self.app.hosts_file.entries[
self.app.selected_entry_index
].is_default_entry()
):
# The selected entry is hidden, so we should show the first visible entry instead
if visible_entries:
# Find the first visible entry in the hosts file
for i, entry in enumerate(self.app.hosts_file.entries):
if not entry.is_default_entry():
self.app.selected_entry_index = i
break
if self.app.selected_entry_index >= len(self.app.hosts_file.entries): if self.app.selected_entry_index >= len(self.app.hosts_file.entries):
self.app.selected_entry_index = 0 self.app.selected_entry_index = 0
entry = self.app.hosts_file.entries[self.app.selected_entry_index] entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Update the input widgets with entry data self.app.query_one("#details-empty-state", Static).add_class("hidden")
ip_input.value = entry.ip_address self.app.query_one("#details-content").remove_class("hidden")
ip_input.placeholder = "" self._set_detail("details-ip-input", entry.ip_address)
hostname_input.value = ", ".join(entry.hostnames) self._set_detail("details-hostname-input", ", ".join(entry.hostnames))
hostname_input.placeholder = "" self._set_detail("details-comment-input", entry.comment or "")
comment_input.value = entry.comment or "" self._set_detail(
comment_input.placeholder = "No comment" "details-active-checkbox", "✓ Active" if entry.is_active else "· Inactive"
active_checkbox.value = entry.is_active )
# For default entries, show warning in placeholder text default_notice = self.app.query_one("#details-default-notice", Static)
if entry.is_default_entry(): if entry.is_default_entry():
ip_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" entry_state = "✓ Active" if entry.is_active else "· Inactive"
hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" self._set_detail(
comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" "details-active-checkbox", f"■ Default (protected) · {entry_state}"
)
default_notice.update(
"Protected Default Entry. This operating-system mapping cannot be changed."
)
default_notice.remove_class("hidden")
else:
default_notice.add_class("hidden")
# Update DNS information if present dns_rows = self.app.query_one("#details-dns-rows")
self._update_dns_information(entry) if entry.has_dns_name():
dns_rows.remove_class("hidden")
self._set_detail("details-dns-name-input", entry.dns_name or "")
self._set_detail("details-dns-status-input", self._dns_status_text(entry))
self._set_detail(
"details-dns-resolved-input",
entry.last_resolved.strftime("%Y-%m-%d %H:%M:%S")
if entry.last_resolved
else "Not resolved yet",
)
else:
dns_rows.add_class("hidden")
@staticmethod
def _dns_status_text(entry) -> str:
labels = {
"not_resolved": "· Not resolved",
"resolving": "! Resolving…",
"resolved": "✓ Resolved",
"failed": "× Resolution failed",
"match": "✓ IP matches DNS",
"mismatch": "! IP differs from DNS",
}
status = labels.get(entry.dns_resolution_status, entry.dns_resolution_status)
if not status:
return "· Not resolved"
if entry.resolved_ip and entry.dns_resolution_status in {
"resolved",
"match",
"mismatch",
}:
return f"{status} ({entry.resolved_ip})"
return status
def update_edit_form(self) -> None: def update_edit_form(self) -> None:
"""Update the edit form with current entry values.""" """Populate the separate editable Entry Editor form."""
details_display = self.app.query_one("#entry-details-display") details_display = self.app.query_one("#entry-details-display")
edit_form = self.app.query_one("#entry-edit-form") edit_form = self.app.query_one("#entry-edit-form")
# Hide details display, show edit form
details_display.add_class("hidden") details_display.add_class("hidden")
edit_form.remove_class("hidden") edit_form.remove_class("hidden")
@ -117,80 +117,8 @@ class DetailsHandler:
return return
entry = self.app.hosts_file.entries[self.app.selected_entry_index] entry = self.app.hosts_file.entries[self.app.selected_entry_index]
self.app.query_one("#ip-input", Input).value = entry.ip_address
# Update form fields with current entry values self.app.query_one("#hostname-input", Input).value = ", ".join(entry.hostnames)
ip_input = self.app.query_one("#ip-input", Input) self.app.query_one("#comment-input", Input).value = entry.comment or ""
hostname_input = self.app.query_one("#hostname-input", Input) self.app.query_one("#active-checkbox", Checkbox).value = entry.is_active
comment_input = self.app.query_one("#comment-input", Input)
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
ip_input.value = entry.ip_address
hostname_input.value = ", ".join(entry.hostnames)
comment_input.value = entry.comment or ""
active_checkbox.value = entry.is_active
# Initialize radio button state and field visibility
self.app.edit_handler.populate_edit_form_with_type_detection() self.app.edit_handler.populate_edit_form_with_type_detection()
def _update_dns_information(self, entry) -> None:
"""Update DNS information display for the selected entry."""
try:
# Get the three separate DNS input fields
dns_name_input = self.app.query_one("#details-dns-name-input", Input)
dns_status_input = self.app.query_one("#details-dns-status-input", Input)
dns_resolved_input = self.app.query_one(
"#details-dns-resolved-input", Input
)
if not entry.has_dns_name():
# Clear all DNS fields if no DNS information
dns_name_input.value = ""
dns_name_input.placeholder = "No DNS name"
dns_status_input.value = ""
dns_status_input.placeholder = "No DNS status"
dns_resolved_input.value = ""
dns_resolved_input.placeholder = "Not resolved yet"
return
# Update DNS Name field
dns_name_input.value = entry.dns_name or ""
dns_name_input.placeholder = "" if entry.dns_name else "No DNS name"
# Update DNS Status field
if entry.dns_resolution_status:
status_text = {
"not_resolved": "Not resolved",
"resolving": "Resolving...",
"resolved": "Resolved",
"failed": "Resolution failed",
"match": "IP matches DNS",
"mismatch": "IP differs from DNS",
}.get(entry.dns_resolution_status, entry.dns_resolution_status)
# Add resolved IP to status if available
if entry.resolved_ip and entry.dns_resolution_status in [
"resolved",
"match",
"mismatch",
]:
status_text += f" ({entry.resolved_ip})"
dns_status_input.value = status_text
dns_status_input.placeholder = ""
else:
dns_status_input.value = ""
dns_status_input.placeholder = "No DNS status"
# Update Last Resolved field
if entry.last_resolved:
time_str = entry.last_resolved.strftime("%H:%M:%S")
date_str = entry.last_resolved.strftime("%Y-%m-%d")
dns_resolved_input.value = f"{date_str} {time_str}"
dns_resolved_input.placeholder = ""
else:
dns_resolved_input.value = ""
dns_resolved_input.placeholder = "Not resolved yet"
except Exception:
# DNS widgets not present yet, silently ignore
pass

View file

@ -425,9 +425,9 @@ class EditHandler:
active_checkbox = self.app.query_one("#active-checkbox", Checkbox) active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
# Build field list based on current entry type # 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: try:
ip_section = self.app.query_one("#edit-ip-section") ip_section = self.app.query_one("#edit-ip-section")
if not ip_section.has_class("hidden"): if not ip_section.has_class("hidden"):
@ -444,8 +444,7 @@ class EditHandler:
except Exception: except Exception:
pass pass
# Add remaining fields fields.extend([comment_input, active_checkbox])
fields.extend([hostname_input, comment_input, active_checkbox])
# Find currently focused field and move to next # Find currently focused field and move to next
for i, field in enumerate(fields): for i, field in enumerate(fields):
@ -471,9 +470,9 @@ class EditHandler:
active_checkbox = self.app.query_one("#active-checkbox", Checkbox) active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
# Build field list based on current entry type # 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: try:
ip_section = self.app.query_one("#edit-ip-section") ip_section = self.app.query_one("#edit-ip-section")
if not ip_section.has_class("hidden"): if not ip_section.has_class("hidden"):
@ -490,8 +489,7 @@ class EditHandler:
except Exception: except Exception:
pass pass
# Add remaining fields fields.extend([comment_input, active_checkbox])
fields.extend([hostname_input, comment_input, active_checkbox])
# Find currently focused field and move to previous # Find currently focused field and move to previous
for i, field in enumerate(fields): for i, field in enumerate(fields):

View file

@ -6,17 +6,15 @@ filtering options including status, type, resolution status, and search filterin
""" """
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Grid, Horizontal, Container from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import ( from textual.widgets import (
Static, Static,
Button, Button,
Checkbox, Checkbox,
Input, Input,
Select, Select,
Label,
RadioSet, RadioSet,
RadioButton, RadioButton,
Collapsible,
) )
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual import on from textual import on
@ -24,131 +22,19 @@ from textual.binding import Binding
from typing import Optional, Dict, List from typing import Optional, Dict, List
from ..core.filters import FilterOptions, EntryFilter from ..core.filters import FilterOptions, EntryFilter
from .styles import FILTER_MODAL_CSS
class FilterModal(ModalScreen[Optional[FilterOptions]]): class FilterModal(ModalScreen[Optional[FilterOptions]]):
"""Advanced filtering configuration modal.""" """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 = """ CSS = FILTER_MODAL_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;
}
"""
def __init__( def __init__(
self, self,
@ -171,133 +57,140 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Compose the filter modal interface.""" """Compose the filter modal interface."""
with Grid(id="filter-dialog"): with VerticalScroll(classes="filter-container"):
yield Static("Advanced Filtering", id="filter-header") yield Static("Advanced Filtering", classes="filter-title")
yield Static("", id="count-display", classes="filter-count")
with Container(id="filter-content"): with Vertical(classes="default-flex-section") as presets:
# Filter presets section presets.border_title = "Presets"
with Collapsible(title="Filter Presets", collapsed=False): with Horizontal(classes="filter-preset-row"):
with Container(classes="filter-section"): yield self._create_preset_select()
with Horizontal(classes="preset-row"): yield Button(
yield Label("Preset:", classes="filter-input-label") "Load", id="load-preset", variant="primary", compact=True
yield self._create_preset_select() )
yield Button("Load", id="load-preset", variant="primary") yield Button("Save", id="save-preset", compact=True)
yield Button("Save", id="save-preset") yield Button(
yield Button("Delete", id="delete-preset", variant="error") "Delete",
id="delete-preset",
variant="error",
compact=True,
)
# Status filtering section with Vertical(classes="default-flex-section") as status:
with Collapsible(title="Status Filtering", collapsed=False): status.border_title = "Entry status"
with Container(classes="filter-section"): with RadioSet(id="status-filter-type", classes="default-radio-set"):
yield Static("Status Filtering", classes="filter-section-title") yield RadioButton("Show all", id="status-all")
with RadioSet(id="status-filter-type"): yield RadioButton("Active only", id="status-active")
yield RadioButton("Show All", id="status-all") yield RadioButton("Inactive only", id="status-inactive")
yield RadioButton("Active Only", id="status-active") yield RadioButton("Custom", id="status-custom")
yield RadioButton("Inactive Only", id="status-inactive") with Vertical(
yield RadioButton("Custom", id="status-custom") 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( with Vertical(classes="default-flex-section") as entry_type:
classes="filter-checkboxes", id="status-custom-options" entry_type.border_title = "Entry type"
): with RadioSet(id="type-filter-type", classes="default-radio-set"):
yield Checkbox( yield RadioButton("Show all", id="type-all")
"Show Active Entries", value=True, id="show-active" yield RadioButton("DNS entries only", id="type-dns")
) yield RadioButton("IP entries only", id="type-ip")
yield Checkbox( yield RadioButton("Custom", id="type-custom")
"Show Inactive Entries", value=True, id="show-inactive" 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 Vertical(classes="default-flex-section") as resolution:
with Collapsible(title="Entry Type Filtering", collapsed=False): resolution.border_title = "DNS resolution"
with Container(classes="filter-section"): with RadioSet(id="resolution-filter-type", classes="default-radio-set"):
yield Static( yield RadioButton("Show all", id="resolution-all")
"Entry Type Filtering", classes="filter-section-title" yield RadioButton("Resolved only", id="resolution-resolved")
) yield RadioButton("Mismatches only", id="resolution-mismatch")
with RadioSet(id="type-filter-type"): yield RadioButton("Custom", id="resolution-custom")
yield RadioButton("Show All", id="type-all") with Vertical(
yield RadioButton("DNS Entries Only", id="type-dns") classes="filter-custom-options", id="resolution-custom-options"
yield RadioButton("IP Entries Only", id="type-ip") ):
yield RadioButton("Custom", id="type-custom") 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( with Vertical(classes="default-flex-section") as search:
classes="filter-checkboxes", id="type-custom-options" search.border_title = "Search"
): yield Input(
yield Checkbox( placeholder="Hostname, IP address, or comment",
"Show DNS Entries", value=True, id="show-dns" value=self.current_options.search_term or "",
) id="search-term",
yield Checkbox("Show IP Entries", value=True, id="show-ip") 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 Horizontal(classes="filter-actions"):
with Collapsible(title="Resolution Status Filtering", collapsed=False): yield Button("Cancel", id="cancel", classes="default-button")
with Container(classes="filter-section"): yield Button("Reset", id="reset", classes="default-button")
yield Static( yield Button(
"Resolution Status Filtering", "Apply", id="apply", variant="primary", classes="default-button"
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")
def _create_preset_select(self) -> Select: def _create_preset_select(self) -> Select:
"""Create the preset picker without selecting a preset by default.""" """Create the preset picker without selecting a preset by default."""
@ -307,18 +200,90 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
preset_options, preset_options,
value=self.current_options.preset_name, value=self.current_options.preset_name,
id="preset-select", id="preset-select",
classes="preset-select", classes="filter-preset-select",
compact=True,
) )
return Select( return Select(
preset_options, preset_options,
id="preset-select", id="preset-select",
classes="preset-select", classes="filter-preset-select",
compact=True,
) )
def on_mount(self) -> None: def on_mount(self) -> None:
"""Initialize the modal with current options.""" """Initialize the modal with current options."""
self._update_ui_from_options() self._update_ui_from_options()
self._update_count_display() 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: def _update_ui_from_options(self) -> None:
"""Update UI controls to reflect current options.""" """Update UI controls to reflect current options."""

View file

@ -0,0 +1,66 @@
"""Dedicated keyboard reference overlay."""
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Static
class HelpModal(ModalScreen[None]):
"""Show the complete binding reference without shrinking the workspace."""
BINDINGS = [
Binding("escape", "close", "Close"),
Binding("question_mark", "close", "Close"),
]
CSS = """
HelpModal { align: center middle; }
#help-container {
width: 72;
height: auto;
max-height: 90%;
background: $surface;
border: thick $primary;
padding: 1 2;
}
.help-title { text-style: bold; color: $primary; text-align: center; }
.help-section { margin-top: 1; text-style: bold; }
.help-copy { color: $text-muted; }
#help-close { margin-top: 1; width: 12; }
"""
def compose(self) -> ComposeResult:
with Vertical(id="help-container"):
yield Static("Keyboard help", classes="help-title")
yield Static("General", classes="help-section")
yield Static(
"q Quit ? Close help c Configuration", classes="help-copy"
)
yield Static("Navigation", classes="help-section")
yield Static(
"↑/↓ Select Host Entry i Sort IP h Sort hostname",
classes="help-copy",
)
yield Static("Filtering", classes="help-section")
yield Static(
"Type in Search for immediate filtering Ctrl+F Advanced filters",
classes="help-copy",
)
yield Static("Privileged Mode", classes="help-section")
yield Static(
"Ctrl+E Enter or leave Privileged Mode n New e Edit d Delete",
classes="help-copy",
)
yield Button("Close", id="help-close", variant="primary")
def on_mount(self) -> None:
self.query_one("#help-close", Button).focus()
def action_close(self) -> None:
self.dismiss(None)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "help-close":
self.action_close()

View file

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

View file

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

View file

@ -5,6 +5,24 @@ This module contains all CSS definitions for consistent styling
across the application. across the application.
""" """
from textual.theme import Theme
HOSTS_DARK_THEME = Theme(
name="hosts-dark",
primary="#0178D4",
secondary="#004578",
warning="#FFA62B",
error="#BA3C5B",
success="#4EBF71",
accent="#FFA62B",
foreground="#E0E0E0",
background="#121212",
surface="#1E1E1E",
panel="#242F38",
dark=True,
)
# Common CSS classes shared across components # Common CSS classes shared across components
COMMON_CSS = """ COMMON_CSS = """
.default-button { .default-button {
@ -71,17 +89,66 @@ HOSTS_MANAGER_CSS = (
margin-bottom: 0; margin-bottom: 0;
} }
#message-rail {
height: 1;
background: $panel;
color: $text;
padding: 0 1;
}
#message-rail.message-error {
color: $error;
}
#message-rail.message-warning {
color: $warning;
}
#minimum-size {
display: none;
height: 1fr;
content-align: center middle;
text-align: center;
padding: 1 2;
border: round $warning;
color: $text;
}
#workspace.hidden,
#minimum-size.hidden,
.detail-rows.hidden,
#details-dns-rows.hidden,
#details-default-notice.hidden {
display: none;
}
#minimum-size.visible {
display: block;
}
.search-input { .search-input {
height: 1fr; height: 1fr;
width: 1fr; width: 1fr;
border: none; border: none;
} }
#filter-summary {
width: auto;
height: 1;
padding: 0 1;
content-align: right middle;
color: $text-muted;
}
.hosts-container { .hosts-container {
height: 1fr; height: 1fr;
margin-top: 0; margin-top: 0;
} }
.compact-layout .entry-form {
padding: 0 1;
}
.common-pane { .common-pane {
border: round $primary; border: round $primary;
margin: 0; margin: 0;
@ -127,24 +194,35 @@ HOSTS_MANAGER_CSS = (
display: none; display: none;
} }
.status-bar {
height: 1;
width: 100%;
background: $error;
color: $text;
content-align: center middle;
layer: overlay;
dock: top;
offset-y: 1;
}
.status-bar.hidden {
display: none;
}
.entry-form { .entry-form {
height: 1fr;
padding: 1 2;
}
.detail-row {
height: 1;
}
.detail-label {
width: 14;
color: $text-muted;
}
.detail-value {
width: 1fr;
color: $text;
}
.detail-note {
height: auto; height: auto;
padding: 1; color: $warning;
margin-top: 1;
}
.empty-state {
height: 1fr;
content-align: center middle;
color: $text-muted;
} }
Header { Header {
@ -157,7 +235,7 @@ Header.-tall {
/* Custom Footer Styling */ /* Custom Footer Styling */
CustomFooter { CustomFooter {
background: $surface; background: $panel;
color: $text; color: $text;
dock: bottom; dock: bottom;
height: 1; height: 1;
@ -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
SAVE_CONFIRMATION_MODAL_CSS = ( SAVE_CONFIRMATION_MODAL_CSS = (
COMMON_CSS COMMON_CSS

View file

@ -168,8 +168,8 @@ class TableHandler:
arrow = "" if self.app.sort_ascending else "" arrow = "" if self.app.sort_ascending else ""
hostname_label = f"{arrow} Canonical Hostname" hostname_label = f"{arrow} Canonical Hostname"
# Add columns with proper labels (Active, IP, Hostname, DNS) # Keep the canonical hostname beside state for fast table scanning.
table.add_columns(active_label, ip_label, hostname_label, dns_label) table.add_columns(active_label, hostname_label, ip_label, dns_label)
# Get visible entries (after filtering) # Get visible entries (after filtering)
visible_entries = self.get_visible_entries() visible_entries = self.get_visible_entries()
@ -185,25 +185,21 @@ class TableHandler:
# Get DNS status indicator # Get DNS status indicator
dns_text = self._get_dns_status_indicator(entry) dns_text = self._get_dns_status_indicator(entry)
# Add row with styling based on active status and default entry status # Markers and wording keep each state understandable without colour.
if is_default: if is_default:
# Default entries are always shown in dim grey regardless of active status entry_state = "✓ Active" if entry.is_active else "· Inactive"
active_text = Text("" if entry.is_active else "", style="dim white") active_text = Text(f"■ Default · {entry_state}", style="dim")
ip_text = Text(entry.ip_address, style="dim white") ip_text = Text(entry.ip_address, style="dim")
hostname_text = Text(canonical_hostname, style="dim white") hostname_text = Text(canonical_hostname, style="dim")
table.add_row(active_text, ip_text, hostname_text, dns_text)
elif entry.is_active: elif entry.is_active:
# Active entries in green with checkmark active_text = Text("✓ Active")
active_text = Text("", style="bold green") ip_text = entry.ip_address
ip_text = Text(entry.ip_address, style="bold green") hostname_text = canonical_hostname
hostname_text = Text(canonical_hostname, style="bold green")
table.add_row(active_text, ip_text, hostname_text, dns_text)
else: else:
# Inactive entries in dim yellow with italic (no checkmark) active_text = Text("· Inactive", style="dim")
active_text = Text("", style="dim yellow italic") ip_text = Text(entry.ip_address, style="dim")
ip_text = Text(entry.ip_address, style="dim yellow italic") hostname_text = Text(canonical_hostname, style="dim")
hostname_text = Text(canonical_hostname, style="dim yellow italic") table.add_row(active_text, hostname_text, ip_text, dns_text)
table.add_row(active_text, ip_text, hostname_text, dns_text)
def restore_cursor_position(self, previous_entry) -> None: def restore_cursor_position(self, previous_entry) -> None:
"""Restore cursor position after reload, maintaining selection if possible.""" """Restore cursor position after reload, maintaining selection if possible."""
@ -265,7 +261,7 @@ class TableHandler:
"""Get DNS name and status indicator for an entry.""" """Get DNS name and status indicator for an entry."""
# If entry has no DNS name configured, show empty # If entry has no DNS name configured, show empty
if not entry.has_dns_name(): if not entry.has_dns_name():
return Text("", style="dim white") return Text("")
# Start with the DNS name # Start with the DNS name
dns_display = entry.dns_name dns_display = entry.dns_name
@ -274,28 +270,21 @@ class TableHandler:
dns_status = entry.dns_resolution_status or "not_resolved" dns_status = entry.dns_resolution_status or "not_resolved"
if dns_status == "not_resolved": if dns_status == "not_resolved":
status_icon = "" status_icon = "· Pending"
style = "dim yellow"
elif dns_status == "resolving": elif dns_status == "resolving":
status_icon = "🔄" status_icon = "! Resolving"
style = "yellow"
elif dns_status == "resolved": elif dns_status == "resolved":
status_icon = "" status_icon = "✓ Resolved"
style = "green"
elif dns_status == "match": elif dns_status == "match":
status_icon = "" status_icon = "✓ Matches"
style = "bold green"
elif dns_status == "mismatch": elif dns_status == "mismatch":
status_icon = "⚠️" status_icon = "! Mismatch"
style = "red"
elif dns_status == "failed": elif dns_status == "failed":
status_icon = "" status_icon = "× Failed"
style = "red"
else: else:
status_icon = "" status_icon = ""
style = "dim white"
return Text(f"{status_icon} {dns_display}", style=style) return Text(f"{status_icon} {dns_display}".strip())
def sort_entries_by_hostname(self) -> None: def sort_entries_by_hostname(self) -> None:
"""Sort entries by canonical hostname.""" """Sort entries by canonical hostname."""

View file

@ -193,15 +193,15 @@ class TestAddEntryModalRadioButtonLogic:
# Mock the query_one method for sections and inputs # Mock the query_one method for sections and inputs
mock_ip_section = Mock() mock_ip_section = Mock()
mock_dns_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): def mock_query_one(selector, widget_type=None):
if selector == "#ip-section": if selector == "#ip-section":
return mock_ip_section return mock_ip_section
elif selector == "#dns-section": elif selector == "#dns-section":
return mock_dns_section return mock_dns_section
elif selector == "#ip-address-input": elif selector == "#hostnames-input":
return mock_ip_input return mock_hostname_input
return Mock() return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one) 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 # Verify IP section is shown and DNS section is hidden
mock_ip_section.remove_class.assert_called_with("hidden") mock_ip_section.remove_class.assert_called_with("hidden")
mock_dns_section.add_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): def test_radio_button_change_to_dns_entry(self):
"""Test radio button change to DNS entry mode.""" """Test radio button change to DNS entry mode."""
# Mock the query_one method for sections and inputs # Mock the query_one method for sections and inputs
mock_ip_section = Mock() mock_ip_section = Mock()
mock_dns_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): def mock_query_one(selector, widget_type=None):
if selector == "#ip-section": if selector == "#ip-section":
return mock_ip_section return mock_ip_section
elif selector == "#dns-section": elif selector == "#dns-section":
return mock_dns_section return mock_dns_section
elif selector == "#dns-name-input": elif selector == "#hostnames-input":
return mock_dns_input return mock_hostname_input
return Mock() return Mock()
self.modal.query_one = Mock(side_effect=mock_query_one) 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 # Verify DNS section is shown and IP section is hidden
mock_ip_section.add_class.assert_called_with("hidden") mock_ip_section.add_class.assert_called_with("hidden")
mock_dns_section.remove_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: class TestAddEntryModalSaveLogic:

View file

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

View file

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

View file

@ -57,15 +57,15 @@ class TestSaveConfirmationModal:
@patch.object(SaveConfirmationModal, "query_one") @patch.object(SaveConfirmationModal, "query_one")
def test_on_mount_sets_focus(self, mock_query_one): def test_on_mount_sets_focus(self, mock_query_one):
"""Test that on_mount sets focus to the save button.""" """Test that on_mount sets focus to the safe cancel button."""
modal = SaveConfirmationModal() modal = SaveConfirmationModal()
mock_save_button = Mock() mock_cancel_button = Mock()
mock_query_one.return_value = mock_save_button mock_query_one.return_value = mock_cancel_button
modal.on_mount() modal.on_mount()
mock_query_one.assert_called_once_with("#save-button", Button) mock_query_one.assert_called_once_with("#cancel-button", Button)
mock_save_button.focus.assert_called_once() mock_cancel_button.focus.assert_called_once()
class TestSaveConfirmationIntegration: class TestSaveConfirmationIntegration: