shinestacker 1.5.4__py3-none-any.whl → 1.6.1__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (45) hide show
  1. shinestacker/_version.py +1 -1
  2. shinestacker/algorithms/multilayer.py +1 -1
  3. shinestacker/algorithms/stack.py +17 -9
  4. shinestacker/app/args_parser_opts.py +4 -0
  5. shinestacker/app/gui_utils.py +10 -2
  6. shinestacker/app/main.py +8 -3
  7. shinestacker/app/project.py +7 -3
  8. shinestacker/app/retouch.py +8 -1
  9. shinestacker/app/settings_dialog.py +171 -0
  10. shinestacker/config/app_config.py +30 -0
  11. shinestacker/config/constants.py +3 -0
  12. shinestacker/config/gui_constants.py +4 -2
  13. shinestacker/config/settings.py +110 -0
  14. shinestacker/core/core_utils.py +3 -12
  15. shinestacker/core/logging.py +3 -2
  16. shinestacker/gui/action_config.py +6 -5
  17. shinestacker/gui/action_config_dialog.py +17 -74
  18. shinestacker/gui/config_dialog.py +78 -0
  19. shinestacker/gui/main_window.py +6 -6
  20. shinestacker/gui/menu_manager.py +2 -0
  21. shinestacker/gui/new_project.py +2 -1
  22. shinestacker/gui/project_controller.py +8 -6
  23. shinestacker/gui/project_model.py +16 -1
  24. shinestacker/gui/recent_file_manager.py +3 -21
  25. shinestacker/retouch/base_filter.py +1 -1
  26. shinestacker/retouch/display_manager.py +48 -7
  27. shinestacker/retouch/image_editor_ui.py +27 -36
  28. shinestacker/retouch/image_view_status.py +4 -1
  29. shinestacker/retouch/image_viewer.py +17 -9
  30. shinestacker/retouch/io_gui_handler.py +96 -44
  31. shinestacker/retouch/io_threads.py +78 -0
  32. shinestacker/retouch/layer_collection.py +12 -0
  33. shinestacker/retouch/overlaid_view.py +13 -5
  34. shinestacker/retouch/paint_area_manager.py +30 -0
  35. shinestacker/retouch/sidebyside_view.py +32 -16
  36. shinestacker/retouch/transformation_manager.py +1 -3
  37. shinestacker/retouch/undo_manager.py +15 -13
  38. shinestacker/retouch/view_strategy.py +79 -26
  39. {shinestacker-1.5.4.dist-info → shinestacker-1.6.1.dist-info}/METADATA +1 -1
  40. {shinestacker-1.5.4.dist-info → shinestacker-1.6.1.dist-info}/RECORD +44 -39
  41. shinestacker/retouch/io_manager.py +0 -69
  42. {shinestacker-1.5.4.dist-info → shinestacker-1.6.1.dist-info}/WHEEL +0 -0
  43. {shinestacker-1.5.4.dist-info → shinestacker-1.6.1.dist-info}/entry_points.txt +0 -0
  44. {shinestacker-1.5.4.dist-info → shinestacker-1.6.1.dist-info}/licenses/LICENSE +0 -0
  45. {shinestacker-1.5.4.dist-info → shinestacker-1.6.1.dist-info}/top_level.txt +0 -0
@@ -2,87 +2,29 @@
2
2
  # pylint: disable=E0606, W0718, R1702, W0102, W0221, R0914, C0302
3
3
  import os
4
4
  import traceback
5
- from PySide6.QtCore import Qt, QTimer
6
- from PySide6.QtWidgets import (QWidget, QPushButton, QHBoxLayout, QLabel, QScrollArea, QMessageBox,
7
- QStackedWidget, QFormLayout, QDialog)
5
+ from PySide6.QtCore import QTimer
6
+ from PySide6.QtWidgets import QWidget, QLabel, QMessageBox, QStackedWidget
8
7
  from .. config.constants import constants
8
+ from .. config.app_config import AppConfig
9
9
  from .. algorithms.align import validate_align_config
10
- from .base_form_dialog import create_form_layout
11
10
  from . action_config import (
12
11
  DefaultActionConfigurator,
13
12
  FIELD_TEXT, FIELD_ABS_PATH, FIELD_REL_PATH, FIELD_FLOAT,
14
13
  FIELD_INT, FIELD_INT_TUPLE, FIELD_BOOL, FIELD_COMBO, FIELD_REF_IDX
15
14
  )
16
15
  from .folder_file_selection import FolderFileSelectionWidget
16
+ from .config_dialog import ConfigDialog
17
17
 
18
18
 
19
- class ActionConfigDialog(QDialog):
19
+ class ActionConfigDialog(ConfigDialog):
20
20
  def __init__(self, action, current_wd, parent=None):
21
- super().__init__(parent)
22
- self.setWindowTitle(f"Configure {action.type_name}")
23
- self.form_layout = create_form_layout(self)
24
- self.current_wd = current_wd
25
21
  self.action = action
26
- scroll_area = QScrollArea()
27
- scroll_area.setWidgetResizable(True)
28
- container_widget = QWidget()
29
- self.container_layout = QFormLayout(container_widget)
30
- self.container_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
31
- self.container_layout.setRowWrapPolicy(QFormLayout.DontWrapRows)
32
- self.container_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignTop)
33
- self.container_layout.setLabelAlignment(Qt.AlignLeft)
34
- self.configurator = self.get_configurator(action.type_name)
35
- self.configurator.create_form(self.container_layout, action)
36
- scroll_area.setWidget(container_widget)
37
- button_box = QHBoxLayout()
38
- ok_button = QPushButton("OK")
39
- ok_button.setFocus()
40
- cancel_button = QPushButton("Cancel")
41
- reset_button = QPushButton("Reset")
42
- button_box.addWidget(ok_button)
43
- button_box.addWidget(cancel_button)
44
- button_box.addWidget(reset_button)
45
- reset_button.clicked.connect(self.reset_to_defaults)
46
- self.form_layout.addRow(scroll_area)
47
- self.form_layout.addRow(button_box)
48
- QTimer.singleShot(0, self.adjust_dialog_size)
49
- ok_button.clicked.connect(self.accept)
50
- cancel_button.clicked.connect(self.reject)
51
-
52
- def adjust_dialog_size(self):
53
- screen_geometry = self.screen().availableGeometry()
54
- screen_height = screen_geometry.height()
55
- screen_width = screen_geometry.width()
56
- scroll_area = self.findChild(QScrollArea)
57
- container_widget = scroll_area.widget()
58
- container_size = container_widget.sizeHint()
59
- container_height = container_size.height()
60
- container_width = container_size.width()
61
- button_row_height = 50 # Approx height of button row
62
- margins_height = 40 # Approx. height of margins
63
- total_height_needed = container_height + button_row_height + margins_height
64
- if total_height_needed < screen_height * 0.8:
65
- width = max(container_width + 40, 600)
66
- height = total_height_needed
67
- self.resize(width, height)
68
- scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
69
- else:
70
- max_height = int(screen_height * 0.9)
71
- width = max(container_width + 40, 600)
72
- width = min(width, int(screen_width * 0.9))
73
- self.resize(width, max_height)
74
- self.setMaximumHeight(max_height)
75
- scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
76
- self.setMinimumHeight(min(max_height, 500))
77
- self.setMinimumWidth(width)
78
- self.center_on_screen()
79
-
80
- def center_on_screen(self):
81
- screen_geometry = self.screen().availableGeometry()
82
- center_point = screen_geometry.center()
83
- frame_geometry = self.frameGeometry()
84
- frame_geometry.moveCenter(center_point)
85
- self.move(frame_geometry.topLeft())
22
+ self.current_wd = current_wd
23
+ super().__init__(f"Configure {action.type_name}", parent)
24
+
25
+ def create_form_content(self):
26
+ self.configurator = self.get_configurator(self.action.type_name)
27
+ self.configurator.create_form(self.container_layout, self.action)
86
28
 
87
29
  def get_configurator(self, action_type):
88
30
  configurators = {
@@ -102,7 +44,8 @@ class ActionConfigDialog(QDialog):
102
44
 
103
45
  def accept(self):
104
46
  if self.configurator.update_params(self.action.params):
105
- self.parent().mark_as_modified(True, "Modify Configuration")
47
+ if hasattr(self.parent(), 'mark_as_modified'):
48
+ self.parent().mark_as_modified(True, "Modify Configuration")
106
49
  super().accept()
107
50
 
108
51
  def reset_to_defaults(self):
@@ -111,7 +54,7 @@ class ActionConfigDialog(QDialog):
111
54
  builder.reset_to_defaults()
112
55
 
113
56
  def expert(self):
114
- return self.parent().expert_options
57
+ return AppConfig.get('expert_options')
115
58
 
116
59
 
117
60
  class JobConfigurator(DefaultActionConfigurator):
@@ -329,7 +272,7 @@ class FocusStackBaseConfigurator(DefaultActionConfigurator):
329
272
  max_threads = self.add_field_to_layout(
330
273
  q_pyramid.layout(), 'pyramid_max_threads', FIELD_INT, 'Max num. of cores',
331
274
  expert=True,
332
- required=False, default=constants.DEFAULT_PY_MAX_THREADS,
275
+ required=False, default=AppConfig.get('focus_stack_params')['max_threads'],
333
276
  min_val=1, max_val=64)
334
277
  tile_size = self.add_field_to_layout(
335
278
  q_pyramid.layout(), 'pyramid_tile_size', FIELD_INT, 'Tile size (px)',
@@ -490,7 +433,7 @@ class CombinedActionsConfigurator(DefaultActionConfigurator):
490
433
  default=True)
491
434
  self.add_field(
492
435
  'max_threads', FIELD_INT, 'Max num. of cores',
493
- required=False, default=constants.DEFAULT_MAX_FWK_THREADS,
436
+ required=False, default=AppConfig.get('combined_actions_params')['max_threads'],
494
437
  expert=True,
495
438
  min_val=1, max_val=64)
496
439
  self.add_field(
@@ -757,7 +700,7 @@ class AlignFramesConfigurator(SubsampleActionConfigurator):
757
700
  min_val=1.0, max_val=64.0)
758
701
  max_threads = self.add_field_to_layout(
759
702
  layout, 'max_threads', FIELD_INT, 'Max num. of cores',
760
- required=False, default=constants.DEFAULT_ALIGN_MAX_THREADS,
703
+ required=False, default=AppConfig.get('align_frames_params')['max_threads'],
761
704
  min_val=1, max_val=64)
762
705
  chunk_submit = self.add_field_to_layout(
763
706
  layout, 'chunk_submit', FIELD_BOOL, 'Submit in chunks',
@@ -0,0 +1,78 @@
1
+ # pylint: disable=C0114, C0115, C0116, E0611
2
+ from abc import abstractmethod
3
+ from PySide6.QtCore import Qt, QTimer
4
+ from PySide6.QtWidgets import QWidget, QPushButton, QHBoxLayout, QScrollArea, QFormLayout, QDialog
5
+ from .base_form_dialog import create_form_layout
6
+
7
+
8
+ class ConfigDialog(QDialog):
9
+ def __init__(self, title, parent=None):
10
+ super().__init__(parent)
11
+ self.setWindowTitle(title)
12
+ self.form_layout = create_form_layout(self)
13
+ scroll_area = QScrollArea()
14
+ scroll_area.setWidgetResizable(True)
15
+ container_widget = QWidget()
16
+ self.container_layout = QFormLayout(container_widget)
17
+ self.container_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
18
+ self.container_layout.setRowWrapPolicy(QFormLayout.DontWrapRows)
19
+ self.container_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignTop)
20
+ self.container_layout.setLabelAlignment(Qt.AlignLeft)
21
+ scroll_area.setWidget(container_widget)
22
+ button_box = QHBoxLayout()
23
+ self.ok_button = QPushButton("OK")
24
+ self.ok_button.setFocus()
25
+ self.cancel_button = QPushButton("Cancel")
26
+ self.reset_button = QPushButton("Reset")
27
+ button_box.addWidget(self.ok_button)
28
+ button_box.addWidget(self.cancel_button)
29
+ button_box.addWidget(self.reset_button)
30
+ self.reset_button.clicked.connect(self.reset_to_defaults)
31
+ self.ok_button.clicked.connect(self.accept)
32
+ self.cancel_button.clicked.connect(self.reject)
33
+ self.form_layout.addRow(scroll_area)
34
+ self.form_layout.addRow(button_box)
35
+ QTimer.singleShot(0, self.adjust_dialog_size)
36
+ self.create_form_content()
37
+
38
+ @abstractmethod
39
+ def create_form_content(self):
40
+ pass
41
+
42
+ def adjust_dialog_size(self):
43
+ screen_geometry = self.screen().availableGeometry()
44
+ screen_height = screen_geometry.height()
45
+ screen_width = screen_geometry.width()
46
+ scroll_area = self.findChild(QScrollArea)
47
+ container_widget = scroll_area.widget()
48
+ container_size = container_widget.sizeHint()
49
+ container_height = container_size.height()
50
+ container_width = container_size.width()
51
+ button_row_height = 50 # Approx height of button row
52
+ margins_height = 40 # Approx. height of margins
53
+ total_height_needed = container_height + button_row_height + margins_height
54
+ if total_height_needed < screen_height * 0.8:
55
+ width = max(container_width + 40, 600)
56
+ height = total_height_needed
57
+ self.resize(width, height)
58
+ scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
59
+ else:
60
+ max_height = int(screen_height * 0.9)
61
+ width = max(container_width + 40, 600)
62
+ width = min(width, int(screen_width * 0.9))
63
+ self.resize(width, max_height)
64
+ self.setMaximumHeight(max_height)
65
+ scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
66
+ self.setMinimumHeight(min(max_height, 500))
67
+ self.setMinimumWidth(width)
68
+ self.center_on_screen()
69
+
70
+ def center_on_screen(self):
71
+ screen_geometry = self.screen().availableGeometry()
72
+ center_point = screen_geometry.center()
73
+ frame_geometry = self.frameGeometry()
74
+ frame_geometry.moveCenter(center_point)
75
+ self.move(frame_geometry.topLeft())
76
+
77
+ def reset_to_defaults(self):
78
+ pass
@@ -7,6 +7,7 @@ from PySide6.QtGui import QGuiApplication, QAction, QIcon
7
7
  from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QMessageBox,
8
8
  QSplitter, QToolBar, QMenu, QMainWindow)
9
9
  from .. config.constants import constants
10
+ from .. config.app_config import AppConfig
10
11
  from .. core.core_utils import running_under_windows, running_under_macos
11
12
  from .colors import ColorPalette
12
13
  from .project_model import Project
@@ -86,7 +87,6 @@ class MainWindow(QMainWindow, LogManager):
86
87
  self._windows = []
87
88
  self._workers = []
88
89
  self.retouch_callback = None
89
- self.expert_options = False
90
90
  self.job_list().setStyleSheet(LIST_STYLE_SHEET)
91
91
  self.action_list().setStyleSheet(LIST_STYLE_SHEET)
92
92
  self.menu_manager.add_menus()
@@ -441,12 +441,12 @@ class MainWindow(QMainWindow, LogManager):
441
441
  return True
442
442
  return False
443
443
 
444
- def toggle_expert_options(self):
445
- self.expert_options = self.menu_manager.expert_options_action.isChecked()
444
+ def handle_config(self):
445
+ self.menu_manager.expert_options_action.setChecked(
446
+ AppConfig.get('expert_options'))
446
447
 
447
- def set_expert_options(self):
448
- self.menu_manager.expert_options_action.setChecked(True)
449
- self.expert_options = True
448
+ def toggle_expert_options(self):
449
+ AppConfig.set('expert_options', self.menu_manager.expert_options_action.isChecked())
450
450
 
451
451
  def before_thread_begins(self):
452
452
  self.menu_manager.run_job_action.setEnabled(False)
@@ -5,6 +5,7 @@ from PySide6.QtCore import Signal, QObject
5
5
  from PySide6.QtGui import QAction, QIcon
6
6
  from PySide6.QtWidgets import QMenu, QComboBox
7
7
  from .. config.constants import constants
8
+ from .. config.app_config import AppConfig
8
9
  from .recent_file_manager import RecentFileManager
9
10
 
10
11
 
@@ -131,6 +132,7 @@ class MenuManager(QObject):
131
132
  menu = self.menubar.addMenu("&View")
132
133
  self.expert_options_action = self.action("Expert Options")
133
134
  self.expert_options_action.setCheckable(True)
135
+ self.expert_options_action.setChecked(AppConfig.get('expert_options'))
134
136
  menu.addAction(self.expert_options_action)
135
137
 
136
138
  def add_job_menu(self):
@@ -7,6 +7,7 @@ from PySide6.QtGui import QIcon
7
7
  from PySide6.QtCore import Qt
8
8
  from .. config.gui_constants import gui_constants
9
9
  from .. config.constants import constants
10
+ from .. config.app_config import AppConfig
10
11
  from .. algorithms.utils import read_img, extension_tif_jpg
11
12
  from .. algorithms.stack import get_bunches
12
13
  from .folder_file_selection import FolderFileSelectionWidget
@@ -31,7 +32,7 @@ class NewProjectDialog(BaseFormDialog):
31
32
  self.selected_filenames = []
32
33
 
33
34
  def expert(self):
34
- return self.parent().expert_options
35
+ return AppConfig.get('expert_options')
35
36
 
36
37
  def add_bold_label(self, label):
37
38
  label = QLabel(label)
@@ -155,9 +155,9 @@ class ProjectController(QObject):
155
155
  if dialog.exec() == QDialog.Accepted:
156
156
  self.save_actions_set_enabled(True)
157
157
  self.project_editor.reset_undo()
158
- input_folder = dialog.get_input_folder().split('/')
159
- working_path = '/'.join(input_folder[:-1])
160
- input_path = input_folder[-1]
158
+ input_folder = dialog.get_input_folder()
159
+ working_path = os.path.dirname(input_folder)
160
+ input_path = os.path.basename(input_folder)
161
161
  selected_filenames = dialog.get_selected_filenames()
162
162
  if dialog.get_noise_detection():
163
163
  job_noise = ActionConfig(
@@ -210,13 +210,15 @@ class ProjectController(QObject):
210
210
  focus_pyramid_name = f'{input_path}-focus-stack-pyramid'
211
211
  focus_pyramid = ActionConfig(constants.ACTION_FOCUSSTACK,
212
212
  {'name': focus_pyramid_name,
213
- 'stacker': constants.STACK_ALGO_PYRAMID})
213
+ 'stacker': constants.STACK_ALGO_PYRAMID,
214
+ 'exif_path': input_path})
214
215
  job.add_sub_action(focus_pyramid)
215
216
  if dialog.get_focus_stack_depth_map():
216
217
  focus_depth_map_name = f'{input_path}-focus-stack-depth-map'
217
218
  focus_depth_map = ActionConfig(constants.ACTION_FOCUSSTACK,
218
219
  {'name': focus_depth_map_name,
219
- 'stacker': constants.STACK_ALGO_DEPTH_MAP})
220
+ 'stacker': constants.STACK_ALGO_DEPTH_MAP,
221
+ 'exif_path': input_path})
220
222
  job.add_sub_action(focus_depth_map)
221
223
  if dialog.get_multi_layer():
222
224
  multi_input_path = []
@@ -231,7 +233,7 @@ class ProjectController(QObject):
231
233
  multi_layer = ActionConfig(
232
234
  constants.ACTION_MULTILAYER,
233
235
  {'name': f'{input_path}-multi-layer',
234
- 'input_path': constants.PATH_SEPARATOR.join(multi_input_path)})
236
+ 'input_path': constants.PATH_SEPARATOR.join(multi_input_path)})
235
237
  job.add_sub_action(multi_layer)
236
238
  self.add_job_to_project(job)
237
239
  self.project_editor.set_modified(True)
@@ -1,12 +1,27 @@
1
1
  # pylint: disable=C0114, C0115, C0116, R0911
2
2
  from copy import deepcopy
3
3
  from .. config.constants import constants
4
+ from .. config.app_config import AppConfig
5
+
6
+
7
+ TYPE_NAME_APP_CONFIG_MAP = {
8
+ constants.ACTION_COMBO: 'combined_actions_params',
9
+ constants.ACTION_ALIGNFRAMES: 'align_frames_params',
10
+ constants.ACTION_FOCUSSTACK: 'focus_stack_params',
11
+ constants.ACTION_FOCUSSTACKBUNCH: 'focus_stack_bunch_params'
12
+ }
4
13
 
5
14
 
6
15
  class ActionConfig:
7
16
  def __init__(self, type_name: str, params=None, parent=None):
8
17
  self.type_name = type_name
9
- self.params = params or {}
18
+ app_config_params_key = TYPE_NAME_APP_CONFIG_MAP.get(type_name, '')
19
+ if app_config_params_key != '':
20
+ self.params = AppConfig.get(app_config_params_key, {})
21
+ else:
22
+ self.params = {}
23
+ if params:
24
+ self.params = {**self.params, **params}
10
25
  self.parent = parent
11
26
  self.sub_actions: list[ActionConfig] = []
12
27
 
@@ -1,30 +1,12 @@
1
1
  # pylint: disable=C0114, C0115, C0116, E0611
2
2
  import os
3
- from PySide6.QtCore import QStandardPaths
3
+ from .. config.settings import StdPathFile
4
4
 
5
5
 
6
- class RecentFileManager:
6
+ class RecentFileManager(StdPathFile):
7
7
  def __init__(self, filename, max_entries=10):
8
- self.filename = filename
8
+ super().__init__(filename)
9
9
  self.max_entries = max_entries
10
- self._config_dir = None
11
-
12
- def get_config_dir(self):
13
- if self._config_dir is None:
14
- config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation)
15
- if not config_dir:
16
- if os.name == 'nt': # Windows
17
- config_dir = os.path.join(os.environ.get('APPDATA', ''), 'ShineStacker')
18
- elif os.name == 'posix': # macOS and Linux
19
- config_dir = os.path.expanduser('~/.config/shinestacker')
20
- else:
21
- config_dir = os.path.join(os.path.expanduser('~'), '.shinestacker')
22
- os.makedirs(config_dir, exist_ok=True)
23
- self._config_dir = config_dir
24
- return self._config_dir
25
-
26
- def get_file_path(self):
27
- return os.path.join(self.get_config_dir(), self.filename)
28
10
 
29
11
  def get_files(self):
30
12
  file_path = self.get_file_path()
@@ -157,7 +157,7 @@ class BaseFilter(QObject, LayerCollectionHandler):
157
157
  except Exception:
158
158
  h, w = self.master_layer_copy().shape[:2]
159
159
  try:
160
- self.undo_manager.extend_undo_area(0, 0, w, h)
160
+ self.undo_manager.set_paint_area(0, 0, w, h)
161
161
  self.undo_manager.save_undo_state(
162
162
  self.master_layer_copy(),
163
163
  self.name
@@ -3,8 +3,9 @@ import numpy as np
3
3
  from PySide6.QtWidgets import (QWidget, QListWidgetItem, QVBoxLayout, QLabel, QInputDialog,
4
4
  QAbstractItemView)
5
5
  from PySide6.QtGui import QPixmap, QImage
6
- from PySide6.QtCore import Qt, QObject, QTimer, QSize, Signal
6
+ from PySide6.QtCore import Qt, QObject, QTimer, QSize, Signal, QThread
7
7
  from .. config.gui_constants import gui_constants
8
+ from .. config.app_config import AppConfig
8
9
  from .layer_collection import LayerCollectionHandler
9
10
 
10
11
 
@@ -23,6 +24,32 @@ class ClickableLabel(QLabel):
23
24
  # pylint: enable=C0103
24
25
 
25
26
 
27
+ class ThumbnailWorker(QThread):
28
+ thumbnail_ready = Signal(object)
29
+
30
+ def __init__(self, layer_data):
31
+ super().__init__()
32
+ self.layer_data = layer_data
33
+
34
+ def run(self):
35
+ if self.layer_data is None:
36
+ self.thumbnail_ready.emit(None)
37
+ return
38
+ source_layer = (self.layer_data // 256).astype(np.uint8) \
39
+ if self.layer_data.dtype == np.uint16 else self.layer_data
40
+ if not source_layer.flags.c_contiguous:
41
+ source_layer = np.ascontiguousarray(source_layer)
42
+ height, width = source_layer.shape[:2]
43
+ if self.layer_data.ndim == 3 and source_layer.shape[-1] == 3:
44
+ qimg = QImage(source_layer.data, width, height, 3 * width, QImage.Format_RGB888)
45
+ else:
46
+ qimg = QImage(source_layer.data, width, height, width, QImage.Format_Grayscale8)
47
+ thumbnail = QPixmap.fromImage(
48
+ qimg.scaledToWidth(
49
+ gui_constants.UI_SIZES['thumbnail_width'], Qt.SmoothTransformation))
50
+ self.thumbnail_ready.emit(thumbnail)
51
+
52
+
26
53
  class DisplayManager(QObject, LayerCollectionHandler):
27
54
  status_message_requested = Signal(str)
28
55
 
@@ -36,8 +63,10 @@ class DisplayManager(QObject, LayerCollectionHandler):
36
63
  self.view_mode = 'master'
37
64
  self.needs_update = False
38
65
  self.update_timer = QTimer()
39
- self.update_timer.setInterval(gui_constants.PAINT_REFRESH_TIMER)
66
+ self.update_timer.setInterval(AppConfig.get('display_refresh_time'))
40
67
  self.update_timer.timeout.connect(self.process_pending_updates)
68
+ self.thumbnail_worker = None
69
+ self.thumbnail_update_pending = False
41
70
 
42
71
  def process_pending_updates(self):
43
72
  if self.needs_update:
@@ -77,7 +106,22 @@ class DisplayManager(QObject, LayerCollectionHandler):
77
106
  if self.has_no_master_layer():
78
107
  self._clear_master_thumbnail()
79
108
  else:
80
- self._set_master_thumbnail(self.create_thumbnail(self.master_layer()))
109
+ self._start_async_thumbnail_generation()
110
+
111
+ def _start_async_thumbnail_generation(self):
112
+ if self.thumbnail_worker and self.thumbnail_worker.isRunning():
113
+ self.thumbnail_update_pending = True
114
+ return
115
+ self.thumbnail_worker = ThumbnailWorker(self.master_layer())
116
+ self.thumbnail_worker.thumbnail_ready.connect(self._on_thumbnail_ready)
117
+ self.thumbnail_worker.start()
118
+
119
+ def _on_thumbnail_ready(self, thumbnail):
120
+ if thumbnail is not None:
121
+ self._set_master_thumbnail(thumbnail)
122
+ if self.thumbnail_update_pending:
123
+ self.thumbnail_update_pending = False
124
+ self._start_async_thumbnail_generation()
81
125
 
82
126
  def _clear_master_thumbnail(self):
83
127
  self.master_thumbnail_label.clear()
@@ -171,15 +215,13 @@ class DisplayManager(QObject, LayerCollectionHandler):
171
215
  def refresh_master_view(self):
172
216
  if self.has_no_master_layer():
173
217
  return
174
- self.image_viewer.update_master_display()
175
- self.image_viewer.refresh_display()
218
+ self.image_viewer.update_master_display_area()
176
219
  self.update_master_thumbnail()
177
220
 
178
221
  def refresh_current_view(self):
179
222
  if self.number_of_layers() == 0:
180
223
  return
181
224
  self.image_viewer.update_current_display()
182
- self.image_viewer.refresh_display()
183
225
 
184
226
  def start_temp_view(self):
185
227
  if self.view_mode == 'master':
@@ -187,7 +229,6 @@ class DisplayManager(QObject, LayerCollectionHandler):
187
229
  self.status_message_requested.emit("Temporary view: Individual layer.")
188
230
  else:
189
231
  self._master_refresh_and_thumb()
190
- self.image_viewer.strategy.brush_preview.hide()
191
232
  self.status_message_requested.emit("Temporary view: Master.")
192
233
 
193
234
  def end_temp_view(self):
@@ -6,6 +6,7 @@ from PySide6.QtGui import QShortcut, QKeySequence, QAction, QActionGroup
6
6
  from PySide6.QtCore import Qt
7
7
  from PySide6.QtGui import QGuiApplication
8
8
  from .. config.constants import constants
9
+ from .. config.app_config import AppConfig
9
10
  from .. config.gui_constants import gui_constants
10
11
  from .. gui.recent_file_manager import RecentFileManager
11
12
  from .image_viewer import ImageViewer
@@ -13,6 +14,7 @@ from .shortcuts_help import ShortcutsHelp
13
14
  from .brush import Brush
14
15
  from .brush_tool import BrushTool
15
16
  from .layer_collection import LayerCollectionHandler
17
+ from .paint_area_manager import PaintAreaManager
16
18
  from .undo_manager import UndoManager
17
19
  from .layer_collection import LayerCollection
18
20
  from .io_gui_handler import IOGuiHandler
@@ -34,9 +36,9 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
34
36
  self.brush = Brush()
35
37
  self.brush_tool = BrushTool()
36
38
  self.modified = False
37
- self.mask_layer = None
38
39
  self.transformation_manager = TransfromationManager(self)
39
- self.undo_manager = UndoManager(self.transformation_manager)
40
+ self.paint_area_manager = PaintAreaManager()
41
+ self.undo_manager = UndoManager(self.transformation_manager, self.paint_area_manager)
40
42
  self.undo_action = None
41
43
  self.redo_action = None
42
44
  self.undo_manager.stack_changed.connect(self.update_undo_redo_actions)
@@ -48,13 +50,13 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
48
50
  central_widget = QWidget()
49
51
  self.setCentralWidget(central_widget)
50
52
  layout = QHBoxLayout(central_widget)
51
- self.image_viewer = ImageViewer(self.layer_collection)
53
+ self.image_viewer = ImageViewer(
54
+ self.layer_collection, self.brush_tool, self.paint_area_manager)
52
55
  self.image_viewer.connect_signals(
53
56
  self.handle_temp_view,
54
- self.begin_copy_brush_area,
55
- self.continue_copy_brush_area,
56
57
  self.end_copy_brush_area,
57
- self.handle_brush_size_change)
58
+ self.handle_brush_size_change,
59
+ self.handle_needs_update)
58
60
  side_panel = QWidget()
59
61
  side_layout = QVBoxLayout(side_panel)
60
62
  side_layout.setContentsMargins(0, 0, 0, 0)
@@ -184,6 +186,7 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
184
186
  def change_layer_item(item):
185
187
  layer_idx = self.thumbnail_list.row(item)
186
188
  self.change_layer(layer_idx)
189
+ self.display_manager.highlight_thumbnail(layer_idx)
187
190
 
188
191
  self.thumbnail_list.itemClicked.connect(change_layer_item)
189
192
  self.thumbnail_list.setStyleSheet("""
@@ -236,6 +239,7 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
236
239
  self.flow_slider)
237
240
  self.image_viewer.set_brush(self.brush_tool.brush)
238
241
  self.image_viewer.set_preview_brush(self.brush_tool.brush)
242
+ self.image_viewer.status.set_zoom_factor_requested.connect(self.handle_set_zoom_factor)
239
243
  self.brush_tool.update_brush_thumb()
240
244
  self.io_gui_handler.setup_ui(self.display_manager, self.image_viewer)
241
245
  menubar = self.menuBar()
@@ -434,7 +438,7 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
434
438
  view_menu.addAction(self.toggle_view_master_individual_action)
435
439
  view_menu.addSeparator()
436
440
 
437
- self.set_strategy('overlaid')
441
+ self.set_strategy(AppConfig.get('view_strategy'))
438
442
 
439
443
  sort_asc_action = QAction("Sort Layers A-Z", self)
440
444
  sort_asc_action.setProperty("requires_file", True)
@@ -471,6 +475,8 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
471
475
  help_menu.setObjectName("Help")
472
476
  shortcuts_help_action = QAction("Shortcuts and Mouse", self)
473
477
 
478
+ self.zoom_factor_label = QLabel("")
479
+ self.statusBar().addPermanentWidget(self.zoom_factor_label)
474
480
  self.statusBar().showMessage("Shine Stacker ready.", 2000)
475
481
 
476
482
  def shortcuts_help():
@@ -488,6 +494,10 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
488
494
  self.set_enabled_file_open_close_actions(False)
489
495
  self.installEventFilter(self)
490
496
 
497
+ def handle_config(self):
498
+ self.set_strategy(AppConfig.get('view_strategy'))
499
+ self.display_manager.update_timer.setInterval(AppConfig.get('display_refresh_time'))
500
+
491
501
  def set_enabled_view_toggles(self, enabled):
492
502
  self.view_master_action.setEnabled(enabled)
493
503
  self.view_individual_action.setEnabled(enabled)
@@ -620,35 +630,11 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
620
630
  self.mark_as_modified()
621
631
  self.statusBar().showMessage(f"Copied layer {self.current_layer_idx() + 1} to master")
622
632
 
623
- def copy_brush_area_to_master(self, view_pos):
624
- if self.layer_stack() is None or self.number_of_layers() == 0 \
625
- or self.display_manager.view_mode != 'master':
626
- return
627
- area = self.brush_tool.apply_brush_operation(
628
- self.master_layer_copy(),
629
- self.current_layer(),
630
- self.master_layer(), self.mask_layer,
631
- view_pos)
632
- self.undo_manager.extend_undo_area(*area)
633
-
634
- def begin_copy_brush_area(self, pos):
635
- if self.display_manager.view_mode == 'master':
636
- self.mask_layer = self.io_gui_handler.blank_layer.copy()
637
- self.copy_master_layer()
638
- self.undo_manager.reset_undo_area()
639
- self.copy_brush_area_to_master(pos)
640
- self.display_manager.needs_update = True
641
- if not self.display_manager.update_timer.isActive():
642
- self.display_manager.update_timer.start()
643
- self.mark_as_modified()
644
-
645
- def continue_copy_brush_area(self, pos):
646
- if self.display_manager.view_mode == 'master':
647
- self.copy_brush_area_to_master(pos)
648
- self.display_manager.needs_update = True
649
- if not self.display_manager.update_timer.isActive():
650
- self.display_manager.update_timer.start()
651
- self.mark_as_modified()
633
+ def handle_needs_update(self):
634
+ self.display_manager.needs_update = True
635
+ if not self.display_manager.update_timer.isActive():
636
+ self.display_manager.update_timer.start()
637
+ self.mark_as_modified()
652
638
 
653
639
  def end_copy_brush_area(self):
654
640
  if self.display_manager.update_timer.isActive():
@@ -658,6 +644,7 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
658
644
  self.mark_as_modified()
659
645
 
660
646
  def update_undo_redo_actions(self, has_undo, undo_desc, has_redo, redo_desc):
647
+ self.image_viewer.update_brush_cursor()
661
648
  if self.undo_action:
662
649
  if has_undo:
663
650
  self.undo_action.setText(f"Undo {undo_desc}")
@@ -700,6 +687,7 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
700
687
  self.io_gui_handler.close_file()
701
688
  self.set_master_layer(None)
702
689
  self.mark_as_modified(False)
690
+ self.zoom_factor_label.setText("")
703
691
 
704
692
  def set_view_master(self):
705
693
  self.display_manager.set_view_master()
@@ -752,3 +740,6 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
752
740
  self.brush_tool.increase_brush_size()
753
741
  else:
754
742
  self.brush_tool.decrease_brush_size()
743
+
744
+ def handle_set_zoom_factor(self, zoom_factor):
745
+ self.zoom_factor_label.setText(f"zoom: {zoom_factor:.1%}")