py2max 0.3.1__tar.gz → 0.3.3__tar.gz

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 (212) hide show
  1. {py2max-0.3.1 → py2max-0.3.3}/CHANGELOG.md +112 -0
  2. {py2max-0.3.1 → py2max-0.3.3}/PKG-INFO +8 -4
  3. {py2max-0.3.1 → py2max-0.3.3}/README.md +3 -3
  4. {py2max-0.3.1 → py2max-0.3.3}/py2max/__init__.py +1 -1
  5. {py2max-0.3.1 → py2max-0.3.3}/py2max/cli.py +9 -69
  6. {py2max-0.3.1 → py2max-0.3.3}/py2max/core/abstract.py +2 -2
  7. {py2max-0.3.1 → py2max-0.3.3}/py2max/core/factory.py +172 -19
  8. {py2max-0.3.1 → py2max-0.3.3}/py2max/core/patcher.py +69 -5
  9. {py2max-0.3.1 → py2max-0.3.3}/py2max/core/serialization.py +3 -6
  10. {py2max-0.3.1 → py2max-0.3.3}/py2max/export/converters.py +21 -38
  11. py2max-0.3.3/py2max/export/svg.py +565 -0
  12. {py2max-0.3.1 → py2max-0.3.3}/py2max/layout/__init__.py +4 -1
  13. {py2max-0.3.1 → py2max-0.3.3}/py2max/layout/base.py +68 -92
  14. py2max-0.3.3/py2max/layout/external.py +238 -0
  15. {py2max-0.3.1 → py2max-0.3.3}/py2max/layout/flow.py +12 -24
  16. py2max-0.3.3/py2max/layout/graph.py +173 -0
  17. {py2max-0.3.1 → py2max-0.3.3}/py2max/layout/grid.py +9 -53
  18. {py2max-0.3.1 → py2max-0.3.3}/py2max/layout/matrix.py +129 -146
  19. {py2max-0.3.1 → py2max-0.3.3}/py2max/maxref/category.py +5 -4
  20. py2max-0.3.3/py2max/maxref/data/bundle.json.gz +0 -0
  21. {py2max-0.3.1 → py2max-0.3.3}/py2max/maxref/db.py +2 -88
  22. {py2max-0.3.1 → py2max-0.3.3}/py2max/maxref/parser.py +136 -54
  23. {py2max-0.3.1 → py2max-0.3.3}/py2max/transformers.py +69 -6
  24. {py2max-0.3.1 → py2max-0.3.3}/py2max/utils.py +17 -1
  25. {py2max-0.3.1 → py2max-0.3.3}/pyproject.toml +26 -1
  26. py2max-0.3.3/tests/conftest.py +98 -0
  27. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/auto_layout_demo.py +1 -1
  28. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/db/category_db_demo.py +16 -16
  29. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/db/maxref_db_demo.py +13 -13
  30. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/layout/columnar_layout_examples.py +24 -27
  31. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/layout/matrix_layout_examples.py +11 -14
  32. py2max-0.3.3/tests/test_abstraction.py +34 -0
  33. {py2max-0.3.1 → py2max-0.3.3}/tests/test_amxd.py +3 -1
  34. {py2max-0.3.1 → py2max-0.3.3}/tests/test_attrui.py +37 -0
  35. {py2max-0.3.1 → py2max-0.3.3}/tests/test_basic.py +4 -3
  36. {py2max-0.3.1 → py2max-0.3.3}/tests/test_beap.py +8 -1
  37. py2max-0.3.3/tests/test_bpatcher.py +44 -0
  38. py2max-0.3.3/tests/test_coll.py +28 -0
  39. py2max-0.3.3/tests/test_colors.py +44 -0
  40. py2max-0.3.3/tests/test_comment.py +20 -0
  41. {py2max-0.3.1 → py2max-0.3.3}/tests/test_db.py +15 -15
  42. py2max-0.3.3/tests/test_defaults.py +26 -0
  43. py2max-0.3.3/tests/test_dict.py +15 -0
  44. py2max-0.3.3/tests/test_edit_operations.py +362 -0
  45. {py2max-0.3.1 → py2max-0.3.3}/tests/test_examples.py +27 -0
  46. py2max-0.3.3/tests/test_ezdac.py +21 -0
  47. py2max-0.3.3/tests/test_itable.py +15 -0
  48. {py2max-0.3.1 → py2max-0.3.3}/tests/test_js.py +14 -2
  49. py2max-0.3.3/tests/test_kwds_filter.py +49 -0
  50. py2max-0.3.3/tests/test_layout_box_dims.py +81 -0
  51. {py2max-0.3.1 → py2max-0.3.3}/tests/test_layout_builtins.py +1 -1
  52. {py2max-0.3.1 → py2max-0.3.3}/tests/test_layout_coverage.py +33 -23
  53. py2max-0.3.3/tests/test_layout_graph.py +77 -0
  54. py2max-0.3.3/tests/test_layout_graph_layout.py +103 -0
  55. py2max-0.3.3/tests/test_layout_graph_manager.py +127 -0
  56. {py2max-0.3.1 → py2max-0.3.3}/tests/test_layout_matrix.py +145 -41
  57. py2max-0.3.3/tests/test_layout_overlaps.py +66 -0
  58. {py2max-0.3.1 → py2max-0.3.3}/tests/test_layout_vertical.py +21 -0
  59. py2max-0.3.3/tests/test_linking.py +76 -0
  60. {py2max-0.3.1 → py2max-0.3.3}/tests/test_m4l.py +0 -6
  61. {py2max-0.3.1 → py2max-0.3.3}/tests/test_maxref_bundle.py +8 -7
  62. py2max-0.3.3/tests/test_maxref_lazy.py +55 -0
  63. py2max-0.3.3/tests/test_maxref_parser.py +60 -0
  64. py2max-0.3.3/tests/test_maxref_refpages.py +85 -0
  65. py2max-0.3.3/tests/test_mc_cycle.py +19 -0
  66. py2max-0.3.3/tests/test_message.py +16 -0
  67. py2max-0.3.3/tests/test_mypatch.py +22 -0
  68. py2max-0.3.3/tests/test_number_tilde.py +87 -0
  69. py2max-0.3.3/tests/test_numbers.py +56 -0
  70. py2max-0.3.3/tests/test_param.py +36 -0
  71. {py2max-0.3.1 → py2max-0.3.3}/tests/test_pitched_osc.py +12 -0
  72. {py2max-0.3.1 → py2max-0.3.3}/tests/test_pydantic.py +29 -0
  73. {py2max-0.3.1 → py2max-0.3.3}/tests/test_rnbo.py +78 -1
  74. py2max-0.3.3/tests/test_rnbo_subpatcher.py +36 -0
  75. py2max-0.3.3/tests/test_scripting_name.py +18 -0
  76. py2max-0.3.3/tests/test_subpatch.py +34 -0
  77. py2max-0.3.3/tests/test_svg_fidelity.py +63 -0
  78. {py2max-0.3.1 → py2max-0.3.3}/tests/test_table.py +32 -2
  79. py2max-0.3.3/tests/test_transformers_e4.py +68 -0
  80. py2max-0.3.3/tests/test_tutorial_simple_synthesis.py +100 -0
  81. py2max-0.3.3/tests/test_umenu.py +18 -0
  82. py2max-0.3.3/tests/test_varname.py +26 -0
  83. py2max-0.3.3/tests/test_wheel_bundle.py +76 -0
  84. py2max-0.3.3/tests/test_zl_group.py +28 -0
  85. py2max-0.3.1/py2max/export/svg.py +0 -456
  86. py2max-0.3.1/py2max/maxref/data/bundle.json.gz +0 -0
  87. py2max-0.3.1/tests/conftest.py +0 -21
  88. py2max-0.3.1/tests/registry.py +0 -1526
  89. py2max-0.3.1/tests/scratch.py +0 -80
  90. py2max-0.3.1/tests/test_abstraction.py +0 -21
  91. py2max-0.3.1/tests/test_bpatcher.py +0 -20
  92. py2max-0.3.1/tests/test_coll.py +0 -16
  93. py2max-0.3.1/tests/test_colors.py +0 -9
  94. py2max-0.3.1/tests/test_comment.py +0 -8
  95. py2max-0.3.1/tests/test_defaults.py +0 -11
  96. py2max-0.3.1/tests/test_dict.py +0 -7
  97. py2max-0.3.1/tests/test_ezdac.py +0 -10
  98. py2max-0.3.1/tests/test_itable.py +0 -7
  99. py2max-0.3.1/tests/test_kwds_filter.py +0 -116
  100. py2max-0.3.1/tests/test_layout_graph_layout.py +0 -113
  101. py2max-0.3.1/tests/test_layout_hola1.py +0 -117
  102. py2max-0.3.1/tests/test_layout_hola2.py +0 -100
  103. py2max-0.3.1/tests/test_layout_hola3.py +0 -123
  104. py2max-0.3.1/tests/test_layout_hola_graph.py +0 -124
  105. py2max-0.3.1/tests/test_layout_networkx1.py +0 -128
  106. py2max-0.3.1/tests/test_layout_networkx2.py +0 -96
  107. py2max-0.3.1/tests/test_layout_nx_graphviz.py +0 -96
  108. py2max-0.3.1/tests/test_layout_nx_orthogonal.py +0 -120
  109. py2max-0.3.1/tests/test_layout_nx_tsmpy.py +0 -108
  110. py2max-0.3.1/tests/test_linking.py +0 -40
  111. py2max-0.3.1/tests/test_mc_cycle.py +0 -7
  112. py2max-0.3.1/tests/test_message.py +0 -7
  113. py2max-0.3.1/tests/test_mypatch.py +0 -12
  114. py2max-0.3.1/tests/test_number_tilde.py +0 -52
  115. py2max-0.3.1/tests/test_numbers.py +0 -34
  116. py2max-0.3.1/tests/test_param.py +0 -8
  117. py2max-0.3.1/tests/test_rnbo_subpatcher.py +0 -19
  118. py2max-0.3.1/tests/test_scripting_name.py +0 -7
  119. py2max-0.3.1/tests/test_subpatch.py +0 -17
  120. py2max-0.3.1/tests/test_tutorial_simple_synthesis.py +0 -44
  121. py2max-0.3.1/tests/test_umenu.py +0 -11
  122. py2max-0.3.1/tests/test_varname.py +0 -11
  123. py2max-0.3.1/tests/test_zl_group.py +0 -11
  124. {py2max-0.3.1 → py2max-0.3.3}/LICENSE +0 -0
  125. {py2max-0.3.1 → py2max-0.3.3}/py2max/__main__.py +0 -0
  126. {py2max-0.3.1 → py2max-0.3.3}/py2max/core/__init__.py +0 -0
  127. {py2max-0.3.1 → py2max-0.3.3}/py2max/core/box.py +0 -0
  128. {py2max-0.3.1 → py2max-0.3.3}/py2max/core/colors.py +0 -0
  129. {py2max-0.3.1 → py2max-0.3.3}/py2max/core/common.py +0 -0
  130. {py2max-0.3.1 → py2max-0.3.3}/py2max/core/patchline.py +0 -0
  131. {py2max-0.3.1 → py2max-0.3.3}/py2max/exceptions.py +0 -0
  132. {py2max-0.3.1 → py2max-0.3.3}/py2max/export/__init__.py +0 -0
  133. {py2max-0.3.1 → py2max-0.3.3}/py2max/log.py +0 -0
  134. {py2max-0.3.1 → py2max-0.3.3}/py2max/m4l.py +0 -0
  135. {py2max-0.3.1 → py2max-0.3.3}/py2max/maxref/__init__.py +0 -0
  136. {py2max-0.3.1 → py2max-0.3.3}/py2max/maxref/legacy.py +0 -0
  137. {py2max-0.3.1 → py2max-0.3.3}/py2max/py.typed +0 -0
  138. {py2max-0.3.1 → py2max-0.3.3}/tests/__init__.py +0 -0
  139. {py2max-0.3.1 → py2max-0.3.3}/tests/data/complex.maxpat +0 -0
  140. {py2max-0.3.1 → py2max-0.3.3}/tests/data/desc.maxpat +0 -0
  141. {py2max-0.3.1 → py2max-0.3.3}/tests/data/empty.maxpat +0 -0
  142. {py2max-0.3.1 → py2max-0.3.3}/tests/data/mydevice.amxd +0 -0
  143. {py2max-0.3.1 → py2max-0.3.3}/tests/data/mydevice2.amxd +0 -0
  144. {py2max-0.3.1 → py2max-0.3.3}/tests/data/nested.maxpat +0 -0
  145. {py2max-0.3.1 → py2max-0.3.3}/tests/data/simple.maxpat +0 -0
  146. {py2max-0.3.1 → py2max-0.3.3}/tests/data/tabular.maxpat +0 -0
  147. {py2max-0.3.1 → py2max-0.3.3}/tests/data/umenu.maxref.xml +0 -0
  148. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/README.md +0 -0
  149. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/advanced/connection_patterns.py +0 -0
  150. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/advanced/custom_extensions.py +0 -0
  151. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/advanced/data_containers.py +0 -0
  152. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/advanced/error_handling.py +0 -0
  153. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/advanced/performance_optimization.py +0 -0
  154. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/advanced/subpatchers.py +0 -0
  155. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/api/box_api_examples.py +0 -0
  156. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/api/patcher_api_examples.py +0 -0
  157. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/layout/flow_layout_examples.py +0 -0
  158. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/layout/grid_layout_examples.py +0 -0
  159. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/basic_synth.maxpat +0 -0
  160. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/basic_synth.svg +0 -0
  161. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/complex_synth.maxpat +0 -0
  162. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/complex_synth.svg +0 -0
  163. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/flow_layout.maxpat +0 -0
  164. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/flow_layout.svg +0 -0
  165. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/grid_layout.maxpat +0 -0
  166. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/grid_layout.svg +0 -0
  167. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/horizontal_layout.maxpat +0 -0
  168. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/horizontal_layout.svg +0 -0
  169. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/styled_no_ports.svg +0 -0
  170. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/styled_no_title.svg +0 -0
  171. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/styled_patch.maxpat +0 -0
  172. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/styled_with_ports.svg +0 -0
  173. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/svg_preview_demo.py +0 -0
  174. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/vertical_layout.maxpat +0 -0
  175. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/vertical_layout.svg +0 -0
  176. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/workflow_demo.maxpat +0 -0
  177. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/preview/workflow_demo.svg +0 -0
  178. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/quickstart/basic_patch.py +0 -0
  179. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/quickstart/layout_examples.py +0 -0
  180. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/tutorial/generative_music.py +0 -0
  181. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/tutorial/interactive_controller.py +0 -0
  182. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/tutorial/signal_processing_chain.py +0 -0
  183. {py2max-0.3.1 → py2max-0.3.3}/tests/examples/tutorial/simple_synthesis.py +0 -0
  184. {py2max-0.3.1 → py2max-0.3.3}/tests/graphs/random/v30e33.tglf +0 -0
  185. {py2max-0.3.1 → py2max-0.3.3}/tests/test_abstract_coverage.py +0 -0
  186. {py2max-0.3.1 → py2max-0.3.3}/tests/test_add.py +0 -0
  187. {py2max-0.3.1 → py2max-0.3.3}/tests/test_cli.py +0 -0
  188. {py2max-0.3.1 → py2max-0.3.3}/tests/test_colors_theme.py +0 -0
  189. {py2max-0.3.1 → py2max-0.3.3}/tests/test_connection_validation.py +0 -0
  190. {py2max-0.3.1 → py2max-0.3.3}/tests/test_converters.py +0 -0
  191. {py2max-0.3.1 → py2max-0.3.3}/tests/test_core_coverage.py +0 -0
  192. {py2max-0.3.1 → py2max-0.3.3}/tests/test_encapsulate.py +0 -0
  193. {py2max-0.3.1 → py2max-0.3.3}/tests/test_error_handling.py +0 -0
  194. {py2max-0.3.1 → py2max-0.3.3}/tests/test_gen.py +0 -0
  195. {py2max-0.3.1 → py2max-0.3.3}/tests/test_group.py +0 -0
  196. {py2max-0.3.1 → py2max-0.3.3}/tests/test_layout.py +0 -0
  197. {py2max-0.3.1 → py2max-0.3.3}/tests/test_layout_flow.py +0 -0
  198. {py2max-0.3.1 → py2max-0.3.3}/tests/test_maxref.py +0 -0
  199. {py2max-0.3.1 → py2max-0.3.3}/tests/test_mc_poly.py +0 -0
  200. {py2max-0.3.1 → py2max-0.3.3}/tests/test_nested.py +0 -0
  201. {py2max-0.3.1 → py2max-0.3.3}/tests/test_nested_patchers.py +0 -0
  202. {py2max-0.3.1 → py2max-0.3.3}/tests/test_patcher.py +0 -0
  203. {py2max-0.3.1 → py2max-0.3.3}/tests/test_presets.py +0 -0
  204. {py2max-0.3.1 → py2max-0.3.3}/tests/test_search.py +0 -0
  205. {py2max-0.3.1 → py2max-0.3.3}/tests/test_semantic_ids.py +0 -0
  206. {py2max-0.3.1 → py2max-0.3.3}/tests/test_svg.py +0 -0
  207. {py2max-0.3.1 → py2max-0.3.3}/tests/test_transformers.py +0 -0
  208. {py2max-0.3.1 → py2max-0.3.3}/tests/test_tree.py +0 -0
  209. {py2max-0.3.1 → py2max-0.3.3}/tests/test_tree_builder.py +0 -0
  210. {py2max-0.3.1 → py2max-0.3.3}/tests/test_two_sines.py +0 -0
  211. {py2max-0.3.1 → py2max-0.3.3}/tests/test_utils.py +0 -0
  212. {py2max-0.3.1 → py2max-0.3.3}/tests/test_validate_attrs.py +0 -0
@@ -2,6 +2,118 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.3]
6
+
7
+ ### Removed: `serve` and `repl` CLI subcommands
8
+
9
+ - The `py2max serve` and `py2max repl` subcommands -- thin stubs that only pointed at the separate `py2max-server` package since v0.3.0 -- have been removed. The interactive live editor and remote REPL still live in `py2max-server` (`pip install py2max-server`).
10
+
11
+ ### Fixed: Graph layouts (`graph:*`) are overlap-free and open on-screen
12
+
13
+ - `GraphLayoutManager` now runs the dimension-aware overlap sweep (`prevent_overlaps`) after placement, so constraint/force engines no longer leave large UI objects (e.g. `scope~` at 130x130) overlapping their neighbours -- matching the guarantee the grid/flow managers already gave.
14
+ - The patcher window is grown to enclose the laid-out graph plus a margin, so `optimize_layout()` output opens with the whole graph visible instead of spilling past the default 640x480 canvas (the window never shrinks below the default).
15
+
16
+ ### Fixed: Clustered grid layout squashed object sizes
17
+
18
+ - The connection-aware clustering path of `GridLayoutManager` (`cluster_connected=True`) still wrote the manager's uniform 66x22 size back onto every clustered object, squashing UI objects (`scope~`, `ezdac~`, `dial`, ...) to text-box size. It now preserves each object's real width/height, matching the already-fixed non-clustered path.
19
+
20
+ ### Fixed: Example patches use valid Max connections
21
+
22
+ - The layout example scripts (`tests/examples/layout/`) and matrix-layout tests wired control objects (`metro`, `loadbang`) straight into oscillator signal inlets, which Max rejects ("error connecting outlet ... to ... inlet"). They now use the correct idiom -- a float number box sets the oscillator frequency, `metro` triggers envelopes, and envelopes modulate amplitude through a `*~` VCA -- so the generated demo patches load cleanly.
23
+
24
+ ### New: `kwds_filter` utility
25
+
26
+ - Added `py2max.utils.kwds_filter(kwds, **elems)`: returns `kwds` merged with the `elems` whose value is not `None` (legitimate falsy values such as `0` / `""` are kept, and the input is not mutated). Lets a method keep an optional parameter in its signature but omit it from the forwarded `**kwds` when the caller leaves it unset.
27
+
28
+ ### Improved: Build and test-output hygiene
29
+
30
+ - Coverage HTML now writes to `build/coverage-html` and the test suite writes its artifacts under `build/test-output/` -- both within the git-ignored `build/` tree -- instead of a tracked `outputs/` directory. A new `make test-outputs` target writes all test artifacts flat into `build/test-outputs/` for quick inspection.
31
+
32
+ ## [0.3.2]
33
+
34
+ ### New: Patch editing and removal API
35
+
36
+ - Loading a patch (`Patcher.from_dict` / `load`) now restores all ID-generation state -- object, node, edge, and semantic-ID counters -- so `add_*` calls made after a load no longer collide with existing object IDs. This fixes the headline "edit an existing patch" round-trip, which previously emitted duplicate IDs on the first post-load add.
37
+ - Added a removal / editing API with referential-integrity cleanup: `remove_line` / `disconnect`, `remove_box` / `remove`, and `replace`. Removing a box also prunes its dangling patchlines and clears the associated node/edge/index bookkeeping, so no orphaned lines or stale IDs are left behind.
38
+
39
+ ### New: Graph-layout engines as layout managers
40
+
41
+ - Three optional graph-layout backends -- HOLA (`hola-graph`), COLA plus force-directed / geometric layouts (`graph-layout`), and OGDF's layered / force-directed / planar layouts (`ogdf-py`) -- are now selectable as first-class layout managers via `layout="graph:<algo>"` (e.g. `graph:hola`, `graph:cola`, `graph:ogdf-sugiyama`). Because these algorithms need the whole graph, positions are applied on `optimize_layout()` rather than as each box is added, and each box's width/height is preserved so UI objects are not squashed. Eleven algorithms are available: `hola`, `cola`, `sugiyama`, `fruchterman-reingold`, `kamada-kawai`, `spectral`, `circular`, `shell`, `ogdf-sugiyama`, `ogdf-fmmm`, `ogdf-planarization`.
42
+ - The engines are lazy-imported inside the manager, so `import py2max` still pulls **zero runtime dependencies**; a missing backend raises a clear error naming the package to install. Install with `pip install "py2max[graph]"` -- the `graph` extra now also bundles `hola-graph` alongside `graph-layout` and `ogdf-py`. Implemented as `GraphLayoutManager` in `py2max/layout/external.py`.
43
+
44
+ ### New: Layout gallery generator and docs page
45
+
46
+ - `scripts/gen_layout_gallery.py` (run via `make gallery`) renders every supported graph layout over one shared sample patch to transparent SVGs under `docs/assets/imgs/`, using py2max's own SVG exporter so the results are directly comparable. Backends that are not installed are skipped rather than failing the run.
47
+ - A new published **Layout Gallery** page (`docs/user_guide/layout_gallery.md`, linked in the User Guide navigation) shows the rendered layouts and documents the `graph:<algo>` layout-manager API. The older networkx / graphviz / tsmpy experiments were dropped in favor of the three maintained backends.
48
+ - The generator also renders py2max's **built-in** managers (grid, flow, columnar, matrix) via `optimize_layout()` -- no external dependencies -- and the **Layout Managers** guide (`docs/user_guide/layout_managers.md`) now embeds these as inline visuals so each layout strategy can be seen, not just described.
49
+
50
+ ### Fixed: Generation correctness
51
+
52
+ - `add_coll` / `add_dict` / `add_table` no longer discard a caller-supplied `text` argument when `name` is `None` (an operator-precedence bug that let the fallback string win).
53
+ - `add_beap` strips the `.maxpat` suffix correctly; previously `rstrip(".maxpat")` could truncate names such as `drum.maxpat` down to `dru`.
54
+ - Parallel patchlines between the same source and destination now receive incrementing `order` values, so they spread apart in Max instead of overlapping.
55
+ - Comments created with `comment=` are emitted on every save path (`save_as`, `to_json`, `to_dict`), not only `save()` / `optimize_layout()`.
56
+ - Hand-typed objects that are not in the built-in defaults now get one outlet instead of zero, so they can act as a connection source.
57
+ - Load/save round-trip no longer injects `autosave` / `dependency_cache` defaults into nested subpatchers the source patch did not have; patches containing subpatchers now round-trip faithfully.
58
+
59
+ ### Fixed: Layout managers
60
+
61
+ - `layout="columnar"` now works; it previously raised `NotImplementedError` despite being documented.
62
+ - Layout optimizers preserve each object's real width and height. UI objects (`scope~`, `dial`, `slider`, `function`, `live.*`, comments) are no longer squashed to text-box size by `optimize_layout()`.
63
+ - The managers now share a single directed-graph model (`PatchGraph`) rather than re-deriving adjacency from patchlines in each manager, and object classification no longer carries contradictory category assignments (its context inference uses maxref signal typing with word-boundary matching).
64
+
65
+ ### Improved: maxref bundle loading (lazy and thread-safe)
66
+
67
+ - In bundle mode (no local Max install), the shipped catalog is no longer eagerly materialized into the cache on first access. The name-to-source map still loads once, but each object is now built from the in-memory bundle on demand, so a single-object query no longer constructs all ~1175 entries. A bundle object whose cache slot is empty is re-materialized from the bundle rather than returning nothing.
68
+ - The process-wide maxref cache is now thread-safe: an `RLock` guards the lazy refdict / category-map initialization and cache population, so concurrent `Box.help()` / validation lookups no longer race on first load or mutate the cache unsafely.
69
+
70
+ ### Fixed: maxref outlet digests and parser robustness
71
+
72
+ - Outlet `<digest>` text is now extracted symmetrically with inlets. A condition bug previously required the `<outlet>` element to have leading text and then stored that text instead of the digest, so outlet descriptions were dropped for almost every object (~2000 digests across ~1093 objects). `Box.help()` / `get_info()` now report outlet descriptions. The shipped offline `bundle.json.gz` was regenerated so bundle-mode users (Linux/Windows/no-Max) get the corrected data.
73
+ - A raw `&` in reference prose no longer drops the whole object on parse. Ampersands that do not begin a valid XML entity are escaped before parsing, hardening against `.maxref.xml` markup variations across Max versions (valid entities like `&amp;`, `&quot;`, `&#181;` are left intact).
74
+
75
+ ### Improved: Cross-platform maxref discovery
76
+
77
+ - Reference-page resolution now honors the `PY2MAX_MAX_REFPAGES` environment override on all platforms and auto-discovers a Windows `refpages` directory, so Windows users with Max installed read live reference data instead of always falling back to the bundled snapshot. A wheel-build test now asserts the offline `bundle.json.gz` actually ships in the built artifact.
78
+
79
+ ### Improved: SVG preview fidelity
80
+
81
+ - The SVG exporter (`Patcher.to_svg` / `py2max preview`) now renders a more faithful preview instead of a uniform grey schematic:
82
+ - **Box colors are honored.** Colors set via `Box.set_color` / `apply_theme` (`bgcolor`, `bordercolor`, `textcolor`) are drawn, converted from Max's `[r, g, b, a]` floats to CSS.
83
+ - **UI objects get recognizable affordances** rather than an identical rectangle: message boxes draw the right-edge flag notch, `toggle` an X, `button` a circle, number boxes (`flonum` / `number`) the left triangle marker, `dial` a circle with a pointer, and `slider` a thumb bar.
84
+ - **Ports resolve from the box's own `numinlets` / `numoutlets`** (what is written to the `.maxpat`) rather than a maxref lookup. Ports therefore render correctly for objects maxref does not know and without a Max install, and patchline endpoints line up with the ports they connect to (both use the same counts).
85
+ - `export_svg_string` now builds the document in memory instead of round-tripping through a temporary file.
86
+
87
+ ### Fixed: Layout classification and overlap resolution
88
+
89
+ - Object classification (matrix / columnar layouts) is now factored into a clear precedence -- curated functional intent, then maxref signal typing for the unknown audio tail, then name patterns -- with the rationale documented. Curated sets must win because functional categories do not map to raw signal I/O (even `cycle~` exposes a signal inlet, so signal typing alone would call it a processor; `adc~` is an input though it is a signal source). The dead, never-effective `_refine_column_assignments_by_flow` no-op and its call site were removed.
90
+ - `LayoutManager.prevent_overlaps` now converges. The previous version cached each object's rect before mutating it (so pushes stopped accumulating) and clamped boxes back inside the canvas (re-introducing the overlaps it had just removed), leaving dense layouts overlapping at the 50-iteration cap. It is replaced with a monotone sweep that pushes each object clear of already-placed ones along the axis of least penetration; it converges in a few passes, early-exits when nothing overlaps, and preserves each object's real size.
91
+
92
+ ### Improved: Patch transformers
93
+
94
+ - `run_pipeline` now delegates to `compose`, removing a duplicated apply loop and giving the previously-unused (but exported) `compose` helper a real use.
95
+ - The `add-comment` transformer's position is now reachable from the CLI: prefix the value with `above|below|left|right:` to place the comment (e.g. `--apply "add-comment=below:tempo"`), defaulting to `above`. A leading token that is not a position is kept as comment text, so `"note: hi"` is left intact.
96
+ - Added two transformers backed by existing APIs: `apply-theme` (apply a named color theme -- `light` / `dark` / `blue` / `high-contrast`) and `scale-positions` (scale every object's x/y position by a factor, sizes unchanged).
97
+
98
+ ### Fixed: Converters module cleanup
99
+
100
+ - Importing `py2max.export.converters` no longer constructs a `Patcher` as an import-time side effect (which pulled in maxref, layout, etc.). The default-attribute set is now computed lazily on first use and cached. Also removed a dead `_infer_category` helper and an unreachable `NotImplementedError` guard (the caller strips subpatcher dicts before that path).
101
+
102
+ ### Removed: Honest CLI help for the moved `serve` / `repl` subcommands
103
+
104
+ - The `serve` and `repl` subcommands (whose implementations moved to `py2max-server` in 0.3.0) no longer advertise themselves as live in `--help`; they now removed.
105
+
106
+ ### Removed: MaxRefDB deprecated alias methods
107
+
108
+ - Removed 12 long-deprecated `MaxRefDB` aliases in favor of the canonical API: `populate_from_maxref` / `populate_all_*` -> `populate([category=...])`, `search_objects` -> `search`, `get_objects_by_category` -> `by_category`, `get_all_categories` -> `.categories`, `get_object_count` -> `.count`, and `export_to_json` / `import_from_json` -> `export` / `load`. The database module is already off the import path (lazily loaded, stdlib-only), so this only trims its API surface. Callers, tests, and docs were updated to the canonical names.
109
+ - While updating the SQLite demo scripts, also fixed two pre-existing broken examples: `from py2max import MaxRefDB` (correct: `from py2max.maxref import MaxRefDB`) and `create_database(...)` used as a free function (it is `MaxRefDB.create_database(...)`). Both `tests/examples/db/` scripts now run.
110
+
111
+ ### Removed: Obsolete layout experiments and vendored editor assets
112
+
113
+ - Deleted the old graph-layout experiment tests (`tests/test_layout_hola{1,2,3}`, `test_layout_hola_graph`, `test_layout_networkx{1,2}`, `test_layout_nx_graphviz`, `test_layout_nx_orthogonal`, `test_layout_nx_tsmpy`). They exercised backends that are no longer supported (raw adaptagrams, networkx, pygraphviz, tsmpy) or duplicated the new `GraphLayoutManager` coverage, and only ever skipped. The maintained path is covered by `tests/test_layout_graph_manager.py`.
114
+ - Removed the vendored browser libraries under `docs/js/` (SVG.js, WebCola, D3) -- reference copies for the interactive editor that moved to the separate `py2max-server` package in 0.3.0 -- and the stale Sphinx build output under `docs/build/` that predated the MkDocs migration and was being copied into the published site.
115
+ - Pruned 30 obsolete design notes from the `docs/notes/` dev journal (REPL, SSE/WebSocket live-preview server, and interactive SVG-editor implementation notes), all for features that moved to `py2max-server`. The 13 still-relevant library/journal notes were kept.
116
+
5
117
  ## [0.3.1]
6
118
 
7
119
  ### New: Standalone `gen.codebox~` Support
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2max
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: A library for offline generation of Max/MSP patcher (.maxpat) files.
5
5
  Keywords: Max,maxpat,offline
6
6
  Author: Shakeeb Alireza
@@ -21,6 +21,9 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Operating System :: OS Independent
23
23
  Classifier: Typing :: Typed
24
+ Requires-Dist: graph-layout>=0.4 ; extra == 'graph'
25
+ Requires-Dist: ogdf-py>=0.3 ; python_full_version >= '3.10' and extra == 'graph'
26
+ Requires-Dist: hola-graph>=0.1 ; extra == 'graph'
24
27
  Maintainer: Shakeeb Alireza
25
28
  Maintainer-email: Shakeeb Alireza <shakfu@users.noreply.github.com>
26
29
  Requires-Python: >=3.9
@@ -29,6 +32,7 @@ Project-URL: Documentation, https://github.com/shakfu/py2max#readme
29
32
  Project-URL: Repository, https://github.com/shakfu/py2max.git
30
33
  Project-URL: Issues, https://github.com/shakfu/py2max/issues
31
34
  Project-URL: Changelog, https://github.com/shakfu/py2max/blob/main/CHANGELOG.md
35
+ Provides-Extra: graph
32
36
  Description-Content-Type: text/markdown
33
37
 
34
38
  # py2max
@@ -172,7 +176,7 @@ p.save()
172
176
 
173
177
  ### MaxRef Integration
174
178
 
175
- Access documentation for 1157 Max objects:
179
+ Access documentation for 1175 Max objects:
176
180
 
177
181
  ```python
178
182
  p = Patcher('demo.maxpat')
@@ -216,10 +220,10 @@ osc = p.find_by_id('cycle_1')
216
220
  Query Max object metadata efficiently:
217
221
 
218
222
  ```python
219
- from py2max import MaxRefDB
223
+ from py2max.maxref import MaxRefDB
220
224
 
221
225
  db = MaxRefDB() # Auto-cached on first use
222
- print(len(db)) # 1157 objects
226
+ print(len(db)) # 1175 objects
223
227
 
224
228
  if 'cycle~' in db:
225
229
  info = db['cycle~']
@@ -139,7 +139,7 @@ p.save()
139
139
 
140
140
  ### MaxRef Integration
141
141
 
142
- Access documentation for 1157 Max objects:
142
+ Access documentation for 1175 Max objects:
143
143
 
144
144
  ```python
145
145
  p = Patcher('demo.maxpat')
@@ -183,10 +183,10 @@ osc = p.find_by_id('cycle_1')
183
183
  Query Max object metadata efficiently:
184
184
 
185
185
  ```python
186
- from py2max import MaxRefDB
186
+ from py2max.maxref import MaxRefDB
187
187
 
188
188
  db = MaxRefDB() # Auto-cached on first use
189
- print(len(db)) # 1157 objects
189
+ print(len(db)) # 1175 objects
190
190
 
191
191
  if 'cycle~' in db:
192
192
  info = db['cycle~']
@@ -35,7 +35,7 @@ Example:
35
35
  >>> p.save()
36
36
  """
37
37
 
38
- __version__ = "0.3.1"
38
+ __version__ = "0.3.3"
39
39
 
40
40
  from .core import Box, Patcher, Patchline
41
41
  from .exceptions import (
@@ -148,6 +148,7 @@ def _generate_test_source(name: str, data: Dict[str, Any]) -> str:
148
148
 
149
149
 
150
150
  def cmd_new(args: argparse.Namespace) -> int:
151
+ """Create a new, empty patcher file at the given path."""
151
152
  path = Path(args.path)
152
153
  if path.exists() and not args.force:
153
154
  print(f"Refusing to overwrite existing file: {path}", file=sys.stderr)
@@ -179,6 +180,7 @@ def cmd_new(args: argparse.Namespace) -> int:
179
180
 
180
181
 
181
182
  def cmd_info(args: argparse.Namespace) -> int:
183
+ """Print a summary of a patcher file (object and connection counts)."""
182
184
  path = Path(args.path)
183
185
  patcher = Patcher.from_file(path)
184
186
  _coerce_rect(patcher)
@@ -201,6 +203,7 @@ def cmd_info(args: argparse.Namespace) -> int:
201
203
 
202
204
 
203
205
  def cmd_optimize(args: argparse.Namespace) -> int:
206
+ """Re-run layout optimization on a patcher and write the result."""
204
207
  input_path = Path(args.input)
205
208
  save_path = Path(args.output) if args.output else input_path
206
209
 
@@ -222,6 +225,7 @@ def cmd_optimize(args: argparse.Namespace) -> int:
222
225
 
223
226
 
224
227
  def cmd_validate(args: argparse.Namespace) -> int:
228
+ """Validate a patcher's connections against maxref inlet/outlet metadata."""
225
229
  path = Path(args.path)
226
230
  patcher = Patcher.from_file(path)
227
231
  _coerce_rect(patcher)
@@ -272,6 +276,7 @@ def _parse_transform_spec(spec: str) -> tuple[str, str | None]:
272
276
 
273
277
 
274
278
  def cmd_transform(args: argparse.Namespace) -> int:
279
+ """Apply one or more transformers to a patcher (or list available ones)."""
275
280
  if args.list_transformers:
276
281
  for name, desc in available_transformers().items():
277
282
  print(f"{name}: {desc}")
@@ -316,6 +321,7 @@ def cmd_transform(args: argparse.Namespace) -> int:
316
321
 
317
322
 
318
323
  def cmd_convert(args: argparse.Namespace) -> int:
324
+ """Convert between formats (e.g. .maxpat to Python, maxref to SQLite)."""
319
325
  if args.mode == "maxpat-to-python":
320
326
  if not args.input or not args.output:
321
327
  print(
@@ -636,19 +642,6 @@ def cmd_db_cache(args: argparse.Namespace) -> int:
636
642
  return 1
637
643
 
638
644
 
639
- def cmd_serve(args: argparse.Namespace) -> int:
640
- """The interactive server moved to the separate py2max-server package."""
641
- target = getattr(args, "input", None) or "<patch.maxpat>"
642
- print(
643
- "The interactive server has moved to the separate 'py2max-server' package.\n"
644
- "Install it and use its CLI instead:\n"
645
- " pip install py2max-server\n"
646
- f" py2max-server serve {target}",
647
- file=sys.stderr,
648
- )
649
- return 1
650
-
651
-
652
645
  def cmd_preview(args: argparse.Namespace) -> int:
653
646
  """Generate SVG preview of a patcher."""
654
647
  import tempfile
@@ -695,20 +688,8 @@ def cmd_preview(args: argparse.Namespace) -> int:
695
688
  return 1
696
689
 
697
690
 
698
- def cmd_repl(args: argparse.Namespace) -> int:
699
- """The remote REPL moved to the separate py2max-server package."""
700
- target = getattr(args, "server", None) or "localhost:9000"
701
- print(
702
- "The remote REPL has moved to the separate 'py2max-server' package.\n"
703
- "Install it and use its CLI instead:\n"
704
- " pip install py2max-server\n"
705
- f" py2max-server repl {target}",
706
- file=sys.stderr,
707
- )
708
- return 1
709
-
710
-
711
691
  def cmd_maxref(args: argparse.Namespace) -> int:
692
+ """Query the Max object reference (list objects or show one's details)."""
712
693
  cache = MaxRefCache()
713
694
 
714
695
  if args.list:
@@ -783,6 +764,7 @@ def cmd_maxref(args: argparse.Namespace) -> int:
783
764
 
784
765
 
785
766
  def build_parser() -> argparse.ArgumentParser:
767
+ """Construct the top-level argument parser with all subcommands."""
786
768
  parser = argparse.ArgumentParser(
787
769
  prog="py2max", description="Utilities for working with Max patchers."
788
770
  )
@@ -836,33 +818,6 @@ def build_parser() -> argparse.ArgumentParser:
836
818
  val_parser.add_argument("path", help="Target .maxpat path")
837
819
  val_parser.set_defaults(func=cmd_validate)
838
820
 
839
- serve_parser = subparsers.add_parser(
840
- "serve", help="Start interactive server with live preview"
841
- )
842
- serve_parser.add_argument("input", help="Input .maxpat file")
843
- serve_parser.add_argument(
844
- "--port",
845
- type=int,
846
- default=8000,
847
- help="HTTP server port (default: 8000, WebSocket on port+1)",
848
- )
849
- serve_parser.add_argument(
850
- "--no-open", action="store_true", help="Don't automatically open browser"
851
- )
852
- serve_parser.add_argument(
853
- "--no-save", action="store_true", help="Disable auto-save on changes"
854
- )
855
- serve_parser.add_argument(
856
- "--repl",
857
- action="store_true",
858
- help="Start interactive REPL for live patch editing",
859
- )
860
- serve_parser.add_argument(
861
- "--log-file",
862
- help="Redirect server logs to file (enables single-terminal REPL mode when used with --repl)",
863
- )
864
- serve_parser.set_defaults(func=cmd_serve)
865
-
866
821
  preview_parser = subparsers.add_parser(
867
822
  "preview", help="Generate SVG preview of a patcher"
868
823
  )
@@ -1094,26 +1049,11 @@ def build_parser() -> argparse.ArgumentParser:
1094
1049
  )
1095
1050
  cache_clear.set_defaults(func=cmd_db)
1096
1051
 
1097
- # REPL client command
1098
- repl_parser = subparsers.add_parser("repl", help="Connect to remote REPL server")
1099
- repl_parser.add_argument(
1100
- "server",
1101
- nargs="?",
1102
- default="localhost:9000",
1103
- help="Server address (default: localhost:9000)",
1104
- )
1105
- repl_parser.add_argument(
1106
- "--token",
1107
- default=None,
1108
- help="Session token printed by the server "
1109
- "(or set PY2MAX_REPL_TOKEN). Required for authentication.",
1110
- )
1111
- repl_parser.set_defaults(func=cmd_repl)
1112
-
1113
1052
  return parser
1114
1053
 
1115
1054
 
1116
1055
  def main(argv: List[str] | None = None) -> int:
1056
+ """CLI entry point: parse ``argv`` and dispatch to the subcommand handler."""
1117
1057
  parser = build_parser()
1118
1058
  args = parser.parse_args(argv)
1119
1059
 
@@ -60,6 +60,8 @@ class AbstractBox(ABC):
60
60
  id: Optional[str]
61
61
  maxclass: str
62
62
  patching_rect: Rect
63
+ numinlets: int
64
+ numoutlets: int
63
65
  _kwds: Dict[str, Any]
64
66
 
65
67
  @abstractmethod
@@ -135,8 +137,6 @@ class AbstractPatcher(ABC):
135
137
  _lines: list[AbstractPatchline]
136
138
  _edge_ids: list[tuple[str, str]]
137
139
  _id_counter: int = 0
138
- _link_counter: int = 0
139
- _last_link: Optional[tuple[str, str]]
140
140
  _reset_on_render: bool
141
141
  _flow_direction: str
142
142
  _cluster_connected: bool
@@ -325,14 +325,13 @@ class BoxFactoryMixin(AbstractPatcher):
325
325
  inlet=dst_inlet,
326
326
  )
327
327
 
328
- # get order of lines between same pair of objects
329
- if (src_id, dst_id) == self._last_link:
330
- self._link_counter += 1
331
- else:
332
- self._link_counter = 0
333
- self._last_link = (src_id, dst_id)
334
-
335
- order = self._link_counter
328
+ # Order of lines between the same pair of objects: Max uses it to fan
329
+ # parallel wires apart. Derive it from the connections that currently
330
+ # exist for this (src, dst) pair rather than a single-slot memo of the
331
+ # last link -- the memo reset ``order`` to 0 whenever any other
332
+ # connection intervened, collapsing parallel wires (and it also could
333
+ # not account for lines restored from a loaded patch).
334
+ order = sum(1 for pl in self._lines if pl.src == src_id and pl.dst == dst_id)
336
335
  src, dst = [src_id, src_outlet], [dst_id, dst_inlet]
337
336
  patchline = Patchline(source=src, destination=dst, order=order)
338
337
  self._lines.append(patchline)
@@ -447,7 +446,11 @@ class BoxFactoryMixin(AbstractPatcher):
447
446
  text=text,
448
447
  maxclass=maxclass or "newobj",
449
448
  numinlets=numinlets if numinlets is not None else 1,
450
- numoutlets=numoutlets if numoutlets is not None else 0,
449
+ # An object with no maxref entry gets one outlet by default (not
450
+ # zero): a zero-outlet object cannot act as a connection source,
451
+ # which is wrong for the hand-typed long-tail objects that land
452
+ # here. Matches Box.__init__'s default.
453
+ numoutlets=numoutlets if numoutlets is not None else 1,
451
454
  outlettype=outlettype if outlettype is not None else [""],
452
455
  patching_rect=patching_rect,
453
456
  **kwds,
@@ -1011,6 +1014,155 @@ class BoxFactoryMixin(AbstractPatcher):
1011
1014
  if edge in self._edge_ids:
1012
1015
  self._edge_ids.remove(edge)
1013
1016
 
1017
+ @staticmethod
1018
+ def _as_box_id(box: "Union[Box, str]") -> str:
1019
+ """Resolve a Box or id string to its id."""
1020
+ return box.id if isinstance(box, Box) else box # type: ignore[return-value]
1021
+
1022
+ def remove_line(self, line: "Patchline") -> None:
1023
+ """Remove a specific patchline connection from the patcher."""
1024
+ self._drop_line(line)
1025
+
1026
+ def disconnect(
1027
+ self,
1028
+ src: "Union[Box, str]",
1029
+ dst: "Union[Box, str]",
1030
+ outlet: int = 0,
1031
+ inlet: int = 0,
1032
+ ) -> int:
1033
+ """Remove connection(s) between ``src`` and ``dst``.
1034
+
1035
+ Removes every patchline from ``src[outlet]`` to ``dst[inlet]`` and
1036
+ returns how many were removed (0 if none matched). ``src``/``dst`` may be
1037
+ Box objects or their ids.
1038
+
1039
+ Example:
1040
+ >>> p.add_line(osc, gain)
1041
+ >>> p.disconnect(osc, gain)
1042
+ 1
1043
+ """
1044
+ src_id = self._as_box_id(src)
1045
+ dst_id = self._as_box_id(dst)
1046
+ matches = [
1047
+ cast(Patchline, pl)
1048
+ for pl in self._lines
1049
+ if pl.src == src_id
1050
+ and pl.dst == dst_id
1051
+ and cast(Patchline, pl).source[1] == outlet
1052
+ and cast(Patchline, pl).destination[1] == inlet
1053
+ ]
1054
+ for line in matches:
1055
+ self._drop_line(line)
1056
+ return len(matches)
1057
+
1058
+ def remove_box(self, box: "Union[Box, str]") -> Optional["Box"]:
1059
+ """Remove a box and every patchline connected to it.
1060
+
1061
+ Accepts a Box or its id. Drops all incident patchlines, the box's entry
1062
+ in the object map / node index, and any pending associated comment for
1063
+ it. Returns the removed Box, or None if it was not present.
1064
+
1065
+ Example:
1066
+ >>> osc = p.add_textbox('cycle~ 440')
1067
+ >>> p.remove_box(osc)
1068
+ """
1069
+ box_id = self._as_box_id(box)
1070
+
1071
+ for line in [pl for pl in self._lines if pl.src == box_id or pl.dst == box_id]:
1072
+ self._drop_line(line)
1073
+
1074
+ removed = self._objects.pop(box_id, None)
1075
+ target = box if isinstance(box, Box) else removed
1076
+ if target in self._boxes:
1077
+ self._boxes.remove(target)
1078
+ if box_id in self._node_ids:
1079
+ self._node_ids.remove(box_id)
1080
+ self._pending_comments = [
1081
+ pc for pc in self._pending_comments if pc[0] != box_id
1082
+ ]
1083
+ return cast(Optional["Box"], target)
1084
+
1085
+ # alias for remove_box
1086
+ remove = remove_box
1087
+
1088
+ def replace(
1089
+ self,
1090
+ old: "Union[Box, str]",
1091
+ new: "Union[Box, str]",
1092
+ **kwds: Any,
1093
+ ) -> "Box":
1094
+ """Replace a box in place, preserving its position and connections.
1095
+
1096
+ ``old`` (a Box or its id) is swapped for ``new`` -- either the text for a
1097
+ replacement object (created with its Max-class defaults) or an
1098
+ already-built Box. The replacement takes ``old``'s slot and window
1099
+ position, and every patchline incident to ``old`` is rewired to it,
1100
+ keeping the same outlet/inlet indices and connection order. Any pending
1101
+ associated comment for ``old`` transfers to the replacement. Returns the
1102
+ new Box.
1103
+
1104
+ Ports are preserved by index: if the replacement has fewer inlets/outlets
1105
+ than the original, wires to the now-missing ports are kept as-is rather
1106
+ than silently dropped here (Max drops them on load).
1107
+
1108
+ Example:
1109
+ >>> osc = p.add('cycle~ 440')
1110
+ >>> p.add_line(osc, gain)
1111
+ >>> saw = p.replace(osc, 'saw~ 220') # saw~ inherits osc's wiring
1112
+ """
1113
+ old_id = self._as_box_id(old)
1114
+ old_box = self._objects.get(old_id)
1115
+ if old_box is None:
1116
+ raise KeyError(f"cannot replace unknown box {old_id!r}")
1117
+
1118
+ box_idx = self._boxes.index(old_box)
1119
+ node_idx = self._node_ids.index(old_id) if old_id in self._node_ids else None
1120
+ ox, oy, _, _ = old_box.patching_rect
1121
+
1122
+ # Build the replacement. add_box/add_textbox appends it at the end; it is
1123
+ # relocated into old's slot after rewiring. Keep the replacement's own
1124
+ # width/height but move it to old's window position.
1125
+ if isinstance(new, Box):
1126
+ if new.id is None:
1127
+ new.id = self.get_id(new.maxclass)
1128
+ _, _, nw, nh = new.patching_rect
1129
+ new.patching_rect = Rect(ox, oy, nw, nh)
1130
+ new_box = self.add_box(new)
1131
+ else:
1132
+ new_box = self.add_textbox(new, **kwds)
1133
+ _, _, nw, nh = new_box.patching_rect
1134
+ new_box.patching_rect = Rect(ox, oy, nw, nh)
1135
+
1136
+ new_id = new_box.id
1137
+ assert new_id
1138
+
1139
+ # Rewire every incident patchline to the new box, preserving port indices
1140
+ # and order, then rebuild the edge index to match.
1141
+ for line in self._lines:
1142
+ pl = cast(Patchline, line)
1143
+ if pl.source and pl.source[0] == old_id:
1144
+ pl.source = [new_id, pl.source[1]]
1145
+ if pl.destination and pl.destination[0] == old_id:
1146
+ pl.destination = [new_id, pl.destination[1]]
1147
+ self._edge_ids = [(pl.src, pl.dst) for pl in self._lines]
1148
+
1149
+ # Transfer any pending associated comment from old to new.
1150
+ self._pending_comments = [
1151
+ (new_id if bid == old_id else bid, text, pos)
1152
+ for (bid, text, pos) in self._pending_comments
1153
+ ]
1154
+
1155
+ # Drop the old box and move the replacement into its original slot so
1156
+ # ordering is preserved (add_* had appended it at the end).
1157
+ del self._objects[old_id]
1158
+ self._boxes.remove(new_box) # drop the end-appended copy
1159
+ self._boxes[box_idx] = new_box # overwrite old_box in its slot
1160
+ if node_idx is not None:
1161
+ self._node_ids.remove(new_id)
1162
+ self._node_ids[node_idx] = new_id
1163
+
1164
+ return new_box
1165
+
1014
1166
  def encapsulate(
1015
1167
  self, boxes: "Iterable[Box]", text: str = "p subpatch", **kwds: Any
1016
1168
  ) -> "Box":
@@ -1241,9 +1393,8 @@ class BoxFactoryMixin(AbstractPatcher):
1241
1393
  return self.add_box(
1242
1394
  Box(
1243
1395
  id=id or self.get_id("coll"),
1244
- text=text or f"coll {name} @embed {embed}"
1245
- if name
1246
- else f"coll @embed {embed}",
1396
+ text=text
1397
+ or (f"coll {name} @embed {embed}" if name else f"coll @embed {embed}"),
1247
1398
  maxclass="newobj",
1248
1399
  numinlets=1,
1249
1400
  numoutlets=4,
@@ -1280,9 +1431,8 @@ class BoxFactoryMixin(AbstractPatcher):
1280
1431
  return self.add_box(
1281
1432
  Box(
1282
1433
  id=id or self.get_id("dict"),
1283
- text=text or f"dict {name} @embed {embed}"
1284
- if name
1285
- else f"dict @embed {embed}",
1434
+ text=text
1435
+ or (f"dict {name} @embed {embed}" if name else f"dict @embed {embed}"),
1286
1436
  maxclass="newobj",
1287
1437
  numinlets=2,
1288
1438
  numoutlets=4,
@@ -1329,9 +1479,12 @@ class BoxFactoryMixin(AbstractPatcher):
1329
1479
  return self.add_box(
1330
1480
  Box(
1331
1481
  id=id or self.get_id(table_type),
1332
- text=text or f"{table_type} {name} @embed {embed}"
1333
- if name
1334
- else f"{table_type} @embed {embed}",
1482
+ text=text
1483
+ or (
1484
+ f"{table_type} {name} @embed {embed}"
1485
+ if name
1486
+ else f"{table_type} @embed {embed}"
1487
+ ),
1335
1488
  maxclass="newobj",
1336
1489
  numinlets=2,
1337
1490
  numoutlets=2,
@@ -1488,5 +1641,5 @@ class BoxFactoryMixin(AbstractPatcher):
1488
1641
  def add_beap(self, name: str, **kwds: Any) -> "Box":
1489
1642
  """Add a beap bpatcher object."""
1490
1643
 
1491
- _varname = name if ".maxpat" not in name else name.rstrip(".maxpat")
1644
+ _varname = name if ".maxpat" not in name else name.removesuffix(".maxpat")
1492
1645
  return self.add_bpatcher(name=name, varname=_varname, extract=1, **kwds)