shinestacker 1.2.0__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shinestacker might be problematic. Click here for more details.

Files changed (43) hide show
  1. shinestacker/_version.py +1 -1
  2. shinestacker/algorithms/align.py +148 -115
  3. shinestacker/algorithms/align_auto.py +64 -0
  4. shinestacker/algorithms/align_parallel.py +296 -0
  5. shinestacker/algorithms/balance.py +14 -13
  6. shinestacker/algorithms/base_stack_algo.py +11 -2
  7. shinestacker/algorithms/multilayer.py +14 -15
  8. shinestacker/algorithms/noise_detection.py +13 -14
  9. shinestacker/algorithms/pyramid.py +4 -4
  10. shinestacker/algorithms/pyramid_auto.py +16 -10
  11. shinestacker/algorithms/pyramid_tiles.py +19 -11
  12. shinestacker/algorithms/stack.py +30 -26
  13. shinestacker/algorithms/stack_framework.py +200 -178
  14. shinestacker/algorithms/vignetting.py +16 -13
  15. shinestacker/app/main.py +7 -3
  16. shinestacker/config/constants.py +63 -26
  17. shinestacker/config/gui_constants.py +1 -1
  18. shinestacker/core/core_utils.py +4 -0
  19. shinestacker/core/framework.py +114 -33
  20. shinestacker/gui/action_config.py +57 -5
  21. shinestacker/gui/action_config_dialog.py +156 -17
  22. shinestacker/gui/base_form_dialog.py +2 -2
  23. shinestacker/gui/folder_file_selection.py +101 -0
  24. shinestacker/gui/gui_images.py +10 -10
  25. shinestacker/gui/gui_run.py +13 -11
  26. shinestacker/gui/main_window.py +10 -5
  27. shinestacker/gui/menu_manager.py +4 -0
  28. shinestacker/gui/new_project.py +171 -74
  29. shinestacker/gui/project_controller.py +13 -9
  30. shinestacker/gui/project_converter.py +4 -2
  31. shinestacker/gui/project_editor.py +72 -53
  32. shinestacker/gui/select_path_widget.py +1 -1
  33. shinestacker/gui/sys_mon.py +96 -0
  34. shinestacker/gui/tab_widget.py +3 -3
  35. shinestacker/gui/time_progress_bar.py +4 -3
  36. shinestacker/retouch/exif_data.py +1 -1
  37. shinestacker/retouch/image_editor_ui.py +2 -0
  38. {shinestacker-1.2.0.dist-info → shinestacker-1.3.0.dist-info}/METADATA +6 -6
  39. {shinestacker-1.2.0.dist-info → shinestacker-1.3.0.dist-info}/RECORD +43 -39
  40. {shinestacker-1.2.0.dist-info → shinestacker-1.3.0.dist-info}/WHEEL +0 -0
  41. {shinestacker-1.2.0.dist-info → shinestacker-1.3.0.dist-info}/entry_points.txt +0 -0
  42. {shinestacker-1.2.0.dist-info → shinestacker-1.3.0.dist-info}/licenses/LICENSE +0 -0
  43. {shinestacker-1.2.0.dist-info → shinestacker-1.3.0.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,8 @@
1
1
  # pylint: disable=C0114, C0115, C0116, E0611, R0913, R0917, R0915, R0912
2
- # pylint: disable=E0606, W0718, R1702, W0102, W0221
2
+ # pylint: disable=E0606, W0718, R1702, W0102, W0221, R0914
3
+ import os
3
4
  import traceback
4
- from typing import Dict, Any
5
- from PySide6.QtWidgets import (QWidget, QPushButton, QHBoxLayout, QLabel, QScrollArea,
5
+ from PySide6.QtWidgets import (QWidget, QPushButton, QHBoxLayout, QLabel, QScrollArea, QSizePolicy,
6
6
  QMessageBox, QStackedWidget, QFormLayout, QDialog)
7
7
  from PySide6.QtCore import Qt, QTimer
8
8
  from .. config.constants import constants
@@ -12,8 +12,9 @@ from .base_form_dialog import create_form_layout
12
12
  from . action_config import (
13
13
  FieldBuilder, ActionConfigurator,
14
14
  FIELD_TEXT, FIELD_ABS_PATH, FIELD_REL_PATH, FIELD_FLOAT,
15
- FIELD_INT, FIELD_INT_TUPLE, FIELD_BOOL, FIELD_COMBO
15
+ FIELD_INT, FIELD_INT_TUPLE, FIELD_BOOL, FIELD_COMBO, FIELD_REF_IDX
16
16
  )
17
+ from .folder_file_selection import FolderFileSelectionWidget
17
18
 
18
19
 
19
20
  class ActionConfigDialog(QDialog):
@@ -101,12 +102,9 @@ class ActionConfigDialog(QDialog):
101
102
  action_type, DefaultActionConfigurator)(self.expert(), self.current_wd)
102
103
 
103
104
  def accept(self):
104
- self.parent().project_editor.add_undo(self.parent().project().clone())
105
105
  if self.configurator.update_params(self.action.params):
106
- self.parent().mark_as_modified()
106
+ self.parent().mark_as_modified(True, "Modify Configuration")
107
107
  super().accept()
108
- else:
109
- self.parent().project_editor.pop_undo()
110
108
 
111
109
  def reset_to_defaults(self):
112
110
  builder = self.configurator.get_builder()
@@ -125,7 +123,7 @@ class NoNameActionConfigurator(ActionConfigurator):
125
123
  def get_builder(self):
126
124
  return self.builder
127
125
 
128
- def update_params(self, params: Dict[str, Any]) -> bool:
126
+ def update_params(self, params):
129
127
  return self.builder.update_params(params)
130
128
 
131
129
  def add_bold_label(self, label):
@@ -140,6 +138,24 @@ class NoNameActionConfigurator(ActionConfigurator):
140
138
  required=False, add_to_layout=None, **kwargs):
141
139
  return self.builder.add_field(tag, field_type, label, required, add_to_layout, **kwargs)
142
140
 
141
+ def labelled_widget(self, label, widget):
142
+ row = QWidget()
143
+ layout = QHBoxLayout()
144
+ layout.setContentsMargins(2, 2, 2, 2)
145
+ layout.setSpacing(8)
146
+ label_widget = QLabel(label)
147
+ label_widget.setFixedWidth(120)
148
+ label_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
149
+ layout.addWidget(label_widget)
150
+ layout.addWidget(widget)
151
+ layout.setStretch(0, 1)
152
+ layout.setStretch(1, 3)
153
+ row.setLayout(layout)
154
+ return row
155
+
156
+ def add_labelled_row(self, label, widget):
157
+ self.add_row(self.labelled_widget(label, widget))
158
+
143
159
 
144
160
  class DefaultActionConfigurator(NoNameActionConfigurator):
145
161
  def create_form(self, layout, action, tag='Action'):
@@ -149,13 +165,98 @@ class DefaultActionConfigurator(NoNameActionConfigurator):
149
165
 
150
166
 
151
167
  class JobConfigurator(DefaultActionConfigurator):
168
+ def __init__(self, expert, current_wd):
169
+ super().__init__(expert, current_wd)
170
+ self.working_path_label = None
171
+ self.input_path_label = None
172
+ self.frames_label = None
173
+ self.input_widget = None
174
+
152
175
  def create_form(self, layout, action):
153
176
  super().create_form(layout, action, "Job")
154
- self.add_field(
155
- 'working_path', FIELD_ABS_PATH, 'Working path', required=True)
156
- self.add_field(
157
- 'input_path', FIELD_REL_PATH, 'Input path', required=False,
158
- must_exist=True, placeholder='relative to working path')
177
+ self.input_widget = FolderFileSelectionWidget()
178
+ self.frames_label = QLabel("0")
179
+ working_path = action.params.get('working_path', '')
180
+ input_path = action.params.get('input_path', '')
181
+ input_filepaths = action.params.get('input_filepaths', [])
182
+ if isinstance(input_filepaths, str) and input_filepaths:
183
+ input_filepaths = input_filepaths.split(constants.PATH_SEPARATOR)
184
+ self.working_path_label = QLabel(working_path or "Not set")
185
+ self.input_path_label = QLabel(input_path or "Not set")
186
+ has_existing_data = working_path or input_path or input_filepaths
187
+ if input_filepaths:
188
+ self.input_widget.files_mode_radio.setChecked(True)
189
+ self.input_widget.selected_files = input_filepaths
190
+ if input_filepaths:
191
+ parent_dir = os.path.dirname(input_filepaths[0])
192
+ self.input_widget.path_edit.setText(parent_dir)
193
+ elif input_path and working_path:
194
+ self.input_widget.folder_mode_radio.setChecked(True)
195
+ input_fullpath = os.path.join(working_path, input_path)
196
+ self.input_widget.path_edit.setText(input_fullpath)
197
+ elif input_path:
198
+ self.input_widget.folder_mode_radio.setChecked(True)
199
+ self.input_widget.path_edit.setText(input_path)
200
+ self.input_widget.text_changed_connect(self.update_paths_and_frames)
201
+ self.input_widget.folder_mode_radio.toggled.connect(self.update_paths_and_frames)
202
+ self.input_widget.files_mode_radio.toggled.connect(self.update_paths_and_frames)
203
+ self.add_bold_label("Input Selection:")
204
+ self.add_row(self.input_widget)
205
+ self.add_labelled_row("Number of frames: ", self.frames_label)
206
+ self.add_bold_label("Derived Paths:")
207
+ self.add_labelled_row("Working path: ", self.working_path_label)
208
+ self.add_labelled_row("Input path:", self.input_path_label)
209
+ if not has_existing_data:
210
+ self.update_paths_and_frames()
211
+ else:
212
+ self.update_frames_count()
213
+
214
+ def update_frames_count(self):
215
+ if self.input_widget.get_selection_mode() == 'files':
216
+ count = len(self.input_widget.get_selected_files())
217
+ else:
218
+ count = self.count_image_files(self.input_widget.get_path())
219
+ self.frames_label.setText(str(count))
220
+
221
+ def update_paths_and_frames(self):
222
+ self.update_frames_count()
223
+ selection_mode = self.input_widget.get_selection_mode()
224
+ selected_files = self.input_widget.get_selected_files()
225
+ input_path = self.input_widget.get_path()
226
+ if selection_mode == 'files' and selected_files:
227
+ input_path = os.path.dirname(selected_files[0])
228
+ input_path_value = os.path.basename(os.path.normpath(input_path)) if input_path else ""
229
+ working_path_value = os.path.dirname(input_path) if input_path else ""
230
+ self.input_path_label.setText(input_path_value or "Not set")
231
+ self.working_path_label.setText(working_path_value or "Not set")
232
+
233
+ def count_image_files(self, path):
234
+ if not path or not os.path.isdir(path):
235
+ return 0
236
+ count = 0
237
+ for filename in os.listdir(path):
238
+ if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.tif', '.tiff')):
239
+ count += 1
240
+ return count
241
+
242
+ def update_params(self, params):
243
+ if not super().update_params(params):
244
+ return False
245
+ selection_mode = self.input_widget.get_selection_mode()
246
+ selected_files = self.input_widget.get_selected_files()
247
+ input_path = self.input_widget.get_path()
248
+ if selection_mode == 'files' and selected_files:
249
+ params['input_filepaths'] = self.input_widget.get_selected_filenames()
250
+ params['input_path'] = os.path.dirname(selected_files[0])
251
+ else:
252
+ params['input_filepaths'] = []
253
+ params['input_path'] = input_path
254
+ if 'working_path' not in params or not params['working_path']:
255
+ if params['input_path']:
256
+ params['working_path'] = os.path.dirname(params['input_path'])
257
+ else:
258
+ params['working_path'] = ''
259
+ return True
159
260
 
160
261
 
161
262
  class NoiseDetectionConfigurator(DefaultActionConfigurator):
@@ -431,11 +532,18 @@ class CombinedActionsConfigurator(DefaultActionConfigurator):
431
532
  'resample', FIELD_INT, 'Resample frame stack', required=False,
432
533
  default=1, min_val=1, max_val=100)
433
534
  self.add_field(
434
- 'ref_idx', FIELD_INT, 'Reference frame index', required=False,
435
- default=-1, min_val=-1, max_val=1000)
535
+ 'reference_index', FIELD_REF_IDX, 'Reference frame', required=False,
536
+ default=0)
436
537
  self.add_field(
437
538
  'step_process', FIELD_BOOL, 'Step process', required=False,
438
539
  default=True)
540
+ self.add_field(
541
+ 'max_threads', FIELD_INT, 'Max num. of cores',
542
+ required=False, default=constants.DEFAULT_MAX_FWK_THREADS,
543
+ min_val=1, max_val=64)
544
+ self.add_field(
545
+ 'chunk_submit', FIELD_BOOL, 'Submit in chunks',
546
+ required=False, default=constants.DEFAULT_MAX_FWK_CHUNK_SUBMIT)
439
547
 
440
548
 
441
549
  class MaskNoiseConfigurator(DefaultActionConfigurator):
@@ -485,6 +593,7 @@ class AlignFramesConfigurator(SubsampleActionConfigurator):
485
593
  TRANSFORM_OPTIONS = ['Rigid', 'Homography']
486
594
  METHOD_OPTIONS = ['Random Sample Consensus (RANSAC)', 'Least Median (LMEDS)']
487
595
  MATCHING_METHOD_OPTIONS = ['K-nearest neighbors', 'Hamming distance']
596
+ MODE_OPTIONS = ['Auto', 'Sequential', 'Parallel']
488
597
 
489
598
  def __init__(self, expert, current_wd):
490
599
  super().__init__(expert, current_wd)
@@ -651,6 +760,36 @@ class AlignFramesConfigurator(SubsampleActionConfigurator):
651
760
  default=constants.DEFAULT_BORDER_BLUR,
652
761
  min_val=0, max_val=1000, step=1)
653
762
  self.add_bold_label("Miscellanea:")
763
+ if self.expert:
764
+ mode = self.add_field(
765
+ 'mode', FIELD_COMBO, 'Mode',
766
+ required=False, options=self.MODE_OPTIONS, values=constants.ALIGN_VALID_MODES,
767
+ default=dict(zip(constants.ALIGN_VALID_MODES,
768
+ self.MODE_OPTIONS))[constants.DEFAULT_ALIGN_MODE])
769
+ memory_limit = self.add_field(
770
+ 'memory_limit', FIELD_FLOAT, 'Memory limit (approx., GBytes)',
771
+ required=False, default=constants.DEFAULT_ALIGN_MEMORY_LIMIT_GB,
772
+ min_val=1.0, max_val=64.0)
773
+ max_threads = self.add_field(
774
+ 'max_threads', FIELD_INT, 'Max num. of cores',
775
+ required=False, default=constants.DEFAULT_ALIGN_MAX_THREADS,
776
+ min_val=1, max_val=64)
777
+ chunk_submit = self.add_field(
778
+ 'chunk_submit', FIELD_BOOL, 'Submit in chunks',
779
+ required=False, default=constants.DEFAULT_ALIGN_CHUNK_SUBMIT)
780
+ bw_matching = self.add_field(
781
+ 'bw_matching', FIELD_BOOL, 'Match using black & white',
782
+ required=False, default=constants.DEFAULT_ALIGN_BW_MATCHING)
783
+
784
+ def change_mode():
785
+ text = mode.currentText()
786
+ enabled = text != self.MODE_OPTIONS[1]
787
+ memory_limit.setEnabled(enabled)
788
+ max_threads.setEnabled(enabled)
789
+ chunk_submit.setEnabled(enabled)
790
+ bw_matching.setEnabled(enabled)
791
+
792
+ mode.currentIndexChanged.connect(change_mode)
654
793
  self.add_field(
655
794
  'plot_summary', FIELD_BOOL, 'Plot summary',
656
795
  required=False, default=False)
@@ -658,7 +797,7 @@ class AlignFramesConfigurator(SubsampleActionConfigurator):
658
797
  'plot_matches', FIELD_BOOL, 'Plot matches',
659
798
  required=False, default=False)
660
799
 
661
- def update_params(self, params: Dict[str, Any]) -> bool:
800
+ def update_params(self, params):
662
801
  if self.detector_field and self.descriptor_field and self.matching_method_field:
663
802
  try:
664
803
  detector = self.detector_field.currentText()
@@ -13,10 +13,10 @@ def create_form_layout(parent):
13
13
 
14
14
 
15
15
  class BaseFormDialog(QDialog):
16
- def __init__(self, title, parent=None):
16
+ def __init__(self, title, width=500, parent=None):
17
17
  super().__init__(parent)
18
18
  self.setWindowTitle(title)
19
- self.resize(500, self.height())
19
+ self.resize(width, self.height())
20
20
  self.form_layout = create_form_layout(self)
21
21
 
22
22
  def add_row_to_layout(self, item):
@@ -0,0 +1,101 @@
1
+ # pylint: disable=C0114, C0115, C0116, E0611
2
+ import os
3
+ from PySide6.QtWidgets import (QWidget, QRadioButton, QButtonGroup, QLineEdit,
4
+ QPushButton, QHBoxLayout, QVBoxLayout, QFileDialog, QMessageBox)
5
+ from PySide6.QtCore import Qt
6
+
7
+
8
+ class FolderFileSelectionWidget(QWidget):
9
+ def __init__(self, parent=None):
10
+ super().__init__(parent)
11
+ self.selection_mode = 'folder' # 'folder' or 'files'
12
+ self.selected_files = []
13
+ self.setup_ui()
14
+
15
+ def setup_ui(self):
16
+ self.mode_group = QButtonGroup(self)
17
+ self.folder_mode_radio = QRadioButton("Select Folder")
18
+ self.folder_mode_radio.setMaximumWidth(100)
19
+ self.files_mode_radio = QRadioButton("Select Files")
20
+ self.files_mode_radio.setMaximumWidth(100)
21
+ self.folder_mode_radio.setChecked(True)
22
+ self.mode_group.addButton(self.folder_mode_radio)
23
+ self.mode_group.addButton(self.files_mode_radio)
24
+ self.path_edit = QLineEdit()
25
+ self.path_edit.setPlaceholderText("input files folder")
26
+ self.browse_button = QPushButton("Browse Folder...")
27
+ self.browse_button.setFixedWidth(120)
28
+ main_layout = QVBoxLayout()
29
+ main_layout.setSpacing(10)
30
+ main_layout.setAlignment(Qt.AlignLeft)
31
+ mode_layout = QHBoxLayout()
32
+ mode_layout.setContentsMargins(2, 2, 2, 2)
33
+ mode_layout.setSpacing(20)
34
+ mode_layout.addWidget(self.folder_mode_radio)
35
+ mode_layout.addWidget(self.files_mode_radio)
36
+ mode_layout.addStretch()
37
+ main_layout.addLayout(mode_layout)
38
+ input_layout = QHBoxLayout()
39
+ input_layout.setContentsMargins(2, 2, 2, 2)
40
+ input_layout.setSpacing(8)
41
+ input_layout.setAlignment(Qt.AlignLeft)
42
+ input_layout.addWidget(self.path_edit)
43
+ input_layout.addWidget(self.browse_button)
44
+ main_layout.addLayout(input_layout)
45
+ self.setLayout(main_layout)
46
+ self.folder_mode_radio.toggled.connect(self.update_selection_mode)
47
+ self.files_mode_radio.toggled.connect(self.update_selection_mode)
48
+ self.browse_button.clicked.connect(self.handle_browse)
49
+
50
+ def update_selection_mode(self):
51
+ if self.folder_mode_radio.isChecked():
52
+ self.selection_mode = 'folder'
53
+ self.browse_button.setText("Browse Folder...")
54
+ # self.path_edit.setPlaceholderText("input files folder")
55
+ else:
56
+ self.selection_mode = 'files'
57
+ self.browse_button.setText("Browse Files...")
58
+ # self.path_edit.setPlaceholderText("input files")
59
+
60
+ def handle_browse(self):
61
+ if self.selection_mode == 'folder':
62
+ self.browse_folder()
63
+ else:
64
+ self.browse_files()
65
+
66
+ def browse_folder(self):
67
+ path = QFileDialog.getExistingDirectory(self, "Select Input Folder")
68
+ if path:
69
+ self.selected_files = []
70
+ self.path_edit.setText(path)
71
+
72
+ def browse_files(self):
73
+ files, _ = QFileDialog.getOpenFileNames(
74
+ self, "Select Input Files", "",
75
+ "Image files (*.png *.jpg *.jpeg *.tif *.tiff)"
76
+ )
77
+ if files:
78
+ parent_dir = os.path.dirname(files[0])
79
+ if all(os.path.dirname(f) == parent_dir for f in files):
80
+ self.selected_files = files
81
+ self.path_edit.setText(parent_dir)
82
+ else:
83
+ QMessageBox.warning(
84
+ self, "Invalid Selection",
85
+ "All files must be in the same directory."
86
+ )
87
+
88
+ def get_selection_mode(self):
89
+ return self.selection_mode
90
+
91
+ def get_selected_files(self):
92
+ return self.selected_files
93
+
94
+ def get_selected_filenames(self):
95
+ return [os.path.basename(file_path) for file_path in self.selected_files]
96
+
97
+ def get_path(self):
98
+ return self.path_edit.text()
99
+
100
+ def text_changed_connect(self, callback):
101
+ self.path_edit.textChanged.connect(callback)
@@ -67,13 +67,13 @@ class GuiImageView(QWidget):
67
67
  super().__init__(parent)
68
68
  self.file_path = file_path
69
69
  self.setFixedWidth(gui_constants.GUI_IMG_WIDTH)
70
- self.layout = QVBoxLayout()
71
- self.layout.setContentsMargins(0, 0, 0, 0)
72
- self.layout.setSpacing(0)
70
+ self.main_layout = QVBoxLayout()
71
+ self.main_layout.setContentsMargins(0, 0, 0, 0)
72
+ self.main_layout.setSpacing(0)
73
73
  self.image_label = QLabel()
74
74
  self.image_label.setAlignment(Qt.AlignCenter)
75
- self.layout.addWidget(self.image_label)
76
- self.setLayout(self.layout)
75
+ self.main_layout.addWidget(self.image_label)
76
+ self.setLayout(self.main_layout)
77
77
  pixmap = QPixmap(file_path)
78
78
  if pixmap:
79
79
  scaled_pixmap = pixmap.scaledToWidth(
@@ -105,13 +105,13 @@ class GuiOpenApp(QWidget):
105
105
  self.file_path = file_path
106
106
  self.app = app
107
107
  self.setFixedWidth(gui_constants.GUI_IMG_WIDTH)
108
- self.layout = QVBoxLayout()
109
- self.layout.setContentsMargins(0, 0, 0, 0)
110
- self.layout.setSpacing(0)
108
+ self.main_layout = QVBoxLayout()
109
+ self.main_layout.setContentsMargins(0, 0, 0, 0)
110
+ self.main_layout.setSpacing(0)
111
111
  self.image_label = QLabel()
112
112
  self.image_label.setAlignment(Qt.AlignCenter)
113
- self.layout.addWidget(self.image_label)
114
- self.setLayout(self.layout)
113
+ self.main_layout.addWidget(self.image_label)
114
+ self.setLayout(self.main_layout)
115
115
  pixmap = QPixmap(file_path)
116
116
  if pixmap:
117
117
  scaled_pixmap = pixmap.scaledToWidth(
@@ -18,6 +18,7 @@ from .colors import (
18
18
  ACTION_STOPPED_COLOR, ACTION_FAILED_COLOR)
19
19
  from .time_progress_bar import TimerProgressBar
20
20
  from .flow_layout import FlowLayout
21
+ from .sys_mon import StatusBarSystemMonitor
21
22
 
22
23
 
23
24
  class ColorButton(QPushButton):
@@ -96,7 +97,8 @@ class RunWindow(QTextEditLogger):
96
97
  self.right_area.setMaximumWidth(0)
97
98
  self.image_area_widget.setFixedWidth(0)
98
99
  layout.addLayout(output_layout)
99
-
100
+ self.system_monitor = StatusBarSystemMonitor(self)
101
+ self.status_bar.addPermanentWidget(self.system_monitor)
100
102
  n_paths = len(self.retouch_paths) if self.retouch_paths else 0
101
103
  if n_paths == 1:
102
104
  self.retouch_widget = QPushButton(f"Retouch {self.retouch_paths[0][0]}")
@@ -284,15 +286,15 @@ class RunWorker(LogWorker):
284
286
  self.id_str = id_str
285
287
  self.status = constants.STATUS_RUNNING
286
288
  self.callbacks = {
287
- 'before_action': self.before_action,
288
- 'after_action': self.after_action,
289
- 'step_counts': self.step_counts,
290
- 'begin_steps': self.begin_steps,
291
- 'end_steps': self.end_steps,
292
- 'after_step': self.after_step,
293
- 'save_plot': self.save_plot,
294
- 'check_running': self.check_running,
295
- 'open_app': self.open_app
289
+ constants.CALLBACK_BEFORE_ACTION: self.before_action,
290
+ constants.CALLBACK_AFTER_ACTION: self.after_action,
291
+ constants.CALLBACK_STEP_COUNTS: self.step_counts,
292
+ constants.CALLBACK_BEGIN_STEPS: self.begin_steps,
293
+ constants.CALLBACK_END_STEPS: self.end_steps,
294
+ constants.CALLBACK_AFTER_STEP: self.after_step,
295
+ constants.CALLBACK_CHECK_RUNNING: self.check_running,
296
+ constants.CALLBACK_SAVE_PLOT: self.save_plot,
297
+ constants.CALLBACK_OPEN_APP: self.open_app
296
298
  }
297
299
  self.tag = ""
298
300
 
@@ -328,7 +330,7 @@ class RunWorker(LogWorker):
328
330
  self.status_signal.emit(f"{self.tag} running...", constants.RUN_ONGOING, "", 0)
329
331
  self.html_signal.emit(f'''
330
332
  <div style="margin: 2px 0; font-family: {constants.LOG_FONTS_STR};">
331
- <span style="color: #{ColorPalette.DARK_BLUE.hex()}; font-style: italic; font-weigt: bold;">{self.tag} begins</span>
333
+ <span style="color: #{ColorPalette.DARK_BLUE.hex()}; font-style: italic; font-weight: bold;">{self.tag} begins</span>
332
334
  </div>
333
335
  ''') # noqa
334
336
  status, error_message = self.do_run()
@@ -43,7 +43,10 @@ class ProjectLogWorker(RunWorker):
43
43
 
44
44
  LIST_STYLE_SHEET = f"""
45
45
  QListWidget::item:selected {{
46
- background-color: #{ColorPalette.LIGHT_BLUE.hex()};;
46
+ background-color: #{ColorPalette.LIGHT_BLUE.hex()};
47
+ }}
48
+ QListWidget::item:hover {{
49
+ background-color: #F0F0F0;
47
50
  }}
48
51
  """
49
52
 
@@ -136,7 +139,7 @@ class MainWindow(QMainWindow, LogManager):
136
139
  self.project_editor.enable_delete_action_signal.connect(
137
140
  self.menu_manager.delete_element_action.setEnabled)
138
141
  self.project_editor.undo_manager.set_enabled_undo_action_requested.connect(
139
- self.menu_manager.undo_action.setEnabled)
142
+ self.menu_manager.set_enabled_undo_action)
140
143
  self.project_controller.update_title_requested.connect(self.update_title)
141
144
  self.project_controller.refresh_ui_requested.connect(self.refresh_ui)
142
145
  self.project_controller.activate_window_requested.connect(self.activateWindow)
@@ -148,8 +151,8 @@ class MainWindow(QMainWindow, LogManager):
148
151
  def modified(self):
149
152
  return self.project_editor.modified()
150
153
 
151
- def mark_as_modified(self, modified=True):
152
- self.project_editor.mark_as_modified(modified)
154
+ def mark_as_modified(self, modified=True, description=''):
155
+ self.project_editor.mark_as_modified(modified, description)
153
156
 
154
157
  def set_project(self, project):
155
158
  self.project_editor.set_project(project)
@@ -421,12 +424,14 @@ class MainWindow(QMainWindow, LogManager):
421
424
  for worker in self._workers:
422
425
  worker.stop()
423
426
  self.close()
427
+ return True
428
+ return False
424
429
 
425
430
  def toggle_expert_options(self):
426
431
  self.expert_options = self.menu_manager.expert_options_action.isChecked()
427
432
 
428
433
  def set_expert_options(self):
429
- self.expert_options_action.setChecked(True)
434
+ self.menu_manager.expert_options_action.setChecked(True)
430
435
  self.expert_options = True
431
436
 
432
437
  def before_thread_begins(self):
@@ -244,3 +244,7 @@ class MenuManager:
244
244
  if not enabled:
245
245
  tooltip += " (requires more tha one job)"
246
246
  self.run_all_jobs_action.setToolTip(tooltip)
247
+
248
+ def set_enabled_undo_action(self, enabled, description):
249
+ self.undo_action.setEnabled(enabled)
250
+ self.undo_action.setText(f"&Undo {description}")