Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4234c16
Added plugin panel and control partial support...
cybergeek1943 Feb 27, 2026
71e835f
Refined plugin tab switching
cybergeek1943 Feb 27, 2026
c1f2cc1
Refined the file I/O support of the editor.
cybergeek1943 Mar 1, 2026
a29c39b
Made the Flow file saver check the hash first to ensure saving change…
cybergeek1943 Mar 2, 2026
328357a
Improved the codebase and started standard plugin implementation.
cybergeek1943 Mar 3, 2026
e57858e
Refine the "run" standard plugin for Studio
cybergeek1943 Mar 4, 2026
efb1ff4
Refine the "run" standard plugin for Studio
cybergeek1943 Mar 4, 2026
3a591f6
Added signal system for plugins to communicate with the view, improve…
cybergeek1943 Mar 5, 2026
b724547
Improved the signal module+policies and fixed the undo method for the…
cybergeek1943 Mar 13, 2026
0a77dbf
Saving as backup
cybergeek1943 Mar 30, 2026
81d7e82
Update the .gitignore
cybergeek1943 Mar 30, 2026
e87ea4f
Added a bin directory for future access to utilities we make such as …
cybergeek1943 Mar 30, 2026
2b0c3cc
Added an official RuleFlow Studio Example Project folder to the tests…
cybergeek1943 Mar 30, 2026
081a7db
Added better exception handling to the builtin run plugin.
cybergeek1943 Apr 3, 2026
4f87f5d
Fleshed out the run plugin and implemented hot reloading and threading.
cybergeek1943 Apr 3, 2026
a182004
Improved the Plugin threading support, improved signals, and started …
cybergeek1943 Apr 4, 2026
47afd06
Improved the Output plugin by refreshing the column width of the data…
cybergeek1943 Apr 5, 2026
11ca873
Added rendering range support to the std Output plugin. This also inv…
cybergeek1943 Apr 8, 2026
53619b8
Added the column tool support to Output plugin.
cybergeek1943 Apr 9, 2026
4d6dea7
Renamed the Output plugin to "Explore". This better reflects its func…
cybergeek1943 Apr 9, 2026
4160a2b
Finished the major Explore plugin functionality.
cybergeek1943 Apr 10, 2026
0fc0ed8
Fixed todo
cybergeek1943 Apr 10, 2026
623eda5
Added one more hover feature adn fixed bug in the Explore plugin
cybergeek1943 Apr 10, 2026
7583d6a
Improved the hover feature in the Explore plugin.
cybergeek1943 Apr 10, 2026
caf38bd
Improved the explore plugin by adding the ruleset viewer and hover tool.
cybergeek1943 Apr 11, 2026
74b7f45
Added type hints to the signals in the lang Rules.
cybergeek1943 Apr 11, 2026
b182314
Removed the multiple Flow sessions feature as it adds unnecessary mai…
cybergeek1943 Apr 12, 2026
ff275ea
Reorganized and added some docstrings.
cybergeek1943 Apr 12, 2026
b66cdde
Added the Analysis standard plugin.
cybergeek1943 Apr 12, 2026
2ae1e3f
Fixed the crash when exporting before building graph.
cybergeek1943 Apr 12, 2026
e795e57
Created the new external tunes plugin for studio.
cybergeek1943 Apr 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/.idea
/.venv
Binary file renamed RuleFlow Studio.lnk → bin/RuleFlow Studio.lnk
Binary file not shown.
3 changes: 3 additions & 0 deletions bin/ruleflow.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash
cd /home/isaac/repos/RuleFlow/src
uv run python -m studio.view
4 changes: 4 additions & 0 deletions bin/ruleflow.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@echo off
cd C:\local\repos\RuleFlow\src
uv run python -m studio.view
@pause
140 changes: 140 additions & 0 deletions docs/plugin_dev_guide.md
Original file line number Diff line number Diff line change
@@ -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 <project_path>/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.
3 changes: 0 additions & 3 deletions ruleflow_studio.bat

This file was deleted.

97 changes: 52 additions & 45 deletions src/core/engine.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Sequence, MutableSequence, NamedTuple, Iterator
from typing import Any, Sequence, MutableSequence, NamedTuple, Iterator, cast, Self
from abc import ABC, abstractmethod
from dataclasses import dataclass
from copy import copy
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -387,65 +387,65 @@ 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


# 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_undo: Signal = Signal()
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()
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
self.event_index_offset: int = 0

# progress tracking attributes
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"""
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"""
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

def clear_evolution(self) -> None:
"""Clear the evolution."""
del self.events[1:]
self.on_clear.emit(self)

@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:
Expand All @@ -455,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
Expand All @@ -476,22 +476,29 @@ 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)
self.on_evolved_step.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:
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.evolve()
n_steps -= 1
self.n_step_progress = (i + 1) / n_steps
i += 1
self._evolve()
if break_when_inert and self.current_event.inert:
break

def undo(self) -> None:
# emit any signals
self.on_evolved_n.emit(self, n_steps)

def _undo(self) -> None:
"""undo the last event..."""
if self.current_event_idx == 0:
return
Expand All @@ -500,17 +507,17 @@ 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)
self.on_undone_step.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.n_step_progress = (_ + 1) / n_steps
self._undo()

self.on_undone_n.emit(self, n_steps)

def __str__(self) -> str:
return '\n'.join(str(e) for e in self.events)
Expand Down
Loading
Loading