Coverage for src/prosemark/freewriting/adapters/tui_adapter.py: 94%

206 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-24 18:08 +0000

1"""TUI adapter implementation using Textual framework. 

2 

3This module provides the concrete implementation of the TUI ports 

4using the Textual framework for terminal user interface operations. 

5""" 

6 

7from __future__ import annotations 

8 

9import time 

10from typing import TYPE_CHECKING, Any, ClassVar 

11 

12from textual.app import App, ComposeResult 

13from textual.containers import Container, VerticalScroll 

14from textual.reactive import reactive 

15from textual.widgets import Footer, Header, Input, Static 

16 

17from prosemark.freewriting.domain.exceptions import TUIError, ValidationError 

18 

19if TYPE_CHECKING: # pragma: no cover 

20 from collections.abc import Callable 

21 

22 from prosemark.freewriting.domain.models import FreewriteSession, SessionConfig 

23 from prosemark.freewriting.ports.freewrite_service import FreewriteServicePort 

24 from prosemark.freewriting.ports.tui_adapter import TUIConfig, UIState 

25 

26from prosemark.freewriting.ports.tui_adapter import ( 

27 TUIAdapterPort, 

28 TUIDisplayPort, 

29 TUIEventPort, 

30 UIState, 

31) 

32 

33 

34class FreewritingApp(App[int]): 

35 """Main Textual application for freewriting sessions.""" 

36 

37 CSS = """ 

38 Screen { 

39 layout: vertical; 

40 } 

41 

42 #content_area { 

43 height: 80%; 

44 border: solid $primary; 

45 padding: 1; 

46 } 

47 

48 #input_container { 

49 height: 20%; 

50 border: solid $secondary; 

51 padding: 1; 

52 } 

53 

54 #input_box { 

55 width: 100%; 

56 } 

57 

58 #stats_display { 

59 dock: top; 

60 height: 1; 

61 background: $surface; 

62 color: $text; 

63 text-align: center; 

64 } 

65 

66 .content_line { 

67 margin-bottom: 1; 

68 padding: 0 1; 

69 } 

70 

71 .error_message { 

72 background: $error; 

73 color: $text; 

74 padding: 1; 

75 margin: 1; 

76 } 

77 """ 

78 

79 BINDINGS: ClassVar = [ 

80 ('ctrl+c', 'quit', 'Quit'), 

81 ('ctrl+s', 'pause', 'Pause/Resume'), 

82 ('escape', 'quit', 'Quit'), 

83 ] 

84 

85 # Reactive attributes for real-time updates 

86 current_session: reactive[FreewriteSession | None] = reactive(None) 

87 elapsed_seconds: reactive[int] = reactive(0) 

88 error_message: reactive[str | None] = reactive(None) 

89 

90 def __init__( 

91 self, 

92 session_config: SessionConfig, 

93 tui_adapter: TUIAdapterPort, 

94 **kwargs: Any, # noqa: ANN401 

95 ) -> None: 

96 """Initialize the freewriting TUI application. 

97 

98 Args: 

99 session_config: Configuration for the session. 

100 tui_adapter: TUI adapter for session operations. 

101 **kwargs: Additional arguments passed to App. 

102 

103 """ 

104 super().__init__(**kwargs) 

105 self.session_config = session_config 

106 self.tui_adapter = tui_adapter 

107 self.start_time = time.time() 

108 self.is_paused = False 

109 

110 # Event callbacks 

111 self._input_change_callbacks: list[Callable[[str], None]] = [] 

112 self._input_submit_callbacks: list[Callable[[str], None]] = [] 

113 self._session_pause_callbacks: list[Callable[[], None]] = [] 

114 self._session_resume_callbacks: list[Callable[[], None]] = [] 

115 self._session_exit_callbacks: list[Callable[[], None]] = [] 

116 

117 def compose(self) -> ComposeResult: # noqa: PLR6301 

118 """Create child widgets for the app.""" 

119 yield Header() 

120 yield Static('', id='stats_display') 

121 yield VerticalScroll(id='content_area') 

122 with Container(id='input_container'): 

123 yield Input( 

124 placeholder='Start writing... (Press Enter to add line)', 

125 id='input_box', 

126 ) 

127 yield Footer() 

128 

129 def on_mount(self) -> None: 

130 """Initialize the application after mounting.""" 

131 try: 

132 # Initialize the session 

133 self.current_session = self.tui_adapter.initialize_session(self.session_config) 

134 

135 # Set up the UI 

136 self.title = 'Freewriting Session' 

137 subtitle = f'Target: {self.session_config.target_node or "Daily File"}' 

138 if self.session_config.title: 138 ↛ 140line 138 didn't jump to line 140 because the condition on line 138 was always true

139 subtitle += f' | {self.session_config.title}' 

140 self.sub_title = subtitle 

141 

142 # Focus the input box 

143 self.query_one('#input_box').focus() 

144 

145 # Start the timer 

146 self.set_interval(1.0, self._update_timer) 

147 

148 # Update display 

149 self._update_display() 

150 

151 except (OSError, RuntimeError, ValueError) as e: 

152 self.error_message = f'Failed to initialize session: {e}' 

153 self.exit(1) 

154 

155 def on_input_submitted(self, event: Input.Submitted) -> None: 

156 """Handle ENTER key press in input box.""" 

157 if not self.current_session or self.is_paused: 

158 return 

159 

160 input_widget = event.input 

161 text = input_widget.value 

162 

163 try: 

164 # Submit content through adapter 

165 updated_session = self.tui_adapter.handle_input_submission(self.current_session, text) 

166 self.current_session = updated_session 

167 

168 # Clear input 

169 input_widget.clear() 

170 

171 # Trigger callbacks 

172 for callback in self._input_submit_callbacks: 172 ↛ 173line 172 didn't jump to line 173 because the loop on line 172 never started

173 callback(text) 

174 

175 # Update display 

176 self._update_display() 

177 

178 # Check if goals are met 

179 progress = self.tui_adapter.calculate_progress(self.current_session) 

180 goals_met = progress.get('goals_met', {}) 

181 if any(goals_met.values()): 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 self._show_completion_message(goals_met) 

183 

184 except (OSError, RuntimeError, ValueError) as e: 

185 ui_state = TextualTUIAdapter.handle_error(e, self.current_session) 

186 self.error_message = ui_state.error_message 

187 # Don't exit on content errors, let user continue 

188 

189 def on_input_changed(self, event: Input.Changed) -> None: 

190 """Handle input text changes.""" 

191 # Trigger callbacks for input changes 

192 for callback in self._input_change_callbacks: 

193 callback(event.value) 

194 

195 def action_pause(self) -> None: 

196 """Toggle pause/resume state.""" 

197 if not self.current_session: 

198 return 

199 

200 if self.is_paused: 

201 self.is_paused = False 

202 for callback in self._session_resume_callbacks: 

203 callback() 

204 self.sub_title = self.sub_title.replace(' [PAUSED]', '') 

205 else: 

206 self.is_paused = True 

207 for callback in self._session_pause_callbacks: 

208 callback() 

209 self.sub_title += ' [PAUSED]' 

210 

211 async def action_quit(self) -> None: 

212 """Handle quit action.""" 

213 # Trigger exit callbacks 

214 for callback in self._session_exit_callbacks: 

215 callback() 

216 

217 # Exit with success code 

218 self.exit(0) 

219 

220 def _update_timer(self) -> None: 

221 """Update elapsed time every second.""" 

222 if not self.is_paused and self.current_session: 

223 current_time = time.time() 

224 self.elapsed_seconds = int(current_time - self.start_time) 

225 

226 # Update session with elapsed time 

227 self.current_session = self.current_session.update_elapsed_time(self.elapsed_seconds) 

228 

229 # Update stats display 

230 self._update_stats_display() 

231 

232 def _update_display(self) -> None: 

233 """Update the content display area.""" 

234 if not self.current_session: 

235 return 

236 

237 # Get display content from adapter 

238 display_lines = TextualTUIAdapter.get_display_content(self.current_session, max_lines=1000) 

239 

240 # Update content area 

241 content_area = self.query_one('#content_area') 

242 content_area.remove_children() 

243 

244 for line in display_lines: 

245 content_area.mount(Static(line, classes='content_line')) 

246 

247 # Auto-scroll to bottom 

248 content_area.scroll_end() 

249 

250 def _update_stats_display(self) -> None: 

251 """Update statistics display.""" 

252 if not self.current_session: 

253 return 

254 

255 progress = self.tui_adapter.calculate_progress(self.current_session) 

256 

257 # Format stats string 

258 stats_parts = [] 

259 

260 # Word count 

261 word_count = progress.get('word_count', 0) 

262 stats_parts.append(f'Words: {word_count}') 

263 

264 if self.session_config.word_count_goal: 

265 goal_progress = (word_count / self.session_config.word_count_goal) * 100 

266 stats_parts.append(f'({goal_progress:.0f}%)') 

267 

268 # Time 

269 elapsed = progress.get('elapsed_time', 0) 

270 elapsed_min = elapsed // 60 

271 elapsed_sec = elapsed % 60 

272 stats_parts.append(f'Time: {elapsed_min:02d}:{elapsed_sec:02d}') 

273 

274 if self.session_config.time_limit: 

275 remaining = max(0, self.session_config.time_limit - elapsed) 

276 remaining_min = remaining // 60 

277 remaining_sec = remaining % 60 

278 stats_parts.append(f'(Remaining: {remaining_min:02d}:{remaining_sec:02d})') 

279 

280 stats_text = ' | '.join(stats_parts) 

281 stats_display = self.query_one('#stats_display', Static) 

282 stats_display.update(stats_text) 

283 

284 def _show_completion_message(self, goals_met: dict[str, bool]) -> None: 

285 """Show completion message when goals are met.""" 

286 messages = [] 

287 if goals_met.get('word_count'): 

288 messages.append('Word count goal reached!') 

289 if goals_met.get('time_limit'): 

290 messages.append('Time limit reached!') 

291 

292 if messages: 

293 completion_text = ' '.join(messages) + ' Press Ctrl+C to exit.' 

294 # For now, just update the sub_title with completion message 

295 self.sub_title = completion_text 

296 

297 

298class TextualTUIAdapter(TUIAdapterPort, TUIEventPort, TUIDisplayPort): 

299 """Concrete implementation of TUI ports using Textual framework.""" 

300 

301 def __init__(self, freewrite_service: FreewriteServicePort) -> None: 

302 """Initialize the Textual TUI adapter. 

303 

304 Args: 

305 freewrite_service: Service for freewriting operations. 

306 

307 """ 

308 self._freewrite_service = freewrite_service 

309 self.app_instance: FreewritingApp | None = None 

310 

311 @property 

312 def freewrite_service(self) -> FreewriteServicePort: 

313 """Freewrite service instance for session operations. 

314 

315 Returns: 

316 The freewrite service instance used by this TUI adapter. 

317 

318 """ 

319 return self._freewrite_service 

320 

321 def initialize_session(self, config: SessionConfig) -> FreewriteSession: 

322 """Initialize a new freewriting session. 

323 

324 Args: 

325 config: Session configuration from CLI. 

326 

327 Returns: 

328 Created session object. 

329 

330 Raises: 

331 ValidationError: If configuration is invalid. 

332 

333 """ 

334 try: 

335 return self._freewrite_service.create_session(config) 

336 except Exception as e: 

337 msg = 'Failed to initialize session' 

338 raise ValidationError('session_config', str(config), msg) from e 

339 

340 def handle_input_submission(self, session: FreewriteSession, input_text: str) -> FreewriteSession: 

341 """Handle user pressing ENTER in input box. 

342 

343 Args: 

344 session: Current session state. 

345 input_text: Text from input box. 

346 

347 Returns: 

348 Updated session after content is appended. 

349 

350 Raises: 

351 FileSystemError: If save operation fails. 

352 

353 """ 

354 return self._freewrite_service.append_content(session, input_text) 

355 

356 @staticmethod 

357 def get_display_content(session: FreewriteSession, max_lines: int) -> list[str]: 

358 """Get content lines to display in content area. 

359 

360 Args: 

361 session: Current session. 

362 max_lines: Maximum lines to return (for bottom of file view). 

363 

364 Returns: 

365 List of content lines for display. 

366 

367 """ 

368 # Return bottom portion of content lines for "tail" view 

369 content_lines = session.content_lines 

370 if len(content_lines) <= max_lines: 

371 return content_lines 

372 return content_lines[-max_lines:] 

373 

374 def calculate_progress(self, session: FreewriteSession) -> dict[str, Any]: 

375 """Calculate session progress metrics. 

376 

377 Args: 

378 session: Current session. 

379 

380 Returns: 

381 Dictionary with progress information. 

382 

383 """ 

384 return self._freewrite_service.get_session_stats(session) 

385 

386 @staticmethod 

387 def handle_error(error: Exception, session: FreewriteSession) -> UIState: 

388 """Handle errors during session operations. 

389 

390 Args: 

391 error: The exception that occurred. 

392 session: Current session state. 

393 

394 Returns: 

395 Updated UI state with error information. 

396 

397 """ 

398 error_message = f'Error: {error}' 

399 

400 return UIState( 

401 session=session, 

402 input_text='', 

403 display_lines=TextualTUIAdapter.get_display_content(session, 1000), 

404 word_count=session.current_word_count, 

405 elapsed_time=session.elapsed_time, 

406 time_remaining=(session.time_limit - session.elapsed_time if session.time_limit else None), 

407 progress_percent=None, 

408 error_message=error_message, 

409 is_paused=False, 

410 ) 

411 

412 def on_input_change(self, callback: Callable[[str], None]) -> None: 

413 """Register callback for input text changes. 

414 

415 Args: 

416 callback: Function to call when input changes. 

417 

418 """ 

419 if self.app_instance: 

420 self.app_instance._input_change_callbacks.append(callback) # noqa: SLF001 

421 

422 def on_input_submit(self, callback: Callable[[str], None]) -> None: 

423 """Register callback for input submission (ENTER key). 

424 

425 Args: 

426 callback: Function to call when input is submitted. 

427 

428 """ 

429 if self.app_instance: 

430 self.app_instance._input_submit_callbacks.append(callback) # noqa: SLF001 

431 

432 def on_session_pause(self, callback: Callable[[], None]) -> None: 

433 """Register callback for session pause events. 

434 

435 Args: 

436 callback: Function to call when session is paused. 

437 

438 """ 

439 if self.app_instance: 

440 self.app_instance._session_pause_callbacks.append(callback) # noqa: SLF001 

441 

442 def on_session_resume(self, callback: Callable[[], None]) -> None: 

443 """Register callback for session resume events. 

444 

445 Args: 

446 callback: Function to call when session is resumed. 

447 

448 """ 

449 if self.app_instance: 

450 self.app_instance._session_resume_callbacks.append(callback) # noqa: SLF001 

451 

452 def on_session_exit(self, callback: Callable[[], None]) -> None: 

453 """Register callback for session exit events. 

454 

455 Args: 

456 callback: Function to call when session exits. 

457 

458 """ 

459 if self.app_instance: 

460 self.app_instance._session_exit_callbacks.append(callback) # noqa: SLF001 

461 

462 def update_content_area(self, _lines: list[str]) -> None: 

463 """Update the main content display area. 

464 

465 Args: 

466 lines: Content lines to display. 

467 

468 """ 

469 if self.app_instance and self.app_instance.current_session: 

470 self.app_instance._update_display() # noqa: SLF001 

471 

472 def update_stats_display(self, _stats: dict[str, Any]) -> None: 

473 """Update statistics display (word count, timer, etc.). 

474 

475 Args: 

476 stats: Statistics to display. 

477 

478 """ 

479 if self.app_instance: 

480 self.app_instance._update_stats_display() # noqa: SLF001 

481 

482 def clear_input_area(self) -> None: 

483 """Clear the input text box.""" 

484 if self.app_instance: 

485 input_box = self.app_instance.query_one('#input_box', Input) 

486 input_box.clear() 

487 

488 def show_error_message(self, message: str) -> None: 

489 """Display error message to user. 

490 

491 Args: 

492 message: Error message to show. 

493 

494 """ 

495 if self.app_instance: 

496 self.app_instance.error_message = message 

497 

498 def hide_error_message(self) -> None: 

499 """Hide any currently displayed error message.""" 

500 if self.app_instance: 

501 self.app_instance.error_message = None 

502 

503 @staticmethod 

504 def set_theme(theme_name: str) -> None: 

505 """Apply UI theme. 

506 

507 Args: 

508 theme_name: Name of theme to apply. 

509 

510 """ 

511 # Textual theme switching would be implemented here 

512 # For now, we'll just validate the theme name 

513 valid_themes = ['dark', 'light'] 

514 if theme_name not in valid_themes: 

515 msg = f'Invalid theme: {theme_name}. Valid themes: {valid_themes}' 

516 raise TUIError('theme', 'set_theme', msg, recoverable=True) 

517 

518 def run_tui(self, session_config: SessionConfig, tui_config: TUIConfig | None = None) -> int: 

519 """Run the TUI application. 

520 

521 Args: 

522 session_config: Session configuration. 

523 tui_config: Optional TUI configuration. 

524 

525 Returns: 

526 Exit code (0 for success). 

527 

528 """ 

529 try: 

530 # Apply theme if specified 

531 if tui_config and tui_config.theme: 

532 TextualTUIAdapter.set_theme(tui_config.theme) 

533 

534 # Create and run the app 

535 app = FreewritingApp(session_config, self) 

536 self.app_instance = app 

537 exit_code = app.run() 

538 except Exception as e: 

539 msg = f'TUI application failed: {e}' 

540 raise TUIError('application', 'run', msg, recoverable=False) from e 

541 else: 

542 return exit_code if exit_code is not None else 0