Compare commits

..

No commits in common. "89df22f4e34cf9232d500342a11bde87c4ee049b" and "54a2e00e2958df365dfb58537710442b19ebf431" have entirely different histories.

13 changed files with 1360 additions and 500 deletions

View file

@ -331,16 +331,7 @@ class HostsManager:
# Remove the entry
deleted_entry = hosts_file.entries.pop(index)
canonical_hostname = deleted_entry.hostnames[0] if deleted_entry.hostnames else deleted_entry.ip_address
# Save the file immediately
save_success, save_message = self.save_hosts_file(hosts_file)
if not save_success:
# If save fails, restore the entry
hosts_file.entries.insert(index, deleted_entry)
return False, f"Failed to save after deletion: {save_message}"
return True, f"Entry deleted: {canonical_hostname}"
return True, f"Entry deleted: {deleted_entry.canonical_hostname}"
except Exception as e:
return False, f"Error deleting entry: {e}"

1007
src/hosts/main_backup.py Normal file

File diff suppressed because it is too large Load diff

17
src/hosts/main_new.py Normal file
View file

@ -0,0 +1,17 @@
"""
Main entry point for the hosts TUI application.
This module contains the main application entry point function.
"""
from .tui.app import HostsManagerApp
def main():
"""Main entry point for the hosts application."""
app = HostsManagerApp()
app.run()
if __name__ == "__main__":
main()

View file

@ -6,7 +6,7 @@ This module provides a floating modal window for creating new host entries.
from textual.app import ComposeResult
from textual.containers import Vertical, Horizontal
from textual.widgets import Static, Button, Input, Checkbox
from textual.widgets import Static, Button, Input, Checkbox, Label
from textual.screen import ModalScreen
from textual.binding import Binding
@ -36,53 +36,47 @@ class AddEntryModal(ModalScreen):
with Vertical(classes="add-entry-container"):
yield Static("Add New Host Entry", classes="add-entry-title")
with Vertical(classes="default-section") as ip_address:
ip_address.border_title = "IP Address"
with Vertical(classes="add-entry-section"):
yield Label("IP Address:")
yield Input(
placeholder="e.g., 192.168.1.1 or 2001:db8::1",
id="ip-address-input",
classes="default-input",
classes="add-entry-input",
)
yield Static("", id="ip-error", classes="validation-error")
with Vertical(classes="default-section") as hostnames:
hostnames.border_title = "Hostnames"
with Vertical(classes="add-entry-section"):
yield Label("Hostnames (comma-separated):")
yield Input(
placeholder="e.g., example.com, www.example.com",
id="hostnames-input",
classes="default-input",
classes="add-entry-input",
)
yield Static("", id="hostnames-error", classes="validation-error")
with Vertical(classes="default-section") as comment:
comment.border_title = "Comment (optional)"
with Vertical(classes="add-entry-section"):
yield Label("Comment (optional):")
yield Input(
placeholder="e.g., Development server",
id="comment-input",
classes="default-input",
classes="add-entry-input",
)
with Vertical(classes="default-section") as active:
active.border_title = "Activate Entry"
yield Checkbox(
"Active",
value=True,
id="active-checkbox",
classes="default-checkbox",
)
with Vertical(classes="add-entry-section"):
yield Checkbox("Active (enabled)", value=True, id="active-checkbox")
with Horizontal(classes="button-row"):
yield Button(
"Add Entry (CTRL+S)",
"Add Entry",
variant="primary",
id="add-button",
classes="default-button",
classes="add-entry-button",
)
yield Button(
"Cancel (ESC)",
"Cancel",
variant="default",
id="cancel-button",
classes="default-button",
classes="add-entry-button",
)
def on_mount(self) -> None:

View file

@ -7,7 +7,7 @@ all the handlers and provides the primary user interface.
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Static, DataTable, Input, Checkbox, Label
from textual.widgets import Header, Footer, Static, DataTable, Input, Checkbox, Label
from textual.reactive import reactive
from ..core.parser import HostsParser
@ -18,7 +18,7 @@ from .config_modal import ConfigModal
from .password_modal import PasswordModal
from .add_entry_modal import AddEntryModal
from .delete_confirmation_modal import DeleteConfirmationModal
from .custom_footer import CustomFooter
from .help_modal import HelpModal
from .styles import HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS
from .table_handler import TableHandler
@ -35,12 +35,9 @@ class HostsManagerApp(App):
with read-only mode by default and explicit edit mode.
"""
ENABLE_COMMAND_PALETTE = False
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)
@ -53,6 +50,7 @@ class HostsManagerApp(App):
def __init__(self):
super().__init__()
self.title = "/etc/hosts Manager"
self.sub_title = "" # Will be set by update_status
# Initialize core components
self.parser = HostsParser()
@ -71,7 +69,7 @@ class HostsManagerApp(App):
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header()
yield CustomFooter(id="custom-footer")
yield Footer()
# Search bar above the panes
with Horizontal(classes="search-container") as search_container:
@ -84,12 +82,12 @@ class HostsManagerApp(App):
with Horizontal(classes="hosts-container"):
# Left pane - entries table
with Vertical(classes="common-pane left-pane") as left_pane:
with Vertical(classes="left-pane") as left_pane:
left_pane.border_title = "Host Entries"
yield DataTable(id="entries-table")
# Right pane - entry details or edit form
with Vertical(classes="common-pane right-pane") as right_pane:
with Vertical(classes="right-pane") as right_pane:
right_pane.border_title = "Entry Details"
yield DataTable(
id="entry-details-table",
@ -116,7 +114,6 @@ class HostsManagerApp(App):
def on_ready(self) -> None:
"""Called when the app is ready."""
self.load_hosts_file()
self._setup_footer()
def load_hosts_file(self) -> None:
"""Load the hosts file and populate the table."""
@ -136,56 +133,6 @@ class HostsManagerApp(App):
except Exception as e:
self.update_status(f"❌ Error loading hosts file: {e}")
def _setup_footer(self) -> None:
"""Setup the footer with initial content based on keybindings."""
try:
footer = self.query_one("#custom-footer", CustomFooter)
# Clear existing items
footer.clear_left_items()
footer.clear_right_items()
# Process keybindings and add to appropriate sections
for binding in self.BINDINGS:
# Skip tuple-style bindings and only process Binding objects
if not hasattr(binding, "show"):
continue
# Only show bindings marked with show=True
if binding.show:
# Get the display key
key_display = getattr(binding, "key_display", None) or binding.key
# Get the description
description = binding.description or binding.action
# Determine positioning from id attribute
binding_id = getattr(binding, "id", None)
if binding_id and binding_id.startswith("left:"):
footer.add_left_item(key_display, description)
elif binding_id and binding_id.startswith("right:"):
footer.add_right_item(key_display, description)
else:
# Default to right if no specific positioning
footer.add_right_item(key_display, description)
# Status section will be updated by update_status
self._update_footer_status()
except Exception:
pass # Footer not ready yet
def _update_footer_status(self) -> None:
"""Update the footer status section."""
try:
footer = self.query_one("#custom-footer", CustomFooter)
mode = "Edit" 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}"
footer.set_status(status)
except Exception:
pass # Footer not ready yet
def update_status(self, message: str = "") -> None:
"""Update the header subtitle and status bar with status information."""
if message:
@ -206,8 +153,12 @@ class HostsManagerApp(App):
pass
# Always update the header subtitle with current status
# Update the footer status
self._update_footer_status()
mode = "Edit mode" if self.edit_mode else "Read-only mode"
entry_count = len(self.hosts_file.entries)
active_count = len(self.hosts_file.get_active_entries())
# Format: "29 entries (6 active) | Read-only mode"
self.sub_title = f"{entry_count} entries ({active_count} active) | {mode}"
def _clear_status_message(self) -> None:
"""Clear the temporary status message."""
@ -297,13 +248,8 @@ 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
"""Show help modal."""
self.push_screen(HelpModal())
def action_config(self) -> None:
"""Show configuration modal."""
@ -335,6 +281,7 @@ class HostsManagerApp(App):
success, message = self.manager.exit_edit_mode()
if success:
self.edit_mode = False
self.sub_title = "Read-only mode"
self.update_status(message)
else:
self.update_status(f"Error exiting edit mode: {message}")
@ -343,6 +290,7 @@ class HostsManagerApp(App):
success, message = self.manager.enter_edit_mode()
if success:
self.edit_mode = True
self.sub_title = "Edit mode"
self.update_status(message)
elif "Password required" in message:
# Show password modal
@ -363,6 +311,7 @@ class HostsManagerApp(App):
success, message = self.manager.enter_edit_mode(password)
if success:
self.edit_mode = True
self.sub_title = "Edit mode"
self.update_status(message)
elif "Incorrect password" in message:
# Show error and try again

View file

@ -51,10 +51,10 @@ class ConfigModal(ModalScreen):
"Save", variant="primary", id="save-button", classes="config-button"
)
yield Button(
"Cancel (ESC)",
"Cancel",
variant="default",
id="cancel-button",
classes="default-button",
classes="config-button",
)
def on_button_pressed(self, event: Button.Pressed) -> None:

View file

@ -1,245 +0,0 @@
"""
Custom footer widget with three sections: left, right, and status.
This module provides a custom footer that divides the footer into three sections:
- Left: Items added from the left side of the screen
- Right: Items added from the right side of the screen
- Status: Right edge section separated by a vertical line
"""
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
from textual.widget import Widget
from rich.text import Text
class FooterKey(Widget):
"""A key/action pair widget for the footer, styled similar to Textual's original FooterKey."""
DEFAULT_CSS = """
FooterKey {
width: auto;
height: 1;
content-align: center middle;
padding: 0 1;
}
.footer-key--key {
text-style: bold;
color: $text;
}
.footer-key--description {
color: $text-muted;
}
"""
def __init__(self, key: str, description: str, **kwargs):
super().__init__(**kwargs)
self.key = key
self.description = description
def render(self) -> Text:
"""Render the key-description pair with proper styling."""
text = Text()
text.append(f"{self.key}", style="bold")
text.append(" ")
text.append(self.description, style="dim")
return text
class CustomFooter(Widget):
"""
A custom footer widget with three sections.
Layout: [Left items] [spacer] [Right items] | [Status]
"""
DEFAULT_CSS = """
CustomFooter {
background: $surface;
color: $text;
dock: bottom;
height: 1;
padding: 0 1;
width: 100%;
}
CustomFooter > Horizontal {
height: 1;
width: 100%;
align: left middle;
}
.footer-left {
width: auto;
text-align: left;
height: 1;
content-align: left middle;
}
.footer-spacer {
width: 1fr;
height: 1;
}
.footer-right {
width: auto;
text-align: right;
height: 1;
content-align: right middle;
}
.footer-separator {
width: auto;
color: $primary;
text-style: dim;
height: 1;
content-align: center middle;
}
.footer-status {
width: auto;
text-align: right;
color: $accent;
text-style: bold;
height: 1;
content-align: right middle;
}
/* Enhanced styling for footer key components */
.footer-left .footer-key--key,
.footer-right .footer-key--key {
text-style: bold;
color: $text;
}
.footer-left .footer-key--description,
.footer-right .footer-key--description {
color: $text-muted;
text-style: dim;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._left_items = [] # List of FooterKey widgets
self._right_items = [] # List of FooterKey widgets
self._status_text = ""
def compose(self) -> ComposeResult:
"""Create the footer layout."""
with Horizontal():
yield Static("", id="footer-left", classes="footer-left")
yield Static("", id="footer-spacer", classes="footer-spacer")
yield Static("", id="footer-right", classes="footer-right")
yield Static("", id="footer-separator", classes="footer-separator")
yield Static("", id="footer-status", classes="footer-status")
def add_left_item(self, key: str, description: str) -> None:
"""Add a key-description pair to the left section."""
footer_key = FooterKey(key, description)
self._left_items.append(footer_key)
self._update_left_section()
def add_right_item(self, key: str, description: str) -> None:
"""Add a key-description pair to the right section."""
footer_key = FooterKey(key, description)
self._right_items.append(footer_key)
self._update_right_section()
def add_left_item_legacy(self, item: str) -> None:
"""Add a legacy item (key: description format) to the left section."""
if ": " in item:
key, description = item.split(": ", 1)
self.add_left_item(key, description)
else:
self.add_left_item(item, "")
def add_right_item_legacy(self, item: str) -> None:
"""Add a legacy item (key: description format) to the right section."""
if ": " in item:
key, description = item.split(": ", 1)
self.add_right_item(key, description)
else:
self.add_right_item(item, "")
# Backward compatibility - temporarily add the old single parameter methods
def add_left_item_old(self, item: str) -> None:
"""Backward compatibility method."""
self.add_left_item_legacy(item)
def add_right_item_old(self, item: str) -> None:
"""Backward compatibility method."""
self.add_right_item_legacy(item)
def clear_left_items(self) -> None:
"""Clear all items from the left section."""
self._left_items.clear()
self._update_left_section()
def clear_right_items(self) -> None:
"""Clear all items from the right section."""
self._right_items.clear()
self._update_right_section()
def set_status(self, status: str) -> None:
"""Set the status text."""
self._status_text = status
self._update_status_section()
def _update_left_section(self) -> None:
"""Update the left section display."""
try:
left_static = self.query_one("#footer-left", Static)
if self._left_items:
# Combine all FooterKey renderings with spacing
combined_text = Text()
for i, footer_key in enumerate(self._left_items):
if i > 0:
combined_text.append(" ") # Add spacing between items
# Render individual key-description pair with styling
combined_text.append(footer_key.key, style="bold")
combined_text.append(" ")
combined_text.append(footer_key.description, style="dim")
left_static.update(combined_text)
else:
left_static.update("")
except Exception:
pass # Widget not ready yet
def _update_right_section(self) -> None:
"""Update the right section display."""
try:
right_static = self.query_one("#footer-right", Static)
if self._right_items:
# Combine all FooterKey renderings with spacing
combined_text = Text()
for i, footer_key in enumerate(self._right_items):
if i > 0:
combined_text.append(" ") # Add spacing between items
# Render individual key-description pair with styling
combined_text.append(footer_key.key, style="bold")
combined_text.append(" ")
combined_text.append(footer_key.description, style="dim")
right_static.update(combined_text)
else:
right_static.update("")
except Exception:
pass # Widget not ready yet
def _update_status_section(self) -> None:
"""Update the status section display."""
try:
status_static = self.query_one("#footer-status", Static)
status_static.update(self._status_text)
except Exception:
pass # Widget not ready yet
def on_mount(self) -> None:
"""Called when the widget is mounted."""
# Initialize all sections
self._update_left_section()
self._update_right_section()
self._update_status_section()

View file

@ -55,13 +55,13 @@ class DeleteConfirmationModal(ModalScreen):
"Delete",
variant="error",
id="delete-button",
classes="default-button",
classes="delete-button",
)
yield Button(
"Cancel (ESC)",
"Cancel",
variant="default",
id="cancel-button",
classes="default-button",
classes="delete-button",
)
def on_mount(self) -> None:

128
src/hosts/tui/help_modal.py Normal file
View file

@ -0,0 +1,128 @@
"""
Help modal window for the hosts TUI application.
This module provides a help dialog showing keyboard shortcuts and usage information.
"""
from textual.app import ComposeResult
from textual.containers import Vertical, Horizontal, ScrollableContainer
from textual.widgets import Static, Button
from textual.screen import ModalScreen
from textual.binding import Binding
from .styles import HELP_MODAL_CSS
class HelpModal(ModalScreen):
"""
Modal screen showing help and keyboard shortcuts.
Provides comprehensive help information for using the application.
"""
CSS = HELP_MODAL_CSS
BINDINGS = [
Binding("escape", "close", "Close"),
Binding("enter", "close", "Close"),
]
def compose(self) -> ComposeResult:
"""Create the help modal layout."""
with Vertical(classes="help-container"):
yield Static("/etc/hosts Manager - Help", classes="help-title")
with ScrollableContainer(classes="help-content"):
# Navigation section
with Vertical(classes="help-section"):
yield Static("Navigation", classes="help-section-title")
yield Static("↑ ↓ - Navigate entries", classes="help-item")
yield Static("Enter - Select entry", classes="help-item")
yield Static("Tab - Navigate between panes", classes="help-item")
# Main Commands section
with Vertical(classes="help-section"):
yield Static("Main Commands", classes="help-section-title")
yield Static(
"[bold]r[/bold] Reload [bold]h[/bold] Help [bold]c[/bold] Config [bold]q[/bold] Quit",
classes="help-item",
)
yield Static(
"[bold]i[/bold] Sort by IP [bold]n[/bold] Sort by hostname",
classes="help-item",
)
# Edit Mode section
with Vertical(classes="help-section"):
yield Static("Edit Mode Commands", classes="help-section-title")
yield Static(
"[bold]Ctrl+E[/bold] - Toggle edit mode (requires sudo)",
classes="help-item",
)
yield Static(
"[italic]Edit mode commands:[/italic] [bold]Space[/bold] Toggle [bold]a[/bold] Add [bold]d[/bold] Delete [bold]e[/bold] Edit",
classes="help-item",
)
yield Static(
"[bold]Shift+↑/↓[/bold] Move entry [bold]Ctrl+S[/bold] Save file",
classes="help-item",
)
# Form Navigation section
with Vertical(classes="help-section"):
yield Static(
"Form & Modal Navigation", classes="help-section-title"
)
yield Static(
"[bold]Tab/Shift+Tab[/bold] Navigate fields [bold]Enter[/bold] Confirm/Save [bold]Escape[/bold] Cancel/Exit",
classes="help-item",
)
# Special Commands section
with Vertical(classes="help-section"):
yield Static(
"Special Dialog Commands", classes="help-section-title"
)
yield Static(
"[bold]s[/bold] Save changes [bold]d[/bold] Discard changes",
classes="help-item",
)
# Status and Tips section
with Vertical(classes="help-section"):
yield Static("Entry Status & Tips", classes="help-section-title")
yield Static(
"✓ Active (enabled) ✗ Inactive (commented out)",
classes="help-item",
)
yield Static(
"• Edit mode commands require [bold]Ctrl+E[/bold] first",
classes="help-item",
)
yield Static(
"• Search supports partial matches in IP, hostname, or comment",
classes="help-item",
)
yield Static(
"• Edit mode creates automatic backups • System entries cannot be modified",
classes="help-item",
)
with Horizontal(classes="button-row"):
yield Button(
"Close", variant="primary", id="close-button", classes="help-button"
)
def on_mount(self) -> None:
"""Focus close button when modal opens."""
close_button = self.query_one("#close-button", Button)
close_button.focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "close-button":
self.action_close()
def action_close(self) -> None:
"""Close the help modal."""
self.dismiss()

View file

@ -9,41 +9,22 @@ from textual.binding import Binding
# Key bindings for the hosts manager application
HOSTS_MANAGER_BINDINGS = [
Binding("a", "add_entry", "Add new entry", show=True, id="left:add_entry"),
Binding("d", "delete_entry", "Delete entry", show=True, id="left:delete_entry"),
Binding("e", "edit_entry", "Edit entry", show=True, id="left:edit_entry"),
Binding(
"space",
"toggle_entry",
"Toggle active/inactive",
show=True,
id="left:toggle_entry",
),
Binding(
"ctrl+e",
"toggle_edit_mode",
"Toggle edit mode",
show=True,
id="left:toggle_edit_mode",
),
Binding("c", "config", "Configuration", show=True, id="right:config"),
Binding(
"question_mark",
"help",
"Show help",
show=True,
key_display="?",
id="right:help",
),
Binding("q", "quit", "Quit", show=True, id="right:quit"),
Binding("r", "reload", "Reload hosts file", show=False),
Binding("i", "sort_by_ip", "Sort by IP address", show=False),
Binding("h", "sort_by_hostname", "Sort by hostname", show=False),
Binding("ctrl+s", "save_file", "Save hosts file", show=False),
Binding("shift+up", "move_entry_up", "Move entry up", show=False),
Binding("shift+down", "move_entry_down", "Move entry down", show=False),
Binding("escape", "exit_edit_entry", "Exit edit mode", show=False),
Binding("tab", "next_field", "Next field", show=False),
Binding("shift+tab", "prev_field", "Previous field", show=False),
Binding("q", "quit", "Quit"),
Binding("r", "reload", "Reload"),
Binding("h", "help", "Help"),
Binding("i", "sort_by_ip", "Sort by IP"),
Binding("n", "sort_by_hostname", "Sort by Hostname"),
Binding("c", "config", "Config"),
Binding("ctrl+e", "toggle_edit_mode", "Edit Mode"),
Binding("a", "add_entry", "Add Entry", show=False),
Binding("d", "delete_entry", "Delete Entry", show=False),
Binding("e", "edit_entry", "Edit Entry", show=False),
Binding("space", "toggle_entry", "Toggle Entry", show=False),
Binding("ctrl+s", "save_file", "Save", show=False),
Binding("shift+up", "move_entry_up", "Move Up", show=False),
Binding("shift+down", "move_entry_down", "Move Down", show=False),
Binding("escape", "exit_edit_entry", "Exit Edit", show=False),
Binding("tab", "next_field", "Next Field", show=False),
Binding("shift+tab", "prev_field", "Prev Field", show=False),
("ctrl+c", "quit", "Quit"),
]

View file

@ -5,7 +5,7 @@ This module provides a secure password input modal for sudo operations.
"""
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.containers import Vertical, Horizontal
from textual.widgets import Static, Button, Input
from textual.screen import ModalScreen
from textual.binding import Binding
@ -27,27 +27,38 @@ class PasswordModal(ModalScreen):
Binding("enter", "submit", "Submit"),
]
def __init__(self):
def __init__(self, message: str = "Enter your password for sudo access:"):
super().__init__()
self.message = message
self.error_message = ""
def compose(self) -> ComposeResult:
"""Create the password modal layout."""
with Vertical(classes="password-container"):
yield Static("Sudo Authentication", classes="password-title")
yield Static(self.message, classes="password-message")
with Vertical(classes="default-section") as password_input:
password_input.border_title = "Enter sudo Password"
yield Input(
placeholder="Password",
password=True,
id="password-input",
classes="default-input",
classes="password-input",
)
# Error message placeholder (initially empty)
yield Static("", id="error-message", classes="error-message")
with Horizontal(classes="button-row"):
yield Button(
"OK", variant="primary", id="ok-button", classes="password-button"
)
yield Button(
"Cancel",
variant="default",
id="cancel-button",
classes="password-button",
)
def on_mount(self) -> None:
"""Focus the password input when modal opens."""
password_input = self.query_one("#password-input", Input)

View file

@ -5,48 +5,8 @@ This module contains all CSS definitions for consistent styling
across the application.
"""
# Common CSS classes shared across components
COMMON_CSS = """
.default-button {
margin: 0 1;
min-width: 10;
}
.default-checkbox {
height: 1fr;
margin: 0 2;
border: none;
}
.default-input {
height: 1fr;
width: 1fr;
margin: 0 2;
border: none;
}
.default-section {
border: round $primary;
height: 3;
padding: 0;
margin: 1 0;
}
.button-row {
margin-top: 2;
height: 3;
align: center middle;
}
.hidden {
display: none;
}
"""
# CSS styles for the hosts manager application
HOSTS_MANAGER_CSS = (
COMMON_CSS
+ """
HOSTS_MANAGER_CSS = """
.search-container {
border: round $primary;
height: 3;
@ -56,27 +16,28 @@ HOSTS_MANAGER_CSS = (
}
.search-input {
height: 1fr;
width: 1fr;
height: 1;
border: none;
}
.hosts-container {
height: 1fr;
# height: 1fr;
margin-top: 0;
}
.common-pane {
border: round $primary;
margin: 0;
}
.left-pane {
width: 60%;
border: round $primary;
margin: 0;
padding: 1;
}
.right-pane {
width: 40%;
border: round $primary;
margin: 0;
padding: 1;
}
.entry-active {
@ -166,64 +127,73 @@ Header {
Header.-tall {
height: 1; /* Fix tall header also to height 1 */
}
"""
/* Custom Footer Styling */
CustomFooter {
# Common CSS classes shared across components
COMMON_CSS = """
.button-row {
margin-top: 1;
align: center middle;
}
.hidden {
display: none;
}
"""
# Help Modal CSS
HELP_MODAL_CSS = (
COMMON_CSS
+ """
HelpModal {
align: center middle;
}
.help-container {
width: 90;
height: 40;
background: $surface;
color: $text;
dock: bottom;
height: 1;
padding: 0 1;
width: 100%;
border: thick $primary;
padding: 1;
}
CustomFooter > Horizontal {
height: 1;
width: 100%;
align: left middle;
}
.footer-left {
width: auto;
text-align: left;
text-style: dim;
height: 1;
content-align: left middle;
}
.footer-spacer {
width: 1fr;
height: 1;
}
.footer-right {
width: auto;
text-align: right;
text-style: dim;
height: 1;
content-align: right middle;
}
.footer-separator {
width: auto;
color: $primary;
text-style: dim;
height: 1;
content-align: center middle;
}
.footer-status {
width: auto;
text-align: right;
color: $accent;
.help-title {
text-align: center;
text-style: bold;
height: 1;
content-align: right middle;
color: $primary;
margin-bottom: 1;
}
.help-content {
height: 35;
margin: 1 0;
}
.help-section {
margin-bottom: 1;
}
.help-section-title {
text-style: bold;
color: $primary;
margin-bottom: 0;
}
.help-item {
margin: 0 2;
}
.keyboard-shortcut {
text-style: bold;
color: $accent;
}
.help-button {
min-width: 10;
}
"""
)
# Add Entry Modal CSS
ADD_ENTRY_MODAL_CSS = (
COMMON_CSS
@ -234,7 +204,7 @@ AddEntryModal {
.add-entry-container {
width: 80;
height: 26;
height: 25;
background: $surface;
border: thick $primary;
padding: 1;
@ -247,6 +217,25 @@ AddEntryModal {
margin-bottom: 1;
}
.add-entry-section {
margin: 1 0;
}
.add-entry-input {
margin: 0 2;
width: 1fr;
}
.button-row {
margin-top: 2;
align: center middle;
}
.add-entry-button {
margin: 0 1;
min-width: 10;
}
.validation-error {
color: $error;
margin: 0 2;
@ -265,7 +254,7 @@ DeleteConfirmationModal {
.delete-container {
width: 60;
height: 16;
height: 15;
background: $surface;
border: thick $error;
padding: 1;
@ -289,6 +278,16 @@ DeleteConfirmationModal {
color: $primary;
margin: 1 0;
}
.button-row {
margin-top: 2;
align: center middle;
}
.delete-button {
margin: 0 1;
min-width: 10;
}
"""
)
@ -302,7 +301,7 @@ PasswordModal {
.password-container {
width: 60;
height: 11;
height: 12;
background: $surface;
border: thick $primary;
padding: 1;
@ -321,6 +320,15 @@ PasswordModal {
margin-bottom: 1;
}
.password-input {
margin: 1 0;
}
.password-button {
margin: 0 1;
min-width: 10;
}
.error-message {
color: $error;
text-align: center;
@ -359,6 +367,16 @@ ConfigModal {
.config-option {
margin: 0 2;
}
.button-row {
margin-top: 2;
align: center middle;
}
.config-button {
margin: 0 1;
min-width: 10;
}
"""
)
@ -395,5 +413,9 @@ SaveConfirmationModal {
margin: 0 1;
min-width: 12;
}
.save-confirmation-button:focus {
border: thick $accent;
}
"""
)

View file

@ -324,12 +324,17 @@ 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()
# Should push the help modal screen
app.push_screen.assert_called_once()
# Verify the modal is a HelpModal instance
from hosts.tui.help_modal import HelpModal
args = app.push_screen.call_args[0]
assert isinstance(args[0], HelpModal)
def test_action_config(self):
"""Test config action opens modal."""
@ -537,7 +542,7 @@ class TestHostsManagerApp:
assert "q" in binding_keys
assert "r" in binding_keys
assert "question_mark" in binding_keys # Help binding (? key)
assert "h" in binding_keys
assert "i" in binding_keys
assert "n" in binding_keys
assert "c" in binding_keys