516 lines
15 KiB
Markdown
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)
|
|
```
|