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

232 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-30 23:09 +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.binding import Binding 

14from textual.containers import Container, VerticalScroll 

15from textual.events import Key 

16from textual.reactive import reactive 

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

18 

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

20 

21if TYPE_CHECKING: # pragma: no cover 

22 from collections.abc import Callable 

23 

24 from textual.events import Key 

25 

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

27 from prosemark.freewriting.ports.freewrite_service import FreewriteServicePort 

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

29 

30from prosemark.freewriting.ports.tui_adapter import ( 

31 TUIAdapterPort, 

32 TUIDisplayPort, 

33 TUIEventPort, 

34 UIState, 

35) 

36 

37 

38class EmacsInput(Input): 

39 """Input widget with emacs-style key bindings.""" 

40 

41 def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 

42 """Initialize the EmacsInput widget. 

43 

44 Args: 

45 *args: Positional arguments passed to Input. 

46 **kwargs: Keyword arguments passed to Input. 

47 

48 """ 

49 super().__init__(*args, **kwargs) 

50 self._kill_buffer: str = '' 

51 self._escape_pressed: bool = False 

52 

53 async def _on_key(self, event: Key) -> None: # pragma: no cover 

54 """Handle key press events with emacs bindings. 

55 

56 Args: 

57 event: The key event to handle. 

58 

59 """ 

60 key = event.key # pragma: no cover 

61 

62 # Handle the case where escape and the next key come as separate events # pragma: no cover 

63 if key == 'escape': # pragma: no cover 

64 self._escape_pressed = True # pragma: no cover 

65 event.prevent_default() # pragma: no cover 

66 event.stop() # pragma: no cover 

67 return # pragma: no cover 

68 

69 # Check if this is the second part of an escape sequence # pragma: no cover 

70 if self._escape_pressed: # pragma: no cover 

71 self._escape_pressed = False # pragma: no cover 

72 if key == 'd': # pragma: no cover 

73 event.prevent_default() # pragma: no cover 

74 event.stop() # pragma: no cover 

75 self._delete_word_forward() # pragma: no cover 

76 return # pragma: no cover 

77 if key == 'f': # pragma: no cover 

78 event.prevent_default() # pragma: no cover 

79 event.stop() # pragma: no cover 

80 self._move_word_forward() # pragma: no cover 

81 return # pragma: no cover 

82 if key == 'b': # pragma: no cover 

83 event.prevent_default() # pragma: no cover 

84 event.stop() # pragma: no cover 

85 self._move_word_backward() # pragma: no cover 

86 return # pragma: no cover 

87 if key == 'backspace': # pragma: no cover 

88 event.prevent_default() # pragma: no cover 

89 event.stop() # pragma: no cover 

90 self._delete_word_backward() # pragma: no cover 

91 return # pragma: no cover 

92 # If it's not a recognized meta sequence, fall through to normal handling # pragma: no cover 

93 

94 # Combine all handlers into one dictionary for simpler lookup # pragma: no cover 

95 all_handlers = { # pragma: no cover 

96 # Ctrl key combinations # pragma: no cover 

97 'ctrl+b': self._move_char_backward, # pragma: no cover 

98 'ctrl+f': self._move_char_forward, # pragma: no cover 

99 'ctrl+a': self._move_line_start, # pragma: no cover 

100 'ctrl+e': self._move_line_end, # pragma: no cover 

101 'ctrl+d': self._delete_char_forward, # pragma: no cover 

102 'ctrl+k': self._kill_to_line_end, # pragma: no cover 

103 'ctrl+y': self._yank_killed_text, # pragma: no cover 

104 'ctrl+w': self._kill_word_backward, # pragma: no cover 

105 # Meta/Alt key combinations for terminals that send them as compound keys # pragma: no cover 

106 'escape+b': self._move_word_backward, # pragma: no cover 

107 'alt+b': self._move_word_backward, # pragma: no cover 

108 'escape+f': self._move_word_forward, # pragma: no cover 

109 'alt+f': self._move_word_forward, # pragma: no cover 

110 'escape+d': self._delete_word_forward, # pragma: no cover 

111 'alt+d': self._delete_word_forward, # pragma: no cover 

112 'meta+d': self._delete_word_forward, # pragma: no cover 

113 'escape+delete': self._delete_word_forward, # pragma: no cover 

114 'alt+delete': self._delete_word_forward, # pragma: no cover 

115 'escape+backspace': self._delete_word_backward, # pragma: no cover 

116 'alt+backspace': self._delete_word_backward, # pragma: no cover 

117 'meta+backspace': self._delete_word_backward, # pragma: no cover 

118 } # pragma: no cover 

119 

120 # Try to handle the key # pragma: no cover 

121 handler = all_handlers.get(key) # pragma: no cover 

122 if handler: # pragma: no cover 

123 # Prevent the event from bubbling up to app-level handlers # pragma: no cover 

124 event.prevent_default() # pragma: no cover 

125 event.stop() # pragma: no cover 

126 handler() # pragma: no cover 

127 else: # pragma: no cover 

128 # Pass through to default handler # pragma: no cover 

129 await super()._on_key(event) # pragma: no cover 

130 

131 def _move_char_backward(self) -> None: 

132 """Move cursor backward one character.""" 

133 try: 

134 current_pos = self.cursor_position # pragma: no cover 

135 if current_pos > 0: # pragma: no cover 

136 self.cursor_position = current_pos - 1 # pragma: no cover 

137 except Exception: # pragma: no cover # noqa: BLE001,S110 

138 # Handle case when reactive properties aren't available (e.g., in tests) 

139 pass 

140 

141 def _move_char_forward(self) -> None: 

142 """Move cursor forward one character.""" 

143 try: 

144 current_pos = self.cursor_position # pragma: no cover 

145 value_len = len(self.value) # pragma: no cover 

146 if current_pos < value_len: # pragma: no cover 

147 self.cursor_position = current_pos + 1 # pragma: no cover 

148 except Exception: # pragma: no cover # noqa: BLE001,S110 

149 # Handle case when reactive properties aren't available (e.g., in tests) 

150 pass 

151 

152 def _move_line_start(self) -> None: 

153 """Move cursor to beginning of line.""" 

154 try: # noqa: SIM105 

155 self.cursor_position = 0 # pragma: no cover 

156 except Exception: # pragma: no cover # noqa: BLE001,S110 

157 # Handle case when reactive properties aren't available (e.g., in tests) 

158 pass 

159 

160 def _move_line_end(self) -> None: 

161 """Move cursor to end of line.""" 

162 try: # noqa: SIM105 

163 self.cursor_position = len(self.value) # pragma: no cover 

164 except Exception: # pragma: no cover # noqa: BLE001,S110 

165 # Handle case when reactive properties aren't available (e.g., in tests) 

166 pass 

167 

168 def _delete_char_forward(self) -> None: 

169 """Delete character at cursor position or quit if buffer is empty.""" 

170 try: 

171 pos = self.cursor_position 

172 value = self.value # pragma: no cover 

173 if pos < len(value): # pragma: no cover 

174 # Delete character at cursor position # pragma: no cover 

175 self.value = value[:pos] + value[pos + 1 :] # pragma: no cover 

176 elif not value: # Buffer is completely empty, trigger quit # pragma: no cover 

177 self.app.exit() # pragma: no cover 

178 except Exception: # pragma: no cover # noqa: BLE001,S110 

179 # Handle case when reactive properties aren't available (e.g., in tests) 

180 pass 

181 

182 def _kill_to_line_end(self) -> None: 

183 """Kill text from cursor to end of line.""" 

184 try: 

185 pos = self.cursor_position 

186 value = self.value # pragma: no cover 

187 self._kill_buffer = value[pos:] # pragma: no cover 

188 self.value = value[:pos] # pragma: no cover 

189 except Exception: # pragma: no cover # noqa: BLE001,S110 

190 # Handle case when reactive properties aren't available (e.g., in tests) 

191 pass 

192 

193 def _yank_killed_text(self) -> None: 

194 """Yank (paste) previously killed text.""" 

195 if self._kill_buffer: # pragma: no cover 

196 try: 

197 pos = self.cursor_position # pragma: no cover 

198 value = self.value # pragma: no cover 

199 new_value = value[:pos] + self._kill_buffer + value[pos:] # pragma: no cover 

200 self.value = new_value # pragma: no cover 

201 self.cursor_position = pos + len(self._kill_buffer) # pragma: no cover 

202 except Exception: # pragma: no cover # noqa: BLE001,S110 

203 # Handle case when reactive properties aren't available (e.g., in tests) 

204 pass 

205 

206 def _kill_word_backward(self) -> None: 

207 """Kill word backward from cursor.""" 

208 try: 

209 pos = self.cursor_position # pragma: no cover 

210 value = self.value # pragma: no cover 

211 if pos > 0: # pragma: no cover 

212 text_before = value[:pos].rstrip() # pragma: no cover 

213 last_space = text_before.rfind(' ') # pragma: no cover 

214 if last_space == -1: # pragma: no cover 

215 self._kill_buffer = value[:pos] # pragma: no cover 

216 self.value = value[pos:] # pragma: no cover 

217 self.cursor_position = 0 # pragma: no cover 

218 else: # pragma: no cover 

219 kill_start = last_space + 1 # pragma: no cover 

220 self._kill_buffer = value[kill_start:pos] # pragma: no cover 

221 self.value = value[:kill_start] + value[pos:] # pragma: no cover 

222 self.cursor_position = kill_start # pragma: no cover 

223 except Exception: # pragma: no cover # noqa: BLE001,S110 

224 # Handle case when reactive properties aren't available (e.g., in tests) 

225 pass 

226 

227 def _move_word_backward(self) -> None: 

228 """Move cursor backward one word.""" 

229 try: 

230 pos = self.cursor_position # pragma: no cover 

231 value = self.value # pragma: no cover 

232 if pos > 0: # pragma: no cover 

233 # Skip trailing spaces # pragma: no cover 

234 while pos > 0 and value[pos - 1] == ' ': # pragma: no cover 

235 pos -= 1 # pragma: no cover 

236 # Move to start of word # pragma: no cover 

237 while pos > 0 and value[pos - 1] != ' ': # pragma: no cover 

238 pos -= 1 # pragma: no cover 

239 self.cursor_position = pos # pragma: no cover 

240 except Exception: # pragma: no cover # noqa: BLE001,S110 

241 # Handle case when reactive properties aren't available (e.g., in tests) 

242 pass 

243 

244 def _move_word_forward(self) -> None: 

245 """Move cursor forward one word.""" 

246 try: 

247 pos = self.cursor_position # pragma: no cover 

248 value = self.value # pragma: no cover 

249 value_len = len(value) # pragma: no cover 

250 if pos < value_len: # pragma: no cover 

251 # Skip current word # pragma: no cover 

252 while pos < value_len and value[pos] != ' ': # pragma: no cover 

253 pos += 1 # pragma: no cover 

254 # Skip spaces # pragma: no cover 

255 while pos < value_len and value[pos] == ' ': # pragma: no cover 

256 pos += 1 # pragma: no cover 

257 self.cursor_position = pos # pragma: no cover 

258 except Exception: # pragma: no cover # noqa: BLE001,S110 

259 # Handle case when reactive properties aren't available (e.g., in tests) 

260 pass 

261 

262 def _delete_word_forward(self) -> None: 

263 """Delete word forward from cursor.""" 

264 try: 

265 pos = self.cursor_position # pragma: no cover 

266 value = self.value # pragma: no cover 

267 value_len = len(value) # pragma: no cover 

268 if pos < value_len: # pragma: no cover 

269 end_pos = pos # pragma: no cover 

270 # Skip to end of current word # pragma: no cover 

271 while end_pos < value_len and value[end_pos] != ' ': # pragma: no cover 

272 end_pos += 1 # pragma: no cover 

273 self._kill_buffer = value[pos:end_pos] # pragma: no cover 

274 self.value = value[:pos] + value[end_pos:] # pragma: no cover 

275 except Exception: # pragma: no cover # noqa: BLE001,S110 

276 # Handle case when reactive properties aren't available (e.g., in tests) 

277 pass 

278 

279 def _delete_word_backward(self) -> None: 

280 """Delete word backward from cursor.""" 

281 try: # pragma: no cover 

282 pos = self.cursor_position # pragma: no cover 

283 value = self.value # pragma: no cover 

284 if pos > 0: # pragma: no cover 

285 start_pos = pos # pragma: no cover 

286 # Skip spaces # pragma: no cover 

287 while start_pos > 0 and value[start_pos - 1] == ' ': # pragma: no cover 

288 start_pos -= 1 # pragma: no cover 

289 # Move to start of word # pragma: no cover 

290 while start_pos > 0 and value[start_pos - 1] != ' ': # pragma: no cover 

291 start_pos -= 1 # pragma: no cover 

292 self._kill_buffer = value[start_pos:pos] # pragma: no cover 

293 self.value = value[:start_pos] + value[pos:] # pragma: no cover 

294 self.cursor_position = start_pos # pragma: no cover 

295 except Exception: # pragma: no cover # noqa: BLE001,S110 

296 # Handle case when reactive properties aren't available (e.g., in tests) 

297 pass 

298 

299 

300class FreewritingApp(App[int]): 

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

302 

303 CSS = """ 

304 Screen { 

305 layout: vertical; 

306 } 

307 

308 #content_area { 

309 height: 80%; 

310 border: solid $primary; 

311 padding: 1; 

312 } 

313 

314 #input_container { 

315 height: 20%; 

316 border: solid $secondary; 

317 padding: 1; 

318 } 

319 

320 #input_box { 

321 width: 100%; 

322 } 

323 

324 #stats_display { 

325 dock: top; 

326 height: 1; 

327 background: $surface; 

328 color: $text; 

329 text-align: center; 

330 } 

331 

332 .content_line { 

333 padding: 0 1; 

334 } 

335 

336 .error_message { 

337 background: $error; 

338 color: $text; 

339 padding: 1; 

340 margin: 1; 

341 } 

342 """ 

343 

344 BINDINGS: ClassVar = [ 

345 Binding('ctrl+c', 'quit', 'Quit', show=True, priority=True), 

346 Binding('ctrl+s', 'pause', 'Pause/Resume', show=True), 

347 ] 

348 

349 # Reactive attributes for real-time updates 

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

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

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

353 

354 def __init__( 

355 self, 

356 session_config: SessionConfig, 

357 tui_adapter: TUIAdapterPort, 

358 **kwargs: Any, # noqa: ANN401 

359 ) -> None: 

360 """Initialize the freewriting TUI application. 

361 

362 Args: 

363 session_config: Configuration for the session. 

364 tui_adapter: TUI adapter for session operations. 

365 **kwargs: Additional arguments passed to App. 

366 

367 """ 

368 super().__init__(**kwargs) 

369 self.session_config = session_config 

370 self.tui_adapter = tui_adapter 

371 self.start_time = time.time() 

372 self.is_paused = False 

373 self.pause_start_time: float | None = None 

374 self.total_paused_time = 0.0 

375 

376 # Event callbacks 

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

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

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

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

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

382 

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

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

385 yield Header() # pragma: no cover 

386 yield Static('', id='stats_display') # pragma: no cover 

387 yield VerticalScroll(id='content_area') # pragma: no cover 

388 with Container(id='input_container'): # pragma: no cover 

389 yield EmacsInput( # pragma: no cover 

390 placeholder='Start writing... (Press Enter to add line)', # pragma: no cover 

391 id='input_box', # pragma: no cover 

392 ) # pragma: no cover 

393 yield Footer() # pragma: no cover 

394 

395 def on_mount(self) -> None: 

396 """Initialize the application after mounting.""" 

397 try: 

398 # Initialize the session 

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

400 

401 # Set up the UI 

402 self.title = 'Freewriting Session' 

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

404 if self.session_config.title: # pragma: no branch 

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

406 self.sub_title = subtitle 

407 

408 # Focus the input box 

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

410 

411 # Start the timer 

412 self.set_interval(1.0, self._update_timer) 

413 

414 # Update display 

415 self._update_display() 

416 

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

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

419 self.exit(1) 

420 

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

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

423 if not self.current_session or self.is_paused: 

424 return 

425 

426 input_widget = event.input 

427 text = input_widget.value 

428 

429 try: 

430 # Submit content through adapter 

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

432 self.current_session = updated_session 

433 

434 # Clear input 

435 input_widget.clear() 

436 

437 # Trigger callbacks 

438 for callback in self._input_submit_callbacks: 

439 callback(text) # pragma: no cover 

440 

441 # Update display 

442 self._update_display() 

443 

444 # Check if goals are met 

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

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

447 if any(goals_met.values()): 

448 self._show_completion_message(goals_met) # pragma: no cover 

449 

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

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

452 self.error_message = ui_state.error_message 

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

454 

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

456 """Handle input text changes.""" 

457 # Trigger callbacks for input changes 

458 for callback in self._input_change_callbacks: 

459 callback(event.value) 

460 

461 def action_pause(self) -> None: 

462 """Toggle pause/resume state.""" 

463 if not self.current_session: 

464 return 

465 

466 if self.is_paused: 

467 # Resume: calculate and accumulate paused time 

468 if self.pause_start_time is not None: 

469 self.total_paused_time += time.time() - self.pause_start_time 

470 self.pause_start_time = None 

471 self.is_paused = False 

472 for callback in self._session_resume_callbacks: 

473 callback() 

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

475 else: 

476 # Pause: record when pause started 

477 self.is_paused = True 

478 self.pause_start_time = time.time() 

479 for callback in self._session_pause_callbacks: 

480 callback() 

481 self.sub_title += ' [PAUSED]' 

482 

483 async def action_quit(self) -> None: 

484 """Handle quit action.""" 

485 # Trigger exit callbacks 

486 for callback in self._session_exit_callbacks: # pragma: no cover 

487 callback() # pragma: no cover 

488 

489 # Exit with success code 

490 self.exit(0) # pragma: no cover 

491 

492 def _update_timer(self) -> None: 

493 """Update elapsed time every second.""" 

494 if not self.is_paused and self.current_session: 

495 current_time = time.time() 

496 # Calculate elapsed time excluding paused time 

497 self.elapsed_seconds = int(current_time - self.start_time - self.total_paused_time) 

498 

499 # Update session with elapsed time 

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

501 

502 # Update stats display 

503 self._update_stats_display() 

504 

505 def _update_display(self) -> None: 

506 """Update the content display area.""" 

507 if not self.current_session: 

508 return 

509 

510 # Get display content from adapter 

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

512 

513 # Update content area 

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

515 content_area.remove_children() 

516 

517 for line in display_lines: 

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

519 

520 # Auto-scroll to bottom 

521 content_area.scroll_end() 

522 

523 def _update_stats_display(self) -> None: 

524 """Update statistics display.""" 

525 if not self.current_session: 

526 return 

527 

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

529 

530 # Format stats string 

531 stats_parts = [] 

532 

533 # Word count 

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

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

536 

537 if self.session_config.word_count_goal: 

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

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

540 

541 # Time 

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

543 elapsed_min = elapsed // 60 

544 elapsed_sec = elapsed % 60 

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

546 

547 if self.session_config.time_limit: 

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

549 remaining_min = remaining // 60 

550 remaining_sec = remaining % 60 

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

552 

553 stats_text = ' | '.join(stats_parts) 

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

555 stats_display.update(stats_text) 

556 

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

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

559 messages = [] 

560 if goals_met.get('word_count'): 

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

562 if goals_met.get('time_limit'): 

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

564 

565 if messages: 

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

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

568 self.sub_title = completion_text 

569 

570 

571class TextualTUIAdapter(TUIAdapterPort, TUIEventPort, TUIDisplayPort): 

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

573 

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

575 """Initialize the Textual TUI adapter. 

576 

577 Args: 

578 freewrite_service: Service for freewriting operations. 

579 

580 """ 

581 self._freewrite_service = freewrite_service 

582 self.app_instance: FreewritingApp | None = None 

583 

584 @property 

585 def freewrite_service(self) -> FreewriteServicePort: 

586 """Freewrite service instance for session operations. 

587 

588 Returns: 

589 The freewrite service instance used by this TUI adapter. 

590 

591 """ 

592 return self._freewrite_service 

593 

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

595 """Initialize a new freewriting session. 

596 

597 Args: 

598 config: Session configuration from CLI. 

599 

600 Returns: 

601 Created session object. 

602 

603 Raises: 

604 ValidationError: If configuration is invalid. 

605 

606 """ 

607 try: 

608 return self._freewrite_service.create_session(config) 

609 except Exception as e: 

610 msg = 'Failed to initialize session' 

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

612 

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

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

615 

616 Args: 

617 session: Current session state. 

618 input_text: Text from input box. 

619 

620 Returns: 

621 Updated session after content is appended. 

622 

623 Raises: 

624 FileSystemError: If save operation fails. 

625 

626 """ 

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

628 

629 @staticmethod 

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

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

632 

633 Args: 

634 session: Current session. 

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

636 

637 Returns: 

638 List of content lines for display. 

639 

640 """ 

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

642 content_lines = session.content_lines 

643 if len(content_lines) <= max_lines: 

644 return content_lines 

645 return content_lines[-max_lines:] 

646 

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

648 """Calculate session progress metrics. 

649 

650 Args: 

651 session: Current session. 

652 

653 Returns: 

654 Dictionary with progress information. 

655 

656 """ 

657 return self._freewrite_service.get_session_stats(session) 

658 

659 @staticmethod 

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

661 """Handle errors during session operations. 

662 

663 Args: 

664 error: The exception that occurred. 

665 session: Current session state. 

666 

667 Returns: 

668 Updated UI state with error information. 

669 

670 """ 

671 error_message = f'Error: {error}' 

672 

673 return UIState( 

674 session=session, 

675 input_text='', 

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

677 word_count=session.current_word_count, 

678 elapsed_time=session.elapsed_time, 

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

680 progress_percent=None, 

681 error_message=error_message, 

682 is_paused=False, 

683 ) 

684 

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

686 """Register callback for input text changes. 

687 

688 Args: 

689 callback: Function to call when input changes. 

690 

691 """ 

692 if self.app_instance: 

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

694 

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

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

697 

698 Args: 

699 callback: Function to call when input is submitted. 

700 

701 """ 

702 if self.app_instance: 

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

704 

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

706 """Register callback for session pause events. 

707 

708 Args: 

709 callback: Function to call when session is paused. 

710 

711 """ 

712 if self.app_instance: 

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

714 

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

716 """Register callback for session resume events. 

717 

718 Args: 

719 callback: Function to call when session is resumed. 

720 

721 """ 

722 if self.app_instance: 

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

724 

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

726 """Register callback for session exit events. 

727 

728 Args: 

729 callback: Function to call when session exits. 

730 

731 """ 

732 if self.app_instance: 

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

734 

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

736 """Update the main content display area. 

737 

738 Args: 

739 lines: Content lines to display. 

740 

741 """ 

742 if self.app_instance and self.app_instance.current_session: 

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

744 

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

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

747 

748 Args: 

749 stats: Statistics to display. 

750 

751 """ 

752 if self.app_instance: 

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

754 

755 def clear_input_area(self) -> None: 

756 """Clear the input text box.""" 

757 if self.app_instance: 

758 input_box = self.app_instance.query_one('#input_box', EmacsInput) 

759 input_box.clear() 

760 

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

762 """Display error message to user. 

763 

764 Args: 

765 message: Error message to show. 

766 

767 """ 

768 if self.app_instance: 

769 self.app_instance.error_message = message 

770 

771 def hide_error_message(self) -> None: 

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

773 if self.app_instance: 

774 self.app_instance.error_message = None 

775 

776 @staticmethod 

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

778 """Apply UI theme. 

779 

780 Args: 

781 theme_name: Name of theme to apply. 

782 

783 """ 

784 # Textual theme switching would be implemented here 

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

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

787 if theme_name not in valid_themes: 

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

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

790 

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

792 """Run the TUI application. 

793 

794 Args: 

795 session_config: Session configuration. 

796 tui_config: Optional TUI configuration. 

797 

798 Returns: 

799 Exit code (0 for success). 

800 

801 """ 

802 try: 

803 # Apply theme if specified 

804 if tui_config and tui_config.theme: 

805 TextualTUIAdapter.set_theme(tui_config.theme) 

806 

807 # Create and run the app 

808 app = FreewritingApp(session_config, self) 

809 self.app_instance = app 

810 exit_code = app.run() 

811 except Exception as e: 

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

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

814 else: 

815 return exit_code if exit_code is not None else 0