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.
Files changed (48) hide show
  1. py2max/__init__.py +67 -0
  2. py2max/__main__.py +6 -0
  3. py2max/cli.py +1251 -0
  4. py2max/core/__init__.py +39 -0
  5. py2max/core/abstract.py +146 -0
  6. py2max/core/box.py +231 -0
  7. py2max/core/common.py +19 -0
  8. py2max/core/patcher.py +1658 -0
  9. py2max/core/patchline.py +68 -0
  10. py2max/exceptions.py +385 -0
  11. py2max/export/__init__.py +20 -0
  12. py2max/export/converters.py +345 -0
  13. py2max/export/svg.py +393 -0
  14. py2max/layout/__init__.py +26 -0
  15. py2max/layout/base.py +463 -0
  16. py2max/layout/flow.py +405 -0
  17. py2max/layout/grid.py +374 -0
  18. py2max/layout/matrix.py +628 -0
  19. py2max/log.py +338 -0
  20. py2max/maxref/__init__.py +78 -0
  21. py2max/maxref/category.py +163 -0
  22. py2max/maxref/db.py +1082 -0
  23. py2max/maxref/legacy.py +324 -0
  24. py2max/maxref/parser.py +703 -0
  25. py2max/py.typed +0 -0
  26. py2max/server/__init__.py +54 -0
  27. py2max/server/client.py +295 -0
  28. py2max/server/inline.py +312 -0
  29. py2max/server/repl.py +561 -0
  30. py2max/server/rpc.py +240 -0
  31. py2max/server/websocket.py +997 -0
  32. py2max/static/cola.min.js +4 -0
  33. py2max/static/d3.v7.min.js +2 -0
  34. py2max/static/dagre-bundle.js +328 -0
  35. py2max/static/elk.bundled.js +6663 -0
  36. py2max/static/index.html +168 -0
  37. py2max/static/interactive.html +589 -0
  38. py2max/static/interactive.js +2111 -0
  39. py2max/static/live-preview.js +324 -0
  40. py2max/static/svg.min.js +13 -0
  41. py2max/static/svg.min.js.map +1 -0
  42. py2max/transformers.py +168 -0
  43. py2max/utils.py +83 -0
  44. py2max-0.2.1.dist-info/METADATA +390 -0
  45. py2max-0.2.1.dist-info/RECORD +48 -0
  46. py2max-0.2.1.dist-info/WHEEL +4 -0
  47. py2max-0.2.1.dist-info/entry_points.txt +3 -0
  48. 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)