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
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +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.containers import Container, VerticalScroll
14from textual.reactive import reactive
15from textual.widgets import Footer, Header, Input, Static
17from prosemark.freewriting.domain.exceptions import TUIError, ValidationError
19if TYPE_CHECKING: # pragma: no cover
20 from collections.abc import Callable
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
26from prosemark.freewriting.ports.tui_adapter import (
27 TUIAdapterPort,
28 TUIDisplayPort,
29 TUIEventPort,
30 UIState,
31)
34class FreewritingApp(App[int]):
35 """Main Textual application for freewriting sessions."""
37 CSS = """
38 Screen {
39 layout: vertical;
40 }
42 #content_area {
43 height: 80%;
44 border: solid $primary;
45 padding: 1;
46 }
48 #input_container {
49 height: 20%;
50 border: solid $secondary;
51 padding: 1;
52 }
54 #input_box {
55 width: 100%;
56 }
58 #stats_display {
59 dock: top;
60 height: 1;
61 background: $surface;
62 color: $text;
63 text-align: center;
64 }
66 .content_line {
67 margin-bottom: 1;
68 padding: 0 1;
69 }
71 .error_message {
72 background: $error;
73 color: $text;
74 padding: 1;
75 margin: 1;
76 }
77 """
79 BINDINGS: ClassVar = [
80 ('ctrl+c', 'quit', 'Quit'),
81 ('ctrl+s', 'pause', 'Pause/Resume'),
82 ('escape', 'quit', 'Quit'),
83 ]
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)
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.
98 Args:
99 session_config: Configuration for the session.
100 tui_adapter: TUI adapter for session operations.
101 **kwargs: Additional arguments passed to App.
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
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]] = []
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()
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)
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
142 # Focus the input box
143 self.query_one('#input_box').focus()
145 # Start the timer
146 self.set_interval(1.0, self._update_timer)
148 # Update display
149 self._update_display()
151 except (OSError, RuntimeError, ValueError) as e:
152 self.error_message = f'Failed to initialize session: {e}'
153 self.exit(1)
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
160 input_widget = event.input
161 text = input_widget.value
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
168 # Clear input
169 input_widget.clear()
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)
175 # Update display
176 self._update_display()
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)
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
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)
195 def action_pause(self) -> None:
196 """Toggle pause/resume state."""
197 if not self.current_session:
198 return
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]'
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()
217 # Exit with success code
218 self.exit(0)
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)
226 # Update session with elapsed time
227 self.current_session = self.current_session.update_elapsed_time(self.elapsed_seconds)
229 # Update stats display
230 self._update_stats_display()
232 def _update_display(self) -> None:
233 """Update the content display area."""
234 if not self.current_session:
235 return
237 # Get display content from adapter
238 display_lines = TextualTUIAdapter.get_display_content(self.current_session, max_lines=1000)
240 # Update content area
241 content_area = self.query_one('#content_area')
242 content_area.remove_children()
244 for line in display_lines:
245 content_area.mount(Static(line, classes='content_line'))
247 # Auto-scroll to bottom
248 content_area.scroll_end()
250 def _update_stats_display(self) -> None:
251 """Update statistics display."""
252 if not self.current_session:
253 return
255 progress = self.tui_adapter.calculate_progress(self.current_session)
257 # Format stats string
258 stats_parts = []
260 # Word count
261 word_count = progress.get('word_count', 0)
262 stats_parts.append(f'Words: {word_count}')
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}%)')
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}')
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})')
280 stats_text = ' | '.join(stats_parts)
281 stats_display = self.query_one('#stats_display', Static)
282 stats_display.update(stats_text)
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!')
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
298class TextualTUIAdapter(TUIAdapterPort, TUIEventPort, TUIDisplayPort):
299 """Concrete implementation of TUI ports using Textual framework."""
301 def __init__(self, freewrite_service: FreewriteServicePort) -> None:
302 """Initialize the Textual TUI adapter.
304 Args:
305 freewrite_service: Service for freewriting operations.
307 """
308 self._freewrite_service = freewrite_service
309 self.app_instance: FreewritingApp | None = None
311 @property
312 def freewrite_service(self) -> FreewriteServicePort:
313 """Freewrite service instance for session operations.
315 Returns:
316 The freewrite service instance used by this TUI adapter.
318 """
319 return self._freewrite_service
321 def initialize_session(self, config: SessionConfig) -> FreewriteSession:
322 """Initialize a new freewriting session.
324 Args:
325 config: Session configuration from CLI.
327 Returns:
328 Created session object.
330 Raises:
331 ValidationError: If configuration is invalid.
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
340 def handle_input_submission(self, session: FreewriteSession, input_text: str) -> FreewriteSession:
341 """Handle user pressing ENTER in input box.
343 Args:
344 session: Current session state.
345 input_text: Text from input box.
347 Returns:
348 Updated session after content is appended.
350 Raises:
351 FileSystemError: If save operation fails.
353 """
354 return self._freewrite_service.append_content(session, input_text)
356 @staticmethod
357 def get_display_content(session: FreewriteSession, max_lines: int) -> list[str]:
358 """Get content lines to display in content area.
360 Args:
361 session: Current session.
362 max_lines: Maximum lines to return (for bottom of file view).
364 Returns:
365 List of content lines for display.
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:]
374 def calculate_progress(self, session: FreewriteSession) -> dict[str, Any]:
375 """Calculate session progress metrics.
377 Args:
378 session: Current session.
380 Returns:
381 Dictionary with progress information.
383 """
384 return self._freewrite_service.get_session_stats(session)
386 @staticmethod
387 def handle_error(error: Exception, session: FreewriteSession) -> UIState:
388 """Handle errors during session operations.
390 Args:
391 error: The exception that occurred.
392 session: Current session state.
394 Returns:
395 Updated UI state with error information.
397 """
398 error_message = f'Error: {error}'
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 )
412 def on_input_change(self, callback: Callable[[str], None]) -> None:
413 """Register callback for input text changes.
415 Args:
416 callback: Function to call when input changes.
418 """
419 if self.app_instance:
420 self.app_instance._input_change_callbacks.append(callback) # noqa: SLF001
422 def on_input_submit(self, callback: Callable[[str], None]) -> None:
423 """Register callback for input submission (ENTER key).
425 Args:
426 callback: Function to call when input is submitted.
428 """
429 if self.app_instance:
430 self.app_instance._input_submit_callbacks.append(callback) # noqa: SLF001
432 def on_session_pause(self, callback: Callable[[], None]) -> None:
433 """Register callback for session pause events.
435 Args:
436 callback: Function to call when session is paused.
438 """
439 if self.app_instance:
440 self.app_instance._session_pause_callbacks.append(callback) # noqa: SLF001
442 def on_session_resume(self, callback: Callable[[], None]) -> None:
443 """Register callback for session resume events.
445 Args:
446 callback: Function to call when session is resumed.
448 """
449 if self.app_instance:
450 self.app_instance._session_resume_callbacks.append(callback) # noqa: SLF001
452 def on_session_exit(self, callback: Callable[[], None]) -> None:
453 """Register callback for session exit events.
455 Args:
456 callback: Function to call when session exits.
458 """
459 if self.app_instance:
460 self.app_instance._session_exit_callbacks.append(callback) # noqa: SLF001
462 def update_content_area(self, _lines: list[str]) -> None:
463 """Update the main content display area.
465 Args:
466 lines: Content lines to display.
468 """
469 if self.app_instance and self.app_instance.current_session:
470 self.app_instance._update_display() # noqa: SLF001
472 def update_stats_display(self, _stats: dict[str, Any]) -> None:
473 """Update statistics display (word count, timer, etc.).
475 Args:
476 stats: Statistics to display.
478 """
479 if self.app_instance:
480 self.app_instance._update_stats_display() # noqa: SLF001
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()
488 def show_error_message(self, message: str) -> None:
489 """Display error message to user.
491 Args:
492 message: Error message to show.
494 """
495 if self.app_instance:
496 self.app_instance.error_message = message
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
503 @staticmethod
504 def set_theme(theme_name: str) -> None:
505 """Apply UI theme.
507 Args:
508 theme_name: Name of theme to apply.
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)
518 def run_tui(self, session_config: SessionConfig, tui_config: TUIConfig | None = None) -> int:
519 """Run the TUI application.
521 Args:
522 session_config: Session configuration.
523 tui_config: Optional TUI configuration.
525 Returns:
526 Exit code (0 for success).
528 """
529 try:
530 # Apply theme if specified
531 if tui_config and tui_config.theme:
532 TextualTUIAdapter.set_theme(tui_config.theme)
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