From 4234c1629c59cc4a072c86b930361d79d0166e34 Mon Sep 17 00:00:00 2001 From: isaac Date: Fri, 27 Feb 2026 02:04:01 -0500 Subject: [PATCH 01/31] Added plugin panel and control partial support... --- src/studio/model.py | 23 ++++++++++++++--------- src/studio/stdplgns/analysis.py | 17 +++++++++++++++++ src/studio/stdplgns/output.py | 17 +++++++++++++++++ src/studio/stdplgns/run.py | 17 +++++++++++++++++ src/studio/view.py | 26 ++++++++++++++++---------- 5 files changed, 81 insertions(+), 19 deletions(-) diff --git a/src/studio/model.py b/src/studio/model.py index 1e3bbe1..b2126f6 100644 --- a/src/studio/model.py +++ b/src/studio/model.py @@ -48,7 +48,9 @@ class Model: on_load: Signal = Signal() on_save: Signal = Signal() - def __init__(self, name: str, project_path: Path) -> None: + def __init__(self, name: str, project_path: Path, app: TextualApp) -> None: + """Name and project path are passed to initiate the model. The textual app is simply passed as a reference so + that plugins maintain access to it.""" # ======== Basic Project Config ======== self.project_name: str = name # name the user has given the project self.project_path: Path = project_path @@ -57,10 +59,12 @@ def __init__(self, name: str, project_path: Path) -> None: self.plugins: list[Plugin] = [] # add builtin plugins - from studio.stdplgns import analysis, output, run - for module in (analysis, output, run): + from studio.stdplgns import run, output, analysis + for module in (run, output, analysis): for _, obj in inspect.getmembers(module): if isinstance(obj, Plugin): + obj.model = self + obj.app = app self.plugins.append(obj) # load all plugins for pp in (self.project_path / "plugins").glob("*.py"): @@ -73,6 +77,8 @@ def __init__(self, name: str, project_path: Path) -> None: # Look for instances of Plugin inside the module for _, obj in inspect.getmembers(module): if isinstance(obj, Plugin): + obj.model = self + obj.app = app self.plugins.append(obj) # ======== Active Flows ======== @@ -124,12 +130,12 @@ class Plugin(ABC): Required attributes: - name: str # the name of the plugin - - refreshable: bool # if the panel and controls should be called again due to a change in widget objects - (only used by the app screen to determine whether to rerender this plugin) + - model: Model # gives the plugin access to the model + - app: TextualApp # gives the plugin access to the app """ @abstractmethod - def __init__(self, model: Model, app: TextualApp) -> None: + def __init__(self) -> None: pass @abstractmethod @@ -138,11 +144,10 @@ def panel(self) -> TabPane | None: return None @abstractmethod - def controls(self) -> tuple[str, list[Collapsible]] | None: + def controls(self) -> list[Collapsible]: """Returns the controls (in renderable format) for modifying this plugin's behavior.""" - return None + return [] - @abstractmethod def save_configuration(self): """Optional method to implement that is called by the editor when exiting.""" pass diff --git a/src/studio/stdplgns/analysis.py b/src/studio/stdplgns/analysis.py index e69de29..dbe152b 100644 --- a/src/studio/stdplgns/analysis.py +++ b/src/studio/stdplgns/analysis.py @@ -0,0 +1,17 @@ +from textual.widgets import Collapsible, TabPane +from textual.app import App +from typing import Optional +from studio.model import Plugin, Model + +class P(Plugin): + def __init__(self) -> None: + self.name: str = "Analysis" + self.model: Optional[Model] = None + self.app: Optional[App] = None + + def panel(self) -> TabPane | None: + return TabPane("Analysis", id='plugin_analysis') + + def controls(self) -> list[Collapsible]: + return [] +plugin = P() diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index e69de29..db900c2 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -0,0 +1,17 @@ +from textual.widgets import Collapsible, TabPane +from textual.app import App +from typing import Optional +from studio.model import Plugin, Model + +class P(Plugin): + def __init__(self) -> None: + self.name: str = "Output" + self.model: Optional[Model] = None + self.app: Optional[App] = None + + def panel(self) -> TabPane | None: + return TabPane("Output", id='plugin_output') + + def controls(self) -> list[Collapsible]: + return [] +plugin = P() diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index e69de29..8c029bb 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -0,0 +1,17 @@ +from textual.widgets import Collapsible, TabPane +from textual.app import App +from typing import Optional +from studio.model import Plugin, Model + +class P(Plugin): + def __init__(self) -> None: + self.name: str = "Run" + self.model: Optional[Model] = None + self.app: Optional[App] = None + + def panel(self) -> TabPane | None: + return TabPane("Run", id='plugin_run') + + def controls(self) -> list[Collapsible]: + return [] +plugin = P() diff --git a/src/studio/view.py b/src/studio/view.py index 4e9439d..c8d2660 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -192,7 +192,8 @@ def btn_open_project(self): if i:=_.highlighted_option: self.app.MODEL = model.Model( # create an instance of model side of the MVC design i.id, # we use id as the field to store the name of the project - config.RecentProjects.get_path(i.id) + config.RecentProjects.get_path(i.id), + self.app ) self.app.push_screen("editor") else: @@ -225,7 +226,7 @@ class EditorScreen(Screen): ] def __refresh_flow_selector__(self) -> None: - """Call to refresh the flow selector""" + """Call to refresh the flow selector.""" # load flows m: model.Model = self.app.MODEL ol: Select = self.query_one("#select-flow") @@ -235,12 +236,8 @@ def __refresh_flow_selector__(self) -> None: ol._init_selected_option(m.flows.index(m.active_flow)) # select the first option except: pass - def __refresh_plugin_components__(self) -> None: - pass - def on_mount(self) -> None: self.__refresh_flow_selector__() - self.__refresh_plugin_components__() def compose(self) -> ComposeResult: # --- LEFT COLUMN: Project Files --- @@ -248,6 +245,7 @@ def compose(self) -> ComposeResult: yield Label(f"⭘ {self.app.MODEL.project_name}", id="project-title-label", classes="pane-header") yield DirectoryTree(self.app.MODEL.project_path, id="project-dir-tree") yield Button('↻ Refresh Directory', id='btn_refresh_project_dir', classes='full-width gray') + # TODO: add file buttons (creation, rename, etc.) # --- MIDDLE COLUMN: Workspace --- with Vertical(id="workspace"): @@ -277,14 +275,19 @@ def compose(self) -> ComposeResult: # Plugin Panel with TabbedContent(id="plugin-panel"): # loop through the plugin TabPanes and yield them here - pass + for plugin in self.app.MODEL.plugins: + if _:=plugin.panel(): + yield _ # --- RIGHT COLUMN: Plugin Control Menu --- with Vertical(id="plugin-controls"): - yield Label("⭘ Run Settings", classes="pane-header", id="plugin-controls-header") + yield Label("", classes="pane-header", id="plugin-controls-header") with ContentSwitcher(id="sidebar-switcher"): # loop through the collapsable's that the plugin provides, and place in Vertical containers. - pass + for plugin in self.app.MODEL.plugins: + with Vertical(id=plugin.name): + for c in plugin.controls(): + yield c # --- Footer --- yield Footer() @@ -399,7 +402,10 @@ def action_run(self): # ==== Panel and Controls ==== def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated): """Dynamically switches the Right Sidebar content AND Title.""" - pass + # TODO: make the ContentSwitcher properly assign the right ID... + # container: ContentSwitcher = self.query_one('#sidebar-switcher') + # container.current = event.pane.id + self.query_one('#plugin-controls-header').content = f"⭘ {event.pane._title}" # ==== File Manager ==== @on(Button.Pressed, '#btn_refresh_project_dir') From 71e835fc7cf122f8a241945f546bcc8d5113aa8b Mon Sep 17 00:00:00 2001 From: isaac Date: Fri, 27 Feb 2026 02:33:28 -0500 Subject: [PATCH 02/31] Refined plugin tab switching --- src/studio/stdplgns/analysis.py | 4 ++-- src/studio/stdplgns/output.py | 4 ++-- src/studio/stdplgns/run.py | 4 ++-- src/studio/view.py | 10 ++++++---- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/studio/stdplgns/analysis.py b/src/studio/stdplgns/analysis.py index dbe152b..1c53c2c 100644 --- a/src/studio/stdplgns/analysis.py +++ b/src/studio/stdplgns/analysis.py @@ -5,12 +5,12 @@ class P(Plugin): def __init__(self) -> None: - self.name: str = "Analysis" + self.name: str = "analysis" self.model: Optional[Model] = None self.app: Optional[App] = None def panel(self) -> TabPane | None: - return TabPane("Analysis", id='plugin_analysis') + return TabPane(self.name.title()) def controls(self) -> list[Collapsible]: return [] diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index db900c2..e40d6a1 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -5,12 +5,12 @@ class P(Plugin): def __init__(self) -> None: - self.name: str = "Output" + self.name: str = "output" self.model: Optional[Model] = None self.app: Optional[App] = None def panel(self) -> TabPane | None: - return TabPane("Output", id='plugin_output') + return TabPane(self.name.title()) def controls(self) -> list[Collapsible]: return [] diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index 8c029bb..9fb2adf 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -5,12 +5,12 @@ class P(Plugin): def __init__(self) -> None: - self.name: str = "Run" + self.name: str = "run" self.model: Optional[Model] = None self.app: Optional[App] = None def panel(self) -> TabPane | None: - return TabPane("Run", id='plugin_run') + return TabPane(self.name.title()) def controls(self) -> list[Collapsible]: return [] diff --git a/src/studio/view.py b/src/studio/view.py index c8d2660..830d6bb 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -284,8 +284,9 @@ def compose(self) -> ComposeResult: yield Label("", classes="pane-header", id="plugin-controls-header") with ContentSwitcher(id="sidebar-switcher"): # loop through the collapsable's that the plugin provides, and place in Vertical containers. - for plugin in self.app.MODEL.plugins: - with Vertical(id=plugin.name): + for i, plugin in enumerate(self.app.MODEL.plugins): + with Vertical(id=f'tab-{i+1}'): + yield Label(f"#tab-{i+1}") # TODO temp test code for c in plugin.controls(): yield c @@ -403,8 +404,9 @@ def action_run(self): def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated): """Dynamically switches the Right Sidebar content AND Title.""" # TODO: make the ContentSwitcher properly assign the right ID... - # container: ContentSwitcher = self.query_one('#sidebar-switcher') - # container.current = event.pane.id + container: ContentSwitcher = self.query_one('#sidebar-switcher') + container.current = event.pane.id + # noinspection PyProtectedMember self.query_one('#plugin-controls-header').content = f"⭘ {event.pane._title}" # ==== File Manager ==== From c1f2cc1f3cb2ccee493ce1e9ef526e791956980f Mon Sep 17 00:00:00 2001 From: isaac Date: Sun, 1 Mar 2026 17:55:54 -0500 Subject: [PATCH 03/31] Refined the file I/O support of the editor. --- src/studio/model.py | 25 +++++++++------- src/studio/stdplgns/run.py | 10 +++++-- src/studio/styles.tcss | 3 +- src/studio/view.py | 60 +++++++++++++++++++++++++++++++++----- 4 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/studio/model.py b/src/studio/model.py index b2126f6..b5ba13f 100644 --- a/src/studio/model.py +++ b/src/studio/model.py @@ -24,20 +24,25 @@ class Flow: """ Represents """ - def __init__(self): + def __init__(self) -> None: + # Metadata + self.name: str = "" + self.file_path: Path | None = None + + # Flow State self.flow: FlowLangBase = FlowLang() - self.src: str = "" - # metadata - self.name: str = "" - self.file_path: Path = Path() - self.is_dirty: bool = False + def write_file(self, text: str) -> None: + if self.file_path: + self.file_path.write_text(text) - def save_file(self): - pass + def read_file(self) -> str | None: + if self.file_path: + return self.file_path.read_text() + return None - def open_file(self): - pass + def open_file(self, path: Path | None): + self.file_path = path class Model: diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index 9fb2adf..30e94f3 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -1,4 +1,5 @@ from textual.widgets import Collapsible, TabPane +from textual.containers import ScrollableContainer from textual.app import App from typing import Optional from studio.model import Plugin, Model @@ -10,8 +11,13 @@ def __init__(self) -> None: self.app: Optional[App] = None def panel(self) -> TabPane | None: - return TabPane(self.name.title()) + return TabPane( + self.name.title(), + ScrollableContainer( + Collapsible(Collapsible(expanded_symbol="-", collapsed_symbol="+")), + Collapsible(), Collapsible()) + ) def controls(self) -> list[Collapsible]: - return [] + return [Collapsible(title=self.name.title())] plugin = P() diff --git a/src/studio/styles.tcss b/src/studio/styles.tcss index 654d251..a62c659 100644 --- a/src/studio/styles.tcss +++ b/src/studio/styles.tcss @@ -171,8 +171,7 @@ EditorScreen { padding: 1 2; } -/*The flow selector dropdown*/ -#select-flow { +#select-flow { /*The flow selector dropdown*/ width: 10; } diff --git a/src/studio/view.py b/src/studio/view.py index 830d6bb..98e704f 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -1,9 +1,13 @@ """View/Controller side of the MVC paradigm +LongTermTODO: +- Make each flow session have its own text editor. + Policies: - For software design reasons, it is best to make the user-flow from welcome screen to editor irreversible for the current process so that we don't have to introspectively modify state if the user selects a different project. -This decision was made due to the ease of plugin implementation and project saving. +This decision was made due to the ease of plugin implementation and project saving. At some point, however, we may +prefer to have a checkbox called exit to project manager when ctrl+q is pressed. """ from pathlib import Path from typing import cast, Iterable @@ -39,7 +43,7 @@ def __init__(self): class DirectoryTree(_DT): def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: for path in paths: - if str(path).endswith("__") or str(path).startswith("__"): + if str(path.stem) == 'plugins' or str(path).endswith("__") or str(path).startswith("__"): continue if path.is_dir() or any(map(path.match, config.SUPPORTED_FILE_TYPES)): yield path @@ -266,7 +270,7 @@ def compose(self) -> ComposeResult: # Code Editor yield (_:=TextArea.code_editor( - text="// Select a .flow file to begin...", + text="", id="code-editor", disabled=True )) @@ -286,7 +290,6 @@ def compose(self) -> ComposeResult: # loop through the collapsable's that the plugin provides, and place in Vertical containers. for i, plugin in enumerate(self.app.MODEL.plugins): with Vertical(id=f'tab-{i+1}'): - yield Label(f"#tab-{i+1}") # TODO temp test code for c in plugin.controls(): yield c @@ -385,8 +388,14 @@ def handle_modal_result(result: dict): @on(Select.Changed, '#select-flow') def select_flow(self, event: Select.Changed): + self.action_save_file() + m: model.Model = self.app.MODEL if isinstance(event.value, int): - self.app.MODEL.active_flow = self.app.MODEL.flows[event.value] + m.active_flow = m.flows[event.value] + self.set_code_editor_text(m.active_flow.read_file()) + else: + m.active_flow = None + self.set_code_editor_text(None) @on(Button.Pressed, "#btn-clear") def btn_clear(self): @@ -403,17 +412,50 @@ def action_run(self): # ==== Panel and Controls ==== def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated): """Dynamically switches the Right Sidebar content AND Title.""" - # TODO: make the ContentSwitcher properly assign the right ID... container: ContentSwitcher = self.query_one('#sidebar-switcher') container.current = event.pane.id # noinspection PyProtectedMember self.query_one('#plugin-controls-header').content = f"⭘ {event.pane._title}" # ==== File Manager ==== + def get_code_editor_text(self) -> str: + return self.query_one("#code-editor").text + + def set_code_editor_text(self, text: str | None) -> None: + ce: TextArea = self.query_one("#code-editor") + if text is None: + ce.load_text("") # clears everything including history + ce.disabled = True + ce.placeholder = "// Select a .flow file to begin..." + return + if ce.disabled: ce.disabled = False + ce.text = text + ce.placeholder = f"// This file is empty!\n// Start typing to edit this file..." + + def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): + m: model.Model = self.app.MODEL + if not m.active_flow: + self.notify("A flow session must be selected first!", severity="warning") + return + if not event.path.exists(): + self.notify("That file no longer exists!", severity="error") + self.query_one(DirectoryTree).reload() + return + m.active_flow.open_file(event.path) + self.set_code_editor_text(m.active_flow.read_file()) + + def action_save_file(self): + m: model.Model = self.app.MODEL + f: model.Flow = m.active_flow + if f is None: + return + if f.file_path: + f.write_file(self.get_code_editor_text()) + self.notify(f"Saved the \"{f.file_path.name}\" file.") + @on(Button.Pressed, '#btn_refresh_project_dir') def btn_refresh_project_dir(self): - dir_tree: DirectoryTree = self.query_one("#project-dir-tree") - dir_tree.reload() + self.query_one(DirectoryTree).reload() self.notify(f"Refreshed Project Directory...") @@ -431,6 +473,8 @@ def action_quit(self): self.exit() def handle_modal_result(result: dict): if result["pressed_button"] == "Yes": + # noinspection PyUnresolvedReferences + self.screen.action_save_file() if result["checkbox"]["save_config"]["value"]: self.app.MODEL.plugins_save_configs() self.exit() From a29c39b52a4bd24fe524bbb19413d7a978143ec5 Mon Sep 17 00:00:00 2001 From: isaac Date: Mon, 2 Mar 2026 11:06:14 -0500 Subject: [PATCH 04/31] Made the Flow file saver check the hash first to ensure saving changes only. --- src/studio/model.py | 28 +++++++++++++++------------- src/studio/stdplgns/run.py | 3 ++- src/studio/view.py | 6 +++--- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/studio/model.py b/src/studio/model.py index b5ba13f..5133762 100644 --- a/src/studio/model.py +++ b/src/studio/model.py @@ -1,13 +1,9 @@ -"""The model side of the MVC paradigm - -# TODO: -- Clean up the way the Model part of the MVC is designed. -- Connect it up to the View/Controller. -""" -from typing import Optional, Any +"""The model side of the MVC paradigm""" +from typing import Optional from lang import FlowLangBase, FlowLang # in the implementation from abc import ABC, abstractmethod -from textual.widgets import TabPane, Collapsible +from textual.widgets import TabPane +from textual.widget import Widget from textual.app import App as TextualApp from core.signals import Signal from copy import deepcopy @@ -22,23 +18,29 @@ class Flow: """ - Represents + Represents a flow instance session (includes API for interacting with .flow files on the disc). """ def __init__(self) -> None: # Metadata self.name: str = "" self.file_path: Path | None = None + self._edit_hash: int = 0 # used to check if some text has already been saved... # Flow State self.flow: FlowLangBase = FlowLang() - def write_file(self, text: str) -> None: - if self.file_path: + def write_file(self, text: str) -> bool: + """Writes to the file and returns True if the file was written to.""" + if self.file_path and self._edit_hash != (eh:=hash(text)): self.file_path.write_text(text) + self._edit_hash = eh + return True + return False def read_file(self) -> str | None: if self.file_path: - return self.file_path.read_text() + self._edit_hash = hash(text:=self.file_path.read_text()) + return text return None def open_file(self, path: Path | None): @@ -149,7 +151,7 @@ def panel(self) -> TabPane | None: return None @abstractmethod - def controls(self) -> list[Collapsible]: + def controls(self) -> list[Widget]: """Returns the controls (in renderable format) for modifying this plugin's behavior.""" return [] diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index 30e94f3..4172ffe 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -1,4 +1,5 @@ from textual.widgets import Collapsible, TabPane +from textual.widget import Widget from textual.containers import ScrollableContainer from textual.app import App from typing import Optional @@ -18,6 +19,6 @@ def panel(self) -> TabPane | None: Collapsible(), Collapsible()) ) - def controls(self) -> list[Collapsible]: + def controls(self) -> list[Widget]: return [Collapsible(title=self.name.title())] plugin = P() diff --git a/src/studio/view.py b/src/studio/view.py index 98e704f..704005e 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -2,6 +2,7 @@ LongTermTODO: - Make each flow session have its own text editor. +- Add edit buttons create, rename, and delete files. Policies: - For software design reasons, it is best to make the user-flow from welcome screen to editor irreversible for the @@ -249,7 +250,6 @@ def compose(self) -> ComposeResult: yield Label(f"⭘ {self.app.MODEL.project_name}", id="project-title-label", classes="pane-header") yield DirectoryTree(self.app.MODEL.project_path, id="project-dir-tree") yield Button('↻ Refresh Directory', id='btn_refresh_project_dir', classes='full-width gray') - # TODO: add file buttons (creation, rename, etc.) # --- MIDDLE COLUMN: Workspace --- with Vertical(id="workspace"): @@ -441,6 +441,7 @@ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): self.notify("That file no longer exists!", severity="error") self.query_one(DirectoryTree).reload() return + self.action_save_file() m.active_flow.open_file(event.path) self.set_code_editor_text(m.active_flow.read_file()) @@ -449,8 +450,7 @@ def action_save_file(self): f: model.Flow = m.active_flow if f is None: return - if f.file_path: - f.write_file(self.get_code_editor_text()) + if f.write_file(self.get_code_editor_text()): self.notify(f"Saved the \"{f.file_path.name}\" file.") @on(Button.Pressed, '#btn_refresh_project_dir') From 328357a823f8802e6186a0a61eb517fc616da37c Mon Sep 17 00:00:00 2001 From: isaac Date: Tue, 3 Mar 2026 16:28:52 -0500 Subject: [PATCH 05/31] Improved the codebase and started standard plugin implementation. --- ruleflow_studio.bat | 3 +- src/studio/model.py | 6 +- src/studio/stdplgns/__init__.py | 0 src/studio/stdplgns/analysis.py | 6 +- src/studio/stdplgns/lib/widgets.py | 21 ++++++ src/studio/stdplgns/output.py | 6 +- src/studio/stdplgns/run.py | 22 ++++-- src/studio/view.py | 103 +++++++++++++++++++++++------ 8 files changed, 131 insertions(+), 36 deletions(-) delete mode 100644 src/studio/stdplgns/__init__.py create mode 100644 src/studio/stdplgns/lib/widgets.py diff --git a/ruleflow_studio.bat b/ruleflow_studio.bat index e5c4276..1f01dbd 100644 --- a/ruleflow_studio.bat +++ b/ruleflow_studio.bat @@ -1,3 +1,4 @@ @echo off cd C:\local\repos\ruleflow\src -uv run python -m studio.view \ No newline at end of file +uv run python -m studio.view +@pause diff --git a/src/studio/model.py b/src/studio/model.py index 5133762..98dd179 100644 --- a/src/studio/model.py +++ b/src/studio/model.py @@ -1,5 +1,5 @@ """The model side of the MVC paradigm""" -from typing import Optional +from typing import Optional, Iterator from lang import FlowLangBase, FlowLang # in the implementation from abc import ABC, abstractmethod from textual.widgets import TabPane @@ -151,9 +151,9 @@ def panel(self) -> TabPane | None: return None @abstractmethod - def controls(self) -> list[Widget]: + def controls(self) -> Iterator[Widget]: """Returns the controls (in renderable format) for modifying this plugin's behavior.""" - return [] + pass def save_configuration(self): """Optional method to implement that is called by the editor when exiting.""" diff --git a/src/studio/stdplgns/__init__.py b/src/studio/stdplgns/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/studio/stdplgns/analysis.py b/src/studio/stdplgns/analysis.py index 1c53c2c..9136642 100644 --- a/src/studio/stdplgns/analysis.py +++ b/src/studio/stdplgns/analysis.py @@ -1,6 +1,6 @@ from textual.widgets import Collapsible, TabPane from textual.app import App -from typing import Optional +from typing import Optional, Iterator from studio.model import Plugin, Model class P(Plugin): @@ -12,6 +12,6 @@ def __init__(self) -> None: def panel(self) -> TabPane | None: return TabPane(self.name.title()) - def controls(self) -> list[Collapsible]: - return [] + def controls(self) -> Iterator[Collapsible]: + return iter([]) plugin = P() diff --git a/src/studio/stdplgns/lib/widgets.py b/src/studio/stdplgns/lib/widgets.py new file mode 100644 index 0000000..4687c10 --- /dev/null +++ b/src/studio/stdplgns/lib/widgets.py @@ -0,0 +1,21 @@ +"""This is where some widgets are located that are uniquely useful to Plugin.controls() UI constructions.\ + +The main reason for doing this is to provide easy access to events (through callbacks) of widgets for non-textual code. +Of course, other useful widgets may be created here as needed for the standard plugin library. +""" + +# Textual Imports +from textual.widgets import Button as _Button + +# Standard Imports +from typing import Callable + + +class Button(_Button): + """A normal Textual Button but extended with pressed callbacks.""" + + def connect_pressed_callback(self, c: Callable): + self._pressed_callback: Callable = c + + def on_button_pressed(self): + self._pressed_callback() diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index e40d6a1..155114f 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -1,6 +1,6 @@ from textual.widgets import Collapsible, TabPane from textual.app import App -from typing import Optional +from typing import Optional, Iterator from studio.model import Plugin, Model class P(Plugin): @@ -12,6 +12,6 @@ def __init__(self) -> None: def panel(self) -> TabPane | None: return TabPane(self.name.title()) - def controls(self) -> list[Collapsible]: - return [] + def controls(self) -> Iterator[Collapsible]: + return iter([]) plugin = P() diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index 4172ffe..e34e49c 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -1,9 +1,14 @@ -from textual.widgets import Collapsible, TabPane +# Textual Imports +from textual.widgets import Collapsible, TabPane, Input, Label, Switch from textual.widget import Widget from textual.containers import ScrollableContainer from textual.app import App -from typing import Optional + +# Standard Imports +from typing import Optional, Iterator from studio.model import Plugin, Model +from studio.stdplgns.lib.widgets import Button + class P(Plugin): def __init__(self) -> None: @@ -19,6 +24,15 @@ def panel(self) -> TabPane | None: Collapsible(), Collapsible()) ) - def controls(self) -> list[Widget]: - return [Collapsible(title=self.name.title())] + def controls(self) -> Iterator[Widget]: + i = Input(type='number', compact=False, valid_empty=True) + i.border_title = 'Timeout (ms)' + b = Button("Test") + b.connect_pressed_callback(lambda: self.app.notify("TEST")) + with Collapsible(title='Hot Reload', collapsed=False): + yield Switch() + yield i + yield Label('Reload after changes:') + yield Input(type='integer', compact=True, valid_empty=True) + yield b plugin = P() diff --git a/src/studio/view.py b/src/studio/view.py index 704005e..92aa46b 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -11,12 +11,12 @@ prefer to have a checkbox called exit to project manager when ctrl+q is pressed. """ from pathlib import Path -from typing import cast, Iterable +from typing import cast, Iterable, Callable, Any from textual.app import App, ComposeResult -from textual.containers import Container, Center, Horizontal, Vertical +from textual.containers import Container, Center, Horizontal, Vertical, ScrollableContainer from textual.screen import Screen, ModalScreen from textual.widgets import ( - DirectoryTree as _DT, TextArea, Button, Label, + DirectoryTree as _DT, TextArea as _TA, Button, Label, Select, TabbedContent, OptionList, Input, Footer, ContentSwitcher, Static, Checkbox ) @@ -50,6 +50,76 @@ def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: yield path +class TextArea(_TA): + def load_text(self, text: str | None) -> None: + """Overrides the load_text method so that placeholders work properly...""" + # NOTE: edit history is cleared + if text is None: + super().load_text("") + self.disabled = True + self.placeholder = "// Select a .flow file to begin..." + return + if self.disabled: self.disabled = False + super().load_text(text) + self.placeholder = f"// This file is empty!\n// Start typing to edit this file..." + + def on_mount(self) -> None: + self._code_editor_changed_callback: Callable[[TextArea.Changed], Any] = lambda _: None + + def connect_code_editor_change_callback(self, c: Callable[[TextArea.Changed], Any]): + self._code_editor_changed_callback = c + + def on_text_area_changed(self, event: TextArea.Changed): + self._code_editor_changed_callback(event) + + +# import re +# class CustomHighlightTextArea(TextArea): +# # 1. Define your custom highlighting rules as (regex_pattern, highlight_name) +# # The 'highlight_name' should match standard names used in Textual themes +# # (e.g., "keyword", "string", "comment", "number", "variable", "function"). +# HIGHLIGHT_RULES = [ +# (r"\b(def|class|return|if|else|elif|import|from)\b", "keyword"), +# (r'".*?"|\'.*?\'', "string"), +# (r"#.*", "comment"), +# (r"\b\d+\b", "number"), +# (r"\b[A-Z][a-zA-Z0-9_]*\b", "type"), # Class names +# ] +# +# def on_mount(self) -> None: +# # Pre-compile the regexes for performance when the widget mounts +# self._compiled_rules = [ +# (re.compile(pattern), name) +# for pattern, name in self.HIGHLIGHT_RULES +# ] +# +# def _build_highlight_map(self) -> None: +# """Override to apply custom regex-based highlights instead of tree-sitter.""" +# +# # 1. Clear the existing line cache and highlight map +# self._line_cache.clear() +# self._highlights.clear() +# +# # 2. Iterate over every line in the document +# for line_index in range(self.document.line_count): +# line_text = self.document[line_index] +# +# # 3. Apply each regex rule to the current line +# for regex, highlight_name in self._compiled_rules: +# for match in regex.finditer(line_text): +# # 4. CRITICAL: Convert Python's character indices to byte offsets. +# # Textual's `_render_line` expects byte offsets because that is +# # what Tree-sitter natively outputs. +# start_char, end_char = match.span() +# start_byte = len(line_text[:start_char].encode("utf-8")) +# end_byte = len(line_text[:end_char].encode("utf-8")) +# +# # 5. Append the highlight tuple for this line +# self._highlights[line_index].append( +# (start_byte, end_byte, highlight_name) +# ) + + class ModalDialog(ModalScreen[dict]): """ A flexible modal with border-titled inputs, notes, and dynamic buttons. @@ -289,7 +359,7 @@ def compose(self) -> ComposeResult: with ContentSwitcher(id="sidebar-switcher"): # loop through the collapsable's that the plugin provides, and place in Vertical containers. for i, plugin in enumerate(self.app.MODEL.plugins): - with Vertical(id=f'tab-{i+1}'): + with ScrollableContainer(id=f'tab-{i+1}'): for c in plugin.controls(): yield c @@ -392,10 +462,10 @@ def select_flow(self, event: Select.Changed): m: model.Model = self.app.MODEL if isinstance(event.value, int): m.active_flow = m.flows[event.value] - self.set_code_editor_text(m.active_flow.read_file()) + self.get_code_editor_widget().text = m.active_flow.read_file() else: m.active_flow = None - self.set_code_editor_text(None) + self.get_code_editor_widget().text = None @on(Button.Pressed, "#btn-clear") def btn_clear(self): @@ -409,6 +479,9 @@ def btn_debug(self): def action_run(self): pass + def get_code_editor_widget(self) -> TextArea: + return self.query_one("#code-editor") + # ==== Panel and Controls ==== def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated): """Dynamically switches the Right Sidebar content AND Title.""" @@ -418,20 +491,6 @@ def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated): self.query_one('#plugin-controls-header').content = f"⭘ {event.pane._title}" # ==== File Manager ==== - def get_code_editor_text(self) -> str: - return self.query_one("#code-editor").text - - def set_code_editor_text(self, text: str | None) -> None: - ce: TextArea = self.query_one("#code-editor") - if text is None: - ce.load_text("") # clears everything including history - ce.disabled = True - ce.placeholder = "// Select a .flow file to begin..." - return - if ce.disabled: ce.disabled = False - ce.text = text - ce.placeholder = f"// This file is empty!\n// Start typing to edit this file..." - def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): m: model.Model = self.app.MODEL if not m.active_flow: @@ -443,14 +502,14 @@ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): return self.action_save_file() m.active_flow.open_file(event.path) - self.set_code_editor_text(m.active_flow.read_file()) + self.get_code_editor_widget().text = m.active_flow.read_file() def action_save_file(self): m: model.Model = self.app.MODEL f: model.Flow = m.active_flow if f is None: return - if f.write_file(self.get_code_editor_text()): + if f.write_file(self.get_code_editor_widget().text): self.notify(f"Saved the \"{f.file_path.name}\" file.") @on(Button.Pressed, '#btn_refresh_project_dir') From e57858e08312feaa505a473fdecd893f40b90aa8 Mon Sep 17 00:00:00 2001 From: isaac Date: Tue, 3 Mar 2026 23:43:10 -0500 Subject: [PATCH 06/31] Refine the "run" standard plugin for Studio --- src/studio/stdplgns/lib/widgets.py | 5 +++++ src/studio/stdplgns/run.py | 33 ++++++++++++++++++------------ src/studio/styles.tcss | 33 ++++++++++++++++-------------- src/studio/view.py | 19 ++++++----------- 4 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/studio/stdplgns/lib/widgets.py b/src/studio/stdplgns/lib/widgets.py index 4687c10..68cd2f2 100644 --- a/src/studio/stdplgns/lib/widgets.py +++ b/src/studio/stdplgns/lib/widgets.py @@ -2,6 +2,11 @@ The main reason for doing this is to provide easy access to events (through callbacks) of widgets for non-textual code. Of course, other useful widgets may be created here as needed for the standard plugin library. + +Best Development Policy: +- Quickly provide widgets that work... no need to be perfectionistic. +- Only after having working controls, then "prettify" them into well-styled/ordered widgets. + - Abstract those into specialized widgets here if deemed elegant. """ # Textual Imports diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index e34e49c..256cd10 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -1,5 +1,5 @@ # Textual Imports -from textual.widgets import Collapsible, TabPane, Input, Label, Switch +from textual.widgets import Collapsible, TabPane, Input, Checkbox from textual.widget import Widget from textual.containers import ScrollableContainer from textual.app import App @@ -9,30 +9,37 @@ from studio.model import Plugin, Model from studio.stdplgns.lib.widgets import Button - class P(Plugin): def __init__(self) -> None: - self.name: str = "run" + self.name: str = 'run' self.model: Optional[Model] = None self.app: Optional[App] = None def panel(self) -> TabPane | None: + # TODO: connect up the controls and the Flow backend... return TabPane( self.name.title(), ScrollableContainer( - Collapsible(Collapsible(expanded_symbol="-", collapsed_symbol="+")), + Collapsible(Collapsible(expanded_symbol='-', collapsed_symbol='+')), Collapsible(), Collapsible()) ) def controls(self) -> Iterator[Widget]: - i = Input(type='number', compact=False, valid_empty=True) - i.border_title = 'Timeout (ms)' - b = Button("Test") - b.connect_pressed_callback(lambda: self.app.notify("TEST")) + # NOTE: there aren't that many settings for the run tab due to most controls being available through the DSL. with Collapsible(title='Hot Reload', collapsed=False): - yield Switch() - yield i - yield Label('Reload after changes:') - yield Input(type='integer', compact=True, valid_empty=True) - yield b + self.hot_mode = Checkbox('Enable Hot Reload Mode') + yield self.hot_mode + self.hot_n_changes = Input(type='integer', value='1') + self.hot_n_changes.border_title = 'Re-run after N changes' + yield self.hot_n_changes + self.hot_timeout = Input(type='number', value='500') + self.hot_timeout.border_title = 'Timeout (ms)' + yield self.hot_timeout + + with Collapsible(title='Profiler', collapsed=False): + self.enable_progress_bar = Checkbox('Progress bar') + yield self.enable_progress_bar + self.enable_program_stats = Checkbox('Resource usage stats') + yield self.enable_program_stats + plugin = P() diff --git a/src/studio/styles.tcss b/src/studio/styles.tcss index a62c659..fd434ba 100644 --- a/src/studio/styles.tcss +++ b/src/studio/styles.tcss @@ -49,20 +49,6 @@ ModalDialog { height: auto; } -/* Inputs and Border Titles */ -ModalDialog Input { - width: 100%; - margin: 1 0; /* Vertical margin gives space for the border title */ - background: #252526; - border: round #444444; - color: #e0e0e0; -} - -ModalDialog Input:focus { - border: round #007acc; - background-tint: #252526; -} - /* Notes (Static text blocks) */ .modal-note { width: 100%; @@ -162,7 +148,6 @@ EditorScreen { #workspace { width: 2fr; height: 100%; - border-right: solid #2b2b2b; layout: vertical; } @@ -189,6 +174,7 @@ EditorScreen { width: 1fr; height: 100%; background: #181818; + border-left: solid #2b2b2b; min-width: 30; } @@ -197,6 +183,23 @@ EditorScreen { COMPONENT STYLES ========================================= */ +Input { + width: 1fr; + background: rgba(0, 0, 0, 0); + border: round #7a7a7a; + color: #e0e0e0; +} + +Input:focus { + border: round #007acc; + background-tint: #252526; +} + + +/* ========================================= + COMPONENT CLASSES + ========================================= */ + .full-width { width: 1fr; } diff --git a/src/studio/view.py b/src/studio/view.py index 92aa46b..acde6f7 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -333,10 +333,8 @@ def compose(self) -> ComposeResult: # yield Label("No Open File", classes='gray') # yield Spacer() yield Button("Run", id="btn-run", classes="action-btn green", compact=True) - yield Label("|", classes="separator") - yield Button("Debug", id="btn-debug", classes="action-btn orange", compact=True) - yield Label("|", classes="separator") - yield Button("Clear", id="btn-clear", classes="action-btn red", compact=True) + yield Label("| ", classes="gray") + yield Button("Stop", id="btn-stop", classes="action-btn orange", compact=True) # Code Editor yield (_:=TextArea.code_editor( @@ -467,18 +465,14 @@ def select_flow(self, event: Select.Changed): m.active_flow = None self.get_code_editor_widget().text = None - @on(Button.Pressed, "#btn-clear") - def btn_clear(self): - pass - - @on(Button.Pressed, "#btn-debug") - def btn_debug(self): - pass - @on(Button.Pressed, "#btn-run") def action_run(self): pass + @on(Button.Pressed, "#btn-stop") + def btn_stop(self): + pass + def get_code_editor_widget(self) -> TextArea: return self.query_one("#code-editor") @@ -559,6 +553,5 @@ def handle_modal_result(result: dict): if __name__ == "__main__": - import textual.events app = Main() app.run() From efb1ff4d82a4a3e2a8b4bbe2cc730ed8b6f2cc78 Mon Sep 17 00:00:00 2001 From: isaac Date: Tue, 3 Mar 2026 23:55:57 -0500 Subject: [PATCH 07/31] Refine the "run" standard plugin for Studio --- src/studio/stdplgns/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index 256cd10..5bab1a4 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -16,7 +16,7 @@ def __init__(self) -> None: self.app: Optional[App] = None def panel(self) -> TabPane | None: - # TODO: connect up the controls and the Flow backend... + # TODO: connect up the controls and the Flow backend...... return TabPane( self.name.title(), ScrollableContainer( From 3a591f65eae64a37dfb07c968c7ca1227a5a61d7 Mon Sep 17 00:00:00 2001 From: isaac Date: Thu, 5 Mar 2026 02:19:10 -0500 Subject: [PATCH 08/31] Added signal system for plugins to communicate with the view, improves the plugin ABC by enforcing better design patterns, and implemented basic plugin widgets. Now its time to connect up the panels and the backend. --- src/core/explorer.py | 1 - src/core/signals.py | 8 +++- src/studio/model.py | 57 ++++++++++++++++---------- src/studio/stdplgns/analysis.py | 44 +++++++++++++++----- src/studio/stdplgns/lib/widgets.py | 26 ------------ src/studio/stdplgns/output.py | 59 ++++++++++++++++++++++----- src/studio/stdplgns/run.py | 28 ++++++++----- src/studio/view.py | 64 +++++++++++++++--------------- 8 files changed, 173 insertions(+), 114 deletions(-) delete mode 100644 src/studio/stdplgns/lib/widgets.py diff --git a/src/core/explorer.py b/src/core/explorer.py index 6918bf2..40449ff 100644 --- a/src/core/explorer.py +++ b/src/core/explorer.py @@ -11,7 +11,6 @@ from rich.text import Text from rich.live import Live from rich import box -from rich.color import Color # Handle import based on your structure from src.core.engine import Flow, Event, Cell diff --git a/src/core/signals.py b/src/core/signals.py index 6771267..a6bc603 100644 --- a/src/core/signals.py +++ b/src/core/signals.py @@ -1,7 +1,8 @@ -from typing import Callable, Hashable, Any +from typing import Callable, Hashable, Any, TypeVar, Generic +SignalT = TypeVar("SignalT") -class Signal: +class Signal(Generic[SignalT]): """Implements a QT-like signal system with instance-specific filtering. Please note that any connected functions will KEEP result in them staying alive in memory until disconnected (so make sure to disconnect any methods before deleting and object). @@ -50,3 +51,6 @@ def disconnect(self, func: Callable, restrict_to_instance: Hashable = None) -> N self.callables.remove(func) except (ValueError, KeyError): pass + +if __name__ == "__main__": + s: Signal[int] = Signal() \ No newline at end of file diff --git a/src/studio/model.py b/src/studio/model.py index 98dd179..ea31d92 100644 --- a/src/studio/model.py +++ b/src/studio/model.py @@ -1,11 +1,9 @@ """The model side of the MVC paradigm""" -from typing import Optional, Iterator +from typing import Optional, Iterator, TYPE_CHECKING, cast from lang import FlowLangBase, FlowLang # in the implementation from abc import ABC, abstractmethod from textual.widgets import TabPane from textual.widget import Widget -from textual.app import App as TextualApp -from core.signals import Signal from copy import deepcopy # used for dynamic imports and path management @@ -15,6 +13,12 @@ import sys import importlib +# used for type checking +if TYPE_CHECKING: + from studio.view import EditorScreen +else: + class EditorScreen(object): pass # must define due to reference in type casting + class Flow: """ @@ -52,16 +56,17 @@ class Model: The source of truth for the application state (a Singleton Pattern). Manages the current workspace and open file flows. """ - on_load: Signal = Signal() - on_save: Signal = Signal() - def __init__(self, name: str, project_path: Path, app: TextualApp) -> None: + def __init__(self, name: str, project_path: Path, view: EditorScreen) -> None: """Name and project path are passed to initiate the model. The textual app is simply passed as a reference so that plugins maintain access to it.""" # ======== Basic Project Config ======== self.project_name: str = name # name the user has given the project self.project_path: Path = project_path + # ======== View Hook ======== + self.view: EditorScreen = view + # ======== Plugins ======== self.plugins: list[Plugin] = [] @@ -70,8 +75,8 @@ def __init__(self, name: str, project_path: Path, app: TextualApp) -> None: for module in (run, output, analysis): for _, obj in inspect.getmembers(module): if isinstance(obj, Plugin): - obj.model = self - obj.app = app + obj._model = self + obj._view = self.view self.plugins.append(obj) # load all plugins for pp in (self.project_path / "plugins").glob("*.py"): @@ -84,9 +89,11 @@ def __init__(self, name: str, project_path: Path, app: TextualApp) -> None: # Look for instances of Plugin inside the module for _, obj in inspect.getmembers(module): if isinstance(obj, Plugin): - obj.model = self - obj.app = app + obj._model = self + obj._view = self.view self.plugins.append(obj) + for p in self.plugins: + p.on_initialized() # ======== Active Flows ======== self.flows: list[Flow] = [] @@ -122,13 +129,8 @@ def delete_selected_flow(self) -> None: else: self.active_flow = None - def plugins_save_configs(self): - """Direct all plugins to save configuration of the plugin.""" - for p in self.plugins: - p.save_configuration() - -# ================ Client Implemented ================ +# ================ Plugin Support ================ class Plugin(ABC): """ Any class that inherits from this, becomes a plugin and is expected to implement the methods below. @@ -138,11 +140,26 @@ class Plugin(ABC): Required attributes: - name: str # the name of the plugin - model: Model # gives the plugin access to the model - - app: TextualApp # gives the plugin access to the app + - view: EditorScreen # gives the plugin access to the app """ - @abstractmethod def __init__(self) -> None: + # Define the unset required attributes + self.name: str = cast(str, cast(object, None)) + self._model: Model = cast(Model, cast(object, None)) + self._view: EditorScreen = cast(EditorScreen, cast(object, None)) + + @property + def model(self) -> Model: + return self._model + + @property + def view(self) -> EditorScreen: + return self._view + + @abstractmethod + def on_initialized(self) -> None: + """Called when the plugin is fully loaded by the model.""" pass @abstractmethod @@ -154,7 +171,3 @@ def panel(self) -> TabPane | None: def controls(self) -> Iterator[Widget]: """Returns the controls (in renderable format) for modifying this plugin's behavior.""" pass - - def save_configuration(self): - """Optional method to implement that is called by the editor when exiting.""" - pass diff --git a/src/studio/stdplgns/analysis.py b/src/studio/stdplgns/analysis.py index 9136642..b6e14a2 100644 --- a/src/studio/stdplgns/analysis.py +++ b/src/studio/stdplgns/analysis.py @@ -1,17 +1,41 @@ -from textual.widgets import Collapsible, TabPane -from textual.app import App -from typing import Optional, Iterator -from studio.model import Plugin, Model +# Textual Imports +from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button, Label +from textual.widget import Widget +from textual.containers import ScrollableContainer + +# Standard Imports +from typing import Iterator +from studio.model import Plugin + class P(Plugin): - def __init__(self) -> None: - self.name: str = "analysis" - self.model: Optional[Model] = None - self.app: Optional[App] = None + def on_initialized(self) -> None: + self.name = 'analysis' def panel(self) -> TabPane | None: return TabPane(self.name.title()) - def controls(self) -> Iterator[Collapsible]: - return iter([]) + def controls(self) -> Iterator[Widget]: + with Collapsible(title='Causal Network', collapsed=False): + self.live_causal_network = Checkbox('Live Mode') + yield self.live_causal_network + self.initial_causal_network_node = Input(type='number', value='0') + self.initial_causal_network_node.border_title = 'Initial Event' + yield self.initial_causal_network_node + self.aggregate_branches = Input() + self.aggregate_branches.border_title = 'Aggregate Branches' + yield self.aggregate_branches + + with Collapsible(title='Branch Graph', collapsed=False): + self.live_branch_network = Checkbox('Live Mode') + yield self.live_branch_network + self.branch_network_range = Input() + self.branch_network_range.border_title = 'Selected Branches' + yield self.branch_network_range + + with Collapsible(title='Growth Metrics', collapsed=False): + yield Label("""Algorithm Controls for \ndetermining network or \nevolution properties such \nas dimension or sparseness.""") + + with Collapsible(title='VisJS Network Viewer', collapsed=True): + yield Label("Options for the web \ngraph renderer.") plugin = P() diff --git a/src/studio/stdplgns/lib/widgets.py b/src/studio/stdplgns/lib/widgets.py deleted file mode 100644 index 68cd2f2..0000000 --- a/src/studio/stdplgns/lib/widgets.py +++ /dev/null @@ -1,26 +0,0 @@ -"""This is where some widgets are located that are uniquely useful to Plugin.controls() UI constructions.\ - -The main reason for doing this is to provide easy access to events (through callbacks) of widgets for non-textual code. -Of course, other useful widgets may be created here as needed for the standard plugin library. - -Best Development Policy: -- Quickly provide widgets that work... no need to be perfectionistic. -- Only after having working controls, then "prettify" them into well-styled/ordered widgets. - - Abstract those into specialized widgets here if deemed elegant. -""" - -# Textual Imports -from textual.widgets import Button as _Button - -# Standard Imports -from typing import Callable - - -class Button(_Button): - """A normal Textual Button but extended with pressed callbacks.""" - - def connect_pressed_callback(self, c: Callable): - self._pressed_callback: Callable = c - - def on_button_pressed(self): - self._pressed_callback() diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index 155114f..4b22b49 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -1,17 +1,56 @@ -from textual.widgets import Collapsible, TabPane -from textual.app import App -from typing import Optional, Iterator -from studio.model import Plugin, Model +# Textual Imports +from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button, Label +from textual.widget import Widget +from textual.containers import ScrollableContainer + +# Standard Imports +from typing import Iterator +from studio.model import Plugin + class P(Plugin): - def __init__(self) -> None: - self.name: str = "output" - self.model: Optional[Model] = None - self.app: Optional[App] = None + def on_initialized(self) -> None: + self.name = 'output' def panel(self) -> TabPane | None: return TabPane(self.name.title()) - def controls(self) -> Iterator[Collapsible]: - return iter([]) + def controls(self) -> Iterator[Widget]: + self.live_update_mode = Checkbox('Live Update Mode') + yield self.live_update_mode + self.render_range = Input() + self.render_range.border_title = 'Render Range' + yield self.render_range + + with Collapsible(title='Pattern Queries', collapsed=False): + self.search_pattern = Input() + self.search_pattern.border_title = 'Search Pattern' + yield self.search_pattern + self.created_at = Input() + self.created_at.border_title = 'Created at Event(s)' + yield self.created_at + self.destroyed_at = Input() + self.destroyed_at.border_title = 'Destroyed at Event(s)' + yield self.destroyed_at + self.highlight_matches = Checkbox('Highlight all matching events') + yield self.highlight_matches + + with Collapsible(title='Selection Info', collapsed=False): + self.selection_info_label = Label('Selection Info:\n- created at: None\n- destroyed at: None') + yield self.selection_info_label + self.enable_hover_highlighting = Checkbox('Enable Hover Highlighting') + yield self.enable_hover_highlighting + + with Collapsible(title='Column Controls', collapsed=True): + self.show_event_indices = Checkbox('Show Event Indices') + yield self.show_event_indices + self.show_causally_connected = Checkbox('Show Causally connected') + yield self.show_causally_connected + + with Collapsible(title='Branch Paths', collapsed=True): + self.branch_render_range = Input() + self.branch_render_range.border_title = 'Branch Range(s)' + yield self.branch_render_range + self.interactive_selection_mode = Checkbox('Interactive Mode') + yield self.interactive_selection_mode plugin = P() diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index 5bab1a4..ba6174a 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -1,19 +1,17 @@ # Textual Imports -from textual.widgets import Collapsible, TabPane, Input, Checkbox +from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button from textual.widget import Widget from textual.containers import ScrollableContainer -from textual.app import App # Standard Imports -from typing import Optional, Iterator -from studio.model import Plugin, Model -from studio.stdplgns.lib.widgets import Button +from typing import Iterator +from studio.model import Plugin + class P(Plugin): - def __init__(self) -> None: - self.name: str = 'run' - self.model: Optional[Model] = None - self.app: Optional[App] = None + def on_initialized(self) -> None: + self.name = 'run' + self.view.sig_button_pressed.connect(self._handle_b) def panel(self) -> TabPane | None: # TODO: connect up the controls and the Flow backend...... @@ -21,11 +19,20 @@ def panel(self) -> TabPane | None: self.name.title(), ScrollableContainer( Collapsible(Collapsible(expanded_symbol='-', collapsed_symbol='+')), - Collapsible(), Collapsible()) + Collapsible(), Collapsible() + ) ) + def _handle_b(self, e: Button.Pressed): + if e.control.id == 'test': + self.view.notify('Test Pressed') + def controls(self) -> Iterator[Widget]: # NOTE: there aren't that many settings for the run tab due to most controls being available through the DSL. + self.test = Button('Test', id='test') + + yield self.test + with Collapsible(title='Hot Reload', collapsed=False): self.hot_mode = Checkbox('Enable Hot Reload Mode') yield self.hot_mode @@ -41,5 +48,4 @@ def controls(self) -> Iterator[Widget]: yield self.enable_progress_bar self.enable_program_stats = Checkbox('Resource usage stats') yield self.enable_program_stats - plugin = P() diff --git a/src/studio/view.py b/src/studio/view.py index acde6f7..44e99a7 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -11,7 +11,7 @@ prefer to have a checkbox called exit to project manager when ctrl+q is pressed. """ from pathlib import Path -from typing import cast, Iterable, Callable, Any +from typing import cast, Iterable from textual.app import App, ComposeResult from textual.containers import Container, Center, Horizontal, Vertical, ScrollableContainer from textual.screen import Screen, ModalScreen @@ -24,6 +24,7 @@ from textual import on from studio import config from studio import model +from core.signals import Signal # we don't use Textual builtin signal system due to limitation with widget mounting being required first. LOGO: str = r"""______ _ ______ _ _____ _ _ _ @@ -63,15 +64,6 @@ def load_text(self, text: str | None) -> None: super().load_text(text) self.placeholder = f"// This file is empty!\n// Start typing to edit this file..." - def on_mount(self) -> None: - self._code_editor_changed_callback: Callable[[TextArea.Changed], Any] = lambda _: None - - def connect_code_editor_change_callback(self, c: Callable[[TextArea.Changed], Any]): - self._code_editor_changed_callback = c - - def on_text_area_changed(self, event: TextArea.Changed): - self._code_editor_changed_callback(event) - # import re # class CustomHighlightTextArea(TextArea): @@ -268,7 +260,7 @@ def btn_open_project(self): self.app.MODEL = model.Model( # create an instance of model side of the MVC design i.id, # we use id as the field to store the name of the project config.RecentProjects.get_path(i.id), - self.app + self.app.editor_screen ) self.app.push_screen("editor") else: @@ -300,6 +292,21 @@ class EditorScreen(Screen): ("ctrl+shift+f1", "toggle_max", "Toggle Max"), ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # ==== Signals ==== + self.sig_button_pressed: Signal[Button.Pressed] = Signal() + self.sig_save_config_directive: Signal = Signal() + + @on(Button.Pressed) + def _emit_button_signals(self, event: Button.Pressed) -> None: + """Handle emitting the button pressed signal""" + self.sig_button_pressed.emit(event) + + def on_mount(self) -> None: + self.__refresh_flow_selector__() + def __refresh_flow_selector__(self) -> None: """Call to refresh the flow selector.""" # load flows @@ -311,9 +318,6 @@ def __refresh_flow_selector__(self) -> None: ol._init_selected_option(m.flows.index(m.active_flow)) # select the first option except: pass - def on_mount(self) -> None: - self.__refresh_flow_selector__() - def compose(self) -> ComposeResult: # --- LEFT COLUMN: Project Files --- with Vertical(id="project-directory"): @@ -335,13 +339,16 @@ def compose(self) -> ComposeResult: yield Button("Run", id="btn-run", classes="action-btn green", compact=True) yield Label("| ", classes="gray") yield Button("Stop", id="btn-stop", classes="action-btn orange", compact=True) + yield Label("| ", classes="gray") + yield Button("Clear", id="btn-clear", classes="action-btn red", compact=True) # Code Editor - yield (_:=TextArea.code_editor( + self.code_editor_text_area: TextArea = TextArea.code_editor( text="", id="code-editor", disabled=True - )) + ) + yield self.code_editor_text_area # _.register_language() # Plugin Panel @@ -460,21 +467,10 @@ def select_flow(self, event: Select.Changed): m: model.Model = self.app.MODEL if isinstance(event.value, int): m.active_flow = m.flows[event.value] - self.get_code_editor_widget().text = m.active_flow.read_file() + self.code_editor_text_area.text = m.active_flow.read_file() else: m.active_flow = None - self.get_code_editor_widget().text = None - - @on(Button.Pressed, "#btn-run") - def action_run(self): - pass - - @on(Button.Pressed, "#btn-stop") - def btn_stop(self): - pass - - def get_code_editor_widget(self) -> TextArea: - return self.query_one("#code-editor") + self.code_editor_text_area.text = None # ==== Panel and Controls ==== def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated): @@ -496,14 +492,14 @@ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): return self.action_save_file() m.active_flow.open_file(event.path) - self.get_code_editor_widget().text = m.active_flow.read_file() + self.code_editor_text_area.text = m.active_flow.read_file() def action_save_file(self): m: model.Model = self.app.MODEL f: model.Flow = m.active_flow if f is None: return - if f.write_file(self.get_code_editor_widget().text): + if f.write_file(self.code_editor_text_area.text): self.notify(f"Saved the \"{f.file_path.name}\" file.") @on(Button.Pressed, '#btn_refresh_project_dir') @@ -515,6 +511,10 @@ def btn_refresh_project_dir(self): class Main(App): CSS_PATH = "styles.tcss" + @property + def editor_screen(self) -> EditorScreen: + return cast(EditorScreen, self.get_screen('editor')) + def on_mount(self): # create the screens and push the welcome page self.install_screen(WelcomeScreen(), name="welcome") @@ -529,7 +529,7 @@ def handle_modal_result(result: dict): # noinspection PyUnresolvedReferences self.screen.action_save_file() if result["checkbox"]["save_config"]["value"]: - self.app.MODEL.plugins_save_configs() + self.editor_screen.sig_save_config_directive.emit() self.exit() # Push the screen with the configuration and callback self.app.push_screen( From b724547c81bc8a466cee86805f706be9d8d7fcfd Mon Sep 17 00:00:00 2001 From: isaac Date: Fri, 13 Mar 2026 00:07:23 -0400 Subject: [PATCH 09/31] Improved the signal module+policies and fixed the undo method for the FlowLang implementation (bug because of the search_buffer in Vec). --- src/core/engine.py | 57 +++++++++------------- src/core/explorer.py | 2 +- src/core/signals.py | 79 ++++++++++++++---------------- src/core/vec.py | 6 ++- src/implementations/sss.py | 2 +- src/lang/implementation.py | 5 +- src/lang/interpreter.py | 21 +++++--- src/studio/model.py | 16 +++--- src/studio/stdplgns/output.py | 34 ++++++++++--- src/studio/stdplgns/run.py | 92 +++++++++++++++++++++++++++-------- src/studio/styles.tcss | 3 ++ src/studio/view.py | 25 +++++++--- tests/lang/test.py | 2 +- 13 files changed, 213 insertions(+), 131 deletions(-) diff --git a/src/core/engine.py b/src/core/engine.py index 64ee022..ed6b0b5 100644 --- a/src/core/engine.py +++ b/src/core/engine.py @@ -1,4 +1,4 @@ -from typing import Any, Sequence, MutableSequence, NamedTuple, Iterator +from typing import Any, Sequence, MutableSequence, NamedTuple, Iterator, cast from abc import ABC, abstractmethod from dataclasses import dataclass from copy import copy @@ -391,19 +391,18 @@ def __str__(self): return '[' + ', '.join(str(space) for space in self.spaces) + ']' # TODO remove this to a dedication printer -# We use these noinspections because of stupid PyCharm bug regarding slots and dataclasses that has not been fixed... -# noinspection PyDunderSlots,PyUnresolvedReferences class Flow: """The base class for a rule flow, additional behavior should be implemented by subclassing this class.""" # Signals (can be used to live update analysis objects like the causal graph) on_evolve: Signal = Signal() + on_evolve_n: Signal = Signal() # after all evolves on_undo: Signal = Signal() + on_undo_n: Signal = Signal() # after all undo's def __init__(self): self.rule_set: RuleSet = RuleSet([]) # can be changed at any time to provide a new set of rules. self.events: list[Event] = [] # defaults to empty... but nothing will work properly - self.event_index_offset: int = 0 def set_ruleset(self, ruleset: RuleSet) -> None: """Used to set the rule set""" @@ -411,10 +410,9 @@ def set_ruleset(self, ruleset: RuleSet) -> None: def set_initial_space(self, initial_space: Sequence[SpaceState]) -> None: """Used to set the initial space""" - self.events.insert( - 0, - Event(0, [DeltaSpaces(tuple((DeltaSpace(i, (i,), (DeltaCell((), ()),)) for i in initial_space)), None)]) # initial output space must be i as well so that next evolve() works. - ) + if not self.events: + self.events.append(cast(Event, cast(object, 0))) + self.events[0] = Event(0, [DeltaSpaces(tuple((DeltaSpace(i, (i,), (DeltaCell((), ()),)) for i in initial_space)), None)]) # initial output space must be `i` as well so that next evolve() works. for i in initial_space: for cell in i.get_all_cells(): cell.created_at = 0 @@ -425,27 +423,13 @@ def clear_evolution(self) -> None: @property def current_event(self) -> Event: - try: - return self.events[self.current_event_idx] - except IndexError: # when the offset is too large or no events exist - if len(self.events) == 0: - raise IndexError('No events exist') - self.set_event_offset(0) # fix offset - return self.current_event + return self.events[-1] @property def current_event_idx(self) -> int: - return len(self.events) - 1 - self.event_index_offset - - def set_event_offset(self, offset: int, from_init: bool = False) -> None: - """Shift the current index in the flow of events""" - if offset < 0: - raise ValueError('Offset cannot be negative') - if from_init: - offset = len(self.events) - 1 - offset - self.event_index_offset = offset + return len(self.events) - 1 - def evolve(self) -> None: + def _evolve(self) -> None: """ Evolve the system by one step. This can be reimplemented by subclasses to modify behavior. As it stands, it does the following: @@ -476,22 +460,27 @@ def evolve(self) -> None: cell.destroyed_at += (current_event_idx,) # first one, of course, will be the main lineage # process causal distance to creation - min_prev: int = min((self.events[e_idx].causal_distance_to_creation for e_idx in self.current_event.causally_connected_events), default=-1) + min_prev: int = min((self.events[e_idx].causal_distance_to_creation + for e_idx in self.current_event.causally_connected_events), + default=-1) self.current_event.causal_distance_to_creation = min_prev + 1 # emit any signals self.on_evolve.emit(self) - def evolve_n(self, n_steps: int, break_when_inert: bool = False) -> None: + def evolve(self, n_steps: int, break_when_inert: bool = False) -> None: """Evolve the system n steps.""" while n_steps > 0: # print(str(next(self.current_event.spaces).cells.search_buffer).replace('A', '\x1b[1;41m A \x1b[0m').replace('B', '\x1b[1;42m B \x1b[0m')) # if we want to see how the buffer changes. - self.evolve() + self._evolve() n_steps -= 1 if break_when_inert and self.current_event.inert: break - def undo(self) -> None: + # emit any signals + self.on_undo_n.emit(self) + + def _undo(self) -> None: """undo the last event...""" if self.current_event_idx == 0: return @@ -500,17 +489,15 @@ def undo(self) -> None: for dc in sd.cell_deltas: for cell in dc.destroyed_cells: cell.destroyed_at = tuple(i for i in cell.destroyed_at if i != self.current_event_idx) - if self.event_index_offset: # shift the created_at up if we are in the middle of an evolution - for cell in dc.new_cells: - cell.created_at = current_event_idx + 1 - self.events.pop(self.current_event_idx) + self.events.pop() # emit any signals self.on_undo.emit(self) - def undo_n(self, n_steps: int) -> None: + def undo(self, n_steps: int) -> None: for _ in range(n_steps): - self.undo() + self._undo() + self.on_undo_n.emit(self) def __str__(self) -> str: return '\n'.join(str(e) for e in self.events) diff --git a/src/core/explorer.py b/src/core/explorer.py index 40449ff..edc5aac 100644 --- a/src/core/explorer.py +++ b/src/core/explorer.py @@ -236,7 +236,7 @@ class FlowExplorerTextual: # 1. Run your simulation system = SSS(rule_set=["ABA -> AAB", "A -> ABA"], initial_space='A') - system.evolve_n(10) + system.evolve(10) # 2. Visualize viz = FlowExplorerRich(system, block_mode=False) diff --git a/src/core/signals.py b/src/core/signals.py index a6bc603..61251e9 100644 --- a/src/core/signals.py +++ b/src/core/signals.py @@ -1,56 +1,51 @@ -from typing import Callable, Hashable, Any, TypeVar, Generic -SignalT = TypeVar("SignalT") +from typing import Callable, Any, TypeVar, Generic +from inspect import signature +SignalT = TypeVar("SignalT") class Signal(Generic[SignalT]): - """Implements a QT-like signal system with instance-specific filtering. + """Implements a QT-like signal system for interactive programming. + + For convenience, the emitter does not force the connected callable to take all the arguments that are emitted. + Thus, even if the emitter includes 2 arguments, but the callee only expects 1 or 0, the *args will be truncated. + If, however, there are more than the expected number of arguments, an error will occur as expected. + + Please note that any connected functions/methods will result in them (or their objects because methods are bound + and thus ephemeral) staying ALIVE in memory until disconnected, so make sure to disconnect any methods before + deleting an object. - Please note that any connected functions will KEEP result in them staying alive in memory until disconnected (so make sure to disconnect any methods before deleting and object). + If you want to implement signals globally, define them on the class space. Conversely, if you want signals to + be tied to a certain classes instances, define them as attributes. It may be useful to do both, hence you would + prefix or suffix the signal names when defining them in both the instance and class spaces. Alternatively, you + could also pass `self` when emitting and let the client decide what to do based on that. """ - __slots__ = ('callables', 'restricted_callables') + __slots__ = ('callables',) def __init__(self) -> None: - self.callables: list[Callable] = [] - # Maps a specific instance to a list of callbacks intended only for it (the caller must pass the hashable as args[0]) - self.restricted_callables: dict[Hashable, list[Callable]] = {} + self.callables: list[tuple[Callable, int]] = [] @property def callables_count(self) -> int: - return len(self.callables) + sum(len(v) for v in self.restricted_callables.values()) + return len(self.callables) def emit(self, *args: Any, **kwargs: Any) -> None: - # Standard global callbacks - for c in self.callables: - c(*args, **kwargs) - - # Instance-restricted callbacks - try: - for c in self.restricted_callables[args[0]]: - c(*args, **kwargs) - except (TypeError, # in the event that args[0] is not hashable... must use try-catch due to fake hash being detected on certain objects (like RuleMatch) that contain un-hashables. - KeyError, # if the args[0] does not exist in the dictionary - IndexError): # if the index 0 does not exist in args. - pass - - def connect(self, func: Callable, restrict_to_instance: Hashable = None) -> None: - if restrict_to_instance is not None: - callbacks = self.restricted_callables.setdefault(restrict_to_instance, []) - if func not in callbacks: - callbacks.append(func) - else: - if func not in self.callables: - self.callables.append(func) - - def disconnect(self, func: Callable, restrict_to_instance: Hashable = None) -> None: - try: - if restrict_to_instance is not None: - self.restricted_callables[restrict_to_instance].remove(func) - if not self.restricted_callables[restrict_to_instance]: - del self.restricted_callables[restrict_to_instance] - else: - self.callables.remove(func) - except (ValueError, KeyError): - pass + for c, arg_len in self.callables: + c(*args[:arg_len], **kwargs) + + def connect(self, c: Callable) -> None: + if c not in self.callables: + self.callables.append((c, len(signature(c).parameters))) + + def disconnect(self, c: Callable) -> None: + matches: list[int] = [i for i, (c_, _) in enumerate(self.callables) if c_ is c] + for i in reversed(matches): + self.callables.pop(i) + if __name__ == "__main__": - s: Signal[int] = Signal() \ No newline at end of file + t: Signal = Signal() + f1 = lambda a: print(a) + f2 = lambda a, b: print(a, b) + t.connect(f1) + t.connect(f2) + t.emit("yup", 'test') diff --git a/src/core/vec.py b/src/core/vec.py index ccf08f5..8747668 100644 --- a/src/core/vec.py +++ b/src/core/vec.py @@ -198,6 +198,10 @@ def __copy__(self): def __deepcopy__(self, memo): # force it to use self.branch for safety return self.branch() + def refresh_search_buffer(self): + """Must be called if you want to refresh a dirty search buffer.""" + self.search_buffer = bytearray((ord(c.quanta) for c in self.vec)) + # ================ Viewer Methods ================ def __len__(self): return len(self.vec) @@ -288,7 +292,7 @@ def branch(self) -> TrieVec: nv: TrieVec = object.__new__(TrieVec) nv.vec = self.vec # we don't need to copy as edit() will do that for us nv.evolver = None - nv.search_buffer = self.search_buffer # note: becomes out-of-date on self after branch (use branch_search_buffer(rfp=True) to reconstruct clean buffer for self) + nv.search_buffer = self.search_buffer # note: becomes dirty on self after branch # we could auto enter edit mode here... however, that is not necessary as this should work just fine because it is auto entered upon edits. return nv diff --git a/src/implementations/sss.py b/src/implementations/sss.py index abcd566..50b2b8b 100644 --- a/src/implementations/sss.py +++ b/src/implementations/sss.py @@ -43,7 +43,7 @@ def __init__(self, rule_set: list[str], initial_space: str): if __name__ == "__main__": sss = SSS(["ABA -> AAB", "A -> ABA"], "AB") - sss.evolve_n(20) + sss.evolve(20) print(sss) # from core.graph import CausalGraph # g = CausalGraph(sss) diff --git a/src/lang/implementation.py b/src/lang/implementation.py index 2bc13d5..d2526dc 100644 --- a/src/lang/implementation.py +++ b/src/lang/implementation.py @@ -3,7 +3,7 @@ Policy: - Any multiways should have the search_buffer optimization disabled (Vec.enable_search_buffer(False)) so that it doesn't become corrupt when branching (one state spawning two states). We have considered coding buffer branching logic... -however, that does not cover everything as Engine.RuleSet.apply() with group_break=False will not branch the buffer. +however, that does not cover everything as Engine.RuleSet.apply() with group_break=False will not branch the buffer (forgot why). In the future, we may consider making the search_buffer branch-able (really, it is already possible by manually using Vec.search_buffer.copy()). Future Considerations: @@ -36,7 +36,7 @@ class Target(NamedTuple): class BaseRule(RuleABC): # ======== Signals ======== # NOTE: time.sleep() can be used by the client to pause flow execution temporally (or play notes, etc.). - on_applied: Signal = Signal() # if the apply() function was called. The modified spaces is passed as Sequence[DeltaSpace] so that the client can test if the rule was effective. + on_applied: Signal = Signal() # if the apply() function was called. The modified spaces are passed as Sequence[DeltaSpace] so that the client can test if the rule was effective. # the three following rules get the RuleMatch along with idx of the current match passed as arguments to the client. on_execution: Signal = Signal() @@ -106,7 +106,6 @@ def __init__(self, selector: Sequence[Selector], target: Sequence[Target]): # Note that additional flags can be set in the syntax, however, they will have no meaning unless included in the control flow by subclassing and modifying particular rule. - def __repr__(self): return f"{self.__class__.__name__}({[s.selector for s in self.selectors]}, {[t.target for t in self.target]})" diff --git a/src/lang/interpreter.py b/src/lang/interpreter.py index 6b54ada..705b6aa 100644 --- a/src/lang/interpreter.py +++ b/src/lang/interpreter.py @@ -122,19 +122,18 @@ def interpret_directives(objects: dict[str, Any], directives: list[tuple[str, An return returns -# TODO: decouple the self.events from the compilation phase so that rulesets can be changed without affecting the current evolution!!! class FlowLangBase(Flow): """The general API of the Flow object used in all language implementations.""" - def interpret(self, s: str) -> None: - """Should set the current ruleset and initial space based on interpreted string. Also, handle directives.""" - raise NotImplementedError() - def interpret_file(self, path: str) -> None: """opens `.flow` files and constructs a FlowLang object.""" with open(path, 'r') as f: return self.interpret(f.read()) + def interpret(self, s: str) -> None: + """Should set the current ruleset and initial space based on interpreted string. Also, handle directives.""" + raise NotImplementedError() + class FlowLang(FlowLangBase): """The main interpreter object, it is what actually runs any given code.""" @@ -171,11 +170,18 @@ def interpret(self, s: str) -> None: # after instantiation interpret_directives({ - 'evolve': self.evolve_n, + 'evolve': self.evolve, + 'undo': self.undo, 'merge': self.__merge_group, 'compress': self.__compress_group }, self.ast['directives']) + def undo(self, n_steps: int) -> None: + super().undo(n_steps) + for space in self.current_event.spaces: # we must remember to refresh the buffer if undoing anything... + # noinspection PyUnresolvedReferences + space.cells.refresh_search_buffer() + def __merge_group(self, identifier: int | str): """A directive to merge a particular group into a chain (a composite rule)""" rules: list[BaseRule] = cast(list[BaseRule], self.rule_set.rules) @@ -216,7 +222,6 @@ def __compress_group(self, identifier: int | str): import gc import timeit - def get_mem(): """Returns current resident set size in MB.""" process = psutil.Process(os.getpid()) @@ -238,7 +243,7 @@ def get_mem(): // A -> ACB; """ flow = FlowLang(code) - time = timeit.timeit(lambda: flow.evolve_n(18), number=1) + time = timeit.timeit(lambda: flow.evolve(18), number=1) mem_end = get_mem() print(f"Total Memory of evolution: {mem_end - mem_start:.2f} MB") diff --git a/src/studio/model.py b/src/studio/model.py index ea31d92..cdcf82b 100644 --- a/src/studio/model.py +++ b/src/studio/model.py @@ -92,8 +92,6 @@ def __init__(self, name: str, project_path: Path, view: EditorScreen) -> None: obj._model = self obj._view = self.view self.plugins.append(obj) - for p in self.plugins: - p.on_initialized() # ======== Active Flows ======== self.flows: list[Flow] = [] @@ -104,6 +102,10 @@ def __init__(self, name: str, project_path: Path, view: EditorScreen) -> None: _.name = "Root" self.active_flow = _ + # ======== Initialize any children models (plugins) ======== + for p in self.plugins: + p.on_initialized() + def get_flow_options(self) -> list[str]: """ Returns list of tuples formatted for a Textual Select widget. @@ -162,12 +164,12 @@ def on_initialized(self) -> None: """Called when the plugin is fully loaded by the model.""" pass - @abstractmethod - def panel(self) -> TabPane | None: - """Returns the widget to be displayed in the panel for this plugin.""" - return None - @abstractmethod def controls(self) -> Iterator[Widget]: """Returns the controls (in renderable format) for modifying this plugin's behavior.""" pass + + @abstractmethod + def panel(self) -> TabPane | None: + """Returns the widget to be displayed in the panel for this plugin.""" + return None diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index 4b22b49..bccc1fb 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -1,26 +1,29 @@ # Textual Imports -from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button, Label +from textual.widgets import Collapsible, TabPane, Input, Checkbox, Label, DataTable +from textual.widgets.data_table import CellKey from textual.widget import Widget +from textual.coordinate import Coordinate from textual.containers import ScrollableContainer # Standard Imports from typing import Iterator -from studio.model import Plugin +from studio.model import Plugin, FlowLang class P(Plugin): def on_initialized(self) -> None: self.name = 'output' - def panel(self) -> TabPane | None: - return TabPane(self.name.title()) + # connect signals + self.model.active_flow.flow.on_evolve.connect(self.on_evolved) + self.model.active_flow.flow.on_undo.connect(self.on_undo) def controls(self) -> Iterator[Widget]: - self.live_update_mode = Checkbox('Live Update Mode') - yield self.live_update_mode self.render_range = Input() self.render_range.border_title = 'Render Range' yield self.render_range + self.live_step = Checkbox('Live Step') + yield self.live_step with Collapsible(title='Pattern Queries', collapsed=False): self.search_pattern = Input() @@ -53,4 +56,23 @@ def controls(self) -> Iterator[Widget]: yield self.branch_render_range self.interactive_selection_mode = Checkbox('Interactive Mode') yield self.interactive_selection_mode + + def panel(self) -> TabPane | None: + self.data_table = DataTable(id='data-table') + self.data_table.add_columns('Steps') + + return TabPane( + self.name.title(), + self.data_table + ) + + def on_evolved(self): + self.data_table.add_row(str(self.model.active_flow.flow.current_event.spaces.__next__())) + self.data_table.scroll_end(x_axis=False, animate=False) + + def on_undo(self): + cell_key: CellKey = self.data_table.coordinate_to_cell_key(Coordinate(self.data_table.row_count - 1, 0)) + self.data_table.remove_row(cell_key.row_key) + self.data_table.scroll_end(x_axis=False, animate=False) + plugin = P() diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index ba6174a..7599f27 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -1,37 +1,27 @@ # Textual Imports -from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button +from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button, ProgressBar, Label, RichLog from textual.widget import Widget from textual.containers import ScrollableContainer # Standard Imports from typing import Iterator +import time +import psutil +import os from studio.model import Plugin class P(Plugin): def on_initialized(self) -> None: self.name = 'run' - self.view.sig_button_pressed.connect(self._handle_b) - def panel(self) -> TabPane | None: - # TODO: connect up the controls and the Flow backend...... - return TabPane( - self.name.title(), - ScrollableContainer( - Collapsible(Collapsible(expanded_symbol='-', collapsed_symbol='+')), - Collapsible(), Collapsible() - ) + # Connect the Test button to our execution logic + self.view.sig_button_pressed.connect( + lambda e: self.execute_run() if e.button.id == 'btn-run' else None ) - def _handle_b(self, e: Button.Pressed): - if e.control.id == 'test': - self.view.notify('Test Pressed') - def controls(self) -> Iterator[Widget]: # NOTE: there aren't that many settings for the run tab due to most controls being available through the DSL. - self.test = Button('Test', id='test') - - yield self.test with Collapsible(title='Hot Reload', collapsed=False): self.hot_mode = Checkbox('Enable Hot Reload Mode') @@ -44,8 +34,72 @@ def controls(self) -> Iterator[Widget]: yield self.hot_timeout with Collapsible(title='Profiler', collapsed=False): - self.enable_progress_bar = Checkbox('Progress bar') + self.enable_progress_bar = Checkbox('Progress bar', value=True) yield self.enable_progress_bar - self.enable_program_stats = Checkbox('Resource usage stats') + self.enable_program_stats = Checkbox('Resource usage stats', value=True) yield self.enable_program_stats + + def panel(self) -> TabPane | None: + # 1. Progress Bar Widget + self.progress_bar = ProgressBar(total=100, show_eta=True, id="run-progress-bar") + self.progress_container = Collapsible(self.progress_bar, title="Execution Progress", collapsed=False) + + # 2. Run Stats Widget (Mem usage & Time) + self.stats_label = Label("Waiting for run...", id="run-stats-label") + self.stats_container = Collapsible(self.stats_label, title="Profiler Stats", collapsed=False) + + # 3. Errors & Parser Notes Widget + self.log_view = RichLog(id="run-log-view", highlight=True, markup=True, wrap=True) + clear_log = Button('Clear Log', id="clear-log") + self.log_container = Collapsible(self.log_view, clear_log, title="Errors & Parser Notes", collapsed=False) + + return TabPane( + self.name.title(), + ScrollableContainer( + self.progress_container, + self.stats_container, + self.log_container + ) + ) + + def execute_run(self) -> None: + """Handles the flow execution and updates the UI components.""" + + # Toggle visibility based on user settings in controls() + self.progress_container.display = self.enable_progress_bar.value + self.stats_container.display = self.enable_program_stats.value + + active_flow_session = self.model.active_flow + if not active_flow_session: + self.log_view.write("[bold red]Error:[/bold red] No active flow selected to run.") + return + + self.log_view.write(f"[bold green]Starting run for '{active_flow_session.name}'...[/bold green]") + + # Memory and Time profiling setup (referenced from interpreter.py) + process = psutil.Process(os.getpid()) + mem_start = process.memory_info().rss / 1024 / 1024 + start_time = time.perf_counter() + + try: + self.model.active_flow.flow.interpret(self.view.code_editor_text_area.text) + + # If the progress bar is enabled, advance it: + if self.enable_progress_bar.value: + self.progress_bar.advance(100) # Mock completion + + except Exception as e: + self.log_view.write(f"[bold red]Execution Error:[/bold red] {str(e)}") + + finally: + # Calculate and display stats + if self.enable_program_stats.value: + mem_end = process.memory_info().rss / 1024 / 1024 + elapsed_time = time.perf_counter() - start_time + mem_diff = mem_end - mem_start + self.stats_label.update( + f"[bold]Time Spent:[/bold] {elapsed_time:.4f} seconds\n" + f"[bold]Memory Change:[/bold] {mem_diff:+.2f} MB\n" + f"[bold]Total Memory:[/bold] {mem_end:.2f} MB" + ) plugin = P() diff --git a/src/studio/styles.tcss b/src/studio/styles.tcss index fd434ba..7f8b8b0 100644 --- a/src/studio/styles.tcss +++ b/src/studio/styles.tcss @@ -195,6 +195,9 @@ Input:focus { background-tint: #252526; } +RichLog { + overflow: auto; +} /* ========================================= COMPONENT CLASSES diff --git a/src/studio/view.py b/src/studio/view.py index 44e99a7..dd6ed3b 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -257,12 +257,12 @@ def handle_modal_result(result: dict): def btn_open_project(self): _: OptionList = cast(OptionList, self.query_one("#recents-list")) if i:=_.highlighted_option: - self.app.MODEL = model.Model( # create an instance of model side of the MVC design - i.id, # we use id as the field to store the name of the project - config.RecentProjects.get_path(i.id), - self.app.editor_screen + self.dismiss( + { + "project_name": i.id, + "project_path": config.RecentProjects.get_path(i.id) + } ) - self.app.push_screen("editor") else: self.notify('Please select a project to open!', severity='warning') @@ -318,6 +318,10 @@ def __refresh_flow_selector__(self) -> None: ol._init_selected_option(m.flows.index(m.active_flow)) # select the first option except: pass + def action_run(self): + """Action to press the run button upon this action...""" + self.query_one('#btn-run').press() + def compose(self) -> ComposeResult: # --- LEFT COLUMN: Project Files --- with Vertical(id="project-directory"): @@ -340,7 +344,7 @@ def compose(self) -> ComposeResult: yield Label("| ", classes="gray") yield Button("Stop", id="btn-stop", classes="action-btn orange", compact=True) yield Label("| ", classes="gray") - yield Button("Clear", id="btn-clear", classes="action-btn red", compact=True) + yield Button("Reset", id="btn-reset", classes="action-btn red", compact=True) # Code Editor self.code_editor_text_area: TextArea = TextArea.code_editor( @@ -519,7 +523,14 @@ def on_mount(self): # create the screens and push the welcome page self.install_screen(WelcomeScreen(), name="welcome") self.install_screen(EditorScreen(), name="editor") - self.push_screen("welcome") + def on_project_opened(result: dict): + self.MODEL = model.Model( + result["project_name"], + result["project_path"], + self.editor_screen + ) + self.push_screen("editor") + self.push_screen("welcome", callback=on_project_opened) def action_quit(self): if not hasattr(self, "MODEL"): diff --git a/tests/lang/test.py b/tests/lang/test.py index 2cdf043..843c0d9 100644 --- a/tests/lang/test.py +++ b/tests/lang/test.py @@ -29,7 +29,7 @@ def get_mem(): // A -> ACB; """ flow = FlowLang(code) - time = timeit.timeit(lambda: flow.evolve_n(18), number=1) + time = timeit.timeit(lambda: flow.evolve(18), number=1) mem_end = get_mem() print(f"Total Memory of evolution: {mem_end - mem_start:.2f} MB") From 0a77dbfff4a5933b3f2bb65ffdd1521cbc7dae51 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Sun, 29 Mar 2026 22:33:47 -0400 Subject: [PATCH 10/31] Saving as backup --- src/concat/__init__.py | 0 src/core/engine.py | 2 ++ src/lang/interpreter.py | 4 ++- src/studio/stdplgns/output.py | 56 +++++++++++++++++++++++++---------- src/studio/view.py | 12 ++++++++ tests/test0.py | 5 ++++ 6 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 src/concat/__init__.py diff --git a/src/concat/__init__.py b/src/concat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/engine.py b/src/core/engine.py index ed6b0b5..130f1bd 100644 --- a/src/core/engine.py +++ b/src/core/engine.py @@ -399,6 +399,7 @@ class Flow: on_evolve_n: Signal = Signal() # after all evolves on_undo: Signal = Signal() on_undo_n: Signal = Signal() # after all undo's + on_clear: Signal = Signal() def __init__(self): self.rule_set: RuleSet = RuleSet([]) # can be changed at any time to provide a new set of rules. @@ -420,6 +421,7 @@ def set_initial_space(self, initial_space: Sequence[SpaceState]) -> None: def clear_evolution(self) -> None: """Clear the evolution.""" del self.events[1:] + self.on_clear.emit(self) @property def current_event(self) -> Event: diff --git a/src/lang/interpreter.py b/src/lang/interpreter.py index 705b6aa..cb5b2f0 100644 --- a/src/lang/interpreter.py +++ b/src/lang/interpreter.py @@ -166,12 +166,14 @@ def interpret(self, s: str) -> None: ) )) Vec: type[vec.Vec] = getattr(vec, r.get('mem', vec.Vec.__name__)) # this is the vector we use (vec.Vec is the default) - self.set_initial_space([SpaceState(Vec([Cell(s) for s in string])) for string in r['init']]) + if not self.events: + self.set_initial_space([SpaceState(Vec([Cell(s) for s in string])) for string in r['init']]) # after instantiation interpret_directives({ 'evolve': self.evolve, 'undo': self.undo, + 'clear': self.clear_evolution, 'merge': self.__merge_group, 'compress': self.__compress_group }, self.ast['directives']) diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index bccc1fb..fa8a91b 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -1,5 +1,6 @@ # Textual Imports from textual.widgets import Collapsible, TabPane, Input, Checkbox, Label, DataTable +from rich.text import Text from textual.widgets.data_table import CellKey from textual.widget import Widget from textual.coordinate import Coordinate @@ -14,15 +15,22 @@ class P(Plugin): def on_initialized(self) -> None: self.name = 'output' - # connect signals + # plugin attributes + # TODO: work on this + self._color_map: dict[str, Text] = { + l: Text(l) for l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + } + + # connect model signals self.model.active_flow.flow.on_evolve.connect(self.on_evolved) self.model.active_flow.flow.on_undo.connect(self.on_undo) + self.model.active_flow.flow.on_clear.connect(self.on_clear) + + # connect view signals + self.view.sig_checkbox_changed.connect(self.handle_checkbox) def controls(self) -> Iterator[Widget]: - self.render_range = Input() - self.render_range.border_title = 'Render Range' - yield self.render_range - self.live_step = Checkbox('Live Step') + self.live_step = Checkbox('Live Step') # update on each step, rather than the end of an evolution. yield self.live_step with Collapsible(title='Pattern Queries', collapsed=False): @@ -50,16 +58,20 @@ def controls(self) -> Iterator[Widget]: self.show_causally_connected = Checkbox('Show Causally connected') yield self.show_causally_connected - with Collapsible(title='Branch Paths', collapsed=True): - self.branch_render_range = Input() - self.branch_render_range.border_title = 'Branch Range(s)' - yield self.branch_render_range - self.interactive_selection_mode = Checkbox('Interactive Mode') - yield self.interactive_selection_mode + with Collapsible(title='Color Controls', collapsed=True): + self.color_mapping = Input() + self.color_mapping.border_title = 'Color Mapping' + yield self.color_mapping + + def handle_checkbox(self, sig: Checkbox.Changed) -> None: + pass def panel(self) -> TabPane | None: self.data_table = DataTable(id='data-table') - self.data_table.add_columns('Steps') + self.data_table.add_columns('Time') + self.data_table.add_columns('Distance') + self.data_table.add_columns('Causally Connected') + self.data_table.add_columns('Evolution') return TabPane( self.name.title(), @@ -67,12 +79,26 @@ def panel(self) -> TabPane | None: ) def on_evolved(self): - self.data_table.add_row(str(self.model.active_flow.flow.current_event.spaces.__next__())) - self.data_table.scroll_end(x_axis=False, animate=False) + # noinspection PyTypeChecker + flow: FlowLang = self.model.active_flow.flow + self.data_table.add_row( + str(flow.current_event.time), + str(flow.current_event.causal_distance_to_creation), + str(tuple(flow.current_event.causally_connected_events)), + str(flow.current_event.spaces.__next__()).replace('A', '[on blue3] A [/on blue3]') + .replace('B', '[on magenta] B [/on magenta]') + .replace('C', '[on yellow] C [/on yellow]') + ) + if self.data_table.is_vertical_scroll_end: + self.data_table.scroll_end(x_axis=False, animate=False) def on_undo(self): cell_key: CellKey = self.data_table.coordinate_to_cell_key(Coordinate(self.data_table.row_count - 1, 0)) self.data_table.remove_row(cell_key.row_key) - self.data_table.scroll_end(x_axis=False, animate=False) + if self.data_table.is_vertical_scroll_end: + self.data_table.scroll_end(x_axis=False, animate=False) + + def on_clear(self): + self.data_table.clear() plugin = P() diff --git a/src/studio/view.py b/src/studio/view.py index dd6ed3b..a703733 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -297,6 +297,8 @@ def __init__(self, *args, **kwargs): # ==== Signals ==== self.sig_button_pressed: Signal[Button.Pressed] = Signal() + self.sig_checkbox_changed: Signal[Checkbox.Changed] = Signal() + self.sig_input_changed: Signal[Input.Changed] = Signal() self.sig_save_config_directive: Signal = Signal() @on(Button.Pressed) @@ -304,6 +306,16 @@ def _emit_button_signals(self, event: Button.Pressed) -> None: """Handle emitting the button pressed signal""" self.sig_button_pressed.emit(event) + @on(Checkbox.Changed) + def _emit_checkbox_signals(self, event: Checkbox.Changed) -> None: + """Handle emitting the checkbox changed signal""" + self.sig_checkbox_changed.emit(event) + + @on(Input.Changed) + def _emit_input_signals(self, event: Input.Changed) -> None: + """Handle emitting the input changed signal""" + self.sig_input_changed.emit(event) + def on_mount(self) -> None: self.__refresh_flow_selector__() diff --git a/tests/test0.py b/tests/test0.py index e69de29..0e3e1ee 100644 --- a/tests/test0.py +++ b/tests/test0.py @@ -0,0 +1,5 @@ +funcs = [lambda: i for i in range(3)] + + +print([f() for f in funcs]) + From 81d7e827b28f78b7d49a792783e0cdcb589e2b9d Mon Sep 17 00:00:00 2001 From: isaac Date: Mon, 30 Mar 2026 19:11:55 -0400 Subject: [PATCH 11/31] Update the .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a09c56d..e739599 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /.idea +/.venv From e87ea4f9da656e154f34ddddfde58f78c12fe9a0 Mon Sep 17 00:00:00 2001 From: isaac Date: Mon, 30 Mar 2026 19:27:32 -0400 Subject: [PATCH 12/31] Added a bin directory for future access to utilities we make such as RuleFlow Studio links, and command line utils. --- RuleFlow Studio.lnk => bin/RuleFlow Studio.lnk | Bin bin/ruleflow.bash | 3 +++ ruleflow_studio.bat => bin/ruleflow.bat | 0 3 files changed, 3 insertions(+) rename RuleFlow Studio.lnk => bin/RuleFlow Studio.lnk (100%) create mode 100755 bin/ruleflow.bash rename ruleflow_studio.bat => bin/ruleflow.bat (100%) diff --git a/RuleFlow Studio.lnk b/bin/RuleFlow Studio.lnk similarity index 100% rename from RuleFlow Studio.lnk rename to bin/RuleFlow Studio.lnk diff --git a/bin/ruleflow.bash b/bin/ruleflow.bash new file mode 100755 index 0000000..931b46e --- /dev/null +++ b/bin/ruleflow.bash @@ -0,0 +1,3 @@ +#!/bin/bash +cd /home/isaac/repos/RuleFlow/src +uv run python -m studio.view diff --git a/ruleflow_studio.bat b/bin/ruleflow.bat similarity index 100% rename from ruleflow_studio.bat rename to bin/ruleflow.bat From 2b0c3ccf00ea16bf1ef038bb7978d14c3faa413f Mon Sep 17 00:00:00 2001 From: isaac Date: Mon, 30 Mar 2026 19:44:16 -0400 Subject: [PATCH 13/31] Added an official RuleFlow Studio Example Project folder to the tests directory containing examples and .flow files to test Studio with. --- tests/example-studio-project/ca.flow | 8 ++++++++ tests/example-studio-project/sss.flow | 9 +++++++++ tests/lang/rule_30.flow | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 tests/example-studio-project/ca.flow create mode 100644 tests/example-studio-project/sss.flow diff --git a/tests/example-studio-project/ca.flow b/tests/example-studio-project/ca.flow new file mode 100644 index 0000000..11174ca --- /dev/null +++ b/tests/example-studio-project/ca.flow @@ -0,0 +1,8 @@ +// Rule 30 +@import(ca.fp); + +// define the rules +@decode(wns, AB, 30); + +// Run n times +@evolve(1); diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow new file mode 100644 index 0000000..9f2ef00 --- /dev/null +++ b/tests/example-studio-project/sss.flow @@ -0,0 +1,9 @@ +// set the initial state +@init("AB"); + +// define the sequential rules +ABA -> AAB; +A -> ABA; + +// Evolve n times (use @undo to undo n steps) +@evolve(1); diff --git a/tests/lang/rule_30.flow b/tests/lang/rule_30.flow index 5dec60f..0521bda 100644 --- a/tests/lang/rule_30.flow +++ b/tests/lang/rule_30.flow @@ -1,5 +1,5 @@ // Rule 30 -@init(AAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAA); +@import(ca.fp) // define the rules @compress(0); From 081a7db5e8b39316c8eac44d0f999ef156d8291f Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Fri, 3 Apr 2026 05:42:28 -0400 Subject: [PATCH 14/31] Added better exception handling to the builtin run plugin. --- bin/RuleFlow Studio.lnk | Bin 2169 -> 2212 bytes bin/ruleflow.bat | 2 +- src/studio/stdplgns/run.py | 89 +++++++++++++++----------- tests/example-studio-project/sss.flow | 2 +- 4 files changed, 52 insertions(+), 41 deletions(-) diff --git a/bin/RuleFlow Studio.lnk b/bin/RuleFlow Studio.lnk index d22706230d3c36c50aac5e4493790c2011419197..32208483198b511e6c67c59d3b259a724c3c445c 100644 GIT binary patch delta 596 zcmewBa1oZjR}3m^fd`to6-*h8%``hGd3BAkDxa!Vth<2sWWP=DDK) zLs4o$ele2HyFi`K9Vfn*s@MLf##qFV%1{8*T@2P41=slzs57WEC)F({znp==32JY1 z%o@k$n2(NnK-HF9Am_?}Z9)*{YZ)1X7)pUINo8;Yy9=nDL4v^xZt_~7$w`@c3>Nip zL)QWgT?;m}{Twq;IS;}-Fe|_7JVO#gCPN;OC&ZA(V8j3lvF4cDjtUG#Ku4wl9jTX; zSi%6b1`zapVD%%A!|J_tliW?@n_ zfQ3#B&@(ZpuHa+PV~AluGkkIulO`uTXeQ5L%H5pF%)>gFli9#zj>J*J{dyk-msU9( ew@E2$e(~LMx7fx;hvw_wmwNr_@I;pfxdH(15R$_H delta 477 zcmZ1?_)}nljA2#1-gU>WJB*vY?AOTz(kJttrMm!WF9rx;gwil-?nGZR8NHYX(t->* z`N@en3^oj$3@i-q_vyv#l{Sf4JagiFDKin{R}47}`3%Vni9nixL4+ZI!4PbMeoR@u z07Fq~L4Glk&bttu@1^R6($g4<7*ZJufVzvpI-{UE^SpFsXQ=1o7{b610X5zvX2LAf7*Ph0@d|%I#v21MGBB@antYU5Q&t8j7z4y08Wd_E zS|5l(AbPR`lj>wYR!%XP2jYuMN>eiP^^y`x7#Kt+H!_J%zQ!!ZC_ec-lm6s$OlgxH zI1CJ2D?g>NeET4{G|1t&O-fnwi|>}Z#WprNG++O|)ay@&C%XJ*6J{RP$qlS508Vsz A_y7O^ diff --git a/bin/ruleflow.bat b/bin/ruleflow.bat index 1f01dbd..d683f48 100644 --- a/bin/ruleflow.bat +++ b/bin/ruleflow.bat @@ -1,4 +1,4 @@ @echo off -cd C:\local\repos\ruleflow\src +cd C:\local\repos\RuleFlow\src uv run python -m studio.view @pause diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index 7599f27..4032dca 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -1,13 +1,15 @@ # Textual Imports from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button, ProgressBar, Label, RichLog from textual.widget import Widget -from textual.containers import ScrollableContainer +from textual.containers import ScrollableContainer, Horizontal # Standard Imports from typing import Iterator import time import psutil import os +import sys +from rich.traceback import Traceback as RichTraceback from studio.model import Plugin @@ -15,9 +17,12 @@ class P(Plugin): def on_initialized(self) -> None: self.name = 'run' + # Attributes + self._process = psutil.Process(os.getpid()) + # Connect the Test button to our execution logic self.view.sig_button_pressed.connect( - lambda e: self.execute_run() if e.button.id == 'btn-run' else None + self.handle_btn_press ) def controls(self) -> Iterator[Widget]: @@ -33,25 +38,26 @@ def controls(self) -> Iterator[Widget]: self.hot_timeout.border_title = 'Timeout (ms)' yield self.hot_timeout - with Collapsible(title='Profiler', collapsed=False): - self.enable_progress_bar = Checkbox('Progress bar', value=True) - yield self.enable_progress_bar - self.enable_program_stats = Checkbox('Resource usage stats', value=True) - yield self.enable_program_stats - def panel(self) -> TabPane | None: - # 1. Progress Bar Widget + # Progress Bar Widget self.progress_bar = ProgressBar(total=100, show_eta=True, id="run-progress-bar") self.progress_container = Collapsible(self.progress_bar, title="Execution Progress", collapsed=False) - # 2. Run Stats Widget (Mem usage & Time) + # Run Stats Widget (Mem usage & Time) self.stats_label = Label("Waiting for run...", id="run-stats-label") self.stats_container = Collapsible(self.stats_label, title="Profiler Stats", collapsed=False) - # 3. Errors & Parser Notes Widget + # Errors & Parser Notes Widget self.log_view = RichLog(id="run-log-view", highlight=True, markup=True, wrap=True) clear_log = Button('Clear Log', id="clear-log") - self.log_container = Collapsible(self.log_view, clear_log, title="Errors & Parser Notes", collapsed=False) + self.show_traceback = Checkbox('Show Traceback') + self.log_container = Collapsible( + self.log_view, + Horizontal( + clear_log, + self.show_traceback + ), + title="Errors & Parser Notes", collapsed=False) return TabPane( self.name.title(), @@ -62,44 +68,49 @@ def panel(self) -> TabPane | None: ) ) + def handle_btn_press(self, e: Button.Pressed): + btn: str = e.button.id + if btn == 'btn-run': + self.execute_run() + elif btn == 'clear-log': + self.clear_log() + + def clear_log(self) -> None: + self.log_view.clear() + self.log_view.write(f"[bold green]Cleared Log[/bold green]") + def execute_run(self) -> None: """Handles the flow execution and updates the UI components.""" - # Toggle visibility based on user settings in controls() - self.progress_container.display = self.enable_progress_bar.value - self.stats_container.display = self.enable_program_stats.value - active_flow_session = self.model.active_flow if not active_flow_session: - self.log_view.write("[bold red]Error:[/bold red] No active flow selected to run.") + self.log_view.write("[bold red]Studio Error:[/bold red] No active flow selected to run.") return - self.log_view.write(f"[bold green]Starting run for '{active_flow_session.name}'...[/bold green]") + self.log_view.write(f"[bold green]Executing '{active_flow_session.name}'...[/bold green]") - # Memory and Time profiling setup (referenced from interpreter.py) - process = psutil.Process(os.getpid()) - mem_start = process.memory_info().rss / 1024 / 1024 + # Memory and Time profiling setup + mem_start = self._process.memory_info().rss / 1024 / 1024 start_time = time.perf_counter() try: + # Attempt to execute the flow self.model.active_flow.flow.interpret(self.view.code_editor_text_area.text) - - # If the progress bar is enabled, advance it: - if self.enable_progress_bar.value: - self.progress_bar.advance(100) # Mock completion - + self.progress_bar.advance(100) # Mock completion except Exception as e: - self.log_view.write(f"[bold red]Execution Error:[/bold red] {str(e)}") - - finally: - # Calculate and display stats - if self.enable_program_stats.value: - mem_end = process.memory_info().rss / 1024 / 1024 - elapsed_time = time.perf_counter() - start_time - mem_diff = mem_end - mem_start - self.stats_label.update( - f"[bold]Time Spent:[/bold] {elapsed_time:.4f} seconds\n" - f"[bold]Memory Change:[/bold] {mem_diff:+.2f} MB\n" - f"[bold]Total Memory:[/bold] {mem_end:.2f} MB" - ) + # Handle the exception + if self.show_traceback.value: + self.log_view.write(RichTraceback.from_exception(*sys.exc_info())) + else: + self.log_view.write(f"[bold red]Execution Error:[/bold red] {str(e)}") + + # Show profiling info to user + mem_end = self._process.memory_info().rss / 1024 / 1024 + elapsed_time = time.perf_counter() - start_time + mem_diff = mem_end - mem_start + self.stats_label.update( + f"[bold]Time Spent:[/bold] {elapsed_time:.4f} seconds\n" + f"[bold]Memory Change:[/bold] {mem_diff:+.2f} MB\n" + f"[bold]Total Memory:[/bold] {mem_end:.2f} MB" + ) plugin = P() diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow index 9f2ef00..016ac2e 100644 --- a/tests/example-studio-project/sss.flow +++ b/tests/example-studio-project/sss.flow @@ -6,4 +6,4 @@ ABA -> AAB; A -> ABA; // Evolve n times (use @undo to undo n steps) -@evolve(1); +@evolve( From 4f87f5d1427f62eb12d85cb5a0d9daa4f2355d64 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Fri, 3 Apr 2026 16:03:48 -0400 Subject: [PATCH 15/31] Fleshed out the run plugin and implemented hot reloading and threading. --- src/core/engine.py | 25 +++-- src/{concat => icat}/__init__.py | 0 src/lang/interpreter.py | 20 ++-- src/studio/stdplgns/output.py | 8 +- src/studio/stdplgns/run.py | 153 +++++++++++++++++--------- src/studio/view.py | 2 +- tests/example-studio-project/ca.flow | 2 +- tests/example-studio-project/sss.flow | 3 +- tests/test0.py | 5 - 9 files changed, 141 insertions(+), 77 deletions(-) rename src/{concat => icat}/__init__.py (100%) diff --git a/src/core/engine.py b/src/core/engine.py index 130f1bd..bbc9006 100644 --- a/src/core/engine.py +++ b/src/core/engine.py @@ -1,4 +1,4 @@ -from typing import Any, Sequence, MutableSequence, NamedTuple, Iterator, cast +from typing import Any, Sequence, MutableSequence, NamedTuple, Iterator, cast, Self from abc import ABC, abstractmethod from dataclasses import dataclass from copy import copy @@ -395,16 +395,19 @@ class Flow: """The base class for a rule flow, additional behavior should be implemented by subclassing this class.""" # Signals (can be used to live update analysis objects like the causal graph) - on_evolve: Signal = Signal() - on_evolve_n: Signal = Signal() # after all evolves - on_undo: Signal = Signal() - on_undo_n: Signal = Signal() # after all undo's - on_clear: Signal = Signal() + on_evolve: Signal[Self] = Signal() + on_evolve_n: Signal[Self] = Signal() # after all evolves + on_undo: Signal[Self] = Signal() + on_undo_n: Signal[Self] = Signal() # after all undo's + on_clear: Signal[Self] = Signal() def __init__(self): self.rule_set: RuleSet = RuleSet([]) # can be changed at any time to provide a new set of rules. self.events: list[Event] = [] # defaults to empty... but nothing will work properly + # progress tracking attributes + self.n_step_progress: float = 1 # percentage of steps run by some_method_n(). + def set_ruleset(self, ruleset: RuleSet) -> None: """Used to set the rule set""" self.rule_set: RuleSet = ruleset @@ -472,15 +475,17 @@ def _evolve(self) -> None: def evolve(self, n_steps: int, break_when_inert: bool = False) -> None: """Evolve the system n steps.""" - while n_steps > 0: + i: int = 0 + while i < n_steps: # print(str(next(self.current_event.spaces).cells.search_buffer).replace('A', '\x1b[1;41m A \x1b[0m').replace('B', '\x1b[1;42m B \x1b[0m')) # if we want to see how the buffer changes. + self.n_step_progress = (i + 1) / n_steps + i += 1 self._evolve() - n_steps -= 1 if break_when_inert and self.current_event.inert: break # emit any signals - self.on_undo_n.emit(self) + self.on_evolve_n.emit(self) def _undo(self) -> None: """undo the last event...""" @@ -498,7 +503,9 @@ def _undo(self) -> None: def undo(self, n_steps: int) -> None: for _ in range(n_steps): + self.n_step_progress = (_ + 1) / n_steps self._undo() + self.on_undo_n.emit(self) def __str__(self) -> str: diff --git a/src/concat/__init__.py b/src/icat/__init__.py similarity index 100% rename from src/concat/__init__.py rename to src/icat/__init__.py diff --git a/src/lang/interpreter.py b/src/lang/interpreter.py index cb5b2f0..5cc390a 100644 --- a/src/lang/interpreter.py +++ b/src/lang/interpreter.py @@ -134,6 +134,18 @@ def interpret(self, s: str) -> None: """Should set the current ruleset and initial space based on interpreted string. Also, handle directives.""" raise NotImplementedError() + def undo(self, n_steps: int) -> None: + super().undo(n_steps) + for space in self.current_event.spaces: # we must remember to refresh the search buffer if undoing anything... + # noinspection PyUnresolvedReferences + space.cells.refresh_search_buffer() + + def clear_evolution(self) -> None: + super().clear_evolution() + for space in self.current_event.spaces: # we must remember to refresh the search buffer if clearing anything... + # noinspection PyUnresolvedReferences + space.cells.refresh_search_buffer() + class FlowLang(FlowLangBase): """The main interpreter object, it is what actually runs any given code.""" @@ -178,12 +190,6 @@ def interpret(self, s: str) -> None: 'compress': self.__compress_group }, self.ast['directives']) - def undo(self, n_steps: int) -> None: - super().undo(n_steps) - for space in self.current_event.spaces: # we must remember to refresh the buffer if undoing anything... - # noinspection PyUnresolvedReferences - space.cells.refresh_search_buffer() - def __merge_group(self, identifier: int | str): """A directive to merge a particular group into a chain (a composite rule)""" rules: list[BaseRule] = cast(list[BaseRule], self.rule_set.rules) @@ -199,7 +205,7 @@ def __merge_group(self, identifier: int | str): break def __compress_group(self, identifier: int | str): - """Compress a Rule Group such that causality is preserved (no cellular change if the characters look the same)""" + """A directive to compress a Rule Group such that causality is preserved (no cellular change if the characters look the same)""" rules: list[BaseRule] = [rule for rule in cast(list[BaseRule], self.rule_set.rules) if rule.group == identifier and not rule.disabled] # If any rule makes no changes, disable it. diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index fa8a91b..caed627 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -16,10 +16,10 @@ def on_initialized(self) -> None: self.name = 'output' # plugin attributes - # TODO: work on this - self._color_map: dict[str, Text] = { - l: Text(l) for l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' - } + # # TODO: work on this + # self._color_map: dict[str, Text] = { + # l: Text(l) for l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + # } # connect model signals self.model.active_flow.flow.on_evolve.connect(self.on_evolved) diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index 4032dca..1a8b666 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -2,6 +2,7 @@ from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button, ProgressBar, Label, RichLog from textual.widget import Widget from textual.containers import ScrollableContainer, Horizontal +from textual.timer import Timer # Standard Imports from typing import Iterator @@ -10,60 +11,70 @@ import os import sys from rich.traceback import Traceback as RichTraceback -from studio.model import Plugin +from textual.worker import Worker + +from studio.model import Plugin, FlowLangBase class P(Plugin): def on_initialized(self) -> None: self.name = 'run' - # Attributes - self._process = psutil.Process(os.getpid()) - - # Connect the Test button to our execution logic + # Connect buttons to our execution logic self.view.sig_button_pressed.connect( self.handle_btn_press ) + self.view.sig_checkbox_changed.connect( + self.handle_checkbox_change + ) - def controls(self) -> Iterator[Widget]: - # NOTE: there aren't that many settings for the run tab due to most controls being available through the DSL. + # Connect flow signals to update progress bar + FlowLangBase.on_evolve.connect(self._handle_progress_updates) + FlowLangBase.on_undo.connect(self._handle_progress_updates) + + # Attributes + self._process = psutil.Process(os.getpid()) + self._prev_flowlang_src: str = '' # for diff checking + self._hot_after_n_changes: int = 0 # for fast reference + self._running_thread: Worker | None = None + def controls(self) -> Iterator[Widget]: + # NOTE: there aren't many settings for the run tab due to most controls being available through the DSL. + self.mem_profile = Checkbox('Show Memory Profile') + yield self.mem_profile with Collapsible(title='Hot Reload', collapsed=False): - self.hot_mode = Checkbox('Enable Hot Reload Mode') + self.hot_mode = Checkbox('Enable hot reload mode', id='hot-reload') yield self.hot_mode - self.hot_n_changes = Input(type='integer', value='1') - self.hot_n_changes.border_title = 'Re-run after N changes' - yield self.hot_n_changes - self.hot_timeout = Input(type='number', value='500') - self.hot_timeout.border_title = 'Timeout (ms)' - yield self.hot_timeout + self.hot_after_n_changes = Input(type='integer', value='1', id='hot-after-change') + self.hot_after_n_changes.border_title = 'After n changes' + yield self.hot_after_n_changes + + self.hot_reload_timer: Timer = self.view.set_interval( + 1, self._handle_hot_reload, + pause=True # start paused. + ) def panel(self) -> TabPane | None: # Progress Bar Widget self.progress_bar = ProgressBar(total=100, show_eta=True, id="run-progress-bar") self.progress_container = Collapsible(self.progress_bar, title="Execution Progress", collapsed=False) - # Run Stats Widget (Mem usage & Time) - self.stats_label = Label("Waiting for run...", id="run-stats-label") - self.stats_container = Collapsible(self.stats_label, title="Profiler Stats", collapsed=False) - - # Errors & Parser Notes Widget + # Standard Output Widget self.log_view = RichLog(id="run-log-view", highlight=True, markup=True, wrap=True) clear_log = Button('Clear Log', id="clear-log") - self.show_traceback = Checkbox('Show Traceback') + self.show_traceback = Checkbox('Show Tracebacks') self.log_container = Collapsible( self.log_view, Horizontal( clear_log, self.show_traceback ), - title="Errors & Parser Notes", collapsed=False) + title="Program Log", collapsed=False) return TabPane( self.name.title(), ScrollableContainer( self.progress_container, - self.stats_container, self.log_container ) ) @@ -73,44 +84,88 @@ def handle_btn_press(self, e: Button.Pressed): if btn == 'btn-run': self.execute_run() elif btn == 'clear-log': - self.clear_log() - - def clear_log(self) -> None: - self.log_view.clear() - self.log_view.write(f"[bold green]Cleared Log[/bold green]") + self.log_view.clear() + self.log_view.write(f"[bold green]Cleared Log[/bold green]") + + def handle_checkbox_change(self, e: Checkbox.Changed): + btn: str = e.checkbox.id + if btn == 'hot-reload': + self.hot_after_n_changes.disabled = e.checkbox.value + if e.checkbox.value: + self._hot_after_n_changes = int(self.hot_after_n_changes.value) + self.hot_reload_timer.resume() + else: + self.hot_reload_timer.pause() - def execute_run(self) -> None: - """Handles the flow execution and updates the UI components.""" + def _flow_src_diff_check(self) -> int: + a: str = self.view.code_editor_text_area.text + b: str = self._prev_flowlang_src + return sum(x != y for x, y in zip(a, b)) + abs(len(a) - len(b)) - active_flow_session = self.model.active_flow - if not active_flow_session: - self.log_view.write("[bold red]Studio Error:[/bold red] No active flow selected to run.") + def _handle_hot_reload(self) -> None: + # import time + # self.log_view.write(time.time()) # for debugging timer + if self._running_thread and self._running_thread.is_running: # do not hot-reload while thread is active return + if self._flow_src_diff_check() >= self._hot_after_n_changes: # only hot-reload after n changes to src + self._prev_flowlang_src = self.view.code_editor_text_area.text + self.execute_run() - self.log_view.write(f"[bold green]Executing '{active_flow_session.name}'...[/bold green]") + def _handle_progress_updates(self, f: FlowLangBase) -> None: + self.view.app.call_from_thread( # we must call from the main thread to be thread-safe according to docs + self.progress_bar.update, + progress=f.n_step_progress * 100 + ) + # import time # to test slowdowns + # time.sleep(0.5) - # Memory and Time profiling setup - mem_start = self._process.memory_info().rss / 1024 / 1024 - start_time = time.perf_counter() + def _execute(self) -> None: + # use self.view.app.call_from_thread to be thread-safe (according to docs on Workers) + if self.mem_profile.value: + mem_start = self._process.memory_info().rss / 1024 / 1024 + start_time = time.perf_counter() + # execute the FlowLang try: - # Attempt to execute the flow self.model.active_flow.flow.interpret(self.view.code_editor_text_area.text) - self.progress_bar.advance(100) # Mock completion except Exception as e: # Handle the exception if self.show_traceback.value: - self.log_view.write(RichTraceback.from_exception(*sys.exc_info())) + self.view.app.call_from_thread( + self.log_view.write, + RichTraceback.from_exception(*sys.exc_info(), word_wrap=True) + ) else: - self.log_view.write(f"[bold red]Execution Error:[/bold red] {str(e)}") - - # Show profiling info to user - mem_end = self._process.memory_info().rss / 1024 / 1024 - elapsed_time = time.perf_counter() - start_time - mem_diff = mem_end - mem_start - self.stats_label.update( - f"[bold]Time Spent:[/bold] {elapsed_time:.4f} seconds\n" - f"[bold]Memory Change:[/bold] {mem_diff:+.2f} MB\n" - f"[bold]Total Memory:[/bold] {mem_end:.2f} MB" + self.view.app.call_from_thread( + self.log_view.write, + f"[bold red]Execution Error:[/bold red] {str(e)}" + ) + + # show profiler info + if self.mem_profile.value: + mem_end = self._process.memory_info().rss / 1024 / 1024 + # noinspection PyUnboundLocalVariable + elapsed_time = time.perf_counter() - start_time + # noinspection PyUnboundLocalVariable + mem_diff = mem_end - mem_start + self.view.app.call_from_thread( + self.log_view.write, + f"[bold]Time Spent:[/bold] {elapsed_time:.4f} seconds\n" + f"[bold]Memory Change:[/bold] {mem_diff:+.2f} MB\n" + f"[bold]Total Studio Memory:[/bold] {mem_end:.2f} MB\n" + ) + + def execute_run(self) -> None: + """Handles the flow execution and updates the UI components.""" + active_flow = self.model.active_flow + if not active_flow: + self.log_view.write("[bold red]Studio Error:[/bold red] No active flow selected to run.") + return + self._running_thread = self.view.run_worker( + self._execute, + thread=True ) + self.log_view.write(f'[bold green]Run "{active_flow.name}" flow...[/bold green]') + # TODO: maybe more info will be logged at some point (if deemed useful) + plugin = P() diff --git a/src/studio/view.py b/src/studio/view.py index a703733..0c82256 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -479,7 +479,7 @@ def handle_modal_result(result: dict): @on(Select.Changed, '#select-flow') def select_flow(self, event: Select.Changed): - self.action_save_file() + # Do not save the file here because of race condition with overwriting the previous file opened when flow is deleted. m: model.Model = self.app.MODEL if isinstance(event.value, int): m.active_flow = m.flows[event.value] diff --git a/tests/example-studio-project/ca.flow b/tests/example-studio-project/ca.flow index 11174ca..61fed7b 100644 --- a/tests/example-studio-project/ca.flow +++ b/tests/example-studio-project/ca.flow @@ -5,4 +5,4 @@ @decode(wns, AB, 30); // Run n times -@evolve(1); +@evolve(10); diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow index 016ac2e..dcd253b 100644 --- a/tests/example-studio-project/sss.flow +++ b/tests/example-studio-project/sss.flow @@ -6,4 +6,5 @@ ABA -> AAB; A -> ABA; // Evolve n times (use @undo to undo n steps) -@evolve( +@clear(); +@evolve(10); diff --git a/tests/test0.py b/tests/test0.py index 0e3e1ee..e69de29 100644 --- a/tests/test0.py +++ b/tests/test0.py @@ -1,5 +0,0 @@ -funcs = [lambda: i for i in range(3)] - - -print([f() for f in funcs]) - From a182004bae974f8ff1f54902885dc1b0755bb615 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Fri, 3 Apr 2026 21:21:34 -0400 Subject: [PATCH 16/31] Improved the Plugin threading support, improved signals, and started implementing the Output plugin. --- src/core/engine.py | 18 ++--- src/core/signals.py | 14 ++-- src/studio/model.py | 7 +- src/studio/stdplgns/output.py | 105 +++++++++++++------------- src/studio/stdplgns/run.py | 47 ++++++------ tests/example-studio-project/sss.flow | 7 +- 6 files changed, 105 insertions(+), 93 deletions(-) diff --git a/src/core/engine.py b/src/core/engine.py index bbc9006..e68d600 100644 --- a/src/core/engine.py +++ b/src/core/engine.py @@ -395,10 +395,10 @@ class Flow: """The base class for a rule flow, additional behavior should be implemented by subclassing this class.""" # Signals (can be used to live update analysis objects like the causal graph) - on_evolve: Signal[Self] = Signal() - on_evolve_n: Signal[Self] = Signal() # after all evolves - on_undo: Signal[Self] = Signal() - on_undo_n: Signal[Self] = Signal() # after all undo's + on_evolved_step: Signal[Self] = Signal() + on_evolved_n: Signal[Self, int] = Signal() # after all evolves + on_undone_step: Signal[Self] = Signal() + on_undone_n: Signal[Self, int] = Signal() # after all undo's on_clear: Signal[Self] = Signal() def __init__(self): @@ -406,7 +406,7 @@ def __init__(self): self.events: list[Event] = [] # defaults to empty... but nothing will work properly # progress tracking attributes - self.n_step_progress: float = 1 # percentage of steps run by some_method_n(). + self.n_step_progress: float = 0 # percentage of steps run by some_method_n(). def set_ruleset(self, ruleset: RuleSet) -> None: """Used to set the rule set""" @@ -471,7 +471,7 @@ def _evolve(self) -> None: self.current_event.causal_distance_to_creation = min_prev + 1 # emit any signals - self.on_evolve.emit(self) + self.on_evolved_step.emit(self) def evolve(self, n_steps: int, break_when_inert: bool = False) -> None: """Evolve the system n steps.""" @@ -485,7 +485,7 @@ def evolve(self, n_steps: int, break_when_inert: bool = False) -> None: break # emit any signals - self.on_evolve_n.emit(self) + self.on_evolved_n.emit(self, n_steps) def _undo(self) -> None: """undo the last event...""" @@ -499,14 +499,14 @@ def _undo(self) -> None: self.events.pop() # emit any signals - self.on_undo.emit(self) + self.on_undone_step.emit(self) def undo(self, n_steps: int) -> None: for _ in range(n_steps): self.n_step_progress = (_ + 1) / n_steps self._undo() - self.on_undo_n.emit(self) + self.on_undone_n.emit(self, n_steps) def __str__(self) -> str: return '\n'.join(str(e) for e in self.events) diff --git a/src/core/signals.py b/src/core/signals.py index 61251e9..9d1df11 100644 --- a/src/core/signals.py +++ b/src/core/signals.py @@ -1,9 +1,9 @@ -from typing import Callable, Any, TypeVar, Generic +from typing import Callable, Any, TypeVar, Generic, ParamSpec from inspect import signature -SignalT = TypeVar("SignalT") +P = ParamSpec("P") -class Signal(Generic[SignalT]): +class Signal(Generic[P]): """Implements a QT-like signal system for interactive programming. For convenience, the emitter does not force the connected callable to take all the arguments that are emitted. @@ -22,21 +22,21 @@ class Signal(Generic[SignalT]): __slots__ = ('callables',) def __init__(self) -> None: - self.callables: list[tuple[Callable, int]] = [] + self.callables: list[tuple[Callable[P, Any], int]] = [] @property def callables_count(self) -> int: return len(self.callables) - def emit(self, *args: Any, **kwargs: Any) -> None: + def emit(self, *args: P.args, **kwargs: P.kwargs) -> None: for c, arg_len in self.callables: c(*args[:arg_len], **kwargs) - def connect(self, c: Callable) -> None: + def connect(self, c: Callable[P, Any]) -> None: if c not in self.callables: self.callables.append((c, len(signature(c).parameters))) - def disconnect(self, c: Callable) -> None: + def disconnect(self, c: Callable[P, Any]) -> None: matches: list[int] = [i for i, (c_, _) in enumerate(self.callables) if c_ is c] for i in reversed(matches): self.callables.pop(i) diff --git a/src/studio/model.py b/src/studio/model.py index cdcf82b..1a09dae 100644 --- a/src/studio/model.py +++ b/src/studio/model.py @@ -1,5 +1,5 @@ """The model side of the MVC paradigm""" -from typing import Optional, Iterator, TYPE_CHECKING, cast +from typing import Optional, Iterator, TYPE_CHECKING, cast, Callable from lang import FlowLangBase, FlowLang # in the implementation from abc import ABC, abstractmethod from textual.widgets import TabPane @@ -159,6 +159,11 @@ def model(self) -> Model: def view(self) -> EditorScreen: return self._view + @property + def cft(self) -> Callable: + """Used to call a textual method/function from another thread (for thread-safety).""" + return self._view.app.call_from_thread + @abstractmethod def on_initialized(self) -> None: """Called when the plugin is fully loaded by the model.""" diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index caed627..5e38188 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -1,67 +1,63 @@ # Textual Imports -from textual.widgets import Collapsible, TabPane, Input, Checkbox, Label, DataTable +from textual.widgets import Collapsible, TabPane, Input, Checkbox, Label, DataTable, SelectionList, Button from rich.text import Text from textual.widgets.data_table import CellKey from textual.widget import Widget from textual.coordinate import Coordinate -from textual.containers import ScrollableContainer +from textual.widgets.selection_list import Selection # Standard Imports from typing import Iterator -from studio.model import Plugin, FlowLang +from studio.model import Plugin, FlowLang, FlowLangBase class P(Plugin): def on_initialized(self) -> None: self.name = 'output' - # plugin attributes - # # TODO: work on this - # self._color_map: dict[str, Text] = { - # l: Text(l) for l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' - # } - # connect model signals - self.model.active_flow.flow.on_evolve.connect(self.on_evolved) - self.model.active_flow.flow.on_undo.connect(self.on_undo) + self.model.active_flow.flow.on_evolved_n.connect(self.on_evolved) + self.model.active_flow.flow.on_undone_n.connect(self.on_undo) self.model.active_flow.flow.on_clear.connect(self.on_clear) # connect view signals self.view.sig_checkbox_changed.connect(self.handle_checkbox) def controls(self) -> Iterator[Widget]: - self.live_step = Checkbox('Live Step') # update on each step, rather than the end of an evolution. - yield self.live_step + with Collapsible(title='Column Controls', collapsed=False): + self.hidden_space_columns = Input() + self.hidden_space_columns.border_title = 'Hidden spaces' + yield self.hidden_space_columns + self.column_controls = SelectionList( + Selection("Event indices", 0, True), + Selection("Causal distance", 1, True), + Selection("Causally connected", 2, True), + Selection(" ├─ Collapsed", 3, False), + Selection(" ╰─ Counted", 4, False) + ) + yield self.column_controls with Collapsible(title='Pattern Queries', collapsed=False): self.search_pattern = Input() - self.search_pattern.border_title = 'Search Pattern' + self.search_pattern.border_title = 'Search pattern' yield self.search_pattern self.created_at = Input() - self.created_at.border_title = 'Created at Event(s)' + self.created_at.border_title = 'Created at event(s)' yield self.created_at self.destroyed_at = Input() - self.destroyed_at.border_title = 'Destroyed at Event(s)' + self.destroyed_at.border_title = 'Destroyed at event(s)' yield self.destroyed_at - self.highlight_matches = Checkbox('Highlight all matching events') - yield self.highlight_matches + yield Button('Find', id="find-pattern-filters") with Collapsible(title='Selection Info', collapsed=False): - self.selection_info_label = Label('Selection Info:\n- created at: None\n- destroyed at: None') + self.selection_info_label = Label('Event Info:\n- Cells: 0\n- Connected: 0\n') yield self.selection_info_label - self.enable_hover_highlighting = Checkbox('Enable Hover Highlighting') - yield self.enable_hover_highlighting - - with Collapsible(title='Column Controls', collapsed=True): - self.show_event_indices = Checkbox('Show Event Indices') - yield self.show_event_indices - self.show_causally_connected = Checkbox('Show Causally connected') - yield self.show_causally_connected + self.selection_info_label = Label('Cell Info:\n- created at: None\n- destroyed at: None\n') + yield self.selection_info_label + self.hover_explorer = Checkbox('Hover explorer') + yield self.hover_explorer - with Collapsible(title='Color Controls', collapsed=True): - self.color_mapping = Input() - self.color_mapping.border_title = 'Color Mapping' - yield self.color_mapping + yield Label() def handle_checkbox(self, sig: Checkbox.Changed) -> None: pass @@ -70,33 +66,38 @@ def panel(self) -> TabPane | None: self.data_table = DataTable(id='data-table') self.data_table.add_columns('Time') self.data_table.add_columns('Distance') - self.data_table.add_columns('Causally Connected') + self.data_table.add_columns('Connected') self.data_table.add_columns('Evolution') - return TabPane( self.name.title(), self.data_table ) - def on_evolved(self): - # noinspection PyTypeChecker - flow: FlowLang = self.model.active_flow.flow - self.data_table.add_row( - str(flow.current_event.time), - str(flow.current_event.causal_distance_to_creation), - str(tuple(flow.current_event.causally_connected_events)), - str(flow.current_event.spaces.__next__()).replace('A', '[on blue3] A [/on blue3]') - .replace('B', '[on magenta] B [/on magenta]') - .replace('C', '[on yellow] C [/on yellow]') - ) - if self.data_table.is_vertical_scroll_end: - self.data_table.scroll_end(x_axis=False, animate=False) - - def on_undo(self): - cell_key: CellKey = self.data_table.coordinate_to_cell_key(Coordinate(self.data_table.row_count - 1, 0)) - self.data_table.remove_row(cell_key.row_key) - if self.data_table.is_vertical_scroll_end: - self.data_table.scroll_end(x_axis=False, animate=False) + def on_evolved(self, f: FlowLangBase, steps: int) -> None: + cft = self.cft + if self.data_table.row_count == 0: + steps += 1 # to include the first space state + for event in f.events[-steps:]: + cft( # we are potentially calling from thread, thus this. + self.data_table.add_row, + event.time, + event.causal_distance_to_creation, + tuple(event.causally_connected_events), + str(event.spaces.__next__()) + .replace('A', '[on blue3] A [/on blue3]') + .replace('B', '[on magenta] B [/on magenta]') + .replace('C', '[on yellow] C [/on yellow]') + ) + + def on_undo(self, f: FlowLangBase, steps: int) -> None: + cft = self.cft + if (_:=self.data_table.row_count) < steps: + steps = _ + for _ in range(steps): + cell_key: CellKey = self.data_table.coordinate_to_cell_key( + Coordinate(self.data_table.row_count - 1, 0) + ) + cft(self.data_table.remove_row, cell_key.row_key) def on_clear(self): self.data_table.clear() diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index 1a8b666..af62395 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -29,25 +29,28 @@ def on_initialized(self) -> None: ) # Connect flow signals to update progress bar - FlowLangBase.on_evolve.connect(self._handle_progress_updates) - FlowLangBase.on_undo.connect(self._handle_progress_updates) + FlowLangBase.on_evolved_step.connect(self._handle_progress_updates) + FlowLangBase.on_undone_step.connect(self._handle_progress_updates) # Attributes self._process = psutil.Process(os.getpid()) self._prev_flowlang_src: str = '' # for diff checking self._hot_after_n_changes: int = 0 # for fast reference - self._running_thread: Worker | None = None + self._running_thread: Worker | None = None # for checking and managing the current thread def controls(self) -> Iterator[Widget]: # NOTE: there aren't many settings for the run tab due to most controls being available through the DSL. - self.mem_profile = Checkbox('Show Memory Profile') - yield self.mem_profile with Collapsible(title='Hot Reload', collapsed=False): self.hot_mode = Checkbox('Enable hot reload mode', id='hot-reload') yield self.hot_mode self.hot_after_n_changes = Input(type='integer', value='1', id='hot-after-change') self.hot_after_n_changes.border_title = 'After n changes' yield self.hot_after_n_changes + with Collapsible(title='Program Log', collapsed=False): + self.mem_profile = Checkbox('Show memory profile') + yield self.mem_profile + self.show_traceback = Checkbox('Show tracebacks') + yield self.show_traceback self.hot_reload_timer: Timer = self.view.set_interval( 1, self._handle_hot_reload, @@ -57,19 +60,20 @@ def controls(self) -> Iterator[Widget]: def panel(self) -> TabPane | None: # Progress Bar Widget self.progress_bar = ProgressBar(total=100, show_eta=True, id="run-progress-bar") - self.progress_container = Collapsible(self.progress_bar, title="Execution Progress", collapsed=False) + self.progress_container = Collapsible( + self.progress_bar, + title="Execution Progress", + collapsed=False + ) # Standard Output Widget self.log_view = RichLog(id="run-log-view", highlight=True, markup=True, wrap=True) - clear_log = Button('Clear Log', id="clear-log") - self.show_traceback = Checkbox('Show Tracebacks') self.log_container = Collapsible( self.log_view, - Horizontal( - clear_log, - self.show_traceback - ), - title="Program Log", collapsed=False) + Button('Clear Log', id="clear-log"), + Label(), + title="Program Log", collapsed=False + ) return TabPane( self.name.title(), @@ -85,7 +89,7 @@ def handle_btn_press(self, e: Button.Pressed): self.execute_run() elif btn == 'clear-log': self.log_view.clear() - self.log_view.write(f"[bold green]Cleared Log[/bold green]") + self.log_view.write(f"[bold green] --- Log Cleared --- [/bold green]") def handle_checkbox_change(self, e: Checkbox.Changed): btn: str = e.checkbox.id @@ -105,14 +109,12 @@ def _flow_src_diff_check(self) -> int: def _handle_hot_reload(self) -> None: # import time # self.log_view.write(time.time()) # for debugging timer - if self._running_thread and self._running_thread.is_running: # do not hot-reload while thread is active - return if self._flow_src_diff_check() >= self._hot_after_n_changes: # only hot-reload after n changes to src self._prev_flowlang_src = self.view.code_editor_text_area.text self.execute_run() def _handle_progress_updates(self, f: FlowLangBase) -> None: - self.view.app.call_from_thread( # we must call from the main thread to be thread-safe according to docs + self.cft( # we must call from the main thread to be thread-safe according to docs self.progress_bar.update, progress=f.n_step_progress * 100 ) @@ -120,7 +122,7 @@ def _handle_progress_updates(self, f: FlowLangBase) -> None: # time.sleep(0.5) def _execute(self) -> None: - # use self.view.app.call_from_thread to be thread-safe (according to docs on Workers) + # use self.cft to be thread-safe (according to docs on Workers) if self.mem_profile.value: mem_start = self._process.memory_info().rss / 1024 / 1024 start_time = time.perf_counter() @@ -131,12 +133,12 @@ def _execute(self) -> None: except Exception as e: # Handle the exception if self.show_traceback.value: - self.view.app.call_from_thread( + self.cft( self.log_view.write, RichTraceback.from_exception(*sys.exc_info(), word_wrap=True) ) else: - self.view.app.call_from_thread( + self.cft( self.log_view.write, f"[bold red]Execution Error:[/bold red] {str(e)}" ) @@ -148,7 +150,7 @@ def _execute(self) -> None: elapsed_time = time.perf_counter() - start_time # noinspection PyUnboundLocalVariable mem_diff = mem_end - mem_start - self.view.app.call_from_thread( + self.cft( self.log_view.write, f"[bold]Time Spent:[/bold] {elapsed_time:.4f} seconds\n" f"[bold]Memory Change:[/bold] {mem_diff:+.2f} MB\n" @@ -161,6 +163,9 @@ def execute_run(self) -> None: if not active_flow: self.log_view.write("[bold red]Studio Error:[/bold red] No active flow selected to run.") return + if self._running_thread and self._running_thread.is_running: # do not run while thread is active + self.log_view.write("[bold red]Studio Error:[/bold red] A flow thread is currently running.") + return self._running_thread = self.view.run_worker( self._execute, thread=True diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow index dcd253b..5528b96 100644 --- a/tests/example-studio-project/sss.flow +++ b/tests/example-studio-project/sss.flow @@ -5,6 +5,7 @@ ABA -> AAB; A -> ABA; -// Evolve n times (use @undo to undo n steps) -@clear(); -@evolve(10); +// Evolve n times +// @clear(); +// @undo(1); +@evolve(100); From 47afd06e6ce7a2262d4d7d2c3c6541d4958e7331 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Sat, 4 Apr 2026 23:12:24 -0500 Subject: [PATCH 17/31] Improved the Output plugin by refreshing the column width of the data table and adding keys to the rows and columns. Also refined the Input widget signals for the view side of our MVC design. --- src/studio/stdplgns/output.py | 46 ++++++++++++++++++++------- src/studio/view.py | 8 ++--- tests/example-studio-project/sss.flow | 4 +-- 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index 5e38188..eb4c49d 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -8,7 +8,7 @@ # Standard Imports from typing import Iterator -from studio.model import Plugin, FlowLang, FlowLangBase +from studio.model import Plugin, FlowLangBase class P(Plugin): @@ -64,10 +64,15 @@ def handle_checkbox(self, sig: Checkbox.Changed) -> None: def panel(self) -> TabPane | None: self.data_table = DataTable(id='data-table') - self.data_table.add_columns('Time') - self.data_table.add_columns('Distance') - self.data_table.add_columns('Connected') - self.data_table.add_columns('Evolution') + self.data_table.add_column('Time', key='time') + self.data_table.add_column('Distance', key='distance') + self.data_table.add_column('Connected', key='connected') + self.data_table.add_column('Evolution', key='evolution') + + # col = self.data_table.get_column('connected') + # col = 0 + # self.data_table.refresh() + return TabPane( self.name.title(), self.data_table @@ -75,31 +80,48 @@ def panel(self) -> TabPane | None: def on_evolved(self, f: FlowLangBase, steps: int) -> None: cft = self.cft + add_row = self.data_table.add_row if self.data_table.row_count == 0: steps += 1 # to include the first space state for event in f.events[-steps:]: cft( # we are potentially calling from thread, thus this. - self.data_table.add_row, + add_row, event.time, event.causal_distance_to_creation, tuple(event.causally_connected_events), str(event.spaces.__next__()) .replace('A', '[on blue3] A [/on blue3]') .replace('B', '[on magenta] B [/on magenta]') - .replace('C', '[on yellow] C [/on yellow]') + .replace('C', '[on yellow] C [/on yellow]'), + key=str(event.time) ) + # if after clearing, we need to update the column width... not that costly. + self._refresh_column_widths() + + # if evolving 1 step, scroll to end + if steps == 1: + self.data_table.scroll_end(animate=False) + def on_undo(self, f: FlowLangBase, steps: int) -> None: cft = self.cft - if (_:=self.data_table.row_count) < steps: + dt = self.data_table + if (_:=dt.row_count) < steps: steps = _ for _ in range(steps): - cell_key: CellKey = self.data_table.coordinate_to_cell_key( - Coordinate(self.data_table.row_count - 1, 0) - ) - cft(self.data_table.remove_row, cell_key.row_key) + cft(dt.remove_row, str(dt.row_count - 1)) + self._refresh_column_widths() def on_clear(self): self.data_table.clear() + def _refresh_column_widths(self) -> None: + """Update the column widths as Textual does not currently do that for us when removing rows.""" + dt = self.data_table + if 0 <= (rc:=(dt.row_count - 1)): + # noinspection PyProtectedMember + dt._update_column_widths( + {dt.coordinate_to_cell_key(Coordinate(rc, i)) for i in range(len(dt.columns))} + ) + plugin = P() diff --git a/src/studio/view.py b/src/studio/view.py index 0c82256..b47680f 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -298,7 +298,7 @@ def __init__(self, *args, **kwargs): # ==== Signals ==== self.sig_button_pressed: Signal[Button.Pressed] = Signal() self.sig_checkbox_changed: Signal[Checkbox.Changed] = Signal() - self.sig_input_changed: Signal[Input.Changed] = Signal() + self.sig_input_submit: Signal[Input.Changed] = Signal() self.sig_save_config_directive: Signal = Signal() @on(Button.Pressed) @@ -311,10 +311,10 @@ def _emit_checkbox_signals(self, event: Checkbox.Changed) -> None: """Handle emitting the checkbox changed signal""" self.sig_checkbox_changed.emit(event) - @on(Input.Changed) - def _emit_input_signals(self, event: Input.Changed) -> None: + @on(Input.Submitted) + def _emit_input_submit_signals(self, event: Input.Changed) -> None: """Handle emitting the input changed signal""" - self.sig_input_changed.emit(event) + self.sig_input_submit.emit(event) def on_mount(self) -> None: self.__refresh_flow_selector__() diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow index 5528b96..7f2cc96 100644 --- a/tests/example-studio-project/sss.flow +++ b/tests/example-studio-project/sss.flow @@ -7,5 +7,5 @@ A -> ABA; // Evolve n times // @clear(); -// @undo(1); -@evolve(100); +// @undo(10); +@evolve(10); From 11ca873aff3e60cf4f2698fa27a45cfcfe4a11ea Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Wed, 8 Apr 2026 02:45:38 -0400 Subject: [PATCH 18/31] Added rendering range support to the std Output plugin. This also involved improvements to other areas in the codebase. --- .../numerical_helpers.py => core/numlib.py} | 4 + src/icat/core.py | 9 +++ src/lang/implementation.py | 2 +- src/lang/parser.py | 2 +- src/studio/stdplgns/output.py | 81 ++++++++++++++----- tests/example-studio-project/sss.flow | 2 +- 6 files changed, 75 insertions(+), 25 deletions(-) rename src/{lang/numerical_helpers.py => core/numlib.py} (98%) create mode 100644 src/icat/core.py diff --git a/src/lang/numerical_helpers.py b/src/core/numlib.py similarity index 98% rename from src/lang/numerical_helpers.py rename to src/core/numlib.py index 133179a..05e8d0f 100644 --- a/src/lang/numerical_helpers.py +++ b/src/core/numlib.py @@ -182,6 +182,10 @@ def str_to_num(num: str) -> int | float: return float(num) +def is_infinity(num: int | Inf) -> bool: + return isinstance(num, Inf) + + if __name__ == '__main__': a = [1, 2, 3] print(a[-INF:1]) diff --git a/src/icat/core.py b/src/icat/core.py new file mode 100644 index 0000000..a972688 --- /dev/null +++ b/src/icat/core.py @@ -0,0 +1,9 @@ +class icat: + def __init__(self): + pass + + def __add__(self, other): + pass + + def __sub__(self, other): + pass diff --git a/src/lang/implementation.py b/src/lang/implementation.py index d2526dc..41b06b2 100644 --- a/src/lang/implementation.py +++ b/src/lang/implementation.py @@ -11,7 +11,7 @@ """ from typing import Sequence, NamedTuple, Literal, cast, Iterator from copy import deepcopy, copy -from lang.numerical_helpers import INF +from core.numlib import INF from core.signals import Signal from core.engine import ( SpaceState1D as SpaceState, diff --git a/src/lang/parser.py b/src/lang/parser.py index 74ac6e9..e0dffcc 100644 --- a/src/lang/parser.py +++ b/src/lang/parser.py @@ -1,6 +1,6 @@ from core import enumerator from lark import Lark, Transformer -from lang.numerical_helpers import str_to_num, INF +from core.numlib import str_to_num, INF from typing import Any, cast diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index eb4c49d..5c0061a 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -8,6 +8,8 @@ # Standard Imports from typing import Iterator +from core.numlib import INF, str_to_num, is_infinity +from core.engine import Event as FlowEvent from studio.model import Plugin, FlowLangBase @@ -15,6 +17,9 @@ class P(Plugin): def on_initialized(self) -> None: self.name = 'output' + # attributes + self._render_range: tuple[int, int] = (-100, INF) + # connect model signals self.model.active_flow.flow.on_evolved_n.connect(self.on_evolved) self.model.active_flow.flow.on_undone_n.connect(self.on_undo) @@ -22,8 +27,13 @@ def on_initialized(self) -> None: # connect view signals self.view.sig_checkbox_changed.connect(self.handle_checkbox) + self.view.sig_input_submit.connect(self.handle_input_submit) def controls(self) -> Iterator[Widget]: + self.render_range = Input(value='-100:', id='render-limit') + self.render_range.border_title = 'Render Range' + yield self.render_range + with Collapsible(title='Column Controls', collapsed=False): self.hidden_space_columns = Input() self.hidden_space_columns.border_title = 'Hidden spaces' @@ -59,9 +69,23 @@ def controls(self) -> Iterator[Widget]: yield Label() - def handle_checkbox(self, sig: Checkbox.Changed) -> None: + def handle_checkbox(self, e: Checkbox.Changed) -> None: pass + def handle_input_submit(self, e: Input.Submitted): + _id: str = e.input.id + if _id == 'render-limit': + try: + rs: list[str] = e.value.strip().split(':') + self._render_range = ( + str_to_num(rs[0]) if rs[0] else 0, + str_to_num(rs[1]) if rs[1] else INF + ) + self._update_rows(self.model.active_flow.flow) + except: + self.view.notify('Invalid render range', severity='error') + e.input.value = '{0}:{1}'.format(*self._render_range) + def panel(self) -> TabPane | None: self.data_table = DataTable(id='data-table') self.data_table.add_column('Time', key='time') @@ -78,30 +102,43 @@ def panel(self) -> TabPane | None: self.data_table ) - def on_evolved(self, f: FlowLangBase, steps: int) -> None: - cft = self.cft - add_row = self.data_table.add_row - if self.data_table.row_count == 0: - steps += 1 # to include the first space state - for event in f.events[-steps:]: - cft( # we are potentially calling from thread, thus this. - add_row, - event.time, - event.causal_distance_to_creation, - tuple(event.causally_connected_events), - str(event.spaces.__next__()) - .replace('A', '[on blue3] A [/on blue3]') - .replace('B', '[on magenta] B [/on magenta]') - .replace('C', '[on yellow] C [/on yellow]'), - key=str(event.time) - ) + def _add_row(self, event: FlowEvent): + self.data_table.add_row( # maybe make a separate add_row() + event.time, + event.causal_distance_to_creation, + tuple(event.causally_connected_events), + str(event.spaces.__next__()) + .replace('A', '[on blue3] A [/on blue3]') + .replace('B', '[on magenta] B [/on magenta]') + .replace('C', '[on yellow] C [/on yellow]'), + key=str(event.time) + ) - # if after clearing, we need to update the column width... not that costly. + def _update_rows(self, f: FlowLangBase) -> None: + a, b = self._render_range + self.on_clear() + for event in f.events[a:b + (1 if b > 0 else 0)]: + self._add_row(event) self._refresh_column_widths() - # if evolving 1 step, scroll to end - if steps == 1: - self.data_table.scroll_end(animate=False) + def on_evolved(self, f: FlowLangBase, steps: int) -> None: + dt = self.data_table + if not dt.row_count: + steps += 1 # to include the first space state + flush_mode: bool = self._render_range[0] < 0 and is_infinity(self._render_range[1]) + render_limit: int = abs(self._render_range[0]) # only used when flush mode is true + if flush_mode and steps >= render_limit: + self._update_rows(f) + else: + short_circuit: bool = False # just a little optimization for large loops + for event in f.events[-steps:]: + if short_circuit or flush_mode and dt.row_count >= render_limit: + short_circuit = True + self.cft(lambda: dt.remove_row(dt.coordinate_to_cell_key(Coordinate(0, 0)).row_key)) + self.cft(self._add_row, event) + self._refresh_column_widths() + if not flush_mode: + dt.scroll_end(animate=False) def on_undo(self, f: FlowLangBase, steps: int) -> None: cft = self.cft diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow index 7f2cc96..88bfc0e 100644 --- a/tests/example-studio-project/sss.flow +++ b/tests/example-studio-project/sss.flow @@ -8,4 +8,4 @@ A -> ABA; // Evolve n times // @clear(); // @undo(10); -@evolve(10); +@evolve(1) From 53619b86e0d3f5e8a5b8d10c590ede091f492f1d Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Thu, 9 Apr 2026 01:21:49 -0400 Subject: [PATCH 19/31] Added the column tool support to Output plugin. --- src/__init__.py | 2 + .../README.md | 0 .../_llm_module.py | 0 src/studio-plugins/gephi-live.py | 0 src/studio-plugins/icat.py | 0 src/studio-plugins/tunes.py | 4 + src/studio/model.py | 3 + src/studio/stdplgns/output.py | 198 +++++++++++++----- src/studio/view.py | 14 +- tests/example-studio-project/sss.flow | 4 +- 10 files changed, 163 insertions(+), 62 deletions(-) rename src/{studio_plugins => studio-plugins}/README.md (100%) rename src/{studio_plugins => studio-plugins}/_llm_module.py (100%) create mode 100644 src/studio-plugins/gephi-live.py create mode 100644 src/studio-plugins/icat.py create mode 100644 src/studio-plugins/tunes.py diff --git a/src/__init__.py b/src/__init__.py index e69de29..88f2549 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1,2 @@ +a = 'h' +print(a.split(',')) \ No newline at end of file diff --git a/src/studio_plugins/README.md b/src/studio-plugins/README.md similarity index 100% rename from src/studio_plugins/README.md rename to src/studio-plugins/README.md diff --git a/src/studio_plugins/_llm_module.py b/src/studio-plugins/_llm_module.py similarity index 100% rename from src/studio_plugins/_llm_module.py rename to src/studio-plugins/_llm_module.py diff --git a/src/studio-plugins/gephi-live.py b/src/studio-plugins/gephi-live.py new file mode 100644 index 0000000..e69de29 diff --git a/src/studio-plugins/icat.py b/src/studio-plugins/icat.py new file mode 100644 index 0000000..e69de29 diff --git a/src/studio-plugins/tunes.py b/src/studio-plugins/tunes.py new file mode 100644 index 0000000..e9fc2c8 --- /dev/null +++ b/src/studio-plugins/tunes.py @@ -0,0 +1,4 @@ +def test(*args): + print(args) + +test(*(i for i in range(10))) diff --git a/src/studio/model.py b/src/studio/model.py index 1a09dae..6ed9bab 100644 --- a/src/studio/model.py +++ b/src/studio/model.py @@ -139,6 +139,9 @@ class Plugin(ABC): Only one instance of this class is expected for each plugin PER APP. If session/flow-instance-specific behavior is desired, the session change signal must be watched and handled. + IMPORTANT NOTE: The view call self.panel() and then self.control() in that order. Thus, calls may need to be placed + strategically if self.panel() references something in self.controls(). + Required attributes: - name: str # the name of the plugin - model: Model # gives the plugin access to the model diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/output.py index 5c0061a..5b63d47 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/output.py @@ -9,7 +9,7 @@ # Standard Imports from typing import Iterator from core.numlib import INF, str_to_num, is_infinity -from core.engine import Event as FlowEvent +from core.engine import Event as FlowEvent, SpaceState from studio.model import Plugin, FlowLangBase @@ -19,6 +19,9 @@ def on_initialized(self) -> None: # attributes self._render_range: tuple[int, int] = (-100, INF) + self._space_columns_limit: int = 1 + self._hidden_space_columns: set[int] = set() + self._columns_control_bitmap: list[bool] = [True, False, False, False, False] # connect model signals self.model.active_flow.flow.on_evolved_n.connect(self.on_evolved) @@ -26,8 +29,12 @@ def on_initialized(self) -> None: self.model.active_flow.flow.on_clear.connect(self.on_clear) # connect view signals - self.view.sig_checkbox_changed.connect(self.handle_checkbox) self.view.sig_input_submit.connect(self.handle_input_submit) + self.view.sig_selection_list_toggled.connect(self.handle_selection_toggle) + + def _column_control_bitmap_zero_out(self): + """Zeros out the column bitmap""" + self._columns_control_bitmap = [False for _ in range(len(self._columns_control_bitmap))] def controls(self) -> Iterator[Widget]: self.render_range = Input(value='-100:', id='render-limit') @@ -35,27 +42,33 @@ def controls(self) -> Iterator[Widget]: yield self.render_range with Collapsible(title='Column Controls', collapsed=False): - self.hidden_space_columns = Input() - self.hidden_space_columns.border_title = 'Hidden spaces' - yield self.hidden_space_columns + control_bits = self._columns_control_bitmap self.column_controls = SelectionList( - Selection("Event indices", 0, True), - Selection("Causal distance", 1, True), - Selection("Causally connected", 2, True), - Selection(" ├─ Collapsed", 3, False), - Selection(" ╰─ Counted", 4, False) + Selection("Event Indices", 0, control_bits[0]), + Selection("Causal Distance", 1, control_bits[1]), + Selection("Causally Connected", 2, control_bits[2]), + Selection(" ├─ Collapsed", 3, control_bits[3]), + Selection(" ╰─ Counted", 4, control_bits[4]), + id='column-controls' ) + self._rebuild_columns(rebuild_rows=False) # must be called here or sometime after to initiated columns. yield self.column_controls + self.space_columns_limit = Input(str(self._space_columns_limit), type='integer', id='space-columns-limit') + self.space_columns_limit.border_title = 'Space Columns Limit' + yield self.space_columns_limit + self.hidden_space_columns = Input(id='hidden-space-columns') + self.hidden_space_columns.border_title = 'Hidden Space Columns' + yield self.hidden_space_columns with Collapsible(title='Pattern Queries', collapsed=False): self.search_pattern = Input() - self.search_pattern.border_title = 'Search pattern' + self.search_pattern.border_title = 'Search Pattern' yield self.search_pattern self.created_at = Input() - self.created_at.border_title = 'Created at event(s)' + self.created_at.border_title = 'Created at Event(s)' yield self.created_at self.destroyed_at = Input() - self.destroyed_at.border_title = 'Destroyed at event(s)' + self.destroyed_at.border_title = 'Destroyed at Event(s)' yield self.destroyed_at yield Button('Find', id="find-pattern-filters") @@ -64,14 +77,11 @@ def controls(self) -> Iterator[Widget]: yield self.selection_info_label self.selection_info_label = Label('Cell Info:\n- created at: None\n- destroyed at: None\n') yield self.selection_info_label - self.hover_explorer = Checkbox('Hover explorer') + self.hover_explorer = Checkbox('Hover Explorer') yield self.hover_explorer yield Label() - def handle_checkbox(self, e: Checkbox.Changed) -> None: - pass - def handle_input_submit(self, e: Input.Submitted): _id: str = e.input.id if _id == 'render-limit': @@ -81,76 +91,152 @@ def handle_input_submit(self, e: Input.Submitted): str_to_num(rs[0]) if rs[0] else 0, str_to_num(rs[1]) if rs[1] else INF ) - self._update_rows(self.model.active_flow.flow) + self._rebuild_rows(self.model.active_flow.flow) except: - self.view.notify('Invalid render range', severity='error') + self.view.notify('Invalid render range.', severity='warning') e.input.value = '{0}:{1}'.format(*self._render_range) - def panel(self) -> TabPane | None: - self.data_table = DataTable(id='data-table') - self.data_table.add_column('Time', key='time') - self.data_table.add_column('Distance', key='distance') - self.data_table.add_column('Connected', key='connected') - self.data_table.add_column('Evolution', key='evolution') + elif _id == 'space-columns-limit': + try: + v: int = int(e.value.strip()) + if not 0 <= v <= 1000: + raise ValueError + self._space_columns_limit = int(e.value) + self._rebuild_columns() + except: + self.view.notify('Invalid column space limit. Value must be between 0 and 1000.', severity='warning') + e.input.value = str(self._space_columns_limit) - # col = self.data_table.get_column('connected') - # col = 0 - # self.data_table.refresh() + elif _id == 'hidden-space-columns': + try: + self._hidden_space_columns.clear() + values = e.value.replace(' ', '').split(',') + if values[0] != '': + for r in values: + if ':' in r: + a, b = r.split(':'); a, b = abs(int(a)), abs(int(b)) + if a > 1000 or b > 1000: raise ValueError + for i in range(a, b + 1): + self._hidden_space_columns.add(i) + else: + a = abs(int(r)) + if a > 1000: raise ValueError + self._hidden_space_columns.add(a) + self._rebuild_columns() + except: + self.view.notify('Invalid hidden ranges. Values/Ranges must be between 0 and 1000.', severity='warning') + def handle_selection_toggle(self, e: SelectionList.SelectionToggled): + _id: str = e.selection_list.id + if _id == 'column-controls': + self._column_control_bitmap_zero_out() + for i in e.selection_list.selected: + self._columns_control_bitmap[i] = True + self._rebuild_columns() + + def panel(self) -> TabPane | None: + self.data_table = DataTable(id='data-table') return TabPane( self.name.title(), self.data_table ) - def _add_row(self, event: FlowEvent): - self.data_table.add_row( # maybe make a separate add_row() - event.time, - event.causal_distance_to_creation, - tuple(event.causally_connected_events), - str(event.spaces.__next__()) - .replace('A', '[on blue3] A [/on blue3]') - .replace('B', '[on magenta] B [/on magenta]') - .replace('C', '[on yellow] C [/on yellow]'), - key=str(event.time) - ) - - def _update_rows(self, f: FlowLangBase) -> None: - a, b = self._render_range - self.on_clear() - for event in f.events[a:b + (1 if b > 0 else 0)]: - self._add_row(event) - self._refresh_column_widths() - def on_evolved(self, f: FlowLangBase, steps: int) -> None: + cft = self.cft dt = self.data_table if not dt.row_count: steps += 1 # to include the first space state flush_mode: bool = self._render_range[0] < 0 and is_infinity(self._render_range[1]) render_limit: int = abs(self._render_range[0]) # only used when flush mode is true if flush_mode and steps >= render_limit: - self._update_rows(f) + cft(self._rebuild_rows, f) else: short_circuit: bool = False # just a little optimization for large loops for event in f.events[-steps:]: if short_circuit or flush_mode and dt.row_count >= render_limit: short_circuit = True - self.cft(lambda: dt.remove_row(dt.coordinate_to_cell_key(Coordinate(0, 0)).row_key)) - self.cft(self._add_row, event) - self._refresh_column_widths() + cft(lambda: dt.remove_row(dt.coordinate_to_cell_key(Coordinate(0, 0)).row_key)) + cft(self._add_row, event) + cft(self._refresh_column_widths) if not flush_mode: dt.scroll_end(animate=False) def on_undo(self, f: FlowLangBase, steps: int) -> None: + # NOTE: this function is not very optimized for updates, but premature optimization is the root of all evil. cft = self.cft dt = self.data_table - if (_:=dt.row_count) < steps: - steps = _ - for _ in range(steps): - cft(dt.remove_row, str(dt.row_count - 1)) - self._refresh_column_widths() + old_rows_count = len(f.events) + steps + for i in range(steps): + try: cft(dt.remove_row, str(old_rows_count - i - 1)) + except: pass + if self._render_range[0] < 0 and is_infinity(self._render_range[1]): # if flushing + cft(self._rebuild_rows, f) + cft(self._refresh_column_widths) def on_clear(self): + self.cft(self.data_table.clear) + + def _add_row(self, event: FlowEvent): + columns = [] + + # Process the info columns + control_bitmap = self._columns_control_bitmap + if control_bitmap[2]: + if control_bitmap[3]: # if we are collapsing it + connected = set(event.causally_connected_events) + else: + connected = tuple(event.causally_connected_events) + if control_bitmap[4]: # if we are counting the causally connect (to display that metric instead) + connected = len(connected) + else: + connected = None + for data, show in zip((event.time, + event.causal_distance_to_creation, + connected), + control_bitmap): + if show: columns.append(data) + + # Process the space columns + spaces: Iterator[SpaceState] = event.spaces + hidden: set[int] = self._hidden_space_columns + for i in range(self._space_columns_limit): + try: + space = spaces.__next__() # we must always increment next (even though it may be hidden, that is what makes the check work) + if i in hidden: + continue + columns.append(space) + except StopIteration: + break + + # Add everything as a row + self.data_table.add_row( + *columns, + key=str(event.time) + ) + + def _rebuild_rows(self, f: FlowLangBase) -> None: + a, b = self._render_range self.data_table.clear() + for event in f.events[a:b + (1 if b > 0 else 0)]: + self._add_row(event) + self._refresh_column_widths() + + def _rebuild_columns(self, rebuild_rows: bool = True) -> None: + dt = self.data_table + dt.clear(columns=True) + if self._columns_control_bitmap[0]: + dt.add_column('Event', key='event') + if self._columns_control_bitmap[1]: + dt.add_column('Distance', key='distance') + if self._columns_control_bitmap[2]: + dt.add_column('Connected', key='connected') + hidden: set[int] = self._hidden_space_columns + for i in range(self._space_columns_limit): + if i in hidden: + continue + dt.add_column(_:=str(i), key=_) + if rebuild_rows: + self._rebuild_rows(self.model.active_flow.flow) def _refresh_column_widths(self) -> None: """Update the column widths as Textual does not currently do that for us when removing rows.""" diff --git a/src/studio/view.py b/src/studio/view.py index b47680f..ca0f708 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -16,8 +16,8 @@ from textual.containers import Container, Center, Horizontal, Vertical, ScrollableContainer from textual.screen import Screen, ModalScreen from textual.widgets import ( - DirectoryTree as _DT, TextArea as _TA, Button, Label, - Select, TabbedContent, OptionList, Input, + DirectoryTree as _DirectoryTree, TextArea as _TextArea, Button, Label, + Select, TabbedContent, OptionList, Input, SelectionList, Footer, ContentSwitcher, Static, Checkbox ) from textual.widgets.option_list import Option, DuplicateID as DuplicateIDError @@ -42,7 +42,7 @@ def __init__(self): self.styles.width = '1fr' # make it take up as much space as possible -class DirectoryTree(_DT): +class DirectoryTree(_DirectoryTree): def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: for path in paths: if str(path.stem) == 'plugins' or str(path).endswith("__") or str(path).startswith("__"): @@ -51,7 +51,7 @@ def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: yield path -class TextArea(_TA): +class TextArea(_TextArea): def load_text(self, text: str | None) -> None: """Overrides the load_text method so that placeholders work properly...""" # NOTE: edit history is cleared @@ -299,6 +299,7 @@ def __init__(self, *args, **kwargs): self.sig_button_pressed: Signal[Button.Pressed] = Signal() self.sig_checkbox_changed: Signal[Checkbox.Changed] = Signal() self.sig_input_submit: Signal[Input.Changed] = Signal() + self.sig_selection_list_toggled: Signal[SelectionListToggled] = Signal() self.sig_save_config_directive: Signal = Signal() @on(Button.Pressed) @@ -316,6 +317,11 @@ def _emit_input_submit_signals(self, event: Input.Changed) -> None: """Handle emitting the input changed signal""" self.sig_input_submit.emit(event) + @on(SelectionList.SelectionToggled) + def _emit_selection_list_toggled(self, event: SelectionList.SelectionToggled) -> None: + """Handle emitting the selection list toggled signal""" + self.sig_selection_list_toggled.emit(event) + def on_mount(self) -> None: self.__refresh_flow_selector__() diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow index 88bfc0e..d6bab0b 100644 --- a/tests/example-studio-project/sss.flow +++ b/tests/example-studio-project/sss.flow @@ -7,5 +7,5 @@ A -> ABA; // Evolve n times // @clear(); -// @undo(10); -@evolve(1) +//@undo(5); +@evolve(10); From 4d6dea71b1630cd30fb85ccfafa876ebf64e7fa4 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Thu, 9 Apr 2026 01:48:53 -0400 Subject: [PATCH 20/31] Renamed the Output plugin to "Explore". This better reflects its functionality. --- src/lang/implementation.py | 2 +- src/studio/model.py | 4 ++-- src/studio/stdplgns/{output.py => explore.py} | 6 +++--- tests/example-studio-project/sss.flow | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/studio/stdplgns/{output.py => explore.py} (97%) diff --git a/src/lang/implementation.py b/src/lang/implementation.py index 41b06b2..402a3aa 100644 --- a/src/lang/implementation.py +++ b/src/lang/implementation.py @@ -1,7 +1,7 @@ """The implementation for 1D space that supports the language features. Policy: -- Any multiways should have the search_buffer optimization disabled (Vec.enable_search_buffer(False)) so that it doesn't +- Any multi-ways should have the search_buffer optimization disabled (Vec.enable_search_buffer(False)) so that it doesn't become corrupt when branching (one state spawning two states). We have considered coding buffer branching logic... however, that does not cover everything as Engine.RuleSet.apply() with group_break=False will not branch the buffer (forgot why). In the future, we may consider making the search_buffer branch-able (really, it is already possible by manually using Vec.search_buffer.copy()). diff --git a/src/studio/model.py b/src/studio/model.py index 6ed9bab..2ffbeac 100644 --- a/src/studio/model.py +++ b/src/studio/model.py @@ -71,8 +71,8 @@ def __init__(self, name: str, project_path: Path, view: EditorScreen) -> None: self.plugins: list[Plugin] = [] # add builtin plugins - from studio.stdplgns import run, output, analysis - for module in (run, output, analysis): + from studio.stdplgns import run, explore, analysis + for module in (run, explore, analysis): for _, obj in inspect.getmembers(module): if isinstance(obj, Plugin): obj._model = self diff --git a/src/studio/stdplgns/output.py b/src/studio/stdplgns/explore.py similarity index 97% rename from src/studio/stdplgns/output.py rename to src/studio/stdplgns/explore.py index 5b63d47..ba4d7f1 100644 --- a/src/studio/stdplgns/output.py +++ b/src/studio/stdplgns/explore.py @@ -15,7 +15,7 @@ class P(Plugin): def on_initialized(self) -> None: - self.name = 'output' + self.name = 'explore' # attributes self._render_range: tuple[int, int] = (-100, INF) @@ -37,7 +37,7 @@ def _column_control_bitmap_zero_out(self): self._columns_control_bitmap = [False for _ in range(len(self._columns_control_bitmap))] def controls(self) -> Iterator[Widget]: - self.render_range = Input(value='-100:', id='render-limit') + self.render_range = Input(value='-100:', placeholder='e.g. -10: or 3:10', id='render-limit') self.render_range.border_title = 'Render Range' yield self.render_range @@ -56,7 +56,7 @@ def controls(self) -> Iterator[Widget]: self.space_columns_limit = Input(str(self._space_columns_limit), type='integer', id='space-columns-limit') self.space_columns_limit.border_title = 'Space Columns Limit' yield self.space_columns_limit - self.hidden_space_columns = Input(id='hidden-space-columns') + self.hidden_space_columns = Input(placeholder='e.g. 5, 10:15, 20', id='hidden-space-columns') self.hidden_space_columns.border_title = 'Hidden Space Columns' yield self.hidden_space_columns diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow index d6bab0b..df1ab51 100644 --- a/tests/example-studio-project/sss.flow +++ b/tests/example-studio-project/sss.flow @@ -6,6 +6,6 @@ ABA -> AAB; A -> ABA; // Evolve n times -// @clear(); +//@clear(); //@undo(5); -@evolve(10); +@evolve(1); From 4160a2b8776c8f72b7e4f02a909969576ba66c11 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Fri, 10 Apr 2026 02:25:30 -0400 Subject: [PATCH 21/31] Finished the major Explore plugin functionality. --- src/__init__.py | 2 - src/core/explorer.py | 253 ------------------------------ src/core/prettier.py | 93 +++++++++++ src/core/signals.py | 23 ++- src/studio-plugins/sessie-enum.py | 0 src/studio-plugins/tunes.py | 4 - src/studio/stdplgns/explore.py | 241 ++++++++++++++++++++++++---- src/studio/stdplgns/run.py | 2 + src/studio/view.py | 4 +- 9 files changed, 319 insertions(+), 303 deletions(-) delete mode 100644 src/core/explorer.py create mode 100644 src/core/prettier.py create mode 100644 src/studio-plugins/sessie-enum.py diff --git a/src/__init__.py b/src/__init__.py index 88f2549..e69de29 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,2 +0,0 @@ -a = 'h' -print(a.split(',')) \ No newline at end of file diff --git a/src/core/explorer.py b/src/core/explorer.py deleted file mode 100644 index edc5aac..0000000 --- a/src/core/explorer.py +++ /dev/null @@ -1,253 +0,0 @@ -""" -explorer.py - -Rich-based visualization wrapper for the Flow engine. -Designed to be agnostic between CLI (Rich Console) and TUI (Textual) environments. -""" - -from typing import Any, Optional, Dict, Iterator, List -from rich.console import Console, Style -from rich.table import Table -from rich.text import Text -from rich.live import Live -from rich import box - -# Handle import based on your structure -from src.core.engine import Flow, Event, Cell - - - -class StyleManager: - """ - Manages the mapping between Cell quanta and Rich styles. - """ - - def __init__(self, custom_map: Optional[Dict[Any, str]] = None): - # Default colors (backgrounds used to support both text and block modes) - Style() - self._color_map: Dict[Any, str] = custom_map or { - 'A': "bold white on red", - 'B': "bold white on green", - 'C': "bold white on blue", - } - - # A palette of background styles for auto-assignment - self._palette = [ - "on cyan", "on magenta", "on yellow", "on bright_black", - "on bright_red", "on bright_green", "on bright_blue" - ] - self._ptr = 0 - - def register(self, quanta: Any, style: str) -> None: - """Manually register a style.""" - self._color_map[quanta] = style - - def get_style(self, quanta: Any) -> str: - """Get style for quanta, assigning a new one from palette if unseen.""" - key = str(quanta) - if key not in self._color_map: - # Assign next color in palette - style = self._palette[self._ptr % len(self._palette)] - self._color_map[key] = style - self._ptr += 1 - return self._color_map[key] - - -class FlowExplorerRich: - """ - Visualization engine for Flow. - """ - def __init__(self, - flow: Flow, - console: Optional[Console] = None, - block_mode: bool = False): - """ - Args: - flow: The simulation engine instance. - console: Optional Rich console (created automatically if None). - block_mode: If True, renders cells as empty colored squares (2 spaces). - """ - self.flow = flow - self.console = console or Console(force_terminal=True) - self.styler = StyleManager() - self.block_mode = block_mode - self._table: Optional[Table] = None - - def _render_cell(self, cell: Cell) -> Text: - """ - Renders a single cell. - """ - style = self.styler.get_style(cell.quanta) - - if self.block_mode: - return Text(" ", style=style) - else: - return Text(f" {cell.quanta} ", style=style) - - def _render_space(self, space_state) -> Text: - """Renders a full space (line of cells).""" - text_builder = Text() - cells = getattr(space_state, 'cells', space_state) - for cell in cells: - text_builder.append(self._render_cell(cell)) - return text_builder - - def _setup_table_structure(self, - title: Optional[str] = None, - box_style: box.Box = box.ROUNDED, - show_header: bool = True, - padding: tuple[int, int] = (0, 1), - show_idx: bool = True, - show_causal_dist: bool = False, - show_connected: bool = False) -> Table: - """ - Creates the empty table definition with customizable visuals. - """ - table = Table( - title=title, - box=box_style, - show_header=show_header, - show_edge=True, - padding=padding, - collapse_padding=True, - pad_edge=False - ) - - if show_idx: - table.add_column("Idx", justify="right", style="dim", no_wrap=True) - - table.add_column("State", ratio=1) - - if show_causal_dist: - table.add_column("Dist", justify="center", style="italic") - - if show_connected: - table.add_column("Causes", style="dim", overflow="fold") - - return table - - def render_event_row(self, - idx: int, - event: Event, - show_idx: bool = True, - show_causal_dist: bool = False, - show_connected: bool = False) -> List[Any]: - """ - Generates the raw data for a single row. - """ - row_data = [] - if show_idx: - row_data.append(str(idx)) - - try: - spaces = list(event.spaces) if isinstance(event.spaces, Iterator) else event.spaces - except TypeError: - spaces = [event.spaces] - - rendered_spaces = [self._render_space(s) for s in spaces] - - if len(rendered_spaces) == 1: - row_data.append(rendered_spaces[0]) - else: - row_data.append(Text("\n").join(rendered_spaces)) - - if show_causal_dist: - dist = getattr(event, 'causal_distance_to_creation', '-') - row_data.append(str(dist)) - - if show_connected: - conn = getattr(event, 'causally_connected_events', []) - row_data.append(str(set(conn)) if conn else "-") - - return row_data - - def get_table(self, - title: Optional[str] = None, - box_style: box.Box = box.ROUNDED, - show_header: bool = True, - show_idx: bool = True, - show_causal_dist: bool = False, - show_connected: bool = False) -> Table: - """ - Returns a fully populated Rich Table. - """ - table = self._setup_table_structure( - title=title, - box_style=box_style, - show_header=show_header, - show_idx=show_idx, - show_causal_dist=show_causal_dist, - show_connected=show_connected - ) - - for i, event in enumerate(self.flow.events): - row_data = self.render_event_row(i, event, show_idx, show_causal_dist, show_connected) - table.add_row(*row_data) - - return table - - def explore(self, - show_idx: bool = True, - show_causal_dist: bool = False, - show_connected: bool = False, - title: Optional[str] = None, - box_style: box.Box = box.ROUNDED, - live: bool = False, - refresh_rate: float = 4.0): - """ - CLI Entry point. - """ - self._table = self._setup_table_structure( - title=title, - box_style=box_style, - show_idx=show_idx, - show_causal_dist=show_causal_dist, - show_connected=show_connected - ) - - def add_rows_to_table(): - current_row_count = self._table.row_count - total_events = len(self.flow.events) - - for i in range(current_row_count, total_events): - event = self.flow.events[i] - row = self.render_event_row(i, event, show_idx, show_causal_dist, show_connected) - self._table.add_row(*row) - - if not live: - add_rows_to_table() - self.console.print(self._table) - else: - with Live(self._table, console=self.console, refresh_per_second=refresh_rate) as live_view: - self._table = self._setup_table_structure(title, box_style, True, (0,1), show_idx, show_causal_dist, show_connected) - live_view.update(self._table) - - for i, event in enumerate(self.flow.events): - row = self.render_event_row(i, event, show_idx, show_causal_dist, show_connected) - self._table.add_row(*row) - - -class FlowExplorerTextual: - pass - - -if __name__ == "__main__": - from src.implementations.sss import SSS - - # 1. Run your simulation - system = SSS(rule_set=["ABA -> AAB", "A -> ABA"], initial_space='A') - system.evolve(10) - - # 2. Visualize - viz = FlowExplorerRich(system, block_mode=False) - - # Optional: Custom colors (defaults are Red for A, Green for B) - # viz.set_color('A', 'bold red on red') - # viz.set_color('B', 'bold green on green') - - # 3. Render - viz.explore( - show_idx=True, - show_causal_dist=True, - show_connected=True - ) diff --git a/src/core/prettier.py b/src/core/prettier.py new file mode 100644 index 0000000..6ce252b --- /dev/null +++ b/src/core/prettier.py @@ -0,0 +1,93 @@ +""" +prettier.py +Rich-based visualization wrapper for the Flow engine's SpaceState. + +TODO: +- Add options for Nd rendering. +""" +from string import ascii_uppercase, ascii_lowercase, digits +from typing import Iterator +from rich.text import Text +from core.engine import SpaceState + + +class SpaceStateStringFormatter: + def __init__(self) -> None: + # We leave these here for maximal configuration. + self.default_colors = [ + '#1a4e8b', '#8b0000', '#2d8b2d', '#8b1a72', '#00728b', '#8b5e1a', '#4e1a8b', '#6b8b1a', '#8b1a43', '#1a7e8b', + '#7b8b1a', '#5c1a8b', '#1a8b43', '#8b431a', '#1a2d8b', '#8b7b1a', '#8b1a62', '#1a8b7e', '#3c1a8b', '#3d8b1a', + '#8b221a', '#1a5c8b', '#1a8b22', '#6e1a8b', '#5a8b1a', '#8b1a82', '#1a8b51', '#8b691a', '#2d1a8b', '#4e8b1a', + '#8b1a33', '#1a788b', '#7e8b1a', '#471a8b', '#1a8b34', '#8b3d1a', '#1a3d8b', '#628b1a', '#8b1a6e', '#1a648b', + '#758b1a', '#221a8b', '#1a8b4e', '#8b511a', '#348b1a', '#511a8b', '#1a8b6e', '#8b1a22', '#1a4e8b', '#5a8b1a', + '#6e1a8b', '#7b8b1a', '#1a8b3d', '#8b2d1a', '#1a228b', '#6b8b1a', '#8b1a7b', '#1a7b8b', '#838b1a', '#431a8b', + '#228b1a', '#8b1a4e' + ] + self.chars = ascii_uppercase + ascii_lowercase + digits + + # this holds pre-rendered Text objects for every character + self._rich_mapping: dict[str, Text] = {} + self.cell_id_style: str = 'on black' + + # initial build + self.config() + + def config(self, styling: bool = True, + default_style_to_symbol: bool = False, + show_symbols: bool = True, + cell_padding: bool = True, + style_mapping_override: dict[str, str] = None, + clear_default_styles_on_override: bool = False, + symbol_mapping_override: dict[str, str] = None) -> None: + """ + Pre-computes the Text objects for the mapping. + All logic is handled here so __call__ is a pure lookup. + """ + if style_mapping_override is None: style_mapping_override = {} + if symbol_mapping_override is None: symbol_mapping_override = {} + + new_mapping = {} + for i, char in enumerate(self.chars): + # determine Display Symbol + display = symbol_mapping_override.get(char, char) if show_symbols else "" + # apply Padding + content = f" {display} " if cell_padding else display + # determine Style + style = "" + if styling: + default_style = self.default_colors[i] if default_style_to_symbol else f'on {self.default_colors[i]}' + if clear_default_styles_on_override and style_mapping_override: + default_style = '' + style = style_mapping_override.get(char, default_style) + # pre-render and cache the Text object + new_mapping[char] = Text(content, style=style, end='') + + new_mapping["\n"] = Text("\n", end='') # CRITICAL: Cache the newline so __call__ doesn't create objects for it + self._rich_mapping = new_mapping + + def __call__(self, s: SpaceState, highlight_cells_with_id: frozenset[int] = frozenset()) -> Text: + """Fast join using the pre-computed mapping. Also highlight specific cells matching highlight_cells_with_id.""" + rm = self._rich_mapping + cell_id_style = self.cell_id_style + def iter_cells() -> Iterator[Text]: + # noinspection PyUnresolvedReferences + for c in s.cells: + cell = rm.get(str(c), Text(str(c), end='')) + if id(c) in highlight_cells_with_id: + cell = cell.copy() + cell.stylize(cell_id_style) + yield cell + return Text(end='').join(iter_cells()) + +if __name__ == "__main__": + from src.implementations.sss import SSS + from rich.console import Console + + # run your simulation + system = SSS(rule_set=["ABA -> AAB", "A -> ABA"], initial_space='AB' + ascii_uppercase + ascii_lowercase + digits) + system.evolve(0) + formatter = SpaceStateStringFormatter() + formatter.config(show_symbols=True) + console = Console(width=1000) + for event in system.events: + console.print(formatter(event.spaces.__next__())) diff --git a/src/core/signals.py b/src/core/signals.py index 9d1df11..0fe17d1 100644 --- a/src/core/signals.py +++ b/src/core/signals.py @@ -1,9 +1,8 @@ -from typing import Callable, Any, TypeVar, Generic, ParamSpec +from typing import Callable, Any from inspect import signature -P = ParamSpec("P") -class Signal(Generic[P]): +class Signal[*T]: """Implements a QT-like signal system for interactive programming. For convenience, the emitter does not force the connected callable to take all the arguments that are emitted. @@ -22,30 +21,30 @@ class Signal(Generic[P]): __slots__ = ('callables',) def __init__(self) -> None: - self.callables: list[tuple[Callable[P, Any], int]] = [] + self.callables: list[tuple[Callable[[*T], Any], int]] = [] @property def callables_count(self) -> int: return len(self.callables) - def emit(self, *args: P.args, **kwargs: P.kwargs) -> None: + def emit(self, *args: *T) -> None: for c, arg_len in self.callables: - c(*args[:arg_len], **kwargs) + c(*args[:arg_len]) - def connect(self, c: Callable[P, Any]) -> None: + def connect(self, c: Callable[..., Any]) -> None: if c not in self.callables: self.callables.append((c, len(signature(c).parameters))) - def disconnect(self, c: Callable[P, Any]) -> None: + def disconnect(self, c: Callable[..., Any]) -> None: matches: list[int] = [i for i, (c_, _) in enumerate(self.callables) if c_ is c] for i in reversed(matches): self.callables.pop(i) if __name__ == "__main__": - t: Signal = Signal() - f1 = lambda a: print(a) - f2 = lambda a, b: print(a, b) + t: Signal[str, str] = Signal() + f1 = lambda a, b: print(a) + f2 = lambda a, b, c: print(a, b) t.connect(f1) t.connect(f2) - t.emit("yup", 'test') + t.emit("yup", "") diff --git a/src/studio-plugins/sessie-enum.py b/src/studio-plugins/sessie-enum.py new file mode 100644 index 0000000..e69de29 diff --git a/src/studio-plugins/tunes.py b/src/studio-plugins/tunes.py index e9fc2c8..e69de29 100644 --- a/src/studio-plugins/tunes.py +++ b/src/studio-plugins/tunes.py @@ -1,4 +0,0 @@ -def test(*args): - print(args) - -test(*(i for i in range(10))) diff --git a/src/studio/stdplgns/explore.py b/src/studio/stdplgns/explore.py index ba4d7f1..dc2c8bf 100644 --- a/src/studio/stdplgns/explore.py +++ b/src/studio/stdplgns/explore.py @@ -1,22 +1,64 @@ # Textual Imports -from textual.widgets import Collapsible, TabPane, Input, Checkbox, Label, DataTable, SelectionList, Button -from rich.text import Text +from textual.widgets import Collapsible, TabPane, Input, Checkbox, Label, DataTable as _DataTable, SelectionList, Button from textual.widgets.data_table import CellKey from textual.widget import Widget from textual.coordinate import Coordinate from textual.widgets.selection_list import Selection +from textual.events import MouseMove # Standard Imports from typing import Iterator from core.numlib import INF, str_to_num, is_infinity -from core.engine import Event as FlowEvent, SpaceState +from core.engine import Event as FlowEvent, SpaceState, Cell as FlowCell, DeltaCell +from core.prettier import SpaceStateStringFormatter +from core.signals import Signal from studio.model import Plugin, FlowLangBase +class DataTable(_DataTable): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sig_mouse_over_inner_cell: Signal[Coordinate | None, int] = Signal() + self.enabled_sig_mouse_over_space_cell: bool = False + + def on_mouse_move(self, event: MouseMove) -> None: + if not self.enabled_sig_mouse_over_space_cell: + return + # Grab the row/col directly from the exact terminal cell the mouse is touching. + meta = event.style.meta # metadata holds the row and column info + # If the mouse is over the header or the empty space below the table, this metadata won't exist. + if not meta or "row" not in meta or "column" not in meta or meta['row'] == -1: + self.sig_mouse_over_inner_cell.emit(None, 0) + return + coord = Coordinate(meta["row"], meta["column"]) + start_x = 0 + PADDING = self.cell_padding + columns = list(self.columns.values()) + for i in range(coord.column): # Calculate start_x ONLY for columns BEFORE the hovered one + col = columns[i] + if col.auto_width: + base_width = max(col.width or 0, col.content_width or 0) + else: + base_width = col.width or col.content_width or 0 + # Add this column's total footprint (width + 1 left pad + 1 right pad) + start_x += base_width + 2 * PADDING + # Calculate the specific character offset inside the cell + virtual_x = event.x + self.scroll_offset.x + # Subtract start_x to zero out the column, then subtract 1 for THIS column's left padding + char_offset = (virtual_x - start_x) - PADDING + + # emit the signal + self.sig_mouse_over_inner_cell.emit(coord, char_offset) + + class P(Plugin): def on_initialized(self) -> None: self.name = 'explore' + # tools + self.space_state_formatter: SpaceStateStringFormatter = SpaceStateStringFormatter() + self._cell_ids_to_highlight: frozenset[int] = frozenset() + # attributes self._render_range: tuple[int, int] = (-100, INF) self._space_columns_limit: int = 1 @@ -31,6 +73,10 @@ def on_initialized(self) -> None: # connect view signals self.view.sig_input_submit.connect(self.handle_input_submit) self.view.sig_selection_list_toggled.connect(self.handle_selection_toggle) + self.view.sig_checkbox_changed.connect(self.handle_checkbox_change) + + # temp trackers + self.__last_hover_coord_and_offset: tuple[Coordinate, int] = (Coordinate(0, 0), 0) def _column_control_bitmap_zero_out(self): """Zeros out the column bitmap""" @@ -60,25 +106,32 @@ def controls(self) -> Iterator[Widget]: self.hidden_space_columns.border_title = 'Hidden Space Columns' yield self.hidden_space_columns - with Collapsible(title='Pattern Queries', collapsed=False): - self.search_pattern = Input() - self.search_pattern.border_title = 'Search Pattern' - yield self.search_pattern - self.created_at = Input() - self.created_at.border_title = 'Created at Event(s)' - yield self.created_at - self.destroyed_at = Input() - self.destroyed_at.border_title = 'Destroyed at Event(s)' - yield self.destroyed_at - yield Button('Find', id="find-pattern-filters") - - with Collapsible(title='Selection Info', collapsed=False): - self.selection_info_label = Label('Event Info:\n- Cells: 0\n- Connected: 0\n') - yield self.selection_info_label - self.selection_info_label = Label('Cell Info:\n- created at: None\n- destroyed at: None\n') - yield self.selection_info_label - self.hover_explorer = Checkbox('Hover Explorer') + with Collapsible(title='Cell Rendering', collapsed=False): + self.style_controls = SelectionList( + Selection("Cell Styling", 0, True), + Selection(" ╰─ On Symbol", 1, False), + Selection("Show Symbols", 2, True), + Selection("Cell Padding", 3, True), + Selection("Clear on Override", 4, False), + id='style-controls' + ) + yield self.style_controls + self.style_map = Input("auto", placeholder='e.g. auto or A: red', id='style-map') + self.style_map.border_title = 'Style Override' + yield self.style_map + self.symbol_map = Input("auto", placeholder='e.g. auto or A: Z', id='symbol-map') + self.symbol_map.border_title = 'Symbol Map' + yield self.symbol_map + + with Collapsible(title='Hover Explorer', collapsed=False): + self.hovered_info_label = Label() + self._reset_hovered_info_label() + yield self.hovered_info_label + self.hover_explorer = Checkbox('Hover Explorer', id='hover-explorer') yield self.hover_explorer + self.hover_style = Input("on black", placeholder='e.g. on red or bold blue', id='hover-style') + self.hover_style.border_title = 'Hover Style' + yield self.hover_style yield Label() @@ -91,7 +144,7 @@ def handle_input_submit(self, e: Input.Submitted): str_to_num(rs[0]) if rs[0] else 0, str_to_num(rs[1]) if rs[1] else INF ) - self._rebuild_rows(self.model.active_flow.flow) + self._rebuild_rows() except: self.view.notify('Invalid render range.', severity='warning') e.input.value = '{0}:{1}'.format(*self._render_range) @@ -126,6 +179,12 @@ def handle_input_submit(self, e: Input.Submitted): except: self.view.notify('Invalid hidden ranges. Values/Ranges must be between 0 and 1000.', severity='warning') + elif _id in ('style-map', 'symbol-map'): + self._handle_styling_update() + + elif _id == 'hover-style': + self.space_state_formatter.cell_id_style = e.value.strip() + def handle_selection_toggle(self, e: SelectionList.SelectionToggled): _id: str = e.selection_list.id if _id == 'column-controls': @@ -133,14 +192,126 @@ def handle_selection_toggle(self, e: SelectionList.SelectionToggled): for i in e.selection_list.selected: self._columns_control_bitmap[i] = True self._rebuild_columns() + if _id == 'style-controls': + self._handle_styling_update() + + def handle_checkbox_change(self, e: Checkbox.Changed): + _id: str = e.checkbox.id + if _id == 'hover-explorer': + self.data_table.enabled_sig_mouse_over_space_cell = e.checkbox.value def panel(self) -> TabPane | None: self.data_table = DataTable(id='data-table') + self.data_table.sig_mouse_over_inner_cell.connect(self._handle_mouse_over_data_table) return TabPane( self.name.title(), self.data_table ) + def _handle_styling_update(self): + control_bitmap: list[bool] = [False, False, False, False, False] + for i in self.style_controls.selected: control_bitmap[i] = True + + try: + style_map: dict[str, str] = { + k.strip(): v.strip() for k, v in (p.split(':') for p in self.style_map.value.split(',')) + } if self.style_map.value != 'auto' else None + + symbol_map: dict[str, str] = { + k.strip(): v.strip() for k, v in (p.split(':') for p in self.symbol_map.value.split(',')) + } if self.symbol_map.value != 'auto' else None + except: + self.view.notify('Invalid style map.', severity='error') + return + + # noinspection PyTypeChecker + self.space_state_formatter.config( + *control_bitmap[:4], + style_map, + control_bitmap[4], + symbol_map + ) + self._rebuild_rows() + + def _reset_hovered_info_label(self): + self.hovered_info_label.content = """[bold]Event Info[/bold] +• ---- +• ---- +• ---- +• ---- +• ---- +• ---- + +[bold]Cell Info[/bold] +• ---- +• ---- +• ---- +""" + + def _handle_mouse_over_data_table(self, coord: Coordinate | None, offset: int) -> None: + def reset_highlighted(): + self._reset_hovered_info_label() + if self._cell_ids_to_highlight: + self._cell_ids_to_highlight = frozenset() + self._rebuild_rows() + if (not coord + or offset == -1 + or self.data_table.coordinate_to_cell_key(coord).column_key in ('event', 'distance', 'connected')): + reset_highlighted() + return + + # calculate offset of the cell index (because of different padding/rendering options) + cell_content: str = str(self.data_table.get_cell_at(coord)) + if cell_content.startswith(' '): # if padding of " " around symbols is being used. + if cell_content.startswith(' '): # if not rendering symbols but blocks of " " + offset = offset // 2 # one extra symbol needs to be removed + else: + offset = offset // 3 # two extra symbols need to be removed + + # if the last cell was the same + if self.__last_hover_coord_and_offset == (coord, offset): + return + self.__last_hover_coord_and_offset = (coord, offset) + + cell_key: CellKey = self.data_table.coordinate_to_cell_key(coord) + row_idx: int = int(cell_key.row_key.value) + column_idx: int = int(cell_key.column_key.value) + + # grab all relevant information about the selected space + event: FlowEvent = self.model.active_flow.flow.events[row_idx] + spaces: tuple[SpaceState, ...] = tuple(event.spaces) + space_state: SpaceState = spaces[column_idx] + + # update the rows + if offset >= len(space_state): + reset_highlighted() + return + flow_cell: FlowCell = space_state.get_all_cells()[offset] + self._cell_ids_to_highlight = frozenset((id(flow_cell),)) + self._rebuild_rows() + + # update the hover info labels + try: + cell_destroyed_at: int = flow_cell.destroyed_at[column_idx] + lifespan: int = cell_destroyed_at - flow_cell.created_at + except IndexError: + cell_destroyed_at: None = None + lifespan: None = None + connected_events = tuple(event.causally_connected_events) + self.hovered_info_label.content = f"""[bold]Event #{event.time} Info[/bold] +• Created Spaces: {len(spaces)} +• Affected Cells: {len(tuple(event.affected_cells))} +• Space Size: {len(space_state)} +• Causal Distance: {event.causal_distance_to_creation} +• Connected Events Abs: {len(connected_events)} +• Connected Events Set: {len(set(connected_events))} + +[bold]Cell #{offset} Info[/bold] +• Created at: {flow_cell.created_at} +• Destroyed at: {cell_destroyed_at} +• Lifespan: {lifespan} +""" + def on_evolved(self, f: FlowLangBase, steps: int) -> None: cft = self.cft dt = self.data_table @@ -174,7 +345,10 @@ def on_undo(self, f: FlowLangBase, steps: int) -> None: cft(self._refresh_column_widths) def on_clear(self): - self.cft(self.data_table.clear) + try: + self.cft(self.data_table.clear) # this function may be called within the main thread. + except RuntimeError: + self.data_table.clear() def _add_row(self, event: FlowEvent): columns = [] @@ -198,31 +372,39 @@ def _add_row(self, event: FlowEvent): # Process the space columns spaces: Iterator[SpaceState] = event.spaces + formatter: SpaceStateStringFormatter = self.space_state_formatter + cells_to_highlight: frozenset[int] = self._cell_ids_to_highlight hidden: set[int] = self._hidden_space_columns for i in range(self._space_columns_limit): try: space = spaces.__next__() # we must always increment next (even though it may be hidden, that is what makes the check work) if i in hidden: continue - columns.append(space) + columns.append(formatter(space, cells_to_highlight)) except StopIteration: break - # Add everything as a row self.data_table.add_row( *columns, key=str(event.time) ) - def _rebuild_rows(self, f: FlowLangBase) -> None: + def _rebuild_rows(self) -> None: + """ + # TODO: make the scrollbars remember position even after clear. + """ a, b = self._render_range - self.data_table.clear() - for event in f.events[a:b + (1 if b > 0 else 0)]: + dt = self.data_table + old_x, old_y = dt.scroll_x, dt.scroll_y + dt.clear() + for event in self.model.active_flow.flow.events[a:b + (1 if b > 0 else 0)]: self._add_row(event) self._refresh_column_widths() + dt.scroll_to(x=old_x, y=old_y, animate=False) def _rebuild_columns(self, rebuild_rows: bool = True) -> None: dt = self.data_table + old_x, old_y = dt.scroll_x, dt.scroll_y dt.clear(columns=True) if self._columns_control_bitmap[0]: dt.add_column('Event', key='event') @@ -236,7 +418,8 @@ def _rebuild_columns(self, rebuild_rows: bool = True) -> None: continue dt.add_column(_:=str(i), key=_) if rebuild_rows: - self._rebuild_rows(self.model.active_flow.flow) + self._rebuild_rows() + dt.scroll_to(x=old_x, y=old_y, animate=False) def _refresh_column_widths(self) -> None: """Update the column widths as Textual does not currently do that for us when removing rows.""" diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index af62395..ede0950 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -87,6 +87,8 @@ def handle_btn_press(self, e: Button.Pressed): btn: str = e.button.id if btn == 'btn-run': self.execute_run() + elif btn == 'btn-clear': + self.model.active_flow.flow.clear_evolution() elif btn == 'clear-log': self.log_view.clear() self.log_view.write(f"[bold green] --- Log Cleared --- [/bold green]") diff --git a/src/studio/view.py b/src/studio/view.py index ca0f708..037fad5 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -360,9 +360,7 @@ def compose(self) -> ComposeResult: # yield Spacer() yield Button("Run", id="btn-run", classes="action-btn green", compact=True) yield Label("| ", classes="gray") - yield Button("Stop", id="btn-stop", classes="action-btn orange", compact=True) - yield Label("| ", classes="gray") - yield Button("Reset", id="btn-reset", classes="action-btn red", compact=True) + yield Button("clear", id="btn-clear", classes="action-btn orange", compact=True) # Code Editor self.code_editor_text_area: TextArea = TextArea.code_editor( From 0fc0ed8a1f54a6df7f4c3671503886dcc1eab5c0 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Fri, 10 Apr 2026 02:38:13 -0400 Subject: [PATCH 22/31] Fixed todo --- src/studio/stdplgns/explore.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/studio/stdplgns/explore.py b/src/studio/stdplgns/explore.py index dc2c8bf..d935cd2 100644 --- a/src/studio/stdplgns/explore.py +++ b/src/studio/stdplgns/explore.py @@ -390,9 +390,6 @@ def _add_row(self, event: FlowEvent): ) def _rebuild_rows(self) -> None: - """ - # TODO: make the scrollbars remember position even after clear. - """ a, b = self._render_range dt = self.data_table old_x, old_y = dt.scroll_x, dt.scroll_y From 623eda5eea2027d9c67dc32735141bc75b549f3f Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Fri, 10 Apr 2026 12:35:30 -0400 Subject: [PATCH 23/31] Added one more hover feature adn fixed bug in the Explore plugin --- src/studio/stdplgns/explore.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/studio/stdplgns/explore.py b/src/studio/stdplgns/explore.py index d935cd2..154f9fb 100644 --- a/src/studio/stdplgns/explore.py +++ b/src/studio/stdplgns/explore.py @@ -246,6 +246,7 @@ def _reset_hovered_info_label(self): • ---- • ---- • ---- +• ---- """ def _handle_mouse_over_data_table(self, coord: Coordinate | None, offset: int) -> None: @@ -307,6 +308,7 @@ def reset_highlighted(): • Connected Events Set: {len(set(connected_events))} [bold]Cell #{offset} Info[/bold] +• Quanta: {flow_cell.quanta} • Created at: {flow_cell.created_at} • Destroyed at: {cell_destroyed_at} • Lifespan: {lifespan} @@ -320,7 +322,7 @@ def on_evolved(self, f: FlowLangBase, steps: int) -> None: flush_mode: bool = self._render_range[0] < 0 and is_infinity(self._render_range[1]) render_limit: int = abs(self._render_range[0]) # only used when flush mode is true if flush_mode and steps >= render_limit: - cft(self._rebuild_rows, f) + cft(self._rebuild_rows) else: short_circuit: bool = False # just a little optimization for large loops for event in f.events[-steps:]: @@ -341,7 +343,7 @@ def on_undo(self, f: FlowLangBase, steps: int) -> None: try: cft(dt.remove_row, str(old_rows_count - i - 1)) except: pass if self._render_range[0] < 0 and is_infinity(self._render_range[1]): # if flushing - cft(self._rebuild_rows, f) + cft(self._rebuild_rows) cft(self._refresh_column_widths) def on_clear(self): From 7583d6a858793575833cfb358d0d82c77d83c3ea Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Fri, 10 Apr 2026 13:12:11 -0400 Subject: [PATCH 24/31] Improved the hover feature in the Explore plugin. --- src/studio/stdplgns/explore.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/studio/stdplgns/explore.py b/src/studio/stdplgns/explore.py index 154f9fb..71c7e12 100644 --- a/src/studio/stdplgns/explore.py +++ b/src/studio/stdplgns/explore.py @@ -241,6 +241,7 @@ def _reset_hovered_info_label(self): • ---- • ---- • ---- +• ---- [bold]Cell Info[/bold] • ---- @@ -293,21 +294,29 @@ def reset_highlighted(): # update the hover info labels try: + affected_cells: DeltaCell = tuple(event.affected_cells)[column_idx] + created_cells: int = len(affected_cells.new_cells) + destroyed_cells: int = len(affected_cells.destroyed_cells) + except IndexError: + created_cells: None = None + destroyed_cells: None = None + try: # in separate try block because of the destroyed_at maybe not existing cell_destroyed_at: int = flow_cell.destroyed_at[column_idx] lifespan: int = cell_destroyed_at - flow_cell.created_at except IndexError: cell_destroyed_at: None = None lifespan: None = None connected_events = tuple(event.causally_connected_events) - self.hovered_info_label.content = f"""[bold]Event #{event.time} Info[/bold] -• Created Spaces: {len(spaces)} -• Affected Cells: {len(tuple(event.affected_cells))} + self.hovered_info_label.content = f"""[bold]Event #{event.time} | Space #{column_idx}[/bold] +• Branched Spaces: {len(spaces) - 1} • Space Size: {len(space_state)} • Causal Distance: {event.causal_distance_to_creation} -• Connected Events Abs: {len(connected_events)} -• Connected Events Set: {len(set(connected_events))} +• Connected Abs: {len(connected_events)} +• Connected Set: {len(set(connected_events))} +• Created Cells: {created_cells} +• Destroyed Cells: {destroyed_cells} -[bold]Cell #{offset} Info[/bold] +[bold]Cell #{offset}[/bold] • Quanta: {flow_cell.quanta} • Created at: {flow_cell.created_at} • Destroyed at: {cell_destroyed_at} From caf38bd28b43e563bd59bb0c5becbf8537325705 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Sat, 11 Apr 2026 02:45:20 -0400 Subject: [PATCH 25/31] Improved the explore plugin by adding the ruleset viewer and hover tool. --- src/core/engine.py | 19 +++- src/core/prettier.py | 30 +++--- src/lang/implementation.py | 6 +- src/lang/interpreter.py | 6 +- src/studio/stdplgns/explore.py | 136 +++++++++++++++++++++++--- src/studio/stdplgns/run.py | 23 +++++ src/studio/view.py | 4 +- tests/example-studio-project/ca.flow | 1 + tests/example-studio-project/sss.flow | 1 - tests/test0.py | 3 + 10 files changed, 192 insertions(+), 37 deletions(-) diff --git a/src/core/engine.py b/src/core/engine.py index e68d600..c527a42 100644 --- a/src/core/engine.py +++ b/src/core/engine.py @@ -337,7 +337,7 @@ class DeltaSpace(NamedTuple): # returned by Rule.apply() in a Sequence[DeltaSpa """Single application of a rule within Rule.apply().""" input_space: SpaceState # we always have this filled so that we know what spaces had what changes (if any) made output_space: Sequence[SpaceState | None] # can include many children branches - cell_deltas: Sequence[DeltaCell] # should be aligned with output_space array + cell_deltas: Sequence[DeltaCell] # should be aligned with output_space array (so branches align) def __bool__(self) -> bool: return any(self.output_space) or any(self.cell_deltas) # we check both to be as robust as possible... what if a rule does not return delta cells due to modifying but not adding or deleting? @@ -387,6 +387,15 @@ def spaces(self) -> Iterator[SpaceState]: if space is not None: yield space + @property # maybe cache this? + def spaces_with_metadata(self) -> Iterator[tuple[DeltaSpaces, DeltaSpace, SpaceState]]: + """Returns all newly created spaces along with their metadata (in the parent structure)""" + for r in self.space_deltas: + for space_delta in r.space_deltas: + for space in space_delta.output_space: + if space is not None: + yield r, space_delta, space + def __str__(self): return '[' + ', '.join(str(space) for space in self.spaces) + ']' # TODO remove this to a dedication printer @@ -400,9 +409,10 @@ class Flow: on_undone_step: Signal[Self] = Signal() on_undone_n: Signal[Self, int] = Signal() # after all undo's on_clear: Signal[Self] = Signal() + on_ruleset_set: Signal[Self] = Signal() def __init__(self): - self.rule_set: RuleSet = RuleSet([]) # can be changed at any time to provide a new set of rules. + self.ruleset: RuleSet = RuleSet([]) # can be changed at any time to provide a new set of rules. self.events: list[Event] = [] # defaults to empty... but nothing will work properly # progress tracking attributes @@ -410,7 +420,8 @@ def __init__(self): def set_ruleset(self, ruleset: RuleSet) -> None: """Used to set the rule set""" - self.rule_set: RuleSet = ruleset + self.ruleset: RuleSet = ruleset + self.on_ruleset_set.emit(self) def set_initial_space(self, initial_space: Sequence[SpaceState]) -> None: """Used to set the initial space""" @@ -444,7 +455,7 @@ def _evolve(self) -> None: - set the applied rules (the applied rules are associated with the space states they modified) - extract all the modified space states from the applied rules and add them to the space states of the Event. """ - applied_rules: list[DeltaSpaces] = self.rule_set.apply(to_spaces=tuple(self.current_event.spaces)) + applied_rules: list[DeltaSpaces] = self.ruleset.apply(to_spaces=tuple(self.current_event.spaces)) if not any(applied_rules): # if no rules made any modifications to the spaces self.current_event.inert = True return diff --git a/src/core/prettier.py b/src/core/prettier.py index 6ce252b..fb575a4 100644 --- a/src/core/prettier.py +++ b/src/core/prettier.py @@ -11,19 +11,22 @@ from core.engine import SpaceState +COLOR_PALETTE: list[str] = [ + '#1a4e8b', '#8b0000', '#2d8b2d', '#8b1a72', '#00728b', '#8b5e1a', '#4e1a8b', '#6b8b1a', '#8b1a43', '#1a7e8b', + '#7b8b1a', '#5c1a8b', '#1a8b43', '#8b431a', '#1a2d8b', '#8b7b1a', '#8b1a62', '#1a8b7e', '#3c1a8b', '#3d8b1a', + '#8b221a', '#1a5c8b', '#1a8b22', '#6e1a8b', '#5a8b1a', '#8b1a82', '#1a8b51', '#8b691a', '#2d1a8b', '#4e8b1a', + '#8b1a33', '#1a788b', '#7e8b1a', '#471a8b', '#1a8b34', '#8b3d1a', '#1a3d8b', '#628b1a', '#8b1a6e', '#1a648b', + '#758b1a', '#221a8b', '#1a8b4e', '#8b511a', '#348b1a', '#511a8b', '#1a8b6e', '#8b1a22', '#1a4e8b', '#5a8b1a', + '#6e1a8b', '#7b8b1a', '#1a8b3d', '#8b2d1a', '#1a228b', '#6b8b1a', '#8b1a7b', '#1a7b8b', '#838b1a', '#431a8b', + '#228b1a', '#8b1a4e', '#64676E' +] # 63 colors for ascii_uppercase + ascii_lowercase + digits + '_' + + class SpaceStateStringFormatter: def __init__(self) -> None: - # We leave these here for maximal configuration. - self.default_colors = [ - '#1a4e8b', '#8b0000', '#2d8b2d', '#8b1a72', '#00728b', '#8b5e1a', '#4e1a8b', '#6b8b1a', '#8b1a43', '#1a7e8b', - '#7b8b1a', '#5c1a8b', '#1a8b43', '#8b431a', '#1a2d8b', '#8b7b1a', '#8b1a62', '#1a8b7e', '#3c1a8b', '#3d8b1a', - '#8b221a', '#1a5c8b', '#1a8b22', '#6e1a8b', '#5a8b1a', '#8b1a82', '#1a8b51', '#8b691a', '#2d1a8b', '#4e8b1a', - '#8b1a33', '#1a788b', '#7e8b1a', '#471a8b', '#1a8b34', '#8b3d1a', '#1a3d8b', '#628b1a', '#8b1a6e', '#1a648b', - '#758b1a', '#221a8b', '#1a8b4e', '#8b511a', '#348b1a', '#511a8b', '#1a8b6e', '#8b1a22', '#1a4e8b', '#5a8b1a', - '#6e1a8b', '#7b8b1a', '#1a8b3d', '#8b2d1a', '#1a228b', '#6b8b1a', '#8b1a7b', '#1a7b8b', '#838b1a', '#431a8b', - '#228b1a', '#8b1a4e' - ] - self.chars = ascii_uppercase + ascii_lowercase + digits + # We have these here for maximal configuration. + self.default_colors = COLOR_PALETTE.copy() + self.chars = ascii_uppercase + ascii_lowercase + digits + '_' # this holds pre-rendered Text objects for every character self._rich_mapping: dict[str, Text] = {} @@ -79,6 +82,11 @@ def iter_cells() -> Iterator[Text]: yield cell return Text(end='').join(iter_cells()) + def convert_pure_str(self, string: str) -> Text: + """Utility method in case a given string needs to be styles the same as the space states (can be used in a ruleset printer for instance).""" + rm = self._rich_mapping + return Text(end='').join(rm.get(str(c), Text(str(c), end='')) for c in string) + if __name__ == "__main__": from src.implementations.sss import SSS from rich.console import Console diff --git a/src/lang/implementation.py b/src/lang/implementation.py index 402a3aa..c830126 100644 --- a/src/lang/implementation.py +++ b/src/lang/implementation.py @@ -72,7 +72,7 @@ class BaseRule(RuleABC): def __init__(self, selector: Sequence[Selector], target: Sequence[Target]): super().__init__() # Functionality Fields (you can have multiple selectors and multiple targets) - self.selectors: Sequence[Selector] = selector # used by self.match() + self.selector: Sequence[Selector] = selector # used by self.match() self.target: Sequence[Target] = target # used by self.apply() # Complex Functionality @@ -107,7 +107,7 @@ def __init__(self, selector: Sequence[Selector], target: Sequence[Target]): # Note that additional flags can be set in the syntax, however, they will have no meaning unless included in the control flow by subclassing and modifying particular rule. def __repr__(self): - return f"{self.__class__.__name__}({[s.selector for s in self.selectors]}, {[t.target for t in self.target]})" + return f"{self.__class__.__name__}({[s.selector for s in self.selector]}, {[t.target for t in self.target]})" def _conflict_detector(self, current_matches: list[tuple[int, int]], match: tuple[int, int]) -> set[int]: """helper that detects collisions between selectors""" @@ -144,7 +144,7 @@ def match(self, spaces: Sequence[SpaceState]) -> Sequence[RuleMatch]: for self in top_self.chain: if self.disabled: # we must check if the rule has been disabled in case the rule is in a chain (has been merged) continue - for pattern in self.selectors: + for pattern in self.selector: finds: Iterator[tuple[int, int]] if pattern.type in ('literal', 'regex'): # finds = space.find(tuple(Cell(c) for c in pattern.selector)) # older slow way (before Vec containers) diff --git a/src/lang/interpreter.py b/src/lang/interpreter.py index 5cc390a..e4d6d31 100644 --- a/src/lang/interpreter.py +++ b/src/lang/interpreter.py @@ -192,7 +192,7 @@ def interpret(self, s: str) -> None: def __merge_group(self, identifier: int | str): """A directive to merge a particular group into a chain (a composite rule)""" - rules: list[BaseRule] = cast(list[BaseRule], self.rule_set.rules) + rules: list[BaseRule] = cast(list[BaseRule], self.ruleset.rules) for i in range(len(rules)): if rules[i].disabled: continue @@ -206,7 +206,7 @@ def __merge_group(self, identifier: int | str): def __compress_group(self, identifier: int | str): """A directive to compress a Rule Group such that causality is preserved (no cellular change if the characters look the same)""" - rules: list[BaseRule] = [rule for rule in cast(list[BaseRule], self.rule_set.rules) + rules: list[BaseRule] = [rule for rule in cast(list[BaseRule], self.ruleset.rules) if rule.group == identifier and not rule.disabled] # If any rule makes no changes, disable it. for rule in rules: @@ -214,7 +214,7 @@ def __compress_group(self, identifier: int | str): continue rule_is_active: bool = False for target in rule.target: - for selector in rule.selectors: + for selector in rule.selector: for s_char, t_char in zip(selector.selector, target.target): # we only care about the first/primary target... (we can't determine how multiple targets will behave on different match sets) if t_char.quanta == '_': continue diff --git a/src/studio/stdplgns/explore.py b/src/studio/stdplgns/explore.py index 71c7e12..6adedb4 100644 --- a/src/studio/stdplgns/explore.py +++ b/src/studio/stdplgns/explore.py @@ -1,17 +1,20 @@ # Textual Imports -from textual.widgets import Collapsible, TabPane, Input, Checkbox, Label, DataTable as _DataTable, SelectionList, Button +from rich.text import Text +from textual.widgets import Collapsible, TabPane, Input, Checkbox, Label, DataTable as _DataTable, SelectionList from textual.widgets.data_table import CellKey from textual.widget import Widget from textual.coordinate import Coordinate from textual.widgets.selection_list import Selection +from textual.containers import VerticalScroll, Horizontal, Vertical from textual.events import MouseMove # Standard Imports -from typing import Iterator +from typing import Iterator, Sequence from core.numlib import INF, str_to_num, is_infinity -from core.engine import Event as FlowEvent, SpaceState, Cell as FlowCell, DeltaCell +from core.engine import Event as FlowEvent, SpaceState, Cell as FlowCell, DeltaCell, DeltaSpace, DeltaSpaces from core.prettier import SpaceStateStringFormatter from core.signals import Signal +from lang.implementation import BaseRule from studio.model import Plugin, FlowLangBase @@ -51,6 +54,23 @@ def on_mouse_move(self, event: MouseMove) -> None: self.sig_mouse_over_inner_cell.emit(coord, char_offset) +class RulesetDashboard(VerticalScroll): + """A scoped container specifically for the ruleset layout.""" + + DEFAULT_CSS = """ + RulesetDashboard Horizontal { + height: auto; + width: 100%; + margin-bottom: 1; + } + RulesetDashboard Vertical { + width: 1fr; + height: auto; + margin-right: 1; + } + """ + + class P(Plugin): def on_initialized(self) -> None: self.name = 'explore' @@ -63,12 +83,13 @@ def on_initialized(self) -> None: self._render_range: tuple[int, int] = (-100, INF) self._space_columns_limit: int = 1 self._hidden_space_columns: set[int] = set() - self._columns_control_bitmap: list[bool] = [True, False, False, False, False] + self._columns_control_bitmap: list[bool] = [True, False, False, False, False, False] # connect model signals self.model.active_flow.flow.on_evolved_n.connect(self.on_evolved) self.model.active_flow.flow.on_undone_n.connect(self.on_undo) self.model.active_flow.flow.on_clear.connect(self.on_clear) + self.model.active_flow.flow.on_ruleset_set.connect(lambda f: self.cft(self._rebuild_ruleset_table, f.ruleset.rules, self.ruleset_table)) # connect view signals self.view.sig_input_submit.connect(self.handle_input_submit) @@ -86,6 +107,8 @@ def controls(self) -> Iterator[Widget]: self.render_range = Input(value='-100:', placeholder='e.g. -10: or 3:10', id='render-limit') self.render_range.border_title = 'Render Range' yield self.render_range + self.show_ruleset = Checkbox('Show Ruleset', value=True, id='show-ruleset') + yield self.show_ruleset with Collapsible(title='Column Controls', collapsed=False): control_bits = self._columns_control_bitmap @@ -94,7 +117,8 @@ def controls(self) -> Iterator[Widget]: Selection("Causal Distance", 1, control_bits[1]), Selection("Causally Connected", 2, control_bits[2]), Selection(" ├─ Collapsed", 3, control_bits[3]), - Selection(" ╰─ Counted", 4, control_bits[4]), + Selection(" ├─ Sorted", 4, control_bits[4]), + Selection(" ╰─ Counted", 5, control_bits[5]), id='column-controls' ) self._rebuild_columns(rebuild_rows=False) # must be called here or sometime after to initiated columns. @@ -129,6 +153,8 @@ def controls(self) -> Iterator[Widget]: yield self.hovered_info_label self.hover_explorer = Checkbox('Hover Explorer', id='hover-explorer') yield self.hover_explorer + self.hover_ruleset_enabled = Checkbox('Hovered Event Ruleset', disabled=True, id='hover-ruleset-enabled') + yield self.hover_ruleset_enabled self.hover_style = Input("on black", placeholder='e.g. on red or bold blue', id='hover-style') self.hover_style.border_title = 'Hover Style' yield self.hover_style @@ -198,14 +224,47 @@ def handle_selection_toggle(self, e: SelectionList.SelectionToggled): def handle_checkbox_change(self, e: Checkbox.Changed): _id: str = e.checkbox.id if _id == 'hover-explorer': - self.data_table.enabled_sig_mouse_over_space_cell = e.checkbox.value + self.data_table.enabled_sig_mouse_over_space_cell = e.value + self.hover_ruleset_enabled.disabled = not e.value + if not e.value: + self.hover_ruleset_enabled.value = self.hovered_ruleset_container.display = False + if _id == 'hover-ruleset-enabled' and self.hover_explorer.value: + self.hovered_ruleset_container.display = e.value + if not e.value: + self.hovered_ruleset_table.clear() + if _id == 'show-ruleset': + self.ruleset_container.display = e.value def panel(self) -> TabPane | None: + # Ruleset Table + self.ruleset_table = _DataTable(id='hovered-ruleset-table', show_cursor=False) + self.ruleset_table.add_columns('Selector', 'Target', 'Type', 'Group') + self.ruleset_container = Vertical( + Label('[bold] Active Ruleset Table [/bold]'), + self.ruleset_table + ) + + # Ruleset Table + self.hovered_ruleset_table = _DataTable(id='ruleset-table', show_cursor=False) + self.hovered_ruleset_table.add_columns('Selector', 'Target', 'Type', 'Group') + self.hovered_ruleset_container = Vertical( + Label('[bold] Hovered Event Ruleset Table [/bold]'), + self.hovered_ruleset_table, + ) + self.hovered_ruleset_container.display = False + + # Evolution Table self.data_table = DataTable(id='data-table') self.data_table.sig_mouse_over_inner_cell.connect(self._handle_mouse_over_data_table) return TabPane( self.name.title(), - self.data_table + RulesetDashboard( + Horizontal( + self.ruleset_container, + self.hovered_ruleset_container + ), + self.data_table + ) ) def _handle_styling_update(self): @@ -231,6 +290,9 @@ def _handle_styling_update(self): control_bitmap[4], symbol_map ) + + # noinspection PyTypeChecker + self._rebuild_ruleset_table(self.model.active_flow.flow.ruleset.rules, self.ruleset_table) self._rebuild_rows() def _reset_hovered_info_label(self): @@ -280,9 +342,10 @@ def reset_highlighted(): column_idx: int = int(cell_key.column_key.value) # grab all relevant information about the selected space - event: FlowEvent = self.model.active_flow.flow.events[row_idx] - spaces: tuple[SpaceState, ...] = tuple(event.spaces) - space_state: SpaceState = spaces[column_idx] + flow: FlowLangBase = self.model.active_flow.flow + event: FlowEvent = flow.events[row_idx] + spaces: tuple[tuple[DeltaSpaces, DeltaSpace, SpaceState], ...] = tuple(event.spaces_with_metadata) + space_state: SpaceState = spaces[column_idx][2] # update the rows if offset >= len(space_state): @@ -323,6 +386,49 @@ def reset_highlighted(): • Lifespan: {lifespan} """ + # place the rule (and its chain if there is one) in the hovered ruleset table. + if self.hover_ruleset_enabled.value: + applied_rule: BaseRule = spaces[column_idx][0].rule + if applied_rule: + self._rebuild_ruleset_table(applied_rule.chain, self.hovered_ruleset_table, hide_disabled=True, remember_old_row_length=True) + + def _rebuild_ruleset_table(self, rules: Sequence[BaseRule], + table: DataTable, + hide_disabled: bool = False, + remember_old_row_length: bool = False) -> None: + # we have the f parameter because this is called by a Flow signal. + def print_rule(rule: BaseRule) -> tuple[Text, Text]: + selectors: list[Text] = [] + for s in rule.selector: + sv = s.selector + if isinstance(sv, str | bytes): + selectors.append(self.space_state_formatter.convert_pure_str(sv)) + else: + selectors.append(Text(str(sv))) + targets: list[Text] = [] + for t in rule.target: + tv = t.target + if isinstance(tv, int): + targets.append(Text(str(tv))) + else: # when `tv` is Sequence[Cell] + targets.append(self.space_state_formatter.convert_pure_str(''.join(str(c) for c in tv))) + return (Text(', ').join(selectors) if len(selectors) > 1 else selectors[0], + Text(', ').join(targets) if len(targets) > 1 else targets[0]) + + old_rows: int = table.row_count + table.clear() + rule: BaseRule + for i, rule in enumerate(rules): + if hide_disabled and rule.disabled: + continue + table.add_row( + *print_rule(rule), rule.__class__.__name__, rule.group, + label=str(i) + ) + old_rows -= 1 + if remember_old_row_length and old_rows: + for _ in range(old_rows): table.add_row() + def on_evolved(self, f: FlowLangBase, steps: int) -> None: cft = self.cft dt = self.data_table @@ -357,8 +463,8 @@ def on_undo(self, f: FlowLangBase, steps: int) -> None: def on_clear(self): try: - self.cft(self.data_table.clear) # this function may be called within the main thread. - except RuntimeError: + self.cft(self.data_table.clear) + except RuntimeError: # this function may or may not be called within the main thread. self.data_table.clear() def _add_row(self, event: FlowEvent): @@ -371,10 +477,12 @@ def _add_row(self, event: FlowEvent): connected = set(event.causally_connected_events) else: connected = tuple(event.causally_connected_events) - if control_bitmap[4]: # if we are counting the causally connect (to display that metric instead) + if control_bitmap[5]: # if we are counting the causally connect (to display that metric instead) connected = len(connected) + elif control_bitmap[4]: + connected = sorted(connected) else: - connected = None + connected = None # just to satisfy the IDE wanting a name in the space for data, show in zip((event.time, event.causal_distance_to_creation, connected), diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index ede0950..8326ee0 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -40,6 +40,9 @@ def on_initialized(self) -> None: def controls(self) -> Iterator[Widget]: # NOTE: there aren't many settings for the run tab due to most controls being available through the DSL. + self.undo_steps = Input(type='integer', value='1') + self.undo_steps.border_title = 'Undo Button Steps' + yield self.undo_steps with Collapsible(title='Hot Reload', collapsed=False): self.hot_mode = Checkbox('Enable hot reload mode', id='hot-reload') yield self.hot_mode @@ -87,6 +90,8 @@ def handle_btn_press(self, e: Button.Pressed): btn: str = e.button.id if btn == 'btn-run': self.execute_run() + elif btn == 'btn-undo': + self.execute_undo() elif btn == 'btn-clear': self.model.active_flow.flow.clear_evolution() elif btn == 'clear-log': @@ -175,4 +180,22 @@ def execute_run(self) -> None: self.log_view.write(f'[bold green]Run "{active_flow.name}" flow...[/bold green]') # TODO: maybe more info will be logged at some point (if deemed useful) + def execute_undo(self) -> None: + """Handles the flow undo and updates the UI components.""" + active_flow = self.model.active_flow + if not active_flow: + self.log_view.write("[bold red]Studio Error:[/bold red] No active flow selected to undo.") + return + if self._running_thread and self._running_thread.is_running: # do not run while thread is active + self.log_view.write("[bold red]Studio Error:[/bold red] A flow thread is currently running.") + return + try: + self._running_thread = self.view.run_worker( + lambda: self.model.active_flow.flow.undo(int(self.undo_steps.value)), + thread=True + ) + self.log_view.write(f'[bold green]Undo "{active_flow.name}" flow...[/bold green]') + except: + self.log_view.write("[bold red]Studio Error:[/bold red] Could not execute undo command.") + plugin = P() diff --git a/src/studio/view.py b/src/studio/view.py index 037fad5..3e98476 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -360,7 +360,9 @@ def compose(self) -> ComposeResult: # yield Spacer() yield Button("Run", id="btn-run", classes="action-btn green", compact=True) yield Label("| ", classes="gray") - yield Button("clear", id="btn-clear", classes="action-btn orange", compact=True) + yield Button("Undo", id="btn-undo", classes="action-btn orange", compact=True) + yield Label("| ", classes="gray") + yield Button("clear", id="btn-clear", classes="action-btn red", compact=True) # Code Editor self.code_editor_text_area: TextArea = TextArea.code_editor( diff --git a/tests/example-studio-project/ca.flow b/tests/example-studio-project/ca.flow index 61fed7b..5435ccc 100644 --- a/tests/example-studio-project/ca.flow +++ b/tests/example-studio-project/ca.flow @@ -1,4 +1,5 @@ // Rule 30 +@init("A" * 10 + "B" + 10 * "A"); @import(ca.fp); // define the rules diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow index df1ab51..50c811d 100644 --- a/tests/example-studio-project/sss.flow +++ b/tests/example-studio-project/sss.flow @@ -7,5 +7,4 @@ A -> ABA; // Evolve n times //@clear(); -//@undo(5); @evolve(1); diff --git a/tests/test0.py b/tests/test0.py index e69de29..d61f2bf 100644 --- a/tests/test0.py +++ b/tests/test0.py @@ -0,0 +1,3 @@ +a = {1, 4, 3} +b = sorted(a) +print(b) From 74b7f45403b698e42718ea346c7facf25fb47b97 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Sat, 11 Apr 2026 02:53:55 -0400 Subject: [PATCH 26/31] Added type hints to the signals in the lang Rules. --- src/lang/implementation.py | 10 +++++----- tests/example-studio-project/plugins/enum.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 tests/example-studio-project/plugins/enum.py diff --git a/src/lang/implementation.py b/src/lang/implementation.py index c830126..95a9f88 100644 --- a/src/lang/implementation.py +++ b/src/lang/implementation.py @@ -9,7 +9,7 @@ Future Considerations: - We will need to create different implementations for higher dimensions spaces. """ -from typing import Sequence, NamedTuple, Literal, cast, Iterator +from typing import Sequence, NamedTuple, Literal, cast, Iterator, Self from copy import deepcopy, copy from core.numlib import INF from core.signals import Signal @@ -36,12 +36,12 @@ class Target(NamedTuple): class BaseRule(RuleABC): # ======== Signals ======== # NOTE: time.sleep() can be used by the client to pause flow execution temporally (or play notes, etc.). - on_applied: Signal = Signal() # if the apply() function was called. The modified spaces are passed as Sequence[DeltaSpace] so that the client can test if the rule was effective. + on_applied: Signal[Self, Sequence[DeltaSpace]] = Signal() # if the apply() function was called. The modified spaces are passed as Sequence[DeltaSpace] so that the client can test if the rule was effective. # the three following rules get the RuleMatch along with idx of the current match passed as arguments to the client. - on_execution: Signal = Signal() - on_branch: Signal = Signal() - on_conflict: Signal = Signal() + on_execution: Signal[Self, RuleMatch, int] = Signal() + on_branch: Signal[Self, RuleMatch, int] = Signal() + on_conflict: Signal[Self, RuleMatch, int] = Signal() FLAG_ALIAS: dict[str, str] = { # IMPORTANT!!!: these must be kept up-to-date with the actual attributes. diff --git a/tests/example-studio-project/plugins/enum.py b/tests/example-studio-project/plugins/enum.py new file mode 100644 index 0000000..66f262e --- /dev/null +++ b/tests/example-studio-project/plugins/enum.py @@ -0,0 +1,20 @@ +# Textual Imports +from textual.widgets import Collapsible, TabPane, Label + +# Standard Imports +from typing import Iterator +from studio.model import Plugin + + +class P(Plugin): + def on_initialized(self) -> None: + self.name = 'Enum' + + def panel(self) -> TabPane | None: + return TabPane(self.name.title(), Label('Content would go here when this plugin is developed.')) + + def controls(self) -> Iterator[Collapsible]: + return iter([]) + + +plugin = P() From b182314ce1a7ad46faae24df3d6f7bccc0037b95 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Sat, 11 Apr 2026 21:39:33 -0400 Subject: [PATCH 27/31] Removed the multiple Flow sessions feature as it adds unnecessary maintenance cost and complexity. Later a plugin can be developed with far more features for flow persistence and session management. Its important that we avoid feature creep on the core studio code. --- src/core/signals.py | 2 + src/studio/model.py | 88 +++-------- src/studio/stdplgns/explore.py | 29 ++-- src/studio/stdplgns/run.py | 27 ++-- src/studio/view.py | 228 ++++++++------------------- tests/example-studio-project/ca.flow | 1 + 6 files changed, 125 insertions(+), 250 deletions(-) diff --git a/src/core/signals.py b/src/core/signals.py index 0fe17d1..69728ce 100644 --- a/src/core/signals.py +++ b/src/core/signals.py @@ -43,6 +43,8 @@ def disconnect(self, c: Callable[..., Any]) -> None: if __name__ == "__main__": t: Signal[str, str] = Signal() + t1: Signal = Signal() + t1.emit() # highlighting bug... f1 = lambda a, b: print(a) f2 = lambda a, b, c: print(a, b) t.connect(f1) diff --git a/src/studio/model.py b/src/studio/model.py index 2ffbeac..6869418 100644 --- a/src/studio/model.py +++ b/src/studio/model.py @@ -4,7 +4,6 @@ from abc import ABC, abstractmethod from textual.widgets import TabPane from textual.widget import Widget -from copy import deepcopy # used for dynamic imports and path management from pathlib import Path @@ -20,37 +19,6 @@ class EditorScreen(object): pass # must define due to reference in type casting -class Flow: - """ - Represents a flow instance session (includes API for interacting with .flow files on the disc). - """ - def __init__(self) -> None: - # Metadata - self.name: str = "" - self.file_path: Path | None = None - self._edit_hash: int = 0 # used to check if some text has already been saved... - - # Flow State - self.flow: FlowLangBase = FlowLang() - - def write_file(self, text: str) -> bool: - """Writes to the file and returns True if the file was written to.""" - if self.file_path and self._edit_hash != (eh:=hash(text)): - self.file_path.write_text(text) - self._edit_hash = eh - return True - return False - - def read_file(self) -> str | None: - if self.file_path: - self._edit_hash = hash(text:=self.file_path.read_text()) - return text - return None - - def open_file(self, path: Path | None): - self.file_path = path - - class Model: """ The source of truth for the application state (a Singleton Pattern). @@ -60,11 +28,14 @@ class Model: def __init__(self, name: str, project_path: Path, view: EditorScreen) -> None: """Name and project path are passed to initiate the model. The textual app is simply passed as a reference so that plugins maintain access to it.""" - # ======== Basic Project Config ======== + # ======== Project Attributes ======== self.project_name: str = name # name the user has given the project self.project_path: Path = project_path + self.flow_path: Path | None = None + self._edit_hash: int = 0 # used to check if some text has already been saved... + self.flow: FlowLangBase = FlowLang() - # ======== View Hook ======== + # ======== View Hook (for access through model/plugin) ======== self.view: EditorScreen = view # ======== Plugins ======== @@ -93,43 +64,26 @@ def __init__(self, name: str, project_path: Path, view: EditorScreen) -> None: obj._view = self.view self.plugins.append(obj) - # ======== Active Flows ======== - self.flows: list[Flow] = [] - self.active_flow: Optional[Flow] = None - - # add default flow - self.flows.append(_:=Flow()) - _.name = "Root" - self.active_flow = _ - # ======== Initialize any children models (plugins) ======== for p in self.plugins: p.on_initialized() - def get_flow_options(self) -> list[str]: - """ - Returns list of tuples formatted for a Textual Select widget. - """ - return [f.name for f in self.flows] - - def create_new_flow(self, name: str, branch_from_current: bool) -> bool: - """Create a new Flow object and return True if the flow was created, otherwise False.""" - if name in (f.name for f in self.flows): - return False - self.flows.append(_:=( - deepcopy(self.active_flow) if self.active_flow and branch_from_current else Flow() - )) - _.name = name - self.active_flow = _ - return True - - def delete_selected_flow(self) -> None: - new_flow_idx: int = self.flows.index(self.active_flow) - 1 - self.flows.remove(self.active_flow) - if len(self.flows) > 0: - self.active_flow = self.flows[new_flow_idx] - else: - self.active_flow = None + def write_file(self, text: str) -> bool: + """Writes to the file and returns True if the file was written to.""" + if self.flow_path and self._edit_hash != (eh:=hash(text)): + self.flow_path.write_text(text) + self._edit_hash = eh + return True + return False + + def read_file(self) -> str | None: + if self.flow_path: + self._edit_hash = hash(text:=self.flow_path.read_text()) + return text + return None + + def open_file(self, path: Path | None): + self.flow_path = path # ================ Plugin Support ================ diff --git a/src/studio/stdplgns/explore.py b/src/studio/stdplgns/explore.py index 6adedb4..0460523 100644 --- a/src/studio/stdplgns/explore.py +++ b/src/studio/stdplgns/explore.py @@ -1,6 +1,7 @@ # Textual Imports from rich.text import Text -from textual.widgets import Collapsible, TabPane, Input, Checkbox, Label, DataTable as _DataTable, SelectionList +from textual.widgets import (Collapsible, TabPane, Input, Select, + Checkbox, Label, DataTable as _DataTable, SelectionList, ContentSwitcher) from textual.widgets.data_table import CellKey from textual.widget import Widget from textual.coordinate import Coordinate @@ -9,7 +10,7 @@ from textual.events import MouseMove # Standard Imports -from typing import Iterator, Sequence +from typing import Iterator, Sequence, cast from core.numlib import INF, str_to_num, is_infinity from core.engine import Event as FlowEvent, SpaceState, Cell as FlowCell, DeltaCell, DeltaSpace, DeltaSpaces from core.prettier import SpaceStateStringFormatter @@ -61,7 +62,6 @@ class RulesetDashboard(VerticalScroll): RulesetDashboard Horizontal { height: auto; width: 100%; - margin-bottom: 1; } RulesetDashboard Vertical { width: 1fr; @@ -86,10 +86,12 @@ def on_initialized(self) -> None: self._columns_control_bitmap: list[bool] = [True, False, False, False, False, False] # connect model signals - self.model.active_flow.flow.on_evolved_n.connect(self.on_evolved) - self.model.active_flow.flow.on_undone_n.connect(self.on_undo) - self.model.active_flow.flow.on_clear.connect(self.on_clear) - self.model.active_flow.flow.on_ruleset_set.connect(lambda f: self.cft(self._rebuild_ruleset_table, f.ruleset.rules, self.ruleset_table)) + self.model.flow.on_evolved_n.connect(self.on_evolved) + self.model.flow.on_undone_n.connect(self.on_undo) + self.model.flow.on_clear.connect(self.on_clear) + self.model.flow.on_ruleset_set.connect( + lambda f: self.cft(self._rebuild_ruleset_table, f.ruleset.rules, self.ruleset_table) + ) # connect view signals self.view.sig_input_submit.connect(self.handle_input_submit) @@ -228,6 +230,8 @@ def handle_checkbox_change(self, e: Checkbox.Changed): self.hover_ruleset_enabled.disabled = not e.value if not e.value: self.hover_ruleset_enabled.value = self.hovered_ruleset_container.display = False + self._reset_hovered_info_label() + self._cell_ids_to_highlight = frozenset() if _id == 'hover-ruleset-enabled' and self.hover_explorer.value: self.hovered_ruleset_container.display = e.value if not e.value: @@ -237,7 +241,7 @@ def handle_checkbox_change(self, e: Checkbox.Changed): def panel(self) -> TabPane | None: # Ruleset Table - self.ruleset_table = _DataTable(id='hovered-ruleset-table', show_cursor=False) + self.ruleset_table = _DataTable(id='ruleset-table', show_cursor=False) self.ruleset_table.add_columns('Selector', 'Target', 'Type', 'Group') self.ruleset_container = Vertical( Label('[bold] Active Ruleset Table [/bold]'), @@ -245,7 +249,7 @@ def panel(self) -> TabPane | None: ) # Ruleset Table - self.hovered_ruleset_table = _DataTable(id='ruleset-table', show_cursor=False) + self.hovered_ruleset_table = _DataTable(id='hovered-ruleset-table', show_cursor=False) self.hovered_ruleset_table.add_columns('Selector', 'Target', 'Type', 'Group') self.hovered_ruleset_container = Vertical( Label('[bold] Hovered Event Ruleset Table [/bold]'), @@ -263,6 +267,7 @@ def panel(self) -> TabPane | None: self.ruleset_container, self.hovered_ruleset_container ), + Label('[bold] Evolution Table [/bold]'), self.data_table ) ) @@ -292,7 +297,7 @@ def _handle_styling_update(self): ) # noinspection PyTypeChecker - self._rebuild_ruleset_table(self.model.active_flow.flow.ruleset.rules, self.ruleset_table) + self._rebuild_ruleset_table(self.model.flow.ruleset.rules, self.ruleset_table) self._rebuild_rows() def _reset_hovered_info_label(self): @@ -342,7 +347,7 @@ def reset_highlighted(): column_idx: int = int(cell_key.column_key.value) # grab all relevant information about the selected space - flow: FlowLangBase = self.model.active_flow.flow + flow: FlowLangBase = self.model.flow event: FlowEvent = flow.events[row_idx] spaces: tuple[tuple[DeltaSpaces, DeltaSpace, SpaceState], ...] = tuple(event.spaces_with_metadata) space_state: SpaceState = spaces[column_idx][2] @@ -513,7 +518,7 @@ def _rebuild_rows(self) -> None: dt = self.data_table old_x, old_y = dt.scroll_x, dt.scroll_y dt.clear() - for event in self.model.active_flow.flow.events[a:b + (1 if b > 0 else 0)]: + for event in self.model.flow.events[a:b + (1 if b > 0 else 0)]: self._add_row(event) self._refresh_column_widths() dt.scroll_to(x=old_x, y=old_y, animate=False) diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index 8326ee0..aa483a7 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -1,4 +1,6 @@ # Textual Imports +from idlelib.outwin import file_line_pats + from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button, ProgressBar, Label, RichLog from textual.widget import Widget from textual.containers import ScrollableContainer, Horizontal @@ -93,7 +95,7 @@ def handle_btn_press(self, e: Button.Pressed): elif btn == 'btn-undo': self.execute_undo() elif btn == 'btn-clear': - self.model.active_flow.flow.clear_evolution() + self.model.flow.clear_evolution() elif btn == 'clear-log': self.log_view.clear() self.log_view.write(f"[bold green] --- Log Cleared --- [/bold green]") @@ -129,14 +131,14 @@ def _handle_progress_updates(self, f: FlowLangBase) -> None: # time.sleep(0.5) def _execute(self) -> None: - # use self.cft to be thread-safe (according to docs on Workers) + # use self.cft to be thread-safe on textual side (according to docs on Workers) if self.mem_profile.value: mem_start = self._process.memory_info().rss / 1024 / 1024 start_time = time.perf_counter() # execute the FlowLang try: - self.model.active_flow.flow.interpret(self.view.code_editor_text_area.text) + self.model.flow.interpret(self.view.code_editor_text_area.text) except Exception as e: # Handle the exception if self.show_traceback.value: @@ -166,9 +168,9 @@ def _execute(self) -> None: def execute_run(self) -> None: """Handles the flow execution and updates the UI components.""" - active_flow = self.model.active_flow - if not active_flow: - self.log_view.write("[bold red]Studio Error:[/bold red] No active flow selected to run.") + flow_path = self.model.flow_path + if not flow_path: + self.log_view.write("[bold red]Studio Error:[/bold red] No flow selected to run.") return if self._running_thread and self._running_thread.is_running: # do not run while thread is active self.log_view.write("[bold red]Studio Error:[/bold red] A flow thread is currently running.") @@ -177,24 +179,25 @@ def execute_run(self) -> None: self._execute, thread=True ) - self.log_view.write(f'[bold green]Run "{active_flow.name}" flow...[/bold green]') + self.log_view.write(f'[bold green]Run {flow_path.name}...[/bold green]') # TODO: maybe more info will be logged at some point (if deemed useful) def execute_undo(self) -> None: """Handles the flow undo and updates the UI components.""" - active_flow = self.model.active_flow - if not active_flow: - self.log_view.write("[bold red]Studio Error:[/bold red] No active flow selected to undo.") + flow_path = self.model.flow_path + if not flow_path: + self.log_view.write("[bold red]Studio Error:[/bold red] No flow selected to undo.") return if self._running_thread and self._running_thread.is_running: # do not run while thread is active self.log_view.write("[bold red]Studio Error:[/bold red] A flow thread is currently running.") return try: + steps: int = int(self.undo_steps.value) self._running_thread = self.view.run_worker( - lambda: self.model.active_flow.flow.undo(int(self.undo_steps.value)), + lambda: self.model.flow.undo(steps), thread=True ) - self.log_view.write(f'[bold green]Undo "{active_flow.name}" flow...[/bold green]') + self.log_view.write(f'[bold green]Undo last {steps} steps...[/bold green]') except: self.log_view.write("[bold red]Studio Error:[/bold red] Could not execute undo command.") diff --git a/src/studio/view.py b/src/studio/view.py index 3e98476..f40d00a 100644 --- a/src/studio/view.py +++ b/src/studio/view.py @@ -222,6 +222,7 @@ def handle_modal_result(result: dict): self.notify('Please enter a valid path to a directory.', severity='error') return try: + # noinspection PyUnresolvedReferences self.query_one("#recents-list").add_option(Option(f'{name} [grey]({path})[/grey]', name)) config.RecentProjects.add(name, path) self.notify(f"Loaded project at: {path}") @@ -292,54 +293,6 @@ class EditorScreen(Screen): ("ctrl+shift+f1", "toggle_max", "Toggle Max"), ] - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # ==== Signals ==== - self.sig_button_pressed: Signal[Button.Pressed] = Signal() - self.sig_checkbox_changed: Signal[Checkbox.Changed] = Signal() - self.sig_input_submit: Signal[Input.Changed] = Signal() - self.sig_selection_list_toggled: Signal[SelectionListToggled] = Signal() - self.sig_save_config_directive: Signal = Signal() - - @on(Button.Pressed) - def _emit_button_signals(self, event: Button.Pressed) -> None: - """Handle emitting the button pressed signal""" - self.sig_button_pressed.emit(event) - - @on(Checkbox.Changed) - def _emit_checkbox_signals(self, event: Checkbox.Changed) -> None: - """Handle emitting the checkbox changed signal""" - self.sig_checkbox_changed.emit(event) - - @on(Input.Submitted) - def _emit_input_submit_signals(self, event: Input.Changed) -> None: - """Handle emitting the input changed signal""" - self.sig_input_submit.emit(event) - - @on(SelectionList.SelectionToggled) - def _emit_selection_list_toggled(self, event: SelectionList.SelectionToggled) -> None: - """Handle emitting the selection list toggled signal""" - self.sig_selection_list_toggled.emit(event) - - def on_mount(self) -> None: - self.__refresh_flow_selector__() - - def __refresh_flow_selector__(self) -> None: - """Call to refresh the flow selector.""" - # load flows - m: model.Model = self.app.MODEL - ol: Select = self.query_one("#select-flow") - ol.set_options([(f.name, i) for i, f in enumerate(m.flows)]) - try: - # noinspection PyProtectedMember - ol._init_selected_option(m.flows.index(m.active_flow)) # select the first option - except: pass - - def action_run(self): - """Action to press the run button upon this action...""" - self.query_one('#btn-run').press() - def compose(self) -> ComposeResult: # --- LEFT COLUMN: Project Files --- with Vertical(id="project-directory"): @@ -351,13 +304,9 @@ def compose(self) -> ComposeResult: with Vertical(id="workspace"): # Top Toolbar with Horizontal(id='workspace-toolbar'): - yield Label("▼ ", classes='gray') - yield Select((), id="select-flow", compact=True) - yield Button('+', id="btn-add-flow", compact=True, classes='increment-btn green') - yield Button('-', id="btn-sub-flow", compact=True, classes='increment-btn red') + self.open_file_label = Label("No Open File", classes='gray') + yield self.open_file_label yield Spacer() - # yield Label("No Open File", classes='gray') - # yield Spacer() yield Button("Run", id="btn-run", classes="action-btn green", compact=True) yield Label("| ", classes="gray") yield Button("Undo", id="btn-undo", classes="action-btn orange", compact=True) @@ -393,7 +342,41 @@ def compose(self) -> ComposeResult: # --- Footer --- yield Footer() - # --- ACTION HANDLERS --- + # ==== Panel and Controls ==== + def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated): + """Dynamically switches the Right Sidebar content AND Title.""" + container: ContentSwitcher = self.query_one('#sidebar-switcher') + container.current = event.pane.id + # noinspection PyProtectedMember + self.query_one('#plugin-controls-header').content = f"⭘ {event.pane._title}" + + # ==== File Manager ==== + def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): + m: model.Model = self.app.MODEL + if not event.path.exists(): + self.notify("That file no longer exists!", severity="error") + self.query_one(DirectoryTree).reload() + return + self.action_save_file() + m.open_file(event.path) + self.code_editor_text_area.text = m.read_file() + self.open_file_label.content = event.path.name + + @on(Button.Pressed, '#btn_refresh_project_dir') + def btn_refresh_project_dir(self): + self.query_one(DirectoryTree).reload() + self.notify(f"Refreshed Project Directory...") + + # ==== Action Handlers ==== + def action_run(self): + """Action to press the run button upon this action...""" + self.query_one('#btn-run').press() + + def action_save_file(self): + m: model.Model = self.app.MODEL + if m.write_file(self.code_editor_text_area.text): + self.notify(f"Saved the \"{m.flow_path.name}\" file.") + def action_toggle_left_sidebar(self): sidebar = self.query_one("#project-directory") sidebar.display = not sidebar.display @@ -418,116 +401,42 @@ def action_toggle_max(self): else: self.maximize(self.focused) - # ==== Top Bar ==== - @on(Button.Pressed, '#btn-add-flow') - def btn_add_flow(self): - def handle_modal_result(result: dict) -> None: - if result["pressed_button"] == "Cancel": - return - if not result["input"]["flow_name"]: - self.notify("A new flow must be given a name!", severity="error") - return - if not self.app.MODEL.create_new_flow(result["input"]["flow_name"], result["checkbox"]["branch_checkbox"]): - self.notify(f"A flow with name \"{result["input"]["flow_name"]}\" already exists.", severity="error") - return - self.__refresh_flow_selector__() - self.notify(f"Created the \"{self.app.MODEL.active_flow.name}\" flow session...") - - # Push the screen with the configuration and callback - self.app.push_screen( - ModalDialog( - title="Flow Creation Tool", - fields=[ - { - "type": "note", - "text": "Create a new flow session and optionally branch it from the current flow session." - }, - { - "type": "input", - "prompt": "New Flow Name", - "placeholder": "e.g. Flow1", - "id": "flow_name" - }, - { - "type": "checkbox", - "label": "Branch from current flow session", - "id": "branch_checkbox", - } - ], - buttons=["Create", "Cancel"] - ), - callback=handle_modal_result - ) + # ==== Initial Setup and Signal Connections ==== + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - @on(Button.Pressed, '#btn-sub-flow') - def btn_remove_flow(self): - if not self.app.MODEL.active_flow: - self.notify("Empty flow sessions!", severity="error") - return - def handle_modal_result(result: dict): - if result.get("pressed_button") == "Yes": - self.notify(f"Deleted the \"{self.app.MODEL.active_flow.name}\" flow session...") - self.app.MODEL.delete_selected_flow() - self.__refresh_flow_selector__() - self.app.push_screen( - ModalDialog( - title="Delete Flow?", - fields=[ - { - "type": "note", - "text": "Please confirm flow deletion..." - } - ], - buttons=["Yes", "No", "Cancel"] - ), - callback=handle_modal_result - ) + # ==== Signals ==== + self.sig_button_pressed: Signal[Button.Pressed] = Signal() + self.sig_checkbox_changed: Signal[Checkbox.Changed] = Signal() + self.sig_input_submit: Signal[Input.Changed] = Signal() + self.sig_selection_list_toggled: Signal[SelectionList.SelectionToggled] = Signal() + self.sig_select_changed: Signal[Select.Changed] = Signal() + self.sig_save_config_directive: Signal = Signal() - @on(Select.Changed, '#select-flow') - def select_flow(self, event: Select.Changed): - # Do not save the file here because of race condition with overwriting the previous file opened when flow is deleted. - m: model.Model = self.app.MODEL - if isinstance(event.value, int): - m.active_flow = m.flows[event.value] - self.code_editor_text_area.text = m.active_flow.read_file() - else: - m.active_flow = None - self.code_editor_text_area.text = None + @on(Button.Pressed) + def _emit_button_signals(self, event: Button.Pressed) -> None: + """Handle emitting the button pressed signal""" + self.sig_button_pressed.emit(event) - # ==== Panel and Controls ==== - def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated): - """Dynamically switches the Right Sidebar content AND Title.""" - container: ContentSwitcher = self.query_one('#sidebar-switcher') - container.current = event.pane.id - # noinspection PyProtectedMember - self.query_one('#plugin-controls-header').content = f"⭘ {event.pane._title}" + @on(Checkbox.Changed) + def _emit_checkbox_signals(self, event: Checkbox.Changed) -> None: + """Handle emitting the checkbox changed signal""" + self.sig_checkbox_changed.emit(event) - # ==== File Manager ==== - def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): - m: model.Model = self.app.MODEL - if not m.active_flow: - self.notify("A flow session must be selected first!", severity="warning") - return - if not event.path.exists(): - self.notify("That file no longer exists!", severity="error") - self.query_one(DirectoryTree).reload() - return - self.action_save_file() - m.active_flow.open_file(event.path) - self.code_editor_text_area.text = m.active_flow.read_file() + @on(Input.Submitted) + def _emit_input_submit_signals(self, event: Input.Changed) -> None: + """Handle emitting the input changed signal""" + self.sig_input_submit.emit(event) - def action_save_file(self): - m: model.Model = self.app.MODEL - f: model.Flow = m.active_flow - if f is None: - return - if f.write_file(self.code_editor_text_area.text): - self.notify(f"Saved the \"{f.file_path.name}\" file.") + @on(SelectionList.SelectionToggled) + def _emit_selection_list_toggled(self, event: SelectionList.SelectionToggled) -> None: + """Handle emitting the selection list toggled signal""" + self.sig_selection_list_toggled.emit(event) - @on(Button.Pressed, '#btn_refresh_project_dir') - def btn_refresh_project_dir(self): - self.query_one(DirectoryTree).reload() - self.notify(f"Refreshed Project Directory...") + @on(Select.Changed) + def _emit_select_changed(self, event: Select.Changed) -> None: + """Handle emitting the select changed signal""" + self.sig_select_changed.emit(event) class Main(App): @@ -558,6 +467,7 @@ def handle_modal_result(result: dict): # noinspection PyUnresolvedReferences self.screen.action_save_file() if result["checkbox"]["save_config"]["value"]: + # noinspection PyTypeChecker self.editor_screen.sig_save_config_directive.emit() self.exit() # Push the screen with the configuration and callback diff --git a/tests/example-studio-project/ca.flow b/tests/example-studio-project/ca.flow index 5435ccc..b81b0ab 100644 --- a/tests/example-studio-project/ca.flow +++ b/tests/example-studio-project/ca.flow @@ -6,4 +6,5 @@ @decode(wns, AB, 30); // Run n times +// @clear(); @evolve(10); From ff275ea5f7b4c1183761d9e68bbc9fddac9649fe Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Sat, 11 Apr 2026 22:33:50 -0400 Subject: [PATCH 28/31] Reorganized and added some docstrings. --- src/core/prettier.py | 2 +- src/icat/__init__.py | 0 src/implementations/__init__.py | 5 +++++ src/integrations/__init__.py | 3 +++ src/{core => integrations}/enumerator.py | 4 ++++ src/integrations/icat/__init__.py | 4 ++++ src/{ => integrations}/icat/core.py | 0 src/lang/parser.py | 2 +- src/studio/stdplgns/analysis.py | 2 ++ {src/implementations => tests}/game_of_life.py | 0 10 files changed, 20 insertions(+), 2 deletions(-) delete mode 100644 src/icat/__init__.py rename src/{core => integrations}/enumerator.py (96%) create mode 100644 src/integrations/icat/__init__.py rename src/{ => integrations}/icat/core.py (100%) rename {src/implementations => tests}/game_of_life.py (100%) diff --git a/src/core/prettier.py b/src/core/prettier.py index fb575a4..231c862 100644 --- a/src/core/prettier.py +++ b/src/core/prettier.py @@ -88,7 +88,7 @@ def convert_pure_str(self, string: str) -> Text: return Text(end='').join(rm.get(str(c), Text(str(c), end='')) for c in string) if __name__ == "__main__": - from src.implementations.sss import SSS + from implementations.sss import SSS from rich.console import Console # run your simulation diff --git a/src/icat/__init__.py b/src/icat/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/implementations/__init__.py b/src/implementations/__init__.py index e69de29..6061f83 100644 --- a/src/implementations/__init__.py +++ b/src/implementations/__init__.py @@ -0,0 +1,5 @@ +"""This module provides core implementations of specific types of systems such as sequential substitution systems +or cellular automata. It uses the core tools and constructs (strictly) to provide implementations for such systems. +A system inherits from the Flow object and utilizes all the various constructs such RuleMatches and Cells to +achieve desired behavior. +""" diff --git a/src/integrations/__init__.py b/src/integrations/__init__.py index e69de29..f10487c 100644 --- a/src/integrations/__init__.py +++ b/src/integrations/__init__.py @@ -0,0 +1,3 @@ +"""This module is for providing integrations with external tools or frameworks. Examples could be Gephi, Wolfram Alpha, +Manim, and many other tools. It provides various tools and constructs for such integrations. +It can also provide other tools such as our own enumeration implementations, icat tools, etc.""" diff --git a/src/core/enumerator.py b/src/integrations/enumerator.py similarity index 96% rename from src/core/enumerator.py rename to src/integrations/enumerator.py index cbc782d..79be83c 100644 --- a/src/core/enumerator.py +++ b/src/integrations/enumerator.py @@ -1,3 +1,7 @@ +""" +This code currently contains rudimentary enumeration decoder tools for rulesets. Later we will want to +generalize this into framework of tools for this type of tooling. +""" import math diff --git a/src/integrations/icat/__init__.py b/src/integrations/icat/__init__.py new file mode 100644 index 0000000..b0e7500 --- /dev/null +++ b/src/integrations/icat/__init__.py @@ -0,0 +1,4 @@ +""" +Provides useful tools as a result of the I-Cat research. This module will likely be separated into its own framework +outside RuleFlow. +""" diff --git a/src/icat/core.py b/src/integrations/icat/core.py similarity index 100% rename from src/icat/core.py rename to src/integrations/icat/core.py diff --git a/src/lang/parser.py b/src/lang/parser.py index e0dffcc..6e1b1c8 100644 --- a/src/lang/parser.py +++ b/src/lang/parser.py @@ -1,4 +1,4 @@ -from core import enumerator +from integrations import enumerator from lark import Lark, Transformer from core.numlib import str_to_num, INF from typing import Any, cast diff --git a/src/studio/stdplgns/analysis.py b/src/studio/stdplgns/analysis.py index b6e14a2..803f3e7 100644 --- a/src/studio/stdplgns/analysis.py +++ b/src/studio/stdplgns/analysis.py @@ -38,4 +38,6 @@ def controls(self) -> Iterator[Widget]: with Collapsible(title='VisJS Network Viewer', collapsed=True): yield Label("Options for the web \ngraph renderer.") + + plugin = P() diff --git a/src/implementations/game_of_life.py b/tests/game_of_life.py similarity index 100% rename from src/implementations/game_of_life.py rename to tests/game_of_life.py From b66cdde52cccead275f81132fcf9d8981380bdb1 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Sun, 12 Apr 2026 08:40:14 -0400 Subject: [PATCH 29/31] Added the Analysis standard plugin. --- src/core/graph.py | 92 ++---- src/studio-plugins/{ => gephi}/gephi-live.py | 0 src/studio-plugins/icat.py | 0 src/studio-plugins/{ => llm}/_llm_module.py | 0 src/studio-plugins/sessie-enum.py | 0 .../sessie/enum.py} | 0 src/studio-plugins/{ => tunes}/tunes.py | 0 src/studio/stdplgns/analysis.py | 309 ++++++++++++++++-- src/studio/stdplgns/explore.py | 23 +- src/studio/stdplgns/run.py | 7 +- src/studio/styles.tcss | 5 + tests/example-studio-project/plugins/enum.py | 20 -- .../example-studio-project/plugins/example.py | 21 ++ tests/example-studio-project/sss.flow | 2 +- 14 files changed, 359 insertions(+), 120 deletions(-) rename src/studio-plugins/{ => gephi}/gephi-live.py (100%) delete mode 100644 src/studio-plugins/icat.py rename src/studio-plugins/{ => llm}/_llm_module.py (100%) delete mode 100644 src/studio-plugins/sessie-enum.py rename src/{__init__.py => studio-plugins/sessie/enum.py} (100%) rename src/studio-plugins/{ => tunes}/tunes.py (100%) delete mode 100644 tests/example-studio-project/plugins/enum.py create mode 100644 tests/example-studio-project/plugins/example.py diff --git a/src/core/graph.py b/src/core/graph.py index 76d79c1..fcc7211 100644 --- a/src/core/graph.py +++ b/src/core/graph.py @@ -1,67 +1,33 @@ -"""Provides all the tools for optimized and rigorous graph analysis.""" -from core.engine import Flow -from networkx import MultiDiGraph, write_gexf -from pyvis.network import Network - - -class CausalGraph(MultiDiGraph): - def __init__(self, flow: Flow) -> None: - super().__init__() - self.flow: Flow = flow - - # TODO - maybe add option to collapse edges. - # TODO: make it so that it can be dynamically adjusted as Flow evolves. - # construct causal graph - because each node is literally the time, and thus index, it can be used to query to the actual event for more granular information. - for event in flow.events: - # if event.time == 0: continue # Skip the initial event (no parents) - self.add_node(event.time, label=f'{event.time}', title=f'Causal Distance: {event.causal_distance_to_creation}\nSpace: {event}', shape='box') # add the node - for parent_time in event.causally_connected_events: - if parent_time is not None: - self.add_edge(parent_time, event.time) +"""Provides all the tools for optimized and rigorous graph analysis. - @property - def adjacency_matrix(self): - return None +FRAMEWORK NOTES: +- use pyvis Network to render interactive graphs. - @property - def dijkstra_algorithm(self): - return None - - # same as the the glxl file... - def save_to_gephi_file(self, path: str) -> None: - """Save to the GLXL file format""" - write_gexf(self, path) - - def render_in_browser(self, filename: str = "causal_network.html", - show_controls: list[str] | bool | None = None, - shape: str = 'box', - show_state: bool = False, - show_label: bool = True, - show_distance: bool = True, - collapse_edges: bool = False): - """ - Uses pyvis to render the causal network from a Flow object. - """ +TODO: +- Redesign to support live graph updating (to keep up to date with flow). +- Add more tools for seamless analysis and integrations. +""" +from core.engine import Flow +from networkx import MultiDiGraph +from typing import Sequence, Self - net = Network(height="800px", width="100%", directed=True, filter_menu=True, select_menu=True, cdn_resources='remote') - net.from_nx(self) - if show_controls: - net.show_buttons(filter_=show_controls) - else: - net.set_options(""" - { - "physics": { - "minVelocity": 0.75 - }, - "interaction": { - "navigationButtons": true - } - } - """) - try: - net.save_graph(filename) - print(f"Successfully generated '{filename}'!") - print("Open this file in your browser to view the interactive graph.") - except Exception as e: - print(f"Error generating graph: {e}") +class EventCausalityGraph(MultiDiGraph): + def build(self, flow: Flow, + event_range: tuple[int, int, int], + collapse_multi_edges: bool = False) -> Self: + # construct causal graph - because each node is literally the time, and thus index, it can be used to query to the actual event for more granular information. + connected_container: type[tuple] | type[set] = set if collapse_multi_edges else tuple + for event in flow.events[event_range[0]:event_range[1]+1:event_range[2]]: + causally_connected: Sequence[int] = connected_container(event.causally_connected_events) + self.add_node( + event.time, + # these get passed on to the nodes of VisJS network. + label=f'{event.time}', + title=f' Causal Distance: {event.causal_distance_to_creation}\n' + f'Connected Events: {len(causally_connected)}', + shape='box' + ) + for parent_time in causally_connected: + self.add_edge(parent_time, event.time) + return self diff --git a/src/studio-plugins/gephi-live.py b/src/studio-plugins/gephi/gephi-live.py similarity index 100% rename from src/studio-plugins/gephi-live.py rename to src/studio-plugins/gephi/gephi-live.py diff --git a/src/studio-plugins/icat.py b/src/studio-plugins/icat.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/studio-plugins/_llm_module.py b/src/studio-plugins/llm/_llm_module.py similarity index 100% rename from src/studio-plugins/_llm_module.py rename to src/studio-plugins/llm/_llm_module.py diff --git a/src/studio-plugins/sessie-enum.py b/src/studio-plugins/sessie-enum.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/__init__.py b/src/studio-plugins/sessie/enum.py similarity index 100% rename from src/__init__.py rename to src/studio-plugins/sessie/enum.py diff --git a/src/studio-plugins/tunes.py b/src/studio-plugins/tunes/tunes.py similarity index 100% rename from src/studio-plugins/tunes.py rename to src/studio-plugins/tunes/tunes.py diff --git a/src/studio/stdplgns/analysis.py b/src/studio/stdplgns/analysis.py index 803f3e7..251d910 100644 --- a/src/studio/stdplgns/analysis.py +++ b/src/studio/stdplgns/analysis.py @@ -1,43 +1,302 @@ +""" +This plugin provides access to various analysis features such as causal networks and overall evolution metrics. +""" # Textual Imports -from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button, Label +from textual.widgets import (Collapsible, TabPane, Input, Checkbox, SelectionList, + Button, Label, Sparkline, Select, DataTable) from textual.widget import Widget -from textual.containers import ScrollableContainer +from textual.containers import VerticalScroll +from textual.widgets.selection_list import Selection # Standard Imports from typing import Iterator from studio.model import Plugin +from core.graph import EventCausalityGraph +from core.numlib import str_to_num, INF +from studio.config import USER_DATA_DIR_PATH +from pyvis.network import Network +import networkx as nx +from statistics import fmean +import math class P(Plugin): def on_initialized(self) -> None: self.name = 'analysis' - def panel(self) -> TabPane | None: - return TabPane(self.name.title()) + # tools + self._causal_graph: EventCausalityGraph | None = None + + # signals + self.view.sig_button_pressed.connect(self.handle_button_press) def controls(self) -> Iterator[Widget]: with Collapsible(title='Causal Network', collapsed=False): - self.live_causal_network = Checkbox('Live Mode') - yield self.live_causal_network - self.initial_causal_network_node = Input(type='number', value='0') - self.initial_causal_network_node.border_title = 'Initial Event' - yield self.initial_causal_network_node - self.aggregate_branches = Input() - self.aggregate_branches.border_title = 'Aggregate Branches' - yield self.aggregate_branches - - with Collapsible(title='Branch Graph', collapsed=False): - self.live_branch_network = Checkbox('Live Mode') - yield self.live_branch_network - self.branch_network_range = Input() - self.branch_network_range.border_title = 'Selected Branches' - yield self.branch_network_range - - with Collapsible(title='Growth Metrics', collapsed=False): - yield Label("""Algorithm Controls for \ndetermining network or \nevolution properties such \nas dimension or sparseness.""") - - with Collapsible(title='VisJS Network Viewer', collapsed=True): - yield Label("Options for the web \ngraph renderer.") + yield Button('Build Graph', id='build-graph', variant="primary") + self.causal_network_event_range = Input(':32') + self.causal_network_event_range.border_title = 'Event Range' + yield self.causal_network_event_range + self.collapse_edges = Checkbox('Collapse Edges') + yield self.collapse_edges + yield Label("\nPersistence") + self.export_format = Select( + [("Gephi", 0), ("GraphML", 1), ("Sparse6", 2), ("Graph6", 3), ("GML", 4), ("Adjacency List", 5), ("Multiline Adjacency", 6)], + allow_blank=False + ) + yield self.export_format + yield Button('Export as Format', id='export-graph') + + with Collapsible(title='VisJS Viewer', collapsed=False): + yield Button('View Graph', id='view-graph', variant="primary") + self.vis_html_path = Input(value="auto", placeholder="e.g., auto or /home/test.html") + self.vis_html_path.border_title = 'HTML File Path' + yield self.vis_html_path + self.vis_open_browser = Checkbox('Open Browser', value=True) + yield self.vis_open_browser + + yield Label("\nCanvas Settings") + self.vis_height = Input(value="800px", placeholder="e.g., 800px") + self.vis_height.border_title = 'Height' + yield self.vis_height + self.vis_width = Input(value="100%", placeholder="e.g., 100%") + self.vis_width.border_title = 'Width' + yield self.vis_width + self.vis_bgcolor = Input(value="#ffffff", placeholder="e.g., #ffffff") + self.vis_bgcolor.border_title = 'Background Color' + yield self.vis_bgcolor + self.vis_font_color = Input(value="", placeholder="e.g., #000000") + self.vis_font_color.border_title = 'Font Color' + yield self.vis_font_color + self.vis_heading = Input(value="") + self.vis_heading.border_title = 'Graph Heading' + yield self.vis_heading + self.vis_cdn = Select( + [("Remote", "remote"), ("Local", "local"), ("Inline", "in_line")], + value="remote", + prompt="CDN Resources", + allow_blank=False + ) + yield self.vis_cdn + + yield Label("\nOptions") + self.vis_toggles = SelectionList( + Selection("Directed Edges", "directed", True), + Selection("Neighborhood Highlight", "neighborhood", True), + Selection("Select Menu", "select_menu", False), + Selection("Filter Menu", "filter_menu", False), + Selection("Hierarchy Layout", "layout", False), + ) + yield self.vis_toggles + yield Label("\nUI Filters") + self.show_buttons = Checkbox('Show Buttons', value=False) + yield self.show_buttons + self.vis_buttons_filter = SelectionList( + Selection("All", "all", False), + Selection("Nodes", "nodes", True), + Selection("Edges", "edges", True), + Selection("Layout", "layout", False), + Selection("Interaction", "interaction", True), + Selection("Manipulation", "manipulation", False), + Selection("Physics", "physics", True), + Selection("Selection", "selection", False), + Selection("Renderer", "renderer", False) + ) + yield self.vis_buttons_filter + + with Collapsible(title='Causal Distribution'): + yield Label('Summery Function:') + self.summary_function = Select([('Min', 0), ('Max', 1), ('Mean', 2)], allow_blank=False) + yield self.summary_function + self.evolution_metrics_event_range = Input(':100') + self.evolution_metrics_event_range.border_title = 'Event Range' + yield self.evolution_metrics_event_range + yield Button('Calculate', id='calculate-metrics', variant="primary") + yield Label() + + def handle_button_press(self, e: Button.Pressed): + _id: str = e.button.id + if _id == 'build-graph': + self._update_causal_graph() + elif _id == 'export-graph': + self._export_as_format() + elif _id == 'view-graph': + self._open_vis_js_viewer() + elif _id == 'calculate-metrics': + self._update_evolution_metrics() + + def _open_vis_js_viewer(self) -> None: + if not self._causal_graph: + self.view.notify('No graph has been built yet!', severity='error') + return + + G: nx.MultiDiGraph = self._causal_graph + + # Get the list of selected internal string values + selected = self.vis_toggles.selected + + # Parse text inputs + f_color = self.vis_font_color.value.strip() + f_color_parsed = f_color if f_color else False + + # Layout requires True or None, so we map it from the selection list + layout_parsed = True if "layout" in selected else None + + # Instantiate the VisJS Network + net = Network( + height=self.vis_height.value.strip() or "800px", + width=self.vis_width.value.strip() or "100%", + directed="directed" in selected, + neighborhood_highlight="neighborhood" in selected, + select_menu="select_menu" in selected, + filter_menu="filter_menu" in selected, + bgcolor=self.vis_bgcolor.value.strip() or "#ffffff", + font_color=f_color_parsed, + layout=layout_parsed, + heading=self.vis_heading.value.strip(), + cdn_resources=self.vis_cdn.value + ) + + # Apply the show_buttons filters based on the selection list + if self.show_buttons.value: + filtered_buttons = self.vis_buttons_filter.selected + if "all" in filtered_buttons: + net.show_buttons(filter_=True) + else: + net.show_buttons(filter_=filtered_buttons) + + # Ingest and Render + net.from_nx(G) + try: + if self.vis_html_path.value == 'auto': + html_path = str(USER_DATA_DIR_PATH.joinpath('temp_vis_js.html')) + else: + html_path = self.vis_html_path.value + if not html_path.endswith('.html'): + html_path += '.html' + net.write_html(html_path, open_browser=self.vis_open_browser.value) + self.view.notify("VisJS graph launched in your browser!") + except Exception as e: + self.view.notify(f"Failed to open viewer: {str(e)}", severity="error") + + def _export_as_format(self): + funcs = [nx.write_gexf, nx.write_graphml, nx.write_sparse6, nx.write_graph6, + nx.write_gml, nx.write_adjlist, nx.write_multiline_adjlist] + stems = ['.gexf', '.graphml', '.sparse6', '.graph6', '.gml', '.adjlist', '.multi_adjlist'] + stem: str = stems[self.export_format.value] + path: str = str( + self.model.project_path.joinpath( + self.model.flow_path.name + f'_at_{self.causal_network_event_range.value.replace(':', '_')}' + stem + ) + ) + try: + funcs[self.export_format.value](self._causal_graph, path) + self.view.notify(f'Successfully exported at "{path}"') + except Exception as e: + self.view.notify(f"Failed to export graph: {str(e)}", severity="error") + + def panel(self) -> TabPane | None: + # ==== network metrics ==== + self.causal_graph_metrics = DataTable(show_cursor=False, zebra_stripes=True) + self.causal_graph_metrics.add_columns(('Metric', 'metric'), ('Value', 'value')) + for i, m in enumerate(('Edge-Node Ratio', 'Network Density', + 'Longest DAG Path', 'Degree Assortativity Coefficient', + 'Flow Hierarchy')): + self.causal_graph_metrics.add_row(m, 'N/A', key=str(i)) + + # ==== Causal Distributions ==== + self.distance_distribution = Sparkline() + self.connected_abs_distribution = Sparkline() + self.connected_set_distribution = Sparkline() + return TabPane( + self.name.title(), + VerticalScroll( + Collapsible( + self.causal_graph_metrics, + title='Causal Network Metrics', collapsed=False + ), + Collapsible( + Label('[bold] Causal Distance [/bold]'), + self.distance_distribution, + Label('\n[bold] Connected Total [/bold]'), + self.connected_abs_distribution, + Label('\n[bold] Connected Unique [/bold]'), + self.connected_set_distribution, + title='Causal Distributions', collapsed=True + ), + ) + ) + + def _update_causal_graph(self): + # get the event range + rs: list[str] = self.causal_network_event_range.value.split(':') + if len(rs) == 2: rs.append('') # it must be 3 things + r = ( + int(rs[0]) if rs[0] else 0, + str_to_num(rs[1]) if rs[1] else INF, + abs(int(rs[2])) if rs[2] else 1 + ) + self._causal_graph = EventCausalityGraph().build(self.model.flow, r, self.collapse_edges.value) + self._update_causal_metrics_table(self._causal_graph) + + def _update_causal_metrics_table(self, g: EventCausalityGraph) -> None: + """Calculates causal graph metrics and updates the Textual DataTable.""" + if g.number_of_nodes() == 0: + for i in range(len(self.causal_graph_metrics.rows)): + self.causal_graph_metrics.update_cell(str(i), "value", "N/A") + return + try: + edge_node_ratio = g.number_of_edges() / g.number_of_nodes() + edge_node_ratio_str = f"{edge_node_ratio:.3f}" + except ZeroDivisionError: edge_node_ratio_str = "0.000" + density = nx.density(g) + density_str = f"{density:.5f}" + try: + depth = nx.dag_longest_path_length(g) + depth_str = str(depth) + except nx.NetworkXUnfeasible: depth_str = "Cycle Detected" + try: + assortativity = nx.degree_assortativity_coefficient(g) + if math.isnan(assortativity): + assort_str = "0.000" + else: + assort_str = f"{assortativity:.4f}" + except Exception: assort_str = "0.000" + try: + flow = nx.flow_hierarchy(g) + flow_str = f"{flow:.4f}" + except ZeroDivisionError: flow_str = "0.000" + + # update the cells + for i, s in enumerate((edge_node_ratio_str, density_str, depth_str, assort_str, flow_str)): + self.causal_graph_metrics.update_cell(str(i), "value", s) + + def _update_evolution_metrics(self): + # get the event range + rs: list[str] = self.evolution_metrics_event_range.value.split(':') + if len(rs) == 2: rs.append('') # it must be 3 things + a, b, c = ( + int(rs[0]) if rs[0] else 0, + str_to_num(rs[1]) if rs[1] else INF, + abs(int(rs[2])) if rs[2] else 1 + ) + + # get summary function + f = (min, max, fmean)[self.summary_function.value] + self.distance_distribution.summary_function = f + self.connected_abs_distribution.summary_function = f + self.connected_set_distribution.summary_function = f + + # calculate the data to show + causal_distance_data: list[int] = [] + connected_abs_distance_data: list[int] = [] + connected_set_distance_data: list[int] = [] + for event in self.model.flow.events[a:b+(1 if b > 0 else 0):c]: + causal_distance_data.append(event.causal_distance_to_creation) + connected_abs_distance_data.append(len(_:=tuple(event.causally_connected_events))) + connected_set_distance_data.append(len(set(_))) + self.distance_distribution.data = causal_distance_data + self.connected_abs_distribution.data = connected_abs_distance_data + self.connected_set_distribution.data = connected_set_distance_data plugin = P() diff --git a/src/studio/stdplgns/explore.py b/src/studio/stdplgns/explore.py index 0460523..e798285 100644 --- a/src/studio/stdplgns/explore.py +++ b/src/studio/stdplgns/explore.py @@ -1,3 +1,6 @@ +""" +This plugin provides an exploratory environment for the evolutions of cellular systems. +""" # Textual Imports from rich.text import Text from textual.widgets import (Collapsible, TabPane, Input, Select, @@ -80,7 +83,7 @@ def on_initialized(self) -> None: self._cell_ids_to_highlight: frozenset[int] = frozenset() # attributes - self._render_range: tuple[int, int] = (-100, INF) + self._render_range: tuple[int, int, int] = (-100, INF, 1) self._space_columns_limit: int = 1 self._hidden_space_columns: set[int] = set() self._columns_control_bitmap: list[bool] = [True, False, False, False, False, False] @@ -118,7 +121,7 @@ def controls(self) -> Iterator[Widget]: Selection("Event Indices", 0, control_bits[0]), Selection("Causal Distance", 1, control_bits[1]), Selection("Causally Connected", 2, control_bits[2]), - Selection(" ├─ Collapsed", 3, control_bits[3]), + Selection(" ├─ Unique", 3, control_bits[3]), Selection(" ├─ Sorted", 4, control_bits[4]), Selection(" ╰─ Counted", 5, control_bits[5]), id='column-controls' @@ -168,14 +171,16 @@ def handle_input_submit(self, e: Input.Submitted): if _id == 'render-limit': try: rs: list[str] = e.value.strip().split(':') + if len(rs) == 2: rs.append('') # it must always be 3 things self._render_range = ( - str_to_num(rs[0]) if rs[0] else 0, - str_to_num(rs[1]) if rs[1] else INF + int(rs[0]) if rs[0] else 0, + str_to_num(rs[1]) if rs[1] else INF, + abs(int(rs[2])) if rs[2] else 1 ) self._rebuild_rows() except: self.view.notify('Invalid render range.', severity='warning') - e.input.value = '{0}:{1}'.format(*self._render_range) + e.input.value = '{0}:{1}:{2}'.format(*self._render_range) elif _id == 'space-columns-limit': try: @@ -379,8 +384,8 @@ def reset_highlighted(): • Branched Spaces: {len(spaces) - 1} • Space Size: {len(space_state)} • Causal Distance: {event.causal_distance_to_creation} -• Connected Abs: {len(connected_events)} -• Connected Set: {len(set(connected_events))} +• Connected Total: {len(connected_events)} +• Connected Unique: {len(set(connected_events))} • Created Cells: {created_cells} • Destroyed Cells: {destroyed_cells} @@ -514,11 +519,11 @@ def _add_row(self, event: FlowEvent): ) def _rebuild_rows(self) -> None: - a, b = self._render_range + a, b, c = self._render_range dt = self.data_table old_x, old_y = dt.scroll_x, dt.scroll_y dt.clear() - for event in self.model.flow.events[a:b + (1 if b > 0 else 0)]: + for event in self.model.flow.events[a:b + (1 if b > 0 else 0):c]: self._add_row(event) self._refresh_column_widths() dt.scroll_to(x=old_x, y=old_y, animate=False) diff --git a/src/studio/stdplgns/run.py b/src/studio/stdplgns/run.py index aa483a7..9df05e8 100644 --- a/src/studio/stdplgns/run.py +++ b/src/studio/stdplgns/run.py @@ -1,6 +1,7 @@ +""" +This plugin provides basic running/undoing features, hot-reload, and several other utilities for interactive with flows. +""" # Textual Imports -from idlelib.outwin import file_line_pats - from textual.widgets import Collapsible, TabPane, Input, Checkbox, Button, ProgressBar, Label, RichLog from textual.widget import Widget from textual.containers import ScrollableContainer, Horizontal @@ -57,6 +58,8 @@ def controls(self) -> Iterator[Widget]: self.show_traceback = Checkbox('Show tracebacks') yield self.show_traceback + yield Label() + self.hot_reload_timer: Timer = self.view.set_interval( 1, self._handle_hot_reload, pause=True # start paused. diff --git a/src/studio/styles.tcss b/src/studio/styles.tcss index 7f8b8b0..6c3741c 100644 --- a/src/studio/styles.tcss +++ b/src/studio/styles.tcss @@ -199,6 +199,11 @@ RichLog { overflow: auto; } +Collapsible { + padding-right: 1; + padding-bottom: 1; +} + /* ========================================= COMPONENT CLASSES ========================================= */ diff --git a/tests/example-studio-project/plugins/enum.py b/tests/example-studio-project/plugins/enum.py deleted file mode 100644 index 66f262e..0000000 --- a/tests/example-studio-project/plugins/enum.py +++ /dev/null @@ -1,20 +0,0 @@ -# Textual Imports -from textual.widgets import Collapsible, TabPane, Label - -# Standard Imports -from typing import Iterator -from studio.model import Plugin - - -class P(Plugin): - def on_initialized(self) -> None: - self.name = 'Enum' - - def panel(self) -> TabPane | None: - return TabPane(self.name.title(), Label('Content would go here when this plugin is developed.')) - - def controls(self) -> Iterator[Collapsible]: - return iter([]) - - -plugin = P() diff --git a/tests/example-studio-project/plugins/example.py b/tests/example-studio-project/plugins/example.py new file mode 100644 index 0000000..86a3c3d --- /dev/null +++ b/tests/example-studio-project/plugins/example.py @@ -0,0 +1,21 @@ +# Textual Imports +from textual.widgets import TabPane, Label, Collapsible +from textual.widget import Widget + +# Standard Imports +from typing import Iterator +from studio.model import Plugin + + +class P(Plugin): + def on_initialized(self) -> None: + self.name = 'Example' + + def controls(self) -> Iterator[Widget]: + return iter([Collapsible(Label("Settings go here!"), Label(" ... "), title='Example Setting')]) + + def panel(self) -> TabPane | None: + return TabPane(self.name.title(), Label(' This is the panel that would be developed in this plugin.')) + + +plugin = P() diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow index 50c811d..e0405a0 100644 --- a/tests/example-studio-project/sss.flow +++ b/tests/example-studio-project/sss.flow @@ -7,4 +7,4 @@ A -> ABA; // Evolve n times //@clear(); -@evolve(1); +@evolve(36); From 2ae1e3ff3650b312d8ee65457a3f40bf31e0909e Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Sun, 12 Apr 2026 08:57:22 -0400 Subject: [PATCH 30/31] Fixed the crash when exporting before building graph. --- src/studio/stdplgns/analysis.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/studio/stdplgns/analysis.py b/src/studio/stdplgns/analysis.py index 251d910..011a596 100644 --- a/src/studio/stdplgns/analysis.py +++ b/src/studio/stdplgns/analysis.py @@ -179,6 +179,10 @@ def _open_vis_js_viewer(self) -> None: self.view.notify(f"Failed to open viewer: {str(e)}", severity="error") def _export_as_format(self): + if not self._causal_graph: + self.view.notify('No graph has been built yet!', severity='error') + return + funcs = [nx.write_gexf, nx.write_graphml, nx.write_sparse6, nx.write_graph6, nx.write_gml, nx.write_adjlist, nx.write_multiline_adjlist] stems = ['.gexf', '.graphml', '.sparse6', '.graph6', '.gml', '.adjlist', '.multi_adjlist'] From e795e570dda3bd9e12ff7e0d86bc638f78f2a0e0 Mon Sep 17 00:00:00 2001 From: Isaac Wolford Date: Sun, 12 Apr 2026 17:21:05 -0400 Subject: [PATCH 31/31] Created the new external tunes plugin for studio. --- docs/plugin_dev_guide.md | 140 +++++++++ src/studio-plugins/tunes.py | 393 ++++++++++++++++++++++++++ src/studio-plugins/tunes/tunes.py | 0 tests/example-studio-project/sss.flow | 2 +- 4 files changed, 534 insertions(+), 1 deletion(-) create mode 100644 docs/plugin_dev_guide.md create mode 100644 src/studio-plugins/tunes.py delete mode 100644 src/studio-plugins/tunes/tunes.py diff --git a/docs/plugin_dev_guide.md b/docs/plugin_dev_guide.md new file mode 100644 index 0000000..117dda6 --- /dev/null +++ b/docs/plugin_dev_guide.md @@ -0,0 +1,140 @@ +RuleFlow Studio Plugin Development Guide + +1. Architectural Overview + +RuleFlow Studio uses a Model-View-Controller (MVC) architecture tightly coupled with a custom reactive signal system. Plugins in RuleFlow Studio act as modular extensions that bridge the Model (engine state) and the View (Textual UI) to provide new functionalities, analysis tools, or visualizers. + +Core Components + +Model (studio.model.Model): The single source of truth. Manages the workspace, project paths, and the core Cellular Automata/Rule engine (self.flow). + +View (studio.view.EditorScreen): The UI controller built on Textual. It manages the layout, consisting of the main workspace (code editor + Plugin Panels) and the right sidebar (Plugin Controls). + +Engine (core.engine.Flow): The mathematical simulation engine. It manages the timeline of Event objects, causality graphs, and multi-way branch evaluations. + +Signals (core.signals.Signal): A synchronous, QT-like event dispatch system. Used to decouple UI interactions from engine events. + +2. The Plugin Interface Contract + +Every plugin must inherit from the studio.model.Plugin abstract base class. A plugin is instantiated once per application lifecycle and relies on the Model to inject runtime dependencies before on_initialized is called. + +Required Attributes (Injected by Model) + +self.name: A string defining the plugin's internal name. + +self.model: Access to the Model layer and the simulation engine (self.model.flow). + +self.view: Access to the Textual EditorScreen for UI modifications and notifications. + +self.cft: A callable (self.view.app.call_from_thread) used to safely update the UI from background worker threads. + +Abstract Methods to Implement + +on_initialized(self) -> None: Executed after dependencies are injected. Used for state initialization and signal connections. + +controls(self) -> Iterator[Widget]: Yields Textual widgets to populate the right-side control sidebar. + +panel(self) -> TabPane | None: Returns the primary workspace widget (a TabPane) to be displayed in the center view, or None if the plugin does not require a central visualizer. + +3. Step-by-Step Implementation Guide + +Step 1: File Structure and Imports + +Plugins must be placed in the /plugins/ directory. They are dynamically loaded at runtime. Standard Textual widgets and specific engine types should be imported as needed. + +from typing import Iterator +from textual.widgets import Collapsible, TabPane, Button, Label, Input +from textual.widget import Widget + +from studio.model import Plugin +from core.engine import FlowLangBase + + +Step 2: Class Definition and Initialization + +Create your class inheriting from Plugin. In on_initialized, define local state variables and map UI/Engine signals to internal handlers. + +class MyPlugin(Plugin): + def on_initialized(self) -> None: + self.name = 'my_plugin' + + # Internal state + self._target_metric: int = 0 + + # Connect View (UI) Signals + self.view.sig_button_pressed.connect(self._handle_button_press) + + # Connect Engine Signals + FlowLangBase.on_evolved_n.connect(self._handle_evolution) + + +Step 3: Building the Sidebar Controls + +Implement the controls() method. It is highly recommended to wrap related controls inside a Collapsible widget to maintain sidebar readability. Always assign strict id strings to interactive widgets; these are required for routing signals later. + + def controls(self) -> Iterator[Widget]: + with Collapsible(title='My Settings', collapsed=False): + yield Button('Calculate', id='my-calc-btn', variant="primary") + + self.metric_input = Input(value='10', id='my-metric-input') + self.metric_input.border_title = 'Target Metric' + yield self.metric_input + + +Step 4: Building the Main Panel + +Implement the panel() method. This defines what is rendered in the center workspace. Use Textual containers (VerticalScroll, Horizontal) to manage layout. + + def panel(self) -> TabPane | None: + self.output_label = Label("Awaiting calculation...") + + return TabPane( + self.name.title(), + self.output_label + ) + + +Step 5: Handling Signals + +Create the handlers bound in on_initialized. + +UI Routing: Use the id of the widget to route logic properly. +Thread Safety: Engine operations (like run.py processing flows) occur in separate workers. If an engine signal triggers a UI update, you must wrap the UI mutation in self.cft(). + + def _handle_button_press(self, event: Button.Pressed) -> None: + if event.button.id == 'my-calc-btn': + try: + # Read from UI state + val = int(self.metric_input.value) + self.view.notify(f"Calculation started for {val}...") + except ValueError: + self.view.notify("Invalid input type.", severity="error") + + def _handle_evolution(self, flow: FlowLangBase, steps: int) -> None: + # Flow execution happens in a worker thread. + # Safely update the Textual UI using self.cft() + + def update_ui(): + self.output_label.update(f"Evolved {steps} steps. Total events: {len(flow.events)}") + + self.cft(update_ui) + + +Step 6: Module Export + +For the dynamic loader to mount the plugin, a singleton instance of the plugin must be initialized at the bottom of the file. + +plugin = MyPlugin() + + +4. Best Practices & Guidelines + +State Management: Do not store complex engine data structures directly on the plugin unless necessary (e.g., caching a graph). Query self.model.flow directly when possible to avoid desync issues. + +Idempotent UI Updates: Because Textual's layout phase resolves via generators (yield), do not rely on on_mount lifecycle hooks inside the plugin class. Build the UI statically in controls and panel, caching widget references as instance variables (e.g., self.my_table = DataTable()) to mutate them later. + +UI Feedback: Always utilize self.view.notify(message, severity) to give users feedback upon completion or failure of a control action. + +Graceful Degradation: Engine states can reset (on_clear). Ensure your plugin listens for reset signals to wipe stale visualization data, preventing IndexError exceptions when referencing destroyed Event arrays. + +Signal Memory Leaks: While RuleFlow's custom signal system cleans up somewhat safely, ensure that your plugin does not continuously connect anonymous lambda functions to the engine without disconnecting them, which may degrade performance. diff --git a/src/studio-plugins/tunes.py b/src/studio-plugins/tunes.py new file mode 100644 index 0000000..68d0d1e --- /dev/null +++ b/src/studio-plugins/tunes.py @@ -0,0 +1,393 @@ +""" +This plugin provides auditory exploration of cellular systems using the SCAMP library. +It translates spatial cell configurations into musical chords and arpeggios, and allows +for sheet music transcription. + +Note: https://www.soundslice.com/ is an excellent site for playing back the generated xml files. +""" +# Textual Imports +from textual.widgets import (Collapsible, TabPane, Input, Select, + Checkbox, Button, Label, RichLog) +from textual.widget import Widget +from textual.containers import VerticalScroll, HorizontalGroup + +# Standard Imports +from typing import Iterator +import threading +from studio.model import Plugin + +# Attempt to import SCAMP. If it fails, we flag it so the UI can warn the user. +try: + import scamp + SCAMP_AVAILABLE = True +except ImportError: + SCAMP_AVAILABLE = False + + +# ==== Musical Scales & Progressions ==== +SCALES = { + "C Pentatonic Major (Bright)": [0, 2, 4, 7, 9], + "A Aeolian Minor (Melancholic)": [0, 2, 3, 5, 7, 8, 10], + "F Lydian (Dreamy)": [0, 2, 4, 6, 7, 9, 11], + "D Harmonic Minor (Classical)": [0, 2, 3, 5, 7, 8, 11], + "Whole Tone (Ethereal)": [0, 2, 4, 6, 8, 10] +} + + +def get_extended_scale(scale_intervals: list[int], octaves: int = 5, base_midi: int = 48) -> list[int]: + """Extends a set of scale intervals across multiple octaves.""" + pitches = [] + for oct in range(octaves): + for interval in scale_intervals: + pitches.append(base_midi + (oct * 12) + interval) + return pitches + + +class P(Plugin): + def on_initialized(self) -> None: + self.name = 'tunes' + + # Internal State + self._is_playing: bool = False + self._playback_thread: threading.Thread | None = None + self._stop_event: threading.Event = threading.Event() + + # Connect UI Signals + self.view.sig_button_pressed.connect(self._handle_button_press) + + def controls(self) -> Iterator[Widget]: + with Collapsible(title='Playback Controls', collapsed=False): + with HorizontalGroup(): + yield Button('▶ Play', id='btn-play-music', variant="success") + yield Button('■ Stop', id='btn-stop-music', variant="error") + + yield Label("\nTempo (BPM)") + self.bpm_input = Input(value='120', placeholder='e.g. 120') + yield self.bpm_input + + yield Label("Playback Timing") + self.timing_select = Select( + [("Sequential (Arpeggio)", "seq"), ("Simultaneous (Chord)", "chord")], + value="seq", allow_blank=False + ) + yield self.timing_select + + with Collapsible(title='Musical Mapping', collapsed=False): + yield Label("Base Instrument") + self.base_instrument_select = Select( + [ + ("Piano", "piano"), ("Marimba", "marimba"), + ("Vibraphone", "vibraphone"), ("Cello", "cello"), + ("Harp", "harp"), ("Glockenspiel", "glockenspiel"), + ("Standard Drumkit", "drumkit:standard"), + ("Electronic Drumkit", "drumkit:electronic"), + ("808 Drumkit", "drumkit:808"), + ("Jazz Drumkit", "drumkit:jazz"), + ("Orchestra Drumkit", "drumkit:orchestra"), + ("Woodblock", "woodblock") + ], + value="piano", allow_blank=False + ) + yield self.base_instrument_select + + yield Label("Instrument Map (Quanta: Instr)") + self.instrument_map_input = Input(value='', placeholder='e.g. A: drumkit:808, B: cello') + yield self.instrument_map_input + + yield Label("Pitch Mapping Mode") + self.mapping_mode = Select( + [ + ("Position + Quanta Combined", "combined"), + ("Quanta Value Only", "quanta"), + ("Spatial Position Only", "position") + ], + value="combined", allow_blank=False + ) + yield self.mapping_mode + + yield Label("Ignored Quanta (Comma Separated)") + self.ignored_input = Input(value='.,0,_', placeholder='e.g. .,0,_') + yield self.ignored_input + + with Collapsible(title='Scale & Tuning Config', collapsed=True): + yield Label("Scale / Progression") + self.scale_select = Select( + [(name, name) for name in SCALES.keys()] + [("Custom (Use Intervals Below)", "custom")], + value="C Pentatonic Major (Bright)", allow_blank=False + ) + yield self.scale_select + + yield Label("Custom Intervals (Comma Separated)") + self.custom_scale_input = Input(value='0, 2, 4, 7, 9', placeholder='e.g. 0, 2, 4, 7, 9') + yield self.custom_scale_input + + yield Label("Number of Octaves") + self.octaves_input = Input(value='5', placeholder='e.g. 5') + yield self.octaves_input + + yield Label("Base MIDI Note (C3 = 48)") + self.base_midi_input = Input(value='48', placeholder='e.g. 48') + yield self.base_midi_input + + with Collapsible(title='Transcription & Export', collapsed=False): + self.export_checkbox = Checkbox('Record Performance', value=False) + yield self.export_checkbox + + self.export_format = Select( + [("MusicXML (.xml)", "xml"), ("MIDI (.mid)", "mid")], + value="xml", allow_blank=False + ) + yield self.export_format + + def panel(self) -> TabPane | None: + self.log_view = RichLog(id="tunes-log-view", highlight=True, markup=True, wrap=True) + return TabPane( + self.name.title(), + VerticalScroll( + self.log_view + ) + ) + + def _handle_button_press(self, event: Button.Pressed) -> None: + if event.button.id == 'btn-play-music': + self._start_playback() + elif event.button.id == 'btn-stop-music': + self._stop_playback() + + def _start_playback(self) -> None: + if not SCAMP_AVAILABLE: + self.log_view.write("[bold red]SCAMP library not found![/bold red]") + self.log_view.write("Please run `pip install scamp` in your environment to use this plugin.") + return + + if self._is_playing: + self.view.notify("Music is already playing.", severity="warning") + return + + if not self.model.flow.events: + self.log_view.write("[bold red]No simulation events found. Run the engine first.[/bold red]") + return + + try: + float(self.bpm_input.value) + except ValueError: + self.view.notify("Invalid BPM value.", severity="error") + return + + self.log_view.clear() + self._is_playing = True + self._stop_event.clear() + + # Use native python threading to allow immediate interruption via threading.Event + self._playback_thread = threading.Thread(target=self._playback_loop, daemon=True) + self._playback_thread.start() + + def _stop_playback(self) -> None: + # Executes directly in UI thread. No self.cft() necessary. + if self._is_playing: + self.log_view.write("[bold yellow]Stopping playback gracefully...[/bold yellow]") + self._stop_event.set() + + def _wait_interruptable(self, session: 'scamp.Session', duration: float) -> bool: + """ + Blocks the SCAMP clock in small increments so that the stop event can interrupt + long musical rests or notes immediately without freezing the application thread. + Returns False if interrupted. + """ + waited = 0.0 + step = 0.1 + while waited < duration: + if self._stop_event.is_set(): + return False + wait_time = min(step, duration - waited) + session.wait(wait_time) + waited += wait_time + return True + + def _playback_loop(self) -> None: + """The main blocking thread loop that processes the cellular flow and plays audio.""" + session = None + try: + bpm = float(self.bpm_input.value) + base_instrument_name = self.base_instrument_select.value + scale_name = self.scale_select.value + playback_timing = self.timing_select.value + mapping_mode = self.mapping_mode.value + ignored_chars = [c.strip() for c in self.ignored_input.value.split(',')] + + # Parse Instrument Map + instr_map = {} + if self.instrument_map_input.value.strip(): + pairs = self.instrument_map_input.value.split(',') + for pair in pairs: + if ':' in pair: + q, instr = pair.split(':', 1) + instr_map[q.strip()] = instr.strip() + + # Setup SCAMP Session + session = scamp.Session(tempo=bpm) + + # Preload parts to avoid stutter during mid-playback instantiation + parts = {} + def get_part(name: str): + name_lower = name.lower() + is_drum = name_lower.startswith("drumkit") or name_lower in ("percussion", "drums") + + if is_drum: + # Determine General MIDI drumkit preset based on the tag + preset_num = 0 # Default to Standard Kit + if "electronic" in name_lower: + preset_num = 24 + elif "808" in name_lower: + preset_num = 25 + elif "jazz" in name_lower: + preset_num = 32 + elif "orchestra" in name_lower: + preset_num = 48 + + # Cache key needs to be unique per kit type + actual_name = f"Drums_{preset_num}" + if actual_name not in parts: + # Map to General MIDI Bank 128 (Percussion) with the specified Preset + parts[actual_name] = session.new_part("Drums", preset=(128, preset_num)) + return parts[actual_name] + else: + if name not in parts: + parts[name] = session.new_part(name) + return parts[name] + + get_part(base_instrument_name) + for instr_name in instr_map.values(): + get_part(instr_name) + + # Parse Tuning Parameters + try: + octaves = int(self.octaves_input.value) + except ValueError: + octaves = 5 + + try: + base_midi = int(self.base_midi_input.value) + except ValueError: + base_midi = 48 + + if scale_name == "custom": + try: + intervals = [int(x.strip()) for x in self.custom_scale_input.value.split(',')] + except ValueError: + intervals = SCALES["C Pentatonic Major (Bright)"] # fallback + else: + intervals = SCALES.get(scale_name, SCALES["C Pentatonic Major (Bright)"]) + + # Generate our spatial mapping scale + extended_scale = get_extended_scale(intervals, octaves=octaves, base_midi=base_midi) + + if self.export_checkbox.value: + session.start_transcribing() + self.cft(self.log_view.write, "[bold green]Transcription started...[/bold green]") + + self.cft(self.log_view.write, f"[bold green]Playing {scale_name}...[/bold green]") + + for event in self.model.flow.events: + if self._stop_event.is_set(): + break + + try: + # Extract the first space from the multi-way branches for deterministic music + space = next(event.spaces) + cells = list(space.get_all_cells()) + except StopIteration: + continue + + elements_to_play = [] + + # Map the cells to pitch scale degrees and instruments + for i, cell in enumerate(cells): + val = str(cell.quanta).strip() + if val and val not in ignored_chars: + + # Instrument Resolution + instr = instr_map.get(val, base_instrument_name) + is_drum = instr.lower().startswith("drumkit") or instr.lower() in ("percussion", "drums") + + # Pitch / Note Resolution + if mapping_mode == "position": + scale_index = i + elif mapping_mode == "quanta": + scale_index = sum(ord(c) for c in val) + else: + scale_index = sum(ord(c) for c in val) + i + + # Drums require specific MIDI notes (General MIDI Drums: 35-81). + # High melodic pitches (e.g., > 81) produce no sound on drum tracks! + if is_drum: + pitch = 35 + (scale_index % 47) + else: + pitch = extended_scale[scale_index % len(extended_scale)] + + elements_to_play.append((i, val, pitch, instr)) + + if not elements_to_play: + self.cft(self.log_view.write, f"Event {event.time}: [grey]Rest[/grey]") + if not self._wait_interruptable(session, 1.0): break + continue + + if playback_timing == "chord": + # Play all notes for this event simultaneously across multiple instruments + log_str = ", ".join(f"{v}->{p}({i_name})" for _, v, p, i_name in elements_to_play) + self.cft(self.log_view.write, f"Event {event.time}: [blue]Chord [{log_str}][/blue]") + + for _, _, pitch, instr in elements_to_play: + part = get_part(instr) + part.play_note(pitch, 0.6, 1.0, blocking=False) + + if not self._wait_interruptable(session, 1.0): break + else: + # Play notes sequentially (Arpeggio style). Each note gets 0.25 beats (16th note) + log_str = ", ".join(f"[{idx}]{v}->{p}({i_name})" for idx, v, p, i_name in elements_to_play) + self.cft(self.log_view.write, f"Event {event.time}: [blue]Seq [{log_str}][/blue]") + + note_dur = 0.25 + for idx, val, pitch, instr in elements_to_play: + if self._stop_event.is_set(): break + part = get_part(instr) + part.play_note(pitch, 0.6, note_dur, blocking=False) + if not self._wait_interruptable(session, note_dur): break + + # ==== Cleanup and Export Pipeline ==== + if self.export_checkbox.value and not self._stop_event.is_set(): + self.cft(self.log_view.write, "\n[bold cyan]Processing Performance Export...[/bold cyan]") + performance = session.stop_transcribing() + + export_fmt = self.export_format.value + export_path = self.model.project_path / f"cellular_score_{self.model.flow_path.stem}.{export_fmt}" + + try: + if export_fmt == 'xml': + # Valid SCAMP 0.8+ API sequence to export MusicXML + score = performance.to_score(title=f"Cellular Automata - {scale_name}") + score.export_music_xml(str(export_path)) + self.cft(self.log_view.write, f"[bold green]Exported MusicXML to: {export_path}[/bold green]") + elif export_fmt == 'mid': + performance.export_to_midi_file(str(export_path)) + self.cft(self.log_view.write, f"[bold green]Exported MIDI to: {export_path}[/bold green]") + + except Exception as e: + self.cft(self.log_view.write, f"[bold red]Failed to export: {str(e)}[/bold red]") + + except Exception as e: + self.cft(self.log_view.write, f"[bold red]Playback Error: {str(e)}[/bold red]") + + finally: + # Prevents sustained instruments (Cello, etc.) from ringing out indefinitely. + if session is not None: + try: + session.kill() + except Exception: + pass + + self.cft(self.log_view.write, "\n[bold yellow]Playback concluded.[/bold yellow]") + self._is_playing = False + + +plugin = P() diff --git a/src/studio-plugins/tunes/tunes.py b/src/studio-plugins/tunes/tunes.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/example-studio-project/sss.flow b/tests/example-studio-project/sss.flow index e0405a0..a6649e3 100644 --- a/tests/example-studio-project/sss.flow +++ b/tests/example-studio-project/sss.flow @@ -7,4 +7,4 @@ A -> ABA; // Evolve n times //@clear(); -@evolve(36); +@evolve(10);