Compare commits
9 commits
54a2e00e29
...
89df22f4e3
Author | SHA1 | Date | |
---|---|---|---|
89df22f4e3 | |||
6107b43ac5 | |||
3f0892fb7b | |||
49dd015d53 | |||
c941a4ba24 | |||
e8faa48e22 | |||
8d3d1e7c11 | |||
50628d78b7 | |||
502bbd87f3 |
13 changed files with 497 additions and 1357 deletions
|
@ -331,7 +331,16 @@ class HostsManager:
|
|||
|
||||
# Remove the entry
|
||||
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:
|
||||
return False, f"Error deleting entry: {e}"
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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()
|
|
@ -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, Label
|
||||
from textual.widgets import Static, Button, Input, Checkbox
|
||||
from textual.screen import ModalScreen
|
||||
from textual.binding import Binding
|
||||
|
||||
|
@ -36,47 +36,53 @@ class AddEntryModal(ModalScreen):
|
|||
with Vertical(classes="add-entry-container"):
|
||||
yield Static("Add New Host Entry", classes="add-entry-title")
|
||||
|
||||
with Vertical(classes="add-entry-section"):
|
||||
yield Label("IP Address:")
|
||||
with Vertical(classes="default-section") as ip_address:
|
||||
ip_address.border_title = "IP Address"
|
||||
yield Input(
|
||||
placeholder="e.g., 192.168.1.1 or 2001:db8::1",
|
||||
id="ip-address-input",
|
||||
classes="add-entry-input",
|
||||
classes="default-input",
|
||||
)
|
||||
yield Static("", id="ip-error", classes="validation-error")
|
||||
|
||||
with Vertical(classes="add-entry-section"):
|
||||
yield Label("Hostnames (comma-separated):")
|
||||
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="add-entry-input",
|
||||
classes="default-input",
|
||||
)
|
||||
yield Static("", id="hostnames-error", classes="validation-error")
|
||||
|
||||
with Vertical(classes="add-entry-section"):
|
||||
yield Label("Comment (optional):")
|
||||
with Vertical(classes="default-section") as comment:
|
||||
comment.border_title = "Comment (optional)"
|
||||
yield Input(
|
||||
placeholder="e.g., Development server",
|
||||
id="comment-input",
|
||||
classes="add-entry-input",
|
||||
classes="default-input",
|
||||
)
|
||||
|
||||
with Vertical(classes="add-entry-section"):
|
||||
yield Checkbox("Active (enabled)", value=True, id="active-checkbox")
|
||||
with Vertical(classes="default-section") as active:
|
||||
active.border_title = "Activate Entry"
|
||||
yield Checkbox(
|
||||
"Active",
|
||||
value=True,
|
||||
id="active-checkbox",
|
||||
classes="default-checkbox",
|
||||
)
|
||||
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button(
|
||||
"Add Entry",
|
||||
"Add Entry (CTRL+S)",
|
||||
variant="primary",
|
||||
id="add-button",
|
||||
classes="add-entry-button",
|
||||
classes="default-button",
|
||||
)
|
||||
yield Button(
|
||||
"Cancel",
|
||||
"Cancel (ESC)",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="add-entry-button",
|
||||
classes="default-button",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
|
|
@ -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, Footer, Static, DataTable, Input, Checkbox, Label
|
||||
from textual.widgets import Header, 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 .help_modal import HelpModal
|
||||
from .custom_footer import CustomFooter
|
||||
from .styles import HOSTS_MANAGER_CSS
|
||||
from .keybindings import HOSTS_MANAGER_BINDINGS
|
||||
from .table_handler import TableHandler
|
||||
|
@ -35,9 +35,12 @@ 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)
|
||||
|
@ -50,7 +53,6 @@ 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()
|
||||
|
@ -69,7 +71,7 @@ class HostsManagerApp(App):
|
|||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets for the app."""
|
||||
yield Header()
|
||||
yield Footer()
|
||||
yield CustomFooter(id="custom-footer")
|
||||
|
||||
# Search bar above the panes
|
||||
with Horizontal(classes="search-container") as search_container:
|
||||
|
@ -82,12 +84,12 @@ class HostsManagerApp(App):
|
|||
|
||||
with Horizontal(classes="hosts-container"):
|
||||
# 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"
|
||||
yield DataTable(id="entries-table")
|
||||
|
||||
# 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"
|
||||
yield DataTable(
|
||||
id="entry-details-table",
|
||||
|
@ -114,6 +116,7 @@ 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."""
|
||||
|
@ -133,6 +136,56 @@ 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:
|
||||
|
@ -153,12 +206,8 @@ class HostsManagerApp(App):
|
|||
pass
|
||||
|
||||
# Always update the header subtitle with current 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}"
|
||||
# Update the footer status
|
||||
self._update_footer_status()
|
||||
|
||||
def _clear_status_message(self) -> None:
|
||||
"""Clear the temporary status message."""
|
||||
|
@ -248,8 +297,13 @@ class HostsManagerApp(App):
|
|||
self.update_status("Hosts file reloaded")
|
||||
|
||||
def action_help(self) -> None:
|
||||
"""Show help modal."""
|
||||
self.push_screen(HelpModal())
|
||||
"""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
|
||||
|
||||
def action_config(self) -> None:
|
||||
"""Show configuration modal."""
|
||||
|
@ -281,7 +335,6 @@ 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}")
|
||||
|
@ -290,7 +343,6 @@ 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
|
||||
|
@ -311,7 +363,6 @@ 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
|
||||
|
|
|
@ -51,10 +51,10 @@ class ConfigModal(ModalScreen):
|
|||
"Save", variant="primary", id="save-button", classes="config-button"
|
||||
)
|
||||
yield Button(
|
||||
"Cancel",
|
||||
"Cancel (ESC)",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="config-button",
|
||||
classes="default-button",
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
|
|
245
src/hosts/tui/custom_footer.py
Normal file
245
src/hosts/tui/custom_footer.py
Normal 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()
|
|
@ -55,13 +55,13 @@ class DeleteConfirmationModal(ModalScreen):
|
|||
"Delete",
|
||||
variant="error",
|
||||
id="delete-button",
|
||||
classes="delete-button",
|
||||
classes="default-button",
|
||||
)
|
||||
yield Button(
|
||||
"Cancel",
|
||||
"Cancel (ESC)",
|
||||
variant="default",
|
||||
id="cancel-button",
|
||||
classes="delete-button",
|
||||
classes="default-button",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
|
|
@ -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()
|
|
@ -9,22 +9,41 @@ from textual.binding import Binding
|
|||
|
||||
# Key bindings for the hosts manager application
|
||||
HOSTS_MANAGER_BINDINGS = [
|
||||
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),
|
||||
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),
|
||||
("ctrl+c", "quit", "Quit"),
|
||||
]
|
||||
|
|
|
@ -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, Horizontal
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Static, Button, Input
|
||||
from textual.screen import ModalScreen
|
||||
from textual.binding import Binding
|
||||
|
@ -27,38 +27,27 @@ class PasswordModal(ModalScreen):
|
|||
Binding("enter", "submit", "Submit"),
|
||||
]
|
||||
|
||||
def __init__(self, message: str = "Enter your password for sudo access:"):
|
||||
def __init__(self):
|
||||
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="password-input",
|
||||
classes="default-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)
|
||||
|
|
|
@ -5,8 +5,48 @@ 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 = """
|
||||
HOSTS_MANAGER_CSS = (
|
||||
COMMON_CSS
|
||||
+ """
|
||||
.search-container {
|
||||
border: round $primary;
|
||||
height: 3;
|
||||
|
@ -16,28 +56,27 @@ 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 {
|
||||
|
@ -127,73 +166,64 @@ Header {
|
|||
Header.-tall {
|
||||
height: 1; /* Fix tall header also to height 1 */
|
||||
}
|
||||
"""
|
||||
|
||||
# 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;
|
||||
/* Custom Footer Styling */
|
||||
CustomFooter {
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1;
|
||||
color: $text;
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.help-title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
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;
|
||||
margin-bottom: 1;
|
||||
text-style: dim;
|
||||
height: 1;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
.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;
|
||||
.footer-status {
|
||||
width: auto;
|
||||
text-align: right;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
.help-button {
|
||||
min-width: 10;
|
||||
text-style: bold;
|
||||
height: 1;
|
||||
content-align: right middle;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# Add Entry Modal CSS
|
||||
ADD_ENTRY_MODAL_CSS = (
|
||||
COMMON_CSS
|
||||
|
@ -204,7 +234,7 @@ AddEntryModal {
|
|||
|
||||
.add-entry-container {
|
||||
width: 80;
|
||||
height: 25;
|
||||
height: 26;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1;
|
||||
|
@ -217,25 +247,6 @@ 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;
|
||||
|
@ -254,7 +265,7 @@ DeleteConfirmationModal {
|
|||
|
||||
.delete-container {
|
||||
width: 60;
|
||||
height: 15;
|
||||
height: 16;
|
||||
background: $surface;
|
||||
border: thick $error;
|
||||
padding: 1;
|
||||
|
@ -278,16 +289,6 @@ DeleteConfirmationModal {
|
|||
color: $primary;
|
||||
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 {
|
||||
width: 60;
|
||||
height: 12;
|
||||
height: 11;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1;
|
||||
|
@ -320,15 +321,6 @@ PasswordModal {
|
|||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.password-input {
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.password-button {
|
||||
margin: 0 1;
|
||||
min-width: 10;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: $error;
|
||||
text-align: center;
|
||||
|
@ -367,16 +359,6 @@ ConfigModal {
|
|||
.config-option {
|
||||
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;
|
||||
min-width: 12;
|
||||
}
|
||||
|
||||
.save-confirmation-button:focus {
|
||||
border: thick $accent;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
|
|
@ -324,17 +324,12 @@ class TestHostsManagerApp:
|
|||
patch("hosts.tui.app.Config", return_value=mock_config),
|
||||
):
|
||||
app = HostsManagerApp()
|
||||
app.push_screen = Mock()
|
||||
app.action_show_help_panel = Mock()
|
||||
|
||||
app.action_help()
|
||||
|
||||
# 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)
|
||||
# Should call the built-in help action
|
||||
app.action_show_help_panel.assert_called_once()
|
||||
|
||||
def test_action_config(self):
|
||||
"""Test config action opens modal."""
|
||||
|
@ -542,7 +537,7 @@ class TestHostsManagerApp:
|
|||
|
||||
assert "q" 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 "n" in binding_keys
|
||||
assert "c" in binding_keys
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue