Files

516 lines
15 KiB
Markdown

# 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)
```