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

1""" 

2Base screen classes for NexusLIMS TUI applications. 

3 

4Provides reusable screen patterns for common UI tasks like list views, 

5forms, and confirmation dialogs. 

6""" 

7 

8from abc import abstractmethod 

9from pathlib import Path 

10from typing import ClassVar 

11 

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 

17 

18 

19class BaseListScreen(Screen): 

20 """ 

21 Base screen for displaying data in a table. 

22 

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 

27 

28 Provides: 

29 - DataTable with navigation and sorting 

30 - Search/filter bar 

31 - Add/Edit/Delete/Quit keybindings 

32 - Header and footer 

33 """ 

34 

35 CSS_PATH: ClassVar = [Path(__file__).parent.parent / "styles" / "base_screens.tcss"] 

36 

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 ] 

47 

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 

55 

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() 

62 

63 def on_mount(self) -> None: 

64 """Set up the data table on mount.""" 

65 table = self.query_one(DataTable) 

66 

67 # Add columns (only if not already added) 

68 if not table.columns: 

69 columns = self.get_columns() 

70 table.add_columns(*columns) 

71 

72 # Load data 

73 self.refresh_data() 

74 

75 # Focus the table (not the filter input) 

76 table.focus() 

77 

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() 

83 

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() 

88 

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 ] 

98 

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 ) 

106 

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) 

112 

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] 

118 

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 

125 

126 self._apply_filter() 

127 

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() 

133 

134 def action_focus_filter(self) -> None: 

135 """Focus the filter input.""" 

136 self.query_one("#filter-input", Input).focus() 

137 

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 

143 

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 

162 

163 self._apply_filter() 

164 

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) 

168 

169 @abstractmethod 

170 def get_columns(self) -> list[str]: 

171 """ 

172 Get column headers for the table. 

173 

174 Returns 

175 ------- 

176 list[str] 

177 Column header names 

178 """ 

179 

180 @abstractmethod 

181 def get_data(self) -> list[dict]: 

182 """ 

183 Get data rows for the table. 

184 

185 Returns 

186 ------- 

187 list[dict] 

188 List of row dictionaries (column_name -> value) 

189 """ 

190 

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() 

198 

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] 

203 

204 self.on_row_selected(event.row_key.value, row_data) 

205 

206 @abstractmethod 

207 def on_row_selected(self, row_key, row_data: dict) -> None: 

208 """ 

209 Handle row selection. 

210 

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 """ 

218 

219 def action_add(self) -> None: 

220 """Handle add action (default: no-op, override in subclass).""" 

221 

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 

231 

232 for i, column in enumerate(columns): 

233 row_data[column] = table.get_row_at(cursor_row)[i] 

234 

235 self.on_row_selected(row_key, row_data) 

236 

237 def action_delete(self) -> None: 

238 """Handle delete action (default: no-op, override in subclass).""" 

239 

240 def action_refresh(self) -> None: 

241 """Handle refresh action.""" 

242 self.refresh_data() 

243 

244 def action_quit(self) -> None: 

245 """Handle quit action.""" 

246 self.app.exit() 

247 

248 def action_help(self) -> None: 

249 """Show help screen.""" 

250 self.app.action_help() 

251 

252 

253class BaseFormScreen(Screen): 

254 """ 

255 Base screen for add/edit forms. 

256 

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 

261 

262 Provides: 

263 - Form layout with save/cancel buttons 

264 - Validation on save 

265 - Header and footer 

266 """ 

267 

268 BINDINGS: ClassVar = [ 

269 ("ctrl+s", "save", "Save"), 

270 ("escape", "cancel", "Cancel"), 

271 ("?", "help", "Help"), 

272 ] 

273 

274 def __init__(self, title: str = "Form", **kwargs): 

275 """ 

276 Initialize form screen. 

277 

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 

287 

288 def compose(self) -> ComposeResult: 

289 """Compose the form layout.""" 

290 yield Header() 

291 

292 with Container(id="form-container"): 

293 yield Label(self.screen_title, classes="form-title") 

294 

295 # Form fields (subclass provides these) 

296 with Horizontal(id="form-fields"): 

297 yield from self.get_form_fields() 

298 

299 # Error display 

300 yield Static("", id="form-error", classes="form-error") 

301 

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") 

306 

307 yield Footer() 

308 

309 @abstractmethod 

310 def get_form_fields(self) -> ComposeResult: 

311 """ 

312 Get form field widgets. 

313 

314 Yields 

315 ------ 

316 Widget 

317 Form field widgets (typically FormField instances) 

318 """ 

319 

320 @abstractmethod 

321 def validate_form(self) -> dict[str, str]: 

322 """ 

323 Validate form data. 

324 

325 Returns 

326 ------- 

327 dict[str, str] 

328 Dictionary mapping field names to error messages. 

329 Empty dict if validation passes. 

330 """ 

331 

332 @abstractmethod 

333 def on_save(self, data: dict) -> None: 

334 """ 

335 Handle save action. 

336 

337 Parameters 

338 ---------- 

339 data : dict 

340 Form data (field_name -> value) 

341 """ 

342 

343 def action_save(self) -> None: 

344 """Handle save action with validation.""" 

345 errors = self.validate_form() 

346 

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") 

358 

359 # Collect form data and save 

360 data = self.collect_form_data() 

361 self.on_save(data) 

362 

363 @on(Button.Pressed, "#save-btn") 

364 def on_save_button(self) -> None: 

365 """Handle save button press.""" 

366 self.action_save() 

367 

368 @on(Button.Pressed, "#cancel-btn") 

369 def on_cancel_button(self) -> None: 

370 """Handle cancel button press.""" 

371 self.action_cancel() 

372 

373 def action_cancel(self) -> None: 

374 """Handle cancel action.""" 

375 self.app.pop_screen() 

376 

377 def action_help(self) -> None: 

378 """Show help screen.""" 

379 self.app.action_help() 

380 

381 def collect_form_data(self) -> dict: 

382 """ 

383 Collect data from all form fields. 

384 

385 Returns 

386 ------- 

387 dict 

388 Field name to value mapping 

389 """ 

390 # Default implementation - override if needed 

391 return {} 

392 

393 

394class ConfirmDialog(ModalScreen[bool]): 

395 """ 

396 Modal confirmation dialog. 

397 

398 Displays a message and Yes/No buttons. Returns True if user confirms, 

399 False if they cancel. 

400 

401 Parameters 

402 ---------- 

403 message : str 

404 Confirmation message to display 

405 title : str 

406 Dialog title 

407 """ 

408 

409 CSS_PATH: ClassVar = [Path(__file__).parent.parent / "styles" / "base_screens.tcss"] 

410 

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 

416 

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") 

425 

426 @on(Button.Pressed, "#yes-btn") 

427 def on_yes(self) -> None: 

428 """Handle yes button.""" 

429 self.dismiss(True) 

430 

431 @on(Button.Pressed, "#no-btn") 

432 def on_no(self) -> None: 

433 """Handle no button.""" 

434 self.dismiss(False)