1- import os
2- import threading
3- import time
41from typing import Callable , Optional
52
63from textual .app import App , ComposeResult
74from textual .binding import Binding
85from textual .containers import Vertical , VerticalScroll
96from textual .css .query import NoMatches
107from textual .widgets import ContentSwitcher , Static
11- from textual .worker import Worker , WorkerFailed , WorkerState
128
139from event_bus import EventBus
14- from plain2code_console import console
1510from plain2code_events import (
1611 LogMessageEmitted ,
1712 RenderCompleted ,
4843 UnitTestsHandler ,
4944)
5045
51- FORCE_EXIT_DELAY = 0.5 # seconds
52-
53-
5446class Plain2CodeTUI (App ):
5547 """A Textual TUI for plain2code."""
5648
@@ -64,7 +56,7 @@ class Plain2CodeTUI(App):
6456 def __init__ (
6557 self ,
6658 event_bus : EventBus ,
67- worker_fun : Callable [[], None ],
59+ on_ready : Callable [[], None ],
6860 render_id : str ,
6961 unittests_script : str ,
7062 conformance_tests_script : str ,
@@ -75,7 +67,7 @@ def __init__(
7567 super ().__init__ (** kwargs )
7668 self .dark = True # Set dark mode as default
7769 self .event_bus = event_bus
78- self .worker_fun = worker_fun
70+ self ._on_ready = on_ready
7971 self .render_id = render_id
8072 self .unittests_script : Optional [str ] = unittests_script
8173 self .conformance_tests_script : Optional [str ] = conformance_tests_script
@@ -134,39 +126,7 @@ def on_mount(self) -> None:
134126 self .event_bus .subscribe (RenderModuleCompleted , self .on_render_module_completed )
135127 self .event_bus .subscribe (LogMessageEmitted , self .on_log_message_emitted )
136128
137- self .render_worker = self .run_worker (self .worker_fun , thread = True )
138-
139- def on_worker_state_changed (self , event : Worker .StateChanged ) -> None :
140- """Handle worker state changes."""
141- if event .worker .state == WorkerState .ERROR :
142- # Extract the original exception from WorkerFailed wrapper
143- error = event .worker .error
144- original_error = error .__cause__ if isinstance (error , WorkerFailed ) and error .__cause__ else error
145- self .exit (result = original_error )
146-
147- def _handle_exception (self , error : Exception ) -> None :
148- """Override Textual's exception handler to suppress console tracebacks for worker errors.
149-
150- Worker exceptions are logged to file via the logging system (configured in file handler in plain2code.py),
151- but the verbose Textual/Rich traceback is suppressed from the terminal. The clean error message
152- is displayed via the exception handlers in main().
153- """
154- # Because TUI is running in main thread and code renderer is running in a worker thread, textual models this
155- # by raising a WorkerFailed exception in this case
156- if isinstance (error , WorkerFailed ):
157- # Here, we still print the error to get some additional information to the file, but it's probably
158- # not necessary because we either way print the entire traceback to the console. Here, we could in the future
159- # print more information if we would want to.
160- original_error = error .__cause__ if error .__cause__ else error
161- console .error (
162- f"Worker failed with exception: { type (original_error ).__name__ } : { original_error } " ,
163- exc_info = (type (original_error ), original_error , original_error .__traceback__ ),
164- stack_info = False ,
165- )
166- return
167-
168- # For non-worker exceptions, use the default Textual behavior
169- super ()._handle_exception (error )
129+ self ._on_ready ()
170130
171131 def compose (self ) -> ComposeResult :
172132 """Create child widgets for the app."""
@@ -290,24 +250,12 @@ def on_render_completed(self, event: RenderCompleted):
290250 def on_render_failed (self , event : RenderFailed ):
291251 """Handle render failure."""
292252 self ._render_error_handler .handle (event .error_message )
293-
294- def _ensure_exit (self ) -> None :
295- """Ensure exit the application immediately."""
296-
297- def ensure_exit ():
298- time .sleep (FORCE_EXIT_DELAY )
299- try :
300- if hasattr (self , "_driver" ) and self ._driver is not None :
301- # suspend_application_mode() calls stop_application_mode() and close()
302- # It ensures terminal reset sequences are flushed before exiting.
303- self ._driver .suspend_application_mode ()
304- except Exception as e :
305- log_to_widget (self , "WARNING" , f"Error suspending application mode: { type (e ).__name__ } : { e } " )
306- finally :
307- os ._exit (0 ) # Kill process immediately, no cleanup - terminates all threads
308-
309- # daemon=True ensures this thread dies with the process if it exits before the timer fires
310- threading .Thread (target = ensure_exit , daemon = True ).start ()
253+ self ._render_finished = True
254+ try :
255+ footer = self .screen .query_one (CustomFooter )
256+ footer .show_render_finished ()
257+ except NoMatches :
258+ pass
311259
312260 async def action_copy_selection (self ) -> None :
313261 """Handle ctrl+c: copy selected text if any.
@@ -332,5 +280,4 @@ def action_quit(self) -> None:
332280 Note: Force exit may leave files partially written if interrupted during file I/O operations.
333281 This is acceptable since the folders in which we are writing are git versioned and are reset in the next render.
334282 """
335- self .render_worker .cancel ()
336283 self .exit ()
0 commit comments