diff --git a/src/apps/ocioview/main.py b/src/apps/ocioview/main.py index 2c2b1bffde..15d897ab5d 100644 --- a/src/apps/ocioview/main.py +++ b/src/apps/ocioview/main.py @@ -1,68 +1,17 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright Contributors to the OpenColorIO Project. -import logging -import os import sys -from pathlib import Path -import PyOpenColorIO as ocio -from PySide6 import QtCore, QtGui, QtWidgets - -import ocioview.log_handlers # Import to initialize logging from ocioview.main_window import OCIOView -from ocioview.style import QSS, DarkPalette - - -ROOT_DIR = Path(__file__).resolve().parent.parent -FONTS_DIR = ROOT_DIR / "fonts" - - -def excepthook(exc_type, exc_value, exc_tb): - """Log uncaught errors""" - if issubclass(exc_type, KeyboardInterrupt): - sys.__excepthook__(exc_type, exc_value, exc_tb) - return - logging.error(f"{exc_value}", exc_info=exc_value) +from ocioview.setup import setup_app if __name__ == "__main__": - sys.excepthook = excepthook - - # OpenGL core profile needed on macOS to access programmatic pipeline - gl_format = QtGui.QSurfaceFormat() - gl_format.setProfile(QtGui.QSurfaceFormat.CoreProfile) - gl_format.setSwapInterval(1) - gl_format.setVersion(4, 0) - QtGui.QSurfaceFormat.setDefaultFormat(gl_format) - - # Create app - app = QtWidgets.QApplication(sys.argv) - - # Initialize style - app.setStyle("fusion") - app.setPalette(DarkPalette()) - app.setStyleSheet(QSS) - app.setEffectEnabled(QtCore.Qt.UI_AnimateCombo, False) - - font = app.font() - font.setPointSize(8) - app.setFont(font) - - # Clean OCIO environment to isolate working config - for env_var in ( - ocio.OCIO_CONFIG_ENVVAR, - ocio.OCIO_ACTIVE_VIEWS_ENVVAR, - ocio.OCIO_ACTIVE_DISPLAYS_ENVVAR, - ocio.OCIO_INACTIVE_COLORSPACES_ENVVAR, - ocio.OCIO_OPTIMIZATION_FLAGS_ENVVAR, - ocio.OCIO_USER_CATEGORIES_ENVVAR, - ): - if env_var in os.environ: - del os.environ[env_var] + app = setup_app() # Start ocioview - ocioview = OCIOView() - ocioview.show() + ocio_view = OCIOView() + ocio_view.show() sys.exit(app.exec_()) diff --git a/src/apps/ocioview/ocioview/config_cache.py b/src/apps/ocioview/ocioview/config_cache.py index 63eaaeeaff..280442ca97 100644 --- a/src/apps/ocioview/ocioview/config_cache.py +++ b/src/apps/ocioview/ocioview/config_cache.py @@ -21,7 +21,10 @@ class ConfigCache: _active_views: Optional[list[str]] = None _all_names: Optional[list[str]] = None _categories: Optional[list[str]] = None - _color_spaces: dict[bool, list[ocio.ColorSpace]] = {} + _color_spaces: dict[ + tuple[bool, ocio.SearchReferenceSpaceType, ocio.ColorSpaceVisibility], + Union[list[ocio.ColorSpace], ocio.ColorSpaceSet], + ] = {} _color_space_names: dict[ocio.SearchReferenceSpaceType, list[str]] = {} _default_color_space_name: Optional[str] = None _default_view_transform_name: Optional[str] = None @@ -117,7 +120,10 @@ def get_active_displays(cls) -> list[str]: cls._active_displays = list( filter( None, - re.split(r" *[,:] *", ocio.GetCurrentConfig().getActiveDisplays()), + re.split( + r" *[,:] *", + ocio.GetCurrentConfig().getActiveDisplays(), + ), ) ) @@ -132,7 +138,9 @@ def get_active_views(cls) -> list[str]: cls._active_views = list( filter( None, - re.split(r" *[,:] *", ocio.GetCurrentConfig().getActiveViews()), + re.split( + r" *[,:] *", ocio.GetCurrentConfig().getActiveViews() + ), ) ) @@ -213,23 +221,34 @@ def get_categories(cls) -> list[str]: @classmethod def get_color_spaces( - cls, as_set: bool = False + cls, + reference_space_type: Optional[ocio.SearchReferenceSpaceType] = None, + visibility: Optional[ocio.ColorSpaceVisibility] = None, + as_set: bool = False, ) -> Union[list[ocio.ColorSpace], ocio.ColorSpaceSet]: """ Get all (all reference space types and visibility states) color spaces from the current config. + :param reference_space_type: Optionally filter by reference + space type. + :param visibility: Optional filter by visibility :param as_set: If True, put returned color spaces into a ColorSpaceSet, which copies the spaces to insulate from config changes. :return: list or color space set of color spaces """ - cache_key = as_set + if reference_space_type is None: + reference_space_type = ocio.SEARCH_REFERENCE_SPACE_ALL + if visibility is None: + visibility = ocio.COLORSPACE_ALL + + cache_key = (as_set, reference_space_type, visibility) if not cls.validate() or cache_key not in cls._color_spaces: config = ocio.GetCurrentConfig() color_spaces = config.getColorSpaces( - ocio.SEARCH_REFERENCE_SPACE_ALL, ocio.COLORSPACE_ALL + reference_space_type, visibility ) if as_set: color_space_set = ocio.ColorSpaceSet() @@ -253,7 +272,10 @@ def get_color_space_names( """ cache_key = reference_space_type - if not cls.validate() or reference_space_type not in cls._color_space_names: + if ( + not cls.validate() + or reference_space_type not in cls._color_space_names + ): cls._color_space_names[cache_key] = list( ocio.GetCurrentConfig().getColorSpaceNames( reference_space_type, ocio.COLORSPACE_ALL @@ -402,7 +424,9 @@ def get_named_transforms(cls) -> list[ocio.NamedTransform]: """ if not cls.validate() or cls._named_transforms is None: cls._named_transforms = list( - ocio.GetCurrentConfig().getNamedTransforms(ocio.NAMEDTRANSFORM_ALL) + ocio.GetCurrentConfig().getNamedTransforms( + ocio.NAMEDTRANSFORM_ALL + ) ) return cls._named_transforms @@ -471,7 +495,9 @@ def get_view_transforms(cls) -> list[ocio.ViewTransform]: :return: List of view transforms from the current config """ if not cls.validate() or cls._view_transforms is None: - cls._view_transforms = list(ocio.GetCurrentConfig().getViewTransforms()) + cls._view_transforms = list( + ocio.GetCurrentConfig().getViewTransforms() + ) return cls._view_transforms @@ -496,7 +522,10 @@ def get_viewing_rule_names(cls) -> list[str]: if not cls.validate() or cls._viewing_rule_names is None: viewing_rules = ocio.GetCurrentConfig().getViewingRules() cls._viewing_rule_names = sorted( - [viewing_rules.getName(i) for i in range(viewing_rules.getNumEntries())] + [ + viewing_rules.getName(i) + for i in range(viewing_rules.getNumEntries()) + ] ) return cls._viewing_rule_names diff --git a/src/apps/ocioview/ocioview/config_dock.py b/src/apps/ocioview/ocioview/config_dock.py index 33660b112e..b1c8ab4c4c 100644 --- a/src/apps/ocioview/ocioview/config_dock.py +++ b/src/apps/ocioview/ocioview/config_dock.py @@ -6,6 +6,7 @@ import PyOpenColorIO as ocio from PySide6 import QtCore, QtWidgets +from .signal_router import SignalRouter from .items import ( ColorSpaceEdit, ConfigPropertiesEdit, @@ -26,10 +27,21 @@ class ConfigDock(TabbedDockWidget): Dockable widget for editing the current config. """ - config_changed = QtCore.Signal() - - def __init__(self, parent: Optional[QtCore.QObject] = None): - super().__init__("Config", get_glyph_icon("ph.file-text"), parent=parent) + def __init__( + self, + corner_widget: Optional[QtWidgets.QWidget] = None, + parent: Optional[QtCore.QObject] = None, + ): + """ + :param corner_widget: Optional widget to place on the right + side of the dock title bar. + """ + super().__init__( + "Config", + get_glyph_icon("ph.file-text"), + corner_widget=corner_widget, + parent=parent, + ) self._models = [] @@ -50,9 +62,13 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self._connect_config_item_model(self.rule_edit.viewing_rule_edit.model) self.display_view_edit = DisplayViewEdit() - self._connect_config_item_model(self.display_view_edit.view_edit.display_model) + self._connect_config_item_model( + self.display_view_edit.view_edit.display_model + ) self._connect_config_item_model(self.display_view_edit.view_edit.model) - self._connect_config_item_model(self.display_view_edit.shared_view_edit.model) + self._connect_config_item_model( + self.display_view_edit.shared_view_edit.model + ) self._connect_config_item_model( self.display_view_edit.active_display_view_edit.active_display_edit.model ) @@ -137,7 +153,9 @@ def update_config_views(self) -> None: """ message_queue.put_nowait(ocio.GetCurrentConfig()) - def _connect_config_item_model(self, model: QtCore.QAbstractItemModel) -> None: + def _connect_config_item_model( + self, model: QtCore.QAbstractItemModel + ) -> None: """ Collect model and route all config changes to the 'config_changed' signal. @@ -154,7 +172,7 @@ def _on_config_changed(self, *args, **kwargs) -> None: """ Broadcast to the wider application that the config has changed. """ - self.config_changed.emit() + SignalRouter.get_instance().emit_config_changed() self.update_config_views() def _on_warning_raised(self, message: str) -> None: diff --git a/src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py b/src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py index 7516ea2fcc..f030cd0009 100644 --- a/src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py +++ b/src/apps/ocioview/ocioview/inspect/chromaticities_inspector.py @@ -371,9 +371,9 @@ def _setup_visuals(self) -> None: ) self._visuals["rgb_color_space_input_3d"].visible = False self._visuals["rgb_color_space_chromaticities_2d"].visible = False - self._visuals["rgb_color_space_chromaticities_2d"].local.position = ( - np.array([0, 0, 0.00005]) - ) + self._visuals[ + "rgb_color_space_chromaticities_2d" + ].local.position = np.array([0, 0, 0.00005]) self._visuals["rgb_color_space_chromaticities_3d"].visible = False self._visuals["rgb_scatter_3d"].visible = False @@ -494,9 +494,11 @@ def _update_visuals(self, *args): conversion_chain = [] image_array = np.copy(self._image_array) + # Don't try to process single or zero pixel images + image_empty = image_array.size <= 3 # 1. Apply current active processor - if self._processor is not None: + if not image_empty and self._processor is not None: if self._context.transform_item_name is not None: conversion_chain += [ self._context.input_color_space, @@ -508,12 +510,12 @@ def _update_visuals(self, *args): ) if rgb_colourspace is not None: - self._visuals["rgb_color_space_input_2d"].colourspace = ( - rgb_colourspace - ) - self._visuals["rgb_color_space_input_3d"].colourspace = ( - rgb_colourspace - ) + self._visuals[ + "rgb_color_space_input_2d" + ].colourspace = rgb_colourspace + self._visuals[ + "rgb_color_space_input_3d" + ].colourspace = rgb_colourspace self._processor.applyRGB(image_array) # 2. Convert from chromaticities input space to "CIE-XYZ-D65" interchange @@ -559,11 +561,12 @@ def _update_visuals(self, *args): # 3. Convert from "CIE-XYZ-D65" to "VisualRGBScatter3D" working space conversion_chain += ["CIE-XYZ-D65", self._working_space] - image_array = XYZ_to_RGB( - image_array, - self._working_space, - illuminant=self._working_whitepoint, - ) + if not image_empty: + image_array = XYZ_to_RGB( + image_array, + self._working_space, + illuminant=self._working_whitepoint, + ) conversion_chain = [ color_space for color_space, _group in groupby(conversion_chain) diff --git a/src/apps/ocioview/ocioview/inspect/code_inspector.py b/src/apps/ocioview/ocioview/inspect/code_inspector.py index 0c8860672f..1e932c2db7 100644 --- a/src/apps/ocioview/ocioview/inspect/code_inspector.py +++ b/src/apps/ocioview/ocioview/inspect/code_inspector.py @@ -55,7 +55,9 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self.export_button = QtWidgets.QToolButton() self.export_button.setIcon(get_glyph_icon("mdi6.file-export-outline")) self.export_button.setText("Export CTF") - self.export_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + self.export_button.setToolButtonStyle( + QtCore.Qt.ToolButtonTextBesideIcon + ) self.export_button.released.connect(self._on_export_button_released) self.ctf_view = LogView() @@ -63,8 +65,12 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self.ctf_view.append_tool_bar_widget(self.export_button) self.gpu_language_box = EnumComboBox(ocio.GpuLanguage) - self.gpu_language_box.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) - self.gpu_language_box.set_member(MessageRouter.get_instance().gpu_language) + self.gpu_language_box.setSizeAdjustPolicy( + QtWidgets.QComboBox.AdjustToContents + ) + self.gpu_language_box.set_member( + MessageRouter.get_instance().gpu_language + ) self.gpu_language_box.currentIndexChanged[int].connect( self._on_gpu_language_changed ) @@ -136,7 +142,9 @@ def _scroll_preserved(self, log_view: LogView) -> None: h_scroll_bar = log_view.horizontalScrollBar() # Get line number from bottom of view - prev_cursor = log_view.cursorForPosition(log_view.html_view.rect().bottomLeft()) + prev_cursor = log_view.cursorForPosition( + log_view.html_view.rect().bottomLeft() + ) prev_line_num = prev_cursor.blockNumber() # Get scroll bar positions @@ -149,7 +157,9 @@ def _scroll_preserved(self, log_view: LogView) -> None: # Restore current line number cursor = QtGui.QTextCursor(log_view.document()) cursor.movePosition( - QtGui.QTextCursor.Down, QtGui.QTextCursor.MoveAnchor, prev_line_num - 1 + QtGui.QTextCursor.Down, + QtGui.QTextCursor.MoveAnchor, + prev_line_num - 1, ) log_view.setTextCursor(cursor) @@ -167,7 +177,9 @@ def _on_config_html_ready(self, record: str) -> None: self.config_view.setHtml(record) @QtCore.Slot(str, ocio.GroupTransform) - def _on_ctf_html_ready(self, record: str, group_tf: ocio.GroupTransform) -> None: + def _on_ctf_html_ready( + self, record: str, group_tf: ocio.GroupTransform + ) -> None: """ Update CTF view with a lossless XML representation of an OCIO processor. @@ -178,7 +190,9 @@ def _on_ctf_html_ready(self, record: str, group_tf: ocio.GroupTransform) -> None self.ctf_view.setHtml(record) @QtCore.Slot(str, ocio.GPUProcessor) - def _on_shader_html_ready(self, record: str, gpu_proc: ocio.GPUProcessor) -> None: + def _on_shader_html_ready( + self, record: str, gpu_proc: ocio.GPUProcessor + ) -> None: """ Update shader view with fragment shader source created from an OCIO GPU processor. diff --git a/src/apps/ocioview/ocioview/inspect/curve_inspector.py b/src/apps/ocioview/ocioview/inspect/curve_inspector.py index b9013cd3a4..e5298cc1d4 100644 --- a/src/apps/ocioview/ocioview/inspect/curve_inspector.py +++ b/src/apps/ocioview/ocioview/inspect/curve_inspector.py @@ -40,7 +40,9 @@ def label(cls) -> str: @classmethod def icon(cls) -> QtGui.QIcon: - return get_glyph_icon("mdi6.chart-bell-curve-cumulative", size=ICON_SIZE_TAB) + return get_glyph_icon( + "mdi6.chart-bell-curve-cumulative", size=ICON_SIZE_TAB + ) def __init__(self, parent: Optional[QtCore.QObject] = None): super().__init__(parent=parent) @@ -50,18 +52,31 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self.input_range_label.setToolTip("Input range") self.input_range_edit = FloatEditArray( labels=["min", "max"], - defaults=[CurveView.INPUT_MIN_DEFAULT, CurveView.INPUT_MAX_DEFAULT], + defaults=[ + CurveView.INPUT_MIN_DEFAULT, + CurveView.INPUT_MAX_DEFAULT, + ], ) self.input_range_edit.setToolTip(self.input_range_label.toolTip()) - self.input_range_edit.value_changed.connect(self._on_input_range_changed) + self.input_range_edit.value_changed.connect( + self._on_input_range_changed + ) - self.sample_count_label = get_glyph_icon("ph.line-segments", as_widget=True) + self.sample_count_label = get_glyph_icon( + "ph.line-segments", as_widget=True + ) self.sample_count_label.setToolTip("Sample count") - self.sample_count_edit = IntEdit(default=CurveView.SAMPLE_COUNT_DEFAULT) + self.sample_count_edit = IntEdit( + default=CurveView.SAMPLE_COUNT_DEFAULT + ) self.sample_count_edit.setToolTip(self.sample_count_label.toolTip()) - self.sample_count_edit.value_changed.connect(self._on_sample_count_changed) + self.sample_count_edit.value_changed.connect( + self._on_sample_count_changed + ) - self.sample_type_label = get_glyph_icon("mdi6.function-variant", as_widget=True) + self.sample_type_label = get_glyph_icon( + "mdi6.function-variant", as_widget=True + ) self.sample_type_label.setToolTip("Sample type") self.sample_type_combo = EnumComboBox(SampleType) self.sample_type_combo.setToolTip(self.sample_type_label.toolTip()) @@ -241,6 +256,7 @@ def __init__( ) # Cached processor from which the OCIO transform is derived + self._prev_proc_context = None self._prev_cpu_proc = None # Graphics scene @@ -363,7 +379,10 @@ def set_sample_count(self, sample_count: int) -> None: :param sample_count: Number of samples. Typically, a power of 2 number. """ - if sample_count != self._sample_count and sample_count >= self.SAMPLE_COUNT_MIN: + if ( + sample_count != self._sample_count + and sample_count >= self.SAMPLE_COUNT_MIN + ): self._sample_count = sample_count self._update_curves() @@ -388,7 +407,9 @@ def set_log_base(self, log_base: int) -> None: self._log_base = log_base self._update_curves() - def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: + def drawBackground( + self, painter: QtGui.QPainter, rect: QtCore.QRectF + ) -> None: """Draw curve grid and axis values.""" # Flood fill background painter.setPen(QtCore.Qt.NoPen) @@ -413,7 +434,9 @@ def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: painter.drawRect(self._curve_rect) # Draw grid rows - y_text_origin = QtGui.QTextOption(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + y_text_origin = QtGui.QTextOption( + QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter + ) y_text_origin.setWrapMode(QtGui.QTextOption.NoWrap) for i, y in enumerate( @@ -436,12 +459,16 @@ def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: painter.scale(1, -1) painter.setPen(text_pen) painter.drawText( - QtCore.QRectF(-42.5, -10, 40, 20), str(label_value), y_text_origin + QtCore.QRectF(-42.5, -10, 40, 20), + str(label_value), + y_text_origin, ) painter.restore() # Draw grid columns - x_text_origin = QtGui.QTextOption(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) + x_text_origin = QtGui.QTextOption( + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) x_text_origin.setWrapMode(QtGui.QTextOption.NoWrap) sample_step = math.ceil(self._sample_count / 10.0) @@ -459,7 +486,9 @@ def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: if x > self._x_min: label_value = round( - x if self._sample_type == SampleType.LINEAR else self._x_log[i], + x + if self._sample_type == SampleType.LINEAR + else self._x_log[i], 2 if self._sample_type == SampleType.LINEAR else 5, ) if label_value == 0.0: @@ -471,11 +500,15 @@ def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: painter.rotate(90) painter.setPen(text_pen) painter.drawText( - QtCore.QRectF(2.5 + 1, -10, 40, 20), str(label_value), x_text_origin + QtCore.QRectF(2.5 + 1, -10, 40, 20), + str(label_value), + x_text_origin, ) painter.restore() - def drawForeground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: + def drawForeground( + self, painter: QtGui.QPainter, rect: QtCore.QRectF + ) -> None: """Draw nearest sample point and coordinates.""" if not self._curve_init: return @@ -484,7 +517,9 @@ def drawForeground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: font.setPixelSize(self.FONT_HEIGHT) painter.setFont(font) - text_origin = QtGui.QTextOption(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) + text_origin = QtGui.QTextOption( + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) text_origin.setWrapMode(QtGui.QTextOption.NoWrap) sample_l = sample_t = None @@ -518,12 +553,18 @@ def drawForeground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: y_label_value = f"{nearest_sample[2]:.05f}" painter.save() - painter.translate(QtCore.QPointF(sample_l, sample_t + (20 * i))) + painter.translate( + QtCore.QPointF(sample_l, sample_t + (20 * i)) + ) painter.scale(1, -1) painter.setPen(GRAY_COLOR) - painter.drawText(QtCore.QRectF(0, -20, 5, 10), "X:", text_origin) - painter.drawText(QtCore.QRectF(0, -10, 5, 10), "Y:", text_origin) + painter.drawText( + QtCore.QRectF(0, -20, 5, 10), "X:", text_origin + ) + painter.drawText( + QtCore.QRectF(0, -10, 5, 10), "Y:", text_origin + ) if color_name == GRAY_COLOR.name(): palette = self.palette() @@ -541,7 +582,9 @@ def drawForeground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: def _invalidate(self) -> None: """Force repaint of visible region of graphics scene.""" - self._scene.invalidate(QtCore.QRectF(self.visibleRegion().boundingRect())) + self._scene.invalidate( + QtCore.QRectF(self.visibleRegion().boundingRect()) + ) def _update_curves(self) -> None: """ @@ -549,8 +592,13 @@ def _update_curves(self) -> None: processor. """ self._update_x_samples() - if self._prev_cpu_proc is not None: - self._on_processor_ready(self._prev_cpu_proc) + if ( + self._prev_proc_context is not None + and self._prev_cpu_proc is not None + ): + self._on_processor_ready( + self._prev_proc_context, self._prev_cpu_proc + ) def _update_x_samples(self): """ @@ -558,13 +606,22 @@ def _update_x_samples(self): parameters. """ self._x_lin = np.linspace( - self._input_min, self._input_max, self._sample_count, dtype=np.float32 + self._input_min, + self._input_max, + self._sample_count, + dtype=np.float32, ) log_min = math.log(max(self.EPSILON, self._input_min)) - log_max = max(log_min + 0.00001, math.log(self._input_max, self._log_base)) + log_max = max( + log_min + 0.00001, math.log(self._input_max, self._log_base) + ) self._x_log = np.logspace( - log_min, log_max, self._sample_count, base=self._log_base, dtype=np.float32 + log_min, + log_max, + self._sample_count, + base=self._log_base, + dtype=np.float32, ) self._x_min = self._x_lin.min() @@ -589,12 +646,16 @@ def _fit(self) -> None: fm.boundingRect( text_rect, text_flags, - "100.01" if self._sample_type == SampleType.LINEAR else "100.00001", + "100.01" + if self._sample_type == SampleType.LINEAR + else "100.00001", ).width() + 10 ) pad_l = fm.boundingRect(text_rect, text_flags, "100.01").width() + 10 - pad_r = fm.boundingRect(text_rect, text_flags, "X: 100.00001").width() + 10 + pad_r = ( + fm.boundingRect(text_rect, text_flags, "X: 100.00001").width() + 10 + ) fit_rect = self._curve_rect.adjusted(-pad_l, -pad_t, pad_r, pad_b) @@ -615,6 +676,7 @@ def _on_processor_ready( """ self.reset() + self._prev_proc_context = proc_context self._prev_cpu_proc = cpu_proc # Get input samples @@ -635,13 +697,15 @@ def _on_processor_ready( b_samples = rgb_samples[2::3] # Collect sample pairs and min/max Y sample values - if np.allclose(r_samples, g_samples, atol=self.EPSILON) and np.allclose( - r_samples, b_samples, atol=self.EPSILON - ): + if np.allclose( + r_samples, g_samples, atol=self.EPSILON + ) and np.allclose(r_samples, b_samples, atol=self.EPSILON): palette = self.palette() color_name = palette.color(palette.ColorRole.Text).name() - self._samples[color_name] = np.stack((self._x_lin, r_samples), axis=-1) + self._samples[color_name] = np.stack( + (self._x_lin, r_samples), axis=-1 + ) self._y_min = r_samples.min() self._y_max = r_samples.max() @@ -702,14 +766,18 @@ def _on_processor_ready( for color_name, channel_samples in self._samples.items(): curve = QtGui.QPainterPath( self._curve_tf.map( - QtCore.QPointF(channel_samples[0][0], channel_samples[0][1]) + QtCore.QPointF( + channel_samples[0][0], channel_samples[0][1] + ) ) ) curve.reserve(channel_samples.shape[0]) for i in range(1, channel_samples.shape[0]): curve.lineTo( self._curve_tf.map( - QtCore.QPointF(channel_samples[i][0], channel_samples[i][1]) + QtCore.QPointF( + channel_samples[i][0], channel_samples[i][1] + ) ) ) self._curve_paths[color_name] = curve @@ -729,7 +797,9 @@ def _on_processor_ready( # Expand scene rect to fit graph max_dim = max(self._curve_rect.width(), self._curve_rect.height()) * 2 - scene_rect = self._curve_rect.adjusted(-max_dim, -max_dim, max_dim, max_dim) + scene_rect = self._curve_rect.adjusted( + -max_dim, -max_dim, max_dim, max_dim + ) self.setSceneRect(scene_rect) self._fit() diff --git a/src/apps/ocioview/ocioview/inspect/log_inspector.py b/src/apps/ocioview/ocioview/inspect/log_inspector.py index b1d5a1c1ae..8675c24174 100644 --- a/src/apps/ocioview/ocioview/inspect/log_inspector.py +++ b/src/apps/ocioview/ocioview/inspect/log_inspector.py @@ -31,8 +31,12 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): # Widgets self.log_level_box = ComboBox() - self.log_level_box.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) - self.log_level_box.addItem("Warning", userData=ocio.LOGGING_LEVEL_WARNING) + self.log_level_box.setSizeAdjustPolicy( + QtWidgets.QComboBox.AdjustToContents + ) + self.log_level_box.addItem( + "Warning", userData=ocio.LOGGING_LEVEL_WARNING + ) self.log_level_box.addItem("Info", userData=ocio.LOGGING_LEVEL_INFO) self.log_level_box.addItem("Debug", userData=ocio.LOGGING_LEVEL_DEBUG) self.log_level_box.setCurrentText( @@ -55,7 +59,9 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self.setLayout(layout) # Initialize - self.log_level_box.currentIndexChanged[int].connect(self._on_log_level_changed) + self.log_level_box.currentIndexChanged[int].connect( + self._on_log_level_changed + ) self.clear_button.released.connect(self.reset) log_router = MessageRouter.get_instance() diff --git a/src/apps/ocioview/ocioview/inspect_dock.py b/src/apps/ocioview/ocioview/inspect_dock.py index c9ef01a6cb..99fcab7f2a 100644 --- a/src/apps/ocioview/ocioview/inspect_dock.py +++ b/src/apps/ocioview/ocioview/inspect_dock.py @@ -5,7 +5,12 @@ from PySide6 import QtCore, QtWidgets -from .inspect import ChromaticitiesInspector, CodeInspector, CurveInspector, LogInspector +from .inspect import ( + ChromaticitiesInspector, + CodeInspector, + CurveInspector, + LogInspector, +) from .utils import get_glyph_icon from .widgets.structure import TabbedDockWidget @@ -16,8 +21,21 @@ class InspectDock(TabbedDockWidget): transform data. """ - def __init__(self, parent: Optional[QtCore.QObject] = None): - super().__init__("Inspect", get_glyph_icon("mdi6.dna"), parent=parent) + def __init__( + self, + corner_widget: Optional[QtWidgets.QWidget] = None, + parent: Optional[QtCore.QObject] = None, + ): + """ + :param corner_widget: Optional widget to place on the right + side of the dock title bar. + """ + super().__init__( + "Inspect", + get_glyph_icon("mdi6.dna"), + corner_widget=corner_widget, + parent=parent, + ) self.setAllowedAreas( QtCore.Qt.BottomDockWidgetArea | QtCore.Qt.TopDockWidgetArea @@ -53,8 +71,7 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): ) def reset(self) -> None: - """Reset data for all inspectors.""" + """Reset data for all inspectors, except the log.""" self.chromaticities_inspector.reset() self.curve_inspector.reset() self.code_inspector.reset() - self.log_inspector.reset() diff --git a/src/apps/ocioview/ocioview/items/active_display_view_edit.py b/src/apps/ocioview/ocioview/items/active_display_view_edit.py index 76d0ffbefb..a13ea05f39 100644 --- a/src/apps/ocioview/ocioview/items/active_display_view_edit.py +++ b/src/apps/ocioview/ocioview/items/active_display_view_edit.py @@ -61,7 +61,9 @@ class ActiveDisplayViewEdit(QtWidgets.QWidget): @classmethod def item_type_icon(cls) -> QtGui.QIcon: - return get_glyph_icon("mdi6.sort-bool-ascending-variant", size=ICON_SIZE_ITEM) + return get_glyph_icon( + "mdi6.sort-bool-ascending-variant", size=ICON_SIZE_ITEM + ) @classmethod def item_type_label(cls, plural: bool = False) -> str: diff --git a/src/apps/ocioview/ocioview/items/active_display_view_model.py b/src/apps/ocioview/ocioview/items/active_display_view_model.py index 63f1d5fd37..47c4db06d0 100644 --- a/src/apps/ocioview/ocioview/items/active_display_view_model.py +++ b/src/apps/ocioview/ocioview/items/active_display_view_model.py @@ -64,7 +64,9 @@ def move_item_up(self, item_name: str) -> bool: if dst_row == src_row: return False - return self.moveRows(self.NULL_INDEX, src_row, 1, self.NULL_INDEX, dst_row) + return self.moveRows( + self.NULL_INDEX, src_row, 1, self.NULL_INDEX, dst_row + ) def move_item_down(self, item_name: str) -> bool: active_names = self.__get_active_items__() @@ -77,7 +79,9 @@ def move_item_down(self, item_name: str) -> bool: if dst_row == src_row: return False - return self.moveRows(self.NULL_INDEX, src_row, 1, self.NULL_INDEX, dst_row) + return self.moveRows( + self.NULL_INDEX, src_row, 1, self.NULL_INDEX, dst_row + ) def flags(self, index: QtCore.QModelIndex) -> int: return super().flags(index) | QtCore.Qt.ItemIsUserCheckable diff --git a/src/apps/ocioview/ocioview/items/color_space_edit.py b/src/apps/ocioview/ocioview/items/color_space_edit.py index b93b2b9093..8b451aa146 100644 --- a/src/apps/ocioview/ocioview/items/color_space_edit.py +++ b/src/apps/ocioview/ocioview/items/color_space_edit.py @@ -9,6 +9,7 @@ from ..config_cache import ConfigCache from ..constants import ICON_SIZE_ITEM +from ..signal_router import SignalRouter from ..utils import get_glyph_icon from ..widgets import ( CheckBox, @@ -50,10 +51,16 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): ) self.aliases_list = StringListWidget( item_basename="alias", - item_icon=get_glyph_icon("ph.bookmark-simple", size=ICON_SIZE_ITEM), + item_icon=get_glyph_icon( + "ph.bookmark-simple", size=ICON_SIZE_ITEM + ), + ) + self.family_edit = CallbackComboBox( + ConfigCache.get_families, editable=True + ) + self.encoding_edit = CallbackComboBox( + ConfigCache.get_encodings, editable=True ) - self.family_edit = CallbackComboBox(ConfigCache.get_families, editable=True) - self.encoding_edit = CallbackComboBox(ConfigCache.get_encodings, editable=True) self.equality_group_edit = CallbackComboBox( ConfigCache.get_equality_groups, editable=True ) @@ -61,34 +68,49 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): self.bit_depth_combo = EnumComboBox(ocio.BitDepth) self.is_data_check = CheckBox() self.allocation_combo = EnumComboBox(ocio.Allocation) - self.allocation_combo.currentIndexChanged.connect(self._on_allocation_changed) + self.allocation_combo.currentIndexChanged.connect( + self._on_allocation_changed + ) self.allocation_vars_edit = FloatEditArray( ("min", "max", "offset"), (0.0, 1.0, 0.0) ) self.categories_list = StringListWidget( item_basename="category", - item_icon=get_glyph_icon("ph.bookmarks-simple", size=ICON_SIZE_ITEM), + item_icon=get_glyph_icon( + "ph.bookmarks-simple", size=ICON_SIZE_ITEM + ), get_presets=self._get_available_categories, ) # Layout self._param_layout.addRow( - self.model.REFERENCE_SPACE_TYPE.label, self.reference_space_type_combo + self.model.REFERENCE_SPACE_TYPE.label, + self.reference_space_type_combo, ) self._param_layout.addRow(self.model.ALIASES.label, self.aliases_list) self._param_layout.addRow(self.model.FAMILY.label, self.family_edit) - self._param_layout.addRow(self.model.ENCODING.label, self.encoding_edit) + self._param_layout.addRow( + self.model.ENCODING.label, self.encoding_edit + ) self._param_layout.addRow( self.model.EQUALITY_GROUP.label, self.equality_group_edit ) - self._param_layout.addRow(self.model.DESCRIPTION.label, self.description_edit) - self._param_layout.addRow(self.model.BIT_DEPTH.label, self.bit_depth_combo) + self._param_layout.addRow( + self.model.DESCRIPTION.label, self.description_edit + ) + self._param_layout.addRow( + self.model.BIT_DEPTH.label, self.bit_depth_combo + ) self._param_layout.addRow(self.model.IS_DATA.label, self.is_data_check) - self._param_layout.addRow(self.model.ALLOCATION.label, self.allocation_combo) + self._param_layout.addRow( + self.model.ALLOCATION.label, self.allocation_combo + ) self._param_layout.addRow( self.model.ALLOCATION_VARS.label, self.allocation_vars_edit ) - self._param_layout.addRow(self.model.CATEGORIES.label, self.categories_list) + self._param_layout.addRow( + self.model.CATEGORIES.label, self.categories_list + ) def update_available_allocation_vars(self) -> None: """ @@ -115,7 +137,11 @@ def _get_available_categories(self) -> list[str]: to this item. """ current_categories = self.categories_list.items() - return [c for c in ConfigCache.get_categories() if c not in current_categories] + return [ + c + for c in ConfigCache.get_categories() + if c not in current_categories + ] class ColorSpaceEdit(BaseConfigItemEdit): @@ -124,6 +150,7 @@ class ColorSpaceEdit(BaseConfigItemEdit): """ __param_edit_type__ = ColorSpaceParamEdit + __signal_router_emit__ = SignalRouter.emit_color_spaces_changed.__name__ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): super().__init__(parent=parent) @@ -131,41 +158,53 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): model = self.model # Map widgets to model columns - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.reference_space_type_combo, model.REFERENCE_SPACE_TYPE.column, ) - self._mapper.addMapping(self.param_edit.aliases_list, model.ALIASES.column) - self._mapper.addMapping(self.param_edit.family_edit, model.FAMILY.column) - self._mapper.addMapping(self.param_edit.encoding_edit, model.ENCODING.column) - self._mapper.addMapping( + self.mapper.addMapping( + self.param_edit.aliases_list, model.ALIASES.column + ) + self.mapper.addMapping( + self.param_edit.family_edit, model.FAMILY.column + ) + self.mapper.addMapping( + self.param_edit.encoding_edit, model.ENCODING.column + ) + self.mapper.addMapping( self.param_edit.equality_group_edit, model.EQUALITY_GROUP.column ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.description_edit, model.DESCRIPTION.column ) - self._mapper.addMapping(self.param_edit.bit_depth_combo, model.BIT_DEPTH.column) - self._mapper.addMapping(self.param_edit.is_data_check, model.IS_DATA.column) - self._mapper.addMapping( + self.mapper.addMapping( + self.param_edit.bit_depth_combo, model.BIT_DEPTH.column + ) + self.mapper.addMapping( + self.param_edit.is_data_check, model.IS_DATA.column + ) + self.mapper.addMapping( self.param_edit.allocation_combo, model.ALLOCATION.column ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.allocation_vars_edit, model.ALLOCATION_VARS.column ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.categories_list, model.CATEGORIES.column ) # list widgets need manual data submission back to model - self.param_edit.aliases_list.items_changed.connect(self._mapper.submit) - self.param_edit.categories_list.items_changed.connect(self._mapper.submit) + self.param_edit.aliases_list.items_changed.connect(self.mapper.submit) + self.param_edit.categories_list.items_changed.connect( + self.mapper.submit + ) # Trigger immediate update from widgets that update the model upon losing focus self.param_edit.reference_space_type_combo.currentIndexChanged.connect( - partial(self.param_edit.submit_mapper_deferred, self._mapper) + partial(self.param_edit.submit_mapper_deferred, self.mapper) ) self.param_edit.is_data_check.stateChanged.connect( - partial(self.param_edit.submit_mapper_deferred, self._mapper) + partial(self.param_edit.submit_mapper_deferred, self.mapper) ) # Initialize diff --git a/src/apps/ocioview/ocioview/items/color_space_model.py b/src/apps/ocioview/ocioview/items/color_space_model.py index 4e71469888..bc65316499 100644 --- a/src/apps/ocioview/ocioview/items/color_space_model.py +++ b/src/apps/ocioview/ocioview/items/color_space_model.py @@ -51,7 +51,9 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self._items = ocio.ColorSpaceSet() self._ref_space_icons = { - ocio.REFERENCE_SPACE_SCENE: get_glyph_icon("ph.sun", size=ICON_SIZE_ITEM), + ocio.REFERENCE_SPACE_SCENE: get_glyph_icon( + "ph.sun", size=ICON_SIZE_ITEM + ), ocio.REFERENCE_SPACE_DISPLAY: get_glyph_icon( "ph.monitor", size=ICON_SIZE_ITEM ), @@ -70,7 +72,9 @@ def get_item_transforms( # Get color space name from subscription item label item_name = self.extract_subscription_item_name(item_label) - ref_space_name = ReferenceSpaceManager.scene_reference_space().getName() + ref_space_name = ( + ReferenceSpaceManager.scene_reference_space().getName() + ) return ( ocio.ColorSpaceTransform(src=ref_space_name, dst=item_name), ocio.ColorSpaceTransform(src=item_name, dst=ref_space_name), @@ -124,10 +128,14 @@ def _remove_item(self, item: ocio.ColorSpace) -> None: def _new_item(self, name: str) -> None: ocio.GetCurrentConfig().addColorSpace( - ocio.ColorSpace(referenceSpace=ocio.REFERENCE_SPACE_SCENE, name=name) + ocio.ColorSpace( + referenceSpace=ocio.REFERENCE_SPACE_SCENE, name=name + ) ) - def _get_value(self, item: ocio.ColorSpace, column_desc: ColumnDesc) -> Any: + def _get_value( + self, item: ocio.ColorSpace, column_desc: ColumnDesc + ) -> Any: # Get parameters if column_desc == self.REFERENCE_SPACE_TYPE: return int(item.getReferenceSpaceType().value) @@ -160,7 +168,9 @@ def _get_value(self, item: ocio.ColorSpace, column_desc: ColumnDesc) -> Any: num_alloc_vars = len(alloc_vars) if num_alloc_vars < 3: default_alloc_vars = [0.0, 1.0, 0.0] - alloc_vars += [default_alloc_vars[i] for i in range(num_alloc_vars, 3)] + alloc_vars += [ + default_alloc_vars[i] for i in range(num_alloc_vars, 3) + ] elif num_alloc_vars > 3: alloc_vars = alloc_vars[:3] return alloc_vars @@ -203,8 +213,12 @@ def _set_value( isData=item.isData(), allocation=item.getAllocation(), allocationVars=item.getAllocationVars(), - toReference=item.getTransform(ocio.COLORSPACE_DIR_TO_REFERENCE), - fromReference=item.getTransform(ocio.COLORSPACE_DIR_FROM_REFERENCE), + toReference=item.getTransform( + ocio.COLORSPACE_DIR_TO_REFERENCE + ), + fromReference=item.getTransform( + ocio.COLORSPACE_DIR_FROM_REFERENCE + ), categories=list(item.getCategories()), ) @@ -214,7 +228,8 @@ def _set_value( # Update parameters if column_desc == self.NAME: - new_item.setName(value) + if value: + new_item.setName(value) elif column_desc == self.ALIASES: new_item.clearAliases() for alias in value: @@ -275,5 +290,6 @@ def _set_value( if column_desc in (self.NAME, self.TO_REFERENCE, self.FROM_REFERENCE): item_name = new_item.getName() self._update_tf_subscribers( - item_name, prev_item_name if prev_item_name != item_name else None + item_name, + prev_item_name if prev_item_name != item_name else None, ) diff --git a/src/apps/ocioview/ocioview/items/config_item_edit.py b/src/apps/ocioview/ocioview/items/config_item_edit.py index e96a78de06..658e1fc45a 100644 --- a/src/apps/ocioview/ocioview/items/config_item_edit.py +++ b/src/apps/ocioview/ocioview/items/config_item_edit.py @@ -7,6 +7,7 @@ from PySide6 import QtCore, QtGui, QtWidgets from ..constants import MARGIN_WIDTH, ICON_SIZE_TAB +from ..signal_router import SignalRouter from ..transform_manager import TransformManager from ..transforms import TransformEditStack from ..utils import get_glyph_icon, SignalsBlocked @@ -50,11 +51,15 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): no_tf_color = palette.color( palette.ColorGroup.Disabled, palette.ColorRole.Text ) - self._from_ref_icon = get_glyph_icon("mdi6.layers-plus", size=ICON_SIZE_TAB) + self._from_ref_icon = get_glyph_icon( + "mdi6.layers-plus", size=ICON_SIZE_TAB + ) self._no_from_ref_icon = get_glyph_icon( "mdi6.layers-plus", color=no_tf_color, size=ICON_SIZE_TAB ) - self._to_ref_icon = get_glyph_icon("mdi6.layers-minus", size=ICON_SIZE_TAB) + self._to_ref_icon = get_glyph_icon( + "mdi6.layers-minus", size=ICON_SIZE_TAB + ) self._no_to_ref_icon = get_glyph_icon( "mdi6.layers-minus", color=no_tf_color, size=ICON_SIZE_TAB ) @@ -84,7 +89,9 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): param_frame.setLayout(param_spacer_layout) param_scroll_area = QtWidgets.QScrollArea() - param_scroll_area.setObjectName("config_item_param_edit__param_scroll_area") + param_scroll_area.setObjectName( + "config_item_param_edit__param_scroll_area" + ) param_scroll_area.setSizePolicy( QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding ) @@ -200,6 +207,9 @@ class BaseConfigItemEdit(QtWidgets.QWidget): # By default, this inherits from __has_list__. __has_splitter__: bool = None + # If set, call the named signal router method on model change + __signal_router_emit__: str = None + @classmethod def item_type_icon(cls) -> QtGui.QIcon: """ @@ -213,7 +223,9 @@ def item_type_label(cls, plural: bool = False) -> str: :param plural: Whether label should be plural :return: Friendly type name """ - return cls.__param_edit_type__.__model_type__.item_type_label(plural=plural) + return cls.__param_edit_type__.__model_type__.item_type_label( + plural=plural + ) def __init__(self, parent: Optional[QtWidgets.QWidget] = None): super().__init__(parent=parent) @@ -225,6 +237,16 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): model = self.model + # Connect signal router to model change + if self.__signal_router_emit__: + signal_router = SignalRouter.get_instance() + emit_method = getattr(signal_router, self.__signal_router_emit__) + model.dataChanged.connect(lambda *a, **kw: emit_method()) + model.item_renamed.connect(lambda *a, **kw: emit_method()) + model.item_added.connect(lambda *a, **kw: emit_method()) + model.item_moved.connect(lambda *a, **kw: emit_method()) + model.item_removed.connect(lambda *a, **kw: emit_method()) + if self.__has_list__: self.list = ItemModelListWidget( model, model.NAME.column, item_icon=self.item_type_icon() @@ -235,16 +257,20 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): model.item_selection_requested.connect( lambda index: self.list.set_current_row(index.row()) ) - model.modelAboutToBeReset.connect(lambda: self.param_edit.setEnabled(False)) + model.modelAboutToBeReset.connect( + lambda: self.param_edit.setEnabled(False) + ) # Map widgets to model columns - self._mapper = QtWidgets.QDataWidgetMapper() - self._mapper.setOrientation(QtCore.Qt.Horizontal) - self._mapper.setSubmitPolicy(QtWidgets.QDataWidgetMapper.AutoSubmit) - self._mapper.setModel(model) + self.mapper = QtWidgets.QDataWidgetMapper() + self.mapper.setOrientation(QtCore.Qt.Horizontal) + self.mapper.setSubmitPolicy(QtWidgets.QDataWidgetMapper.AutoSubmit) + self.mapper.setModel(model) try: - self._mapper.addMapping(self.param_edit.name_edit, model.NAME.column) + self.mapper.addMapping( + self.param_edit.name_edit, model.NAME.column + ) except RuntimeError: # Some derived classes may delete this widget to handle custom mapping pass @@ -254,7 +280,9 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): # in transit. Conversely, handling transform updates manually via # signals/slots is stable, so used here instead. if self.param_edit.__has_transforms__: - self.param_edit.from_ref_stack.edited.connect(self._on_from_ref_edited) + self.param_edit.from_ref_stack.edited.connect( + self._on_from_ref_edited + ) self.param_edit.to_ref_stack.edited.connect(self._on_to_ref_edited) model.dataChanged.connect(self._on_data_changed) @@ -292,7 +320,9 @@ def set_splitter_sizes(self, from_sizes: list[int]) -> None: adapt_splitter_sizes(from_sizes, self.splitter.sizes()) ) - def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool: + def eventFilter( + self, watched: QtCore.QObject, event: QtCore.QEvent + ) -> bool: """ Handle setting subscription for the current item's transform on number key press. @@ -316,7 +346,9 @@ def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool: ) ): current_index = self.list.current_index() - item_label = self.model.format_subscription_item_label(current_index) + item_label = self.model.format_subscription_item_label( + current_index + ) if item_label: TransformManager.set_subscription( int(event.text()), self.model, item_label @@ -334,19 +366,21 @@ def _on_current_row_changed(self, row: int) -> None: if row < 0: self.param_edit.reset() else: - self._mapper.setCurrentIndex(row) + self.mapper.setCurrentIndex(row) # Manually update transform stacks from model, on current row change if self.param_edit.__has_transforms__: model = self.model with SignalsBlocked( - self.param_edit.from_ref_stack, self.param_edit.to_ref_stack + self.param_edit.from_ref_stack, + self.param_edit.to_ref_stack, ): self.param_edit.from_ref_stack.set_transform( model.data( model.index( - row, self.param_edit.__from_ref_column_desc__.column + row, + self.param_edit.__from_ref_column_desc__.column, ), QtCore.Qt.EditRole, ) @@ -354,7 +388,8 @@ def _on_current_row_changed(self, row: int) -> None: self.param_edit.to_ref_stack.set_transform( model.data( model.index( - row, self.param_edit.__to_ref_column_desc__.column + row, + self.param_edit.__to_ref_column_desc__.column, ), QtCore.Qt.EditRole, ) @@ -363,7 +398,10 @@ def _on_current_row_changed(self, row: int) -> None: @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex, list) def _on_data_changed( - self, top_left: QtCore.QModelIndex, bottom_right: QtCore.QModelIndex, roles=() + self, + top_left: QtCore.QModelIndex, + bottom_right: QtCore.QModelIndex, + roles=(), ) -> None: """ Manually update transform stacks from model, on model data diff --git a/src/apps/ocioview/ocioview/items/config_item_model.py b/src/apps/ocioview/ocioview/items/config_item_model.py index e77b4a7fcd..19505c1058 100644 --- a/src/apps/ocioview/ocioview/items/config_item_model.py +++ b/src/apps/ocioview/ocioview/items/config_item_model.py @@ -75,7 +75,9 @@ def item_type_icon(cls) -> QtGui.QIcon: :return: Item type icon """ if cls.__icon__ is None: - cls.__icon__ = get_glyph_icon(cls.__icon_glyph__, size=ICON_SIZE_ITEM) + cls.__icon__ = get_glyph_icon( + cls.__icon_glyph__, size=ICON_SIZE_ITEM + ) return cls.__icon__ @classmethod @@ -221,7 +223,9 @@ def move_item_up(self, item_name: str) -> bool: if dst_row == src_row: return False - return self.moveRows(self.NULL_INDEX, src_row, 1, self.NULL_INDEX, dst_row) + return self.moveRows( + self.NULL_INDEX, src_row, 1, self.NULL_INDEX, dst_row + ) def move_item_down(self, item_name: str) -> bool: """ @@ -240,7 +244,9 @@ def move_item_down(self, item_name: str) -> bool: if dst_row == src_row: return False - return self.moveRows(self.NULL_INDEX, src_row, 1, self.NULL_INDEX, dst_row) + return self.moveRows( + self.NULL_INDEX, src_row, 1, self.NULL_INDEX, dst_row + ) def flags(self, index: QtCore.QModelIndex) -> int: return QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled @@ -263,10 +269,15 @@ def headerData( """ :return: Column labels """ - if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole: + if ( + orientation == QtCore.Qt.Horizontal + and role == QtCore.Qt.DisplayRole + ): return self.COLUMNS[column].label - def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole) -> Any: + def data( + self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole + ) -> Any: """ :return: Item data pulled from the current config for the index-referenced column. @@ -293,7 +304,10 @@ def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole) -> return None def setData( - self, index: QtCore.QModelIndex, value: Any, role: int = QtCore.Qt.EditRole + self, + index: QtCore.QModelIndex, + value: Any, + role: int = QtCore.Qt.EditRole, ) -> bool: """ Push modified item data to the current config for the @@ -312,7 +326,7 @@ def setData( if checked_column_desc is not None: column_desc = checked_column_desc index = index.sibling(index.row(), column_desc.column) - value = value == QtCore.Qt.Checked + value = value == QtCore.Qt.Checked.value role = QtCore.Qt.EditRole undo_cmd_type = self._get_undo_command_type(column_desc) @@ -419,7 +433,9 @@ def moveRows( all_names = self.get_item_names() all_items = { name: item - for name, item in zip(all_names, self._get_items(preserve=True)) + for name, item in zip( + all_names, self._get_items(preserve=True) + ) } insert_before = None @@ -427,7 +443,8 @@ def moveRows( insert_before = all_names[dst_row] move_names = [ - all_names.pop(i) for i in reversed(range(src_row, src_row + count)) + all_names.pop(i) + for i in reversed(range(src_row, src_row + count)) ] if insert_before is not None: @@ -436,7 +453,9 @@ def moveRows( new_dst_row = len(all_names) with ConfigSnapshotUndoCommand( - f"Move {self.item_type_label()}", model=self, item_name=move_names[0] + f"Move {self.item_type_label()}", + model=self, + item_name=move_names[0], ): for i in range(len(move_names)): all_names.insert(new_dst_row, move_names[i]) @@ -459,40 +478,45 @@ def removeRows( Remove ``count`` items from the current config, starting at ``row`` index. """ - self.beginRemoveRows(parent, row, row + count - 1) - items = self._get_items() item_names = self.get_item_names() num_items = len(items) - do_not_remove = [] + remove_rows = [] + could_not_remove = [] + + for i in range(row, row + count): + if i < num_items: + item = items[i] + can_be_removed, reason = self._can_item_be_removed(item) + if not can_be_removed: + could_not_remove.append((item_names[i], reason)) + else: + remove_rows.append(i) + + if remove_rows: + with ConfigSnapshotUndoCommand( + f"Delete {self.item_type_label()}", + model=self, + item_name=item_names[row], + ): + self.beginRemoveRows(parent, row, row + count - 1) - with ConfigSnapshotUndoCommand( - f"Delete {self.item_type_label()}", model=self, item_name=item_names[row] - ): - for i in reversed(range(row, row + count)): - if i < num_items: - item = items[i] - can_be_removed, reason = self._can_item_be_removed(item) - if not can_be_removed: - do_not_remove.append((item_names[i], reason)) - else: - self._remove_item(item) - - if num_items: - self.item_removed.emit() + for i in reversed(remove_rows): + self._remove_item(items[i]) - self.endRemoveRows() + self.item_removed.emit() + self.endRemoveRows() # Warn user about refused item removals - if do_not_remove: + if could_not_remove: item_warning_lines = [] - for item_name, reason in do_not_remove: + for item_name, reason in could_not_remove: item_warning_lines.append(f"{item_name} {reason}") item_warnings = "

".join(item_warning_lines) self.warning_raised.emit( - f"{len(do_not_remove)} " - f"{self.item_type_label(plural=len(do_not_remove) != 1).lower()} could " + f"{len(could_not_remove)} " + f"{self.item_type_label(plural=len(could_not_remove) != 1).lower()} could " f"not be removed:

{item_warnings}" ) @@ -552,7 +576,9 @@ def get_item_transforms( """ return None, None - def get_index_from_item_name(self, item_name: str) -> Optional[QtCore.QModelIndex]: + def get_index_from_item_name( + self, item_name: str + ) -> Optional[QtCore.QModelIndex]: """ Lookup the model index for the named item. @@ -631,7 +657,9 @@ def _can_item_be_removed(self, item: __item_type__) -> tuple[bool, str]: """ return True, "" - def _get_display(self, item: __item_type__, column_desc: ColumnDesc) -> str: + def _get_display( + self, item: __item_type__, column_desc: ColumnDesc + ) -> str: """ :return: Display role value for a given model column """ @@ -663,7 +691,9 @@ def _get_subscription_color( """ slot = TransformManager.get_subscription_slot( self, - self.format_subscription_item_label(self._get_value(item, column_desc)), + self.format_subscription_item_label( + self._get_value(item, column_desc) + ), ) return TransformManager.get_subscription_slot_color( slot, saturation=0.25, value=0.25 @@ -678,7 +708,9 @@ def _get_subscription_icon( """ slot = TransformManager.get_subscription_slot( self, - self.format_subscription_item_label(self._get_value(item, column_desc)), + self.format_subscription_item_label( + self._get_value(item, column_desc) + ), ) return TransformManager.get_subscription_slot_icon(slot) @@ -754,7 +786,9 @@ def _update_tf_subscribers( # the model. item_label = self.format_subscription_item_label(item_name) if prev_item_name: - prev_item_label = self.format_subscription_item_label(prev_item_name) + prev_item_label = self.format_subscription_item_label( + prev_item_name + ) else: prev_item_label = None diff --git a/src/apps/ocioview/ocioview/items/config_properties_edit.py b/src/apps/ocioview/ocioview/items/config_properties_edit.py index ea2bca0354..9eb42b54ea 100644 --- a/src/apps/ocioview/ocioview/items/config_properties_edit.py +++ b/src/apps/ocioview/ocioview/items/config_properties_edit.py @@ -54,12 +54,18 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): # Layout self._param_layout.addRow(self.model.VERSION.label, self.version_edit) - self._param_layout.addRow(self.model.DESCRIPTION.label, self.description_edit) + self._param_layout.addRow( + self.model.DESCRIPTION.label, self.description_edit + ) self._param_layout.addRow( self.model.ENVIRONMENT_VARS.label, self.env_vars_table ) - self._param_layout.addRow(self.model.SEARCH_PATH.label, self.search_path_list) - self._param_layout.addRow(self.model.WORKING_DIR.label, self.working_dir_edit) + self._param_layout.addRow( + self.model.SEARCH_PATH.label, self.search_path_list + ) + self._param_layout.addRow( + self.model.WORKING_DIR.label, self.working_dir_edit + ) self._param_layout.addRow( self.model.FAMILY_SEPARATOR.label, self.family_separator_edit ) @@ -106,33 +112,40 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): model = self.model # Map widgets to model columns - self._mapper.addMapping(self.param_edit.version_edit, model.VERSION.column) - self._mapper.addMapping( + self.mapper.addMapping( + self.param_edit.version_edit, model.VERSION.column + ) + self.mapper.addMapping( self.param_edit.description_edit, model.DESCRIPTION.column ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.env_vars_table, model.ENVIRONMENT_VARS.column ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.search_path_list, model.SEARCH_PATH.column ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.working_dir_edit, model.WORKING_DIR.column ) - self._mapper.addMapping( - self.param_edit.family_separator_edit, model.FAMILY_SEPARATOR.column + self.mapper.addMapping( + self.param_edit.family_separator_edit, + model.FAMILY_SEPARATOR.column, ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.default_luma_coefs_edit, model.DEFAULT_LUMA_COEFS.column, ) # Table and list widgets need manual data submission back to model - self.param_edit.env_vars_table.items_changed.connect(self._mapper.submit) - self.param_edit.search_path_list.items_changed.connect(self._mapper.submit) + self.param_edit.env_vars_table.items_changed.connect( + self.mapper.submit + ) + self.param_edit.search_path_list.items_changed.connect( + self.mapper.submit + ) # Reload sole item on reset - model.modelReset.connect(lambda: self._mapper.setCurrentIndex(0)) + model.modelReset.connect(lambda: self.mapper.setCurrentIndex(0)) # Initialize - self._mapper.setCurrentIndex(0) + self.mapper.setCurrentIndex(0) diff --git a/src/apps/ocioview/ocioview/items/display_model.py b/src/apps/ocioview/ocioview/items/display_model.py index 1f8c782133..78d80a095f 100644 --- a/src/apps/ocioview/ocioview/items/display_model.py +++ b/src/apps/ocioview/ocioview/items/display_model.py @@ -20,7 +20,7 @@ class View: type: ViewType name: str color_space: str - view_transform: str + view_transform: str = "" looks: str = "" rule: str = "" description: str = "" @@ -68,38 +68,46 @@ def _get_items(self, preserve: bool = False) -> list[Display]: self._items = [] # Get views to preserve through display name changes - for name in ConfigCache.get_displays(): + for name in config.getDisplays(): display = Display(name) # Display-defined views - for view in ConfigCache.get_views( - name, view_type=ocio.VIEW_DISPLAY_DEFINED - ): - display.views.append( - View( - get_view_type(name, view), - view, + for view in config.getViews(ocio.VIEW_DISPLAY_DEFINED, name): + view_type = get_view_type(name, view) + + if view_type == ViewType.VIEW_SCENE: + view_ref = View( + view_type, + name, + config.getDisplayViewColorSpaceName(name, view), + looks=config.getDisplayViewLooks(name, view), + ) + display.views.append(view_ref) + + else: # VIEW_DISPLAY + view_ref = View( + view_type, + name, config.getDisplayViewColorSpaceName(name, view), config.getDisplayViewTransformName(name, view), config.getDisplayViewLooks(name, view), config.getDisplayViewRule(name, view), config.getDisplayViewDescription(name, view), ) - ) + display.views.append(view_ref) # Shared views - for view in ConfigCache.get_views(name, view_type=ocio.VIEW_SHARED): - display.views.append( - View( - ViewType.VIEW_SHARED, - view, - config.getDisplayViewColorSpaceName("", view), - config.getDisplayViewTransformName("", view), - config.getDisplayViewLooks("", view), - config.getDisplayViewRule("", view), - config.getDisplayViewDescription("", view), - ) + for view in config.getViews(ocio.VIEW_SHARED, name): + view_ref = View( + ViewType.VIEW_SHARED, + view, + config.getDisplayViewColorSpaceName("", view), + config.getDisplayViewTransformName("", view), + config.getDisplayViewLooks("", view), + config.getDisplayViewRule("", view), + config.getDisplayViewDescription("", view), ) + display.views.append(view_ref) self._items.append(display) @@ -175,7 +183,9 @@ def _new_item(self, name: str) -> None: displayColorSpaceName=color_space, ) else: - config.addDisplayView(name, new_view, colorSpaceName=color_space) + config.addDisplayView( + name, new_view, colorSpaceName=color_space + ) def _get_value(self, item: Display, column_desc: ColumnDesc) -> Any: # Get parameters diff --git a/src/apps/ocioview/ocioview/items/file_rule_edit.py b/src/apps/ocioview/ocioview/items/file_rule_edit.py index 95bda2c4b1..d3602a6b81 100644 --- a/src/apps/ocioview/ocioview/items/file_rule_edit.py +++ b/src/apps/ocioview/ocioview/items/file_rule_edit.py @@ -3,13 +3,13 @@ from typing import Optional +import PyOpenColorIO as ocio from PySide6 import QtCore, QtWidgets -from ..config_cache import ConfigCache from ..constants import ICON_SIZE_ITEM from ..utils import get_glyph_icon from ..widgets import ( - CallbackComboBox, + ColorSpaceComboBox, ExpandingStackedWidget, FormLayout, LineEdit, @@ -49,9 +49,11 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): params_layout.addRow(self.model.NAME.label, name_edit) if file_rule_type != FileRuleType.RULE_OCIO_V1: - color_space_combo = CallbackComboBox(ConfigCache.get_color_space_names) + color_space_combo = ColorSpaceComboBox() self.color_space_combos[file_rule_type] = color_space_combo - params_layout.addRow(self.model.COLOR_SPACE.label, color_space_combo) + params_layout.addRow( + self.model.COLOR_SPACE.label, color_space_combo + ) if file_rule_type == FileRuleType.RULE_BASIC: pattern_edit = LineEdit() @@ -60,14 +62,19 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): extension_edit = LineEdit() self.extension_edits[file_rule_type] = extension_edit - params_layout.addRow(self.model.EXTENSION.label, extension_edit) + params_layout.addRow( + self.model.EXTENSION.label, extension_edit + ) if file_rule_type == FileRuleType.RULE_REGEX: regex_edit = LineEdit() self.regex_edits[file_rule_type] = regex_edit params_layout.addRow(self.model.REGEX.label, regex_edit) - if file_rule_type in (FileRuleType.RULE_BASIC, FileRuleType.RULE_REGEX): + if file_rule_type in ( + FileRuleType.RULE_BASIC, + FileRuleType.RULE_REGEX, + ): custom_keys_table = StringMapTableWidget( ("Key Name", "Key Value"), item_icon=get_glyph_icon("ph.key", size=ICON_SIZE_ITEM), @@ -75,7 +82,9 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): default_value="value", ) self.custom_keys_tables[file_rule_type] = custom_keys_table - params_layout.addRow(self.model.CUSTOM_KEYS.label, custom_keys_table) + params_layout.addRow( + self.model.CUSTOM_KEYS.label, custom_keys_table + ) params = QtWidgets.QFrame() params.setLayout(params_layout) @@ -102,14 +111,18 @@ def update_available_params( ) if file_rule_type in self.name_edits: - mapper.addMapping(self.name_edits[file_rule_type], self.model.NAME.column) + mapper.addMapping( + self.name_edits[file_rule_type], self.model.NAME.column + ) self.name_edits[file_rule_type].setEnabled( - file_rule_type in (FileRuleType.RULE_BASIC, FileRuleType.RULE_REGEX) + file_rule_type + in (FileRuleType.RULE_BASIC, FileRuleType.RULE_REGEX) ) if file_rule_type in self.color_space_combos: mapper.addMapping( - self.color_space_combos[file_rule_type], self.model.COLOR_SPACE.column + self.color_space_combos[file_rule_type], + self.model.COLOR_SPACE.column, ) self.color_space_combos[file_rule_type].setEnabled( file_rule_type != FileRuleType.RULE_OCIO_V1 @@ -124,14 +137,17 @@ def update_available_params( ) if file_rule_type in self.regex_edits: - mapper.addMapping(self.regex_edits[file_rule_type], self.model.REGEX.column) + mapper.addMapping( + self.regex_edits[file_rule_type], self.model.REGEX.column + ) self.regex_edits[file_rule_type].setEnabled( file_rule_type == FileRuleType.RULE_REGEX ) if file_rule_type in self.extension_edits: mapper.addMapping( - self.extension_edits[file_rule_type], self.model.EXTENSION.column + self.extension_edits[file_rule_type], + self.model.EXTENSION.column, ) self.extension_edits[file_rule_type].setEnabled( file_rule_type == FileRuleType.RULE_BASIC @@ -139,10 +155,12 @@ def update_available_params( if file_rule_type in self.custom_keys_tables: mapper.addMapping( - self.custom_keys_tables[file_rule_type], self.model.CUSTOM_KEYS.column + self.custom_keys_tables[file_rule_type], + self.model.CUSTOM_KEYS.column, ) self.custom_keys_tables[file_rule_type].setEnabled( - file_rule_type in (FileRuleType.RULE_BASIC, FileRuleType.RULE_REGEX) + file_rule_type + in (FileRuleType.RULE_BASIC, FileRuleType.RULE_REGEX) ) @@ -159,11 +177,11 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): model = self.model # Clear default mapped widgets. Widgets will be remapped per file rule type. - self._mapper.clearMapping() + self.mapper.clearMapping() # Table widgets need manual data submission back to model for custom_keys_table in self.param_edit.custom_keys_tables.values(): - custom_keys_table.items_changed.connect(self._mapper.submit) + custom_keys_table.items_changed.connect(self.mapper.submit) # Initialize if model.rowCount(): @@ -178,6 +196,8 @@ def _on_current_row_changed(self, row: int) -> None: self.model.index(row, self.model.FILE_RULE_TYPE.column), QtCore.Qt.EditRole, ) - self.param_edit.update_available_params(self._mapper, file_rule_type) + self.param_edit.update_available_params( + self.mapper, file_rule_type + ) super()._on_current_row_changed(row) diff --git a/src/apps/ocioview/ocioview/items/file_rule_model.py b/src/apps/ocioview/ocioview/items/file_rule_model.py index 2cad0c54ec..170b1f69db 100644 --- a/src/apps/ocioview/ocioview/items/file_rule_model.py +++ b/src/apps/ocioview/ocioview/items/file_rule_model.py @@ -63,7 +63,15 @@ class FileRuleModel(BaseConfigItemModel): CUSTOM_KEYS = ColumnDesc(6, "Custom Keys", list) COLUMNS = sorted( - [FILE_RULE_TYPE, NAME, COLOR_SPACE, PATTERN, REGEX, EXTENSION, CUSTOM_KEYS], + [ + FILE_RULE_TYPE, + NAME, + COLOR_SPACE, + PATTERN, + REGEX, + EXTENSION, + CUSTOM_KEYS, + ], key=lambda s: s.column, ) @@ -106,8 +114,12 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): super().__init__(parent=parent) self._rule_type_icons = { - FileRuleType.RULE_BASIC: self.get_rule_type_icon(FileRuleType.RULE_BASIC), - FileRuleType.RULE_REGEX: self.get_rule_type_icon(FileRuleType.RULE_REGEX), + FileRuleType.RULE_BASIC: self.get_rule_type_icon( + FileRuleType.RULE_BASIC + ), + FileRuleType.RULE_REGEX: self.get_rule_type_icon( + FileRuleType.RULE_REGEX + ), FileRuleType.RULE_OCIO_V1: self.get_rule_type_icon( FileRuleType.RULE_OCIO_V1 ), @@ -146,7 +158,9 @@ def add_preset(self, preset_name: str) -> int: ConfigCache.get_default_color_space_name(), ) else: - return file_rules.getIndexForRule(ocio.FILE_PATH_SEARCH_RULE_NAME) + return file_rules.getIndexForRule( + ocio.FILE_PATH_SEARCH_RULE_NAME + ) # Make new rule top priority row = 0 @@ -184,7 +198,9 @@ def move_item_up(self, item_name: str) -> bool: return False with ConfigSnapshotUndoCommand( - f"Move {self.item_type_label()}", model=self, item_name=item_name + f"Move {self.item_type_label()}", + model=self, + item_name=item_name, ): file_rules.increaseRulePriority(src_rule_index) ocio.GetCurrentConfig().setFileRules(file_rules) @@ -217,7 +233,9 @@ def move_item_down(self, item_name: str) -> bool: return False with ConfigSnapshotUndoCommand( - f"Move {self.item_type_label()}", model=self, item_name=item_name + f"Move {self.item_type_label()}", + model=self, + item_name=item_name, ): file_rules.decreaseRulePriority(src_rule_index) ocio.GetCurrentConfig().setFileRules(file_rules) @@ -233,7 +251,9 @@ def get_item_names(self) -> list[str]: config = ocio.GetCurrentConfig() file_rules = config.getFileRules() - return [file_rules.getName(i) for i in range(file_rules.getNumEntries())] + return [ + file_rules.getName(i) for i in range(file_rules.getNumEntries()) + ] def _get_icon( self, item: FileRule, column_desc: ColumnDesc @@ -294,7 +314,9 @@ def _clear_items(self) -> None: ocio.GetCurrentConfig().setFileRules(ocio.FileRules()) @staticmethod - def _insert_rule(index: int, file_rules: ocio.FileRules, item: FileRule) -> None: + def _insert_rule( + index: int, file_rules: ocio.FileRules, item: FileRule + ) -> None: """ Insert rule into an ``ocio.FileRules`` object from a FileRule instance. @@ -308,7 +330,9 @@ def _insert_rule(index: int, file_rules: ocio.FileRules, item: FileRule) -> None for key_name, key_value in item.custom_keys.items(): file_rules.setCustomKey(index, key_name, key_value) - def _remove_named_rule(self, file_rules: ocio.ViewingRules, item: FileRule) -> None: + def _remove_named_rule( + self, file_rules: ocio.ViewingRules, item: FileRule + ) -> None: """Remove existing rule with name matching the provided rule.""" # Default rule can't be removed if item.name != ocio.DEFAULT_RULE_NAME: diff --git a/src/apps/ocioview/ocioview/items/look_edit.py b/src/apps/ocioview/ocioview/items/look_edit.py index 630051c9ca..d815be1811 100644 --- a/src/apps/ocioview/ocioview/items/look_edit.py +++ b/src/apps/ocioview/ocioview/items/look_edit.py @@ -7,8 +7,7 @@ import PyOpenColorIO as ocio from PySide6 import QtWidgets -from ..config_cache import ConfigCache -from ..widgets import CallbackComboBox, TextEdit +from ..widgets import ColorSpaceComboBox, TextEdit from .look_model import LookModel from .config_item_edit import BaseConfigItemParamEdit, BaseConfigItemEdit @@ -27,8 +26,8 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): super().__init__(parent=parent) # Widgets - self.process_space_combo = CallbackComboBox( - lambda: ConfigCache.get_color_space_names(ocio.SEARCH_REFERENCE_SPACE_SCENE) + self.process_space_combo = ColorSpaceComboBox( + ocio.SEARCH_REFERENCE_SPACE_SCENE, include_roles=True ) self.description_edit = TextEdit() @@ -36,7 +35,9 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): self._param_layout.addRow( self.model.PROCESS_SPACE.label, self.process_space_combo ) - self._param_layout.addRow(self.model.DESCRIPTION.label, self.description_edit) + self._param_layout.addRow( + self.model.DESCRIPTION.label, self.description_edit + ) class LookEdit(BaseConfigItemEdit): @@ -52,16 +53,16 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): model = self.model # Map widgets to model columns - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.process_space_combo, model.PROCESS_SPACE.column ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.description_edit, model.DESCRIPTION.column ) # Trigger immediate update from widgets that update the model upon losing focus - self.param_edit.process_space_combo.currentIndexChanged.connect( - partial(self.param_edit.submit_mapper_deferred, self._mapper) + self.param_edit.process_space_combo.color_space_changed.connect( + partial(self.param_edit.submit_mapper_deferred, self.mapper) ) # Initialize diff --git a/src/apps/ocioview/ocioview/items/look_model.py b/src/apps/ocioview/ocioview/items/look_model.py index 780a950db2..b2e19372dc 100644 --- a/src/apps/ocioview/ocioview/items/look_model.py +++ b/src/apps/ocioview/ocioview/items/look_model.py @@ -40,7 +40,9 @@ def get_item_transforms( # Get look name from subscription item label item_name = self.extract_subscription_item_name(item_label) - scene_ref_name = ReferenceSpaceManager.scene_reference_space().getName() + scene_ref_name = ( + ReferenceSpaceManager.scene_reference_space().getName() + ) return ( ocio.LookTransform( src=scene_ref_name, @@ -59,9 +61,9 @@ def get_item_transforms( def _get_icon( self, item: ocio.ColorSpace, column_desc: ColumnDesc ) -> Optional[QtGui.QIcon]: - return self._get_subscription_icon(item, column_desc) or super()._get_icon( + return self._get_subscription_icon( item, column_desc - ) + ) or super()._get_icon(item, column_desc) def _get_bg_color( self, item: __item_type__, column_desc: ColumnDesc @@ -130,7 +132,10 @@ def _get_value(self, item: ocio.Look, column_desc: ColumnDesc) -> Any: # Process space is unset; find a reasonable default. Start with the most # common roles for shot grades or ACES LMTs. - for role in (ocio.ROLE_COLOR_TIMING, ocio.ROLE_INTERCHANGE_SCENE): + for role in ( + ocio.ROLE_COLOR_TIMING, + ocio.ROLE_INTERCHANGE_SCENE, + ): process_space = config.getCanonicalName(role) if process_space: break @@ -195,7 +200,9 @@ def _set_value( # Preserve item order when replacing item due to name change, which requires # removing the old item to add the new. if column_desc == self.NAME: - items = [copy.deepcopy(other_item) for other_item in config.getLooks()] + items = [ + copy.deepcopy(other_item) for other_item in config.getLooks() + ] config.clearLooks() for other_look in items: if other_look.getName() == prev_item_name: @@ -212,5 +219,6 @@ def _set_value( if column_desc in (self.NAME, self.TRANSFORM, self.INVERSE_TRANSFORM): item_name = new_item.getName() self._update_tf_subscribers( - item_name, prev_item_name if prev_item_name != item_name else None + item_name, + prev_item_name if prev_item_name != item_name else None, ) diff --git a/src/apps/ocioview/ocioview/items/named_transform_edit.py b/src/apps/ocioview/ocioview/items/named_transform_edit.py index fefe69f544..b70d9919af 100644 --- a/src/apps/ocioview/ocioview/items/named_transform_edit.py +++ b/src/apps/ocioview/ocioview/items/named_transform_edit.py @@ -30,23 +30,37 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): # Widgets self.aliases_list = StringListWidget( item_basename="alias", - item_icon=get_glyph_icon("ph.bookmark-simple", size=ICON_SIZE_ITEM), + item_icon=get_glyph_icon( + "ph.bookmark-simple", size=ICON_SIZE_ITEM + ), + ) + self.family_edit = CallbackComboBox( + ConfigCache.get_families, editable=True + ) + self.encoding_edit = CallbackComboBox( + ConfigCache.get_encodings, editable=True ) - self.family_edit = CallbackComboBox(ConfigCache.get_families, editable=True) - self.encoding_edit = CallbackComboBox(ConfigCache.get_encodings, editable=True) self.description_edit = TextEdit() self.categories_list = StringListWidget( item_basename="category", - item_icon=get_glyph_icon("ph.bookmarks-simple", size=ICON_SIZE_ITEM), + item_icon=get_glyph_icon( + "ph.bookmarks-simple", size=ICON_SIZE_ITEM + ), get_presets=self._get_available_categories, ) # Layout self._param_layout.addRow(self.model.ALIASES.label, self.aliases_list) self._param_layout.addRow(self.model.FAMILY.label, self.family_edit) - self._param_layout.addRow(self.model.ENCODING.label, self.encoding_edit) - self._param_layout.addRow(self.model.DESCRIPTION.label, self.description_edit) - self._param_layout.addRow(self.model.CATEGORIES.label, self.categories_list) + self._param_layout.addRow( + self.model.ENCODING.label, self.encoding_edit + ) + self._param_layout.addRow( + self.model.DESCRIPTION.label, self.description_edit + ) + self._param_layout.addRow( + self.model.CATEGORIES.label, self.categories_list + ) def _get_available_categories(self) -> list[str]: """ @@ -54,7 +68,11 @@ def _get_available_categories(self) -> list[str]: to this item. """ current_categories = self.categories_list.items() - return [c for c in ConfigCache.get_categories() if c not in current_categories] + return [ + c + for c in ConfigCache.get_categories() + if c not in current_categories + ] class NamedTransformEdit(BaseConfigItemEdit): @@ -70,19 +88,27 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): model = self.model # Map widgets to model columns - self._mapper.addMapping(self.param_edit.aliases_list, model.ALIASES.column) - self._mapper.addMapping(self.param_edit.family_edit, model.FAMILY.column) - self._mapper.addMapping(self.param_edit.encoding_edit, model.ENCODING.column) - self._mapper.addMapping( + self.mapper.addMapping( + self.param_edit.aliases_list, model.ALIASES.column + ) + self.mapper.addMapping( + self.param_edit.family_edit, model.FAMILY.column + ) + self.mapper.addMapping( + self.param_edit.encoding_edit, model.ENCODING.column + ) + self.mapper.addMapping( self.param_edit.description_edit, model.DESCRIPTION.column ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.categories_list, model.CATEGORIES.column ) # list widgets need manual data submission back to model - self.param_edit.aliases_list.items_changed.connect(self._mapper.submit) - self.param_edit.categories_list.items_changed.connect(self._mapper.submit) + self.param_edit.aliases_list.items_changed.connect(self.mapper.submit) + self.param_edit.categories_list.items_changed.connect( + self.mapper.submit + ) # Initialize if model.rowCount(): diff --git a/src/apps/ocioview/ocioview/items/named_transform_model.py b/src/apps/ocioview/ocioview/items/named_transform_model.py index f42378438e..814e1ec854 100644 --- a/src/apps/ocioview/ocioview/items/named_transform_model.py +++ b/src/apps/ocioview/ocioview/items/named_transform_model.py @@ -40,7 +40,9 @@ class NamedTransformModel(BaseConfigItemModel): def __init__(self, parent: Optional[QtCore.QObject] = None): super().__init__(parent=parent) - self._item_icon = get_glyph_icon("ph.arrow-square-right", size=ICON_SIZE_ITEM) + self._item_icon = get_glyph_icon( + "ph.arrow-square-right", size=ICON_SIZE_ITEM + ) def get_item_names(self) -> list[str]: return [item.getName() for item in self._get_items()] @@ -57,15 +59,23 @@ def get_item_transforms( fwd_tf = named_transform.getTransform(ocio.TRANSFORM_DIR_FORWARD) if not fwd_tf: - inv_tf_ = named_transform.getTransform(ocio.TRANSFORM_DIR_INVERSE) + inv_tf_ = named_transform.getTransform( + ocio.TRANSFORM_DIR_INVERSE + ) if inv_tf_: - fwd_tf = ocio.GroupTransform([inv_tf_], ocio.TRANSFORM_DIR_INVERSE) + fwd_tf = ocio.GroupTransform( + [inv_tf_], ocio.TRANSFORM_DIR_INVERSE + ) inv_tf = named_transform.getTransform(ocio.TRANSFORM_DIR_INVERSE) if not inv_tf: - fwd_tf_ = named_transform.getTransform(ocio.TRANSFORM_DIR_FORWARD) + fwd_tf_ = named_transform.getTransform( + ocio.TRANSFORM_DIR_FORWARD + ) if fwd_tf_: - inv_tf = ocio.GroupTransform([fwd_tf_], ocio.TRANSFORM_DIR_INVERSE) + inv_tf = ocio.GroupTransform( + [fwd_tf_], ocio.TRANSFORM_DIR_INVERSE + ) return fwd_tf, inv_tf else: @@ -74,9 +84,9 @@ def get_item_transforms( def _get_icon( self, item: ocio.ColorSpace, column_desc: ColumnDesc ) -> Optional[QtGui.QIcon]: - return self._get_subscription_icon(item, column_desc) or super()._get_icon( + return self._get_subscription_icon( item, column_desc - ) + ) or super()._get_icon(item, column_desc) def _get_bg_color( self, item: __item_type__, column_desc: ColumnDesc @@ -89,7 +99,8 @@ def _get_bg_color( def _get_items(self, preserve: bool = False) -> list[ocio.ColorSpace]: if preserve: self._items = [ - copy.deepcopy(item) for item in ConfigCache.get_named_transforms() + copy.deepcopy(item) + for item in ConfigCache.get_named_transforms() ] return self._items else: @@ -116,10 +127,14 @@ def _remove_item(self, item: ocio.NamedTransform) -> None: def _new_item(self, name: str) -> None: ocio.GetCurrentConfig().addNamedTransform( - ocio.NamedTransform(name=name, forwardTransform=ocio.GroupTransform()) + ocio.NamedTransform( + name=name, forwardTransform=ocio.GroupTransform() + ) ) - def _get_value(self, item: ocio.NamedTransform, column_desc: ColumnDesc) -> Any: + def _get_value( + self, item: ocio.NamedTransform, column_desc: ColumnDesc + ) -> Any: # Get parameters if column_desc == self.NAME: return item.getName() @@ -203,8 +218,13 @@ def _set_value( config.addNamedTransform(new_item) # Broadcast transform or name changes to subscribers - if column_desc in (self.NAME, self.FORWARD_TRANSFORM, self.INVERSE_TRANSFORM): + if column_desc in ( + self.NAME, + self.FORWARD_TRANSFORM, + self.INVERSE_TRANSFORM, + ): item_name = new_item.getName() self._update_tf_subscribers( - item_name, prev_item_name if prev_item_name != item_name else None + item_name, + prev_item_name if prev_item_name != item_name else None, ) diff --git a/src/apps/ocioview/ocioview/items/role_edit.py b/src/apps/ocioview/ocioview/items/role_edit.py index f527ceef1e..56593de78c 100644 --- a/src/apps/ocioview/ocioview/items/role_edit.py +++ b/src/apps/ocioview/ocioview/items/role_edit.py @@ -5,6 +5,7 @@ from PySide6 import QtGui, QtWidgets +from ..signal_router import SignalRouter from ..widgets import ItemModelTableWidget from .delegates import RoleDelegate from .role_model import RoleModel @@ -35,6 +36,24 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): self.model = RoleModel() + # Connect signal router to model change + signal_router = SignalRouter.get_instance() + self.model.dataChanged.connect( + lambda *a, **kw: signal_router.emit_roles_changed() + ) + self.model.item_renamed.connect( + lambda *a, **kw: signal_router.emit_roles_changed() + ) + self.model.item_added.connect( + lambda *a, **kw: signal_router.emit_roles_changed() + ) + self.model.item_moved.connect( + lambda *a, **kw: signal_router.emit_roles_changed() + ) + self.model.item_removed.connect( + lambda *a, **kw: signal_router.emit_roles_changed() + ) + # Widgets self.table = ItemModelTableWidget(self.model) self.table.view.setItemDelegate(RoleDelegate(self.model)) diff --git a/src/apps/ocioview/ocioview/items/shared_view_edit.py b/src/apps/ocioview/ocioview/items/shared_view_edit.py index db474e5316..1f93bf0644 100644 --- a/src/apps/ocioview/ocioview/items/shared_view_edit.py +++ b/src/apps/ocioview/ocioview/items/shared_view_edit.py @@ -8,7 +8,7 @@ from PySide6 import QtWidgets from ..config_cache import ConfigCache -from ..widgets import CallbackComboBox, LineEdit +from ..widgets import CallbackComboBox, ColorSpaceComboBox, LineEdit from .config_item_edit import BaseConfigItemParamEdit, BaseConfigItemEdit from .shared_view_model import SharedViewModel @@ -23,25 +23,31 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): super().__init__(parent=parent) # Widgets - self.color_space_combo = CallbackComboBox( - lambda: [ocio.OCIO_VIEW_USE_DISPLAY_NAME] - + ConfigCache.get_color_space_names(ocio.SEARCH_REFERENCE_SPACE_DISPLAY) + self.color_space_combo = ColorSpaceComboBox( + reference_space_type=ocio.SEARCH_REFERENCE_SPACE_DISPLAY, + include_use_display_name=True, ) self.view_transform_combo = CallbackComboBox( ConfigCache.get_view_transform_names ) self.looks_edit = LineEdit() - self.rule_combo = CallbackComboBox(ConfigCache.get_viewing_rule_names) + self.rule_combo = CallbackComboBox( + lambda: [""] + ConfigCache.get_viewing_rule_names() + ) self.description_edit = LineEdit() # Layout - self._param_layout.addRow(self.model.COLOR_SPACE.label, self.color_space_combo) + self._param_layout.addRow( + self.model.COLOR_SPACE.label, self.color_space_combo + ) self._param_layout.addRow( self.model.VIEW_TRANSFORM.label, self.view_transform_combo ) self._param_layout.addRow(self.model.LOOKS.label, self.looks_edit) self._param_layout.addRow(self.model.RULE.label, self.rule_combo) - self._param_layout.addRow(self.model.DESCRIPTION.label, self.description_edit) + self._param_layout.addRow( + self.model.DESCRIPTION.label, self.description_edit + ) class SharedViewEdit(BaseConfigItemEdit): @@ -57,24 +63,24 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): model = self.model # Map widgets to model columns - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.color_space_combo, model.COLOR_SPACE.column ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.view_transform_combo, model.VIEW_TRANSFORM.column ) - self._mapper.addMapping(self.param_edit.looks_edit, model.LOOKS.column) - self._mapper.addMapping(self.param_edit.rule_combo, model.RULE.column) - self._mapper.addMapping( + self.mapper.addMapping(self.param_edit.looks_edit, model.LOOKS.column) + self.mapper.addMapping(self.param_edit.rule_combo, model.RULE.column) + self.mapper.addMapping( self.param_edit.description_edit, model.DESCRIPTION.column ) # Trigger immediate update from widgets that update the model upon losing focus - self.param_edit.color_space_combo.currentIndexChanged.connect( - partial(self.param_edit.submit_mapper_deferred, self._mapper) + self.param_edit.color_space_combo.color_space_changed.connect( + partial(self.param_edit.submit_mapper_deferred, self.mapper) ) self.param_edit.view_transform_combo.currentIndexChanged.connect( - partial(self.param_edit.submit_mapper_deferred, self._mapper) + partial(self.param_edit.submit_mapper_deferred, self.mapper) ) # Initialize diff --git a/src/apps/ocioview/ocioview/items/shared_view_model.py b/src/apps/ocioview/ocioview/items/shared_view_model.py index 7769127781..e6ff4063a4 100644 --- a/src/apps/ocioview/ocioview/items/shared_view_model.py +++ b/src/apps/ocioview/ocioview/items/shared_view_model.py @@ -65,7 +65,9 @@ def _get_items(self, preserve: bool = False) -> list[SharedView]: shared_view_display_map = defaultdict(set) for display in ConfigCache.get_displays(): - for view in ConfigCache.get_views(display, view_type=ocio.VIEW_SHARED): + for view in ConfigCache.get_views( + display, view_type=ocio.VIEW_SHARED + ): shared_view_display_map[view].add(display) self._items = [] diff --git a/src/apps/ocioview/ocioview/items/utils.py b/src/apps/ocioview/ocioview/items/utils.py index 24ab61256d..6beb6dde17 100644 --- a/src/apps/ocioview/ocioview/items/utils.py +++ b/src/apps/ocioview/ocioview/items/utils.py @@ -4,11 +4,15 @@ from __future__ import annotations import enum +import logging from typing import Optional import PyOpenColorIO as ocio +logger = logging.getLogger(__name__) + + class ViewType(str, enum.Enum): """Enum of view types.""" @@ -17,20 +21,17 @@ class ViewType(str, enum.Enum): VIEW_SCENE = "View (Scene Reference Space)" -def get_view_type(display: str, view: str) -> tuple[ViewType, str | None]: +def get_view_type(display: str, view: str) -> ViewType: """ Get the view type from a display and view. :param display: Display name. An empty string indicates a shared display. :param view: View name - :return: Tuple of view type and any warning raised while inspecting - the display and view. + :return: View type """ - warning = None - if not display: - return ViewType.VIEW_SHARED, warning + return ViewType.VIEW_SHARED config = ocio.GetCurrentConfig() @@ -39,24 +40,27 @@ def get_view_type(display: str, view: str) -> tuple[ViewType, str | None]: color_space = config.getColorSpace(color_space_name) if color_space is not None: - if color_space.getReferenceSpaceType() == ocio.REFERENCE_SPACE_DISPLAY: + if color_space.getReferenceSpaceType() == ocio.REFERENCE_SPACE_SCENE: if view_transform_name: - warning = ( + logger.warning( f"Invalid view '{display}/{view}' references a view transform " - f"('{view_transform_name}') with a non-display color space " + f"('{view_transform_name}') and a non-display color space " f"('{color_space_name}'). The view transform will be dropped to " f"preserve the color space selection." ) - return ViewType.VIEW_DISPLAY, warning + return ViewType.VIEW_SCENE else: - return ViewType.VIEW_SCENE, warning + return ViewType.VIEW_DISPLAY + elif view_transform_name: - return ViewType.VIEW_DISPLAY, warning + return ViewType.VIEW_DISPLAY else: - return ViewType.VIEW_SCENE, warning + return ViewType.VIEW_SCENE -def adapt_splitter_sizes(from_sizes: list[int], to_sizes: list[int]) -> list[int]: +def adapt_splitter_sizes( + from_sizes: list[int], to_sizes: list[int] +) -> list[int]: """ Given source and destination splitter size lists, adapt the destination sizes to match the source sizes. Supports between two diff --git a/src/apps/ocioview/ocioview/items/view_edit.py b/src/apps/ocioview/ocioview/items/view_edit.py index fecd655f6a..da4ab9089a 100644 --- a/src/apps/ocioview/ocioview/items/view_edit.py +++ b/src/apps/ocioview/ocioview/items/view_edit.py @@ -11,6 +11,7 @@ from ..transform_manager import TransformManager from ..widgets import ( CallbackComboBox, + ColorSpaceComboBox, ExpandingStackedWidget, FormLayout, ItemModelListWidget, @@ -57,7 +58,8 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): self.description_edits = {} self.edit_shared_view_button = QtWidgets.QPushButton( - self.model.get_view_type_icon(ViewType.VIEW_SHARED), "Edit Shared View" + self.model.get_view_type_icon(ViewType.VIEW_SHARED), + "Edit Shared View", ) for view_type in self.VIEW_LAYERS: @@ -77,40 +79,42 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): view_transform_combo = CallbackComboBox( ConfigCache.get_view_transform_names ) - self.view_transform_combos[view_type] = view_transform_combo + self.view_transform_combos[ + view_type + ] = view_transform_combo params_layout.addRow( self.model.VIEW_TRANSFORM.label, view_transform_combo ) if view_type == ViewType.VIEW_SCENE: - get_view_color_space_names = ( - lambda: ConfigCache.get_color_space_names( - ocio.SEARCH_REFERENCE_SPACE_SCENE - ) + color_space_combo = ColorSpaceComboBox( + reference_space_type=ocio.SEARCH_REFERENCE_SPACE_SCENE, ) else: # ViewType.VIEW_DISPLAY - get_view_color_space_names = ( - lambda: ConfigCache.get_color_space_names( - ocio.SEARCH_REFERENCE_SPACE_DISPLAY - ) + color_space_combo = ColorSpaceComboBox( + reference_space_type=ocio.SEARCH_REFERENCE_SPACE_DISPLAY, ) - - color_space_combo = CallbackComboBox(get_view_color_space_names) self.color_space_combos[view_type] = color_space_combo - params_layout.addRow(self.model.COLOR_SPACE.label, color_space_combo) + params_layout.addRow( + self.model.COLOR_SPACE.label, color_space_combo + ) looks_edit = LineEdit() self.looks_edits[view_type] = looks_edit params_layout.addRow(self.model.LOOKS.label, looks_edit) if view_type == ViewType.VIEW_DISPLAY: - rule_combo = CallbackComboBox(ConfigCache.get_viewing_rule_names) + rule_combo = CallbackComboBox( + ConfigCache.get_viewing_rule_names + ) self.rule_combos[view_type] = rule_combo params_layout.addRow(self.model.RULE.label, rule_combo) description_edit = LineEdit() self.description_edits[view_type] = description_edit - params_layout.addRow(self.model.DESCRIPTION.label, description_edit) + params_layout.addRow( + self.model.DESCRIPTION.label, description_edit + ) elif view_type == ViewType.VIEW_SHARED: params_layout.addRow(self.edit_shared_view_button) @@ -146,19 +150,25 @@ def update_available_params( ) if view_type in (ViewType.VIEW_DISPLAY, ViewType.VIEW_SCENE): - view_mapper.addMapping(self.name_edits[view_type], self.model.NAME.column) + view_mapper.addMapping( + self.name_edits[view_type], self.model.NAME.column + ) color_space_combo = self.color_space_combos[view_type] - view_mapper.addMapping(color_space_combo, self.model.COLOR_SPACE.column) + view_mapper.addMapping( + color_space_combo, self.model.COLOR_SPACE.column + ) # Trigger color space update before losing focus if color_space_combo not in self._connected[view_mapper]: - color_space_combo.currentIndexChanged.connect( + color_space_combo.color_space_changed.connect( partial(self.submit_mapper_deferred, view_mapper) ) self._connected[view_mapper].append(color_space_combo) - view_mapper.addMapping(self.looks_edits[view_type], self.model.LOOKS.column) + view_mapper.addMapping( + self.looks_edits[view_type], self.model.LOOKS.column + ) if view_type == ViewType.VIEW_DISPLAY: view_transform_combo = self.view_transform_combos[view_type] @@ -178,7 +188,8 @@ def update_available_params( self.rule_combos[view_type], self.model.RULE.column ) view_mapper.addMapping( - self.description_edits[view_type], self.model.DESCRIPTION.column + self.description_edits[view_type], + self.model.DESCRIPTION.column, ) @@ -193,7 +204,9 @@ class ViewEdit(BaseConfigItemEdit): @classmethod def item_type_label(cls, plural: bool = False) -> str: - return f"Display{'s' if plural else ''} and View{'s' if plural else ''}" + return ( + f"Display{'s' if plural else ''} and View{'s' if plural else ''}" + ) def __init__(self, parent: Optional[QtWidgets.QWidget] = None): super().__init__(parent=parent) @@ -210,7 +223,9 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): item_icon=DisplayModel.item_type_icon(), ) self.display_list.current_row_changed.connect(self._on_display_changed) - self.display_model.item_added.connect(self.display_list.set_current_item) + self.display_model.item_added.connect( + self.display_list.set_current_item + ) self.display_model.item_selection_requested.connect( lambda index: self.display_list.set_current_row(index.row()) ) @@ -219,11 +234,15 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): # Map display widget to model self._display_mapper = QtWidgets.QDataWidgetMapper() self._display_mapper.setOrientation(QtCore.Qt.Horizontal) - self._display_mapper.setSubmitPolicy(QtWidgets.QDataWidgetMapper.ManualSubmit) + self._display_mapper.setSubmitPolicy( + QtWidgets.QDataWidgetMapper.ManualSubmit + ) self._display_mapper.setModel(self.display_model) for view_type, display_edit in self.param_edit.display_edits.items(): - display_edit.editingFinished.connect(self._on_display_editing_finished) + display_edit.editingFinished.connect( + self._on_display_editing_finished + ) self.param_edit.edit_shared_view_button.released.connect( self._on_edit_shared_view_button_clicked @@ -231,7 +250,7 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): # Clear default mapped widgets from view model. Widgets will be remapped per # view type. - self._mapper.clearMapping() + self.mapper.clearMapping() # Layout self.splitter.insertWidget(0, self.display_list) @@ -260,7 +279,7 @@ def _on_display_editing_finished(self) -> None: signal is emitted twice when pressing enter. See: https://forum.qt.io/topic/39141/qlineedit-editingfinished-signal-is-emitted-twice """ - view_type, _ = self._get_view_type(self.list.current_row()) + view_type = self._get_view_type(self.list.current_row()) self.param_edit.display_edits[view_type].blockSignals(True) self._display_mapper.submit() self.param_edit.display_edits[view_type].blockSignals(False) @@ -280,7 +299,9 @@ def _on_display_renamed(self, display: str, prev_display: str) -> None: prev_item_label = self.model.format_subscription_item_label( view_index, display=prev_display ) - slot = TransformManager.get_subscription_slot(self.model, prev_item_label) + slot = TransformManager.get_subscription_slot( + self.model, prev_item_label + ) if slot != -1: TransformManager.set_subscription(slot, self.model, item_label) @@ -298,7 +319,9 @@ def _on_display_changed(self, display_row: int) -> None: else: # Get display and view names display = self.display_model.data( - self.display_model.index(display_row, self.display_model.NAME.column) + self.display_model.index( + display_row, self.display_model.NAME.column + ) ) # Update view model @@ -332,7 +355,9 @@ def _on_current_row_changed(self, view_row: int) -> None: view_type = None if view_row != -1: self._prev_view = self.model.data( - self.model.index(self.list.current_row(), self.model.NAME.column) + self.model.index( + self.list.current_row(), self.model.NAME.column + ) ) view_type = self.model.data( self.model.index(view_row, self.model.VIEW_TYPE.column), @@ -341,11 +366,13 @@ def _on_current_row_changed(self, view_row: int) -> None: # Update parameter widget states, since view type may have changed self.param_edit.update_available_params( - self._display_mapper, self._mapper, view_type + self._display_mapper, self.mapper, view_type ) # Update display params - self._display_mapper.setCurrentIndex(self._display_mapper.currentIndex()) + self._display_mapper.setCurrentIndex( + self._display_mapper.currentIndex() + ) # Update view params super()._on_current_row_changed(view_row) diff --git a/src/apps/ocioview/ocioview/items/view_model.py b/src/apps/ocioview/ocioview/items/view_model.py index e4602552f0..0af2350794 100644 --- a/src/apps/ocioview/ocioview/items/view_model.py +++ b/src/apps/ocioview/ocioview/items/view_model.py @@ -58,7 +58,9 @@ def requires_presets(cls) -> bool: @classmethod def get_presets(cls) -> Optional[Union[list[str], dict[str, QtGui.QIcon]]]: presets = { - ViewType.VIEW_DISPLAY: cls.get_view_type_icon(ViewType.VIEW_DISPLAY), + ViewType.VIEW_DISPLAY: cls.get_view_type_icon( + ViewType.VIEW_DISPLAY + ), ViewType.VIEW_SCENE: cls.get_view_type_icon(ViewType.VIEW_SCENE), } for view in ConfigCache.get_shared_views(): @@ -72,8 +74,12 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self._display = None self._view_type_icons = { - ViewType.VIEW_SHARED: self.get_view_type_icon(ViewType.VIEW_SHARED), - ViewType.VIEW_DISPLAY: self.get_view_type_icon(ViewType.VIEW_DISPLAY), + ViewType.VIEW_SHARED: self.get_view_type_icon( + ViewType.VIEW_SHARED + ), + ViewType.VIEW_DISPLAY: self.get_view_type_icon( + ViewType.VIEW_DISPLAY + ), ViewType.VIEW_SCENE: self.get_view_type_icon(ViewType.VIEW_SCENE), } @@ -103,10 +109,15 @@ def add_preset(self, preset_name: str) -> int: ) if color_spaces: color_space = color_spaces[0] - views = ConfigCache.get_views(view_type=ocio.VIEW_DISPLAY_DEFINED) + views = ConfigCache.get_views( + view_type=ocio.VIEW_DISPLAY_DEFINED + ) item = View( - ViewType.VIEW_SCENE, next_name("View_", views), color_space, "" + ViewType.VIEW_SCENE, + next_name("View_", views), + color_space, + "", ) else: self.warning_raised.emit( @@ -128,7 +139,9 @@ def add_preset(self, preset_name: str) -> int: ) if color_spaces: color_space = color_spaces[0] - views = ConfigCache.get_views(view_type=ocio.VIEW_DISPLAY_DEFINED) + views = ConfigCache.get_views( + view_type=ocio.VIEW_DISPLAY_DEFINED + ) item = View( ViewType.VIEW_DISPLAY, @@ -158,7 +171,9 @@ def add_preset(self, preset_name: str) -> int: if item is not None: with ConfigSnapshotUndoCommand( - f"Add {self.item_type_label()}", model=self, item_name=item.name + f"Add {self.item_type_label()}", + model=self, + item_name=item.name, ): self._add_item(item) row = self.get_item_names().index(item.name) @@ -246,7 +261,9 @@ def get_item_transforms( # Get view name from subscription item label item_name = self.extract_subscription_item_name(item_label) - scene_ref_name = ReferenceSpaceManager.scene_reference_space().getName() + scene_ref_name = ( + ReferenceSpaceManager.scene_reference_space().getName() + ) return ( ocio.DisplayViewTransform( src=scene_ref_name, @@ -290,11 +307,14 @@ def _get_undo_command_text( # Insert display name before view item_name = self.get_item_name(index) text = text.replace( - f"({item_name})", f"({self.format_subscription_item_label(item_name)})" + f"({item_name})", + f"({self.format_subscription_item_label(item_name)})", ) return text - def _get_icon(self, item: View, column_desc: ColumnDesc) -> Optional[QtGui.QIcon]: + def _get_icon( + self, item: View, column_desc: ColumnDesc + ) -> Optional[QtGui.QIcon]: if column_desc == self.NAME: return ( self._get_subscription_icon(item, column_desc) @@ -336,7 +356,7 @@ def _get_placeholder_view(self) -> View: ) ) - return View(ViewType.VIEW_SCENE, "_", color_space, "") + return View(ViewType.VIEW_SCENE, "_", color_space) def _reset_cache(self) -> None: self._items = [] @@ -354,12 +374,19 @@ def _get_items(self, preserve: bool = False) -> list[View]: # Display views for name in config.getViews(ocio.VIEW_DISPLAY_DEFINED, self._display): - view_type, warning = get_view_type(self._display, name) - if warning: - self.warning_raised.emit(warning) + view_type = get_view_type(self._display, name) + + if view_type == ViewType.VIEW_SCENE: + view = View( + view_type, + name, + config.getDisplayViewColorSpaceName(self._display, name), + looks=config.getDisplayViewLooks(self._display, name), + ) + self._items.append(view) - self._items.append( - View( + else: # VIEW_DISPLAY + view = View( view_type, name, config.getDisplayViewColorSpaceName(self._display, name), @@ -368,21 +395,20 @@ def _get_items(self, preserve: bool = False) -> list[View]: config.getDisplayViewRule(self._display, name), config.getDisplayViewDescription(self._display, name), ) - ) + self._items.append(view) # Shared views for name in config.getViews(ocio.VIEW_SHARED, self._display): - self._items.append( - View( - ViewType.VIEW_SHARED, - name, - config.getDisplayViewColorSpaceName("", name), - config.getDisplayViewTransformName("", name), - config.getDisplayViewLooks("", name), - config.getDisplayViewRule("", name), - config.getDisplayViewDescription("", name), - ) + view = View( + ViewType.VIEW_SHARED, + name, + config.getDisplayViewColorSpaceName("", name), + config.getDisplayViewTransformName("", name), + config.getDisplayViewLooks("", name), + config.getDisplayViewRule("", name), + config.getDisplayViewDescription("", name), ) + self._items.append(view) return self._items @@ -393,7 +419,9 @@ def _clear_items(self) -> None: # Insert placeholder view to keep display alive placeholder_view = self._get_placeholder_view() config.addDisplayView( - self._display, placeholder_view.name, placeholder_view.color_space + self._display, + placeholder_view.name, + placeholder_view.color_space, ) # Views must be removed in reverse to preserve internal indices @@ -461,7 +489,11 @@ def _get_value(self, item: View, column_desc: ColumnDesc) -> Any: return None def _set_value( - self, item: View, column_desc: ColumnDesc, value: Any, index: QtCore.QModelIndex + self, + item: View, + column_desc: ColumnDesc, + value: Any, + index: QtCore.QModelIndex, ) -> None: item_names = self.get_item_names() if item.name not in item_names: @@ -479,22 +511,21 @@ def _set_value( color_space = config.getColorSpace(value) if color_space: if ( - item.view_transform - and ( - color_space.getReferenceSpaceType() - == ocio.REFERENCE_SPACE_DISPLAY - ) - ) or ( - not item.view_transform + item.type == ViewType.VIEW_SCENE and color_space.getReferenceSpaceType() == ocio.REFERENCE_SPACE_SCENE + ) or ( + item.type == ViewType.VIEW_DISPLAY + and color_space.getReferenceSpaceType() + == ocio.REFERENCE_SPACE_DISPLAY ): items[item_index].color_space = value elif column_desc == self.VIEW_TRANSFORM: color_space = config.getColorSpace(item.color_space) if color_space and ( - color_space.getReferenceSpaceType() == ocio.REFERENCE_SPACE_DISPLAY + color_space.getReferenceSpaceType() + == ocio.REFERENCE_SPACE_DISPLAY ): items[item_index].view_transform = value diff --git a/src/apps/ocioview/ocioview/items/view_transform_edit.py b/src/apps/ocioview/ocioview/items/view_transform_edit.py index 8d0ac15cd4..b200faf600 100644 --- a/src/apps/ocioview/ocioview/items/view_transform_edit.py +++ b/src/apps/ocioview/ocioview/items/view_transform_edit.py @@ -10,7 +10,12 @@ from ..config_cache import ConfigCache from ..constants import ICON_SIZE_ITEM from ..utils import get_glyph_icon -from ..widgets import EnumComboBox, CallbackComboBox, StringListWidget, TextEdit +from ..widgets import ( + EnumComboBox, + CallbackComboBox, + StringListWidget, + TextEdit, +) from .config_item_edit import BaseConfigItemParamEdit, BaseConfigItemEdit from .view_transform_model import ViewTransformModel @@ -41,21 +46,30 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): ), }, ) - self.family_edit = CallbackComboBox(ConfigCache.get_families, editable=True) + self.family_edit = CallbackComboBox( + ConfigCache.get_families, editable=True + ) self.description_edit = TextEdit() self.categories_list = StringListWidget( item_basename="category", - item_icon=get_glyph_icon("ph.bookmarks-simple", size=ICON_SIZE_ITEM), + item_icon=get_glyph_icon( + "ph.bookmarks-simple", size=ICON_SIZE_ITEM + ), get_presets=self._get_available_categories, ) # Layout self._param_layout.addRow( - self.model.REFERENCE_SPACE_TYPE.label, self.reference_space_type_combo + self.model.REFERENCE_SPACE_TYPE.label, + self.reference_space_type_combo, ) self._param_layout.addRow(self.model.FAMILY.label, self.family_edit) - self._param_layout.addRow(self.model.DESCRIPTION.label, self.description_edit) - self._param_layout.addRow(self.model.CATEGORIES.label, self.categories_list) + self._param_layout.addRow( + self.model.DESCRIPTION.label, self.description_edit + ) + self._param_layout.addRow( + self.model.CATEGORIES.label, self.categories_list + ) def _get_available_categories(self) -> list[str]: """ @@ -63,7 +77,11 @@ def _get_available_categories(self) -> list[str]: to this item. """ current_categories = self.categories_list.items() - return [c for c in ConfigCache.get_categories() if c not in current_categories] + return [ + c + for c in ConfigCache.get_categories() + if c not in current_categories + ] class ViewTransformEdit(BaseConfigItemEdit): @@ -79,24 +97,28 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): model = self.model # Map widgets to model columns - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.reference_space_type_combo, model.REFERENCE_SPACE_TYPE.column, ) - self._mapper.addMapping(self.param_edit.family_edit, model.FAMILY.column) - self._mapper.addMapping( + self.mapper.addMapping( + self.param_edit.family_edit, model.FAMILY.column + ) + self.mapper.addMapping( self.param_edit.description_edit, model.DESCRIPTION.column ) - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.categories_list, model.CATEGORIES.column ) # list widgets need manual data submission back to model - self.param_edit.categories_list.items_changed.connect(self._mapper.submit) + self.param_edit.categories_list.items_changed.connect( + self.mapper.submit + ) # Trigger immediate update from widgets that update the model upon losing focus self.param_edit.reference_space_type_combo.currentIndexChanged.connect( - partial(self.param_edit.submit_mapper_deferred, self._mapper) + partial(self.param_edit.submit_mapper_deferred, self.mapper) ) # Initialize diff --git a/src/apps/ocioview/ocioview/items/view_transform_model.py b/src/apps/ocioview/ocioview/items/view_transform_model.py index 10f8d5ea22..82593dc489 100644 --- a/src/apps/ocioview/ocioview/items/view_transform_model.py +++ b/src/apps/ocioview/ocioview/items/view_transform_model.py @@ -11,7 +11,10 @@ from ..constants import ICON_SIZE_ITEM from ..utils import get_enum_member, get_glyph_icon from .config_item_model import ColumnDesc, BaseConfigItemModel -from .utils import get_scene_to_display_transform, get_display_to_scene_transform +from .utils import ( + get_scene_to_display_transform, + get_display_to_scene_transform, +) class ViewTransformModel(BaseConfigItemModel): @@ -41,7 +44,9 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): super().__init__(parent=parent) self._ref_space_icons = { - ocio.REFERENCE_SPACE_SCENE: get_glyph_icon("ph.sun", size=ICON_SIZE_ITEM), + ocio.REFERENCE_SPACE_SCENE: get_glyph_icon( + "ph.sun", size=ICON_SIZE_ITEM + ), ocio.REFERENCE_SPACE_DISPLAY: get_glyph_icon( "ph.monitor", size=ICON_SIZE_ITEM ), @@ -88,7 +93,8 @@ def _get_bg_color( def _get_items(self, preserve: bool = False) -> list[ocio.ViewTransform]: if preserve: self._items = [ - copy.deepcopy(item) for item in ConfigCache.get_view_transforms() + copy.deepcopy(item) + for item in ConfigCache.get_view_transforms() ] return self._items else: @@ -123,7 +129,9 @@ def _new_item(self, name: str) -> None: ) ) - def _get_value(self, item: ocio.ViewTransform, column_desc: ColumnDesc) -> Any: + def _get_value( + self, item: ocio.ViewTransform, column_desc: ColumnDesc + ) -> Any: # Get parameters if column_desc == self.REFERENCE_SPACE_TYPE: return int(item.getReferenceSpaceType().value) @@ -167,7 +175,9 @@ def _set_value( name=item.getName(), family=item.getFamily(), description=item.getDescription(), - toReference=item.getTransform(ocio.VIEWTRANSFORM_DIR_TO_REFERENCE), + toReference=item.getTransform( + ocio.VIEWTRANSFORM_DIR_TO_REFERENCE + ), fromReference=item.getTransform( ocio.VIEWTRANSFORM_DIR_FROM_REFERENCE ), @@ -203,7 +213,8 @@ def _set_value( # type change, which requires removing the old item to add the new. if column_desc in (self.REFERENCE_SPACE_TYPE, self.NAME): items = [ - copy.deepcopy(other_item) for other_item in config.getViewTransforms() + copy.deepcopy(other_item) + for other_item in config.getViewTransforms() ] config.clearViewTransforms() for other_item in items: @@ -223,5 +234,6 @@ def _set_value( if column_desc in (self.NAME, self.TO_REFERENCE, self.FROM_REFERENCE): item_name = new_item.getName() self._update_tf_subscribers( - item_name, prev_item_name if prev_item_name != item_name else None + item_name, + prev_item_name if prev_item_name != item_name else None, ) diff --git a/src/apps/ocioview/ocioview/items/viewing_rule_edit.py b/src/apps/ocioview/ocioview/items/viewing_rule_edit.py index 31aed1bf79..fc72e28407 100644 --- a/src/apps/ocioview/ocioview/items/viewing_rule_edit.py +++ b/src/apps/ocioview/ocioview/items/viewing_rule_edit.py @@ -73,8 +73,12 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): params_layout_a = FormLayout() params_layout_a.setContentsMargins(0, 0, 0, 0) params_layout_a.addRow(self.model.NAME.label, self.name_edit_a) - params_layout_a.addRow(self.model.COLOR_SPACES.label, self.color_space_list) - params_layout_a.addRow(self.model.CUSTOM_KEYS.label, self.custom_keys_table_a) + params_layout_a.addRow( + self.model.COLOR_SPACES.label, self.color_space_list + ) + params_layout_a.addRow( + self.model.CUSTOM_KEYS.label, self.custom_keys_table_a + ) params_a = QtWidgets.QFrame() params_a.setLayout(params_layout_a) @@ -82,7 +86,9 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): params_layout_b.setContentsMargins(0, 0, 0, 0) params_layout_b.addRow(self.model.NAME.label, self.name_edit_b) params_layout_b.addRow(self.model.ENCODINGS.label, self.encoding_list) - params_layout_b.addRow(self.model.CUSTOM_KEYS.label, self.custom_keys_table_b) + params_layout_b.addRow( + self.model.CUSTOM_KEYS.label, self.custom_keys_table_b + ) params_b = QtWidgets.QFrame() params_b.setLayout(params_layout_b) @@ -101,19 +107,25 @@ def reset(self) -> None: self._param_stack.setCurrentIndex(0) def update_available_params( - self, mapper: QtWidgets.QDataWidgetMapper, viewing_rule_type: ViewingRuleType + self, + mapper: QtWidgets.QDataWidgetMapper, + viewing_rule_type: ViewingRuleType, ) -> None: """ Map and show the interface needed to edit this rule's type. """ if viewing_rule_type == ViewingRuleType.RULE_COLOR_SPACE: mapper.addMapping(self.name_edit_a, self.model.NAME.column) - mapper.addMapping(self.custom_keys_table_a, self.model.CUSTOM_KEYS.column) + mapper.addMapping( + self.custom_keys_table_a, self.model.CUSTOM_KEYS.column + ) self._param_stack.setCurrentIndex(1) else: # ViewingRuleType.RULE_ENCODING mapper.addMapping(self.name_edit_b, self.model.NAME.column) - mapper.addMapping(self.custom_keys_table_b, self.model.CUSTOM_KEYS.column) + mapper.addMapping( + self.custom_keys_table_b, self.model.CUSTOM_KEYS.column + ) self._param_stack.setCurrentIndex(2) def _on_item_removed(self) -> None: @@ -135,16 +147,24 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): model = self.model # Map widgets to model columns - self._mapper.addMapping( + self.mapper.addMapping( self.param_edit.color_space_list, model.COLOR_SPACES.column ) - self._mapper.addMapping(self.param_edit.encoding_list, model.ENCODINGS.column) + self.mapper.addMapping( + self.param_edit.encoding_list, model.ENCODINGS.column + ) # list and table widgets need manual data submission back to model - self.param_edit.color_space_list.items_changed.connect(self._mapper.submit) - self.param_edit.encoding_list.items_changed.connect(self._mapper.submit) - self.param_edit.custom_keys_table_a.items_changed.connect(self._mapper.submit) - self.param_edit.custom_keys_table_b.items_changed.connect(self._mapper.submit) + self.param_edit.color_space_list.items_changed.connect( + self.mapper.submit + ) + self.param_edit.encoding_list.items_changed.connect(self.mapper.submit) + self.param_edit.custom_keys_table_a.items_changed.connect( + self.mapper.submit + ) + self.param_edit.custom_keys_table_b.items_changed.connect( + self.mapper.submit + ) # Initialize if model.rowCount(): @@ -159,6 +179,8 @@ def _on_current_row_changed(self, row: int) -> None: self.model.index(row, self.model.VIEWING_RULE_TYPE.column), QtCore.Qt.EditRole, ) - self.param_edit.update_available_params(self._mapper, viewing_rule_type) + self.param_edit.update_available_params( + self.mapper, viewing_rule_type + ) super()._on_current_row_changed(row) diff --git a/src/apps/ocioview/ocioview/main_window.py b/src/apps/ocioview/ocioview/main_window.py index c14a0100d9..c68d8d6bb6 100644 --- a/src/apps/ocioview/ocioview/main_window.py +++ b/src/apps/ocioview/ocioview/main_window.py @@ -14,10 +14,14 @@ from .constants import ICON_PATH_OCIO from .inspect_dock import InspectDock from .message_router import MessageRouter +from .mode import OCIOViewMode from .ref_space_manager import ReferenceSpaceManager +from .signal_router import SignalRouter from .settings import settings from .undo import undo_stack +from .utils import get_glyph_icon, SignalsBlocked from .viewer_dock import ViewerDock +from .widgets import EnumComboBox logger = logging.getLogger(__name__) @@ -42,16 +46,20 @@ class OCIOView(QtWidgets.QMainWindow): def __init__( self, config_path: Optional[Path] = None, + transient: bool = False, parent: Optional[QtCore.QObject] = None, ): """ :param config_path: Optional OCIO config path to load. Defaults to the builtin raw config. + :param transient: Set to True to prevent any save operations, + making all config edits temporary. """ super().__init__(parent=parent) self._config_path = None self._config_save_cache_id = None + self._transient = transient # Configure window self.setWindowIcon(QtGui.QIcon(str(ICON_PATH_OCIO))) @@ -60,9 +68,31 @@ def __init__( self.recent_configs_menu = QtWidgets.QMenu("Load Recent Config") self.recent_images_menu = QtWidgets.QMenu("Load Recent Image") + # Mode switcher + self.mode_box = EnumComboBox( + OCIOViewMode, + icons={ + m: get_glyph_icon(m.value) + for m in OCIOViewMode.__members__.values() + }, + ) + self.mode_box.setToolTip("Application Mode") + self.mode_box.setMinimumContentsLength( + max(map(len, OCIOViewMode.__members__.keys())) + ) + self.mode_box.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) + self.mode_box.setSizePolicy( + QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed + ) + ) + self.mode_box.currentIndexChanged[int].connect( + self._on_mode_box_index_changed + ) + # Dock widgets self.inspect_dock = InspectDock() - self.config_dock = ConfigDock() + self.config_dock = ConfigDock(corner_widget=self.mode_box) # Central widget self.viewer_dock = ViewerDock(self.recent_images_menu) @@ -72,21 +102,30 @@ def __init__( self.file_menu.addAction("New Config", self.new_config) self.file_menu.addAction("Load Config...", self.load_config) self.file_menu.addMenu(self.recent_configs_menu) - self.file_menu.addAction( - "Save config", self.save_config, QtGui.QKeySequence("Ctrl+S") - ) - self.file_menu.addAction( - "Save Config As...", self.save_config_as, QtGui.QKeySequence("Ctrl+Shift+S") - ) - self.file_menu.addAction( - "Save and Backup Config", - self.save_and_backup_config, - QtGui.QKeySequence("Ctrl+Alt+S"), - ) - self.file_menu.addAction("Restore Config Backup...", self.restore_config_backup) + + if not self._transient: + self.file_menu.addAction( + "Save config", self.save_config, QtGui.QKeySequence("Ctrl+S") + ) + self.file_menu.addAction( + "Save Config As...", + self.save_config_as, + QtGui.QKeySequence("Ctrl+Shift+S"), + ) + self.file_menu.addAction( + "Save and Backup Config", + self.save_and_backup_config, + QtGui.QKeySequence("Ctrl+Alt+S"), + ) + self.file_menu.addAction( + "Restore Config Backup...", self.restore_config_backup + ) + self.file_menu.addSeparator() self.file_menu.addAction( - "Load Image...", self.viewer_dock.load_image, QtGui.QKeySequence("Ctrl+I") + "Load Image...", + lambda: self.viewer_dock.load_image(), + QtGui.QKeySequence("Ctrl+I"), ) self.file_menu.addMenu(self.recent_images_menu) self.file_menu.addAction( @@ -95,7 +134,9 @@ def __init__( QtGui.QKeySequence("Ctrl+Shift+I"), ) self.file_menu.addSeparator() - self.file_menu.addAction("Exit", self.close, QtGui.QKeySequence("Ctrl+X")) + self.file_menu.addAction( + "Exit", self.close, QtGui.QKeySequence("Ctrl+X") + ) self.edit_menu = QtWidgets.QMenu("Edit") undo_action = undo_stack.createUndoAction(self.edit_menu) @@ -116,9 +157,15 @@ def __init__( QtWidgets.QMainWindow.ForceTabbedDocks | QtWidgets.QMainWindow.GroupedDragging ) - self.setTabPosition(QtCore.Qt.BottomDockWidgetArea, QtWidgets.QTabWidget.North) - self.setTabPosition(QtCore.Qt.LeftDockWidgetArea, QtWidgets.QTabWidget.North) - self.setTabPosition(QtCore.Qt.RightDockWidgetArea, QtWidgets.QTabWidget.North) + self.setTabPosition( + QtCore.Qt.BottomDockWidgetArea, QtWidgets.QTabWidget.North + ) + self.setTabPosition( + QtCore.Qt.LeftDockWidgetArea, QtWidgets.QTabWidget.North + ) + self.setTabPosition( + QtCore.Qt.RightDockWidgetArea, QtWidgets.QTabWidget.North + ) for corner in (QtCore.Qt.TopLeftCorner, QtCore.Qt.BottomLeftCorner): self.setCorner(corner, QtCore.Qt.LeftDockWidgetArea) @@ -131,8 +178,11 @@ def __init__( self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.config_dock) # Connections - self.config_dock.config_changed.connect(self.viewer_dock.update_current_viewer) - self.config_dock.config_changed.connect(self._update_window_title) + signal_router = SignalRouter.get_instance() + signal_router.config_changed.connect( + lambda: self.viewer_dock.update_current_viewer() + ) + signal_router.config_reloaded.connect(self._update_window_title) # Restore settings settings.beginGroup(self.__class__.__name__) @@ -141,16 +191,21 @@ def __init__( if settings.contains(self.SETTING_STATE): # If the version is not recognized, the restore will be bypassed self.restoreState( - settings.value(self.SETTING_STATE), version=self.SETTING_STATE_VERSION + settings.value(self.SETTING_STATE), + version=self.SETTING_STATE_VERSION, ) settings.endGroup() # Initialize + SignalRouter.get_instance().mode_changed.connect( + self._on_mode_changed_external + ) + if config_path is not None: self.load_config(config_path) else: # New config - self._init_config_tracking() + self.new_config() self._update_recent_configs_menu() self._update_window_title() @@ -165,18 +220,20 @@ def reset(self) -> None: self._init_config_tracking() self.config_dock.reset() - self.viewer_dock.reset() self.inspect_dock.reset() + self.viewer_dock.reset() def closeEvent(self, event: QtGui.QCloseEvent) -> None: if self._can_close_config(): # Save settings - settings.beginGroup(self.__class__.__name__) - settings.setValue(self.SETTING_GEOMETRY, self.saveGeometry()) - settings.setValue( - self.SETTING_STATE, self.saveState(self.SETTING_STATE_VERSION) - ) - settings.endGroup() + if not self._transient: + settings.beginGroup(self.__class__.__name__) + settings.setValue(self.SETTING_GEOMETRY, self.saveGeometry()) + settings.setValue( + self.SETTING_STATE, + self.saveState(self.SETTING_STATE_VERSION), + ) + settings.endGroup() event.accept() super().closeEvent(event) @@ -187,8 +244,12 @@ def new_config(self) -> None: """ Create and load a new OCIO raw config. """ - if not self._can_close_config(): - return + if ( + self._config_path is not None + or self._config_save_cache_id is not None + ): + if not self._can_close_config(): + return self._config_path = None self._config_save_cache_id = None @@ -197,6 +258,8 @@ def new_config(self) -> None: ocio.SetCurrentConfig(config) self.reset() + SignalRouter.get_instance().emit_config_reloaded() + def load_config(self, config_path: Optional[Path] = None) -> None: """ Load a user specified OCIO config. @@ -208,19 +271,29 @@ def load_config(self, config_path: Optional[Path] = None) -> None: if config_path is None or not config_path.is_file(): config_dir = self._get_config_dir(config_path) - config_path_str, file_filter = QtWidgets.QFileDialog.getOpenFileName( - self, "Load Config", dir=config_dir, filter="OCIO Config (*.ocio)" + ( + config_path_str, + file_filter, + ) = QtWidgets.QFileDialog.getOpenFileName( + self, + "Load Config", + dir=config_dir, + filter="OCIO Config (*.ocio)", ) if not config_path_str: return config_path = Path(config_path_str) - settings.setValue(self.SETTING_CONFIG_DIR, config_path.parent.as_posix()) + if not self._transient: + settings.setValue( + self.SETTING_CONFIG_DIR, config_path.parent.as_posix() + ) self._config_path = config_path # Add path to recent config files - self._add_recent_config_path(self._config_path) + if not self._transient: + self._add_recent_config_path(self._config_path) # Reset application with empty config to clean all components config = ocio.Config() @@ -232,6 +305,8 @@ def load_config(self, config_path: Optional[Path] = None) -> None: ocio.SetCurrentConfig(config) self.reset() + SignalRouter.get_instance().emit_config_reloaded() + def save_config(self) -> bool: """ Save the current OCIO config to the previously loaded config @@ -240,7 +315,9 @@ def save_config(self) -> bool: :return: Whether config was saved """ - if self._config_path is None: + if self._transient: + return False + elif self._config_path is None: return self.save_config_as() else: try: @@ -268,10 +345,16 @@ def save_config_as(self, config_path: Optional[Path] = None) -> bool: :param config_path: Config file path :return: Whether config was saved """ + if self._transient: + return False + try: if config_path is None or not config_path.is_file(): config_dir = self._get_config_dir(config_path) - config_path_str, file_filter = QtWidgets.QFileDialog.getSaveFileName( + ( + config_path_str, + file_filter, + ) = QtWidgets.QFileDialog.getSaveFileName( self, "Save Config", dir=config_dir, @@ -310,7 +393,10 @@ def save_and_backup_config(self) -> bool: """ if self.save_config(): try: - if self._config_path is not None and self._config_path.is_file(): + if ( + self._config_path is not None + and self._config_path.is_file() + ): next_version_path = self._get_next_version_path() shutil.copy2(self._config_path, next_version_path) return True @@ -335,7 +421,10 @@ def restore_config_backup(self) -> None: backup_dir = self._get_backup_dir() if backup_dir is not None: - version_path_str, file_filter = QtWidgets.QFileDialog.getOpenFileName( + ( + version_path_str, + file_filter, + ) = QtWidgets.QFileDialog.getOpenFileName( self, "Restore Config", dir=backup_dir.as_posix(), @@ -368,7 +457,9 @@ def _get_next_version_path(self) -> Optional[Path]: return None max_version = 0 - for other_version_path in backup_dir.glob(self._format_version_filename()): + for other_version_path in backup_dir.glob( + self._format_version_filename() + ): if other_version_path.is_file() and other_version_path.suffixes: other_version_str = other_version_path.suffixes[0].strip(".") if other_version_str.isdigit(): @@ -434,7 +525,9 @@ def _get_recent_config_paths(self) -> list[Path]: num_configs = settings.beginReadArray(self.SETTING_RECENT_CONFIGS) for i in range(num_configs): settings.setArrayIndex(i) - recent_config_path_str = settings.value(self.SETTING_RECENT_CONFIG_PATH) + recent_config_path_str = settings.value( + self.SETTING_RECENT_CONFIG_PATH + ) if recent_config_path_str: recent_config_path = Path(recent_config_path_str) if recent_config_path.is_file(): @@ -480,7 +573,9 @@ def _update_recent_configs_menu(self) -> None: def _update_window_title(self) -> None: filename = ( - "untitiled" if self._config_path is None else self._config_path.name + "untitiled" + if self._config_path is None + else self._config_path.name ) + ("*" if self._has_unsaved_changes() else "") self.setWindowTitle(f"ocioview {ocio.__version__} | {filename}") @@ -500,6 +595,9 @@ def _has_unsaved_changes(self) -> bool: :return: Whether the current config has unsaved changes, when compared to the previously saved config state. """ + if self._transient: + return False + config_cache_id, is_valid = ConfigCache.get_cache_id() return not is_valid or config_cache_id != self._config_save_cache_id @@ -540,3 +638,18 @@ def _init_config_tracking(self) -> None: """Setup app-dependent config objects and change tracking.""" ReferenceSpaceManager.init_reference_spaces() self._update_cache_id() + + @QtCore.Slot(int) + def _on_mode_box_index_changed(self, index: int) -> None: + """Called when the application mode has been manually changed.""" + with SignalsBlocked(self.mode_box): + OCIOViewMode.set_current_mode(self.mode_box.member()) + + def _on_mode_changed_external(self) -> None: + """ + Called when the application mode has been changed externally. + """ + with SignalsBlocked(self.mode_box): + mode = OCIOViewMode.current_mode() + if mode != self.mode_box.member(): + self.mode_box.set_member(mode) diff --git a/src/apps/ocioview/ocioview/message_router.py b/src/apps/ocioview/ocioview/message_router.py index a18da91260..402d940a9f 100644 --- a/src/apps/ocioview/ocioview/message_router.py +++ b/src/apps/ocioview/ocioview/message_router.py @@ -14,7 +14,11 @@ from PySide6 import QtCore, QtGui, QtWidgets from .processor_context import ProcessorContext -from .utils import config_to_html, processor_to_ctf_html, processor_to_shader_html +from .utils import ( + config_to_html, + processor_to_ctf_html, + processor_to_shader_html, +) # Global message queue @@ -191,7 +195,7 @@ def start_routing(self) -> None: # Python or OCIO log record else: - self._handle_log_message(str(msg_raw)) + self._handle_log_message(msg_raw) self._is_routing = False @@ -206,7 +210,9 @@ def _handle_config_message(self, config: ocio.Config) -> None: self.config_html_ready.emit(config_html_data) except Exception as e: # Pass error to log - self._handle_log_message(str(e), force_level=self.LOG_LEVEL_WARNING) + self._handle_log_message( + str(e), force_level=self.LOG_LEVEL_WARNING + ) def _handle_processor_message( self, @@ -221,7 +227,9 @@ def _handle_processor_message( """ try: if self._processor_updates_allowed: - self.processor_ready.emit(proc_context, proc.getDefaultCPUProcessor()) + self.processor_ready.emit( + proc_context, proc.getDefaultCPUProcessor() + ) if self._ctf_updates_allowed: ctf_html_data, group_tf = processor_to_ctf_html(proc) @@ -236,7 +244,9 @@ def _handle_processor_message( except Exception as e: # Pass error to log - self._handle_log_message(str(e), force_level=self.LOG_LEVEL_WARNING) + self._handle_log_message( + str(e), force_level=self.LOG_LEVEL_WARNING + ) def _handle_image_message(self, image_array: np.ndarray) -> None: """ @@ -248,7 +258,9 @@ def _handle_image_message(self, image_array: np.ndarray) -> None: self.image_ready.emit(image_array) except Exception as e: # Pass error to log - self._handle_log_message(str(e), force_level=self.LOG_LEVEL_WARNING) + self._handle_log_message( + str(e), force_level=self.LOG_LEVEL_WARNING + ) def _handle_log_message( self, log_record: str, force_level: Optional[str] = None @@ -330,7 +342,14 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self._thread = QtCore.QThread() self._runner = MessageRunner() self._runner.moveToThread(self._thread) - self._thread.started.connect(self._runner.start_routing) + + # Delay router start to ease application startup + self._timer = QtCore.QTimer() + self._timer.setSingleShot(True) + self._timer.setInterval(int(MessageRunner.LOOP_INTERVAL * 1000)) + self._timer.timeout.connect(self._runner.start_routing) + + self._thread.started.connect(self._timer.start) # Make sure thread stops and routing is cleaned up on app close app = QtWidgets.QApplication.instance() diff --git a/src/apps/ocioview/ocioview/mode.py b/src/apps/ocioview/ocioview/mode.py new file mode 100644 index 0000000000..4bf08bb11f --- /dev/null +++ b/src/apps/ocioview/ocioview/mode.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenColorIO Project. + +from __future__ import annotations + +import enum + + +class OCIOViewMode(enum.Enum): + """ + ocioview application mode enum. + + This class also manages the current application mode, which + components will refer to for mode-specific layout and behavior. + """ + + Edit = "mdi6.file-document-edit-outline" + """Mode for editing and inspecting an OCIO config.""" + + Preview = "mdi6.television-play" + """ + Mode for previewing an OCIO config's user experience in a reference + integration. + """ + + __current = None + """Current application mode.""" + + @classmethod + def current_mode(cls) -> OCIOViewMode: + """Get the current application mode.""" + return cls.__current + + @classmethod + def set_current_mode(cls, mode: OCIOViewMode) -> None: + """Set the current application mode.""" + if mode != cls.__current: + from .signal_router import SignalRouter + + cls.__current = mode + + signal_router = SignalRouter.get_instance() + signal_router.emit_mode_changed() + + +OCIOViewMode.set_current_mode(OCIOViewMode.Edit) diff --git a/src/apps/ocioview/ocioview/ref_space_manager.py b/src/apps/ocioview/ocioview/ref_space_manager.py index 3de7ab84dc..022dc927df 100644 --- a/src/apps/ocioview/ocioview/ref_space_manager.py +++ b/src/apps/ocioview/ocioview/ref_space_manager.py @@ -78,7 +78,9 @@ def _update_scene_reference_space(cls) -> None: or scene_ref_color_space.getTransform( ocio.COLORSPACE_DIR_FROM_REFERENCE ) - or scene_ref_color_space.getTransform(ocio.COLORSPACE_DIR_TO_REFERENCE) + or scene_ref_color_space.getTransform( + ocio.COLORSPACE_DIR_TO_REFERENCE + ) ): cls._ref_scene_name = None @@ -89,8 +91,12 @@ def _update_scene_reference_space(cls) -> None: ): if ( not color_space.isData() - and not color_space.getTransform(ocio.COLORSPACE_DIR_FROM_REFERENCE) - and not color_space.getTransform(ocio.COLORSPACE_DIR_TO_REFERENCE) + and not color_space.getTransform( + ocio.COLORSPACE_DIR_FROM_REFERENCE + ) + and not color_space.getTransform( + ocio.COLORSPACE_DIR_TO_REFERENCE + ) ): cls._ref_scene_name = color_space.getName() break @@ -121,7 +127,9 @@ def _update_display_reference_space(cls) -> None: # Verify existing display reference space if cls._ref_display_name: - display_ref_color_space = config.getColorSpace(cls._ref_display_name) + display_ref_color_space = config.getColorSpace( + cls._ref_display_name + ) if ( not display_ref_color_space or display_ref_color_space.getReferenceSpaceType() @@ -143,8 +151,12 @@ def _update_display_reference_space(cls) -> None: ): if ( not color_space.isData() - and not color_space.getTransform(ocio.COLORSPACE_DIR_FROM_REFERENCE) - and not color_space.getTransform(ocio.COLORSPACE_DIR_TO_REFERENCE) + and not color_space.getTransform( + ocio.COLORSPACE_DIR_FROM_REFERENCE + ) + and not color_space.getTransform( + ocio.COLORSPACE_DIR_TO_REFERENCE + ) ): cls._ref_display_name = color_space.getName() break diff --git a/src/apps/ocioview/ocioview/setup.py b/src/apps/ocioview/ocioview/setup.py new file mode 100644 index 0000000000..33699bad6f --- /dev/null +++ b/src/apps/ocioview/ocioview/setup.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenColorIO Project. + +import logging +import os +import sys +from typing import Optional + +import PyOpenColorIO as ocio +from PySide6 import QtCore, QtGui, QtWidgets + +from . import log_handlers # Import to initialize logging +from .style import QSS, DarkPalette + + +def excepthook(exc_type, exc_value, exc_tb): + """Log uncaught errors (especially those raised in Qt slots).""" + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_tb) + return + logging.error(f"{exc_value}", exc_info=exc_value) + + +def setup_excepthook() -> None: + """Install exception hook.""" + sys.excepthook = excepthook + + +def setup_opengl() -> None: + """ + OpenGL core profile is needed on macOS to access programmatic + pipeline. + """ + gl_format = QtGui.QSurfaceFormat() + gl_format.setProfile(QtGui.QSurfaceFormat.CoreProfile) + gl_format.setSwapInterval(1) + gl_format.setVersion(4, 0) + QtGui.QSurfaceFormat.setDefaultFormat(gl_format) + + +def setup_env() -> None: + """Clean OCIO environment to isolate working config.""" + for env_var in ( + ocio.OCIO_CONFIG_ENVVAR, + ocio.OCIO_ACTIVE_VIEWS_ENVVAR, + ocio.OCIO_ACTIVE_DISPLAYS_ENVVAR, + ocio.OCIO_INACTIVE_COLORSPACES_ENVVAR, + ocio.OCIO_OPTIMIZATION_FLAGS_ENVVAR, + ocio.OCIO_USER_CATEGORIES_ENVVAR, + ): + if env_var in os.environ: + del os.environ[env_var] + + +def setup_style(app: QtWidgets.QApplication) -> None: + """Initialize app style.""" + app.setStyle("fusion") + app.setPalette(DarkPalette()) + app.setStyleSheet(QSS) + app.setEffectEnabled(QtCore.Qt.UI_AnimateCombo, False) + + font = app.font() + font.setPointSize(8) + app.setFont(font) + + +def setup_app( + app: Optional[QtWidgets.QApplication] = None, +) -> QtWidgets.QApplication: + """Create and configure QApplication.""" + # Setup environment + setup_excepthook() + setup_opengl() + setup_env() + + # Create and/or setup app + if app is None: + app = QtWidgets.QApplication(sys.argv) + setup_style(app) + + return app diff --git a/src/apps/ocioview/ocioview/signal_router.py b/src/apps/ocioview/ocioview/signal_router.py new file mode 100644 index 0000000000..7ed7834a17 --- /dev/null +++ b/src/apps/ocioview/ocioview/signal_router.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenColorIO Project. + +from __future__ import annotations + +from typing import Optional + +from PySide6 import QtCore + + +class SignalRouter(QtCore.QObject): + """ + Singleton router which routes application-wide signals to + listeners. + """ + + mode_changed = QtCore.Signal() + """Emitted when the current application mode is changed.""" + + config_changed = QtCore.Signal() + """Emitted when the current config is modified.""" + + config_reloaded = QtCore.Signal() + """Emitted when the current config is reloaded or replaced.""" + + color_spaces_changed = QtCore.Signal() + """Emitted when a color space is added, removed, or changed.""" + + roles_changed = QtCore.Signal() + """Emitted when a color space role is added, removed, or changed.""" + + __instance: SignalRouter = None + + @classmethod + def get_instance(cls) -> SignalRouter: + """Get singleton SignalRouter instance.""" + if cls.__instance is None: + cls.__instance = SignalRouter() + return cls.__instance + + def __init__(self, parent: Optional[QtCore.QObject] = None): + super().__init__(parent=parent) + + # Only allow __init__ to be called once + if self.__instance is not None: + raise RuntimeError( + f"{self.__class__.__name__} is a singleton. Please call " + f"'get_instance' to access this type." + ) + else: + self.__instance = self + + def emit_mode_changed(self) -> None: + """ + Notify listeners that the current application mode has changed. + """ + self.mode_changed.emit() + + def emit_config_changed(self) -> None: + """ + Notify listeners that the current OCIO config has been modified. + """ + self.config_changed.emit() + + def emit_config_reloaded(self) -> None: + """ + Notify listeners that the current OCIO config has been reloaded + or replaced and changed. + """ + self.config_reloaded.emit() + self.emit_config_changed() + + def emit_color_spaces_changed(self) -> None: + """ + Notify listeners when a color space is added, removed, or + changed. + """ + self.color_spaces_changed.emit() + + def emit_roles_changed(self) -> None: + """ + Notify listeners when a color space role is added, removed, or + changed. + """ + self.roles_changed.emit() diff --git a/src/apps/ocioview/ocioview/transform_manager.py b/src/apps/ocioview/ocioview/transform_manager.py index c1990d2014..45950b048c 100644 --- a/src/apps/ocioview/ocioview/transform_manager.py +++ b/src/apps/ocioview/ocioview/transform_manager.py @@ -98,8 +98,12 @@ def set_subscription( # Connect new subscription tf_subscription = TransformSubscription(item_model, item_label) tf_agent = item_model.get_transform_agent(slot) - tf_agent.item_name_changed.connect(partial(cls._on_item_name_changed, slot)) - tf_agent.item_tf_changed.connect(partial(cls._on_item_tf_changed, slot)) + tf_agent.item_name_changed.connect( + partial(cls._on_item_name_changed, slot) + ) + tf_agent.item_tf_changed.connect( + partial(cls._on_item_tf_changed, slot) + ) cls._tf_subscriptions[slot] = tf_subscription # Inform menu subscribers of the menu change @@ -110,7 +114,9 @@ def set_subscription( init_callback(slot) # Trigger immediate update to subscribers of this slot - cls._on_item_tf_changed(slot, *item_model.get_item_transforms(item_label)) + cls._on_item_tf_changed( + slot, *item_model.get_item_transforms(item_label) + ) # Repaint views for previous and new model if prev_item_model is not None: @@ -182,7 +188,9 @@ def get_subscription_slot_icon(cls, slot: int) -> Union[QtGui.QIcon, None]: }[slot] color = cls.get_subscription_slot_color(slot) return get_glyph_icon( - f"ph.number-circle-{slot_word}", color=color, size=ICON_SIZE_ITEM + f"ph.number-circle-{slot_word}", + color=color, + size=ICON_SIZE_ITEM, ) else: return None @@ -223,7 +231,9 @@ def subscribe_to_transform_menu(cls, menu_callback: Callable) -> None: menu_callback(cls.get_subscription_menu_items()) @classmethod - def subscribe_to_transform_subscription_init(cls, init_callback: Callable) -> None: + def subscribe_to_transform_subscription_init( + cls, init_callback: Callable + ) -> None: """ Subscribe to transform subscription initialization on all slots. @@ -241,7 +251,9 @@ def subscribe_to_transform_subscription_init(cls, init_callback: Callable) -> No break @classmethod - def subscribe_to_transforms_at(cls, slot: int, tf_callback: Callable) -> None: + def subscribe_to_transforms_at( + cls, slot: int, tf_callback: Callable + ) -> None: """ Subscribe to transform updates at the given slot number. @@ -306,7 +318,8 @@ def _on_item_name_changed(cls, slot: int, item_label: str) -> None: if tf_subscription is not None: tf_subscription.item_label = item_label cls._on_item_tf_changed( - slot, *tf_subscription.item_model.get_item_transforms(item_label) + slot, + *tf_subscription.item_model.get_item_transforms(item_label), ) cls._update_menu_items() diff --git a/src/apps/ocioview/ocioview/transforms/cdl_edit.py b/src/apps/ocioview/ocioview/transforms/cdl_edit.py index d411d16aea..5f404f187b 100644 --- a/src/apps/ocioview/ocioview/transforms/cdl_edit.py +++ b/src/apps/ocioview/ocioview/transforms/cdl_edit.py @@ -14,6 +14,7 @@ class CDLTransformEdit(BaseTransformEdit): __icon_glyph__ = "ph.circles-three" + __tf_type_label__ = "CDL" def __init__(self, parent: Optional[QtCore.QObject] = None): super().__init__(parent=parent) diff --git a/src/apps/ocioview/ocioview/transforms/color_space_edit.py b/src/apps/ocioview/ocioview/transforms/color_space_edit.py index db83ec88b5..e432f85865 100644 --- a/src/apps/ocioview/ocioview/transforms/color_space_edit.py +++ b/src/apps/ocioview/ocioview/transforms/color_space_edit.py @@ -6,8 +6,7 @@ import PyOpenColorIO as ocio from PySide6 import QtCore -from ..config_cache import ConfigCache -from ..widgets import CheckBox, CallbackComboBox +from ..widgets import CheckBox, ColorSpaceComboBox from .transform_edit import BaseTransformEdit from .transform_edit_factory import TransformEditFactory @@ -19,11 +18,11 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): super().__init__(parent=parent) # Widget - self.src_combo = CallbackComboBox(ConfigCache.get_color_space_names) - self.src_combo.currentIndexChanged.connect(self._on_edit) + self.src_combo = ColorSpaceComboBox(include_roles=True) + self.src_combo.color_space_changed.connect(self._on_edit) - self.dst_combo = CallbackComboBox(ConfigCache.get_color_space_names) - self.dst_combo.currentIndexChanged.connect(self._on_edit) + self.dst_combo = ColorSpaceComboBox(include_roles=True) + self.dst_combo.color_space_changed.connect(self._on_edit) self.data_bypass_check = CheckBox("Data Bypass") self.data_bypass_check.stateChanged.connect(self._on_edit) @@ -38,23 +37,25 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): def transform(self) -> ocio.ColorSpaceTransform: transform = super().transform() - transform.setSrc(self.src_combo.currentText()) - transform.setDst(self.dst_combo.currentText()) + transform.setSrc(self.src_combo.color_space_name()) + transform.setDst(self.dst_combo.color_space_name()) transform.setDataBypass(self.data_bypass_check.isChecked()) return transform def update_from_transform(self, transform: ocio.Transform) -> None: super().update_from_transform(transform) - self.src_combo.setCurrentText(transform.getSrc()) - self.dst_combo.setCurrentText(transform.getDst()) + self.src_combo.set_color_space(transform.getSrc()) + self.dst_combo.set_color_space(transform.getDst()) self.data_bypass_check.setChecked(transform.getDataBypass()) def update_from_config(self): """ Update available color spaces from current config. """ - self.src_combo.update() - self.dst_combo.update() + self.src_combo.update_color_spaces() + self.dst_combo.update_color_spaces() -TransformEditFactory.register(ocio.ColorSpaceTransform, ColorSpaceTransformEdit) +TransformEditFactory.register( + ocio.ColorSpaceTransform, ColorSpaceTransformEdit +) diff --git a/src/apps/ocioview/ocioview/transforms/display_view_edit.py b/src/apps/ocioview/ocioview/transforms/display_view_edit.py index e5cb3bfaec..1894ffed2b 100644 --- a/src/apps/ocioview/ocioview/transforms/display_view_edit.py +++ b/src/apps/ocioview/ocioview/transforms/display_view_edit.py @@ -8,7 +8,7 @@ from ..config_cache import ConfigCache from ..utils import SignalsBlocked -from ..widgets import CheckBox, ComboBox, CallbackComboBox +from ..widgets import CheckBox, ComboBox, CallbackComboBox, ColorSpaceComboBox from .transform_edit import BaseTransformEdit from .transform_edit_factory import TransformEditFactory @@ -20,14 +20,18 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): super().__init__(parent=parent) # Widget - self.src_combo = CallbackComboBox(ConfigCache.get_color_space_names) - self.src_combo.currentIndexChanged.connect(self._on_edit) + self.src_combo = ColorSpaceComboBox( + ocio.SEARCH_REFERENCE_SPACE_SCENE, include_roles=True + ) + self.src_combo.color_space_changed.connect(self._on_edit) self.display_combo = CallbackComboBox( ConfigCache.get_displays, get_default_item=lambda: ocio.GetCurrentConfig().getDefaultDisplay(), ) - self.display_combo.currentIndexChanged.connect(self._on_display_changed) + self.display_combo.currentIndexChanged.connect( + self._on_display_changed + ) self.display_combo.currentIndexChanged.connect(self._on_edit) self.view_combo = ComboBox() @@ -55,7 +59,7 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): def transform(self) -> ocio.ColorSpaceTransform: transform = super().transform() - transform.setSrc(self.src_combo.currentText()) + transform.setSrc(self.src_combo.color_space_name()) transform.setDisplay(self.display_combo.currentText()) transform.setView(self.view_combo.currentText()) transform.setLooksBypass(self.looks_bypass_check.isChecked()) @@ -64,7 +68,7 @@ def transform(self) -> ocio.ColorSpaceTransform: def update_from_transform(self, transform: ocio.Transform) -> None: super().update_from_transform(transform) - self.src_combo.setCurrentText(transform.getSrc()) + self.src_combo.set_color_space(transform.getSrc()) self.display_combo.setCurrentText(transform.getDisplay()) self.view_combo.setCurrentText(transform.getView()) self.looks_bypass_check.setChecked(transform.getLooksBypass()) @@ -75,8 +79,8 @@ def update_from_config(self): Update available color spaces and displays from the current config. """ - self.src_combo.update() - self.display_combo.update() + self.src_combo.update_color_spaces() + self.display_combo.update_items() self._on_display_changed(self.display_combo.currentIndex()) @QtCore.Slot(int) @@ -88,11 +92,13 @@ def _on_display_changed(self, index: int): config = ocio.GetCurrentConfig() display = self.display_combo.itemText(index) view = self.view_combo.currentText() - color_space_name = self.src_combo.currentText() + color_space_name = self.src_combo.color_space_name() with SignalsBlocked(self.view_combo): self.view_combo.clear() - self.view_combo.addItems(ConfigCache.get_views(display, color_space_name)) + self.view_combo.addItems( + ConfigCache.get_views(display, color_space_name) + ) view_index = self.view_combo.findText(view) if view_index != -1: @@ -103,4 +109,6 @@ def _on_display_changed(self, index: int): ) -TransformEditFactory.register(ocio.DisplayViewTransform, DisplayViewTransformEdit) +TransformEditFactory.register( + ocio.DisplayViewTransform, DisplayViewTransformEdit +) diff --git a/src/apps/ocioview/ocioview/transforms/look_edit.py b/src/apps/ocioview/ocioview/transforms/look_edit.py index e6b0005a1e..086a203083 100644 --- a/src/apps/ocioview/ocioview/transforms/look_edit.py +++ b/src/apps/ocioview/ocioview/transforms/look_edit.py @@ -6,8 +6,7 @@ import PyOpenColorIO as ocio from PySide6 import QtCore -from ..config_cache import ConfigCache -from ..widgets import CheckBox, CallbackComboBox, LineEdit +from ..widgets import CheckBox, ColorSpaceComboBox, LineEdit from .transform_edit import BaseTransformEdit from .transform_edit_factory import TransformEditFactory @@ -19,14 +18,18 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): super().__init__(parent=parent) # Widgets - self.src_combo = CallbackComboBox(ConfigCache.get_color_space_names) - self.src_combo.currentIndexChanged.connect(self._on_edit) + self.src_combo = ColorSpaceComboBox(include_roles=True) + self.src_combo.color_space_changed.connect(self._on_edit) - self.dst_combo = CallbackComboBox(ConfigCache.get_color_space_names) - self.dst_combo.currentIndexChanged.connect(self._on_edit) + self.dst_combo = ColorSpaceComboBox(include_roles=True) + self.dst_combo.color_space_changed.connect(self._on_edit) - self.skip_color_space_conversion_check = CheckBox("Skip Color Space Conversion") - self.skip_color_space_conversion_check.stateChanged.connect(self._on_edit) + self.skip_color_space_conversion_check = CheckBox( + "Skip Color Space Conversion" + ) + self.skip_color_space_conversion_check.stateChanged.connect( + self._on_edit + ) # TODO: Add look completer and validator self.looks_edit = LineEdit() @@ -43,8 +46,8 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): def transform(self) -> ocio.ColorSpaceTransform: transform = super().transform() - transform.setSrc(self.src_combo.currentText()) - transform.setDst(self.dst_combo.currentText()) + transform.setSrc(self.src_combo.color_space_name()) + transform.setDst(self.dst_combo.color_space_name()) transform.setLooks(self.looks_edit.text()) transform.setSkipColorSpaceConversion( self.skip_color_space_conversion_check.isChecked() @@ -53,8 +56,8 @@ def transform(self) -> ocio.ColorSpaceTransform: def update_from_transform(self, transform: ocio.Transform) -> None: super().update_from_transform(transform) - self.src_combo.setCurrentText(transform.getSrc()) - self.dst_combo.setCurrentText(transform.getDst()) + self.src_combo.set_color_space(transform.getSrc()) + self.dst_combo.set_color_space(transform.getDst()) self.looks_edit.setText(transform.getLooks()) self.skip_color_space_conversion_check.setChecked( transform.getSkipColorSpaceConversion() @@ -64,8 +67,8 @@ def update_from_config(self): """ Update available color spaces from current config. """ - self.src_combo.update() - self.dst_combo.update() + self.src_combo.update_color_spaces() + self.dst_combo.update_color_spaces() TransformEditFactory.register(ocio.LookTransform, LookTransformEdit) diff --git a/src/apps/ocioview/ocioview/transforms/transform_edit.py b/src/apps/ocioview/ocioview/transforms/transform_edit.py index c517a71d86..99a3cc021c 100644 --- a/src/apps/ocioview/ocioview/transforms/transform_edit.py +++ b/src/apps/ocioview/ocioview/transforms/transform_edit.py @@ -10,7 +10,10 @@ from PySide6 import QtCore, QtGui, QtWidgets from ..constants import ICON_SIZE_ITEM, BORDER_COLOR_ROLE -from ..style import apply_top_tool_bar_style, apply_widget_with_top_tool_bar_style +from ..style import ( + apply_top_tool_bar_style, + apply_widget_with_top_tool_bar_style, +) from ..utils import get_glyph_icon, item_type_label from ..widgets import EnumComboBox, FormLayout @@ -45,7 +48,9 @@ def transform_type_icon(cls) -> QtGui.QIcon: :return: Transform type icon """ if cls.__icon__ is None: - cls.__icon__ = get_glyph_icon(cls.__icon_glyph__, size=ICON_SIZE_ITEM) + cls.__icon__ = get_glyph_icon( + cls.__icon_glyph__, size=ICON_SIZE_ITEM + ) return cls.__icon__ @classmethod @@ -55,7 +60,9 @@ def transform_type_label(cls) -> str: """ if cls.__tf_type_label__ is None: # Remove trailing "Transform" token - cls.__tf_type_label__ = item_type_label(cls.__tf_type__).rsplit(" ", 1)[0] + cls.__tf_type_label__ = item_type_label(cls.__tf_type__).rsplit( + " ", 1 + )[0] return cls.__tf_type_label__ @classmethod @@ -96,7 +103,9 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self.icon_label = None if self.__icon_glyph__ is not None: - self.icon_label = get_glyph_icon(self.__icon_glyph__, as_widget=True) + self.icon_label = get_glyph_icon( + self.__icon_glyph__, as_widget=True + ) self.expand_button = QtWidgets.QToolButton() self.expand_button.setIcon(self._collapse_icon) @@ -111,7 +120,9 @@ def __init__(self, parent: Optional[QtCore.QObject] = None): self.move_down_button = QtWidgets.QToolButton() self.move_down_button.setIcon(get_glyph_icon("ph.arrow-down")) self.move_down_button.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly) - self.move_down_button.released.connect(partial(self.moved_down.emit, self)) + self.move_down_button.released.connect( + partial(self.moved_down.emit, self) + ) self.delete_button = QtWidgets.QToolButton() self.delete_button.setIcon(get_glyph_icon("ph.x")) @@ -238,7 +249,11 @@ def _update_state(self) -> None: """ if self._tf_frame.isHidden(): self.expand_button.setIcon(self._expand_icon) - apply_top_tool_bar_style(self._header_frame, border_bottom_radius=3) + apply_top_tool_bar_style( + self._header_frame, border_bottom_radius=3 + ) else: self.expand_button.setIcon(self._collapse_icon) - apply_top_tool_bar_style(self._header_frame, border_bottom_radius=0) + apply_top_tool_bar_style( + self._header_frame, border_bottom_radius=0 + ) diff --git a/src/apps/ocioview/ocioview/utils.py b/src/apps/ocioview/ocioview/utils.py index 32b46ee925..d1b1c27042 100644 --- a/src/apps/ocioview/ocioview/utils.py +++ b/src/apps/ocioview/ocioview/utils.py @@ -138,7 +138,9 @@ def item_type_label(item_type: type) -> str: :param item_type: Config item type :return: Friendly type name """ - return " ".join(filter(None, re.split(r"([A-Z]+[a-z]+)", item_type.__name__))) + return " ".join( + filter(None, re.split(r"([A-Z]+[a-z]+)", item_type.__name__)) + ) def m44_to_m33(m44: list) -> list: @@ -181,7 +183,9 @@ def config_to_html(config: ocio.Config) -> str: ) -def processor_to_ctf_html(processor: ocio.Processor) -> tuple[str, ocio.GroupTransform]: +def processor_to_ctf_html( + processor: ocio.Processor, +) -> tuple[str, ocio.GroupTransform]: """Return processor CTF formatted as HTML.""" config = ocio.GetCurrentConfig() group_tf = processor.createGroupTransform() @@ -223,14 +227,20 @@ def processor_to_shader_html( Return processor shader in the requested language, formatted as HTML. """ - gpu_shader_desc = ocio.GpuShaderDesc.CreateShaderDesc(language=gpu_language) + gpu_shader_desc = ocio.GpuShaderDesc.CreateShaderDesc( + language=gpu_language + ) gpu_proc.extractGpuShaderInfo(gpu_shader_desc) shader_data = gpu_shader_desc.getShaderText() return increase_html_lineno_padding( highlight( shader_data, - (GLShaderLexer if "GLSL" in gpu_language.name else HLSLShaderLexer)(), + ( + GLShaderLexer + if "GLSL" in gpu_language.name + else HLSLShaderLexer + )(), HtmlFormatter(linenos="inline"), ) ) @@ -290,7 +300,7 @@ def color_space_to_rgb_colourspace(color_space: str) -> RGB_Colourspace | None: config = ocio.GetCurrentConfig() if (not config.hasRole(ocio.ROLE_INTERCHANGE_DISPLAY)) or ( - color_space not in ConfigCache.get_color_space_names() + color_space not in ConfigCache.get_color_space_names() ): return None diff --git a/src/apps/ocioview/ocioview/viewer/image_plane.py b/src/apps/ocioview/ocioview/viewer/image_plane.py index aa128bf0f5..5117934ada 100644 --- a/src/apps/ocioview/ocioview/viewer/image_plane.py +++ b/src/apps/ocioview/ocioview/viewer/image_plane.py @@ -97,7 +97,9 @@ class ImagePlane(QtOpenGLWidgets.QOpenGLWidget): """ image_loaded = QtCore.Signal(Path, int, int) - sample_changed = QtCore.Signal(int, int, float, float, float, float, float, float) + sample_changed = QtCore.Signal( + int, int, float, float, float, float, float, float + ) scale_changed = QtCore.Signal(float) tf_subscription_requested = QtCore.Signal(int) @@ -184,8 +186,12 @@ def initializeGL(self) -> None: ctypes.c_void_p(0), ) - GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE) - GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE + ) self._set_ocio_tex_params(GL.GL_TEXTURE_2D, ocio.INTERP_LINEAR) # Init image plane @@ -244,7 +250,9 @@ def initializeGL(self) -> None: plane_position_data, GL.GL_STATIC_DRAW, ) - GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0)) + GL.glVertexAttribPointer( + 0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0) + ) GL.glEnableVertexAttribArray(0) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._plane_tex_coord_vbo) @@ -254,7 +262,9 @@ def initializeGL(self) -> None: plane_tex_coord_data, GL.GL_STATIC_DRAW, ) - GL.glVertexAttribPointer(1, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0)) + GL.glVertexAttribPointer( + 1, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0) + ) GL.glEnableVertexAttribArray(1) GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self._plane_index_vbo) @@ -310,10 +320,14 @@ def paintGL(self) -> None: # Set uniforms mvp_mat = self._proj_mat @ self._model_view_mat - mvp_mat_loc = GL.glGetUniformLocation(self._shader_program, "mvpMat") + mvp_mat_loc = GL.glGetUniformLocation( + self._shader_program, "mvpMat" + ) GL.glUniformMatrix4fv(mvp_mat_loc, 1, GL.GL_FALSE, mvp_mat.T) - image_tex_loc = GL.glGetUniformLocation(self._shader_program, "imageTex") + image_tex_loc = GL.glGetUniformLocation( + self._shader_program, "imageTex" + ) GL.glUniform1i(image_tex_loc, 0) # Bind texture, VAO, and draw @@ -425,7 +439,8 @@ def clear_transform(self) -> None: self.update_ocio_proc( ProcessorContext(self._ocio_proc_context.input_color_space), - force_update=True) + force_update=True, + ) def reset_ocio_proc(self, update: bool = False) -> None: """ @@ -448,7 +463,7 @@ def update_ocio_proc( transform: Optional[ocio.Transform] = None, channel: Optional[int] = None, force_update: bool = False, - ): + ) -> None: """ Update one or more aspects of the OCIO GPU renderer. Parameters are cached, so not providing a parameter maintains the existing @@ -474,7 +489,9 @@ def update_ocio_proc( config = ocio.GetCurrentConfig() has_scene_linear = config.hasRole(ocio.ROLE_SCENE_LINEAR) - scene_ref_name = ReferenceSpaceManager.scene_reference_space().getName() + scene_ref_name = ( + ReferenceSpaceManager.scene_reference_space().getName() + ) # Build simplified viewing pipeline: # - GPU: For viewport rendering @@ -483,7 +500,11 @@ def update_ocio_proc( cpu_viewing_pipeline = ocio.GroupTransform() # Convert to scene linear space if input space is known - if has_scene_linear and self._ocio_proc_context: + if ( + has_scene_linear + and self._ocio_proc_context + and self._ocio_proc_context.input_color_space + ): to_scene_linear = ocio.ColorSpaceTransform( src=self._ocio_proc_context.input_color_space, dst=ocio.ROLE_SCENE_LINEAR, @@ -501,7 +522,10 @@ def update_ocio_proc( # Convert to the scene reference space, which is the expected input space for # all provided transforms. If the input color space is not known, the transform # will be applied to unmodified input pixels. - if self._ocio_proc_context and self._ocio_proc_context.input_color_space: + if ( + self._ocio_proc_context + and self._ocio_proc_context.input_color_space + ): if has_scene_linear: to_scene_ref = ocio.ColorSpaceTransform( src=ocio.ROLE_SCENE_LINEAR, dst=scene_ref_name @@ -510,7 +534,8 @@ def update_ocio_proc( cpu_viewing_pipeline.appendTransform(to_scene_ref) else: to_scene_ref = ocio.ColorSpaceTransform( - src=self._ocio_proc_context.input_color_space, dst=scene_ref_name + src=self._ocio_proc_context.input_color_space, + dst=scene_ref_name, ) gpu_viewing_pipeline.appendTransform(to_scene_ref) cpu_viewing_pipeline.appendTransform(to_scene_ref) @@ -521,9 +546,13 @@ def update_ocio_proc( cpu_viewing_pipeline.appendTransform(self._ocio_tf) # Or restore input color space, if known - elif self._ocio_proc_context and self._ocio_proc_context.input_color_space: + elif ( + self._ocio_proc_context + and self._ocio_proc_context.input_color_space + ): from_scene_ref = ocio.ColorSpaceTransform( - src=scene_ref_name, dst=self._ocio_proc_context.input_color_space + src=scene_ref_name, + dst=self._ocio_proc_context.input_color_space, ) gpu_viewing_pipeline.appendTransform(from_scene_ref) cpu_viewing_pipeline.appendTransform(from_scene_ref) @@ -544,7 +573,14 @@ def update_ocio_proc( ) # Create GPU processor - gpu_proc = config.getProcessor(gpu_viewing_pipeline, ocio.TRANSFORM_DIR_FORWARD) + try: + gpu_proc = config.getProcessor( + gpu_viewing_pipeline, ocio.TRANSFORM_DIR_FORWARD + ) + except ocio.Exception: + # Config may have changed between transform creation and now. If this + # doesn't error, CPU processor construction should succeed. + return if gpu_proc.getCacheID() != self._ocio_proc_cache_id: # Update CPU processor @@ -569,12 +605,16 @@ def update_ocio_proc( self._update_ocio_dyn_prop( ocio.DYNAMIC_PROPERTY_EXPOSURE, self._ocio_exposure ) - self._update_ocio_dyn_prop(ocio.DYNAMIC_PROPERTY_GAMMA, self._ocio_gamma) + self._update_ocio_dyn_prop( + ocio.DYNAMIC_PROPERTY_GAMMA, self._ocio_gamma + ) self.update() # Log processor change after render - message_queue.put_nowait((self._ocio_proc_context, self._ocio_proc)) + message_queue.put_nowait( + (self._ocio_proc_context, self._ocio_proc) + ) elif force_update: self.update() @@ -582,8 +622,13 @@ def update_ocio_proc( # The transform and processor has not changed, but other app components # which view it may have dropped the reference. Log processor to update # them as needed. - if self._ocio_proc is not None and self._ocio_proc_context is not None: - message_queue.put_nowait((self._ocio_proc_context, self._ocio_proc)) + if ( + self._ocio_proc is not None + and self._ocio_proc_context is not None + ): + message_queue.put_nowait( + (self._ocio_proc_context, self._ocio_proc) + ) def exposure(self) -> float: """ @@ -663,10 +708,12 @@ def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: ] ) model_pos = ( - np.linalg.inv(self._proj_mat @ self._model_view_mat) @ screen_pos + np.linalg.inv(self._proj_mat @ self._model_view_mat) + @ screen_pos ) pixel_pos = ( - np.array([model_pos[0] + 0.5, model_pos[1] + 0.5]) * self._image_size + np.array([model_pos[0] + 0.5, model_pos[1] + 0.5]) + * self._image_size ) # Broadcast sample position @@ -689,7 +736,9 @@ def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: else: pixel_output = pixel_input.copy() - self.sample_changed.emit(pixel_x, pixel_y, *pixel_input, *pixel_output) + self.sample_changed.emit( + pixel_x, pixel_y, *pixel_input, *pixel_output + ) else: # Out of image bounds self.sample_changed.emit(-1, -1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) @@ -799,7 +848,9 @@ def _install_shortcuts(self) -> None: # Number keys = Subscribe to transform @ slot for i in range(10): - subscribe_shortcut = QtGui.QShortcut(QtGui.QKeySequence(str(i)), self) + subscribe_shortcut = QtGui.QShortcut( + QtGui.QKeySequence(str(i)), self + ) subscribe_shortcut.activated.connect( lambda slot=i: self.tf_subscription_requested.emit(slot) ) @@ -807,7 +858,9 @@ def _install_shortcuts(self) -> None: # Ctrl + Number keys = Power of 2 scale: 1 = x1, 2 = x2, 3 = x4, ... for i in range(9): - scale_shortcut = QtGui.QShortcut(QtGui.QKeySequence(f"Ctrl+{i + 1}"), self) + scale_shortcut = QtGui.QShortcut( + QtGui.QKeySequence(f"Ctrl+{i + 1}"), self + ) scale_shortcut.activated.connect( lambda exponent=i: self.zoom( self.rect().center(), float(2**exponent), absolute=True @@ -840,7 +893,9 @@ def _compile_shader( compile_status = GL.glGetShaderiv(shader, GL.GL_COMPILE_STATUS) if not compile_status: compile_log = GL.glGetShaderInfoLog(shader) - logger.error("Shader program compile error: {log}".format(log=compile_log)) + logger.error( + "Shader program compile error: {log}".format(log=compile_log) + ) return None return shader @@ -872,7 +927,9 @@ def _build_program(self, force: bool = False) -> None: # Vert shader only needs to be built once if not self._vert_shader: - self._vert_shader = self._compile_shader(GLSL_VERT_SRC, GL.GL_VERTEX_SHADER) + self._vert_shader = self._compile_shader( + GLSL_VERT_SRC, GL.GL_VERTEX_SHADER + ) if not self._vert_shader: return @@ -889,7 +946,9 @@ def _build_program(self, force: bool = False) -> None: frag_src = GLSL_FRAG_OCIO_SRC_FMT.format( ocio_src=self._ocio_shader_desc.getShaderText() ) - self._frag_shader = self._compile_shader(frag_src, GL.GL_FRAGMENT_SHADER) + self._frag_shader = self._compile_shader( + frag_src, GL.GL_FRAGMENT_SHADER + ) if not self._frag_shader: return @@ -900,10 +959,14 @@ def _build_program(self, force: bool = False) -> None: GL.glBindAttribLocation(self._shader_program, 1, "in_texCoord") GL.glLinkProgram(self._shader_program) - link_status = GL.glGetProgramiv(self._shader_program, GL.GL_LINK_STATUS) + link_status = GL.glGetProgramiv( + self._shader_program, GL.GL_LINK_STATUS + ) if not link_status: link_log = GL.glGetProgramInfoLog(self._shader_program) - logger.error("Shader program link error: {log}".format(log=link_log)) + logger.error( + "Shader program link error: {log}".format(log=link_log) + ) return # Store cache ID to detect reuse @@ -939,7 +1002,9 @@ def _orthographic_proj_matrix( b = 2 / top_minus_bottom c = -2 / far_minus_near - return np.array([[a, 0, 0, tx], [0, b, 0, ty], [0, 0, c, tz], [0, 0, 0, 1]]) + return np.array( + [[a, 0, 0, tx], [0, b, 0, ty], [0, 0, c, tz], [0, 0, 0, 1]] + ) def _update_model_view_mat(self, update: bool = True) -> None: """ @@ -953,7 +1018,12 @@ def _update_model_view_mat(self, update: bool = True) -> None: # Flip Y to account for different OIIO/OpenGL image origin self._model_view_mat *= [1.0, -1.0, 1.0, 1.0] - self._model_view_mat *= [self._image_scale, self._image_scale, 1.0, 1.0] + self._model_view_mat *= [ + self._image_scale, + self._image_scale, + 1.0, + 1.0, + ] self._model_view_mat[:2, -1] += [ self._image_pos[0] * self._image_scale, -self._image_pos[1] * self._image_scale, @@ -983,11 +1053,19 @@ def _set_ocio_tex_params( self.makeCurrent() if interpolation == ocio.INTERP_NEAREST: - GL.glTexParameteri(tex_type, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST) - GL.glTexParameteri(tex_type, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST) + GL.glTexParameteri( + tex_type, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST + ) + GL.glTexParameteri( + tex_type, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST + ) else: - GL.glTexParameteri(tex_type, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR) - GL.glTexParameteri(tex_type, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR) + GL.glTexParameteri( + tex_type, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + tex_type, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR + ) def _allocate_ocio_tex(self) -> None: """ @@ -1088,7 +1166,13 @@ def _allocate_ocio_tex(self) -> None: ) self._ocio_tex_ids.append( - (tex, tex_info.textureName, tex_info.samplerName, tex_type, tex_index) + ( + tex, + tex_info.textureName, + tex_info.samplerName, + tex_type, + tex_index, + ) ) tex_index += 1 @@ -1098,7 +1182,13 @@ def _del_ocio_tex(self) -> None: """ self.makeCurrent() - for tex, tex_name, sampler_name, tex_type, tex_index in self._ocio_tex_ids: + for ( + tex, + tex_name, + sampler_name, + tex_type, + tex_index, + ) in self._ocio_tex_ids: GL.glDeleteTextures([tex]) del self._ocio_tex_ids[:] @@ -1108,11 +1198,18 @@ def _use_ocio_tex(self) -> None: """ self.makeCurrent() - for tex, tex_name, sampler_name, tex_type, tex_index in self._ocio_tex_ids: + for ( + tex, + tex_name, + sampler_name, + tex_type, + tex_index, + ) in self._ocio_tex_ids: GL.glActiveTexture(GL.GL_TEXTURE0 + tex_index) GL.glBindTexture(tex_type, tex) GL.glUniform1i( - GL.glGetUniformLocation(self._shader_program, sampler_name), tex_index + GL.glGetUniformLocation(self._shader_program, sampler_name), + tex_index, ) def _del_ocio_uniforms(self) -> None: diff --git a/src/apps/ocioview/ocioview/viewer/image_viewer.py b/src/apps/ocioview/ocioview/viewer/image_viewer.py index ca0fde2198..3a8be5eeb1 100644 --- a/src/apps/ocioview/ocioview/viewer/image_viewer.py +++ b/src/apps/ocioview/ocioview/viewer/image_viewer.py @@ -5,17 +5,28 @@ from contextlib import contextmanager from pathlib import Path -from typing import Generator, Optional, Type +from typing import Generator, Optional, Union import PyOpenColorIO as ocio from PySide6 import QtCore, QtGui, QtWidgets +from ..config_cache import ConfigCache +from ..constants import ( + GRAY_COLOR, + R_COLOR, + G_COLOR, + B_COLOR, + ICON_SIZE_TAB, +) +from ..items.display_model import DisplayModel +from ..items.view_model import ViewModel +from ..mode import OCIOViewMode from ..processor_context import ProcessorContext +from ..ref_space_manager import ReferenceSpaceManager +from ..signal_router import SignalRouter from ..transform_manager import TransformManager -from ..config_cache import ConfigCache -from ..constants import GRAY_COLOR, R_COLOR, G_COLOR, B_COLOR, ICON_SIZE_TAB from ..utils import float_to_uint8, get_glyph_icon, SignalsBlocked -from ..widgets import ComboBox, CallbackComboBox +from ..widgets import ComboBox, CallbackComboBox, ColorSpaceComboBox from .image_plane import ImagePlane @@ -30,7 +41,7 @@ class ViewerChannels(object): class ImageViewer(QtWidgets.QWidget): """ - Main image viewer widget, which can display an image with internal + Image viewer widget, which can display an image with internal 32-bit float precision. """ @@ -44,65 +55,95 @@ class ImageViewer(QtWidgets.QWidget): PASSTHROUGH = "passthrough" PASSTHROUGH_LABEL = FMT_GRAY_LABEL.format(v=f"{PASSTHROUGH}:") - WIDGET_HEIGHT_IO = 32 - ROLE_SLOT = QtCore.Qt.UserRole + 1 ROLE_ITEM_TYPE = QtCore.Qt.UserRole + 2 ROLE_ITEM_NAME = QtCore.Qt.UserRole + 3 @classmethod def viewer_type_icon(cls) -> QtGui.QIcon: - """ - :return: Viewer type icon - """ + """Get viewer type icon.""" return get_glyph_icon("mdi6.image-outline", size=ICON_SIZE_TAB) @classmethod def viewer_type_label(cls) -> str: - """ - :return: Friendly viewer type name - """ + """Get friendly viewer type name.""" return "Image Viewer" def __init__(self, parent: Optional[QtWidgets.QWidget] = None): super().__init__(parent) + self._sample_format = "" self._tf_subscription_slot = -1 self._tf_fwd = None self._tf_inv = None - self._sample_format = "" # Widgets + # --------------------------------------------------------------------- + + # Viewport self.image_plane = ImagePlane(self) self.image_plane.setSizePolicy( QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding, ) ) - self.input_color_space_label = get_glyph_icon("mdi6.import", as_widget=True) + # Input color space + self.input_color_space_label = get_glyph_icon( + "mdi6.import", as_widget=True + ) self.input_color_space_label.setToolTip("Input color space") - self.input_color_space_box = CallbackComboBox( - lambda: ConfigCache.get_color_space_names(ocio.SEARCH_REFERENCE_SPACE_SCENE) + self.input_color_space_box = ColorSpaceComboBox(include_roles=True) + self.input_color_space_box.setToolTip( + self.input_color_space_label.toolTip() ) - self.input_color_space_box.setFixedHeight(self.WIDGET_HEIGHT_IO) - self.input_color_space_box.setToolTip(self.input_color_space_label.toolTip()) + # Edit mode self.tf_label = get_glyph_icon("mdi6.export", as_widget=True) self.tf_box = ComboBox() - self.tf_box.setFixedHeight(self.WIDGET_HEIGHT_IO) self.tf_box.setToolTip("Output transform") self._tf_direction_forward_icon = get_glyph_icon("mdi6.layers-plus") self._tf_direction_inverse_icon = get_glyph_icon("mdi6.layers-minus") self.tf_direction_button = QtWidgets.QPushButton() - self.tf_direction_button.setFixedHeight(self.WIDGET_HEIGHT_IO) self.tf_direction_button.setCheckable(True) self.tf_direction_button.setChecked(False) self.tf_direction_button.setIcon(self._tf_direction_forward_icon) self.tf_direction_button.setToolTip("Transform direction: Forward") + self.output_tf_direction_label = QtWidgets.QLabel("+") + + # Preview mode + self.display_view_label = get_glyph_icon( + ViewModel.__icon_glyph__, as_widget=True + ) + self.display_view_label.setToolTip( + f"{DisplayModel.item_type_label()} / {ViewModel.item_type_label()}" + ) + + self.display_box = CallbackComboBox( + self._get_displays, + get_default_item=self._get_default_display, + ) + self.display_box.setSizeAdjustPolicy( + QtWidgets.QComboBox.AdjustToContents + ) + self.display_box.setToolTip(DisplayModel.item_type_label()) + self.display_box.currentIndexChanged[int].connect( + self._on_display_changed + ) + + self.view_box = CallbackComboBox( + self._get_views, + get_default_item=self._get_default_view, + ) + self.view_box.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) + self.view_box.setToolTip(ViewModel.item_type_label()) + self.view_box.currentIndexChanged[int].connect(self._on_view_changed) + + # Image adjustments self.exposure_label = get_glyph_icon("ph.aperture", as_widget=True) self.exposure_label.setToolTip("Exposure") self.exposure_box = QtWidgets.QDoubleSpinBox() @@ -125,19 +166,32 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): "Sample precision (number of digits after the decimal point)" ) self.sample_precision_box = QtWidgets.QSpinBox() - self.sample_precision_box.setToolTip(self.sample_precision_label.toolTip()) + self.sample_precision_box.setToolTip( + self.sample_precision_label.toolTip() + ) self.sample_precision_box.setValue(5) + # Info and inspect labels self.image_name_label = QtWidgets.QLabel() - self.image_scale_label = QtWidgets.QLabel(self.FMT_IMAGE_SCALE.format(s=100)) + self.image_scale_label = QtWidgets.QLabel( + self.FMT_IMAGE_SCALE.format(s=100) + ) - self.input_w_label = QtWidgets.QLabel(self.FMT_GRAY_LABEL.format(v="W:")) + self.input_w_label = QtWidgets.QLabel( + self.FMT_GRAY_LABEL.format(v="W:") + ) self.image_w_value_label = QtWidgets.QLabel("0") - self.input_h_label = QtWidgets.QLabel(self.FMT_GRAY_LABEL.format(v="H:")) + self.input_h_label = QtWidgets.QLabel( + self.FMT_GRAY_LABEL.format(v="H:") + ) self.image_h_value_label = QtWidgets.QLabel("0") - self.input_x_label = QtWidgets.QLabel(self.FMT_GRAY_LABEL.format(v="X:")) + self.input_x_label = QtWidgets.QLabel( + self.FMT_GRAY_LABEL.format(v="X:") + ) self.image_x_value_label = QtWidgets.QLabel("0") - self.input_y_label = QtWidgets.QLabel(self.FMT_GRAY_LABEL.format(v="Y:")) + self.input_y_label = QtWidgets.QLabel( + self.FMT_GRAY_LABEL.format(v="Y:") + ) self.image_y_value_label = QtWidgets.QLabel("0") self.input_sample_label = get_glyph_icon( @@ -152,7 +206,6 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): self.FMT_SWATCH_CSS.format(r=0, g=0, b=0) ) - self.output_tf_direction_label = QtWidgets.QLabel("+") self.output_sample_label = get_glyph_icon( "mdi6.export", color=GRAY_COLOR, as_widget=True ) @@ -166,137 +219,234 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None): ) # Layout - info_layout = QtWidgets.QHBoxLayout() - info_layout.setContentsMargins(8, 8, 8, 8) - info_layout.addWidget(self.image_name_label) - info_layout.addStretch() - info_layout.addWidget(self.image_scale_label) + # --------------------------------------------------------------------- + + # Info and inspect labels + self.info_layout = QtWidgets.QHBoxLayout() + self.info_layout.setContentsMargins(8, 8, 8, 8) + self.info_layout.addWidget(self.image_name_label) + self.info_layout.addStretch() + self.info_layout.addWidget(self.image_scale_label) self.info_bar = QtWidgets.QFrame() - self.info_bar.setObjectName("image_viewer__info_bar") + self.info_bar.setObjectName("base_image_viewer__info_bar") self.info_bar.setStyleSheet( - "QFrame#image_viewer__info_bar { background-color: black; }" - ) - self.info_bar.setLayout(info_layout) - - inspect_layout = QtWidgets.QGridLayout() - inspect_layout.setContentsMargins(8, 8, 8, 8) - - inspect_layout.addWidget(self.input_w_label, 0, 0, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.image_w_value_label, 0, 1, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.input_h_label, 0, 2, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.image_h_value_label, 0, 3, QtCore.Qt.AlignRight) - inspect_layout.addWidget(QtWidgets.QLabel(), 0, 4) - inspect_layout.addWidget(self.input_sample_label, 0, 6, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.input_r_sample_label, 0, 7, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.input_g_sample_label, 0, 8, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.input_b_sample_label, 0, 9, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.input_sample_swatch, 0, 10, QtCore.Qt.AlignLeft) - - inspect_layout.addWidget(self.input_x_label, 1, 0, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.image_x_value_label, 1, 1, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.input_y_label, 1, 2, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.image_y_value_label, 1, 3, QtCore.Qt.AlignRight) - inspect_layout.addWidget(QtWidgets.QLabel(), 1, 4) - inspect_layout.setColumnStretch(4, 1) - inspect_layout.addWidget( - self.output_tf_direction_label, 1, 5, QtCore.Qt.AlignRight + "QFrame#base_image_viewer__info_bar { background-color: black; }" + ) + self.info_bar.setLayout(self.info_layout) + + self.inspect_layout = QtWidgets.QGridLayout() + self.inspect_layout.setContentsMargins(8, 8, 8, 8) + + self.inspect_layout.addWidget( + self.input_w_label, 0, 0, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.image_w_value_label, 0, 1, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.input_h_label, 0, 2, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.image_h_value_label, 0, 3, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget(QtWidgets.QLabel(), 0, 4) + self.inspect_layout.addWidget( + self.input_sample_label, 0, 6, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.input_r_sample_label, 0, 7, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.input_g_sample_label, 0, 8, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.input_b_sample_label, 0, 9, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.input_sample_swatch, 0, 10, QtCore.Qt.AlignLeft + ) + + self.inspect_layout.addWidget( + self.input_x_label, 1, 0, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.image_x_value_label, 1, 1, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.input_y_label, 1, 2, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.image_y_value_label, 1, 3, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget(QtWidgets.QLabel(), 1, 4) + self.inspect_layout.setColumnStretch(4, 1) + self.inspect_layout.addWidget( + self.output_sample_label, 1, 6, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.output_r_sample_label, 1, 7, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.output_g_sample_label, 1, 8, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.output_b_sample_label, 1, 9, QtCore.Qt.AlignRight + ) + self.inspect_layout.addWidget( + self.output_sample_swatch, 1, 10, QtCore.Qt.AlignLeft ) - inspect_layout.addWidget(self.output_sample_label, 1, 6, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.output_r_sample_label, 1, 7, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.output_g_sample_label, 1, 8, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.output_b_sample_label, 1, 9, QtCore.Qt.AlignRight) - inspect_layout.addWidget(self.output_sample_swatch, 1, 10, QtCore.Qt.AlignLeft) self.inspect_bar = QtWidgets.QFrame() - self.inspect_bar.setObjectName("image_viewer__status_bar") + self.inspect_bar.setObjectName("base_image_viewer__status_bar") self.inspect_bar.setStyleSheet( - "QFrame#image_viewer__status_bar { background-color: black; }" - ) - self.inspect_bar.setLayout(inspect_layout) - - tf_layout = QtWidgets.QHBoxLayout() - tf_layout.setContentsMargins(0, 0, 0, 0) - tf_layout.setSpacing(0) - tf_layout.addWidget(self.tf_box) - tf_layout.setStretch(0, 1) - tf_layout.addWidget(self.tf_direction_button) - - io_layout = QtWidgets.QHBoxLayout() - io_layout.addWidget(self.input_color_space_label) - io_layout.addWidget(self.input_color_space_box) - io_layout.setStretch(1, 1) - io_layout.addWidget(self.tf_label) - io_layout.addLayout(tf_layout) - io_layout.setStretch(3, 1) - - adjust_layout = QtWidgets.QHBoxLayout() - adjust_layout.addWidget(self.exposure_label) - adjust_layout.addWidget(self.exposure_box) - adjust_layout.setStretch(1, 2) - adjust_layout.addWidget(self.gamma_label) - adjust_layout.addWidget(self.gamma_box) - adjust_layout.setStretch(3, 2) - adjust_layout.addWidget(self.sample_precision_label) - adjust_layout.addWidget(self.sample_precision_box) - adjust_layout.setStretch(5, 1) - - image_plane_layout = QtWidgets.QVBoxLayout() - image_plane_layout.setSpacing(0) - image_plane_layout.addWidget(self.info_bar) - image_plane_layout.addWidget(self.image_plane) - image_plane_layout.addWidget(self.inspect_bar) + "QFrame#base_image_viewer__status_bar { background-color: black; }" + ) + self.inspect_bar.setLayout(self.inspect_layout) + + # Edit mode + self.tf_layout = QtWidgets.QHBoxLayout() + self.tf_layout.setContentsMargins(0, 0, 0, 0) + self.tf_layout.setSpacing(0) + self.tf_layout.addWidget(self.tf_box) + self.tf_layout.setStretch(0, 1) + self.tf_layout.addWidget(self.tf_direction_button) + + self.tf_layout_outer = QtWidgets.QHBoxLayout() + self.tf_layout_outer.setContentsMargins(0, 0, 0, 0) + self.tf_layout_outer.addWidget(self.tf_label) + self.tf_layout_outer.setStretch(0, 0) + self.tf_layout_outer.addLayout(self.tf_layout) + self.tf_layout_outer.setStretch(1, 1) + + self.tf_frame = QtWidgets.QFrame() + self.tf_frame.setLayout(self.tf_layout_outer) + + self.inspect_layout.addWidget( + self.output_tf_direction_label, 1, 5, QtCore.Qt.AlignRight + ) + # Preview mode + self.display_view_layout = QtWidgets.QHBoxLayout() + self.display_view_layout.setContentsMargins(0, 0, 0, 0) + self.display_view_layout.setSpacing(0) + self.display_view_layout.addWidget(self.display_box) + self.display_view_layout.addWidget(self.view_box) + + self.display_view_layout_outer = QtWidgets.QHBoxLayout() + self.display_view_layout_outer.setContentsMargins(0, 0, 0, 0) + self.display_view_layout_outer.addWidget(self.display_view_label) + self.display_view_layout_outer.setStretch(0, 0) + self.display_view_layout_outer.addLayout(self.display_view_layout) + self.display_view_layout_outer.setStretch(1, 1) + + self.display_view_frame = QtWidgets.QFrame() + self.display_view_frame.setLayout(self.display_view_layout_outer) + + # Mode switch stack + self.mode_stack = QtWidgets.QStackedWidget() + self.mode_stack.addWidget(self.tf_frame) # Edit mode + self.mode_stack.addWidget(self.display_view_frame) # Preview mode + + # Input/output + self.io_layout = QtWidgets.QHBoxLayout() + self.io_layout.addWidget(self.input_color_space_label) + self.io_layout.addWidget(self.input_color_space_box) + self.io_layout.setStretch(1, 1) + self.io_layout.addWidget(self.mode_stack) + self.io_layout.setStretch(2, 1) + + # Image adjustments + self.adjust_layout = QtWidgets.QHBoxLayout() + self.adjust_layout.addWidget(self.exposure_label) + self.adjust_layout.addWidget(self.exposure_box) + self.adjust_layout.setStretch(1, 2) + self.adjust_layout.addWidget(self.gamma_label) + self.adjust_layout.addWidget(self.gamma_box) + self.adjust_layout.setStretch(3, 2) + self.adjust_layout.addWidget(self.sample_precision_label) + self.adjust_layout.addWidget(self.sample_precision_box) + self.adjust_layout.setStretch(5, 1) + + # Viewport + self.image_plane_layout = QtWidgets.QVBoxLayout() + self.image_plane_layout.setSpacing(0) + self.image_plane_layout.addWidget(self.info_bar) + self.image_plane_layout.addWidget(self.image_plane) + self.image_plane_layout.addWidget(self.inspect_bar) + + # Main layout layout = QtWidgets.QVBoxLayout() - layout.addLayout(io_layout) - layout.addLayout(adjust_layout) - layout.addLayout(image_plane_layout) + layout.addLayout(self.io_layout) + layout.addLayout(self.adjust_layout) + layout.addLayout(self.image_plane_layout) + layout.setStretch(2, 1) self.setLayout(layout) # Connect signals/slots + # --------------------------------------------------------------------- + self.image_plane.image_loaded.connect(self._on_image_loaded) self.image_plane.scale_changed.connect(self._on_scale_changed) self.image_plane.sample_changed.connect(self._on_sample_changed) - self.image_plane.tf_subscription_requested.connect( - self._on_tf_subscription_requested - ) - self.input_color_space_box.currentTextChanged[str].connect( + self.input_color_space_box.color_space_changed.connect( self._on_input_color_space_changed ) - self.tf_box.currentIndexChanged[int].connect(self._on_transform_changed) - self.tf_direction_button.clicked[bool].connect(self._on_inverse_check_clicked) self.exposure_box.valueChanged.connect(self._on_exposure_changed) self.gamma_box.valueChanged.connect(self._on_gamma_changed) self.sample_precision_box.valueChanged.connect( self._on_sample_precision_changed ) + signal_router = SignalRouter.get_instance() + signal_router.mode_changed.connect(self._on_mode_changed) + + # Edit mode + self.image_plane.tf_subscription_requested.connect( + self._on_tf_subscription_requested + ) + self.tf_box.currentIndexChanged[int].connect( + self._on_transform_changed + ) + self.tf_direction_button.clicked[bool].connect( + self._on_inverse_check_clicked + ) + # Initialize - TransformManager.subscribe_to_transform_menu(self._on_transform_menu_changed) + # --------------------------------------------------------------------- + + self._on_sample_precision_changed(self.sample_precision_box.value()) + self._on_sample_changed(-1, -1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + # Edit mode + TransformManager.subscribe_to_transform_menu( + self._on_transform_menu_changed + ) TransformManager.subscribe_to_transform_subscription_init( self._on_transform_subscription_init ) + + # Initialize viewport self.update(force=True) - self._on_sample_precision_changed(self.sample_precision_box.value()) - self._on_sample_changed(-1, -1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) - def update(self, force: bool = False) -> None: + def update(self, force: bool = False, update_items: bool = True) -> None: """ Make this image viewer the current OpenGL rendering context and ask it to redraw. :param force: Whether to force the image to redraw, regardless of OCIO processor changes. + :param update_items: Whether to update dynamic OCIO items that + affect image processing. """ - self._update_input_color_spaces(update=False) + mode = OCIOViewMode.current_mode() + if mode == OCIOViewMode.Preview and update_items: + self.display_box.update_items() + self.view_box.update_items() self.image_plane.update_ocio_proc( - proc_context=ProcessorContext( - self.input_color_space(), - self.transform_item_type(), - self.transform_item_name(), - self.transform_direction(), - ), + proc_context=self._make_processor_context(), + transform=self._make_transform(), force_update=force, ) @@ -310,10 +460,12 @@ def reset(self) -> None: self.image_plane.reset_ocio_proc(update=False) # Update widgets to match image plane - with SignalsBlocked(self): - self.set_transform_direction(False) + with self._ocio_signals_blocked(): + self.set_transform_direction(ocio.TRANSFORM_DIR_FORWARD) self.set_exposure(self.image_plane.exposure()) self.set_gamma(self.image_plane.gamma()) + self.display_box.reset() + self.view_box.reset() # Update input color spaces and redraw viewport self.update() @@ -334,6 +486,21 @@ def load_image(self, image_path: Path) -> None: with self._ocio_signals_blocked(): self.set_input_color_space(self.image_plane.input_color_space()) + def input_color_space(self) -> str: + """Get input color space name.""" + return self.input_color_space_box.color_space_name() + + def set_input_color_space(self, color_space: str) -> None: + """ + Override current input color space. This controls how an input + image should be interpreted by OCIO. Each loaded image utilizes + OCIO config file rules to determine this automatically, so this + override only guarantees persistence for the current image. + + :param color_space: OCIO color space name + """ + self.input_color_space_box.set_color_space(color_space) + def view_channel(self, channel: int) -> None: """ Isolate a specific channel by its index. Specifying an out @@ -344,28 +511,41 @@ def view_channel(self, channel: int) -> None: """ self.image_plane.update_ocio_proc(channel=channel) - def input_color_space(self) -> str: + def exposure(self) -> float: + """Get exposure value.""" + return self.exposure_box.value() + + def set_exposure(self, value: float) -> None: """ - :return: Input color space name + Update viewer exposure, applied in scene_linear space prior to + the output transform. + + :param value: Exposure value in stops """ - return self.input_color_space_box.currentText() + self.exposure_box.setValue(value) - def set_input_color_space(self, color_space: str) -> None: + def gamma(self) -> float: + """Get gamma value.""" + return self.gamma_box.value() + + def set_gamma(self, value: float) -> None: """ - Override current input color space. This controls how an input - image should be interpreted by OCIO. Each loaded image utilizes - OCIO config file rules to determine this automatically, so this - override only guarantees persistence for the current image. + Update viewer gamma, applied after the OCIO output transform. - :param color_space: OCIO color space name + :param value: Gamma value used like: pow(rgb, 1/gamma) """ - self._update_input_color_spaces(update=False) - self.input_color_space_box.setCurrentText(color_space) + self.gamma_box.setValue(value) + + def display(self) -> str: + """Get current OCIO display.""" + return self.display_box.currentText() + + def view(self) -> str: + """Get current OCIO view.""" + return self.view_box.currentText() def transform(self) -> Optional[ocio.Transform]: - """ - :return: Current OCIO transform - """ + """Get current OCIO transform.""" return self.image_plane.transform() def set_transform( @@ -386,25 +566,21 @@ def set_transform( if ( slot != self._tf_subscription_slot - or (transform_fwd is None and tf_direction == ocio.TRANSFORM_DIR_FORWARD) - or (transform_inv is None and tf_direction == ocio.TRANSFORM_DIR_INVERSE) + or ( + transform_fwd is None + and tf_direction == ocio.TRANSFORM_DIR_FORWARD + ) + or ( + transform_inv is None + and tf_direction == ocio.TRANSFORM_DIR_INVERSE + ) ): return self._tf_fwd = transform_fwd self._tf_inv = transform_inv - self.image_plane.update_ocio_proc( - proc_context=ProcessorContext( - self.input_color_space(), - self.transform_item_type(), - self.transform_item_name(), - tf_direction, - ), - transform=self._tf_inv - if tf_direction == ocio.TRANSFORM_DIR_INVERSE - else self._tf_fwd, - ) + self.update() def clear_transform(self) -> None: """ @@ -420,62 +596,31 @@ def clear_transform(self) -> None: self.image_plane.clear_transform() - def transform_item_type(self) -> Type | None: - """ - :return: Transform source config item type - """ + def transform_item_type(self) -> type | None: + """Get transform source config item type.""" return self.tf_box.currentData(role=self.ROLE_ITEM_TYPE) def transform_item_name(self) -> str | None: - """ - :return: Transform source config item name - """ + """Get transform source config item name.""" return self.tf_box.currentData(role=self.ROLE_ITEM_NAME) def transform_direction(self) -> ocio.TransformDirection: - """ - :return: Transform direction being viewed - """ + """Get transform direction being viewed.""" return ( ocio.TRANSFORM_DIR_INVERSE if self.tf_direction_button.isChecked() else ocio.TRANSFORM_DIR_FORWARD ) - def set_transform_direction(self, direction: ocio.TransformDirection) -> None: + def set_transform_direction( + self, direction: ocio.TransformDirection + ) -> None: """ :param direction: Set the transform direction to be viewed """ - self.tf_direction_button.setChecked(direction == ocio.TRANSFORM_DIR_INVERSE) - - def exposure(self) -> float: - """ - :return: Exposure value - """ - return self.exposure_box.value() - - def set_exposure(self, value: float) -> None: - """ - Update viewer exposure, applied in scene_linear space prior to - the output transform. - - :param value: Exposure value in stops - """ - self.exposure_box.setValue(value) - - def gamma(self) -> float: - """ - :return: Gamma value - """ - return self.gamma_box.value() - - def set_gamma(self, value: float) -> None: - """ - Update viewer gamma, applied after the OCIO output transform. - - :param value: Gamma value used like: pow(rgb, 1/gamma) - """ - self.gamma_box.setValue(value) + self.tf_direction_button.setChecked( + direction == ocio.TRANSFORM_DIR_INVERSE + ) @contextmanager def _ocio_signals_blocked(self) -> Generator: @@ -487,124 +632,134 @@ def _ocio_signals_blocked(self) -> Generator: self.input_color_space_box.blockSignals(True) self.exposure_box.blockSignals(True) self.gamma_box.blockSignals(True) + self.display_box.blockSignals(True) + self.view_box.blockSignals(True) yield self.input_color_space_box.blockSignals(False) self.exposure_box.blockSignals(False) self.gamma_box.blockSignals(False) + self.display_box.blockSignals(False) + self.view_box.blockSignals(False) + + def _make_processor_context(self) -> ProcessorContext: + """Create processor context from available data.""" + mode = OCIOViewMode.current_mode() + if mode == OCIOViewMode.Preview: + return ProcessorContext( + self.input_color_space(), + ViewModel.__item_type__, + self.view(), + ocio.TRANSFORM_DIR_FORWARD, + ) + else: # Edit + return ProcessorContext( + self.input_color_space(), + self.transform_item_type(), + self.transform_item_name(), + self.transform_direction(), + ) - def _update_input_color_spaces(self, update: bool = True) -> None: - """ - If the current color space is no longer available, reload all - input color spaces and choose a reasonable default. - """ - color_space_names = ConfigCache.get_color_space_names( + def _make_transform(self) -> Union[ocio.Transform, None]: + """Create viewer transform.""" + transform = None + mode = OCIOViewMode.current_mode() + + if mode == OCIOViewMode.Preview: + display = self.display() + view = self.view() + + if display and view: + # Image plane expects all transforms to be relative to the current + # config's scene reference space. + transform = ocio.DisplayViewTransform( + src=ReferenceSpaceManager.scene_reference_space().getName(), + display=display, + view=view, + direction=ocio.TRANSFORM_DIR_FORWARD, + ) + + else: # Edit + if self._tf_fwd is not None and self._tf_inv is not None: + if self.transform_direction() == ocio.TRANSFORM_DIR_INVERSE: + return self._tf_inv + else: + return self._tf_fwd + else: + # Return no-op transform. Returning None instead results in the image + # plane processor being unchanged, which is problematic when switching + # application modes, since it will retain the previous display/view + # transform. + return ocio.ExponentTransform() + + return transform + + def _get_default_color_space(self) -> str: + """Get reasonable default color space.""" + all_color_spaces = ConfigCache.get_color_space_names( ocio.SEARCH_REFERENCE_SPACE_SCENE ) + default_color_space = ConfigCache.get_default_color_space_name() if ( - not self.input_color_space_box.count() - or self.input_color_space() not in color_space_names + default_color_space is not None + and default_color_space in all_color_spaces ): - default_color_space = ConfigCache.get_default_color_space_name() + return default_color_space + elif all_color_spaces: + return all_color_spaces[0] + else: + return "" - with self._ocio_signals_blocked(): - self.input_color_space_box.clear() - self.input_color_space_box.addItems(color_space_names) - self.input_color_space_box.setCurrentText(default_color_space) + def _get_displays(self) -> list[str]: + """Get all active OCIO displays.""" + config = ocio.GetCurrentConfig() + return list(config.getDisplays()) - if update: - self._on_input_color_space_changed(self.input_color_space()) + def _get_default_display(self) -> str: + """Get default OCIO display.""" + config = ocio.GetCurrentConfig() + return config.getDefaultDisplay() - def _on_transform_menu_changed( - self, menu_items: list[tuple[int, str, QtGui.QIcon]] - ) -> None: + def _get_views(self) -> list[str]: """ - Called to refresh transform menu items, and either reselect the - existing subscription, or deselect any subscription. + Get all active OCIO views, given the current input color space. """ - target_index = -1 - current_slot = -1 - if self.tf_box.count(): - current_slot = self.tf_box.currentData(role=self.ROLE_SLOT) - - with SignalsBlocked(self.tf_box): - self.tf_box.clear() - - # The first item is always no transform - self.tf_box.addItem(self.PASSTHROUGH) - self.tf_box.setItemData(0, -1, role=self.ROLE_SLOT) - - for i, (slot, item_label, item_type, item_name, slot_icon) in enumerate( - menu_items - ): - index = i + 1 - self.tf_box.addItem(slot_icon, item_label) - self.tf_box.setItemData(index, slot, role=self.ROLE_SLOT) - self.tf_box.setItemData(index, item_type, role=self.ROLE_ITEM_TYPE) - self.tf_box.setItemData(index, item_name, role=self.ROLE_ITEM_NAME) - if slot == current_slot: - target_index = index # Offset for "Passthrough" item - - # Restore previous item? - if target_index != -1: - self.tf_box.setCurrentIndex(target_index) - - # Switch to "Passthrough" if previous slot not found - if target_index == -1 and self.tf_box.count(): - with SignalsBlocked(self.tf_box): - self.tf_box.setCurrentIndex(0) - - # Force update transform - self._on_transform_changed(0) + config = ocio.GetCurrentConfig() + input_color_space = self.input_color_space() + if input_color_space: + return config.getViews(self.display(), input_color_space) + else: + return config.getViews(self.display()) - def _on_transform_subscription_init(self, slot: int) -> None: + def _get_default_view(self) -> str: """ - If this viewer is not subscribed to a specific transform - subscription slot, subscribe to the first slot to receive a - transform subscription. - - :param slot: Transform subscription slot + Get default OCIO view, given the current display and input + color space. """ - if self._tf_subscription_slot == -1: - index = self.tf_box.findData(slot, role=self.ROLE_SLOT) - if index != -1: - self.tf_box.setCurrentIndex(index) - - @QtCore.Slot(int) - def _on_transform_changed(self, index: int) -> None: - if index == 0: - TransformManager.unsubscribe_from_all_transforms(self.set_transform) - self.clear_transform() + config = ocio.GetCurrentConfig() + input_color_space = None + if input_color_space: + return config.getDefaultView(self.display(), input_color_space) else: - self._tf_subscription_slot = self.tf_box.currentData(role=self.ROLE_SLOT) - TransformManager.subscribe_to_transforms_at( - self._tf_subscription_slot, self.set_transform - ) + return config.getDefaultView(self.display()) - @QtCore.Slot(int) - def _on_tf_subscription_requested(self, slot: int) -> None: - # If the requested slot does not have a subscription, "Passthrough" will - # be selected. - self.tf_box.setCurrentIndex( - max(0, self.tf_box.findData(slot, role=self.ROLE_SLOT)) - ) + def _on_mode_changed(self) -> None: + """Called when the application mode changes.""" + mode = OCIOViewMode.current_mode() - @QtCore.Slot(bool) - def _on_inverse_check_clicked(self, checked: bool) -> None: - self.set_transform(self._tf_subscription_slot, self._tf_fwd, self._tf_inv) - if self.tf_direction_button.isChecked(): - self.tf_direction_button.setIcon(self._tf_direction_inverse_icon) - self.tf_direction_button.setToolTip("Transform direction: Inverse") - # Use 'minus' character to match the width of "+" - self.output_tf_direction_label.setText("\u2212") - else: - self.tf_direction_button.setIcon(self._tf_direction_forward_icon) - self.tf_direction_button.setToolTip("Transform direction: Forward") - self.output_tf_direction_label.setText("+") + if mode == OCIOViewMode.Preview: + self.mode_stack.setCurrentWidget(self.display_view_frame) + else: # Edit + self.mode_stack.setCurrentWidget(self.tf_frame) + + self.output_tf_direction_label.setVisible(mode == OCIOViewMode.Edit) + self.update(force=True) @QtCore.Slot(Path, int, int) - def _on_image_loaded(self, image_path: Path, width: int, height: int) -> None: + def _on_image_loaded( + self, image_path: Path, width: int, height: int + ) -> None: self.image_name_label.setText( self.FMT_GRAY_LABEL.format(v=image_path.as_posix()) ) @@ -669,15 +824,9 @@ def _on_sample_changed( ) ) - @QtCore.Slot(str) - def _on_input_color_space_changed(self, input_color_space: str) -> None: + def _on_input_color_space_changed(self) -> None: self.image_plane.update_ocio_proc( - proc_context=ProcessorContext( - input_color_space, - self.transform_item_type(), - self.transform_item_name(), - self.transform_direction(), - ) + proc_context=self._make_processor_context() ) @QtCore.Slot(float) @@ -688,6 +837,119 @@ def _on_exposure_changed(self, value: float) -> None: def _on_gamma_changed(self, value: float) -> None: self.image_plane.update_gamma(value) + @QtCore.Slot(int) + def _on_display_changed(self, index: int) -> None: + """Called when the display changes.""" + with SignalsBlocked(self.view_box): + self.view_box.update_items() + self._on_view_changed(0) + + @QtCore.Slot(int) + def _on_view_changed(self, index: int) -> None: + """Called when the view changes.""" + self.update(update_items=False) + @QtCore.Slot(int) def _on_sample_precision_changed(self, value: float) -> None: self._sample_format = f"{{v:.{value}f}}" + + def _on_transform_menu_changed( + self, menu_items: list[tuple[int, str, QtGui.QIcon]] + ) -> None: + """ + Called to refresh transform menu items, and either reselect the + existing subscription, or deselect any subscription. + """ + target_index = -1 + current_slot = -1 + if self.tf_box.count(): + current_slot = self.tf_box.currentData(role=self.ROLE_SLOT) + + with SignalsBlocked(self.tf_box): + self.tf_box.clear() + + # The first item is always no transform + self.tf_box.addItem(self.PASSTHROUGH) + self.tf_box.setItemData(0, -1, role=self.ROLE_SLOT) + + for i, ( + slot, + item_label, + item_type, + item_name, + slot_icon, + ) in enumerate(menu_items): + index = i + 1 + self.tf_box.addItem(slot_icon, item_label) + self.tf_box.setItemData(index, slot, role=self.ROLE_SLOT) + self.tf_box.setItemData( + index, item_type, role=self.ROLE_ITEM_TYPE + ) + self.tf_box.setItemData( + index, item_name, role=self.ROLE_ITEM_NAME + ) + if slot == current_slot: + target_index = index # Offset for "Passthrough" item + + # Restore previous item? + if target_index != -1: + self.tf_box.setCurrentIndex(target_index) + + # Switch to "Passthrough" if previous slot not found + if target_index == -1 and self.tf_box.count(): + with SignalsBlocked(self.tf_box): + self.tf_box.setCurrentIndex(0) + + # Force update transform + self._on_transform_changed(0) + + def _on_transform_subscription_init(self, slot: int) -> None: + """ + If this viewer is not subscribed to a specific transform + subscription slot, subscribe to the first slot to receive a + transform subscription. + + :param slot: Transform subscription slot + """ + if self._tf_subscription_slot == -1: + index = self.tf_box.findData(slot, role=self.ROLE_SLOT) + if index != -1: + self.tf_box.setCurrentIndex(index) + + @QtCore.Slot(int) + def _on_transform_changed(self, index: int) -> None: + if index == 0: + TransformManager.unsubscribe_from_all_transforms( + self.set_transform + ) + self.clear_transform() + else: + self._tf_subscription_slot = self.tf_box.currentData( + role=self.ROLE_SLOT + ) + TransformManager.subscribe_to_transforms_at( + self._tf_subscription_slot, self.set_transform + ) + + @QtCore.Slot(int) + def _on_tf_subscription_requested(self, slot: int) -> None: + # If the requested slot does not have a subscription, "Passthrough" will + # be selected. + self.tf_box.setCurrentIndex( + max(0, self.tf_box.findData(slot, role=self.ROLE_SLOT)) + ) + + @QtCore.Slot(bool) + def _on_inverse_check_clicked(self, checked: bool) -> None: + self.set_transform( + self._tf_subscription_slot, self._tf_fwd, self._tf_inv + ) + if self.tf_direction_button.isChecked(): + self.tf_direction_button.setIcon(self._tf_direction_inverse_icon) + self.tf_direction_button.setToolTip("Transform direction: Inverse") + # Use 'minus' character to match the width of "+" + self.output_tf_direction_label.setText("\u2212") + else: + self.tf_direction_button.setIcon(self._tf_direction_forward_icon) + self.tf_direction_button.setToolTip("Transform direction: Forward") + self.output_tf_direction_label.setText("+") diff --git a/src/apps/ocioview/ocioview/viewer_dock.py b/src/apps/ocioview/ocioview/viewer_dock.py index b295fc35c8..243e94363c 100644 --- a/src/apps/ocioview/ocioview/viewer_dock.py +++ b/src/apps/ocioview/ocioview/viewer_dock.py @@ -26,10 +26,19 @@ class ViewerDock(TabbedDockWidget): def __init__( self, recent_images_menu: QtWidgets.QMenu, + corner_widget: Optional[QtWidgets.QWidget] = None, parent: Optional[QtCore.QObject] = None, ): + """ + :param recent_images_menu: Menu for managing recent images + :param corner_widget: Optional widget to place on the right + side of the dock title bar. + """ super().__init__( - "Viewer", get_glyph_icon("mdi6.image-filter-center-focus"), parent=parent + "Viewer", + get_glyph_icon("mdi6.image-filter-center-focus"), + corner_widget=corner_widget, + parent=parent, ) self._recent_images_menu = recent_images_menu @@ -48,7 +57,9 @@ def __init__( # Initialize self._update_recent_images_menu() - def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool: + def eventFilter( + self, watched: QtCore.QObject, event: QtCore.QEvent + ) -> bool: """Tab context menu implementation.""" if watched == self._tab_bar: if event.type() == QtCore.QEvent.ContextMenu: @@ -193,7 +204,9 @@ def _get_recent_image_paths(self) -> list[Path]: num_images = settings.beginReadArray(self.SETTING_RECENT_IMAGES) for i in range(num_images): settings.setArrayIndex(i) - recent_image_path_str = settings.value(self.SETTING_RECENT_IMAGE_PATH) + recent_image_path_str = settings.value( + self.SETTING_RECENT_IMAGE_PATH + ) if recent_image_path_str: recent_image_path = Path(recent_image_path_str) if recent_image_path.is_file(): @@ -215,7 +228,7 @@ def _add_recent_image_path(self, image_path: Path) -> None: image_paths.insert(0, image_path) if len(image_paths) > 10: - image_paths = image_path[:10] + image_paths = image_paths[:10] settings.beginWriteArray(self.SETTING_RECENT_IMAGES) for i, recent_image_path in enumerate(image_paths): diff --git a/src/apps/ocioview/ocioview/widgets/__init__.py b/src/apps/ocioview/ocioview/widgets/__init__.py index 0ce1766718..9f0bf2e0e4 100644 --- a/src/apps/ocioview/ocioview/widgets/__init__.py +++ b/src/apps/ocioview/ocioview/widgets/__init__.py @@ -2,7 +2,12 @@ # Copyright Contributors to the OpenColorIO Project. from .check_box import CheckBox -from .combo_box import ComboBox, EnumComboBox, CallbackComboBox +from .combo_box import ( + ComboBox, + EnumComboBox, + CallbackComboBox, + ColorSpaceComboBox, +) from .layout import FormLayout from .line_edit import ( LineEdit, diff --git a/src/apps/ocioview/ocioview/widgets/combo_box.py b/src/apps/ocioview/ocioview/widgets/combo_box.py index b2a95fe26b..d8c63d29e7 100644 --- a/src/apps/ocioview/ocioview/widgets/combo_box.py +++ b/src/apps/ocioview/ocioview/widgets/combo_box.py @@ -1,11 +1,18 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright Contributors to the OpenColorIO Project. +from __future__ import annotations + import enum -from typing import Callable, Optional +from contextlib import contextmanager +from functools import partial +from typing import Callable, Optional, Union +import PyOpenColorIO as ocio from PySide6 import QtCore, QtGui, QtWidgets +from ..config_cache import ConfigCache +from ..signal_router import SignalRouter from ..utils import SignalsBlocked @@ -133,12 +140,8 @@ def update_items(self) -> str: :return: Current item string """ # Get current state - current_item = None - if not self.count(): - if self._get_default_item is not None: - current_item = self._get_default_item() - else: - current_item = self.currentText() + current_item = self.currentText() + current_item_restored = False # Reload all items with SignalsBlocked(self): @@ -156,11 +159,13 @@ def update_items(self) -> str: else: self.addItems(self._get_items()) - # Restore original state - index = self.findText(current_item) - if index != -1: - self.setCurrentIndex(index) - elif self._get_default_item is not None: + # Restore original state + index = self.findText(current_item) + if index != -1: + self.setCurrentIndex(index) + current_item_restored = True + + if not current_item_restored and self._get_default_item is not None: self.setCurrentText(self._get_default_item()) return self.currentText() @@ -183,3 +188,264 @@ def showPopup(self) -> None: def reset(self) -> None: super().reset() self.update_items() + + +class ColorSpaceComboBox(QtWidgets.QPushButton): + """ + Combo box which maintains a menu of all active color spaces in the + current config. + """ + + color_space_changed = QtCore.Signal() + + def __init__( + self, + reference_space_type: Optional[ocio.SearchReferenceSpaceType] = None, + include_roles: bool = False, + include_use_display_name: bool = False, + parent: Optional[QtCore.QObject] = None, + ): + """ + :param reference_space_type: Optional reference space type. + Defaults to all reference spaces. + :param include_roles: Whether to include a 'Roles' sub-menu + :param include_use_display_name: Whether to include a special + '' item, used when creating shared views. + """ + super().__init__(parent) + self.setStyleSheet("text-align: left; padding-left: 4px;") + self.setMinimumHeight(24) + + if reference_space_type is None: + reference_space_type = ocio.SEARCH_REFERENCE_SPACE_ALL + + self._reference_space_type = reference_space_type + self._include_roles = include_roles + self._include_use_display_name = include_use_display_name + self._config_cache_id = None + self._menu = QtWidgets.QMenu() + self._color_space_actions: dict[str, QtGui.QAction] = {} + self._value: str | None = None + + # Initialize menu + self.update_color_spaces() + self._start_external_updates() + + # DataWidgetMapper user property interface + @QtCore.Property(str, user=True) + def __data(self) -> str: + return self.color_space_name() + + @__data.setter + def __data(self, data: str) -> None: + with SignalsBlocked(self): + self.set_color_space(data) + + def set_color_space( + self, color_space_or_name: Union[ocio.ColorSpace, str] + ) -> bool: + """ + Set the selected color space. + + :param color_space_or_name: Color space object or name + :return: Whether the color space was selected + """ + # Handle special shared view case + if color_space_or_name == ocio.OCIO_VIEW_USE_DISPLAY_NAME: + if self._include_use_display_name: + # Complete selection + self._commit_value(ocio.OCIO_VIEW_USE_DISPLAY_NAME) + return True + else: + return False + + # Detect argument type + if isinstance(color_space_or_name, ocio.ColorSpace): + color_space_name = color_space_or_name.getName() + else: + color_space_name = color_space_or_name + + # Verify color space + config = ocio.GetCurrentConfig() + color_space = config.getColorSpace(color_space_name) + if color_space is not None: + # Uncheck all color spaces + for other_action in self._color_space_actions.values(): + other_action.setChecked(False) + + # Check selected color space + action = self._color_space_actions.get(color_space_name) + if action is None: + return False + else: + action.setChecked(True) + + # Complete selection + self._commit_value(color_space_name, action.text()) + return True + else: + return False + + def color_space(self) -> Union[ocio.ColorSpace, str, None]: + """ + Get the selected color space. + + :return: Color space object, or None if no color space is + selected. `OCIO_VIEW_USE_DISPLAY_NAME` may ne returned + if 'include_use_display_name' was True on initialization. + """ + # Handle special shared view case + if ( + self._include_use_display_name + and self._value == ocio.OCIO_VIEW_USE_DISPLAY_NAME + ): + return ocio.OCIO_VIEW_USE_DISPLAY_NAME + + # Lookup and return the color space instance + config = ocio.GetCurrentConfig() + if self._value: + return config.getColorSpace(self._value) + else: + return None + + def color_space_name(self) -> str | None: + """ + Get the selected color space name. + + :return: Color space name + """ + # Handle special shared view case + if ( + self._include_use_display_name + and self._value == ocio.OCIO_VIEW_USE_DISPLAY_NAME + ): + return ocio.OCIO_VIEW_USE_DISPLAY_NAME + + config = ocio.GetCurrentConfig() + + # Is value a role? + if config.hasRole(self._value): + return self._value + + # Make sure value still references a color space + color_space = self.color_space() + if color_space is not None: + return color_space.getName() + else: + return None + + def update_color_spaces(self) -> None: + """Reload color spaces from the current config.""" + config_cache_id = ConfigCache.get_cache_id() + if ConfigCache.validate() and config_cache_id == self._config_cache_id: + return + + self._config_cache_id = config_cache_id + + # Preserve existing selection if possible + current_name = self.color_space_name() + current_name_available = False + + # Delete previous menu and its actions + self._color_space_actions.clear() + prev_menu = self._menu + if prev_menu is not None: + prev_menu.deleteLater() + + # Replace menu + self._menu = QtWidgets.QMenu() + self.setMenu(self._menu) + + # Add special shared view action + if self._include_use_display_name: + action = self._menu.addAction( + ocio.OCIO_VIEW_USE_DISPLAY_NAME, + partial(self.set_color_space, ocio.OCIO_VIEW_USE_DISPLAY_NAME), + ) + action.setCheckable(True) + self._color_space_actions[ocio.OCIO_VIEW_USE_DISPLAY_NAME] = action + self._menu.addSeparator() + + # Configure color space menu helper + config = ocio.GetCurrentConfig() + + menu_params = ocio.ColorSpaceMenuParameters(config) + menu_params.setIncludeColorSpaces() + menu_params.setSearchReferenceSpaceType(self._reference_space_type) + menu_params.setIncludeRoles(self._include_roles) + + # Build menu hierarchy + menu_helper = ocio.ColorSpaceMenuHelper(menu_params) + + for i in range(menu_helper.getNumColorSpaces()): + name = menu_helper.getName(i) + label = menu_helper.getUIName(i) + family = menu_helper.getFamily(i) + description = menu_helper.getDescription(i) + + if name == current_name: + current_name_available = True + + if family == "Roles": + self._menu.addSeparator() + + parent_menu = self._menu + for level in menu_helper.getHierarchyLevels(i): + child_menu = parent_menu.findChild( + QtWidgets.QMenu, + level, + options=QtCore.Qt.FindDirectChildrenOnly, + ) + if child_menu is None: + child_menu = parent_menu.addMenu(level) + child_menu.setObjectName(level) + parent_menu = child_menu + + # Add color space action + action = parent_menu.addAction( + label, partial(self.set_color_space, name) + ) + action.setToolTip(description) + action.setCheckable(True) + self._color_space_actions[name] = action + + # Restore previous selection or select a reasonable default + if current_name_available: + self.set_color_space(current_name) + else: + default_name = ConfigCache.get_default_color_space_name() + if default_name: + self.set_color_space(default_name) + else: + self.setText("") + + def _commit_value(self, value: str, label: Optional[str] = None) -> None: + """Commit color space value and broadcast to listeners.""" + with self._external_updates_paused(): + self._value = value + self.setText(label or value) + self.color_space_changed.emit() + + def _start_external_updates(self) -> None: + """Start color space updates from external config changes.""" + signal_router = SignalRouter.get_instance() + signal_router.config_reloaded.connect(self.update_color_spaces) + signal_router.color_spaces_changed.connect(self.update_color_spaces) + signal_router.roles_changed.connect(self.update_color_spaces) + + def _stop_external_updates(self) -> None: + """Stop color space updates from external config changes.""" + signal_router = SignalRouter.get_instance() + signal_router.config_reloaded.disconnect(self.update_color_spaces) + signal_router.color_spaces_changed.disconnect(self.update_color_spaces) + signal_router.roles_changed.disconnect(self.update_color_spaces) + + @contextmanager + def _external_updates_paused(self) -> None: + """ + Context manager to pause color space updates from external + config changes within the enclosed scope. + """ + self._stop_external_updates() + yield + self._start_external_updates() diff --git a/src/apps/ocioview/ocioview/widgets/structure.py b/src/apps/ocioview/ocioview/widgets/structure.py index f5fe7d3054..d4f49b61a1 100644 --- a/src/apps/ocioview/ocioview/widgets/structure.py +++ b/src/apps/ocioview/ocioview/widgets/structure.py @@ -15,11 +15,17 @@ class DockTitleBar(QtWidgets.QFrame): """Dock widget title bar widget with icon.""" def __init__( - self, title: str, icon: QtGui.QIcon, parent: Optional[QtCore.QObject] = None + self, + title: str, + icon: QtGui.QIcon, + widget: Optional[QtWidgets.QWidget] = None, + parent: Optional[QtCore.QObject] = None, ): """ :param title: Title text :param icon: Dock icon + :param widget: Optional widget to display opposite the title + and icon. """ super().__init__(parent=parent) @@ -33,6 +39,7 @@ def __init__( self.icon = QtWidgets.QLabel() self.icon.setPixmap(icon.pixmap(ICON_SIZE_ITEM)) self.title = QtWidgets.QLabel(title) + self.widget = widget # Layout inner_layout = QtWidgets.QHBoxLayout() @@ -41,6 +48,8 @@ def __init__( inner_layout.addWidget(self.icon) inner_layout.addWidget(self.title) inner_layout.addStretch() + if widget is not None: + inner_layout.addWidget(self.widget) inner_frame = QtWidgets.QFrame() inner_frame.setFrameShape(QtWidgets.QFrame.StyledPanel) @@ -65,14 +74,20 @@ class TabbedDockWidget(QtWidgets.QDockWidget): _tab_icons = {} def __init__( - self, title: str, icon: QtGui.QIcon, parent: Optional[QtCore.QObject] = None + self, + title: str, + icon: QtGui.QIcon, + corner_widget: Optional[QtWidgets.QWidget] = None, + parent: Optional[QtCore.QObject] = None, ): """ :param title: Title text :param icon: Dock icon + :param corner_widget: Optional widget to place on the right + side of the dock title bar. """ super().__init__(parent=parent) - self.setTitleBarWidget(DockTitleBar(title, icon)) + self.setTitleBarWidget(DockTitleBar(title, icon, widget=corner_widget)) self.setFeatures( QtWidgets.QDockWidget.DockWidgetMovable | QtWidgets.QDockWidget.DockWidgetFloatable @@ -144,7 +159,9 @@ def _rotate_icon( return QtGui.QIcon(pixmap) @QtCore.Slot(QtCore.Qt.DockWidgetArea) - def _on_dock_location_changed(self, area: QtCore.Qt.DockWidgetArea) -> None: + def _on_dock_location_changed( + self, area: QtCore.Qt.DockWidgetArea + ) -> None: """ Adjust tab icons to always orient upward on dock area move. """ @@ -211,5 +228,6 @@ def _on_current_changed(self, index: int) -> None: ) else: widget.setSizePolicy( - QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Ignored + QtWidgets.QSizePolicy.Ignored, + QtWidgets.QSizePolicy.Ignored, ) diff --git a/src/apps/ocioview/pyproject.toml b/src/apps/ocioview/pyproject.toml index ca3e2453fc..858223b4d5 100644 --- a/src/apps/ocioview/pyproject.toml +++ b/src/apps/ocioview/pyproject.toml @@ -13,13 +13,13 @@ imageio = ">= 2, < 3" networkx = ">= 2.7, < 3" numpy = ">= 1.22, < 2" opencolorio = "*" -qtawesome = "*" pygfx = "*" -wpgu = "*" pygments = "*" pyopengl = "*" pyside6 = "*" +qtawesome = "*" scipy = ">= 1.8, < 2" +wpgu = "*" [tool.poetry.group.dev.dependencies] black = "*" @@ -33,6 +33,7 @@ pre-commit = "*" pyright = "*" pytest = "*" pytest-cov = "*" +pytest-qt = "*" pytest-xdist = "*" ruff = "*" toml = "*" diff --git a/src/apps/ocioview/requirements.txt b/src/apps/ocioview/requirements.txt index 55d31e852e..124acd49da 100644 --- a/src/apps/ocioview/requirements.txt +++ b/src/apps/ocioview/requirements.txt @@ -1,12 +1,13 @@ -OpenColorIO -PyOpenGL -PySide6 -QtAwesome colour-science @ git+https://github.com/colour-science/colour.git colour-visuals @ git+https://github.com/colour-science/colour-visuals.git imageio networkx numpy +OpenColorIO pygfx pygments +PyOpenGL +PySide6 +QtAwesome +scipy wgpu diff --git a/src/apps/ocioview/tests/conftest.py b/src/apps/ocioview/tests/conftest.py new file mode 100644 index 0000000000..94240b71ac --- /dev/null +++ b/src/apps/ocioview/tests/conftest.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenColorIO Project. + +import pytest + + +@pytest.fixture(scope="session") +def qapp(qapp): + """ + pytest-qt qapp fixture override, with ocioview-specific setup + steps. + """ + from ocioview.setup import setup_app + + setup_app(qapp) + return qapp + + +@pytest.fixture +def qtbot(qapp, qtbot): + """ + pytest-qt qtbot fixture override, injecting the overridden qapp + fixture before qtbot can initialize the default implementation. + """ + return qtbot + + +@pytest.fixture(scope="session") +def ocio(): + import PyOpenColorIO as ocio + + return ocio + + +@pytest.fixture +def ocio_view(ocio, qtbot): + from ocioview.main_window import OCIOView + + ocio_view = OCIOView(transient=True) + ocio_view.show() + qtbot.addWidget(ocio_view) + + return ocio_view + + +@pytest.fixture +def ocio_config(ocio): + """ + .. note:: + This fixture should be used AFTER the `ocio_view` fixture, + since `OCIOView` instantiation resets the current config, + invalidating any existing references. + """ + return ocio.GetCurrentConfig() diff --git a/src/apps/ocioview/tests/items/test_color_space_edit.py b/src/apps/ocioview/tests/items/test_color_space_edit.py new file mode 100644 index 0000000000..596265cf61 --- /dev/null +++ b/src/apps/ocioview/tests/items/test_color_space_edit.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright Contributors to the OpenColorIO Project. + + +def test_add_and_remove_color_space(ocio_view, ocio_config): + color_space_count = len(ocio_config.getColorSpaces()) + assert ocio_config.getColorSpace("ColorSpace_1") is None + + edit = ocio_view.config_dock.color_space_edit + edit.list.add_button.click() + + assert ocio_config.getColorSpace("ColorSpace_1") is not None + assert len(ocio_config.getColorSpaces()) == color_space_count + 1 + + edit.list.remove_button.click() + + assert ocio_config.getColorSpace("ColorSpace_1") is None + assert len(ocio_config.getColorSpaces()) == color_space_count + + +def test_rename_color_space(ocio_view, ocio_config): + edit = ocio_view.config_dock.color_space_edit + edit.list.add_button.click() + color_space_count = len(ocio_config.getColorSpaces()) + + assert ocio_config.getColorSpace("ColorSpace_1") is not None + assert ocio_config.getColorSpace("test") is None + + edit.param_edit.name_edit.set_value("test") + edit.mapper.submit() + + assert ocio_config.getColorSpace("ColorSpace_1") is None + assert ocio_config.getColorSpace("test") is not None + assert len(ocio_config.getColorSpaces()) == color_space_count + + +def test_edit_color_space_reference_space_type(ocio, ocio_view, ocio_config): + edit = ocio_view.config_dock.color_space_edit + edit.list.add_button.click() + + assert ( + ocio_config.getColorSpace("ColorSpace_1").getReferenceSpaceType() + == ocio.REFERENCE_SPACE_SCENE + ) + + edit.param_edit.reference_space_type_combo.set_member( + ocio.REFERENCE_SPACE_DISPLAY + ) + edit.mapper.submit() + + assert ( + ocio_config.getColorSpace("ColorSpace_1").getReferenceSpaceType() + == ocio.REFERENCE_SPACE_DISPLAY + )