diff --git a/event_bus.py b/event_bus.py index d58020a..594bb57 100644 --- a/event_bus.py +++ b/event_bus.py @@ -7,11 +7,6 @@ class EventBus: def __init__(self): self._listeners: defaultdict[Type[BaseEvent], list[Callable[[Any], None]]] = defaultdict(list) - self._dispatch_wrapper: Callable[[Callable], None] | None = None - - def register_dispatch_wrapper(self, fn: Callable[[Callable], None]): - """Set a wrapper for dispatching listeners (e.g., Textual's app.call_from_thread).""" - self._dispatch_wrapper = fn def subscribe(self, event_type: Type[BaseEvent], listener: Callable[[Any], None]): """Registers a listener for a specific event type.""" @@ -19,12 +14,5 @@ def subscribe(self, event_type: Type[BaseEvent], listener: Callable[[Any], None] def publish(self, event: BaseEvent): """Publishes an event to all registered listeners.""" - - def _dispatch(): - for listener in self._listeners[type(event)]: - listener(event) - - if self._dispatch_wrapper: - self._dispatch_wrapper(_dispatch) - else: - _dispatch() + for listener in self._listeners[type(event)]: + listener(event) diff --git a/module_renderer.py b/module_renderer.py index e247649..1e5f63b 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -30,6 +30,7 @@ def __init__( run_state: RunState, event_bus: EventBus, stop_event: threading.Event | None = None, + enter_pause_event: threading.Event | None = None, ): self.codeplainAPI = codeplainAPI self.filename = filename @@ -39,6 +40,7 @@ def __init__( self.run_state = run_state self.event_bus = event_bus self.stop_event = stop_event + self.enter_pause_event = enter_pause_event def _ensure_module_folders_exist(self, module_name: str, first_render_frid: str) -> tuple[str, str]: """ @@ -181,6 +183,7 @@ def _build_render_context_for_module( event_bus=self.event_bus, test_script_timeout=self.args.test_script_timeout, stop_event=self.stop_event, + enter_pause_event=self.enter_pause_event, ) def _render_module( diff --git a/plain2code.py b/plain2code.py index 554d7f6..6a0dae3 100644 --- a/plain2code.py +++ b/plain2code.py @@ -203,6 +203,7 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 _check_connection(codeplainAPI) stop_event = threading.Event() + enter_pause_event = threading.Event() signal.signal(signal.SIGTERM, lambda _signum, _frame: stop_event.set()) module_renderer = ModuleRenderer( @@ -214,6 +215,7 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 run_state, event_bus, stop_event=stop_event, + enter_pause_event=enter_pause_event, ) render_error: list[Exception] = [] @@ -245,6 +247,7 @@ def run_render(): conformance_tests_script=args.conformance_tests_script, prepare_environment_script=args.prepare_environment_script, state_machine_version=system_config.client_version, + enter_pause_event=enter_pause_event, css_path="styles.css", ) app.run() diff --git a/plain2code_events.py b/plain2code_events.py index e957a8b..790a943 100644 --- a/plain2code_events.py +++ b/plain2code_events.py @@ -61,3 +61,8 @@ class RenderModuleCompleted(BaseEvent): @dataclass class RenderModuleStarted(BaseEvent): module_name: str + + +@dataclass +class RenderPaused(BaseEvent): + pass diff --git a/render_machine/code_renderer.py b/render_machine/code_renderer.py index b40cdf6..30ead21 100644 --- a/render_machine/code_renderer.py +++ b/render_machine/code_renderer.py @@ -1,11 +1,14 @@ +import time from copy import deepcopy from transitions.extensions.diagrams import HierarchicalGraphMachine -from plain2code_events import RenderModuleCompleted, RenderModuleStarted, RenderStateUpdated +from plain2code_events import RenderModuleCompleted, RenderModuleStarted, RenderPaused, RenderStateUpdated from render_machine.render_context import RenderContext from render_machine.state_machine_config import StateMachineConfig, States +PAUSE_POLL_INTERVAL_SECONDS = 1 + class CodeRenderer: """Main code renderer class that orchestrates the code generation workflow using a hierarchical state machine.""" @@ -35,7 +38,14 @@ def run(self): self.render_context.event_bus.publish(RenderModuleStarted(module_name=self.render_context.module_name)) previous_action_payload = None previous_state = None + while True: + if self.render_context.enter_pause_event.is_set(): + self.render_context.event_bus.publish(RenderPaused()) + + while self.render_context.enter_pause_event.is_set(): + time.sleep(PAUSE_POLL_INTERVAL_SECONDS) + self.render_context.event_bus.publish( RenderStateUpdated( state=self.render_context.state, diff --git a/render_machine/render_context.py b/render_machine/render_context.py index 1607728..c523364 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -54,6 +54,7 @@ def __init__( event_bus: EventBus, test_script_timeout: Optional[int] = None, stop_event: Optional[threading.Event] = None, + enter_pause_event: Optional[threading.Event] = None, ): self.codeplain_api: CodeplainAPI = codeplain_api self.memory_manager = memory_manager @@ -77,6 +78,7 @@ def __init__( self.run_state = run_state self.event_bus = event_bus self.stop_event = stop_event + self.enter_pause_event = enter_pause_event self.script_execution_history = ScriptExecutionHistory() self.starting_frid = None self.test_script_timeout = test_script_timeout diff --git a/tui/components.py b/tui/components.py index 8c4493e..c394b91 100644 --- a/tui/components.py +++ b/tui/components.py @@ -1,9 +1,9 @@ -import time from enum import Enum -from typing import Optional +from typing import Literal, Optional from textual.containers import Horizontal, Vertical, VerticalScroll from textual.message import Message +from textual.timer import Timer from textual.widgets import Button, Static from .models import Substate @@ -13,7 +13,10 @@ class CustomFooter(Horizontal): """A custom footer with keyboard shortcuts and render ID.""" - FOOTER_TEXT = "ctrl+c: copy * ctrl+d: quit * ctrl+l: toggle logs" + FOOTER_BASE_TEXT = "ctrl+c: copy * ctrl+d: quit * ctrl+l: toggle logs" + FOOTER_RENDERING_TEXT = FOOTER_BASE_TEXT + " * ctrl+p: pause" + RENDER_PAUSING_TEXT = FOOTER_BASE_TEXT + " * pausing ..." + RENDER_PAUSED_TEXT = FOOTER_BASE_TEXT + " * ctrl+p: resume" RENDER_FINISHED_TEXT = "enter: exit * ctrl+c: copy * ctrl+l: toggle logs" def __init__(self, render_id: str = "", **kwargs): @@ -21,15 +24,28 @@ def __init__(self, render_id: str = "", **kwargs): self.render_id = render_id def compose(self): - self._footer_text_widget = Static(self.FOOTER_TEXT, classes="custom-footer-text") + self._footer_text_widget = Static(self.FOOTER_RENDERING_TEXT, classes="custom-footer-text") yield self._footer_text_widget if self.render_id: yield Static(f"render id: {self.render_id} ", classes="custom-footer-render-id") - def show_render_finished(self) -> None: - """Update footer text to show render-finished keybindings.""" + def update_footer_state(self, state: Literal["rendering", "pausing", "paused", "finished"]) -> None: + self.remove_class("footer-state-default") + self.remove_class("footer-state-paused") + if self._footer_text_widget is not None: - self._footer_text_widget.update(self.RENDER_FINISHED_TEXT) + if state == "rendering": + self._footer_text_widget.update(self.FOOTER_RENDERING_TEXT) + self.add_class("footer-state-default") + elif state == "pausing": + self._footer_text_widget.update(self.RENDER_PAUSING_TEXT) + self.add_class("footer-state-paused") + elif state == "paused": + self._footer_text_widget.update(self.RENDER_PAUSED_TEXT) + self.add_class("footer-state-paused") + elif state == "finished": + self._footer_text_widget.update(self.RENDER_FINISHED_TEXT) + self.add_class("footer-state-default") class ScriptOutputType(str, Enum): @@ -90,12 +106,14 @@ class TUIComponents(str, Enum): class SubstateLine(Horizontal): """A single substate row with an attached timer.""" - def __init__(self, text: str, indent: str, **kwargs): + def __init__(self, text: str, indent: str, progress_status: str, **kwargs): super().__init__(**kwargs) self.text = text self.indent = indent - self.start_time = time.monotonic() + self._progress_status = progress_status self._line_widget: Static | None = None + self._timer: Timer | None = None + self._seconds_elapsed = 0 def compose(self): self._line_widget = Static(self._format_line(), classes="substate-line-text") @@ -103,10 +121,25 @@ def compose(self): def on_mount(self) -> None: self._refresh_timer() - self.set_interval(1, self._refresh_timer) + self._timer = self.set_interval(1, self._add_second) + if self._progress_status == ProgressItem.PAUSED: + self._timer.pause() + + def set_progress_status(self, progress_status: str) -> None: + self._progress_status = progress_status + if self._timer is None: + return + if progress_status == ProgressItem.PAUSED: + self._timer.pause() + else: + self._timer.resume() + + def _add_second(self) -> None: + self._seconds_elapsed += 1 + self._refresh_timer() def _format_timer(self) -> str: - elapsed = int(time.monotonic() - self.start_time) + elapsed = int(self._seconds_elapsed) if elapsed < 60: return f"{elapsed}s" minutes = elapsed // 60 @@ -135,10 +168,13 @@ class ProgressItem(Vertical): PROCESSING = "PROCESSING" COMPLETED = "COMPLETED" STOPPED = "STOPPED" + PAUSED = "PAUSED" + PAUSING = "PAUSING" def __init__(self, initial_text: str, **kwargs): super().__init__(**kwargs) self.initial_text = initial_text + self.current_status = self.PENDING def compose(self): # Main row with status and description @@ -156,11 +192,16 @@ def _get_status_text(self, status: str) -> str: return "◉ processing" elif status == self.STOPPED: return "◼ stopped" + elif status == self.PAUSING: + return "◉ pausing" + elif status == self.PAUSED: + return "⏸ paused" else: return "○ pending" async def update_status(self, status: str): # TODO: Move to plain2code_tui.py + self.current_status = status try: # Get the main row container main_row = self.query_one(f"#{self.id}-main-row", Horizontal) @@ -173,21 +214,20 @@ async def update_status(self, status: str): pass # Add appropriate widget based on status - if status == self.PROCESSING: + if status == self.PROCESSING or status == self.PAUSING: # Use spinner for processing state - spinner = Spinner(text="processing", classes=f"status {status}") + spinner = Spinner( + text="processing" if status == self.PROCESSING else "pausing", classes=f"status {status}" + ) await main_row.mount(spinner, before=0) else: # Use static text for pending/completed status_widget = Static(self._get_status_text(status), classes=f"status {status}") await main_row.mount(status_widget, before=0) - except Exception: - pass + for line in self.query(SubstateLine): + line.set_progress_status(status) - def update_text(self, text: str): - try: - self.query_one(".description", Static).update(text) except Exception: pass @@ -224,7 +264,7 @@ async def _render_substates_recursive(self, container: Vertical, substates: list for substate in substates: # Render the current substate - substate_widget = SubstateLine(substate.text, indent, classes="substate-row") + substate_widget = SubstateLine(substate.text, indent, self.current_status, classes="substate-row") await container.mount(substate_widget) # Recursively render children if they exist @@ -376,13 +416,6 @@ def update_functionality_text(self, text: str) -> None: except Exception: pass - def update_fr_status(self, status: str) -> None: - try: - widget = self.query_one(f"#{TUIComponents.FRID_PROGRESS_RENDER_FR.value}", ProgressItem) - self.call_later(widget.update_status, status) - except Exception: - pass - def on_mount(self) -> None: self.border_title = "FRID Progress" diff --git a/tui/plain2code_tui.py b/tui/plain2code_tui.py index 02fcd1d..4c71401 100644 --- a/tui/plain2code_tui.py +++ b/tui/plain2code_tui.py @@ -1,3 +1,4 @@ +import threading from typing import Callable, Optional from textual.app import App, ComposeResult @@ -14,16 +15,18 @@ RenderFailed, RenderModuleCompleted, RenderModuleStarted, + RenderPaused, RenderStateUpdated, ) from render_machine.states import States -from tui.widget_helpers import log_to_widget +from tui.widget_helpers import log_to_widget, transition_frid_progress from .components import ( CustomFooter, FRIDProgress, LogFilterChanged, LogLevelFilter, + ProgressItem, RenderingInfoBox, ScriptOutputType, StructuredLogView, @@ -47,10 +50,13 @@ class Plain2CodeTUI(App): """A Textual TUI for plain2code.""" + ENABLE_COMMAND_PALETTE = False + BINDINGS = [ Binding("ctrl+c", "copy_selection", "Copy", show=False), Binding("ctrl+d", "quit", "Quit", show=False), Binding("enter", "enter_exit", "Exit", show=False), + Binding("ctrl+p", "pause", "Pause", show=False, priority=True), ("ctrl+l", "toggle_logs", "Toggle Logs"), ] @@ -63,6 +69,7 @@ def __init__( conformance_tests_script: str, prepare_environment_script: str, state_machine_version: str, + enter_pause_event: threading.Event | None = None, **kwargs, ): super().__init__(**kwargs) @@ -74,6 +81,7 @@ def __init__( self.conformance_tests_script: Optional[str] = conformance_tests_script self.prepare_environment_script: Optional[str] = prepare_environment_script self.state_machine_version = state_machine_version + self.enter_pause_event = enter_pause_event self._render_finished = False # Initialize state handlers @@ -118,14 +126,13 @@ def get_active_script_types(self) -> list[ScriptOutputType]: def on_mount(self) -> None: """Called when the app is mounted.""" - self.event_bus.register_dispatch_wrapper(self.call_from_thread) - self.event_bus.subscribe(RenderStateUpdated, self.on_render_state_updated) self.event_bus.subscribe(RenderCompleted, self.on_render_completed) self.event_bus.subscribe(RenderFailed, self.on_render_failed) self.event_bus.subscribe(RenderModuleStarted, self.on_render_module_started) self.event_bus.subscribe(RenderModuleCompleted, self.on_render_module_completed) self.event_bus.subscribe(LogMessageEmitted, self.on_log_message_emitted) + self.event_bus.subscribe(RenderPaused, self.on_render_paused) self._on_ready() @@ -238,13 +245,19 @@ def _handle_frid_state( self._state_completion_handler.handle(segments, snapshot, previous_state_segments) + def on_render_paused(self, event: RenderPaused): + footer = self.screen.query_one(CustomFooter) + footer.update_footer_state("paused") + transition_frid_progress(self, ProgressItem.PAUSING, ProgressItem.PAUSED) + pass + def on_render_completed(self, event: RenderCompleted): """Handle successful render completion.""" self._render_success_handler.handle(event.rendered_code_path) self._render_finished = True try: footer = self.screen.query_one(CustomFooter) - footer.show_render_finished() + footer.update_footer_state("finished") except NoMatches: pass @@ -254,7 +267,7 @@ def on_render_failed(self, event: RenderFailed): self._render_finished = True try: footer = self.screen.query_one(CustomFooter) - footer.show_render_finished() + footer.update_footer_state("finished") except NoMatches: pass @@ -270,6 +283,21 @@ async def action_copy_selection(self) -> None: self.screen.clear_selection() self.notify("Copied to clipboard", timeout=2) + def action_pause(self) -> None: + """Handle ctrl+p: request the render machine to pause.""" + if not self._render_finished and self.enter_pause_event is not None: + if self.enter_pause_event.is_set(): + transition_frid_progress(self, ProgressItem.PAUSED, ProgressItem.PROCESSING) + transition_frid_progress(self, ProgressItem.PAUSING, ProgressItem.PROCESSING) + footer = self.screen.query_one(CustomFooter) + footer.update_footer_state("rendering") + self.enter_pause_event.clear() + else: + transition_frid_progress(self, ProgressItem.PROCESSING, ProgressItem.PAUSING) + footer = self.screen.query_one(CustomFooter) + footer.update_footer_state("pausing") + self.enter_pause_event.set() + def action_enter_exit(self) -> None: """Handle enter: exit the TUI only after rendering has finished.""" if self._render_finished: diff --git a/tui/state_handlers.py b/tui/state_handlers.py index 7c578ee..8b3aa43 100644 --- a/tui/state_handlers.py +++ b/tui/state_handlers.py @@ -13,7 +13,7 @@ display_error_message, display_success_message, get_frid_progress, - set_frid_progress_to_stopped, + transition_frid_progress, update_progress_item_status, update_progress_item_substates, ) @@ -331,7 +331,7 @@ def __init__(self, tui): self.tui = tui def handle(self, error_message: str) -> None: - set_frid_progress_to_stopped(self.tui) + transition_frid_progress(self.tui, None, ProgressItem.STOPPED) display_error_message(self.tui, error_message) diff --git a/tui/styles.css b/tui/styles.css index 5824faa..c395d5d 100644 --- a/tui/styles.css +++ b/tui/styles.css @@ -194,6 +194,12 @@ TestScriptsContainer { background: #c77777; } +.status.PAUSED, +.status.PAUSING { + color: #fff; + background: #0a1fd5; +} + .description { margin-left: 1; } @@ -388,20 +394,32 @@ Footer { /* Custom Footer */ CustomFooter { dock: bottom; - height: 2; + height: auto; + min-height: 1; + max-height: 2; background: transparent; padding: 0 1; align: left bottom; } +CustomFooter.footer-state-default { + background: $background; + color: #888; +} + +CustomFooter.footer-state-paused { + background: #0a1fd5; + color: #fff; +} + .custom-footer-text { width: 1fr; - color: #888; + background: transparent; } .custom-footer-render-id { width: auto; - color: #888; + background: transparent; text-align: right; } diff --git a/tui/widget_helpers.py b/tui/widget_helpers.py index 702439b..6587eff 100644 --- a/tui/widget_helpers.py +++ b/tui/widget_helpers.py @@ -111,17 +111,23 @@ def display_success_message(tui, rendered_code_path: str): widget.update(message) -def set_frid_progress_to_stopped(tui): - progress_ids = [ - TUIComponents.FRID_PROGRESS_RENDER_FR.value, - TUIComponents.FRID_PROGRESS_UNIT_TEST.value, - TUIComponents.FRID_PROGRESS_REFACTORING.value, - TUIComponents.FRID_PROGRESS_CONFORMANCE_TEST.value, - ] - - for widget_id in progress_ids: - update_progress_item_status(tui, widget_id, ProgressItem.STOPPED) - clear_progress_item_substates(tui, widget_id) +FRID_PROGRESS_IDS = [ + TUIComponents.FRID_PROGRESS_RENDER_FR.value, + TUIComponents.FRID_PROGRESS_UNIT_TEST.value, + TUIComponents.FRID_PROGRESS_REFACTORING.value, + TUIComponents.FRID_PROGRESS_CONFORMANCE_TEST.value, +] + + +def transition_frid_progress(tui, from_status: str | None, to_status: str): + """Transition all FRID progress items matching from_status to to_status.""" + for widget_id in FRID_PROGRESS_IDS: + try: + widget = tui.query_one(f"#{widget_id}", ProgressItem) + if widget.current_status == from_status or from_status is None: + update_progress_item_status(tui, widget_id, to_status) + except Exception: + pass def display_error_message(tui, error_message: str):