Compare commits

...

9 commits

Author SHA1 Message Date
phg
89df22f4e3 Enhance entry deletion process: ensure immediate file save after deletion and restore entry on save failure for improved reliability. 2025-08-16 22:44:47 +02:00
phg
6107b43ac5 Refactor keybindings and footer management: streamline keybinding processing and enhance footer item display with key-description pairs for improved clarity and usability. 2025-08-16 22:37:29 +02:00
phg
3f0892fb7b Refactor footer setup: enhance footer item management based on keybindings for improved usability and clarity. 2025-08-16 22:23:39 +02:00
phg
49dd015d53 Refactor keybindings: update visibility of key bindings for improved usability and consistency in the hosts manager application. 2025-08-16 21:56:46 +02:00
phg
c941a4ba24 Refactor HostsManagerApp and keybindings: update footer item arrangement for better usability and modify key bindings to hide unused keys for a cleaner interface. 2025-08-16 21:23:30 +02:00
phg
e8faa48e22 Refactor CustomFooter styles: enhance alignment and height properties for improved layout consistency across the TUI application. 2025-08-16 21:15:25 +02:00
phg
8d3d1e7c11 Refactor hosts TUI application: replace Footer with CustomFooter, implement footer setup and status updates, and enhance styling for improved user experience 2025-08-16 20:58:41 +02:00
phg
50628d78b7 Refactor hosts TUI application: remove HelpModal, update help action, and clean up related code 2025-08-16 17:57:35 +02:00
phg
502bbd87f3 Refactor hosts TUI application: remove main entry point, enhance modal styles, and unify button classes
- Deleted the main entry point file for the hosts application.
- Updated the Add Entry Modal to improve section titles and input styles.
- Refactored HostsManagerApp layout to use common pane styles for left and right sections.
- Enhanced Config, Delete Confirmation, and Help modals with consistent button labels and styles.
- Improved Password Modal layout and removed unnecessary parameters.
- Consolidated common CSS styles for buttons, inputs, and sections to ensure consistent styling across the application.
2025-08-16 16:52:53 +02:00
13 changed files with 497 additions and 1357 deletions

View file

@ -331,7 +331,16 @@ class HostsManager:
# Remove the entry # Remove the entry
deleted_entry = hosts_file.entries.pop(index) deleted_entry = hosts_file.entries.pop(index)
return True, f"Entry deleted: {deleted_entry.canonical_hostname}" 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}"
except Exception as e: except Exception as e:
return False, f"Error deleting entry: {e}" return False, f"Error deleting entry: {e}"

File diff suppressed because it is too large Load diff

View file

@ -1,17 +0,0 @@
"""
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.app import ComposeResult
from textual.containers import Vertical, Horizontal from textual.containers import Vertical, Horizontal
from textual.widgets import Static, Button, Input, Checkbox, Label from textual.widgets import Static, Button, Input, Checkbox
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.binding import Binding from textual.binding import Binding
@ -36,47 +36,53 @@ class AddEntryModal(ModalScreen):
with Vertical(classes="add-entry-container"): with Vertical(classes="add-entry-container"):
yield Static("Add New Host Entry", classes="add-entry-title") yield Static("Add New Host Entry", classes="add-entry-title")
with Vertical(classes="add-entry-section"): with Vertical(classes="default-section") as ip_address:
yield Label("IP Address:") ip_address.border_title = "IP Address"
yield Input( yield Input(
placeholder="e.g., 192.168.1.1 or 2001:db8::1", placeholder="e.g., 192.168.1.1 or 2001:db8::1",
id="ip-address-input", id="ip-address-input",
classes="add-entry-input", classes="default-input",
) )
yield Static("", id="ip-error", classes="validation-error") yield Static("", id="ip-error", classes="validation-error")
with Vertical(classes="add-entry-section"): with Vertical(classes="default-section") as hostnames:
yield Label("Hostnames (comma-separated):") hostnames.border_title = "Hostnames"
yield Input( yield Input(
placeholder="e.g., example.com, www.example.com", placeholder="e.g., example.com, www.example.com",
id="hostnames-input", id="hostnames-input",
classes="add-entry-input", classes="default-input",
) )
yield Static("", id="hostnames-error", classes="validation-error") yield Static("", id="hostnames-error", classes="validation-error")
with Vertical(classes="add-entry-section"): with Vertical(classes="default-section") as comment:
yield Label("Comment (optional):") comment.border_title = "Comment (optional)"
yield Input( yield Input(
placeholder="e.g., Development server", placeholder="e.g., Development server",
id="comment-input", id="comment-input",
classes="add-entry-input", classes="default-input",
) )
with Vertical(classes="add-entry-section"): with Vertical(classes="default-section") as active:
yield Checkbox("Active (enabled)", value=True, id="active-checkbox") active.border_title = "Activate Entry"
yield Checkbox(
"Active",
value=True,
id="active-checkbox",
classes="default-checkbox",
)
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button( yield Button(
"Add Entry", "Add Entry (CTRL+S)",
variant="primary", variant="primary",
id="add-button", id="add-button",
classes="add-entry-button", classes="default-button",
) )
yield Button( yield Button(
"Cancel", "Cancel (ESC)",
variant="default", variant="default",
id="cancel-button", id="cancel-button",
classes="add-entry-button", classes="default-button",
) )
def on_mount(self) -> None: 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.app import App, ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Footer, Static, DataTable, Input, Checkbox, Label from textual.widgets import Header, Static, DataTable, Input, Checkbox, Label
from textual.reactive import reactive from textual.reactive import reactive
from ..core.parser import HostsParser from ..core.parser import HostsParser
@ -18,7 +18,7 @@ from .config_modal import ConfigModal
from .password_modal import PasswordModal from .password_modal import PasswordModal
from .add_entry_modal import AddEntryModal from .add_entry_modal import AddEntryModal
from .delete_confirmation_modal import DeleteConfirmationModal from .delete_confirmation_modal import DeleteConfirmationModal
from .help_modal import HelpModal from .custom_footer import CustomFooter
from .styles import HOSTS_MANAGER_CSS from .styles import HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS from .keybindings import HOSTS_MANAGER_BINDINGS
from .table_handler import TableHandler from .table_handler import TableHandler
@ -35,9 +35,12 @@ class HostsManagerApp(App):
with read-only mode by default and explicit edit mode. with read-only mode by default and explicit edit mode.
""" """
ENABLE_COMMAND_PALETTE = False
CSS = HOSTS_MANAGER_CSS CSS = HOSTS_MANAGER_CSS
BINDINGS = HOSTS_MANAGER_BINDINGS BINDINGS = HOSTS_MANAGER_BINDINGS
help_visible = False
# Reactive attributes # Reactive attributes
hosts_file: reactive[HostsFile] = reactive(HostsFile()) hosts_file: reactive[HostsFile] = reactive(HostsFile())
selected_entry_index: reactive[int] = reactive(0) selected_entry_index: reactive[int] = reactive(0)
@ -50,7 +53,6 @@ class HostsManagerApp(App):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.title = "/etc/hosts Manager" self.title = "/etc/hosts Manager"
self.sub_title = "" # Will be set by update_status
# Initialize core components # Initialize core components
self.parser = HostsParser() self.parser = HostsParser()
@ -69,7 +71,7 @@ class HostsManagerApp(App):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create child widgets for the app.""" """Create child widgets for the app."""
yield Header() yield Header()
yield Footer() yield CustomFooter(id="custom-footer")
# Search bar above the panes # Search bar above the panes
with Horizontal(classes="search-container") as search_container: with Horizontal(classes="search-container") as search_container:
@ -82,12 +84,12 @@ class HostsManagerApp(App):
with Horizontal(classes="hosts-container"): with Horizontal(classes="hosts-container"):
# Left pane - entries table # Left pane - entries table
with Vertical(classes="left-pane") as left_pane: with Vertical(classes="common-pane left-pane") as left_pane:
left_pane.border_title = "Host Entries" left_pane.border_title = "Host Entries"
yield DataTable(id="entries-table") yield DataTable(id="entries-table")
# Right pane - entry details or edit form # Right pane - entry details or edit form
with Vertical(classes="right-pane") as right_pane: with Vertical(classes="common-pane right-pane") as right_pane:
right_pane.border_title = "Entry Details" right_pane.border_title = "Entry Details"
yield DataTable( yield DataTable(
id="entry-details-table", id="entry-details-table",
@ -114,6 +116,7 @@ class HostsManagerApp(App):
def on_ready(self) -> None: def on_ready(self) -> None:
"""Called when the app is ready.""" """Called when the app is ready."""
self.load_hosts_file() self.load_hosts_file()
self._setup_footer()
def load_hosts_file(self) -> None: def load_hosts_file(self) -> None:
"""Load the hosts file and populate the table.""" """Load the hosts file and populate the table."""
@ -133,6 +136,56 @@ class HostsManagerApp(App):
except Exception as e: except Exception as e:
self.update_status(f"❌ Error loading hosts file: {e}") self.update_status(f"❌ Error loading hosts file: {e}")
def _setup_footer(self) -> None:
"""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: def update_status(self, message: str = "") -> None:
"""Update the header subtitle and status bar with status information.""" """Update the header subtitle and status bar with status information."""
if message: if message:
@ -153,12 +206,8 @@ class HostsManagerApp(App):
pass pass
# Always update the header subtitle with current status # Always update the header subtitle with current status
mode = "Edit mode" if self.edit_mode else "Read-only mode" # Update the footer status
entry_count = len(self.hosts_file.entries) self._update_footer_status()
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: def _clear_status_message(self) -> None:
"""Clear the temporary status message.""" """Clear the temporary status message."""
@ -248,8 +297,13 @@ class HostsManagerApp(App):
self.update_status("Hosts file reloaded") self.update_status("Hosts file reloaded")
def action_help(self) -> None: def action_help(self) -> None:
"""Show help modal.""" """Show help panel."""
self.push_screen(HelpModal()) if self.help_visible:
self.action_hide_help_panel()
self.help_visible = False
else:
self.action_show_help_panel()
self.help_visible = True
def action_config(self) -> None: def action_config(self) -> None:
"""Show configuration modal.""" """Show configuration modal."""
@ -281,7 +335,6 @@ class HostsManagerApp(App):
success, message = self.manager.exit_edit_mode() success, message = self.manager.exit_edit_mode()
if success: if success:
self.edit_mode = False self.edit_mode = False
self.sub_title = "Read-only mode"
self.update_status(message) self.update_status(message)
else: else:
self.update_status(f"Error exiting edit mode: {message}") self.update_status(f"Error exiting edit mode: {message}")
@ -290,7 +343,6 @@ class HostsManagerApp(App):
success, message = self.manager.enter_edit_mode() success, message = self.manager.enter_edit_mode()
if success: if success:
self.edit_mode = True self.edit_mode = True
self.sub_title = "Edit mode"
self.update_status(message) self.update_status(message)
elif "Password required" in message: elif "Password required" in message:
# Show password modal # Show password modal
@ -311,7 +363,6 @@ class HostsManagerApp(App):
success, message = self.manager.enter_edit_mode(password) success, message = self.manager.enter_edit_mode(password)
if success: if success:
self.edit_mode = True self.edit_mode = True
self.sub_title = "Edit mode"
self.update_status(message) self.update_status(message)
elif "Incorrect password" in message: elif "Incorrect password" in message:
# Show error and try again # Show error and try again

View file

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

View file

@ -0,0 +1,245 @@
"""
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", "Delete",
variant="error", variant="error",
id="delete-button", id="delete-button",
classes="delete-button", classes="default-button",
) )
yield Button( yield Button(
"Cancel", "Cancel (ESC)",
variant="default", variant="default",
id="cancel-button", id="cancel-button",
classes="delete-button", classes="default-button",
) )
def on_mount(self) -> None: def on_mount(self) -> None:

View file

@ -1,128 +0,0 @@
"""
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,22 +9,41 @@ from textual.binding import Binding
# Key bindings for the hosts manager application # Key bindings for the hosts manager application
HOSTS_MANAGER_BINDINGS = [ HOSTS_MANAGER_BINDINGS = [
Binding("q", "quit", "Quit"), Binding("a", "add_entry", "Add new entry", show=True, id="left:add_entry"),
Binding("r", "reload", "Reload"), Binding("d", "delete_entry", "Delete entry", show=True, id="left:delete_entry"),
Binding("h", "help", "Help"), Binding("e", "edit_entry", "Edit entry", show=True, id="left:edit_entry"),
Binding("i", "sort_by_ip", "Sort by IP"), Binding(
Binding("n", "sort_by_hostname", "Sort by Hostname"), "space",
Binding("c", "config", "Config"), "toggle_entry",
Binding("ctrl+e", "toggle_edit_mode", "Edit Mode"), "Toggle active/inactive",
Binding("a", "add_entry", "Add Entry", show=False), show=True,
Binding("d", "delete_entry", "Delete Entry", show=False), id="left:toggle_entry",
Binding("e", "edit_entry", "Edit Entry", show=False), ),
Binding("space", "toggle_entry", "Toggle Entry", show=False), Binding(
Binding("ctrl+s", "save_file", "Save", show=False), "ctrl+e",
Binding("shift+up", "move_entry_up", "Move Up", show=False), "toggle_edit_mode",
Binding("shift+down", "move_entry_down", "Move Down", show=False), "Toggle edit mode",
Binding("escape", "exit_edit_entry", "Exit Edit", show=False), show=True,
Binding("tab", "next_field", "Next Field", show=False), id="left:toggle_edit_mode",
Binding("shift+tab", "prev_field", "Prev Field", show=False), ),
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),
("ctrl+c", "quit", "Quit"), ("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.app import ComposeResult
from textual.containers import Vertical, Horizontal from textual.containers import Vertical
from textual.widgets import Static, Button, Input from textual.widgets import Static, Button, Input
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.binding import Binding from textual.binding import Binding
@ -27,38 +27,27 @@ class PasswordModal(ModalScreen):
Binding("enter", "submit", "Submit"), Binding("enter", "submit", "Submit"),
] ]
def __init__(self, message: str = "Enter your password for sudo access:"): def __init__(self):
super().__init__() super().__init__()
self.message = message
self.error_message = "" self.error_message = ""
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create the password modal layout.""" """Create the password modal layout."""
with Vertical(classes="password-container"): with Vertical(classes="password-container"):
yield Static("Sudo Authentication", classes="password-title") yield Static("Sudo Authentication", classes="password-title")
yield Static(self.message, classes="password-message")
yield Input( with Vertical(classes="default-section") as password_input:
placeholder="Password", password_input.border_title = "Enter sudo Password"
password=True, yield Input(
id="password-input", placeholder="Password",
classes="password-input", password=True,
) id="password-input",
classes="default-input",
)
# Error message placeholder (initially empty) # Error message placeholder (initially empty)
yield Static("", id="error-message", classes="error-message") 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: def on_mount(self) -> None:
"""Focus the password input when modal opens.""" """Focus the password input when modal opens."""
password_input = self.query_one("#password-input", Input) password_input = self.query_one("#password-input", Input)

View file

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

View file

@ -324,17 +324,12 @@ class TestHostsManagerApp:
patch("hosts.tui.app.Config", return_value=mock_config), patch("hosts.tui.app.Config", return_value=mock_config),
): ):
app = HostsManagerApp() app = HostsManagerApp()
app.push_screen = Mock() app.action_show_help_panel = Mock()
app.action_help() app.action_help()
# Should push the help modal screen # Should call the built-in help action
app.push_screen.assert_called_once() app.action_show_help_panel.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): def test_action_config(self):
"""Test config action opens modal.""" """Test config action opens modal."""
@ -542,7 +537,7 @@ class TestHostsManagerApp:
assert "q" in binding_keys assert "q" in binding_keys
assert "r" in binding_keys assert "r" in binding_keys
assert "h" in binding_keys assert "question_mark" in binding_keys # Help binding (? key)
assert "i" in binding_keys assert "i" in binding_keys
assert "n" in binding_keys assert "n" in binding_keys
assert "c" in binding_keys assert "c" in binding_keys