Coverage for nexusLIMS/tui/common/base_screens.py: 100%
175 statements
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
1"""
2Base screen classes for NexusLIMS TUI applications.
4Provides reusable screen patterns for common UI tasks like list views,
5forms, and confirmation dialogs.
6"""
8from abc import abstractmethod
9from pathlib import Path
10from typing import ClassVar
12from textual import on
13from textual.app import ComposeResult
14from textual.containers import Container, Horizontal, Vertical
15from textual.screen import ModalScreen, Screen
16from textual.widgets import Button, DataTable, Footer, Header, Input, Label, Static
19class BaseListScreen(Screen):
20 """
21 Base screen for displaying data in a table.
23 Subclasses must implement:
24 - get_columns() -> list[str]: Column headers
25 - get_data() -> list[dict]: Row data
26 - on_row_selected(row_key, row_data): Handle row selection
28 Provides:
29 - DataTable with navigation and sorting
30 - Search/filter bar
31 - Add/Edit/Delete/Quit keybindings
32 - Header and footer
33 """
35 CSS_PATH: ClassVar = [Path(__file__).parent.parent / "styles" / "base_screens.tcss"]
37 BINDINGS: ClassVar = [
38 ("a", "add", "Add"),
39 ("e", "edit", "Edit"),
40 ("d", "delete", "Delete"),
41 ("r", "refresh", "Refresh"),
42 ("s", "cycle_sort", "Sort"),
43 ("q", "quit", "Quit"),
44 ("?", "help", "Help"),
45 ("/", "focus_filter", "Filter"),
46 ]
48 def __init__(self, **kwargs):
49 """Initialize the list screen."""
50 super().__init__(**kwargs)
51 self._filter_text = ""
52 self._all_data = []
53 self._sort_column = None
54 self._sort_reverse = False
56 def compose(self) -> ComposeResult:
57 """Compose the list screen layout."""
58 yield Header()
59 yield Input(placeholder="Filter (press / to focus)...", id="filter-input")
60 yield DataTable(id="data-table", cursor_type="row")
61 yield Footer()
63 def on_mount(self) -> None:
64 """Set up the data table on mount."""
65 table = self.query_one(DataTable)
67 # Add columns (only if not already added)
68 if not table.columns:
69 columns = self.get_columns()
70 table.add_columns(*columns)
72 # Load data
73 self.refresh_data()
75 # Focus the table (not the filter input)
76 table.focus()
78 def refresh_data(self) -> None:
79 """Reload data into the table."""
80 # Get all data and store it
81 self._all_data = self.get_data()
82 self._apply_filter()
84 def _apply_filter(self) -> None:
85 """Apply current filter to the data and update table."""
86 table = self.query_one(DataTable)
87 table.clear()
89 # Filter data based on filter text
90 filtered_data = self._all_data
91 if self._filter_text:
92 filter_lower = self._filter_text.lower()
93 filtered_data = [
94 row
95 for row in self._all_data
96 if any(filter_lower in str(v).lower() for v in row.values())
97 ]
99 # Sort data if a sort column is set
100 if self._sort_column:
101 filtered_data = sorted(
102 filtered_data,
103 key=lambda row: str(row.get(self._sort_column, "")),
104 reverse=self._sort_reverse,
105 )
107 # Add filtered rows to table
108 for row in filtered_data:
109 # Use first column value as row key (should be unique ID)
110 row_key = next(iter(row.values())) if row else None
111 table.add_row(*row.values(), key=row_key)
113 @on(DataTable.HeaderSelected)
114 def on_header_selected(self, event: DataTable.HeaderSelected) -> None:
115 """Handle column header click for sorting."""
116 columns = self.get_columns()
117 column_name = columns[event.column_index]
119 # Toggle sort direction if clicking same column, otherwise sort ascending
120 if self._sort_column == column_name:
121 self._sort_reverse = not self._sort_reverse
122 else:
123 self._sort_column = column_name
124 self._sort_reverse = False
126 self._apply_filter()
128 @on(Input.Changed, "#filter-input")
129 def on_filter_changed(self, event: Input.Changed) -> None:
130 """Handle filter input changes."""
131 self._filter_text = event.value
132 self._apply_filter()
134 def action_focus_filter(self) -> None:
135 """Focus the filter input."""
136 self.query_one("#filter-input", Input).focus()
138 def action_cycle_sort(self) -> None:
139 """Cycle through sort columns (press 's' repeatedly to change column)."""
140 columns = self.get_columns()
141 if not columns:
142 return
144 if self._sort_column is None:
145 # Start sorting by first column
146 self._sort_column = columns[0]
147 self._sort_reverse = False
148 elif self._sort_column in columns:
149 current_index = columns.index(self._sort_column)
150 if self._sort_reverse:
151 # Move to next column, ascending
152 next_index = (current_index + 1) % len(columns)
153 self._sort_column = columns[next_index]
154 self._sort_reverse = False
155 else:
156 # Toggle to descending for current column
157 self._sort_reverse = True
158 else:
159 # Fallback: start from first column
160 self._sort_column = columns[0]
161 self._sort_reverse = False
163 self._apply_filter()
165 # Show notification about current sort
166 direction = "↓" if self._sort_reverse else "↑"
167 self.app.notify(f"Sorting by: {self._sort_column} {direction}", timeout=1)
169 @abstractmethod
170 def get_columns(self) -> list[str]:
171 """
172 Get column headers for the table.
174 Returns
175 -------
176 list[str]
177 Column header names
178 """
180 @abstractmethod
181 def get_data(self) -> list[dict]:
182 """
183 Get data rows for the table.
185 Returns
186 -------
187 list[dict]
188 List of row dictionaries (column_name -> value)
189 """
191 @on(DataTable.RowSelected)
192 def on_row_selected_event(self, event: DataTable.RowSelected) -> None:
193 """Handle row selection from table."""
194 # Get row data from the table using cursor_row
195 table = self.query_one(DataTable)
196 row_data = {}
197 columns = self.get_columns()
199 # Get the row values from the table
200 row_values = table.get_row_at(event.cursor_row)
201 for i, column in enumerate(columns):
202 row_data[column] = row_values[i]
204 self.on_row_selected(event.row_key.value, row_data)
206 @abstractmethod
207 def on_row_selected(self, row_key, row_data: dict) -> None:
208 """
209 Handle row selection.
211 Parameters
212 ----------
213 row_key
214 Row key (typically primary key value)
215 row_data : dict
216 Dictionary mapping column names to values
217 """
219 def action_add(self) -> None:
220 """Handle add action (default: no-op, override in subclass)."""
222 def action_edit(self) -> None:
223 """Handle edit action (default: edit selected row)."""
224 table = self.query_one(DataTable)
225 if table.cursor_row is not None and table.row_count > 0:
226 # Trigger row selected event for current row
227 row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
228 row_data = {}
229 columns = self.get_columns()
230 cursor_row = table.cursor_row
232 for i, column in enumerate(columns):
233 row_data[column] = table.get_row_at(cursor_row)[i]
235 self.on_row_selected(row_key, row_data)
237 def action_delete(self) -> None:
238 """Handle delete action (default: no-op, override in subclass)."""
240 def action_refresh(self) -> None:
241 """Handle refresh action."""
242 self.refresh_data()
244 def action_quit(self) -> None:
245 """Handle quit action."""
246 self.app.exit()
248 def action_help(self) -> None:
249 """Show help screen."""
250 self.app.action_help()
253class BaseFormScreen(Screen):
254 """
255 Base screen for add/edit forms.
257 Subclasses must implement:
258 - get_form_fields() -> ComposeResult: Yield form field widgets
259 - validate_form() -> dict[str, str]: Validate and return errors
260 - on_save(data: dict): Handle save action
262 Provides:
263 - Form layout with save/cancel buttons
264 - Validation on save
265 - Header and footer
266 """
268 BINDINGS: ClassVar = [
269 ("ctrl+s", "save", "Save"),
270 ("escape", "cancel", "Cancel"),
271 ("?", "help", "Help"),
272 ]
274 def __init__(self, title: str = "Form", **kwargs):
275 """
276 Initialize form screen.
278 Parameters
279 ----------
280 title : str
281 Screen title
282 **kwargs
283 Additional arguments passed to Screen
284 """
285 super().__init__(**kwargs)
286 self.screen_title = title
288 def compose(self) -> ComposeResult:
289 """Compose the form layout."""
290 yield Header()
292 with Container(id="form-container"):
293 yield Label(self.screen_title, classes="form-title")
295 # Form fields (subclass provides these)
296 with Horizontal(id="form-fields"):
297 yield from self.get_form_fields()
299 # Error display
300 yield Static("", id="form-error", classes="form-error")
302 # Buttons
303 with Horizontal(id="form-buttons"):
304 yield Button("Save (Ctrl+S)", id="save-btn", variant="primary")
305 yield Button("Cancel (Esc)", id="cancel-btn", variant="default")
307 yield Footer()
309 @abstractmethod
310 def get_form_fields(self) -> ComposeResult:
311 """
312 Get form field widgets.
314 Yields
315 ------
316 Widget
317 Form field widgets (typically FormField instances)
318 """
320 @abstractmethod
321 def validate_form(self) -> dict[str, str]:
322 """
323 Validate form data.
325 Returns
326 -------
327 dict[str, str]
328 Dictionary mapping field names to error messages.
329 Empty dict if validation passes.
330 """
332 @abstractmethod
333 def on_save(self, data: dict) -> None:
334 """
335 Handle save action.
337 Parameters
338 ----------
339 data : dict
340 Form data (field_name -> value)
341 """
343 def action_save(self) -> None:
344 """Handle save action with validation."""
345 errors = self.validate_form()
347 if errors:
348 # Show errors
349 error_static = self.query_one("#form-error", Static)
350 error_messages = "\n".join(f" • {msg}" for msg in errors.values())
351 error_static.update(f"Validation errors:\n{error_messages}")
352 error_static.add_class("visible")
353 else:
354 # Clear errors and save
355 error_static = self.query_one("#form-error", Static)
356 error_static.update("")
357 error_static.remove_class("visible")
359 # Collect form data and save
360 data = self.collect_form_data()
361 self.on_save(data)
363 @on(Button.Pressed, "#save-btn")
364 def on_save_button(self) -> None:
365 """Handle save button press."""
366 self.action_save()
368 @on(Button.Pressed, "#cancel-btn")
369 def on_cancel_button(self) -> None:
370 """Handle cancel button press."""
371 self.action_cancel()
373 def action_cancel(self) -> None:
374 """Handle cancel action."""
375 self.app.pop_screen()
377 def action_help(self) -> None:
378 """Show help screen."""
379 self.app.action_help()
381 def collect_form_data(self) -> dict:
382 """
383 Collect data from all form fields.
385 Returns
386 -------
387 dict
388 Field name to value mapping
389 """
390 # Default implementation - override if needed
391 return {}
394class ConfirmDialog(ModalScreen[bool]):
395 """
396 Modal confirmation dialog.
398 Displays a message and Yes/No buttons. Returns True if user confirms,
399 False if they cancel.
401 Parameters
402 ----------
403 message : str
404 Confirmation message to display
405 title : str
406 Dialog title
407 """
409 CSS_PATH: ClassVar = [Path(__file__).parent.parent / "styles" / "base_screens.tcss"]
411 def __init__(self, message: str, title: str = "Confirm", **kwargs):
412 """Initialize confirmation dialog."""
413 super().__init__(**kwargs)
414 self.message = message
415 self.title = title
417 def compose(self) -> ComposeResult:
418 """Compose the dialog layout."""
419 with Vertical(id="dialog"):
420 yield Label(self.title, classes="dialog-title")
421 yield Static(self.message, id="message")
422 with Horizontal(id="buttons"):
423 yield Button("Yes", id="yes-btn", variant="error")
424 yield Button("No", id="no-btn", variant="primary")
426 @on(Button.Pressed, "#yes-btn")
427 def on_yes(self) -> None:
428 """Handle yes button."""
429 self.dismiss(True)
431 @on(Button.Pressed, "#no-btn")
432 def on_no(self) -> None:
433 """Handle no button."""
434 self.dismiss(False)