Reviewed-on: #15 Co-authored-by: phg <mail@philip-henning.com> Co-committed-by: phg <mail@philip-henning.com>
This commit is contained in:
parent
9f8e9c3415
commit
0b7b52521f
19 changed files with 1507 additions and 723 deletions
|
|
@ -13,6 +13,8 @@ is reachable in the current application.
|
|||
privileged mode, sudo handling, system-file writes, or backups.
|
||||
- Read [ADR 0002](docs/adr/0002-normalize-hosts-serialization.md) before changing
|
||||
parsing, serialization, comment placement, or formatting preservation.
|
||||
- Read the [TUI design guide](docs/design-guide.md) before changing layouts,
|
||||
components, interaction states, user-visible copy, or terminal-size behavior.
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ Core code that is not reachable through the TUI is not yet a supported product
|
|||
feature. Do not advertise it in user-facing documentation until users can
|
||||
actually invoke it.
|
||||
|
||||
Read the [TUI design guide](docs/design-guide.md) before changing layouts,
|
||||
components, interaction states, user-visible copy, or terminal-size behavior.
|
||||
|
||||
## Domain language and decisions
|
||||
|
||||
Read [CONTEXT.md](CONTEXT.md) before naming user-visible concepts. Use its
|
||||
|
|
@ -111,6 +114,8 @@ Documentation is part of the behavior contract:
|
|||
persistence, recovery, configuration, or troubleshooting change.
|
||||
- Update [CONTEXT.md](CONTEXT.md) when the canonical domain language changes;
|
||||
keep it a glossary rather than an architecture guide or progress log.
|
||||
- Update the [TUI design guide](docs/design-guide.md) when an intentional UI
|
||||
pattern should become the new project-wide standard.
|
||||
- Update ADR 0002 and the README if serialization stops being normalized or
|
||||
becomes byte-preserving.
|
||||
|
||||
|
|
|
|||
404
docs/design-guide.md
Normal file
404
docs/design-guide.md
Normal 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.
|
||||
|
||||
|
|
@ -45,7 +45,17 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
|||
)
|
||||
yield RadioButton("DNS Name Entry", id="dns-entry-radio")
|
||||
|
||||
# IP Address Section
|
||||
# Hostnames Section
|
||||
with Vertical(classes="default-section") as hostnames:
|
||||
hostnames.border_title = "Hostnames"
|
||||
yield Input(
|
||||
placeholder="e.g., example.com, www.example.com",
|
||||
id="hostnames-input",
|
||||
classes="default-input",
|
||||
)
|
||||
yield Static("", id="hostnames-error", classes="validation-error")
|
||||
|
||||
# Address sections follow Hostnames in both visual and Tab order.
|
||||
with Vertical(classes="default-section", id="ip-section") as ip_address:
|
||||
ip_address.border_title = "IP Address"
|
||||
yield Input(
|
||||
|
|
@ -55,7 +65,6 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
|||
)
|
||||
yield Static("", id="ip-error", classes="validation-error")
|
||||
|
||||
# DNS Name Section (initially hidden)
|
||||
with Vertical(
|
||||
classes="default-section hidden", id="dns-section"
|
||||
) as dns_name:
|
||||
|
|
@ -67,16 +76,6 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
|||
)
|
||||
yield Static("", id="dns-error", classes="validation-error")
|
||||
|
||||
# Hostnames Section
|
||||
with Vertical(classes="default-section") as hostnames:
|
||||
hostnames.border_title = "Hostnames"
|
||||
yield Input(
|
||||
placeholder="e.g., example.com, www.example.com",
|
||||
id="hostnames-input",
|
||||
classes="default-input",
|
||||
)
|
||||
yield Static("", id="hostnames-error", classes="validation-error")
|
||||
|
||||
# Comment Section
|
||||
with Vertical(classes="default-section") as comment:
|
||||
comment.border_title = "Comment (optional)"
|
||||
|
|
@ -99,22 +98,21 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
|||
# Buttons
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button(
|
||||
"Add Entry (CTRL+S)",
|
||||
variant="primary",
|
||||
id="add-button",
|
||||
classes="default-button",
|
||||
)
|
||||
yield Button(
|
||||
"Cancel (ESC)",
|
||||
"Cancel",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="default-button",
|
||||
)
|
||||
yield Button(
|
||||
"Add Host Entry",
|
||||
variant="primary",
|
||||
id="add-button",
|
||||
classes="default-button",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Focus IP address input when modal opens."""
|
||||
ip_input = self.query_one("#entry-type-radio", RadioSet)
|
||||
ip_input.focus()
|
||||
"""Focus the entry type choice when the modal opens."""
|
||||
self.query_one("#entry-type-radio", RadioSet).focus()
|
||||
|
||||
def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
|
||||
"""Handle entry type radio button changes."""
|
||||
|
|
@ -135,9 +133,7 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
|||
if isinstance(active_section, Vertical):
|
||||
active_section.border_title = "Activate Entry"
|
||||
|
||||
# Focus IP input
|
||||
ip_input = self.query_one("#ip-address-input", Input)
|
||||
ip_input.focus()
|
||||
self.query_one("#hostnames-input", Input).focus()
|
||||
elif pressed_radio and pressed_radio.id == "dns-entry-radio":
|
||||
# Show DNS section, hide IP section
|
||||
ip_section = self.query_one("#ip-section")
|
||||
|
|
@ -155,9 +151,7 @@ class AddEntryModal(ModalScreen[HostEntry | None]):
|
|||
"Activate Entry (DNS entries activate after resolution)"
|
||||
)
|
||||
|
||||
# Focus DNS input
|
||||
dns_input = self.query_one("#dns-name-input", Input)
|
||||
dns_input.focus()
|
||||
self.query_one("#hostnames-input", Input).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ from .config_modal import ConfigModal
|
|||
from .add_entry_modal import AddEntryModal
|
||||
from .delete_confirmation_modal import DeleteConfirmationModal
|
||||
from .filter_modal import FilterModal
|
||||
from .help_modal import HelpModal
|
||||
from .custom_footer import CustomFooter
|
||||
from .styles import HOSTS_MANAGER_CSS
|
||||
from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS
|
||||
from .keybindings import HOSTS_MANAGER_BINDINGS
|
||||
from .table_handler import TableHandler
|
||||
from .details_handler import DetailsHandler
|
||||
|
|
@ -60,8 +61,6 @@ class HostsManagerApp(App):
|
|||
CSS = HOSTS_MANAGER_CSS
|
||||
BINDINGS = HOSTS_MANAGER_BINDINGS
|
||||
|
||||
help_visible = False
|
||||
|
||||
# Reactive attributes
|
||||
hosts_file: reactive[HostsFile] = reactive(HostsFile())
|
||||
selected_entry_index: reactive[int] = reactive(0)
|
||||
|
|
@ -74,6 +73,8 @@ class HostsManagerApp(App):
|
|||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title = "/etc/hosts Manager"
|
||||
self.register_theme(HOSTS_DARK_THEME)
|
||||
self.theme = "hosts-dark"
|
||||
|
||||
# Initialize core components
|
||||
self.parser = HostsParser()
|
||||
|
|
@ -99,13 +100,18 @@ class HostsManagerApp(App):
|
|||
|
||||
# State for edit mode
|
||||
self.original_entry_values = None
|
||||
self._status_timer = None
|
||||
self._viewport_too_small = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets for the app."""
|
||||
yield Header()
|
||||
yield CustomFooter(id="custom-footer")
|
||||
yield Static("", id="message-rail")
|
||||
yield Static("", id="minimum-size", classes="hidden")
|
||||
|
||||
# Search bar above the panes
|
||||
with Vertical(id="workspace"):
|
||||
# Search remains visible above the master-detail workspace.
|
||||
with Horizontal(classes="search-container") as search_container:
|
||||
search_container.border_title = "Search"
|
||||
yield Input(
|
||||
|
|
@ -113,6 +119,7 @@ class HostsManagerApp(App):
|
|||
id="search-input",
|
||||
classes="search-input",
|
||||
)
|
||||
yield Static("", id="filter-summary")
|
||||
|
||||
with Horizontal(classes="hosts-container"):
|
||||
# Left pane - entries table
|
||||
|
|
@ -124,76 +131,74 @@ class HostsManagerApp(App):
|
|||
with Vertical(classes="common-pane right-pane") as right_pane:
|
||||
right_pane.border_title = "Entry Details"
|
||||
|
||||
# Details display form (disabled inputs)
|
||||
# Inspection values deliberately use text, not disabled inputs.
|
||||
with Vertical(id="entry-details-display", classes="entry-form"):
|
||||
yield Static(
|
||||
"No Host Entry selected.",
|
||||
id="details-empty-state",
|
||||
classes="empty-state",
|
||||
)
|
||||
with Vertical(
|
||||
classes="default-section section-no-top-margin"
|
||||
) as ip_address:
|
||||
ip_address.border_title = "IP Address"
|
||||
yield Input(
|
||||
placeholder="No entry selected",
|
||||
id="details-ip-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
id="details-content", classes="detail-rows hidden"
|
||||
):
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("IP address", classes="detail-label")
|
||||
yield Static(
|
||||
"", id="details-ip-input", classes="detail-value"
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as hostnames:
|
||||
hostnames.border_title = "Hostnames (comma-separated)"
|
||||
yield Input(
|
||||
placeholder="No entry selected",
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("Hostnames", classes="detail-label")
|
||||
yield Static(
|
||||
"",
|
||||
id="details-hostname-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
classes="detail-value",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as dns_name:
|
||||
dns_name.border_title = "DNS Name"
|
||||
yield Input(
|
||||
placeholder="No DNS name",
|
||||
id="details-dns-name-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as dns_status:
|
||||
dns_status.border_title = "DNS Status"
|
||||
yield Input(
|
||||
placeholder="No DNS status",
|
||||
id="details-dns-status-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as dns_resolved:
|
||||
dns_resolved.border_title = "Last Resolved"
|
||||
yield Input(
|
||||
placeholder="Not resolved yet",
|
||||
id="details-dns-resolved-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as comment:
|
||||
comment.border_title = "Comment:"
|
||||
yield Input(
|
||||
placeholder="No entry selected",
|
||||
id="details-comment-input",
|
||||
disabled=True,
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as active:
|
||||
active.border_title = "Active"
|
||||
yield Checkbox(
|
||||
"Active",
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("State", classes="detail-label")
|
||||
yield Static(
|
||||
"",
|
||||
id="details-active-checkbox",
|
||||
disabled=True,
|
||||
classes="default-checkbox",
|
||||
classes="detail-value",
|
||||
)
|
||||
with Vertical(id="details-dns-rows", classes="hidden"):
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("DNS name", classes="detail-label")
|
||||
yield Static(
|
||||
"",
|
||||
id="details-dns-name-input",
|
||||
classes="detail-value",
|
||||
)
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("DNS status", classes="detail-label")
|
||||
yield Static(
|
||||
"",
|
||||
id="details-dns-status-input",
|
||||
classes="detail-value",
|
||||
)
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static(
|
||||
"Last resolved", classes="detail-label"
|
||||
)
|
||||
yield Static(
|
||||
"",
|
||||
id="details-dns-resolved-input",
|
||||
classes="detail-value",
|
||||
)
|
||||
with Horizontal(classes="detail-row"):
|
||||
yield Static("Comment", classes="detail-label")
|
||||
yield Static(
|
||||
"",
|
||||
id="details-comment-input",
|
||||
classes="detail-value",
|
||||
)
|
||||
yield Static(
|
||||
"",
|
||||
id="details-default-notice",
|
||||
classes="detail-note hidden",
|
||||
)
|
||||
|
||||
# Edit form (initially hidden)
|
||||
with Vertical(id="entry-edit-form", classes="entry-form hidden"):
|
||||
# Entry Type Selection
|
||||
with Vertical(
|
||||
classes="default-flex-section section-no-top-margin"
|
||||
) as entry_type:
|
||||
|
|
@ -202,13 +207,22 @@ class HostsManagerApp(App):
|
|||
id="edit-entry-type-radio", classes="default-radio-set"
|
||||
):
|
||||
yield RadioButton(
|
||||
"IP Address Entry", value=True, id="edit-ip-entry-radio"
|
||||
"IP Address Entry",
|
||||
value=True,
|
||||
id="edit-ip-entry-radio",
|
||||
)
|
||||
yield RadioButton(
|
||||
"DNS Name Entry", id="edit-dns-entry-radio"
|
||||
)
|
||||
|
||||
# IP Address Section
|
||||
with Vertical(classes="default-section") as hostnames:
|
||||
hostnames.border_title = "Hostnames (comma-separated)"
|
||||
yield Input(
|
||||
placeholder="Enter hostnames",
|
||||
id="hostname-input",
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(
|
||||
classes="default-section", id="edit-ip-section"
|
||||
) as ip_address:
|
||||
|
|
@ -219,7 +233,6 @@ class HostsManagerApp(App):
|
|||
classes="default-input",
|
||||
)
|
||||
|
||||
# DNS Name Section (initially hidden)
|
||||
with Vertical(
|
||||
classes="default-section hidden", id="edit-dns-section"
|
||||
) as dns_name:
|
||||
|
|
@ -230,14 +243,6 @@ class HostsManagerApp(App):
|
|||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as hostnames:
|
||||
hostnames.border_title = "Hostnames (comma-separated)"
|
||||
yield Input(
|
||||
placeholder="Enter hostnames",
|
||||
id="hostname-input",
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="default-section") as comment:
|
||||
comment.border_title = "Comment:"
|
||||
yield Input(
|
||||
|
|
@ -249,16 +254,58 @@ class HostsManagerApp(App):
|
|||
with Vertical(classes="default-section") as active:
|
||||
active.border_title = "Active"
|
||||
yield Checkbox(
|
||||
"Active", id="active-checkbox", classes="default-checkbox"
|
||||
"Active",
|
||||
id="active-checkbox",
|
||||
classes="default-checkbox",
|
||||
)
|
||||
|
||||
# Status bar for error/temporary messages (overlay, doesn't affect layout)
|
||||
yield Static("", id="status-bar", classes="status-bar hidden")
|
||||
|
||||
def on_ready(self) -> None:
|
||||
"""Called when the app is ready."""
|
||||
self.load_hosts_file()
|
||||
self._setup_footer()
|
||||
self._update_viewport()
|
||||
|
||||
def on_resize(self) -> None:
|
||||
"""Switch to a safe, explicit presentation below the supported viewport."""
|
||||
self._update_viewport()
|
||||
|
||||
def _update_viewport(self) -> None:
|
||||
try:
|
||||
too_small = self.size.width < 100 or self.size.height < 30
|
||||
self._viewport_too_small = too_small
|
||||
workspace = self.query_one("#workspace")
|
||||
minimum_size = self.query_one("#minimum-size", Static)
|
||||
footer = self.query_one("#custom-footer", CustomFooter)
|
||||
if too_small:
|
||||
workspace.add_class("hidden")
|
||||
minimum_size.update(
|
||||
"Terminal too small\n\nhosts requires at least 100 columns × 30 rows\n"
|
||||
f"Current size: {self.size.width} × {self.size.height}\n\nq Quit"
|
||||
)
|
||||
minimum_size.remove_class("hidden")
|
||||
minimum_size.add_class("visible")
|
||||
footer.add_class("hidden")
|
||||
else:
|
||||
workspace.remove_class("hidden")
|
||||
minimum_size.add_class("hidden")
|
||||
minimum_size.remove_class("visible")
|
||||
footer.remove_class("hidden")
|
||||
self.set_class(
|
||||
self.size.width < 120 or self.size.height < 40, "compact-layout"
|
||||
)
|
||||
self._setup_footer()
|
||||
except Exception:
|
||||
# The widgets are not mounted during early application startup.
|
||||
pass
|
||||
|
||||
def _allow_mutation_action(self) -> bool:
|
||||
"""Keep every mutation unreachable while the workspace is replaced."""
|
||||
if not self._viewport_too_small:
|
||||
return True
|
||||
self.update_status(
|
||||
"Cannot mutate while the terminal is too small. Resize to at least 100 columns × 30 rows."
|
||||
)
|
||||
return False
|
||||
|
||||
def load_hosts_file(self) -> None:
|
||||
"""Load the hosts file and populate the table."""
|
||||
|
|
@ -276,7 +323,7 @@ class HostsManagerApp(App):
|
|||
self.table_handler.restore_cursor_position(previous_entry)
|
||||
self.update_status()
|
||||
except Exception as e:
|
||||
self.update_status(f"❌ Error loading hosts file: {e}")
|
||||
self.update_status(f"Error loading hosts file: {e}")
|
||||
|
||||
def _setup_footer(self) -> None:
|
||||
"""Setup the footer with initial content based on keybindings."""
|
||||
|
|
@ -287,19 +334,45 @@ class HostsManagerApp(App):
|
|||
footer.clear_left_items()
|
||||
footer.clear_right_items()
|
||||
|
||||
# Process keybindings and add to appropriate sections
|
||||
# The footer is a contextual reminder, not a complete binding catalogue.
|
||||
# The dedicated help overlay remains the reachable source of all keys.
|
||||
visible_actions = {
|
||||
"new_entry",
|
||||
"edit_entry",
|
||||
"toggle_edit_mode",
|
||||
"show_filters",
|
||||
"help",
|
||||
"quit",
|
||||
}
|
||||
if self.size.width < 120 or self.size.height < 40:
|
||||
visible_actions = {
|
||||
"toggle_edit_mode",
|
||||
"show_filters",
|
||||
"help",
|
||||
"quit",
|
||||
}
|
||||
|
||||
for binding in self.BINDINGS:
|
||||
# Skip tuple-style bindings and only process Binding objects
|
||||
if not isinstance(binding, Binding):
|
||||
continue
|
||||
|
||||
# Only show bindings marked with show=True
|
||||
if binding.show:
|
||||
if binding.show and binding.action in visible_actions:
|
||||
# Get the display key
|
||||
key_display = getattr(binding, "key_display", None) or binding.key
|
||||
|
||||
# Get the description
|
||||
description = binding.description or binding.action
|
||||
descriptions = {
|
||||
"new_entry": "New",
|
||||
"edit_entry": "Edit",
|
||||
"toggle_edit_mode": "Mode",
|
||||
"show_filters": "Filters",
|
||||
"help": "Help",
|
||||
"quit": "Quit",
|
||||
}
|
||||
description = descriptions.get(
|
||||
binding.action, binding.description or binding.action
|
||||
)
|
||||
|
||||
# Determine positioning from id attribute
|
||||
binding_id = getattr(binding, "id", None)
|
||||
|
|
@ -320,44 +393,98 @@ class HostsManagerApp(App):
|
|||
"""Update the footer status section."""
|
||||
try:
|
||||
footer = self.query_one("#custom-footer", CustomFooter)
|
||||
mode = "Edit" if self.edit_mode else "Read-only"
|
||||
mode = "PRIVILEGED" if self.edit_mode else "READ-ONLY"
|
||||
entry_count = len(self.hosts_file.entries)
|
||||
active_count = len(self.hosts_file.get_active_entries())
|
||||
|
||||
status = f"{entry_count} entries ({active_count} active) | {mode}"
|
||||
filter_count = self._active_filter_count()
|
||||
status = (
|
||||
f"{entry_count} entries ({active_count} active) · "
|
||||
f"Filters: {filter_count} · {mode}"
|
||||
)
|
||||
footer.set_status(status)
|
||||
self.query_one("#filter-summary", Static).update(
|
||||
f"Filters: {filter_count}" if filter_count else ""
|
||||
)
|
||||
except Exception:
|
||||
pass # Footer not ready yet
|
||||
|
||||
def _active_filter_count(self) -> int:
|
||||
"""Count the active filter groups for the durable workspace summary."""
|
||||
options = self.current_filter_options
|
||||
return sum(
|
||||
(
|
||||
bool(options.search_term),
|
||||
options.active_only
|
||||
or options.inactive_only
|
||||
or not (options.show_active and options.show_inactive),
|
||||
options.dns_only
|
||||
or options.ip_only
|
||||
or not (options.show_dns_entries and options.show_ip_entries),
|
||||
options.mismatch_only
|
||||
or options.resolved_only
|
||||
or not all(
|
||||
(
|
||||
options.show_resolved,
|
||||
options.show_unresolved,
|
||||
options.show_resolving,
|
||||
options.show_failed,
|
||||
options.show_mismatched,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def update_status(self, message: str = "") -> None:
|
||||
"""Update the header subtitle and status bar with status information."""
|
||||
"""Update the reserved message rail and durable footer state."""
|
||||
if message:
|
||||
# Show temporary message in the status bar
|
||||
try:
|
||||
status_bar = self.query_one("#status-bar", Static)
|
||||
status_bar.update(message)
|
||||
status_bar.remove_class("hidden")
|
||||
|
||||
if message.startswith("❌"):
|
||||
# Auto-clear error message after 5 seconds
|
||||
self.set_timer(5.0, lambda: self._clear_status_message())
|
||||
if self._status_timer is not None:
|
||||
self._status_timer.stop()
|
||||
self._status_timer = None
|
||||
rail = self.query_one("#message-rail", Static)
|
||||
normalized = (
|
||||
message.lstrip("✓×!· ")
|
||||
.replace("❌", "×")
|
||||
.replace("✅", "✓")
|
||||
.replace("🔄", "!")
|
||||
.replace("⚠️", "!")
|
||||
)
|
||||
normalized = normalized.replace("Edit mode", "Privileged Mode").replace(
|
||||
"edit mode", "Privileged Mode"
|
||||
)
|
||||
rail.update(normalized)
|
||||
rail.remove_class("message-error")
|
||||
rail.remove_class("message-warning")
|
||||
persistent = any(
|
||||
word in normalized.lower()
|
||||
for word in (
|
||||
"error",
|
||||
"failed",
|
||||
"cannot",
|
||||
"not granted",
|
||||
"read-only",
|
||||
)
|
||||
)
|
||||
if persistent:
|
||||
rail.add_class("message-error")
|
||||
elif normalized.startswith("!"):
|
||||
rail.add_class("message-warning")
|
||||
else:
|
||||
# Auto-clear regular message after 3 seconds
|
||||
self.set_timer(3.0, lambda: self._clear_status_message())
|
||||
self._status_timer = self.set_timer(
|
||||
3.0, lambda: self._clear_status_message()
|
||||
)
|
||||
except Exception:
|
||||
# Fallback if status bar not found (during initialization)
|
||||
pass
|
||||
|
||||
# Always update the header subtitle with current status
|
||||
# Update the footer status
|
||||
self._update_footer_status()
|
||||
|
||||
def _clear_status_message(self) -> None:
|
||||
"""Clear the temporary status message."""
|
||||
try:
|
||||
status_bar = self.query_one("#status-bar", Static)
|
||||
status_bar.update("")
|
||||
status_bar.add_class("hidden")
|
||||
rail = self.query_one("#message-rail", Static)
|
||||
rail.update("")
|
||||
rail.remove_class("message-error")
|
||||
rail.remove_class("message-warning")
|
||||
self._status_timer = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -370,6 +497,13 @@ class HostsManagerApp(App):
|
|||
|
||||
def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool:
|
||||
"""Persist a mutation, restoring its complete pre-action state on failure."""
|
||||
if not self._allow_mutation_action():
|
||||
self.hosts_file = snapshot.manager_state.hosts_file
|
||||
self.manager.undo_redo_history = snapshot.manager_state.undo_redo_history
|
||||
self.selected_entry_index = snapshot.selected_entry_index
|
||||
self.table_handler.populate_entries_table()
|
||||
self.details_handler.update_entry_details()
|
||||
return False
|
||||
save_success, save_message, hosts_file = self.manager.save_mutation(
|
||||
self.hosts_file, snapshot.manager_state
|
||||
)
|
||||
|
|
@ -496,13 +630,9 @@ class HostsManagerApp(App):
|
|||
self.update_status("Hosts file reloaded")
|
||||
|
||||
def action_help(self) -> None:
|
||||
"""Show help panel."""
|
||||
if self.help_visible:
|
||||
self.action_hide_help_panel()
|
||||
self.help_visible = False
|
||||
else:
|
||||
self.action_show_help_panel()
|
||||
self.help_visible = True
|
||||
"""Open the keyboard reference without reducing workspace width."""
|
||||
if not self.screen_stack or not isinstance(self.screen, HelpModal):
|
||||
self.push_screen(HelpModal())
|
||||
|
||||
def action_config(self) -> None:
|
||||
"""Show configuration modal."""
|
||||
|
|
@ -517,6 +647,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_show_filters(self) -> None:
|
||||
"""Open the advanced filter controls and apply their returned options."""
|
||||
if isinstance(self.screen, FilterModal):
|
||||
return
|
||||
|
||||
def handle_filter_result(filter_options: FilterOptions | None) -> None:
|
||||
if filter_options is None:
|
||||
|
|
@ -564,6 +696,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_toggle_edit_mode(self) -> None:
|
||||
"""Toggle between read-only and edit mode."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if self.edit_mode:
|
||||
# Exit edit mode
|
||||
success, message = self.manager.exit_edit_mode()
|
||||
|
|
@ -626,6 +760,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_edit_entry(self) -> None:
|
||||
"""Enter edit mode for the selected entry."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status(
|
||||
"❌ Cannot edit entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||
|
|
@ -665,6 +801,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_exit_edit_entry(self) -> None:
|
||||
"""Exit entry edit mode and return focus to the entries table."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
self.edit_handler.exit_edit_entry_with_confirmation()
|
||||
|
||||
def action_next_field(self) -> None:
|
||||
|
|
@ -677,22 +815,32 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_toggle_entry(self) -> None:
|
||||
"""Toggle the active state of the selected entry."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
self.navigation_handler.toggle_entry()
|
||||
|
||||
def action_move_entry_up(self) -> None:
|
||||
"""Move the selected entry up in the list."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
self.navigation_handler.move_entry_up()
|
||||
|
||||
def action_move_entry_down(self) -> None:
|
||||
"""Move the selected entry down in the list."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
self.navigation_handler.move_entry_down()
|
||||
|
||||
def action_save_file(self) -> None:
|
||||
"""Save the hosts file to disk."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
self.navigation_handler.save_hosts_file()
|
||||
|
||||
def action_add_entry(self) -> None:
|
||||
"""Show the add entry modal."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status(
|
||||
"❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||
|
|
@ -736,6 +884,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_delete_entry(self) -> None:
|
||||
"""Show the delete confirmation modal for the selected entry."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status(
|
||||
"❌ Cannot delete entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||
|
|
@ -792,6 +942,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_undo(self) -> None:
|
||||
"""Undo the last operation."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status("❌ Cannot undo: Application is in read-only mode")
|
||||
return
|
||||
|
|
@ -819,6 +971,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_redo(self) -> None:
|
||||
"""Redo the last undone operation."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status("❌ Cannot redo: Application is in read-only mode")
|
||||
return
|
||||
|
|
@ -846,6 +1000,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_refresh_dns(self) -> None:
|
||||
"""Manually refresh DNS resolution for all entries."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status(
|
||||
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||
|
|
@ -940,6 +1096,8 @@ class HostsManagerApp(App):
|
|||
|
||||
def action_update_single_dns(self) -> None:
|
||||
"""Manually refresh DNS resolution for the currently selected entry."""
|
||||
if not self._allow_mutation_action():
|
||||
return
|
||||
if not self.edit_mode:
|
||||
self.update_status(
|
||||
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
|
||||
|
|
@ -1134,6 +1292,10 @@ class HostsManagerApp(App):
|
|||
"""Update the edit form with current entry values."""
|
||||
self.details_handler.update_edit_form()
|
||||
|
||||
def watch_edit_mode(self, edit_mode: bool) -> None:
|
||||
"""Keep elevated capability explicit without changing the full palette."""
|
||||
self.sub_title = "PRIVILEGED" if edit_mode else ""
|
||||
|
||||
def watch_entry_edit_mode(self, entry_edit_mode: bool) -> None:
|
||||
"""Update the right pane border title when entry edit mode changes."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -48,14 +48,14 @@ class ConfigModal(ModalScreen[bool]):
|
|||
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button(
|
||||
"Save", variant="primary", id="save-button", classes="config-button"
|
||||
)
|
||||
yield Button(
|
||||
"Cancel (ESC)",
|
||||
"Cancel",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="default-button",
|
||||
)
|
||||
yield Button(
|
||||
"Save", variant="primary", id="save-button", classes="config-button"
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ class DeleteConfirmationModal(ModalScreen[bool]):
|
|||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("enter", "confirm", "Delete"),
|
||||
]
|
||||
|
||||
def __init__(self, entry: HostEntry):
|
||||
|
|
@ -35,7 +34,7 @@ class DeleteConfirmationModal(ModalScreen[bool]):
|
|||
def compose(self) -> ComposeResult:
|
||||
"""Create the delete confirmation modal layout."""
|
||||
with Vertical(classes="delete-container"):
|
||||
yield Static("Delete Entry", classes="delete-title")
|
||||
yield Static("Delete Host Entry", classes="delete-title")
|
||||
|
||||
yield Static(
|
||||
"Are you sure you want to delete this entry?", classes="delete-message"
|
||||
|
|
@ -52,15 +51,15 @@ class DeleteConfirmationModal(ModalScreen[bool]):
|
|||
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button(
|
||||
"Delete",
|
||||
variant="error",
|
||||
id="delete-button",
|
||||
"Cancel",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="default-button",
|
||||
)
|
||||
yield Button(
|
||||
"Cancel (ESC)",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
"Delete Host Entry",
|
||||
variant="error",
|
||||
id="delete-button",
|
||||
classes="default-button",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,113 +1,113 @@
|
|||
"""
|
||||
Details pane management for the hosts TUI application.
|
||||
"""Read-only Entry Details and editable Entry Editor coordination."""
|
||||
|
||||
This module handles the display and updating of entry details
|
||||
and edit forms in the right pane.
|
||||
"""
|
||||
|
||||
from textual.widgets import Input, Checkbox
|
||||
from textual.widgets import Checkbox, Input, Static
|
||||
|
||||
|
||||
class DetailsHandler:
|
||||
"""Handles all details pane operations for the hosts manager."""
|
||||
"""Render selected Host Entry data without making inspection look editable."""
|
||||
|
||||
def __init__(self, app):
|
||||
"""Initialize the details handler with reference to the main app."""
|
||||
self.app = app
|
||||
|
||||
def update_entry_details(self) -> None:
|
||||
"""Update the right pane with selected entry details."""
|
||||
if self.app.entry_edit_mode:
|
||||
self.update_edit_form()
|
||||
else:
|
||||
self.update_details_display()
|
||||
|
||||
def _set_detail(self, detail_id: str, value: str) -> None:
|
||||
self.app.query_one(f"#{detail_id}", Static).update(value)
|
||||
|
||||
def _show_empty_state(self, message: str) -> None:
|
||||
self.app.query_one("#details-empty-state", Static).update(message)
|
||||
self.app.query_one("#details-empty-state", Static).remove_class("hidden")
|
||||
self.app.query_one("#details-content").add_class("hidden")
|
||||
self.app.query_one("#details-dns-rows").add_class("hidden")
|
||||
self.app.query_one("#details-default-notice").add_class("hidden")
|
||||
|
||||
def update_details_display(self) -> None:
|
||||
"""Update the details display using disabled Input widgets."""
|
||||
"""Show compact label-value rows for the currently selected Host Entry."""
|
||||
details_display = self.app.query_one("#entry-details-display")
|
||||
edit_form = self.app.query_one("#entry-edit-form")
|
||||
|
||||
# Show details display, hide edit form
|
||||
details_display.remove_class("hidden")
|
||||
edit_form.add_class("hidden")
|
||||
|
||||
# Get the input widgets
|
||||
ip_input = self.app.query_one("#details-ip-input", Input)
|
||||
hostname_input = self.app.query_one("#details-hostname-input", Input)
|
||||
comment_input = self.app.query_one("#details-comment-input", Input)
|
||||
active_checkbox = self.app.query_one("#details-active-checkbox", Checkbox)
|
||||
|
||||
if not self.app.hosts_file.entries:
|
||||
# Show empty message
|
||||
ip_input.value = ""
|
||||
ip_input.placeholder = "No entries loaded"
|
||||
hostname_input.value = ""
|
||||
hostname_input.placeholder = "No entries loaded"
|
||||
comment_input.value = ""
|
||||
comment_input.placeholder = "No entries loaded"
|
||||
active_checkbox.value = False
|
||||
self._show_empty_state("No Host Entries are loaded.")
|
||||
return
|
||||
|
||||
# Get visible entries to check if we need to adjust selection
|
||||
visible_entries = self.app.table_handler.get_visible_entries()
|
||||
if not visible_entries:
|
||||
ip_input.value = ""
|
||||
ip_input.placeholder = "No visible entries"
|
||||
hostname_input.value = ""
|
||||
hostname_input.placeholder = "No visible entries"
|
||||
comment_input.value = ""
|
||||
comment_input.placeholder = "No visible entries"
|
||||
active_checkbox.value = False
|
||||
self._show_empty_state(
|
||||
"No Host Entries match the current filters. Press Ctrl+F to change them."
|
||||
)
|
||||
return
|
||||
|
||||
# If default entries are hidden and selected_entry_index points to a hidden entry,
|
||||
# we need to find the corresponding visible entry
|
||||
show_defaults = self.app.config.should_show_default_entries()
|
||||
if not show_defaults:
|
||||
# Check if the currently selected entry is a default entry (hidden)
|
||||
if (
|
||||
self.app.selected_entry_index < len(self.app.hosts_file.entries)
|
||||
and self.app.hosts_file.entries[
|
||||
self.app.selected_entry_index
|
||||
].is_default_entry()
|
||||
):
|
||||
# The selected entry is hidden, so we should show the first visible entry instead
|
||||
if visible_entries:
|
||||
# Find the first visible entry in the hosts file
|
||||
for i, entry in enumerate(self.app.hosts_file.entries):
|
||||
if not entry.is_default_entry():
|
||||
self.app.selected_entry_index = i
|
||||
break
|
||||
|
||||
if self.app.selected_entry_index >= len(self.app.hosts_file.entries):
|
||||
self.app.selected_entry_index = 0
|
||||
|
||||
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||
|
||||
# Update the input widgets with entry data
|
||||
ip_input.value = entry.ip_address
|
||||
ip_input.placeholder = ""
|
||||
hostname_input.value = ", ".join(entry.hostnames)
|
||||
hostname_input.placeholder = ""
|
||||
comment_input.value = entry.comment or ""
|
||||
comment_input.placeholder = "No comment"
|
||||
active_checkbox.value = entry.is_active
|
||||
self.app.query_one("#details-empty-state", Static).add_class("hidden")
|
||||
self.app.query_one("#details-content").remove_class("hidden")
|
||||
self._set_detail("details-ip-input", entry.ip_address)
|
||||
self._set_detail("details-hostname-input", ", ".join(entry.hostnames))
|
||||
self._set_detail("details-comment-input", entry.comment or "—")
|
||||
self._set_detail(
|
||||
"details-active-checkbox", "✓ Active" if entry.is_active else "· Inactive"
|
||||
)
|
||||
|
||||
# For default entries, show warning in placeholder text
|
||||
default_notice = self.app.query_one("#details-default-notice", Static)
|
||||
if entry.is_default_entry():
|
||||
ip_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
|
||||
hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
|
||||
comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified"
|
||||
entry_state = "✓ Active" if entry.is_active else "· Inactive"
|
||||
self._set_detail(
|
||||
"details-active-checkbox", f"■ Default (protected) · {entry_state}"
|
||||
)
|
||||
default_notice.update(
|
||||
"Protected Default Entry. This operating-system mapping cannot be changed."
|
||||
)
|
||||
default_notice.remove_class("hidden")
|
||||
else:
|
||||
default_notice.add_class("hidden")
|
||||
|
||||
# Update DNS information if present
|
||||
self._update_dns_information(entry)
|
||||
dns_rows = self.app.query_one("#details-dns-rows")
|
||||
if entry.has_dns_name():
|
||||
dns_rows.remove_class("hidden")
|
||||
self._set_detail("details-dns-name-input", entry.dns_name or "—")
|
||||
self._set_detail("details-dns-status-input", self._dns_status_text(entry))
|
||||
self._set_detail(
|
||||
"details-dns-resolved-input",
|
||||
entry.last_resolved.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if entry.last_resolved
|
||||
else "Not resolved yet",
|
||||
)
|
||||
else:
|
||||
dns_rows.add_class("hidden")
|
||||
|
||||
@staticmethod
|
||||
def _dns_status_text(entry) -> str:
|
||||
labels = {
|
||||
"not_resolved": "· Not resolved",
|
||||
"resolving": "! Resolving…",
|
||||
"resolved": "✓ Resolved",
|
||||
"failed": "× Resolution failed",
|
||||
"match": "✓ IP matches DNS",
|
||||
"mismatch": "! IP differs from DNS",
|
||||
}
|
||||
status = labels.get(entry.dns_resolution_status, entry.dns_resolution_status)
|
||||
if not status:
|
||||
return "· Not resolved"
|
||||
if entry.resolved_ip and entry.dns_resolution_status in {
|
||||
"resolved",
|
||||
"match",
|
||||
"mismatch",
|
||||
}:
|
||||
return f"{status} ({entry.resolved_ip})"
|
||||
return status
|
||||
|
||||
def update_edit_form(self) -> None:
|
||||
"""Update the edit form with current entry values."""
|
||||
"""Populate the separate editable Entry Editor form."""
|
||||
details_display = self.app.query_one("#entry-details-display")
|
||||
edit_form = self.app.query_one("#entry-edit-form")
|
||||
|
||||
# Hide details display, show edit form
|
||||
details_display.add_class("hidden")
|
||||
edit_form.remove_class("hidden")
|
||||
|
||||
|
|
@ -117,80 +117,8 @@ class DetailsHandler:
|
|||
return
|
||||
|
||||
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||
|
||||
# Update form fields with current entry values
|
||||
ip_input = self.app.query_one("#ip-input", Input)
|
||||
hostname_input = self.app.query_one("#hostname-input", Input)
|
||||
comment_input = self.app.query_one("#comment-input", Input)
|
||||
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
||||
|
||||
ip_input.value = entry.ip_address
|
||||
hostname_input.value = ", ".join(entry.hostnames)
|
||||
comment_input.value = entry.comment or ""
|
||||
active_checkbox.value = entry.is_active
|
||||
|
||||
# Initialize radio button state and field visibility
|
||||
self.app.query_one("#ip-input", Input).value = entry.ip_address
|
||||
self.app.query_one("#hostname-input", Input).value = ", ".join(entry.hostnames)
|
||||
self.app.query_one("#comment-input", Input).value = entry.comment or ""
|
||||
self.app.query_one("#active-checkbox", Checkbox).value = entry.is_active
|
||||
self.app.edit_handler.populate_edit_form_with_type_detection()
|
||||
|
||||
def _update_dns_information(self, entry) -> None:
|
||||
"""Update DNS information display for the selected entry."""
|
||||
try:
|
||||
# Get the three separate DNS input fields
|
||||
dns_name_input = self.app.query_one("#details-dns-name-input", Input)
|
||||
dns_status_input = self.app.query_one("#details-dns-status-input", Input)
|
||||
dns_resolved_input = self.app.query_one(
|
||||
"#details-dns-resolved-input", Input
|
||||
)
|
||||
|
||||
if not entry.has_dns_name():
|
||||
# Clear all DNS fields if no DNS information
|
||||
dns_name_input.value = ""
|
||||
dns_name_input.placeholder = "No DNS name"
|
||||
dns_status_input.value = ""
|
||||
dns_status_input.placeholder = "No DNS status"
|
||||
dns_resolved_input.value = ""
|
||||
dns_resolved_input.placeholder = "Not resolved yet"
|
||||
return
|
||||
|
||||
# Update DNS Name field
|
||||
dns_name_input.value = entry.dns_name or ""
|
||||
dns_name_input.placeholder = "" if entry.dns_name else "No DNS name"
|
||||
|
||||
# Update DNS Status field
|
||||
if entry.dns_resolution_status:
|
||||
status_text = {
|
||||
"not_resolved": "Not resolved",
|
||||
"resolving": "Resolving...",
|
||||
"resolved": "Resolved",
|
||||
"failed": "Resolution failed",
|
||||
"match": "IP matches DNS",
|
||||
"mismatch": "IP differs from DNS",
|
||||
}.get(entry.dns_resolution_status, entry.dns_resolution_status)
|
||||
|
||||
# Add resolved IP to status if available
|
||||
if entry.resolved_ip and entry.dns_resolution_status in [
|
||||
"resolved",
|
||||
"match",
|
||||
"mismatch",
|
||||
]:
|
||||
status_text += f" ({entry.resolved_ip})"
|
||||
|
||||
dns_status_input.value = status_text
|
||||
dns_status_input.placeholder = ""
|
||||
else:
|
||||
dns_status_input.value = ""
|
||||
dns_status_input.placeholder = "No DNS status"
|
||||
|
||||
# Update Last Resolved field
|
||||
if entry.last_resolved:
|
||||
time_str = entry.last_resolved.strftime("%H:%M:%S")
|
||||
date_str = entry.last_resolved.strftime("%Y-%m-%d")
|
||||
dns_resolved_input.value = f"{date_str} {time_str}"
|
||||
dns_resolved_input.placeholder = ""
|
||||
else:
|
||||
dns_resolved_input.value = ""
|
||||
dns_resolved_input.placeholder = "Not resolved yet"
|
||||
|
||||
except Exception:
|
||||
# DNS widgets not present yet, silently ignore
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -425,9 +425,9 @@ class EditHandler:
|
|||
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
||||
|
||||
# Build field list based on current entry type
|
||||
fields = [radio_set]
|
||||
fields = [radio_set, hostname_input]
|
||||
|
||||
# Add IP or DNS field based on visibility
|
||||
# The address field follows Hostnames in the visual editor.
|
||||
try:
|
||||
ip_section = self.app.query_one("#edit-ip-section")
|
||||
if not ip_section.has_class("hidden"):
|
||||
|
|
@ -444,8 +444,7 @@ class EditHandler:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Add remaining fields
|
||||
fields.extend([hostname_input, comment_input, active_checkbox])
|
||||
fields.extend([comment_input, active_checkbox])
|
||||
|
||||
# Find currently focused field and move to next
|
||||
for i, field in enumerate(fields):
|
||||
|
|
@ -471,9 +470,9 @@ class EditHandler:
|
|||
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
||||
|
||||
# Build field list based on current entry type
|
||||
fields = [radio_set]
|
||||
fields = [radio_set, hostname_input]
|
||||
|
||||
# Add IP or DNS field based on visibility
|
||||
# The address field follows Hostnames in the visual editor.
|
||||
try:
|
||||
ip_section = self.app.query_one("#edit-ip-section")
|
||||
if not ip_section.has_class("hidden"):
|
||||
|
|
@ -490,8 +489,7 @@ class EditHandler:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Add remaining fields
|
||||
fields.extend([hostname_input, comment_input, active_checkbox])
|
||||
fields.extend([comment_input, active_checkbox])
|
||||
|
||||
# Find currently focused field and move to previous
|
||||
for i, field in enumerate(fields):
|
||||
|
|
|
|||
|
|
@ -6,17 +6,15 @@ filtering options including status, type, resolution status, and search filterin
|
|||
"""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Grid, Horizontal, Container
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.widgets import (
|
||||
Static,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Select,
|
||||
Label,
|
||||
RadioSet,
|
||||
RadioButton,
|
||||
Collapsible,
|
||||
)
|
||||
from textual.screen import ModalScreen
|
||||
from textual import on
|
||||
|
|
@ -24,131 +22,19 @@ from textual.binding import Binding
|
|||
from typing import Optional, Dict, List
|
||||
|
||||
from ..core.filters import FilterOptions, EntryFilter
|
||||
from .styles import FILTER_MODAL_CSS
|
||||
|
||||
|
||||
class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||
"""Advanced filtering configuration modal."""
|
||||
|
||||
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("tab", "focus_next", show=False),
|
||||
Binding("shift+tab", "focus_previous", show=False),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
FilterModal {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#filter-dialog {
|
||||
grid-size: 1;
|
||||
grid-gutter: 1 2;
|
||||
grid-rows: auto 1fr auto;
|
||||
padding: 0 1;
|
||||
width: 80;
|
||||
height: auto;
|
||||
border: thick $background 80%;
|
||||
background: $surface;
|
||||
max-height: 90%;
|
||||
}
|
||||
|
||||
#filter-header {
|
||||
dock: top;
|
||||
width: 1fr;
|
||||
height: 3;
|
||||
content-align: center middle;
|
||||
text-style: bold;
|
||||
background: $primary;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
#filter-content {
|
||||
layout: vertical;
|
||||
overflow-y: auto;
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#filter-actions {
|
||||
dock: bottom;
|
||||
layout: horizontal;
|
||||
width: 1fr;
|
||||
height: 3;
|
||||
align: center middle;
|
||||
padding: 0 1;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin: 1 0;
|
||||
padding: 1;
|
||||
border: round $primary 20%;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
.filter-section-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.filter-checkboxes {
|
||||
layout: vertical;
|
||||
margin: 0 2;
|
||||
}
|
||||
|
||||
.filter-radios {
|
||||
layout: vertical;
|
||||
margin: 0 2;
|
||||
}
|
||||
|
||||
.filter-input-row {
|
||||
layout: horizontal;
|
||||
margin: 0 2;
|
||||
height: 3;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
.filter-input-label {
|
||||
width: 20;
|
||||
content-align: left middle;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
width: 30;
|
||||
}
|
||||
|
||||
.preset-row {
|
||||
layout: horizontal;
|
||||
margin: 1 2;
|
||||
height: 3;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
.preset-select {
|
||||
width: 30;
|
||||
margin-right: 2;
|
||||
}
|
||||
|
||||
Button {
|
||||
margin: 0 1;
|
||||
min-width: 12;
|
||||
}
|
||||
|
||||
Checkbox {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
RadioButton {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
.count-display {
|
||||
text-style: italic;
|
||||
color: $text-muted;
|
||||
content-align: center middle;
|
||||
height: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
"""
|
||||
CSS = FILTER_MODAL_CSS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -171,133 +57,140 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the filter modal interface."""
|
||||
with Grid(id="filter-dialog"):
|
||||
yield Static("Advanced Filtering", id="filter-header")
|
||||
with VerticalScroll(classes="filter-container"):
|
||||
yield Static("Advanced Filtering", classes="filter-title")
|
||||
yield Static("", id="count-display", classes="filter-count")
|
||||
|
||||
with Container(id="filter-content"):
|
||||
# Filter presets section
|
||||
with Collapsible(title="Filter Presets", collapsed=False):
|
||||
with Container(classes="filter-section"):
|
||||
with Horizontal(classes="preset-row"):
|
||||
yield Label("Preset:", classes="filter-input-label")
|
||||
with Vertical(classes="default-flex-section") as presets:
|
||||
presets.border_title = "Presets"
|
||||
with Horizontal(classes="filter-preset-row"):
|
||||
yield self._create_preset_select()
|
||||
yield Button("Load", id="load-preset", variant="primary")
|
||||
yield Button("Save", id="save-preset")
|
||||
yield Button("Delete", id="delete-preset", variant="error")
|
||||
yield Button(
|
||||
"Load", id="load-preset", variant="primary", compact=True
|
||||
)
|
||||
yield Button("Save", id="save-preset", compact=True)
|
||||
yield Button(
|
||||
"Delete",
|
||||
id="delete-preset",
|
||||
variant="error",
|
||||
compact=True,
|
||||
)
|
||||
|
||||
# Status filtering section
|
||||
with Collapsible(title="Status Filtering", collapsed=False):
|
||||
with Container(classes="filter-section"):
|
||||
yield Static("Status Filtering", classes="filter-section-title")
|
||||
with RadioSet(id="status-filter-type"):
|
||||
yield RadioButton("Show All", id="status-all")
|
||||
yield RadioButton("Active Only", id="status-active")
|
||||
yield RadioButton("Inactive Only", id="status-inactive")
|
||||
with Vertical(classes="default-flex-section") as status:
|
||||
status.border_title = "Entry status"
|
||||
with RadioSet(id="status-filter-type", classes="default-radio-set"):
|
||||
yield RadioButton("Show all", id="status-all")
|
||||
yield RadioButton("Active only", id="status-active")
|
||||
yield RadioButton("Inactive only", id="status-inactive")
|
||||
yield RadioButton("Custom", id="status-custom")
|
||||
|
||||
with Container(
|
||||
classes="filter-checkboxes", id="status-custom-options"
|
||||
with Vertical(
|
||||
classes="filter-custom-options", id="status-custom-options"
|
||||
):
|
||||
yield Checkbox(
|
||||
"Show Active Entries", value=True, id="show-active"
|
||||
"Show active entries",
|
||||
value=True,
|
||||
id="show-active",
|
||||
compact=True,
|
||||
)
|
||||
yield Checkbox(
|
||||
"Show Inactive Entries", value=True, id="show-inactive"
|
||||
"Show inactive entries",
|
||||
value=True,
|
||||
id="show-inactive",
|
||||
compact=True,
|
||||
)
|
||||
|
||||
# DNS type filtering section
|
||||
with Collapsible(title="Entry Type Filtering", collapsed=False):
|
||||
with Container(classes="filter-section"):
|
||||
yield Static(
|
||||
"Entry Type Filtering", classes="filter-section-title"
|
||||
)
|
||||
with RadioSet(id="type-filter-type"):
|
||||
yield RadioButton("Show All", id="type-all")
|
||||
yield RadioButton("DNS Entries Only", id="type-dns")
|
||||
yield RadioButton("IP Entries Only", id="type-ip")
|
||||
with Vertical(classes="default-flex-section") as entry_type:
|
||||
entry_type.border_title = "Entry type"
|
||||
with RadioSet(id="type-filter-type", classes="default-radio-set"):
|
||||
yield RadioButton("Show all", id="type-all")
|
||||
yield RadioButton("DNS entries only", id="type-dns")
|
||||
yield RadioButton("IP entries only", id="type-ip")
|
||||
yield RadioButton("Custom", id="type-custom")
|
||||
|
||||
with Container(
|
||||
classes="filter-checkboxes", id="type-custom-options"
|
||||
with Vertical(
|
||||
classes="filter-custom-options", id="type-custom-options"
|
||||
):
|
||||
yield Checkbox(
|
||||
"Show DNS Entries", value=True, id="show-dns"
|
||||
"Show DNS entries", value=True, id="show-dns", compact=True
|
||||
)
|
||||
yield Checkbox(
|
||||
"Show IP entries", value=True, id="show-ip", compact=True
|
||||
)
|
||||
yield Checkbox("Show IP Entries", value=True, id="show-ip")
|
||||
|
||||
# DNS resolution status filtering section
|
||||
with Collapsible(title="Resolution Status Filtering", collapsed=False):
|
||||
with Container(classes="filter-section"):
|
||||
yield Static(
|
||||
"Resolution Status Filtering",
|
||||
classes="filter-section-title",
|
||||
)
|
||||
with RadioSet(id="resolution-filter-type"):
|
||||
yield RadioButton("Show All", id="resolution-all")
|
||||
yield RadioButton(
|
||||
"Resolved Only",
|
||||
id="resolution-resolved",
|
||||
)
|
||||
yield RadioButton(
|
||||
"Mismatches Only",
|
||||
id="resolution-mismatch",
|
||||
)
|
||||
with Vertical(classes="default-flex-section") as resolution:
|
||||
resolution.border_title = "DNS resolution"
|
||||
with RadioSet(id="resolution-filter-type", classes="default-radio-set"):
|
||||
yield RadioButton("Show all", id="resolution-all")
|
||||
yield RadioButton("Resolved only", id="resolution-resolved")
|
||||
yield RadioButton("Mismatches only", id="resolution-mismatch")
|
||||
yield RadioButton("Custom", id="resolution-custom")
|
||||
|
||||
with Container(
|
||||
classes="filter-checkboxes", id="resolution-custom-options"
|
||||
with Vertical(
|
||||
classes="filter-custom-options", id="resolution-custom-options"
|
||||
):
|
||||
yield Checkbox(
|
||||
"Show Resolved", value=True, id="show-resolved"
|
||||
"Show resolved", value=True, id="show-resolved", compact=True
|
||||
)
|
||||
yield Checkbox(
|
||||
"Show Unresolved", value=True, id="show-unresolved"
|
||||
"Show unresolved",
|
||||
value=True,
|
||||
id="show-unresolved",
|
||||
compact=True,
|
||||
)
|
||||
yield Checkbox(
|
||||
"Show Resolving", value=True, id="show-resolving"
|
||||
"Show resolving",
|
||||
value=True,
|
||||
id="show-resolving",
|
||||
compact=True,
|
||||
)
|
||||
yield Checkbox("Show Failed", value=True, id="show-failed")
|
||||
yield Checkbox(
|
||||
"Show Mismatched", value=True, id="show-mismatched"
|
||||
"Show failed", value=True, id="show-failed", compact=True
|
||||
)
|
||||
yield Checkbox(
|
||||
"Show mismatched",
|
||||
value=True,
|
||||
id="show-mismatched",
|
||||
compact=True,
|
||||
)
|
||||
|
||||
# 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")
|
||||
with Vertical(classes="default-flex-section") as search:
|
||||
search.border_title = "Search"
|
||||
yield Input(
|
||||
placeholder="Enter search term...",
|
||||
placeholder="Hostname, IP address, or comment",
|
||||
value=self.current_options.search_term or "",
|
||||
id="search-term",
|
||||
classes="filter-input",
|
||||
classes="filter-search-input",
|
||||
)
|
||||
|
||||
with Container(classes="filter-checkboxes"):
|
||||
with Vertical(classes="filter-custom-options"):
|
||||
yield Checkbox(
|
||||
"Search in hostnames", value=True, id="search-hostnames"
|
||||
"Search hostnames",
|
||||
value=True,
|
||||
id="search-hostnames",
|
||||
compact=True,
|
||||
)
|
||||
yield Checkbox(
|
||||
"Search in comments", value=True, id="search-comments"
|
||||
"Search comments",
|
||||
value=True,
|
||||
id="search-comments",
|
||||
compact=True,
|
||||
)
|
||||
yield Checkbox(
|
||||
"Search in IP addresses", value=True, id="search-ips"
|
||||
"Search IP addresses",
|
||||
value=True,
|
||||
id="search-ips",
|
||||
compact=True,
|
||||
)
|
||||
yield Checkbox(
|
||||
"Case sensitive",
|
||||
value=False,
|
||||
id="search-case-sensitive",
|
||||
compact=True,
|
||||
)
|
||||
|
||||
# Entry count display
|
||||
yield Static("", id="count-display", classes="count-display")
|
||||
|
||||
with Horizontal(id="filter-actions"):
|
||||
yield Button("Apply", id="apply", variant="primary")
|
||||
yield Button("Reset", id="reset")
|
||||
yield Button("Cancel", id="cancel")
|
||||
with Horizontal(classes="filter-actions"):
|
||||
yield Button("Cancel", id="cancel", classes="default-button")
|
||||
yield Button("Reset", id="reset", classes="default-button")
|
||||
yield Button(
|
||||
"Apply", id="apply", variant="primary", classes="default-button"
|
||||
)
|
||||
|
||||
def _create_preset_select(self) -> Select:
|
||||
"""Create the preset picker without selecting a preset by default."""
|
||||
|
|
@ -307,18 +200,90 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
|||
preset_options,
|
||||
value=self.current_options.preset_name,
|
||||
id="preset-select",
|
||||
classes="preset-select",
|
||||
classes="filter-preset-select",
|
||||
compact=True,
|
||||
)
|
||||
return Select(
|
||||
preset_options,
|
||||
id="preset-select",
|
||||
classes="preset-select",
|
||||
classes="filter-preset-select",
|
||||
compact=True,
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the modal with current options."""
|
||||
self._update_ui_from_options()
|
||||
self._update_count_display()
|
||||
self.query_one("#preset-select", Select).focus()
|
||||
|
||||
def _focusable_control_ids(self) -> List[str]:
|
||||
"""Return the modal's tab order, omitting hidden custom controls."""
|
||||
control_ids = [
|
||||
"preset-select",
|
||||
"load-preset",
|
||||
"save-preset",
|
||||
"delete-preset",
|
||||
"status-filter-type",
|
||||
]
|
||||
if self.query_one("#status-custom", RadioButton).value:
|
||||
control_ids.extend(("show-active", "show-inactive"))
|
||||
|
||||
control_ids.append("type-filter-type")
|
||||
if self.query_one("#type-custom", RadioButton).value:
|
||||
control_ids.extend(("show-dns", "show-ip"))
|
||||
|
||||
control_ids.append("resolution-filter-type")
|
||||
if self.query_one("#resolution-custom", RadioButton).value:
|
||||
control_ids.extend(
|
||||
(
|
||||
"show-resolved",
|
||||
"show-unresolved",
|
||||
"show-resolving",
|
||||
"show-failed",
|
||||
"show-mismatched",
|
||||
)
|
||||
)
|
||||
|
||||
control_ids.extend(
|
||||
(
|
||||
"search-term",
|
||||
"search-hostnames",
|
||||
"search-comments",
|
||||
"search-ips",
|
||||
"search-case-sensitive",
|
||||
"cancel",
|
||||
"reset",
|
||||
"apply",
|
||||
)
|
||||
)
|
||||
return control_ids
|
||||
|
||||
def action_focus_next(self) -> None:
|
||||
"""Move focus in form order, including controls below the scroll position."""
|
||||
self._move_focus_in_form(1)
|
||||
|
||||
def action_focus_previous(self) -> None:
|
||||
"""Move focus backward in form order, including controls below the scroll position."""
|
||||
self._move_focus_in_form(-1)
|
||||
|
||||
def _move_focus_in_form(self, direction: int) -> None:
|
||||
"""Focus the next or previous currently-visible form control."""
|
||||
control_ids = self._focusable_control_ids()
|
||||
focused_id = (
|
||||
str(self.focused.id)
|
||||
if self.focused is not None and self.focused.id is not None
|
||||
else None
|
||||
)
|
||||
if focused_id is None:
|
||||
focused_index = -1 if direction > 0 else 0
|
||||
else:
|
||||
try:
|
||||
focused_index = control_ids.index(focused_id)
|
||||
except ValueError:
|
||||
focused_index = -1 if direction > 0 else 0
|
||||
|
||||
next_id = control_ids[(focused_index + direction) % len(control_ids)]
|
||||
self.query_one(f"#{next_id}").focus()
|
||||
|
||||
def _update_ui_from_options(self) -> None:
|
||||
"""Update UI controls to reflect current options."""
|
||||
|
|
|
|||
66
src/hosts/tui/help_modal.py
Normal file
66
src/hosts/tui/help_modal.py
Normal 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()
|
||||
|
|
@ -22,7 +22,7 @@ HOSTS_MANAGER_BINDINGS = [
|
|||
Binding(
|
||||
"ctrl+e",
|
||||
"toggle_edit_mode",
|
||||
"Toggle edit mode",
|
||||
"Privileged Mode",
|
||||
show=True,
|
||||
id="left:toggle_edit_mode",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -40,29 +40,27 @@ class SaveConfirmationModal(ModalScreen[str]):
|
|||
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button(
|
||||
"Save (S)",
|
||||
variant="primary",
|
||||
id="save-button",
|
||||
"Cancel",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="save-confirmation-button",
|
||||
)
|
||||
yield Button(
|
||||
"Discard (D)",
|
||||
"Discard",
|
||||
variant="default",
|
||||
id="discard-button",
|
||||
classes="save-confirmation-button",
|
||||
)
|
||||
yield Button(
|
||||
"Cancel (ESC)",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
"Save",
|
||||
variant="primary",
|
||||
id="save-button",
|
||||
classes="save-confirmation-button",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Called when the modal is mounted. Set focus to the first button."""
|
||||
# Focus on the Save button by default
|
||||
save_button = self.query_one("#save-button", Button)
|
||||
save_button.focus()
|
||||
"""Start at the non-destructive Cancel action."""
|
||||
self.query_one("#cancel-button", Button).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
|
|
|
|||
|
|
@ -5,6 +5,24 @@ This module contains all CSS definitions for consistent styling
|
|||
across the application.
|
||||
"""
|
||||
|
||||
from textual.theme import Theme
|
||||
|
||||
|
||||
HOSTS_DARK_THEME = Theme(
|
||||
name="hosts-dark",
|
||||
primary="#0178D4",
|
||||
secondary="#004578",
|
||||
warning="#FFA62B",
|
||||
error="#BA3C5B",
|
||||
success="#4EBF71",
|
||||
accent="#FFA62B",
|
||||
foreground="#E0E0E0",
|
||||
background="#121212",
|
||||
surface="#1E1E1E",
|
||||
panel="#242F38",
|
||||
dark=True,
|
||||
)
|
||||
|
||||
# Common CSS classes shared across components
|
||||
COMMON_CSS = """
|
||||
.default-button {
|
||||
|
|
@ -71,17 +89,66 @@ HOSTS_MANAGER_CSS = (
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#message-rail {
|
||||
height: 1;
|
||||
background: $panel;
|
||||
color: $text;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#message-rail.message-error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
#message-rail.message-warning {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
#minimum-size {
|
||||
display: none;
|
||||
height: 1fr;
|
||||
content-align: center middle;
|
||||
text-align: center;
|
||||
padding: 1 2;
|
||||
border: round $warning;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
#workspace.hidden,
|
||||
#minimum-size.hidden,
|
||||
.detail-rows.hidden,
|
||||
#details-dns-rows.hidden,
|
||||
#details-default-notice.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#minimum-size.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
height: 1fr;
|
||||
width: 1fr;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#filter-summary {
|
||||
width: auto;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
content-align: right middle;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.hosts-container {
|
||||
height: 1fr;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.compact-layout .entry-form {
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
.common-pane {
|
||||
border: round $primary;
|
||||
margin: 0;
|
||||
|
|
@ -127,24 +194,35 @@ HOSTS_MANAGER_CSS = (
|
|||
display: none;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
height: 1;
|
||||
width: 100%;
|
||||
background: $error;
|
||||
color: $text;
|
||||
content-align: center middle;
|
||||
layer: overlay;
|
||||
dock: top;
|
||||
offset-y: 1;
|
||||
}
|
||||
|
||||
.status-bar.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.entry-form {
|
||||
height: 1fr;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
width: 14;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
width: 1fr;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.detail-note {
|
||||
height: auto;
|
||||
padding: 1;
|
||||
color: $warning;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 1fr;
|
||||
content-align: center middle;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
Header {
|
||||
|
|
@ -157,7 +235,7 @@ Header.-tall {
|
|||
|
||||
/* Custom Footer Styling */
|
||||
CustomFooter {
|
||||
background: $surface;
|
||||
background: $panel;
|
||||
color: $text;
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
|
|
@ -313,6 +391,68 @@ ConfigModal {
|
|||
"""
|
||||
)
|
||||
|
||||
# Advanced Filter Modal CSS
|
||||
FILTER_MODAL_CSS = (
|
||||
COMMON_CSS
|
||||
+ """
|
||||
FilterModal {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
width: 80;
|
||||
height: 28;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
height: 1;
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.filter-preset-row {
|
||||
height: 1;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
.filter-preset-select {
|
||||
width: 1fr;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
.filter-custom-options {
|
||||
height: auto;
|
||||
margin: 0 2;
|
||||
}
|
||||
|
||||
.filter-search-input {
|
||||
height: 1;
|
||||
width: 1fr;
|
||||
margin: 0 2;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
dock: bottom;
|
||||
height: 3;
|
||||
background: $surface;
|
||||
align: center middle;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Save Confirmation Modal CSS
|
||||
SAVE_CONFIRMATION_MODAL_CSS = (
|
||||
COMMON_CSS
|
||||
|
|
|
|||
|
|
@ -168,8 +168,8 @@ class TableHandler:
|
|||
arrow = "↑" if self.app.sort_ascending else "↓"
|
||||
hostname_label = f"{arrow} Canonical Hostname"
|
||||
|
||||
# Add columns with proper labels (Active, IP, Hostname, DNS)
|
||||
table.add_columns(active_label, ip_label, hostname_label, dns_label)
|
||||
# Keep the canonical hostname beside state for fast table scanning.
|
||||
table.add_columns(active_label, hostname_label, ip_label, dns_label)
|
||||
|
||||
# Get visible entries (after filtering)
|
||||
visible_entries = self.get_visible_entries()
|
||||
|
|
@ -185,25 +185,21 @@ class TableHandler:
|
|||
# Get DNS status indicator
|
||||
dns_text = self._get_dns_status_indicator(entry)
|
||||
|
||||
# Add row with styling based on active status and default entry status
|
||||
# Markers and wording keep each state understandable without colour.
|
||||
if is_default:
|
||||
# Default entries are always shown in dim grey regardless of active status
|
||||
active_text = Text("✓" if entry.is_active else "", style="dim white")
|
||||
ip_text = Text(entry.ip_address, style="dim white")
|
||||
hostname_text = Text(canonical_hostname, style="dim white")
|
||||
table.add_row(active_text, ip_text, hostname_text, dns_text)
|
||||
entry_state = "✓ Active" if entry.is_active else "· Inactive"
|
||||
active_text = Text(f"■ Default · {entry_state}", style="dim")
|
||||
ip_text = Text(entry.ip_address, style="dim")
|
||||
hostname_text = Text(canonical_hostname, style="dim")
|
||||
elif entry.is_active:
|
||||
# Active entries in green with checkmark
|
||||
active_text = Text("✓", style="bold green")
|
||||
ip_text = Text(entry.ip_address, style="bold green")
|
||||
hostname_text = Text(canonical_hostname, style="bold green")
|
||||
table.add_row(active_text, ip_text, hostname_text, dns_text)
|
||||
active_text = Text("✓ Active")
|
||||
ip_text = entry.ip_address
|
||||
hostname_text = canonical_hostname
|
||||
else:
|
||||
# Inactive entries in dim yellow with italic (no checkmark)
|
||||
active_text = Text("", style="dim yellow italic")
|
||||
ip_text = Text(entry.ip_address, style="dim yellow italic")
|
||||
hostname_text = Text(canonical_hostname, style="dim yellow italic")
|
||||
table.add_row(active_text, ip_text, hostname_text, dns_text)
|
||||
active_text = Text("· Inactive", style="dim")
|
||||
ip_text = Text(entry.ip_address, style="dim")
|
||||
hostname_text = Text(canonical_hostname, style="dim")
|
||||
table.add_row(active_text, hostname_text, ip_text, dns_text)
|
||||
|
||||
def restore_cursor_position(self, previous_entry) -> None:
|
||||
"""Restore cursor position after reload, maintaining selection if possible."""
|
||||
|
|
@ -265,7 +261,7 @@ class TableHandler:
|
|||
"""Get DNS name and status indicator for an entry."""
|
||||
# If entry has no DNS name configured, show empty
|
||||
if not entry.has_dns_name():
|
||||
return Text("", style="dim white")
|
||||
return Text("")
|
||||
|
||||
# Start with the DNS name
|
||||
dns_display = entry.dns_name
|
||||
|
|
@ -274,28 +270,21 @@ class TableHandler:
|
|||
dns_status = entry.dns_resolution_status or "not_resolved"
|
||||
|
||||
if dns_status == "not_resolved":
|
||||
status_icon = "⏳"
|
||||
style = "dim yellow"
|
||||
status_icon = "· Pending"
|
||||
elif dns_status == "resolving":
|
||||
status_icon = "🔄"
|
||||
style = "yellow"
|
||||
status_icon = "! Resolving"
|
||||
elif dns_status == "resolved":
|
||||
status_icon = "✅"
|
||||
style = "green"
|
||||
status_icon = "✓ Resolved"
|
||||
elif dns_status == "match":
|
||||
status_icon = "✅"
|
||||
style = "bold green"
|
||||
status_icon = "✓ Matches"
|
||||
elif dns_status == "mismatch":
|
||||
status_icon = "⚠️"
|
||||
style = "red"
|
||||
status_icon = "! Mismatch"
|
||||
elif dns_status == "failed":
|
||||
status_icon = "❌"
|
||||
style = "red"
|
||||
status_icon = "× Failed"
|
||||
else:
|
||||
status_icon = ""
|
||||
style = "dim white"
|
||||
|
||||
return Text(f"{status_icon} {dns_display}", style=style)
|
||||
return Text(f"{status_icon} {dns_display}".strip())
|
||||
|
||||
def sort_entries_by_hostname(self) -> None:
|
||||
"""Sort entries by canonical hostname."""
|
||||
|
|
|
|||
|
|
@ -193,15 +193,15 @@ class TestAddEntryModalRadioButtonLogic:
|
|||
# Mock the query_one method for sections and inputs
|
||||
mock_ip_section = Mock()
|
||||
mock_dns_section = Mock()
|
||||
mock_ip_input = Mock(spec=Input)
|
||||
mock_hostname_input = Mock(spec=Input)
|
||||
|
||||
def mock_query_one(selector, widget_type=None):
|
||||
if selector == "#ip-section":
|
||||
return mock_ip_section
|
||||
elif selector == "#dns-section":
|
||||
return mock_dns_section
|
||||
elif selector == "#ip-address-input":
|
||||
return mock_ip_input
|
||||
elif selector == "#hostnames-input":
|
||||
return mock_hostname_input
|
||||
return Mock()
|
||||
|
||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||
|
|
@ -225,22 +225,22 @@ class TestAddEntryModalRadioButtonLogic:
|
|||
# Verify IP section is shown and DNS section is hidden
|
||||
mock_ip_section.remove_class.assert_called_with("hidden")
|
||||
mock_dns_section.add_class.assert_called_with("hidden")
|
||||
mock_ip_input.focus.assert_called_once()
|
||||
mock_hostname_input.focus.assert_called_once()
|
||||
|
||||
def test_radio_button_change_to_dns_entry(self):
|
||||
"""Test radio button change to DNS entry mode."""
|
||||
# Mock the query_one method for sections and inputs
|
||||
mock_ip_section = Mock()
|
||||
mock_dns_section = Mock()
|
||||
mock_dns_input = Mock(spec=Input)
|
||||
mock_hostname_input = Mock(spec=Input)
|
||||
|
||||
def mock_query_one(selector, widget_type=None):
|
||||
if selector == "#ip-section":
|
||||
return mock_ip_section
|
||||
elif selector == "#dns-section":
|
||||
return mock_dns_section
|
||||
elif selector == "#dns-name-input":
|
||||
return mock_dns_input
|
||||
elif selector == "#hostnames-input":
|
||||
return mock_hostname_input
|
||||
return Mock()
|
||||
|
||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||
|
|
@ -264,7 +264,7 @@ class TestAddEntryModalRadioButtonLogic:
|
|||
# Verify DNS section is shown and IP section is hidden
|
||||
mock_ip_section.add_class.assert_called_with("hidden")
|
||||
mock_dns_section.remove_class.assert_called_with("hidden")
|
||||
mock_dns_input.focus.assert_called_once()
|
||||
mock_hostname_input.focus.assert_called_once()
|
||||
|
||||
|
||||
class TestAddEntryModalSaveLogic:
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ from unittest.mock import Mock, patch
|
|||
|
||||
import pytest
|
||||
from textual.app import SuspendNotSupported
|
||||
from textual.widgets import HelpPanel, RadioButton, Static
|
||||
from textual.widgets import Input, RadioButton, Static
|
||||
|
||||
from src.hosts.core.filters import FilterOptions
|
||||
from src.hosts.core.models import HostEntry, HostsFile
|
||||
from src.hosts.tui.app import HostsManagerApp
|
||||
from src.hosts.tui.custom_footer import CustomFooter
|
||||
from src.hosts.tui.filter_modal import FilterModal
|
||||
from src.hosts.tui.help_modal import HelpModal
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -45,16 +46,13 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter():
|
|||
async with app.run_test() as pilot:
|
||||
footer = app.query_one("#custom-footer", CustomFooter)
|
||||
footer_text = footer.query_one("#footer-right", Static).render()
|
||||
assert "ctrl+f Filter entries" in str(footer_text)
|
||||
assert "ctrl+f Filters" in str(footer_text)
|
||||
|
||||
app.action_help()
|
||||
await pilot.pause()
|
||||
assert isinstance(app.query_one(HelpPanel), HelpPanel)
|
||||
assert any(
|
||||
binding.action == "show_filters" and binding.description == "Filter entries"
|
||||
for _, binding, _, _ in app.screen.active_bindings.values()
|
||||
)
|
||||
app.action_help()
|
||||
assert isinstance(app.screen, HelpModal)
|
||||
assert app.screen.query_one("#help-close") is not None
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("ctrl+f")
|
||||
|
|
@ -72,6 +70,71 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter():
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_shortcut_does_not_stack_filter_modals():
|
||||
"""Repeated filter shortcuts keep the existing filter modal in focus."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("ctrl+f")
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("ctrl+f")
|
||||
await pilot.press("ctrl+f")
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, FilterModal)
|
||||
assert sum(isinstance(screen, FilterModal) for screen in app.screen_stack) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_overlay_closes_to_the_control_that_opened_it():
|
||||
"""Help is an overlay and returns keyboard focus to its opener."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
search = app.query_one("#search-input", Input)
|
||||
search.focus()
|
||||
app.action_help()
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, HelpModal)
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.focused is search
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_modal_tabs_through_controls_in_visual_order():
|
||||
"""The modal starts at Presets and tabs through the visible controls in order."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("ctrl+f")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.focused is app.screen.query_one("#preset-select")
|
||||
for expected_id in (
|
||||
"load-preset",
|
||||
"save-preset",
|
||||
"delete-preset",
|
||||
"status-filter-type",
|
||||
"type-filter-type",
|
||||
"resolution-filter-type",
|
||||
"search-term",
|
||||
"search-hostnames",
|
||||
"search-comments",
|
||||
"search-ips",
|
||||
"search-case-sensitive",
|
||||
"cancel",
|
||||
"reset",
|
||||
"apply",
|
||||
):
|
||||
await pilot.press("tab")
|
||||
assert app.focused is app.screen.query_one(f"#{expected_id}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters():
|
||||
"""Reset clears the form on Apply, while Cancel leaves the applied filter alone."""
|
||||
|
|
@ -103,6 +166,79 @@ async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters(
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_uses_read_only_detail_rows_and_hides_irrelevant_dns_fields():
|
||||
"""An IP Host Entry has compact text details instead of disabled controls."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
app.table_handler.populate_entries_table()
|
||||
app.details_handler.update_entry_details()
|
||||
await pilot.pause()
|
||||
|
||||
details = app.query_one("#entry-details-display")
|
||||
assert not list(details.query(Input))
|
||||
assert "192.0.2.1" in str(app.query_one("#details-ip-input", Static).render())
|
||||
assert "Active" in str(
|
||||
app.query_one("#details-active-checkbox", Static).render()
|
||||
)
|
||||
assert app.query_one("#details-dns-rows").has_class("hidden")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_exposes_dns_and_protection_details_and_durable_state():
|
||||
"""DNS and Default Entry states remain explicit independently of colour."""
|
||||
app = app_with_filterable_entries()
|
||||
app.hosts_file.entries[0].dns_name = "active.example"
|
||||
app.hosts_file.entries[0].dns_resolution_status = "resolved"
|
||||
app.hosts_file.entries[0].resolved_ip = "192.0.2.1"
|
||||
app.current_filter_options.active_only = True
|
||||
app.current_filter_options.show_inactive = False
|
||||
|
||||
async with app.run_test(size=(100, 30)) as pilot:
|
||||
app.table_handler.populate_entries_table()
|
||||
app.details_handler.update_entry_details()
|
||||
app._update_footer_status()
|
||||
await pilot.pause()
|
||||
|
||||
assert not app.query_one("#details-dns-rows").has_class("hidden")
|
||||
assert "Resolved" in str(
|
||||
app.query_one("#details-dns-status-input", Static).render()
|
||||
)
|
||||
footer = app.query_one("#custom-footer", CustomFooter)
|
||||
assert "Filters: 1" in footer._status_text
|
||||
assert "READ-ONLY" in footer._status_text
|
||||
assert app.has_class("compact-layout")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_viewport_replaces_workspace_with_an_explicit_safe_message():
|
||||
"""A viewport below the contract cannot expose clipped mutating controls."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
assert not app.query_one("#workspace").display
|
||||
minimum_size = app.query_one("#minimum-size", Static)
|
||||
assert "requires at least 100 columns × 30 rows" in str(minimum_size.render())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_viewport_blocks_hidden_mutation_shortcuts():
|
||||
"""A minimum-size presentation cannot open a hidden editor or privilege flow."""
|
||||
app = app_with_filterable_entries()
|
||||
app.push_screen = Mock()
|
||||
app.manager = Mock()
|
||||
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("n", "ctrl+e")
|
||||
await pilot.pause()
|
||||
|
||||
app.push_screen.assert_not_called()
|
||||
app.manager.enter_edit_mode.assert_not_called()
|
||||
|
||||
|
||||
class TestPrivilegedModeAuthorization:
|
||||
"""Test the user-visible privileged-mode authorization flow."""
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ class TestHostsManagerApp:
|
|||
|
||||
# Should handle error gracefully
|
||||
app.update_status.assert_called_with(
|
||||
"❌ Error loading hosts file: Hosts file not found"
|
||||
"Error loading hosts file: Hosts file not found"
|
||||
)
|
||||
|
||||
def test_load_hosts_file_permission_error(self):
|
||||
|
|
@ -122,7 +122,7 @@ class TestHostsManagerApp:
|
|||
|
||||
# Should handle error gracefully
|
||||
app.update_status.assert_called_with(
|
||||
"❌ Error loading hosts file: Permission denied"
|
||||
"Error loading hosts file: Permission denied"
|
||||
)
|
||||
|
||||
def test_populate_entries_table_logic(self):
|
||||
|
|
@ -207,13 +207,15 @@ class TestHostsManagerApp:
|
|||
|
||||
app.update_entry_details()
|
||||
|
||||
# Verify input widgets were updated with entry data
|
||||
# Verify read-only detail rows were updated with entry data.
|
||||
mock_details_display.remove_class.assert_called_with("hidden")
|
||||
mock_edit_form.add_class.assert_called_with("hidden")
|
||||
assert mock_ip_input.value == "127.0.0.1"
|
||||
assert mock_hostname_input.value == "localhost, local"
|
||||
assert mock_comment_input.value == "Test comment"
|
||||
assert mock_active_checkbox.value
|
||||
mock_ip_input.update.assert_called_with("127.0.0.1")
|
||||
mock_hostname_input.update.assert_called_with("localhost, local")
|
||||
mock_comment_input.update.assert_called_with("Test comment")
|
||||
mock_active_checkbox.update.assert_called_with(
|
||||
"■ Default (protected) · ✓ Active"
|
||||
)
|
||||
|
||||
def test_update_entry_details_no_entries(self):
|
||||
"""Test updating entry details with no entries."""
|
||||
|
|
@ -233,6 +235,7 @@ class TestHostsManagerApp:
|
|||
mock_hostname_input = Mock()
|
||||
mock_comment_input = Mock()
|
||||
mock_active_checkbox = Mock()
|
||||
mock_empty_state = Mock()
|
||||
|
||||
def mock_query_one(selector, widget_type=None):
|
||||
if selector == "#entry-details-display":
|
||||
|
|
@ -247,6 +250,8 @@ class TestHostsManagerApp:
|
|||
return mock_comment_input
|
||||
elif selector == "#details-active-checkbox":
|
||||
return mock_active_checkbox
|
||||
elif selector == "#details-empty-state":
|
||||
return mock_empty_state
|
||||
return Mock()
|
||||
|
||||
cast(Any, app).query_one = mock_query_one
|
||||
|
|
@ -254,16 +259,10 @@ class TestHostsManagerApp:
|
|||
|
||||
app.update_entry_details()
|
||||
|
||||
# Verify widgets show empty state placeholders
|
||||
# Verify the detail pane names the empty condition.
|
||||
mock_details_display.remove_class.assert_called_with("hidden")
|
||||
mock_edit_form.add_class.assert_called_with("hidden")
|
||||
assert mock_ip_input.value == ""
|
||||
assert mock_ip_input.placeholder == "No entries loaded"
|
||||
assert mock_hostname_input.value == ""
|
||||
assert mock_hostname_input.placeholder == "No entries loaded"
|
||||
assert mock_comment_input.value == ""
|
||||
assert mock_comment_input.placeholder == "No entries loaded"
|
||||
assert not mock_active_checkbox.value
|
||||
mock_empty_state.update.assert_called_with("No Host Entries are loaded.")
|
||||
|
||||
def test_update_status_default(self):
|
||||
"""Test status bar update with default information."""
|
||||
|
|
@ -301,7 +300,7 @@ class TestHostsManagerApp:
|
|||
# Verify footer status was updated
|
||||
mock_footer.set_status.assert_called_once()
|
||||
status_call = mock_footer.set_status.call_args[0][0]
|
||||
assert "Read-only" in status_call
|
||||
assert "READ-ONLY" in status_call
|
||||
assert "2 entries" in status_call
|
||||
assert "1 active" in status_call
|
||||
|
||||
|
|
@ -318,12 +317,12 @@ class TestHostsManagerApp:
|
|||
|
||||
# Mock set_timer and query_one to avoid event loop and UI issues
|
||||
app.set_timer = Mock()
|
||||
mock_status_bar = Mock()
|
||||
mock_message_rail = Mock()
|
||||
mock_footer = Mock()
|
||||
|
||||
def mock_query_one(selector, widget_type=None):
|
||||
if selector == "#status-bar":
|
||||
return mock_status_bar
|
||||
if selector == "#message-rail":
|
||||
return mock_message_rail
|
||||
elif selector == "#custom-footer":
|
||||
return mock_footer
|
||||
return Mock()
|
||||
|
|
@ -343,14 +342,13 @@ class TestHostsManagerApp:
|
|||
|
||||
app.update_status("Custom status message")
|
||||
|
||||
# Verify status bar was updated with custom message
|
||||
mock_status_bar.update.assert_called_with("Custom status message")
|
||||
mock_status_bar.remove_class.assert_called_with("hidden")
|
||||
# Verify the reserved message rail was updated with the message.
|
||||
mock_message_rail.update.assert_called_with("Custom status message")
|
||||
# Verify footer status was updated with current status (not the custom message)
|
||||
mock_footer.set_status.assert_called_once()
|
||||
footer_status = mock_footer.set_status.call_args[0][0]
|
||||
assert "2 entries" in footer_status
|
||||
assert "Read-only" in footer_status
|
||||
assert "READ-ONLY" in footer_status
|
||||
# Verify timer was set for auto-clearing
|
||||
app.set_timer.assert_called_once()
|
||||
|
||||
|
|
@ -382,12 +380,12 @@ class TestHostsManagerApp:
|
|||
patch("hosts.tui.app.Config", return_value=mock_config),
|
||||
):
|
||||
app = HostsManagerApp()
|
||||
app.action_show_help_panel = Mock()
|
||||
app.push_screen = Mock()
|
||||
|
||||
app.action_help()
|
||||
|
||||
# Should call the built-in help action
|
||||
app.action_show_help_panel.assert_called_once()
|
||||
# Help is a dedicated overlay, not a docked panel.
|
||||
app.push_screen.assert_called_once()
|
||||
|
||||
def test_action_config(self):
|
||||
"""Test config action opens modal."""
|
||||
|
|
|
|||
|
|
@ -57,15 +57,15 @@ class TestSaveConfirmationModal:
|
|||
|
||||
@patch.object(SaveConfirmationModal, "query_one")
|
||||
def test_on_mount_sets_focus(self, mock_query_one):
|
||||
"""Test that on_mount sets focus to the save button."""
|
||||
"""Test that on_mount sets focus to the safe cancel button."""
|
||||
modal = SaveConfirmationModal()
|
||||
mock_save_button = Mock()
|
||||
mock_query_one.return_value = mock_save_button
|
||||
mock_cancel_button = Mock()
|
||||
mock_query_one.return_value = mock_cancel_button
|
||||
|
||||
modal.on_mount()
|
||||
|
||||
mock_query_one.assert_called_once_with("#save-button", Button)
|
||||
mock_save_button.focus.assert_called_once()
|
||||
mock_query_one.assert_called_once_with("#cancel-button", Button)
|
||||
mock_cancel_button.focus.assert_called_once()
|
||||
|
||||
|
||||
class TestSaveConfirmationIntegration:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue