diff --git a/README.md b/README.md index 2b387a243..116b3ce2a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - +

diff --git a/bookmarks/__init__.py b/bookmarks/__init__.py index a03bc878b..59e139e9e 100644 --- a/bookmarks/__init__.py +++ b/bookmarks/__init__.py @@ -76,7 +76,7 @@ .. |label3| image:: https://img.shields.io/badge/Platform-Windows-lightgrey :height: 18 -.. |label4| image:: https://img.shields.io/badge/Version-v0.8.5-green +.. |label4| image:: https://img.shields.io/badge/Version-v0.9.1-green :height: 18 .. |image1| image:: ./images/active_bookmark.png @@ -100,7 +100,7 @@ __email__ = 'hello@gergely-wootsch.com' #: Project version -__version__ = '0.8.5' +__version__ = '0.9.1' #: Project version __version_info__ = __version__.split('.') diff --git a/bookmarks/actions.py b/bookmarks/actions.py index 670280d59..fcb2ddb86 100644 --- a/bookmarks/actions.py +++ b/bookmarks/actions.py @@ -16,6 +16,7 @@ from . import common from . import database from . import images +from. import log def must_be_initialized(func): @@ -449,6 +450,30 @@ def set_active(k, v): common.settings.setValue(f'active/{k}', v) +@QtCore.Slot(QtCore.QModelIndex) +def apply_default_to_scenes_folder(index): + """Slot responsible for applying the default to scenes folder setting. + + The slot is connected to the :class:`bookmarks.items.file_items.FileItemModel`'s + `activeChanged` signal, and we're using it to modify the active 'task' path before + resetting the file model. + + Args: + index (QtCore.QModelIndex): The index of the activated asset items. + + """ + v = common.settings.value('settings/default_to_scenes_folder') + if not v: + return + + if not index.isValid(): + return + + # Get the current scene folder name from the token configuration. + from .tokens import tokens + set_active('task', tokens.get_folder(tokens.SceneFolder)) + + @common.error @common.debug def set_task_folder(v): @@ -703,11 +728,11 @@ def reset_row_size(): @common.error @common.debug -def show_bookmarker(): - """Shows :class:`~bookmarks.bookmarker.main.BookmarkerWidget`. +def show_job_editor(): + """Shows :class:`~bookmarks.editor.jobs.JobsEditor` widget. """ - from .bookmarker import main as editor + from .editor import jobs as editor widget = editor.show() return widget @@ -843,7 +868,7 @@ def add_item(): """ idx = common.current_tab() if idx == common.BookmarkTab: - show_bookmarker() + show_job_editor() elif idx == common.AssetTab: show_add_asset() elif idx == common.FileTab: @@ -883,6 +908,14 @@ def refresh(idx=None): """ w = common.widget(idx=idx) model = w.model().sourceModel() + + # Remove the asset list cache if we're forcing a refresh on the asset tab + if common.current_tab() == common.AssetTab: + cache = f'{common.active("root", path=True)}/{common.bookmark_cache_dir}/assets.cache' + if os.path.exists(cache): + log.debug('Removing asset cache:', cache) + os.remove(cache) + model.reset_data(force=True) @@ -1401,6 +1434,68 @@ def execute(index, first=False): else: path = common.get_sequence_end_path(path) + ext = QtCore.QFileInfo(path).suffix() + + # Handle Maya files + if ext in ('ma', 'mb'): + for app in ( + 'maya', 'maya2017', 'maya2018', 'maya2019', 'maya2020', 'maya2022', 'maya2023', 'maya2024', + 'maya2025', 'maya2026' + ): + executable = common.get_binary(app) + if not executable: + continue + execute_detached(executable, args=['-file', path]) + return + + # Handle Nuke files + if ext in ('nk', 'nknc'): + executable = common.get_binary('nuke') + if executable: + execute_detached(path, args=[path,]) + return + + # Handle Houdini files + if ext == 'hiplc': + for app in ('houdiniinidie', 'houindie', 'houind', 'houdiniind', 'hindie'): + executable = common.get_binary(app) + if executable: + execute_detached(executable, args=[path,]) + return + + if ext == 'hip': + for app in ('houdini', 'houdinifx', 'houfx', 'hfx', 'houdinicore', 'hcore'): + executable = common.get_binary(app) + if executable: + execute_detached(executable, args=[path,]) + return + + # Handle RV files + if ext == 'rv': + for app in ('rv', 'tweakrv', 'shotgunrv', 'shotgridrv', 'sgrv'): + executable = common.get_binary(app) + if executable: + execute_detached(executable, args=[path,]) + return + + # Handle blender files + if ext in ('blend', ): + for app in ('blender', 'blender2.8', 'blender2.9', 'blender3', 'blender3.0', 'blender3.1', 'blender3.2', + 'blender3.3', 'blender3.4', 'blender3.5' + ): + executable = common.get_binary(app) + if executable: + execute_detached(executable, args=[path,]) + return + + # Handle After Effects files + if ext in ('aep', ): + for app in ('afterfx', 'aftereffects', 'ae', 'afx'): + executable = common.get_binary(app) + if executable: + execute_detached(executable, args=[path,]) + return + url = QtCore.QUrl.fromLocalFile(path) QtGui.QDesktopServices.openUrl(url) @@ -1505,20 +1600,39 @@ def pick_thumbnail_from_library(index): ) -def execute_detached(path): +def execute_detached(path, args=None): """Utility function used to execute a file as a detached process. - On Windows, we'll call the give file through the explorer. This is so, that the - new process does not inherit the current environment. + On Windows, we'll call the given file using the file explorer as we want to + avoid the process inheriting the parent process' environment variables. + Args: + path (str): The path to the file to execute. + args (list): A list of optional arguments to pass to the process. """ if common.get_platform() == common.PlatformWindows: proc = QtCore.QProcess() - proc.setProgram('cmd.exe') - proc.setArguments( - ['/c', 'start', '/i', "%windir%\explorer.exe", os.path.normpath(path)] - ) + + proc.setProgram(os.path.normpath(path)) + if args: + proc.setArguments(args) + + # We don't want to pass on our current environment (we might be calling from inside a DCC) + env = QtCore.QProcessEnvironment.systemEnvironment() + + # But we do want to pass on the currently active items. This information can be used in an + # unsupported DCC to manipulate context + env.insert('BOOKMARKS_ROOT', os.environ['BOOKMARKS_ROOT']) + env.insert('BOOKMARKS_ACTIVE_SERVER', common.active('server')) + env.insert('BOOKMARKS_ACTIVE_JOB', common.active('job')) + env.insert('BOOKMARKS_ACTIVE_ROOT', common.active('root')) + env.insert('BOOKMARKS_ACTIVE_ASSET', common.active('asset')) + env.insert('BOOKMARKS_ACTIVE_TASK', common.active('task')) + + proc.setProcessEnvironment(env) proc.startDetached() + else: + raise NotImplementedError('Not implemented.') @common.debug @@ -1654,6 +1768,12 @@ def convert_image_sequence(index): from .external import ffmpeg_widget ffmpeg_widget.show(index) +@common.debug +@common.error +@selection +def convert_image_sequence_with_akaconvert(index): + from .external import akaconvert + akaconvert.show(index) def add_zip_template(source, mode, prompt=False): """Adds the selected source zip archive as a `mode` template file. @@ -1821,11 +1941,9 @@ def pick_template(mode): dialog.setNameFilters(['*.zip', ]) dialog.setFilter(QtCore.QDir.Files | QtCore.QDir.NoDotAndDotDot) dialog.setLabelText( - QtWidgets.QFileDialog.Accept, 'Select a {} template'.format(mode.title()) - ) - dialog.setWindowTitle( - 'Select *.zip archive to use as a {} template'.format(mode.lower()) + QtWidgets.QFileDialog.Accept, f'Select a {mode.title()} template' ) + dialog.setWindowTitle(f'Select *.zip archive to use as a {mode.lower()} template') if dialog.exec_() == QtWidgets.QDialog.Rejected: return source = next((f for f in dialog.selectedFiles()), None) diff --git a/bookmarks/bookmarker/__init__.py b/bookmarks/bookmarker/__init__.py deleted file mode 100644 index a76ca2dfe..000000000 --- a/bookmarks/bookmarker/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""This module defines the widgets needed to allow the user to select and create -bookmark items. - -This requires specifying servers, creating job folders, and letting the user pick -existing folders to be used as bookmark items. - -See :class:`~bookmarks.bookmarker.main.BookmarkerWidget`, -:class:`~bookmarks.bookmarker.server_editor.ServerItemEditor`, -:class:`~bookmarks.bookmarker.job_editor.JobItemEditor`, -and :class:`~bookmarks.bookmarker.bookmark_editor.BookmarkItemEditor` for more -information. - - -To show the main editor call: - -.. code-block:: python - :linenos: - - import bookmarks.actions - bookmarks.actions.show_bookmarker() - - -""" diff --git a/bookmarks/bookmarker/bookmark_editor.py b/bookmarks/bookmarker/bookmark_editor.py deleted file mode 100644 index 4483b36d0..000000000 --- a/bookmarks/bookmarker/bookmark_editor.py +++ /dev/null @@ -1,375 +0,0 @@ -"""Editor widget used by :class:`~bookmarks.bookmarker.main.BookmarkerWidget` to save -bookmark items to the user settings file. - - -""" -import functools -import json -import os - -from PySide2 import QtCore, QtWidgets - -from .. import actions -from .. import common -from .. import contextmenu -from .. import log -from .. import shortcuts -from .. import ui - - -class BookmarkEditorContextMenu(contextmenu.BaseContextMenu): - """Context menu associated with :class:`BookmarkItemEditor`. - - """ - - def setup(self): - """Creates the context menu. - - """ - self.add_menu() - self.separator() - if isinstance( - self.index, QtWidgets.QListWidgetItem - ) and self.index.flags() & QtCore.Qt.ItemIsEnabled: - self.bookmark_properties_menu() - self.reveal_menu() - self.copy_json_menu() - self.separator() - self.refresh_menu() - - def add_menu(self): - """Add bookmark item action.""" - self.menu[contextmenu.key()] = { - 'text': 'Pick a new bookmark item...', - 'action': self.parent().add, - 'icon': ui.get_icon('add', color=common.color(common.color_green)) - } - - def reveal_menu(self): - """Reveal bookmark item action.""" - self.menu[contextmenu.key()] = { - 'text': 'Reveal', - 'action': functools.partial( - actions.reveal, f'{self.index.data(QtCore.Qt.UserRole)}/.' - ), - 'icon': ui.get_icon('folder') - } - - def refresh_menu(self): - """Refresh bookmark item list action.""" - self.menu[contextmenu.key()] = { - 'text': 'Refresh', - 'action': self.parent().init_data, - 'icon': ui.get_icon('refresh') - } - - def bookmark_properties_menu(self): - """Show the bookmark item property editor.""" - server = self.parent().window().server() - job = self.parent().window().job() - root = self.index.data(QtCore.Qt.DisplayRole) - - self.menu[contextmenu.key()] = { - 'text': 'Edit Properties...', - 'action': functools.partial(actions.edit_bookmark, server, job, root), - 'icon': ui.get_icon('settings') - } - - def copy_json_menu(self): - """Copy bookmark item as JSON action.""" - server = self.parent().window().server() - job = self.parent().window().job() - root = self.index.data(QtCore.Qt.DisplayRole) - - d = { - f'{server}/{job}/{root}': { - 'server': server, - 'job': job, - 'root': root - } - } - s = json.dumps(d) - - self.menu[contextmenu.key()] = { - 'text': 'Copy as JSON', - 'action': functools.partial( - QtWidgets.QApplication.clipboard().setText, s - ), - 'icon': ui.get_icon('copy') - } - - -class BookmarkItemEditor(ui.ListWidget): - """List widget containing a job's available bookmark items. - - """ - loaded = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__( - default_icon='folder', - parent=parent - ) - - self._interrupt_requested = False - - self.setWindowTitle('Bookmark Item Editor') - self.setObjectName('BookmarkEditor') - - self.setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.viewport().setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.viewport().setAttribute(QtCore.Qt.WA_TranslucentBackground) - - self.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.MinimumExpanding - ) - self.setMinimumWidth(common.size(common.size_width) * 0.2) - - self._connect_signals() - self._init_shortcuts() - - def _init_shortcuts(self): - """Initialize shortcuts.""" - shortcuts.add_shortcuts(self, shortcuts.BookmarkEditorShortcuts) - connect = functools.partial( - shortcuts.connect, shortcuts.BookmarkEditorShortcuts - ) - connect(shortcuts.AddItem, self.add) - - def _connect_signals(self): - """Connect signals.""" - super()._connect_signals() - - self.itemActivated.connect(self.toggle_item_state) - self.itemChanged.connect( - lambda item: self.add_remove_bookmark( - item.checkState(), - item.data(QtCore.Qt.DisplayRole) - ) - ) - - common.signals.serversChanged.connect(self.init_data) - - @QtCore.Slot(QtWidgets.QListWidgetItem) - def add_remove_bookmark(self, state, v): - """Slot used to add or remove a bookmark item. - - Args: - state (QtCore.Qt.CheckState): A check state. - v (QtWidgets.QListWidgetItem): The item that was just toggled. - - """ - if state == QtCore.Qt.Checked: - try: - actions.add_bookmark( - self.window().server(), - self.window().job(), - v - ) - except: - log.error('Could not add bookmark') - elif state == QtCore.Qt.Unchecked: - try: - actions.remove_bookmark( - self.window().server(), - self.window().job(), - v - ) - except: - log.error('Could not remove bookmark') - else: - raise ValueError('Invalid check state.') - - @QtCore.Slot(QtWidgets.QListWidgetItem) - def toggle_item_state(self, item): - """Slot used to toggle the check state of an item.""" - if item.checkState() == QtCore.Qt.Checked: - item.setCheckState(QtCore.Qt.Unchecked) - elif item.checkState() == QtCore.Qt.Unchecked: - item.setCheckState(QtCore.Qt.Checked) - self.add_remove_bookmark( - item.checkState(), - item.data(QtCore.Qt.DisplayRole) - ) - - @common.debug - @common.error - @QtCore.Slot() - def add(self, *args, **kwargs): - """Pick and add a folder as a new bookmark item. - - """ - if not self.window().server() or not self.window().job(): - return - - path = QtWidgets.QFileDialog.getExistingDirectory( - self, - 'Pick a new bookmark folder', - self.window().job_path(), - QtWidgets.QFileDialog.ShowDirsOnly | - QtWidgets.QFileDialog.DontResolveSymlinks - ) - - if not path: - return - - if self.window().job_path() not in path: - raise RuntimeError('Bookmark item must be inside the current job folder.') - dir = QtCore.QDir(path) - if not dir.exists() and not dir.mkdir(common.bookmark_cache_dir): - raise RuntimeError('Could not create bookmark') - - name = path[len(self.window().job_path()) + 1:] - - for n in range(self.count()): - item = self.item(n) - if item.data(QtCore.Qt.DisplayRole) == name: - common.show_message( - f'Cannot add "{name}" as a bookmark', - body='"{name}" is already a bookmark item.', - message_type='error' - ) - return - - # Add link - if not common.add_link(self.window().job_path(), name, section='links/root'): - log.error('Could not add link') - - # Add the QListWidgetItem - item = QtWidgets.QListWidgetItem() - item.setFlags( - QtCore.Qt.ItemIsEnabled | - QtCore.Qt.ItemIsSelectable | - QtCore.Qt.ItemIsUserCheckable - ) - item.setCheckState(QtCore.Qt.Unchecked) - item.setData(QtCore.Qt.DisplayRole, name) - item.setData(QtCore.Qt.UserRole, path) - size = QtCore.QSize( - 0, - common.size(common.size_margin) * 2 - ) - item.setSizeHint(size) - self.update_state(item) - self.insertItem(self.count(), item) - self.setCurrentItem(item) - - def contextMenuEvent(self, event): - """Context menu event.""" - item = self.itemAt(event.pos()) - menu = BookmarkEditorContextMenu(item, parent=self) - pos = event.pos() - pos = self.mapToGlobal(pos) - menu.move(pos) - menu.exec_() - - @QtCore.Slot() - def init_data(self): - """Initializes data. - - """ - self.clear() - - if not self.window().job(): - return - - max_recursion = common.settings.value('settings/job_scan_depth') - max_recursion = 3 if not max_recursion else max_recursion - - it = self.item_generator(self.window().job_path(), max_recursion=max_recursion) - items = sorted(set({f for f in it})) - - for name, path in items: - self.progressUpdate.emit(f'Parsing {path}...') - - item = QtWidgets.QListWidgetItem() - item.setFlags( - QtCore.Qt.ItemIsEnabled | - QtCore.Qt.ItemIsSelectable | - QtCore.Qt.ItemIsUserCheckable - ) - item.setData(QtCore.Qt.DisplayRole, name) - item.setData(QtCore.Qt.UserRole, path) - item.setData(QtCore.Qt.StatusTipRole, path) - item.setData(QtCore.Qt.WhatsThisRole, path) - item.setData(QtCore.Qt.ToolTipRole, path) - item.setSizeHint(QtCore.QSize(0, common.size(common.size_margin) * 2)) - - self.update_state(item) - self.insertItem(self.count(), item) - - self.progressUpdate.emit('') - - @QtCore.Slot(QtWidgets.QListWidgetItem) - def update_state(self, item): - """Checks if the item is part of the current bookmark set and sets the item's - check state and icon accordingly. - - Args: - item(QtWidgets.QListWidgetItem): The item to verify. - - """ - self.blockSignals(True) - if item.data(QtCore.Qt.UserRole) in list(common.bookmarks): - item.setCheckState(QtCore.Qt.Checked) - else: - item.setCheckState(QtCore.Qt.Unchecked) - self.blockSignals(False) - - def item_generator(self, path, recursion=0, max_recursion=3): - """Recursive scanning function for finding bookmark folders - inside the given path. - - """ - if self._interrupt_requested: - return - - # If links exist, return items stored in the link file and nothing else - if recursion == 0: - links = common.get_links(path, section='links/root') - for v in links: - if self._interrupt_requested: - return - yield v, f'{path}/{v}' - - # Otherwise parse the folder - recursion += 1 - if recursion > max_recursion: - return - - # Let unreadable paths fail silently - try: - it = os.scandir(path) - except: - return - - for entry in it: - if not entry.is_dir(): - continue - - # yield the match - path = entry.path.replace('\\', '/') - self.progressUpdate.emit(f'Scanning {path}. Please wait...') - - if entry.name == common.bookmark_cache_dir: - _path = '/'.join(path.split('/')[:-1]) - _name = _path[len(self.window().job_path()) + 1:] - yield _name, _path - - for _name, _path in self.item_generator( - path, - recursion=recursion, - max_recursion=max_recursion - ): - if self._interrupt_requested: - return - yield _name, _path - - def keyPressEvent(self, event): - """Key press event handler. - - """ - if event.key() == QtCore.Qt.Key_Escape: - self._interrupt_requested = True diff --git a/bookmarks/bookmarker/job_editor.py b/bookmarks/bookmarker/job_editor.py deleted file mode 100644 index f412f139d..000000000 --- a/bookmarks/bookmarker/job_editor.py +++ /dev/null @@ -1,485 +0,0 @@ -"""Editor widget used by :class:`~bookmarks.bookmarker.main.BookmarkerWidget` to add and -select jobs found inside a server. - -The module also defines :class:`AddJobWidget`, an editor used to create new jobs inside -a server. - -""" -import functools -import os - -from PySide2 import QtCore, QtGui, QtWidgets - -from .. import actions -from .. import common -from .. import contextmenu -from .. import images -from .. import log -from .. import shortcuts -from .. import templates -from .. import ui -from ..editor import base - - -def get_job_icon(path): - """Checks the given job folder for the presence of a thumbnail image file. - - """ - for entry in os.scandir(path): - if 'thumbnail' not in entry.name.lower(): - continue - pixmap = images.ImageCache.get_pixmap( - QtCore.QFileInfo(entry.path).filePath(), - common.thumbnail_size - ) - if not pixmap or pixmap.isNull(): - continue - return QtGui.QIcon(pixmap) - - path = common.rsc( - f'{common.GuiResource}/asset_item.{common.thumbnail_format}' - ) - pixmap = images.ImageCache.get_pixmap(path, common.thumbnail_size) - return QtGui.QIcon(pixmap) - - -class AddJobWidget(base.BasePropertyEditor): - """A custom `BasePropertyEditor` used to add new jobs on a server. - - """ - buttons = ('Create Job', 'Cancel') - - #: UI layout definition - sections = { - 0: { - 'name': 'Add Job', - 'icon': '', - 'color': common.color(common.color_dark_background), - 'groups': { - 0: { - 0: { - 'name': 'Name', - 'key': None, - 'validator': base.job_name_validator, - 'widget': ui.LineEdit, - 'placeholder': 'Name, e.g. `MY_NEW_JOB`', - 'description': 'The job\'s name, e.g. `MY_NEW_JOB`.', - }, - }, - 1: { - 0: { - 'name': 'Template', - 'key': None, - 'validator': None, - 'widget': functools.partial( - templates.TemplatesWidget, templates.JobTemplateMode - ), - 'placeholder': None, - 'description': 'Select a folder template to create this asset.', - }, - }, - }, - }, - } - - def __init__(self, server, parent=None): - super().__init__( - server, - None, - None, - asset=None, - db_table=None, - buttons=self.buttons, - parent=parent - ) - - self.jobs = None - self.setWindowTitle(f'{self.server}: Add Job') - - common.signals.templateExpanded.connect(self.close) - common.signals.jobAdded.connect(self.close) - common.signals.serversChanged.connect(self.close) - - def db_source(self): - """A file path to use as the source of database values. - - Returns: - str: The database source file. - - """ - return None - - def init_data(self): - """Initialize data. - - """ - self.jobs = [] - items = [] - - it = self.parent().job_editor.item_generator(self.server, emit_progress=False) - for name, path in it: - items.append(name) - - completer = QtWidgets.QCompleter(items, parent=self) - completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) - common.set_stylesheet(completer.popup()) - self.name_editor.setCompleter(completer) - self.name_editor.setFocus(QtCore.Qt.MouseFocusReason) - - @common.error - @common.debug - def save_changes(self): - """Saves changes. - - """ - text = self.name_editor.text() - if not text: - raise ValueError('Must enter a name to create a job.') - - text = text.replace('\\', '/') - root = self.server - - if '/' in self.name_editor.text(): - _name = text.split('/')[-1] - _root = text[:-len(_name) - 1] - name = _name - root = f'{self.server}/{_root}'.rstrip('/') - - if not QtCore.QFileInfo(root).exists(): - if not QtCore.QDir(root).mkpath('.'): - raise RuntimeError('Error creating folders.') - else: - name = text - - # Create template and signal - self.template_editor.template_list_widget.create(name, root) - path = f'{root}/{name}' - - # Save link - if '/' in text: - if not common.add_link(root, name, section='links/job'): - log.error('Could not add link') - - file_info = QtCore.QFileInfo(path) - if not file_info.exists(): - raise RuntimeError('Could not find the added job.') - - try: - path += f'/thumbnail.{common.thumbnail_format}' - self.thumbnail_editor.save_image(destination=path) - except: - pass - - common.signals.jobAdded.emit(file_info.filePath()) - common.show_message(f'{name} was successfully created.', message_type='success') - return True - - -class JobContextMenu(contextmenu.BaseContextMenu): - """Context menu associated with :class:`JobItemEditor`. - - """ - - def setup(self): - """Creates the context menu. - - """ - self.add_menu() - self.separator() - self.reveal_menu() - self.separator() - self.refresh_menu() - - def add_menu(self): - """Add job item action. - - """ - self.menu['Add Job...'] = { - 'action': self.parent().add, - 'icon': ui.get_icon('add', color=common.color(common.color_green)) - } - - def reveal_menu(self): - """Reveal job item action. - - """ - self.menu['Reveal...'] = { - 'action': lambda: actions.reveal( - f'{self.index.data(QtCore.Qt.UserRole)}/.' - ), - 'icon': ui.get_icon('folder') - } - - def refresh_menu(self): - """Refresh job list action. - - """ - self.menu['Refresh'] = { - 'action': self.parent().init_data, - 'icon': ui.get_icon('refresh') - } - - -class JobItemEditor(ui.ListViewWidget): - """Simple list widget used to add and remove servers to/from the local - common. - - """ - - def __init__(self, parent=None): - super().__init__( - default_icon='asset', - parent=parent - ) - - self._interrupt_requested = False - - self.setWindowTitle('Job Editor') - self.setObjectName('JobEditor') - - self.setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.viewport().setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.viewport().setAttribute(QtCore.Qt.WA_TranslucentBackground) - - self.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.MinimumExpanding - ) - self.setMinimumWidth(common.size(common.size_width) * 0.2) - - self._connect_signals() - self._init_shortcuts() - - def _init_shortcuts(self): - """Initialize shortcuts. - - """ - shortcuts.add_shortcuts(self, shortcuts.BookmarkEditorShortcuts) - connect = functools.partial( - shortcuts.connect, shortcuts.BookmarkEditorShortcuts - ) - connect(shortcuts.AddItem, self.add) - - def _connect_signals(self): - """Connect signals. - - """ - super()._connect_signals() - - self.selectionModel().selectionChanged.connect( - functools.partial(common.save_selection, self) - ) - - common.signals.bookmarkAdded.connect(self.update_text) - common.signals.bookmarkRemoved.connect(self.update_text) - - common.signals.jobAdded.connect(self.init_data) - common.signals.jobAdded.connect( - lambda v: common.select_index(self, v, role=QtCore.Qt.UserRole) - ) - common.signals.serversChanged.connect(self.init_data) - - def item_generator(self, source, emit_progress=True): - """Scans the given source, usually a server, to find job items. - - """ - if emit_progress: - self.progressUpdate.emit('') - - # Parse source otherwise - for entry in os.scandir(source): - if self._interrupt_requested: - if emit_progress: - self.progressUpdate.emit('') - return - - if not entry.is_dir(): - continue - - file_info = QtCore.QFileInfo(entry.path) - if emit_progress: - self.progressUpdate.emit(f'Scanning: {file_info.filePath()}') - - if file_info.isHidden(): - continue - if not file_info.isReadable(): - continue - # Test access - try: - next(os.scandir(file_info.filePath())) - except: - continue - - # Use paths in the link file, if available - links = common.get_links(file_info.filePath(), section='links/job') - if links: - for link in links: - _file_info = QtCore.QFileInfo(f'{file_info.filePath()}/{link}') - _name = _file_info.filePath()[len(source) + 1:] - yield _name, _file_info.filePath() - else: - yield entry.name, file_info.filePath() - - if emit_progress: - self.progressUpdate.emit('') - - @QtCore.Slot() - def init_data(self, *args, **kwargs): - """Load job item data. - - """ - selected_index = common.get_selected_index(self) - selected_name = selected_index.data( - QtCore.Qt.DisplayRole - ) if selected_index.isValid() else None - - self.selectionModel().blockSignals(True) - - self.model().sourceModel().clear() - self._interrupt_requested = False - - if ( - not self.window().server() or - not QtCore.QFileInfo(self.window().server()).exists() - ): - self.selectionModel().blockSignals(False) - return - - for name, path in self.item_generator(self.window().server()): - item = QtGui.QStandardItem() - - item.setFlags( - QtCore.Qt.ItemIsEnabled | - QtCore.Qt.ItemIsSelectable - ) - - _name = ( - name. - replace('_', ' '). - replace(' ', ' '). - strip(). - replace('/', ' | '). - strip() - ) - - item.setData(_name, role=QtCore.Qt.DisplayRole) - item.setData(path, role=QtCore.Qt.UserRole) - item.setData(name, role=QtCore.Qt.UserRole + 1) - item.setData(path, role=QtCore.Qt.StatusTipRole) - item.setData(path, role=QtCore.Qt.WhatsThisRole) - item.setData(path, role=QtCore.Qt.ToolTipRole) - - size = QtCore.QSize(0, common.size(common.size_margin) * 2) - item.setSizeHint(size) - - _icon = get_job_icon(path) - item.setData( - _icon if _icon else None, - role=QtCore.Qt.DecorationRole - ) - - self.addItem(item) - - self.update_text() - self.progressUpdate.emit('Loading jobs...') - - if selected_name: - for idx in range(self.model().rowCount()): - index = self.model().index(idx, 0) - if index.data(QtCore.Qt.DisplayRole) == selected_name: - self.selectionModel().select( - index, QtCore.QItemSelectionModel.ClearAndSelect - ) - break - - self.selectionModel().blockSignals(False) - - common.restore_selection(self) - self.progressUpdate.emit('') - - @QtCore.Slot() - def update_text(self): - """Checks each job item to see if they have active bookmark items, and marks - them visually as active. - - """ - if not self.model(): - return - - active_jobs = set([f['job'] for f in common.bookmarks.values()]) - suffix = ' (Active)' - - for n in range(self.model().rowCount()): - index = self.model().index(n, 0) - if not index.isValid(): - continue - source_index = self.model().mapToSource(index) - if not source_index.isValid(): - continue - item = self.model().sourceModel().itemFromIndex(source_index) - if not item: - continue - - if item.data(QtCore.Qt.UserRole + 1) in active_jobs: - item.setData( - common.color(common.color_green), - role=QtCore.Qt.ForegroundRole - ) - if suffix not in item.data(QtCore.Qt.DisplayRole): - item.setData( - f'{item.data(QtCore.Qt.DisplayRole)}{suffix}', - role=QtCore.Qt.DisplayRole - ) - continue - - item.setData( - common.color(common.color_text), - role=QtCore.Qt.ForegroundRole - ) - name = item.data(QtCore.Qt.DisplayRole).replace(suffix, '') - item.setData( - name, - role=QtCore.Qt.DisplayRole - ) - - @QtCore.Slot() - def add(self): - """Opens the widget used to create new job items. - - """ - if not self.window().server(): - return - - widget = AddJobWidget(self.window().server(), parent=self.window()) - widget.open() - - def set_filter(self, v): - """Set a search filter. - - Args: - v (str): The search filter. - - """ - self.selectionModel().blockSignals(True) - self.model().setFilterWildcard(v) - common.restore_selection(self) - self.selectionModel().blockSignals(False) - - def keyPressEvent(self, event): - """Key press event handler. - - """ - if event.key() == QtCore.Qt.Key_Escape: - self._interrupt_requested = True - - def contextMenuEvent(self, event): - """Context menu event handler. - - """ - item = self.indexAt(event.pos()) - menu = JobContextMenu(item, parent=self) - pos = event.pos() - pos = self.mapToGlobal(pos) - menu.move(pos) - menu.exec_() diff --git a/bookmarks/bookmarker/main.py b/bookmarks/bookmarker/main.py deleted file mode 100644 index c5ed2f2ba..000000000 --- a/bookmarks/bookmarker/main.py +++ /dev/null @@ -1,396 +0,0 @@ -""":class:`BookmarkerWidget`, the main editor widget. - -The editor is made up of :class:`~bookmarks.bookmarker.server_editor.ServerItemEditor`, -:class:`~bookmarks.bookmarker.job_editor.JobItemEditor` and -:class:`~bookmarks.bookmarker.bookmark_editor.BookmarkItemEditor`, and defines -functionality needed saver and remove bookmark items to and from the user settings file. - -""" - -from PySide2 import QtCore, QtWidgets - -from . import bookmark_editor -from . import job_editor -from . import server_editor -from .. import actions -from .. import common -from .. import images -from .. import ui - -HINT = 'Activate or disable existing bookmark items, or create new ones using the ' \ - 'options below.' - - -def close(): - """Closes the :class:`BookmarkerWidget` editor. - - """ - if common.bookmarker_widget is None: - return - try: - common.bookmarker_widget.close() - common.bookmarker_widget.deleteLater() - except: - pass - common.bookmarker_widget = None - - -def show(): - """Shows the :class:`BookmarkerWidget` editor. - - """ - if not common.bookmarker_widget: - common.bookmarker_widget = BookmarkerWidget() - - common.restore_window_geometry(common.bookmarker_widget) - common.restore_window_state(common.bookmarker_widget) - - -class BookmarkerWidget(QtWidgets.QDialog): - """The main editor used to add or remove bookmark items, jobs and servers. - - """ - - def __init__(self, parent=None): - super().__init__( - parent=parent, - f=QtCore.Qt.CustomizeWindowHint | - QtCore.Qt.WindowTitleHint | - QtCore.Qt.WindowCloseButtonHint | - QtCore.Qt.WindowMaximizeButtonHint - ) - - self.server_editor = None - self.server_add_button = None - self.job_editor = None - self.job_add_button = None - self.bookmark_editor = None - self.bookmark_add_button = None - self.default_bookmarks_button = None - self.prune_bookmarks_button = None - - self.setObjectName('AddRemoveBookmarkItemsWidget') - self.setWindowTitle('Manage Bookmarks Items') - - self._create_ui() - self._connect_signals() - - def _create_ui(self): - """Create ui.""" - common.set_stylesheet(self) - QtWidgets.QVBoxLayout(self) - - o = common.size(common.size_indicator * 1.5) - self.layout().setContentsMargins(0, 0, 0, o) - self.layout().setSpacing(0) - - h = common.size(common.size_row_height) * 0.8 - - # ===================================================== - - _o = common.size(common.size_margin) - main_row = ui.add_row(None, height=None, parent=self, cls=QtWidgets.QSplitter) - main_row.setObjectName('mainRow') - main_row.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.MinimumExpanding - ) - - main_row.layout().setSpacing(o) - main_row.layout().setContentsMargins(o, 0, o, 0) - - # ===================================================== - - pixmap = images.rsc_pixmap( - 'icon', - color=None, - size=common.size(common.size_row_height * 4), - opacity=0.8, - ) - label = QtWidgets.QLabel(parent=self) - label.setPixmap(pixmap) - main_row.layout().addWidget(label) - - # ===================================================== - - row = ui.add_row(None, vertical=True, height=None, parent=main_row) - row.layout().setSpacing(o * 0.5) - row.layout().setContentsMargins(0, o, 0, o) - - _grp = ui.get_group( - parent=row, margin=common.size( - common.size_indicator - ) * 1.5 - ) - _grp.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.MinimumExpanding - ) - _row = ui.add_row(None, height=None, parent=_grp) - _row.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.Maximum - ) - grp = ui.get_group( - parent=_grp, margin=common.size( - common.size_indicator - ) * 0.66 - ) - grp.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.MinimumExpanding - ) - - label = ui.PaintedLabel( - 'Servers', - color=common.color(common.color_secondary_text) - ) - self.server_editor = server_editor.ServerItemEditor(parent=grp) - self.server_add_button = ui.ClickableIconButton( - 'add', - (common.color(common.color_green), - common.color(common.color_selected_text)), - h, - description='Click to add a new server', - state=True, - parent=self - ) - _row.layout().addWidget(label, 0) - _row.layout().addStretch(1) - _row.layout().addWidget(self.server_add_button) - grp.layout().addWidget(self.server_editor, 1) - - # ===================================================== - - row = ui.add_row(None, vertical=True, height=None, parent=main_row) - row.layout().setSpacing(o * 0.5) - row.layout().setContentsMargins(0, o, 0, o) - - _grp = ui.get_group( - parent=row, margin=common.size( - common.size_indicator - ) * 1.5 - ) - _grp.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.MinimumExpanding - ) - _row = ui.add_row(None, height=None, parent=_grp) - _row.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.Maximum - ) - - self.job_filter_widget = ui.LineEdit(parent=self) - self.job_filter_widget.setAlignment( - QtCore.Qt.AlignVCenter | QtCore.Qt.AlignRight - ) - self.job_filter_widget.setPlaceholderText('Search...') - _grp.layout().addWidget(self.job_filter_widget) - - grp = ui.get_group( - parent=_grp, margin=common.size( - common.size_indicator - ) * 0.66 - ) - grp.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.MinimumExpanding - ) - - label = ui.PaintedLabel( - 'Jobs', - color=common.color(common.color_secondary_text) - ) - - self.job_editor = job_editor.JobItemEditor(parent=self) - self.job_add_button = ui.ClickableIconButton( - 'add', - (common.color(common.color_green), - common.color(common.color_selected_text)), - h, - description='Click to create a new job', - state=True, - parent=self - ) - - _row.layout().addWidget(label, 0) - _row.layout().addStretch(1) - _row.layout().addWidget(self.job_add_button) - - grp.layout().addWidget(self.job_editor, 1) - - # ===================================================== - - row = ui.add_row(None, vertical=True, height=None, parent=main_row) - row.layout().setSpacing(o * 0.5) - row.layout().setContentsMargins(0, o, 0, o) - - _grp = ui.get_group( - parent=row, margin=common.size( - common.size_indicator - ) * 1.5 - ) - _grp.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.MinimumExpanding - ) - _row = ui.add_row(None, height=None, parent=_grp) - _row.layout().setAlignment(QtCore.Qt.AlignCenter) - _row.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.Maximum - ) - grp = ui.get_group( - parent=_grp, margin=common.size( - common.size_indicator - ) * 0.66 - ) - grp.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.MinimumExpanding - ) - - label = ui.PaintedLabel( - 'Root folders', - color=common.color(common.color_secondary_text) - ) - - self.bookmark_editor = bookmark_editor.BookmarkItemEditor(parent=self) - self.bookmark_add_button = ui.ClickableIconButton( - 'add', - (common.color(common.color_green), - common.color(common.color_selected_text)), - h, - description='Click to select a folder and use it as a bookmark item.', - state=True, - parent=self - ) - - _row.layout().addWidget(label, 0) - _row.layout().addStretch(1) - _row.layout().addWidget(self.bookmark_add_button) - grp.layout().addWidget(self.bookmark_editor, 1) - - self.layout().addSpacing(o * 2) - - row = ui.add_row(None, height=None, parent=self) - row.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.Maximum, - ) - row.layout().setContentsMargins(o, 0, o, 0) - self.done_button = ui.PaintedButton( - 'Done', - parent=self - ) - - self.default_bookmarks_button = ui.PaintedButton( - 'Show default_bookmark_items.json' - ) - row.layout().addWidget(self.default_bookmarks_button, 0) - self.prune_bookmarks_button = ui.PaintedButton( - 'Remove invalid' - ) - row.layout().addWidget(self.prune_bookmarks_button, 0) - row.layout().addWidget(self.done_button, 1) - - def _connect_signals(self): - """Connect signals.""" - self.server_editor.selectionModel().selectionChanged.connect( - self.bookmark_editor.clear - ) - self.server_editor.selectionModel().selectionChanged.connect( - self.job_editor.init_data - ) - self.job_editor.selectionModel().selectionChanged.connect( - self.bookmark_editor.init_data - ) - - self.server_add_button.clicked.connect(self.server_editor.add) - self.job_add_button.clicked.connect(self.job_editor.add) - self.bookmark_add_button.clicked.connect(self.bookmark_editor.add) - - self.done_button.clicked.connect(self.close) - - self.default_bookmarks_button.clicked.connect( - actions.reveal_default_bookmarks_json - ) - self.prune_bookmarks_button.clicked.connect( - actions.prune_bookmarks - ) - - self.job_filter_widget.textChanged.connect(self.job_editor.set_filter) - - def server(self): - """Get the selected server. - - Returns: - str: The selected server. - - """ - index = common.get_selected_index(self.server_editor) - if not index.isValid(): - return None - return index.data(QtCore.Qt.DisplayRole) - - def job(self): - """Get the selected job. - - Returns: - str: The selected job. - - """ - index = common.get_selected_index(self.job_editor) - if not index.isValid(): - return None - return index.data(QtCore.Qt.UserRole + 1) - - def job_path(self): - """Get the selected job path. - - Returns: - str: The selected job path. - - """ - index = common.get_selected_index(self.job_editor) - if not index.isValid(): - return None - return index.data(QtCore.Qt.UserRole) - - def init_data(self): - """Initializes data. - - """ - self.server_editor.init_data() - - def changeEvent(self, event): - """Change event handler.""" - if event.type() == QtCore.QEvent.WindowStateChange: - common.save_window_state(self) - - def hideEvent(self, event): - """Hide event handler.""" - common.save_window_state(self) - super().hideEvent(event) - - def closeEvent(self, event): - """Close event handler.""" - common.save_window_state(self) - super().closeEvent(event) - - def showEvent(self, event): - """Show event handler. - - """ - QtCore.QTimer.singleShot(100, self.init_data) - super().showEvent(event) - - def sizeHint(self): - """Returns a size hint. - - """ - return QtCore.QSize( - common.size(common.size_width), - common.size(common.size_height) * 1.33 - ) diff --git a/bookmarks/bookmarker/server_editor.py b/bookmarks/bookmarker/server_editor.py deleted file mode 100644 index 8b58c1859..000000000 --- a/bookmarks/bookmarker/server_editor.py +++ /dev/null @@ -1,410 +0,0 @@ -"""Editor widget used by -:class:`~bookmarks.bookmarker.main.BookmarkerWidget` -to select and save a new server item. - -""" -import functools - -from PySide2 import QtCore, QtGui, QtWidgets - -from .. import actions -from .. import common -from .. import contextmenu -from .. import images -from .. import shortcuts -from .. import ui - - -class AddServerEditor(QtWidgets.QDialog): - """Dialog used to add a new server to user settings file. - - """ - - def __init__(self, parent=None): - super().__init__(parent=parent) - self.ok_button = None - self.pick_button = None - self.editor = None - - self.setWindowTitle('Add New Server') - - self._create_ui() - self._connect_signals() - self._add_completer() - - def _create_ui(self): - """Create ui.""" - if not self.parent(): - common.set_stylesheet(self) - - QtWidgets.QVBoxLayout(self) - - o = common.size(common.size_margin) - self.layout().setSpacing(o) - self.layout().setContentsMargins(o, o, o, o) - - self.ok_button = ui.PaintedButton('Done', parent=self) - self.ok_button.setFixedHeight(common.size(common.size_row_height)) - self.pick_button = ui.PaintedButton('Pick', parent=self) - - self.editor = ui.LineEdit(parent=self) - self.editor.setPlaceholderText( - 'Enter the path to a server, e.g. \'//my_server/jobs\'' - ) - self.setFocusProxy(self.editor) - self.editor.setFocusPolicy(QtCore.Qt.StrongFocus) - - row = ui.add_row(None, parent=self) - row.layout().addWidget(self.editor, 1) - row.layout().addWidget(self.pick_button, 0) - - row = ui.add_row(None, parent=self) - row.layout().addWidget(self.ok_button, 1) - - def _add_completer(self): - """Add and populate a QCompleter with mounted drive names. - - """ - items = [] - for info in QtCore.QStorageInfo.mountedVolumes(): - if info.isValid(): - items.append(info.rootPath()) - items += common.servers.values() - - completer = QtWidgets.QCompleter(items, parent=self) - completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) - common.set_stylesheet(completer.popup()) - self.editor.setCompleter(completer) - - def _connect_signals(self): - """Connect signals.""" - self.ok_button.clicked.connect( - lambda: self.done(QtWidgets.QDialog.Accepted) - ) - self.pick_button.clicked.connect(self.pick) - self.editor.textChanged.connect( - lambda: self.editor.setStyleSheet( - f'color: {common.rgb(common.color_green)};' - ) - ) - - @QtCore.Slot() - def pick(self): - """Get an existing directory to use as a server. - - """ - _dir = QtWidgets.QFileDialog.getExistingDirectory(parent=self) - if not _dir: - return - - file_info = QtCore.QFileInfo(_dir) - if file_info.exists(): - self.editor.setText(file_info.absoluteFilePath()) - - @common.error - @common.debug - def done(self, result): - """Finalize action. - - """ - if result == QtWidgets.QDialog.Rejected: - super().done(result) - return - - if not self.text(): - return - - v = self.text() - file_info = QtCore.QFileInfo(v) - - if not file_info.exists() or not file_info.isReadable() or v in \ - common.servers: - # Indicate the selected item is invalid and keep the editor open - self.editor.setStyleSheet( - 'color: {0}; border-color: {0}'.format( - common.rgb(common.color_red) - ) - ) - self.editor.blockSignals(True) - self.editor.setText(v) - self.editor.blockSignals(False) - return - - actions.add_server(v) - super().done(QtWidgets.QDialog.Accepted) - - def text(self): - """Sanitize text. - - Returns: - str: The sanitized text. - - """ - v = self.editor.text() - return common.strip(v) if v else '' - - def showEvent(self, event): - """Show event handler. - - """ - self.editor.setFocus() - common.center_window(self) - - def sizeHint(self): - """Returns a size hint. - - """ - return QtCore.QSize( - common.size(common.size_width), - common.size(common.size_row_height) * 2 - ) - - -class ServerContextMenu(contextmenu.BaseContextMenu): - """Context menu associated with :class:`ServerItemEditor`. - - """ - - def setup(self): - """Creates the context menu. - - """ - self.add_menu() - self.separator() - if isinstance( - self.index, QtWidgets.QListWidgetItem - ) and self.index.flags() & QtCore.Qt.ItemIsEnabled: - self.reveal_menu() - self.remove_menu() - elif isinstance( - self.index, - QtWidgets.QListWidgetItem - ) and not self.index.flags() & QtCore.Qt.ItemIsEnabled: - self.remove_menu() - self.separator() - self.refresh_menu() - - def add_menu(self): - """Add server action. - - """ - self.menu['Add New Server...'] = { - 'action': self.parent().add, - 'icon': ui.get_icon('add', color=common.color(common.color_green)) - } - - def reveal_menu(self): - """Reveal server item action. - - """ - self.menu['Reveal...'] = { - 'action': lambda: actions.reveal(f'{self.index.text()}/.'), - 'icon': ui.get_icon('folder'), - } - - def remove_menu(self): - """Remove server item action. - - """ - self.menu['Remove'] = { - 'action': self.parent().remove, - 'icon': ui.get_icon('close', color=common.color(common.color_red)) - } - - def refresh_menu(self): - """Refresh server list action. - - """ - self.menu['Refresh'] = { - 'action': self.parent().init_data, - 'icon': ui.get_icon('refresh') - } - - -class ServerItemEditor(ui.ListWidget): - """List widget used to add and remove servers to and from the local - user settings. - - """ - - def __init__(self, parent=None): - super().__init__( - default_icon='server', - parent=parent - ) - - self.setItemDelegate(ui.ListWidgetDelegate(parent=self)) - self.setWindowTitle('Server Editor') - self.setObjectName('ServerEditor') - - self.setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.viewport().setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.viewport().setAttribute(QtCore.Qt.WA_TranslucentBackground) - - self.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.MinimumExpanding - ) - self.setMinimumWidth(common.size(common.size_width) * 0.2) - - self._connect_signals() - self._init_shortcuts() - - def _init_shortcuts(self): - """Initializes shortcuts. - - """ - shortcuts.add_shortcuts(self, shortcuts.BookmarkEditorShortcuts) - connect = functools.partial( - shortcuts.connect, shortcuts.BookmarkEditorShortcuts - ) - connect(shortcuts.AddItem, self.add) - connect(shortcuts.RemoveItem, self.remove) - - def _connect_signals(self): - """Connects signals. - - """ - super()._connect_signals() - - self.selectionModel().selectionChanged.connect( - functools.partial(common.save_selection, self) - ) - - common.signals.serversChanged.connect(self.init_data) - common.signals.serverAdded.connect( - functools.partial(common.select_index, self) - ) - - @common.debug - @common.error - @QtCore.Slot() - def remove(self, *args, **kwargs): - """Remove a server item. - - """ - index = common.get_selected_index(self) - if not index.isValid(): - return - - v = index.data(QtCore.Qt.DisplayRole) - v = common.strip(v) - actions.remove_server(v) - - @common.debug - @common.error - @QtCore.Slot() - def add(self, *args, **kwargs): - """Add a server item. - - """ - w = AddServerEditor(parent=self.window()) - pos = self.mapToGlobal(self.window().rect().topLeft()) - w.move(pos) - if w.exec_() == QtWidgets.QDialog.Accepted: - self.init_data() - - def contextMenuEvent(self, event): - """Context menu event handler. - - """ - item = self.itemAt(event.pos()) - menu = ServerContextMenu(item, parent=self) - pos = event.pos() - pos = self.mapToGlobal(pos) - menu.move(pos) - menu.exec_() - - @common.debug - @common.error - @QtCore.Slot() - def init_data(self, *args, **kwargs): - """Load data. - - """ - selected_index = common.get_selected_index(self) - selected_name = selected_index.data( - QtCore.Qt.DisplayRole - ) if selected_index.isValid() else None - - self.selectionModel().blockSignals(True) - self.clear() - - for path in common.servers: - item = QtWidgets.QListWidgetItem() - item.setData(QtCore.Qt.DisplayRole, path) - item.setData(QtCore.Qt.UserRole, path) - item.setData(QtCore.Qt.UserRole + 1, path) - item.setData(QtCore.Qt.StatusTipRole, path) - item.setData(QtCore.Qt.WhatsThisRole, path) - item.setData(QtCore.Qt.ToolTipRole, path) - - size = QtCore.QSize( - 0, - common.size(common.size_margin) * 2 - ) - item.setSizeHint(size) - self.validate_item(item) - self.insertItem(self.count(), item) - - self.progressUpdate.emit('') - - if selected_name: - for idx in range(self.model().rowCount()): - index = self.model().index(idx, 0) - if index.data(QtCore.Qt.DisplayRole) == selected_name: - self.selectionModel().select( - index, QtCore.QItemSelectionModel.ClearAndSelect - ) - - self.selectionModel().blockSignals(False) - common.restore_selection(self) - - @QtCore.Slot(QtWidgets.QListWidgetItem) - def validate_item(self, item): - """Check if the given server item is valid. - - """ - selected_index = common.get_selected_index(self) - - self.blockSignals(True) - - pixmap = images.rsc_pixmap( - 'server', common.color(common.color_text), - common.size(common.size_row_height) * 0.8 - ) - pixmap_selected = images.rsc_pixmap( - 'server', common.color(common.color_selected_text), - common.size(common.size_row_height) * 0.8 - ) - pixmap_disabled = images.rsc_pixmap( - 'close', common.color(common.color_red), - common.size(common.size_row_height) * 0.8 - ) - icon = QtGui.QIcon() - - file_info = QtCore.QFileInfo(item.text()) - if file_info.exists() and file_info.isReadable() and file_info.isWritable(): - icon.addPixmap(pixmap, QtGui.QIcon.Normal) - icon.addPixmap(pixmap_selected, QtGui.QIcon.Selected) - icon.addPixmap(pixmap_selected, QtGui.QIcon.Active) - icon.addPixmap(pixmap_disabled, QtGui.QIcon.Disabled) - item.setFlags( - QtCore.Qt.ItemIsEnabled | - QtCore.Qt.ItemIsSelectable - ) - valid = True - else: - icon.addPixmap(pixmap_disabled, QtGui.QIcon.Normal) - icon.addPixmap(pixmap_disabled, QtGui.QIcon.Selected) - icon.addPixmap(pixmap_disabled, QtGui.QIcon.Active) - icon.addPixmap(pixmap_disabled, QtGui.QIcon.Disabled) - valid = False - - item.setData(QtCore.Qt.DecorationRole, icon) - self.blockSignals(False) - - index = self.indexFromItem(item) - if not valid and selected_index == index: - self.selectionModel().clearSelection() diff --git a/bookmarks/common/__init__.py b/bookmarks/common/__init__.py index 10aff7ff2..e2eb0423e 100644 --- a/bookmarks/common/__init__.py +++ b/bookmarks/common/__init__.py @@ -99,13 +99,14 @@ launcher_widget = None message_widget = None preference_editor_widget = None -bookmarker_widget = None +job_editor = None bookmark_property_editor = None asset_property_editor = None file_saver_widget = None publish_widget = None maya_export_widget = None ffmpeg_export_widget = None +akaconvert_widget = None screen_capture_widget = None pick_thumbnail_widget = None notes_widget = None @@ -123,7 +124,7 @@ from .font import * from .monitor import * from .sequence import * -from .session_lock import * +from .active_mode import * from .settings import * from .setup import * from .signals import * diff --git a/bookmarks/common/session_lock.py b/bookmarks/common/active_mode.py similarity index 62% rename from bookmarks/common/session_lock.py rename to bookmarks/common/active_mode.py index d7ad91cd2..15ededab2 100644 --- a/bookmarks/common/session_lock.py +++ b/bookmarks/common/active_mode.py @@ -1,5 +1,4 @@ -"""Module defines the classes and methods needed to set and edit session lock -files. +"""Defines active path reading mode. The app has two session modes. When `common.active_mode` is `common.SynchronisedActivePaths`, the app will save active paths in the user settings @@ -11,6 +10,11 @@ active path values are read from the user settings, but active paths changes won't be saved to the settings file. +The session mode will also be set to `common.PrivateActivePaths` if any of the +`BOOKMARKS_ACTIVE_SERVER`, `BOOKMARKS_ACTIVE_JOB`, `BOOKMARKS_ACTIVE_ROOT`, +`BOOKMARKS_ACTIVE_ASSET` and `BOOKMARKS_ACTIVE_TASK` environment variables are set +as these will take precedence over the user settings. + To toggle between the two modes use :func:`bookmarks.actions.toggle_active_mode`. Also see :class:`bookmarks.statusbar.ToggleSessionModeButton`. @@ -34,11 +38,7 @@ def get_lock_path(): return LOCK_PATH.format( root=QtCore.QStandardPaths.writableLocation( QtCore.QStandardPaths.GenericDataLocation - ), - product=common.product, - prefix=PREFIX, - pid=os.getpid(), - ext=FORMAT + ), product=common.product, prefix=PREFIX, pid=os.getpid(), ext=FORMAT ) @@ -49,9 +49,7 @@ def prune_lock(): path = LOCK_DIR.format( root=QtCore.QStandardPaths.writableLocation( QtCore.QStandardPaths.GenericDataLocation - ), - product=common.product, - ) + ), product=common.product, ) r = fr'{PREFIX}_([0-9]+)\.{FORMAT}' pids = psutil.pids() @@ -72,23 +70,46 @@ def prune_lock(): raise RuntimeError('Failed to remove a lockfile.') -def init_lock(): - """Initialises the Bookmark's session lock. +def init_active_mode(): + """Initialises the Bookmark's active path reading mode. + + We define two modes, ``SynchronisedActivePaths`` (when Bookmarks is in sync with the user settings) and + ``PrivateActivePaths`` when the Bookmarks sessions set the active paths values internally without changing the user + settings. + + The session mode will be initialised to a default value based on the following conditions: + + If any of the `BOOKMARKS_ACTIVE_SERVER`, `BOOKMARKS_ACTIVE_JOB`, `BOOKMARKS_ACTIVE_ROOT`, + `BOOKMARKS_ACTIVE_ASSET` and `BOOKMARKS_ACTIVE_TASK` environment values have valid values, the session will + automatically be marked ``PrivateActivePaths``. + + If the environment has not been set but there's already an active ``SynchronisedActivePaths`` session + running, the current session will be set to ``PrivateActivePaths``. + + Any sessions that doesn't have environment values set and does not find synchronized session lock files will + be marked ``SynchronisedActivePaths``. - We'll check all lock-files and to see if there's already a - ``SynchronisedActivePaths`` session. As we want only one session controlling - the active path settings we'll set all subsequent application sessions - to be ``PrivateActivePaths`` (when ``PrivateActivePaths`` is on, all active path - settings will be kept in memory, instead of writing them out to the - disk). """ + # Remove stale lock files + prune_lock() + + # Check if any of the environment variables are set + _env_active_server = os.environ.get('BOOKMARKS_ACTIVE_SERVER', None) + _env_active_job = os.environ.get('BOOKMARKS_ACTIVE_JOB', None) + _env_active_root = os.environ.get('BOOKMARKS_ACTIVE_ROOT', None) + _env_active_asset = os.environ.get('BOOKMARKS_ACTIVE_ASSET', None) + _env_active_task = os.environ.get('BOOKMARKS_ACTIVE_TASK', None) + + if any((_env_active_server, _env_active_job, _env_active_root, _env_active_asset, _env_active_task)): + common.active_mode = common.PrivateActivePaths + return write_current_mode_to_lock() + path = LOCK_DIR.format( root=QtCore.QStandardPaths.writableLocation( QtCore.QStandardPaths.GenericDataLocation - ), - product=common.product, - ) + ), product=common.product, ) + # Iterate over all lock files and check their contents for entry in os.scandir(path): if entry.is_dir(): diff --git a/bookmarks/common/core.py b/bookmarks/common/core.py index a6abbdde8..d3b4bdc4f 100644 --- a/bookmarks/common/core.py +++ b/bookmarks/common/core.py @@ -121,6 +121,8 @@ def _idx_func(reset=False, start=None): QueueRole = QtCore.Qt.ItemDataRole(idx()) #: List item role for getting the item's data type (sequence or file) DataTypeRole = QtCore.Qt.ItemDataRole(idx()) +#: List item role for getting the container data dictionary +DataDictRole = QtCore.Qt.ItemDataRole(idx()) #: The view tab associated with the item ItemTabRole = QtCore.Qt.ItemDataRole(idx()) #: Data used to sort the items by name @@ -465,19 +467,21 @@ def get_sequence_and_shot(s): return seq, shot -def get_entry_from_path(path, is_dir=True): +def get_entry_from_path(path, is_dir=True, force_exists=False): """Returns a scandir entry of the given file path. Args: path (str): Path to directory. is_dir (bool): Is the path a directory or a file. + force_exists (bool): Force skip checking the existence of the path if we know path exist. Returns: scandir.DirEntry: A scandir entry, or None if not found. """ file_info = QtCore.QFileInfo(path) - if not file_info.exists(): + + if not force_exists and not file_info.exists(): return None for entry in os.scandir(file_info.dir().path()): @@ -496,22 +500,21 @@ def get_links(path, section='links/asset'): inside job templates. If a .links file contains two relative paths, - `subfolder1/nested_asset1` and `subfolder2/nested_asset2`... + `subfolder1/nested_asset1` and `subfolder2/nested_asset2` .. code-block:: text - asset/ + root_asset_folder/ ├─ .links ├─ subfolder1/ │ ├─ nested_asset1/ ├─ subfolder2/ │ ├─ nested_asset2/ - ...two asset will be read, `nested_asset1` and `nested_asset2` - (but not the original root `asset`). + ...two asset items will be read - `nested_asset1` and `nested_asset2` but not the original root `root_asset_folder`. Args: - path (str): Path to a folder where the link file resides. E.g. an asset root folder. + path (str): Path to a folder where the link file resides. section (str): The settings section to look for links in. Optional. Defaults to 'links/asset'. @@ -639,15 +642,31 @@ class DataDict(dict): """Custom dictionary class used to store model item data. This class adds compatibility for :class:`weakref.ref` referencing - and custom attributes for storing data state. + and custom attributes for storing data states. """ + def __str__(self): + return ( + f'' + ) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._loaded = False self._refresh_needed = False self._data_type = None + self._shotgun_names = [] + self._sg_task_names = [] + self._file_types = [] + self._subdirectories = [] + self._servers = [] + self._jobs = [] + self._roots = [] @property def loaded(self): @@ -685,6 +704,69 @@ def data_type(self): def data_type(self, v): self._data_type = v + @property + def shotgun_names(self): + """Returns a list of Shotgun task names associated with the data dictionary.""" + return self._shotgun_names + + @shotgun_names.setter + def shotgun_names(self, v): + self._shotgun_names = v + + @property + def sg_task_names(self): + """Returns a list of Shotgun task names associated with the data dictionary.""" + return self._sg_task_names + + @sg_task_names.setter + def sg_task_names(self, v): + self._sg_task_names = v + + @property + def file_types(self): + """Returns a list of file types stored in the data dictionary.""" + return self._file_types + + @file_types.setter + def file_types(self, v): + self._file_types = v + + @property + def subdirectories(self): + """Returns a list of file types stored in the data dictionary.""" + return self._subdirectories + + @subdirectories.setter + def subdirectories(self, v): + self._subdirectories = v + + @property + def servers(self): + """Returns a list of file types stored in the data dictionary.""" + return self._servers + + @servers.setter + def servers(self, v): + self._servers = v + + @property + def jobs(self): + """Returns a list of file types stored in the data dictionary.""" + return self._jobs + + @jobs.setter + def jobs(self, v): + self._jobs = v + + @property + def roots(self): + """Returns a list of file types stored in the data dictionary.""" + return self._roots + + @roots.setter + def roots(self, v): + self._roots = v + class Timer(QtCore.QTimer): """A custom QTimer class used across the app. diff --git a/bookmarks/common/data.py b/bookmarks/common/data.py index d6f4b5388..e6a59d5a7 100644 --- a/bookmarks/common/data.py +++ b/bookmarks/common/data.py @@ -41,14 +41,25 @@ def sort_key(_idx): ) d = common.DataDict() + + # Copy over the property values from the source data d.loaded = ref().loaded d.data_type = ref().data_type + d.refresh_needed = ref().refresh_needed + d.shotgun_names = ref().shotgun_names + d.sg_task_names = ref().sg_task_names + d.file_types = ref().file_types + d.subdirectories = ref().subdirectories + d.servers = ref().servers + d.jobs = ref().jobs + d.roots = ref().roots for n, idx in enumerate(sorted_idxs): if not ref(): raise RuntimeError('Model mutated during sorting.') d[n] = ref()[idx] d[n][common.IdRole] = n + return d @@ -77,6 +88,27 @@ def get_data(key, task, data_type): return common.item_data[key][task][data_type] +def get_data_from_value(value, data_type, role=common.PathRole): + """Get the internal data dictionary associated with a path. + + Args: + value (object): A value to match. + data_type (int): One of :attr:`~bookmarks.common.FileItem` or :attr:`~bookmarks.common.SequenceItem`. + + Returns: + common.DataDict: The cached data or None if not found. + + """ + for key in common.item_data: + for task in common.item_data[key]: + if data_type not in common.item_data[key][task]: + return None + data = common.item_data[key][task][data_type] + for idx in data: + if value in data[idx][role]: + return data + return None + def get_task_data(key, task): """Get cached data from :attr:`~bookmarks.common.item_data`. @@ -209,6 +241,9 @@ def set_data(key, task, data_type, data): data_type (int): One of :attr:`bookmarks.common.FileItem` or :attr:`bookmarks.common.SequenceItem`. data (common.DataDict): The data to set in the cache. + Returns: + common.DataDict: The cached data. + """ common.check_type(key, tuple) common.check_type(task, str) @@ -219,3 +254,5 @@ def set_data(key, task, data_type, data): elif task not in common.item_data[key]: reset_data(key, task) common.item_data[key][task][data_type] = data + + return common.item_data[key][task][data_type] diff --git a/bookmarks/common/env.py b/bookmarks/common/env.py index 84a03cf64..f63d33d67 100644 --- a/bookmarks/common/env.py +++ b/bookmarks/common/env.py @@ -9,6 +9,7 @@ from PySide2 import QtCore, QtWidgets + external_binaries = ( 'ffmpeg', 'rvpush', @@ -18,36 +19,54 @@ def get_binary(binary_name): - """External binary paths must be set explicitly by the environment or by the - user in the user settings. + """Binary path getter. - Bookmarks will look for user defined binary paths, or failing that, - environment values in a ``{PREFIX}_{BINARY_NAME}`` format, - e.g. ``BOOKMARKS_FFMPEG``, or ``BOOKMARKS_RV``. These environment variables - should point to an appropriate executable, e.g. - ``BOOKMARKS_FFMPEG=C:/ffmpeg/ffmpeg.exe`` + The paths are resolved from the following sources and order: + - active bookmark item's application launcher items + - user settings + - environment variables in a ``{PREFIX}_{BINARY_NAME}`` format, + e.g. ``BOOKMARKS_FFMPEG``, or ``BOOKMARKS_RV``. These environment variables + should point to an appropriate executable, e.g. + ``BOOKMARKS_FFMPEG=C:/ffmpeg/ffmpeg.exe`` - If the environment variable is absent, we'll look at the PATH environment to - see if the binary is available there. + If the environment variable is absent, we'll look at the PATH environment to + see if the binary is available there. Args: - binary_name (str): One of the pre-defined external binary names. - E.g. `ffmpeg`. + binary_name (str): Name of a binary, lower-case, without spaces. E.g. `aftereffects`, `oiiotool`, `ffmpeg`, etc. Returns: - str: Path to an executable binary, or `None` if the binary is not found. + str: Path to an executable binary, or `None` if the binary is not found in any of the sources. """ - if binary_name.lower() not in [f.lower() for f in external_binaries]: - raise ValueError(f'{binary_name} is not a recognised binary name.') - + # Sanitize the binary name + binary_name = re.sub(r'\s+', '', binary_name).lower().strip() + + # Check the active bookmark item's database for possible values + from .. import database + from .. import common + + args = common.active('root', args=True) + + if args: + db = database.get(*args) + applications = db.value(db.source(), 'applications', database.BookmarkTable) + if applications: + # Sanitize names, so they're all lower-case and without spaces + names = [re.sub(r'\s+', '', v['name']).lower().strip() for v in applications.values()] + if binary_name in names: + # We have a match, return the path + v = applications[names.index(binary_name)]['path'] + if v and QtCore.QFileInfo(v).exists(): + return v + + # Check the user settings for possible values v = get_user_setting(binary_name) if v: return v - from . import product - - key = f'{product}_{binary_name}'.upper() + # Check the environment variables for possible values + key = f'{common.product}_{binary_name}'.upper() if key in os.environ: v = os.environ[key] try: diff --git a/bookmarks/common/monitor.py b/bookmarks/common/monitor.py index 6a18d37b5..93008b595 100644 --- a/bookmarks/common/monitor.py +++ b/bookmarks/common/monitor.py @@ -1,6 +1,7 @@ """QFileSystemWatchers used to monitor for file and directory changes. """ +import weakref from PySide2 import QtCore @@ -17,29 +18,43 @@ def get_watcher(tab_idx): class FileWatcher(QtCore.QFileSystemWatcher): + modelNeedsRefresh = QtCore.Signal(weakref.ref) + def __init__(self, tab_idx, parent=None): super().__init__(parent=parent) self.tab_idx = tab_idx - self.update_queue_timer = common.Timer(parent=self) - self.update_queue_timer.setSingleShot(True) - self.update_queue_timer.setInterval(500) + #: Timer used to limit the number of updates + self.update_timer = common.Timer(parent=self) + self.update_timer.setInterval(500) + self.update_timer.setTimerType(QtCore.Qt.CoarseTimer) + self.update_timer.setSingleShot(True) - self.changed_items = set() + self.update_queue = set() self._connect_signals() def _connect_signals(self): - self.update_queue_timer.timeout.connect(self.item_changed) + self.update_timer.timeout.connect(self.process_update_queue) + self.directoryChanged.connect(self.queue_changed_item) - QtCore.Slot(str) + self.modelNeedsRefresh.connect(common.signals.updateTopBarButtons) + self.modelNeedsRefresh.connect(self.update_model) + @QtCore.Slot(str) def queue_changed_item(self, v): - if v not in self.changed_items: - self.changed_items.add(v) - self.update_queue_timer.start(self.update_queue_timer.interval()) + """Slot used to add an updated path to the update queue. + + Args: + v (str): The path to add to the update queue. + + """ + if v not in self.update_queue.copy(): + self.update_queue.add(v) + + self.update_timer.start(self.update_timer.interval()) def add_directories(self, paths): """Adds the given list of directories to the file system watcher. @@ -55,30 +70,70 @@ def add_directories(self, paths): self.addPath(path) def reset(self): - """Remove all watch directories. + """Reset the watcher to its initial state. """ for v in self.directories(): self.removePath(v) for v in self.files(): self.removePath(v) + self.update_queue.clear() @QtCore.Slot() - def item_changed(self): - """Slot used to update the model status. + def process_update_queue(self): + """Slot used to mark a data dictionary as needing to be refreshed. + + Emits the modelNeedsRefresh signal for each data dictionary in the update queue. + + """ + refs = [] + for path in self.update_queue: + for data_type in (common.SequenceItem, common.FileItem): + data_dict = common.get_data_from_value(path, data_type, role=common.PathRole) + + if not data_dict: + continue + + ref = weakref.ref(data_dict) + if not ref in refs: + refs.append(data_dict) + else: + continue + + ref().refresh_needed = True + common.widget(self.tab_idx).filter_indicator_widget.repaint() + self.modelNeedsRefresh.emit(ref) + + self.update_queue.clear() + self.update_timer.stop() + + @QtCore.Slot(weakref.ref) + def update_model(self, ref): + """Slot used to update the model associated with the item tab index. + + Args: + ref (weakref.ref): A weak reference to the data dictionary that needs updating. """ - if self.tab_idx == common.FileTab: - self._file_item_updated() + if not ref(): + return - def _file_item_updated(self): - p = common.active('task', path=True) - if not p: + # If the data is relatively small, we don't have to bail out... + if len(ref()) > 999: return - for v in self.changed_items.copy(): - if p in v: - model = common.source_model(common.FileTab) - model.set_refresh_needed(True) - common.widget(common.FileTab).filter_indicator_widget.repaint() - break - self.changed_items = set() + + source_model = common.source_model(self.tab_idx) + p = source_model.source_path() + k = source_model.task() + + for t in (common.FileItem, common.SequenceItem,): + data = common.get_data(p, k, t) + + if not data: + source_model.reset_data(force=True) + return + + # Force reset the source model if we find a data match + if data == ref(): + source_model.reset_data(force=True) + return diff --git a/bookmarks/common/settings.py b/bookmarks/common/settings.py index fbbab463a..df7d592eb 100644 --- a/bookmarks/common/settings.py +++ b/bookmarks/common/settings.py @@ -30,17 +30,21 @@ 'user/favourites', ), 'settings': ( - 'settings/job_scan_depth', 'settings/ui_scale', 'settings/show_menu_icons', 'settings/paint_thumbnail_bg', 'settings/disable_oiio', + 'settings/hide_item_descriptions', + 'settings/default_to_scenes_folder', 'settings/always_always_on_top', 'settings/bin_ffmpeg', 'settings/bin_rv', 'settings/bin_rvpush', 'settings/bin_oiiotool', ), + 'jobs': ( + 'jobs/scandepth', + ), 'filters': ( 'filters/active', 'filters/archived', @@ -81,17 +85,21 @@ 'file_saver/template', 'file_saver/user', ), - 'bookmarker': ( - 'bookmarker/server', - 'bookmarker/job', - 'bookmarker/root', - ), 'ffmpeg': ( 'ffmpeg/preset', 'ffmpeg/size', 'ffmpeg/timecode_preset', 'ffmpeg/pushtorv', ), + 'akaconvert': ( + 'akaconvert/preset', + 'akaconvert/size', + 'akaconvert/acesprofile', + 'akaconvert/inputcolor', + 'akaconvert/outputcolor', + 'akaconvert/videoburnin', + 'akaconvert/pushtorv', + ), 'maya': ( 'maya/sync_workspace', 'maya/workspace_save_warnings', @@ -110,10 +118,8 @@ 'publish': ( 'publish/archive_existing', 'publish/template', - 'publish/task', 'publish/copy_path', 'publish/reveal', - 'publish/teams_notification', ), } @@ -139,9 +145,10 @@ def init_settings(): for key in SECTIONS['active']: common.active_paths[mode][key] = None - # Create the setting object, this will load the previously saved active - # paths from the ini file. + # Initialize the user settings instance common.settings = UserSettings() + + # Load values from the user settings file common.settings.load_active_values() common.update_private_values() @@ -154,6 +161,7 @@ def init_settings(): if not v or not isinstance(v, dict): v = {} common.favourites = v + common.signals.favouritesChanged.emit() _init_bookmarks() @@ -305,12 +313,26 @@ def bookmark_key(server, job, root): def update_private_values(): - """Copy the ``SynchronisedActivePaths`` values to ``PrivateActivePaths``. + """Copy the controlling values to ``PrivateActivePaths``. + + The source of the value is determined by the active mode and the current environment. """ + _env_active_server = os.environ.get('BOOKMARKS_ACTIVE_SERVER', None) + _env_active_job = os.environ.get('BOOKMARKS_ACTIVE_JOB', None) + _env_active_root = os.environ.get('BOOKMARKS_ACTIVE_ROOT', None) + _env_active_asset = os.environ.get('BOOKMARKS_ACTIVE_ASSET', None) + _env_active_task = os.environ.get('BOOKMARKS_ACTIVE_TASK', None) + + if any((_env_active_server, _env_active_job, _env_active_root, _env_active_asset, _env_active_task)): + for k in SECTIONS['active']: + common.active_paths[PrivateActivePaths][k] = os.environ.get(f'BOOKMARKS_ACTIVE_{k.upper()}', None) + for k in SECTIONS['active']: - common.active_paths[PrivateActivePaths][k] = \ - common.active_paths[SynchronisedActivePaths][k] + common.active_paths[PrivateActivePaths][k] = common.active_paths[SynchronisedActivePaths][k] + + # Verify the values + common.settings.verify_active(PrivateActivePaths) _true = {'True', 'true', '1', True} @@ -335,12 +357,14 @@ def __init__(self, parent=None): self.verify_timer.setSingleShot(False) self.verify_timer.setTimerType(QtCore.Qt.CoarseTimer) self.verify_timer.timeout.connect(self.load_active_values) + self.verify_timer.start() def load_active_values(self): - """Load previously saved active path elements from the settings file. + """Load active path elements from the settings file. - If the resulting path is invalid, we'll progressively unset the invalid - path segments until we find a valid path. + Whilst the function will load the current values from the settings file, + it doesn't guarantee the values will actually be used. App will only use + these values if the active mode is set to `SynchronisedActivePaths`. """ self.sync() @@ -350,22 +374,21 @@ def load_active_values(self): v = None common.active_paths[SynchronisedActivePaths][k] = v self.verify_active(SynchronisedActivePaths) - self.verify_active(PrivateActivePaths) - def verify_active(self, m): + def verify_active(self, active_mode): """Verify the active path values and unset any item, that refers to an invalid path. Args: - m (int): The active mode. + active_mode (int): One of ``SynchronisedActivePaths`` or ``Pr. """ p = str() for k in SECTIONS['active']: - if common.active_paths[m][k]: - p += common.active_paths[m][k] + if common.active_paths[active_mode][k]: + p += common.active_paths[active_mode][k] if not os.path.exists(p): - common.active_paths[m][k] = None - if m == SynchronisedActivePaths: + common.active_paths[active_mode][k] = None + if active_mode == SynchronisedActivePaths: self.setValue(f'active/{k}', None) p += '/' @@ -451,7 +474,7 @@ def setValue(self, key, v): v (object): The value to save. """ - # Skip saving active values when PrivateActivePaths is on + # We don't want to save private active values to the user settings if common.active_mode == PrivateActivePaths and key in SECTIONS['active']: return diff --git a/bookmarks/common/setup.py b/bookmarks/common/setup.py index 2a9b1dfe4..4fbac042f 100644 --- a/bookmarks/common/setup.py +++ b/bookmarks/common/setup.py @@ -75,8 +75,7 @@ def initialize(mode): os.makedirs(os.path.normpath(common.temp_path())) common.init_signals() - common.prune_lock() - common.init_lock() # Sets the current active mode + common.init_active_mode() common.init_settings() _init_ui_scale() @@ -120,6 +119,25 @@ def initialize(mode): break +def initialize_core(): + """""" + if common.init_mode is not None: + raise RuntimeError(f'Already initialized as "{common.init_mode}"!') + + common.init_mode = common.EmbeddedMode + + _init_config() + + common.item_data = common.DataDict() + + if not os.path.isdir(common.temp_path()): + os.makedirs(os.path.normpath(common.temp_path())) + + common.init_signals(connect_signals=False) + common.init_active_mode() + common.init_settings() + + def uninitialize(): """Un-initializes all app components. diff --git a/bookmarks/common/signals.py b/bookmarks/common/signals.py index 91c4f7c9a..a67738503 100644 --- a/bookmarks/common/signals.py +++ b/bookmarks/common/signals.py @@ -2,21 +2,25 @@ """ import functools +import weakref from PySide2 import QtCore from .. import common -def init_signals(): +def init_signals(connect_signals=True): """Initialize signals.""" - common.signals = CoreSignals() + common.signals = CoreSignals(connect_signals=connect_signals) class CoreSignals(QtCore.QObject): """A utility class used to store application-wide signals. """ + #: Signal emitted by worker threads when the internal data of a model is fully loaded + internalDataReady = QtCore.Signal(weakref.ref) + #: Update top bar widget buttons updateTopBarButtons = QtCore.Signal() @@ -78,6 +82,9 @@ class CoreSignals(QtCore.QObject): #: Signals a filter button state change toggleFavouritesButton = QtCore.Signal() + #: Signal emitted when the filter text changes of a list view's proxy model + filterTextChanged = QtCore.Signal(str) + #: Signal emitted when the active path mode changes activeModeChanged = QtCore.Signal(int) @@ -125,9 +132,12 @@ class CoreSignals(QtCore.QObject): #: Signals an item is ready to be processed by a thread threadItemsQueued = QtCore.Signal() - def __init__(self, parent=None): + def __init__(self, connect_signals=True, parent=None): super().__init__(parent=parent) + if not connect_signals: + return + from .. import actions self.toggleFilterButton.connect(actions.toggle_filter_editor) diff --git a/bookmarks/common/ui.py b/bookmarks/common/ui.py index ab424d392..2247a65fd 100644 --- a/bookmarks/common/ui.py +++ b/bookmarks/common/ui.py @@ -114,21 +114,23 @@ def center_window(w): return -def center_to_parent(w): - """Move the given widget to the available screen geometry's middle. +def center_to_parent(widget, parent=None): + """Move the given widget to the widget's parent's middle. Args: - w (QWidget): The widget to center. - p (QWidget): The widget to center to. + widget (QWidget): The widget to center. + parent (QWidget): Optional. The widget to center to. """ - if not w.parent(): + if not widget.parent() and not parent: return + if widget.parent() and not parent: + parent = widget.parent() - w.adjustSize() - g = w.parent().geometry() - r = w.rect() - w.move(g.center() + (r.topLeft() - r.center())) + widget.adjustSize() + g = parent.geometry() + r = widget.rect() + widget.move(g.center() + (r.topLeft() - r.center())) return @@ -364,7 +366,7 @@ def show_message(title, body='', disable_animation=False, icon='icon', message_t body (str): The body of the message box. disable_animation (bool): Whether to show the message box without animation. icon (str): The icon to use. - message_type (str): The message type. + message_type (str): The message type. One of 'info', 'success' or 'error'. buttons (list): The buttons to show. modal (bool): Whether the message box should be modal. parent (QWidget): The parent widget. @@ -398,7 +400,7 @@ def show_message(title, body='', disable_animation=False, icon='icon', message_t if disable_animation: mbox.show() mbox.raise_() - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) return mbox return mbox.open() @@ -498,6 +500,11 @@ def select_index(widget, v, *args, role=QtCore.Qt.DisplayRole, **kwargs): QtCore.QItemSelectionModel.ClearAndSelect | QtCore.QItemSelectionModel.Rows ) + widget.selectionModel().setCurrentIndex( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) widget.scrollTo( index, QtWidgets.QAbstractItemView.PositionAtCenter diff --git a/bookmarks/contextmenu.py b/bookmarks/contextmenu.py index 21e0f7f07..5d482ec60 100644 --- a/bookmarks/contextmenu.py +++ b/bookmarks/contextmenu.py @@ -832,7 +832,7 @@ def quit_menu(self): if common.init_mode == common.EmbeddedMode: return self.menu[key()] = { - 'text': 'Quit {}'.format(common.product), + 'text': f'Quit {common.product.title()}', 'action': common.uninitialize, 'icon': ui.get_icon('close'), 'shortcut': shortcuts.get( @@ -950,7 +950,7 @@ def bookmark_editor_menu(self): self.menu[key()] = { 'text': 'Manage Bookmark Items...', 'icon': icon, - 'action': actions.show_bookmarker, + 'action': actions.show_job_editor, 'shortcut': shortcuts.get( shortcuts.MainWidgetShortcuts, shortcuts.AddItem @@ -1011,7 +1011,7 @@ def task_folder_toggle_menu(self): item_on_pixmap = ui.get_icon('check', color=common.color(common.color_green)) item_off_pixmap = ui.get_icon('folder') - k = 'Select Task Folder...' + k = 'Select asset folder...' self.menu[k] = collections.OrderedDict() self.menu[f'{k}:icon'] = ui.get_icon( 'folder', color=common.color(common.color_green) @@ -1295,6 +1295,10 @@ def launcher_menu(self): 'icon': ui.get_icon('icon'), 'text': 'Application Launcher', 'action': actions.pick_launcher_item, + 'shortcut': shortcuts.get( + shortcuts.MainWidgetShortcuts, + shortcuts.ApplicationLauncher + ).key(), } def sg_thumbnail_menu(self): @@ -1506,7 +1510,7 @@ def sg_rv_menu(self): self.menu[k][key()] = { 'text': 'Add as source', 'icon': ui.get_icon('sg'), - 'action': functools.partial(rv.execute_rvpush_command, path, rv.Add, basecommand=rv.MERGE) + 'action': functools.partial(rv.execute_rvpush_command, path, rv.Add) } self.separator(self.menu[k]) @@ -1529,7 +1533,7 @@ def sg_publish_menu(self): self.separator(self.menu[k]) self.menu[k][key()] = { - 'text': 'SG Publish: MP4 as Version', + 'text': 'Publish Video', 'icon': ui.get_icon('sg', color=common.color(common.color_green)), 'action': functools.partial(sg_actions.publish, formats=('mp4', 'mov')), } @@ -1581,12 +1585,21 @@ def convert_menu(self): if ext.lower() not in images.get_oiio_extensions(): return + # AkaConvert + from .external import akaconvert + if akaconvert.KEY in os.environ and os.environ[akaconvert.KEY]: + self.menu[key()] = { + 'text': 'AkaConvert...', + 'icon': ui.get_icon('studioaka', color=common.color(common.color_blue)), + 'action': actions.convert_image_sequence_with_akaconvert + } + # Can only convert when FFMpeg is present if not common.get_binary('ffmpeg'): return self.menu[key()] = { - 'text': 'Convert Sequence', + 'text': 'Convert Sequence...', 'icon': ui.get_icon('convert'), 'action': actions.convert_image_sequence } @@ -1669,19 +1682,33 @@ def scripts_menu(self): @common.error def _run(name): module = importlib.import_module(f'.scripts.{name}', package=__package__) + + if not hasattr(module, 'run'): + raise RuntimeError(f'Failed to run module: {name} - Missing run() function in {module}!') + module.run() for v in data.values(): - # Check if the script needs_active + if v['name'] == 'separator': + self.separator(menu=self.menu[k]) + continue + + # Check if the script needs active item if 'needs_active' in v and v['needs_active']: if not common.active(v['needs_active'], args=True): continue + # Check if the script needs an application to be set + if 'needs_application' in v and v['needs_application']: + afxs = ('aftereffects', 'afx', 'afterfx') + if not any(([common.get_binary(f)] for f in afxs)): + print(f'Could not find After Effects. Tried: {afxs}') + continue if 'icon' in v and v['icon']: icon = ui.get_icon(v['icon']) else: icon = ui.get_icon('icon') - self.menu[k][v['name']] = { + self.menu[k][key()] = { 'text': v['name'], 'action': functools.partial(_run, v['module']), 'icon': icon, diff --git a/bookmarks/database.py b/bookmarks/database.py index e6cbf7118..8f1da2ea9 100644 --- a/bookmarks/database.py +++ b/bookmarks/database.py @@ -259,6 +259,14 @@ 'sql': 'TEXT', 'type': str }, + 'sg_episode_id': { + 'sql': 'INT', + 'type': int + }, + 'sg_episode_name': { + 'sql': 'TEXT', + 'type': str + }, 'url1': { 'sql': 'TEXT', 'type': str, @@ -274,7 +282,15 @@ 'applications': { 'sql': 'TEXT', 'type': dict, - } + }, + 'bookmark_display_token': { + 'sql': 'TEXT', + 'type': str + }, + 'asset_display_token': { + 'sql': 'TEXT', + 'type': str + }, } } @@ -692,7 +708,7 @@ def get_row(self, source, table): Args: source (str): A source file path. - table (str): A database 01table name. + table (str): A database table name. Returns: dict: A dictionary of column/value pairs. @@ -741,7 +757,7 @@ def value(self, source, key, table): table (str, optional): Optional table parameter, defaults to `AssetTable`. Returns: - The value stored in the database, or None. + object: The value stored in the database, or None. """ if not self.is_valid(): diff --git a/bookmarks/editor/base.py b/bookmarks/editor/base.py index 605b5683d..77a0d8687 100644 --- a/bookmarks/editor/base.py +++ b/bookmarks/editor/base.py @@ -1,14 +1,19 @@ -"""Contains :class:`.BasePropertyEditor` and its required attributes and methods. +"""Contains :class:`.BasePropertyEditor` the property editor base class used +across the application. -The property editor's layout is defined by a previously specified SECTIONS +The editor is designed to interact with the bookmark item database and user +settings file but can be used without either as a general dialog for user actions. + +The editor's layout is defined by SECTIONS dictionary. This contains the sections, rows and editor widget definitions - plus linkage information needed to associate the widget with a bookmark database columns or user setting keys. :class:`BasePropertyEditor` is relatively flexible and has a number of abstract methods that need implementing in subclasses depending on the desired -functionality. See, :meth:`.BasePropertyEditor.db_source`, -:meth:`.BasePropertyEditor.init_data` and :meth:`.BasePropertyEditor.save_changes`. +functionality. In all subclasses, the :meth:`.BasePropertyEditor.db_source`, +:meth:`.BasePropertyEditor.init_data` and :meth:`.BasePropertyEditor.save_changes` +methods must be implemented. """ import datetime @@ -38,6 +43,8 @@ domain_validator.setRegExp(QtCore.QRegExp(r'[a-zA-Z0-9/:\.]+')) version_validator = QtGui.QRegExpValidator() version_validator.setRegExp(QtCore.QRegExp(r'[v]?[0-9]{1,4}')) +token_validator = QtGui.QRegExpValidator() +token_validator.setRegExp(QtCore.QRegExp(r'.*')) span = { 'start': f'', @@ -93,7 +100,7 @@ def add_section(icon, label, parent, color=None): class BasePropertyEditor(QtWidgets.QDialog): - """Base class for constructing a property editor widget. + """Base class for constructing a property editor widgets. Args: server (str or None): `server` path segment. @@ -101,8 +108,8 @@ class BasePropertyEditor(QtWidgets.QDialog): root (str or None): `root` path segment. asset (str or None): `asset` path segment. db_table (str or None): - An optional name of a bookmark database table. When not `None`, the editor - will load and save data to the database. Defaults to `None`. + An optional name of a bookmark database table. When set, the editor + will load and save data to and from the bookmark item database. Defaults to `None`. buttons (tuple): Button labels. Defaults to `('Save', 'Cancel')`. alignment (int): Text alignment. Defaults to `QtCore.Qt.AlignRight`. fallback_thumb (str): An image name. Defaults to `'placeholder'`. @@ -127,8 +134,9 @@ def __init__( alignment=QtCore.Qt.AlignRight, fallback_thumb='placeholder', hide_thumbnail_editor=False, + section_buttons=True, + frameless=False, parent=None, - section_buttons=True ): super().__init__( parent=parent, @@ -141,13 +149,30 @@ def __init__( ) ) + common.check_type(server, (str, None)) + common.check_type(job, (str, None)) + common.check_type(root, (str, None)) + common.check_type(asset, (str, None)) + common.check_type(db_table, (str, None)) + common.check_type(buttons, (tuple, list)) + common.check_type(alignment, int) + common.check_type(fallback_thumb, str) + common.check_type(hide_thumbnail_editor, bool) + common.check_type(section_buttons, bool) + common.check_type(frameless, bool) + common.check_type(parent, (QtWidgets.QWidget, None)) + + if len(buttons) > 2: + raise ValueError('`buttons` must be a tuple of 1 or 2 items.') + self._fallback_thumb = fallback_thumb self._alignment = alignment - self._section_widgets = [] self._buttons = buttons self._db_table = db_table + self._frameless = frameless self.section_buttons = section_buttons + self._section_widgets = [] self.server = server self.job = job @@ -179,6 +204,23 @@ def __init__( else: self.setWindowTitle(f'{server}/{job}/{root}/{asset}') + if self._frameless: + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setAttribute(QtCore.Qt.WA_NoSystemBackground) + + self.setWindowFlags( + QtCore.Qt.Dialog | + QtCore.Qt.FramelessWindowHint + ) + + # Shadow effect + self.effect = QtWidgets.QGraphicsDropShadowEffect(self) + self.effect.setBlurRadius(common.size(common.size_margin) * 2) + self.effect.setXOffset(0) + self.effect.setYOffset(0) + self.effect.setColor(QtGui.QColor(0, 0, 0, 200)) + self.setGraphicsEffect(self.effect) + self._create_ui() self._connect_signals() @@ -225,7 +267,7 @@ def _create_ui(self): QtWidgets.QVBoxLayout(self.section_headers_widget) self.section_headers_widget.layout().setContentsMargins(0, 0, 0, 0) self.section_headers_widget.layout().setSpacing( - common.size(common.size_indicator) + common.size(common.size_indicator) * 2 ) parent = QtWidgets.QWidget(parent=self) @@ -264,13 +306,96 @@ def _create_ui(self): parent.layout().addStretch(1) self._add_buttons() + def _connect_signals(self): + self.cancel_button.clicked.connect( + lambda: self.done(QtWidgets.QDialog.Rejected) + ) + self.save_button.clicked.connect( + lambda: self.done(QtWidgets.QDialog.Accepted) + ) + + common.signals.databaseValueUpdated.connect(self.update_changed_database_value) + + def _connect_data_changed_signals(self, key, _type, editor): + """Utility method for connecting an editor's change signal to `data_changed`. + + `data_changed` will save the changed current value internally. This data + later can be used, for instance, to save the changed values to the + database. + + """ + if hasattr(editor, 'dataUpdated'): + editor.dataUpdated.connect( + functools.partial( + self.data_changed, + key, + _type, + editor + ) + ) + elif hasattr(editor, 'textChanged'): + editor.textChanged.connect( + functools.partial( + self.data_changed, + key, + _type, + editor + ) + ) + elif hasattr(editor, 'currentTextChanged'): + editor.currentTextChanged.connect( + functools.partial( + self.data_changed, + key, + _type, + editor + ) + ) + elif hasattr(editor, 'stateChanged'): + editor.stateChanged.connect( + functools.partial( + self.data_changed, + key, + _type, + editor + ) + ) + + def _connect_settings_save_signals(self, keys): + """Utility method for connecting editor signals to save their current + value in the user setting file. + + Args: + keys (tuple): A tuple of user setting keys. + + """ + for k in keys: + _k = k.replace('/', '_') + if not hasattr(self, f'{_k}_editor'): + print(f'No editor found for {k}') + continue + + editor = getattr(self, f'{_k}_editor') + + if hasattr(editor, 'currentTextChanged'): + signal = getattr(editor, 'currentTextChanged') + elif hasattr(editor, 'textChanged'): + signal = getattr(editor, 'textChanged') + elif hasattr(editor, 'stateChanged'): + signal = getattr(editor, 'stateChanged') + else: + continue + + func = functools.partial(common.settings.setValue, k) + signal.connect(func) + def _create_sections(self): """Translates the section data into a UI layout. """ parent = self.scroll_area.widget() for section in self.sections.values(): - grp = add_section( + section_widget = add_section( section['icon'], section['name'], parent, @@ -278,18 +403,27 @@ def _create_sections(self): ) if self.section_buttons: - self.add_section_header_button(section['name'], grp) + self.add_section_header_button(section['name'], section_widget) for item in section['groups'].values(): - _grp = ui.get_group(parent=grp) + group_widget = ui.get_group(parent=section_widget) for v in item.values(): - self._add_row(v, grp, _grp) + self._add_row(v, group_widget) + + def _add_row(self, v, group_widget): + """Utility method for translating a row item in the section data to a UI layout. + + Args: + v (dict): The row data. + group_widget.parent() (QWidget): The parent group widget. + group_widget (QWidget): The child group widget. - def _add_row(self, v, grp, _grp): - row = ui.add_row(v['name'], parent=_grp, height=None) + """ + row = ui.add_row(v['name'], parent=group_widget, height=None) k = v['key'] _k = k.replace('/', '_') if k else k + name = v['name'] _name = name.lower() if name else name _name = re.sub(r'\W+', '_', _name) if _name else _name @@ -301,12 +435,13 @@ def _add_row(self, v, grp, _grp): if 'widget' in v and v['widget']: if 'no_group' in v and v['no_group']: - editor = v['widget'](parent=grp) - grp.layout().insertWidget(1, editor, 1) + editor = v['widget'](parent=group_widget.parent()) + group_widget.parent().layout().insertWidget(1, editor, 1) else: editor = v['widget'](parent=row) if isinstance(editor, QtWidgets.QCheckBox): # We don't want checkboxes to fully extend across a row + editor.setSizePolicy( QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Maximum, @@ -368,7 +503,7 @@ def _add_row(self, v, grp, _grp): if 'help' in v and v['help']: ui.add_description( - v['help'], label=None, parent=_grp + v['help'], label=None, parent=group_widget ) if 'button' in v and v['button']: @@ -403,34 +538,6 @@ def _add_row(self, v, grp, _grp): ) row.layout().addWidget(button2, 0) - def add_section_header_button(self, name, widget): - """Add a header button to help reveal the given section widget. - - """ - if not name: - return - button = QtWidgets.QPushButton( - name, - parent=self.section_headers_widget - ) - button.setFocusPolicy(QtCore.Qt.NoFocus) - - font, _ = common.font_db.bold_font(common.size(common.size_font_small)) - button.setStyleSheet( - 'outline: none;' - 'border: none;' - f'color: {common.rgb(common.color_light_background)};' - 'text-align: left;' - 'padding: 0px;' - 'margin: 0px;' - f'font-size: {common.size(common.size_font_small)}px;' - f'font-family: "{font.family()}"' - ) - self.section_headers_widget.layout().addWidget(button) - button.clicked.connect( - functools.partial(self.scroll_to_section, widget) - ) - def _add_buttons(self): if not self._buttons: return @@ -439,9 +546,15 @@ def _add_buttons(self): self.save_button = ui.PaintedButton( self._buttons[0], parent=self ) - self.cancel_button = ui.PaintedButton( - self._buttons[1], parent=self - ) + if len(self._buttons) > 1: + self.cancel_button = ui.PaintedButton( + self._buttons[1], parent=self + ) + else: + self.cancel_button = ui.PaintedButton( + 'Close', parent=self + ) + self.cancel_button.setHidden(True) row = ui.add_row( None, height=h * 2, parent=self.right_row @@ -452,6 +565,19 @@ def _add_buttons(self): row.layout().addWidget(self.cancel_button, 0) row.layout().addSpacing(common.size(common.size_margin)) + def add_section_header_button(self, name, widget): + """Add a header button to help reveal the given section widget. + + """ + if not name: + return + button = ui.PaintedLabel(name, parent=self.section_headers_widget) + + self.section_headers_widget.layout().addWidget(button) + button.clicked.connect( + functools.partial(self.scroll_to_section, widget) + ) + @QtCore.Slot(QtWidgets.QWidget) def scroll_to_section(self, widget): """Slot used to scroll to a section when a section header is clicked. @@ -462,89 +588,6 @@ def scroll_to_section(self, widget): point.y() + self.scroll_area.verticalScrollBar().value() ) - def _connect_data_changed_signals(self, key, _type, editor): - """Utility method for connecting an editor's change signal to `data_changed`. - - `data_changed` will save the changed current value internally. This data - later can be used, for instance, to save the changed values to the - database. - - """ - if hasattr(editor, 'dataUpdated'): - editor.dataUpdated.connect( - functools.partial( - self.data_changed, - key, - _type, - editor - ) - ) - elif hasattr(editor, 'textChanged'): - editor.textChanged.connect( - functools.partial( - self.data_changed, - key, - _type, - editor - ) - ) - elif hasattr(editor, 'currentTextChanged'): - editor.currentTextChanged.connect( - functools.partial( - self.data_changed, - key, - _type, - editor - ) - ) - elif hasattr(editor, 'stateChanged'): - editor.stateChanged.connect( - functools.partial( - self.data_changed, - key, - _type, - editor - ) - ) - - def _connect_signals(self): - self.cancel_button.clicked.connect( - lambda: self.done(QtWidgets.QDialog.Rejected) - ) - self.save_button.clicked.connect( - lambda: self.done(QtWidgets.QDialog.Accepted) - ) - - common.signals.databaseValueUpdated.connect(self.update_changed_database_value) - - def _connect_settings_save_signals(self, keys): - """Utility method for connecting editor signals to save their current - value in the user setting file. - - Args: - keys (tuple): A tuple of user setting keys. - - """ - for k in keys: - _k = k.replace('/', '_') - if not hasattr(self, f'{_k}_editor'): - print(f'No editor found for {k}') - continue - - editor = getattr(self, f'{_k}_editor') - - if hasattr(editor, 'currentTextChanged'): - signal = getattr(editor, 'currentTextChanged') - elif hasattr(editor, 'textChanged'): - signal = getattr(editor, 'textChanged') - elif hasattr(editor, 'stateChanged'): - signal = getattr(editor, 'stateChanged') - else: - continue - - func = functools.partial(common.settings.setValue, k) - signal.connect(func) - def load_saved_user_settings(self, keys): """Utility method will load user setting values and apply them to the corresponding editors. @@ -757,29 +800,6 @@ def data_changed(self, key, _type, editor, v): f'color: {common.rgb(common.color_text)};' ) - def db_source(self): - """A file path to use as the source of database values. - - Returns: - str: The database source file. - - """ - raise NotImplementedError('Abstract method must be implemented by subclass.') - - @QtCore.Slot() - def init_data(self): - """Initializes data. - - """ - raise NotImplementedError('Abstract method must be implemented by subclass.') - - @QtCore.Slot() - def save_changes(self): - """Perform save actions and/or data saving. - - """ - raise NotImplementedError('Abstract method must be implemented by subclass.') - @QtCore.Slot() def done(self, result): """Finish editing the item. @@ -801,6 +821,18 @@ def done(self, result): return super().done(result) + def paintEvent(self, event): + if self._frameless: + painter = QtGui.QPainter(self) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(common.color(common.color_background)) + + o = common.size(common.size_margin) * 2 + painter.drawRect(self.rect().adjusted(o, o, -o, -o)) + painter.end() + + super().paintEvent(event) + def changeEvent(self, event): """Change event handler. @@ -840,6 +872,7 @@ def sizeHint(self): common.size(common.size_height) * 1.5 ) + @QtCore.Slot(str) @QtCore.Slot(str) @QtCore.Slot(str) @QtCore.Slot(object) @@ -853,6 +886,12 @@ def update_changed_database_value(self, table, source, key, value): value (object): The new database value. """ + try: + self.db_source() + except NotImplementedError: + print('Skipping database updates, no db source set.') + return + if source != self.db_source(): return @@ -895,3 +934,33 @@ def url2_button_clicked(self): if not v: return QtGui.QDesktopServices.openUrl(v) + + def db_source(self): + """A file path to use as the source of database values. + + When returns `None`, the editor will not load or save data to the + database. + + Returns: + str: A file or folder path. + + """ + raise NotImplementedError('Abstract method must be implemented by subclass.') + + @QtCore.Slot() + def init_data(self): + """Implement this method to initialize the editor's data. + + """ + raise NotImplementedError('Abstract method must be implemented by subclass.') + + @QtCore.Slot() + def save_changes(self): + """Implement to execute any actions and/or save data before closing the + editor. + + For editors that interact with the user database, this method should call + :meth:`save_changed_data_to_db` to save the changed data to the database. + + """ + raise NotImplementedError('Abstract method must be implemented by subclass.') diff --git a/bookmarks/editor/base_widgets.py b/bookmarks/editor/base_widgets.py index cb0bed6b4..6650945e4 100644 --- a/bookmarks/editor/base_widgets.py +++ b/bookmarks/editor/base_widgets.py @@ -16,8 +16,7 @@ HEIGHT = common.size(common.size_row_height) * 0.8 TEMP_THUMBNAIL_PATH = '{temp}/{product}/temp/{uuid}.{ext}' -ProjectTypes = ('Project',) -AssetTypes = ('Episode', 'Asset', 'Sequence', 'Shot') +AssetTypes = ('Asset', 'Sequence', 'Shot') @common.error @@ -142,8 +141,19 @@ def init_items(self): """Initialize items. """ - for entity_type in ProjectTypes: - self.addItem(entity_type) + self.addItem('Project') + + +class SGEpisodeTypesWidget(BaseComboBox): + """ShotGrid entity type picker. + + """ + + def init_items(self): + """Initialize items. + + """ + self.addItem('Episode') class SGAssetTypesWidget(BaseComboBox): diff --git a/bookmarks/editor/bookmark_properties.py b/bookmarks/editor/bookmark_properties.py index 3735565c5..28c869742 100644 --- a/bookmarks/editor/bookmark_properties.py +++ b/bookmarks/editor/bookmark_properties.py @@ -159,7 +159,27 @@ class BookmarkPropertyEditor(base.BasePropertyEditor): 'left empty, all folders in the bookmark will be ' 'interpreted as assets.', } - } + }, + 4: { + 0: { + 'name': 'Bookmark Display Name', + 'key': 'bookmark_display_token', + 'validator': base.token_validator, + 'widget': ui.LineEdit, + 'placeholder': '{server}/{job}/{root}', + 'description': 'Specify the token used to display bookmark items', + 'button': '+' + }, + 1: { + 'name': 'Asset Display Name', + 'key': 'asset_display_token', + 'validator': base.token_validator, + 'widget': ui.LineEdit, + 'placeholder': '{asset}', + 'description': 'Specify the token used to display asset items', + 'button': '+' + }, + }, } }, 1: { @@ -222,31 +242,52 @@ class BookmarkPropertyEditor(base.BasePropertyEditor): 'description': 'Link item with a ShotGrid Entity', 'button': 'Link with ShotGrid Entity', }, - 1: { - 'name': 'Type', + }, + 1: { + 0: { + 'name': 'ShotGrid Entity Type', 'key': 'shotgun_type', 'validator': base.int_validator, 'widget': base_widgets.SGProjectTypesWidget, 'placeholder': None, 'description': 'Select the item\'s ShotGrid type', }, - 2: { - 'name': 'ID', + 1: { + 'name': 'ShotGrid Project Id', 'key': 'shotgun_id', 'validator': base.int_validator, 'widget': ui.LineEdit, 'placeholder': 'ShotGrid Project ID, e.g. \'123\'', - 'description': 'The ShotGrid ID number this item is associated ' + 'description': 'The ShotGrid entity id number this item is associated ' 'with. e.g. \'123\'.', }, - 3: { - 'name': 'Name', + 2: { + 'name': 'ShotGrid Project Name', 'key': 'shotgun_name', 'validator': None, 'widget': ui.LineEdit, 'placeholder': 'ShotGrid project name, e.g. \'MyProject\'', 'description': 'The ShotGrid project name', }, + }, + 2: { + 0: { + 'name': 'ShotGrid Episode Id', + 'key': 'sg_episode_id', + 'validator': base.int_validator, + 'widget': ui.LineEdit, + 'placeholder': 'ShotGrid episode id, e.g. \'123\'', + 'description': 'The ShotGrid episode entity number this item is associated ' + 'with. e.g. \'123\'.', + }, + 1: { + 'name': 'ShotGrid Episode Name', + 'key': 'sg_episode_name', + 'validator': None, + 'widget': ui.LineEdit, + 'placeholder': 'ShotGrid episode entity name, e.g. \'Episode1\'', + 'description': 'The ShotGrid episode entity name', + }, } } }, @@ -476,3 +517,27 @@ def applications_button_clicked(self): """ self.applications_editor.add_new_item() + + @QtCore.Slot() + def bookmark_display_token_button_clicked(self): + k = 'bookmark_display_token' + if not hasattr(self, f'{k}_editor'): + raise RuntimeError(f'{k}_editor not found') + + from ..tokens import tokens_editor + editor = getattr(self, f'{k}_editor') + w = tokens_editor.TokenEditor(self.server, self.job, self.root, parent=editor) + w.tokenSelected.connect(lambda x: editor.setText(f'{editor.text()}{x}')) + w.exec_() + + @QtCore.Slot() + def asset_display_token_button_clicked(self): + k = 'asset_display_token' + if not hasattr(self, f'{k}_editor'): + raise RuntimeError(f'{k}_editor not found') + + from ..tokens import tokens_editor + editor = getattr(self, f'{k}_editor') + w = tokens_editor.TokenEditor(self.server, self.job, self.root, parent=editor) + w.tokenSelected.connect(lambda x: editor.setText(f'{editor.text()}{x}')) + w.exec_() \ No newline at end of file diff --git a/bookmarks/editor/jobs.py b/bookmarks/editor/jobs.py new file mode 100644 index 000000000..8ef2b2ed8 --- /dev/null +++ b/bookmarks/editor/jobs.py @@ -0,0 +1,261 @@ +"""Widgets to create jobs and edit bookmark items. + +Job is a pseudo-entity in the Bookmarks as it is simply a folder on a server that contains +a bookmarks items. This module allows adding a server path to the user settings, +create new jobs using templates and pick folders from within the job to use as +bookmark items. + +To show the main editor call: + +.. code-block:: python + :linenos: + + from bookmarks import actions + bookmarks.actions.show_job_editor() + + +""" + +from PySide2 import QtCore + +from . import base +from . import jobs_widgets +from .. import common + + +def close(): + """Closes the :class:`JobsEditor` editor. + + """ + if common.job_editor is None: + return + try: + common.job_editor.close() + common.job_editor.deleteLater() + except: + pass + common.jobs_editor = None + + +def show(): + """Shows the :class:`JobsEditor` editor. + + """ + close() + common.job_editor = JobsEditor() + + common.restore_window_geometry(common.job_editor) + common.restore_window_state(common.job_editor) + + return common.bookmark_property_editor + + +class JobsEditor(base.BasePropertyEditor): + """The :class:`JobsEditor` class provides a widget for creating jobs and + the bookmark items within the job. + + """ + #: UI Layout definition + + sections = { + 0: { + 'name': 'Edit Jobs', + 'icon': 'icon_bw', + 'color': common.color(common.color_green), + 'groups': { + 0: { + 0: { + 'name': None, + 'key': 'server_btn', + 'validator': None, + 'widget': None, + 'placeholder': None, + 'description': 'Edit the list of servers Bookmarks should read jobs from.', + # 'help': f'Edit the list of servers Bookmarks should read jobs from. The server should be ' + # f'a directory where jobs are kept, e.g. ' + # 'Z:/ or' + # '//server/jobs.'.format(c=common.rgb( + # common.color_green) + # ), + 'button': 'Add server...' + }, + 1: { + 'name': None, + 'key': 'server', + 'validator': None, + 'widget': jobs_widgets.ServersWidget, + 'placeholder': None, + 'description': 'List of servers added to the user preferences.', + }, + }, + 1: { + 0: { + 'name': None, + 'key': 'job_btn', + 'validator': None, + 'widget': None, + 'placeholder': None, + 'description': 'Add jobs and bookmark items', + # 'help': 'Add jobs to the current server. A job is a folder on a server that contains ' + # 'bookmark items. To mark a folder as a bookmark item, right-click on a job and ' + # 'select "Add bookmark item...".', + 'button': 'Add job...' + }, + 1: { + 'name': None, + 'key': 'job', + 'validator': None, + 'widget': jobs_widgets.JobsView, + 'placeholder': None, + 'description': 'The list of jobs in the current server', + }, + }, + 2: { + 0: { + 'name': 'Bookmark item search depth', + 'key': 'jobs/scandepth', + 'validator': None, + 'widget': jobs_widgets.ScanDepthComboBox, + 'placeholder': None, + 'description': 'Set the maximum folder depth to parse. Parsing large ' + 'project folders will take a long time. This setting ' + 'will limit the number of sub-directories the editor ' + 'parses when looking for bookmark items.', + }, + } + } + } + } + + def __init__(self, parent=None): + super().__init__( + None, + None, + None, + buttons=('Close',), + fallback_thumb='icon_bw', + hide_thumbnail_editor=True, + section_buttons=False, + parent=parent + ) + + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.setWindowTitle('Edit Jobs') + + def init_data(self): + self.load_saved_user_settings(common.SECTIONS['jobs']) + self._connect_settings_save_signals(common.SECTIONS['jobs']) + + self.server_editor.selectionModel().selectionChanged.connect(self.server_selection_changed) + self.server_editor.model().modelAboutToBeReset.connect(self.server_selection_changed) + self.server_editor.model().modelReset.connect(self.server_selection_changed) + + self.server_editor.progressUpdate.connect(self.show_message) + self.job_editor.progressUpdate.connect(self.show_message) + + self.server_editor.init_data() + + @QtCore.Slot(str) + @QtCore.Slot(str) + def show_message(self, title, body): + """Shows a progress message as the models are loading. + + """ + if not title: + common.close_message() + return + + try: + common.message_widget.set_labels(title, body) + return + except: + pass + + common.show_message( + title, + body=body, + disable_animation=True, + message_type=None, + buttons=[], + ) + + def save_changes(self): + return True + + def get_args(self): + """Returns the server, job and root arguments based on the current + bookmark item selection. + + Returns: + tuple: The server, job and root arguments. + + """ + editor = self.job_editor + model = editor.model() + server = model.root_node.name + + if not server or server == 'server': + return None, None, None + + if not editor.selectionModel().hasSelection(): + return None, None, None + + index = next(f for f in editor.selectionModel().selectedIndexes()) + node = index.internalPointer() + + if not node: + return None, None, None + + if not isinstance(node, jobs_widgets.BookmarkItemNode): + return None, None, None + + job = node.parent.name[len(server) + 1:] + root = node.name[len(server) + len(job) + 2:] + return server, job, root + + @QtCore.Slot() + def server_selection_changed(self, *args, **kwargs): + """Slot -> called when the server editor's selection changes. + + """ + model = self.server_editor.selectionModel() + + if not model.hasSelection(): + return self.job_editor.model().init_data('server') + + index = next(f for f in model.selectedIndexes()) + + if not index.isValid(): + return self.job_editor.model().init_data('server') + + v = index.data(QtCore.Qt.UserRole) + if not v: + return self.job_editor.model().init_data('server') + + self.job_editor.model().init_data(v) + + @QtCore.Slot() + def server_btn_button_clicked(self): + """Slot -> Called when the server editor's 'Add' button is clicked. + + """ + self.server_editor.add() + + @QtCore.Slot() + def job_btn_button_clicked(self): + """Slot -> Called when the server editor's 'Add' button is clicked. + + """ + self.job_editor.add() + + def hideEvent(self, event): + self._disconnect_signals() + + def _disconnect_signals(self): + self.server_editor.selectionModel().selectionChanged.disconnect() + self.server_editor.model().modelAboutToBeReset.disconnect() + self.server_editor.model().modelReset.disconnect() + + self.job_editor.selectionModel().selectionChanged.disconnect() + self.job_editor.model().modelAboutToBeReset.disconnect() + self.job_editor.model().modelReset.disconnect() diff --git a/bookmarks/editor/jobs_widgets.py b/bookmarks/editor/jobs_widgets.py new file mode 100644 index 000000000..558a48c72 --- /dev/null +++ b/bookmarks/editor/jobs_widgets.py @@ -0,0 +1,1497 @@ +"""The widgets needed by the jobs editor. + +""" +import functools +import json +import os + +from PySide2 import QtCore, QtGui, QtWidgets + +from . import base +from .. import actions +from .. import common +from .. import contextmenu +from .. import images +from .. import shortcuts +from .. import templates +from .. import ui + +cache = common.DataDict() + + +class ScanDepthComboBox(QtWidgets.QComboBox): + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setView(QtWidgets.QListView()) + self.init_data() + + def init_data(self): + """Initializes data. + + """ + self.blockSignals(True) + for k in range(1, 5): + self.addItem(str(f'{k}'), userData=k) + self.blockSignals(False) + + +class AddServerDialog(QtWidgets.QDialog): + """Dialog used to add a new server to user settings file. + + """ + + def __init__(self, parent=None): + super().__init__(parent=parent) + + self.ok_button = None + self.pick_button = None + self.editor = None + + self.setWindowTitle('Add new server') + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setAttribute(QtCore.Qt.WA_NoSystemBackground) + + self.setWindowFlags( + QtCore.Qt.Dialog | + QtCore.Qt.FramelessWindowHint + ) + + # Shadow effect + self.effect = QtWidgets.QGraphicsDropShadowEffect(self) + self.effect.setBlurRadius(common.size(common.size_margin) * 2) + self.effect.setXOffset(0) + self.effect.setYOffset(0) + self.effect.setColor(QtGui.QColor(0, 0, 0, 200)) + self.setGraphicsEffect(self.effect) + + self._create_ui() + self._connect_signals() + self._add_completer() + + def _create_ui(self): + if not self.parent(): + common.set_stylesheet(self) + + QtWidgets.QVBoxLayout(self) + + o = common.size(common.size_margin) + _o = common.size(common.size_margin) * 3 + self.layout().setSpacing(o) + self.layout().setContentsMargins(_o, _o, _o, _o) + + self.ok_button = ui.PaintedButton('Add', parent=self) + self.ok_button.setFixedHeight( + common.size(common.size_row_height) * 0.8 + ) + self.cancel_button = ui.PaintedButton('Cancel', parent=self) + self.cancel_button.setFixedHeight( + common.size(common.size_row_height) * 0.8 + ) + self.pick_button = ui.PaintedButton('Pick', parent=self) + + self.editor = ui.LineEdit(parent=self) + self.editor.setPlaceholderText( + 'Enter the path to a server, e.g. \'//my_server/jobs\'' + ) + self.setFocusProxy(self.editor) + self.editor.setFocusPolicy(QtCore.Qt.StrongFocus) + + row = ui.add_row(None, parent=self) + row.layout().addWidget(self.editor, 1) + row.layout().addWidget(self.pick_button, 0) + + row = ui.add_row(None, parent=self) + row.layout().addWidget(self.ok_button, 1) + row.layout().addWidget(self.cancel_button, 0) + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(common.color(common.color_background)) + + o = common.size(common.size_margin) * 2 + painter.drawRect(self.rect().adjusted(o, o, -o, -o)) + painter.end() + + def _connect_signals(self): + """Connect signals.""" + self.ok_button.clicked.connect( + lambda: self.done(QtWidgets.QDialog.Accepted) + ) + self.cancel_button.clicked.connect( + lambda: self.done(QtWidgets.QDialog.Rejected) + ) + self.pick_button.clicked.connect(self.pick) + self.editor.textChanged.connect( + lambda: self.editor.setStyleSheet( + f'color: {common.rgb(common.color_green)};' + ) + ) + + def _add_completer(self): + """Add and populate a QCompleter with mounted drive names. + + """ + items = [] + for info in QtCore.QStorageInfo.mountedVolumes(): + if info.isValid(): + items.append(info.rootPath()) + items += common.servers.values() + + completer = QtWidgets.QCompleter(items, parent=self) + completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + common.set_stylesheet(completer.popup()) + self.editor.setCompleter(completer) + + @QtCore.Slot() + def pick(self): + """Get an existing directory to use as a server. + + """ + _dir = QtWidgets.QFileDialog.getExistingDirectory(parent=self) + if not _dir: + return + + file_info = QtCore.QFileInfo(_dir) + if file_info.exists(): + self.editor.setText(file_info.absoluteFilePath()) + + @common.error + @common.debug + def done(self, result): + """Finalize action. + + """ + if result == QtWidgets.QDialog.Rejected: + super().done(result) + return + + if not self.text(): + raise RuntimeError('No server path specified.') + + v = self.text() + file_info = QtCore.QFileInfo(v) + + if not file_info.exists(): + self._apply_invalid_style() + raise RuntimeError(f'{file_info.filePath()} path does not exist.') + + if not file_info.isReadable(): + self._apply_invalid_style() + raise RuntimeError(f'{file_info.filePath()} path is not readable.') + + if v in common.servers: + self._apply_invalid_style() + raise RuntimeError(f'{file_info.filePath()} path already added to the server list.') + + actions.add_server(v) + super().done(QtWidgets.QDialog.Accepted) + + def _apply_invalid_style(self): + # Indicate the selected item is invalid and keep the editor open + self.editor.setStyleSheet( + 'color: {0}; border-color: {0}'.format( + common.rgb(common.color_red) + ) + ) + self.editor.blockSignals(True) + self.editor.setText(self.text()) + self.editor.blockSignals(False) + + def text(self): + """Sanitize text. + + Returns: + str: The sanitized text. + + """ + v = self.editor.text() + return common.strip(v) if v else '' + + def showEvent(self, event): + """Show event handler. + + """ + common.center_to_parent(self, self.parent().window()) + self.editor.setFocus() + + def sizeHint(self): + """Returns a size hint. + + """ + return QtCore.QSize( + common.size(common.size_width), + common.size(common.size_row_height) * 2 + ) + + +class ServersWidgetContextMenu(contextmenu.BaseContextMenu): + """Context menu associated with :class:`ServersWidget`. + + """ + + def setup(self): + """Creates the context menu. + + """ + self.add_menu() + self.separator() + if isinstance( + self.index, QtWidgets.QListWidgetItem + ) and self.index.flags() & QtCore.Qt.ItemIsEnabled: + self.reveal_menu() + self.remove_menu() + elif isinstance( + self.index, + QtWidgets.QListWidgetItem + ) and not self.index.flags() & QtCore.Qt.ItemIsEnabled: + self.remove_menu() + self.separator() + self.refresh_menu() + + def add_menu(self): + """Add server action. + + """ + self.menu[contextmenu.key()] = { + 'text': 'Add server...', + 'action': self.parent().add, + 'icon': ui.get_icon('add', color=common.color(common.color_green)) + } + + def reveal_menu(self): + """Reveal server item action. + + """ + self.menu['Reveal...'] = { + 'action': lambda: actions.reveal(f'{self.index.text()}/.'), + 'icon': ui.get_icon('folder'), + } + + def remove_menu(self): + """Remove server item action. + + """ + self.menu['Remove'] = { + 'action': self.parent().remove, + 'icon': ui.get_icon('close', color=common.color(common.color_red)) + } + + def refresh_menu(self): + """Refresh server list action. + + """ + self.menu['Refresh'] = { + 'action': self.parent().init_data, + 'icon': ui.get_icon('refresh') + } + + +class ServersWidget(ui.ListWidget): + """List widget used to add and remove servers to and from the local + user settings. + + """ + progressUpdate = QtCore.Signal(str, str) + + def __init__(self, parent=None): + super().__init__( + default_icon='server', + parent=parent + ) + + self.setAttribute(QtCore.Qt.WA_NoSystemBackground) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + self._connect_signals() + self._init_shortcuts() + + def _init_shortcuts(self): + """Initializes shortcuts. + + """ + shortcuts.add_shortcuts(self, shortcuts.JobEditorShortcuts) + connect = functools.partial( + shortcuts.connect, shortcuts.JobEditorShortcuts + ) + connect(shortcuts.AddItem, self.add) + connect(shortcuts.RemoveItem, self.remove) + + def _connect_signals(self): + """Connects signals. + + """ + super()._connect_signals() + + self.selectionModel().selectionChanged.connect( + functools.partial(common.save_selection, self) + ) + + common.signals.serversChanged.connect(self.init_data) + common.signals.serverAdded.connect( + functools.partial(common.select_index, self) + ) + + @common.debug + @common.error + @QtCore.Slot() + def remove(self, *args, **kwargs): + """Remove a server item. + + """ + index = common.get_selected_index(self) + if not index.isValid(): + return + + v = index.data(QtCore.Qt.DisplayRole) + v = common.strip(v) + actions.remove_server(v) + + @common.debug + @common.error + @QtCore.Slot() + def add(self, *args, **kwargs): + """Add a server item. + + """ + w = AddServerDialog(parent=self) + w.accepted.connect(self.init_data) + w.open() + + def contextMenuEvent(self, event): + """Context menu event handler. + + """ + item = self.itemAt(event.pos()) + menu = ServersWidgetContextMenu(item, parent=self) + pos = event.pos() + pos = self.mapToGlobal(pos) + menu.move(pos) + menu.exec_() + + @common.debug + @common.error + @QtCore.Slot() + def init_data(self, *args, **kwargs): + """Load data. + + """ + common.save_selection(self) + + self.selectionModel().clearSelection() + self.selectionModel().blockSignals(True) + + self.clear() + + size = QtCore.QSize( + 0, + common.size(common.size_row_height) * 0.8 + ) + + for path in sorted(common.servers, key=lambda x: x.lower()): + self.progressUpdate.emit('Loading servers', f'Loading {path}...') + + item = QtWidgets.QListWidgetItem() + item.setData(QtCore.Qt.DisplayRole, path) + item.setData(QtCore.Qt.UserRole, path) + item.setData(QtCore.Qt.StatusTipRole, path) + item.setData(QtCore.Qt.WhatsThisRole, path) + item.setData(QtCore.Qt.ToolTipRole, path) + item.setSizeHint(size) + + self.validate_item(item) + self.insertItem(self.count(), item) + + self.progressUpdate.emit('', '') + self.selectionModel().blockSignals(False) + common.restore_selection(self) + + @QtCore.Slot(QtWidgets.QListWidgetItem) + def validate_item(self, item): + """Check if the given server item is valid. + + """ + selected_index = common.get_selected_index(self) + + pixmap = images.rsc_pixmap( + 'server', common.color(common.color_text), + common.size(common.size_row_height) * 0.8 + ) + pixmap_selected = images.rsc_pixmap( + 'server', common.color(common.color_green), + common.size(common.size_row_height) * 0.8 + ) + pixmap_disabled = images.rsc_pixmap( + 'close', common.color(common.color_red), + common.size(common.size_row_height) * 0.8 + ) + icon = QtGui.QIcon() + + file_info = QtCore.QFileInfo(item.text()) + if file_info.exists() and file_info.isReadable(): + icon.addPixmap(pixmap, QtGui.QIcon.Normal) + icon.addPixmap(pixmap_selected, QtGui.QIcon.Selected) + icon.addPixmap(pixmap_selected, QtGui.QIcon.Active) + icon.addPixmap(pixmap_disabled, QtGui.QIcon.Disabled) + item.setFlags( + QtCore.Qt.ItemIsEnabled | + QtCore.Qt.ItemIsSelectable + ) + valid = True + else: + icon.addPixmap(pixmap_disabled, QtGui.QIcon.Normal) + icon.addPixmap(pixmap_disabled, QtGui.QIcon.Selected) + icon.addPixmap(pixmap_disabled, QtGui.QIcon.Active) + icon.addPixmap(pixmap_disabled, QtGui.QIcon.Disabled) + valid = False + + item.setData(QtCore.Qt.DecorationRole, icon) + + index = self.indexFromItem(item) + if not valid and selected_index == index: + self.selectionModel().clearSelection() + + +class Node(QtCore.QObject): + + def __contains__(self, v): + if not isinstance(v, str): + return False + return [f for f in self.children if f.name.lower() == v.lower()] != [] + + def __init__(self, name): + self.name = name + self.children = [] + self.parent = None + + self.fetched_children = False + + def add_child(self, child): + child.parent = self + + children = self.children + children.append(child) + self.children = sorted(children, key=lambda x: x.name.lower()) + + def child(self, row): + if row < 0 or row >= len(self.children): + return None + return self.children[row] + + def child_count(self): + return len(self.children) + + def row(self): + if self.parent: + return self.parent.children.index(self) + return 0 + + +class ServerNode(Node): + pass + + +class JobNode(Node): + pass + + +class BookmarkItemNode(Node): + pass + + +class AddJobDialog(base.BasePropertyEditor): + """A custom `BasePropertyEditor` used to add new jobs on a server. + + """ + + #: UI layout definition + sections = { + 0: { + 'name': 'Add Job', + 'icon': '', + 'color': common.color(common.color_dark_background), + 'groups': { + 0: { + 0: { + 'name': 'Name', + 'key': None, + 'validator': base.job_name_validator, + 'widget': ui.LineEdit, + 'placeholder': 'Name, e.g. `MY_NEW_JOB`', + 'description': 'The job\'s name, e.g. `MY_NEW_JOB`.', + }, + }, + 1: { + 0: { + 'name': 'Template', + 'key': None, + 'validator': None, + 'widget': functools.partial( + templates.TemplatesWidget, templates.JobTemplateMode + ), + 'placeholder': None, + 'description': 'Select a folder template to create this asset.', + }, + }, + }, + }, + } + + def __init__(self, server, parent=None): + super().__init__( + server, + None, + None, + asset=None, + db_table=None, + buttons=('Add job', 'Cancel'), + hide_thumbnail_editor=False, + section_buttons=False, + frameless=True, + fallback_thumb='placeholder', + parent=parent + ) + + self.setWindowTitle(f'{self.server}: Add job') + + o = common.size(common.size_margin) * 2 + self.layout().setContentsMargins(o, o, o, o) + + self.scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + common.signals.templateExpanded.connect(self.close) + common.signals.jobAdded.connect(self.close) + common.signals.serversChanged.connect(self.close) + + def db_source(self): + """A file path to use as the source of database values. + + Returns: + str: The database source file. + + """ + return None + + def init_data(self): + """Initialize data. + + """ + pass + + @common.error + @common.debug + def save_changes(self): + """Verify user options and create a new job item. + + """ + + value = self.name_editor.text() + if not value: + raise ValueError('Must enter a name to create a job.') + + # Check if there's a thumbnail set + if not self.thumbnail_editor.image() or self.thumbnail_editor.image().isNull(): + if common.show_message( + 'No thumbnail set for new job', + 'Are you sure want to continue? You can add a thumbnail after the job was created by ' + 'saving a `thumbnail.png` file to the job\'s folder.', + buttons=[common.YesButton, common.CancelButton], + message_type=None, + modal=True + ) == QtWidgets.QDialog.Rejected: + return False + + value = value.replace('\\', '/') + server = self.server + + if QtCore.QFileInfo(f'{server}/{value}').exists(): + raise RuntimeError(f'{server}/{value} already exists.') + + # Assets with relative paths + if '/' in self.name_editor.text(): + segments = value.split('/') + name = segments.pop() + + # Create the folder structure + rel_path = '/'.join(segments) + + _dir = QtCore.QDir(f'{server}/{rel_path}') + if not _dir.exists() and not _dir.mkpath('.'): + raise RuntimeError(f'Could not create {_dir.path()}') + + # Get the folder the template will be expanded into and + # create it if it doesn't exist + asset_root = f'{server}/{segments.pop(0)}' + _dir = QtCore.QDir(asset_root) + if not _dir.exists() and not _dir.mkpath('.'): + raise RuntimeError(f'Could not create {_dir.path()}') + + # Expand the template into the asset folder + self.template_editor.template_list_widget.create(name, f'{server}/{rel_path}') + path = f'{server}/{rel_path}/{name}' + + # Add the link to the first folder of the asset structure + if not common.add_link( + asset_root, + f'{"/".join(segments)}/{name}'.strip('/'), + section='links/job' + ): + raise RuntimeError(f'Could not add link to {server}/{segments[0]}') + else: + name = value + + # Expand the template into the asset folder + self.template_editor.template_list_widget.create(name, server) + path = f'{server}/{name}' + + # Verify the job was created + file_info = QtCore.QFileInfo(path) + if not file_info.exists(): + raise RuntimeError(f'Could not find {path}') + + # Create a thumbnail + try: + path += f'/thumbnail.{common.thumbnail_format}' + self.thumbnail_editor.save_image(destination=path) + except: + pass + + # Let the outside world know + common.signals.jobAdded.emit(file_info.filePath()) + + common.show_message( + 'Success', + f'{name} was successfully created at\n{file_info.filePath()}', + message_type='success' + ) + + return True + + def showEvent(self, event): + """Show event handler. + + """ + common.center_to_parent(self, self.parent().window()) + self.name_editor.setFocus() + + def sizeHint(self): + """Returns a size hint. + + """ + return QtCore.QSize( + common.size(common.size_width) * 1.4, + common.size(common.size_height) * 1.0 + ) + + +class JobsViewContextMenu(contextmenu.BaseContextMenu): + """Context menu associated with :class:`BookmarkItemEditor`. + + """ + + def setup(self): + """Creates the context menu. + + """ + self.add_menu() + self.separator() + self.bookmark_properties_menu() + self.separator() + self.reveal_menu() + self.copy_json_menu() + self.show_links_menu() + self.separator() + self.collapse_menu() + self.separator() + self.refresh_menu() + self.separator() + self.prune_bookmarks_menu() + self.reveal_default_bookmarks_menu() + + def collapse_menu(self): + """Menu used to collapse items. + + """ + self.menu[contextmenu.key()] = { + 'text': 'Collapse all', + 'action': self.parent().collapseAll, + } + + def add_menu(self): + """Menu used to mark a folder as a bookmark item. + + """ + self.menu[contextmenu.key()] = { + 'text': 'Add bookmark item...', + 'action': self.parent().add_bookmark_item, + 'icon': ui.get_icon('add', color=common.color(common.color_green)) + } + + def reveal_menu(self): + """Reveal bookmark item action.""" + self.menu[contextmenu.key()] = { + 'text': 'Reveal', + 'action': functools.partial( + actions.reveal, f'{self.index.data(QtCore.Qt.UserRole)}/.' + ), + 'icon': ui.get_icon('folder') + } + + def refresh_menu(self): + """Forces a model data refresh. + + """ + model = self.parent().model() + self.menu[contextmenu.key()] = { + 'text': 'Refresh', + 'action': lambda: model.init_data(model.root_node.name, force=True), + 'icon': ui.get_icon('refresh') + } + + def bookmark_properties_menu(self): + """Show the bookmark item property editor. + + """ + server, job, root = self.parent().window().get_args() + if not all((server, job, root)): + return + + self.menu[contextmenu.key()] = { + 'text': 'Edit bookmark item properties...', + 'action': functools.partial(actions.edit_bookmark, server, job, root), + 'icon': ui.get_icon('settings') + } + + @QtCore.Slot() + @common.error + @common.debug + def copy_json_menu(self): + """Copy bookmark item as JSON action. + + """ + server, job, root = self.parent().window().get_args() + if not all((server, job, root)): + return + + if not QtCore.QFileInfo(f'{server}/{job}/{root}').exists(): + raise RuntimeError(f'{server}/{job}/{root} does not exist.') + + d = { + f'{server}/{job}/{root}': { + 'server': server, + 'job': job, + 'root': root + } + } + s = json.dumps( + d, + indent=4, + ) + + def show_json(s): + """Shows a popup with the bookmark item as json text. + + """ + w = QtWidgets.QDialog(parent=self.parent()) + w.setMinimumWidth(common.size(common.size_width)) + w.setMinimumHeight(common.size(common.size_height * 0.5)) + b = QtWidgets.QTextBrowser(parent=w) + b.setText(s) + QtWidgets.QVBoxLayout(w) + w.layout().addWidget(b) + w.open() + + QtWidgets.QApplication.clipboard().setText(s) + + self.menu[contextmenu.key()] = { + 'text': 'Item as json...', + 'action': functools.partial(show_json, s), + 'icon': ui.get_icon('copy') + } + + @QtCore.Slot() + @common.debug + @common.error + def show_links_menu(self): + """Show the links associated with the selected item. + + """ + if not self.parent().selectionModel().hasSelection(): + return + + index = next(iter(self.parent().selectionModel().selectedIndexes())) + if not index.isValid(): + return + + server = self.parent().model().root_node.name + if server == 'server': + return + + path = index.data(QtCore.Qt.UserRole) + + v = common.get_links( + f'{server}/{path[len(server) + 1:].split("/")[0]}', + section='links/job' + ) + + def _show_links(s): + """Shows a popup with the bookmark item as json text. + + """ + w = QtWidgets.QDialog(parent=self.parent()) + w.setMinimumWidth(common.size(common.size_width)) + w.setMinimumHeight(common.size(common.size_height * 0.5)) + b = QtWidgets.QTextBrowser(parent=w) + b.setText(s) + QtWidgets.QVBoxLayout(w) + w.layout().addWidget(b) + w.open() + + QtWidgets.QApplication.clipboard().setText(s) + + self.menu[contextmenu.key()] = { + 'text': 'Show links...', + 'action': functools.partial(_show_links, '\n'.join(v)), + 'icon': ui.get_icon('copy') + } + + @QtCore.Slot() + @common.debug + @common.error + def prune_bookmarks_menu(self): + """Prune bookmarks. + + """ + + def prune(): + """Prune bookmarks. + + """ + actions.prune_bookmarks() + server = self.parent().model().root_node.name + if server == 'server': + return + self.parent().model().init_data(server, force=True) + + self.menu[contextmenu.key()] = { + 'text': 'Prune bookmark items', + 'action': prune, + } + + @QtCore.Slot() + @common.debug + @common.error + def reveal_default_bookmarks_menu(self): + self.menu[contextmenu.key()] = { + 'text': 'Reveal default bookmarks', + 'action': actions.reveal_default_bookmarks_json, + } + + +class JobsModel(QtCore.QAbstractItemModel): + progressUpdate = QtCore.Signal(str, str) + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.root_node = ServerNode('server') + + def index(self, row, column, parent=QtCore.QModelIndex()): + if not self.hasIndex(row, column, parent): + return QtCore.QModelIndex() + + if not parent.isValid(): + parent_node = self.root_node + else: + parent_node = parent.internalPointer() + + child_node = parent_node.child(row) + if child_node: + return self.createIndex(row, column, child_node) + else: + return QtCore.QModelIndex() + + def parent(self, index): + if not index.isValid(): + return QtCore.QModelIndex() + + node = index.internalPointer() + if not node: + return QtCore.QModelIndex() + + if node.parent == self.root_node: + return QtCore.QModelIndex() + + return self.createIndex(node.parent.row(), 0, node.parent) + + def rowCount(self, parent=QtCore.QModelIndex()): + if parent.column() > 0: + return 0 + + if not parent.isValid(): + parent_node = self.root_node + else: + parent_node = parent.internalPointer() + + return parent_node.child_count() + + def columnCount(self, parent=QtCore.QModelIndex()): + return 2 + + def headerData(self, section, orientation, role): + if role == QtCore.Qt.DisplayRole: + if section == 0: + return 'Job' + elif section == 1: + return 'Status' + + if role == QtCore.Qt.SizeHintRole: + return QtCore.QSize( + 0, + common.size(common.size_row_height) * 0.8 + ) + + if role == QtCore.Qt.TextAlignmentRole: + return QtCore.Qt.AlignCenter + + return None + + def data(self, index, role, parent=QtCore.QModelIndex()): + if not index.isValid(): + return None + node = index.internalPointer() + if not node: + return None + + if role == QtCore.Qt.SizeHintRole: + if isinstance(node, BookmarkItemNode): + return QtCore.QSize( + super().parent().width() * 0.5, + common.size(common.size_row_height) * 0.8 + ) + elif isinstance(node, JobNode): + return QtCore.QSize( + super().parent().width() * 0.5, + common.size(common.size_row_height) * 0.8 + ) + else: + return QtCore.QSize( + super().parent().width() * 0.5, + common.size(common.size_row_height) + ) + + if index.column() == 0: + if role == QtCore.Qt.DisplayRole: + return node.name[len(node.parent.name):].strip('/') + + if role == QtCore.Qt.UserRole: + return index.internalPointer().name + + if role == QtCore.Qt.ToolTipRole: + return index.internalPointer().name + + if role == QtCore.Qt.WhatsThisRole: + return index.internalPointer().name + + if role == QtCore.Qt.StatusTipRole: + return index.internalPointer().name + + if role == QtCore.Qt.ForegroundRole: + if isinstance(node, JobNode) and self.hasChildren(index): + return common.color(common.color_text) + elif isinstance(node, JobNode) and not self.hasChildren(index): + return common.color(common.color_disabled_text) + elif isinstance(node, BookmarkItemNode) and node.name in common.bookmarks: + return common.color(common.color_text) + elif isinstance(node, BookmarkItemNode) and node.name not in common.bookmarks: + return common.color(common.color_disabled_text) + + if role == QtCore.Qt.DecorationRole: + if isinstance(node, ServerNode): + return ui.get_icon('server') + elif isinstance(node, JobNode) and self.hasChildren(index): + return ui.get_icon('asset', color=common.color(common.color_disabled_text)) + elif isinstance(node, JobNode) and not self.hasChildren(index): + return ui.get_icon('asset', color=common.color(common.color_dark_background)) + elif isinstance(node, BookmarkItemNode) and node.name in common.bookmarks: + return ui.get_icon('bookmark', color=common.color(common.color_green)) + elif isinstance(node, BookmarkItemNode): + return ui.get_icon('bookmark', color=common.color(common.color_disabled_text)) + + if index.column() == 1: + + if isinstance(node, JobNode): + if role == QtCore.Qt.DecorationRole: + return ui.get_icon('add_circle', color=common.color(common.color_green)) + if role == QtCore.Qt.DisplayRole: + return 'Add bookmark item' + if role == QtCore.Qt.ForegroundRole: + return common.color(common.color_green) + if role == QtCore.Qt.WhatsThisRole: + return 'Click to add a new bookmark item' + if role == QtCore.Qt.ToolTipRole: + return 'Click to add a new bookmark item' + if role == QtCore.Qt.StatusTipRole: + return 'Click to add a new bookmark item' + + if isinstance(node, BookmarkItemNode): + if role == QtCore.Qt.DisplayRole: + if node.name in common.bookmarks: + return 'active' + else: + return 'inactive' + if role == QtCore.Qt.ForegroundRole: + if node.name in common.bookmarks: + return common.color(common.color_green) + else: + return common.color(common.color_disabled_text) + if role == QtCore.Qt.DecorationRole: + if node.name in common.bookmarks: + return ui.get_icon('check', color=common.color(common.color_green)) + else: + return ui.get_icon('close', color=common.color(common.color_red)) + if role == QtCore.Qt.WhatsThisRole: + return f'Bookmark item: {node.name}' + if role == QtCore.Qt.ToolTipRole: + return f'Bookmark item: {node.name}' + if role == QtCore.Qt.StatusTipRole: + return f'Bookmark item: {node.name}' + + return None + + def canFetchMore(self, index): + """Returns True if the parent node has not been expanded yet. + + """ + if not index.isValid(): + return False + + node = index.internalPointer() + + if isinstance(node.parent, JobNode): + return False + + if not node.fetched_children: + return True + + return False + + def fetchMore(self, index): + """Fetches children for the given parent node. + + """ + node = index.internalPointer() + + if node.fetched_children: + self.progressUpdate.emit('', '') + return + + self.layoutAboutToBeChanged.emit() + + recursion = common.settings.value('jobs/scandepth') + recursion = int(recursion) if recursion is not None else 2 + + for path in self.bookmark_item_generator(node.name, max_recursion=recursion): + if path in node: + continue + node.add_child(BookmarkItemNode(path)) + self.layoutChanged.emit() + + node.fetched_children = True + self.progressUpdate.emit('', '') + + def hasChildren(self, index): + if not index.isValid(): + return True + + node = index.internalPointer() + + if isinstance(node.parent, JobNode): + return False + + if not node.fetched_children: + return True + + return node.child_count() > 0 + + def flags(self, index): + if not index.isValid(): + return QtCore.Qt.NoItemFlags + if index.column() == 0: + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + if index.column() == 1: + return QtCore.Qt.ItemIsEnabled + + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + @QtCore.Slot(str) + def init_data(self, server, force=False): + self.progressUpdate.emit('', '') + + self.beginResetModel() + + if not force and server in cache: + self.root_node = cache[server] + self.endResetModel() + self.progressUpdate.emit('', '') + return + + self.root_node = ServerNode(server) + + if server == 'server': + self.endResetModel() + self.progressUpdate.emit('', '') + return + + for job in self.item_generator(): + node = JobNode(job) + self.root_node.add_child(node) + + self.endResetModel() + + cache[server] = self.root_node + + self.progressUpdate.emit('', '') + + def item_generator(self): + """Scans the current server to find job items. + + """ + server = self.root_node.name + if not server: + return + if server.endswith(':'): + server = f'{server}/' + + # Parse source otherwise + for entry in os.scandir(server): + if not entry.is_dir(): + continue + if entry.name.startswith('.'): + continue + if entry.name.startswith('$'): + continue + + file_info = QtCore.QFileInfo(entry.path) + + if file_info.isHidden(): + continue + if not file_info.isReadable(): + continue + + # Test access + try: + next(os.scandir(file_info.filePath())) + except: + continue + + # Use paths in the link file, if available + links = common.get_links(file_info.filePath(), section='links/job') + if links: + for link in links: + _file_info = QtCore.QFileInfo(f'{file_info.filePath()}/{link}') + yield _file_info.filePath() + else: + yield file_info.filePath() + + def bookmark_item_generator(self, path, recursion=0, max_recursion=2): + """Recursive scanning function for finding bookmark folders + inside the given path. + + """ + # If links exist, return items stored in the link file and nothing else + if recursion == 0: + links = common.get_links(path, section='links/root') + for v in links: + yield f'{path}/{v}' + + # Otherwise parse the folder + recursion += 1 + if recursion > max_recursion: + return + + # Let unreadable paths fail silently + try: + it = os.scandir(path) + except: + return + + for entry in it: + + if not entry.is_dir(): + continue + if entry.name.startswith('.'): + continue + if entry.name.startswith('$'): + continue + + self.progressUpdate.emit(f'Scanning...', entry.name) + + file_info = QtCore.QFileInfo(entry.path) + if file_info.isHidden(): + continue + if not file_info.isReadable(): + continue + + # yield the match + path = entry.path.replace('\\', '/') + + if entry.name == common.bookmark_cache_dir: + _path = '/'.join(path.split('/')[:-1]) + yield _path + + yield from self.bookmark_item_generator(path, recursion=recursion, max_recursion=max_recursion) + + +class JobsView(QtWidgets.QTreeView): + progressUpdate = QtCore.Signal(str, str) + + def __init__(self, parent=None): + super().__init__(parent=parent) + + self.setModel(JobsModel(parent=self)) + + self.setRootIsDecorated(True) + self.setIndentation(common.size(common.size_margin)) + self.setUniformRowHeights(False) + + self.setItemDelegate(ui.ListWidgetDelegate(parent=self)) + + self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + + self.setAttribute(QtCore.Qt.WA_NoSystemBackground) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + self.installEventFilter(self) + + # Hide the top header + header = QtWidgets.QHeaderView(QtCore.Qt.Horizontal, parent=self) + + header.setSectionsMovable(False) + header.setSectionsClickable(False) + header.setStretchLastSection(True) + header.setSectionResizeMode(QtWidgets.QHeaderView.Fixed) + + header.setMinimumSectionSize(common.size(common.size_width) * 0.2) + + self.setHeader(header) + self.header().hide() + + self.setSizePolicy( + QtWidgets.QSizePolicy.MinimumExpanding, + QtWidgets.QSizePolicy.MinimumExpanding + ) + + # Disable the horizontal scrollbar + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + self._connect_signals() + + def _connect_signals(self): + self.model().modelReset.connect(self.expand_active_items) + + self.model().progressUpdate.connect(self.progressUpdate) + + self.expanded.connect(self.resize_columns) + self.collapsed.connect(self.resize_columns) + self.model().layoutChanged.connect(self.resize_columns) + self.model().modelReset.connect(self.resize_columns) + + self.doubleClicked.connect(self.toggle_bookmark_item) + + common.signals.jobAdded.connect(self.job_added) + + def contextMenuEvent(self, event): + """Context menu event. + + """ + index = self.indexAt(event.pos()) + menu = JobsViewContextMenu(index, parent=self) + pos = event.pos() + pos = self.mapToGlobal(pos) + menu.move(pos) + menu.exec_() + + def eventFilter(self, widget, event): + """Event filter handler. + + """ + if widget is not self: + return False + if event.type() == QtCore.QEvent.Paint: + ui.paint_background_icon('asset', widget) + return True + return False + + @QtCore.Slot() + def resize_columns(self, *args, **kwargs): + """Resize the columns to fit the data. + + """ + self.header().resizeSections(QtWidgets.QHeaderView.ResizeToContents) + self.header().resizeSection( + 0, + self.width() - self.header().sectionSize(1) - common.size(common.size_margin) + ) + + @QtCore.Slot(str) + def job_added(self, path): + """Slot -> Shows a recently added job in the view. + + Args: + path (str): The path to the job. + + """ + if not path: + return + if not QtCore.QFileInfo(path).exists(): + return + + path = QtCore.QFileInfo(path).filePath() + model = self.model() + + # Reload the data + model.init_data(model.root_node.name, force=True) + for node in model.root_node.children: + if node.name.lower() == path.lower(): + index = model.createIndex(node.row(), 0, node) + self.setExpanded(index, True) + self.selectionModel().select( + index, + QtCore.QItemSelectionModel.ClearAndSelect | QtCore.QItemSelectionModel.Rows + ) + self.selectionModel().setCurrentIndex( + index, + QtCore.QItemSelectionModel.ClearAndSelect | QtCore.QItemSelectionModel.Rows + ) + return + + @QtCore.Slot() + def expand_active_items(self): + """Slot -> Expands all job items that have active bookmark items + + """ + for v in common.bookmarks: + for node in self.model().root_node.children: + if node.name.lower() in v.lower(): + index = self.model().createIndex(node.row(), 0, node) + self.setExpanded(index, True) + break + + @common.debug + @common.error + @QtCore.Slot() + def add_bookmark_item(self, *args, **kwargs): + """Pick and add a folder as a new bookmark item. + + """ + if self.model().root_node.name == 'server': + raise RuntimeError('No server selected.') + + if not self.selectionModel().hasSelection(): + raise RuntimeError('Must select a job first.') + + index = next(f for f in self.selectionModel().selectedIndexes()) + if not index.isValid(): + return + + if not QtCore.QFileInfo(index.data(QtCore.Qt.UserRole)).exists(): + raise RuntimeError(f'{index.data(QtCore.Qt.UserRole)} does not exist.') + + if not self.isExpanded(index): + self.setExpanded(index, True) + + path = QtWidgets.QFileDialog.getExistingDirectory( + self, + 'Select a folder to use as a bookmark item', + index.data(QtCore.Qt.UserRole), + QtWidgets.QFileDialog.ShowDirsOnly | + QtWidgets.QFileDialog.DontResolveSymlinks + ) + + if not path: + return + + if index.data(QtCore.Qt.UserRole).lower() not in path.lower(): + raise RuntimeError('Bookmark item must be inside the selected job folder.') + + node = index.internalPointer() + + if path in node: + common.show_message( + f'Error', + body=f'Cannot select {path.split("/")[-1]} because it is already a bookmark item.', + message_type='error' + ) + return + + name = path[len(node.name):].strip('/') + + # Add link + if not common.add_link(node.name, name, section='links/root'): + raise RuntimeError('Failed to add link.') + + self.model().layoutAboutToBeChanged.emit() + if path not in node: + node.add_child(BookmarkItemNode(path)) + + self.model().layoutChanged.emit() + + @common.debug + @common.error + @QtCore.Slot() + def add(self, *args, **kwargs): + """Add a server item. + + """ + server = self.model().root_node.name + if server == 'server': + raise RuntimeError('No server selected.') + + w = AddJobDialog(server, parent=self) + w.open() + + @QtCore.Slot() + @common.debug + @common.error + def toggle_bookmark_item(self, *args, **kwargs): + server, job, root = self.parent().window().get_args() + if not all((server, job, root)): + return + + if f'{server}/{job}/{root}' not in common.bookmarks: + actions.add_bookmark(server, job, root) + else: + actions.remove_bookmark(server, job, root) + + if self.selectionModel().hasSelection(): + index = next(f for f in self.selectionModel().selectedIndexes()) + self.update(index) + + def mouseReleaseEvent(self, event): + """Mouse release event. + + """ + index = self.indexAt(event.pos()) + if index.isValid() and index.column() == 1: + node = index.internalPointer() + if isinstance(node, JobNode): + event.accept() + self.add_bookmark_item(index) + return + if isinstance(node, BookmarkItemNode): + event.accept() + self.toggle_bookmark_item() + return + + super().mouseReleaseEvent(event) + + def sizeHint(self): + return QtCore.QSize( + common.size(common.size_width), + common.size(common.size_height) * 0.8 + ) diff --git a/bookmarks/editor/preferences.py b/bookmarks/editor/preferences.py index f695843db..d651be84a 100644 --- a/bookmarks/editor/preferences.py +++ b/bookmarks/editor/preferences.py @@ -113,6 +113,16 @@ class PreferenceEditor(base.BasePropertyEditor): 'description': 'Check to hide thumbnail background colors' }, 3: { + 'name': 'Hide item descriptions', + 'key': 'settings/hide_item_descriptions', + 'validator': None, + 'widget': functools.partial( + QtWidgets.QCheckBox, 'Enable' + ), + 'placeholder': 'Check to hide item descriptions', + 'description': 'Check to hide item descriptions', + }, + 4: { 'name': 'Disable Image Thumbnails', 'key': 'settings/disable_oiio', 'validator': None, @@ -126,6 +136,21 @@ class PreferenceEditor(base.BasePropertyEditor): }, }, 1: { + 0: { + 'name': 'Default to scene folder', + 'key': 'settings/default_to_scenes_folder', + 'validator': None, + 'widget': functools.partial( + QtWidgets.QCheckBox, 'Enable' + ), + 'placeholder': 'Default to scene folder', + 'description': 'Default to the scene Folder when the active asset changes', + 'help': 'If enabled, the files tab will always show the ' + 'contents of the scene folder (instead of the last ' + 'selected folder) when the active asset changes.', + }, + }, + 2: { 0: { 'name': 'Bookmark item search depth', 'key': 'settings/job_scan_depth', @@ -234,13 +259,13 @@ class PreferenceEditor(base.BasePropertyEditor): 'captures in the file explorer.', }, 2: { - 'name': 'Disable "Latest" Capture', + 'name': 'Copy capture to "latest" folder', 'key': 'maya/publish_capture', 'validator': None, 'widget': functools.partial(QtWidgets.QCheckBox, 'Disable'), 'placeholder': None, 'description': 'The last capture by default will be ' - 'published into a "Latest" folder with using a ' + 'published into a "latest" folder with using a ' 'generic filename.\nThis can be useful for ' 'creating quick edits in RV. Check the box ' 'above to disable.', diff --git a/bookmarks/external/akaconvert.py b/bookmarks/external/akaconvert.py new file mode 100644 index 000000000..f202e4a28 --- /dev/null +++ b/bookmarks/external/akaconvert.py @@ -0,0 +1,517 @@ +"""AkaConvert control widget. + +""" +import functools +import os +import re + +from PySide2 import QtCore, QtWidgets + +from .. import common +from .. import database +from ..editor import base + + +def close(): + """Closes the :class:`AkaConvertWidget` editor. + + """ + if common.akaconvert_widget is None: + return + try: + common.akaconvert_widget.close() + common.akaconvert_widget.deleteLater() + except: + pass + common.akaconvert_widget = None + + +def show(index): + """Opens the :class:`AkaConvertWidget` editor. + + Args: + index (QModelIndex): The source image sequence index. + + Returns: + QWidget: The AkaConvertWidget instance. + + """ + close() + common.akaconvert_widget = AkaConvertWidget(index) + common.akaconvert_widget.open() + return common.akaconvert_widget + + +KEY = 'AKACONVERT_ROOT' + +SIZE_PRESETS = { + common.idx(reset=True, start=0): { + 'name': 'Original', + 'value': (None, None) + }, + common.idx(): { + 'name': '1080p', + 'value': (1920, 1080) + }, + common.idx(): { + 'name': f'{int(1080 * 1.5)}p', + 'value': (1920 * 1.5, 1080 * 1.5) + }, + common.idx(): { + 'name': f'{int(1080 * 2)}p', + 'value': (1920 * 2, 1080 * 2) + }, +} + + +def get_framerate(fallback_framerate=24.0): + """Get the currently set frame-rate from the bookmark item database. + + Returns: + float: The current frame-rate set in the active context. + + """ + if not all(common.active('root', args=True)): + return fallback_framerate + + db = database.get(*common.active('root', args=True)) + + bookmark_framerate = db.value(common.active('root', path=True), 'framerate', database.BookmarkTable) + asset_framerate = db.value(common.active('asset', path=True), 'asset_framerate', database.AssetTable) + + v = asset_framerate or bookmark_framerate or fallback_framerate + if not isinstance(v, (int, float)) or v < 1.0: + return fallback_framerate + + return v + + +def get_environment(func): + """Decorator function to check if the environment variable exists. + + """ + + def wrapper(*args, **kwargs): + """Wrapper function. + + """ + if KEY not in os.environ: + raise RuntimeError(f'Environment variable not found: {KEY}') + + if not QtCore.QFileInfo(os.environ[KEY]).exists(): + raise RuntimeError(f'Environment variable found, but the folder does not exist: {os.environ[KEY]}') + + return func(QtCore.QFileInfo(os.environ[KEY]).absoluteFilePath(), *args, **kwargs) + + return wrapper + + +@get_environment +def get_convert_script_path(root): + """Return the path to AkaConvert.bat. + + """ + v = f'{root}/AkaConvert.bat' + if not QtCore.QFileInfo(v).exists(): + raise RuntimeError(f'AkaConvert not found: {v}') + return QtCore.QFileInfo(v).absoluteFilePath() + + +@get_environment +def get_oiiotool_path(root): + """Return the path to oiiotool.exe. + + """ + v = f'{root}/bin/oiiotool.exe' + if not QtCore.QFileInfo(v).exists(): + raise RuntimeError(f'Could not find {v}.') + return QtCore.QFileInfo(v).absoluteFilePath() + + +@get_environment +def get_ocio_colourspaces(root): + """Return the list of OCIO colourspaces. + + """ + assumed = ( + 'ACES - ACES2065-1', 'ACES - ACEScg', 'Utility - sRGB - Texture', 'Utility - Raw', 'Utility - Curve - Rec.709', + 'Utility - Curve - sRGB', 'Utility - Linear - Rec.2020', 'Utility - Linear - Rec.709', + 'Utility - Linear - sRGB', 'Utility - Gamma 1.8 - Rec.709 - Texture', 'Utility - Gamma 2.2 - Rec.709 - Texture', + 'Output - sRGB', 'Output - sRGB (D60 sim.)', 'Output - Rec.709', 'Output - Rec.709 (D60 sim.)',) + + proc = QtCore.QProcess() + proc.setProgram(get_oiiotool_path()) + proc.setArguments(['--help', ]) + + env = QtCore.QProcessEnvironment.systemEnvironment() + # We want to pass on AkaConvert's ocio environment variable + + # aces config directory + ocio_config_path = f'{root}/ocio_config/aces_1.2/config.ocio' + + if not QtCore.QFileInfo(ocio_config_path).exists(): + raise RuntimeError(f'Could not find {ocio_config_path}.') + + # Insert the OCIO config path into the environment variable + env.insert('OCIO', ocio_config_path) + proc.setProcessEnvironment(env) + + # Start the process and capture the output + proc.start() + proc.waitForFinished() + output = proc.readAllStandardOutput().data().decode('utf-8') + + # Check if the output contains the OCIO config path + if 'OpenColorIO' not in output: + raise RuntimeError( + f'Was not able to find OpenColorIO in the output: {output}. Was OpenImageIO compiled with OCIO support?' + ) + + # Find the line starting with "Known color spaces:" and extract the colourspaces + m = re.search(r'Known color spaces:(.*)', output, re.IGNORECASE | re.MULTILINE) + if not m: + raise RuntimeError(f'Was not able to find the colourspaces in the output.') + + all_colourspaces = [x.strip().strip('"') for x in m.group(1).split(',')] + + # Check if the assumed colourspaces are in the list of all colourspaces + good_colourspaces = [] + for c in assumed: + if [f for f in all_colourspaces if c.strip().lower() in f.strip().lower()]: + good_colourspaces.append(c) + + if not good_colourspaces: + raise RuntimeError(f'Was not able to find any of the expected colourspaces. Is the OCIO config correct?') + + return sorted(good_colourspaces) + + +class PresetComboBox(QtWidgets.QComboBox): + """FFMpeg preset picker. + + """ + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setView(QtWidgets.QListView()) + self.init_data() + + def init_data(self): + """Initializes data. + + """ + self.blockSignals(True) + for k in ('h264', 'prores', 'dnxhd'): + self.addItem(k.upper(), userData=k) + self.blockSignals(False) + + +class SizeComboBox(QtWidgets.QComboBox): + """FFMpeg output size picker. + + """ + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setView(QtWidgets.QListView()) + self.init_data() + + def init_data(self): + """Initializes data. + + """ + db = database.get(*common.active('root', args=True)) + bookmark_width = db.value(db.source(), 'width', database.BookmarkTable) + bookmark_height = db.value(db.source(), 'height', database.BookmarkTable) + asset_width = db.value(common.active('asset', path=True), 'asset_width', database.AssetTable) + asset_height = db.value(common.active('asset', path=True), 'asset_height', database.AssetTable) + + width = asset_width or bookmark_width or None + height = asset_height or bookmark_height or None + + if all((width, height)): + self.addItem(f'Project | {int(height)}p', userData=(width, height)) + self.addItem(f'Project | {int(height * 0.5)}p', userData=(int(width * 0.5), int(height * 0.5))) + + self.blockSignals(True) + for v in SIZE_PRESETS.values(): + self.addItem(v['name'], userData=v['value']) + + self.blockSignals(False) + + +class AcesComboBox(QtWidgets.QComboBox): + """FFMpeg preset picker. + + """ + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setView(QtWidgets.QListView()) + self.init_data() + + def init_data(self): + """Initializes data. + + """ + self.blockSignals(True) + for k in ('aces_1.2',): + self.addItem(k, userData=k) + self.blockSignals(False) + + +class ColorComboBox(QtWidgets.QComboBox): + """FFMpeg preset picker. + + """ + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setView(QtWidgets.QListView()) + self.init_data() + + def init_data(self): + """Initializes data. + + """ + self.blockSignals(True) + for k in get_ocio_colourspaces(): + self.addItem(k, userData=k) + self.blockSignals(False) + + +class AkaConvertWidget(base.BasePropertyEditor): + """Widget used to convert an image sequence to a video. + + """ + #: UI layout definition + sections = { + 0: { + 'name': 'AkaConvert', + 'icon': 'studioaka', + 'color': common.color(common.color_dark_background), + 'groups': { + 0: { + common.idx(reset=True, start=0): { + 'name': 'Video preset', + 'key': 'akaconvert_preset', + 'validator': None, + 'widget': PresetComboBox, + 'placeholder': None, + 'description': 'Select the video preset', + }, + common.idx(): { + 'name': 'Output size', + 'key': 'akaconvert_size', + 'validator': None, + 'widget': SizeComboBox, + 'placeholder': None, + 'description': 'Set the output video size', + }, + common.idx(): { + 'name': 'ACES version', + 'key': 'akaconvert_acesprofile', + 'validator': None, + 'widget': AcesComboBox, + 'placeholder': None, + 'description': 'Select the Aces config', + }, + }, + 1: { + common.idx(): { + 'name': 'Input colour profile', + 'key': 'akaconvert_inputcolor', + 'validator': None, + 'widget': ColorComboBox, + 'placeholder': None, + 'description': 'Select the image source\'s colour profile', + }, + common.idx(): { + 'name': 'Output colour profile', + 'key': 'akaconvert_outputcolor', + 'validator': None, + 'widget': ColorComboBox, + 'placeholder': None, + 'description': 'Select the output colour profile', + }, + }, + 2: { + common.idx(): { + 'name': 'Add burn-in', + 'key': 'akaconvert_videoburnin', + 'validator': None, + 'widget': functools.partial(QtWidgets.QCheckBox, 'Add burn-in to video'), + 'placeholder': None, + 'description': 'Add video burn-in with timecode to the output video', + }, + common.idx(): { + 'name': 'Push to RV', + 'key': 'akaconvert_pushtorv', + 'validator': None, + 'widget': functools.partial(QtWidgets.QCheckBox, 'Push to RV'), + 'placeholder': None, + 'description': 'View the converted clip with RV.', + }, + }, + }, + }, + } + + def __init__(self, index, parent=None): + super().__init__( + None, None, None, fallback_thumb='convert', hide_thumbnail_editor=True, buttons=( + 'Convert', 'Cancel'), parent=parent + ) + self._index = index + self._connect_settings_save_signals(common.SECTIONS['akaconvert']) + + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + self.setFixedWidth(common.size(common.size_width)) + self.setFixedHeight(common.size(common.size_height * 1.05)) + self.setWindowFlags( + self.windowFlags() | QtCore.Qt.FramelessWindowHint + ) + + @common.error + @common.debug + def init_data(self): + """Initializes data. + + """ + self.load_saved_user_settings(common.SECTIONS['akaconvert']) + + @common.debug + @common.error + def save_changes(self): + """Saves changes. + + """ + index = self._index + if not index.isValid(): + return False + + path = index.data(common.PathRole) + if not path: + return False + + is_collapsed = common.is_collapsed(path) + if not is_collapsed: + raise RuntimeError(f'{index.data(QtCore.Qt.DisplayRole)} is not a sequence.') + + frames = index.data(common.FramesRole) + if not frames: + raise RuntimeError( + f'{index.data(QtCore.Qt.DisplayRole)} does not seem to have any frames.' + ) + + if len(frames) < 4: + raise RuntimeError( + f'{index.data(QtCore.Qt.DisplayRole)} is too short.' + ) + + source = common.get_sequence_start_path(path) + if not QtCore.QFileInfo(source).exists(): + raise RuntimeError(f'{source} does not exist.') + + args = (source, '-video-framerate', f'{get_framerate()}', '-video-width', + f'{self.akaconvert_size_editor.currentData()[0]}', '-video-height', + f'{self.akaconvert_size_editor.currentData()[1]}', '-video-burnin', + 'true' if self.akaconvert_videoburnin_editor.isChecked() else 'false', '-video-codec', + self.akaconvert_preset_editor.currentData(), '-aces-config', + self.akaconvert_acesprofile_editor.currentData(), '-ocio-in-profile', + self.akaconvert_inputcolor_editor.currentData(), '-ocio-out-profile', + self.akaconvert_outputcolor_editor.currentData(),) + + self._error_lines = [] + self._progress_lines = [] + + self.process = QtCore.QProcess(parent=self) + self.process.readyReadStandardOutput.connect(self.read_output) + self.process.finished.connect(self.convert_process_finished) + + self.process.setProgram(get_convert_script_path()) + self.process.setArguments(args) + + env = QtCore.QProcessEnvironment.systemEnvironment() + self.process.setProcessEnvironment(env) + self.process.start() + + @QtCore.Slot() + def read_output(self): + data = self.process.readAllStandardOutput().data().decode('utf-8') + + for line in data.splitlines(): + if 'Movie saved to' in line: + + # Get the path to the output video + video_path = line.split('Movie saved to')[-1].strip().strip('"') + + if not QtCore.QFileInfo(video_path).exists(): + print(f'Could not find the output video: {video_path}') + else: + # Show the video in the files tab + common.signals.fileAdded.emit(video_path) + + # Push to RV + if self.akaconvert_pushtorv_editor.isChecked(): + from ..external import rv + rv.execute_rvpush_command(video_path, rv.PushAndClear) + + if 'Finished' in line: + self.convert_process_finished(0, QtCore.QProcess.NormalExit) + return + + if line.startswith('[AkaConvert Error]'): + if line not in self._error_lines: + self._error_lines.append(line) + elif line.startswith('[AkaConvert Info]'): + if line not in self._progress_lines: + self._progress_lines.append(line) + + current_progress_line = self._progress_lines[-1] if self._progress_lines else None + if current_progress_line: + common.show_message( + 'Converting...', body=current_progress_line.replace('[AkaConvert Info]', ''), message_type=None, + buttons=[], disable_animation=True, ) + + def convert_process_finished(self, exit_code, exit_status): + # I don't know why, but the process doesn't terminate itself and will + # keep running in the background. So we need to terminate it manually I guess! + if exit_code != 0: + common.show_message( + 'Finished.', + f'Conversion has finished but process exited with a code {exit_code}.\n{self._error_lines[-1]}' + ) + if exit_code == 0 and exit_status == QtCore.QProcess.NormalExit: + common.show_message( + 'Finished.', f'Conversion has finished successfully.' + ) + + raise RuntimeError('Finished.') + + def sizeHint(self): + """Returns a size hint. + + """ + return QtCore.QSize( + common.size(common.size_width) * 0.66, common.size(common.size_height) * 1.2 + ) + + def showEvent(self, event): + super().showEvent(event) + + if not self._index: + return + + item_rect = common.widget().visualRect(self._index) + corner = common.widget().mapToGlobal(item_rect.bottomLeft()) + + self.move(corner) + self.setGeometry( + self.geometry().x() + (item_rect.width() / 2) - ( + self.geometry().width() / 2), self.geometry().y(), self.geometry().width(), self.geometry( + + ).height() + ) + common.move_widget_to_available_geo(self) diff --git a/bookmarks/external/ffmpeg.py b/bookmarks/external/ffmpeg.py index b8bc30617..4609d7b13 100644 --- a/bookmarks/external/ffmpeg.py +++ b/bookmarks/external/ffmpeg.py @@ -449,7 +449,7 @@ def convert( common.message_widget.body_label.setText( f'Converting frame {int(match.group(1))} of {int(endframe)}' ) - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) # Verify the output if proc.returncode == 1: diff --git a/bookmarks/external/ffmpeg_widget.py b/bookmarks/external/ffmpeg_widget.py index 959789ebc..e9b5f0d41 100644 --- a/bookmarks/external/ffmpeg_widget.py +++ b/bookmarks/external/ffmpeg_widget.py @@ -16,7 +16,6 @@ from .. import ui from ..editor import base from ..external import rv -from ..threads import threads from ..tokens import tokens @@ -149,7 +148,7 @@ def init_data(self): """ db = database.get(*common.active('root', args=True)) bookmark_width = db.value(db.source(), 'width', database.BookmarkTable) - bookmark_height = db.value(db.source(), 'width', database.BookmarkTable) + bookmark_height = db.value(db.source(), 'height', database.BookmarkTable) asset_width = db.value(common.active('asset', path=True), 'asset_width', database.AssetTable) asset_height = db.value(common.active('asset', path=True), 'asset_height', database.AssetTable) @@ -219,7 +218,7 @@ class FFMpegWidget(base.BasePropertyEditor): def __init__(self, index, parent=None): super().__init__( None, None, None, fallback_thumb='convert', hide_thumbnail_editor=True, buttons=( - 'Convert', 'Cancel'), parent=parent + 'Convert', 'Cancel'), parent=parent ) self._index = index self._connect_settings_save_signals(common.SECTIONS['ffmpeg']) @@ -287,9 +286,9 @@ def save_changes(self): if _f.exists(): if common.show_message( 'File already exists', f'{destination} already exists.\nDo you want to replace it with a new ' - f'version?', buttons=[ - common.YesButton, - common.NoButton], message_type='error', modal=True, ) == QtWidgets.QDialog.Rejected: + f'version?', buttons=[common.YesButton, + common.NoButton], message_type='error', modal=True, + ) == QtWidgets.QDialog.Rejected: return False if not _f.remove(): raise RuntimeError(f'Could not remove {destination}') @@ -312,8 +311,6 @@ def save_changes(self): for f in source_image_paths: images.ImageCache.flush(f) - if not QtCore.QFile(f).remove(): - log.error(f'Failed to remove {f}') if not mov: common.close_message() @@ -340,7 +337,7 @@ def save_changes(self): def preprocess_sequence(self, preconversion_format='jpg'): """Preprocesses the source image sequence. - FFMpeg can't handle missing frames, so we'll check for them and fill in the gaps and convert the source images + FFMpeg can't handle missing frames, so we'll check and fill in the gaps and convert the source images to jpeg images using OpenImageIO if they're not already supported by FFMpeg. Args: @@ -427,19 +424,17 @@ def preprocess_sequence(self, preconversion_format='jpg'): source_path, destination_path = items common.message_widget.body_label.setText(f'Copying image {idx} of {len(source_images)}...') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) if not QtCore.QFile.copy(source_path, destination_path): raise RuntimeError(f'Could not copy {source_path} to {destination_path}') - raise RuntimeError('needs_conversion') - # Convert the source images to jpeg images using OpenImageIO if needs_conversion: common.message_widget.body_label.setText( f'The sequence needs pre-converting:\nConverting {len(source_images)} images, please wait...' ) - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) if not pyimageutil.convert_images(source_images, ffmpeg_source_images, max_size=-1, release_gil=True): raise RuntimeError('Failed to convert an image using OpenImageIO.') @@ -471,7 +466,7 @@ def showEvent(self, event): self.move(corner) self.setGeometry( self.geometry().x() + (item_rect.width() / 2) - ( - self.geometry().width() / 2), self.geometry().y(), self.geometry().width(), self.geometry( + self.geometry().width() / 2), self.geometry().y(), self.geometry().width(), self.geometry( ).height() ) diff --git a/bookmarks/external/rv.py b/bookmarks/external/rv.py index 432b5125f..c9129ef1d 100644 --- a/bookmarks/external/rv.py +++ b/bookmarks/external/rv.py @@ -3,83 +3,86 @@ TODO: This module is a stub and needs more testing and development. """ -import os -import subprocess +import uuid + +from PySide2 import QtCore from .. import common from .. import database -from .. import log -#: The base command used to call an RV push command -URL = f'"{{executable}}" -tag {common.product} url \'rvlink:// {{rv_command}}\'' -MERGE = f'"{{executable}}" -tag {common.product} merge {{rv_command}}' -PushAndClear = 'PushAndClear' -PushAndClearFullScreen = 'PushAndClearFullScreen' -Add = 'Add' +tag = f'{common.product}{uuid.uuid4().hex}' + +PushAndClear = common.idx(reset=True, start=0) +PushAndClearFullScreen = common.idx() +Add = common.idx() +InitializeSession = common.idx() RV_COMMANDS = { - PushAndClearFullScreen: '-reuse 1 -inferSequence -l -play -fullscreen -nofloat -lookback 0 -nomb -fps {framerate} ' - '\"{source}\"', - PushAndClear: '-reuse 1 -inferSequence -l -play -lookback 0 -fps {framerate} \"{source}\"', - Add: '\"{source}\"', + InitializeSession: '-tag {tag} py-eval-return "print(\'Starting RV\')"', + PushAndClearFullScreen: '-tag {tag} url rvlink:// "-reuse 1 -inferSequence -l -play -fullscreen -nofloat -lookback 0 -nomb -fps {framerate} "{source}""', + PushAndClear: '-tag {tag} url rvlink:// "-reuse 1 -inferSequence -l -play -lookback 0 -fps {framerate} "{source}""', + Add: '-tag {tag} merge "{source}"', } -def _execute_rvpush_command(cmd): - """Executes the given command. - - Args: - cmd (str): The command to execute. - - """ - # Execute the command - if common.get_platform() == common.PlatformWindows: - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = subprocess.SW_HIDE - - with open(os.devnull, 'w') as devnull: - subprocess.Popen(cmd, stdout=devnull, stderr=devnull, startupinfo=startupinfo) - - else: - raise NotImplementedError('RV push is not implemented for this platform.') - - @common.error @common.debug -def execute_rvpush_command(source, command, basecommand=URL): +def execute_rvpush_command(source, command): """Calls rvpush with the given source and commands. Args: source (str): The path to a footage source. command (str): An RV command enum. - basecommand (str): The base command to use when calling rvpush. """ common.check_type(source, str) - common.check_type(command, str) - common.check_type(basecommand, str) + common.check_type(command, int) if command not in RV_COMMANDS: - raise ValueError(f'Invalid RV command: {command}. Valid commands are: {RV_COMMANDS.keys()}') + raise ValueError(f'Invalid RV command: {command}') # Get the command string command = RV_COMMANDS[command] - # Get the path to the executable executable = common.get_binary('rvpush') if not executable: - log.error(f'Could not find rvpush.') - return + raise RuntimeError(f'Could not find RV. Make sure the app is added as an Application Launcher Item,' + f'or that the RV binary is added to the PATH environment variable.') # Get the framerate from the database db = database.get(*common.active('root', args=True)) framerate = db.value(db.source(), 'framerate', database.BookmarkTable) framerate = float(framerate) if framerate else 24.0 - # Format the command - rv_command = command.format(framerate=framerate, source=source) - cmd = basecommand.format(executable=executable, rv_command=rv_command) + # The url protocol cannot communicate with RV unless there's already an instance running. + # So, let's execute a dummy python command to start RV. I'm assuming after calling + # rvpush initialized RV with the correct tag we can push commands to it. - _execute_rvpush_command(cmd) + process1 = QtCore.QProcess() + env = QtCore.QProcessEnvironment.systemEnvironment() + process1.setProcessEnvironment(env) + + _cmd = RV_COMMANDS[InitializeSession].format(tag=tag) + _cmd = f'"{executable}" {_cmd}' + process1.start(_cmd) + process1.waitForFinished(7000) + + # Wait 3 seconds for the process + QtCore.QThread.msleep(2000) + + # Format the command + cmd = command.format( + tag=tag, + framerate=framerate, + source=source + ) + cmd = f'"{executable}" {cmd}' + process2 = QtCore.QProcess() + process2.setProcessEnvironment(env) + process2.start(cmd) + process2.waitForFinished(7000) + + errors = process2.readAllStandardError().data().decode('utf-8') + if errors: + raise RuntimeError(errors) diff --git a/bookmarks/file_saver/widgets.py b/bookmarks/file_saver/widgets.py index 9d0e4c2cb..b9e0269fe 100644 --- a/bookmarks/file_saver/widgets.py +++ b/bookmarks/file_saver/widgets.py @@ -328,11 +328,10 @@ def sizeHint(self): class FileNameInfo(QtWidgets.QLabel): - def __init__(self, parent=None): super().__init__(parent=parent) self.setAlignment(QtCore.Qt.AlignCenter) - self.setFixedHeight(common.size(common.size_asset_row_height)) + self.setFixedHeight(common.size(common.size_row_height) * 1.5) self.setSizePolicy( QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.MinimumExpanding diff --git a/bookmarks/images.py b/bookmarks/images.py index 0a55c0fac..641983edd 100644 --- a/bookmarks/images.py +++ b/bookmarks/images.py @@ -546,7 +546,7 @@ def rsc_pixmap( file_info = QtCore.QFileInfo(source) return file_info.absoluteFilePath() - size = size if isinstance(size, (float, int)) else -1 + size = size * common.pixel_ratio if isinstance(size, (float, int)) else -1 _color = color.name() if isinstance(color, QtGui.QColor) else 'null' k = 'rsc:' + name + ':' + str(int(size)) + ':' + _color diff --git a/bookmarks/importexport.py b/bookmarks/importexport.py index 03927c683..ae6594644 100644 --- a/bookmarks/importexport.py +++ b/bookmarks/importexport.py @@ -387,7 +387,7 @@ def import_json_asset_properties(indexes, prompt=True, path=None): continue common.message_widget.body_label.setText(f'Processing {data[item]["name"]} ({n} of {count})...') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) # Set valid database values with db.connection(): diff --git a/bookmarks/items/asset_items.py b/bookmarks/items/asset_items.py index 683f1f337..50147109b 100644 --- a/bookmarks/items/asset_items.py +++ b/bookmarks/items/asset_items.py @@ -32,6 +32,7 @@ import copy import functools import os +import weakref from PySide2 import QtCore, QtWidgets @@ -45,19 +46,7 @@ from .. import log from .. import progress from ..threads import threads - - -def get_display_name(s): - """Manipulate the given file name to a display friendly name. - - Args: - s (str): Source asset item file name. - - Returns: - str: A modified asset item display name. - - """ - return s +from ..tokens import tokens class AssetItemViewContextMenu(contextmenu.BaseContextMenu): @@ -190,33 +179,34 @@ def init_data(self): # Let's get the identifier from the bookmark database db = database.get(*p) - asset_identifier = db.value( + + # ...and the display token + display_token = db.value( + source, + 'asset_display_token', + database.BookmarkTable + ) + prefix = db.value( source, - 'identifier', + 'prefix', database.BookmarkTable ) + config = tokens.get(*p) - nth = 1 + nth = 17 c = 0 - for entry in self.item_generator(source): + for filepath in self.item_generator(source): if self._interrupt_requested: break - filepath = entry.path.replace('\\', '/') - - if asset_identifier: - identifier = f'{filepath}/{asset_identifier}' - if not os.path.isfile(identifier): - continue - # Progress bar - c += 9 + c += 1 if not c % nth: common.signals.showStatusBarMessage.emit( f'Loading assets ({c} found)...' ) - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) filename = filepath[len(source) + 1:] flags = models.DEFAULT_ITEM_FLAGS @@ -229,8 +219,26 @@ def init_data(self): if active and active == filename: flags = flags | common.MarkedAsActive - # Beautify the name - name = get_display_name(filename) + display_name = filename + + # Set the display name based on the bookmark item's configuration value + if display_token: + seq, shot = common.get_sequence_and_shot(filepath) + _display_name = config.expand_tokens( + display_token, + use_database=False, + server=p[0], + job=p[1], + root=p[2], + asset=filename, + seq=seq, + sequence=seq if seq else '', + shot=shot if shot else '', + prefix=prefix + ) + if tokens.invalid_token not in _display_name: + display_name = _display_name + parent_path_role = p + (filename,) sort_by_name_role = models.DEFAULT_SORT_BY_NAME_ROLE.copy() @@ -246,7 +254,7 @@ def init_data(self): data[idx] = common.DataDict( { - QtCore.Qt.DisplayRole: name, + QtCore.Qt.DisplayRole: display_name, QtCore.Qt.EditRole: filename, common.PathRole: filepath, QtCore.Qt.SizeHintRole: self.row_size, @@ -258,9 +266,10 @@ def init_data(self): # common.QueueRole: self.queues, common.DataTypeRole: t, + common.DataDictRole: weakref.ref(data), common.ItemTabRole: common.AssetTab, # - common.EntryRole: [entry, ], + common.EntryRole: [], common.FlagsRole: flags, common.ParentPathRole: parent_path_role, common.DescriptionRole: '', @@ -277,7 +286,7 @@ def init_data(self): common.SortByNameRole: sort_by_name_role, common.SortByLastModifiedRole: 0, common.SortBySizeRole: 0, - common.SortByTypeRole: name, + common.SortByTypeRole: display_name, # common.IdRole: idx, # @@ -287,6 +296,10 @@ def init_data(self): } ) + # Cache the list of assets for later use + with open(f'{common.active("root", path=True)}/{common.bookmark_cache_dir}/assets.cache', 'w') as f: + f.write('\n'.join([v[common.PathRole] for v in data.values()])) + # Explicitly emit `activeChanged` to notify other dependent models self.activeChanged.emit(self.active_index()) @@ -306,10 +319,21 @@ def source_path(self): def item_generator(self, path): """Yields the asset items to be processed by :meth:`init_data`. + Args: + path (string): The path to a directory containing asset folders. + Yields: DirEntry: Entry instances of valid asset folders. """ + # Read from the cache if it exists + cache = f'{common.active("root", path=True)}/{common.bookmark_cache_dir}/assets.cache' + if os.path.isfile(cache): + with open(cache, 'r') as f: + for line in f: + yield line.strip() + return + try: it = os.scandir(path) except OSError as e: @@ -332,17 +356,13 @@ def item_generator(self, path): ) for link in links: - v = f'{path}/{entry.name}/{link}' - _entry = common.get_entry_from_path(v) - if not _entry: - log.error(f'Could not get entry from link {v}') - continue - yield _entry + yield f'{path}/{entry.name}/{link}' + # Don't yield the asset if it has links if links: continue - yield entry + yield entry.path.replace('\\', '/') def save_active(self): """Saves the active item. @@ -382,7 +402,7 @@ def default_row_size(self): """Returns the default item size. """ - return QtCore.QSize(1, common.size(common.size_asset_row_height)) + return QtCore.QSize(1, common.size(common.size_row_height)) class AssetItemView(views.ThreadedItemView): diff --git a/bookmarks/items/bookmark_items.py b/bookmarks/items/bookmark_items.py index 62bdc9e80..789a71743 100644 --- a/bookmarks/items/bookmark_items.py +++ b/bookmarks/items/bookmark_items.py @@ -54,6 +54,7 @@ """ +import weakref from PySide2 import QtCore, QtWidgets @@ -64,6 +65,8 @@ from .. import common from .. import contextmenu from .. import database +from .. import log +from ..tokens import tokens from ..threads import threads @@ -146,6 +149,10 @@ def init_data(self): data = common.get_data(p, _k, t) database.remove_all_connections() + _servers = [] + _jobs = [] + _roots = [] + for k, v in self.item_generator(): common.check_type(v, dict) @@ -158,7 +165,31 @@ def init_data(self): job = v['job'] root = v['root'] - database.get(server, job, root) + _servers.append(server) + _jobs.append(job) + _roots.append(root) + + # Get the display name based on the value set in the database + + db = database.get(server, job, root) + display_name_token = db.value(db.source(), 'bookmark_display_token', database.BookmarkTable) + + # Default display name + display_name = root + + # If a token is set, expand it + if display_name_token: + config = tokens.get(server, job, root) + _display_name = config.expand_tokens( + display_name_token, + server=server, + job=job, + root=root, + prefix=db.value(db.source(), 'prefix', database.BookmarkTable) + ) + + if tokens.invalid_token not in _display_name: + display_name = _display_name file_info = QtCore.QFileInfo(k) exists = file_info.exists() @@ -179,9 +210,9 @@ def init_data(self): # bookmark exist if all( ( - server == common.active('server'), - job == common.active('job'), - root == common.active('root') + server == common.active('server'), + job == common.active('job'), + root == common.active('root') ) ) and exists: flags = flags | common.MarkedAsActive @@ -206,14 +237,15 @@ def init_data(self): data[idx] = common.DataDict( { - QtCore.Qt.DisplayRole: root, - QtCore.Qt.EditRole: root, + QtCore.Qt.DisplayRole: display_name, + QtCore.Qt.EditRole: display_name, common.PathRole: filepath, QtCore.Qt.ToolTipRole: filepath, QtCore.Qt.SizeHintRole: self.row_size, # common.QueueRole: self.queues, common.DataTypeRole: t, + common.DataDictRole: weakref.ref(data), common.ItemTabRole: common.BookmarkTab, # common.FlagsRole: flags, @@ -244,6 +276,10 @@ def init_data(self): if not exists: continue + data.servers = sorted(set(_servers)) + data.jobs = sorted(set(_jobs)) + data.roots = sorted(set(_roots)) + self.activeChanged.emit(self.active_index()) def item_generator(self): @@ -290,7 +326,7 @@ def default_row_size(self): """Returns the default item size. """ - return QtCore.QSize(1, common.size(common.size_bookmark_row_height)) + return QtCore.QSize(1, common.size(common.size_row_height)) def filter_setting_dict_key(self): """The custom dictionary key used to save filter settings to the user settings @@ -354,4 +390,4 @@ def get_hint_string(self): """Returns an informative hint text. """ - return 'Right-click and select \'Manage Bookmark Items\' to add items' + return 'Right-click and select \'Edit Jobs\' to add bookmark items' diff --git a/bookmarks/items/delegate.py b/bookmarks/items/delegate.py index ccf444f4b..459c9868a 100644 --- a/bookmarks/items/delegate.py +++ b/bookmarks/items/delegate.py @@ -42,16 +42,14 @@ BackgroundRect = 0 IndicatorRect = 1 ThumbnailRect = 2 -AssetNameRect = 3 -AssetDescriptionRect = 4 -AddItemRect = 5 -TodoRect = 6 -RevealRect = 7 -ArchiveRect = 8 -FavouriteRect = 9 -DataRect = 10 -PropertiesRect = 11 -InlineBackgroundRect = 12 +AddItemRect = 3 +TodoRect = 4 +RevealRect = 5 +ArchiveRect = 6 +FavouriteRect = 7 +DataRect = 8 +PropertiesRect = 9 +InlineBackgroundRect = 10 #: Used to paint a DCC icon if the asset name contains any of these names DCC_ICONS = { @@ -214,69 +212,6 @@ def _get_pixmap_rect(rect_height, pixmap_width, pixmap_height): return QtCore.QRect(0, 0, int(w), int(h)) -@functools.lru_cache(maxsize=4194304) -def get_bookmark_text_segments(text, label): - """Caches and returns the text segments used to paint bookmark items. - - Used to mimic rich-text like coloring of individual text elements. - - Args: - text (str): The source text. - label (str): Item's label string. - - Returns: - dict: A dict of (str, QColor) pairs. - - """ - if not text: - return {} - label = label if label else '' - - k = f'{text}{label}' - if k in common.delegate_text_segments: - return common.delegate_text_segments[k] - - text = text.upper().strip().strip('/').strip('\\') - if not text: - return {} - - d = {} - v = text.split('|') - - s_color = common.color(common.color_dark_blue) - - for i, s in enumerate(v): - if i == 0: - c = common.color(common.color_text) - if '/' in s: - s = s.split('/')[-1] - else: - c = s_color - - _v = s.split('/') - for _i, _s in enumerate(_v): - _s = _s.strip() - - d[len(d)] = (_s, c) - if _i < (len(_v) - 1): - d[len(d)] = (' / ', s_color) - if i < (len(v) - 1): - d[len(d)] = (' | ', s_color) - - if label: - d[len(d)] = (' | ', common.color(common.color_dark_background)) - v = label.split('•') - for __i, s in enumerate(v): - s = s.strip() - - d[len(d)] = (s, s_color) - if __i != len(v) - 1: - d[len(d)] = (' ‖ ', s_color) - - common.delegate_text_segments[k] = d - return common.delegate_text_segments[k] - - @functools.lru_cache(maxsize=4194304) def get_asset_text_segments(text, label): """Caches and returns the text segments used to paint asset items. @@ -524,7 +459,7 @@ def get_subdir_cache_key(index, rect): str: The cache key. """ - return f'{index.data(common.PathRole)}_subdir_{rect.size()}' + return f'{index.data(QtCore.Qt.DisplayRole)}_subdir:[{rect.x()},{rect.y()},{rect.width()},{rect.height()}]' def get_subdir_bg_cache_key(index, rect, text_edge=None): @@ -534,8 +469,8 @@ def get_subdir_bg_cache_key(index, rect, text_edge=None): str: The cache key. """ - p = index.data(common.PathRole) - d = index.data(common.DescriptionRole) + p = index.data(QtCore.Qt.DisplayRole) + d = index.data(common.DescriptionRole) if not common.settings.value('settings/hide_item_descriptions') else '' return f'{p}:{d}:[{rect.x()},{rect.y()},{rect.width()},{rect.height()},{text_edge}]' @@ -550,10 +485,10 @@ def get_clickable_cache_key(index, rect): str: The cache key. """ - p = index.data(common.PathRole) + p = index.data(common.QtCore.Qt.DisplayRole) f = index.data(common.FileDetailsRole) - d = index.data(common.DescriptionRole) - return f'{p}{f}{d}[{rect.x()},{rect.y()},{rect.width()},{rect.height()}]' + d = index.data(common.DescriptionRole) if not common.settings.value('settings/hide_item_descriptions') else '' + return f'{p}:{f}:{d}:[{rect.x()},{rect.y()},{rect.width()},{rect.height()}]' def get_description_cache_key(index, rect, button): @@ -568,10 +503,10 @@ def get_description_cache_key(index, rect, button): str: The cache key. """ - p = index.data(common.PathRole) + p = index.data(common.QtCore.Qt.DisplayRole) f = index.data(common.FileDetailsRole) - d = index.data(common.DescriptionRole) - return f'{p}{f}{d}{button}[{rect.x()},{rect.y()},{rect.width()},{rect.height()}]' + d = index.data(common.DescriptionRole) if not common.settings.value('settings/hide_item_descriptions') else '' + return f'{p}:{f}:{d}:{button}:[{rect.x()},{rect.y()},{rect.width()},{rect.height()}]' def get_subdir_rectangles(option, index, rectangles, metrics): @@ -657,14 +592,14 @@ def get_text_segments(index): return {} if len(pp) == 3: - return get_bookmark_text_segments( + return get_asset_text_segments( index.data(QtCore.Qt.DisplayRole), - index.data(common.DescriptionRole) + index.data(common.DescriptionRole) if not common.settings.value('settings/hide_item_descriptions') else '' ) elif len(pp) == 4: return get_asset_text_segments( index.data(QtCore.Qt.DisplayRole), - index.data(common.DescriptionRole) + index.data(common.DescriptionRole) if not common.settings.value('settings/hide_item_descriptions') else '' ) elif len(pp) > 4: return get_file_text_segments( @@ -804,6 +739,7 @@ def _create_gradient_pixmap(self): # 3. Create a QPixmap and fill it with transparent color pixmap = QtGui.QPixmap(width, 1) + pixmap.setDevicePixelRatio(common.pixel_ratio) pixmap.fill(QtCore.Qt.transparent) # 4. Render the gradient onto the QPixmap @@ -848,7 +784,7 @@ def setModelData(self, editor, model, index): """Sets the model data for the given index to the given value. """ - text = f'{index.data(common.DescriptionRole)}' + text = index.data(common.DescriptionRole) if text.lower() == editor.text().lower(): return source_path = index.data(common.ParentPathRole) @@ -1022,7 +958,11 @@ def get_paint_arguments(self, painter, option, index, antialiasing=False): archived = flags & common.MarkedAsArchived active = flags & common.MarkedAsActive rectangles = self.get_rectangles(index) - font, metrics = common.font_db.bold_font(common.size(common.size_font_medium)) + + if option.rect.height() < common.size(common.size_row_height) * 1.5: + font, metrics = common.font_db.medium_font(common.size(common.size_font_small)) + else: + font, metrics = common.font_db.bold_font(common.size(common.size_font_medium)) painter.setFont(font) cursor_position = self.parent().viewport().mapFromGlobal(common.cursor.pos()) @@ -1073,6 +1013,7 @@ def draw_subdir_rectangles(self, bg_rect, *args): ) filter_text = self.parent().model().filter_text() + filter_text = filter_text.lower().strip('\\/-_\'" ') if filter_text else '' # Paint the background rectangle of the sub-folder modifiers = QtWidgets.QApplication.instance().keyboardModifiers() @@ -1105,8 +1046,7 @@ def draw_subdir_rectangles(self, bg_rect, *args): color = common.color(common.color_dark_background) # Green the sub-folder is set as a text filter - ftext = f'"{text}"' - if filter_text and ftext.lower() in filter_text.lower(): + if text.lower() in filter_text.lower(): color = common.color(common.color_green) if rect.contains(cursor_position): @@ -1174,12 +1114,10 @@ def paint_name(self, *args): painter.setRenderHint(QtGui.QPainter.Antialiasing, on=True) pp = index.data(common.ParentPathRole) - if len(pp) == 3: - return self.paint_bookmark_name(*args) - elif len(pp) == 4: + if len(pp) <= 4: return self.paint_asset_name( *args, - offset=common.size(common.size_indicator) * 2 + offset=common.size(common.size_indicator) ) elif len(pp) > 4: return self.paint_file_name(*args) @@ -1260,9 +1198,10 @@ def draw_file_description( label_text_color = common.color(common.color_text) filter_text = self.parent().model().filter_text() - filter_text = filter_text.lower() if filter_text else '' - filter_texts = re.split(f'\s', filter_text, flags=re.IGNORECASE) - filter_texts = {f.lower().strip('"').strip('-').strip() for f in filter_texts} + filter_text = filter_text.lower().strip('\\/-_\'" ') if filter_text else '' + + filter_texts = re.split(r'\s', filter_text, flags=re.IGNORECASE) + filter_texts = {f.lower().strip('\\/-_\'" ') for f in filter_texts} for s in reversed(it): width = metrics.horizontalAdvance(s) @@ -2402,43 +2341,6 @@ def paint_deleted(self, *args, **kwargs): painter.setBrush(common.color(common.color_separator)) painter.drawRect(rect) - @save_painter - def paint_bookmark_name(self, *args): - """Paints name of the ``BookmarkWidget``'s items.""" - ( - rectangles, - painter, - option, - index, - selected, - focused, - active, - archived, - favourite, - hover, - font, - metrics, - cursor_position - ) = args - - if not index.isValid(): - return - if not index.data(QtCore.Qt.DisplayRole): - return - if not index.data(common.ParentPathRole): - return - - # Paint the job as a clickable floating rectangle - bg_rect = draw_subdir_bg_rectangles(rectangles[DataRect].right(), *args) - text = index.data(common.ParentPathRole)[1] - add_clickable_rectangle(index, option, bg_rect, text) - - self.draw_subdir_rectangles(bg_rect, *args) - - self.paint_asset_name( - *args, offset=(bg_rect.right() - rectangles[DataRect].left()) - ) - @save_painter def paint_asset_name(self, *args, offset=0): """Paints name of the ``AssetWidget``'s items.""" @@ -2494,7 +2396,7 @@ def paint_asset_name(self, *args, offset=0): painter.setBrush(common.color(common.color_secondary_text)) text = elided_text( metrics, - 'Double-click to edit...', + 'Double-click to edit description...', QtCore.Qt.ElideRight, description_rect.width(), ) @@ -2557,6 +2459,8 @@ def paint_asset_name(self, *args, offset=0): painter.setPen(QtCore.Qt.NoPen) filter_text = self.parent().model().filter_text() + filter_text = filter_text.lower().strip('\\/-_\'" ') if filter_text else '' + overlay_rect_left_edge = None for segment in text_segments.values(): @@ -2732,7 +2636,7 @@ def paint_dcc_icon(self, *args): if not index.data(common.ParentPathRole): return - d = index.data(QtCore.Qt.DisplayRole) + d = index.data(common.PathRole) icon = next((DCC_ICONS[f] for f in DCC_ICONS if f.lower() in d.lower()), None) if not icon: return @@ -2832,6 +2736,10 @@ def paint(self, painter, option, index): self.paint_hover(*args) self.paint_thumbnail_shadow(*args) self.paint_name(*args) + + if common.main_widget.stacked_widget.animation_in_progress: + return + self.paint_archived(*args) self.paint_inline_background(*args) self.paint_inline_icons(*args) @@ -2873,6 +2781,10 @@ def paint(self, painter, option, index): self.paint_hover(*args) self.paint_thumbnail_shadow(*args) self.paint_name(*args) + + if common.main_widget.stacked_widget.animation_in_progress: + return + self.paint_archived(*args) self.paint_description_editor_background(*args) self.paint_inline_background(*args) @@ -2913,6 +2825,7 @@ def paint(self, painter, option, index): args = self.get_paint_arguments(painter, option, index) if not index.data(QtCore.Qt.DisplayRole): return + p_role = index.data(common.ParentPathRole) if p_role: self.paint_background(*args) @@ -2920,6 +2833,9 @@ def paint(self, painter, option, index): self.paint_hover(*args) self.paint_name(*args) + if common.main_widget.stacked_widget.animation_in_progress: + return + self.paint_thumbnail_shadow(*args) self.paint_archived(*args) diff --git a/bookmarks/items/favourite_items.py b/bookmarks/items/favourite_items.py index bd74714a4..3328819f8 100644 --- a/bookmarks/items/favourite_items.py +++ b/bookmarks/items/favourite_items.py @@ -2,6 +2,7 @@ """ import os +import weakref from PySide2 import QtCore, QtWidgets @@ -85,10 +86,6 @@ def init_data(self): if self._interrupt_requested: break - # Skipping directories - if entry.is_dir(): - continue - filename = entry.name _source_path = '/'.join(source_paths) @@ -131,6 +128,7 @@ def init_data(self): # common.QueueRole: self.queues, common.DataTypeRole: t, + common.DataDictRole: weakref.ref(data), common.ItemTabRole: common.FavouriteTab, # common.EntryRole: [entry, ], @@ -182,6 +180,7 @@ def init_data(self): # common.QueueRole: self.queues, common.DataTypeRole: common.SequenceItem, + common.DataDictRole: None, common.ItemTabRole: common.FavouriteTab, # common.EntryRole: [], @@ -251,8 +250,11 @@ def init_data(self): v[common.DataTypeRole] = common.FileItem data[idx] = v + data[idx][common.DataDictRole] = weakref.ref(data) data[idx][common.IdRole] = idx + self.set_refresh_needed(False) + def source_path(self): """The path of the source file. diff --git a/bookmarks/items/file_items.py b/bookmarks/items/file_items.py index bf14039a3..96842ef14 100644 --- a/bookmarks/items/file_items.py +++ b/bookmarks/items/file_items.py @@ -40,6 +40,7 @@ """ import functools import os +import weakref from PySide2 import QtWidgets, QtCore @@ -151,7 +152,7 @@ def get_sequence_elements(filepath): sequence_path = None if seq: - sequence_path = f'{seq.group(1)}{common.SEQPROXY}{seq.group(3)}.{seq.group(4)}' + sequence_path = seq.group(1) + common.SEQPROXY + seq.group(3) + '.' + seq.group(4) return seq, sequence_path @@ -299,7 +300,10 @@ def init_data(self): if not p or not all(p) or not k or t is None: return - _dirs = [] + _subdirectories = [] + _watch_paths = [] + _extensions = [] + data = common.get_data(p, k, t) sequence_data = common.DataDict() # temporary dict for temp data @@ -308,7 +312,7 @@ def init_data(self): _source_path = '/'.join(p + (k,)) if not QtCore.QFileInfo(_source_path).exists(): return - _dirs.append(_source_path) + _watch_paths.append(_source_path) # Let's get the token config instance to check what extensions are # currently allowed to be displayed in the task folder @@ -354,7 +358,6 @@ def init_data(self): entry.path, _source_path ) - _dirs.append(_dir) # We'll check against the current file extension against the allowed # extensions. If the task folder is not defined in the token config, @@ -362,13 +365,19 @@ def init_data(self): if not disable_filter and ext not in valid_extensions: continue + if _dir: + _watch_paths.append(_dir) + _d = _dir[len(_source_path) + 1:] + _subdirectories += [('/' + f) for f in _d.split('/')] + _extensions.append(ext) + # Progress bar c += 1 if not c % nth: common.signals.showStatusBarMessage.emit( 'Loading files (found ' + str(c) + ' items)...' ) - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) flags = models.DEFAULT_ITEM_FLAGS seq, sequence_path = get_sequence_elements(filepath) @@ -399,6 +408,7 @@ def init_data(self): # common.QueueRole: self.queues, common.DataTypeRole: common.FileItem, + common.DataDictRole: weakref.ref(data), common.ItemTabRole: common.FileTab, # common.EntryRole: [entry, ], @@ -455,6 +465,7 @@ def init_data(self): # common.QueueRole: self.queues, common.DataTypeRole: common.SequenceItem, + common.DataDictRole: None, common.ItemTabRole: common.FileTab, # common.EntryRole: [], @@ -485,7 +496,8 @@ def init_data(self): sequence_data[sequence_path][common.FramesRole].append(seq.group(2)) sequence_data[sequence_path][common.EntryRole].append(entry) else: - # Copy the existing file item + # The sequence dictionary should contain not only sequence items but single files also, + # so we'll add them here sequence_data[filepath] = common.DataDict(data[idx]) sequence_data[filepath][common.IdRole] = -1 @@ -524,12 +536,27 @@ def init_data(self): v[common.DataTypeRole] = common.FileItem data[idx] = v + data[idx][common.DataDictRole] = weakref.ref(data) data[idx][common.IdRole] = idx watcher = common.get_watcher(common.FileTab) - watcher.reset() - watcher.add_directories(list(set(_dirs))) - self.set_refresh_needed(False) + _directories = watcher.directories() + # watcher.reset() + watcher.add_directories(sorted(set([f for f in _watch_paths if f] + _directories))) + + # Add the list of file extensions to the model's data + _extensions = sorted(set([('.' + f) for f in _extensions if f])) + common.get_data(p, k, common.FileItem).file_types = _extensions + common.get_data(p, k, common.SequenceItem).file_types = _extensions + + # Add the list of subdirectories to the model's data + _subdirectories = sorted(set([f for f in _subdirectories if f])) + common.get_data(p, k, common.FileItem).subdirectories = _subdirectories + common.get_data(p, k, common.SequenceItem).subdirectories = _subdirectories + + # Model does not need a refresh at this point + common.get_data(p, k, common.FileItem).refresh_needed = False + common.get_data(p, k, common.SequenceItem).refresh_needed = False def disable_filter(self): """Overrides the token config and disables file filters.""" @@ -722,7 +749,7 @@ class FileItemView(views.ThreadedItemView): queues = (threads.FileInfo, threads.FileThumbnail) def __init__(self, icon='file', parent=None): - super(FileItemView, self).__init__( + super().__init__( icon=icon, parent=parent ) @@ -756,7 +783,7 @@ def get_hint_string(self): model = self.model().sourceModel() k = model.task() if not k: - return 'Click the File tab to select a folder' + return 'No asset folder select. Click the file tab to select a folder to browse' return f'No files found in "{k}"' @common.error diff --git a/bookmarks/items/models.py b/bookmarks/items/models.py index 2058101d3..1bd95d9a2 100644 --- a/bookmarks/items/models.py +++ b/bookmarks/items/models.py @@ -38,7 +38,8 @@ from .. import importexport from .. import log -MAX_HISTORY = 20 +MAX_HISTORY = 15 + DEFAULT_ITEM_FLAGS = ( QtCore.Qt.ItemNeverHasChildren | QtCore.Qt.ItemIsEnabled | @@ -52,8 +53,15 @@ DEFAULT_SORT_BY_NAME_ROLE = [str()] * 8 +# Filter expressions used by the filter proxy model +re_negative = re.compile(r'--([^\"\'\[\]\*\s]+)', flags=re.IGNORECASE | re.MULTILINE) +re_quoted_negative = re.compile(r'--"(.*?)"', flags=re.IGNORECASE | re.MULTILINE) +re_positive = re.compile(r'(--[^\"\'\[\]*\s]+)', flags=re.IGNORECASE | re.MULTILINE) +re_quoted_positive = re.compile(r'(--".*?")', flags=re.IGNORECASE | re.MULTILINE) + + def initdata(func): - """The decorator is responsible validating the current active paths, and emitting + """Decorator used by init_data to validate the active items, and emitting the ``beginResetModel``, ``endResetModel`` and :attr:`.ItemModel.coreDataLoaded` signals. @@ -66,11 +74,11 @@ def func_wrapper(self, *args, **kwargs): """ common.settings.load_active_values() - self.beginResetModel() self._interrupt_requested = False self._load_in_progress = True try: + self.beginResetModel() func(self, *args, **kwargs) except: raise @@ -79,7 +87,6 @@ def func_wrapper(self, *args, **kwargs): self._load_in_progress = False self.endResetModel() - # Emit references to the just loaded core data p = self.source_path() k = self.task() t1 = self.data_type() @@ -103,20 +110,10 @@ def filter_includes_row(filter_text, searchable): """ _filter_text = filter_text - it = re.finditer( - r'(--[^\"\'\[\]\*\s]+)', - filter_text, - flags=re.IGNORECASE | re.MULTILINE - ) - it_quoted = re.finditer( - r'(--".*?")', - filter_text, - flags=re.IGNORECASE | re.MULTILINE - ) - - for match in it: + + for match in re_positive.finditer(filter_text): _filter_text = re.sub(match.group(1), '', _filter_text) - for match in it_quoted: + for match in re_quoted_positive.finditer(filter_text): _filter_text = re.sub(match.group(1), '', _filter_text) for text in _filter_text.split(): @@ -127,23 +124,22 @@ def filter_includes_row(filter_text, searchable): @functools.lru_cache(maxsize=4194304) -def _filter_excludes_row(filter_text, searchable): - it = re.finditer( - r'--([^\"\'\[\]\*\s]+)', - filter_text, - flags=re.IGNORECASE | re.MULTILINE - ) - it_quoted = re.finditer( - r'--"(.*?)"', - filter_text, - flags=re.IGNORECASE | re.MULTILINE - ) - - for match in it: - if match.group(1).lower() in searchable: +def filter_excludes_row(filter_text, searchable_text): + """Checks whether the given filter text matches the given searchable text. + + Args: + filter_text (str): The filter text. + searchable_text (str): The searchable text. + + Returns: + bool: True if the filter text matches the searchable text, False otherwise. + + """ + for match in re_negative.finditer(filter_text): + if match.group(1).lower() in searchable_text: return True - for match in it_quoted: - if match.group(1).lower() in searchable: + for match in re_quoted_negative.finditer(filter_text): + if match.group(1).lower() in searchable_text: return True return False @@ -161,8 +157,6 @@ class ItemModel(QtCore.QAbstractTableModel): missing data. coreDataReset (QtCore.Signal): Signals that the underlying model data has been reset. Used by the thread workers to empty their queues. - dataTypeSorted (QtCore.Signal -> int): Signals that the underlying model - data was sorted. sortingChanged (QtCore.Signal -> int, bool): Emitted when the sorting order or sorting role was changed by the user. activeChanged (QtCore.Signal): Signals :meth:`.ItemModel.active_index` @@ -177,7 +171,6 @@ class ItemModel(QtCore.QAbstractTableModel): coreDataLoaded = QtCore.Signal(weakref.ref, weakref.ref) coreDataReset = QtCore.Signal() - dataTypeSorted = QtCore.Signal(int) sortingChanged = QtCore.Signal(int, bool) # (SortRole, SortOrder) activeChanged = QtCore.Signal(QtCore.QModelIndex) @@ -207,7 +200,6 @@ def __init__(self, parent=None): self._datatype = {} # used by the files model only self.sortingChanged.connect(self.set_sorting) - self.dataTypeSorted.connect(self.emit_reset_model) self.modelAboutToBeReset.connect(common.signals.updateTopBarButtons) self.modelReset.connect(common.signals.updateTopBarButtons) @@ -291,7 +283,7 @@ def supportedDropActions(self): return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction def supportedDragActions(self): - return QtCore.Qt.MoveAction + return QtCore.Qt.CopyAction def mimeData(self, indexes): """Returns the drag mime data for the given indexes. @@ -412,10 +404,13 @@ def _drop_properties_action(self, mime, action, row, column, parent): log.error('Could not remove temp file.') return - def item_generator(self): + def item_generator(self, path): """A generator method used by :func:`init_data` to yield the items the model should load. + Args: + path (string): Path to a directory. + Yields: DirEntry: os.scandir DirEntry objects. @@ -482,14 +477,19 @@ def reset_data(self, *args, force=False, emit_active=True): self.set_active(index) @QtCore.Slot(int) - def emit_reset_model(self, data_type): + def internal_data_about_to_be_sorted(self, data_type): + if self.data_type() != data_type: + return + self.layoutAboutToBeChanged.emit() + + @QtCore.Slot(int) + def internal_data_sorted(self, data_type): """Slot used to emit the reset model signals. """ if self.data_type() != data_type: return - self.beginResetModel() - self.endResetModel() + self.layoutChanged.emit() @QtCore.Slot(bool) @QtCore.Slot(int) @@ -845,6 +845,9 @@ def __init__(self, parent=None): self.queued_invalidate_timer.timeout.connect(self.delayed_invalidate) + # Notify the outside world that the filter text has changed + self.filterTextChanged.connect(common.signals.filterTextChanged) + def verify(self): """Verify the filter model contents to make sure archived items remain hidden when they're not meant to be visible. @@ -1046,7 +1049,7 @@ def filterAcceptsRow(self, idx, parent=None): ) if not filter_includes_row(filter_text, searchable): return False - if _filter_excludes_row(filter_text, searchable): + if filter_excludes_row(filter_text, searchable): return False if self.filter_flag(common.MarkedAsActive) and active: diff --git a/bookmarks/items/task_items.py b/bookmarks/items/task_items.py index 825f71ba3..39694f297 100644 --- a/bookmarks/items/task_items.py +++ b/bookmarks/items/task_items.py @@ -9,6 +9,7 @@ """ import functools import os +import weakref from PySide2 import QtWidgets, QtGui, QtCore @@ -19,6 +20,7 @@ from .. import contextmenu from .. import images from .. import log +from ..threads import threads from ..tokens import tokens @@ -126,15 +128,23 @@ def paint_name(self, *args): return active = index.data(QtCore.Qt.DisplayRole) == common.active('task') + o = common.size(common.size_margin) + rect = option.rect if index.data(common.NoteCountRole): color = common.color( common.color_selected_text ) if hover else common.color(common.color_text) + pixmap = images.rsc_pixmap( + 'folder', common.color(common.color_separator), o + ) else: color = common.color(common.color_text) if hover else common.color( common.color_light_background ) + pixmap = images.rsc_pixmap( + 'folder', common.color(common.color_dark_background), o + ) color = common.color(common.color_selected_text) if active else color color = common.color(common.color_selected_text) if selected else color @@ -142,18 +152,6 @@ def paint_name(self, *args): common.size(common.size_font_medium) )[0] - o = common.size(common.size_margin) - rect = QtCore.QRect(option.rect) - - if index.data(common.NoteCountRole): - pixmap = images.rsc_pixmap( - 'folder', common.color(common.color_separator), o - ) - else: - pixmap = images.rsc_pixmap( - 'folder', common.color(common.color_dark_background), o - ) - _rect = QtCore.QRect(0, 0, o, o) _rect.moveCenter(option.rect.center()) _rect.moveLeft( @@ -172,13 +170,14 @@ def paint_name(self, *args): items = [] - if index.data(QtCore.Qt.ToolTipRole): + if index.data(common.DescriptionRole): + color = common.color( - common.color_selected_text - ) if active else common.color(common.color_text) - color = common.color(common.color_selected_text) if hover else color - color = common.color(common.color_selected_text) if selected else color - items.append((index.data(QtCore.Qt.ToolTipRole), color)) + common.color_green + ) if active else common.color(common.color_secondary_text) + color = common.color(common.color_text) if hover else color + color = common.color(common.color_text) if selected else color + items.append((index.data(common.DescriptionRole), color)) for idx, val in enumerate(items): text, color = val @@ -211,6 +210,11 @@ def sizeHint(self, option, index): """Size hint. """ + if index.data(common.NoteCountRole) == 0: + return QtCore.QSize( + self.parent().model().sourceModel().row_size.width(), + self.parent().model().sourceModel().row_size.height() * 0.5 + ) return self.parent().model().sourceModel().row_size @@ -281,8 +285,6 @@ def init_data(self): return _source_path = '/'.join(source_path) - config = tokens.get(*source_path[0:3]) - for entry in self.item_generator(source): if entry.name.startswith('.'): continue @@ -292,7 +294,6 @@ def init_data(self): path = entry.path.replace('\\', '/') idx = len(data) - description = config.get_description(entry.name) data[idx] = common.DataDict( { @@ -301,20 +302,21 @@ def init_data(self): common.PathRole: path, QtCore.Qt.SizeHintRole: self.row_size, # - QtCore.Qt.StatusTipRole: description, - QtCore.Qt.AccessibleDescriptionRole: description, - QtCore.Qt.WhatsThisRole: description, - QtCore.Qt.ToolTipRole: description, + QtCore.Qt.StatusTipRole: path, + QtCore.Qt.AccessibleDescriptionRole: path, + QtCore.Qt.WhatsThisRole: path, + QtCore.Qt.ToolTipRole: path, # common.QueueRole: self.queues, common.DataTypeRole: common.FileItem, + common.DataDictRole: weakref.ref(data), common.ItemTabRole: common.TaskTab, # common.EntryRole: [entry, ], common.FlagsRole: flags, - common.ParentPathRole: source_path, - common.DescriptionRole: description, - common.NoteCountRole: self.file_count(path), + common.ParentPathRole: list(source_path) + [entry.name,], + common.DescriptionRole: '', + common.NoteCountRole: 0, common.FileDetailsRole: '', common.SequenceRole: None, common.FramesRole: [], @@ -324,10 +326,10 @@ def init_data(self): common.FileInfoLoaded: False, common.ThumbnailLoaded: True, # - common.SortByNameRole: entry.name.lower(), - common.SortByLastModifiedRole: entry.name.lower(), - common.SortBySizeRole: entry.name.lower(), - common.SortByTypeRole: entry.name.lower(), + common.SortByNameRole: 'z' + entry.name.lower(), + common.SortByLastModifiedRole: 'z' + entry.name.lower(), + common.SortBySizeRole: 'z' + entry.name.lower(), + common.SortByTypeRole: 'z' + entry.name.lower(), # common.IdRole: idx, # @@ -357,17 +359,6 @@ def filter_setting_dict_key(self): """ return 'task' - def file_count(self, source): - """Counts the number of file items the current task folder has. - - """ - count = 0 - for _ in self.item_generator(source): - count += 1 - if count > 9: - break - return count - class TaskItemView(views.ThreadedItemView): """The view responsible for displaying the available data-keys. @@ -376,13 +367,15 @@ class TaskItemView(views.ThreadedItemView): Delegate = TaskItemViewDelegate ContextMenu = TaskItemContextMenu - queues = () + queues = (threads.FileInfo2, ) def __init__(self, parent=None): super().__init__( icon='folder', parent=parent ) + self.filter_indicator_widget.setHidden(True) + self._context_menu_active = False self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu) @@ -452,6 +445,12 @@ def inline_icons_count(self): """ return 0 + def paint_hint(self, widget, event): + return + + def paint_status_message(self, widget, event): + return + def hideEvent(self, event): """Event handler. diff --git a/bookmarks/items/views.py b/bookmarks/items/views.py index 0074badd2..87be6a434 100644 --- a/bookmarks/items/views.py +++ b/bookmarks/items/views.py @@ -235,8 +235,10 @@ def _get(s, color=common.color(common.color_green)): class ListsWidget(QtWidgets.QStackedWidget): - """This stacked widget contains the main bookmark, asset, file and favourite item - views. + """This stacked widget contains the main :class:`~bookmarks.items.bookmark_items.BookmarkItemView`, + :class:`~bookmarks.items.asset_items.AssetItemView`, :class:`~bookmarks.items.file_items.FileItemView` and + :class:`~bookmarks.items.favourite_items.FavouriteItemView`. + widgets. """ @@ -244,6 +246,8 @@ def __init__(self, parent=None): super().__init__(parent=parent) self.setObjectName('BrowserStackedWidget') + self.animation_in_progress = False + common.signals.tabChanged.connect(self.setCurrentIndex) def setCurrentIndex(self, idx): @@ -289,10 +293,20 @@ def animation_finished(a): super(ListsWidget, self).setCurrentIndex(_idx) common.signals.tabChanged.emit(_idx) + @QtCore.Slot() + def animation_state_changed(state, old_state): + if state == QtCore.QAbstractAnimation.Running: + self.animation_in_progress = True + if state == QtCore.QAbstractAnimation.Stopped: + self.animation_in_progress = False + + animation = QtCore.QParallelAnimationGroup() animation.finished.connect(functools.partial(animation_finished, animation)) + animation.stateChanged.connect(animation_state_changed) duration = 200 + # Create animation for outgoing widget out_anim = QtCore.QPropertyAnimation(self.widget(current_index), b"geometry") out_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) @@ -331,7 +345,7 @@ def animation_finished(a): animation.addAnimation(out_op) animation.addAnimation(in_op) - animation.start() + animation.start(QtCore.QPropertyAnimation.DeleteWhenStopped) self.widget(idx).show() def showEvent(self, event): @@ -360,7 +374,7 @@ class ProgressWidget(QtWidgets.QWidget): """Widget responsible for indicating files are being loaded.""" def __init__(self, parent=None): - super(ProgressWidget, self).__init__(parent=parent) + super().__init__(parent=parent) self.setAttribute(QtCore.Qt.WA_NoSystemBackground) self.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) @@ -368,6 +382,11 @@ def __init__(self, parent=None): self.setWindowFlags(QtCore.Qt.Widget) self._message = 'Loading...' + self._connect_signals() + + def _connect_signals(self): + pass + def showEvent(self, event): """Event handler. @@ -414,6 +433,14 @@ class FilterOnOverlayWidget(ProgressWidget): if a model has filters set or if it requires a refresh. """ + def _connect_signals(self): + super()._connect_signals() + + common.signals.bookmarkActivated.connect(self.update) + common.signals.assetActivated.connect(self.update) + common.signals.tabChanged.connect(self.update) + common.signals.updateTopBarButtons.connect(self.update) + common.signals.filterTextChanged.connect(self.update) def paintEvent(self, event): """Event handler. @@ -541,10 +568,10 @@ def __init__(self, icon='icon_bw', parent=None): self.filter_editor = filter_editor.TextFilterEditor(parent=self.parent()) self.filter_editor.setHidden(True) - self.setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.viewport().setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.viewport().setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setAttribute(QtCore.Qt.WA_NoSystemBackground, True) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + self.viewport().setAttribute(QtCore.Qt.WA_NoSystemBackground, True) + self.viewport().setAttribute(QtCore.Qt.WA_TranslucentBackground, True) self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) @@ -817,6 +844,11 @@ def restore_selection(self): index, hint=QtWidgets.QAbstractItemView.PositionAtCenter ) + self.selectionModel().select( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) self.selectionModel().setCurrentIndex( index, QtCore.QItemSelectionModel.ClearAndSelect | @@ -827,16 +859,30 @@ def restore_selection(self): # Select the active item index = proxy.sourceModel().active_index() if index.isValid(): + self.selectionModel().select( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) self.selectionModel().setCurrentIndex( - index, QtCore.QItemSelectionModel.ClearAndSelect + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) self.scrollTo(index, QtWidgets.QAbstractItemView.PositionAtCenter) return # Select the first item in the list index = proxy.index(0, 0) + self.selectionModel().select( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) self.selectionModel().setCurrentIndex( - index, QtCore.QItemSelectionModel.ClearAndSelect + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) self.scrollTo(index, QtWidgets.QAbstractItemView.PositionAtCenter) @@ -1053,8 +1099,12 @@ def key_down(self): item in the list, we'll jump to the beginning, and vice-versa. """ - sel = self.selectionModel() - current_index = sel.currentIndex() + model = self.selectionModel() + if model.hasSelection(): + current_index = next(f for f in model.selectedIndexes()) + else: + current_index = QtCore.QModelIndex() + first_index = self.model().index(0, 0) last_index = self.model().index( self.model().rowCount() - 1, 0 @@ -1063,22 +1113,40 @@ def key_down(self): if first_index == last_index: return if not current_index.isValid(): # No selection - sel.setCurrentIndex( + model.select( + first_index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + model.setCurrentIndex( first_index, - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) return if current_index == last_index: # Last item is selected - sel.setCurrentIndex( + model.select( + first_index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + model.setCurrentIndex( first_index, - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) return - sel.setCurrentIndex( + model.select( + self.model().index(current_index.row() + 1, 0), + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + model.setCurrentIndex( self.model().index(current_index.row() + 1, 0), - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) def key_up(self): @@ -1089,8 +1157,15 @@ def key_up(self): item in the list, we'll jump to the beginning, and vice-versa. """ - sel = self.selectionModel() - current_index = sel.currentIndex() + + model = self.selectionModel() + if model.hasSelection(): + current_index = next(f for f in model.selectedIndexes()) + else: + current_index = QtCore.QModelIndex() + + self.itemDelegate().closeEditor.emit(None, QtWidgets.QAbstractItemDelegate.NoHint) + first_index = self.model().index(0, 0) last_index = self.model().index(self.model().rowCount() - 1, 0) @@ -1098,21 +1173,39 @@ def key_up(self): return if not current_index.isValid(): # No selection - sel.setCurrentIndex( + model.select( + last_index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + model.setCurrentIndex( last_index, - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) return if current_index == first_index: # First item is selected - sel.setCurrentIndex( + model.select( + last_index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + model.setCurrentIndex( last_index, - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) return - sel.setCurrentIndex( + model.select( + self.model().index(current_index.row() - 1, 0), + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + model.setCurrentIndex( self.model().index(current_index.row() - 1, 0), - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) def key_tab(self): @@ -1287,8 +1380,15 @@ def reset_row_layout(self, *args, **kwargs): # Restore the selection if row >= 0: index = proxy.index(row, 0) + self.selectionModel().select( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) self.selectionModel().setCurrentIndex( - index, QtCore.QItemSelectionModel.ClearAndSelect + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) self.scrollTo( index, QtWidgets.QAbstractItemView.PositionAtCenter @@ -1356,13 +1456,15 @@ def select_item(self, v, role=QtCore.Qt.DisplayRole): if v == k: index = proxy.mapFromSource(model.index(idx, 0)) - self.selectionModel().setCurrentIndex( + self.selectionModel().select( index, - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) - self.selectionModel().select( + self.selectionModel().setCurrentIndex( index, - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) self.scrollTo( index, @@ -1540,8 +1642,15 @@ def keyPressEvent(self, event): self.interruptRequested.emit() if self.selectionModel().hasSelection(): + self.selectionModel().select( + QtCore.QModelIndex(), + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) self.selectionModel().setCurrentIndex( - QtCore.QModelIndex(), QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QModelIndex(), + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) return if event.key() == QtCore.Qt.Key_Space: @@ -1611,9 +1720,15 @@ def keyPressEvent(self, event): if index.data(QtCore.Qt.DisplayRole)[ 0].lower() == self.timed_search_string.lower(): - sel.setCurrentIndex( + self.selectionModel().select( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + self.selectionModel().setCurrentIndex( index, - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) self.delay_save_selection() break @@ -1628,9 +1743,15 @@ def keyPressEvent(self, event): match = None if match: - sel.setCurrentIndex( + self.selectionModel().select( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + self.selectionModel().setCurrentIndex( index, - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) self.delay_save_selection() return @@ -1715,9 +1836,15 @@ def mousePressEvent(self, event): cursor_position = self.viewport().mapFromGlobal(common.cursor.pos()) index = self.indexAt(cursor_position) if not index.isValid(): + self.selectionModel().select( + QtCore.QModelIndex(), + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) self.selectionModel().setCurrentIndex( QtCore.QModelIndex(), - QtCore.QItemSelectionModel.ClearAndSelect + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows ) super().mousePressEvent(event) @@ -1840,41 +1967,67 @@ def clickable_rectangle_event(self, event): if not text: continue - text = text.lower() + _text = text.lower().strip('\\/-_\'" ') if not rect.contains(cursor_position): continue filter_text = self.model().filter_text() - filter_text = filter_text.lower() if filter_text else '' + filter_text = filter_text if filter_text else '' + + filter_texts = filter_text.split(' ') if filter_text else [] + _filter_texts = [ + ('--' + f.lower().strip('\\/-_\'" ') if f.startswith('--') else f.lower().strip('\\/-_\'" ')) + for f in filter_texts + ] + filter_texts_ = [f.lower().strip('\\/-_\'" ') for f in filter_texts] + # Shift modifier toggles a text filter if shift_modifier: - # Shift modifier will add a "positive" filter and hide all items - # that does not contain the given text. - folder_filter = f'"{text}"' + # If the filter is empty we'll add a positive filter + if not filter_texts: + if '#' not in _text: + _text = f'"/{_text}"' + self.model().set_filter_text(_text) + self.repaint(self.rect()) + return - if folder_filter in filter_text: - filter_text = filter_text.replace(folder_filter, '') - else: - filter_text = f'{filter_text} {folder_filter}' + # If the clicked item is already in the filter, we'll remove it + for idx, filter_text_element in enumerate(_filter_texts): + if _text in filter_text_element: + del filter_texts[idx] + self.model().set_filter_text(' '.join(filter_texts)) + self.repaint(self.rect()) + return + # If the filter has items we'll append + if '#' not in _text: + _text = f'"/{_text}"' + self.model().set_filter_text(f'{filter_text} {_text}') - self.model().set_filter_text(filter_text) self.repaint(self.rect()) return + # Alt or control modifiers toggle a negative filter if alt_modifier or control_modifier: - # The alt or control modifiers will add a "negative filter" - # and hide the selected sub-folder from the view - folder_filter = f'--"{text}"' - _folder_filter = f'"{text}"' - - if filter_text: - if _folder_filter in filter_text: - filter_text = filter_text.replace(_folder_filter, '') - if folder_filter not in filter_text: - folder_filter = f'{filter_text} {folder_filter}' - - self.model().set_filter_text(folder_filter) + # If the filter is empty we'll add a negative filter + if not filter_texts: + if '#' not in _text: + _text = f'"/{_text}"' + self.model().set_filter_text(f'--{_text}') + self.repaint(self.rect()) + return + + # If the clicked item is already in the filter, we'll remove it + for idx, filter_text_element in enumerate(filter_texts_): + if _text in filter_text_element: + del filter_texts[idx] + + if '#' not in _text: + _text = f'"/{_text}"' + filter_texts.append(f'--{_text}') + + # add negative filter + self.model().set_filter_text(' '.join(filter_texts)) self.repaint(self.rect()) return @@ -2315,7 +2468,7 @@ def update_row(self, ref): """Queues an update request by the threads for later processing.""" if not ref(): return - if ref not in self.update_queue: + if ref not in self.update_queue.copy(): self.update_queue.append(ref) self.update_queue_timer.start(self.update_queue_timer.interval()) diff --git a/bookmarks/items/widgets/filter_editor.py b/bookmarks/items/widgets/filter_editor.py index 1862e0de8..33a780385 100644 --- a/bookmarks/items/widgets/filter_editor.py +++ b/bookmarks/items/widgets/filter_editor.py @@ -50,7 +50,7 @@ def set_opacity(self, value): self._opacity = value self.setWindowOpacity(value) self.repaint() - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) def eventFilter(self, source, event): if event.type() == QtCore.QEvent.KeyRelease: diff --git a/bookmarks/launcher/gallery.py b/bookmarks/launcher/gallery.py index 886968678..3217d41f5 100644 --- a/bookmarks/launcher/gallery.py +++ b/bookmarks/launcher/gallery.py @@ -1,16 +1,17 @@ """The application launcher item viewer. """ -from PySide2 import QtWidgets +from PySide2 import QtWidgets, QtCore from .. import actions +from .. import images from .. import common from .. import database from .. import ui def close(): - """Opens the :class:`LauncherGallery` editor. + """Opens the :class:`ApplicationLauncherWidget` editor. """ if common.launcher_widget is None: @@ -24,23 +25,23 @@ def close(): def show(): - """Shows the :class:`LauncherGallery` editor. + """Shows the :class:`ApplicationLauncherWidget` editor. """ close() - common.launcher_widget = LauncherGallery() + common.launcher_widget = ApplicationLauncherWidget() common.launcher_widget.open() return common.launcher_widget -class LauncherGallery(ui.GalleryWidget): +class ApplicationLauncherWidget(ui.GalleryWidget): """A generic gallery widget used to let the user pick an item. """ def __init__(self, parent=None): super().__init__( - 'Applications', + 'Application Launcher', item_height=common.size(common.size_row_height) * 4, parent=parent ) @@ -76,11 +77,56 @@ def item_generator(self): return actions.edit_bookmark() - for k in sorted(v, key=lambda _k: v[_k]['name']): - yield v[k]['name'], v[k]['path'], v[k]['thumbnail'] + for k in sorted(v, key=lambda idx: v[idx]['name']): + yield v[k] - def focusOutEvent(self, event): - """Event handler. + def init_data(self): + """Initializes data. """ - self.close() + row = 0 + idx = 0 + + + for v in self.item_generator(): + if 'name' not in v or not v['name']: + continue + label = v['name'] + + if 'path' not in v or not v['path']: + continue + path = v['path'] + + if not QtCore.QFileInfo(path).exists(): + continue + + if 'thumbnail' not in v or not v['thumbnail']: + thumbnail = images.rsc_pixmap( + 'icon', + None, + None, + get_path=True, + ) + else: + thumbnail = v['thumbnail'] + if 'hidden' not in v or not v['hidden']: + is_hidden = False + else: + is_hidden = v['hidden'] + + if is_hidden: + continue + + item = ui.GalleryItem( + label, path, thumbnail, height=self._item_height, parent=self + ) + + column = idx % self.columns + if column == 0: + row += 1 + + self.scroll_area.widget().layout().addWidget(item, row, column) + item.clicked.connect(self.itemSelected) + item.clicked.connect(self.close) + + idx += 1 \ No newline at end of file diff --git a/bookmarks/launcher/main.py b/bookmarks/launcher/main.py index 169abd56f..a8c8f995a 100644 --- a/bookmarks/launcher/main.py +++ b/bookmarks/launcher/main.py @@ -12,27 +12,34 @@ #: Default launcher item definition DEFAULT_ITEM = { - 0: { + common.idx(reset=True, start=0): { 'key': 'name', 'placeholder': 'Name, e.g. "Maya"', 'widget': ui.LineEdit, 'description': 'Enter the item\'s name, e.g. Maya', 'button': None, }, - 1: { + common.idx(): { 'key': 'path', 'placeholder': 'Path, e.g. "C:/maya/maya.exe"', 'widget': ui.LineEdit, 'description': 'Path to the executable.', 'button': 'Pick', }, - 2: { + common.idx(): { 'key': 'thumbnail', 'placeholder': 'Path to an image, e.g. "C:/images/maya.png"', 'widget': ui.LineEdit, 'description': 'Path to an image file used to represent this item', 'button': 'Pick', }, + common.idx(): { + 'key': 'hidden', + 'placeholder': None, + 'widget': functools.partial(QtWidgets.QCheckBox, 'Hidden'), + 'description': 'Hide the item from the application launcher.', + 'button': None, + }, } #: Default launcher item size @@ -49,7 +56,7 @@ class LauncherItemEditor(QtWidgets.QDialog): itemAdded = QtCore.Signal(dict) def __init__(self, data=None, parent=None): - super(LauncherItemEditor, self).__init__(parent=parent) + super().__init__(parent=parent) self.thumbnail_viewer_widget = None self.done_button = None @@ -88,6 +95,8 @@ def _create_ui(self): self.thumbnail_viewer_widget.setFixedSize(QtCore.QSize(w, w)) grp.layout().addWidget(self.thumbnail_viewer_widget, 0) + self.thumbnail_viewer_widget.setPixmap(images.rsc_pixmap('icon', color=None, size=w)) + _grp = ui.get_group( margin=common.size( common.size_indicator @@ -134,8 +143,22 @@ def init_data(self, item): for idx in DEFAULT_ITEM: k = DEFAULT_ITEM[idx]['key'] + + if k not in item: + continue + + if not hasattr(self, k + '_editor'): + continue + editor = getattr(self, k + '_editor') - editor.setText(item[k]) + + if isinstance(editor, QtWidgets.QCheckBox): + editor.setChecked(item[k]) + continue + + if isinstance(editor, QtWidgets.QLineEdit): + editor.setText(item[k]) + continue @QtCore.Slot(str) def update_thumbnail_image(self, path): @@ -146,8 +169,10 @@ def update_thumbnail_image(self, path): """ image = QtGui.QImage(path) - if image.isNull(): - self.thumbnail_viewer_widget.setPixmap(QtGui.QPixmap()) + if not path or image.isNull(): + h = common.size(common.size_margin) * 2 + w = h * len(DEFAULT_ITEM) + (common.size(common.size_indicator) * 2) + self.thumbnail_viewer_widget.setPixmap(images.rsc_pixmap('icon', None, w)) return image.setDevicePixelRatio(common.pixel_ratio) @@ -171,13 +196,26 @@ def action(self): raise RuntimeError('Must specify a path to an executable.') if not self.thumbnail_editor.text(): - raise RuntimeError('Must specify thumbnail image path.') + if common.show_message( + 'No thumbnail image specified.', + body=f'Are you sure you want continue without specifying a thumbnail image?', + buttons=[common.YesButton, common.CancelButton], + modal=True, ) == QtWidgets.QDialog.Rejected: + return data = {} for idx in DEFAULT_ITEM: k = DEFAULT_ITEM[idx]['key'] editor = getattr(self, k + '_editor') - v = editor.text() + + if isinstance(editor, QtWidgets.QCheckBox): + v = editor.isChecked() + elif isinstance(editor, QtWidgets.QLineEdit): + v = editor.text() + v = v if v else None + else: + v = None + data[k] = v if self._data and self._data != data: diff --git a/bookmarks/main.py b/bookmarks/main.py index 15d498498..4efbfeaeb 100644 --- a/bookmarks/main.py +++ b/bookmarks/main.py @@ -181,6 +181,9 @@ def _connect_signals(self): a.model().sourceModel().reset_data ) # Asset -> File + a.model().sourceModel().activeChanged.connect( + actions.apply_default_to_scenes_folder + ) a.model().sourceModel().activeChanged.connect( f.model().sourceModel().reset_data ) @@ -219,6 +222,7 @@ def _connect_signals(self): ) ) + # Load bookmark items upon initialization self.initialized.connect(b.model().sourceModel().reset_data) @QtCore.Slot() @@ -239,7 +243,7 @@ def initialize(self): self._connect_signals() self.aboutToInitialize.emit() - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) # Update the window title to display the current active paths for n in range(3): @@ -330,6 +334,8 @@ def _init_shortcuts(self): connect(shortcuts.Refresh, actions.refresh) connect(shortcuts.AltRefresh, actions.refresh) + connect(shortcuts.ApplicationLauncher, actions.pick_launcher_item) + connect(shortcuts.CopyItemPath, actions.copy_selected_path) connect(shortcuts.CopyAltItemPath, actions.copy_selected_alt_path) connect(shortcuts.RevealItem, actions.reveal_selected) @@ -383,7 +389,7 @@ def _paint_loading(self, painter): pixmaprect = QtCore.QRect(rect) center = pixmaprect.center() - s = common.size(common.size_asset_row_height) * 1.5 + s = common.size(common.size_row_height) * 3 o = common.size(common.size_margin) pixmaprect.setWidth(s) diff --git a/bookmarks/maya/actions.py b/bookmarks/maya/actions.py index 059bd8664..d039a1a7a 100644 --- a/bookmarks/maya/actions.py +++ b/bookmarks/maya/actions.py @@ -121,12 +121,41 @@ def apply_settings(*args, **kwargs): ) == QtWidgets.QDialog.Rejected: return - base.patch_workspace_file_rules() - base.set_framerate(props.framerate) - base.set_startframe(props.startframe) - base.set_endframe(props.endframe) - base.apply_default_render_values() - base.set_render_resolution(props.width, props.height) + try: + base.patch_workspace_file_rules() + except Exception as e: + log.error(f'Could not patch workspace.mel:\n{e}') + return + + try: + base.set_framerate(props.framerate) + except Exception as e: + log.error(f'Could not set framerate:\n{e}') + return + + try: + base.set_startframe(props.startframe) + except Exception as e: + log.error(f'Could not set startframe:\n{e}') + return + + try: + base.set_endframe(props.endframe) + except Exception as e: + log.error(f'Could not set endframe:\n{e}') + return + + try: + base.apply_default_render_values() + except Exception as e: + log.error(f'Could not apply default render values:\n{e}') + return + + try: + base.set_render_resolution(props.width, props.height) + except Exception as e: + log.error(f'Could not set render resolution:\n{e}') + return @common.error @@ -188,7 +217,7 @@ def save_scene(increment=False, type='mayaAscii'): @common.error @common.debug def execute(index): - """Action used to execute a selected file item. + """Action used to execute a selected file item in Maya. """ file_path = common.get_sequence_end_path( @@ -197,7 +226,7 @@ def execute(index): file_info = QtCore.QFileInfo(file_path) # Open alembic, and maya files: - if file_info.suffix().lower() in ('ma', 'mb', 'abc'): + if file_info.suffix().lower() in ('ma', 'mb', 'abc', 'obj', 'fbx', 'usd', 'usda', 'usdc'): open_scene(file_info.filePath()) return @@ -230,9 +259,11 @@ def save_warning(*args): return if workspace_info.path().lower() not in scene_file.filePath().lower(): + p = workspace_info.path() common.show_message( - f'Looks like you are saving "{scene_file.fileName()}" outside the current project\n\n', - body=f'The current project is:\n "{workspace_info.path()}"', + f'Scene not part of the current project.', + body=f'"{scene_file.fileName()}" is being saved to: \n"{p}"\n\n' + f'You can safely ignore this message, it\'s just a friendly reminder.', message_type=None, disable_animation=True ) @@ -700,15 +731,6 @@ def import_camera_preset(): cmds.file(path, i=True, defaultNamespace=True, type="mayaAscii") -@QtCore.Slot() -def show_shader_tool(): - """Shows the bundled shader utility tool. - - """ - from . import shadertool - shadertool.show() - - @QtCore.Slot() @common.error @common.debug diff --git a/bookmarks/maya/base.py b/bookmarks/maya/base.py index 93db6ec2c..1b3be5e6c 100644 --- a/bookmarks/maya/base.py +++ b/bookmarks/maya/base.py @@ -495,13 +495,9 @@ def report_export_progress(start, current, end, start_time): else: progress = float(_current) / float(_end) * 100 - progress = '[{}{}] {}%'.format( - '#' * int(progress), ' ' * (100 - int(progress)), int(progress) - ) - - msg = '# Exporting frame {current} of {end}\n# {progress}\n# Elapsed: {' \ + msg = '# Exporting frame {current} of {end}\n# Elapsed: {' \ 'elapsed}\n'.format( - current=current, end=end, progress=progress, elapsed=elapsed + current=current, end=end, elapsed=elapsed ) sys.stdout.write(msg) diff --git a/bookmarks/maya/contextmenu.py b/bookmarks/maya/contextmenu.py index 051cc4cd1..aeef48bac 100644 --- a/bookmarks/maya/contextmenu.py +++ b/bookmarks/maya/contextmenu.py @@ -17,6 +17,7 @@ from .. import common from .. import ui from .. import contextmenu +from .. import database from . import actions from . import export @@ -30,23 +31,24 @@ def setup(self): """Creates the context menu. """ + self.save_menu() + self.separator() self.scripts_menu() self.separator() self.apply_bookmark_settings_menu() self.separator() - self.save_menu() - self.separator() self.open_import_scene_menu() self.separator() self.export_menu() self.separator() self.import_camera_menu() - self.shader_tool_menu() self.separator() self.viewport_presets_menu() self.capture_menu() self.separator() self.hud_menu() + self.separator() + self.maya_preferences_menu() def apply_bookmark_settings_menu(self): """Apply settings action. @@ -143,16 +145,6 @@ def export_menu(self): 'action': export.show } - def shader_tool_menu(self): - """Shader tool action. - - """ - k = contextmenu.key() - self.menu[k] = { - 'text': 'Show Shader Tool', - 'action': actions.show_shader_tool - } - def import_camera_menu(self): """Import camera template action. @@ -213,12 +205,12 @@ def show_window_menu(self): } def scripts_menu(self): - """Custom Maya scripts deployed with the Maya module. + """Custom scripts deployed with the Maya module. """ k = 'Scripts' self.menu[k] = collections.OrderedDict() - self.menu[f'{k}:icon'] = ui.get_icon('maya') + self.menu[f'{k}:icon'] = ui.get_icon('icon') p = os.path.normpath(f'{__file__}/../scripts/scripts.json') if not os.path.isfile(p): @@ -227,15 +219,45 @@ def scripts_menu(self): with open(p, 'r') as f: data = json.load(f) + @common.debug + @common.error def _run(name): module = importlib.import_module(f'.scripts.{name}', package=__package__) + + if not hasattr(module, 'run'): + raise RuntimeError(f'Failed to run module: {name} - Missing run() function in {module}!') + module.run() for v in data.values(): - self.menu[k][v['name']] = { + if v['name'] == 'separator': + self.separator(menu=self.menu[k]) + continue + + # Check if the script needs_active + if 'needs_active' in v and v['needs_active']: + if not common.active(v['needs_active'], args=True): + continue + # Check if the script needs_active + if 'needs_application' in v and v['needs_application']: + if not common.active('root', args=True): + continue + # Get the bookmark database + db = database.get(*common.active('root', args=True)) + applications = db.value(db.source(), 'applications', database.BookmarkTable) + if not applications: + continue + if not [app for app in applications.values() if v['needs_application'].lower() in app['name'].lower()]: + continue + if 'icon' in v and v['icon']: + icon = ui.get_icon(v['icon']) + else: + icon = ui.get_icon('icon') + + self.menu[k][contextmenu.key()] = { 'text': v['name'], 'action': functools.partial(_run, v['module']), - 'icon': ui.get_icon('maya'), + 'icon': icon, } def hud_menu(self): @@ -245,6 +267,39 @@ def hud_menu(self): 'action': actions.toggle_hud } + def maya_preferences_menu(self): + item_on_icon = ui.get_icon('check', color=common.color(common.color_green)) + item_off_icon = ui.get_icon('close', color=common.color(common.color_red)) + + k = 'Options' + self.menu[k] = collections.OrderedDict() + self.menu[f'{k}:icon'] = ui.get_icon('settings') + + for item in ( + ('maya/sync_workspace', 'Set Workspace'), + ('maya/set_sg_context', 'Set ShotGrid Context'), + 'separator', + ('maya/reveal_capture', 'Reveal capture in explorer'), + ('maya/publish_capture', 'Copy capture to \'latest\''), + 'separator', + ('maya/workspace_save_warnings', 'Show Warnings'), + ): + if item == 'separator': + self.separator(menu=self.menu[k]) + continue + + key, name = item + + value = common.settings.value(key) + value = value if value is not None else False + + # "True" values refer to the functionality in "disabled" state, so we need to flip the values + self.menu[k][contextmenu.key()] = { + 'text': name, + 'icon': item_on_icon if not value else item_off_icon, + 'action': functools.partial(common.settings.setValue, key, not value) + } + class MayaButtonWidgetContextMenu(PluginContextMenu): """The context-menu associated with the BrowserButton.""" @@ -253,31 +308,3 @@ def __init__(self, parent=None): super().__init__( QtCore.QModelIndex(), parent=parent ) - - -class MayaWidgetContextMenu(PluginContextMenu): - """Context menu associated with :class:`MayaWidget`. - - """ - - @common.error - @common.debug - def setup(self): - """Creates the context menu. - - """ - self.apply_bookmark_settings_menu() - self.separator() - self.save_menu() - self.separator() - self.open_import_scene_menu() - self.separator() - self.export_menu() - self.separator() - self.import_camera_menu() - self.shader_tool_menu() - self.separator() - self.viewport_presets_menu() - self.capture_menu() - self.separator() - self.hud_menu() diff --git a/bookmarks/maya/export.py b/bookmarks/maya/export.py index 7d0c69ae7..7d9add2cf 100644 --- a/bookmarks/maya/export.py +++ b/bookmarks/maya/export.py @@ -42,34 +42,382 @@ def show(): return common.maya_export_widget + +def export_maya( + destination, outliner_set, start_frame, end_frame, step=1.0 +): + """Main Maya scene export function. + + Args: + start_frame (int): Start frame. + end_frame (int): End frame. + destination (str): Path to the output file. + outliner_set (tuple): A list of transforms contained in a geometry set. + step (float): Frame step. + + """ + common.check_type(destination, str) + common.check_type(outliner_set, (tuple, list)) + common.check_type(start_frame, (int, float)) + common.check_type(end_frame, (int, float)) + common.check_type(step, (float, int)) + + _destination = str(destination) + + cmds.select(outliner_set, replace=True) + + cmds.file( + _destination, + force=True, + preserveReferences=True, + type='mayaAscii', + exportSelected=True, + options="v=0;" + ) + +def export_alembic( + destination, outliner_set, start_frame, end_frame, step=1.0 +): + """Main alembic export function. + + Only shapes, normals and uvs are exported by this implementation. The list + of shapes contained in the `outliner_set` will be rebuilt in the root of + the scene to avoid parenting issues. + + Args: + start_frame (int): Start frame. + end_frame (int): End frame. + destination (str): Path to the output file. + outliner_set (tuple): A list of transforms contained in a geometry set. + step (int, float): Frame step. + + """ + common.check_type(destination, str) + common.check_type(outliner_set, (tuple, list)) + common.check_type(start_frame, (int, float)) + common.check_type(end_frame, (int, float)) + common.check_type(step, (float, int)) + + def _is_intermediate(s): + return cmds.getAttr(f'{s}.intermediateObject') + + def teardown(): + """We will delete the previously created namespace and the objects + contained inside. I wrapped the call into an evalDeferred to let maya + recover after the export and delete the objects more safely. + + """ + + def _teardown(): + if cmds.namespace(exists=mayabase.TEMP_NAMESPACE): + cmds.namespace( + removeNamespace=mayabase.TEMP_NAMESPACE, + deleteNamespaceContent=True + ) + + cmds.evalDeferred(_teardown) + + # We'll need to use the DecomposeMatrix Nodes, let's check if the plugin + # is loaded and ready to use + + world_shapes = [] + valid_shapes = [] + + # First, we will collect the available shapes from the given set + for item in outliner_set: + shapes = cmds.listRelatives(item, fullPath=True) + for shape in shapes: + if _is_intermediate(shape): + continue + + basename = shape.split('|')[-1] + try: + # AbcExport will fail if a transform or a shape node's name is + # not unique. This was suggested on a forum - listing the + # relatives for an object without a unique name should raise a + # ValueError + cmds.listRelatives(basename) + except ValueError as err: + s = f'"{shape}" does not have a unique name. This is not ' \ + f'usually allowed for alembic exports and might cause the ' \ + f'export to fail.' + log.error(s) + + # Cameras don't have mesh nodes, but we still want to export them! + if cmds.nodeType(shape) != 'camera': + if not cmds.attributeQuery('outMesh', node=shape, exists=True): + continue + valid_shapes.append(shape) + + if not valid_shapes: + nodes = '", "'.join(outliner_set) + raise RuntimeError( + f'Could not find any nodes to export in the set. The set contains:\n' + f'"{nodes}"' + ) + + cmds.select(clear=True) + + # Creating a temporary namespace to avoid name-clashes later when we + # duplicate the meshes. We will delete this namespace, and it's contents + # after the export + if cmds.namespace(exists=mayabase.TEMP_NAMESPACE): + cmds.namespace( + removeNamespace=mayabase.TEMP_NAMESPACE, + deleteNamespaceContent=True + ) + cmds.namespace(add=mayabase.TEMP_NAMESPACE) + ns = mayabase.TEMP_NAMESPACE + + world_transforms = [] + + try: + # For meshes, we will create an empty mesh node and connect the + # outMesh and UV attributes from our source. We will also apply the + # source mesh's transform matrix to the newly created mesh + for shape in valid_shapes: + basename = shape.split('|').pop() + if cmds.nodeType(shape) != 'camera': + # Create new empty shape node + world_shape = cmds.createNode('mesh', name=f'{ns}:{basename}') + + # outMesh -> inMesh + cmds.connectAttr( + f'{shape}.outMesh', + f'{world_shape}.inMesh', + force=True + ) + # uvSet -> uvSet + cmds.connectAttr( + f'{shape}.uvSet', + f'{world_shape}.uvSet', + force=True + ) + + # worldMatrix -> transform + decompose_matrix = cmds.createNode( + 'decomposeMatrix', + name=f'{ns}:decomposeMatrix#' + ) + cmds.connectAttr( + f'{shape}.worldMatrix[0]', + f'{decompose_matrix}.inputMatrix', + force=True + ) + + transform = cmds.listRelatives( + world_shape, + fullPath=True, + type='transform', + parent=True + )[0] + world_transforms.append(transform) + + cmds.connectAttr( + f'{decompose_matrix}.outputTranslate', + f'{transform}.translate', + force=True + ) + cmds.connectAttr( + f'{decompose_matrix}.outputRotate', + f'{transform}.rotate', + force=True + ) + cmds.connectAttr( + f'{decompose_matrix}.outputScale', + f'{transform}.scale', + force=True + ) + else: + world_shape = shape + world_transforms.append( + cmds.listRelatives( + world_shape, + fullPath=True, + type='transform', + parent=True + )[0] + ) + world_shapes.append(world_shape) + except: + teardown() + raise RuntimeError('Failed to prepare scene.') + + try: + # Build the export command + cmd = '{f} {fr} {s} {uv} {ws} {wv} {wuvs} {wcs} {wfs} {sn} {rt} {df} {ro}' + cmd = cmd.format( + f=f'-file "{destination}"', + fr=f'-framerange {start_frame} {end_frame}', + s=f'-step {step}', + uv='-uvWrite', + ws='-worldSpace', + wv='-writeVisibility', + # eu='-eulerFilter', + wuvs='-writeuvsets', + wcs='-writeColorSets', + wfs='-writeFaceSets', + sn='-stripNamespaces', + rt=f'-root {" -root ".join(world_transforms)}', + df='-dataFormat ogawa', + ro='-renderableOnly' + ) + s = f'Alembic Export Job Arguments:\n{cmd}' + log.success(s) + cmds.AbcExport(jobArg=cmd) + log.success(f'{destination} exported successfully.') + except Exception: + log.error('The alembic export failed.') + raise + finally: + teardown() + +def export_ass( + destination, outliner_set, start_frame, end_frame, step=1.0 +): + """Main Arnold ASS export function. + + Args: + start_frame (int): Start frame. + end_frame (int): End frame. + destination (str): Path to the output file. + outliner_set (tuple): A list of transforms contained in a geometry set. + step (float, int): Frame step. + + """ + common.check_type(destination, str) + common.check_type(outliner_set, (tuple, list)) + common.check_type(start_frame, (int, float)) + common.check_type(end_frame, (int, float)) + common.check_type(step, (float, int)) + + try: + import arnold + except ImportError: + raise ImportError('Could not find arnold.') + + # Let's get the first renderable camera. This is a bit of a leap of faith but + # ideally there's only one renderable camera in the scene. + cams = cmds.ls(cameras=True) + cam = None + for cam in cams: + if cmds.getAttr(f'{cam}.renderable'): + break + + cmds.select(outliner_set, replace=True) + + ext = destination.split('.')[-1] + _destination = str(destination) + start_time = time.time() + + for fr in range(start_frame, end_frame + 1): + cmds.currentTime(fr, edit=True) + + if not start_frame == end_frame: + # Create a mock version, if it does not exist + open(destination, 'a').close() + _destination = destination.replace(f'.{ext}', '') + _destination += '_' + _destination += str(fr).zfill(mayabase.DefaultPadding) + _destination += '.' + _destination += ext + + cmds.arnoldExportAss( + f=_destination, + cam=cam, + s=True, # selected + mask=arnold.AI_NODE_CAMERA | + arnold.AI_NODE_SHAPE | + arnold.AI_NODE_SHADER | + arnold.AI_NODE_OVERRIDE | + arnold.AI_NODE_LIGHT + ) + + mayabase.report_export_progress(start_frame, fr, end_frame, start_time) + +def export_obj( + destination, outliner_set, start_frame, end_frame, step=1.0 +): + """Main obj export function. + + Args: + start_frame (int): Start frame. + end_frame (int): End frame. + destination (str): Path to the output file. + outliner_set (tuple): A list of transforms contained in a geometry set. + step (float, int): Frame step. + + """ + common.check_type(destination, str) + common.check_type(outliner_set, (tuple, list)) + common.check_type(start_frame, (int, float)) + common.check_type(end_frame, (int, float)) + common.check_type(step, (float, int)) + + ext = destination.split('.')[-1] + _destination = str(destination) + start_time = time.time() + + cmds.select(outliner_set, replace=True) + + for fr in range(start_frame, end_frame + 1): + cmds.currentTime(fr, edit=True) + + if not start_frame == end_frame: + # Create a mock version, if it does not exist + open(destination, 'a').close() + _destination = destination.replace(f'.{ext}', '') + _destination += '_' + _destination += str(fr).zfill(mayabase.DefaultPadding) + _destination += '.' + _destination += ext + + if ( + QtCore.QFileInfo(_destination).exists() and + not QtCore.QFile(_destination).remove() + ): + raise RuntimeError(f'Failed to remove {_destination}') + + cmds.file( + _destination, + preserveReferences=True, + type='OBJexport', + exportSelected=True, + options='groups=1;ptgroups=1;materials=1;smoothing=1; normals=1' + ) + + mayabase.report_export_progress(start_frame, fr, end_frame, start_time) + + + #: Maya cache export presets PRESETS = { 'alembic': { 'name': 'Alembic', 'extension': 'abc', 'plugins': ('AbcExport.mll', 'matrixNodes.mll'), - 'action': 'export_alembic', + 'action': export_alembic, 'ogs_pause': True, }, 'ass': { 'name': 'Arnold ASS', 'extension': 'ass', 'plugins': ('mtoa.mll',), - 'action': 'export_ass', + 'action': export_ass, 'ogs_pause': True, }, 'obj': { 'name': 'OBJ', 'extension': 'obj', 'plugins': ('objExport.mll',), - 'action': 'export_obj', + 'action': export_obj, 'ogs_pause': True, }, 'ma': { 'name': 'Maya Scene', 'extension': 'ma', 'plugins': (), - 'action': 'export_maya', + 'action': export_maya, 'ogs_pause': False, }, } @@ -359,7 +707,7 @@ def save_changes(self): self.progress_widget.setRange(int(start), int(end)) self.progress_widget.open() - action = getattr(self, PRESETS[k]['action']) + action = PRESETS[k]['action'] action(file_path, items, int(start), int(end)) common.signals.fileAdded.emit(file_path) @@ -435,372 +783,3 @@ def sizeHint(self): common.size(common.size_width) * 0.66, common.size(common.size_height * 1.2) ) - - def export_maya( - self, destination, outliner_set, start_frame, end_frame, step=1.0 - ): - """Main Maya scene export function. - - Args: - start_frame (int): Start frame. - end_frame (int): End frame. - destination (str): Path to the output file. - outliner_set (tuple): A list of transforms contained in a geometry set. - step (float): Frame step. - - """ - common.check_type(destination, str) - common.check_type(outliner_set, (tuple, list)) - common.check_type(start_frame, (int, float)) - common.check_type(end_frame, (int, float)) - common.check_type(step, (float, int)) - - _destination = str(destination) - - cmds.select(outliner_set, replace=True) - - cmds.file( - _destination, - force=True, - preserveReferences=True, - type='mayaAscii', - exportSelected=True, - options="v=0;" - ) - - def export_alembic( - self, destination, outliner_set, start_frame, end_frame, step=1.0 - ): - """Main alembic export function. - - Only shapes, normals and uvs are exported by this implementation. The list - of shapes contained in the `outliner_set` will be rebuilt in the root of - the scene to avoid parenting issues. - - Args: - start_frame (int): Start frame. - end_frame (int): End frame. - destination (str): Path to the output file. - outliner_set (tuple): A list of transforms contained in a geometry set. - step (int, float): Frame step. - - """ - common.check_type(destination, str) - common.check_type(outliner_set, (tuple, list)) - common.check_type(start_frame, (int, float)) - common.check_type(end_frame, (int, float)) - common.check_type(step, (float, int)) - - def _is_intermediate(s): - return cmds.getAttr(f'{s}.intermediateObject') - - def teardown(): - """We will delete the previously created namespace and the objects - contained inside. I wrapped the call into an evalDeferred to let maya - recover after the export and delete the objects more safely. - - """ - - def _teardown(): - cmds.namespace( - removeNamespace=mayabase.TEMP_NAMESPACE, - deleteNamespaceContent=True - ) - - cmds.evalDeferred(_teardown) - - # We'll need to use the DecomposeMatrix Nodes, let's check if the plugin - # is loaded and ready to use - - world_shapes = [] - valid_shapes = [] - - # First, we will collect the available shapes from the given set - for item in outliner_set: - shapes = cmds.listRelatives(item, fullPath=True) - for shape in shapes: - if _is_intermediate(shape): - continue - - basename = shape.split('|')[-1] - try: - # AbcExport will fail if a transform or a shape node's name is - # not unique. This was suggested on a forum - listing the - # relatives for an object without a unique name should raise a - # ValueError - cmds.listRelatives(basename) - except ValueError as err: - s = f'"{shape}" does not have a unique name. This is not ' \ - f'usually allowed for alembic exports and might cause the ' \ - f'export to fail.' - log.error(s) - - # Camera's don't have mesh nodes, but we still want to export them! - if cmds.nodeType(shape) != 'camera': - if not cmds.attributeQuery('outMesh', node=shape, exists=True): - continue - valid_shapes.append(shape) - - if not valid_shapes: - nodes = '", "'.join(outliner_set) - raise RuntimeError( - f'Could not find any nodes to export in the set. The set contains:\n' - f'"{nodes}"' - ) - - cmds.select(clear=True) - - # Creating a temporary namespace to avoid name-clashes later when we - # duplicate the meshes. We will delete this namespace, and it's contents - # after the export - if cmds.namespace(exists=mayabase.TEMP_NAMESPACE): - cmds.namespace( - removeNamespace=mayabase.TEMP_NAMESPACE, - deleteNamespaceContent=True - ) - cmds.namespace(add=mayabase.TEMP_NAMESPACE) - ns = mayabase.TEMP_NAMESPACE - - world_transforms = [] - - try: - # For meshes, we will create an empty mesh node and connect the - # outMesh and UV attributes from our source. We will also apply the - # source mesh's transform matrix to the newly created mesh - for shape in valid_shapes: - basename = shape.split('|').pop() - if cmds.nodeType(shape) != 'camera': - # Create new empty shape node - world_shape = cmds.createNode('mesh', name=f'{ns}:{basename}') - - # outMesh -> inMesh - cmds.connectAttr( - f'{shape}.outMesh', - f'{world_shape}.inMesh', - force=True - ) - # uvSet -> uvSet - cmds.connectAttr( - f'{shape}.uvSet', - f'{world_shape}.uvSet', - force=True - ) - - # worldMatrix -> transform - decompose_matrix = cmds.createNode( - 'decomposeMatrix', - name=f'{ns}:decomposeMatrix#' - ) - cmds.connectAttr( - f'{shape}.worldMatrix[0]', - f'{decompose_matrix}.inputMatrix', - force=True - ) - - transform = cmds.listRelatives( - world_shape, - fullPath=True, - type='transform', - parent=True - )[0] - world_transforms.append(transform) - - cmds.connectAttr( - f'{decompose_matrix}.outputTranslate', - f'{transform}.translate', - force=True - ) - cmds.connectAttr( - f'{decompose_matrix}.outputRotate', - f'{transform}.rotate', - force=True - ) - cmds.connectAttr( - f'{decompose_matrix}.outputScale', - f'{transform}.scale', - force=True - ) - else: - world_shape = shape - world_transforms.append( - cmds.listRelatives( - world_shape, - fullPath=True, - type='transform', - parent=True - )[0] - ) - world_shapes.append(world_shape) - except: - teardown() - raise RuntimeError('Failed to prepare scene.') - - try: - # Our custom progress callback - perframecallback = f'"from bookmarks.maya import base;' \ - f'base.report_export_progress(' \ - f'{start_frame}, #FRAME#, {end_frame}, ' \ - f'{time.time()})"' - - # Build the export command - cmd = '{f} {fr} {s} {uv} {ws} {wv} {wuvs} {wcs} {wfs} {sn} {rt} {df} {pfc} {ro}' - cmd = cmd.format( - f=f'-file "{destination}"', - fr=f'-framerange {start_frame} {end_frame}', - s=f'-step {step}', - uv='-uvWrite', - ws='-worldSpace', - wv='-writeVisibility', - # eu='-eulerFilter', - wuvs='-writeuvsets', - wcs='-writeColorSets', - wfs='-writeFaceSets', - sn='-stripNamespaces', - rt=f'-root {" -root ".join(world_transforms)}', - df='-dataFormat ogawa', - pfc=f'-pythonperframecallback {perframecallback}', - ro='-renderableOnly' - ) - s = f'Alembic Export Job Arguments:\n{cmd}' - log.success(s) - cmds.AbcExport(jobArg=cmd) - log.success(f'{destination} exported successfully.') - except Exception: - log.error('The alembic export failed.') - raise - finally: - teardown() - - def export_ass( - self, destination, outliner_set, start_frame, end_frame, step=1.0 - ): - """Main Arnold ASS export function. - - Args: - start_frame (int): Start frame. - end_frame (int): End frame. - destination (str): Path to the output file. - outliner_set (tuple): A list of transforms contained in a geometry set. - step (float, int): Frame step. - - """ - common.check_type(destination, str) - common.check_type(outliner_set, (tuple, list)) - common.check_type(start_frame, (int, float)) - common.check_type(end_frame, (int, float)) - common.check_type(step, (float, int)) - - try: - import arnold - except ImportError: - raise ImportError('Could not find arnold.') - - # Let's get the first renderable camera. This is a bit of a leap of faith but - # ideally there's only one renderable camera in the scene. - cams = cmds.ls(cameras=True) - cam = None - for cam in cams: - if cmds.getAttr(f'{cam}.renderable'): - break - - cmds.select(outliner_set, replace=True) - - ext = destination.split('.')[-1] - _destination = str(destination) - start_time = time.time() - - for fr in range(start_frame, end_frame + 1): - QtWidgets.QApplication.instance().processEvents() - if self._interrupt_requested: - self._interrupt_requested = False - return - - cmds.currentTime(fr, edit=True) - if self.progress_widget.wasCanceled(): - return - else: - self.progress_widget.setValue(fr) - - if not start_frame == end_frame: - # Create a mock version, if it does not exist - open(destination, 'a').close() - _destination = destination.replace(f'.{ext}', '') - _destination += '_' - _destination += str(fr).zfill(mayabase.DefaultPadding) - _destination += '.' - _destination += ext - - cmds.arnoldExportAss( - f=_destination, - cam=cam, - s=True, # selected - mask=arnold.AI_NODE_CAMERA | - arnold.AI_NODE_SHAPE | - arnold.AI_NODE_SHADER | - arnold.AI_NODE_OVERRIDE | - arnold.AI_NODE_LIGHT - ) - - mayabase.report_export_progress(start_frame, fr, end_frame, start_time) - - def export_obj( - self, destination, outliner_set, start_frame, end_frame, step=1.0 - ): - """Main obj export function. - - Args: - start_frame (int): Start frame. - end_frame (int): End frame. - destination (str): Path to the output file. - outliner_set (tuple): A list of transforms contained in a geometry set. - step (float, int): Frame step. - - """ - common.check_type(destination, str) - common.check_type(outliner_set, (tuple, list)) - common.check_type(start_frame, (int, float)) - common.check_type(end_frame, (int, float)) - common.check_type(step, (float, int)) - - ext = destination.split('.')[-1] - _destination = str(destination) - start_time = time.time() - - cmds.select(outliner_set, replace=True) - - for fr in range(start_frame, end_frame + 1): - QtWidgets.QApplication.instance().processEvents() - if self._interrupt_requested: - self._interrupt_requested = False - return - - cmds.currentTime(fr, edit=True) - if self.progress_widget.wasCanceled(): - return - else: - self.progress_widget.setValue(fr) - - if not start_frame == end_frame: - # Create a mock version, if it does not exist - open(destination, 'a').close() - _destination = destination.replace(f'.{ext}', '') - _destination += '_' - _destination += str(fr).zfill(mayabase.DefaultPadding) - _destination += '.' - _destination += ext - - if ( - QtCore.QFileInfo(_destination).exists() and - not QtCore.QFile(_destination).remove() - ): - raise RuntimeError(f'Failed to remove {_destination}') - - cmds.file( - _destination, - preserveReferences=True, - type='OBJexport', - exportSelected=True, - options='groups=1;ptgroups=1;materials=1;smoothing=1; normals=1' - ) - - mayabase.report_export_progress(start_frame, fr, end_frame, start_time) diff --git a/bookmarks/maya/main.py b/bookmarks/maya/main.py index 984a45c4f..0daf277d8 100644 --- a/bookmarks/maya/main.py +++ b/bookmarks/maya/main.py @@ -68,6 +68,11 @@ def show(): for widget in QtWidgets.QApplication.instance().allWidgets(): # Skipping workspaceControls objects, just in case there's a name conflict # between what the parent().objectName() and this method yields + try: + widget.objectName() + except: + continue + if re.match(f'{common.product}_.*WorkspaceControl', widget.objectName()): continue @@ -127,7 +132,7 @@ def show(): def init_maya_widget(): """Initializes the maya widget. - Usually the Maya plugin will call this method. + Usually the Maya plugin will call this function. """ if isinstance(common.maya_widget, MayaWidget): @@ -464,7 +469,7 @@ def __init__(self, parent=None): self._workspacecontrol = None self._callbacks = [] # Maya api callbacks - self.setWindowTitle(common.product) + self.setWindowTitle(common.product.title()) common.set_stylesheet(self) # Rename object @@ -586,7 +591,7 @@ def customFilesContextMenuEvent(self, index, parent): width = (width * 0.5) if width > common.size(common.size_width) else width width = width - common.size(common.size_indicator) - widget = contextmenu.MayaWidgetContextMenu(index, parent=parent) + widget = contextmenu.PluginContextMenu(index, parent=parent) if index.isValid(): rect = parent.visualRect(index) widget.move( diff --git a/bookmarks/maya/plugin.py b/bookmarks/maya/plugin.py index e6e231ee7..641263cbc 100644 --- a/bookmarks/maya/plugin.py +++ b/bookmarks/maya/plugin.py @@ -17,7 +17,7 @@ product = 'bookmarks' __author__ = 'Gergely Wootsch' -__version__ = '0.8.5' +__version__ = '0.9.1' maya_useNewAPI = True diff --git a/bookmarks/maya/scripts/aka_character_caches.py b/bookmarks/maya/scripts/aka_character_caches.py new file mode 100644 index 000000000..3a3107aeb --- /dev/null +++ b/bookmarks/maya/scripts/aka_character_caches.py @@ -0,0 +1,909 @@ +"""Export script for Odyssey cloth sims. + +The script automates the animation cache export from Maya. + +It performs the follow steps: + - Saves the current animation to studio library + - Removes the animation from the body and root controllers + - Adds a preroll with a reset pose + - Saves the animation caches + +Gergely Wootsch. +hello@gergely-wootsch.com +Studio Aka, 2023 October + +""" +import json +import os +import sys + +import maya.cmds as cmds +from PySide2 import QtWidgets, QtCore + +from . import aka_make_export_sets +from .. import base as mayabase +from .. import export +from ... import actions +from ... import common +from ... import database +from ... import images +from ... import log +from ... import ui +from ...tokens import tokens + +instance = None + +#: The path of the output cache file +cache_path = '{dir}/{basename}_{version}.{ext}' + +#: The default values for the dialog +DEFAULT_VALUES = { + 'Studio Library': { + 'studio_library_folder': { + 'default': '', + 'placeholder': 'C:/Users/aka/Documents/studiolibrary', + 'widget': ui.LineEdit, + }, + 'studio_library_reset_pose': { + 'default': 'Characters/IbogaineMarcus/Reset/A-Pose-v2-FullFK.pose/pose.json', + 'placeholder': 'relative/path/to/pose.json', + 'widget': ui.LineEdit, + }, + 'studio_library_output_folder': { + 'default': 'Shots/{prefix}/{asset0}_{shot}', + 'placeholder': 'relative/path/to/output/dir', + 'widget': ui.LineEdit, + }, + }, + 'Character': { + 'namespace': { + 'default': 'IbogaineMarcus_01', + 'placeholder': 'CharacterNamespace_01', + 'widget': ui.LineEdit, + }, + 'controllers_set': { + 'default': 'rig_controllers_grp', + 'placeholder': 'controller_set_name', + 'widget': ui.LineEdit, + }, + 'hip_controller': { + 'default': 'body_C0_ctl', + 'placeholder': 'hip_controller_name', + 'widget': ui.LineEdit, + }, + 'exclude_reset_pose_from': { + 'default': 'legUI_L0_ctl, armUI_R0_ctl, faceUI_C0_ctl, legUI_R0_ctl, spineUI_C0_ctl, armUI_L0_ctl, body_C0_ctl, world_ctl, root_C0_ctl', + 'placeholder': 'list, of, controllers, to, exclude', + 'widget': ui.LineEdit, + }, + 'apply_animation_exclusions': { + 'default': True, + 'placeholder': '', + 'widget': lambda: QtWidgets.QCheckBox('Apply Animation Exclusions'), + }, + 'exclude_animation_from': { + 'default': 'body_C0_ctl, world_ctl, root_C0_ctl', + 'placeholder': 'list, of, controllers, to, exclude', + 'widget': ui.LineEdit, + }, + } +} + + +def export_nullLocator_to_alembic(output_file_path, cut_in, cut_out): + """Export the nullLocator to Alembic. + + Args: + output_file_path (str): The output file path. + cut_in (int): The cut in frame. + cut_out (int): The cut out frame. + + """ + # Check if the nullLocator exists + if not cmds.objExists('nullLocator'): + raise ValueError('nullLocator does not exist in the scene.') + + # Ensure that the nullLocator is in world space + parent = cmds.listRelatives('nullLocator', parent=True) + if parent: + # Parent it to the world + cmds.parent('nullLocator', world=True) + + # Export to Alembic + export_command = ( + f'-frameRange {cut_in} {cut_out} ' + f'-worldSpace ' # Ensures the object is exported in world space + f'-root nullLocator ' # The object we want to export + f'-file "{output_file_path}"' + ) + cmds.AbcExport(j=export_command) + + print("Exported nullLocator to: {}".format(output_file_path)) + + +def find_studio_library(): + for path in sys.path: + if not os.path.isdir(path): + continue + for entry in os.scandir(path): + if 'studiolibrary' in entry.name: + if os.path.isdir(f'{entry.path}/src'): + return os.path.normpath(os.path.abspath(f'{entry.path}/src')) + return None + + +def add_studio_library_to_path(): + path = find_studio_library() + if not path: + raise RuntimeError('Could not find Studio Library.') + + if path not in sys.path: + sys.path.insert(0, path) + + +def get_cache_path(set_name, ext, makedir=True): + """Get the path of the output cache file. + + """ + workspace = cmds.workspace(q=True, fn=True) + + export_dir = mayabase.DEFAULT_CACHE_DIR.format( + export_dir=tokens.get_folder(tokens.CacheFolder), ext=tokens.get_subfolder(tokens.CacheFolder, ext) + ) + file_path = mayabase.CACHE_PATH.format( + workspace=workspace, export_dir=export_dir, set=set_name, ext=ext + ) + file_path = mayabase.sanitize_namespace(file_path) + + file_info = QtCore.QFileInfo(file_path) + _version = 1 + while True: + version = 'v' + f'{_version}'.zfill(3) + + file_path = cache_path.format( + dir=file_info.dir().path(), basename=file_info.completeBaseName(), version=version, ext=file_info.suffix() + ) + + if not QtCore.QFileInfo(file_path).exists(): + break + if _version >= 999: + break + + _version += 1 + + if makedir: + QtCore.QFileInfo(file_path).dir().mkpath('.') + return file_path + + +def get_studio_library_default_library(): + """Get the default library from the Studio Library settings file.""" + try: + import studiolibrary + import mutils + except ImportError as e: + try: + add_studio_library_to_path() + except ImportError: + raise e + else: + import studiolibrary + import mutils + + s = studiolibrary.settingsPath() + if not s or not QtCore.QFileInfo(s).exists(): + raise RuntimeError('Could not find the Studio Library settings file.') + + with open(s, 'r') as f: + studio_library_settings = json.loads(f.read()) + + if 'Default' not in studio_library_settings: + raise RuntimeError('Studio Library does not seem to have been configured. Please check your settings.') + if 'path' not in studio_library_settings['Default']: + raise RuntimeError('Studio Library seems to be missing a required settings key "path"') + + v = studio_library_settings['Default']['path'] + if not QtCore.QFileInfo(v).exists(): + raise RuntimeError( + 'Could not find the default Studio Library directory. Check that everything is configured correctly.' + ) + + return v + + +class ExportCharacterCachesDialog(QtWidgets.QDialog): + statusChanged = QtCore.Signal(str, str) + + """Dialog to provide options for exporting character caches.""" + + def __init__(self, parent=None): + super().__init__(parent) + + self.settings = QtCore.QSettings('StudioAka', 'ExportCharacterCaches') + self.setWindowTitle('Export Character Caches') + + self._create_ui() + self._connect_signals() + + def _create_ui(self): + if not self.parent(): + common.set_stylesheet(self) + + QtWidgets.QVBoxLayout(self) + + pixmap, color = images.get_thumbnail( + common.active('server'), + common.active('job'), + common.active('root'), + common.active('asset', path=True), + size=common.size(common.size_row_height) * 3, + fallback_thumb='placeholder', + get_path=False + ) + + row = ui.add_row(None, height=None, parent=self) + label = QtWidgets.QLabel(parent=self) + label.setPixmap(pixmap) + row.layout().addWidget(label, 0) + row.layout().addSpacing(common.size(common.size_margin) * 0.5) + + active_index = common.active_index(common.AssetTab) + if not active_index or not active_index.isValid(): + raise RuntimeError('Could not find active asset. Please make sure an asset is activated.') + asset_name = active_index.data(QtCore.Qt.DisplayRole) + asset_name = asset_name if asset_name else 'Export Cache' + + row.layout().addWidget(ui.PaintedLabel(asset_name, size=common.size(common.size_font_large))) + + for k in DEFAULT_VALUES: + grp = ui.get_group(parent=self, ) + + for _k, _v in DEFAULT_VALUES[k].items(): + v = self.settings.value(_k, _v['default']) + editor = _v['widget']() + + if isinstance(editor, QtWidgets.QCheckBox): + if not isinstance(v, bool): + v = True + editor.setChecked(v) + + if isinstance(editor, ui.LineEdit): + if not isinstance(v, str): + v = '' + editor.setText(v) + if isinstance(_v['placeholder'], str): + editor.setPlaceholderText(_v['placeholder']) + + row = ui.add_row(_k.replace('_', ' ').title(), parent=grp) + if _k == 'studio_library_folder': + row.setHidden(True) + row.layout().addWidget(editor) + + setattr(self, f'{_k}_editor', editor) + + self.layout().addWidget(grp) + + # Buttons + button_layout = QtWidgets.QHBoxLayout() + self.ok_button = ui.PaintedButton('Export Caches') + self.cancel_button = ui.PaintedButton('Cancel') + self.apply_hip_pose_button = ui.PaintedButton('Apply Hip Pose') + + button_layout.addWidget(self.ok_button, 1) + button_layout.addWidget(self.apply_hip_pose_button, 1) + button_layout.addWidget(self.cancel_button, 0) + + self.layout().addStretch(1) + self.layout().addLayout(button_layout) + + def _connect_signals(self): + self.ok_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + self.statusChanged.connect(self.show_status) + self.apply_hip_pose_button.clicked.connect(self.apply_hip_pose) + + @QtCore.Slot(str, str) + def show_status(self, title, body): + common.show_message( + title, body=body, message_type=None, buttons=[], disable_animation=True, parent=self + ) + + def accept(self): + """Save the settings and accept the dialog. + + """ + # Get database and config + if not all(common.active('asset', args=True)): + raise RuntimeError('An asset must be active to export.') + + try: + cmds.ogs(pause=True) + except: + pass + + try: + cmds.refresh(suspend=True) + except: + pass + + try: + output_paths = self.export() + finally: + try: + cmds.refresh(suspend=False) + except: + pass + + try: + cmds.ogs(pause=False) + except: + pass + + common.show_message( + 'Success', f'All cache files were exported. Check the console for details.', message_type='success' + ) + + log.success(f'Character caches were exported successfully.') + print('\n\n>>> ======================') + print('>>> Exported files:') + for path in output_paths: + print(QtCore.QFileInfo(path).filePath()) + + super().accept() + + def _get_options(self): + # Collect all the current settings to a dictionary + self.statusChanged.emit('Preparing...', 'Validating options, please wait') + + options = {} + for k in DEFAULT_VALUES: + for _k, _v in DEFAULT_VALUES[k].items(): + options[_k] = self.settings.value(_k, _v['default']) + + config = tokens.get(*common.active('root', args=True)) + seq, shot = common.get_sequence_and_shot(common.active('asset')) + + db = database.get(*common.active('root', args=True)) + cut_in = db.value(common.active('asset', path=True), 'cut_in', database.AssetTable) + cut_out = db.value(common.active('asset', path=True), 'cut_out', database.AssetTable) + prefix = db.value(common.active('root', path=True), 'prefix', database.BookmarkTable) + + # --- Validate values --- + if not all((seq, shot)): + raise RuntimeError('Could not find sequence and shot names.') + + # Check if none of the values are None + if any((x is None for x in (cut_in, cut_out, prefix))): + raise RuntimeError('Not all required values are set: We need cut_in, cut_out or prefix to be set.') + + if cut_out <= cut_in: + raise RuntimeError(f'Cut out is smaller than cut in: {cut_out} <= {cut_in}') + + # --- Validate the options --- + + # Get the default library from the Studio Library settings file + options['studio_library_folder'] = get_studio_library_default_library() + + # studio_library_reset_pose + if not options['studio_library_reset_pose']: + raise RuntimeError('Please specify a reset pose.') + + # Check if we specified a valid reset pose + _p = options['studio_library_folder'].replace('\\', '/') + __p = options['studio_library_reset_pose'].replace('\\', '/') + options['studio_library_reset_pose'] = f'{_p}/{__p}'.replace('\\', '/') + + if not QtCore.QFileInfo(options['studio_library_reset_pose']).exists(): + raise RuntimeError(f'Reset pose "{options["studio_library_reset_pose"]}" does not exist.') + + # studio_library_output_folder + if not options['studio_library_output_folder']: + raise RuntimeError('Please specify an output folder template.') + + _p = options['studio_library_folder'].replace('\\', '/') + __p = options['studio_library_output_folder'].replace('\\', '/') + __p = config.expand_tokens( + __p, + asset=common.active('asset'), + shot=shot, + sequence=seq, + prefix=prefix.split('_')[-1].upper(), + ).replace('\\', '/') + options['studio_library_output_folder'] = f'{_p}/{__p}' + + # namespace + if not options['namespace']: + raise RuntimeError('Please specify a namespace.') + # Check if we specified a valid namespace + if not cmds.namespace(exists=options['namespace']): + raise RuntimeError(f'Namespace "{options["namespace"]}" does not exist.') + + # controllers_set + if not options['controllers_set']: + raise RuntimeError('Please specify a controllers set.') + # Check if we specified a valid set. The controllers set should be an object set and part of the namespace. + if not cmds.objExists(f'{options["namespace"]}:{options["controllers_set"]}'): + raise RuntimeError(f'Controllers set "{options["namespace"]}:{options["controllers_set"]}" does not exist.') + options['controllers_set'] = f'{options["namespace"]}:{options["controllers_set"]}' + + # Hip controller + if not options['hip_controller']: + raise RuntimeError('Please specify a hip controller.') + # Check if we specified a valid hip controller + if not cmds.objExists(f'{options["namespace"]}:{options["hip_controller"]}'): + raise RuntimeError(f'Hip controller "{options["namespace"]}:{options["hip_controller"]}" does not exist.') + options['hip_controller'] = f'{options["namespace"]}:{options["hip_controller"]}' + + # apply_animation_exclusions + # Change the value to a boolean + options['apply_animation_exclusions'] = self.apply_animation_exclusions_editor.isChecked() + + # exclude_animation_from + if options['apply_animation_exclusions']: + _not_found = options['exclude_animation_from'].split(',') + _not_found = [x.strip() for x in _not_found] + _not_found = [f'{options["namespace"]}:{x}' for x in _not_found if + not cmds.objExists(f'{options["namespace"]}:{x}')] + + if _not_found: + print(f'Warning: Could not find the following controllers: {_not_found}') + + options['exclude_animation_from'] = options['exclude_animation_from'].split(',') + options['exclude_animation_from'] = [x.strip() for x in options['exclude_animation_from']] + options['exclude_animation_from'] = [f'{options["namespace"]}:{x}' for x in + options['exclude_animation_from'] if + x and cmds.objExists(f'{options["namespace"]}:{x}')] + else: + options['exclude_animation_from'] = [] + + print(f'Excluding animation from: {options["exclude_animation_from"]}') + + # exclude_reset_pose_from + _not_found = options['exclude_reset_pose_from'].split(',') + _not_found = [x.strip() for x in _not_found] + _not_found = [f'{options["namespace"]}:{x}' for x in _not_found if + not cmds.objExists(f'{options["namespace"]}:{x}')] + + if _not_found: + print(f'Warning: Could not find the following controllers: {_not_found}') + + options['exclude_reset_pose_from'] = options['exclude_reset_pose_from'].split(',') + options['exclude_reset_pose_from'] = [x.strip() for x in options['exclude_reset_pose_from']] + options['exclude_reset_pose_from'] = [f'{options["namespace"]}:{x}' for x in options['exclude_reset_pose_from'] + if + x and cmds.objExists(f'{options["namespace"]}:{x}')] + + print(f'Excluding reset pose from: {options["exclude_reset_pose_from"]}') + + options['cut_in'] = cut_in + options['cut_out'] = cut_out + options['prefix'] = prefix + + return options + + @common.error + def export(self): + """The main export function. + + """ + # Save the current settings + for k in DEFAULT_VALUES: + for _k, _v in DEFAULT_VALUES[k].items(): + editor = getattr(self, f'{_k}_editor') + if isinstance(editor, QtWidgets.QCheckBox): + self.settings.setValue(_k, editor.isChecked()) + if isinstance(editor, QtWidgets.QLineEdit): + self.settings.setValue(_k, editor.text()) + + options = self._get_options() + cut_in = options['cut_in'] + cut_out = options['cut_out'] + + # --- Start the export process --- + preroll = 51 + + # Save current selection + original_selection = cmds.ls(selection=True) + output_paths = [] + + # Clear selection + cmds.select(clear=True) + + # Let's make the export sets + try: + aka_make_export_sets.run() + except RuntimeError as e: + raise RuntimeError(f'Could not create export sets: {e}') + + # Add pre-roll to the timeline + cmds.playbackOptions( + animationStartTime=cut_in - preroll, minTime=-cut_in - preroll, animationEndTime=cut_out, maxTime=cut_out + ) + + # Move the current time to cut_in + cmds.currentTime(cut_in) + + self.statusChanged.emit('Exporting...', 'Clearing keyframes...') + + # Let's keyframe the controllers at cut_in and cut_out + for n in (cut_in, cut_out): + cmds.currentTime(n) + cmds.select(cmds.sets(options['controllers_set'], query=True), replace=True, ne=True) + cmds.setKeyframe( + cmds.sets( + options['controllers_set'], query=True + ), breakdown=False, preserveCurveShape=False, hierarchy='none', controlPoints=False, shape=True, ) + + # Remove any keyframes before between cut_in and cut_in - preroll - 1 + for node in cmds.sets(options['controllers_set'], query=True): + attrs = cmds.listAnimatable(node) + if not attrs: + continue + for attr in attrs: + if not cmds.keyframe(attr, query=True, time=(cut_in - preroll - 1, cut_in - 1)): + continue + cmds.cutKey(attr, time=(cut_in - preroll - 1, cut_in - 1)) + + # Move the current time to cut_in + cmds.currentTime(cut_in) + + # Studio Library: Save the full current animation + _dir = QtCore.QDir(options["studio_library_output_folder"]) + _dir.mkpath('.') + + self.statusChanged.emit('Exporting...', 'Exporting Studio Library clips...') + + try: + import studiolibrary + import mutils + except ImportError as e: + try: + add_studio_library_to_path() + except ImportError: + raise e + else: + import studiolibrary + import mutils + + try: + mutils.saveAnim( + cmds.sets( + options['controllers_set'], query=True + ), os.path.normpath( + f'{options["studio_library_output_folder"]}/' + f'{options["namespace"]}_fullanim.anim' + ), time=(cut_in, cut_out), bakeConnected=False, metadata='' + ) + p = os.path.normpath( + f'{options["studio_library_output_folder"]}/' + f'{options["namespace"]}_fullanim.anim' + ) + log.success(f'Studio Library clip saved to:\n{p}') + except UnicodeDecodeError as e: + # Might be a bug in the Studio Library module, seems like it can be ignored + print(e) + + # Studio Library: Save the animation start pose + try: + # Move the current time to cut_in + cmds.currentTime(cut_in) + + mutils.savePose( + os.path.normpath( + f'{options["studio_library_output_folder"]}/{options["namespace"]}_animstart.pose/pose.json' + ), cmds.sets( + options['controllers_set'], query=True + ), ) + p = os.path.normpath( + f'{options["studio_library_output_folder"]}/{options["namespace"]}_animstart.pose/pose.json' + ) + output_paths.append(p) + log.success(f'Studio Library pose saved to:\n{p}') + except UnicodeDecodeError as e: + print(e) + + self.statusChanged.emit('Exporting...', 'Baking hip animation...') + + # Parent and bake a null to the hip and save the world animation for later use + locator = cmds.spaceLocator(name='nullLocator')[0] + constraint = cmds.parentConstraint(options['hip_controller'], locator, maintainOffset=False)[0] + + cmds.bakeResults( + locator, + time=(cut_in,cut_out), + simulation=True, + sampleBy=1, oversamplingRate=1, disableImplicitControl=True, + preserveOutsideKeys=True, sparseAnimCurveBake=False, removeBakedAttributeFromLayer=False, + bakeOnOverrideLayer=False, minimizeRotation=True, controlPoints=False, shape=True + ) + + self.statusChanged.emit('Exporting...', 'Saving hip animation...') + + # Save the hip animation + try: + mutils.saveAnim( + [locator, ], os.path.normpath( + f'{options["studio_library_output_folder"]}/' + f'{options["namespace"]}_hip.anim' + ), time=(cut_in, cut_out), bakeConnected=False, metadata='' + ) + p = os.path.normpath( + f'{options["studio_library_output_folder"]}/' + f'{options["namespace"]}_hip.anim' + ) + output_paths.append(p) + log.success(f'Studio Library clip saved to:\n{p}') + except UnicodeDecodeError as e: + print(e) + + # Export the nullLocator to Alembic + self.statusChanged.emit('Exporting...', 'Saving nullLocator animation...') + p = get_cache_path('nullLocator', 'abc') + output_paths.append(p) + export_nullLocator_to_alembic(p, cut_in, cut_out) + + # Cleanup + cmds.delete(constraint) + cmds.delete(locator) + + # Remove all animation from any specified excluded controllers + if options['apply_animation_exclusions'] and options['exclude_animation_from']: + for node in options['exclude_animation_from']: + print(f'Removing animation from: {node}') + cmds.cutKey(node, clear=True) + + # Make sure the pose is unaltered at cut_in + cmds.currentTime(cut_in) + mutils.loadPose( + os.path.normpath( + f'{options["studio_library_output_folder"]}/{options["namespace"]}_animstart.pose/pose.json' + ), key=True + ) + + # Move the current time to cut_in - preroll + cmds.currentTime(cut_in - preroll) + + # Studio Library: Apply the reset pose at cut_in - preroll + # We only want to apply the reset pose to the controllers that are not excluded + _s1 = set(cmds.sets(options['controllers_set'], query=True)) + _s3 = set(options["exclude_reset_pose_from"]) + + mutils.loadPose( + os.path.normpath(options["studio_library_reset_pose"]), + objects=list(_s1 - _s3), + key=True, + namespaces=[options['namespace'], ] + ) + + # -- Cache exports -- + + self.statusChanged.emit('Caching...', 'Saving camera cache...') + + # Export the camera + if cmds.objExists('camera_export') and cmds.sets('camera_export', query=True): + p = get_cache_path('camera_export', 'ma') + output_paths.append(p) + export.export_maya( + p, cmds.sets('camera_export', query=True), cut_in, cut_out, step=1.0 + ) + log.success(f'Camera cache saved to:\n{p}') + + p = get_cache_path('camera_export', 'abc') + output_paths.append(p) + export.export_alembic( + p, cmds.sets('camera_export', query=True), cut_in, cut_out, + step=1.0 + ) + log.success(f'Camera cache saved to:\n{p}') + else: + print('Warning: "camera_export" not found, or empty. Skipping export.') + + # Export character caches + # In the case of namespaces suffixed by _01, we want to remove the suffix + # as the export sets are not suffixed by _01 (this does not apply to _02, _03, etc.) + if options['namespace'].endswith('_01'): + export_namespace = options['namespace'].replace('_01', '') + else: + export_namespace = options['namespace'] + + # Body + self.statusChanged.emit('Caching...', 'Saving body cache...') + + if cmds.objExists(f'{export_namespace}_body_export') and cmds.sets( + f'{export_namespace}_body_export', query=True + ): + p = get_cache_path(f'{export_namespace}_body_export', 'abc') + output_paths.append(p) + export.export_alembic( + p, cmds.sets(f'{export_namespace}_body_export', query=True), cut_in - preroll, cut_out, step=1.0 + ) + log.success(f'Body cache saved to:\n{p}') + else: + print(f'Warning: "{export_namespace}_body_export" not found, or empty. Skipping export.') + + # Cloth + self.statusChanged.emit('Caching...', 'Saving cloth cache...') + + if cmds.objExists(f'{export_namespace}_cloth_export') and cmds.sets( + f'{export_namespace}_cloth_export', query=True + ): + p = get_cache_path(f'{export_namespace}_cloth_export', 'abc') + output_paths.append(p) + export.export_alembic( + p, cmds.sets( + f'{export_namespace}_cloth_export', query=True + ), cut_in - preroll, cut_in - preroll, step=1.0 + ) + log.success(f'Cloth cache saved to:\n{p}') + else: + print(f'Warning: "{export_namespace}_cloth_export" not found, or empty. Skipping export.') + + # Extra + self.statusChanged.emit('Caching...', 'Saving extra cache...') + + if cmds.objExists(f'{export_namespace}_extra_export') and cmds.sets( + f'{export_namespace}_extra_export', query=True + ): + p = get_cache_path(f'{export_namespace}_extra_export', 'abc') + output_paths.append(p) + export.export_alembic( + p, cmds.sets(f'{export_namespace}_extra_export', query=True), cut_in - preroll, cut_out, step=1.0 + ) + log.success(f'Extra cache saved to:\n{p}') + + # -- Cleanup -- + + # Studio Library: Load back the full animation + mutils.loadAnims( + [os.path.normpath( + f'{options["studio_library_output_folder"]}/{options["namespace"]}_fullanim.anim' + ), ], objects=cmds.sets( + options['controllers_set'], query=True + ), currentTime=False, option='replaceCompletely', namespaces=[options['namespace'], ] + ) + + cmds.playbackOptions(animationStartTime=cut_in, minTime=cut_in, animationEndTime=cut_out, maxTime=cut_out) + + # Move the current time to cut_in + cmds.currentTime(cut_in) + + # Reset the selection + cmds.select(original_selection, replace=True) + + return output_paths + + @common.error + def apply_hip_pose(self): + self._apply_hip_pose() + common.show_message('Success', 'Hip pose applied successfully.', message_type='success') + + def _apply_hip_pose(self): + try: + import studiolibrary + import mutils + except ImportError as e: + try: + add_studio_library_to_path() + except ImportError: + raise e + else: + import studiolibrary + import mutils + + sel = cmds.ls(selection=True) + if not sel: + raise RuntimeError('Please select a single object.') + node = sel[0] + + cmds.select(clear=True) + + # make sure the selection is a transformable node (e.g. not a shape) + if not cmds.listRelatives(node, shapes=True): + raise RuntimeError('Please select a transformable node.') + + # Collect all the current settings to a dictionary + self.statusChanged.emit('Working...', 'Validating options, please wait') + + options = {} + for k in DEFAULT_VALUES: + for _k, _v in DEFAULT_VALUES[k].items(): + options[_k] = self.settings.value(_k, _v['default']) + + config = tokens.get(*common.active('root', args=True)) + seq, shot = common.get_sequence_and_shot(common.active('asset')) + + db = database.get(*common.active('root', args=True)) + cut_in = db.value(common.active('asset', path=True), 'cut_in', database.AssetTable) + cut_out = db.value(common.active('asset', path=True), 'cut_out', database.AssetTable) + prefix = db.value(common.active('root', path=True), 'prefix', database.BookmarkTable) + + # --- Validate values --- + if not all((seq, shot)): + raise RuntimeError('Could not find sequence and shot names.') + + # Check if none of the values are None + if any((x is None for x in (cut_in, cut_out, prefix))): + raise RuntimeError('Not all required values are set: We need cut_in, cut_out or prefix to be set.') + + if cut_out <= cut_in: + raise RuntimeError(f'Cut out is smaller than cut in: {cut_out} <= {cut_in}') + + # --- Validate the options --- + + # Get the default library from the Studio Library settings file + options['studio_library_folder'] = get_studio_library_default_library() + + if not options['namespace']: + raise RuntimeError('Please specify a namespace.') + + # studio_library_output_folder + if not options['studio_library_output_folder']: + raise RuntimeError('Please specify an output folder template.') + + _p = options['studio_library_folder'].replace('\\', '/') + __p = options['studio_library_output_folder'].replace('\\', '/') + __p = config.expand_tokens( + __p, + asset=common.active('asset'), + shot=shot, + sequence=seq, + prefix=prefix.split('_')[-1].upper(), + ).replace('\\', '/') + options['studio_library_output_folder'] = f'{_p}/{__p}' + + # Delete the locator if it exists + if cmds.objExists('nullLocator'): + cmds.delete('nullLocator') + + locator = cmds.spaceLocator(name='nullLocator')[0] + + if not QtCore.QFileInfo(f'{options["studio_library_output_folder"]}/{options["namespace"]}_hip.anim').exists(): + raise RuntimeError(f'Could not find hip animation: {options["studio_library_output_folder"]}/{options["namespace"]}_hip.anim') + + self.statusChanged.emit('Working...', 'Importing animation...') + + # Load the hip animation onto the locator + mutils.loadAnims( + [os.path.normpath( + f'{options["studio_library_output_folder"]}/{options["namespace"]}_hip.anim' + ), ], + objects=[locator,], + currentTime=False, + option='replaceCompletely' + ) + + # Move the current time to cut_in + cmds.currentTime(cut_in) + + # Reset node transforms + cmds.move(0, 0, 0, node, worldSpace=True) + cmds.rotate(0, 0, 0, node, worldSpace=True) + cmds.scale(1, 1, 1, node, worldSpace=True) + + # Parent constrain the selection to the locator + cmds.parentConstraint(locator, node, maintainOffset=True)[0] + def showEvent(self, event): + super().showEvent(event) + common.center_window(self) + + def sizeHint(self): + return QtCore.QSize(common.size(common.size_width), common.size(common.size_height)) + + +def run(): + global instance + if instance: + try: + instance.close() + instance.deleteLater() + instance = None + except: + pass + + instance = ExportCharacterCachesDialog() + instance.accepted.connect(lambda: common.source_model(common.FileTab).reset_data(force=True)) + actions.change_tab(common.FileTab) + actions.set_task_folder(tokens.get_folder(tokens.CacheFolder)) + + instance.open() + instance.raise_() diff --git a/bookmarks/maya/scripts/aka_odsy_dji_make_export_sets.py b/bookmarks/maya/scripts/aka_make_export_sets.py similarity index 81% rename from bookmarks/maya/scripts/aka_odsy_dji_make_export_sets.py rename to bookmarks/maya/scripts/aka_make_export_sets.py index c2a0f0a4d..ce7c91310 100644 --- a/bookmarks/maya/scripts/aka_odsy_dji_make_export_sets.py +++ b/bookmarks/maya/scripts/aka_make_export_sets.py @@ -1,3 +1,14 @@ +""" +This script creates sets for each export group in the scene. +Used in conjunction with Bookmarks to export character meshes for Houdini cloth simulation. + +Author: + Studio AKA, 2023 (c) All rights reserved. + https://www.studioaka.co.uk/ + Gergely Wootsch, + hello@gergely-wootsch.com + +""" import maya.cmds as cmds @@ -32,7 +43,6 @@ def create_set(set_name): def run(): - # Mapping of set names and their corresponding objects set_object_mapping = { 'IbogaineDJ_body_export': ['*DJ*:head_geo', '*DJ*:body_geo', '*DJ*:shoes_geo'], @@ -42,6 +52,10 @@ def run(): 'IbogaineMatty_cloth_export': ['*Matty*:cloth_geo', ], 'IbogaineMarcus_body_export': ['*Marcus*:head_geo', '*Marcus*:body_geo', '*Marcus*:shoes_geo'], 'IbogaineMarcus_cloth_export': ['*Marcus*:tshirt_geo', '*Marcus*:trousers_geo'], + 'IbogaineMarcus_extra_export': ['*Marcus*:eye_L', '*Marcus*:eye_R'], + 'IbogaineMarcus_body_mirrored_export': ['*Marcus*:head_geo_mirrored', '*Marcus*:body_geo_mirrored', '*Marcus*:shoes_geo_mirrored'], + 'IbogaineMarcus_cloth_mirrored_export': ['*Marcus*:tshirt_geo_mirrored', '*Marcus*:trousers_geo_mirrored'], + 'IbogaineMarcus_extra_mirrored_export': ['*Marcus*:eye_L_mirrored', '*Marcus*:eye_R_mirrored'], 'set_ground_export': ['set_ground_geo', ], 'set_background_export': ['set_background_geo', ], 'set_floor_export': ['set_floor_geo', ], @@ -49,6 +63,7 @@ def run(): } cmds.undoInfo(openChunk=True) + try: # Create each set and add its objects created_sets = [] diff --git a/bookmarks/maya/scripts/aka_odsy_dji_shaders.py b/bookmarks/maya/scripts/aka_shader_templates.py similarity index 99% rename from bookmarks/maya/scripts/aka_odsy_dji_shaders.py rename to bookmarks/maya/scripts/aka_shader_templates.py index 3e4806cbf..3d786eefb 100644 --- a/bookmarks/maya/scripts/aka_odsy_dji_shaders.py +++ b/bookmarks/maya/scripts/aka_shader_templates.py @@ -32,7 +32,7 @@ from PySide2 import QtCore, QtGui, QtWidgets -WINDOW_TITLE = 'AKA Odyssey Shaders' +WINDOW_TITLE = 'Aka Shader Templates' NUMERIC_TYPES = ['double', 'float', 'long', 'short', 'byte', 'int'] DEFAULT_PREFIX = 'main' diff --git a/bookmarks/maya/scripts/scripts.json b/bookmarks/maya/scripts/scripts.json index e2b510c6a..1baef0a90 100644 --- a/bookmarks/maya/scripts/scripts.json +++ b/bookmarks/maya/scripts/scripts.json @@ -1,17 +1,26 @@ { "0": { - "name": "AKA-ODSY-DJI | Shader Import/Export...", - "module": "aka_odsy_dji_shaders", + "name": "Studio Aka | Odyssey | Shader Import/Export...", + "module": "aka_shader_templates", "description": "Shader template import and export tool" }, "1": { - "name": "AKA-ODSY-DJI | Make EXPORT Sets", - "module": "aka_odsy_dji_make_export_sets", + "name": "Studio Aka | Odyssey | Make Export Sets", + "module": "aka_make_export_sets", "description": "" }, "2": { + "name": "Studio Aka | Odyssey | Export Character Caches", + "module": "aka_character_caches", + "needs_active": "asset", + "description": "Exports cloth caches for simulation" + }, + "3": { + "name": "separator" + }, + "4": { "name": "Reset joint orientations", "module": "reset_joint_orientations", - "description": "Resets bone orientation values too zero without moving the bones" + "description": "Resets bone orientation values to zero without moving the bones" } } \ No newline at end of file diff --git a/bookmarks/maya/shadertool.py b/bookmarks/maya/shadertool.py deleted file mode 100644 index 72dfe08b9..000000000 --- a/bookmarks/maya/shadertool.py +++ /dev/null @@ -1,805 +0,0 @@ -"""A Maya utility tool used to work with shaders. - -""" -import functools -import re - -import shiboken2 -from PySide2 import QtWidgets, QtCore, QtGui - -try: - from maya import OpenMayaUI - from maya import cmds - from maya import mel - from maya.api import OpenMaya - from maya.app.general import mayaMixin -except ImportError: - raise ImportError('Could not find the Maya modules.') - -from .. import common - - -def show(): - _ = mel - common.shaders_widget = ShadersWidget() - common.shaders_widget.show(dockable=True) - - -def add_row(parent): - widget = QtWidgets.QWidget(parent=parent) - widget.setSizePolicy( - QtWidgets.QSizePolicy.MinimumExpanding, - QtWidgets.QSizePolicy.Maximum, - ) - widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - widget.setAttribute(QtCore.Qt.WA_NoSystemBackground) - QtWidgets.QHBoxLayout(widget) - widget.layout().setContentsMargins(0, 0, 0, 0) - parent.layout().addWidget(widget, 0) - widget.layout().setAlignment(QtCore.Qt.AlignCenter) - return widget - - -def icon(name): - return QtGui.QIcon(QtGui.QPixmap(f':/{name}')) - - -@functools.lru_cache(maxsize=1048576) -def elided_text(v): - font = bold_font() - metrics = QtGui.QFontMetrics(font) - text = metrics.elidedText(v, QtCore.Qt.ElideLeft, 250) - return text - - -@functools.lru_cache(maxsize=1024) -def bold_font(): - font = QtGui.QFont() - font.setBold(True) - return font - - -@functools.lru_cache(maxsize=1048576) -def get_attrs(shader): - attrs = {} - for attr in cmds.listAttr(shader): - if '.' in attr: - continue - if cmds.attributeQuery(attr, node=shader, internal=True): - continue - if not cmds.attributeQuery(attr, node=shader, writable=True): - continue - if cmds.attributeQuery(attr, node=shader, listParent=True): - continue - _type = cmds.getAttr(f'{shader}.{attr}', type=True) - if _type not in ('float3', 'float'): - continue - if any(f in attr for f in ('Camera', 'Id', 'Matte', 'Direction')): - continue - attrs[attr] = _type - return attrs - - -class RenameDialog(QtWidgets.QDialog): - renameRequested = QtCore.Signal(str) - - def __init__(self, parent=None): - super().__init__(parent=parent) - QtWidgets.QVBoxLayout(self) - widget = add_row(self) - - self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - self.setWindowTitle('Rename Shader') - self.prefix_editor = QtWidgets.QLineEdit(parent=self) - self.prefix_editor.setValidator( - QtGui.QRegExpValidator(QtCore.QRegExp(r'[a-zA-Z][:a-zA-Z0-9]*')) - ) - self.prefix_editor.setPlaceholderText('Shader prefix...') - self.suffix_editor = QtWidgets.QLineEdit(parent=self) - self.suffix_editor.setValidator( - QtGui.QRegExpValidator(QtCore.QRegExp(r'[a-zA-Z][:a-zA-Z0-9]*')) - ) - self.suffix_editor.setPlaceholderText('Shader suffix...') - widget.layout().addWidget(self.prefix_editor, 0) - widget.layout().addWidget(self.suffix_editor, 0) - - widget = add_row(self) - self.ok_button = QtWidgets.QPushButton('Finish') - self.cancel_button = QtWidgets.QPushButton('Cancel') - widget.layout().addWidget(self.ok_button, 0) - widget.layout().addWidget(self.cancel_button, 0) - - self.ok_button.clicked.connect(self.action) - self.cancel_button.clicked.connect(self.close) - - def action(self): - prefix = self.prefix_editor.text() - suffix = self.suffix_editor.text() - if not all((prefix, suffix)): - OpenMaya.MGlobal.displayWarning(f'Prefix or suffix not set') - return - - shader = f'{prefix}_{suffix}_shader' - if cmds.objExists(shader): - OpenMaya.MGlobal.displayWarning(f'"{shader}" already exist.') - return - - self.renameRequested.emit(f'{prefix}_{suffix}') - self.done(QtWidgets.QDialog.Accepted) - - def sizeHint(self): - """Returns a size hint. - - """ - """Returns a size hint. - - """ - return QtCore.QSize(480, 60) - - -class ShaderModel(QtCore.QAbstractTableModel): - def __init__(self): - super().__init__() - self.internal_data = {} - - self.update_timer = QtCore.QTimer(parent=self) - self.update_timer.setInterval(333) - self.update_timer.setTimerType(QtCore.Qt.CoarseTimer) - self.update_timer.setSingleShot(True) - self.update_timer.timeout.connect(self.init_data) - - def rowCount(self, parent=QtCore.QModelIndex()): - return len(self.internal_data) - - def columnCount(self, parent=QtCore.QModelIndex()): - return 4 - - def flags(self, parent=QtCore.QModelIndex()): - return ( - QtCore.Qt.ItemIsEnabled | - QtCore.Qt.ItemIsSelectable - ) - - def index(self, row, column, parent=QtCore.QModelIndex()): - return self.createIndex(row, column, parent=parent) - - def data(self, index, role=QtCore.Qt.DisplayRole, parent=QtCore.QModelIndex()): - if role == QtCore.Qt.SizeHintRole: - if index.column() == 0: - metrics = QtGui.QFontMetrics(bold_font()) - width = metrics.horizontalAdvance( - self.data(index, role=QtCore.Qt.DisplayRole) - ) + 12 - return QtCore.QSize(width, 18) - else: - metrics = QtGui.QFontMetrics(QtGui.QFont()) - width = metrics.horizontalAdvance( - self.data(index, role=QtCore.Qt.DisplayRole) - ) + 12 - return QtCore.QSize(width, 18) - if role == QtCore.Qt.ToolTipRole: - v = self.internal_data[index.row()]['shapes'] - return '\n'.join(v) - if role == QtCore.Qt.TextAlignmentRole and index.column() != 0: - return QtCore.Qt.AlignCenter - if role == QtCore.Qt.FontRole and index.column() == 0: - return bold_font() - if not index.isValid(): - return None - if index.column() == 0 and role == QtCore.Qt.DisplayRole: - v = str(self.internal_data[index.row()][QtCore.Qt.DisplayRole]) - return elided_text(v) - if index.column() == 1 and role == QtCore.Qt.DisplayRole: - return str(self.internal_data[index.row()]['type']) - if index.column() == 2 and role == QtCore.Qt.DisplayRole: - v = self.internal_data[index.row()]['shapes'] - return f'{len(v)} shapes' if v else '-' - if index.column() == 3 and role == QtCore.Qt.DisplayRole: - v = self.internal_data[index.row()]['connections'] - return f'{len(v)} cnxs' if v else '-' - return None - - def headerData(self, column, orientation, role=QtCore.Qt.DisplayRole): - if role == QtCore.Qt.DisplayRole: - if orientation == QtCore.Qt.Horizontal: - if column == 0: - return 'Shader' - if column == 1: - return 'Type' - if column == 2: - return 'Meshes' - if column == 3: - return 'Connections' - return None - - def init_data(self): - """Initializes data. - - """ - self.beginResetModel() - - arnold_shaders = cmds.listNodeTypes( - 'rendernode/arnold/shader/surface' - ) - surface_shaders = ['standardSurface', ] - if arnold_shaders: - surface_shaders += arnold_shaders - - self.internal_data = {} - for node in cmds.ls(type='shadingEngine'): - for attr in cmds.listAttr(node): - if '.' in attr: - continue - if cmds.attributeQuery(attr, node=node, internal=True): - continue - if not cmds.attributeQuery(attr, node=node, writable=True): - continue - if cmds.attributeQuery(attr, node=node, listParent=True): - continue - _type = cmds.getAttr(f'{node}.{attr}', type=True) - if _type not in ('float3', 'float', 'Tdata'): - continue - if any(f in attr for f in ('Camera', 'Id', 'Matte', 'Direction')): - continue - - if cmds.connectionInfo(f'{node}.{attr}', isDestination=True): - source = cmds.connectionInfo( - f'{node}.{attr}', sourceFromDestination=True - ) - shader = source.split('.')[0] - - if cmds.objectType(shader) not in surface_shaders: - continue - - if not re.match(r'.*[a-zA-Z0-9]+_[a-zA-Z0-9]+_shader$', shader): - continue - - attrs = get_attrs(shader) - cnxs = [ - a for a in attrs if - cmds.connectionInfo(f'{shader}.{a}', id=True) - ] - - item_data = { - QtCore.Qt.DisplayRole: shader, - 'shader': shader, - 'type': cmds.objectType(shader), - 'shadingEngine': node, - 'attributes': attrs, - 'connections': cnxs, - 'shapes': [] - } - - nodes = cmds.sets(node, query=True) - nodes = nodes if nodes else [] - - for _node in nodes: - for n in cmds.ls(_node, long=True): - item_data['shapes'].append(n) - item_data['shapes'] = sorted(item_data['shapes']) - self.internal_data[len(self.internal_data)] = item_data - - self.endResetModel() - - -class ShaderView(QtWidgets.QTableView): - - def __init__(self, parent=None): - super().__init__(parent=parent) - - self.verticalHeader().setHidden(True) - self.verticalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Fixed) - self.verticalHeader().setDefaultSectionSize(18) - self.horizontalHeader().setSectionsMovable(False) - self.setSortingEnabled(False) - self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) - self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.setAutoFillBackground(True) - self.setModel(ShaderModel()) - - self.callbacks = [] - - self.activated.connect(self.item_activated) - self.selectionModel().selectionChanged.connect(self.selection_changed) - - self.model().modelReset.connect(self.resizeColumnsToContents) - self.model().modelAboutToBeReset.connect( - functools.partial(common.save_selection, self) - ) - self.model().modelReset.connect( - functools.partial(common.restore_selection, self) - ) - - @QtCore.Slot() - def select_shapes(self, *args, **kwargs): - if not self.selectionModel().hasSelection(): - return - index = next(f for f in self.selectionModel().selectedIndexes()) - if not index.isValid(): - return - shader = index.model().internal_data[index.row()]['shader'] - - cmds.hyperShade(objects=shader) - - @QtCore.Slot(QtCore.QModelIndex) - def selection_changed(self, index): - if not self.selectionModel().hasSelection(): - return - index = next(f for f in self.selectionModel().selectedIndexes()) - if not index.isValid(): - return - shader = self.model().internal_data[index.row()]['shader'] - cmds.select(shader, replace=True) - - @QtCore.Slot(QtCore.QModelIndex) - def item_activated(self, index): - data = index.model().internal_data - node = data[index.row()]['shadingEngine'] - shader = data[index.row()]['shader'] - cmds.select(node, replace=True) - cmds.select(shader, add=True) - cmds.HypershadeWindow() - melcmd = \ - 'hyperShadePanelGraphCommand("hyperShadePanel1", "showUpAndDownstream");' - 'evalDeferred -lp "hyperShadeRefreshActiveNode";' - cmd = f"from maya import mel;mel.eval('{melcmd}')" - cmds.evalDeferred(cmd) - - def init_callbacks(self): - if self.callbacks: - return - - cb = OpenMaya.MDGMessage.addNodeAddedCallback( - self.callback, - 'dependNode', - clientData='addNodeAddedCallback' - ) - self.callbacks.append(cb) - cb = OpenMaya.MDGMessage.addNodeRemovedCallback( - self.callback, - 'dependNode', - clientData='addNodeRemovedCallback' - ) - self.callbacks.append(cb) - cb = OpenMaya.MDGMessage.addConnectionCallback( - self.callback, - clientData='addNodeAddedCallback' - ) - self.callbacks.append(cb) - cb = OpenMaya.MDagMessage.addAllDagChangesCallback( - self.callback, - clientData='addAllDagChangesCallback' - ) - # self.callbacks.append(cb) - - def remove_callbacks(self): - for cb in self.callbacks: - OpenMaya.MMessage.removeCallback(cb) - self.callbacks = [] - - def callback(self, *args, clientData=None): - self.model().update_timer.start(self.model().update_timer.interval()) - - -class ShadersWidget(mayaMixin.MayaQWidgetDockableMixin, QtWidgets.QWidget): - def __init__(self): - - ptr = OpenMayaUI.MQtUtil.mainWindow() - if ptr: - parent = shiboken2.wrapInstance(int(ptr), QtWidgets.QMainWindow) - else: - parent = None - super().__init__(parent=parent) - common.set_stylesheet(self) - - self.setWindowTitle('Shader Setup Utility') - self.setWindowFlags( - QtCore.Qt.Dialog - ) - self.add_layer_button = None - self.active_layer_editor = None - self.show_layers_button = None - self.visible_layer_editor = None - self.search_editor = None - self.property_override_row = None - self.shader_override_row = None - self.prefix_editor = None - self.suffix_editor = None - self.type_picker = None - - self._create_ui() - self._connect_signals() - self.init_data() - - def _create_ui(self): - QtWidgets.QVBoxLayout(self) - o = 6 - self.layout().setContentsMargins(o, o, o, o) - - # ===== - group = QtWidgets.QGroupBox(parent=self) - QtWidgets.QVBoxLayout(group) - self.layout().addWidget(group, 0) - - # ===== - widget = add_row(group) - self.prefix_editor = QtWidgets.QLineEdit(parent=self) - self.prefix_editor.setValidator( - QtGui.QRegExpValidator(QtCore.QRegExp(r'[a-zA-Z][:a-zA-Z0-9]*')) - ) - self.prefix_editor.setPlaceholderText('Shader prefix...') - self.suffix_editor = QtWidgets.QLineEdit(parent=self) - self.suffix_editor.setValidator( - QtGui.QRegExpValidator(QtCore.QRegExp(r'[a-zA-Z][:a-zA-Z0-9]*')) - ) - self.suffix_editor.setPlaceholderText('Shader suffix...') - self.type_picker = QtWidgets.QComboBox(parent=self) - self.type_picker.setView(QtWidgets.QListView()) - widget.layout().addWidget(self.prefix_editor, 0) - widget.layout().addWidget(self.suffix_editor, 0) - widget.layout().addWidget(self.type_picker, 0) - - # Row9 - widget = add_row(group) - self.create_shader_button = QtWidgets.QPushButton( - '&Create Shader', parent=self - ) - self.rename_assigned_button = QtWidgets.QPushButton( - 'Rename Assigned', parent=self - ) - self.assign_shader_button = QtWidgets.QPushButton('Assign', parent=self) - widget.layout().addWidget(self.create_shader_button, 1) - widget.layout().addWidget(self.rename_assigned_button, 0) - widget.layout().addWidget(self.assign_shader_button, 0) - - group = QtWidgets.QGroupBox(parent=self) - QtWidgets.QVBoxLayout(group) - self.layout().addWidget(group, 1) - - # ===== - widget = add_row(group) - self.select_button = QtWidgets.QPushButton( - icon('edit'), '&Select Meshes' - ) - self.rename_button = QtWidgets.QPushButton( - icon('textBeam'), 'Re&name' - ) - self.duplicate_button = QtWidgets.QPushButton( - icon('shaderList'), '&Duplicate' - ) - self.refresh_button = QtWidgets.QPushButton( - icon('refresh'), 'Refresh' - ) - - widget.layout().addWidget(self.select_button, 0) - widget.layout().addWidget(self.rename_button, 0) - widget.layout().addWidget(self.duplicate_button, 0) - widget.layout().addWidget(self.refresh_button, 0) - widget.layout().addStretch(1) - - # ===== - self.shader_view = ShaderView(parent=self) - group.layout().addWidget(self.shader_view, 1) - - def _connect_signals(self): - self.refresh_button.clicked.connect( - self.shader_view.model().init_data - ) - self.refresh_button.clicked.connect( - self.init_data - ) - self.select_button.clicked.connect( - self.shader_view.select_shapes - ) - self.create_shader_button.clicked.connect( - self.create_shader_button_clicked - ) - self.duplicate_button.clicked.connect( - self.duplicate_button_clicked - ) - self.shader_view.selectionModel().selectionChanged.connect( - self.selection_changed - ) - self.rename_button.clicked.connect(self.rename_button_clicked) - self.assign_shader_button.clicked.connect(self.assign_shader_button_clicked) - self.rename_assigned_button.clicked.connect( - self.rename_assigned_button_clicked - ) - # Make sure the callbacks are removed - self.destroyed.connect(self.shader_view.remove_callbacks) - - @QtCore.Slot() - def rename_assigned_button_clicked(self): - v = self._selected_prefix_suffix() - if not v: - return - sel = cmds.ls(selection=True) - if not sel: - OpenMaya.MGlobal.displayWarning('Nothing is selected') - self.rename_assigned_nodes(f'{v[0]}_{v[1]}', sel) - - @QtCore.Slot() - def assign_shader_button_clicked(self): - if not self.shader_view.selectionModel().hasSelection(): - OpenMaya.MGlobal.displayWarning('No shader selection') - return - if not cmds.ls(selection=True): - OpenMaya.MGlobal.displayWarning('Nothing is selected') - return - - index = next( - f for f in self.shader_view.selectionModel().selectedIndexes() - ) - if not index.isValid(): - return - shader = index.model().internal_data[index.row()]['shader'] - cmds.hyperShade(assign=shader) - - @QtCore.Slot() - def rename_button_clicked(self): - v = self._selected_prefix_suffix() - if not v: - return - w = RenameDialog(parent=self) - w.prefix_editor.setText(v[0]) - w.suffix_editor.setText(v[1]) - old_name = f'{v[0]}_{v[1]}' - w.renameRequested.connect(functools.partial(self.rename, old_name)) - w.open() - - @QtCore.Slot(str) - @QtCore.Slot(str) - def rename(self, old_name, new_name): - if old_name == new_name: - OpenMaya.MGlobal.displayWarning(f'Old and new names are the same.') - return - - for node in cmds.ls(long=True): - if old_name not in node: - continue - - try: - _node = node.replace(old_name, new_name) - cmds.rename(node, _node) - OpenMaya.MGlobal.displayInfo(f'Renamed {node} > {_node}') - except: - OpenMaya.MGlobal.displayWarning(f'Could not rename {node}') - - self.shader_view.model().init_data() - - @QtCore.Slot(QtCore.QItemSelection) - def selection_changed(self, selection): - v = self._selected_prefix_suffix() - if not v: - return - index = next(f for f in self.shader_view.selectionModel().selectedIndexes()) - _type = index.model().internal_data[index.row()]['type'] - self.type_picker.setCurrentText(_type) - self.prefix_editor.setText(v[0]) - self.suffix_editor.setText(v[1]) - - def _selected_prefix_suffix(self): - if not self.shader_view.selectionModel().hasSelection(): - return None - index = next(f for f in self.shader_view.selectionModel().selectedIndexes()) - if not index.isValid(): - return None - name = index.data(role=QtCore.Qt.DisplayRole) - match = re.match( - r'([a-zA-Z0-9:]+?)_([a-zA-Z0-9]+?)_.*', - name - ) - if not match: - return None - return match.group(1), match.group(2) - - @QtCore.Slot() - def duplicate_button_clicked(self): - v = self._selected_prefix_suffix() - if not v: - return - w = RenameDialog(parent=self) - w.prefix_editor.setText(v[0]) - w.suffix_editor.setText(v[1]) - old_name = f'{v[0]}_{v[1]}' - w.renameRequested.connect(functools.partial(self.duplicate, old_name)) - w.open() - - @QtCore.Slot(str) - @QtCore.Slot(str) - def duplicate(self, old_name, new_name): - index = next(f for f in self.shader_view.selectionModel().selectedIndexes()) - if not index.isValid(): - return - sg = index.model().internal_data[index.row()]['shadingEngine'] - nodes = cmds.duplicate(sg, renameChildren=True, upstreamNodes=True) - - for node in nodes: - node = cmds.ls(node, long=True)[0] - if old_name not in node: - continue - try: - _node = node.replace(old_name, new_name) - _node = re.sub(r'[0-9]+$', '', _node) - if cmds.objExists(_node): - _node += 'Meshes' - cmds.rename(node, _node) - OpenMaya.MGlobal.displayInfo(f'Renamed {node} > {_node}') - except: - OpenMaya.MGlobal.displayWarning(f'Could not rename {node}') - - self.shader_view.model().init_data() - - @QtCore.Slot() - def init_data(self): - """Initializes data. - - """ - arnold_shaders = cmds.listNodeTypes( - 'rendernode/arnold/shader/surface' - ) - surface_shaders = ['standardSurface', ] - if arnold_shaders: - surface_shaders += arnold_shaders - self.type_picker.clear() - for item in surface_shaders: - self.type_picker.addItem(item) - - _types = cmds.listNodeTypes('rendernode/arnold/shader/volume') - _types = _types if _types else [] - for item in _types: - self.type_picker.addItem(item) - - @QtCore.Slot() - def create_shader_button_clicked(self): - sel = cmds.ls(selection=True) - sel = sel if sel else [] - rel = cmds.listRelatives( - sel, allDescendents=True, type='mesh', - path=True - ) - rel = rel if rel else [] - - prefix = self.prefix_editor.text() - suffix = self.suffix_editor.text() - - if not all((prefix, suffix)): - OpenMaya.MGlobal.displayWarning(f'Prefix or suffix not set') - return - - _type = self.type_picker.currentText() - if not _type: - return - - shader = f'{prefix}_{suffix}_shader' - if cmds.objExists(shader): - OpenMaya.MGlobal.displayWarning(f'"{shader}" already exist.') - return - cmds.shadingNode( - _type, - asShader=True, name=shader - ) - - material = f'{prefix}_{suffix}_material' - if cmds.objExists(material): - OpenMaya.MGlobal.displayWarning(f'"{material}" already exist.') - cmds.delete(shader) - return - cmds.sets( - name=material, - renderable=True, - noSurfaceShader=True, - empty=True - ) - cmds.connectAttr( - f'{shader}.outColor', - f'{material}.surfaceShader', - force=True - ) - - displacement = f'{prefix}_{suffix}_displacement' - if cmds.objExists(displacement): - OpenMaya.MGlobal.displayWarning(f'"{displacement}" already exist.') - else: - cmds.shadingNode( - 'displacementShader', - asShader=True, - name=displacement - ) - cmds.connectAttr( - f'{displacement}.displacement', - f'{material}.displacementShader', - force=True - ) - - # Rename selected elements - cmds.select(rel + sel, replace=True) - if rel + sel: - cmds.hyperShade(assign=shader) - if sel: - self.rename_assigned_nodes(f'{prefix}_{suffix}', sel) - - def rename_assigned_nodes(self, name, sel): - SUFFIXES = { - 'transform': '_t', - 'mesh': 'Shape', - 'nurbsSurface': 'Shape', - 'nurbsCurve': 'Crv', - 'bezierCurve': 'Crv', - 'locator': 'Loc', - } - - def _filter(lst): - """ Returns a filtered list accepting only the specified object types. - The resulting list is reverse sorted, to avoid missing object names when - renaming.""" - - lst = set(lst) - if not lst: - return [] - arr = [] - for i in lst: - for typ in SUFFIXES: - if cmds.objectType(i) == typ: - arr.append(i) - - arr.sort(key=lambda x: x.count('|')) - return arr[::-1] # reverse list - - _shapes = ['mesh', 'nurbsSurface'] - _f = [f for f in sel if cmds.objectType(f) == 'transform'] - f_ = [cmds.listRelatives(f, parent=True)[0] for f in sel if - cmds.objectType(f) in _shapes] - sel = _filter(_f + f_) - sel = sorted(sel) if sel else [] - - if not sel: - return [] - - if not name: - return [] - - for node in sel: - if name in node: - continue - - suffix = [SUFFIXES[f] for f in SUFFIXES if f == cmds.objectType(node)] - if not suffix: - continue - - suffix = suffix[0] - cmds.rename( - node, - f'{name}{suffix}#', - ignoreShape=False - ) - - def paintEvent(self, event): - """Event handler. - - """ - painter = QtGui.QPainter() - painter.begin(self) - painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(common.color(common.color_background)) - painter.drawRect(self.rect()) - painter.end() - - def showEvent(self, event): - """Event handler. - - """ - super().showEvent(event) - self.shader_view.model().init_data() - self.shader_view.init_callbacks() - - def hideEvent(self, event): - """Event handler. - - """ - super().hideEvent(event) - self.shader_view.remove_callbacks() - - def closeEvent(self, event): - super().closeEvent(event) - self.shader_view.remove_callbacks() diff --git a/bookmarks/progress.py b/bookmarks/progress.py index 54e4b17e7..c593b5f53 100644 --- a/bookmarks/progress.py +++ b/bookmarks/progress.py @@ -155,6 +155,16 @@ def paint(self, painter, option, index): t = common.FileItem _data = common.get_data(p, k, t) + + if not _data: + return + if source_index.row() not in _data: + return + if not _data[source_index.row()][common.AssetProgressRole]: + return + if index.column() - 1 not in _data[source_index.row()][common.AssetProgressRole]: + return + data = _data[source_index.row()][common.AssetProgressRole][index.column() - 1] right_edge = self._draw_background(painter, option, data) diff --git a/bookmarks/publish.py b/bookmarks/publish.py index 97e993295..54c05656c 100644 --- a/bookmarks/publish.py +++ b/bookmarks/publish.py @@ -8,7 +8,6 @@ import functools import json import os -import re import time import pyimageutil @@ -122,7 +121,7 @@ def get_payload(kwargs, destination): class TemplateModel(ui.AbstractListModel): - """Model used to list all available publish templates. + """Model used to list all available publish templates from the active bookmark item's token configuration. """ @@ -141,6 +140,16 @@ def init_data(self): template = common.settings.value('publish/template') for v in data[tokens.PublishConfig].values(): + if 'name' not in v: + print(f'Warning: Invalid publish template: {v}') + continue + if 'value' not in v: + print(f'Warning: Invalid publish template: {v}') + continue + if 'description' not in v: + print(f'Warning: Invalid publish template: {v}') + continue + if template == v['name']: pixmap = images.rsc_pixmap( 'check', common.color(common.color_green), common.size(common.size_margin) * 2 @@ -166,6 +175,8 @@ def init_data(self): class TemplateComboBox(QtWidgets.QComboBox): """Publish template picker. + The editor will list all relevant templates stored in the bookmark item database's token configuration. + """ def __init__(self, parent=None): @@ -174,82 +185,6 @@ def __init__(self, parent=None): self.setModel(TemplateModel()) -class TaskModel(ui.AbstractListModel): - def __init__(self, parent=None): - super(TaskModel, self).__init__(parent=parent) - - @common.error - @common.debug - def init_data(self): - """Initializes data. - - """ - self._data = {} - - k = common.active('asset', path=True) - if not k or not QtCore.QFileInfo(k).exists(): - return - - # Load the available task folders from the active bookmark item's `tokens`. - self._add_sub_folders(tokens.SceneFolder) - self._add_separator('Custom (click \'Add\' to add new)') - - def _add_separator(self, label): - self._data[len(self._data)] = { - QtCore.Qt.DisplayRole: label, - QtCore.Qt.DecorationRole: None, - QtCore.Qt.ForegroundRole: common.color(common.color_disabled_text), - QtCore.Qt.SizeHintRole: self.row_size, - QtCore.Qt.UserRole: None, - common.FlagsRole: QtCore.Qt.NoItemFlags - } - - def _add_sub_folders(self, token): - _icon = ui.get_icon('icon_bw', size=common.size(common.size_margin) * 2) - description = tokens.get_description(token) - for sub_folder in tokens.get_subfolders(token): - self._data[len(self._data)] = { - QtCore.Qt.DisplayRole: self.display_name(sub_folder), - QtCore.Qt.DecorationRole: _icon, - QtCore.Qt.ForegroundRole: common.color(common.color_text), - QtCore.Qt.SizeHintRole: self.row_size, - QtCore.Qt.StatusTipRole: description, - QtCore.Qt.AccessibleDescriptionRole: description, - QtCore.Qt.WhatsThisRole: description, - QtCore.Qt.ToolTipRole: description, - QtCore.Qt.UserRole: sub_folder, - } - - def add_item(self, task): - """Adds a new task item. - - """ - icon = ui.get_icon('icon_bw', size=common.size(common.size_margin) * 2) - - self.modelAboutToBeReset.emit() - self.beginResetModel() - - self._data[len(self._data)] = { - QtCore.Qt.DisplayRole: self.display_name(task), - QtCore.Qt.DecorationRole: icon, - QtCore.Qt.ForegroundRole: common.color(common.color_text), - QtCore.Qt.SizeHintRole: self.row_size, - QtCore.Qt.UserRole: task, - } - - self.endResetModel() - - -class TaskComboBox(QtWidgets.QComboBox): - """Task picker. - - """ - - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setView(QtWidgets.QListView()) - self.setModel(TaskModel()) - class PublishWidget(base.BasePropertyEditor): """Publishes a footage. @@ -258,16 +193,20 @@ class PublishWidget(base.BasePropertyEditor): #: UI layout definition sections = { 0: { - 'name': None, - 'icon': None, - 'color': common.color(common.color_dark_background), + 'name': 'Publish File', + 'icon': 'icon', + 'color': common.color(common.color_green), 'groups': { 0: { 0: { 'name': 'Source', 'key': 'source', 'validator': None, - 'widget': QtWidgets.QLabel, + 'widget': functools.partial( + ui.PaintedLabel, + '', + color=common.color(common.color_selected_text) + ), 'placeholder': '', 'description': 'Source file path', }, @@ -275,9 +214,13 @@ class PublishWidget(base.BasePropertyEditor): 'name': 'Destination', 'key': 'destination', 'validator': None, - 'widget': QtWidgets.QLabel, + 'widget': functools.partial( + ui.PaintedLabel, + '', + color=common.color(common.color_green) + ), 'placeholder': '', - 'description': 'Final output path', + 'description': 'The publish\'s destination path', }, }, }, @@ -285,7 +228,7 @@ class PublishWidget(base.BasePropertyEditor): 1: { 'name': 'Template', 'icon': None, - 'color': common.color(common.color_dark_background), + 'color': None, 'groups': { 0: { 0: { @@ -294,17 +237,13 @@ class PublishWidget(base.BasePropertyEditor): 'validator': None, 'widget': TemplateComboBox, 'placeholder': None, - 'description': 'Select the publish template', + 'description': 'Select a publish template', + 'help': 'Select a publish template from the list. The templates can be customized in the ' + 'bookmark item\'s property editor.', }, - 1: { - 'name': 'Task', - 'key': 'publish/task', - 'validator': None, - 'widget': TaskComboBox, - 'placeholder': None, - 'description': 'Select the publish template', - }, - 2: { + }, + 1: { + 0: { 'name': 'Description', 'key': 'description', 'validator': None, @@ -314,7 +253,7 @@ class PublishWidget(base.BasePropertyEditor): 'publish.\nIndicate significant changes and ' 'notes here.', }, - 3: { + 1: { 'name': 'Specify Element', 'key': 'element', 'validator': base.text_validator, @@ -327,7 +266,7 @@ class PublishWidget(base.BasePropertyEditor): }, }, 2: { - 'name': 'Settings', + 'name': 'Options', 'icon': None, 'color': common.color(common.color_dark_background), 'groups': { @@ -343,15 +282,15 @@ class PublishWidget(base.BasePropertyEditor): }, 1: { 0: { - 'name': 'Copy Path to Clipboard', + 'name': 'Copy publish path', 'key': 'publish/copy_path', 'validator': None, 'widget': functools.partial(QtWidgets.QCheckBox, 'Enable'), 'placeholder': None, - 'description': 'Copy the path to the clipboard after finish.', + 'description': 'Copy the publish path to the clipboard after finish.', }, 1: { - 'name': 'Reveal Publish', + 'name': 'Reveal files', 'key': 'publish/reveal', 'validator': None, 'widget': functools.partial(QtWidgets.QCheckBox, 'Enable'), @@ -380,6 +319,44 @@ def __init__(self, index, parent=None): self._connect_settings_save_signals(common.SECTIONS['publish']) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + self.source_editor.clicked.connect(self.source_editor_clicked) + self.destination_editor.clicked.connect(self.destination_editor_clicked) + + @QtCore.Slot() + def source_editor_clicked(self): + v = self.source_editor.text() + if not v: + return + v = common.get_sequence_start_path(v) + file_info = QtCore.QFileInfo(v) + if not file_info.exists(): + common.show_message( + 'Warning', + f'Publish source file does not exist:\n\n{v}' + ) + return + + actions.reveal(v) + + @QtCore.Slot() + def destination_editor_clicked(self): + text = self.destination_editor.text() + if not text: + return + + file_info = QtCore.QFileInfo(text) + _dir = file_info.dir() + if not _dir.exists(): + common.show_message( + 'Publish directory does not exist', + f'Publish has not yet been created:\n\n{_dir.path()}' + ) + return + + actions.reveal(_dir.path()) + def init_progress_bar(self): """Initializes the progress bar. @@ -412,59 +389,12 @@ def init_data(self): raise ValueError('Invalid index value.') self.load_saved_user_settings(common.SECTIONS['publish']) - self.guess_task_and_element() self.init_db_data() self.init_thumbnail() self.set_source_text() - self.update_timer.start() self.init_progress_bar() - def guess_task_and_element(self): - """Guess the task and element name from the input source file name. - - Publishing an element requires both the task and the element - to be specified. The user can set these manually but if we can suggest them - based on the input name we'll set them for the user. - - """ - kwargs = self.get_publish_kwargs() - - p = self._index.data(common.PathRole) - p = common.get_sequence_start_path(p) - - i = QtCore.QFileInfo(p) - - s = f'{i.dir().path()}/{i.baseName()}' - s = s.replace(kwargs['prefix'], '') # remove prefix - s = _strip(s) - s = re.sub(r'v[0-9]{1,9}', '', s) # remove version string - s = _strip(s) - s = s.replace(kwargs['asset'], '') # remove asset name - s = _strip(s) - s = s.replace(common.get_username(), '') # remove username - s = _strip(s) - - u = common.settings.value('file_saver/user') - u = u if u else '' - s = s.replace(u, '') - s = _strip(s) - - sub_dirs = tokens.get_subfolders(tokens.SceneFolder) - if sub_dirs: - task_candidates = [f for f in sub_dirs if f in s] - if len(task_candidates) == 1: - self.publish_task_editor.setCurrentText(task_candidates[0]) - s = s.replace(task_candidates[0], '') - s = _strip(s) - - if s and len(s) >= 3 and 'main' not in s and '_' not in s: - if common.show_message( - 'Publish', body=f'Found a possible element name. Is it correct?\n\n{s}', buttons=[common.YesButton, - common.NoButton], modal=True, ) == QtWidgets.QDialog.Rejected: - return - self.element_editor.setText(s) - def init_thumbnail(self): """Load the item's current thumbnail. @@ -483,19 +413,15 @@ def set_source_text(self): """Set source item label. """ - c = common.rgb(common.color_green) - n = self._index.data(common.PathRole) - - self.source_editor.setText( - f'{n}' - ) + self.source_editor.setText(self._index.data(common.PathRole)) @QtCore.Slot() def update_expanded_template(self): - """Slot connected to the update timer used to preview the output file name. + """Slot used update the source and destination editors. """ kwargs = self.get_publish_kwargs() + if not kwargs['publish_template']: return tokens.invalid_token @@ -550,7 +476,6 @@ def get_publish_kwargs(self, **_kwargs): kwargs['sq'] = kwargs['sequence'] kwargs['seq'] = kwargs['sequence'] - kwargs['task'] = self.publish_task_editor.currentData() kwargs['element'] = self.element_editor.text() kwargs['element'] = kwargs['element'] if kwargs['element'] else 'main' @@ -587,12 +512,10 @@ def save_changes(self): payload = get_payload(kwargs, destination) config = tokens.get(*common.active('root', args=True)) - data = config.data() - flag = next( - (v['filter'] for v in data[tokens.PublishConfig].values() if - v['value'] == kwargs['publish_template']), None - ) - exts = config.get_extensions(flag) + + flag = tokens.SceneFormat | tokens.ImageFormat | tokens.MovieFormat | tokens.AudioFormat | tokens.CacheFormat + exts = config.get_extensions(flag, force=True) + if kwargs['ext'] not in exts: raise RuntimeError( f'"{kwargs["ext"]}" is not a valid publish format.\n' @@ -602,8 +525,11 @@ def save_changes(self): self.prepare_publish(destination, payload=payload) self.copy_payload_files(payload=payload) self.save_thumbnail(destination, payload=payload) - jpegs = self.make_jpegs(payload=payload) - self.make_videos(destination, jpegs, payload=payload) + + # TODO: We can't implement this without more user control! + # jpegs = self.make_jpegs(payload=payload) + # self.make_videos(destination, jpegs, payload=payload) + self.write_manifest(destination, payload=payload) self.post_publish(destination, kwargs) return True @@ -739,6 +665,8 @@ def make_jpegs(self, payload): f = f'{folder}/jpg/{name}.jpg' files.append(f) buf.write(f) + + images.ImageCache.flush(destination) images.ImageCache.flush(f) payload['jpgs'] = files @@ -848,3 +776,70 @@ def post_publish(self, destination, kwargs): actions.reveal(QtCore.QFileInfo(destination).dir().path()) if kwargs['publish_copy_path']: actions.copy_path(destination) + + # Get the source's description from the database + index = self._index + if not index.isValid(): + return + + path = index.data(common.PathRole) + if kwargs['is_collapsed']: + path = common.proxy_path(path) + + db = database.get(*common.active('root', args=True)) + description = db.value(path, 'description', database.AssetTable) + description = description if description else '' + + if '#published' not in description: + description += f' #published' + db.set_value(path, 'description', description, database.AssetTable) + + # Add a note to the database + note = ( + f'\nTime: {time.strftime("%d/%m/%Y %H:%M:%S")}' + f'\nUser: {common.get_username()}' + f'\nDestination:\n{QtCore.QFileInfo(destination).dir().path()}' + ) + notes = db.value(path, 'notes', database.AssetTable) + notes = notes if notes else {} + notes[len(notes)] = { + 'title': 'Publish Log (server)', + 'body': note, + 'extra_data': { + 'created_by': common.get_username(), + 'created_at': time.strftime('%d/%m/%Y %H:%M:%S'), + 'fold': False, + } + } + db.set_value(path, 'notes', notes, database.AssetTable) + + def showEvent(self, event): + """Called when the widget is shown. + + Args: + event (QtCore.QEvent): The event that triggered this slot. + + """ + super().showEvent(event) + self.update_timer.start() + + def hideEvent(self, event): + """Called when the widget is hidden. + + Args: + event (QtCore.QEvent): The event that triggered this slot. + + """ + super().hideEvent(event) + self.update_timer.stop() + + def closeEvent(self, event): + """Called when the widget is closed. + + Args: + event (QtCore.QEvent): The event that triggered this slot. + + """ + super().closeEvent(event) + self.update_timer.stop() + close() \ No newline at end of file diff --git a/bookmarks/rsc/config.json b/bookmarks/rsc/config.json index 06ae0b9c2..eb012955b 100644 --- a/bookmarks/rsc/config.json +++ b/bookmarks/rsc/config.json @@ -3,6 +3,8 @@ "github_url": "https://github.com/wgergely/bookmarks", "env_key": "BOOKMARKS_ROOT", "product": "bookmarks", + "organization": "bookmarks", + "organization_domain": "bookmarks-vfx.com", "link_file": ".links", "bookmark_cache_dir": ".bookmark", "bookmark_database": "bookmark.db", @@ -31,8 +33,6 @@ "size_font_medium": 12.0, "size_font_large": 16.0, "size_row_height": 34.0, - "size_bookmark_row_height": 52.0, - "size_asset_row_height": 64.0, "size_separator": 1.0, "size_section": 86.0, "size_margin": 18.0, diff --git a/bookmarks/rsc/gui/arrow_left.png b/bookmarks/rsc/gui/arrow_left.png new file mode 100644 index 000000000..a8163b1d7 Binary files /dev/null and b/bookmarks/rsc/gui/arrow_left.png differ diff --git a/bookmarks/rsc/gui/arrow_right.png b/bookmarks/rsc/gui/arrow_right.png new file mode 100644 index 000000000..589dddfed Binary files /dev/null and b/bookmarks/rsc/gui/arrow_right.png differ diff --git a/bookmarks/rsc/gui/branch_closed.png b/bookmarks/rsc/gui/branch_closed.png index 7d219db63..62f8ce918 100644 Binary files a/bookmarks/rsc/gui/branch_closed.png and b/bookmarks/rsc/gui/branch_closed.png differ diff --git a/bookmarks/rsc/gui/branch_open.png b/bookmarks/rsc/gui/branch_open.png index 64a166dac..b76a26bd1 100644 Binary files a/bookmarks/rsc/gui/branch_open.png and b/bookmarks/rsc/gui/branch_open.png differ diff --git a/bookmarks/rsc/stylesheet.qss b/bookmarks/rsc/stylesheet.qss index a54ad6855..bdfea3f74 100644 --- a/bookmarks/rsc/stylesheet.qss +++ b/bookmarks/rsc/stylesheet.qss @@ -185,10 +185,8 @@ QComboBox QListView::item:hover {{ QLineEdit, -QLineEdit:disabled, -QTextEdit, -QTextEdit:disabled {{ - background-color: {color_opaque}; +QTextEdit {{ + background-color: transparent; height: {size_row_height2}px; max-height: {size_row_height2}px; min-height: {size_row_height2}px; @@ -201,7 +199,12 @@ QTextEdit:disabled {{ QTextEdit:disabled, QLineEdit:disabled {{ color: {color_disabled_text}; - background-color: {color_dark_background}; + background-color: transparent; +}} + +QLineEdit:focus, +QTextEdit:focus {{ + background-color: {color_opaque}; }} QTextBrowser {{ @@ -232,6 +235,8 @@ QScrollBar::sub-line {{ QTableWidget, QTableView, +QTreeWidget, +QTreeView, QListWidget, QListView {{ border-radius: {size_indicator1}px; @@ -239,6 +244,8 @@ QListView {{ }} QTableWidget::item, QTableView::item, +QTreeWidget::item, +QTreeView::item, QListWidget::item, QListView::item {{ outline: 0; @@ -248,24 +255,32 @@ QListView::item {{ }} QTableWidget::item:hover, QTableView::item:hover, +QTreeWidget::item:hover, +QTreeView::item:hover, QListWidget::item:hover, QListView::item:hover {{ background: {color_light_background}; }} QTableWidget::item:selected, QTableView::item:selected, +QTreeWidget::item:selected, +QTreeView::item:selected, QListWidget::item:selected, QListView::item:selected {{ background: {color_light_background}; }} QTableWidget::item:selected:hover, QTableView::item:selected:hover, +QTreeWidget::item:selected:hover, +QTreeView::item:selected:hover, QListWidget::item:selected:hover, QListView::item:selected:hover {{ background: {color_light_background}; }} QTableWidget::item:disabled, QTableView::item:disabled, +QTreeWidget::item:disabled, +QTreeView::item:disabled, QListWidget::item:disabled, QListView::item:disabled {{ color: {color_disabled_text}; @@ -273,6 +288,8 @@ QListView::item:disabled {{ }} QTableWidget::indicator, QListWidget::indicator, +QTreeWidget::indicator, +QTreeView::indicator, QListView::indicator {{ width: {size_font_medium}px; height: {size_font_medium}px; @@ -303,78 +320,60 @@ TableWidget::item {{ }} QHeaderView {{ - font-family: "{font_primary}"; + font-family: "{font_secondary}"; + font-size: {size_font_small}px; border: none; - background-color: rgba(0,0,10,30); + background-color: {color_background}; border-top-left-radius: {size_indicator}px; border-top-right-radius: {size_indicator}px; - border-bottom: 1px solid rgba(0,0,0,50); }} QHeaderView::section {{ - font-family: "{font_primary}"; - font-size: {size_font_small}px; - background-color: transparent; - alignment: center; - text-align: center; - color: rgba(170,170,175,255); + padding-left: {size_margin}px; + background-color: {color_background}; + color: {color_secondary_text}; border: none; - border-right: 1px solid rgba(0,0,0,50); -}} -QHeaderView::down-arrow {{ - image: none; + border-right: {size_separator}px solid {color_dark_background}; }} +QHeaderView::section:down-arrow, QHeaderView::up-arrow {{ image: none; }} -QTreeWidget, -QTreeView {{ - font-size: {size_font_medium}px; - background-color: {color_dark_background}; - border: none; -}} -QTreeWidget::item, -QTreeView::item {{ - border: none; -}} -QTreeWidget::item:hover, -QTreeView::item:hover {{ - background: {color_background}; -}} -QTreeWidget::item:focus, -QTreeView::item:focus {{ - background: transparent; -}} -QTreeWidget::item:selected, -QTreeView::item:selected {{ - background: {color_light_background}; -}} -QTreeWidget:branch -QTreeView:branch {{ - background-color: {color_dark_background}; +QTreeWidget::branch +QTreeView::branch, +QTreeWidget::branch:open, +QTreeView::branch:open, +QTreeWidget::branch:closed, +QTreeView::branch:closed, +QTreeWidget::branch:has-sibling, +QTreeView::branch:has-sibling +QTreeWidget::branch:has-children, +QTreeView::branch:has-children {{ border: none; border-image: none; + background: transparent; }} -QTreeWidget::branch:focus, -QTreeView::branch:focus {{ - background: transparent; -}} -QTreeWidget:branch:selected, -QTreeView:branch:selected {{ - background: {color_light_background}; + +QTreeWidget::branch:closed:has-children, +QTreeView::branch:closed:has-children {{ + size: {size_margin}px; + image: url("{branch_closed}"); }} -QTreeWidget:branch:!open:has-children, -QTreeView:branch:!open:has-children {{ - image: url("{branch_closed}"); + +QTreeWidget::branch:closed:!has-children, +QTreeView::branch:closed:!has-children {{ + image: none; }} -QTreeWidget:branch:open:has-children:!has-siblings, -QTreeWidget:branch:open:has-children:has-siblings, -QTreeView:branch:open:has-children:!has-siblings, -QTreeView:branch:open:has-children:has-siblings {{ - image: url("{branch_open}"); + +QTreeWidget::branch:open:has-children:!has-siblings, +QTreeWidget::branch:open:has-children:has-siblings, +QTreeView::branch:open:has-children:!has-siblings, +QTreeView::branch:open:has-children:has-siblings {{ + image: url("{branch_open}"); }} + QLabel {{ font-size: {size_font_medium}px; background-color: transparent; @@ -651,10 +650,21 @@ QWidget#mainRow {{ }} -GalleryWidget QWidget:focus {{ - border: {size_separator}px solid transparent; +GalleryWidget {{ + border: {size_separator}px solid {color_blue}; +}} + +GalleryWidget, +GalleryWidget > QScrollArea, +GalleryWidget QScrollArea, +GalleryWidget QScrollArea > QWidget, +GalleryWidget QScrollArea > QWidget > QWidget +{{ + background-color: {color_dark_background}; }} + + Viewer, ImageViewer {{ background-color: rgba(20,20,20,250); diff --git a/bookmarks/scripts/clips.py b/bookmarks/scripts/clips.py index a6b478b11..e42c6ca33 100644 --- a/bookmarks/scripts/clips.py +++ b/bookmarks/scripts/clips.py @@ -2,7 +2,6 @@ """ import functools import os -import re import opentimelineio as otio from PySide2 import QtWidgets, QtCore @@ -10,6 +9,7 @@ from .. import actions from .. import common from .. import database +from .. import log from .. import ui from ..editor import base from ..external import rv @@ -712,11 +712,11 @@ def _get_sources(path, ext): asset_data[k] = db.value(db.source(), k, database.BookmarkTable) common.message_widget.title_label.setText(f'Processing {asset}...') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) for k in DEFAULT_SOURCES: if not hasattr(self, f'{k.replace("/", "_")}_editor'): - print(f'No editor found for {k}, skipping.') + log.debug(f'No editor found for {k}, skipping.') continue editor = getattr(self, f'{k.replace("/", "_")}_editor') @@ -730,11 +730,11 @@ def _get_sources(path, ext): source_ext = source_info.suffix() if not QtCore.QFileInfo(source_dir).exists(): - print(f'{source_dir} does not exist, skipping.') + log.debug(f'{source_dir} does not exist, skipping.') continue common.message_widget.body_label.setText(f'Found source dir:\n{source_dir}') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) # Get the source files for source in _get_sources(source_dir, source_ext): diff --git a/bookmarks/scripts/render_layers_to_ae.py b/bookmarks/scripts/render_layers_to_ae.py deleted file mode 100644 index a431c23a3..000000000 --- a/bookmarks/scripts/render_layers_to_ae.py +++ /dev/null @@ -1,167 +0,0 @@ -import os -import re - -from PySide2 import QtCore - -from .. import common -from .. import database -from ..tokens import tokens - -RENDER_NAME_TEMPLATE = '///__' -pattern = r'{source_dir}/(.+)/(.+)/(.+)/.+\_\d+.exr$' - - -def recursive_parse(path): - - for entry in os.scandir(path): - if entry.is_dir(): - yield from recursive_parse(entry.path) - continue - - if '_broken' in entry.name: - continue - if os.path.splitext(entry.name)[-1] != '.exr': - continue - - yield entry.path.replace('\\', '/') - - -def get_footage_sources(): - source_dir = common.active('task', path=True) - if not source_dir: - raise RuntimeError('No active task') - if not QtCore.QFileInfo(source_dir).isDir(): - raise RuntimeError('Active task is not a directory') - - db = database.get(*common.active('root', args=True)) - framerate = db.value(db.source(), 'framerate', database.BookmarkTable) - - data = {} - for path in recursive_parse(source_dir): - seq = common.get_sequence(path) - if not seq: - continue - - match = re.match(pattern.format(source_dir=source_dir), path, re.IGNORECASE) - if not match: - continue - - k = f'{seq.group(1).strip("/_-.")}{seq.group(3).strip("/_-.")}'.split('/')[-1] - if k not in data: - data[k] = { - 'name': f'{match.group(1)} -> {match.group(3)} ({match.group(2)})', - 'files': [], - 'layer': match.group(1), - 'version': match.group(2), - 'pass': match.group(3), - 'framerate': framerate, - } - - data[k]['files'].append(path) - - return data - - -def generate_jsx_script(footage_sources): - db = database.get(*common.active('root', args=True)) - - # Look for framerate and resolution in the bookmark... - bookmark_framerate = db.value(db.source(), 'framerate', database.BookmarkTable) - bookmark_width = db.value(db.source(), 'width', database.BookmarkTable) - bookmark_height = db.value(db.source(), 'height', database.BookmarkTable) - - # ...and if not found, look for it in the asset - asset_framerate = db.value(common.active('asset', path=True), 'asset_framerate', database.AssetTable) - asset_width = db.value(common.active('asset', path=True), 'asset_width', database.AssetTable) - asset_height = db.value(common.active('asset', path=True), 'asset_height', database.AssetTable) - - # If still not found, use default values - framerate = asset_framerate or bookmark_framerate or 25 - width = asset_width or bookmark_width or 1920 - height = asset_height or bookmark_height or 1080 - - cut_in = db.value(common.active('asset', path=True), 'cut_in', database.AssetTable) - cut_out = db.value(common.active('asset', path=True), 'cut_out', database.AssetTable) - - config = tokens.get(*common.active('root', args=True), force=True) - - # TODO: 'asset1' is hardcoded here, but it should correspond to the asset name - # ({asset1} in the Studio Aka SG pipeline, for example) - comp_name = config.expand_tokens('{asset1}_comp', asset=common.active('asset')) - - jsx_script = "" - - # Begin ExtendScript - jsx_script += """ -var project = app.project; - -// Check if the "cgi" folder already exists -var cgi_folder = null; -for (var i = 1; i <= project.numItems; i++) { - if (project.item(i) instanceof FolderItem && project.item(i).name == 'cgi') { - cgi_folder = project.item(i); - break; - } -} -// If the "cgi" folder does not exist, create it -if (!cgi_folder) { - cgi_folder = project.items.addFolder('cgi'); -} - -function importFootage(path, framerate, name) { - // Check if the footage item already exists - for (var i = 1; i <= project.numItems; i++) { - if (project.item(i) instanceof FootageItem && project.item(i).name == name) { - return; // Footage item already exists, skip importing - } - } - var importOptions = new ImportOptions(File(path)); - importOptions.sequence = true; - var footageItem = project.importFile(importOptions); - footageItem.name = name; - footageItem.mainSource.conformFrameRate = framerate; - footageItem.parentFolder = cgi_folder; - return footageItem; -} - -app.beginUndoGroup('Import Footage'); - """ - - # Iterate over footage sources and add to the ExtendScript - for name, data in footage_sources.items(): - # Sort the files to get the first one - files = sorted(data['files']) - first_file = files[0] - jsx_script += f'var footageItem = importFootage("{first_file}", {data["framerate"]}, "{data["name"]}");\n' - - # Create new composition if it doesn't exist yet - jsx_script += f""" -var comp = null; -for (var i = 1; i <= project.numItems; i++) {{ - if (project.item(i) instanceof CompItem && project.item(i).name == '{comp_name}') {{ - comp = project.item(i); - break; - }} -}} -if (!comp) {{ - comp = project.items.addComp('{comp_name}', {width}, {height}, 1, ({cut_out}-{cut_in})/{framerate}, {framerate}); - comp.layers.add(footageItem); -}} -comp.workAreaStart = {cut_in}/{framerate}; - """ - - # Close the undo group - jsx_script += "app.endUndoGroup();\n" - - return jsx_script - - -def run(): - data = get_footage_sources() - with open(f'{common.active("task", path=True)}/import_footage.jsx', 'w') as f: - f.write(generate_jsx_script(data)) - common.show_message( - 'Render Layers to After Effects', - body=f'Import script generated successfully and saved to:\n{common.active("task", path=True)}/import_footage.jsx', - message_type='success', - ) \ No newline at end of file diff --git a/bookmarks/scripts/scripts.json b/bookmarks/scripts/scripts.json index 1ca033f9d..38d5cd5c6 100644 --- a/bookmarks/scripts/scripts.json +++ b/bookmarks/scripts/scripts.json @@ -1,23 +1,27 @@ { "0": { - "name": "SG Sync (experimental)", - "module": "sg_sync", - "description": "Syncs editorial data with ShotGrid and server assets", + "name": "Synchronize Asset and Entity Data", + "module": "sync_asset_data", + "description": "Synchronizes bookmark asset items and ShotGrid entities with a source data file.", "needs_active": "root", - "icon": "sg" + "icon": "spinner" }, "1": { - "name": "Auto-o-matic", + "name": "Preview and Save Playlists", "module": "clips", "description": "View clips and export them to editorial", "needs_active": "root", - "icon": "sg" + "icon": "image" }, "2": { - "name": "Export renders to After Effects", - "module": "render_layers_to_ae", - "description": "View clips and export them to editorial", + "name": "separator" + }, + "3": { + "name": "Send Images to After Effects", + "module": "send_images_to_after_effects", + "description": "Send image sequences to After Effects", "needs_active": "task", - "icon": "file" + "needs_application": "after effects", + "icon": "add_file" } } \ No newline at end of file diff --git a/bookmarks/scripts/send_images_to_after_effects.py b/bookmarks/scripts/send_images_to_after_effects.py new file mode 100644 index 000000000..88c882450 --- /dev/null +++ b/bookmarks/scripts/send_images_to_after_effects.py @@ -0,0 +1,272 @@ +""" +This is a utility script to send image sequences to After Effects rendered with the Maya. + +The script expects the images sequences to use the Maya specified render template format. +Any valid image sequences will be included in an ExtendScript script that will be sent to +After Effects. + +""" +import os +import re + +from PySide2 import QtCore + +from .. import actions +from .. import common +from .. import database +from ..tokens import tokens + +#: The render name template used by Maya +RENDER_NAME_TEMPLATE = '///__' +#: The pattern used to match the render name template +pattern = r'{source_dir}/(.+)/(.+)/(.+)/.+\.[a-z]{{2,4}}$' + + +def items_it(): + """Yield a list of paths of the currently visible file items. + + """ + if not common.widget(): + return + + if not common.active('task'): + raise RuntimeError('An active task folder must be set to export items.') + + model = common.model(common.FileTab) + for idx in range(model.rowCount()): + index = model.index(idx, 0) + if not index.isValid(): + continue + + # Skip broken render images (RoyalRender) + if '_broken__' in index.data(QtCore.Qt.DisplayRole): + continue + + path = index.data(common.PathRole) + if not path: + continue + yield index + + +def get_footage_sources(): + """Traverse over the active task folder's subdirectories and return a dictionary of image sequences. + + """ + source_dir = common.active('task', path=True) + if not source_dir: + raise RuntimeError('Must have an active task folder selected!') + + db = database.get(*common.active('root', args=True)) + framerate = db.value(db.source(), 'framerate', database.BookmarkTable) + + data = {} + _pattern = pattern.format(source_dir=source_dir) + + for index in items_it(): + seq = index.data(common.SequenceRole) + path = index.data(common.PathRole) + + if not seq: + print(f'Skipping non-sequence item:\n{path}') + continue + + match = re.match(_pattern, path, re.IGNORECASE) + if not match: + print(f'Skipping malformed sequence:\n{path}') + continue + + k = f'{seq.group(1).strip("/_-.")}{seq.group(3).strip("/_-.")}'.split('/')[-1] + if k not in data: + print(f'Found sequence:\n{path}') + data[k] = { + 'name': f'{match.group(1)} -> {match.group(3)} ({match.group(2)})', + 'files': [], + 'layer': match.group(1), + 'version': match.group(2), + 'pass': match.group(3), + 'framerate': framerate, + } + + data[k]['files'].append(common.get_sequence_start_path(path)) + + return data + + +def generate_jsx_script(footage_sources): + db = database.get(*common.active('root', args=True)) + + # Look for framerate and resolution in the bookmark... + bookmark_framerate = db.value(db.source(), 'framerate', database.BookmarkTable) + bookmark_width = db.value(db.source(), 'width', database.BookmarkTable) + bookmark_height = db.value(db.source(), 'height', database.BookmarkTable) + + # ...and if not found, look for it in the asset + asset_framerate = db.value(common.active('asset', path=True), 'asset_framerate', database.AssetTable) + asset_width = db.value(common.active('asset', path=True), 'asset_width', database.AssetTable) + asset_height = db.value(common.active('asset', path=True), 'asset_height', database.AssetTable) + + # If still not found, use default values + framerate = asset_framerate or bookmark_framerate or 25 + width = asset_width or bookmark_width or 1920 + height = asset_height or bookmark_height or 1080 + + cut_in = db.value(common.active('asset', path=True), 'cut_in', database.AssetTable) + cut_out = db.value(common.active('asset', path=True), 'cut_out', database.AssetTable) + + config = tokens.get(*common.active('root', args=True), force=True) + + # TODO: 'asset1' is hardcoded here, but it should correspond to the asset name + # ({asset1} in the Studio Aka SG pipeline, for example) + comp_name = config.expand_tokens('{asset1}_comp', asset=common.active('asset')) + + jsx_script = "" + + # Begin ExtendScript + jsx_script += """ +var project = app.project; + +// Check if the "cgi" folder already exists +var cgi_folder = null; +for (var i = 1; i <= project.numItems; i++) { + if (project.item(i) instanceof FolderItem && project.item(i).name == 'cgi') { + cgi_folder = project.item(i); + break; + } +} +// If the "cgi" folder does not exist, create it +if (!cgi_folder) { + cgi_folder = project.items.addFolder('cgi'); +} + +function importFootage(path, framerate, name) { + var existingFootageItem = null; + + // Check if the footage item already exists + for (var i = 1; i <= app.project.numItems; i++) { + var currentItem = app.project.item(i); + if (currentItem instanceof FootageItem && currentItem.name == name) { + existingFootageItem = currentItem; + break; + } + } + + var io = new ImportOptions(); + io.file = File(path); + io.sequence = true; + + if (io.canImportAs(ImportAsType.FOOTAGE)) { + io.importAs = ImportAsType.FOOTAGE; + } else { + alert('Could not import footage as a sequence. Please check the file path and try again.'); + return; + } + + // If the footage exists and its source differs from the intended source + if (existingFootageItem && existingFootageItem.mainSource.file.path != path) { + var newFootage = app.project.importFile(io); + + existingFootageItem.replaceWithSequence(newFootage.mainSource.file, true); + existingFootageItem.mainSource.conformFrameRate = framerate; + + // Clean up by removing the temporarily imported footage + newFootage.remove(); + + } else if (!existingFootageItem) { // If footage doesn't exist + + var footageItem = app.project.importFile(io); + footageItem.name = name; + footageItem.mainSource.conformFrameRate = framerate; + footageItem.parentFolder = cgi_folder; + return footageItem; + } +} + +app.beginUndoGroup('Import Footage');""" + + # Create new composition if it doesn't exist yet + jsx_script += f""" +var comp = null; +for (var i = 1; i <= project.numItems; i++) {{ + if (project.item(i) instanceof CompItem && project.item(i).name == '{comp_name}') {{ + comp = project.item(i); + // Apply the cut_out and cut_in values to the composition + break; + }} +}} +if (!comp) {{ + comp = project.items.addComp('{comp_name}', {width}, {height}, 1, ({cut_out}-{cut_in})/{framerate}, {framerate}); +}} + +// Apply the cut out and cut in values to the composition +comp.duration = ({cut_out}-{cut_in}+1)/{framerate}; +comp.displayStartFrame = {cut_in}; +comp.workAreaStart = 0; +comp.workAreaDuration = comp.duration; + +""" + + # Iterate over footage sources and add to the ExtendScript + for name, data in footage_sources.items(): + # Sort the files to get the first one + files = sorted(data['files']) + first_file = files[0] + jsx_script += f'var footageItem = importFootage("{first_file}", {data["framerate"]}, "{data["name"]}");\n' + + # Close the undo group + jsx_script += "app.endUndoGroup();\n" + + return jsx_script, footage_sources + + +def send_to_after_effects(script_path): + """Send the generated script to After Effects. + + Args: + script_path (str): The path to the generated script. + + """ + + if not common.active('root', args=True): + raise RuntimeError('Must have an active bookmark') + + # Get the path to the executable + possible_names = ['afterfx', 'aftereffects', 'afx'] + for name in possible_names: + executable = common.get_binary(name) + if executable: + break + else: + raise RuntimeError(f'Could not find After Effects. Tried: {possible_names}') + + # Call after effects using with the generated script as an argument: + actions.execute_detached(executable, ['-r', os.path.normpath(script_path)]) + + return True + + +def run(): + """Run the script. + + """ + data = get_footage_sources() + if not data: + common.show_message( + 'No sequences found.', + body='No sequences were found in the current task folder.', + ) + return + + script_path = f'{common.active("task", path=True)}/import_footage.jsx' + jsx_script, footage_sources = generate_jsx_script(data) + with open(script_path, 'w') as f: + f.write(jsx_script) + + if not send_to_after_effects(script_path): + print('Could not find After Effects. Footage was not sent.') + + common.show_message( + 'Success.', + body=f'Found {len(footage_sources)} items. An import script was saved to:\n' + f'{common.active("task", path=True)}/import_footage.jsx', + message_type='success', + ) \ No newline at end of file diff --git a/bookmarks/scripts/sg_sync.py b/bookmarks/scripts/sync_asset_data.py similarity index 98% rename from bookmarks/scripts/sg_sync.py rename to bookmarks/scripts/sync_asset_data.py index 7b2f2da7c..b04c51b8d 100644 --- a/bookmarks/scripts/sg_sync.py +++ b/bookmarks/scripts/sync_asset_data.py @@ -619,7 +619,7 @@ def process_entities( # Get all ShotGrid entities for n, entity in enumerate(entities): common.message_widget.title_label.setText(f'Processing {entity["code"]} ({n + 1} of {len(entities)})') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) if source_entity_type == 'Shot': seq, shot = common.get_sequence_and_shot(entity['code']) @@ -692,7 +692,7 @@ def process_entities( if update_data: common.message_widget.body_label.setText(f'Updating ShotGrid entity...') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) sg.update( 'Shot', entity['id'], update_data, ) @@ -706,7 +706,7 @@ def process_entities( print(f'Uploading thumbnail for {entity["code"]}') common.message_widget.body_label.setText(f'Uploading ShotGrid thumbnail...') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) sg.upload_thumbnail('Shot', entity['id'], thumbnail_path) @@ -752,7 +752,7 @@ def process_entities( items_data[task_path]['description'] = asset_data_item['description'] common.message_widget.body_label.setText(f'Finding tasks...') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) # Find the associated task entity for task_entity in task_entities: @@ -764,7 +764,7 @@ def process_entities( # Create any missing files and folders inside the Maya work folders if self.sg_sync_create_folders_editor.isChecked(): common.message_widget.body_label.setText(f'Making folders...') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) print(f'Patching {root}/{task_path}') self.patch_asset(asset_template_path, f'{root}/{task_path}') @@ -788,7 +788,7 @@ def process_entities( if self.sg_sync_sync_data_to_bookmarks_editor.isChecked(): common.message_widget.title_label.setText(f'Adding asset links...') common.message_widget.body_label.setText('') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) for k in links: if not os.path.isdir(f'{bookmark_root}/{k}'): @@ -811,7 +811,7 @@ def process_entities( # Reset the filters common.message_widget.title_label.setText(f'Updating asset items...') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) common.model(common.AssetTab).set_filter_text('') # Refresh model @@ -819,7 +819,7 @@ def process_entities( # Wait for the model to refresh common.message_widget.body_label.setText(f'Waiting for refresh...') - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) model = common.source_model(common.AssetTab) p = model.source_path() @@ -835,7 +835,7 @@ def process_entities( _time_slept = 0 _increment = 0.1 while not data.loaded: - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) if _time_slept >= max_sleep: break _time_slept += _increment diff --git a/bookmarks/shortcuts.py b/bookmarks/shortcuts.py index fc27e5d69..85075ff33 100644 --- a/bookmarks/shortcuts.py +++ b/bookmarks/shortcuts.py @@ -30,6 +30,8 @@ Refresh = common.idx() AltRefresh = common.idx() +ApplicationLauncher = common.idx() + CopyItemPath = common.idx() CopyAltItemPath = common.idx() RevealItem = common.idx() @@ -56,7 +58,7 @@ PushToRV = common.idx() PushToRVFullScreen = common.idx() -BookmarkEditorShortcuts = { +JobEditorShortcuts = { AddItem: { 'value': QtGui.QKeySequence.New, 'default': QtGui.QKeySequence.New, @@ -78,7 +80,7 @@ 'value': 'Ctrl+Shift+N', 'default': 'Ctrl+Shift+N', 'repeat': False, - 'description': f'Open a new {common.product} instance...', + 'description': f'Open a new {common.product.title()} instance...', 'shortcut': None, }, RowIncrease: { @@ -172,6 +174,13 @@ 'description': 'Refresh', 'shortcut': None, }, + ApplicationLauncher: { + 'value': 'Alt+L', + 'default': 'Alt+L', + 'repeat': False, + 'description': 'Application Launcher', + 'shortcut': None, + }, CopyItemPath: { 'value': 'Ctrl+C', 'default': 'Ctrl+C', @@ -281,28 +290,28 @@ 'value': 'Ctrl+.', 'default': 'Ctrl+.', 'repeat': False, - 'description': 'Show {} Preferences'.format(common.product), + 'description': f'Show {common.product.title()} Preferences', 'shortcut': None, }, OpenTodo: { 'value': 'Alt+N', 'default': 'Alt+N', 'repeat': False, - 'description': 'Show {} Preferences'.format(common.product), + 'description': f'Show {common.product.title()} Preferences', 'shortcut': None, }, ToggleItemArchived: { 'value': 'Ctrl+A', 'default': 'Ctrl+A', 'repeat': False, - 'description': 'Show {} Preferences'.format(common.product), + 'description': f'Show {common.product.title()} Preferences', 'shortcut': None, }, ToggleItemFavourite: { 'value': 'Ctrl+S', 'default': 'Ctrl+S', 'repeat': False, - 'description': 'Show {} Preferences'.format(common.product), + 'description': f'Show {common.product.title()} Preferences', 'shortcut': None, }, PushToRV: { diff --git a/bookmarks/shotgun/sg_publish_clip.py b/bookmarks/shotgun/sg_publish_clip.py index e5747834e..80204afc6 100644 --- a/bookmarks/shotgun/sg_publish_clip.py +++ b/bookmarks/shotgun/sg_publish_clip.py @@ -1,11 +1,12 @@ """The publishing widget used by Bookmarks to create new PublishedFiles and Version entities on ShotGrid. -Our publishing logic creates `Version` and `PublishFile` entities linked against +The publish logic creates `Version` and `PublishFile` entities linked against the current active project and asset and uploads any custom thumbnails set. """ import re +import time from PySide2 import QtCore, QtGui, QtWidgets @@ -313,7 +314,6 @@ def save_changes(self): if not sg_properties.verify(asset=True): raise ValueError('Asset not configured.') - errors = [] with sg_properties.connection() as sg: @@ -327,7 +327,6 @@ def save_changes(self): disable_animation=True, parent=self ) - QtWidgets.QApplication.instance().processEvents() existing_version = sg.find_one( 'Version', [['code', 'is', data['name']]] @@ -348,11 +347,19 @@ def save_changes(self): ) # Publish steps for creating the Cut, CutInfo and Version entities + # Get the entity type + if 'task_entity' in data and data['task_entity']: + entity = data['task_entity'] + elif 'asset_entity' in data and data['asset_entity']: + entity = data['asset_entity'] + else: + raise RuntimeError('No asset or task entity found to associate with the publish.') + version_data = { 'project': data['project_entity'], 'code': data['name'], 'description': data['description'], - 'entity': data['asset_entity'], + 'entity': entity, 'user': user_entity } version = sg.create('Version', version_data) @@ -370,11 +377,10 @@ def save_changes(self): disable_animation=True, parent=self ) - QtWidgets.QApplication.instance().processEvents() cut_data = { 'project': data['project_entity'], - 'entity': data['asset_entity'], + 'entity': entity, 'description': data['description'], 'version': version } @@ -392,7 +398,6 @@ def save_changes(self): disable_animation=True, parent=self ) - QtWidgets.QApplication.instance().processEvents() cut_item_data = { 'project': data['project_entity'], @@ -418,7 +423,6 @@ def save_changes(self): disable_animation=True, parent=self ) - QtWidgets.QApplication.instance().processEvents() published_file_data = { 'project': data['project_entity'], @@ -459,7 +463,6 @@ def save_changes(self): disable_animation=True, parent=self ) - QtWidgets.QApplication.instance().processEvents() # Upload the actual file to ShotGrid sg.upload("Version", version['id'], data['file_path'], field_name='sg_uploaded_movie') @@ -479,6 +482,8 @@ def save_changes(self): common.close_message() + self.annotate_local_file(data) + errs = '\n\n'.join([str(e) for e in errors]) body = f'Publish successful.\n{len(errors)} errors occurred:\n\n{errs}' if errors else 'Publish successful.' common.show_message( @@ -487,7 +492,38 @@ def save_changes(self): message_type='success', parent=common.main_widget ) - return True + + def annotate_local_file(self, data): + # Stamp the file + # Get the source's description from the database + db = database.get(*common.active('root', args=True)) + description = db.value(data['file_path'], 'description', database.AssetTable) + description = description if description else '' + + if '#sg_published' not in description: + description += f' #sg_published' + db.set_value(data['file_path'], 'description', description, database.AssetTable) + + # Add a note to the database + note = ( + f'Publish Log (ShotGrid)' + f'\nName: {data["name"]}' + f'\nDescription: {data["description"]}' + f'\nTime: {time.strftime("%d/%m/%Y %H:%M:%S")}' + f'\nUser: {common.get_username()}' + ) + notes = db.value(data['file_path'], 'notes', database.AssetTable) + notes = notes if notes else {} + notes[len(notes)] = { + 'title': 'Publish Log (ShotGrid)', + 'body': note, + 'extra_data': { + 'created_by': common.get_username(), + 'created_at': time.strftime('%d/%m/%Y %H:%M:%S'), + 'fold': False, + } + } + db.set_value(data['file_path'], 'notes', notes, database.AssetTable) def get_publish_args(self): """Get all necessary arguments for publishing. diff --git a/bookmarks/shotgun/shotgun.py b/bookmarks/shotgun/shotgun.py index d2c7d50d9..c31b20996 100644 --- a/bookmarks/shotgun/shotgun.py +++ b/bookmarks/shotgun/shotgun.py @@ -247,6 +247,9 @@ def __init__(self, *args, **kwargs): self.bookmark_id = None self.bookmark_name = None + self.episode_id = None + self.episode_name = None + self.asset_type = None self.asset_id = None self.asset_name = None @@ -297,11 +300,15 @@ def _load_values_from_database(self, db): self.bookmark_id = db.value(s, 'shotgun_id', t) self.bookmark_name = db.value(s, 'shotgun_name', t) + self.episode_id = db.value(s, 'sg_episode_id', t) + self.episode_name = db.value(s, 'sg_episode_name', t) + if not self.asset: return t = database.AssetTable s = db.source(self.asset) + self.asset_type = db.value(s, 'shotgun_type', t) self.asset_id = db.value(s, 'shotgun_id', t) self.asset_name = db.value(s, 'shotgun_name', t) @@ -442,13 +449,17 @@ def urls(self): return [] urls = [] + urls.append(self.domain) if all((self.bookmark_id, self.bookmark_type)): - urls.append( - ENTITY_URL.format(domain=self.domain, entity_type=self.bookmark_type, entity_id=self.bookmark_id) - ) + urls.append(ENTITY_URL.format(domain=self.domain, entity_type=self.bookmark_type, entity_id=self.bookmark_id)) + if not self.episode_id is None: + urls.append(ENTITY_URL.format(domain=self.domain, entity_type='Episode', entity_id=self.episode_id)) if all((self.asset_id, self.asset_type)): urls.append(ENTITY_URL.format(domain=self.domain, entity_type=self.asset_type, entity_id=self.asset_id)) + if not self.asset_task_id is None: + urls.append(ENTITY_URL.format(domain=self.domain, entity_type='Task', entity_id=self.asset_task_id)) + return urls diff --git a/bookmarks/standalone.py b/bookmarks/standalone.py index d9a846fa9..68ae80cfd 100644 --- a/bookmarks/standalone.py +++ b/bookmarks/standalone.py @@ -141,7 +141,7 @@ def __init__(self, parent=None): w = TrayMenu(parent=self.window()) self.setContextMenu(w) - self.setToolTip(common.product) + self.setToolTip(common.product.title()) self.activated.connect(self.tray_activated) @@ -345,7 +345,6 @@ def _connect_standalone_signals(self): """Extra signal connections when Bookmarks runs in standalone mode. """ - func = functools.partial(common.save_window_state, self) self.files_widget.activated.connect(actions.execute) self.favourites_widget.activated.connect(actions.execute) @@ -402,11 +401,16 @@ class BookmarksApp(QtWidgets.QApplication): def __init__(self, args): _set_application_properties() - super().__init__([__file__, '-platform', 'windows:dpiawareness=2']) + + super().__init__([__file__,]) _set_application_properties(app=self) self.setApplicationVersion(__version__) - self.setApplicationName(common.product) - self.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, bool=True) + + self.setApplicationName(common.product.title()) + self.setOrganizationName(common.organization) + self.setOrganizationDomain(common.organization_domain) + + self.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, True) self._set_model_id() self._set_window_icon() @@ -441,6 +445,8 @@ def eventFilter(self, widget, event): if hasattr(widget, 'statusTip') and widget.statusTip(): common.signals.showStatusTipMessage.emit(widget.statusTip()) if event.type() == QtCore.QEvent.Leave: + if not common.signals: + return False common.signals.clearStatusBarMessage.emit() return False diff --git a/bookmarks/statusbar.py b/bookmarks/statusbar.py index 62a89631c..47e054ca1 100644 --- a/bookmarks/statusbar.py +++ b/bookmarks/statusbar.py @@ -73,7 +73,7 @@ def __init__(self, parent=None): 'check', (common.color(common.color_green), common.color(common.color_red)), common.size(common.size_margin), - description=f'Click to toggle {common.product}.', + description=f'Click to toggle {common.product.title()}.', parent=parent ) self.setMouseTracking(True) diff --git a/bookmarks/templates.py b/bookmarks/templates.py index 5deb9122c..be4a14f54 100644 --- a/bookmarks/templates.py +++ b/bookmarks/templates.py @@ -132,25 +132,6 @@ def _reveal(): } -class TemplateListDelegate(ui.ListWidgetDelegate): - """Delegate associated with :class:`TemplateListWidget`. - - """ - - def __init__(self, parent=None): - super().__init__(parent=parent) - - def createEditor(self, parent, option, index): - """Custom editor for editing the template's name. - - """ - editor = super().createEditor(parent, option, index) - validator = QtGui.QRegExpValidator(parent=editor) - validator.setRegExp(QtCore.QRegExp(r'[\_\-a-zA-z0-9]+')) - editor.setValidator(validator) - return editor - - class TemplateListWidget(ui.ListWidget): """Widget used to display a list of zip template files associated with the given `mode`. @@ -170,12 +151,6 @@ def __init__(self, mode=JobTemplateMode, parent=None): self.setDragDropMode(QtWidgets.QAbstractItemView.DropOnly) self.installEventFilter(self) self.viewport().installEventFilter(self) - self.setItemDelegate(TemplateListDelegate(parent=self)) - - self.setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.viewport().setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.viewport().setAttribute(QtCore.Qt.WA_TranslucentBackground) self._connect_signals() @@ -208,11 +183,11 @@ def init_data(self): dir_ = QtCore.QDir(get_template_folder(self.mode())) dir_.setNameFilters(['*.zip', ]) - h = common.size(common.size_row_height) - size = QtCore.QSize(1, h) + h = common.size(common.size_row_height) * 0.8 + size = QtCore.QSize(0, h) off_pixmap = images.rsc_pixmap( - 'close', common.color(common.color_separator), h + 'folder_sm', common.color(common.color_separator), h ) on_pixmap = images.rsc_pixmap( 'check', common.color(common.color_green), h @@ -259,7 +234,7 @@ def init_data(self): self.selectionModel().blockSignals(False) common.restore_selection(self) - self.progressUpdate.emit('') + self.progressUpdate.emit('', '') def mode(self): """The TemplateWidget's current mode. @@ -410,12 +385,6 @@ def eventFilter(self, widget, event): painter.drawRoundedRect(rect, o, o) painter.setOpacity(op) - pen = QtGui.QPen(common.color(common.color_separator)) - pen.setWidthF(common.size(common.size_separator)) - painter.setPen(pen) - painter.setBrush(common.color(common.color_separator)) - painter.drawRoundedRect(rect, o, o) - return False def showEvent(self, event): @@ -445,7 +414,7 @@ def sizeHint(self): ) -class TemplatesPreviewWidget(QtWidgets.QListWidget): +class TemplatesPreviewWidget(ui.ListWidget): """List widget used to peak into the contents of a zip template file. """ diff --git a/bookmarks/threads/threads.py b/bookmarks/threads/threads.py index d84f57576..8a8e47c24 100644 --- a/bookmarks/threads/threads.py +++ b/bookmarks/threads/threads.py @@ -101,18 +101,7 @@ def __init__(self, q, t): }, 'worker': workers.InfoWorker, 'role': common.FileInfoLoaded, - 'tab': common.FileTab, - }, - FileInfo3: { - 'queue': collections.deque([], common.max_list_items), - 'preload': True, - 'data_types': { - common.FileItem: DataType(FileInfo, common.FileItem), - common.SequenceItem: DataType(FileInfo, common.SequenceItem), - }, - 'worker': workers.InfoWorker, - 'role': common.FileInfoLoaded, - 'tab': common.FileTab, + 'tab': common.TaskTab, }, FileThumbnail: { 'queue': collections.deque([], 99), diff --git a/bookmarks/threads/workers.py b/bookmarks/threads/workers.py index cac67d56e..a49b159f7 100644 --- a/bookmarks/threads/workers.py +++ b/bookmarks/threads/workers.py @@ -14,6 +14,7 @@ from .. import database from .. import images from .. import log +from ..tokens import tokens from ..shotgun import shotgun @@ -86,14 +87,19 @@ def func_wrapper(self, *args, **kwargs): if not ref() or self.interrupt: return - # Let the source model know that we loaded a data segment fully if ref().data_type in (common.FileItem, common.SequenceItem): - # Mark the model loaded + # Mark the internal model loaded ref().loaded = True if not ref() or self.interrupt: return - self.sort_data_type(ref) + + # Sort the data + data = self.sort_internal_data(ref) + + # Signal the world + common.signals.internalDataReady.emit(weakref.ref(data)) + return if not ref() or self.interrupt: @@ -136,6 +142,7 @@ class BaseWorker(QtCore.QObject): coreDataLoaded = QtCore.Signal(weakref.ref, weakref.ref) coreDataReset = QtCore.Signal() + dataTypeAboutToBeSorted = QtCore.Signal(int) dataTypeSorted = QtCore.Signal(int) queueItems = QtCore.Signal(list) @@ -170,7 +177,7 @@ def init_worker(self): from . import threads self.queue_timer = common.Timer(parent=self) - self.queue_timer.setObjectName('{}Timer_{}'.format(self.queue, uuid.uuid1().hex)) + self.queue_timer.setObjectName(f'{self.queue}Timer_{uuid.uuid1().hex}') self.queue_timer.setInterval(1) # Local direct worker signal connections @@ -205,7 +212,10 @@ def init_worker(self): if threads.THREADS[q]['preload'] and model and widget: model.coreDataLoaded.connect(self.coreDataLoaded, cnx) - self.dataTypeSorted.connect(model.dataTypeSorted, cnx) + + self.dataTypeAboutToBeSorted.connect(model.internal_data_about_to_be_sorted, cnx) + self.dataTypeSorted.connect(model.internal_data_sorted, cnx) + common.signals.databaseValueUpdated.connect(self.databaseValueUpdated, cnx) self.sgEntityDataReady.connect(common.signals.sgEntityDataReady, cnx) @@ -214,10 +224,10 @@ def update_changed_database_value(self, table, source, key, value): """Process changes when any bookmark database value changes. Args: - tab_type (idx): A tab type used to match the slot with the model type. + table (str): The database table. source (str): A file path. - role (int): An item role. - v (object): The value to set. + key (str): The database value key (column). + value (object): The value to set. Returns: type: Description of returned object. @@ -298,10 +308,6 @@ def queue_model(self, data_type_ref1, data_type_ref2): if ref().loaded: continue # Skip if the model is loaded already - # Skip if the model has already been queued - if ref in threads.THREADS[q]['queue']: - continue - idxs = ref().keys() for idx in idxs: if not ref() or self.interrupt: @@ -320,7 +326,7 @@ def queue_model(self, data_type_ref1, data_type_ref2): self.queue_timer.start() @common.error - def sort_data_type(self, ref): + def sort_internal_data(self, ref): """Sorts the data of the given data type. Args: @@ -331,7 +337,7 @@ def sort_data_type(self, ref): model = _model(self.queue) if not model: - return + return None sort_by = model.sort_by() sort_order = model.sort_order() @@ -341,15 +347,23 @@ def sort_data_type(self, ref): t = ref().data_type if not ref(): - return + return None + + if model.data_type() == t: + self.dataTypeAboutToBeSorted.emit(t) + d = common.sort_data(ref, sort_by, sort_order) + if not ref(): - return - common.set_data(p, k, t, d) + return None + + data = common.set_data(p, k, t, d) if model.data_type() == t: self.dataTypeSorted.emit(t) + return data + @QtCore.Slot() def clear_queue(self): """Slot called by the `resetQueue` signal and is responsible for @@ -371,6 +385,12 @@ def clear_queue(self): @common.error @QtCore.Slot(weakref.ref) def process_data(self, ref): + """Processes the given data item. + + Args: + ref (weakref.ref): A data item. + + """ # Do nothing by default if not ref() or self.interrupt: return False @@ -432,10 +452,9 @@ def get_bookmark_description(bookmark_row_data): description = f'{sep}{v["description"]}' if v['description'] else '' width = v['width'] if (v['width'] and v['height']) else '' height = f'*{v["height"]}px' if (v['width'] and v['height']) else '' - framerate = f'{sep}{v["framerate"]}fps' if v['framerate'] else '' - prefix = f'{sep}{v["prefix"]}' if v['prefix'] else '' + framerate = f', {v["framerate"]}fps' if v['framerate'] else '' - s = f'{width}{height}{framerate}{prefix}{description}' + s = f'{description}{sep}{width}{height}{framerate}' s = s.replace(sep + sep, sep) s = s.strip(sep).strip() return s @@ -512,7 +531,7 @@ def process_data(self, ref): """Populates the item with the missing file information. Args: - ref (weakref): An internal model data item's weakref. + ref (weakref.ref): A data item as created by the :meth:`bookmarks.items.models.ItemModel.init_data` method. Returns: bool: `True` on success, `False` otherwise. @@ -538,6 +557,8 @@ def process_data(self, ref): def _process_data(self, ref): """Utility method for :meth:`process_data. + ref (weakref): A data item as created by the :meth:`bookmarks.items.models.ItemModel.init_data` method. + """ pp = ref()[common.ParentPathRole] st = ref()[common.PathRole] @@ -571,6 +592,38 @@ def _process_data(self, ref): # Asset Progress Data if len(pp) == 4 and asset_row_data['progress']: ref()[common.AssetProgressRole] = asset_row_data['progress'] + # Asset entry data + if len(pp) == 4: + ref()[common.EntryRole].append( + common.get_entry_from_path( + ref()[common.PathRole], + is_dir=True, + force_exists=True + ) + ) + + # Asset ShotGrid task to list + if ( + len(pp) == 4 and + asset_row_data['sg_task_name'] and + ref()[common.DataDictRole] and + not asset_row_data['flags'] & common.MarkedAsArchived + ): + if ref(): + _ref = ref()[common.DataDictRole] + + # TODO: This does not seem to be thread safe (?) but since we only have one asset worker + # it should be fine for now. + if _ref(): + if asset_row_data['sg_task_name'] and asset_row_data['sg_task_name'] not in _ref().sg_task_names: + if _ref(): + _ref().sg_task_names.append(asset_row_data['sg_task_name']) + + if _ref(): + if asset_row_data['shotgun_name'] and asset_row_data['shotgun_name'] not in _ref().shotgun_names: + if _ref(): + _ref().shotgun_names.append(asset_row_data['shotgun_name']) + # ShotGrid status if len(pp) <= 4: update_shotgun_configured(pp, bookmark_row_data, asset_row_data, ref) @@ -588,7 +641,63 @@ def _process_data(self, ref): flags |= _proxy_flags if _proxy_flags else 0 ref()[common.FlagsRole] = QtCore.Qt.ItemFlags(flags) - self.count_items(ref, st) + if ref() and ref()[common.ItemTabRole] == common.TaskTab: + # Let's get the token config instance to check what extensions are + # currently allowed to be displayed in the task folder + config = tokens.get(*pp[0:3]) + + description = config.get_description(pp[-1]) + ref()[common.DescriptionRole] = description + + is_valid_task = config.check_task(pp[-1]) + if is_valid_task: + valid_extensions = config.get_task_extensions(pp[-1]) + else: + valid_extensions = config.get_extensions(tokens.AllFormat) + + def _file_it(path): + for entry in os.scandir(path): + if entry.is_symlink(): + continue + if entry.name.startswith('.'): + continue + if entry.name == 'thumbs.db': + continue + + if entry.is_dir(): + yield from _file_it(entry.path) + + if not entry.is_file(): + continue + + if QtCore.QFileInfo(entry.path).suffix().lower() not in valid_extensions: + continue + + yield entry.path + + _idx = 0 + _max = 199 + for _idx, _ in enumerate(_file_it(st)): + if not ref(): + break + + ref()[common.NoteCountRole] = _idx + 1 + + if _idx == 1: + _s = 'a' + pp[-1].lower() + ref()[common.SortByNameRole] = _s + ref()[common.SortByLastModifiedRole] = _s + ref()[common.SortBySizeRole] = _s + ref()[common.SortByTypeRole] = _s + + if _idx > _max: + break + + _suffix = f'{_idx + 1} items' if _idx < _max else f'{_max}+ items' + _suffix = _suffix if _idx > 0 else '' + _suffix = f' ({_suffix})' if description and _idx > 0 else _suffix + + ref()[common.DescriptionRole] += _suffix self._process_bookmark_item(ref, db.source(), bookmark_row_data, pp) self._process_file_item(ref, item_type) @@ -694,9 +803,6 @@ def _process_file_item(self, ref, item_type): ref()[common.FileDetailsRole] = info_string ref()[common.SortBySizeRole] = size - def count_items(self, ref, source): - pass - class ThumbnailWorker(BaseWorker): """Thread worker responsible for creating and loading thumbnails. @@ -722,7 +828,7 @@ def process_data(self, ref): for details. Args: - ref (weakref.ref): A weakref to a data segment. + ref (weakref.ref): A data item as created by the :meth:`bookmarks.items.models.ItemModel.init_data` method. Returns: ref or None: `ref` if loaded successfully, else `None`. @@ -816,7 +922,7 @@ class TransactionsWorker(BaseWorker): """ @common.error - def process_data(self): + def process_data(self, *args, **kwargs): verify_thread_affinity() if self.interrupt: @@ -835,7 +941,7 @@ class SGWorker(BaseWorker): """This worker is used to retrieve data from ShotGrid.""" @common.error - def process_data(self): + def process_data(self, *args, **kwargs): verify_thread_affinity() if self.interrupt: diff --git a/bookmarks/tokens/tokens.py b/bookmarks/tokens/tokens.py index b1d84ebfd..c9434c935 100644 --- a/bookmarks/tokens/tokens.py +++ b/bookmarks/tokens/tokens.py @@ -40,9 +40,6 @@ from .. import database from .. import log -#: The database column name -TOKENS_DB_KEY = 'tokens' - FileFormatConfig = 'FileFormatConfig' FileNameConfig = 'FileNameConfig' PublishConfig = 'PublishConfig' @@ -74,6 +71,7 @@ SceneFolder = 'scene' CacheFolder = 'cache' +CaptureFolder = 'captures' RenderFolder = 'render' DataFolder = 'data' ReferenceFolder = 'reference' @@ -84,7 +82,7 @@ #: The default token value configuration DEFAULT_TOKEN_CONFIG = { FileFormatConfig: { - 0: { + common.idx(reset=True, start=0): { 'name': 'Scene Formats', 'flag': SceneFormat, 'value': common.sort_words( @@ -93,7 +91,7 @@ ), 'description': 'Scene file formats' }, - 1: { + common.idx(): { 'name': 'Image Formats', 'flag': ImageFormat, 'value': common.sort_words( @@ -101,7 +99,7 @@ ), 'description': 'Image file formats' }, - 2: { + common.idx(): { 'name': 'Cache Formats', 'flag': CacheFormat, 'value': common.sort_words( @@ -110,7 +108,7 @@ ), 'description': 'CG cache formats' }, - 3: { + common.idx(): { 'name': 'Movie Formats', 'flag': MovieFormat, 'value': common.sort_words( @@ -118,7 +116,7 @@ ), 'description': 'Movie file formats' }, - 4: { + common.idx(): { 'name': 'Audio Formats', 'flag': AudioFormat, 'value': common.sort_words( @@ -126,7 +124,7 @@ ), 'description': 'Audio file formats' }, - 5: { + common.idx(): { 'name': 'Document Formats', 'flag': DocFormat, 'value': common.sort_words( @@ -134,7 +132,7 @@ ), 'description': 'Audio file formats' }, - 6: { + common.idx(): { 'name': 'Script Formats', 'flag': ScriptFormat, 'value': common.sort_words( @@ -142,7 +140,7 @@ ), 'description': 'Various script file formats' }, - 7: { + common.idx(): { 'name': 'Miscellaneous Formats', 'flag': MiscFormat, 'value': common.sort_words( @@ -152,103 +150,36 @@ }, }, FileNameConfig: { - 0: { - 'name': 'Asset Scene Task', - 'value': '{prefix}_{asset}_{mode}_{element}_{user}_{version}.{ext}', + common.idx(reset=True, start=0): { + 'name': 'Asset Scene', + 'value': '{prefix}_{asset}_{element}.{version}.{ext}', 'description': 'Uses the project prefix, asset, task, element, ' - 'user and version names' - }, - 1: { - 'name': 'Asset Scene File (without task and element)', - 'value': '{prefix}_{asset}_{user}_{version}.{ext}', - 'description': 'Uses the project prefix, asset, user and version names' - }, - 2: { - 'name': 'Shot Scene Task', - 'value': '{prefix}_{seq}_{shot}_{mode}_{element}_{user}_{version}.{ext}', - 'description': 'Uses the project prefix, sequence, shot, mode, element, ' - 'user and version names' - }, - 3: { - 'name': 'Shot Scene Task (without task and element)', - 'value': '{prefix}_{seq}_{shot}_{user}_{version}.{ext}', - 'description': 'Uses the project prefix, sequence, shot, user and ' - 'version names' + 'user and version names', }, - 4: { - 'name': 'Versioned Element', - 'value': '{element}_{version}.{ext}', - 'description': 'File name with an element and version name' - }, - 5: { - 'name': 'Non-Versioned Element', - 'value': '{element}.{ext}', - 'description': 'A non-versioned element file' - }, - 6: { - 'name': 'Studio Aka - Shot', + common.idx(): { + 'name': 'Shot Scene', 'value': '{prefix}_{seq}_{shot}_{mode}_{element}.{version}.{ext}', - 'description': 'Studio Aka - ShotGrid file template' - }, - 7: { - 'name': 'Studio Aka - Asset', - 'value': '{asset1}_{asset5}_{element}.{version}.{ext}', - 'description': 'Studio Aka - ShotGrid file template' + 'description': 'Template name used save shot scene files', } }, PublishConfig: { - 0: { - 'name': 'Shot Task', - 'value': '{server}/{job}/publish/{sequence}_{shot}/{task}/{element}/{prefix}_{sequence}_{shot}_{task}_{' - 'element}.{ext}', - 'description': 'Publish a shot task element', - 'filter': SceneFormat | ImageFormat | MovieFormat | CacheFormat, + common.idx(): { + 'name': 'Publish: Asset Item', + 'value': '{server}/{job}/{root}/{asset}/publish/{prefix}_{asset}_{task}_{element}.{ext}', + 'description': 'Publish an asset scene', }, - 1: { - 'name': 'Asset Task', - 'value': '{server}/{job}/publish/asset_{asset}/{task}/{element}/{prefix}_{asset}_{task}_{element}.{ext}', - 'description': 'Publish an asset task element', - 'filter': SceneFormat | ImageFormat | MovieFormat | CacheFormat, - }, - 3: { - 'name': 'Shot Thumbnail', - 'value': '{server}/{job}/publish/{sequence}_{shot}/thumbnail.{ext}', - 'description': 'Publish an shot thumbnail', - 'filter': ImageFormat, - }, - 4: { - 'name': 'Asset Thumbnail', - 'value': '{server}/{job}/publish/asset_{asset}/thumbnail.{ext}', - 'description': 'Publish an asset thumbnail', - 'filter': ImageFormat, - }, - 5: { - 'name': 'Asset (local publish)', - 'value': '{server}/{job}/{root}/{asset}/publish/{prefix}_{asset1}_{task}_{element}.{ext}', - 'description': 'Publish an asset', - 'filter': SceneFormat | ImageFormat | MovieFormat | CacheFormat, - }, - 6: { - 'name': 'Shot (local publish)', + common.idx(): { + 'name': 'Publish: Shot Item', 'value': '{server}/{job}/{root}/{asset}/publish/{prefix}_{seq}_{shot}_{element}.{ext}', - 'description': 'Publish a shot element', - 'filter': SceneFormat | ImageFormat | MovieFormat | CacheFormat, - }, - 7: { - 'name': 'Studio Aka - vCur', - 'value': '{server}/{job}/{root}/{asset0}/{asset1}/{asset2}/publish/{asset4}/{asset5}/{asset1}_{asset5}_{' - 'element}.vCur.{ext}', - 'description': 'Studio Aka - vCur', - 'filter': SceneFormat | ImageFormat | MovieFormat | CacheFormat, + 'description': 'Publish a shot scene', }, }, AssetFolderConfig: { - 0: { + common.idx(reset=True, start=0): { 'name': CacheFolder, 'value': CacheFolder, 'description': 'Alembic, FBX, OBJ and other CG caches', - 'filter': SceneFormat | ImageFormat | MovieFormat | AudioFormat | - CacheFormat, + 'filter': SceneFormat | ImageFormat | MovieFormat | AudioFormat | CacheFormat, 'subfolders': { 0: { 'name': 'abc', @@ -317,29 +248,26 @@ } } }, - 1: { + common.idx(): { 'name': DataFolder, 'value': DataFolder, 'description': 'Temporary data files, or content generated by ' 'applications', 'filter': AllFormat, - 'subfolders': {}, }, - 2: { + common.idx(): { 'name': ReferenceFolder, 'value': ReferenceFolder, 'description': 'References, e.g., images, videos or sound files', 'filter': ImageFormat | DocFormat | AudioFormat | MovieFormat, - 'subfolders': {}, }, - 3: { + common.idx(): { 'name': RenderFolder, 'value': 'images', 'description': 'Render layer outputs', 'filter': ImageFormat | AudioFormat | MovieFormat, - 'subfolders': {}, }, - 4: { + common.idx(): { 'name': SceneFolder, 'value': 'scenes', 'description': 'Project and scene files', @@ -392,39 +320,43 @@ }, }, }, - 5: { + common.idx(): { 'name': PublishFolder, 'value': 'publish', 'description': 'Asset publish files', + 'filter': SceneFormat | ImageFormat | MovieFormat | AudioFormat + }, + common.idx(): { + 'name': CaptureFolder, + 'value': 'captures', + 'description': 'Viewport captures and preview files', 'filter': ImageFormat | MovieFormat | AudioFormat }, - 6: { + common.idx(): { 'name': TextureFolder, 'value': 'sourceimages', 'description': '2D and 3D texture files', 'filter': ImageFormat | MovieFormat | AudioFormat, - 'subfolders': {}, }, - 7: { + common.idx(): { 'name': MiscFolder, 'value': 'other', 'description': 'Miscellaneous asset files', 'filter': AllFormat, - 'subfolders': {}, } }, FFMpegTCConfig: { - 0: { + common.idx(reset=True, start=0): { 'name': 'Shot', 'value': '{job} | {sequence}-{shot}-{task}-{version} | {date} {user} | {in_frame}-{out_frame}', 'description': 'Timecode to use for shots' }, - 1: { + common.idx(): { 'name': 'Asset', 'value': '{job} | {asset}-{task}-{version} | {date} {user}', 'description': 'Timecode to use for assets' }, - 2: { + common.idx(): { 'name': 'Date and user', 'value': '{job} | {date} {user}', 'description': 'Sparse timecode with the date and username' @@ -598,7 +530,7 @@ def data(self, force=False): db = database.get(self.server, self.job, self.root) v = db.value( db.source(), - TOKENS_DB_KEY, + 'tokens', database.BookmarkTable ) if not v or not isinstance(v, dict): @@ -631,7 +563,7 @@ def set_data(self, data): db = database.get(self.server, self.job, self.root) db.set_value( db.source(), - TOKENS_DB_KEY, + 'tokens', data, table=database.BookmarkTable ) @@ -701,7 +633,7 @@ def get_description(self, token, force=False): return '' def expand_tokens( - self, s, user=common.get_username(), version='v001', + self, s, use_database=True, user=common.get_username(), version='v001', host=socket.gethostname(), task='main', ext=None, prefix=None, **_kwargs ): @@ -712,12 +644,7 @@ def expand_tokens( Args: s (str): The string containing tokens to be expanded. - user (str, optional): Username. - version (str, optional): The version string. - host (str, optional): The name of the current machine/host. - task (str, optional): Task folder name. - ext (str, optional): File format extension. - prefix (str, optional): Bookmark item prefix. + use_database (bool, optional): Use values stored in the database. _kwargs (dict, optional): Optional token/value pairs. Returns: @@ -726,6 +653,7 @@ def expand_tokens( """ kwargs = self.get_tokens( + use_database=use_database, user=user, version=version, ver=version, @@ -747,7 +675,7 @@ def expand_tokens( del kwargs[k] # To avoid KeyErrors when invalid tokens are passed we will replace - # the these with a custom marker + # these with a custom marker, e.g. {invalid_token} # via https://stackoverflow.com/questions/17215400/format-string-unused # -named-arguments return string.Formatter().vformat( @@ -756,11 +684,11 @@ def expand_tokens( collections.defaultdict(lambda: invalid_token, **kwargs) ) - def get_tokens(self, force=False, **kwargs): + def get_tokens(self, force=False, use_database=True, **kwargs): """Get all available tokens. Args: - force (bool, optional): Force retrieve tokens from the database. + force (bool, optional): Force retrieve defined tokens from the database. Returns: dict: A dictionary of token/value pairs. @@ -791,34 +719,37 @@ def get_tokens(self, force=False, **kwargs): tokens[k] = v # We can also use bookmark item properties as tokens - db = database.get(self.server, self.job, self.root) - for _k in database.TABLES[database.BookmarkTable]: - if _k == 'id': - continue - if database.TABLES[database.BookmarkTable][_k]['type'] == dict: - continue - if _k not in kwargs or not kwargs[_k]: - _v = db.value(db.source(), _k, database.BookmarkTable) - _v = _v if _v else invalid_token - tokens[_k] = _v - - # The asset root token will only be available when the asset is manually - # specified - if 'asset' in kwargs and kwargs['asset']: - source = f'{self.server}/{self.job}/{self.root}/{kwargs["asset"]}' + if use_database: + db = database.get(self.server, self.job, self.root) - for _k in database.TABLES[database.AssetTable]: + for _k in database.TABLES[database.BookmarkTable]: if _k == 'id': continue - if database.TABLES[database.AssetTable][_k]['type'] == dict: + if database.TABLES[database.BookmarkTable][_k]['type'] == dict: continue if _k not in kwargs or not kwargs[_k]: - _v = db.value(source, _k, database.AssetTable) + _v = db.value(db.source(), _k, database.BookmarkTable) _v = _v if _v else invalid_token tokens[_k] = _v - # Let's also override width, height and fps tokens - tokens[_k.replace('asset_', '')] = _v + # The asset root token will only be available when the asset is manually + # specified + if 'asset' in kwargs and kwargs['asset']: + source = f'{self.server}/{self.job}/{self.root}/{kwargs["asset"]}' + + if use_database: + for _k in database.TABLES[database.AssetTable]: + if _k == 'id': + continue + if database.TABLES[database.AssetTable][_k]['type'] == dict: + continue + if _k not in kwargs or not kwargs[_k]: + _v = db.value(source, _k, database.AssetTable) + _v = _v if _v else invalid_token + tokens[_k] = _v + + # Let's also override width, height and fps tokens + tokens[_k.replace('asset_', '')] = _v # We also want to use the path elements as tokens. for k in ('server', 'job', 'root', 'asset', 'task'): diff --git a/bookmarks/tokens/tokens_editor.py b/bookmarks/tokens/tokens_editor.py index 7fbc0e6f7..66e562055 100644 --- a/bookmarks/tokens/tokens_editor.py +++ b/bookmarks/tokens/tokens_editor.py @@ -3,6 +3,7 @@ See the :mod:`bookmarks.tokens.tokens` for the interface details. """ +import copy import functools from PySide2 import QtWidgets, QtCore @@ -13,13 +14,34 @@ from .. import ui from ..editor import base -SECTIONS = ( - (tokens.FileNameConfig, 'File name template'), - (tokens.AssetFolderConfig, 'Asset folders template'), - (tokens.FileFormatConfig, 'Format whitelist'), - (tokens.PublishConfig, 'Publish template'), - (tokens.FFMpegTCConfig, 'Timecode template'), -) +MoveUp = 0 +MoveDown = 1 + +SECTIONS = { + tokens.FileNameConfig: { + 'name': 'File Name Templates', + 'description': 'File name templates are used to define the names scene files. These usually include the ' + 'project\'s prefix, sequence and shot numbers, and the task name.', + }, + tokens.PublishConfig: { + 'name': 'Publish Templates', + 'description': 'Publish templates are used to define the save location of published files.', + }, + tokens.FFMpegTCConfig: { + 'name': 'Timecode Template', + 'description': 'The template used by ffmpeg for video text overlays and burn-ins.', + }, + tokens.AssetFolderConfig: { + 'name': 'Asset Folders', + 'description': 'Common folders that define the principal folders of an asset item. These values are' + 'used when browsing files, saving scene files and publishing items.', + }, + tokens.FileFormatConfig: { + 'name': 'Format Whitelist', + 'description': 'The list of file formats that are allowed to be shown.', + + }, +} def _set(d, keys, v): @@ -69,7 +91,7 @@ def _create_ui(self): editor.setSpacing(0) editor.itemClicked.connect( - lambda x: self.tokenSelected.emit(x.data(QtCore.Qt.DisplayRole)) + lambda x: self.tokenSelected.emit(x.data(QtCore.Qt.UserRole)) ) editor.itemClicked.connect( lambda x: self.done(QtWidgets.QDialog.Accepted) @@ -78,34 +100,21 @@ def _create_ui(self): self.layout().addWidget(editor, 0) config = tokens.get(self.server, self.job, self.root) - v = config.get_tokens( - user='username', - version='v001', - host='my-machine', - task='anim', - mode='anim', - element='main', - ext='ma', - prefix='MY_PROJECT', - asset='asset/with/multiple/subfolders', - seq='SQ010', - sequence='SQ010', - shot='SH0010', - project='my_project', - ) - for k in sorted(v.keys()): - token = '{{{}}}'.format(k) - editor.addItem(token) + v = config.get_tokens() + + for k in sorted(v.keys(), key=lambda x: x.strip('{}').lower()): + editor.addItem(f'{k}{" > {}".format(v[k]) if v[k] != "{invalid_token}" else ""}') item = editor.item(editor.count() - 1) item.setFlags(QtCore.Qt.ItemIsEnabled) + item.setData( QtCore.Qt.ToolTipRole, f'Current value: "{v[k]}"' ) - - @QtCore.Slot(QtWidgets.QListWidgetItem) - def item_clicked(self, item): - item = item.data(QtCore.Qt.DisplayRole) + item.setData( + QtCore.Qt.UserRole, + '{{{}}}'.format(k) + ) def sizeHint(self): """Returns a size hint. @@ -136,7 +145,7 @@ class FormatEditor(QtWidgets.QDialog): """ def __init__(self, *args, **kwargs): - super(FormatEditor, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.listwidget = None self.setWindowTitle('Edit Formats') @@ -179,18 +188,18 @@ def _create_ui(self): class SubfolderEditor(QtWidgets.QDialog): - """A popup editor used to edit the subfolders of a task folder. + """A popup editor used to edit the sub-folders of a task folder. """ def __init__(self, section, k, v, data, parent=None): - super(SubfolderEditor, self).__init__(parent=parent) + super().__init__(parent=parent) self.section = section self.k = k self.v = v self.data = data - self.setWindowTitle('Edit Subfolders') + self.setWindowTitle('Edit Sub-folders') self._create_ui() def _create_ui(self): @@ -199,7 +208,7 @@ def _create_ui(self): self.layout().setContentsMargins(o, o, o, o) self.layout().setSpacing(o) - main_grp = base.add_section('', 'Edit Subfolders', self) + main_grp = base.add_section('', 'Edit Sub-folders', self) grp = ui.get_group(parent=main_grp) for _k, _v in self.v['subfolders'].items(): @@ -248,7 +257,7 @@ class TokenConfigEditor(QtWidgets.QWidget): """ def __init__(self, server, job, root, parent=None): - super(TokenConfigEditor, self).__init__(parent=parent) + super().__init__(parent=parent) self.server = server self.job = job self.root = root @@ -263,6 +272,8 @@ def __init__(self, server, job, root, parent=None): self.init_data() + self.ui_groups = {} + self._create_ui() self._connect_signals() @@ -271,41 +282,178 @@ def _create_ui(self): QtWidgets.QVBoxLayout(self) o = common.size(common.size_margin) - h = common.size(common.size_row_height) self.layout().setContentsMargins(0, 0, 0, 0) self.layout().setSpacing(o) + # Re-fetch the config data from the database + + for k in SECTIONS: + self._add_section(k) + + def _add_section_item(self, parent, section, data): + """Adds a new item to a section. + + Args: + parent (QtWidgets.QWidget): The parent widget. + section (str): The section to add the item to. + data (dict): The data to add. + + Returns: + QtWidgets.QWidget: The widget that was added. + + """ + grp = ui.get_group(vertical=False, parent=parent) + grp.setObjectName('section_item_group') + grp.section = section + + _row1 = ui.add_row(None, height=None, vertical=True, parent=grp) + + for _k in ('name', 'value', 'description'): + row = ui.add_row(_k.title(), height=common.size(common.size_row_height), parent=_row1) + editor = ui.LineEdit(parent=row) + editor.setObjectName(f'section_item_{_k}') + + editor.setAlignment(QtCore.Qt.AlignRight) + editor.setText(data[_k]) + editor.setPlaceholderText(f'Edit {_k}...') + + if _k in ('name', 'description'): + editor.setStyleSheet( + f'color: {common.rgb(common.color_secondary_text)};' + ) + else: + editor.setStyleSheet( + f'color: {common.rgb(common.color_text)};' + ) + row.layout().addWidget(editor, 1) + + _row2 = ui.add_row(None, height=None, vertical=False, parent=None) + grp.layout().addWidget(_row2, 0) + + button = ui.ClickableIconButton( + 'add_circle', + (common.color(common.color_text), common.color(common.color_text)), + common.size(common.size_margin), + description='Insert token', + parent=_row2 + ) + _row2.layout().addWidget(button, 0) + value_editor = [x for x in grp.findChildren(QtWidgets.QLineEdit) if x.objectName() == 'section_item_value'][0] + button.clicked.connect( + functools.partial(self.show_token_editor, value_editor) + ) + + button = ui.ClickableIconButton( + 'arrow_up', + (common.color(common.color_text), common.color(common.color_text)), + common.size(common.size_margin), + description='Move item up', + parent=_row2 + ) + _row2.layout().addWidget(button, 0) + button.clicked.connect( + functools.partial(self.move_item, grp, MoveUp) + ) + + button = ui.ClickableIconButton( + 'arrow_down', + (common.color(common.color_text), common.color(common.color_text)), + common.size(common.size_margin), + description='Move item down', + parent=_row2 + ) + _row2.layout().addWidget(button, 0) + button.clicked.connect( + functools.partial(self.move_item, grp, MoveDown) + ) + + button = ui.ClickableIconButton( + 'archive', + (common.color(common.color_red), common.color(common.color_red)), + common.size(common.size_margin), + description='Remove item', + parent=_row2 + ) + _row2.layout().addWidget(button, 0) + button.clicked.connect( + functools.partial(self.remove_item, grp) + ) + + return grp + + def _add_section(self, section): # Re-fetch the config data from the database data = self.tokens.data(force=True) - for section, section_name in SECTIONS: - if section not in data: - continue - if not isinstance(data[section], dict): - log.error('Invalid data.') + if section not in data: + print(f'Invalid section: {section}. Skipping.') + return + + if not isinstance(data[section], dict): + log.error('Invalid data.') + return + + for k, v in data[section].items(): + if not isinstance( + v, dict + ) or 'name' not in v or 'description' not in v: + log.error(f'Invalid data. Key: {k}, value: {v}') return - for k, v in data[section].items(): - if not isinstance( - v, dict - ) or 'name' not in v or 'description' not in v: - log.error(f'Invalid data. Key: {k}, value: {v}') - return - - main_grp = base.add_section( - '', - section_name, - self, - color=common.color(common.color_dark_background) + + h = common.size(common.size_row_height) + + main_grp = base.add_section( + 'file', + SECTIONS[section]['name'], + self, + color=common.color(common.color_dark_background) + ) + + self.ui_groups[section] = main_grp + self.header_buttons.append((SECTIONS[section]['name'], main_grp)) + + _grp = ui.get_group(parent=main_grp) + + # Control buttons + control_row = ui.add_row( + None, height=None, parent=_grp + ) + + ui.add_description( + SECTIONS[section]['description'], + height=None, + label=None, + parent=control_row + ) + control_row.layout().addStretch(1) + + if section in (tokens.PublishConfig, tokens.FFMpegTCConfig, tokens.FileNameConfig): + add_button = ui.ClickableIconButton( + 'add', + (common.color(common.color_green), common.color(common.color_green)), + common.size(common.size_margin) * 1.5, + description='Add new item', + parent=control_row + ) + control_row.layout().addWidget(add_button, 0) + + add_button.clicked.connect( + functools.partial(self.add_item, _grp, section) ) - # Save header data for later use - self.header_buttons.append((section_name, main_grp)) + reset_button = ui.PaintedButton('Revert to defaults') + reset_button.clicked.connect(functools.partial(self.restore_defaults, section)) - _grp = ui.get_group(parent=main_grp) - for k, v in data[section].items(): + control_row.layout().addWidget(reset_button, 0) + + for k, v in data[section].items(): + + if section in (tokens.PublishConfig, tokens.FFMpegTCConfig, tokens.FileNameConfig): + grp = self._add_section_item(_grp, section, v) + else: _name = v['name'].title() - _name = f'{_name} Folder' if section == tokens.AssetFolderConfig else _name row = ui.add_row(_name, height=h, parent=_grp) + row.setStatusTip(v['description']) row.setWhatsThis(v['description']) row.setToolTip(v['description']) @@ -315,6 +463,8 @@ def _create_ui(self): editor.setAlignment(QtCore.Qt.AlignRight) editor.setText(v['value']) + row.layout().addWidget(editor, 1) + # Save current data key = f'{section}/{k}/value' self.current_data[key] = v['value'] @@ -323,22 +473,6 @@ def _create_ui(self): functools.partial(self.text_changed, key, editor) ) - row.layout().addWidget(editor) - - if section == tokens.PublishConfig or section == tokens.FFMpegTCConfig: - button = ui.PaintedButton('+', parent=row) - button.clicked.connect( - functools.partial(self.show_token_editor, editor) - ) - row.layout().addWidget(button, 0) - - if section == tokens.FileNameConfig: - button = ui.PaintedButton('+', parent=row) - button.clicked.connect( - functools.partial(self.show_token_editor, editor) - ) - row.layout().addWidget(button, 0) - if section == tokens.AssetFolderConfig: button = ui.PaintedButton('Formats', parent=row) row.layout().addWidget(button, 0) @@ -362,24 +496,119 @@ def _create_ui(self): else: button.setDisabled(True) + def init_data(self): """Initializes data. """ self.tokens = tokens.get(self.server, self.job, self.root) - def contextMenuEvent(self, event): - action = QtWidgets.QAction( - 'Reset all template settings to their defaults' + @QtCore.Slot(QtWidgets.QWidget) + @QtCore.Slot(int) + def move_item(self, widget, direction): + """Moves an item up or down in its own section. + + Args: + widget (QtWidgets.QWidget): The widget to move. + direction (int): The direction to move the widget in. + + """ + # Get the layout index of the widget + layout = widget.parent().layout() + index = layout.indexOf(widget) + + # Get the new index + if direction == MoveUp: + min_index = 1 # Skip the first row as this is the control row + new_index = index - 1 + new_index = min_index if new_index < min_index else new_index + else: + new_index = index + 1 + new_index = layout.count() - 1 if new_index >= layout.count() else new_index + + # Set the new layout index + layout.insertWidget(new_index, layout.takeAt(index).widget()) + + @QtCore.Slot(QtWidgets.QWidget) + @QtCore.Slot(str) + @common.error + def add_item(self, parent, section): + """Adds a new item to a section. + + Args: + parent (QtWidgets.QWidget): The parent widget. + section (str): The section to add the item to. + + """ + grp = self._add_section_item(parent, section, {'name': '', 'value': '', 'description': ''}) + + # Find the value editor + editor = next( + (x for x in grp.findChildren(QtWidgets.QLineEdit) if x.objectName() == 'section_item_value'), + None ) - action.triggered.connect(self.restore_to_defaults) + if not editor: + raise RuntimeError('Unable to find the value editor.') + editor.setFocus(QtCore.Qt.PopupFocusReason) + self.window().scroll_to_section(grp) + + @QtCore.Slot(QtWidgets.QWidget) + def remove_item(self, widget): + """Removes an item from a section. + + Args: + widget (QtWidgets.QWidget): The widget to remove. + + """ + # Prompt the user for confirmation + if common.show_message( + 'Are you sure you want to remove this item?', + body='This action cannot be undone.', + buttons=[common.YesButton, common.CancelButton], + modal=True, + ) == QtWidgets.QDialog.Rejected: + return + layout = widget.parent().layout() + layout.removeWidget(widget) + widget.deleteLater() + + @QtCore.Slot(str) + def restore_defaults(self, section): + """Restores the default values for a given section. + + Args: + section (str): The section to restore. + + """ + if common.show_message( + 'Are you sure you want to restore the default values?', + body='Any custom values will be permanently lost.', + buttons=[common.YesButton, common.CancelButton], + modal=True, + ) == QtWidgets.QDialog.Rejected: + return False + + if section not in self.ui_groups: + print(f'Invalid section: {section}. Skipping.') + return + + if section not in self.tokens.data(): + print(f'Invalid section: {section}. Skipping.') + return + + if not self.ui_groups[section]: + print(f'Invalid section: {section}. Skipping.') + return + + if not self.write_default_to_database(section): + return - menu = QtWidgets.QMenu(parent=self) - menu.addAction(action) - pos = self.mapToGlobal(event.pos()) - menu.move(pos) - menu.exec_() - menu.deleteLater() + for k in SECTIONS: + self.ui_groups[k].deleteLater() + self.ui_groups[k] = None + del self.ui_groups[k] + + self._add_section(k) @QtCore.Slot(str) @QtCore.Slot(dict) @@ -411,17 +640,29 @@ def show_filter_editor(self, key, v, data): ) editor.exec_() - @QtCore.Slot() - def restore_to_defaults(self): - if common.show_message( - 'Are you sure you want to restore all templates to the default value?', - body='Your custom settings will be permanently lost.', - buttons=[common.YesButton, common.CancelButton], - modal=True, - ) == QtWidgets.QDialog.Rejected: - return - self.tokens.set_data(tokens.DEFAULT_TOKEN_CONFIG.copy()) - self.window().close() + def write_default_to_database(self, section): + """Restore the default values for a given section and write them to the database. + + Args: + section (str): The section to restore. + + Returns: + bool: True if successful. + + """ + data = self.tokens.data(force=True) + if section not in data: + print(f'Invalid section: {section}. Skipping.') + return False + if section not in tokens.DEFAULT_TOKEN_CONFIG: + print(f'Invalid section: {section}. Skipping.') + return False + + default_section = copy.deepcopy(tokens.DEFAULT_TOKEN_CONFIG[section]) + data[section] = default_section + + self.tokens.set_data(data) + return True @QtCore.Slot(str) @QtCore.Slot(dict) @@ -436,6 +677,12 @@ def show_subfolders_editor(self, section, k, v, data): @QtCore.Slot(QtWidgets.QWidget) def show_token_editor(self, editor): + """Shows the token editor. + + Args: + editor (QtWidgets.QWidget): The editor to insert the token into. + + """ w = TokenEditor(self.server, self.job, self.root, parent=editor) w.tokenSelected.connect(editor.insert) w.exec_() @@ -475,12 +722,38 @@ def save_changes(self): """Saves changes. """ - if not self.changed_data: - return - data = self.tokens.data() + # Retrieve the current data from the database + data = self.tokens.data(force=True) + + # Update the data with the changed values for keys, v in self.changed_data.copy().items(): _set(data, keys, v) del self.changed_data[keys] + + for section in (tokens.PublishConfig, tokens.FFMpegTCConfig, tokens.FileNameConfig): + if section not in data: + print(f'"{section}" not found in data! Skipping.') + + # Reset the current data section and replace it with the values in the UI. + data[section] = {} + + # Let's find the list of section group widgets + items = [f for f in self.findChildren(QtWidgets.QWidget, 'section_item_group') if f.section == section] + for item in items: + + name_editor = item.findChildren(QtWidgets.QWidget, 'section_item_name')[0] + value_editor = item.findChildren(QtWidgets.QWidget, 'section_item_value')[0] + description_editor = item.findChildren(QtWidgets.QWidget, 'section_item_description')[0] + + if not all((name_editor.text(), value_editor.text())): + continue + + data[section][len(data[section])] = { + 'name': name_editor.text(), + 'value': value_editor.text(), + 'description': description_editor.text() if description_editor.text() else '', + } + self.tokens.set_data(data) def _connect_signals(self): diff --git a/bookmarks/topbar/buttons.py b/bookmarks/topbar/buttons.py index fe157c63a..17dbe53c2 100644 --- a/bookmarks/topbar/buttons.py +++ b/bookmarks/topbar/buttons.py @@ -293,3 +293,29 @@ def state(self): return True return False + + +class ApplicationLauncherButton(BaseControlButton): + """A button used to launch applications. + + """ + + def __init__(self, parent=None): + s = shortcuts.string( + shortcuts.MainWidgetShortcuts, + shortcuts.ApplicationLauncher + ) + super().__init__( + 'icon', + f'Application Launcher - {s}', + color=( + common.color(common.color_green), + common.color(common.color_green), + ), + parent=parent + ) + self.clicked.connect(actions.pick_launcher_item) + self.clicked.connect(self.update) + + def state(self): + return True \ No newline at end of file diff --git a/bookmarks/topbar/filters.py b/bookmarks/topbar/filters.py new file mode 100644 index 000000000..d0430041f --- /dev/null +++ b/bookmarks/topbar/filters.py @@ -0,0 +1,416 @@ +"""""" +import weakref + +from PySide2 import QtCore, QtWidgets + +from .. import common +from .. import log +from .. import ui + + +class BaseFilterModel(ui.AbstractListModel): + + def __init__(self, section_name_label, data_source, tab_index, icon, parent=None): + self.tab_index = tab_index + + self.icon = icon + self.show_all_label = ' - Show All -' + self.section_name_label = section_name_label + self.data_source = data_source + + super().__init__(parent=parent) + + common.signals.internalDataReady.connect(self.internal_data_ready) + common.signals.bookmarksChanged.connect(self.reset_data) + common.signals.bookmarkActivated.connect(self.reset_data) + + def data(self, index, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DecorationRole: + _text = common.model(self.tab_index).filter_text() + _text = _text.lower().strip() if _text else '' + + if not _text: + return super().data(index, role) + + text = super().data(index, QtCore.Qt.DisplayRole) + if not text: + return super().data(index, role) + if text == self.show_all_label: + return super().data(index, role) + if text == self.section_name_label: + return super().data(index, role) + + text = text.lower().strip() + if _text == text: + return ui.get_icon('check', color=common.color(common.color_green)) + + if _text == f'"{text}"': + return ui.get_icon('check', color=common.color(common.color_green)) + + return super().data(index, role) + + @QtCore.Slot(weakref.ref) + def internal_data_ready(self, ref): + if not ref(): + return + + source_model = common.source_model(self.tab_index) + data = common.get_data( + source_model.source_path(), source_model.task(), source_model.data_type() + ) + + if ref() != data: + return + + self.reset_data() + + def reset_data(self): + self.beginResetModel() + self.init_data() + self.endResetModel() + + def init_data(self, *args, **kwargs): + """Initializes data. + + """ + self._data = common.DataDict() + + source_model = common.source_model(self.tab_index) + + data = common.get_data( + source_model.source_path(), source_model.task(), source_model.data_type() + ) + + if not hasattr(data, self.data_source): + log.error(f'No {self.data_source} found in data!') + return + if not getattr(data, self.data_source): + return + + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: self.section_name_label, + QtCore.Qt.SizeHintRole: QtCore.QSize(1, common.size(common.size_row_height) * 0.66), + common.FlagsRole: QtCore.Qt.NoItemFlags, + QtCore.Qt.TextAlignmentRole: QtCore.Qt.AlignLeft | QtCore.Qt.AlignBottom, + } + + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: '', + common.FlagsRole: QtCore.Qt.NoItemFlags, + QtCore.Qt.SizeHintRole:QtCore.QSize(1, common.size(common.size_separator)), + } + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: '', + common.FlagsRole: QtCore.Qt.NoItemFlags, + QtCore.Qt.SizeHintRole: QtCore.QSize(1, common.size(common.size_separator)), + } + + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: self.show_all_label, + QtCore.Qt.DecorationRole: ui.get_icon('archivedVisible', color=common.color(common.color_green)), + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.TextAlignmentRole: QtCore.Qt.AlignCenter, + } + + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: '', + common.FlagsRole: QtCore.Qt.NoItemFlags, + QtCore.Qt.SizeHintRole:QtCore.QSize(1, common.size(common.size_separator)), + } + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: '', + common.FlagsRole: QtCore.Qt.NoItemFlags, + QtCore.Qt.SizeHintRole:QtCore.QSize(1, common.size(common.size_separator)), + } + + icon = ui.get_icon(self.icon) + + for v in sorted(getattr(data, self.data_source)): + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: v, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.DecorationRole: icon, + QtCore.Qt.StatusTipRole: v, + QtCore.Qt.AccessibleDescriptionRole: v, + QtCore.Qt.WhatsThisRole: v, + QtCore.Qt.ToolTipRole: v, + QtCore.Qt.TextAlignmentRole: QtCore.Qt.AlignCenter, + } + + +class BaseFilterButton(QtWidgets.QComboBox): + """The combo box used to set a text filter based on the available ShotGrid task names. + + """ + + def __init__(self, Model, tab_index, parent=None): + super().__init__(parent=parent) + + self.tab_index = tab_index + view = QtWidgets.QListView(parent=self) + view.setSizeAdjustPolicy( + QtWidgets.QAbstractScrollArea.AdjustToContents + ) + self.setView(view) + self.setModel(Model()) + + self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) + self.setFixedHeight(common.size(common.size_margin)) + self.setMinimumWidth(common.size(common.size_margin) * 3) + self.setMaxVisibleItems(48) + + min_width = self.minimumSizeHint().width() + self.view().setMinimumWidth(min_width * 3) + + common.signals.updateTopBarButtons.connect(lambda: self.setHidden(not common.current_tab() == self.tab_index)) + common.model(self.tab_index).filterTextChanged.connect(self.select_text) + common.signals.internalDataReady.connect(self.select_text) + + self.textActivated.connect(self.update_filter_text) + + @QtCore.Slot(str) + def update_filter_text(self, text): + """Update the filter text. + + Args: + text (str): The text to set as the filter text. + + """ + if text == self.model().show_all_label: + text = '' + else: + text = f'"{text.lower().strip()}"' + + common.model(self.tab_index).set_filter_text(text) + + @QtCore.Slot() + def select_text(self, *args, **kwargs): + """Update the filter text. + + """ + self.setCurrentIndex(0) + + _text = common.model(self.tab_index).filter_text() + _text = _text.lower().strip() if _text else '' + + if not _text: + return + + for i in range(self.count()): + text = self.itemText(i) + if not text: + continue + if text == self.model().show_all_label: + continue + if text == self.model().section_name_label: + continue + + text = text.lower().strip() + if _text == text: + self.setCurrentIndex(i) + return + + if _text == f'"{text}"': + self.setCurrentIndex(i) + return + + +class TaskFilterModel(BaseFilterModel): + + def __init__(self, parent=None): + super().__init__( + 'Tasks', 'sg_task_names', common.AssetTab, 'sg', parent=parent + ) + + +class TaskFilterButton(BaseFilterButton): + """The combo box used to set a text filter based on the available ShotGrid task names. + + """ + + def __init__(self, parent=None): + super().__init__( + TaskFilterModel, common.AssetTab, parent=parent + ) + + +class EntityFilterModel(BaseFilterModel): + + def __init__(self, parent=None): + super().__init__( + 'Assets', 'shotgun_names', common.AssetTab, 'sg', parent=parent + ) + + +class EntityFilterButton(BaseFilterButton): + """The combo box used to set a text filter based on the available ShotGrid task names. + + """ + + def __init__(self, parent=None): + super().__init__( + EntityFilterModel, common.AssetTab, parent=parent + ) + + +class TypeFilterModel(BaseFilterModel): + + def __init__(self, parent=None): + super().__init__( + 'File Types', 'file_types', common.FileTab, 'file', parent=parent + ) + + common.signals.assetActivated.connect(self.reset_data) + common.signals.taskFolderChanged.connect(self.reset_data) + + +class TypeFilterButton(BaseFilterButton): + """The combo box used to set a text filter based on the available file types + + """ + + def __init__(self, parent=None): + super().__init__( + TypeFilterModel, common.FileTab, parent=parent + ) + + +class SubdirFilterModel(BaseFilterModel): + + def __init__(self, parent=None): + super().__init__( + 'Folders', 'subdirectories', common.FileTab, 'folder', parent=parent + ) + + common.signals.assetActivated.connect(self.reset_data) + common.signals.taskFolderChanged.connect(self.reset_data) + + def init_data(self, *args, **kwargs): + super().init_data(*args, **kwargs) + + data = {} + + insert_idx = 3 + + for idx, v in self._data.items(): + if idx == 1: + data[idx] = v + + k = '- Hide Folders -' + data[idx + insert_idx] = { + QtCore.Qt.DisplayRole: k, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.DecorationRole: ui.get_icon('archivedHidden', color=common.color(common.color_red)), + QtCore.Qt.StatusTipRole: k, + QtCore.Qt.AccessibleDescriptionRole: k, + QtCore.Qt.WhatsThisRole: k, + QtCore.Qt.ToolTipRole: k, + QtCore.Qt.TextAlignmentRole: QtCore.Qt.AlignCenter, + } + continue + + if idx > insert_idx: + idx += 1 + data[idx] = v + + self._data = data + +class SubdirFilterButton(BaseFilterButton): + """The combo box used to set a text filter based on the available file types + + """ + + def __init__(self, parent=None): + super().__init__( + SubdirFilterModel, common.FileTab, parent=parent + ) + + + @QtCore.Slot(str) + def update_filter_text(self, text): + """Update the filter text. + + Args: + text (str): The text to set as the filter text. + + """ + if text == '- Hide Folders -': + filter_texts = [] + for i in range(self.count()): + text = self.itemText(i) + if not text: + continue + if text == self.model().show_all_label: + continue + if text == self.model().section_name_label: + continue + if text == '- Hide Folders -': + continue + filter_texts.append(f'--"{text}"') + common.model(self.tab_index).set_filter_text(' '.join(filter_texts)) + else: + super().update_filter_text(text) +class ServersFilterModel(BaseFilterModel): + + def __init__(self, parent=None): + super().__init__( + 'Servers', 'servers', common.BookmarkTab, 'icon', parent=parent + ) + + common.signals.assetActivated.connect(self.reset_data) + common.signals.taskFolderChanged.connect(self.reset_data) + + +class ServersFilterButton(BaseFilterButton): + """The combo box used to set a text filter based on the available file types + + """ + + def __init__(self, parent=None): + super().__init__( + ServersFilterModel, common.BookmarkTab, parent=parent + ) + + +class JobsFilterModel(BaseFilterModel): + + def __init__(self, parent=None): + super().__init__( + 'Jobs', 'jobs', common.BookmarkTab, 'icon', parent=parent + ) + + common.signals.assetActivated.connect(self.reset_data) + common.signals.taskFolderChanged.connect(self.reset_data) + + +class JobsFilterButton(BaseFilterButton): + """The combo box used to set a text filter based on the available file types + + """ + + def __init__(self, parent=None): + super().__init__( + JobsFilterModel, common.BookmarkTab, parent=parent + ) + + +class RootsFilterModel(BaseFilterModel): + + def __init__(self, parent=None): + super().__init__( + 'Bookmarks', 'roots', common.BookmarkTab, 'icon', parent=parent + ) + + common.signals.assetActivated.connect(self.reset_data) + common.signals.taskFolderChanged.connect(self.reset_data) + + +class RootsFilterButton(BaseFilterButton): + """The combo box used to set a text filter based on the available file types + + """ + + def __init__(self, parent=None): + super().__init__( + RootsFilterModel, common.BookmarkTab, parent=parent + ) diff --git a/bookmarks/topbar/quickswitch.py b/bookmarks/topbar/quickswitch.py index 17a5fa895..90bcda018 100644 --- a/bookmarks/topbar/quickswitch.py +++ b/bookmarks/topbar/quickswitch.py @@ -51,13 +51,9 @@ def add_switch_menu(self, idx, label): 'disabled': True } - active = False model = common.model(idx) for n in range(model.rowCount()): - index = QtCore.QPersistentModelIndex(model.index(n, 0)) - path = index.data(common.PathRole) - - active = active_index.isValid() and active_index.data(common.PathRole) == path + index = model.index(n, 0) pixmap, _ = images.get_thumbnail( index.data(common.ParentPathRole)[0], @@ -67,22 +63,14 @@ def add_switch_menu(self, idx, label): size=common.size(common.size_margin) * 4, fallback_thumb='icon_bw' ) - if pixmap and not pixmap.isNull(): - icon = QtGui.QIcon(pixmap) - else: - icon = item_icon - if active: + if not pixmap or pixmap.isNull(): + icon = ui.get_icon('icon_bw') + elif active_index.isValid() and active_index.data(common.PathRole) == index.data(common.PathRole): icon = on_icon + else: + icon = QtGui.QIcon(pixmap) - name = path - if len(index.data(common.ParentPathRole)) > 3: - name = '/'.join(index.data(common.ParentPathRole)[3:]) - elif len(index.data(common.ParentPathRole)) <= 3: - name = '/'.join(index.data(common.ParentPathRole)[1:]) - if index.data(common.AssetCountRole): - name = f'{name} | {index.data(common.AssetCountRole)} items' - name = name.replace('/', ' > ') - + name = index.data(QtCore.Qt.DisplayRole) self.menu[contextmenu.key()] = { 'text': name, 'icon': icon, @@ -108,7 +96,7 @@ def add_menu(self): self.menu['add'] = { 'icon': ui.get_icon('add', color=common.color(common.color_green)), 'text': 'Manage Bookmark Items...', - 'action': actions.show_bookmarker, + 'action': actions.show_job_editor, 'shortcut': shortcuts.string( shortcuts.MainWidgetShortcuts, shortcuts.AddItem diff --git a/bookmarks/topbar/tabs.py b/bookmarks/topbar/tabs.py index d10891750..a2ca9c42d 100644 --- a/bookmarks/topbar/tabs.py +++ b/bookmarks/topbar/tabs.py @@ -308,7 +308,7 @@ class FavouritesTabButton(BaseTabButton): def __init__(self, parent=None): super().__init__( - 'Starred', + 'Favourites', common.FavouriteTab, 'Click to see your saved favourites', parent=parent diff --git a/bookmarks/topbar/topbar.py b/bookmarks/topbar/topbar.py index 94dd42b5c..4fd9d2b2a 100644 --- a/bookmarks/topbar/topbar.py +++ b/bookmarks/topbar/topbar.py @@ -4,11 +4,12 @@ from PySide2 import QtWidgets, QtGui, QtCore from . import buttons +from . import filters from . import tabs from .. import common from .. import images +from .. import ui -n = (f for f in range(common.FavouriteTab + 1, 999)) BUTTONS = { common.BookmarkTab: { @@ -27,62 +28,327 @@ 'widget': tabs.FavouritesTabButton, 'hidden': False, }, - next(n): { - 'widget': buttons.RefreshButton, + common.idx(reset=True, start=common.FavouriteTab + 1): { + 'widget': buttons.ApplicationLauncherButton, 'hidden': False, }, - next(n): { + common.idx(): { + 'widget': filters.JobsFilterButton, + 'hidden': True, + }, + common.idx(): { + 'widget': filters.EntityFilterButton, + 'hidden': True, + }, + common.idx(): { + 'widget': filters.TaskFilterButton, + 'hidden': True, + }, + common.idx(): { + 'widget': filters.SubdirFilterButton, + 'hidden': True, + }, + common.idx(): { + 'widget': filters.TypeFilterButton, + 'hidden': True, + }, + common.idx(): { 'widget': buttons.FilterButton, 'hidden': False, }, - next(n): { + common.idx(): { + 'widget': buttons.RefreshButton, + 'hidden': False, + }, + common.idx(): { 'widget': buttons.ToggleSequenceButton, 'hidden': False, }, - next(n): { + common.idx(): { 'widget': buttons.ToggleArchivedButton, 'hidden': False, }, - next(n): { + common.idx(): { 'widget': buttons.ToggleFavouriteButton, 'hidden': False, }, - next(n): { + common.idx(): { 'widget': buttons.ToggleInlineIcons, 'hidden': False, }, } -class TopBarWidget(QtWidgets.QWidget): - """The bar above the stacked widget containing the main app control buttons. +class ContextStatusBar(QtWidgets.QWidget): + """The widget used to draw an informative status label below the main bar. + + The status label will display the current active context. """ def __init__(self, parent=None): - super().__init__(parent=parent) + super().__init__( + parent=parent + ) self.setAttribute(QtCore.Qt.WA_NoSystemBackground, True) self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) - self._buttons = {} + self.label_widget = None + self.note_widget = None + + self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu) self._create_ui() + self._connect_signals() def _create_ui(self): + o = common.size(common.size_indicator) * 3 + height = common.size(common.size_margin) + o + + self.setFixedHeight(height) + QtWidgets.QHBoxLayout(self) - self.layout().setContentsMargins(0, 0, 0, 0) - self.layout().setSpacing(0) self.layout().setAlignment(QtCore.Qt.AlignCenter) + self.layout().setContentsMargins(o, 0, o, 0) + self.layout().setSpacing(0) + + self.label_widget = ui.PaintedLabel( + '', + color=common.color(common.color_text), + size=common.size(common.size_font_medium) * 1.1, + parent=self + ) + self.note_widget = ui.PaintedLabel( + '', + color=common.color(common.color_blue), + size=common.size(common.size_font_medium) * 0.9, + parent=self + ) + + + self.arrow_left_button = ui.ClickableIconButton( + 'arrow_left', + (common.color(common.color_text), common.color(common.color_text)), + size=common.size(common.size_margin), + description='Previous item', + parent=self + ) + + self.arrow_right_button = ui.ClickableIconButton( + 'arrow_right', + (common.color(common.color_text), common.color(common.color_text)), + size=common.size(common.size_margin), + description='Next item', + parent=self + ) + + self.layout().addStretch() + self.layout().addWidget(self.arrow_left_button) + self.layout().addSpacing(o) + self.layout().addWidget(self.label_widget) + self.layout().addWidget(self.note_widget) + self.layout().addSpacing(o) + self.layout().addWidget(self.arrow_right_button) + self.layout().addStretch() + + def _connect_signals(self): + common.signals.bookmarkActivated.connect(self.update) + common.signals.assetActivated.connect(self.update) + common.signals.taskFolderChanged.connect(self.update) + common.signals.tabChanged.connect(self.update) + common.signals.updateTopBarButtons.connect(self.update) + + self.arrow_left_button.clicked.connect(self.arrow_left) + self.arrow_right_button.clicked.connect(self.arrow_right) + + self.label_widget.clicked.connect(self.show_quick_switch_menu) + + @QtCore.Slot() + def arrow_left(self): + """Slot responsible for activating the previous index in the current tab's parent view. + + """ + idx = common.current_tab() + if idx == common.BookmarkTab: + return + + widget = common.widget(idx - 1) + if not widget: + return + + index = widget.model().mapFromSource(widget.model().sourceModel().active_index()) + + widget.selectionModel().select( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + widget.selectionModel().setCurrentIndex( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + + widget.key_up() + widget.key_enter() + + @QtCore.Slot() + def arrow_right(self): + """Slot responsible for activating the next index in the current tab's parent view. + + """ + idx = common.current_tab() + if idx == common.BookmarkTab: + return + + widget = common.widget(idx - 1) + if not widget: + return + + index = widget.model().mapFromSource(widget.model().sourceModel().active_index()) + + widget.selectionModel().select( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + widget.selectionModel().setCurrentIndex( + index, + QtCore.QItemSelectionModel.ClearAndSelect | + QtCore.QItemSelectionModel.Rows + ) + + widget.key_down() + widget.key_enter() + + def contextMenuEvent(self, event): + self.label_widget.clicked.emit() + + @QtCore.Slot() + def show_quick_switch_menu(self): + """Slot responsible for showing the quick switch menu. + + """ + idx = common.current_tab() + + from . import quickswitch + if idx == common.AssetTab: + menu = quickswitch.SwitchBookmarkMenu( + QtCore.QModelIndex(), + parent=self + ) + elif idx == common.FileTab: + menu = quickswitch.SwitchAssetMenu( + QtCore.QModelIndex(), + parent=self + ) + else: + return + + # Move the menu to the left of the label and just below it + menu.move( + self.label_widget.mapToGlobal( + QtCore.QPoint( + 0, + self.label_widget.height() + ) + ) + ) + menu.exec_() + + + @QtCore.Slot() + def update(self, *args, **kwargs): + """Update the informative labels based on the current context. + + """ + idx = common.current_tab() + + if idx == common.BookmarkTab: + self.setHidden(True) + else: + self.setHidden(False) + + display_name = '' + if idx > common.BookmarkTab: + active_index = common.active_index(idx - 1) + if active_index and active_index.isValid(): + display_name = active_index.data(QtCore.Qt.DisplayRole) + + if idx == common.FileTab: + task = common.active('task') + task = task if task else '(no asset folder selected)' + display_name = f'{display_name}/{task}' + + display_name = display_name.strip(' _-').replace('/', ' • ') + + if idx == common.FavouriteTab: + display_name = 'Favourites' + self.arrow_left_button.setHidden(True) + self.arrow_right_button.setHidden(True) + else: + self.arrow_left_button.setHidden(False) + self.arrow_right_button.setHidden(False) + + self.label_widget.setText(display_name) + + # Update note widget + source_model = common.source_model(common.current_tab()) + p = source_model.source_path() + k = source_model.task() + t = source_model.data_type() + + data = common.get_data(p, k, t) + if data and data.refresh_needed: + self.note_widget.setHidden(False) + self.note_widget.setText('(refresh needed)') + else: + self.note_widget.setHidden(True) + self.note_widget.setText('') + + + + +class TopBarWidget(QtWidgets.QWidget): + """The bar above the stacked widget containing the main app control buttons. + + """ + + def __init__(self, parent=None): + super().__init__(parent=parent) + + self._buttons = {} + + self._create_ui() + self._connect_signals() + + def _create_ui(self): o = common.size(common.size_indicator) * 3 height = common.size(common.size_margin) + o - self.setFixedHeight(height) + QtWidgets.QVBoxLayout(self) + + self.layout().setContentsMargins(0, 0, 0, 0) + self.layout().setSpacing(0) + self.layout().setAlignment(QtCore.Qt.AlignTop) + self.setAttribute(QtCore.Qt.WA_NoBackground, True) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + self.setSizePolicy( + QtWidgets.QSizePolicy.MinimumExpanding, + QtWidgets.QSizePolicy.Maximum + ) + # Buttons bar widget = QtWidgets.QWidget() + QtWidgets.QHBoxLayout(widget) + widget.layout().setContentsMargins(0, 0, o, 0) widget.layout().setSpacing(o) - widget.setAttribute(QtCore.Qt.WA_NoBackground, True) + widget.setFixedHeight(height) + + widget.setAttribute(QtCore.Qt.WA_NoSystemBackground, True) widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) for idx in BUTTONS: @@ -92,30 +358,21 @@ def _create_ui(self): if idx > common.FavouriteTab: widget.layout().addWidget(self._buttons[idx], 0) else: - self.layout().addWidget(self._buttons[idx], 1) + widget.layout().addWidget(self._buttons[idx], 1) if idx == common.FavouriteTab: - self.layout().addStretch() + widget.layout().addStretch() self.layout().addWidget(widget) + widget = ContextStatusBar(parent=self) + self.layout().addWidget(widget, 1) + widget.setHidden(True) + + def _connect_signals(self): + pass + def button(self, idx): if idx not in self._buttons: raise ValueError('Button does not exist') return self._buttons[idx] - - def paintEvent(self, event): - """`TopBarWidget`' paint event.""" - painter = QtGui.QPainter() - painter.begin(self) - painter.setPen(QtCore.Qt.NoPen) - - pixmap = images.rsc_pixmap( - 'gradient', None, self.height() - ) - t = QtGui.QTransform() - t.rotate(90) - pixmap = pixmap.transformed(t) - painter.setOpacity(0.8) - painter.drawPixmap(self.rect(), pixmap, pixmap.rect()) - painter.end() diff --git a/bookmarks/ui.py b/bookmarks/ui.py index 8fae55e82..8c57b3b6c 100644 --- a/bookmarks/ui.py +++ b/bookmarks/ui.py @@ -199,18 +199,31 @@ def set_clicked_button(self, button): self._clicked_button = button def set_labels(self, title, body): + """Sets the message box's labels. + + Args: + title (str): The message box's title. + body (str): The message box's body. + + """ self.title_label.setText(title) self.body_label.setText(body) + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) + def sizeHint(self): - """Returns a size hint.""" + """Returns a size hint. + + """ return QtCore.QSize( common.size(common.size_width * 0.90), common.size(common.size_height * 0.66) ) def showEvent(self, event): - """Override the show event to start the fade in animation.""" + """Override the show event to start the fade in animation. + + """ common.center_to_parent(self) common.move_widget_to_available_geo(self) @@ -283,7 +296,7 @@ class LineEdit(QtWidgets.QLineEdit): """Custom line edit widget with a single underline.""" def __init__(self, parent=None): - super(LineEdit, self).__init__(parent=parent) + super().__init__(parent=parent) self.setSizePolicy( QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.MinimumExpanding, @@ -361,7 +374,10 @@ def paintEvent(self, event): class PaintedLabel(QtWidgets.QLabel): - """QLabel used for static aliased label.""" + """QLabel used for static aliased label. + + """ + clicked = QtCore.Signal() def __init__( self, text, color=common.color(common.color_text), @@ -369,9 +385,21 @@ def __init__( parent=None ): super().__init__(text, parent=parent) + self._size = size self._color = color self._text = text + + self.update_size() + + def setText(self, v): + self._text = v + self.update_size() + + super().setText(v) + + def set_color(self, v): + self._color = v self.update_size() def update_size(self): @@ -393,12 +421,12 @@ def paintEvent(self, event): painter.begin(self) hover = option.state & QtWidgets.QStyle.State_MouseOver - pressed = option.state & QtWidgets.QStyle.State_Sunken - focus = option.state & QtWidgets.QStyle.State_HasFocus + # pressed = option.state & QtWidgets.QStyle.State_Sunken + # focus = option.state & QtWidgets.QStyle.State_HasFocus disabled = not self.isEnabled() o = 1.0 if hover else 0.8 - o = 0.3 if disabled else 1.0 + o = 0.3 if disabled else o painter.setOpacity(o) rect = self.rect() @@ -425,6 +453,18 @@ def enterEvent(self, event): """ self.update() + def mouseReleaseEvent(self, event): + """Event handler. + + """ + if not isinstance(event, QtGui.QMouseEvent): + return + # Check if the click was inside the label + if not self.rect().contains(event.pos()): + return + if event.button() == QtCore.Qt.LeftButton: + self.clicked.emit() + class ClickableIconButton(QtWidgets.QLabel): """A utility class for creating a square icon button. @@ -577,11 +617,12 @@ def __init__(self, parent=None): ) @QtCore.Slot(str) - def set_message(self, message): - if message == self._message: + @QtCore.Slot(str) + def set_message(self, title, body): + if title == self._message: return - self._message = message + self._message = title self.update() def paintEvent(self, event): @@ -637,11 +678,14 @@ class ListWidgetDelegate(QtWidgets.QStyledItemDelegate): def paint(self, painter, option, index): checked = index.data(QtCore.Qt.CheckStateRole) == QtCore.Qt.Checked + hover = option.state & QtWidgets.QStyle.State_MouseOver selected = option.state & QtWidgets.QStyle.State_Selected focus = option.state & QtWidgets.QStyle.State_HasFocus + opened = option.state & QtWidgets.QStyle.State_Open checkable = index.flags() & QtCore.Qt.ItemIsUserCheckable decoration = index.data(QtCore.Qt.DecorationRole) + text = index.data(QtCore.Qt.DisplayRole) disabled = index.flags() == QtCore.Qt.NoItemFlags @@ -656,38 +700,42 @@ def paint(self, painter, option, index): rect = option.rect.adjusted(o * 0.3, o * 0.3, -o * 0.3, -o * 0.3) # Background - _o = 0.6 if hover else 0.2 - _o = 0.1 if disabled else _o - _o = 1.0 if selected else _o - painter.setOpacity(_o) - painter.setPen(QtCore.Qt.NoPen) + if index.column() == 0: + _o = 0.6 if hover else 0.2 + _o = 0.1 if disabled else _o + _o = 1.0 if selected else _o + painter.setOpacity(_o) + painter.setPen(QtCore.Qt.NoPen) - if selected or hover: - painter.setBrush(common.color(common.color_light_background)) - else: - painter.setBrush(common.color(common.color_separator)) - painter.drawRoundedRect(rect, o, o) + if selected or hover: + color = common.color(common.color_light_background) + elif opened: + color = common.color(common.color_separator) + else: + color = QtGui.QColor(0, 0, 0, 0) - if focus: - painter.setBrush(QtCore.Qt.NoBrush) - pen = QtGui.QPen(common.color(common.color_blue)) - pen.setWidthF(common.size(common.size_separator)) - painter.setPen(pen) + painter.setBrush(color) painter.drawRoundedRect(rect, o, o) - # Checkbox - rect = QtCore.QRect(rect) + if focus: + painter.setBrush(QtCore.Qt.NoBrush) + pen = QtGui.QPen(common.color(common.color_blue)) + pen.setWidthF(common.size(common.size_separator)) + painter.setPen(pen) + painter.drawRoundedRect(rect, o, o) + + # image rectangle + painter.setPen(QtCore.Qt.NoPen) + _ = painter.setOpacity(1.0) if hover else painter.setOpacity(0.9) + + rect = QtCore.QRect(option.rect) rect.setWidth(rect.height()) center = rect.center() + h = common.size(common.size_margin) rect.setSize(QtCore.QSize(h, h)) rect.moveCenter(center) - h = rect.height() / 2.0 - painter.setPen(QtCore.Qt.NoPen) - - _ = painter.setOpacity(1.0) if hover else painter.setOpacity(0.9) - if checkable and checked: pixmap = images.rsc_pixmap( 'check', common.color(common.color_green), rect.height() @@ -713,26 +761,27 @@ def paint(self, painter, option, index): mode, QtGui.QIcon.On ) - else: - rect.setWidth(o * 2) - - # Label - font, metrics = common.font_db.bold_font( - common.size(common.size_font_small) - ) _fg = index.data(QtCore.Qt.ForegroundRole) color = _fg if _fg else common.color(common.color_text) color = common.color(common.color_selected_text) if selected else color color = common.color(common.color_text) if checked else color - + color = common.color(common.color_selected_text) if opened else color painter.setBrush(color) - x = rect.right() + common.size(common.size_indicator) * 3 + # Label + padding = common.size(common.size_indicator) * 2 + x = rect.right() + padding + + font, metrics = common.font_db.bold_font( + common.size(common.size_font_small) + ) + + width = option.rect.width() - (rect.right() - option.rect.left()) - padding * 2 text = metrics.elidedText( text, QtCore.Qt.ElideRight, - option.rect.width() - x - common.size(common.size_indicator), + width ) y = option.rect.center().y() + (metrics.ascent() / 2.0) @@ -745,19 +794,22 @@ def paint(self, painter, option, index): painter.drawPath(path) def sizeHint(self, option, index): - _, metrics = common.font_db.bold_font( - common.size(common.size_font_small) - ) + opened = option.state & QtWidgets.QStyle.State_Open + x = 1 if not opened else 1.4 - width = ( - metrics.horizontalAdvance(index.data(QtCore.Qt.DisplayRole)) + - common.size(common.size_row_height) + - common.size(common.size_margin) - ) - return QtCore.QSize( - width, - common.size(common.size_row_height) - ) + if index.isValid() and index.data(QtCore.Qt.SizeHintRole): + height = index.data(QtCore.Qt.SizeHintRole).height() * x + else: + height = common.size(common.size_row_height) * x + + padding = common.size(common.size_indicator) * 2 + if index.data(QtCore.Qt.DisplayRole): + _, metrics = common.font_db.bold_font(common.size(common.size_font_small)) + text_width = metrics.boundingRect(index.data(QtCore.Qt.DisplayRole)).width() + width = padding + common.size(common.size_margin) + padding + text_width + padding + else: + width = 0 + return QtCore.QSize(width, height) def createEditor(self, parent, option, index): """Custom editor for editing the template's name. @@ -783,7 +835,7 @@ class ListWidget(QtWidgets.QListWidget): """A custom list widget used to display selectable item. """ - progressUpdate = QtCore.Signal(str) + progressUpdate = QtCore.Signal(str, str) resized = QtCore.Signal(QtCore.QSize) def __init__(self, default_message='No items', default_icon='icon', parent=None): @@ -806,10 +858,13 @@ def __init__(self, default_message='No items', default_icon='icon', parent=None) self.setAcceptDrops(False) self.setDragEnabled(False) self.setSpacing(0) + self.setItemDelegate(ListWidgetDelegate(parent=self)) + self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu) self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectItems) self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.setMouseTracking(True) self.viewport().setMouseTracking(True) @@ -838,7 +893,7 @@ def _connect_signals(self): self.resized.connect(self.overlay.resize) self.progressUpdate.connect(self.overlay.set_message) - self.progressUpdate.connect(common.signals.showStatusTipMessage) + self.progressUpdate.connect(lambda x, y: common.signals.showStatusTipMessage.emit(x)) self.itemEntered.connect( lambda item: common.signals.showStatusTipMessage.emit( @@ -900,7 +955,7 @@ class ListViewWidget(QtWidgets.QListView): """A custom list widget used to display selectable item. """ - progressUpdate = QtCore.Signal(str) + progressUpdate = QtCore.Signal(str, str) resized = QtCore.Signal(QtCore.QSize) itemEntered = QtCore.Signal(QtCore.QModelIndex) @@ -960,7 +1015,7 @@ def eventFilter(self, widget, event): def _connect_signals(self): self.resized.connect(self.overlay.resize) self.progressUpdate.connect(self.overlay.set_message) - self.progressUpdate.connect(common.signals.showStatusTipMessage) + self.progressUpdate.connect(lambda x, y: common.signals.showStatusTipMessage.emit(x)) self.itemEntered.connect( lambda item: common.signals.showStatusTipMessage.emit( @@ -1036,7 +1091,7 @@ def resizeEvent(self, event): def get_icon( name, color=common.color(common.color_disabled_text), - size=common.size(common.size_row_height), + size=common.size(common.size_row_height) * 2, opacity=1.0, resource=common.GuiResource ): @@ -1196,7 +1251,7 @@ def add_line_edit(label, parent=None): def add_description( - text, label=' ', color=common.color(common.color_secondary_text), parent=None + text, label=' ', height=None, color=common.color(common.color_secondary_text), parent=None ): """Utility method for adding a description field. @@ -1204,7 +1259,7 @@ def add_description( QLabel: the added QLabel. """ - row = add_row(label, height=None, parent=parent) + row = add_row(label, height=height, parent=parent) row.layout().setSpacing(0) label = Label(text, color=color, parent=parent) @@ -1214,7 +1269,6 @@ def add_description( label.setFocusPolicy(QtCore.Qt.NoFocus) return row - def paint_background_icon(name, widget): """Paints a decorative background icon to the middle of the given widget. @@ -1372,20 +1426,27 @@ def __init__( ): super().__init__(parent=parent) + self.anim = None self.scroll_area = None self.columns = columns self._label = label self._item_height = item_height self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.FramelessWindowHint) - self.setAttribute(QtCore.Qt.WA_NoSystemBackground) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - self.setWindowOpacity(0.8) + self.setWindowOpacity(0.95) + + self.installEventFilter(self) self._create_ui() self.init_data() + def eventFilter(self, widget, event): + if event.type() == QtCore.QEvent.WindowDeactivate: + self.close() + return True + return False + def _create_ui(self): if not self.parent(): common.set_stylesheet(self) @@ -1405,6 +1466,7 @@ def _create_ui(self): parent=self ) self.layout().addWidget(label) + self.layout().addSpacing(common.size(common.size_margin) * 1.5) _width = ( (common.size(common.size_indicator) * 2) + @@ -1429,9 +1491,7 @@ def _create_ui(self): ) widget = QtWidgets.QWidget(parent=self) - widget.setStyleSheet( - f'background-color: {common.rgb(common.color_separator)};' - ) + widget.eventFilter = self.eventFilter QtWidgets.QGridLayout(widget) widget.layout().setAlignment(QtCore.Qt.AlignCenter) @@ -1443,10 +1503,8 @@ def _create_ui(self): self.scroll_area.setWidgetResizable(True) self.scroll_area.setWidget(widget) - # self.layout().addSpacing(common.size(common.size_margin)) self.layout().addWidget(self.scroll_area, 1) - - self.setFocusProxy(widget) + self.setFocusProxy(self.scroll_area.widget()) def init_data(self): """Initializes data. @@ -1480,21 +1538,6 @@ def item_generator(self): """ raise NotImplementedError('Abstract method must be implemented by subclass.') - def paintEvent(self, event): - painter = QtGui.QPainter() - painter.begin(self) - painter.setBrush(common.color(common.color_separator)) - pen = QtGui.QPen(QtGui.QColor(0, 0, 0, 150)) - pen.setWidth(common.size(common.size_separator)) - painter.setPen(pen) - painter.setRenderHint(QtGui.QPainter.Antialiasing) - o = common.size(common.size_indicator) * 2.0 - painter.drawRoundedRect( - self.rect().marginsRemoved(QtCore.QMargins(o, o, o, o)), - o, o - ) - painter.end() - def focusOutEvent(self, event): self.accept() # or self.reject() @@ -1502,17 +1545,18 @@ def showEvent(self, event): """Show event handler. """ - if not self.parent(): - common.center_window(self) + common.center_to_parent(self, common.main_widget) + common.move_widget_to_available_geo(self) self.anim = QtCore.QPropertyAnimation(self, b'windowOpacity') self.anim.setDuration(500) # Animation duration in milliseconds self.anim.setStartValue(0) self.anim.setEndValue(0.95) self.anim.setEasingCurve(QtCore.QEasingCurve.OutCubic) - self.anim.start() + self.anim.start(QtCore.QAbstractAnimation.DeleteWhenStopped) - self.scroll_area.widget().setFocus(QtCore.Qt.PopupFocusReason) + self.anim.finished.connect(self.raise_) + self.anim.finished.connect(lambda: self.setFocus(QtCore.Qt.PopupFocusReason)) def done(self, r): if r == QtWidgets.QDialog.Rejected: @@ -1524,7 +1568,7 @@ def done(self, r): self.anim.start() self.anim.finished.connect(lambda: super(GalleryWidget, self).done(r)) else: - super(GalleryWidget, self).done(r) + super().done(r) class AbstractListModel(QtCore.QAbstractListModel): @@ -1538,10 +1582,7 @@ def __init__(self, parent=None): super().__init__(parent=parent) self._data = {} - - self.beginResetModel() - self.init_data() - self.endResetModel() + self.reset_data() def rowCount(self, parent=QtCore.QModelIndex()): return len(self._data) @@ -1549,6 +1590,12 @@ def rowCount(self, parent=QtCore.QModelIndex()): def display_name(self, v): return v.replace('/', ' | ') + def reset_data(self): + """Resets the model's data.""" + self.beginResetModel() + self.init_data() + self.endResetModel() + def init_data(self, *args, **kwargs): raise NotImplementedError('Abstract method must be implemented by subclass.') diff --git a/bookmarks/versioncontrol/versioncontrol.py b/bookmarks/versioncontrol/versioncontrol.py index 62c103ef9..e95361b9e 100644 --- a/bookmarks/versioncontrol/versioncontrol.py +++ b/bookmarks/versioncontrol/versioncontrol.py @@ -234,7 +234,7 @@ def check(): if total_length is None: # no content length header progress_widget.setMaximum(0) progress_widget.forceShow() - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) f.write(response.content) progress_widget.close() return @@ -245,7 +245,7 @@ def check(): progress_widget.forceShow() while True: - QtWidgets.QApplication.instance().processEvents() + QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) data = response.read(4096) if not data: break diff --git a/docs/src/conf.py b/docs/src/conf.py index e5da1e39a..f238bdff3 100644 --- a/docs/src/conf.py +++ b/docs/src/conf.py @@ -33,7 +33,7 @@ author = 'Gergely Wootsch' # The full version, including alpha/beta/rc tags -release = '0.8.5' +release = '0.9.1' html_baseurl = 'https://bookmarks-vfx.com' html_extra_path = [ diff --git a/docs/src/generate_modules_skeleton.py b/docs/src/generate_modules_skeleton.py index fb71421e0..3b86607b9 100644 --- a/docs/src/generate_modules_skeleton.py +++ b/docs/src/generate_modules_skeleton.py @@ -1,49 +1,102 @@ +"""This script generates the rst files for the modules in the bookmarks package. + +Use it in conjunction with make.bat to generate the documentation. + +""" import os -SOURCE = os.path.normpath(f'{__file__}/{os.pardir}/{os.pardir}/{os.pardir}/bookmarks').replace('\\', '/') -DOCS_DIR = os.path.normpath(f'{__file__}/{os.pardir}/modules').replace('\\', '/') +PYTHON_MODULE_DIR = os.path.normpath(f'{__file__}/{os.pardir}/{os.pardir}/{os.pardir}/bookmarks').replace('\\', '/') +PYTHON_MODULE_DOCS_DIR = os.path.normpath(f'{__file__}/{os.pardir}/modules').replace('\\', '/') -def recursive_search(path): - for entry in os.scandir(path): - if entry.is_dir(): - yield from recursive_search(entry.path) - else: - yield entry +if not os.path.isdir(PYTHON_MODULE_DOCS_DIR): + raise RuntimeError(f'Could not find the docs directory: {PYTHON_MODULE_DOCS_DIR}') +if not os.path.isdir(PYTHON_MODULE_DIR): + raise RuntimeError(f'Could not find the source directory: {PYTHON_MODULE_DIR}') -_source = '/'.join(SOURCE.split('/')[:-1]) -contents = [] -for entry in recursive_search(SOURCE): - if not entry.name.endswith('.py'): - continue +def recursive_search(_path): + if '__pycache__' in _path: + return + + print(f'>>> Searching for modules in: {_path}') + for _entry in os.scandir(_path): + if _entry.is_dir(): + yield from recursive_search(_entry.path.replace('\\', '/')) + else: + yield _entry - path = entry.path.replace('\\', '/') - parent = os.path.join(path, os.pardir) - basemod = os.path.normpath(parent).replace('\\', '/').replace(_source, '').strip('/') - basemod = basemod if basemod else 'bookmarks' - if entry.name == '__init__.py': - rst_dir = f'{DOCS_DIR}/{basemod}' - rst_file = f'{rst_dir}/{basemod}.rst' +if __name__ == '__main__': + print('>>> Generating the documentation for the bookmarks package:') + print(f'>>> Source: {PYTHON_MODULE_DIR}') - if not os.path.isdir(rst_dir): - os.makedirs(rst_dir) - continue + _source = '/'.join(PYTHON_MODULE_DIR.split('/')[:-1]) + contents = [] - basename = path.replace(_source, '').strip('/').replace('.py', '.rst') - basename = basename if basename else 'bookmarks' - rst_file = f'{DOCS_DIR}/{basename}' - with open(rst_file, 'w') as f: + # Generate the index bookmarks.rst file + with open(f'{PYTHON_MODULE_DOCS_DIR}/bookmarks.rst', 'w') as f: f.write(f'.. meta::\n') f.write(f' :description: Developer documentation page for the Bookmarks python modules\n') - f.write(f' :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO\n') + f.write( + f' :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,' + f'Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ' + f'ffmpeg, openimageio, publish, manage, digital content management\n' + ) f.write(f'\n') - title = '.'.join(basename.replace(".rst", '').split('/')[1:]) - f.write(f'{title}\n') - f.write('=' * (len(basemod) + 10) + '\n') + f.write(f'bookmarks\n') + f.write(f'=========\n') f.write(f'\n') - f.write(f'.. automodule:: {basename.replace("/", ".").replace(".rst", "")}\n') - f.write(' :members:\n') - f.write(' :show-inheritance:\n') \ No newline at end of file + f.write(f'.. automodule:: bookmarks\n') + f.write(f' :members:\n') + f.write(f' :show-inheritance:\n') + f.write(f'\n') + f.write(f'.. toctree::\n') + f.write(f' :glob:\n') + f.write(f'\n') + f.write(f' bookmarks/**\n') + f.write(f'\n') + + for entry in recursive_search(PYTHON_MODULE_DIR): + if not entry.name.endswith('.py'): + continue + + path = entry.path.replace('\\', '/') + + parent = os.path.join(path, os.pardir) + basemod = os.path.normpath(parent).replace('\\', '/').replace(_source, '').strip('/') + basemod = basemod if basemod else 'bookmarks' + + if entry.name == '__init__.py': + rst_dir = f'{PYTHON_MODULE_DOCS_DIR}/{basemod}' + rst_file = f'{rst_dir}/{basemod}.rst' + os.makedirs(rst_dir, exist_ok=True) + continue + + basename = path.replace(_source, '').strip('/').replace('.py', '.rst') + basename = basename if basename else 'bookmarks' + rst_file = f'{PYTHON_MODULE_DOCS_DIR}/{basename}' + + os.makedirs(os.path.dirname(rst_file), exist_ok=True) + + with open(rst_file, 'w') as f: + print(f'>>> Generating rst for: {path}') + + f.write(f'.. meta::\n') + f.write(f' :description: Developer documentation page for the Bookmarks python modules\n') + f.write( + f' :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,' + f'Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ' + f'ffmpeg, openimageio, publish, manage, digital content management\n' + ) + f.write(f'\n') + title = '.'.join(basename.replace(".rst", '').split('/')[1:]) + f.write(f'{title}\n') + f.write('=' * (len(basemod) + 10) + '\n') + f.write(f'\n') + f.write(f'.. automodule:: {basename.replace("/", ".").replace(".rst", "")}\n') + f.write(' :members:\n') + f.write(' :show-inheritance:\n') + + diff --git a/docs/src/guide.rst b/docs/src/guide.rst index 0a5be9e92..5c4e787c5 100644 --- a/docs/src/guide.rst +++ b/docs/src/guide.rst @@ -25,7 +25,7 @@ Get Bookmarks The project is hosted on `Github `_. -.. admonition:: Download the latest Windows release: `Bookmarks v0.8.5 `_ +.. admonition:: Download the latest Windows release: `Bookmarks v0.9.1 `_ ☹ Currently, Bookmarks only supports Windows. diff --git a/docs/src/modules/bookmarks.rst b/docs/src/modules/bookmarks.rst index d9def023e..4a7a9dbca 100644 --- a/docs/src/modules/bookmarks.rst +++ b/docs/src/modules/bookmarks.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management bookmarks ========= @@ -9,8 +9,8 @@ bookmarks :members: :show-inheritance: - .. toctree:: - :glob: + :glob: + + bookmarks/** - bookmarks/** \ No newline at end of file diff --git a/docs/src/modules/bookmarks/actions.rst b/docs/src/modules/bookmarks/actions.rst index 065f05e68..6df354cd8 100644 --- a/docs/src/modules/bookmarks/actions.rst +++ b/docs/src/modules/bookmarks/actions.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management actions =================== diff --git a/docs/src/modules/bookmarks/bookmarker/bookmark_editor.rst b/docs/src/modules/bookmarks/bookmarker/bookmark_editor.rst deleted file mode 100644 index 1b1599adb..000000000 --- a/docs/src/modules/bookmarks/bookmarker/bookmark_editor.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. meta:: - :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO - -bookmarker.bookmark_editor -============================== - -.. automodule:: bookmarks.bookmarker.bookmark_editor - :members: - :show-inheritance: diff --git a/docs/src/modules/bookmarks/bookmarker/job_editor.rst b/docs/src/modules/bookmarks/bookmarker/job_editor.rst deleted file mode 100644 index 2258aada5..000000000 --- a/docs/src/modules/bookmarks/bookmarker/job_editor.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. meta:: - :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO - -bookmarker.job_editor -============================== - -.. automodule:: bookmarks.bookmarker.job_editor - :members: - :show-inheritance: diff --git a/docs/src/modules/bookmarks/bookmarker/main.rst b/docs/src/modules/bookmarks/bookmarker/main.rst deleted file mode 100644 index f4a46a077..000000000 --- a/docs/src/modules/bookmarks/bookmarker/main.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. meta:: - :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO - -bookmarker.main -============================== - -.. automodule:: bookmarks.bookmarker.main - :members: - :show-inheritance: diff --git a/docs/src/modules/bookmarks/bookmarker/server_editor.rst b/docs/src/modules/bookmarks/bookmarker/server_editor.rst deleted file mode 100644 index 5e0bf8213..000000000 --- a/docs/src/modules/bookmarks/bookmarker/server_editor.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. meta:: - :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO - -bookmarker.server_editor -============================== - -.. automodule:: bookmarks.bookmarker.server_editor - :members: - :show-inheritance: diff --git a/docs/src/modules/bookmarks/common/active_mode.rst b/docs/src/modules/bookmarks/common/active_mode.rst new file mode 100644 index 000000000..30fe7be59 --- /dev/null +++ b/docs/src/modules/bookmarks/common/active_mode.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +common.active_mode +========================== + +.. automodule:: bookmarks.common.active_mode + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/common/core.rst b/docs/src/modules/bookmarks/common/core.rst index 966112b75..98d53ae3e 100644 --- a/docs/src/modules/bookmarks/common/core.rst +++ b/docs/src/modules/bookmarks/common/core.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management common.core ========================== diff --git a/docs/src/modules/bookmarks/common/data.rst b/docs/src/modules/bookmarks/common/data.rst index 7bc0d4358..2d05a31a9 100644 --- a/docs/src/modules/bookmarks/common/data.rst +++ b/docs/src/modules/bookmarks/common/data.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management common.data ========================== diff --git a/docs/src/modules/bookmarks/common/env.rst b/docs/src/modules/bookmarks/common/env.rst index 8cb6638af..e1db87fe2 100644 --- a/docs/src/modules/bookmarks/common/env.rst +++ b/docs/src/modules/bookmarks/common/env.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management common.env ========================== diff --git a/docs/src/modules/bookmarks/common/font.rst b/docs/src/modules/bookmarks/common/font.rst index 642754c22..27960c79c 100644 --- a/docs/src/modules/bookmarks/common/font.rst +++ b/docs/src/modules/bookmarks/common/font.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management common.font ========================== diff --git a/docs/src/modules/bookmarks/common/monitor.rst b/docs/src/modules/bookmarks/common/monitor.rst index a1d4634d1..973c67b6e 100644 --- a/docs/src/modules/bookmarks/common/monitor.rst +++ b/docs/src/modules/bookmarks/common/monitor.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management common.monitor ========================== diff --git a/docs/src/modules/bookmarks/common/sequence.rst b/docs/src/modules/bookmarks/common/sequence.rst index 146c1dde0..8dc401946 100644 --- a/docs/src/modules/bookmarks/common/sequence.rst +++ b/docs/src/modules/bookmarks/common/sequence.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management common.sequence ========================== diff --git a/docs/src/modules/bookmarks/common/session_lock.rst b/docs/src/modules/bookmarks/common/session_lock.rst deleted file mode 100644 index db7459bf9..000000000 --- a/docs/src/modules/bookmarks/common/session_lock.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. meta:: - :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO - -common.session_lock -========================== - -.. automodule:: bookmarks.common.session_lock - :members: - :show-inheritance: diff --git a/docs/src/modules/bookmarks/common/settings.rst b/docs/src/modules/bookmarks/common/settings.rst index 530745948..4d5655f16 100644 --- a/docs/src/modules/bookmarks/common/settings.rst +++ b/docs/src/modules/bookmarks/common/settings.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management common.settings ========================== diff --git a/docs/src/modules/bookmarks/common/setup.rst b/docs/src/modules/bookmarks/common/setup.rst index a6c6728a5..ce7bd7e43 100644 --- a/docs/src/modules/bookmarks/common/setup.rst +++ b/docs/src/modules/bookmarks/common/setup.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management common.setup ========================== diff --git a/docs/src/modules/bookmarks/common/signals.rst b/docs/src/modules/bookmarks/common/signals.rst index c43c08c4e..cb82318bb 100644 --- a/docs/src/modules/bookmarks/common/signals.rst +++ b/docs/src/modules/bookmarks/common/signals.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management common.signals ========================== diff --git a/docs/src/modules/bookmarks/common/ui.rst b/docs/src/modules/bookmarks/common/ui.rst index 21f318dfc..2ad208cca 100644 --- a/docs/src/modules/bookmarks/common/ui.rst +++ b/docs/src/modules/bookmarks/common/ui.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management common.ui ========================== diff --git a/docs/src/modules/bookmarks/contextmenu.rst b/docs/src/modules/bookmarks/contextmenu.rst index f3bbf2bee..3d6f5e25e 100644 --- a/docs/src/modules/bookmarks/contextmenu.rst +++ b/docs/src/modules/bookmarks/contextmenu.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management contextmenu =================== diff --git a/docs/src/modules/bookmarks/database.rst b/docs/src/modules/bookmarks/database.rst index 7f7c6841b..4447dfcef 100644 --- a/docs/src/modules/bookmarks/database.rst +++ b/docs/src/modules/bookmarks/database.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management database =================== diff --git a/docs/src/modules/bookmarks/editor/asset_properties.rst b/docs/src/modules/bookmarks/editor/asset_properties.rst index d29cbac7b..a61b17db9 100644 --- a/docs/src/modules/bookmarks/editor/asset_properties.rst +++ b/docs/src/modules/bookmarks/editor/asset_properties.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management editor.asset_properties ========================== diff --git a/docs/src/modules/bookmarks/editor/base.rst b/docs/src/modules/bookmarks/editor/base.rst index e9898a4ee..b54b27e1f 100644 --- a/docs/src/modules/bookmarks/editor/base.rst +++ b/docs/src/modules/bookmarks/editor/base.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management editor.base ========================== diff --git a/docs/src/modules/bookmarks/editor/base_widgets.rst b/docs/src/modules/bookmarks/editor/base_widgets.rst index d49c1e415..092dde287 100644 --- a/docs/src/modules/bookmarks/editor/base_widgets.rst +++ b/docs/src/modules/bookmarks/editor/base_widgets.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management editor.base_widgets ========================== diff --git a/docs/src/modules/bookmarks/editor/bookmark_properties.rst b/docs/src/modules/bookmarks/editor/bookmark_properties.rst index 86bd9dc3e..dbeda6318 100644 --- a/docs/src/modules/bookmarks/editor/bookmark_properties.rst +++ b/docs/src/modules/bookmarks/editor/bookmark_properties.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management editor.bookmark_properties ========================== diff --git a/docs/src/modules/bookmarks/editor/jobs.rst b/docs/src/modules/bookmarks/editor/jobs.rst new file mode 100644 index 000000000..227762da5 --- /dev/null +++ b/docs/src/modules/bookmarks/editor/jobs.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +editor.jobs +========================== + +.. automodule:: bookmarks.editor.jobs + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/editor/jobs_widgets.rst b/docs/src/modules/bookmarks/editor/jobs_widgets.rst new file mode 100644 index 000000000..4b9c9c6b4 --- /dev/null +++ b/docs/src/modules/bookmarks/editor/jobs_widgets.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +editor.jobs_widgets +========================== + +.. automodule:: bookmarks.editor.jobs_widgets + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/editor/preferences.rst b/docs/src/modules/bookmarks/editor/preferences.rst index 11870642c..55673d9c2 100644 --- a/docs/src/modules/bookmarks/editor/preferences.rst +++ b/docs/src/modules/bookmarks/editor/preferences.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management editor.preferences ========================== diff --git a/docs/src/modules/bookmarks/external/akaconvert.rst b/docs/src/modules/bookmarks/external/akaconvert.rst new file mode 100644 index 000000000..e4411487f --- /dev/null +++ b/docs/src/modules/bookmarks/external/akaconvert.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +external.akaconvert +============================ + +.. automodule:: bookmarks.external.akaconvert + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/external/ffmpeg.rst b/docs/src/modules/bookmarks/external/ffmpeg.rst index 5268435a3..5f3837c3b 100644 --- a/docs/src/modules/bookmarks/external/ffmpeg.rst +++ b/docs/src/modules/bookmarks/external/ffmpeg.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management external.ffmpeg ============================ diff --git a/docs/src/modules/bookmarks/external/ffmpeg_widget.rst b/docs/src/modules/bookmarks/external/ffmpeg_widget.rst index 915cd59ab..1b24a1abb 100644 --- a/docs/src/modules/bookmarks/external/ffmpeg_widget.rst +++ b/docs/src/modules/bookmarks/external/ffmpeg_widget.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management external.ffmpeg_widget ============================ diff --git a/docs/src/modules/bookmarks/external/rv.rst b/docs/src/modules/bookmarks/external/rv.rst index 0c9797387..7e5e16257 100644 --- a/docs/src/modules/bookmarks/external/rv.rst +++ b/docs/src/modules/bookmarks/external/rv.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management external.rv ============================ diff --git a/docs/src/modules/bookmarks/file_saver/main.rst b/docs/src/modules/bookmarks/file_saver/main.rst index 4b809c4c8..032a3aab0 100644 --- a/docs/src/modules/bookmarks/file_saver/main.rst +++ b/docs/src/modules/bookmarks/file_saver/main.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management file_saver.main ============================== diff --git a/docs/src/modules/bookmarks/file_saver/widgets.rst b/docs/src/modules/bookmarks/file_saver/widgets.rst index 2e14ad9fc..95937423f 100644 --- a/docs/src/modules/bookmarks/file_saver/widgets.rst +++ b/docs/src/modules/bookmarks/file_saver/widgets.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management file_saver.widgets ============================== diff --git a/docs/src/modules/bookmarks/images.rst b/docs/src/modules/bookmarks/images.rst index 17a6eb534..b2f0beb4b 100644 --- a/docs/src/modules/bookmarks/images.rst +++ b/docs/src/modules/bookmarks/images.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management images =================== diff --git a/docs/src/modules/bookmarks/importexport.rst b/docs/src/modules/bookmarks/importexport.rst index a3251f89a..bf5a16209 100644 --- a/docs/src/modules/bookmarks/importexport.rst +++ b/docs/src/modules/bookmarks/importexport.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management importexport =================== diff --git a/docs/src/modules/bookmarks/items/asset_items.rst b/docs/src/modules/bookmarks/items/asset_items.rst index dd4426385..0018c85b0 100644 --- a/docs/src/modules/bookmarks/items/asset_items.rst +++ b/docs/src/modules/bookmarks/items/asset_items.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.asset_items ========================= diff --git a/docs/src/modules/bookmarks/items/bookmark_items.rst b/docs/src/modules/bookmarks/items/bookmark_items.rst index 71c75cd38..2cde6bfa2 100644 --- a/docs/src/modules/bookmarks/items/bookmark_items.rst +++ b/docs/src/modules/bookmarks/items/bookmark_items.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.bookmark_items ========================= diff --git a/docs/src/modules/bookmarks/items/delegate.rst b/docs/src/modules/bookmarks/items/delegate.rst index 9a3c94bae..75a1339dd 100644 --- a/docs/src/modules/bookmarks/items/delegate.rst +++ b/docs/src/modules/bookmarks/items/delegate.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.delegate ========================= diff --git a/docs/src/modules/bookmarks/items/favourite_items.rst b/docs/src/modules/bookmarks/items/favourite_items.rst index 805031fe1..86c3a959a 100644 --- a/docs/src/modules/bookmarks/items/favourite_items.rst +++ b/docs/src/modules/bookmarks/items/favourite_items.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.favourite_items ========================= diff --git a/docs/src/modules/bookmarks/items/file_items.rst b/docs/src/modules/bookmarks/items/file_items.rst index d3812e634..55099b362 100644 --- a/docs/src/modules/bookmarks/items/file_items.rst +++ b/docs/src/modules/bookmarks/items/file_items.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.file_items ========================= diff --git a/docs/src/modules/bookmarks/items/models.rst b/docs/src/modules/bookmarks/items/models.rst index 07ba55fde..57dcb30df 100644 --- a/docs/src/modules/bookmarks/items/models.rst +++ b/docs/src/modules/bookmarks/items/models.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.models ========================= diff --git a/docs/src/modules/bookmarks/items/task_items.rst b/docs/src/modules/bookmarks/items/task_items.rst index e785c04a9..c5da150c3 100644 --- a/docs/src/modules/bookmarks/items/task_items.rst +++ b/docs/src/modules/bookmarks/items/task_items.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.task_items ========================= diff --git a/docs/src/modules/bookmarks/items/views.rst b/docs/src/modules/bookmarks/items/views.rst index 096025159..cb2ad2495 100644 --- a/docs/src/modules/bookmarks/items/views.rst +++ b/docs/src/modules/bookmarks/items/views.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.views ========================= diff --git a/docs/src/modules/bookmarks/items/widgets/filter_editor.rst b/docs/src/modules/bookmarks/items/widgets/filter_editor.rst index a6e9f7990..dec84b25b 100644 --- a/docs/src/modules/bookmarks/items/widgets/filter_editor.rst +++ b/docs/src/modules/bookmarks/items/widgets/filter_editor.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.widgets.filter_editor ================================= diff --git a/docs/src/modules/bookmarks/items/widgets/image_viewer.rst b/docs/src/modules/bookmarks/items/widgets/image_viewer.rst index 1f90b62a8..e394305ee 100644 --- a/docs/src/modules/bookmarks/items/widgets/image_viewer.rst +++ b/docs/src/modules/bookmarks/items/widgets/image_viewer.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.widgets.image_viewer ================================= diff --git a/docs/src/modules/bookmarks/items/widgets/thumb_capture.rst b/docs/src/modules/bookmarks/items/widgets/thumb_capture.rst index fd9fe5fb2..68b97e7f3 100644 --- a/docs/src/modules/bookmarks/items/widgets/thumb_capture.rst +++ b/docs/src/modules/bookmarks/items/widgets/thumb_capture.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.widgets.thumb_capture ================================= diff --git a/docs/src/modules/bookmarks/items/widgets/thumb_library.rst b/docs/src/modules/bookmarks/items/widgets/thumb_library.rst index 93f7fbb5d..71fcebeff 100644 --- a/docs/src/modules/bookmarks/items/widgets/thumb_library.rst +++ b/docs/src/modules/bookmarks/items/widgets/thumb_library.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.widgets.thumb_library ================================= diff --git a/docs/src/modules/bookmarks/items/widgets/thumb_picker.rst b/docs/src/modules/bookmarks/items/widgets/thumb_picker.rst index bd65ddea4..73e6dcd79 100644 --- a/docs/src/modules/bookmarks/items/widgets/thumb_picker.rst +++ b/docs/src/modules/bookmarks/items/widgets/thumb_picker.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management items.widgets.thumb_picker ================================= diff --git a/docs/src/modules/bookmarks/launcher/gallery.rst b/docs/src/modules/bookmarks/launcher/gallery.rst index a4ff231f5..78da3020e 100644 --- a/docs/src/modules/bookmarks/launcher/gallery.rst +++ b/docs/src/modules/bookmarks/launcher/gallery.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management launcher.gallery ============================ diff --git a/docs/src/modules/bookmarks/launcher/main.rst b/docs/src/modules/bookmarks/launcher/main.rst index 15cd42877..5bd195c1e 100644 --- a/docs/src/modules/bookmarks/launcher/main.rst +++ b/docs/src/modules/bookmarks/launcher/main.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management launcher.main ============================ diff --git a/docs/src/modules/bookmarks/log.rst b/docs/src/modules/bookmarks/log.rst index d56da2166..21d497d65 100644 --- a/docs/src/modules/bookmarks/log.rst +++ b/docs/src/modules/bookmarks/log.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management log =================== diff --git a/docs/src/modules/bookmarks/main.rst b/docs/src/modules/bookmarks/main.rst index ec7797ced..e01800bdc 100644 --- a/docs/src/modules/bookmarks/main.rst +++ b/docs/src/modules/bookmarks/main.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management main =================== diff --git a/docs/src/modules/bookmarks/maya/actions.rst b/docs/src/modules/bookmarks/maya/actions.rst index 5425c2514..72ae67266 100644 --- a/docs/src/modules/bookmarks/maya/actions.rst +++ b/docs/src/modules/bookmarks/maya/actions.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management maya.actions ======================== diff --git a/docs/src/modules/bookmarks/maya/base.rst b/docs/src/modules/bookmarks/maya/base.rst index ed29480cf..b9a3b4fe5 100644 --- a/docs/src/modules/bookmarks/maya/base.rst +++ b/docs/src/modules/bookmarks/maya/base.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management maya.base ======================== diff --git a/docs/src/modules/bookmarks/maya/capture.rst b/docs/src/modules/bookmarks/maya/capture.rst index 9817c3ee3..edbde978c 100644 --- a/docs/src/modules/bookmarks/maya/capture.rst +++ b/docs/src/modules/bookmarks/maya/capture.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management maya.capture ======================== diff --git a/docs/src/modules/bookmarks/maya/contextmenu.rst b/docs/src/modules/bookmarks/maya/contextmenu.rst index fe8d8a58e..e2e3ce0fc 100644 --- a/docs/src/modules/bookmarks/maya/contextmenu.rst +++ b/docs/src/modules/bookmarks/maya/contextmenu.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management maya.contextmenu ======================== diff --git a/docs/src/modules/bookmarks/maya/export.rst b/docs/src/modules/bookmarks/maya/export.rst index a69014c67..31af8d5b9 100644 --- a/docs/src/modules/bookmarks/maya/export.rst +++ b/docs/src/modules/bookmarks/maya/export.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management maya.export ======================== diff --git a/docs/src/modules/bookmarks/maya/hud.rst b/docs/src/modules/bookmarks/maya/hud.rst index 9532f7ef6..d89775c06 100644 --- a/docs/src/modules/bookmarks/maya/hud.rst +++ b/docs/src/modules/bookmarks/maya/hud.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management maya.hud ======================== diff --git a/docs/src/modules/bookmarks/maya/main.rst b/docs/src/modules/bookmarks/maya/main.rst index 99ee38463..18fc6d0a9 100644 --- a/docs/src/modules/bookmarks/maya/main.rst +++ b/docs/src/modules/bookmarks/maya/main.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management maya.main ======================== diff --git a/docs/src/modules/bookmarks/maya/plugin.rst b/docs/src/modules/bookmarks/maya/plugin.rst index f38f6b396..1ad704883 100644 --- a/docs/src/modules/bookmarks/maya/plugin.rst +++ b/docs/src/modules/bookmarks/maya/plugin.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management maya.plugin ======================== diff --git a/docs/src/modules/bookmarks/maya/scripts/aka_character_caches.rst b/docs/src/modules/bookmarks/maya/scripts/aka_character_caches.rst new file mode 100644 index 000000000..234c7ea1b --- /dev/null +++ b/docs/src/modules/bookmarks/maya/scripts/aka_character_caches.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +maya.scripts.aka_character_caches +================================ + +.. automodule:: bookmarks.maya.scripts.aka_character_caches + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/maya/scripts/aka_make_export_sets.rst b/docs/src/modules/bookmarks/maya/scripts/aka_make_export_sets.rst new file mode 100644 index 000000000..6ccb77992 --- /dev/null +++ b/docs/src/modules/bookmarks/maya/scripts/aka_make_export_sets.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +maya.scripts.aka_make_export_sets +================================ + +.. automodule:: bookmarks.maya.scripts.aka_make_export_sets + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/maya/scripts/aka_odyssey_shaders.rst b/docs/src/modules/bookmarks/maya/scripts/aka_odyssey_shaders.rst deleted file mode 100644 index 8dea41ec6..000000000 --- a/docs/src/modules/bookmarks/maya/scripts/aka_odyssey_shaders.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. meta:: - :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO - -maya.scripts.aka_odyssey_shaders -================================ - -.. automodule:: bookmarks.maya.scripts.aka_odyssey_shaders - :members: - :show-inheritance: diff --git a/docs/src/modules/bookmarks/maya/scripts/aka_shader_templates.rst b/docs/src/modules/bookmarks/maya/scripts/aka_shader_templates.rst new file mode 100644 index 000000000..11ead732b --- /dev/null +++ b/docs/src/modules/bookmarks/maya/scripts/aka_shader_templates.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +maya.scripts.aka_shader_templates +================================ + +.. automodule:: bookmarks.maya.scripts.aka_shader_templates + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/maya/scripts/reset_joint_orientations.rst b/docs/src/modules/bookmarks/maya/scripts/reset_joint_orientations.rst new file mode 100644 index 000000000..f721c883c --- /dev/null +++ b/docs/src/modules/bookmarks/maya/scripts/reset_joint_orientations.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +maya.scripts.reset_joint_orientations +================================ + +.. automodule:: bookmarks.maya.scripts.reset_joint_orientations + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/maya/shadertool.rst b/docs/src/modules/bookmarks/maya/shadertool.rst deleted file mode 100644 index 5fcc45313..000000000 --- a/docs/src/modules/bookmarks/maya/shadertool.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. meta:: - :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO - -maya.shadertool -======================== - -.. automodule:: bookmarks.maya.shadertool - :members: - :show-inheritance: diff --git a/docs/src/modules/bookmarks/maya/viewport.rst b/docs/src/modules/bookmarks/maya/viewport.rst index 661d4baa6..16d54eaf0 100644 --- a/docs/src/modules/bookmarks/maya/viewport.rst +++ b/docs/src/modules/bookmarks/maya/viewport.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management maya.viewport ======================== diff --git a/docs/src/modules/bookmarks/notes.rst b/docs/src/modules/bookmarks/notes.rst index faa53129a..2841949ac 100644 --- a/docs/src/modules/bookmarks/notes.rst +++ b/docs/src/modules/bookmarks/notes.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management notes =================== diff --git a/docs/src/modules/bookmarks/progress.rst b/docs/src/modules/bookmarks/progress.rst index 6b9701e37..f0d7ded21 100644 --- a/docs/src/modules/bookmarks/progress.rst +++ b/docs/src/modules/bookmarks/progress.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management progress =================== diff --git a/docs/src/modules/bookmarks/publish.rst b/docs/src/modules/bookmarks/publish.rst index 69f1333e6..b2aea3fd5 100644 --- a/docs/src/modules/bookmarks/publish.rst +++ b/docs/src/modules/bookmarks/publish.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management publish =================== diff --git a/docs/src/modules/bookmarks/scripts/aka_odyssey_sync.rst b/docs/src/modules/bookmarks/scripts/aka_odyssey_sync.rst deleted file mode 100644 index e75f2b8a4..000000000 --- a/docs/src/modules/bookmarks/scripts/aka_odyssey_sync.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. meta:: - :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO - -scripts.aka_odyssey_sync -=========================== - -.. automodule:: bookmarks.scripts.aka_odyssey_sync - :members: - :show-inheritance: diff --git a/docs/src/modules/bookmarks/scripts/clips.rst b/docs/src/modules/bookmarks/scripts/clips.rst new file mode 100644 index 000000000..e6fb97bf9 --- /dev/null +++ b/docs/src/modules/bookmarks/scripts/clips.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +scripts.clips +=========================== + +.. automodule:: bookmarks.scripts.clips + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/scripts/send_images_to_after_effects.rst b/docs/src/modules/bookmarks/scripts/send_images_to_after_effects.rst new file mode 100644 index 000000000..929f60ae5 --- /dev/null +++ b/docs/src/modules/bookmarks/scripts/send_images_to_after_effects.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +scripts.send_images_to_after_effects +=========================== + +.. automodule:: bookmarks.scripts.send_images_to_after_effects + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/scripts/sync_asset_data.rst b/docs/src/modules/bookmarks/scripts/sync_asset_data.rst new file mode 100644 index 000000000..f20f8c804 --- /dev/null +++ b/docs/src/modules/bookmarks/scripts/sync_asset_data.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +scripts.sync_asset_data +=========================== + +.. automodule:: bookmarks.scripts.sync_asset_data + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/shortcuts.rst b/docs/src/modules/bookmarks/shortcuts.rst index 174e99815..fd17c4cb5 100644 --- a/docs/src/modules/bookmarks/shortcuts.rst +++ b/docs/src/modules/bookmarks/shortcuts.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management shortcuts =================== diff --git a/docs/src/modules/bookmarks/shotgun/actions.rst b/docs/src/modules/bookmarks/shotgun/actions.rst index ef70d2b39..d74df640d 100644 --- a/docs/src/modules/bookmarks/shotgun/actions.rst +++ b/docs/src/modules/bookmarks/shotgun/actions.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management shotgun.actions =========================== diff --git a/docs/src/modules/bookmarks/shotgun/link.rst b/docs/src/modules/bookmarks/shotgun/link.rst index bddfb869b..122236901 100644 --- a/docs/src/modules/bookmarks/shotgun/link.rst +++ b/docs/src/modules/bookmarks/shotgun/link.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management shotgun.link =========================== diff --git a/docs/src/modules/bookmarks/shotgun/link_asset.rst b/docs/src/modules/bookmarks/shotgun/link_asset.rst index 08639030c..b0df44e3e 100644 --- a/docs/src/modules/bookmarks/shotgun/link_asset.rst +++ b/docs/src/modules/bookmarks/shotgun/link_asset.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management shotgun.link_asset =========================== diff --git a/docs/src/modules/bookmarks/shotgun/link_assets.rst b/docs/src/modules/bookmarks/shotgun/link_assets.rst index c4566a91b..9d52ab79a 100644 --- a/docs/src/modules/bookmarks/shotgun/link_assets.rst +++ b/docs/src/modules/bookmarks/shotgun/link_assets.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management shotgun.link_assets =========================== diff --git a/docs/src/modules/bookmarks/shotgun/link_bookmark.rst b/docs/src/modules/bookmarks/shotgun/link_bookmark.rst index 53f3dbf02..cf0888df6 100644 --- a/docs/src/modules/bookmarks/shotgun/link_bookmark.rst +++ b/docs/src/modules/bookmarks/shotgun/link_bookmark.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management shotgun.link_bookmark =========================== diff --git a/docs/src/modules/bookmarks/shotgun/publish.rst b/docs/src/modules/bookmarks/shotgun/publish.rst deleted file mode 100644 index a66872768..000000000 --- a/docs/src/modules/bookmarks/shotgun/publish.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. meta:: - :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO - -shotgun.publish -=========================== - -.. automodule:: bookmarks.shotgun.publish - :members: - :show-inheritance: diff --git a/docs/src/modules/bookmarks/shotgun/publish_widgets.rst b/docs/src/modules/bookmarks/shotgun/publish_widgets.rst index 4000557fe..d1340f766 100644 --- a/docs/src/modules/bookmarks/shotgun/publish_widgets.rst +++ b/docs/src/modules/bookmarks/shotgun/publish_widgets.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management shotgun.publish_widgets =========================== diff --git a/docs/src/modules/bookmarks/shotgun/sg_publish_clip.rst b/docs/src/modules/bookmarks/shotgun/sg_publish_clip.rst new file mode 100644 index 000000000..1125bccef --- /dev/null +++ b/docs/src/modules/bookmarks/shotgun/sg_publish_clip.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +shotgun.sg_publish_clip +=========================== + +.. automodule:: bookmarks.shotgun.sg_publish_clip + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/shotgun/shotgun.rst b/docs/src/modules/bookmarks/shotgun/shotgun.rst index c8ceb1079..ad0f6d9c7 100644 --- a/docs/src/modules/bookmarks/shotgun/shotgun.rst +++ b/docs/src/modules/bookmarks/shotgun/shotgun.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management shotgun.shotgun =========================== diff --git a/docs/src/modules/bookmarks/shotgun/tasks.rst b/docs/src/modules/bookmarks/shotgun/tasks.rst index 3a0068b2d..40f9f0308 100644 --- a/docs/src/modules/bookmarks/shotgun/tasks.rst +++ b/docs/src/modules/bookmarks/shotgun/tasks.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management shotgun.tasks =========================== diff --git a/docs/src/modules/bookmarks/standalone.rst b/docs/src/modules/bookmarks/standalone.rst index 68ce76ead..8606f07e1 100644 --- a/docs/src/modules/bookmarks/standalone.rst +++ b/docs/src/modules/bookmarks/standalone.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management standalone =================== diff --git a/docs/src/modules/bookmarks/statusbar.rst b/docs/src/modules/bookmarks/statusbar.rst index 553db236c..6e7b3a67c 100644 --- a/docs/src/modules/bookmarks/statusbar.rst +++ b/docs/src/modules/bookmarks/statusbar.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management statusbar =================== diff --git a/docs/src/modules/bookmarks/templates.rst b/docs/src/modules/bookmarks/templates.rst index 035ed09bc..a41f7b820 100644 --- a/docs/src/modules/bookmarks/templates.rst +++ b/docs/src/modules/bookmarks/templates.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management templates =================== diff --git a/docs/src/modules/bookmarks/threads/threads.rst b/docs/src/modules/bookmarks/threads/threads.rst index 27e347362..b808bec9e 100644 --- a/docs/src/modules/bookmarks/threads/threads.rst +++ b/docs/src/modules/bookmarks/threads/threads.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management threads.threads =========================== diff --git a/docs/src/modules/bookmarks/threads/workers.rst b/docs/src/modules/bookmarks/threads/workers.rst index 6def0df8a..63f265984 100644 --- a/docs/src/modules/bookmarks/threads/workers.rst +++ b/docs/src/modules/bookmarks/threads/workers.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management threads.workers =========================== diff --git a/docs/src/modules/bookmarks/tokens/tokens.rst b/docs/src/modules/bookmarks/tokens/tokens.rst index 568ac98b4..9e71be158 100644 --- a/docs/src/modules/bookmarks/tokens/tokens.rst +++ b/docs/src/modules/bookmarks/tokens/tokens.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management tokens.tokens ========================== diff --git a/docs/src/modules/bookmarks/tokens/tokens_editor.rst b/docs/src/modules/bookmarks/tokens/tokens_editor.rst index 47a086cf1..89376292b 100644 --- a/docs/src/modules/bookmarks/tokens/tokens_editor.rst +++ b/docs/src/modules/bookmarks/tokens/tokens_editor.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management tokens.tokens_editor ========================== diff --git a/docs/src/modules/bookmarks/topbar/buttons.rst b/docs/src/modules/bookmarks/topbar/buttons.rst index 58376b852..0c123b9ec 100644 --- a/docs/src/modules/bookmarks/topbar/buttons.rst +++ b/docs/src/modules/bookmarks/topbar/buttons.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management topbar.buttons ========================== diff --git a/docs/src/modules/bookmarks/topbar/filters.rst b/docs/src/modules/bookmarks/topbar/filters.rst new file mode 100644 index 000000000..c6eee0446 --- /dev/null +++ b/docs/src/modules/bookmarks/topbar/filters.rst @@ -0,0 +1,10 @@ +.. meta:: + :description: Developer documentation page for the Bookmarks python modules + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management + +topbar.filters +========================== + +.. automodule:: bookmarks.topbar.filters + :members: + :show-inheritance: diff --git a/docs/src/modules/bookmarks/topbar/quickswitch.rst b/docs/src/modules/bookmarks/topbar/quickswitch.rst index 2b0ebc8be..1dae3afe6 100644 --- a/docs/src/modules/bookmarks/topbar/quickswitch.rst +++ b/docs/src/modules/bookmarks/topbar/quickswitch.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management topbar.quickswitch ========================== diff --git a/docs/src/modules/bookmarks/topbar/tabs.rst b/docs/src/modules/bookmarks/topbar/tabs.rst index c45715939..86fb935f3 100644 --- a/docs/src/modules/bookmarks/topbar/tabs.rst +++ b/docs/src/modules/bookmarks/topbar/tabs.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management topbar.tabs ========================== diff --git a/docs/src/modules/bookmarks/topbar/topbar.rst b/docs/src/modules/bookmarks/topbar/topbar.rst index 5a75d8e7c..0f11ab377 100644 --- a/docs/src/modules/bookmarks/topbar/topbar.rst +++ b/docs/src/modules/bookmarks/topbar/topbar.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management topbar.topbar ========================== diff --git a/docs/src/modules/bookmarks/ui.rst b/docs/src/modules/bookmarks/ui.rst index d662b7584..9228a5f74 100644 --- a/docs/src/modules/bookmarks/ui.rst +++ b/docs/src/modules/bookmarks/ui.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management ui =================== diff --git a/docs/src/modules/bookmarks/versioncontrol/version.rst b/docs/src/modules/bookmarks/versioncontrol/version.rst index d171e9cf1..5c59acc86 100644 --- a/docs/src/modules/bookmarks/versioncontrol/version.rst +++ b/docs/src/modules/bookmarks/versioncontrol/version.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management versioncontrol.version ================================== diff --git a/docs/src/modules/bookmarks/versioncontrol/versioncontrol.rst b/docs/src/modules/bookmarks/versioncontrol/versioncontrol.rst index 87c452d00..5bcbadcd0 100644 --- a/docs/src/modules/bookmarks/versioncontrol/versioncontrol.rst +++ b/docs/src/modules/bookmarks/versioncontrol/versioncontrol.rst @@ -1,6 +1,6 @@ .. meta:: :description: Developer documentation page for the Bookmarks python modules - :keywords: Bookmarks, bookmarksvfx, asset manager, assets, PySide, Qt5, PySide2, Python, vfx, animation, film, productivity, free, open-source, opensource, lightweight, ShotGrid, RV, FFMpeg, ffmpeg, publish, manage, digital content management, production, OpenImageIO + :keywords: Bookmarks, bookmarksvfx, pipeline, pipe, asset manager, assets, PySide, Qt, PySide,Python, vfx, animation, film, production, open-source, opensource, ShotGun, ShotGrid, RV, ffmpeg, openimageio, publish, manage, digital content management versioncontrol.versioncontrol ================================== diff --git a/package/CMakeLists.txt b/package/CMakeLists.txt index 92a5f23d1..1c9d2484d 100644 --- a/package/CMakeLists.txt +++ b/package/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.20) project( Bookmarks - VERSION 0.8.5 + VERSION 0.9.1 DESCRIPTION "Bookmarks is a lightweight asset manager for digital artists working in the animation, motion graphics and VFX industries." HOMEPAGE_URL "https://bookmarks-vfx.com" )