From d4b2ca41b8c729ac44d69105d9e8c2c3c7c38480 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Wed, 1 Apr 2026 10:34:12 +0200 Subject: [PATCH] add pause key binding to TUI add PAUSED state to state machine add paused state to progress items in TUI Revert "add PAUSED state to state machine" This reverts commit ddcef527f63d55539a754c62bf8bc6ea5454b903. add a threading event for pausing the state machine formatting fix remove dead code introduce progress entering pause alongside paused remove unused code override textual's command palette to use the key binding design update for the pausing and paused statuses design and wording update remove dead code remove eventbus dispatch wrapping stop progress timer when paused remove unused import --- event_bus.py | 16 +----- module_renderer.py | 3 ++ plain2code.py | 3 ++ plain2code_events.py | 5 ++ render_machine/code_renderer.py | 12 ++++- render_machine/render_context.py | 2 + tui/components.py | 85 ++++++++++++++++++++++---------- tui/plain2code_tui.py | 38 ++++++++++++-- tui/state_handlers.py | 4 +- tui/styles.css | 24 +++++++-- tui/widget_helpers.py | 28 ++++++----- 11 files changed, 158 insertions(+), 62 deletions(-) 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):