"""
Base screen classes for NexusLIMS TUI applications.
Provides reusable screen patterns for common UI tasks like list views,
forms, and confirmation dialogs.
"""
from abc import abstractmethod
from pathlib import Path
from typing import ClassVar
from textual import on
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.screen import ModalScreen, Screen
from textual.widgets import Button, DataTable, Footer, Header, Input, Label, Static
[docs]
class BaseListScreen(Screen):
"""
Base screen for displaying data in a table.
Subclasses must implement:
- get_columns() -> list[str]: Column headers
- get_data() -> list[dict]: Row data
- on_row_selected(row_key, row_data): Handle row selection
Provides:
- DataTable with navigation and sorting
- Search/filter bar
- Add/Edit/Delete/Quit keybindings
- Header and footer
"""
CSS_PATH: ClassVar = [Path(__file__).parent.parent / "styles" / "base_screens.tcss"]
BINDINGS: ClassVar = [
("a", "add", "Add"),
("e", "edit", "Edit"),
("d", "delete", "Delete"),
("r", "refresh", "Refresh"),
("s", "cycle_sort", "Sort"),
("q", "quit", "Quit"),
("?", "help", "Help"),
("/", "focus_filter", "Filter"),
]
def __init__(self, **kwargs):
"""Initialize the list screen."""
super().__init__(**kwargs)
self._filter_text = ""
self._all_data = []
self._sort_column = None
self._sort_reverse = False
[docs]
def compose(self) -> ComposeResult:
"""Compose the list screen layout."""
yield Header()
yield Input(placeholder="Filter (press / to focus)...", id="filter-input")
yield DataTable(id="data-table", cursor_type="row")
yield Footer()
[docs]
def on_mount(self) -> None:
"""Set up the data table on mount."""
table = self.query_one(DataTable)
# Add columns (only if not already added)
if not table.columns:
columns = self.get_columns()
table.add_columns(*columns)
# Load data
self.refresh_data()
# Focus the table (not the filter input)
table.focus()
[docs]
def refresh_data(self) -> None:
"""Reload data into the table."""
# Get all data and store it
self._all_data = self.get_data()
self._apply_filter()
def _apply_filter(self) -> None:
"""Apply current filter to the data and update table."""
table = self.query_one(DataTable)
table.clear()
# Filter data based on filter text
filtered_data = self._all_data
if self._filter_text:
filter_lower = self._filter_text.lower()
filtered_data = [
row
for row in self._all_data
if any(filter_lower in str(v).lower() for v in row.values())
]
# Sort data if a sort column is set
if self._sort_column:
filtered_data = sorted(
filtered_data,
key=lambda row: str(row.get(self._sort_column, "")),
reverse=self._sort_reverse,
)
# Add filtered rows to table
for row in filtered_data:
# Use first column value as row key (should be unique ID)
row_key = next(iter(row.values())) if row else None
table.add_row(*row.values(), key=row_key)
[docs]
@on(Input.Changed, "#filter-input")
def on_filter_changed(self, event: Input.Changed) -> None:
"""Handle filter input changes."""
self._filter_text = event.value
self._apply_filter()
[docs]
def action_focus_filter(self) -> None:
"""Focus the filter input."""
self.query_one("#filter-input", Input).focus()
[docs]
def action_cycle_sort(self) -> None:
"""Cycle through sort columns (press 's' repeatedly to change column)."""
columns = self.get_columns()
if not columns:
return
if self._sort_column is None:
# Start sorting by first column
self._sort_column = columns[0]
self._sort_reverse = False
elif self._sort_column in columns:
current_index = columns.index(self._sort_column)
if self._sort_reverse:
# Move to next column, ascending
next_index = (current_index + 1) % len(columns)
self._sort_column = columns[next_index]
self._sort_reverse = False
else:
# Toggle to descending for current column
self._sort_reverse = True
else:
# Fallback: start from first column
self._sort_column = columns[0]
self._sort_reverse = False
self._apply_filter()
# Show notification about current sort
direction = "↓" if self._sort_reverse else "↑"
self.app.notify(f"Sorting by: {self._sort_column} {direction}", timeout=1)
[docs]
@abstractmethod
def get_columns(self) -> list[str]:
"""
Get column headers for the table.
Returns
-------
list[str]
Column header names
"""
[docs]
@abstractmethod
def get_data(self) -> list[dict]:
"""
Get data rows for the table.
Returns
-------
list[dict]
List of row dictionaries (column_name -> value)
"""
[docs]
@on(DataTable.RowSelected)
def on_row_selected_event(self, event: DataTable.RowSelected) -> None:
"""Handle row selection from table."""
# Get row data from the table using cursor_row
table = self.query_one(DataTable)
row_data = {}
columns = self.get_columns()
# Get the row values from the table
row_values = table.get_row_at(event.cursor_row)
for i, column in enumerate(columns):
row_data[column] = row_values[i]
self.on_row_selected(event.row_key.value, row_data)
[docs]
@abstractmethod
def on_row_selected(self, row_key, row_data: dict) -> None:
"""
Handle row selection.
Parameters
----------
row_key
Row key (typically primary key value)
row_data : dict
Dictionary mapping column names to values
"""
[docs]
def action_add(self) -> None:
"""Handle add action (default: no-op, override in subclass)."""
[docs]
def action_edit(self) -> None:
"""Handle edit action (default: edit selected row)."""
table = self.query_one(DataTable)
if table.cursor_row is not None and table.row_count > 0:
# Trigger row selected event for current row
row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
row_data = {}
columns = self.get_columns()
cursor_row = table.cursor_row
for i, column in enumerate(columns):
row_data[column] = table.get_row_at(cursor_row)[i]
self.on_row_selected(row_key, row_data)
[docs]
def action_delete(self) -> None:
"""Handle delete action (default: no-op, override in subclass)."""
[docs]
def action_refresh(self) -> None:
"""Handle refresh action."""
self.refresh_data()
[docs]
def action_quit(self) -> None:
"""Handle quit action."""
self.app.exit()
[docs]
def action_help(self) -> None:
"""Show help screen."""
self.app.action_help()
[docs]
class ConfirmDialog(ModalScreen[bool]):
"""
Modal confirmation dialog.
Displays a message and Yes/No buttons. Returns True if user confirms,
False if they cancel.
Parameters
----------
message : str
Confirmation message to display
title : str
Dialog title
"""
CSS_PATH: ClassVar = [Path(__file__).parent.parent / "styles" / "base_screens.tcss"]
def __init__(self, message: str, title: str = "Confirm", **kwargs):
"""Initialize confirmation dialog."""
super().__init__(**kwargs)
self.message = message
self.title = title
[docs]
def compose(self) -> ComposeResult:
"""Compose the dialog layout."""
with Vertical(id="dialog"):
yield Label(self.title, classes="dialog-title")
yield Static(self.message, id="message")
with Horizontal(id="buttons"):
yield Button("Yes", id="yes-btn", variant="error")
yield Button("No", id="no-btn", variant="primary")
[docs]
@on(Button.Pressed, "#yes-btn")
def on_yes(self) -> None:
"""Handle yes button."""
self.dismiss(True)
[docs]
@on(Button.Pressed, "#no-btn")
def on_no(self) -> None:
"""Handle no button."""
self.dismiss(False)