py2max 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py2max/__init__.py +67 -0
- py2max/__main__.py +6 -0
- py2max/cli.py +1251 -0
- py2max/core/__init__.py +39 -0
- py2max/core/abstract.py +146 -0
- py2max/core/box.py +231 -0
- py2max/core/common.py +19 -0
- py2max/core/patcher.py +1658 -0
- py2max/core/patchline.py +68 -0
- py2max/exceptions.py +385 -0
- py2max/export/__init__.py +20 -0
- py2max/export/converters.py +345 -0
- py2max/export/svg.py +393 -0
- py2max/layout/__init__.py +26 -0
- py2max/layout/base.py +463 -0
- py2max/layout/flow.py +405 -0
- py2max/layout/grid.py +374 -0
- py2max/layout/matrix.py +628 -0
- py2max/log.py +338 -0
- py2max/maxref/__init__.py +78 -0
- py2max/maxref/category.py +163 -0
- py2max/maxref/db.py +1082 -0
- py2max/maxref/legacy.py +324 -0
- py2max/maxref/parser.py +703 -0
- py2max/py.typed +0 -0
- py2max/server/__init__.py +54 -0
- py2max/server/client.py +295 -0
- py2max/server/inline.py +312 -0
- py2max/server/repl.py +561 -0
- py2max/server/rpc.py +240 -0
- py2max/server/websocket.py +997 -0
- py2max/static/cola.min.js +4 -0
- py2max/static/d3.v7.min.js +2 -0
- py2max/static/dagre-bundle.js +328 -0
- py2max/static/elk.bundled.js +6663 -0
- py2max/static/index.html +168 -0
- py2max/static/interactive.html +589 -0
- py2max/static/interactive.js +2111 -0
- py2max/static/live-preview.js +324 -0
- py2max/static/svg.min.js +13 -0
- py2max/static/svg.min.js.map +1 -0
- py2max/transformers.py +168 -0
- py2max/utils.py +83 -0
- py2max-0.2.1.dist-info/METADATA +390 -0
- py2max-0.2.1.dist-info/RECORD +48 -0
- py2max-0.2.1.dist-info/WHEEL +4 -0
- py2max-0.2.1.dist-info/entry_points.txt +3 -0
- py2max-0.2.1.dist-info/licenses/LICENSE +19 -0
py2max/core/patcher.py
ADDED
|
@@ -0,0 +1,1658 @@
|
|
|
1
|
+
"""Patcher class for creating and managing Max/MSP patches.
|
|
2
|
+
|
|
3
|
+
This module contains the main Patcher class for creating Max/MSP patches
|
|
4
|
+
programmatically with automatic layout management and connection validation.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> p = Patcher('out.maxpat')
|
|
8
|
+
>>> osc1 = p.add_textbox('cycle~ 440')
|
|
9
|
+
>>> gain = p.add_textbox('gain~')
|
|
10
|
+
>>> dac = p.add_textbox('ezdac~')
|
|
11
|
+
>>> p.add_line(osc1, gain)
|
|
12
|
+
>>> p.add_line(gain, dac)
|
|
13
|
+
>>> p.save()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Optional, Tuple, Union, cast
|
|
19
|
+
|
|
20
|
+
from py2max import layout as layout_module
|
|
21
|
+
from py2max import maxref
|
|
22
|
+
from py2max.exceptions import InvalidConnectionError, PatcherIOError
|
|
23
|
+
from py2max.log import get_logger, log_operation
|
|
24
|
+
|
|
25
|
+
from .abstract import (
|
|
26
|
+
AbstractBox,
|
|
27
|
+
AbstractLayoutManager,
|
|
28
|
+
AbstractPatcher,
|
|
29
|
+
AbstractPatchline,
|
|
30
|
+
)
|
|
31
|
+
from .box import Box
|
|
32
|
+
from .common import Rect
|
|
33
|
+
from .patchline import Patchline
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# CONSTANTS
|
|
37
|
+
|
|
38
|
+
MAX_VER_MAJOR = 8
|
|
39
|
+
MAX_VER_MINOR = 5
|
|
40
|
+
MAX_VER_REVISION = 5
|
|
41
|
+
|
|
42
|
+
# Module logger
|
|
43
|
+
logger = get_logger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Primary Classes
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Patcher(AbstractPatcher):
|
|
51
|
+
"""Core class for creating and managing Max/MSP patches.
|
|
52
|
+
|
|
53
|
+
The Patcher class provides a high-level interface for creating Max/MSP patches
|
|
54
|
+
programmatically. It handles object positioning, connection validation, and
|
|
55
|
+
automatic layout management.
|
|
56
|
+
|
|
57
|
+
Features:
|
|
58
|
+
- Automatic object positioning with multiple layout managers
|
|
59
|
+
- Connection validation using Max object metadata
|
|
60
|
+
- Support for all major Max object types
|
|
61
|
+
- Hierarchical patch organization with subpatchers
|
|
62
|
+
- Export to .maxpat file format
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
path: Output file path for the patch.
|
|
66
|
+
title: Optional title for the patch.
|
|
67
|
+
parent: Parent patcher for hierarchical organization.
|
|
68
|
+
classnamespace: Namespace for object classes (e.g., 'rnbo').
|
|
69
|
+
reset_on_render: Whether to reset layout on render.
|
|
70
|
+
layout: Layout manager type ('horizontal', 'vertical', 'grid', 'flow', 'matrix').
|
|
71
|
+
auto_hints: Whether to automatically generate object hints.
|
|
72
|
+
openinpresentation: Presentation mode setting.
|
|
73
|
+
validate_connections: Whether to validate patchline connections.
|
|
74
|
+
flow_direction: Direction for flow-based layouts ('horizontal', 'vertical').
|
|
75
|
+
cluster_connected: Whether to cluster connected objects in grid layout.
|
|
76
|
+
num_dimensions: Number of rows used by the matrix layout (also treated as column count when flow_direction='column').
|
|
77
|
+
dimension_spacing: Spacing between rows/columns for matrix layout variants.
|
|
78
|
+
semantic_ids: Whether to generate semantic IDs based on object names (e.g., 'cycle_1')
|
|
79
|
+
instead of numeric IDs (e.g., 'obj-1'). Enables more readable debugging.
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
>>> p = Patcher('my-patch.maxpat', layout='grid')
|
|
83
|
+
>>> osc = p.add_textbox('cycle~ 440')
|
|
84
|
+
>>> gain = p.add_textbox('gain~')
|
|
85
|
+
>>> p.add_line(osc, gain)
|
|
86
|
+
>>> p.save()
|
|
87
|
+
|
|
88
|
+
>>> # With semantic IDs
|
|
89
|
+
>>> p = Patcher('my-patch.maxpat', semantic_ids=True)
|
|
90
|
+
>>> osc1 = p.add_textbox('cycle~ 440') # ID: 'cycle_1'
|
|
91
|
+
>>> osc2 = p.add_textbox('cycle~ 220') # ID: 'cycle_2'
|
|
92
|
+
>>> gain = p.add_textbox('gain~') # ID: 'gain_1'
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
path: Optional[Union[str, Path]] = None,
|
|
98
|
+
title: Optional[str] = None,
|
|
99
|
+
parent: Optional["Patcher"] = None,
|
|
100
|
+
classnamespace: Optional[str] = None,
|
|
101
|
+
reset_on_render: bool = True,
|
|
102
|
+
layout: str = "horizontal",
|
|
103
|
+
auto_hints: bool = False,
|
|
104
|
+
openinpresentation: int = 0,
|
|
105
|
+
validate_connections: bool = False,
|
|
106
|
+
flow_direction: str = "horizontal",
|
|
107
|
+
cluster_connected: bool = False,
|
|
108
|
+
# Matrix layout configuration parameters
|
|
109
|
+
num_dimensions: int = 4,
|
|
110
|
+
dimension_spacing: float = 100.0,
|
|
111
|
+
semantic_ids: bool = False,
|
|
112
|
+
):
|
|
113
|
+
logger.debug(
|
|
114
|
+
f"Initializing Patcher: path={path}, layout={layout}, "
|
|
115
|
+
f"validate_connections={validate_connections}, semantic_ids={semantic_ids}"
|
|
116
|
+
)
|
|
117
|
+
self._path = path
|
|
118
|
+
self._parent = parent
|
|
119
|
+
self._node_ids: list[str] = [] # ids by order of creation
|
|
120
|
+
self._objects: dict[str, AbstractBox] = {} # dict of objects by id
|
|
121
|
+
self._boxes: list[AbstractBox] = [] # store child objects (boxes, etc.)
|
|
122
|
+
self._lines: list[AbstractPatchline] = [] # store patchline objects
|
|
123
|
+
self._edge_ids: list[
|
|
124
|
+
tuple[str, str]
|
|
125
|
+
] = [] # store edge-ids by order of creation
|
|
126
|
+
self._id_counter = 0
|
|
127
|
+
self._link_counter = 0
|
|
128
|
+
self._last_link: Optional[tuple[str, str]] = None
|
|
129
|
+
self._reset_on_render = reset_on_render
|
|
130
|
+
self._semantic_ids = semantic_ids
|
|
131
|
+
self._semantic_counters: dict[str, int] = {} # Track counts per object type
|
|
132
|
+
self._flow_direction = flow_direction
|
|
133
|
+
self._cluster_connected = cluster_connected
|
|
134
|
+
self._num_dimensions = num_dimensions
|
|
135
|
+
self._dimension_spacing = dimension_spacing
|
|
136
|
+
self._layout_mgr: AbstractLayoutManager = self.set_layout_mgr(layout)
|
|
137
|
+
self._auto_hints = auto_hints
|
|
138
|
+
self._validate_connections = validate_connections
|
|
139
|
+
self._pending_comments: list[
|
|
140
|
+
tuple[str, str, Optional[str]]
|
|
141
|
+
] = [] # [(box_id, comment_text, comment_pos), ...]
|
|
142
|
+
self._maxclass_methods = {
|
|
143
|
+
# specialized methods
|
|
144
|
+
"m": self.add_message, # custom -- like keyboard shortcut
|
|
145
|
+
"c": self.add_comment, # custom -- like keyboard shortcut
|
|
146
|
+
"coll": self.add_coll,
|
|
147
|
+
"dict": self.add_dict,
|
|
148
|
+
"table": self.add_table,
|
|
149
|
+
"itable": self.add_itable,
|
|
150
|
+
"umenu": self.add_umenu,
|
|
151
|
+
"bpatcher": self.add_bpatcher,
|
|
152
|
+
}
|
|
153
|
+
# --------------------------------------------------------------------
|
|
154
|
+
# begin max attributes
|
|
155
|
+
if title: # not a default attribute
|
|
156
|
+
self.title = title
|
|
157
|
+
self.fileversion: int = 1
|
|
158
|
+
self.appversion = {
|
|
159
|
+
"major": MAX_VER_MAJOR,
|
|
160
|
+
"minor": MAX_VER_MINOR,
|
|
161
|
+
"revision": MAX_VER_REVISION,
|
|
162
|
+
"architecture": "x64",
|
|
163
|
+
"modernui": 1,
|
|
164
|
+
}
|
|
165
|
+
self.classnamespace = classnamespace or "box"
|
|
166
|
+
self.rect = Rect(85.0, 104.0, 640.0, 480.0)
|
|
167
|
+
self.bglocked = 0
|
|
168
|
+
self.openinpresentation = openinpresentation
|
|
169
|
+
self.default_fontsize = 12.0
|
|
170
|
+
self.default_fontface = 0
|
|
171
|
+
self.default_fontname = "Arial"
|
|
172
|
+
self.gridonopen = 1
|
|
173
|
+
self.gridsize = [15.0, 15.0]
|
|
174
|
+
self.gridsnaponopen = 1
|
|
175
|
+
self.objectsnaponopen = 1
|
|
176
|
+
self.statusbarvisible = 2
|
|
177
|
+
self.toolbarvisible = 1
|
|
178
|
+
self.lefttoolbarpinned = 0
|
|
179
|
+
self.toptoolbarpinned = 0
|
|
180
|
+
self.righttoolbarpinned = 0
|
|
181
|
+
self.bottomtoolbarpinned = 0
|
|
182
|
+
self.toolbars_unpinned_last_save = 0
|
|
183
|
+
self.tallnewobj = 0
|
|
184
|
+
self.boxanimatetime = 200
|
|
185
|
+
self.enablehscroll = 1
|
|
186
|
+
self.enablevscroll = 1
|
|
187
|
+
self.devicewidth = 0.0
|
|
188
|
+
self.description = ""
|
|
189
|
+
self.digest = ""
|
|
190
|
+
self.tags = ""
|
|
191
|
+
self.style = ""
|
|
192
|
+
self.subpatcher_template = ""
|
|
193
|
+
self.assistshowspatchername = 0
|
|
194
|
+
self.boxes: list[dict] = []
|
|
195
|
+
self.lines: list[dict] = []
|
|
196
|
+
# self.parameters: dict = {}
|
|
197
|
+
self.dependency_cache: list = []
|
|
198
|
+
self.autosave = 0
|
|
199
|
+
|
|
200
|
+
def __repr__(self):
|
|
201
|
+
return f"{self.__class__.__name__}(path='{self._path}')"
|
|
202
|
+
|
|
203
|
+
def __iter__(self):
|
|
204
|
+
yield self
|
|
205
|
+
for box in self._boxes:
|
|
206
|
+
yield from iter(box)
|
|
207
|
+
|
|
208
|
+
def find_by_id(self, box_id: str) -> Optional["Box"]:
|
|
209
|
+
"""Find a box by its ID.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
box_id: The ID of the box to find.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
The Box object if found, None otherwise.
|
|
216
|
+
|
|
217
|
+
Example:
|
|
218
|
+
>>> p = Patcher('patch.maxpat')
|
|
219
|
+
>>> osc = p.add_textbox('cycle~ 440')
|
|
220
|
+
>>> found = p.find_by_id(osc.id)
|
|
221
|
+
>>> assert found is osc
|
|
222
|
+
"""
|
|
223
|
+
return cast(Optional["Box"], self._objects.get(box_id))
|
|
224
|
+
|
|
225
|
+
def find_by_type(self, maxclass: str) -> List["Box"]:
|
|
226
|
+
"""Find all boxes of a specific Max object type.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
maxclass: The Max object class name (e.g., 'newobj', 'message', 'comment').
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
List of Box objects matching the type.
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
>>> p = Patcher('patch.maxpat')
|
|
236
|
+
>>> p.add_textbox('cycle~ 440')
|
|
237
|
+
>>> p.add_textbox('saw~ 220')
|
|
238
|
+
>>> p.add_message('bang')
|
|
239
|
+
>>> oscillators = [b for b in p.find_by_type('newobj')
|
|
240
|
+
... if 'cycle~' in b.text or 'saw~' in b.text]
|
|
241
|
+
>>> assert len(oscillators) == 2
|
|
242
|
+
"""
|
|
243
|
+
return cast(
|
|
244
|
+
List["Box"],
|
|
245
|
+
[box for box in self._boxes if getattr(box, "maxclass", None) == maxclass],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def find_by_text(self, pattern: str, case_sensitive: bool = False) -> List["Box"]:
|
|
249
|
+
"""Find all boxes whose text matches a pattern.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
pattern: The text pattern to search for (substring match).
|
|
253
|
+
case_sensitive: Whether the search should be case-sensitive (default: False).
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
List of Box objects whose text contains the pattern.
|
|
257
|
+
|
|
258
|
+
Example:
|
|
259
|
+
>>> p = Patcher('patch.maxpat')
|
|
260
|
+
>>> p.add_textbox('cycle~ 440')
|
|
261
|
+
>>> p.add_textbox('saw~ 220')
|
|
262
|
+
>>> p.add_textbox('gain~ 0.5')
|
|
263
|
+
>>> oscillators = p.find_by_text('~')
|
|
264
|
+
>>> assert len(oscillators) == 3
|
|
265
|
+
>>> just_cycle = p.find_by_text('cycle')
|
|
266
|
+
>>> assert len(just_cycle) == 1
|
|
267
|
+
"""
|
|
268
|
+
results = []
|
|
269
|
+
for box in self._boxes:
|
|
270
|
+
text = getattr(box, "text", "") or ""
|
|
271
|
+
if not case_sensitive:
|
|
272
|
+
if pattern.lower() in text.lower():
|
|
273
|
+
results.append(box)
|
|
274
|
+
else:
|
|
275
|
+
if pattern in text:
|
|
276
|
+
results.append(box)
|
|
277
|
+
return cast(List["Box"], results)
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def filepath(self) -> Union[str, Path]:
|
|
281
|
+
if self._path is None:
|
|
282
|
+
return ""
|
|
283
|
+
return self._path
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def width(self) -> float:
|
|
287
|
+
"""width of patcher window."""
|
|
288
|
+
return self.rect.w
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def height(self) -> float:
|
|
292
|
+
"""height of patcher windows."""
|
|
293
|
+
return self.rect.h
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def from_dict(cls, patcher_dict: dict, save_to: Optional[str] = None) -> "Patcher":
|
|
297
|
+
"""create a patcher instance from a dict"""
|
|
298
|
+
|
|
299
|
+
if save_to:
|
|
300
|
+
patcher = cls(save_to)
|
|
301
|
+
else:
|
|
302
|
+
patcher = cls()
|
|
303
|
+
patcher.__dict__.update(patcher_dict)
|
|
304
|
+
|
|
305
|
+
for box_dict in patcher.boxes:
|
|
306
|
+
box = box_dict["box"]
|
|
307
|
+
b = Box.from_dict(box)
|
|
308
|
+
assert b.id, "box must have id"
|
|
309
|
+
patcher._objects[b.id] = b
|
|
310
|
+
# b = patcher.box_from_dict(box)
|
|
311
|
+
patcher._boxes.append(b)
|
|
312
|
+
|
|
313
|
+
# Set parent reference for nested subpatchers
|
|
314
|
+
if hasattr(b, "_patcher") and b._patcher is not None:
|
|
315
|
+
b._patcher._parent = patcher
|
|
316
|
+
|
|
317
|
+
for line_dict in patcher.lines:
|
|
318
|
+
line = line_dict["patchline"]
|
|
319
|
+
pl = Patchline.from_dict(line)
|
|
320
|
+
patcher._lines.append(pl)
|
|
321
|
+
|
|
322
|
+
return patcher
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def from_file(
|
|
326
|
+
cls, path: Union[str, Path], save_to: Optional[str] = None
|
|
327
|
+
) -> "Patcher":
|
|
328
|
+
"""create a patcher instance from a .maxpat json file"""
|
|
329
|
+
|
|
330
|
+
with open(path, encoding="utf8") as f:
|
|
331
|
+
maxpat = json.load(f)
|
|
332
|
+
return Patcher.from_dict(maxpat["patcher"], save_to)
|
|
333
|
+
|
|
334
|
+
def to_dict(self) -> dict:
|
|
335
|
+
"""create dict from object with extra kwds included"""
|
|
336
|
+
d = vars(self).copy()
|
|
337
|
+
to_del = [k for k in d if k.startswith("_")]
|
|
338
|
+
for k in to_del:
|
|
339
|
+
del d[k]
|
|
340
|
+
if not self._parent:
|
|
341
|
+
return dict(patcher=d)
|
|
342
|
+
return d
|
|
343
|
+
|
|
344
|
+
def to_json(self) -> str:
|
|
345
|
+
"""cascade convert to json"""
|
|
346
|
+
self.render()
|
|
347
|
+
return json.dumps(self.to_dict(), indent=4)
|
|
348
|
+
|
|
349
|
+
def find(self, text: str) -> Optional["Box"]:
|
|
350
|
+
"""Find box object by maxclass or text pattern.
|
|
351
|
+
|
|
352
|
+
Recursively searches through all objects in the patch to find
|
|
353
|
+
one matching the specified maxclass or text pattern.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
text: The maxclass name or text pattern to search for.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
The first matching Box object, or None if not found.
|
|
360
|
+
"""
|
|
361
|
+
for obj in self:
|
|
362
|
+
if not isinstance(obj, Patcher):
|
|
363
|
+
if obj.maxclass == text:
|
|
364
|
+
return obj
|
|
365
|
+
if hasattr(obj, "text"):
|
|
366
|
+
if obj.text and obj.text.startswith(text):
|
|
367
|
+
return obj
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
def find_box(self, text: str) -> Optional["Box"]:
|
|
371
|
+
"""find box object by maxclass or type
|
|
372
|
+
|
|
373
|
+
returns box if found else None
|
|
374
|
+
"""
|
|
375
|
+
for box in self._objects.values():
|
|
376
|
+
if box.maxclass == text:
|
|
377
|
+
return cast("Box", box)
|
|
378
|
+
if hasattr(box, "text"):
|
|
379
|
+
if box.text and box.text.startswith(text):
|
|
380
|
+
return cast("Box", box)
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
def find_box_with_index(self, text: str) -> Optional[Tuple[int, "Box"]]:
|
|
384
|
+
"""find box object by maxclass or type
|
|
385
|
+
|
|
386
|
+
returns (index, box) if found
|
|
387
|
+
"""
|
|
388
|
+
for i, box in enumerate(self._boxes):
|
|
389
|
+
if box.maxclass == text:
|
|
390
|
+
return (i, cast("Box", box))
|
|
391
|
+
if hasattr(box, "text"):
|
|
392
|
+
if box.text and box.text.startswith(text):
|
|
393
|
+
return (i, cast("Box", box))
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
def render(self, reset: bool = False) -> None:
|
|
397
|
+
"""cascade convert py2max objects to dicts."""
|
|
398
|
+
if reset or self._reset_on_render:
|
|
399
|
+
self.boxes = []
|
|
400
|
+
self.lines = []
|
|
401
|
+
for box in self._boxes:
|
|
402
|
+
box.render()
|
|
403
|
+
self.boxes.append(box.to_dict())
|
|
404
|
+
self.lines = [line.to_dict() for line in self._lines]
|
|
405
|
+
|
|
406
|
+
def save_as(self, path: Union[str, Path]) -> None:
|
|
407
|
+
"""Save the patch to a specified file path with security validation.
|
|
408
|
+
|
|
409
|
+
Renders all objects and connections, then saves the patch as a
|
|
410
|
+
.maxpat JSON file that can be opened in Max/MSP.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
path: File path where the patch should be saved.
|
|
414
|
+
|
|
415
|
+
Raises:
|
|
416
|
+
PatcherIOError: If file cannot be written or path is invalid.
|
|
417
|
+
"""
|
|
418
|
+
logger.debug(f"Saving patcher to: {path}")
|
|
419
|
+
path = Path(path)
|
|
420
|
+
|
|
421
|
+
# Security: Validate path to prevent path traversal attacks
|
|
422
|
+
try:
|
|
423
|
+
# Check for suspicious path components BEFORE resolving
|
|
424
|
+
path_str = str(path)
|
|
425
|
+
if (
|
|
426
|
+
".." in path.parts
|
|
427
|
+
or path_str.startswith("/etc")
|
|
428
|
+
or path_str.startswith("/sys")
|
|
429
|
+
):
|
|
430
|
+
raise PatcherIOError(
|
|
431
|
+
f"Invalid path detected (potential path traversal): {path}",
|
|
432
|
+
file_path=str(path),
|
|
433
|
+
operation="validate",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Resolve path to absolute and normalize it
|
|
437
|
+
resolved_path = path.resolve()
|
|
438
|
+
|
|
439
|
+
# Check for path traversal attempts
|
|
440
|
+
# Ensure the resolved path is within the current working directory or user-specified location
|
|
441
|
+
# This prevents attacks like ../../etc/passwd
|
|
442
|
+
if not resolved_path.is_absolute():
|
|
443
|
+
raise PatcherIOError(
|
|
444
|
+
f"Path must resolve to absolute path: {path}",
|
|
445
|
+
file_path=str(path),
|
|
446
|
+
operation="validate",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
except PatcherIOError:
|
|
450
|
+
# Re-raise PatcherIOError exceptions as-is
|
|
451
|
+
raise
|
|
452
|
+
except (OSError, RuntimeError) as e:
|
|
453
|
+
raise PatcherIOError(
|
|
454
|
+
f"Invalid file path: {path}", file_path=str(path), operation="validate"
|
|
455
|
+
) from e
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
# Create parent directories if needed
|
|
459
|
+
if resolved_path.parent:
|
|
460
|
+
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
461
|
+
logger.debug(f"Created parent directories: {resolved_path.parent}")
|
|
462
|
+
|
|
463
|
+
with log_operation(
|
|
464
|
+
logger, "render patcher", boxes=len(self._boxes), lines=len(self._lines)
|
|
465
|
+
):
|
|
466
|
+
self.render()
|
|
467
|
+
|
|
468
|
+
# Use resolved path for writing
|
|
469
|
+
with open(resolved_path, "w", encoding="utf8") as f:
|
|
470
|
+
json.dump(self.to_dict(), f, indent=4)
|
|
471
|
+
|
|
472
|
+
logger.info(
|
|
473
|
+
f"Saved patcher to: {resolved_path} ({len(self._boxes)} objects, {len(self._lines)} connections)"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
except IOError as e:
|
|
477
|
+
logger.error(f"Failed to write patcher to: {resolved_path}")
|
|
478
|
+
raise PatcherIOError(
|
|
479
|
+
"Failed to write patcher file",
|
|
480
|
+
file_path=str(resolved_path),
|
|
481
|
+
operation="write",
|
|
482
|
+
) from e
|
|
483
|
+
|
|
484
|
+
def save(self) -> None:
|
|
485
|
+
"""Save the patch to the default file path.
|
|
486
|
+
|
|
487
|
+
Uses the path specified during Patcher creation. If no path
|
|
488
|
+
was specified, this method will do nothing. Before saving,
|
|
489
|
+
processes any pending associated comments to ensure they are
|
|
490
|
+
positioned correctly relative to their boxes.
|
|
491
|
+
"""
|
|
492
|
+
# Process pending comments before saving
|
|
493
|
+
self._process_pending_comments()
|
|
494
|
+
|
|
495
|
+
if self._path:
|
|
496
|
+
self.save_as(self._path)
|
|
497
|
+
|
|
498
|
+
def to_svg(
|
|
499
|
+
self,
|
|
500
|
+
output_path: Union[str, Path],
|
|
501
|
+
show_ports: bool = True,
|
|
502
|
+
title: Optional[str] = None,
|
|
503
|
+
) -> None:
|
|
504
|
+
"""Export this patcher to SVG format.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
output_path: Output file path for the SVG.
|
|
508
|
+
show_ports: Whether to show inlet/outlet ports on boxes.
|
|
509
|
+
title: Optional title to display at top of SVG.
|
|
510
|
+
|
|
511
|
+
Example:
|
|
512
|
+
>>> p = Patcher('my-patch.maxpat')
|
|
513
|
+
>>> osc = p.add_textbox('cycle~ 440')
|
|
514
|
+
>>> dac = p.add_textbox('ezdac~')
|
|
515
|
+
>>> p.add_line(osc, dac)
|
|
516
|
+
>>> p.to_svg('/tmp/my-patch.svg')
|
|
517
|
+
"""
|
|
518
|
+
from ..export.svg import export_svg
|
|
519
|
+
|
|
520
|
+
export_svg(self, output_path, show_ports=show_ports, title=title)
|
|
521
|
+
|
|
522
|
+
def to_svg_string(
|
|
523
|
+
self,
|
|
524
|
+
show_ports: bool = True,
|
|
525
|
+
title: Optional[str] = None,
|
|
526
|
+
) -> str:
|
|
527
|
+
"""Export this patcher to SVG format as a string.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
show_ports: Whether to show inlet/outlet ports on boxes.
|
|
531
|
+
title: Optional title to display at top of SVG.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
SVG content as a string.
|
|
535
|
+
|
|
536
|
+
Example:
|
|
537
|
+
>>> p = Patcher('my-patch.maxpat')
|
|
538
|
+
>>> osc = p.add_textbox('cycle~ 440')
|
|
539
|
+
>>> svg_content = p.to_svg_string()
|
|
540
|
+
"""
|
|
541
|
+
from ..export.svg import export_svg_string
|
|
542
|
+
|
|
543
|
+
return export_svg_string(self, show_ports=show_ports, title=title)
|
|
544
|
+
|
|
545
|
+
async def serve(self, port: int = 8000, auto_open: bool = True):
|
|
546
|
+
"""Start an interactive WebSocket server for this patcher.
|
|
547
|
+
|
|
548
|
+
Opens a web browser with interactive editor that allows bidirectional
|
|
549
|
+
editing between Python and the browser. Supports drag-and-drop,
|
|
550
|
+
connection drawing, and object creation.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
port: HTTP server port (default: 8000, WebSocket on port+1)
|
|
554
|
+
auto_open: Automatically open browser (default: True)
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
InteractivePatcherServer instance (async context manager)
|
|
558
|
+
|
|
559
|
+
Example:
|
|
560
|
+
>>> p = Patcher('demo.maxpat')
|
|
561
|
+
>>> async with await p.serve() as server:
|
|
562
|
+
... # Edit in browser - changes sync back to Python!
|
|
563
|
+
... await asyncio.sleep(10)
|
|
564
|
+
|
|
565
|
+
Note:
|
|
566
|
+
Requires websockets package: pip install websockets
|
|
567
|
+
"""
|
|
568
|
+
from py2max.server import serve_interactive
|
|
569
|
+
|
|
570
|
+
self._server = await serve_interactive(self, port, auto_open)
|
|
571
|
+
return self._server
|
|
572
|
+
|
|
573
|
+
def get_id(self, object_name: Optional[str] = None) -> str:
|
|
574
|
+
"""Generate object ID, optionally semantic based on object name.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
object_name: Optional Max object name (e.g., 'cycle~', 'gain~').
|
|
578
|
+
Used to generate semantic IDs like 'cycle_1' when
|
|
579
|
+
semantic_ids mode is enabled.
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
Object ID string (e.g., 'obj-5' or 'cycle_1').
|
|
583
|
+
"""
|
|
584
|
+
if self._semantic_ids and object_name:
|
|
585
|
+
# Sanitize object name (remove ~, spaces, special chars)
|
|
586
|
+
clean_name = (
|
|
587
|
+
object_name.replace("~", "")
|
|
588
|
+
.replace(" ", "_")
|
|
589
|
+
.replace(".", "_")
|
|
590
|
+
.replace("-", "_")
|
|
591
|
+
.replace("[", "")
|
|
592
|
+
.replace("]", "")
|
|
593
|
+
.replace("(", "")
|
|
594
|
+
.replace(")", "")
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
# Get or increment counter for this object type
|
|
598
|
+
count = self._semantic_counters.get(clean_name, 0) + 1
|
|
599
|
+
self._semantic_counters[clean_name] = count
|
|
600
|
+
return f"{clean_name}_{count}"
|
|
601
|
+
else:
|
|
602
|
+
# Standard numeric ID
|
|
603
|
+
self._id_counter += 1
|
|
604
|
+
return f"obj-{self._id_counter}"
|
|
605
|
+
|
|
606
|
+
def set_layout_mgr(self, name: str) -> layout_module.LayoutManager:
|
|
607
|
+
"""takes a name and returns an instance of a layout manager"""
|
|
608
|
+
if name == "horizontal":
|
|
609
|
+
return layout_module.HorizontalLayoutManager(self)
|
|
610
|
+
elif name == "vertical":
|
|
611
|
+
return layout_module.VerticalLayoutManager(self)
|
|
612
|
+
elif name == "flow":
|
|
613
|
+
return layout_module.FlowLayoutManager(
|
|
614
|
+
self, flow_direction=self._flow_direction
|
|
615
|
+
)
|
|
616
|
+
elif name == "grid":
|
|
617
|
+
return layout_module.GridLayoutManager(
|
|
618
|
+
self,
|
|
619
|
+
flow_direction=self._flow_direction,
|
|
620
|
+
cluster_connected=self._cluster_connected,
|
|
621
|
+
)
|
|
622
|
+
elif name == "matrix":
|
|
623
|
+
return layout_module.MatrixLayoutManager(
|
|
624
|
+
self,
|
|
625
|
+
flow_direction=self._flow_direction,
|
|
626
|
+
num_dimensions=self._num_dimensions,
|
|
627
|
+
dimension_spacing=self._dimension_spacing,
|
|
628
|
+
)
|
|
629
|
+
else:
|
|
630
|
+
raise NotImplementedError(f"layout '{name}' doesn't exist")
|
|
631
|
+
|
|
632
|
+
def get_pos(self, maxclass: Optional[str] = None) -> Rect:
|
|
633
|
+
"""get box rect (position) via maxclass or layout_manager"""
|
|
634
|
+
if maxclass:
|
|
635
|
+
return self._layout_mgr.get_pos(maxclass)
|
|
636
|
+
return self._layout_mgr.get_pos()
|
|
637
|
+
|
|
638
|
+
def optimize_layout(self) -> None:
|
|
639
|
+
"""Optimize object positions based on layout manager type.
|
|
640
|
+
|
|
641
|
+
Calls the layout manager's optimization method to improve object
|
|
642
|
+
positioning, then repositions any associated comments based on
|
|
643
|
+
the new box positions. The effect depends on the layout manager:
|
|
644
|
+
|
|
645
|
+
- FlowLayoutManager: Arranges objects by signal flow topology
|
|
646
|
+
- GridLayoutManager: Clusters connected objects together
|
|
647
|
+
- Other managers: May have limited or no effect
|
|
648
|
+
|
|
649
|
+
This method should be called after all objects and connections
|
|
650
|
+
have been added to the patch.
|
|
651
|
+
"""
|
|
652
|
+
if hasattr(self._layout_mgr, "optimize_layout"):
|
|
653
|
+
self._layout_mgr.optimize_layout()
|
|
654
|
+
|
|
655
|
+
# Process pending comments after layout optimization
|
|
656
|
+
self._process_pending_comments()
|
|
657
|
+
|
|
658
|
+
def _get_object_name(self, obj: AbstractBox) -> str:
|
|
659
|
+
"""Get the actual object name for validation purposes.
|
|
660
|
+
|
|
661
|
+
For 'newobj' maxclass objects, extract the first word from the text field.
|
|
662
|
+
For other objects, use the maxclass directly.
|
|
663
|
+
"""
|
|
664
|
+
if obj.maxclass == "newobj":
|
|
665
|
+
# Text is stored in _kwds for Box objects
|
|
666
|
+
text = obj._kwds.get("text", "")
|
|
667
|
+
if text:
|
|
668
|
+
# Extract the first word from text (the object name)
|
|
669
|
+
return text.split()[0] if text.split() else obj.maxclass
|
|
670
|
+
return obj.maxclass
|
|
671
|
+
|
|
672
|
+
def add_box(
|
|
673
|
+
self,
|
|
674
|
+
box: "Box",
|
|
675
|
+
comment: Optional[str] = None,
|
|
676
|
+
comment_pos: Optional[str] = None,
|
|
677
|
+
) -> "Box":
|
|
678
|
+
"""registers the box and adds it to the patcher"""
|
|
679
|
+
|
|
680
|
+
assert box.id, f"object {box} must have an id"
|
|
681
|
+
self._node_ids.append(box.id)
|
|
682
|
+
self._objects[box.id] = box
|
|
683
|
+
self._boxes.append(box)
|
|
684
|
+
if comment:
|
|
685
|
+
self.add_associated_comment(box, comment, comment_pos)
|
|
686
|
+
return box
|
|
687
|
+
|
|
688
|
+
def add_associated_comment(
|
|
689
|
+
self, box: "Box", comment: str, comment_pos: Optional[str] = None
|
|
690
|
+
):
|
|
691
|
+
"""Store a comment association to be processed later during layout optimization or save.
|
|
692
|
+
|
|
693
|
+
This defers the actual comment positioning until after layout optimization,
|
|
694
|
+
ensuring comments stay properly positioned relative to their associated boxes.
|
|
695
|
+
"""
|
|
696
|
+
|
|
697
|
+
if comment_pos:
|
|
698
|
+
assert comment_pos in [
|
|
699
|
+
"above",
|
|
700
|
+
"below",
|
|
701
|
+
"right",
|
|
702
|
+
"left",
|
|
703
|
+
], f"comment:{comment} / comment_pos: {comment_pos}"
|
|
704
|
+
|
|
705
|
+
# Store the association for deferred processing
|
|
706
|
+
if box.id is None:
|
|
707
|
+
raise AssertionError("associated comment requires box with id")
|
|
708
|
+
self._pending_comments.append((box.id, comment, comment_pos))
|
|
709
|
+
|
|
710
|
+
def _process_pending_comments(self) -> None:
|
|
711
|
+
"""Process all pending comment associations and position comments relative to their boxes.
|
|
712
|
+
|
|
713
|
+
This method is called during layout optimization and save operations to ensure
|
|
714
|
+
comments are positioned correctly after any layout changes.
|
|
715
|
+
"""
|
|
716
|
+
for box_id, comment_text, comment_pos in self._pending_comments:
|
|
717
|
+
if box_id not in self._objects:
|
|
718
|
+
continue # Skip if box was removed
|
|
719
|
+
|
|
720
|
+
box = self._objects[box_id]
|
|
721
|
+
rect = box.patching_rect
|
|
722
|
+
x, y, w, h = rect
|
|
723
|
+
|
|
724
|
+
# Adjust rect height if needed
|
|
725
|
+
if h != self._layout_mgr.box_height:
|
|
726
|
+
if box.maxclass in maxref.MAXCLASS_DEFAULTS:
|
|
727
|
+
dh: float = 0.0
|
|
728
|
+
_, _, _, dh = maxref.MAXCLASS_DEFAULTS[box.maxclass][
|
|
729
|
+
"patching_rect"
|
|
730
|
+
]
|
|
731
|
+
rect = Rect(x, y, w, dh)
|
|
732
|
+
else:
|
|
733
|
+
h = self._layout_mgr.box_height
|
|
734
|
+
rect = Rect(x, y, w, h)
|
|
735
|
+
|
|
736
|
+
# Calculate comment position
|
|
737
|
+
if comment_pos:
|
|
738
|
+
patching_rect = getattr(self._layout_mgr, comment_pos)(rect)
|
|
739
|
+
else:
|
|
740
|
+
patching_rect = self._layout_mgr.above(rect)
|
|
741
|
+
|
|
742
|
+
# Create the comment with appropriate justification
|
|
743
|
+
if comment_pos == "left": # special case
|
|
744
|
+
self.add_comment(comment_text, patching_rect, justify="right")
|
|
745
|
+
else:
|
|
746
|
+
self.add_comment(comment_text, patching_rect)
|
|
747
|
+
|
|
748
|
+
# Clear pending comments after processing
|
|
749
|
+
self._pending_comments.clear()
|
|
750
|
+
|
|
751
|
+
def add_patchline_by_index(
|
|
752
|
+
self, src_id: str, dst_id: str, dst_inlet: int = 0, src_outlet: int = 0
|
|
753
|
+
) -> "Patchline":
|
|
754
|
+
"""Patchline creation between two objects using stored indexes"""
|
|
755
|
+
|
|
756
|
+
src = self._objects[src_id]
|
|
757
|
+
dst = self._objects[dst_id]
|
|
758
|
+
assert src.id and dst.id, f"object {src} and {dst} require ids"
|
|
759
|
+
return self.add_patchline(src.id, src_outlet, dst.id, dst_inlet)
|
|
760
|
+
|
|
761
|
+
def add_patchline(
|
|
762
|
+
self, src_id: str, src_outlet: int, dst_id: str, dst_inlet: int
|
|
763
|
+
) -> "Patchline":
|
|
764
|
+
"""Primary patchline creation method with validation and logging.
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
src_id: Source object ID.
|
|
768
|
+
src_outlet: Source outlet index.
|
|
769
|
+
dst_id: Destination object ID.
|
|
770
|
+
dst_inlet: Destination inlet index.
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
Created Patchline object.
|
|
774
|
+
|
|
775
|
+
Raises:
|
|
776
|
+
InvalidConnectionError: If connection validation fails.
|
|
777
|
+
"""
|
|
778
|
+
logger.debug(
|
|
779
|
+
f"Adding patchline: {src_id}[{src_outlet}] -> {dst_id}[{dst_inlet}]"
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
# Validate connection if validation is enabled
|
|
783
|
+
if self._validate_connections:
|
|
784
|
+
src_obj = self._objects.get(src_id)
|
|
785
|
+
dst_obj = self._objects.get(dst_id)
|
|
786
|
+
|
|
787
|
+
if not src_obj:
|
|
788
|
+
raise InvalidConnectionError(
|
|
789
|
+
f"Source object not found: {src_id}",
|
|
790
|
+
src=src_id,
|
|
791
|
+
dst=dst_id,
|
|
792
|
+
outlet=src_outlet,
|
|
793
|
+
inlet=dst_inlet,
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
if not dst_obj:
|
|
797
|
+
raise InvalidConnectionError(
|
|
798
|
+
f"Destination object not found: {dst_id}",
|
|
799
|
+
src=src_id,
|
|
800
|
+
dst=dst_id,
|
|
801
|
+
outlet=src_outlet,
|
|
802
|
+
inlet=dst_inlet,
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
# Get the actual object names for validation
|
|
806
|
+
src_name = self._get_object_name(src_obj)
|
|
807
|
+
dst_name = self._get_object_name(dst_obj)
|
|
808
|
+
|
|
809
|
+
is_valid, error_msg = maxref.validate_connection(
|
|
810
|
+
src_name, src_outlet, dst_name, dst_inlet
|
|
811
|
+
)
|
|
812
|
+
if not is_valid:
|
|
813
|
+
logger.warning(
|
|
814
|
+
f"Connection validation failed: {src_name}[{src_outlet}] -> {dst_name}[{dst_inlet}]: {error_msg}"
|
|
815
|
+
)
|
|
816
|
+
raise InvalidConnectionError(
|
|
817
|
+
f"Invalid connection from {src_name}[{src_outlet}] to {dst_name}[{dst_inlet}]: {error_msg}",
|
|
818
|
+
src=src_id,
|
|
819
|
+
dst=dst_id,
|
|
820
|
+
outlet=src_outlet,
|
|
821
|
+
inlet=dst_inlet,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
# get order of lines between same pair of objects
|
|
825
|
+
if (src_id, dst_id) == self._last_link:
|
|
826
|
+
self._link_counter += 1
|
|
827
|
+
else:
|
|
828
|
+
self._link_counter = 0
|
|
829
|
+
self._last_link = (src_id, dst_id)
|
|
830
|
+
|
|
831
|
+
order = self._link_counter
|
|
832
|
+
src, dst = [src_id, src_outlet], [dst_id, dst_inlet]
|
|
833
|
+
patchline = Patchline(source=src, destination=dst, order=order)
|
|
834
|
+
self._lines.append(patchline)
|
|
835
|
+
self._edge_ids.append((src_id, dst_id))
|
|
836
|
+
|
|
837
|
+
logger.debug(
|
|
838
|
+
f"Created patchline (order={order}): {src_id}[{src_outlet}] -> {dst_id}[{dst_inlet}]"
|
|
839
|
+
)
|
|
840
|
+
return patchline
|
|
841
|
+
|
|
842
|
+
def add_line(
|
|
843
|
+
self, src_obj: "Box", dst_obj: "Box", inlet: int = 0, outlet: int = 0
|
|
844
|
+
) -> "Patchline":
|
|
845
|
+
"""Create a connection between two objects.
|
|
846
|
+
|
|
847
|
+
Connects an outlet of the source object to an inlet of the destination
|
|
848
|
+
object. Validates the connection if validation is enabled.
|
|
849
|
+
|
|
850
|
+
Args:
|
|
851
|
+
src_obj: Source object to connect from.
|
|
852
|
+
dst_obj: Destination object to connect to.
|
|
853
|
+
inlet: Destination inlet index (default: 0).
|
|
854
|
+
outlet: Source outlet index (default: 0).
|
|
855
|
+
|
|
856
|
+
Returns:
|
|
857
|
+
The created Patchline object.
|
|
858
|
+
|
|
859
|
+
Raises:
|
|
860
|
+
InvalidConnectionError: If connection validation fails.
|
|
861
|
+
|
|
862
|
+
Example:
|
|
863
|
+
>>> osc = p.add_textbox('cycle~ 440')
|
|
864
|
+
>>> gain = p.add_textbox('gain~')
|
|
865
|
+
>>> p.add_line(osc, gain) # Connect outlet 0 to inlet 0
|
|
866
|
+
"""
|
|
867
|
+
assert src_obj.id and dst_obj.id, f"objects {src_obj} and {dst_obj} require ids"
|
|
868
|
+
return self.add_patchline(src_obj.id, outlet, dst_obj.id, inlet)
|
|
869
|
+
|
|
870
|
+
# alias for add_line
|
|
871
|
+
link = add_line
|
|
872
|
+
|
|
873
|
+
def add_textbox(
|
|
874
|
+
self,
|
|
875
|
+
text: str,
|
|
876
|
+
maxclass: Optional[str] = None,
|
|
877
|
+
numinlets: Optional[int] = None,
|
|
878
|
+
numoutlets: Optional[int] = None,
|
|
879
|
+
outlettype: Optional[List[str]] = None,
|
|
880
|
+
patching_rect: Optional[Rect] = None,
|
|
881
|
+
id: Optional[str] = None,
|
|
882
|
+
comment: Optional[str] = None,
|
|
883
|
+
comment_pos: Optional[str] = None,
|
|
884
|
+
**kwds,
|
|
885
|
+
) -> "Box":
|
|
886
|
+
"""Add a text-based Max object to the patch.
|
|
887
|
+
|
|
888
|
+
Creates a Max object from a text specification (e.g., 'cycle~ 440').
|
|
889
|
+
Automatically looks up default attributes and applies appropriate
|
|
890
|
+
maxclass based on the object type.
|
|
891
|
+
|
|
892
|
+
Args:
|
|
893
|
+
text: Max object specification (e.g., 'cycle~ 440', 'gain~').
|
|
894
|
+
maxclass: Override the automatically determined maxclass.
|
|
895
|
+
numinlets: Number of input connections.
|
|
896
|
+
numoutlets: Number of output connections.
|
|
897
|
+
outlettype: Types of outputs (e.g., ['signal', 'int']).
|
|
898
|
+
patching_rect: Position and size rectangle.
|
|
899
|
+
id: Unique identifier for the object.
|
|
900
|
+
comment: Optional comment text.
|
|
901
|
+
comment_pos: Comment position ('above', 'below', etc.).
|
|
902
|
+
**kwds: Additional Max object properties.
|
|
903
|
+
|
|
904
|
+
Returns:
|
|
905
|
+
The created Box object.
|
|
906
|
+
|
|
907
|
+
Example:
|
|
908
|
+
>>> osc = p.add_textbox('cycle~ 440')
|
|
909
|
+
>>> gain = p.add_textbox('gain~')
|
|
910
|
+
>>> metro = p.add_textbox('metro 500')
|
|
911
|
+
"""
|
|
912
|
+
_maxclass, *tail = text.split()
|
|
913
|
+
|
|
914
|
+
defaults = maxref.MAXCLASS_DEFAULTS.get(_maxclass)
|
|
915
|
+
|
|
916
|
+
if defaults:
|
|
917
|
+
if maxclass is None and defaults.get("maxclass"):
|
|
918
|
+
maxclass = defaults["maxclass"]
|
|
919
|
+
|
|
920
|
+
if numinlets is None and "numinlets" in defaults:
|
|
921
|
+
numinlets = defaults["numinlets"]
|
|
922
|
+
|
|
923
|
+
if numoutlets is None and "numoutlets" in defaults:
|
|
924
|
+
numoutlets = defaults["numoutlets"]
|
|
925
|
+
|
|
926
|
+
if outlettype is None and "outlettype" in defaults:
|
|
927
|
+
outlettype = defaults["outlettype"]
|
|
928
|
+
|
|
929
|
+
kwds = self._textbox_helper(_maxclass, kwds)
|
|
930
|
+
|
|
931
|
+
layout_rect = self.get_pos(maxclass) if maxclass else self.get_pos()
|
|
932
|
+
if patching_rect is None and defaults and defaults.get("patching_rect"):
|
|
933
|
+
default_rect = defaults["patching_rect"]
|
|
934
|
+
patching_rect = Rect(
|
|
935
|
+
layout_rect.x, layout_rect.y, default_rect.w, default_rect.h
|
|
936
|
+
)
|
|
937
|
+
elif patching_rect is None:
|
|
938
|
+
patching_rect = layout_rect
|
|
939
|
+
|
|
940
|
+
return self.add_box(
|
|
941
|
+
Box(
|
|
942
|
+
id=id or self.get_id(_maxclass),
|
|
943
|
+
text=text,
|
|
944
|
+
maxclass=maxclass or "newobj",
|
|
945
|
+
numinlets=numinlets if numinlets is not None else 1,
|
|
946
|
+
numoutlets=numoutlets if numoutlets is not None else 0,
|
|
947
|
+
outlettype=outlettype if outlettype is not None else [""],
|
|
948
|
+
patching_rect=patching_rect,
|
|
949
|
+
**kwds,
|
|
950
|
+
),
|
|
951
|
+
comment,
|
|
952
|
+
comment_pos,
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
def _textbox_helper(self, maxclass, kwds: dict) -> dict:
|
|
956
|
+
"""adds special case support for textbox"""
|
|
957
|
+
if self.classnamespace == "rnbo":
|
|
958
|
+
kwds["rnbo_classname"] = maxclass
|
|
959
|
+
if maxclass in ["codebox", "codebox~"]:
|
|
960
|
+
if "code" in kwds and "rnbo_extra_attributes" not in kwds:
|
|
961
|
+
if "\r" not in kwds["code"]:
|
|
962
|
+
kwds["code"] = kwds["code"].replace("\n", "\r\n")
|
|
963
|
+
kwds["rnbo_extra_attributes"] = dict(
|
|
964
|
+
code=kwds["code"],
|
|
965
|
+
hot=0,
|
|
966
|
+
)
|
|
967
|
+
return kwds
|
|
968
|
+
|
|
969
|
+
def _add_float(self, value, *args, **kwds) -> "Box":
|
|
970
|
+
"""type-handler for float values in `add`"""
|
|
971
|
+
|
|
972
|
+
assert isinstance(value, float)
|
|
973
|
+
name = None
|
|
974
|
+
if args:
|
|
975
|
+
name = args[0]
|
|
976
|
+
elif "name" in kwds:
|
|
977
|
+
name = kwds.get("name")
|
|
978
|
+
else:
|
|
979
|
+
return self.add_floatparam(longname="", initial=value, **kwds)
|
|
980
|
+
|
|
981
|
+
if isinstance(name, str):
|
|
982
|
+
return self.add_floatparam(longname=name, initial=value, **kwds)
|
|
983
|
+
raise ValueError(
|
|
984
|
+
"should be: .add(<float>, '<name>') OR .add(<float>, name='<name>')"
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
def _add_int(self, value, *args, **kwds) -> "Box":
|
|
988
|
+
"""type-handler for int values in `add`"""
|
|
989
|
+
|
|
990
|
+
assert isinstance(value, int)
|
|
991
|
+
name = None
|
|
992
|
+
if args:
|
|
993
|
+
name = args[0]
|
|
994
|
+
elif "name" in kwds:
|
|
995
|
+
name = kwds.get("name")
|
|
996
|
+
else:
|
|
997
|
+
return self.add_intparam(longname="", initial=value, **kwds)
|
|
998
|
+
|
|
999
|
+
if isinstance(name, str):
|
|
1000
|
+
return self.add_intparam(longname=name, initial=value, **kwds)
|
|
1001
|
+
raise ValueError(
|
|
1002
|
+
"should be: .add(<int>, '<name>') OR .add(<int>, name='<name>')"
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
def _add_str(self, value, *args, **kwds) -> "Box":
|
|
1006
|
+
"""type-handler for str values in `add`"""
|
|
1007
|
+
|
|
1008
|
+
assert isinstance(value, str)
|
|
1009
|
+
|
|
1010
|
+
maxclass, *text = value.split()
|
|
1011
|
+
txt = " ".join(text)
|
|
1012
|
+
|
|
1013
|
+
# first check _maxclass_methods
|
|
1014
|
+
# these methods don't need the maxclass, just the `text` tail of value
|
|
1015
|
+
if maxclass in self._maxclass_methods:
|
|
1016
|
+
return self._maxclass_methods[maxclass](txt, **kwds) # type: ignore
|
|
1017
|
+
# next two require value as a whole
|
|
1018
|
+
if maxclass == "p":
|
|
1019
|
+
return self.add_subpatcher(value, **kwds)
|
|
1020
|
+
if maxclass == "gen~":
|
|
1021
|
+
return self.add_gen_tilde(**kwds)
|
|
1022
|
+
if maxclass == "rnbo~":
|
|
1023
|
+
return self.add_rnbo(value, **kwds)
|
|
1024
|
+
return self.add_textbox(text=value, **kwds)
|
|
1025
|
+
|
|
1026
|
+
def add(self, value, *args, **kwds) -> "Box":
|
|
1027
|
+
"""generic adder: value can be a number or a list or text for an object."""
|
|
1028
|
+
|
|
1029
|
+
if isinstance(value, float):
|
|
1030
|
+
return self._add_float(value, *args, **kwds)
|
|
1031
|
+
|
|
1032
|
+
if isinstance(value, int):
|
|
1033
|
+
return self._add_int(value, *args, **kwds)
|
|
1034
|
+
|
|
1035
|
+
if isinstance(value, str):
|
|
1036
|
+
return self._add_str(value, *args, **kwds)
|
|
1037
|
+
|
|
1038
|
+
raise NotImplementedError
|
|
1039
|
+
|
|
1040
|
+
def add_codebox(
|
|
1041
|
+
self,
|
|
1042
|
+
code: str,
|
|
1043
|
+
patching_rect: Optional[Rect] = None,
|
|
1044
|
+
id: Optional[str] = None,
|
|
1045
|
+
comment: Optional[str] = None,
|
|
1046
|
+
comment_pos: Optional[str] = None,
|
|
1047
|
+
tilde=False,
|
|
1048
|
+
**kwds,
|
|
1049
|
+
) -> "Box":
|
|
1050
|
+
"""Add a codebox."""
|
|
1051
|
+
|
|
1052
|
+
_maxclass = "codebox~" if tilde else "codebox"
|
|
1053
|
+
if "\r" not in code:
|
|
1054
|
+
code = code.replace("\n", "\r\n")
|
|
1055
|
+
|
|
1056
|
+
if self.classnamespace == "rnbo":
|
|
1057
|
+
kwds["rnbo_classname"] = _maxclass
|
|
1058
|
+
if "rnbo_extra_attributes" not in kwds:
|
|
1059
|
+
kwds["rnbo_extra_attributes"] = dict(
|
|
1060
|
+
code=code,
|
|
1061
|
+
hot=0,
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
return self.add_box(
|
|
1065
|
+
Box(
|
|
1066
|
+
id=id or self.get_id(_maxclass),
|
|
1067
|
+
code=code,
|
|
1068
|
+
maxclass=_maxclass,
|
|
1069
|
+
outlettype=[""],
|
|
1070
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1071
|
+
**kwds,
|
|
1072
|
+
),
|
|
1073
|
+
comment,
|
|
1074
|
+
comment_pos,
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
def add_codebox_tilde(
|
|
1078
|
+
self,
|
|
1079
|
+
code: str,
|
|
1080
|
+
patching_rect: Optional[Rect] = None,
|
|
1081
|
+
id: Optional[str] = None,
|
|
1082
|
+
comment: Optional[str] = None,
|
|
1083
|
+
comment_pos: Optional[str] = None,
|
|
1084
|
+
**kwds,
|
|
1085
|
+
) -> "Box":
|
|
1086
|
+
"""Add a codebox_tilde"""
|
|
1087
|
+
return self.add_codebox(
|
|
1088
|
+
code, patching_rect, id, comment, comment_pos, tilde=True, **kwds
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
def add_message(
|
|
1092
|
+
self,
|
|
1093
|
+
text: Optional[str] = None,
|
|
1094
|
+
patching_rect: Optional[Rect] = None,
|
|
1095
|
+
id: Optional[str] = None,
|
|
1096
|
+
comment: Optional[str] = None,
|
|
1097
|
+
comment_pos: Optional[str] = None,
|
|
1098
|
+
**kwds,
|
|
1099
|
+
) -> "Box":
|
|
1100
|
+
"""Add a max message."""
|
|
1101
|
+
|
|
1102
|
+
return self.add_box(
|
|
1103
|
+
Box(
|
|
1104
|
+
id=id or self.get_id("message"),
|
|
1105
|
+
text=text or "",
|
|
1106
|
+
maxclass="message",
|
|
1107
|
+
numinlets=2,
|
|
1108
|
+
numoutlets=1,
|
|
1109
|
+
outlettype=[""],
|
|
1110
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1111
|
+
**kwds,
|
|
1112
|
+
),
|
|
1113
|
+
comment,
|
|
1114
|
+
comment_pos,
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
def add_comment(
|
|
1118
|
+
self,
|
|
1119
|
+
text: str,
|
|
1120
|
+
patching_rect: Optional[Rect] = None,
|
|
1121
|
+
id: Optional[str] = None,
|
|
1122
|
+
justify: Optional[str] = None,
|
|
1123
|
+
**kwds,
|
|
1124
|
+
) -> "Box":
|
|
1125
|
+
"""Add a basic comment object."""
|
|
1126
|
+
if justify:
|
|
1127
|
+
kwds["textjustification"] = {"left": 0, "center": 1, "right": 2}[justify]
|
|
1128
|
+
return self.add_box(
|
|
1129
|
+
Box(
|
|
1130
|
+
id=id or self.get_id("comment"),
|
|
1131
|
+
text=text,
|
|
1132
|
+
maxclass="comment",
|
|
1133
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1134
|
+
**kwds,
|
|
1135
|
+
)
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
def add_intbox(
|
|
1139
|
+
self,
|
|
1140
|
+
comment: Optional[str] = None,
|
|
1141
|
+
comment_pos: Optional[str] = None,
|
|
1142
|
+
patching_rect: Optional[Rect] = None,
|
|
1143
|
+
id: Optional[str] = None,
|
|
1144
|
+
**kwds,
|
|
1145
|
+
) -> "Box":
|
|
1146
|
+
"""Add an int box object."""
|
|
1147
|
+
|
|
1148
|
+
return self.add_box(
|
|
1149
|
+
Box(
|
|
1150
|
+
id=id or self.get_id("number"),
|
|
1151
|
+
maxclass="number",
|
|
1152
|
+
numinlets=1,
|
|
1153
|
+
numoutlets=2,
|
|
1154
|
+
outlettype=["", "bang"],
|
|
1155
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1156
|
+
**kwds,
|
|
1157
|
+
),
|
|
1158
|
+
comment,
|
|
1159
|
+
comment_pos,
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
# alias
|
|
1163
|
+
add_int = add_intbox
|
|
1164
|
+
|
|
1165
|
+
def add_floatbox(
|
|
1166
|
+
self,
|
|
1167
|
+
comment: Optional[str] = None,
|
|
1168
|
+
comment_pos: Optional[str] = None,
|
|
1169
|
+
patching_rect: Optional[Rect] = None,
|
|
1170
|
+
id: Optional[str] = None,
|
|
1171
|
+
**kwds,
|
|
1172
|
+
) -> "Box":
|
|
1173
|
+
"""Add an float box object."""
|
|
1174
|
+
|
|
1175
|
+
return self.add_box(
|
|
1176
|
+
Box(
|
|
1177
|
+
id=id or self.get_id("flonum"),
|
|
1178
|
+
maxclass="flonum",
|
|
1179
|
+
numinlets=1,
|
|
1180
|
+
numoutlets=2,
|
|
1181
|
+
outlettype=["", "bang"],
|
|
1182
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1183
|
+
**kwds,
|
|
1184
|
+
),
|
|
1185
|
+
comment,
|
|
1186
|
+
comment_pos,
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
# alias
|
|
1190
|
+
add_float = add_floatbox
|
|
1191
|
+
|
|
1192
|
+
def add_floatparam(
|
|
1193
|
+
self,
|
|
1194
|
+
longname: str,
|
|
1195
|
+
initial: Optional[float] = None,
|
|
1196
|
+
minimum: Optional[float] = None,
|
|
1197
|
+
maximum: Optional[float] = None,
|
|
1198
|
+
shortname: Optional[str] = None,
|
|
1199
|
+
id: Optional[str] = None,
|
|
1200
|
+
rect: Optional[Rect] = None,
|
|
1201
|
+
hint: Optional[str] = None,
|
|
1202
|
+
comment: Optional[str] = None,
|
|
1203
|
+
comment_pos: Optional[str] = None,
|
|
1204
|
+
**kwds,
|
|
1205
|
+
) -> "Box":
|
|
1206
|
+
"""Add a float parameter object."""
|
|
1207
|
+
|
|
1208
|
+
return self.add_box(
|
|
1209
|
+
Box(
|
|
1210
|
+
id=id or self.get_id("flonum"),
|
|
1211
|
+
maxclass="flonum",
|
|
1212
|
+
numinlets=1,
|
|
1213
|
+
numoutlets=2,
|
|
1214
|
+
outlettype=["", "bang"],
|
|
1215
|
+
parameter_enable=1,
|
|
1216
|
+
saved_attribute_attributes=dict(
|
|
1217
|
+
valueof=dict(
|
|
1218
|
+
parameter_initial=[initial or 0.5],
|
|
1219
|
+
parameter_initial_enable=1,
|
|
1220
|
+
parameter_longname=longname,
|
|
1221
|
+
# parameter_mmax=maximum,
|
|
1222
|
+
parameter_shortname=shortname or "",
|
|
1223
|
+
parameter_type=0,
|
|
1224
|
+
)
|
|
1225
|
+
),
|
|
1226
|
+
maximum=maximum,
|
|
1227
|
+
minimum=minimum,
|
|
1228
|
+
patching_rect=rect or self.get_pos(),
|
|
1229
|
+
hint=hint or (longname if self._auto_hints else ""),
|
|
1230
|
+
**kwds,
|
|
1231
|
+
),
|
|
1232
|
+
comment or longname, # units can also be added here
|
|
1233
|
+
comment_pos,
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
def add_intparam(
|
|
1237
|
+
self,
|
|
1238
|
+
longname: str,
|
|
1239
|
+
initial: Optional[int] = None,
|
|
1240
|
+
minimum: Optional[int] = None,
|
|
1241
|
+
maximum: Optional[int] = None,
|
|
1242
|
+
shortname: Optional[str] = None,
|
|
1243
|
+
id: Optional[str] = None,
|
|
1244
|
+
rect: Optional[Rect] = None,
|
|
1245
|
+
hint: Optional[str] = None,
|
|
1246
|
+
comment: Optional[str] = None,
|
|
1247
|
+
comment_pos: Optional[str] = None,
|
|
1248
|
+
**kwds,
|
|
1249
|
+
) -> "Box":
|
|
1250
|
+
"""Add an int parameter object."""
|
|
1251
|
+
|
|
1252
|
+
return self.add_box(
|
|
1253
|
+
Box(
|
|
1254
|
+
id=id or self.get_id("number"),
|
|
1255
|
+
maxclass="number",
|
|
1256
|
+
numinlets=1,
|
|
1257
|
+
numoutlets=2,
|
|
1258
|
+
outlettype=["", "bang"],
|
|
1259
|
+
parameter_enable=1,
|
|
1260
|
+
saved_attribute_attributes=dict(
|
|
1261
|
+
valueof=dict(
|
|
1262
|
+
parameter_initial=[initial or 1],
|
|
1263
|
+
parameter_initial_enable=1,
|
|
1264
|
+
parameter_longname=longname,
|
|
1265
|
+
parameter_mmax=maximum,
|
|
1266
|
+
parameter_shortname=shortname or "",
|
|
1267
|
+
parameter_type=1,
|
|
1268
|
+
)
|
|
1269
|
+
),
|
|
1270
|
+
maximum=maximum,
|
|
1271
|
+
minimum=minimum,
|
|
1272
|
+
patching_rect=rect or self.get_pos(),
|
|
1273
|
+
hint=hint or (longname if self._auto_hints else ""),
|
|
1274
|
+
**kwds,
|
|
1275
|
+
),
|
|
1276
|
+
comment or longname, # units can also be added here
|
|
1277
|
+
comment_pos,
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
def add_attr(
|
|
1281
|
+
self,
|
|
1282
|
+
name: str,
|
|
1283
|
+
value: float,
|
|
1284
|
+
shortname: Optional[str] = None,
|
|
1285
|
+
id: Optional[str] = None,
|
|
1286
|
+
rect: Optional[Rect] = None,
|
|
1287
|
+
hint: Optional[str] = None,
|
|
1288
|
+
comment: Optional[str] = None,
|
|
1289
|
+
comment_pos: Optional[str] = None,
|
|
1290
|
+
autovar=True,
|
|
1291
|
+
show_label=False,
|
|
1292
|
+
**kwds,
|
|
1293
|
+
) -> "Box":
|
|
1294
|
+
"""create a param-linke attrui entry"""
|
|
1295
|
+
if autovar:
|
|
1296
|
+
kwds["varname"] = name
|
|
1297
|
+
|
|
1298
|
+
return self.add_box(
|
|
1299
|
+
Box(
|
|
1300
|
+
id=id or self.get_id("attrui"),
|
|
1301
|
+
text="attrui",
|
|
1302
|
+
maxclass="attrui",
|
|
1303
|
+
attr=name,
|
|
1304
|
+
parameter_enable=1,
|
|
1305
|
+
attr_display=show_label,
|
|
1306
|
+
saved_attribute_attributes=dict(
|
|
1307
|
+
valueof=dict(
|
|
1308
|
+
parameter_initial=[name, value],
|
|
1309
|
+
parameter_initial_enable=1,
|
|
1310
|
+
parameter_longname=name,
|
|
1311
|
+
parameter_shortname=shortname or "",
|
|
1312
|
+
)
|
|
1313
|
+
),
|
|
1314
|
+
patching_rect=rect or self.get_pos(),
|
|
1315
|
+
hint=name if self._auto_hints else hint or "",
|
|
1316
|
+
**kwds,
|
|
1317
|
+
),
|
|
1318
|
+
comment or name, # units can also be added here
|
|
1319
|
+
comment_pos,
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
def add_subpatcher(
|
|
1323
|
+
self,
|
|
1324
|
+
text: str,
|
|
1325
|
+
maxclass: Optional[str] = None,
|
|
1326
|
+
numinlets: Optional[int] = None,
|
|
1327
|
+
numoutlets: Optional[int] = None,
|
|
1328
|
+
outlettype: Optional[List[str]] = None,
|
|
1329
|
+
patching_rect: Optional[Rect] = None,
|
|
1330
|
+
id: Optional[str] = None,
|
|
1331
|
+
patcher: Optional["Patcher"] = None,
|
|
1332
|
+
**kwds,
|
|
1333
|
+
) -> "Box":
|
|
1334
|
+
"""Add a subpatcher object."""
|
|
1335
|
+
|
|
1336
|
+
# For subpatchers, use the text (e.g., "p subpatch") for semantic ID
|
|
1337
|
+
obj_name = text.split()[0] if text else "newobj"
|
|
1338
|
+
return self.add_box(
|
|
1339
|
+
Box(
|
|
1340
|
+
id=id or self.get_id(obj_name),
|
|
1341
|
+
text=text,
|
|
1342
|
+
maxclass=maxclass or "newobj",
|
|
1343
|
+
numinlets=numinlets or 1,
|
|
1344
|
+
numoutlets=numoutlets or 0,
|
|
1345
|
+
outlettype=outlettype or [""],
|
|
1346
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1347
|
+
patcher=patcher or Patcher(parent=self),
|
|
1348
|
+
**kwds,
|
|
1349
|
+
)
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
def add_gen(self, text: Optional[str] = None, tilde=False, **kwds):
|
|
1353
|
+
"""Add a gen object."""
|
|
1354
|
+
prefix = "gen~" if tilde else "gen"
|
|
1355
|
+
_text = f"{prefix} {text}" if text else prefix
|
|
1356
|
+
return self.add_subpatcher(
|
|
1357
|
+
_text, patcher=Patcher(parent=self, classnamespace="dsp.gen"), **kwds
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
def add_gen_tilde(self, text: Optional[str] = None, **kwds):
|
|
1361
|
+
"""Add a gen~ object."""
|
|
1362
|
+
return self.add_gen(text=text, tilde=True, **kwds)
|
|
1363
|
+
|
|
1364
|
+
def add_rnbo(self, text: str = "rnbo~", **kwds):
|
|
1365
|
+
"""Add an rnbo~ object."""
|
|
1366
|
+
if "inletInfo" not in kwds:
|
|
1367
|
+
if "numinlets" in kwds:
|
|
1368
|
+
inletInfo: dict[str, list] = {"IOInfo": []}
|
|
1369
|
+
for i in range(kwds["numinlets"]):
|
|
1370
|
+
inletInfo["IOInfo"].append(
|
|
1371
|
+
dict(comment="", index=i + 1, tag=f"in{i + 1}", type="signal")
|
|
1372
|
+
)
|
|
1373
|
+
kwds["inletInfo"] = inletInfo
|
|
1374
|
+
if "outletInfo" not in kwds:
|
|
1375
|
+
if "numoutlets" in kwds:
|
|
1376
|
+
outletInfo: dict[str, list] = {"IOInfo": []}
|
|
1377
|
+
for i in range(kwds["numoutlets"]):
|
|
1378
|
+
outletInfo["IOInfo"].append(
|
|
1379
|
+
dict(comment="", index=i + 1, tag=f"out{i + 1}", type="signal")
|
|
1380
|
+
)
|
|
1381
|
+
kwds["outletInfo"] = outletInfo
|
|
1382
|
+
|
|
1383
|
+
return self.add_subpatcher(
|
|
1384
|
+
text, patcher=Patcher(parent=self, classnamespace="rnbo"), **kwds
|
|
1385
|
+
)
|
|
1386
|
+
|
|
1387
|
+
def add_coll(
|
|
1388
|
+
self,
|
|
1389
|
+
name: Optional[str] = None,
|
|
1390
|
+
dictionary: Optional[dict] = None,
|
|
1391
|
+
embed: int = 1,
|
|
1392
|
+
patching_rect: Optional[Rect] = None,
|
|
1393
|
+
text: Optional[str] = None,
|
|
1394
|
+
id: Optional[str] = None,
|
|
1395
|
+
comment: Optional[str] = None,
|
|
1396
|
+
comment_pos: Optional[str] = None,
|
|
1397
|
+
**kwds,
|
|
1398
|
+
):
|
|
1399
|
+
"""Add a coll object with option to pre-populate from a py dictionary."""
|
|
1400
|
+
extra = {"saved_object_attributes": {"embed": embed, "precision": 6}}
|
|
1401
|
+
if dictionary:
|
|
1402
|
+
extra["coll_data"] = { # type: ignore
|
|
1403
|
+
"count": len(dictionary.keys()),
|
|
1404
|
+
"data": [{"key": k, "value": v} for k, v in dictionary.items()], # type: ignore
|
|
1405
|
+
}
|
|
1406
|
+
kwds.update(extra)
|
|
1407
|
+
return self.add_box(
|
|
1408
|
+
Box(
|
|
1409
|
+
id=id or self.get_id("coll"),
|
|
1410
|
+
text=text or f"coll {name} @embed {embed}"
|
|
1411
|
+
if name
|
|
1412
|
+
else f"coll @embed {embed}",
|
|
1413
|
+
maxclass="newobj",
|
|
1414
|
+
numinlets=1,
|
|
1415
|
+
numoutlets=4,
|
|
1416
|
+
outlettype=["", "", "", ""],
|
|
1417
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1418
|
+
**kwds,
|
|
1419
|
+
),
|
|
1420
|
+
comment,
|
|
1421
|
+
comment_pos,
|
|
1422
|
+
)
|
|
1423
|
+
|
|
1424
|
+
def add_dict(
|
|
1425
|
+
self,
|
|
1426
|
+
name: Optional[str] = None,
|
|
1427
|
+
dictionary: Optional[dict] = None,
|
|
1428
|
+
embed: int = 1,
|
|
1429
|
+
patching_rect: Optional[Rect] = None,
|
|
1430
|
+
text: Optional[str] = None,
|
|
1431
|
+
id: Optional[str] = None,
|
|
1432
|
+
comment: Optional[str] = None,
|
|
1433
|
+
comment_pos: Optional[str] = None,
|
|
1434
|
+
**kwds,
|
|
1435
|
+
):
|
|
1436
|
+
"""Add a dict object with option to pre-populate from a py dictionary."""
|
|
1437
|
+
extra = {
|
|
1438
|
+
"saved_object_attributes": {
|
|
1439
|
+
"embed": embed,
|
|
1440
|
+
"parameter_enable": kwds.get("parameter_enable", 0),
|
|
1441
|
+
"parameter_mappable": kwds.get("parameter_mappable", 0),
|
|
1442
|
+
},
|
|
1443
|
+
"data": dictionary or {},
|
|
1444
|
+
}
|
|
1445
|
+
kwds.update(extra)
|
|
1446
|
+
return self.add_box(
|
|
1447
|
+
Box(
|
|
1448
|
+
id=id or self.get_id("dict"),
|
|
1449
|
+
text=text or f"dict {name} @embed {embed}"
|
|
1450
|
+
if name
|
|
1451
|
+
else f"dict @embed {embed}",
|
|
1452
|
+
maxclass="newobj",
|
|
1453
|
+
numinlets=2,
|
|
1454
|
+
numoutlets=4,
|
|
1455
|
+
outlettype=["dictionary", "", "", ""],
|
|
1456
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1457
|
+
**kwds,
|
|
1458
|
+
),
|
|
1459
|
+
comment,
|
|
1460
|
+
comment_pos,
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
def add_table(
|
|
1464
|
+
self,
|
|
1465
|
+
name: Optional[str] = None,
|
|
1466
|
+
array: Optional[List[Union[int, float]]] = None,
|
|
1467
|
+
embed: int = 1,
|
|
1468
|
+
patching_rect: Optional[Rect] = None,
|
|
1469
|
+
text: Optional[str] = None,
|
|
1470
|
+
id: Optional[str] = None,
|
|
1471
|
+
comment: Optional[str] = None,
|
|
1472
|
+
comment_pos: Optional[str] = None,
|
|
1473
|
+
tilde=False,
|
|
1474
|
+
**kwds,
|
|
1475
|
+
):
|
|
1476
|
+
"""Add a table object with option to pre-populate from a py list."""
|
|
1477
|
+
|
|
1478
|
+
extra = {
|
|
1479
|
+
"embed": embed,
|
|
1480
|
+
"saved_object_attributes": {
|
|
1481
|
+
"name": name,
|
|
1482
|
+
"parameter_enable": kwds.get("parameter_enable", 0),
|
|
1483
|
+
"parameter_mappable": kwds.get("parameter_mappable", 0),
|
|
1484
|
+
"range": kwds.get("range", 128),
|
|
1485
|
+
"showeditor": 0,
|
|
1486
|
+
"size": len(array) if array else 128,
|
|
1487
|
+
},
|
|
1488
|
+
# "showeditor": 0,
|
|
1489
|
+
# 'size': kwds.get('size', 128),
|
|
1490
|
+
"table_data": array or [],
|
|
1491
|
+
"editor_rect": [100.0, 100.0, 300.0, 300.0],
|
|
1492
|
+
}
|
|
1493
|
+
kwds.update(extra)
|
|
1494
|
+
table_type = "table~" if tilde else "table"
|
|
1495
|
+
return self.add_box(
|
|
1496
|
+
Box(
|
|
1497
|
+
id=id or self.get_id(table_type),
|
|
1498
|
+
text=text or f"{table_type} {name} @embed {embed}"
|
|
1499
|
+
if name
|
|
1500
|
+
else f"{table_type} @embed {embed}",
|
|
1501
|
+
maxclass="newobj",
|
|
1502
|
+
numinlets=2,
|
|
1503
|
+
numoutlets=2,
|
|
1504
|
+
outlettype=["int", "bang"],
|
|
1505
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1506
|
+
**kwds,
|
|
1507
|
+
),
|
|
1508
|
+
comment,
|
|
1509
|
+
comment_pos,
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
def add_table_tilde(
|
|
1513
|
+
self,
|
|
1514
|
+
name: Optional[str] = None,
|
|
1515
|
+
array: Optional[List[Union[int, float]]] = None,
|
|
1516
|
+
embed: int = 1,
|
|
1517
|
+
patching_rect: Optional[Rect] = None,
|
|
1518
|
+
text: Optional[str] = None,
|
|
1519
|
+
id: Optional[str] = None,
|
|
1520
|
+
comment: Optional[str] = None,
|
|
1521
|
+
comment_pos: Optional[str] = None,
|
|
1522
|
+
**kwds,
|
|
1523
|
+
):
|
|
1524
|
+
"""Add a table~ object with option to pre-populate from a py list."""
|
|
1525
|
+
|
|
1526
|
+
return self.add_table(
|
|
1527
|
+
name,
|
|
1528
|
+
array,
|
|
1529
|
+
embed,
|
|
1530
|
+
patching_rect,
|
|
1531
|
+
text,
|
|
1532
|
+
id,
|
|
1533
|
+
comment,
|
|
1534
|
+
comment_pos,
|
|
1535
|
+
tilde=True,
|
|
1536
|
+
**kwds,
|
|
1537
|
+
)
|
|
1538
|
+
|
|
1539
|
+
def add_itable(
|
|
1540
|
+
self,
|
|
1541
|
+
name: Optional[str] = None,
|
|
1542
|
+
array: Optional[List[Union[int, float]]] = None,
|
|
1543
|
+
patching_rect: Optional[Rect] = None,
|
|
1544
|
+
text: Optional[str] = None,
|
|
1545
|
+
id: Optional[str] = None,
|
|
1546
|
+
comment: Optional[str] = None,
|
|
1547
|
+
comment_pos: Optional[str] = None,
|
|
1548
|
+
**kwds,
|
|
1549
|
+
):
|
|
1550
|
+
"""Add a itable object with option to pre-populate from a py list."""
|
|
1551
|
+
|
|
1552
|
+
extra = {
|
|
1553
|
+
"range": kwds.get("range", 128),
|
|
1554
|
+
"size": len(array) if array else 128,
|
|
1555
|
+
"table_data": array or [],
|
|
1556
|
+
}
|
|
1557
|
+
kwds.update(extra)
|
|
1558
|
+
return self.add_box(
|
|
1559
|
+
Box(
|
|
1560
|
+
id=id or self.get_id("itable"),
|
|
1561
|
+
text=text or f"itable {name}",
|
|
1562
|
+
maxclass="itable",
|
|
1563
|
+
numinlets=2,
|
|
1564
|
+
numoutlets=2,
|
|
1565
|
+
outlettype=["int", "bang"],
|
|
1566
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1567
|
+
**kwds,
|
|
1568
|
+
),
|
|
1569
|
+
comment,
|
|
1570
|
+
comment_pos,
|
|
1571
|
+
)
|
|
1572
|
+
|
|
1573
|
+
def add_umenu(
|
|
1574
|
+
self,
|
|
1575
|
+
prefix: Optional[str] = None,
|
|
1576
|
+
autopopulate: int = 1,
|
|
1577
|
+
items: Optional[List[str]] = None,
|
|
1578
|
+
patching_rect: Optional[Rect] = None,
|
|
1579
|
+
depth: Optional[int] = None,
|
|
1580
|
+
id: Optional[str] = None,
|
|
1581
|
+
comment: Optional[str] = None,
|
|
1582
|
+
comment_pos: Optional[str] = None,
|
|
1583
|
+
**kwds,
|
|
1584
|
+
):
|
|
1585
|
+
"""Add a umenu object with option to pre-populate items from a py list."""
|
|
1586
|
+
|
|
1587
|
+
# interleave commas in a list
|
|
1588
|
+
def _commas(xs):
|
|
1589
|
+
return [i for pair in zip(xs, [","] * len(xs)) for i in pair]
|
|
1590
|
+
|
|
1591
|
+
return self.add_box(
|
|
1592
|
+
Box(
|
|
1593
|
+
id=id or self.get_id("umenu"),
|
|
1594
|
+
maxclass="umenu",
|
|
1595
|
+
numinlets=1,
|
|
1596
|
+
numoutlets=3,
|
|
1597
|
+
outlettype=["int", "", ""],
|
|
1598
|
+
autopopulate=autopopulate or 1,
|
|
1599
|
+
depth=depth or 1,
|
|
1600
|
+
items=_commas(items) or [],
|
|
1601
|
+
prefix=prefix or "",
|
|
1602
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1603
|
+
**kwds,
|
|
1604
|
+
),
|
|
1605
|
+
comment,
|
|
1606
|
+
comment_pos,
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
def add_bpatcher(
|
|
1610
|
+
self,
|
|
1611
|
+
name: str,
|
|
1612
|
+
numinlets: int = 1,
|
|
1613
|
+
numoutlets: int = 1,
|
|
1614
|
+
outlettype: Optional[List[str]] = None,
|
|
1615
|
+
bgmode: int = 0,
|
|
1616
|
+
border: int = 0,
|
|
1617
|
+
clickthrough: int = 0,
|
|
1618
|
+
enablehscroll: int = 0,
|
|
1619
|
+
enablevscroll: int = 0,
|
|
1620
|
+
lockeddragscroll: int = 0,
|
|
1621
|
+
offset: Optional[List[float]] = None,
|
|
1622
|
+
viewvisibility: int = 1,
|
|
1623
|
+
patching_rect: Optional[Rect] = None,
|
|
1624
|
+
id: Optional[str] = None,
|
|
1625
|
+
comment: Optional[str] = None,
|
|
1626
|
+
comment_pos: Optional[str] = None,
|
|
1627
|
+
**kwds,
|
|
1628
|
+
):
|
|
1629
|
+
"""Add a bpatcher object -- name or patch of bpatcher .maxpat is required."""
|
|
1630
|
+
|
|
1631
|
+
return self.add_box(
|
|
1632
|
+
Box(
|
|
1633
|
+
id=id or self.get_id("bpatcher"),
|
|
1634
|
+
name=name,
|
|
1635
|
+
maxclass="bpatcher",
|
|
1636
|
+
numinlets=numinlets,
|
|
1637
|
+
numoutlets=numoutlets,
|
|
1638
|
+
bgmode=bgmode,
|
|
1639
|
+
border=border,
|
|
1640
|
+
clickthrough=clickthrough,
|
|
1641
|
+
enablehscroll=enablehscroll,
|
|
1642
|
+
enablevscroll=enablevscroll,
|
|
1643
|
+
lockeddragscroll=lockeddragscroll,
|
|
1644
|
+
viewvisibility=viewvisibility,
|
|
1645
|
+
outlettype=outlettype or ["float", "", ""],
|
|
1646
|
+
patching_rect=patching_rect or self.get_pos(),
|
|
1647
|
+
offset=offset or [0.0, 0.0],
|
|
1648
|
+
**kwds,
|
|
1649
|
+
),
|
|
1650
|
+
comment,
|
|
1651
|
+
comment_pos,
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1654
|
+
def add_beap(self, name: str, **kwds):
|
|
1655
|
+
"""Add a beap bpatcher object."""
|
|
1656
|
+
|
|
1657
|
+
_varname = name if ".maxpat" not in name else name.rstrip(".maxpat")
|
|
1658
|
+
return self.add_bpatcher(name=name, varname=_varname, extract=1, **kwds)
|