Files

15 KiB

synodetik

Modular audiovisual synoptic node editor.

Installation

Just git clone the project. Open a terminal in the cloned folder.

$ python -m venv .venv
$ pip install -r requirements.txt
$ python synodetik.py

Module Development

Synodetik supports a modular architecture that allows developers to extend functionality through custom modules. Modules can add menu items, subscribe to events, and integrate with the node editor.

Module Locations

Modules can be placed in two locations:

Location Path Description
Built-in synodetik/modules/builtins/ Shipped with the application
User modules ~/.synodetik/modules/ Custom user modules

Creating a Module

Create a new Python file in one of the module directories. Your module must:

  1. Subclass the Module base class
  2. Implement the info property returning a ModuleInfo
  3. Implement on_load() and on_unload() lifecycle methods

Basic Module Template

"""
My Custom Module - Brief description of what it does.
"""
from synodetik.modules import Module, ModuleInfo, MenuItem
from synodetik.events import EventType


class MyCustomModule(Module):
    """
    Description of your module's functionality.
    """
    
    @property
    def info(self) -> ModuleInfo:
        """Return module metadata."""
        return ModuleInfo(
            id="my_custom_module",      # Unique identifier (lowercase, no spaces)
            name="My Custom Module",     # Display name
            version="1.0.0",             # Semantic version
            author="Your Name",          # Author name
            description="Does something useful",  # Brief description
            dependencies=[],             # List of required module IDs
            website=""                   # Optional documentation URL
        )
    
    def on_load(self) -> bool:
        """
        Called when the module is activated.
        
        Use this to:
        - Subscribe to events
        - Initialize resources
        - Set up UI elements
        
        Returns:
            True if loading succeeded, False to abort
        """
        print(f"{self.info.name} loaded!")
        return True
    
    def on_unload(self) -> None:
        """
        Called when the module is deactivated.
        
        Use this to:
        - Unsubscribe from events
        - Clean up resources
        - Remove UI elements
        """
        print(f"{self.info.name} unloaded!")

Adding Menu Items

Override get_menu_items() to add entries to the application menu:

def get_menu_items(self) -> list[MenuItem]:
    """Return menu items to add to the application."""
    return [
        MenuItem(
            label="My Action",           # Menu item text
            callback=self._my_callback,  # Function to call
            shortcut="Ctrl+M",           # Optional keyboard shortcut display
            parent_menu="View",          # Target menu: "File", "Edit", "View", "Modules"
            separator_before=False       # Add separator before this item
        )
    ]

def _my_callback(self, sender=None, app_data=None):
    """Handle menu item click."""
    print("Menu item clicked!")

Subscribing to Events

Use the event system to react to application changes:

def on_load(self) -> bool:
    # Subscribe to events
    self.events.on(EventType.MACHINE_ADDED, self._on_machine_added)
    self.events.on(EventType.CONNECTION_CREATED, self._on_connection_created)
    return True

def on_unload(self) -> None:
    # Always unsubscribe when unloading
    self.events.off(EventType.MACHINE_ADDED, self._on_machine_added)
    self.events.off(EventType.CONNECTION_CREATED, self._on_connection_created)

def _on_machine_added(self, event):
    """Handle machine added event."""
    machine = event.data.get('machine')
    print(f"Machine added: {machine.name}")

def _on_connection_created(self, event):
    """Handle connection created event."""
    connection = event.data.get('connection')
    print(f"Connection created: {connection.id}")

Available Event Types

Event Description Data
APP_STARTED Application has started -
APP_CLOSING Application is closing -
SYNOPTIK_CREATED New synoptik created synoptik
SYNOPTIK_LOADED Synoptik loaded from file synoptik
SYNOPTIK_SAVED Synoptik saved to file filepath
SYNOPTIK_MODIFIED Synoptik was modified machine_id, property, value
MACHINE_ADDED Machine added to synoptik machine
MACHINE_REMOVED Machine removed from synoptik machine_id
CONNECTION_CREATED Connection created connection
CONNECTION_REMOVED Connection removed connection_id
TEMPLATE_CREATED Machine template created machine
TEMPLATE_DELETED Machine template deleted machine_id

Accessing Application Services

Modules have access to core services through properties:

def on_load(self) -> bool:
    # Access the main application
    app = self.app
    
    # Access the current synoptik (diagram)
    if self.app and self.app.synoptik:
        machines = self.app.synoptik.machines
        connections = self.app.synoptik.connections
    
    # Access the event dispatcher
    dispatcher = self.events
    
    return True

Adding Context Menu Items

Override on_context_menu() to add items to the right-click menu:

def on_context_menu(self, menu_parent: int, context: dict) -> None:
    """
    Called when building the context menu.
    
    Args:
        menu_parent: DearPyGui parent ID for adding items
        context: Dict containing:
            - 'selected_machines': List of selected machine IDs
            - 'selected_connections': List of selected connection IDs  
            - 'position': Mouse position (x, y)
    """
    import dearpygui.dearpygui as dpg
    
    selected = context.get('selected_machines', [])
    if selected:
        dpg.add_menu_item(
            label="My Custom Action",
            callback=self._custom_action,
            user_data=selected,
            parent=menu_parent
        )

def _custom_action(self, sender, app_data, user_data):
    """Handle context menu action."""
    selected_machines = user_data
    print(f"Action on: {selected_machines}")

Adding to Properties Panel

Override on_properties_panel() to add custom properties:

def on_properties_panel(self, panel_parent: int, selection: dict) -> None:
    """
    Called when properties panel needs custom content.
    
    Args:
        panel_parent: DearPyGui parent ID for adding items
        selection: Dict with selected machine/connection info
    """
    import dearpygui.dearpygui as dpg
    
    dpg.add_separator(parent=panel_parent)
    dpg.add_text("My Module Properties", parent=panel_parent)
    dpg.add_button(
        label="Custom Action",
        callback=self._panel_action,
        parent=panel_parent
    )

Complete Example: Statistics Module

Here's a complete example that displays synoptik statistics:

"""
Statistics module - Displays synoptik metrics.
"""
import dearpygui.dearpygui as dpg

from synodetik.modules import Module, ModuleInfo, MenuItem
from synodetik.events import EventType


class StatisticsModule(Module):
    """Displays statistics about the current synoptik."""
    
    @property
    def info(self) -> ModuleInfo:
        return ModuleInfo(
            id="statistics",
            name="Statistics",
            version="1.0.0",
            author="Synodetik",
            description="Display synoptik statistics"
        )
    
    def on_load(self) -> bool:
        # Subscribe to relevant events
        self.events.on(EventType.MACHINE_ADDED, self._update_stats)
        self.events.on(EventType.MACHINE_REMOVED, self._update_stats)
        self.events.on(EventType.CONNECTION_CREATED, self._update_stats)
        self.events.on(EventType.CONNECTION_REMOVED, self._update_stats)
        return True
    
    def on_unload(self) -> None:
        # Unsubscribe from events
        self.events.off(EventType.MACHINE_ADDED, self._update_stats)
        self.events.off(EventType.MACHINE_REMOVED, self._update_stats)
        self.events.off(EventType.CONNECTION_CREATED, self._update_stats)
        self.events.off(EventType.CONNECTION_REMOVED, self._update_stats)
        
        # Clean up UI
        if dpg.does_item_exist("stats_window"):
            dpg.delete_item("stats_window")
    
    def get_menu_items(self) -> list[MenuItem]:
        return [
            MenuItem(
                label="Show Statistics",
                callback=self._show_window,
                parent_menu="View"
            )
        ]
    
    def _show_window(self, sender=None, app_data=None):
        """Create or focus the statistics window."""
        if dpg.does_item_exist("stats_window"):
            dpg.focus_item("stats_window")
            return
        
        with dpg.window(
            label="Statistics",
            tag="stats_window",
            width=250,
            height=150,
            pos=(100, 100)
        ):
            dpg.add_text("Machines: 0", tag="stats_machines")
            dpg.add_text("Connections: 0", tag="stats_connections")
        
        self._update_stats(None)
    
    def _update_stats(self, event):
        """Update statistics display."""
        if not self.app or not self.app.synoptik:
            return
        
        synoptik = self.app.synoptik
        
        if dpg.does_item_exist("stats_machines"):
            dpg.set_value("stats_machines", f"Machines: {len(synoptik.machines)}")
        if dpg.does_item_exist("stats_connections"):
            dpg.set_value("stats_connections", f"Connections: {len(synoptik.connections)}")

Best Practices

  1. Always clean up - Unsubscribe from events and delete UI elements in on_unload()
  2. Check for None - Always verify self.app and self.app.synoptik exist before accessing
  3. Use unique IDs - Prefix DearPyGui tags with your module ID to avoid conflicts
  4. Handle errors gracefully - Wrap callbacks in try/except to prevent crashes
  5. Document your module - Include docstrings explaining functionality
  6. Test thoroughly - Test loading, unloading, and all event handlers

Module Dependencies

If your module requires other modules, list them in dependencies:

@property
def info(self) -> ModuleInfo:
    return ModuleInfo(
        id="my_advanced_module",
        name="Advanced Module",
        version="1.0.0",
        dependencies=["statistics", "other_module"]  # Required modules
    )

The module manager will automatically load dependencies before your module.


Command System (Undo/Redo)

Synodetik provides a command pattern implementation for undo/redo functionality. Modules can create custom undoable commands and integrate with the application's undo/redo stack.

Keyboard Shortcuts

Action Shortcut
Undo Ctrl+Z
Redo Ctrl+Y or Ctrl+Shift+Z

Using the Command Manager

from synodetik.core.commands import get_command_manager

# Get the global command manager
manager = get_command_manager()

# Check state
if manager.can_undo:
    print(f"Can undo: {manager.undo_description}")

if manager.can_redo:
    print(f"Can redo: {manager.redo_description}")

# Execute undo/redo
manager.undo()
manager.redo()

Creating Custom Commands

Create undoable commands by subclassing Command:

from synodetik.core.commands import Command, get_command_manager


class ChangeColorCommand(Command):
    """Command to change a machine's color."""
    
    def __init__(self, machine, new_color: str):
        super().__init__()
        self._machine = machine
        self._new_color = new_color
        self._old_color = machine.color  # Store for undo
    
    @property
    def description(self) -> str:
        return f"Change color of '{self._machine.name}'"
    
    def execute(self) -> None:
        """Apply the new color."""
        self._machine.color = self._new_color
        # Update UI if needed
    
    def undo(self) -> None:
        """Restore the old color."""
        self._machine.color = self._old_color
        # Update UI if needed


# Execute the command (adds to undo stack)
cmd = ChangeColorCommand(machine, "#FF5500")
get_command_manager().execute(cmd)

Built-in Commands

Command Description
AddMachineCommand Add a machine to the synoptik
RemoveMachineCommand Remove a machine and its connections
MoveMachineCommand Move a machine to a new position
CreateConnectionCommand Create a connection between ports
RemoveConnectionCommand Remove a connection

Batch Commands

Group multiple commands into a single undo operation:

manager = get_command_manager()

# Start batch mode
manager.begin_batch()

# Execute multiple commands
manager.execute(cmd1)
manager.execute(cmd2)
manager.execute(cmd3)

# End batch - all commands become one undo operation
manager.end_batch("Import machines")

# Now undo() will undo all three commands at once

State Change Notifications

Listen for undo/redo state changes (useful for updating UI):

def on_state_changed(state):
    """Called when undo/redo state changes."""
    print(f"Can undo: {state.can_undo} ({state.undo_description})")
    print(f"Can redo: {state.can_redo} ({state.redo_description})")
    print(f"History: {state.undo_count} undo, {state.redo_count} redo")

get_command_manager().on_state_changed = on_state_changed

Example: Undoable Module Action

Complete example of a module with undoable actions:

from synodetik.modules import Module, ModuleInfo, MenuItem
from synodetik.core.commands import Command, get_command_manager


class SetNoteCommand(Command):
    """Command to set a note on a machine."""
    
    def __init__(self, machine, note: str):
        super().__init__()
        self._machine = machine
        self._new_note = note
        self._old_note = getattr(machine, 'note', '')
    
    @property
    def description(self) -> str:
        return f"Set note on '{self._machine.name}'"
    
    def execute(self) -> None:
        self._machine.note = self._new_note
    
    def undo(self) -> None:
        self._machine.note = self._old_note


class NotesModule(Module):
    """Module that adds notes to machines."""
    
    @property
    def info(self) -> ModuleInfo:
        return ModuleInfo(
            id="notes",
            name="Machine Notes",
            version="1.0.0",
            author="Developer",
            description="Add notes to machines"
        )
    
    def on_load(self) -> bool:
        return True
    
    def on_unload(self) -> None:
        pass
    
    def set_note(self, machine, note: str):
        """Set a note on a machine (undoable)."""
        cmd = SetNoteCommand(machine, note)
        get_command_manager().execute(cmd)