pycontrol-gui 3.0.0b2__tar.gz → 3.0.0b4__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 (182) hide show
  1. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/PKG-INFO +2 -2
  2. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/pyproject.toml +2 -2
  3. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/session_controller.py +13 -0
  4. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_run_view.py +0 -21
  5. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/log_view.py +45 -27
  6. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/run_task_tab.py +1 -16
  7. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/settings_dialog.py +124 -90
  8. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/subject_run_panel.py +13 -17
  9. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/task_status.py +52 -1
  10. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_log_view.py +81 -0
  11. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_session_controller.py +10 -4
  12. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_settings_dialog.py +7 -10
  13. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_task_status_widget.py +27 -1
  14. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/uv.lock +1 -1
  15. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/.gitignore +0 -0
  16. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/.python-version +0 -0
  17. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/CONTRIBUTING.md +0 -0
  18. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/LICENSE.txt +0 -0
  19. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/README.md +0 -0
  20. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/docs/addons.md +0 -0
  21. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/docs/architecture.md +0 -0
  22. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/docs/changelog.md +0 -0
  23. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/docs/maintainability.md +0 -0
  24. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/docs/plot-addons.md +0 -0
  25. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/docs/task-controls.md +0 -0
  26. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/docs/task-sharing.md +0 -0
  27. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/__init__.py +0 -0
  28. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/__main__.py +0 -0
  29. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/app.py +0 -0
  30. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/app_settings.py +0 -0
  31. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/bus_adapter.py +0 -0
  32. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/error_logging.py +0 -0
  33. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/experiment_handlers.py +0 -0
  34. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/gui_gc.py +0 -0
  35. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/main_window.py +0 -0
  36. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/persistent_variables.py +0 -0
  37. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/plotting/__init__.py +0 -0
  38. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/plotting/base.py +0 -0
  39. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/plotting/default_plotter.py +0 -0
  40. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/plotting/host.py +0 -0
  41. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/plotting/loader.py +0 -0
  42. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/plotting/profiling.py +0 -0
  43. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/plotting/widgets/__init__.py +0 -0
  44. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/plotting/widgets/analog_plot.py +0 -0
  45. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/plotting/widgets/events_plot.py +0 -0
  46. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/plotting/widgets/states_plot.py +0 -0
  47. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/__init__.py +0 -0
  48. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/add.svg +0 -0
  49. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/bar-graph.svg +0 -0
  50. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/book.svg +0 -0
  51. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/circle-arrow-up_white.svg +0 -0
  52. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/close.svg +0 -0
  53. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/close_white.svg +0 -0
  54. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/collapse.svg +0 -0
  55. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/connect.svg +0 -0
  56. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/delete.svg +0 -0
  57. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/detach.svg +0 -0
  58. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/disconnect.svg +0 -0
  59. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/down.svg +0 -0
  60. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/edit.svg +0 -0
  61. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/enable.svg +0 -0
  62. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/expand.svg +0 -0
  63. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/filter.svg +0 -0
  64. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/folder.svg +0 -0
  65. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/funnel.svg +0 -0
  66. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/github.svg +0 -0
  67. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/help.svg +0 -0
  68. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/info.svg +0 -0
  69. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/keyboard.svg +0 -0
  70. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/left.svg +0 -0
  71. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/logo.svg +0 -0
  72. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/mount.svg +0 -0
  73. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/pause.svg +0 -0
  74. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/play.svg +0 -0
  75. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/play_white.svg +0 -0
  76. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/record_white.svg +0 -0
  77. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/refresh.svg +0 -0
  78. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/remove.svg +0 -0
  79. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/right.svg +0 -0
  80. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/run_white.svg +0 -0
  81. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/save.svg +0 -0
  82. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/save_as.svg +0 -0
  83. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/settings.svg +0 -0
  84. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/share.svg +0 -0
  85. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/stop_white.svg +0 -0
  86. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/trash.svg +0 -0
  87. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/up.svg +0 -0
  88. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/warning.svg +0 -0
  89. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/resources/icons/wrench.svg +0 -0
  90. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/sandbox_session.py +0 -0
  91. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/session_helpers.py +0 -0
  92. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/session_runtime.py +0 -0
  93. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/setup_maintenance.py +0 -0
  94. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/task_variables.py +0 -0
  95. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/update_check.py +0 -0
  96. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/__init__.py +0 -0
  97. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/about_dialog.py +0 -0
  98. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/controls_dialog.py +0 -0
  99. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/controls_dialog_editor.py +0 -0
  100. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/controls_host.py +0 -0
  101. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/controls_mount_controller.py +0 -0
  102. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/controls_spec.py +0 -0
  103. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/error_log_dialog.py +0 -0
  104. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_editor_rows.py +0 -0
  105. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_editor_state.py +0 -0
  106. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_editor_table.py +0 -0
  107. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_run_actions.py +0 -0
  108. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_run_models.py +0 -0
  109. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_run_outcomes.py +0 -0
  110. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_run_plots.py +0 -0
  111. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_run_preflight.py +0 -0
  112. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_run_summary.py +0 -0
  113. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_subject_table.py +0 -0
  114. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_variable_values.py +0 -0
  115. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiment_variables_table.py +0 -0
  116. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/experiments_tab.py +0 -0
  117. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/file_tree.py +0 -0
  118. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/hwdef_selector.py +0 -0
  119. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/keyboard_shortcuts_dialog.py +0 -0
  120. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/log_filter_controller.py +0 -0
  121. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/sandbox_dialog.py +0 -0
  122. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/session_controls_shell.py +0 -0
  123. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/session_event_ui.py +0 -0
  124. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/session_panel.py +0 -0
  125. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/session_shutdown_ui.py +0 -0
  126. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/session_task_binding.py +0 -0
  127. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/setup_variables_dialog.py +0 -0
  128. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/setups_tab.py +0 -0
  129. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/setups_table.py +0 -0
  130. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/share_dialog.py +0 -0
  131. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/style.py +0 -0
  132. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/task_selector_widgets.py +0 -0
  133. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/widgets/welcome_dialog.py +0 -0
  134. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/workspace_model.py +0 -0
  135. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/src/pycontrol_gui/workspace_snapshot.py +0 -0
  136. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/CLASSIFICATION.md +0 -0
  137. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/__init__.py +0 -0
  138. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/conftest.py +0 -0
  139. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/fixtures/controls/LED_controls.json +0 -0
  140. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/fixtures/controls/LED_controlz.json +0 -0
  141. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/fixtures/controls/blinker_gui.json +0 -0
  142. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/fixtures/controls/wheel_controls.json +0 -0
  143. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/support/__init__.py +0 -0
  144. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/support/core_events.py +0 -0
  145. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_app.py +0 -0
  146. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_bus_adapter.py +0 -0
  147. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_controls_dialog.py +0 -0
  148. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_controls_editor_round_trip.py +0 -0
  149. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_core_import_surface.py +0 -0
  150. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_error_log_dialog.py +0 -0
  151. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_error_logging.py +0 -0
  152. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_experiment_editor_state.py +0 -0
  153. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_experiment_run_actions.py +0 -0
  154. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_experiment_run_end_to_end.py +0 -0
  155. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_experiment_run_plots.py +0 -0
  156. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_experiment_run_preflight.py +0 -0
  157. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_experiment_run_view.py +0 -0
  158. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_experiment_subject_hwdef.py +0 -0
  159. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_experiment_variable_values.py +0 -0
  160. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_experiments_tab.py +0 -0
  161. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_gui_gc.py +0 -0
  162. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_main_window_shortcuts.py +0 -0
  163. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_model_view_widgets.py +0 -0
  164. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_persistent_variables.py +0 -0
  165. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_plotting_loader.py +0 -0
  166. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_plotting_widgets.py +0 -0
  167. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_run_task_tab.py +0 -0
  168. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_session_event_ui.py +0 -0
  169. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_session_helpers.py +0 -0
  170. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_session_runtime.py +0 -0
  171. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_setup_maintenance.py +0 -0
  172. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_setups_tab.py +0 -0
  173. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_share_dialog.py +0 -0
  174. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_smoke.py +0 -0
  175. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_task_variables.py +0 -0
  176. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_update_check.py +0 -0
  177. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_welcome_dialog.py +0 -0
  178. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_workspace_model.py +0 -0
  179. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tests/test_workspace_snapshot.py +0 -0
  180. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tools/check_wheels.py +0 -0
  181. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tools/run_tests.py +0 -0
  182. {pycontrol_gui-3.0.0b2 → pycontrol_gui-3.0.0b4}/tools/smoke_installed.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pycontrol-gui
3
- Version: 3.0.0b2
3
+ Version: 3.0.0b4
4
4
  Summary: Qt desktop frontend for pycontrol-core.
5
5
  Author: Karpova Lab contributors
6
6
  License: GPL-3.0-or-later
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Topic :: Scientific/Engineering
14
14
  Requires-Python: >=3.12
15
15
  Requires-Dist: packaging>=24
16
- Requires-Dist: pycontrol-core<3.1,>=3.0.0b1
16
+ Requires-Dist: pycontrol-core<3.1,>=3.0.0b3
17
17
  Requires-Dist: pyqtgraph>=0.13.7
18
18
  Requires-Dist: pyside6>=6.8
19
19
  Provides-Extra: test
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycontrol-gui"
7
- version = "3.0.0b2"
7
+ version = "3.0.0b4"
8
8
  description = "Qt desktop frontend for pycontrol-core."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -20,7 +20,7 @@ classifiers = [
20
20
  ]
21
21
  dependencies = [
22
22
  "packaging>=24",
23
- "pycontrol-core>=3.0.0b1,<3.1",
23
+ "pycontrol-core>=3.0.0b3,<3.1",
24
24
  "PySide6>=6.8",
25
25
  "pyqtgraph>=0.13.7",
26
26
  ]
@@ -520,6 +520,7 @@ class SessionController(QObject):
520
520
  self._pending_end = None
521
521
  self._end_poll.stop()
522
522
  self._handled_session_end_signature = signature
523
+ self._log_run_error(ended)
523
524
  self._finish_active_run(ended)
524
525
  self.state_changed.emit(session.state)
525
526
  self.run_stopped.emit(ended)
@@ -641,6 +642,18 @@ class SessionController(QObject):
641
642
  if session.state in (SessionState.CONNECTED, SessionState.READY):
642
643
  self._pending_notes.append((note, 0))
643
644
 
645
+ def _log_run_error(self, ended: SessionEnded) -> None:
646
+ """Persist a board traceback or lost connection to the error log, not just the run's log view."""
647
+ if not ended.error_message:
648
+ return
649
+ run = self._active_run
650
+ if run is not None:
651
+ where = f"setup {run.plan.setup_name}, subject {run.plan.subject_id}, port {run.plan.port}"
652
+ else:
653
+ where = f"port {self._connected_port}"
654
+ message = ended.error_message.replace("\r\n", "\n").strip()
655
+ logger.error("Run on %s (task %s) ended with an error:\n%s", where, self._last_task_name, message)
656
+
644
657
  def _finish_active_run(self, ended: SessionEnded | None = None) -> None:
645
658
  """Close and package the run in flight, if any.
646
659
 
@@ -176,7 +176,6 @@ class ExperimentRunView(QWidget):
176
176
  self._persistent_variables_path: Path | None = None
177
177
  self._last_summary_dialog: SummaryVariablesDialog | None = None
178
178
  self._log_stretch_item: QLayoutItem | None = None
179
- self._log_live_updates_enabled = True
180
179
  self._experiment_plots_mounted = False
181
180
  self._syncing_panel_splitters = False
182
181
  self._session_splitter_layout: tuple[str, list[int]] | None = None
@@ -374,7 +373,6 @@ class ExperimentRunView(QWidget):
374
373
  self.hardware_test_text.setText(hardware_test_name or "")
375
374
  self._fit_header_text_fields()
376
375
  self._set_hardware_test_header_visible(hardware_test_name is not None)
377
- self._set_log_live_updates_enabled(self._log_live_updates_enabled)
378
376
  self._hooks = ExperimentHookSequencer(
379
377
  self._hook_dispatcher,
380
378
  experiment_name=experiment.name,
@@ -566,10 +564,6 @@ class ExperimentRunView(QWidget):
566
564
  )
567
565
  self.log_filters_button = self._log_filter_controller.button
568
566
  self._log_filter_name_checkboxes = self._log_filter_controller.name_checkboxes
569
- self.log_autoscroll_checkbox = QCheckBox("Auto-scroll")
570
- self.log_autoscroll_checkbox.setChecked(True)
571
- self.log_autoscroll_checkbox.setToolTip("When enabled, all experiment logs follow new entries to the bottom.")
572
-
573
567
  self.logs_visible_checkbox = QCheckBox("Logs")
574
568
  self.controls_visible_checkbox = QCheckBox("Controls")
575
569
  self.plots_visible_checkbox = QCheckBox("Plots")
@@ -607,7 +601,6 @@ class ExperimentRunView(QWidget):
607
601
  logs_layout.setContentsMargins(12, 8, 12, 10)
608
602
  logs_layout.setSpacing(8)
609
603
  logs_layout.addWidget(self.log_filters_button)
610
- logs_layout.addWidget(self.log_autoscroll_checkbox)
611
604
  logs_layout.addStretch(1)
612
605
 
613
606
  self.plot_window_group = QGroupBox("Plot window")
@@ -679,7 +672,6 @@ class ExperimentRunView(QWidget):
679
672
  self.cancel_experiment_button.clicked.connect(self._cancel_experiment)
680
673
  self.startstopclose_all_button.clicked.connect(self._start_stop_close_all)
681
674
  self.hook_message.connect(self._log_hook_message)
682
- self.log_autoscroll_checkbox.toggled.connect(self._on_log_live_updates_toggled)
683
675
  for name, checkbox in self._pane_visibility_checkboxes.items():
684
676
  checkbox.toggled.connect(lambda visible, name=name: self._set_all_session_panes_visible(name, visible))
685
677
 
@@ -1040,24 +1032,11 @@ class ExperimentRunView(QWidget):
1040
1032
  )
1041
1033
  self._apply_panel_height_policy()
1042
1034
 
1043
- def _on_log_live_updates_toggled(self, enabled: bool) -> None:
1044
- self._set_log_live_updates_enabled(enabled)
1045
-
1046
- def _set_log_live_updates_enabled(self, enabled: bool) -> None:
1047
- self._log_live_updates_enabled = enabled
1048
- self.log_autoscroll_checkbox.blockSignals(True)
1049
- self.log_autoscroll_checkbox.setChecked(enabled)
1050
- self.log_autoscroll_checkbox.blockSignals(False)
1051
- for panel in self._panels.values():
1052
- panel.set_log_live_updates_enabled(enabled)
1053
-
1054
1035
  def _apply_log_controls_to_panel(self, panel: SubjectRunPanel) -> None:
1055
1036
  self._log_filter_controller.apply_to_log_view(panel.log_view)
1056
- panel.set_log_live_updates_enabled(self._log_live_updates_enabled)
1057
1037
 
1058
1038
  def _set_log_filter_controls_enabled(self, enabled: bool) -> None:
1059
1039
  self._log_filter_controller.set_enabled(enabled)
1060
- self.log_autoscroll_checkbox.setEnabled(enabled)
1061
1040
  self.expand_one_at_a_time_checkbox.setEnabled(enabled)
1062
1041
 
1063
1042
  def _add_log_stretch(self) -> None:
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections import deque
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
6
8
  from dataclasses import dataclass
7
9
 
8
10
  from PySide6.QtGui import QColor, QPalette, QTextCharFormat, QTextCursor
@@ -68,7 +70,15 @@ class LogView(QPlainTextEdit):
68
70
  self._entries: deque[LogEntry] = deque(maxlen=max_entries)
69
71
  self._visible_types: set[str] = set(self._FILTERABLE_KINDS)
70
72
  self._hidden_names: dict[str, set[str]] = {kind: set() for kind in self._FILTERABLE_KINDS}
71
- self._live_updates_enabled = True
73
+ # Whether the view follows new rows: true while the reader is at the bottom.
74
+ # Only the reader's own scrolling changes it, so a layout change that
75
+ # shrinks the view (the plots and status row appearing at run start) or
76
+ # a deferred scroll-range update cannot leave a following view stranded.
77
+ self._following = True
78
+ self._adjusting_scroll = False
79
+ vbar = self.verticalScrollBar()
80
+ vbar.valueChanged.connect(self._on_scrolled)
81
+ vbar.rangeChanged.connect(self._on_scroll_range_changed)
72
82
 
73
83
  @property
74
84
  def entries(self) -> tuple[LogEntry, ...]:
@@ -119,12 +129,11 @@ class LogView(QPlainTextEdit):
119
129
  self._hidden_names.setdefault(kind, set()).clear()
120
130
  self._rerender_visible_entries()
121
131
 
122
- def set_live_updates_enabled(self, enabled: bool) -> None:
123
- if self._live_updates_enabled == enabled:
124
- return
125
- self._live_updates_enabled = enabled
126
- if enabled:
127
- self.moveCursor(QTextCursor.MoveOperation.End)
132
+ def follow_new_rows(self) -> None:
133
+ """Jump to the bottom and follow new rows until the reader scrolls up."""
134
+ self._following = True
135
+ vbar = self.verticalScrollBar()
136
+ vbar.setValue(vbar.maximum())
128
137
 
129
138
  def append_line(self, text: str) -> None:
130
139
  self.append_entry(LogEntry(timestamp_ms=None, kind="info", content=text))
@@ -156,22 +165,37 @@ class LogView(QPlainTextEdit):
156
165
  fmt.setForeground(self._row_color(entry.kind))
157
166
  source_fmt = QTextCharFormat()
158
167
  source_fmt.setForeground(self._source_color())
159
- if self._live_updates_enabled:
160
- cursor = self.textCursor()
168
+ # Writing through a separate cursor leaves the reader's selection alone.
169
+ with self._keeping_scroll_position():
170
+ cursor = QTextCursor(self.document())
161
171
  cursor.movePosition(QTextCursor.MoveOperation.End)
162
172
  self._insert_row(cursor, entry, row, fmt, source_fmt)
163
- self.setTextCursor(cursor)
164
- self.moveCursor(QTextCursor.MoveOperation.End)
173
+
174
+ @contextmanager
175
+ def _keeping_scroll_position(self) -> Iterator[None]:
176
+ """Restore the scroll position after an edit, or stay at the bottom while following."""
177
+ if self._adjusting_scroll: # already inside an outer edit, which restores the position
178
+ yield
165
179
  return
166
180
  vbar = self.verticalScrollBar()
167
181
  hbar = self.horizontalScrollBar()
168
182
  vpos = vbar.value()
169
183
  hpos = hbar.value()
170
- cursor = QTextCursor(self.document())
171
- cursor.movePosition(QTextCursor.MoveOperation.End)
172
- self._insert_row(cursor, entry, row, fmt, source_fmt)
173
- vbar.setValue(vpos)
174
- hbar.setValue(hpos)
184
+ self._adjusting_scroll = True
185
+ try:
186
+ yield
187
+ finally:
188
+ vbar.setValue(vbar.maximum() if self._following else vpos)
189
+ hbar.setValue(hpos)
190
+ self._adjusting_scroll = False
191
+
192
+ def _on_scrolled(self, value: int) -> None:
193
+ if not self._adjusting_scroll:
194
+ self._following = value >= self.verticalScrollBar().maximum()
195
+
196
+ def _on_scroll_range_changed(self, _minimum: int, maximum: int) -> None:
197
+ if self._following and not self._adjusting_scroll:
198
+ self.verticalScrollBar().setValue(maximum)
175
199
 
176
200
  @staticmethod
177
201
  def _insert_row(
@@ -184,17 +208,11 @@ class LogView(QPlainTextEdit):
184
208
  cursor.insertText("\n", fmt)
185
209
 
186
210
  def _rerender_visible_entries(self) -> None:
187
- vbar = self.verticalScrollBar()
188
- hbar = self.horizontalScrollBar()
189
- vpos = vbar.value()
190
- hpos = hbar.value()
191
- super().clear()
192
- for entry in self._entries:
193
- if self._is_entry_visible(entry):
194
- self._append_rendered_entry(entry)
195
- if not self._live_updates_enabled:
196
- vbar.setValue(vpos)
197
- hbar.setValue(hpos)
211
+ with self._keeping_scroll_position():
212
+ super().clear()
213
+ for entry in self._entries:
214
+ if self._is_entry_visible(entry):
215
+ self._append_rendered_entry(entry)
198
216
 
199
217
  def _row_color(self, kind: str) -> QColor:
200
218
  text_color = self.palette().color(QPalette.ColorRole.Text)
@@ -18,7 +18,6 @@ from PySide6.QtCore import QSize, Qt, QTimer, Signal
18
18
  from PySide6.QtGui import QAction, QColor, QFont, QKeySequence, QPalette, QShortcut
19
19
  from PySide6.QtWidgets import (
20
20
  QAbstractSpinBox,
21
- QCheckBox,
22
21
  QComboBox,
23
22
  QDialog,
24
23
  QFileDialog,
@@ -293,13 +292,9 @@ class RunTaskTab(QWidget):
293
292
  filters_layout = QHBoxLayout(self.log_filters_bar)
294
293
  filters_layout.setContentsMargins(0, 0, 0, 0)
295
294
  filters_layout.setSpacing(10)
296
- self.log_autoscroll_checkbox = QCheckBox("Auto-scroll")
297
- self.log_autoscroll_checkbox.setChecked(True)
298
- self.log_autoscroll_checkbox.setToolTip("When enabled, the log view follows new entries to the bottom.")
299
295
  self.clear_log_button = QPushButton("Clear log")
300
296
  self.clear_log_button.setToolTip("Remove all entries from the log.")
301
297
  filters_layout.addWidget(self.log_filters_button)
302
- filters_layout.addWidget(self.log_autoscroll_checkbox)
303
298
  filters_layout.addStretch(1)
304
299
  filters_layout.addWidget(self.clear_log_button)
305
300
 
@@ -394,7 +389,6 @@ class RunTaskTab(QWidget):
394
389
  self._upload_task_shortcut = QShortcut(QKeySequence(Qt.Key.Key_U), self)
395
390
  self._upload_task_shortcut.setContext(Qt.ShortcutContext.WindowShortcut)
396
391
  self._upload_task_shortcut.activated.connect(self._on_upload_task_shortcut)
397
- self._set_log_live_updates_enabled(True)
398
392
  self._set_plot_live_updates_enabled(True)
399
393
 
400
394
  def _wire_ui(self) -> None:
@@ -414,7 +408,6 @@ class RunTaskTab(QWidget):
414
408
  self.view_board_files_action.triggered.connect(self.view_board_files_requested.emit)
415
409
  self.unmount_controls_action.triggered.connect(self._unmount_controls_dialog)
416
410
  self.mount_controls_action.triggered.connect(self._mount_controls_dialog)
417
- self.log_autoscroll_checkbox.toggled.connect(self._on_log_live_updates_toggled)
418
411
  self.clear_log_button.clicked.connect(self.log_view.clear)
419
412
  self.plot_live_button.toggled.connect(self._on_plot_live_updates_toggled)
420
413
  self.task_plot.plotter_changed.connect(self._refresh_plot_live_button_visibility)
@@ -585,6 +578,7 @@ class RunTaskTab(QWidget):
585
578
  self._refresh_controls()
586
579
 
587
580
  def on_session_started(self, ev: SessionStarted) -> None:
581
+ self.log_view.follow_new_rows()
588
582
  self.task_plot.run_start()
589
583
  if self.task_status.mode == "default":
590
584
  # A task handler's own status stands until it publishes the run's first.
@@ -802,18 +796,9 @@ class RunTaskTab(QWidget):
802
796
  self._append_log_entry(kind="info", content=self._RUN_STOP_SEPARATOR)
803
797
  self._append_log_entry(kind="info", content="")
804
798
 
805
- def _on_log_live_updates_toggled(self, enabled: bool) -> None:
806
- self._set_log_live_updates_enabled(enabled)
807
-
808
799
  def _on_plot_live_updates_toggled(self, paused: bool) -> None:
809
800
  self._set_plot_live_updates_enabled(not paused)
810
801
 
811
- def _set_log_live_updates_enabled(self, enabled: bool) -> None:
812
- self.log_view.set_live_updates_enabled(enabled)
813
- self.log_autoscroll_checkbox.blockSignals(True)
814
- self.log_autoscroll_checkbox.setChecked(enabled)
815
- self.log_autoscroll_checkbox.blockSignals(False)
816
-
817
802
  def _set_plot_live_updates_enabled(self, enabled: bool) -> None:
818
803
  self._plot_live_updates_enabled = enabled
819
804
  self.plot_live_button.blockSignals(True)
@@ -42,6 +42,8 @@ from pycontrol_gui.resources import icon
42
42
 
43
43
  logger = logging.getLogger(__name__)
44
44
 
45
+ _STACK_INDEX_ROLE = Qt.ItemDataRole.UserRole + 1
46
+
45
47
 
46
48
  @dataclass(frozen=True, slots=True)
47
49
  class _BoundSettingSpec:
@@ -53,18 +55,27 @@ class _BoundSettingSpec:
53
55
  @dataclass(frozen=True, slots=True)
54
56
  class _AddonRow:
55
57
  group: QGroupBox
56
- list_widget: QListWidget
58
+ list_widgets: dict[str, QListWidget]
57
59
  stack: QStackedWidget
58
60
  go_to_file_button: QPushButton
59
61
 
60
62
 
61
63
  @dataclass(frozen=True, slots=True)
62
64
  class _AddonSettingsError:
65
+ category: str
63
66
  label: str
64
67
  file_path: Path
65
68
  message: str
66
69
 
67
70
 
71
+ _AddonGroup = tuple[str, tuple[str, ...], tuple[AddonSettingSpec, ...], Path | None]
72
+
73
+ _PLOTTERS = "Plotters"
74
+ _TASK_HANDLERS = "Task Handlers"
75
+ _EXPERIMENT_HANDLERS = "Experiment Handlers"
76
+ _ADDON_CATEGORIES = (_PLOTTERS, _TASK_HANDLERS, _EXPERIMENT_HANDLERS)
77
+
78
+
68
79
  class SettingsDialog(QDialog):
69
80
  """Edit settings stored in the active workspace."""
70
81
 
@@ -289,59 +300,109 @@ class SettingsDialog(QDialog):
289
300
  layout.setColumnStretch(2, 1)
290
301
  return group
291
302
 
292
- def _build_addon_row(
293
- self, title: str, groups: tuple[tuple[str, tuple[str, ...], tuple[AddonSettingSpec, ...], Path | None], ...]
294
- ) -> QGroupBox:
303
+ def _build_addon_row(self, title: str, groups: dict[str, list[_AddonGroup]]) -> QGroupBox:
295
304
  group = QGroupBox(title)
296
305
  layout = QHBoxLayout(group)
297
306
  layout.setSpacing(12)
298
307
 
299
308
  list_panel = QWidget()
309
+ list_panel.setMinimumWidth(300)
310
+ list_panel.setMaximumWidth(420)
300
311
  list_layout = QVBoxLayout(list_panel)
301
312
  list_layout.setContentsMargins(0, 0, 0, 0)
302
- list_widget = QListWidget()
303
- list_widget.setMinimumWidth(300)
304
- list_widget.setMaximumWidth(420)
305
- list_widget.setMinimumHeight(100)
306
- list_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
313
+ stack = QStackedWidget()
307
314
  go_to_file_button = QPushButton("Go to file")
308
315
  go_to_file_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
309
- go_to_file_button.clicked.connect(lambda _checked=False: self._reveal_selected_addon_file(list_widget))
310
- stack = QStackedWidget()
316
+ go_to_file_button.setEnabled(False)
317
+ list_widgets: dict[str, QListWidget] = {}
311
318
 
312
- def update_go_to_file_button(row: int) -> None:
313
- item = list_widget.item(row)
314
- go_to_file_button.setEnabled(bool(item and item.data(Qt.ItemDataRole.UserRole)))
319
+ def select_item(source: QListWidget, item: QListWidgetItem | None) -> None:
320
+ if item is None:
321
+ return
322
+ for other in list_widgets.values():
323
+ if other is not source:
324
+ other.blockSignals(True)
325
+ other.setCurrentRow(-1)
326
+ other.clearSelection()
327
+ other.blockSignals(False)
328
+ stack.setCurrentIndex(int(item.data(_STACK_INDEX_ROLE)))
329
+ go_to_file_button.setEnabled(bool(item.data(Qt.ItemDataRole.UserRole)))
330
+
331
+ def selected_item() -> QListWidgetItem | None:
332
+ return next(
333
+ (widget.currentItem() for widget in list_widgets.values() if widget.currentItem() is not None), None
334
+ )
315
335
 
316
- if groups or self._addon_settings_errors:
317
- for group_title, path, specs, file_path in groups:
318
- item = QListWidgetItem(self._addon_item_label(path))
319
- item.setData(Qt.ItemDataRole.UserRole, "" if file_path is None else str(file_path))
320
- list_widget.addItem(item)
321
- stack.addWidget(self._build_group(group_title, path, specs, show_title=False))
336
+ go_to_file_button.clicked.connect(lambda _checked=False: self._reveal_addon_file(selected_item()))
337
+
338
+ first_list: QListWidget | None = None
339
+ for category in _ADDON_CATEGORIES:
340
+ heading = QLabel(category)
341
+ font = heading.font()
342
+ font.setBold(True)
343
+ heading.setFont(font)
344
+ list_widget = QListWidget()
345
+ list_widgets[category] = list_widget
346
+
347
+ for group_title, path, specs, file_path in groups.get(category, []):
348
+ self._add_addon_item(
349
+ list_widget,
350
+ stack,
351
+ self._addon_item_label(path),
352
+ file_path,
353
+ self._build_group(group_title, path, specs, show_title=False),
354
+ )
322
355
  for error in self._addon_settings_errors:
323
- item = QListWidgetItem(f"{error.label} (error)")
324
- item.setData(Qt.ItemDataRole.UserRole, str(error.file_path))
325
- item.setToolTip(error.message)
326
- list_widget.addItem(item)
327
- stack.addWidget(self._build_addon_error_widget(error))
328
- list_widget.currentRowChanged.connect(stack.setCurrentIndex)
329
- list_widget.currentRowChanged.connect(update_go_to_file_button)
330
- list_widget.setCurrentRow(0)
331
- else:
332
- list_widget.setEnabled(False)
333
- go_to_file_button.setEnabled(False)
356
+ if error.category == category:
357
+ item = self._add_addon_item(
358
+ list_widget,
359
+ stack,
360
+ f"{error.label} (error)",
361
+ error.file_path,
362
+ self._build_addon_error_widget(error),
363
+ )
364
+ item.setToolTip(error.message)
365
+
366
+ if list_widget.count():
367
+ list_widget.currentItemChanged.connect(
368
+ lambda item, _previous, source=list_widget: select_item(source, item)
369
+ )
370
+ first_list = first_list or list_widget
371
+ else:
372
+ placeholder = QListWidgetItem("No configurable files")
373
+ placeholder.setFlags(Qt.ItemFlag.NoItemFlags)
374
+ list_widget.addItem(placeholder)
375
+ list_widget.setFixedHeight(self._list_height(list_widget, min(max(list_widget.count(), 3), 8)))
376
+
377
+ list_layout.addWidget(heading)
378
+ list_layout.addWidget(list_widget)
379
+
380
+ if first_list is None:
334
381
  placeholder = QLabel("No settings files found.")
335
382
  placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
336
383
  stack.addWidget(placeholder)
384
+ else:
385
+ first_list.setCurrentRow(0)
337
386
 
338
- list_layout.addWidget(list_widget)
339
387
  list_layout.addWidget(go_to_file_button)
340
388
  layout.addWidget(list_panel)
341
389
  layout.addWidget(stack, 1)
342
- self._addon_rows[title] = _AddonRow(group, list_widget, stack, go_to_file_button)
390
+ self._addon_rows[title] = _AddonRow(group, list_widgets, stack, go_to_file_button)
343
391
  return group
344
392
 
393
+ def _add_addon_item(
394
+ self, list_widget: QListWidget, stack: QStackedWidget, label: str, file_path: Path | None, page: QWidget
395
+ ) -> QListWidgetItem:
396
+ item = QListWidgetItem(label)
397
+ item.setData(Qt.ItemDataRole.UserRole, "" if file_path is None else str(file_path))
398
+ item.setData(_STACK_INDEX_ROLE, stack.addWidget(page))
399
+ list_widget.addItem(item)
400
+ return item
401
+
402
+ def _list_height(self, list_widget: QListWidget, rows: int) -> int:
403
+ row_height = list_widget.sizeHintForRow(0) if list_widget.count() else self.fontMetrics().height() + 4
404
+ return rows * row_height + 2 * list_widget.frameWidth() + 4
405
+
345
406
  def _build_addon_error_widget(self, error: _AddonSettingsError) -> QLabel:
346
407
  label = QLabel(
347
408
  f"Settings could not be loaded from {error.file_path.name}.\n\n"
@@ -356,13 +417,10 @@ class SettingsDialog(QDialog):
356
417
 
357
418
  def _addon_item_label(self, path: tuple[str, ...]) -> str:
358
419
  if path == ("addons", "plotters", "DefaultPlotter"):
359
- return "pycontrol-gui/DefaultPlotter"
360
- if len(path) >= 3 and path[0] == "addons":
361
- return f"{path[1]}/{path[2]}"
420
+ return "DefaultPlotter (pycontrol-gui)"
362
421
  return path[-1]
363
422
 
364
- def _reveal_selected_addon_file(self, list_widget: QListWidget) -> None:
365
- item = list_widget.currentItem()
423
+ def _reveal_addon_file(self, item: QListWidgetItem | None) -> None:
366
424
  if item is None:
367
425
  return
368
426
  raw_path = item.data(Qt.ItemDataRole.UserRole)
@@ -405,64 +463,31 @@ class SettingsDialog(QDialog):
405
463
  self._group_widgets.append(addon_row)
406
464
  self.set_locked(self._locked)
407
465
 
408
- def _dynamic_addon_groups(
409
- self,
410
- ) -> tuple[tuple[str, tuple[str, ...], tuple[AddonSettingSpec, ...], Path | None], ...]:
411
- groups: list[tuple[str, tuple[str, ...], tuple[AddonSettingSpec, ...], Path | None]] = []
412
- groups.append(
413
- (
414
- "pycontrol-gui / DefaultPlotter",
415
- ("addons", "plotters", "DefaultPlotter"),
416
- DefaultPlotter.SETTINGS,
417
- None,
418
- )
466
+ def _dynamic_addon_groups(self) -> dict[str, list[_AddonGroup]]:
467
+ default_plotter: _AddonGroup = (
468
+ "pycontrol-gui / DefaultPlotter",
469
+ ("addons", "plotters", "DefaultPlotter"),
470
+ DefaultPlotter.SETTINGS,
471
+ None,
419
472
  )
420
- groups.extend(self._plotter_groups())
421
- groups.extend(self._task_handler_groups())
422
- groups.extend(self._experiment_handler_groups())
423
- return tuple(groups)
424
-
425
- def _experiment_handler_groups(self) -> list[tuple[str, tuple[str, ...], tuple[AddonSettingSpec, ...], Path]]:
426
- if self._experiment_handlers_dir is None or not self._experiment_handlers_dir.is_dir():
427
- return []
428
- groups: list[tuple[str, tuple[str, ...], tuple[AddonSettingSpec, ...], Path]] = []
429
- for path in sorted(self._experiment_handlers_dir.glob("*.py")):
430
- if path.name.startswith("_"):
431
- continue
432
- specs = self._settings_specs_from_module(path)
433
- if specs:
434
- groups.append(
435
- (
436
- f"Experiment Handlers / {path.stem}",
437
- ("addons", "experiment_handlers", path.stem),
438
- specs,
439
- path,
440
- )
441
- )
442
- return groups
443
-
444
- def _plotter_groups(self) -> list[tuple[str, tuple[str, ...], tuple[AddonSettingSpec, ...], Path]]:
445
- if self._plotters_dir is None or not self._plotters_dir.is_dir():
446
- return []
447
- groups: list[tuple[str, tuple[str, ...], tuple[AddonSettingSpec, ...], Path]] = []
448
- for path in sorted(self._plotters_dir.glob("*.py")):
449
- if path.name.startswith("_"):
450
- continue
451
- specs = self._settings_specs_from_module(path)
452
- if specs:
453
- groups.append((f"Task Plotters / {path.stem}", ("addons", "plotters", path.stem), specs, path))
454
- return groups
473
+ return {
474
+ _PLOTTERS: [default_plotter, *self._addon_file_groups(self._plotters_dir, "plotters", "Task Plotters")],
475
+ _TASK_HANDLERS: self._addon_file_groups(self._task_handlers_dir, "task_handlers", "Task Handlers"),
476
+ _EXPERIMENT_HANDLERS: self._addon_file_groups(
477
+ self._experiment_handlers_dir, "experiment_handlers", "Experiment Handlers"
478
+ ),
479
+ }
455
480
 
456
- def _task_handler_groups(self) -> list[tuple[str, tuple[str, ...], tuple[AddonSettingSpec, ...], Path]]:
457
- if self._task_handlers_dir is None or not self._task_handlers_dir.is_dir():
481
+ def _addon_file_groups(self, directory: Path | None, key: str, title_prefix: str) -> list[_AddonGroup]:
482
+ if directory is None or not directory.is_dir():
458
483
  return []
459
- groups: list[tuple[str, tuple[str, ...], tuple[AddonSettingSpec, ...], Path]] = []
460
- for path in sorted(self._task_handlers_dir.glob("*.py")):
484
+ groups: list[_AddonGroup] = []
485
+ for path in sorted(directory.glob("*.py")):
461
486
  if path.name.startswith("_"):
462
487
  continue
463
488
  specs = self._settings_specs_from_module(path)
464
489
  if specs:
465
- groups.append((f"Task Handlers / {path.stem}", ("addons", "task_handlers", path.stem), specs, path))
490
+ groups.append((f"{title_prefix} / {path.stem}", ("addons", key, path.stem), specs, path))
466
491
  return groups
467
492
 
468
493
  def _settings_specs_from_module(self, path: Path) -> tuple[AddonSettingSpec, ...]:
@@ -504,9 +529,18 @@ class SettingsDialog(QDialog):
504
529
 
505
530
  def _record_addon_settings_error(self, path: Path, message: str) -> None:
506
531
  self._addon_settings_errors.append(
507
- _AddonSettingsError(label=f"{path.parent.name}/{path.stem}", file_path=path, message=message)
532
+ _AddonSettingsError(
533
+ category=self._addon_category_for_path(path), label=path.stem, file_path=path, message=message
534
+ )
508
535
  )
509
536
 
537
+ def _addon_category_for_path(self, path: Path) -> str:
538
+ if self._plotters_dir is not None and path.parent == self._plotters_dir:
539
+ return _PLOTTERS
540
+ if self._task_handlers_dir is not None and path.parent == self._task_handlers_dir:
541
+ return _TASK_HANDLERS
542
+ return _EXPERIMENT_HANDLERS
543
+
510
544
  def _minimum_label_width(self, specs: tuple[AddonSettingSpec, ...]) -> int:
511
545
  metrics = self.fontMetrics()
512
546
  return max(metrics.horizontalAdvance(spec.title) for spec in specs) + 8