collagraph 0.9.2__tar.gz → 0.9.4__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 (177) hide show
  1. {collagraph-0.9.2 → collagraph-0.9.4}/.github/workflows/ci.yml +17 -1
  2. {collagraph-0.9.2 → collagraph-0.9.4}/.gitignore +1 -0
  3. {collagraph-0.9.2 → collagraph-0.9.4}/PKG-INFO +1 -1
  4. collagraph-0.9.4/collagraph/__pyinstaller/hook-collagraph.py +126 -0
  5. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/fragment.py +121 -38
  6. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/treewidgetitem.py +49 -3
  7. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/sfc/importer.py +5 -2
  8. collagraph-0.9.4/examples/pyside/tree_dnd_example.cgx +290 -0
  9. collagraph-0.9.4/examples/pyside/tree_dnd_item.cgx +28 -0
  10. collagraph-0.9.4/examples/pyside/tree_dnd_model.py +232 -0
  11. collagraph-0.9.4/examples/pyside/tree_dnd_widget.py +77 -0
  12. {collagraph-0.9.2 → collagraph-0.9.4}/pyproject.toml +2 -1
  13. collagraph-0.9.4/tests/conftest.py +169 -0
  14. collagraph-0.9.4/tests/pyinstaller/expected_output.json +28 -0
  15. collagraph-0.9.4/tests/pyinstaller/test_pyinstaller.sh +38 -0
  16. collagraph-0.9.4/tests/pyinstaller/todo_app/app.py +14 -0
  17. collagraph-0.9.4/tests/pyinstaller/todo_app/helpers.py +3 -0
  18. collagraph-0.9.4/tests/pyinstaller/todo_app/todo_item.cgx +11 -0
  19. collagraph-0.9.4/tests/pyinstaller/todo_app/todo_list.cgx +23 -0
  20. collagraph-0.9.4/tests/pyside/test_tree_dnd_example.py +621 -0
  21. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_directive_for_keyed.py +19 -119
  22. collagraph-0.9.4/tests/test_directive_for_keyed_multi_root.py +249 -0
  23. collagraph-0.9.4/tests/test_directive_for_keyed_nested.py +197 -0
  24. collagraph-0.9.4/tests/test_directive_for_keyed_reverse.py +377 -0
  25. collagraph-0.9.4/tests/test_tree_dnd_model.py +367 -0
  26. collagraph-0.9.2/collagraph/__pyinstaller/hook-collagraph.py +0 -58
  27. collagraph-0.9.2/tests/conftest.py +0 -47
  28. {collagraph-0.9.2 → collagraph-0.9.4}/.coveragerc +0 -0
  29. {collagraph-0.9.2 → collagraph-0.9.4}/.pre-commit-config.yaml +0 -0
  30. {collagraph-0.9.2 → collagraph-0.9.4}/LICENSE +0 -0
  31. {collagraph-0.9.2 → collagraph-0.9.4}/README.md +0 -0
  32. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/__init__.py +0 -0
  33. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/__main__.py +0 -0
  34. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/__pyinstaller/__init__.py +0 -0
  35. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/collagraph.py +0 -0
  36. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/component.py +0 -0
  37. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/constants.py +0 -0
  38. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/hot_reload.py +0 -0
  39. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/__init__.py +0 -0
  40. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/dict_renderer.py +0 -0
  41. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pygfx_renderer.py +0 -0
  42. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/__init__.py +0 -0
  43. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/__init__.py +0 -0
  44. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/combobox.py +0 -0
  45. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/dialogbuttonbox.py +0 -0
  46. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/dockwidget.py +0 -0
  47. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/itemmodel.py +0 -0
  48. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/layouts.py +0 -0
  49. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/listview.py +0 -0
  50. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/menu.py +0 -0
  51. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/menubar.py +0 -0
  52. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/qobject.py +0 -0
  53. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/scrollarea.py +0 -0
  54. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/splitter.py +0 -0
  55. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/standarditem.py +0 -0
  56. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/statusbar.py +0 -0
  57. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/tab.py +0 -0
  58. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/toolbar.py +0 -0
  59. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/treewidget.py +0 -0
  60. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/widget.py +0 -0
  61. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside/objects/window.py +0 -0
  62. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/renderers/pyside_renderer.py +0 -0
  63. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/sfc/__init__.py +0 -0
  64. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/sfc/compiler.py +0 -0
  65. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/sfc/parser.py +0 -0
  66. {collagraph-0.9.2 → collagraph-0.9.4}/collagraph/weak.py +0 -0
  67. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pygfx/README.md +0 -0
  68. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pygfx/app_point_cloud.cgx +0 -0
  69. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pygfx/button.cgx +0 -0
  70. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pygfx/combined-example.py +0 -0
  71. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pygfx/component-example.py +0 -0
  72. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pygfx/numberpad.cgx +0 -0
  73. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pygfx/point.cgx +0 -0
  74. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pygfx/point_cloud.cgx +0 -0
  75. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pygfx/pygfx-component.cgx +0 -0
  76. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pygfx/render_widget.cgx +0 -0
  77. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/app.py +0 -0
  78. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/big_list.cgx +0 -0
  79. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/cgx_component_example.cgx +0 -0
  80. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/combobox_example.cgx +0 -0
  81. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/counter.cgx +0 -0
  82. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/debug_example.cgx +0 -0
  83. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/dialog_example.cgx +0 -0
  84. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/dialog_example_window.cgx +0 -0
  85. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/flow_layout.py +0 -0
  86. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/item.cgx +0 -0
  87. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/layout_example.cgx +0 -0
  88. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/list_and_form.cgx +0 -0
  89. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/lists_example.cgx +0 -0
  90. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/slider_example.cgx +0 -0
  91. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/tabs_example.cgx +0 -0
  92. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/template_refs.cgx +0 -0
  93. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/todo_example.cgx +0 -0
  94. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/todo_list.cgx +0 -0
  95. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/tree_view_example.cgx +0 -0
  96. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/tree_view_item.cgx +0 -0
  97. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/tree_view_model.cgx +0 -0
  98. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/tree_widget_example.cgx +0 -0
  99. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/tree_widget_item.cgx +0 -0
  100. {collagraph-0.9.2 → collagraph-0.9.4}/examples/pyside/treewidget_selection_demo.cgx +0 -0
  101. {collagraph-0.9.2 → collagraph-0.9.4}/tests/__init__.py +0 -0
  102. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/component/events_child.cgx +0 -0
  103. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/component/events_parent.cgx +0 -0
  104. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/component/provide_child.cgx +0 -0
  105. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/component/provide_parent.cgx +0 -0
  106. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/component/provide_root.cgx +0 -0
  107. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/example.cgx +0 -0
  108. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/file_dunder.cgx +0 -0
  109. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/multiple_classes_right_order.cgx +0 -0
  110. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/multiple_classes_wrong_order.cgx +0 -0
  111. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/multiple_root_elements.cgx +0 -0
  112. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/no_component_class.cgx +0 -0
  113. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/pyside/app.cgx +0 -0
  114. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/pyside/test_pyside_update.cgx +0 -0
  115. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/shadow_imports.cgx +0 -0
  116. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/simple.cgx +0 -0
  117. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/dynamic_component.cgx +0 -0
  118. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/dynamic_for.cgx +0 -0
  119. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/dynamic_for_template.cgx +0 -0
  120. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/dynamic_if.cgx +0 -0
  121. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/dynamic_if_template.cgx +0 -0
  122. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/node.cgx +0 -0
  123. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/simple_tree.cgx +0 -0
  124. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/slots_implicit_default_slot.cgx +0 -0
  125. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/template.cgx +0 -0
  126. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/template_empty.cgx +0 -0
  127. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/template_implicit_default_slot.cgx +0 -0
  128. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/template_implicit_default_slot_name.cgx +0 -0
  129. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/template_partial.cgx +0 -0
  130. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/tree.cgx +0 -0
  131. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/slots/widget_with_slots.cgx +0 -0
  132. {collagraph-0.9.2 → collagraph-0.9.4}/tests/data/text/expressions.cgx +0 -0
  133. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/conftest.py +0 -0
  134. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/lists.cgx +0 -0
  135. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/test_pyside_ci.py +0 -0
  136. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/test_pyside_elements.py +0 -0
  137. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/test_pyside_layouts.py +0 -0
  138. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/test_pyside_renderer.py +0 -0
  139. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/test_pyside_resilience.py +0 -0
  140. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/test_pyside_sfc.py +0 -0
  141. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/test_pyside_trigger_signals.py +0 -0
  142. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/test_treewidget_selection.py +0 -0
  143. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/test_update.py +0 -0
  144. {collagraph-0.9.2 → collagraph-0.9.4}/tests/pyside/test_widget.py +0 -0
  145. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_anchors.py +0 -0
  146. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_attributes_dynamic.py +0 -0
  147. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_attributes_static.py +0 -0
  148. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_collagraph.py +0 -0
  149. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_component.py +0 -0
  150. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_component_events.py +0 -0
  151. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_component_life_cycle.py +0 -0
  152. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_component_provide_inject.py +0 -0
  153. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_directive_else.py +0 -0
  154. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_directive_else_if.py +0 -0
  155. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_directive_for.py +0 -0
  156. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_directive_if.py +0 -0
  157. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_directive_if_no_unnecessary_remount.py +0 -0
  158. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_events.py +0 -0
  159. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_hot_reload.py +0 -0
  160. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_hot_reload_preservation.py +0 -0
  161. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_load_string.py +0 -0
  162. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_parser.py +0 -0
  163. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_pygfx_renderer.py +0 -0
  164. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_resolve_names.py +0 -0
  165. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_sfc_comments.py +0 -0
  166. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_sfc_compilation.py +0 -0
  167. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_sfc_import.py +0 -0
  168. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_sfc_linting_support.py +0 -0
  169. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_sfc_shadowing.py +0 -0
  170. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_slots.py +0 -0
  171. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_slots_dynamic.py +0 -0
  172. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_tag_component.py +0 -0
  173. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_tag_dynamic.py +0 -0
  174. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_tag_static.py +0 -0
  175. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_tag_template.py +0 -0
  176. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_template_refs.py +0 -0
  177. {collagraph-0.9.2 → collagraph-0.9.4}/tests/test_text_expressions.py +0 -0
@@ -65,6 +65,22 @@ jobs:
65
65
  env:
66
66
  QT_QPA_PLATFORM: offscreen
67
67
 
68
+ pyinstaller:
69
+ name: Test PyInstaller hooks
70
+ runs-on: ubuntu-latest
71
+ steps:
72
+ - uses: actions/checkout@v5
73
+ - name: Install uv
74
+ uses: astral-sh/setup-uv@v6
75
+ - name: Set up Python
76
+ uses: actions/setup-python@v5
77
+ with:
78
+ python-version-file: "pyproject.toml"
79
+ - name: Install dependencies
80
+ run: uv sync
81
+ - name: Test PyInstaller hooks
82
+ run: bash tests/pyinstaller/test_pyinstaller.sh
83
+
68
84
  build:
69
85
  name: Build and test wheel
70
86
  runs-on: ubuntu-latest
@@ -91,7 +107,7 @@ jobs:
91
107
  publish:
92
108
  name: Publish to Github and Pypi
93
109
  runs-on: ubuntu-latest
94
- needs: [lint, test, build]
110
+ needs: [lint, test, build, pyinstaller]
95
111
  if: success() && startsWith(github.ref, 'refs/tags/v')
96
112
  environment:
97
113
  name: pypi
@@ -4,5 +4,6 @@ __pycache__
4
4
  .coverage
5
5
  dist
6
6
  build
7
+ *.spec
7
8
  uv.lock
8
9
  .venv
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collagraph
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Reactive user interfaces
5
5
  Author-email: Berend Klein Haneveld <berendkleinhaneveld@gmail.com>, Korijn van Golen <korijn@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,126 @@
1
+ import ast
2
+ import atexit
3
+ from pathlib import Path
4
+
5
+ from PyInstaller.lib.modulegraph.modulegraph import DependencyInfo, MissingModule
6
+
7
+ from collagraph.sfc.compiler import construct_ast, get_script_ast
8
+ from collagraph.sfc.parser import CGXParser
9
+
10
+ # Keep track of generated files so they can be cleaned up
11
+ _generated_files: list[Path] = []
12
+
13
+
14
+ def _cleanup_generated_files():
15
+ for path in _generated_files:
16
+ path.unlink(missing_ok=True)
17
+
18
+
19
+ atexit.register(_cleanup_generated_files)
20
+
21
+
22
+ def hook(hook_api):
23
+ collagraph_uses = hook_api.analysis.graph.get_code_using("collagraph")
24
+
25
+ hidden_imports = set()
26
+ cgx_modules = set()
27
+
28
+ for package, code in collagraph_uses.items():
29
+ source_dir = Path(code.co_filename).parent
30
+
31
+ # Collect CGX files and their imports from the source directory
32
+ cgx_files = list(source_dir.glob("**/*.cgx"))
33
+ hidden_imports |= collect_hidden_imports(cgx_files)
34
+
35
+ # Pre-compile each CGX file to Python source so that
36
+ # PyInstaller can bundle them as regular Python modules,
37
+ # removing the need to bundle .cgx files and compile them
38
+ # at runtime
39
+ for cgx_path in cgx_files:
40
+ module_name = cgx_path.stem
41
+ tree, _name = construct_ast(cgx_path)
42
+ python_source = ast.unparse(tree)
43
+
44
+ # Write compiled module next to the CGX file so
45
+ # PyInstaller can discover it on its existing search path
46
+ py_path = cgx_path.with_suffix(".py")
47
+ if not py_path.exists():
48
+ py_path.write_text(python_source, encoding="utf-8")
49
+ _generated_files.append(py_path)
50
+ cgx_modules.add(module_name)
51
+
52
+ # Filter out imports that correspond to CGX modules, since
53
+ # they are now available as pre-compiled Python modules
54
+ hidden_imports -= cgx_modules
55
+
56
+ graph = hook_api.analysis.graph
57
+
58
+ # Remove any MissingModule nodes for CGX modules that were
59
+ # already marked as missing during initial analysis (before
60
+ # the compiled .py files existed).
61
+ # removeNode() calls hide_node() which moves the node to
62
+ # hidden_nodes. Graph.add_node() silently ignores nodes in
63
+ # hidden_nodes, so we must also delete from hidden_nodes to
64
+ # allow import_hook to re-add the module as a SourceModule.
65
+ for name in cgx_modules:
66
+ node = graph.find_node(name)
67
+ if isinstance(node, MissingModule):
68
+ graph.removeNode(node)
69
+ if name in graph.graph.hidden_nodes:
70
+ del graph.graph.hidden_nodes[name]
71
+
72
+ # Import CGX modules via the module graph and create edges
73
+ # to the collagraph node so they are included in the bundle.
74
+ collagraph_node = graph.find_node("collagraph")
75
+ for name in cgx_modules:
76
+ try:
77
+ nodes = graph.import_hook(name, collagraph_node)
78
+ for node in nodes:
79
+ graph._updateReference(
80
+ collagraph_node,
81
+ node,
82
+ edge_data=DependencyInfo(
83
+ conditional=False,
84
+ fromlist=False,
85
+ function=False,
86
+ tryexcept=False,
87
+ ),
88
+ )
89
+ except ImportError:
90
+ pass
91
+
92
+ # Add remaining hidden imports (non-CGX Python modules)
93
+ hook_api.add_imports(*hidden_imports)
94
+
95
+
96
+ def collect_hidden_imports(cgx_files):
97
+ hidden_imports = set()
98
+ for path in cgx_files:
99
+ template = path.read_text(encoding="utf-8")
100
+ # Parse the file component into a tree of Node instances
101
+ parser = CGXParser()
102
+ parser.feed(template)
103
+
104
+ # Get the AST from the script tag
105
+ script_tree = get_script_ast(parser, path)
106
+
107
+ # Find a list of imported module names
108
+ imported_names = ImportsCollector()
109
+ imported_names.visit(script_tree)
110
+
111
+ hidden_imports |= imported_names.names
112
+
113
+ return hidden_imports
114
+
115
+
116
+ class ImportsCollector(ast.NodeVisitor):
117
+ def __init__(self):
118
+ self.names = set()
119
+
120
+ def visit_ImportFrom(self, node):
121
+ if node.module:
122
+ self.names.add(node.module)
123
+
124
+ def visit_Import(self, node):
125
+ for alias in node.names:
126
+ self.names.add(alias.name)
@@ -581,11 +581,20 @@ class ListFragment(Fragment):
581
581
 
582
582
  # Keys that are no longer present - unmount them
583
583
  removed_keys = old_key_set - new_key_set
584
+ removed_fragments = set()
584
585
  for key in removed_keys:
585
586
  fragment = self.key_to_fragment.pop(key)
586
- # Remove from children list
587
- self.children.remove(fragment)
588
587
  fragment.unmount()
588
+ removed_fragments.add(fragment)
589
+
590
+ # Create a filtered snapshot of the old children order
591
+ # (excluding removed fragments) for position comparisons
592
+ old_children = [f for f in self.children if f not in removed_fragments]
593
+
594
+ # Build a map from fragment to its old position
595
+ old_fragment_positions = {
596
+ frag: i for i, frag in enumerate(old_children)
597
+ }
589
598
 
590
599
  # Build new children array in the correct order
591
600
  new_children = []
@@ -605,9 +614,33 @@ class ListFragment(Fragment):
605
614
  self.key_to_fragment[key] = fragment
606
615
  new_children.append(fragment)
607
616
 
608
- # Now we need to reorder/mount the DOM elements to match new_children
609
- # We process from the end to the beginning to avoid interference
610
- # from previous moves
617
+ # Map reused fragments to their old positions, skip new ones
618
+ old_positions = []
619
+ reused_indices = []
620
+ for i, fragment in enumerate(new_children):
621
+ if fragment in old_fragment_positions:
622
+ old_positions.append(old_fragment_positions[fragment])
623
+ reused_indices.append(i)
624
+
625
+ # Find LIS - these fragments are already in correct order
626
+ # Example: [A, B, C] -> [C, A, B]
627
+ # Old positions: {A:0, B:1, C:2}
628
+ # Map to old positions: [2, 0, 1]
629
+ # LIS: [0, 1] (A, B already in order)
630
+ # Only move C
631
+ lis_indices = longest_increasing_subsequence(old_positions)
632
+ # Convert LIS result to set of new_children indices
633
+ dont_move = {reused_indices[i] for i in lis_indices}
634
+
635
+ # Reconciliation strategy: Process new_children from end to
636
+ # start using anchors. Three different orderings in play:
637
+ # - old_children: The OLD order (filtered snapshot)
638
+ # - new_children: The desired NEW order
639
+ # - DOM state: Updated incrementally as we process each fragment
640
+ #
641
+ # We process backwards (end to beginning) so that anchors
642
+ # (elements to the right) are already in their final
643
+ # positions when we use them as insertion points.
611
644
  for i in range(len(new_children) - 1, -1, -1):
612
645
  fragment = new_children[i]
613
646
 
@@ -625,40 +658,14 @@ class ListFragment(Fragment):
625
658
  if not fragment._mounted:
626
659
  # Mount new fragment at the correct position
627
660
  fragment.mount(target, anchor=anchor)
628
- else:
629
- # Fragment is already mounted, move it in the DOM if needed
630
- if fragment.element:
631
- # Check if it's already in the correct position
632
- # Get the actual next sibling in the current DOM
633
- current_next = self._get_next_sibling(
634
- fragment.element, target
635
- )
636
-
637
- # Only move if not already in correct position
638
- if current_next != anchor:
639
- # Remove from current position
640
- # (but keep element reference)
641
- # and insert at new position
642
- self.renderer.remove(fragment.element, target)
643
- self.renderer.insert(
644
- fragment.element, parent=target, anchor=anchor
645
- )
646
-
647
- # Update children list
648
- self.children = new_children
649
-
650
- def _get_next_sibling(element, parent):
651
- """Get the next sibling element in the parent's children list"""
652
- try:
653
- idx = parent.children.index(element)
654
- if idx + 1 < len(parent.children):
655
- return parent.children[idx + 1]
656
- return None
657
- except (ValueError, AttributeError):
658
- return None
661
+ elif i not in dont_move:
662
+ # Fragment needs to be moved (not in LIS)
663
+ # Move the fragment and all its DOM children
664
+ move_fragment_dom(fragment, self.renderer, target, anchor)
659
665
 
660
- # Bind the helper function to self
661
- self._get_next_sibling = _get_next_sibling
666
+ # Update children list to match new_children
667
+ # This removes any unmounted fragments and ensures correct order
668
+ self.children = new_children
662
669
 
663
670
  # Watch for changes
664
671
  self._watchers["list"] = watch_effect(update_children_keyed)
@@ -1098,3 +1105,79 @@ class DynamicFragment(Fragment):
1098
1105
  def _remove(self):
1099
1106
  if self._active_fragment:
1100
1107
  self._active_fragment._remove()
1108
+
1109
+
1110
+ def move_fragment_dom(
1111
+ fragment: Fragment, renderer: Renderer, target: Any, anchor: Any | None
1112
+ ) -> None:
1113
+ """
1114
+ Recursively move all DOM elements in a fragment tree to a new position.
1115
+
1116
+ For fragments with a direct element, moves that element.
1117
+ For fragments without (e.g. ComponentFragment), recursively moves children.
1118
+ """
1119
+ if fragment.element:
1120
+ # Fragment has a single element - move it
1121
+ renderer.remove(fragment.element, target)
1122
+ renderer.insert(fragment.element, parent=target, anchor=anchor)
1123
+ else:
1124
+ # Fragment without direct element - move all child DOM elements
1125
+ for child in fragment.children:
1126
+ if child._mounted:
1127
+ move_fragment_dom(child, renderer, target, anchor)
1128
+
1129
+
1130
+ def longest_increasing_subsequence(nums: list[int]) -> list[int]:
1131
+ """
1132
+ Find the longest increasing subsequence in a list of numbers.
1133
+ Returns the indices of elements in the LIS.
1134
+
1135
+ Used to optimize keyed list reconciliation by finding which elements
1136
+ are already in the correct relative order and don't need to move.
1137
+
1138
+ Example:
1139
+ nums = [2, 0, 1]
1140
+ returns [1, 2] # indices of values 0, 1 (the LIS)
1141
+ """
1142
+ if not nums:
1143
+ return []
1144
+
1145
+ n = len(nums)
1146
+ # tails[i] = smallest tail element for LIS of length i+1
1147
+ tails = []
1148
+ # predecessor[i] = index of previous element in LIS ending at i
1149
+ predecessor = [-1] * n
1150
+ # tail_indices[i] = index in nums of tails[i]
1151
+ tail_indices = []
1152
+
1153
+ for i, num in enumerate(nums):
1154
+ # Binary search for the position to insert num
1155
+ left, right = 0, len(tails)
1156
+ while left < right:
1157
+ mid = (left + right) // 2
1158
+ if tails[mid] < num:
1159
+ left = mid + 1
1160
+ else:
1161
+ right = mid
1162
+
1163
+ # Update predecessor
1164
+ if left > 0:
1165
+ predecessor[i] = tail_indices[left - 1]
1166
+
1167
+ # Update or append to tails
1168
+ if left < len(tails):
1169
+ tails[left] = num
1170
+ tail_indices[left] = i
1171
+ else:
1172
+ tails.append(num)
1173
+ tail_indices.append(i)
1174
+
1175
+ # Reconstruct the LIS indices
1176
+ result = []
1177
+ current = tail_indices[-1]
1178
+ while current != -1:
1179
+ result.append(current)
1180
+ current = predecessor[current]
1181
+
1182
+ result.reverse()
1183
+ return result
@@ -4,6 +4,34 @@ from ... import PySideRenderer
4
4
  from .qobject import set_attribute as qobject_set_attribute
5
5
 
6
6
 
7
+ def _snapshot_item_state(
8
+ item: QTreeWidgetItem,
9
+ ) -> list[tuple[QTreeWidgetItem, bool, bool]]:
10
+ """Record ``(isExpanded, isSelected)`` for ``item`` and all descendants.
11
+
12
+ Qt resets both bits across a ``removeChild``, so callers snapshot
13
+ here before removing and restore after re-inserting (the path
14
+ keyed-v-for reordering takes).
15
+ """
16
+ snapshot: list[tuple[QTreeWidgetItem, bool, bool]] = []
17
+
18
+ def walk(it: QTreeWidgetItem) -> None:
19
+ snapshot.append((it, it.isExpanded(), it.isSelected()))
20
+ for i in range(it.childCount()):
21
+ walk(it.child(i))
22
+
23
+ walk(item)
24
+ return snapshot
25
+
26
+
27
+ def _restore_item_state(
28
+ snapshot: list[tuple[QTreeWidgetItem, bool, bool]],
29
+ ) -> None:
30
+ for it, expanded, selected in snapshot:
31
+ it.setExpanded(expanded)
32
+ it.setSelected(selected)
33
+
34
+
7
35
  @PySideRenderer.register_insert(QTreeWidgetItem)
8
36
  def insert(self, el: QTreeWidgetItem, anchor=None):
9
37
  if not isinstance(el, QTreeWidgetItem):
@@ -17,13 +45,26 @@ def insert(self, el: QTreeWidgetItem, anchor=None):
17
45
  if anchor is not None:
18
46
  index = self.indexOfChild(anchor)
19
47
  if self.treeWidget():
48
+ # ``el`` may already be in the tree (legacy code paths
49
+ # call insert without removing first); snapshot so we
50
+ # can preserve its expansion/selection across removeChild.
51
+ state = _snapshot_item_state(el)
20
52
  self.removeChild(el)
21
- self.insertChild(index, el)
53
+ self.insertChild(index, el)
54
+ _restore_item_state(state)
55
+ else:
56
+ self.insertChild(index, el)
22
57
  else:
23
58
  self.addChild(el)
24
59
 
25
- # After mounting, process some attributes that can only
26
- # be adjusted when the item is mounted in the tree structure
60
+ # Restore state snapshotted at remove-time (keyed reorder via a
61
+ # separate remove/insert pair, see remove() below).
62
+ if hasattr(el, "_state_snapshot"):
63
+ _restore_item_state(el._state_snapshot)
64
+ del el._state_snapshot
65
+
66
+ # After mounting, process some attributes that can only be
67
+ # adjusted when the item is mounted in the tree structure.
27
68
  if hasattr(el, "_expanded"):
28
69
  el.setExpanded(el._expanded)
29
70
  delattr(el, "_expanded")
@@ -43,6 +84,11 @@ def remove(self, el: QTreeWidgetItem):
43
84
  tree_widget.blockSignals(True)
44
85
 
45
86
  try:
87
+ # Stash UI state so a paired insert() (keyed reorder) can put
88
+ # the subtree back the way it was. The stash is consumed by
89
+ # the next insert of ``el``; if the item is being removed for
90
+ # good the attribute is garbage-collected with it.
91
+ el._state_snapshot = _snapshot_item_state(el)
46
92
  self.removeChild(el)
47
93
  finally:
48
94
  if tree_widget:
@@ -66,5 +66,8 @@ class CGXPathFinder(MetaPathFinder):
66
66
  return spec
67
67
 
68
68
 
69
- # Add cgx path finder at the end of the list of finders
70
- sys.meta_path.append(CGXPathFinder())
69
+ # Add cgx path finder at the end of the list of finders.
70
+ # Skip when running as a frozen PyInstaller bundle, since CGX files
71
+ # are pre-compiled to Python modules by the PyInstaller hook.
72
+ if not getattr(sys, "frozen", False):
73
+ sys.meta_path.append(CGXPathFinder())