Coverage for src/prosemark/freewriting/adapters/tui_adapter.py: 100%
232 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-28 19:17 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-28 19:17 +0000
1"""TUI adapter implementation using Textual framework.
3This module provides the concrete implementation of the TUI ports
4using the Textual framework for terminal user interface operations.
5"""
7from __future__ import annotations
9import time
10from typing import TYPE_CHECKING, Any, ClassVar
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
19from prosemark.freewriting.domain.exceptions import TUIError, ValidationError
21if TYPE_CHECKING: # pragma: no cover
22 from collections.abc import Callable
24 from textual.events import Key
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
30from prosemark.freewriting.ports.tui_adapter import (
31 TUIAdapterPort,
32 TUIDisplayPort,
33 TUIEventPort,
34 UIState,
35)
38class EmacsInput(Input):
39 """Input widget with emacs-style key bindings."""
41 def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
42 """Initialize the EmacsInput widget.
44 Args:
45 *args: Positional arguments passed to Input.
46 **kwargs: Keyword arguments passed to Input.
48 """
49 super().__init__(*args, **kwargs)
50 self._kill_buffer: str = ''
51 self._escape_pressed: bool = False
53 async def _on_key(self, event: Key) -> None: # pragma: no cover
54 """Handle key press events with emacs bindings.
56 Args:
57 event: The key event to handle.
59 """
60 key = event.key # pragma: no cover
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
300class FreewritingApp(App[int]):
301 """Main Textual application for freewriting sessions."""
303 CSS = """
304 Screen {
305 layout: vertical;
306 }
308 #content_area {
309 height: 80%;
310 border: solid $primary;
311 padding: 1;
312 }
314 #input_container {
315 height: 20%;
316 border: solid $secondary;
317 padding: 1;
318 }
320 #input_box {
321 width: 100%;
322 }
324 #stats_display {
325 dock: top;
326 height: 1;
327 background: $surface;
328 color: $text;
329 text-align: center;
330 }
332 .content_line {
333 padding: 0 1;
334 }
336 .error_message {
337 background: $error;
338 color: $text;
339 padding: 1;
340 margin: 1;
341 }
342 """
344 BINDINGS: ClassVar = [
345 Binding('ctrl+c', 'quit', 'Quit', show=True, priority=True),
346 Binding('ctrl+s', 'pause', 'Pause/Resume', show=True),
347 ]
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)
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.
362 Args:
363 session_config: Configuration for the session.
364 tui_adapter: TUI adapter for session operations.
365 **kwargs: Additional arguments passed to App.
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
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]] = []
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
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)
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
408 # Focus the input box
409 self.query_one('#input_box').focus()
411 # Start the timer
412 self.set_interval(1.0, self._update_timer)
414 # Update display
415 self._update_display()
417 except (OSError, RuntimeError, ValueError) as e:
418 self.error_message = f'Failed to initialize session: {e}'
419 self.exit(1)
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
426 input_widget = event.input
427 text = input_widget.value
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
434 # Clear input
435 input_widget.clear()
437 # Trigger callbacks
438 for callback in self._input_submit_callbacks:
439 callback(text) # pragma: no cover
441 # Update display
442 self._update_display()
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
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
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)
461 def action_pause(self) -> None:
462 """Toggle pause/resume state."""
463 if not self.current_session:
464 return
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]'
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
489 # Exit with success code
490 self.exit(0) # pragma: no cover
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)
499 # Update session with elapsed time
500 self.current_session = self.current_session.update_elapsed_time(self.elapsed_seconds)
502 # Update stats display
503 self._update_stats_display()
505 def _update_display(self) -> None:
506 """Update the content display area."""
507 if not self.current_session:
508 return
510 # Get display content from adapter
511 display_lines = TextualTUIAdapter.get_display_content(self.current_session, max_lines=1000)
513 # Update content area
514 content_area = self.query_one('#content_area')
515 content_area.remove_children()
517 for line in display_lines:
518 content_area.mount(Static(line, classes='content_line'))
520 # Auto-scroll to bottom
521 content_area.scroll_end()
523 def _update_stats_display(self) -> None:
524 """Update statistics display."""
525 if not self.current_session:
526 return
528 progress = self.tui_adapter.calculate_progress(self.current_session)
530 # Format stats string
531 stats_parts = []
533 # Word count
534 word_count = progress.get('word_count', 0)
535 stats_parts.append(f'Words: {word_count}')
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}%)')
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}')
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})')
553 stats_text = ' | '.join(stats_parts)
554 stats_display = self.query_one('#stats_display', Static)
555 stats_display.update(stats_text)
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!')
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
571class TextualTUIAdapter(TUIAdapterPort, TUIEventPort, TUIDisplayPort):
572 """Concrete implementation of TUI ports using Textual framework."""
574 def __init__(self, freewrite_service: FreewriteServicePort) -> None:
575 """Initialize the Textual TUI adapter.
577 Args:
578 freewrite_service: Service for freewriting operations.
580 """
581 self._freewrite_service = freewrite_service
582 self.app_instance: FreewritingApp | None = None
584 @property
585 def freewrite_service(self) -> FreewriteServicePort:
586 """Freewrite service instance for session operations.
588 Returns:
589 The freewrite service instance used by this TUI adapter.
591 """
592 return self._freewrite_service
594 def initialize_session(self, config: SessionConfig) -> FreewriteSession:
595 """Initialize a new freewriting session.
597 Args:
598 config: Session configuration from CLI.
600 Returns:
601 Created session object.
603 Raises:
604 ValidationError: If configuration is invalid.
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
613 def handle_input_submission(self, session: FreewriteSession, input_text: str) -> FreewriteSession:
614 """Handle user pressing ENTER in input box.
616 Args:
617 session: Current session state.
618 input_text: Text from input box.
620 Returns:
621 Updated session after content is appended.
623 Raises:
624 FileSystemError: If save operation fails.
626 """
627 return self._freewrite_service.append_content(session, input_text)
629 @staticmethod
630 def get_display_content(session: FreewriteSession, max_lines: int) -> list[str]:
631 """Get content lines to display in content area.
633 Args:
634 session: Current session.
635 max_lines: Maximum lines to return (for bottom of file view).
637 Returns:
638 List of content lines for display.
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:]
647 def calculate_progress(self, session: FreewriteSession) -> dict[str, Any]:
648 """Calculate session progress metrics.
650 Args:
651 session: Current session.
653 Returns:
654 Dictionary with progress information.
656 """
657 return self._freewrite_service.get_session_stats(session)
659 @staticmethod
660 def handle_error(error: Exception, session: FreewriteSession) -> UIState:
661 """Handle errors during session operations.
663 Args:
664 error: The exception that occurred.
665 session: Current session state.
667 Returns:
668 Updated UI state with error information.
670 """
671 error_message = f'Error: {error}'
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 )
685 def on_input_change(self, callback: Callable[[str], None]) -> None:
686 """Register callback for input text changes.
688 Args:
689 callback: Function to call when input changes.
691 """
692 if self.app_instance:
693 self.app_instance._input_change_callbacks.append(callback) # noqa: SLF001
695 def on_input_submit(self, callback: Callable[[str], None]) -> None:
696 """Register callback for input submission (ENTER key).
698 Args:
699 callback: Function to call when input is submitted.
701 """
702 if self.app_instance:
703 self.app_instance._input_submit_callbacks.append(callback) # noqa: SLF001
705 def on_session_pause(self, callback: Callable[[], None]) -> None:
706 """Register callback for session pause events.
708 Args:
709 callback: Function to call when session is paused.
711 """
712 if self.app_instance:
713 self.app_instance._session_pause_callbacks.append(callback) # noqa: SLF001
715 def on_session_resume(self, callback: Callable[[], None]) -> None:
716 """Register callback for session resume events.
718 Args:
719 callback: Function to call when session is resumed.
721 """
722 if self.app_instance:
723 self.app_instance._session_resume_callbacks.append(callback) # noqa: SLF001
725 def on_session_exit(self, callback: Callable[[], None]) -> None:
726 """Register callback for session exit events.
728 Args:
729 callback: Function to call when session exits.
731 """
732 if self.app_instance:
733 self.app_instance._session_exit_callbacks.append(callback) # noqa: SLF001
735 def update_content_area(self, _lines: list[str]) -> None:
736 """Update the main content display area.
738 Args:
739 lines: Content lines to display.
741 """
742 if self.app_instance and self.app_instance.current_session:
743 self.app_instance._update_display() # noqa: SLF001
745 def update_stats_display(self, _stats: dict[str, Any]) -> None:
746 """Update statistics display (word count, timer, etc.).
748 Args:
749 stats: Statistics to display.
751 """
752 if self.app_instance:
753 self.app_instance._update_stats_display() # noqa: SLF001
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()
761 def show_error_message(self, message: str) -> None:
762 """Display error message to user.
764 Args:
765 message: Error message to show.
767 """
768 if self.app_instance:
769 self.app_instance.error_message = message
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
776 @staticmethod
777 def set_theme(theme_name: str) -> None:
778 """Apply UI theme.
780 Args:
781 theme_name: Name of theme to apply.
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)
791 def run_tui(self, session_config: SessionConfig, tui_config: TUIConfig | None = None) -> int:
792 """Run the TUI application.
794 Args:
795 session_config: Session configuration.
796 tui_config: Optional TUI configuration.
798 Returns:
799 Exit code (0 for success).
801 """
802 try:
803 # Apply theme if specified
804 if tui_config and tui_config.theme:
805 TextualTUIAdapter.set_theme(tui_config.theme)
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