Refactor left pane to use DataTable for entries display and add sorting functionality by IP and hostname

This commit is contained in:
Philip Henning 2025-07-29 16:57:41 +02:00
parent 407e37fffd
commit 15a3b6230f

View file

@ -6,7 +6,7 @@ This module contains the main application class and entry point function.
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Footer, Static, ListView, ListItem, Label
from textual.widgets import Header, Footer, Static, DataTable
from textual.binding import Binding
from textual.reactive import reactive
@ -56,12 +56,31 @@ class HostsManagerApp(App):
height: 1;
padding: 0 1;
}
/* DataTable styling to match background */
#entries-table {
background: $background;
}
#entries-table .datatable--header {
background: $surface;
}
#entries-table .datatable--even-row {
background: $background;
}
#entries-table .datatable--odd-row {
background: $surface;
}
"""
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"),
("ctrl+c", "quit", "Quit"),
]
@ -69,6 +88,8 @@ class HostsManagerApp(App):
hosts_file: reactive[HostsFile] = reactive(HostsFile())
selected_entry_index: reactive[int] = reactive(0)
edit_mode: reactive[bool] = reactive(False)
sort_column: reactive[str] = reactive("") # "ip" or "hostname"
sort_ascending: reactive[bool] = reactive(True)
def __init__(self):
super().__init__()
@ -84,7 +105,7 @@ class HostsManagerApp(App):
left_pane = Vertical(classes="left-pane")
left_pane.border_title = "Hosts Entries"
with left_pane:
yield ListView(id="entries-list")
yield DataTable(id="entries-table")
yield left_pane
right_pane = Vertical(classes="right-pane")
@ -110,7 +131,7 @@ class HostsManagerApp(App):
try:
self.hosts_file = self.parser.parse()
self.populate_entries_list()
self.populate_entries_table()
# Restore cursor position with a timer to ensure ListView is fully rendered
self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry))
@ -127,25 +148,34 @@ class HostsManagerApp(App):
self.log(f"Error loading hosts file: {e}")
self.update_status(f"Error: {e}")
def populate_entries_list(self) -> None:
"""Populate the left pane with hosts entries."""
entries_list = self.query_one("#entries-list", ListView)
entries_list.clear()
def populate_entries_table(self) -> None:
"""Populate the left pane with hosts entries using DataTable."""
table = self.query_one("#entries-table", DataTable)
table.clear()
for i, entry in enumerate(self.hosts_file.entries):
# Format entry display
hostnames_str = ", ".join(entry.hostnames)
display_text = f"{entry.ip_address}{hostnames_str}"
# Configure DataTable properties
table.zebra_stripes = True
table.cursor_type = "row"
table.show_header = True
if entry.comment:
display_text += f" # {entry.comment}"
# Add columns only if they don't exist
if not table.columns:
table.add_columns("IP Address", "Canonical Hostname")
# Create list item with appropriate styling
item = ListItem(
Label(display_text),
classes="entry-active" if entry.is_active else "entry-inactive"
)
entries_list.append(item)
# Update column headers with sort indicators
self.update_column_headers()
# Add rows
for entry in self.hosts_file.entries:
# Get the canonical hostname (first hostname)
canonical_hostname = entry.hostnames[0] if entry.hostnames else ""
# Add row with styling based on active status
if entry.is_active:
table.add_row(entry.ip_address, canonical_hostname)
else:
# For inactive entries, we'll style them differently
table.add_row(f"# {entry.ip_address}", canonical_hostname)
def restore_cursor_position(self, previous_entry) -> None:
"""Restore cursor position after reload, maintaining selection if possible."""
@ -168,14 +198,12 @@ class HostsManagerApp(App):
# Entry not found, default to first entry
self.selected_entry_index = 0
# Update the ListView selection and ensure it's highlighted
entries_list = self.query_one("#entries-list", ListView)
if entries_list.children and self.selected_entry_index < len(entries_list.children):
# Set the index and focus the ListView
entries_list.index = self.selected_entry_index
entries_list.focus()
# Force refresh of the selection highlighting
entries_list.refresh()
# Update the DataTable cursor position
table = self.query_one("#entries-table", DataTable)
if table.row_count > 0 and self.selected_entry_index < table.row_count:
# Move cursor to the selected row
table.move_cursor(row=self.selected_entry_index)
table.focus()
# Update the details pane to match the selection
self.update_entry_details()
@ -226,16 +254,16 @@ class HostsManagerApp(App):
status_widget.update(status_text)
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle entry selection in the left pane."""
if event.list_view.id == "entries-list":
self.selected_entry_index = event.list_view.index or 0
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Handle row highlighting (cursor movement) in the DataTable."""
if event.data_table.id == "entries-table":
self.selected_entry_index = event.cursor_row
self.update_entry_details()
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
"""Handle entry highlighting (cursor movement) in the left pane."""
if event.list_view.id == "entries-list":
self.selected_entry_index = event.list_view.index or 0
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection in the DataTable."""
if event.data_table.id == "entries-table":
self.selected_entry_index = event.cursor_row
self.update_entry_details()
def action_reload(self) -> None:
@ -248,6 +276,88 @@ class HostsManagerApp(App):
# For now, just update the status with help info
self.update_status("Help: ↑/↓ Navigate, r Reload, q Quit, h Help")
def update_column_headers(self) -> None:
"""Update column headers with sort indicators."""
table = self.query_one("#entries-table", DataTable)
if not table.columns or len(table.columns) < 2:
return
# Get current column labels
ip_label = "IP Address"
hostname_label = "Canonical Hostname"
# Add sort indicators
if self.sort_column == "ip":
arrow = "" if self.sort_ascending else ""
ip_label = f"{arrow} IP Address"
elif self.sort_column == "hostname":
arrow = "" if self.sort_ascending else ""
hostname_label = f"{arrow} Canonical Hostname"
# Update column labels safely
try:
table.columns[0].label = ip_label
table.columns[1].label = hostname_label
except (IndexError, KeyError):
# If we can't update the labels, just continue
pass
def action_sort_by_ip(self) -> None:
"""Sort entries by IP address, toggle ascending/descending."""
# Toggle sort direction if already sorting by IP
if self.sort_column == "ip":
self.sort_ascending = not self.sort_ascending
else:
self.sort_column = "ip"
self.sort_ascending = True
# Sort the entries
import ipaddress
def ip_sort_key(entry):
try:
ip_str = entry.ip_address.lstrip('# ')
ip_obj = ipaddress.ip_address(ip_str)
# Create a tuple for sorting: (version, ip_int)
# This ensures IPv4 comes before IPv6, and within each version they're sorted numerically
return (ip_obj.version, int(ip_obj))
except ValueError:
# If IP parsing fails, use string comparison with high sort priority
return (999, entry.ip_address)
self.hosts_file.entries.sort(key=ip_sort_key, reverse=not self.sort_ascending)
self.populate_entries_table()
direction = "ascending" if self.sort_ascending else "descending"
self.update_status(f"Sorted by IP address ({direction})")
def action_sort_by_hostname(self) -> None:
"""Sort entries by canonical hostname, toggle ascending/descending."""
# Toggle sort direction if already sorting by hostname
if self.sort_column == "hostname":
self.sort_ascending = not self.sort_ascending
else:
self.sort_column = "hostname"
self.sort_ascending = True
# Sort the entries
self.hosts_file.entries.sort(
key=lambda entry: (entry.hostnames[0] if entry.hostnames else "").lower(),
reverse=not self.sort_ascending
)
self.populate_entries_table()
direction = "ascending" if self.sort_ascending else "descending"
self.update_status(f"Sorted by hostname ({direction})")
def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
"""Handle column header clicks for sorting."""
if event.data_table.id == "entries-table":
# Check if the column key contains "IP Address" (handles sort indicators)
if "IP Address" in str(event.column_key):
self.action_sort_by_ip()
elif "Canonical Hostname" in str(event.column_key):
self.action_sort_by_hostname()
def action_quit(self) -> None:
"""Quit the application."""
self.exit()