# synodetik Modular audiovisual synoptic node editor. ## Installation Just git clone the project. Open a terminal in the cloned folder. ```shell $ 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 ```python """ 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: ```python 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: ```python 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: ```python 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: ```python 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: ```python 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: ```python """ 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: ```python @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 ```python 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`: ```python 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: ```python 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): ```python 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: ```python 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) ```