diff --git a/src/hosts/main.py b/src/hosts/main.py index ff6d6ee..bf7f2c7 100644 --- a/src/hosts/main.py +++ b/src/hosts/main.py @@ -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 + + # 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: - display_text += f" # {entry.comment}" - - # 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) + # 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()