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.app import App, ComposeResult
from textual.containers import Horizontal, Vertical 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.binding import Binding
from textual.reactive import reactive from textual.reactive import reactive
@ -56,12 +56,31 @@ class HostsManagerApp(App):
height: 1; height: 1;
padding: 0 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 = [ BINDINGS = [
Binding("q", "quit", "Quit"), Binding("q", "quit", "Quit"),
Binding("r", "reload", "Reload"), Binding("r", "reload", "Reload"),
Binding("h", "help", "Help"), Binding("h", "help", "Help"),
Binding("i", "sort_by_ip", "Sort by IP"),
Binding("n", "sort_by_hostname", "Sort by Hostname"),
("ctrl+c", "quit", "Quit"), ("ctrl+c", "quit", "Quit"),
] ]
@ -69,6 +88,8 @@ class HostsManagerApp(App):
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)
edit_mode: reactive[bool] = reactive(False) edit_mode: reactive[bool] = reactive(False)
sort_column: reactive[str] = reactive("") # "ip" or "hostname"
sort_ascending: reactive[bool] = reactive(True)
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -84,7 +105,7 @@ class HostsManagerApp(App):
left_pane = Vertical(classes="left-pane") left_pane = Vertical(classes="left-pane")
left_pane.border_title = "Hosts Entries" left_pane.border_title = "Hosts Entries"
with left_pane: with left_pane:
yield ListView(id="entries-list") yield DataTable(id="entries-table")
yield left_pane yield left_pane
right_pane = Vertical(classes="right-pane") right_pane = Vertical(classes="right-pane")
@ -110,7 +131,7 @@ class HostsManagerApp(App):
try: try:
self.hosts_file = self.parser.parse() 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 # Restore cursor position with a timer to ensure ListView is fully rendered
self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry)) 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.log(f"Error loading hosts file: {e}")
self.update_status(f"Error: {e}") self.update_status(f"Error: {e}")
def populate_entries_list(self) -> None: def populate_entries_table(self) -> None:
"""Populate the left pane with hosts entries.""" """Populate the left pane with hosts entries using DataTable."""
entries_list = self.query_one("#entries-list", ListView) table = self.query_one("#entries-table", DataTable)
entries_list.clear() table.clear()
for i, entry in enumerate(self.hosts_file.entries): # Configure DataTable properties
# Format entry display table.zebra_stripes = True
hostnames_str = ", ".join(entry.hostnames) table.cursor_type = "row"
display_text = f"{entry.ip_address}{hostnames_str}" table.show_header = True
# Add columns only if they don't exist
if not table.columns:
table.add_columns("IP Address", "Canonical Hostname")
# 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 ""
if entry.comment: # Add row with styling based on active status
display_text += f" # {entry.comment}" if entry.is_active:
table.add_row(entry.ip_address, canonical_hostname)
# Create list item with appropriate styling else:
item = ListItem( # For inactive entries, we'll style them differently
Label(display_text), table.add_row(f"# {entry.ip_address}", canonical_hostname)
classes="entry-active" if entry.is_active else "entry-inactive"
)
entries_list.append(item)
def restore_cursor_position(self, previous_entry) -> None: def restore_cursor_position(self, previous_entry) -> None:
"""Restore cursor position after reload, maintaining selection if possible.""" """Restore cursor position after reload, maintaining selection if possible."""
@ -168,14 +198,12 @@ class HostsManagerApp(App):
# Entry not found, default to first entry # Entry not found, default to first entry
self.selected_entry_index = 0 self.selected_entry_index = 0
# Update the ListView selection and ensure it's highlighted # Update the DataTable cursor position
entries_list = self.query_one("#entries-list", ListView) table = self.query_one("#entries-table", DataTable)
if entries_list.children and self.selected_entry_index < len(entries_list.children): if table.row_count > 0 and self.selected_entry_index < table.row_count:
# Set the index and focus the ListView # Move cursor to the selected row
entries_list.index = self.selected_entry_index table.move_cursor(row=self.selected_entry_index)
entries_list.focus() table.focus()
# Force refresh of the selection highlighting
entries_list.refresh()
# Update the details pane to match the selection # Update the details pane to match the selection
self.update_entry_details() self.update_entry_details()
@ -226,16 +254,16 @@ class HostsManagerApp(App):
status_widget.update(status_text) status_widget.update(status_text)
def on_list_view_selected(self, event: ListView.Selected) -> None: def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Handle entry selection in the left pane.""" """Handle row highlighting (cursor movement) in the DataTable."""
if event.list_view.id == "entries-list": if event.data_table.id == "entries-table":
self.selected_entry_index = event.list_view.index or 0 self.selected_entry_index = event.cursor_row
self.update_entry_details() self.update_entry_details()
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle entry highlighting (cursor movement) in the left pane.""" """Handle row selection in the DataTable."""
if event.list_view.id == "entries-list": if event.data_table.id == "entries-table":
self.selected_entry_index = event.list_view.index or 0 self.selected_entry_index = event.cursor_row
self.update_entry_details() self.update_entry_details()
def action_reload(self) -> None: def action_reload(self) -> None:
@ -248,6 +276,88 @@ class HostsManagerApp(App):
# For now, just update the status with help info # For now, just update the status with help info
self.update_status("Help: ↑/↓ Navigate, r Reload, q Quit, h Help") 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: def action_quit(self) -> None:
"""Quit the application.""" """Quit the application."""
self.exit() self.exit()