class ShotgunSystemTrayIcon(QtGui.QSystemTrayIcon): """ wrapper around system tray icon """ clicked = QtCore.Signal() double_clicked = QtCore.Signal() right_clicked = QtCore.Signal() def __init__(self, parent=None): QtGui.QSystemTrayIcon.__init__(self, parent) # configure the system tray icon icon = QtGui.QIcon(":/tk-desktop/default_systray_icon") self.setIcon(icon) self.setToolTip("Shotgun") # connect up signal handlers self.activated.connect(self.__activated) def __activated(self, reason): if reason == QtGui.QSystemTrayIcon.Trigger: self.clicked.emit() elif reason == QtGui.QSystemTrayIcon.DoubleClick: self.double_clicked.emit() elif reason == QtGui.QSystemTrayIcon.Context: self.right_clicked.emit()
class ListBase(QtGui.QWidget): clicked = QtCore.Signal(QtGui.QWidget) double_clicked = QtCore.Signal(QtGui.QWidget) def __init__(self, app, worker, parent=None): QtGui.QWidget.__init__(self, parent) self._app = app self._worker = worker self.ui = self._setup_ui() def supports_selection(self): return False def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: # handle this event! self.clicked.emit(self) def mouseDoubleClickEvent(self, event): self.double_clicked.emit(self) def set_selected(self, status): pass def is_selected(self): return False def set_title(self, title): pass def set_details(self, text): pass def get_title(self): return None def get_details(self): return None def _setup_ui(self): """ Setup the Qt UI. Typically, this just instantiates the UI class and calls its .setupUi(self) method. This can be overriden in child classes - this provides a simple mechanism to replace the item UI with a custom version if needed :returns: The constructed QWidget """ raise NotImplementedError()
class Task(QtCore.QObject): """ This is a wrapper class which allows us to run tank commands inside the QT universe. This approach is handy when an engine needs to start up a qt event loop as part of its initailization. """ finished = QtCore.Signal() def __init__(self, engine, callback): QtCore.QObject.__init__(self) self._callback = callback self._engine = engine def run_command(self): # execute the callback # note that because pyside has its own exception wrapper around # exec we need to catch and log any exceptions here. try: self._callback() except tank.TankError, e: self._engine.log_error(str(e)) except Exception: self._engine.log_exception("A general error was reported.")
class Task(QtCore.QObject): """ This is a wrapper class which allows us to run tank commands inside the QT universe. This approach is handy when an engine needs to start up a qt event loop as part of its initailization. """ finished = QtCore.Signal() def __init__(self, engine, callback, args): QtCore.QObject.__init__(self) self._callback = callback self._args = args self._engine = engine def run_command(self): try: # execute the callback self._callback(*self._args) except tank.TankError, e: self._engine.log_error(str(e)) except KeyboardInterrupt: self._engine.log_info("The operation was cancelled by the user.")
class ListBase(QtGui.QWidget): clicked = QtCore.Signal(QtGui.QWidget) double_clicked = QtCore.Signal(QtGui.QWidget) def __init__(self, app, worker, parent=None): QtGui.QWidget.__init__(self, parent) self._app = app self._worker = worker def supports_selection(self): return False def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: # handle this event! self.clicked.emit(self) def mouseDoubleClickEvent(self, event): self.double_clicked.emit(self) def set_selected(self, status): pass def is_selected(self): return False def set_title(self, title): pass def set_details(self, text): pass def get_title(self): return None def get_details(self): return None
class DockWidget(QtGui.QDockWidget): """ Widget used for docking app panels that ensures the widget is closed when the dock is closed """ closed = QtCore.Signal(QtCore.QObject) def closeEvent(self, event): widget = self.widget() if widget: widget.close() self.closed.emit(self)
class ResizeEventFilter(QtCore.QObject): """ Event filter which emits a resized signal whenever the monitored widget resizes """ resized = QtCore.Signal() def eventFilter(self, obj, event): # peek at the message if event.type() == QtCore.QEvent.Resize: # re-broadcast any resize events self.resized.emit() # pass it on! return False
class ResizeEventFilter(QtCore.QObject): """ Event filter which emits a resized signal whenever the monitored widget resizes. This is so that the overlay wrapper class can be informed whenever the Widget gets a resize event. """ resized = QtCore.Signal() def eventFilter(self, obj, event): # peek at the message if event.type() == QtCore.QEvent.Resize: # re-broadcast any resize events self.resized.emit() # pass it on! return False
class QtTask(QtCore.QObject): """ QT dialogue wrapper """ finished = QtCore.Signal() def __init__(self, title, engine, widget_class, args, kwargs): """ Constructor """ QtCore.QObject.__init__(self) self._title = title self._engine = engine self._widget_class = widget_class self._args = args self._kwargs = kwargs self._return_data = None def run_command(self): """ Execute the payload of the task. Emit finished signal at the end. """ try: # execute the callback self._return_data = self._engine.show_modal( self._title, self._engine, self._widget_class, *self._args, **self._kwargs) except KeyboardInterrupt: self._engine.log_info("The operation was cancelled by the user.") finally: # broadcast that we have finished this command self.finished.emit() def get_return_data(self): """ Return the return value from the modal dialog """ return self._return_data
class Task(QtCore.QObject): """ This is a wrapper class which allows us to run tank commands inside the QT universe. This approach is handy when an engine needs to start up a qt event loop as part of its initailization. """ finished = QtCore.Signal() def __init__(self, engine, callback, args): QtCore.QObject.__init__(self) self._callback = callback self._args = args self._engine = engine def run_command(self): try: # execute the callback self._callback(*self._args) except tank.TankError as e: self._engine.log_error(str(e)) except KeyboardInterrupt: self._engine.log_info("The operation was cancelled by the user.") except Exception: self._engine.log_exception("A general error was reported.") finally: # broadcast that we have finished this command if not self._engine.has_received_ui_creation_requests(): # while the app has been doing its thing, no UIs were # created (at least not any tank UIs) - assume it is a # console style app and that the end of its callback # execution means that it is complete and that we should return self.finished.emit()
class Worker(QtCore.QThread): work_completed = QtCore.Signal(str, dict) work_failure = QtCore.Signal(str, str) def __init__(self, app, parent=None): QtCore.QThread.__init__(self, parent) self._execute_tasks = True self._app = app self._queue_mutex = QtCore.QMutex() self._queue = [] self._receivers = {} def stop(self): """ Stops the worker, run this before shutdown """ self._execute_tasks = False def clear(self): """ Empties the queue """ self._queue_mutex.lock() try: self._queue = [] finally: self._queue_mutex.unlock() def queue_work(self, worker_fn, params, asap=False): """ Queues up some work. Returns a unique identifier to identify this item """ uid = uuid.uuid4().hex work = {"id": uid, "fn": worker_fn, "params": params} self._queue_mutex.lock() try: if asap: # first in the queue self._queue.insert(0, work) else: self._queue.append(work) finally: self._queue_mutex.unlock() return uid ############################################################################################ # def run(self): while self._execute_tasks: self._queue_mutex.lock() try: queue_len = len(self._queue) finally: self._queue_mutex.unlock() if queue_len == 0: # polling. TODO: replace with semaphor! self.msleep(200) else: # pop queue self._queue_mutex.lock() try: item_to_process = self._queue.pop(0) finally: self._queue_mutex.unlock() data = None try: data = item_to_process["fn"](item_to_process["params"]) except Exception, e: if self._execute_tasks: self.work_failure.emit(item_to_process["id"], "An error occured: %s" % e) else: if self._execute_tasks: self.work_completed.emit(item_to_process["id"], data)
class BrowserWidget(QtGui.QWidget): ###################################################################################### # SIGNALS # when the selection changes selection_changed = QtCore.Signal() # when someone double clicks on an item action_requested = QtCore.Signal() ###################################################################################### # Init & Destruct def __init__(self, parent=None): QtGui.QWidget.__init__(self, parent) # set up the UI self.ui = Ui_Browser() self.ui.setupUi(self) # hide the overlays self.ui.load_overlay.setVisible(False) self.ui.message_overlay.setVisible(False) self._app = None self._worker = None self._current_work_id = None self._selected_item = None self._selected_items = [] self._dynamic_widgets = [] self._multi_select = False self._search = True # spinner self._spin_icons = [] self._spin_icons.append(QtGui.QPixmap(":/res/progress_bar_1.png")) self._spin_icons.append(QtGui.QPixmap(":/res/progress_bar_2.png")) self._spin_icons.append(QtGui.QPixmap(":/res/progress_bar_3.png")) self._spin_icons.append(QtGui.QPixmap(":/res/progress_bar_4.png")) self._timer = QtCore.QTimer(self) self._timer.timeout.connect(self._update_spinner) self._current_spinner_index = 0 # search self.ui.search.textEdited.connect(self._on_search_text_changed) def enable_multi_select(self, status): """ Should we enable multi select """ self._multi_select = True def enable_search(self, status): """ Toggle the search bar (on by default) """ self.ui.search.setVisible(status) def destroy(self): if self._worker: self._worker.stop() def set_app(self, app): """ associate with an app object """ self._app = app # set up worker queue self._worker = Worker(app) self._worker.work_completed.connect(self._on_worker_signal) self._worker.work_failure.connect(self._on_worker_failure) self._worker.start() def set_label(self, label): """ Sets the text next to the search button """ self.ui.label.setText("<big>%s</big>" % label) ###################################################################################### # Public Methods def load(self, data): """ Loads data into the browser widget. Called by outside code """ # start spinning self.ui.scroll_area.setVisible(False) self.ui.load_overlay.setVisible(True) self._timer.start(100) # queue up work self._current_work_id = self._worker.queue_work(self.get_data, data, asap=True) def clear(self): """ Clear widget of its contents. """ # hide overlays self.ui.load_overlay.setVisible(False) self.ui.message_overlay.setVisible(False) self.ui.scroll_area.setVisible(True) # clear search box self.ui.search.setText("") # also reset any jobs that are processing. No point processing them # if their requestors are gone. if self._worker: self._worker.clear() for x in self._dynamic_widgets: self.ui.scroll_area_layout.removeWidget(x) x.deleteLater() self._dynamic_widgets = [] # lastly, clear selection self.clear_selection() def set_message(self, message): """ Replace the list of items with a single message """ self.ui.load_overlay.setVisible(False) self.ui.message_overlay.setVisible(True) self.ui.scroll_area.setVisible(False) self.ui.status_message.setText(message) def clear_selection(self): """ Clears the selection """ for x in self._dynamic_widgets: x.set_selected(False) self._selected_item = None self._selected_items = [] def get_selected_item(self): """ Gets the last selected item, None if no selection """ return self._selected_item def get_selected_items(self): """ Returns entire selection """ return self._selected_items def get_items(self): return self._dynamic_widgets def select(self, item): self._on_item_clicked(item) # in order for the scroll to happen during load, first give # the scroll area chance to resize it self by processing its event queue. QtCore.QCoreApplication.processEvents() # and focus on the selection self.ui.scroll_area.ensureWidgetVisible(item) ########################################################################################## # Protected stuff - implemented by deriving classes def get_data(self, data): """ Needs to be implemented by subclasses """ raise Exception("not implemented!") def process_result(self, result): """ Needs to be implemented by subclasses """ raise Exception("not implemented!") ########################################################################################## # Internals def _on_search_text_changed(self, text): """ Cull based on search box """ if text == "": # show all items for i in self._dynamic_widgets: i.setVisible(True) elif len(text) > 2: # cull by string for strings > 2 chars # if running PyQt, convert QString to str if not isinstance(text, basestring): # convert QString to str text = str(text) # now we have a str or unicode object which has the lower() method lower_text = text.lower() for i in self._dynamic_widgets: details = i.get_details() # if running PyQt, convert QString to str details_lower = details if not isinstance(details_lower, basestring): details_lower = str(details_lower) # now we have a str or unicode object which has the lower() method details_lower = details_lower.lower() if details is None: # header i.setVisible(True) elif lower_text in details_lower: i.setVisible(True) else: i.setVisible(False) def _on_worker_failure(self, uid, msg): """ The worker couldn't execute stuff """ if self._current_work_id != uid: # not our job. ignore return # finally, turn off progress indication and turn on display self.ui.scroll_area.setVisible(True) self.ui.load_overlay.setVisible(False) self._timer.stop() # show error message self.set_message(msg) def _on_worker_signal(self, uid, data): """ Signalled whenever the worker completes something """ if self._current_work_id != uid: # not our job. ignore return # finally, turn off progress indication and turn on display self.ui.scroll_area.setVisible(True) self.ui.load_overlay.setVisible(False) self._timer.stop() # process! self.process_result(data) def _update_spinner(self): """ Animate spinner icon """ self.ui.progress_bar.setPixmap( self._spin_icons[self._current_spinner_index]) self._current_spinner_index += 1 if self._current_spinner_index == 4: self._current_spinner_index = 0 def _on_item_clicked(self, item): if item.supports_selection() == False: # not all items are selectable return if self._multi_select: if item.is_selected(): # remove from selection item.set_selected(False) # remove it from list of selected items s = set(self._selected_items) - set([item]) self._selected_items = list(s) if len(self._selected_items) > 0: self._selected_item = self._selected_items[0] else: self._selected_item = None else: # add to selection item.set_selected(True) self._selected_item = item self._selected_items.append(item) else: # single select self.clear_selection() item.set_selected(True) self._selected_item = item self._selected_items = [item] self.selection_changed.emit() def _on_item_double_clicked(self, item): self.action_requested.emit() def add_item(self, item_class): """ Adds a list item. Returns the created object. """ widget = item_class(self._app, self._worker, self) self.ui.scroll_area_layout.addWidget(widget) self._dynamic_widgets.append(widget) widget.clicked.connect(self._on_item_clicked) widget.double_clicked.connect(self._on_item_double_clicked) return widget
class ThumbnailWidget(QtGui.QWidget): """ Thumbnail widget that provides screen capture functionality """ thumbnail_changed = QtCore.Signal() def __init__(self, parent=None): """ Construction """ QtGui.QWidget.__init__(self, parent) self._ui = Ui_ThumbnailWidget() self._ui.setupUi(self) # create layout to control buttons frame layout = QtGui.QHBoxLayout() layout.addWidget(self._ui.buttons_frame) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.setLayout(layout) # connect to buttons: self._ui.camera_btn.clicked.connect(self._on_camera_clicked) self._btns_transition_anim = None self._update_ui() # @property def _get_thumbnail(self): pm = self._ui.thumbnail.pixmap() return pm if pm and not pm.isNull() else None # @thumbnail.setter def _set_thumbnail(self, value): self._ui.thumbnail.setPixmap(value if value else QtGui.QPixmap()) self._update_ui() self.thumbnail_changed.emit() thumbnail = property(_get_thumbnail, _set_thumbnail) def enable_screen_capture(self, enable): self._ui.camera_btn.setVisible(enable) def resizeEvent(self, event): self._update_ui() def enterEvent(self, event): """ when the cursor enters the control, show the buttons """ if self.thumbnail and self._are_any_btns_enabled(): self._ui.buttons_frame.show() if hasattr(QtCore, "QAbstractAnimation"): self._run_btns_transition_anim( QtCore.QAbstractAnimation.Forward) else: # Q*Animation classes aren't available so just # make sure the button is visible: self.btn_visibility = 1.0 def leaveEvent(self, event): """ when the cursor leaves the control, hide the buttons """ if self.thumbnail and self._are_any_btns_enabled(): if hasattr(QtCore, "QAbstractAnimation"): self._run_btns_transition_anim( QtCore.QAbstractAnimation.Backward) else: # Q*Animation classes aren't available so just # make sure the button is hidden: self._ui.buttons_frame.hide() self.btn_visibility = 0.0 def _are_any_btns_enabled(self): """ Return if any of the buttons are enabled """ return not (self._ui.camera_btn.isHidden()) """ button visibility property used by QPropertyAnimation """ def get_btn_visibility(self): return self._btns_visibility def set_btn_visibility(self, value): self._btns_visibility = value self._ui.buttons_frame.setStyleSheet( "#buttons_frame {border-radius: 2px; background-color: rgba(32, 32, 32, %d);}" % (64 * value)) btn_visibility = QtCore.Property(float, get_btn_visibility, set_btn_visibility) def _run_btns_transition_anim(self, direction): """ Run the transition animation for the buttons """ if not self._btns_transition_anim: # set up anim: self._btns_transition_anim = QtCore.QPropertyAnimation( self, "btn_visibility") self._btns_transition_anim.setDuration(150) self._btns_transition_anim.setStartValue(0.0) self._btns_transition_anim.setEndValue(1.0) self._btns_transition_anim.finished.connect( self._on_btns_transition_anim_finished) if self._btns_transition_anim.state( ) == QtCore.QAbstractAnimation.Running: if self._btns_transition_anim.direction() != direction: self._btns_transition_anim.pause() self._btns_transition_anim.setDirection(direction) self._btns_transition_anim.resume() else: pass # just let animation continue! else: self._btns_transition_anim.setDirection(direction) self._btns_transition_anim.start() def _on_btns_transition_anim_finished(self): if self._btns_transition_anim.direction( ) == QtCore.QAbstractAnimation.Backward: self._ui.buttons_frame.hide() def _on_camera_clicked(self): pm = self._on_screenshot() if pm: self.thumbnail = pm def _update_ui(self): # maximum size of thumbnail is widget geom: thumbnail_geom = self.geometry() thumbnail_geom.moveTo(0, 0) scale_contents = False pm = self.thumbnail if pm: # work out size thumbnail should be to maximize size # whilst retaining aspect ratio pm_sz = pm.size() h_scale = float(thumbnail_geom.height() - 4) / float( pm_sz.height()) w_scale = float(thumbnail_geom.width() - 4) / float(pm_sz.width()) scale = min(1.0, h_scale, w_scale) scale_contents = (scale < 1.0) new_height = min(int(pm_sz.height() * scale), thumbnail_geom.height()) new_width = min(int(pm_sz.width() * scale), thumbnail_geom.width()) new_geom = QtCore.QRect(thumbnail_geom) new_geom.moveLeft(( (thumbnail_geom.width() - 4) / 2 - new_width / 2) + 2) new_geom.moveTop(( (thumbnail_geom.height() - 4) / 2 - new_height / 2) + 2) new_geom.setWidth(new_width) new_geom.setHeight(new_height) thumbnail_geom = new_geom self._ui.thumbnail.setScaledContents(scale_contents) self._ui.thumbnail.setGeometry(thumbnail_geom) # now update buttons based on current thumbnail: if not self._btns_transition_anim or self._btns_transition_anim.state( ) == QtCore.QAbstractAnimation.Stopped: if self.thumbnail or not self._are_any_btns_enabled(): self._ui.buttons_frame.hide() self._btns_visibility = 0.0 else: self._ui.buttons_frame.show() self._btns_visibility = 1.0 def _safe_get_dialog(self): """ Get the widgets dialog parent. just call self.window() but this is unstable in Nuke Previously this would causing a crash on exit - suspect that it's caching something internally which then doesn't get cleaned up properly... """ current_widget = self while current_widget: if isinstance(current_widget, QtGui.QDialog): return current_widget current_widget = current_widget.parentWidget() return None def _on_screenshot(self): """ Perform the actual screenshot """ # hide the containing window - we can't actuall hide # the window as this will break modality! Instead # we have to move the window off the screen: win = self._safe_get_dialog() win_geom = None if win: win_geom = win.geometry() win.setGeometry(1000000, 1000000, win_geom.width(), win_geom.height()) # make sure this event is processed: QtCore.QCoreApplication.processEvents() QtCore.QCoreApplication.sendPostedEvents(None, 0) QtCore.QCoreApplication.flush() try: # get temporary file to use: # to be cross-platform and python 2.5 compliant, we can't use # tempfile.NamedTemporaryFile with delete=False. Instead, we # use tempfile.mkstemp which does practically the same thing! tf, path = tempfile.mkstemp(suffix=".png", prefix="tanktmp") if tf: os.close(tf) pm = screen_grab.screen_capture() finally: # restore the window: if win: win.setGeometry(win_geom) QtCore.QCoreApplication.processEvents() return pm
class FileListView(browser_widget.BrowserWidget): # signals - note, 'object' is used to avoid # issues with PyQt when None is passed as PyQt # doesn't allow None to be passed to classes # other than object (an exception is raised) open_previous_workfile = QtCore.Signal(object)#FileItem open_previous_publish = QtCore.Signal(object)#FileItem view_in_shotgun = QtCore.Signal(object)#FileItem NO_TASK_NAME = "No Task" def __init__(self, parent=None): """ Construction """ browser_widget.BrowserWidget.__init__(self, parent) self._current_filter = {} # tweak style self.title_style = "none" self._update_title() @property def selected_published_file(self): selected_item = self.get_selected_item() if selected_item: return selected_item.published_file return None @property def selected_work_file(self): selected_item = self.get_selected_item() if selected_item: return selected_item.work_file return None # Enable to force all work to be done in the main thread # which can help when debugging # IMPORTANT - set this to False before releasing!!! DEBUG_GET_DATA_IN_MAIN_THREAD=False def get_data(self, data): """ Called by browser widget in worker thread to query the list of files to display for the specified context """ if FileListView.DEBUG_GET_DATA_IN_MAIN_THREAD: # debug only - _get_data will be called first in # process_result which runs in the main thread return data else: return self._get_data(data) def _get_data(self, data): """ Retrieve the list of files to display as well as the various display and grouping options required to build the file list. :param data: Dictionary containing: handler - A 'WorkFiles' instance containing the main application business logic filter - The current 'FileFilter' instance being applied to the list :returns: Dictionary containing the various display & grouping options required to build the file list as well as the list of files organised by task. """ result = {"task_groups":{}, "task_name_order":{}} handler = data["handler"] filter = data.get("filter") mode = filter.mode # get some additional info from the handler: ctx = handler.get_current_work_area() result["can_do_new_file"] = handler.can_do_new_file() result["have_valid_workarea"] = (ctx and (ctx.entity or ctx.project)) result["have_valid_configuration"] = handler.have_valid_configuration_for_work_area() result["current_task_name"] = ctx.task.get("name") if ctx and ctx.task else None result["can_change_work_area"] = handler.can_change_work_area() result["filter"] = filter result["task_order"] = [] if result["have_valid_workarea"] and result["have_valid_configuration"]: # get the list of files from the handler: files = handler.find_files(filter) # re-pivot this list of files ready to display: # # builds the following structure # { task_name : { (file)name : { "files" : { 1:file,2:file, ... }, "thumbnail" : path, ... } } } task_groups = {} for file in files: # first level is task group task_name = file.task.get("name") if file.task else FileListView.NO_TASK_NAME task_group = task_groups.setdefault(task_name, dict()) # next level is name: name_group = task_group.setdefault(file.name, dict()) # finally, add file to files: file_versions = name_group.setdefault("files", dict()) file_versions[file.version] = file # do some pre-processing of file groups: filtered_task_groups = {} task_modified_pairs = [] task_name_order = {} for task, name_groups in task_groups.iteritems(): name_modified_pairs = [] filtered_name_groups = {} for name, details in name_groups.iteritems(): files_versions = details["files"] # find highest version info: local_versions = [f.version for f in files_versions.values() if f.is_local] if mode == FileFilter.WORKFILES_MODE and not local_versions: # don't have a version of this file to display! continue publish_versions = [f.version for f in files_versions.values() if f.is_published] if mode == FileFilter.PUBLISHES_MODE and not publish_versions: # don't have a version of this file to display! continue highest_local_version = -1 if local_versions: highest_local_version = max(local_versions) details["highest_local_file"] = files_versions[highest_local_version] highest_publish_version = -1 if publish_versions: highest_publish_version = max(publish_versions) details["highest_publish_file"] = files_versions[highest_publish_version] # find thumbnail to use: sorted_versions = sorted(files_versions.keys(), reverse=True) thumbnail = None for version in sorted_versions: # skip any versions that are greater than the one we are looking for # Note: we shouldn't choose a thumbnail for versions that aren't # going to be displayed so filter these out if ((mode == FileFilter.WORKFILES_MODE and version > highest_local_version) or (mode == FileFilter.PUBLISHES_MODE and version > highest_publish_version)): continue thumbnail = files_versions[version].thumbnail if thumbnail: # special case - update the thumbnail! if mode == FileFilter.WORKFILES_MODE and version < highest_local_version: files_versions[highest_local_version].set_thumbnail(thumbnail) break details["thumbnail"] = thumbnail # update group with details: filtered_name_groups[name] = details # determine when this file was last updated (modified or published) # this is used to sort the files in the list: last_updated = None if mode == FileFilter.WORKFILES_MODE and highest_local_version >= 0: last_updated = files_versions[highest_local_version].modified_at if highest_publish_version >= 0: published_at = files_versions[highest_publish_version].published_at last_updated = max(last_updated, published_at) if last_updated else published_at name_modified_pairs.append((name, last_updated)) if not filtered_name_groups: # everything in this group was filtered out! continue filtered_task_groups[task] = filtered_name_groups # sort names in reverse order of modified date: name_modified_pairs.sort(key=itemgetter(1), reverse=True) task_name_order[task] = [n for (n, _) in name_modified_pairs] task_modified_pairs.append((task, max([m for (_, m) in name_modified_pairs]))) # sort tasks in reverse order of modified date: task_modified_pairs.sort(key=itemgetter(1), reverse=True) task_order = [n for (n, _) in task_modified_pairs] result["task_groups"] = filtered_task_groups result["task_name_order"] = task_name_order result["task_order"] = task_order return result def process_result(self, result): """ Process list of tasks retrieved by get_data on the main thread :param result: Dictionary containing the various display & grouping options required to build the file list as well as the list of files organised by task. """ if FileListView.DEBUG_GET_DATA_IN_MAIN_THREAD: # gathering of data was not done in the get_data stage so we # should do it here instead - this method gets called in the # main thread result = self._get_data(result) task_groups = result["task_groups"] task_name_order = result["task_name_order"] task_order = result["task_order"] current_task_name = result["current_task_name"] self._current_filter = result["filter"] self._update_title() if not task_groups: # build a useful error message using the info we have available: msg = "" if not result["can_change_work_area"]: if not result["have_valid_workarea"]: msg = "The current Work Area is not valid!" elif not result["have_valid_configuration"]: msg = ("Shotgun File Manager has not been configured for the environment " "being used by the selected Work Area!") elif not result["can_do_new_file"]: msg = "Couldn't find any files in this Work Area!" else: msg = "Couldn't find any files!\nClick the New file button to start work." else: if not result["have_valid_workarea"]: msg = "The current Work Area is not valid!" elif not result["have_valid_configuration"]: msg = ("Shotgun File Manager has not been configured for the environment " "being used by the selected Work Area!\n" "Please choose a different Work Area to continue.") elif not result["can_do_new_file"]: msg = "Couldn't find any files in this Work Area!\nTry selecting a different Work Area." else: msg = "Couldn't find any files!\nClick the New file button to start work." self.set_message(msg) return for task_name in task_order: name_groups = task_groups[task_name] if (len(task_groups) > 1 or (task_name != current_task_name and task_name != FileListView.NO_TASK_NAME and current_task_name == None)): # add header for task: h = self.add_item(browser_widget.ListHeader) h.set_title("%s" % (task_name)) ordered_names = task_name_order[task_name] for name in ordered_names: details = name_groups[name] files = details["files"] highest_local_file = details.get("highest_local_file") highest_publish_file = details.get("highest_publish_file") thumbnail = details["thumbnail"] # add new item to list: item = self._add_file_item(highest_publish_file, highest_local_file) if not item: continue # set thumbnail if have one: if thumbnail: item.set_thumbnail(thumbnail) # add context menu: item.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu) # if it's a publish then add 'View In Shotgun' item: if highest_publish_file: action = QtGui.QAction("View latest Publish in Shotgun", item) # (AD) - the '[()]' syntax in action.triggered[()].connect looks weird right! # 'triggered' is a QtCore.SignalInstance which actually defines multiple # signals: triggered() & triggered(bool). PySide will correctly determine which # one to use but PyQt gets confused and calls the (bool) version instead which # causes problems for us... Luckily, Qt lets us use the argument list () to # index into the SignalInstance object to force the use of the non-bool version - yay! action.triggered[()].connect(lambda f=highest_publish_file: self._on_show_in_shotgun_action_triggered(f)) item.addAction(action) # build context menu for all publish versions: published_versions = [f.version for f in files.values() if f.is_published and isinstance(f.version, int)] if published_versions: published_versions.sort(reverse=True) publishes_action = QtGui.QAction("Open Publish Read-Only", item) publishes_sm = QtGui.QMenu(item) publishes_action.setMenu(publishes_sm) item.addAction(publishes_action) for v in published_versions[:20]: f = files[v] msg = ("v%03d" % f.version) action = QtGui.QAction(msg, publishes_sm) # see above for explanation of [()] syntax in action.triggered[()].connect... action.triggered[()].connect(lambda f=f: self._on_open_publish_action_triggered(f)) publishes_sm.addAction(action) # build context menu for all work files: wf_versions = [f.version for f in files.values() if f.is_local and isinstance(f.version, int)] if wf_versions: wf_versions.sort(reverse=True) wf_action = QtGui.QAction("Open Work File", item) wf_sm = QtGui.QMenu(item) wf_action.setMenu(wf_sm) item.addAction(wf_action) for v in wf_versions[:20]: f = files[v] msg = ("v%03d" % f.version) action = QtGui.QAction(msg, wf_sm) # see above for explanation of [()] syntax in action.triggered[()].connect... action.triggered[()].connect(lambda f=f: self._on_open_workfile_action_triggered(f)) wf_sm.addAction(action) def _update_title(self): """ Update the list title depending on the mode """ if not self._current_filter: return self.set_label(self._current_filter.list_title) def _add_file_item(self, latest_published_file, latest_work_file): """ Add an item to the file list given the latest publish & work files :param latest_published_file: The latest published version of the file to be added :param latest_work_file: The latest work/local version of the file to be added """ details = "" tooltip = "" # colours for item titles: red = "rgb(200, 84, 74)" green = "rgb(145, 206, 95)" current_mode = self._current_filter.mode file = None editable = True not_editable_reason = "" if current_mode == FileFilter.WORKFILES_MODE: file = latest_work_file title_colour = None if latest_published_file: if file.compare_with_publish(latest_published_file) >= 0: # work file is most recent title_colour = green tooltip += "This is the latest version of this file" else: # published file is most recent title_colour = red tooltip += "<b>A more recent published version of this file is available:</b>" tooltip += "<br>" tooltip += ("<br><b>Version v%03d</b>" % latest_published_file.version) tooltip += "<br>" + latest_published_file.format_published_by_details() tooltip += "<br>" tooltip += "<br><b>Description:</b>" tooltip += "<br>" + latest_published_file.format_publish_description() else: tooltip += "This file has never been published" if file.version is not None: details = "<b>%s, v%03d</b>" % (file.name, file.version) else: details = "<b>%s</b>" % (file.name) if title_colour: details = "<span style='color:%s'>%s</span>" % (title_colour, details) details += "<br>" + file.format_modified_by_details() editable = file.editable not_editable_reason = file.not_editable_reason elif current_mode == FileFilter.PUBLISHES_MODE: file = latest_published_file title_colour = None tooltip += "<b>Description:</b>" tooltip += "<br>" + file.format_publish_description() tooltip += "<hr>" if latest_work_file: if latest_work_file.compare_with_publish(file) <= 0: # published file is most recent title_colour = green tooltip += "This is the latest version of this file" else: # work file is most recent #title_colour = red tooltip += "<b>A more recent version of this file was found in your work area:</b>" tooltip += "<br>" #tooltip += "<br><b>Details:</b>" tooltip += ("<br><b>Version v%03d</b>" % latest_work_file.version) tooltip += "<br>" + latest_work_file.format_modified_by_details() else: title_colour = green tooltip += "This is the latest version of this file" details = "<b>%s, v%03d</b>" % (file.name, file.version) if title_colour: details = "<span style='color:%s'>%s</span>" % (title_colour, details) details += "<br>" + file.format_published_by_details() editable = file.editable not_editable_reason = file.not_editable_reason else: raise TankError("Display mode is not recognised!") # update editable info on the tooltip if not editable: tooltip += "<hr>" tooltip += "Read-only: " + not_editable_reason # add item: item = self.add_item(FileItemForm) item.published_file = latest_published_file item.work_file = latest_work_file # set tool tip item.setToolTip(tooltip) # build and set details string: item.set_details(details) item.set_is_editable(editable, not_editable_reason) return item def _on_open_workfile_action_triggered(self, file): """ Open action triggered from context menu """ self.open_previous_workfile.emit(file) def _on_open_publish_action_triggered(self, file): """ Open action triggered from context menu """ self.open_previous_publish.emit(file) def _on_show_in_shotgun_action_triggered(self, file): """ Show in Shotgun action triggered from context menu """ self.view_in_shotgun.emit(file)
class ThumbnailWidget(QtGui.QWidget): """ Thumbnail widget that provides screen capture functionality """ thumbnail_changed = QtCore.Signal() def __init__(self, parent=None): """ Construction """ QtGui.QWidget.__init__(self, parent) self._ui = Ui_ThumbnailWidget() self._ui.setupUi(self) # create layout to control buttons frame layout = QtGui.QHBoxLayout() layout.addWidget(self._ui.buttons_frame) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.setLayout(layout) # connect to buttons: self._ui.camera_btn.clicked.connect(self._on_camera_clicked) self._btns_transition_anim = None self._update_ui() @property def thumbnail(self): pm = self._ui.thumbnail.pixmap() return pm if pm and not pm.isNull() else None @thumbnail.setter def thumbnail(self, value): self._ui.thumbnail.setPixmap(value if value else QtGui.QPixmap()) self._update_ui() self.thumbnail_changed.emit() def enable_screen_capture(self, enable): self._ui.camera_btn.setVisible(enable) def resizeEvent(self, event): self._update_ui() def enterEvent(self, event): """ when the cursor enters the control, show the buttons """ if self.thumbnail and self._are_any_btns_enabled(): self._ui.buttons_frame.show() self._run_btns_transition_anim(QtCore.QAbstractAnimation.Forward) def leaveEvent(self, event): """ when the cursor leaves the control, hide the buttons """ if self.thumbnail and self._are_any_btns_enabled(): self._run_btns_transition_anim(QtCore.QAbstractAnimation.Backward) def _are_any_btns_enabled(self): """ Return if any of the buttons are enabled """ return not (self._ui.camera_btn.isHidden()) """ button visibility property used by QPropertyAnimation """ def get_btn_visibility(self): return self._btns_visibility def set_btn_visibility(self, value): self._btns_visibility = value self._ui.buttons_frame.setStyleSheet( "#buttons_frame {border-radius: 2px; background-color: rgba(32, 32, 32, %d);}" % (64 * value)) btn_visibility = QtCore.Property(float, get_btn_visibility, set_btn_visibility) def _run_btns_transition_anim(self, direction): """ Run the transition animation for the buttons """ if not self._btns_transition_anim: # set up anim: self._btns_transition_anim = QtCore.QPropertyAnimation( self, "btn_visibility") self._btns_transition_anim.setDuration(150) self._btns_transition_anim.setStartValue(0.0) self._btns_transition_anim.setEndValue(1.0) self._btns_transition_anim.finished.connect( self._on_btns_transition_anim_finished) if self._btns_transition_anim.state( ) == QtCore.QAbstractAnimation.Running: if self._btns_transition_anim.direction() != direction: self._btns_transition_anim.pause() self._btns_transition_anim.setDirection(direction) self._btns_transition_anim.resume() else: pass # just let animation continue! else: self._btns_transition_anim.setDirection(direction) self._btns_transition_anim.start() def _on_btns_transition_anim_finished(self): if self._btns_transition_anim.direction( ) == QtCore.QAbstractAnimation.Backward: self._ui.buttons_frame.hide() def _on_camera_clicked(self): pm = self._on_screenshot() if pm: self.thumbnail = pm def _update_ui(self): # maximum size of thumbnail is widget geom: thumbnail_geom = self.geometry() thumbnail_geom.moveTo(0, 0) scale_contents = False pm = self.thumbnail if pm: # work out size thumbnail should be to maximize size # whilst retaining aspect ratio pm_sz = pm.size() h_scale = float(thumbnail_geom.height() - 4) / float( pm_sz.height()) w_scale = float(thumbnail_geom.width() - 4) / float(pm_sz.width()) scale = min(1.0, h_scale, w_scale) scale_contents = (scale < 1.0) new_height = min(int(pm_sz.height() * scale), thumbnail_geom.height()) new_width = min(int(pm_sz.width() * scale), thumbnail_geom.width()) new_geom = QtCore.QRect(thumbnail_geom) new_geom.moveLeft(( (thumbnail_geom.width() - 4) / 2 - new_width / 2) + 2) new_geom.moveTop(( (thumbnail_geom.height() - 4) / 2 - new_height / 2) + 2) new_geom.setWidth(new_width) new_geom.setHeight(new_height) thumbnail_geom = new_geom self._ui.thumbnail.setScaledContents(scale_contents) self._ui.thumbnail.setGeometry(thumbnail_geom) # now update buttons based on current thumbnail: if not self._btns_transition_anim or self._btns_transition_anim.state( ) == QtCore.QAbstractAnimation.Stopped: if self.thumbnail or not self._are_any_btns_enabled(): self._ui.buttons_frame.hide() self._btns_visibility = 0.0 else: self._ui.buttons_frame.show() self._btns_visibility = 1.0 def _safe_get_dialog(self): """ Get the widgets dialog parent. just call self.window() but this is unstable in Nuke Previously this would causing a crash on exit - suspect that it's caching something internally which then doesn't get cleaned up properly... """ current_widget = self while current_widget: if isinstance(current_widget, QtGui.QDialog): return current_widget current_widget = current_widget.parentWidget() return None class ScreenshotThread(QtCore.QThread): """ Wrap screenshot call in a thread just to be on the safe side! This helps avoid the os thinking the application has hung for certain applications (e.g. Softimage on Windows) """ def __init__(self, path): QtCore.QThread.__init__(self) self._path = path self._error = None def get_error(self): return self._error def run(self): try: if sys.platform == "darwin": # use built-in screenshot command on the mac os.system("screencapture -m -i -s %s" % self._path) elif sys.platform == "linux2": # use image magick os.system("import %s" % self._path) elif sys.platform == "win32": # use external boxcutter tool bc = os.path.abspath( os.path.join(__file__, "../resources/boxcutter.exe")) subprocess.check_call([bc, self._path]) except Exception, e: self._error = str(e)
class SgProjectModel(ShotgunModel): """ This model represents the data which is displayed in the projects list view """ DISPLAY_NAME_ROLE = QtCore.Qt.UserRole + 101 thumbnail_updated = QtCore.Signal(QtGui.QStandardItem) project_launched = QtCore.Signal() _supports_project_templates = None @classmethod def supports_project_templates(cls): """ Tests if Shotgun 6.0 Project Templates are supported on the server. If this method has never been called, the server will be contacted synchronously and the result will be cached so subsequent calls are faster. :returns: True if the server supports Shotgun 6.0 Project Templates, False otherwise. """ connection = sgtk.platform.current_engine().shotgun # If we haven't checked on the server yet. if cls._supports_project_templates is None: try: # Try to read the field's schema. connection.schema_field_read("Project", "is_template") # It worked therefore it exists. cls._supports_project_templates = True except Exception: # We got an error, so it doesn't exist. cls._supports_project_templates = False return cls._supports_project_templates def __init__(self, parent, overlay_parent_widget): """ Constructor """ ShotgunModel.__init__(self, parent, download_thumbs=True) # load up the thumbnail to use when there is none set in Shotgun self._missing_thumbnail_project = QtGui.QPixmap( ":/tk-desktop/missing_thumbnail_project.png" ) # load up the cached data for the model filters = [["name", "is_not", "Template Project"], ["archived", "is_not", True]] # Template projects is a Shotgun 6.0 feature, so make sure it exists # on the server before filtering on that value. if SgProjectModel.supports_project_templates(): filters.append(["is_template", "is_not", True]) interesting_fields = [ "name", "sg_status", "current_user_favorite", "last_accessed_by_current_user", "sg_description", ] ShotgunModel._load_data( self, entity_type="Project", filters=filters, hierarchy=["name"], fields=interesting_fields, order=[], ) # and force a refresh of the data from Shotgun self._refresh_data() def update_project_accessed_time(self, project): """ Set the current user's last-accessed time for the given project. This will update the value in the model and in Shotgun. """ if project is None: return # Update Project.last_accessed_by_current_user in Shotgun engine = sgtk.platform.current_engine() engine.shotgun.update_project_last_accessed(project, engine.get_current_login()) # Update the data in the model item = self.item_from_entity("Project", project["id"]) # set to unix seconds rather than datetime to be compatible with Shotgun model utc_now_epoch = time.mktime(datetime.datetime.utcnow().utctimetuple()) project["last_accessed_by_current_user"] = utc_now_epoch item.setData(project, ShotgunModel.SG_DATA_ROLE) self.project_launched.emit() def _populate_item(self, item, sg_data): """ Implement the abstract class method from ShotgunModel. Set the item data based on the Shotgun project. """ item.setData(sg_data.get("name", "No Name"), self.DISPLAY_NAME_ROLE) def _populate_default_thumbnail(self, item): """ Implement the abstract class method from ShotgunModel. Set the default thumbnail to the missing thumbnail. """ item.setIcon(self._missing_thumbnail_project) def _populate_thumbnail(self, item, field, path): """ Implement the abstract class method from ShotgunModel. Set the thumbnail directly from the path. """ # first load as a pixmap to avoid the icon delayed loading thumb = QtGui.QPixmap(path) item.setIcon(thumb) # signal anybody listening for thumbnail updates self.thumbnail_updated.emit(item)
class WorkFilesForm(QtGui.QWidget): """ Primary work area UI """ # signals - note, 'object' is used to avoid # issues with PyQt when None is passed open_publish = QtCore.Signal(object, object, bool) #FileItem, FileItem, bool open_workfile = QtCore.Signal(object, object, bool) #FileItem, FileItem, bool open_previous_publish = QtCore.Signal(object) #FileItem open_previous_workfile = QtCore.Signal(object) #FileItem new_file = QtCore.Signal() show_in_fs = QtCore.Signal() show_in_shotgun = QtCore.Signal(object) #FileItem def __init__(self, app, handler, parent=None): """ Construction """ QtGui.QWidget.__init__(self, parent) self._app = app self._handler = handler # set up the UI from .ui.work_files_form import Ui_WorkFilesForm self._ui = Ui_WorkFilesForm() self._ui.setupUi(self) # patch up the lines to be the same colour as the font: clr = QtGui.QApplication.palette().color(QtGui.QPalette.Text) rgb_str = "rgb(%d,%d,%d)" % (clr.red(), clr.green(), clr.blue()) self._ui.project_line.setStyleSheet( "#project_line{background-color: %s;}" % rgb_str) self._ui.entity_line.setStyleSheet( "#entity_line{background-color: %s;}" % rgb_str) self._ui.task_line.setStyleSheet("#task_line{background-color: %s;}" % rgb_str) ss = "{font-size: 14pt; border-style: dashed; border-width: 2px; border-radius: 3px; border-color: %s;}" % rgb_str self._ui.no_project_label.setStyleSheet("#no_project_label %s" % ss) self._ui.no_task_label.setStyleSheet("#no_task_label %s" % ss) self._ui.no_entity_label.setStyleSheet("#no_entity_label %s" % ss) # set up the work area depending on if it's possible to change it: if self._handler.can_change_work_area(): # update the style sheet for the work area form so it # becomes highlighted when hovering over it # test for lightness ahead of use to stay compatible with Qt 4.6.2 if hasattr(clr, 'lightness'): clr = QtGui.QApplication.palette().color(QtGui.QPalette.Window) clr = clr.lighter() if clr.lightness() < 128 else clr.darker() ss = self._ui.work_area_frame.styleSheet() ss = "%s #work_area_frame:hover {background-color: rgb(%d,%d,%d);}" % ( ss, clr.red(), clr.green(), clr.blue()) self._ui.work_area_frame.setStyleSheet(ss) self._ui.work_area_frame.setCursor(QtCore.Qt.PointingHandCursor) self._ui.work_area_frame.setToolTip("Click to Change Work Area...") self._ui.no_change_frame.setVisible(False) self._ui.work_area_frame.mousePressEvent = self._on_work_area_mouse_press_event else: self._ui.work_area_frame.setCursor(QtCore.Qt.ArrowCursor) self._ui.work_area_frame.setToolTip( "The Work Area is locked and cannot be changed") self._ui.no_change_frame.setVisible(True) # connect up controls: self._ui.show_in_fs_label.mousePressEvent = self._on_show_in_fs_mouse_press_event self._ui.filter_combo.currentIndexChanged.connect( self._on_filter_selection_changed) self._ui.open_file_btn.clicked.connect(self._on_open_file) self._ui.new_file_btn.clicked.connect(self._on_new_file) self._ui.file_list.action_requested.connect(self._on_open_file) self._ui.file_list.selection_changed.connect( self._on_file_selection_changed) self._ui.file_list.set_app(self._app) self._ui.file_list.open_previous_workfile.connect( self._on_open_previous_workfile) self._ui.file_list.open_previous_publish.connect( self._on_open_previous_publish) self._ui.file_list.view_in_shotgun.connect(self._on_view_in_shotgun) # set up the work area: ctx = self._handler.get_current_work_area() self._set_work_area(ctx) self._on_file_selection_changed() @property def filter(self): return self._get_current_filter() def _on_view_in_shotgun(self, file): self.show_in_shotgun.emit(file) def _on_show_in_fs_mouse_press_event(self, event): """ Emit event when the user clicks the show in file system link: """ self.show_in_fs.emit() def closeEvent(self, e): """ Ensure everything is cleaned up when the widget is closed """ self._ui.file_list.destroy() return QtGui.QWidget.closeEvent(self, e) def _on_open_file(self): """ """ # get the currently selected work file work_file = self._ui.file_list.selected_work_file published_file = self._ui.file_list.selected_published_file current_filter = self._get_current_filter() if not current_filter: return if current_filter.mode == FileFilter.WORKFILES_MODE: self.open_workfile.emit(work_file, published_file, False) else: # current_filter.mode == FileFilter.PUBLISHES_MODE: self.open_publish.emit(published_file, work_file, False) def _on_open_previous_workfile(self, file): """ """ self.open_previous_workfile.emit(file) def _on_open_previous_publish(self, file): """ """ self.open_previous_publish.emit(file) def _on_new_file(self): """ """ self.new_file.emit() def _set_work_area(self, ctx): """ Set the current work area to the specified context. """ self._app.log_debug( "Setting the work area in the File Manager UI") # to %s..." % ctx) # update work area info: self._app.log_debug(" - Updating Work Area") self._update_work_area(ctx) # update the filter menu: self._app.log_debug(" - Updating Filter menu") self._update_filter_menu() # finally, update file list: self._app.log_debug(" - Refreshing File List") self._refresh_file_list() # update new button enabled state can_do_new = self._handler.can_do_new_file() self._ui.new_file_btn.setEnabled(can_do_new) self._app.log_debug( "Finished setting the work area in the File Manager UI!") def _on_work_area_mouse_press_event(self, event): """ Event triggered when mouse is pressed in the work area form """ new_ctx = self._handler.select_work_area() if new_ctx: self._set_work_area(new_ctx[0]) def _refresh_file_list(self): """ Refresh the file list based on the current filter """ # get the file filter: filter = self._get_current_filter() if not filter: return # hide/show the show-in-filesystem link: self._ui.show_in_fs_label.setVisible(filter.show_in_file_system) # clear and reload list: self._ui.file_list.clear() self._ui.file_list.load({"handler": self._handler, "filter": filter}) self._on_file_selection_changed() def _on_filter_selection_changed(self, idx): """ Called when the filter is changed """ self._refresh_file_list() def _on_file_selection_changed(self): """ """ something_selected = ( self._ui.file_list.selected_published_file is not None or self._ui.file_list.selected_work_file is not None) self._ui.open_file_btn.setEnabled(something_selected) def _update_filter_menu(self): """ Update the list of users to display work files for """ # get list of filters from handler: filters = self._handler.get_file_filters() # get selected filter: previous_filter = self._get_current_filter() # clear menu: self._ui.filter_combo.clear() # add back in filters: selected_idx = 0 separator_count = 0 for filter in filters: if filter == "separator": # special 'filter' signifying a separator should be added # - don't add it yet though in case there aren't any more # non separator items following! separator_count += 1 continue if not filter.menu_label: # filter doesn't have a menu label - bad! continue # add any separators: while separator_count > 0: separator_count -= 1 self._ui.filter_combo.insertSeparator( self._ui.filter_combo.count()) # see if this is the previously selected filter: if filter == previous_filter: selected_idx = self._ui.filter_combo.count() # add filter: self._ui.filter_combo.addItem(filter.menu_label, filter) # set the current index: self._ui.filter_combo.setCurrentIndex(selected_idx) def _get_current_filter(self): """ Get the current filter """ filter = None idx = self._ui.filter_combo.currentIndex() if idx >= 0: filter = self._ui.filter_combo.itemData(idx) # convert from QVariant object if itemData is returned as such if hasattr(QtCore, "QVariant") and isinstance( filter, QtCore.QVariant): filter = filter.toPyObject() return filter def _update_work_area(self, ctx): """ A lot of this should be done in a worker thread! """ if ctx and ctx.project: # context has a project - that's something at least! self._ui.project_pages.setCurrentWidget(self._ui.project_page) # get additional details: sg_details = {} try: sg_details = self._app.shotgun.find_one( "Project", [["id", "is", ctx.project["id"]]], ["sg_description", "image", "code"]) except: pass # header: project_name = ctx.project.get("name") or sg_details.get("code") self._ui.project_label.setText("Project: %s" % (project_name or "-")) self._ui.project_frame.setToolTip("%s" % (project_name or "")) # thumbnail: project_thumbnail = QtGui.QPixmap() project_img_url = sg_details.get("image") if project_img_url: thumbnail_path = self._download_thumbnail(project_img_url) if thumbnail_path: project_thumbnail = QtGui.QPixmap(thumbnail_path) self._set_thumbnail(self._ui.project_thumbnail, project_thumbnail) # description: desc = sg_details.get( "sg_description" ) or "<i>No description was entered for this Project</i>" self._ui.project_description.setText(desc) if ctx.entity: # work area defined - yay! self._ui.entity_pages.setCurrentWidget(self._ui.entity_page) self._ui.entity_pages.setVisible(True) # get any extra fields that have been defined for this entity type. This will be a dictionary # of label:field pairs for the current entity type: extra_fields = self._app.get_setting( "sg_entity_type_extra_display_fields", {}).get(ctx.entity["type"], {}) # get additional details: sg_details = {} try: sg_details = self._app.shotgun.find_one( ctx.entity["type"], [["project", "is", ctx.project], ["id", "is", ctx.entity["id"]]], ["description", "image", "code"] + extra_fields.values()) except: pass # header: entity_type_name = tank.util.get_entity_type_display_name( self._app.tank, ctx.entity.get("type")) entity_name = ctx.entity.get("name") or sg_details.get("code") self._ui.entity_label.setText( "%s: %s" % (entity_type_name, entity_name or "-")) self._ui.entity_frame.setToolTip("%s" % (entity_name or "")) # thumbnail: entity_thumbnail = QtGui.QPixmap() entity_img_url = sg_details.get("image") if entity_img_url: thumbnail_path = self._download_thumbnail(entity_img_url) if thumbnail_path: entity_thumbnail = QtGui.QPixmap(thumbnail_path) self._set_thumbnail(self._ui.entity_thumbnail, entity_thumbnail) # description including the display of extra fields: extra_info = ", ".join([ "%s: %s" % (label, str(sg_details.get(field))) for label, field in extra_fields.iteritems() ]) desc = "" if extra_info: desc += "(%s)<br>" % extra_info desc += sg_details.get("description") or ( "<i>No description was entered for this %s</i>" % entity_type_name) self._ui.entity_description.setText(desc) # task: if ctx.task: # have a task - double yay!! self._ui.task_pages.setCurrentWidget(self._ui.task_page) self._ui.task_pages.setVisible(True) # get additional details: sg_details = {} try: sg_details = self._app.shotgun.find_one( "Task", [["project", "is", ctx.project], ["id", "is", ctx.task["id"]]], ["task_assignees", "step.Step.code", "content"]) except Exception, e: pass # header: task_name = ctx.task.get("name") or sg_details.get( "content") self._ui.task_label.setText("Task: %s" % (task_name or "-")) self._ui.task_frame.setToolTip("%s" % (task_name or "")) # thumbnail: task_thumbnail = QtGui.QPixmap() task_assignees = sg_details.get("task_assignees", []) if len(task_assignees) > 0: user_id = task_assignees[0]["id"] sg_user_details = {} try: sg_user_details = self._app.shotgun.find_one( "HumanUser", [["id", "is", user_id]], ["image"]) except Exception, e: pass if sg_user_details: img_url = sg_user_details.get("image") if img_url: thumbnail_path = self._download_thumbnail( img_url) if thumbnail_path: task_thumbnail = QtGui.QPixmap( thumbnail_path) self._set_thumbnail(self._ui.task_thumbnail, task_thumbnail) # details assignees = [] for assignee in sg_details.get("task_assignees", []): name = assignee.get("name") if name: assignees.append(name) assignees_str = ", ".join(assignees) if assignees else "-" step_str = sg_details.get("step.Step.code") if step_str is None: step_str = "-" self._ui.task_description.setText( "Assigned to: %s<br>Pipeline Step: %s" % (assignees_str, step_str)) else: # task not chosen if not self._handler.can_change_work_area(): self._ui.task_pages.setVisible(False) else: self._ui.task_pages.setCurrentWidget( self._ui.no_task_page) else:
class PublishResultForm(QtGui.QWidget): """ Implementation of the main publish UI """ close = QtCore.Signal() def __init__(self, parent=None): """ Construction """ QtGui.QWidget.__init__(self, parent) self._status = True self._errors = [] # set up the UI from .ui.publish_result_form import Ui_PublishResultForm self._ui = Ui_PublishResultForm() self._ui.setupUi(self) self._ui.close_btn.clicked.connect(self._on_close) self._update_ui() # @property def __get_status(self): return self._status # @status.setter def __set_status(self, value): self._status = value self._update_ui() status = property(__get_status, __set_status) # @property def __get_errors(self): return self._errors # @errors.setter def __set_errors(self, value): self._errors = value self._update_ui() errors = property(__get_errors, __set_errors) def _on_close(self): self.close.emit() def _update_ui(self): self._ui.status_icon.setPixmap( QtGui.QPixmap([":/res/failure.png", ":/res/success.png"][self._status])) self._ui.status_title.setText(["Failure!", "Success"][self._status]) details = "" if self._status: details = ("Your Publish has successfully completed. Your " "work has been shared, your scene has been " "versioned up and your mates have been notified!") else: details = "\n\n".join(self._errors) self._ui.status_details.setText(details)
class GroupingModel(QtGui.QStandardItemModel): """ A model that manages items in collapsible groups. """ GROUP_ROLE = QtCore.Qt.UserRole + 1001 # stores the group an item is in GROUP_RANK_ROLE = QtCore.Qt.UserRole + 1002 # stores the ordering for a group GROUP_VISIBLE_ROLE = QtCore.Qt.UserRole + 1003 # store whether a group is collapsed ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 1004 # store the type of an item (header, footer, content) # the types for non-content items ITEM_TYPE_HEADER = "group_header" ITEM_TYPE_FOOTER = "group_footer" # a signal that is emitted when a group is expanded or collapsed group_toggled = QtCore.Signal(str, bool) # a signal that is emitted when groups are modified groups_modified = QtCore.Signal() def __init__(self, parent=None): QtGui.QStandardItemModel.__init__(self, parent) # key for the default group self.__groups = {} self.__default_group = None # keep track of changes that could effect grouping self.rowsRemoved.connect(self.__handle_rows_removed) def clear(self): # clear groups and default group in addition to the model QtGui.QStandardItemModel.clear(self) if self.__groups: self.__groups = {} self.__default_group = None self.groups_modified.emit() # manage groups def create_group(self, group_key, expanded=True): """ Create a group for group_key Returns a tuple of the header and footer model items created for the group. """ if group_key in self.__groups: raise ValueError("group already exists '%s'" % group_key) # create the header item, defaults to expanded header = QtGui.QStandardItem() header.setData(self.ITEM_TYPE_HEADER, self.ITEM_TYPE_ROLE) header.setData(group_key, self.GROUP_ROLE) header.setData(expanded, self.GROUP_VISIBLE_ROLE) # create the footer item footer = QtGui.QStandardItem() footer.setData(self.ITEM_TYPE_FOOTER, self.ITEM_TYPE_ROLE) footer.setData(group_key, self.GROUP_ROLE) # set flags so clicks flow through to these items header.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEditable) footer.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEditable) # track the header and the footer self.__groups[group_key] = { "header": header, "footer": footer, } # and add to the model self.appendRow(header) self.appendRow(footer) self.groups_modified.emit() return (header, footer) def set_default_group(self, group_key): """ Set the group that an item if there is no explicit assignment """ if group_key not in self.__groups: raise KeyError("no group '%s'" % group_key) if self.__default_group != group_key: self.groups_modified.emit() self.__default_group = group_key def get_header_items(self): """ Returns all the header items """ self.__get_items_of_type(self.ITEM_TYPE_HEADER) def get_footer_items(self): """ Returns all the footer items """ self.__get_items_of_type(self.ITEM_TYPE_FOOTER) def get_group_header(self, group_key): """ Returns the header item for the given group """ if group_key not in self.__groups: raise KeyError("no group '%s'" % group_key) return self.__groups[group_key]["header"] def get_group_footer(self, group_key): """ Returns the footer item for the given group """ if group_key not in self.__groups: raise KeyError("no group '%s'" % group_key) return self.__groups[group_key].get("footer") def get_items_in_group(self, group): """ Returns all the content items for the given group """ start = self.index(0, 0) match_flags = QtCore.Qt.MatchExactly matching_indexes = self.match(start, self.GROUP_ROLE, group, -1, match_flags) return [ index for index in matching_indexes if index.data(self.ITEM_TYPE_ROLE) is None ] def set_group_rank(self, group_key, rank): """ Set the rank for the given group. Groups are ordered by rank. """ # get all items with matching group_key start = self.index(0, 0) match_flags = QtCore.Qt.MatchExactly matching_indexes = self.match(start, self.GROUP_ROLE, group_key, -1, match_flags) if len(matching_indexes) == 0: raise KeyError("no group '%s'" % group_key) # and update the rank on all of them for index in matching_indexes: item = self.itemFromIndex(index) item.setData(rank, self.GROUP_RANK_ROLE) self.groups_modified.emit() def is_group_expanded(self, group_key): """ Returns true of the given group is expanded """ if group_key not in self.__groups: raise KeyError("no group '%s'" % group_key) # expanded/collapsed is stored in the header header = self.__groups[group_key]["header"] if header.data(self.GROUP_VISIBLE_ROLE): return True return False def set_group_expanded(self, group_key, expanded): """ Set the given group to be expanded or not """ if group_key not in self.__groups: raise KeyError("no group '%s'" % group_key) # store the expanded state in the header of the group header = self.__groups[group_key]["header"] header.setData(expanded, self.GROUP_VISIBLE_ROLE) # let listeners know that the state has changed self.group_toggled.emit(group_key, expanded) def get_expanded_state(self): state = {} for group_key in self.__groups.keys(): header = self.__groups[group_key]["header"] state[group_key] = header.data(self.GROUP_VISIBLE_ROLE) return state def set_expanded_state(self, state): for (group_key, expanded) in state.iteritems(): if group_key in self.__groups: self.set_group_expanded(group_key, expanded) # manage items def set_item_group(self, item, group_key): """ Put the given item in the given group """ if group_key not in self.__groups: raise KeyError("no group '%s'" % group_key) # update the group and rank of the item rank = self.__groups[group_key]["header"].data(self.GROUP_RANK_ROLE) item.setData(group_key, self.GROUP_ROLE) item.setData(rank, self.GROUP_RANK_ROLE) self.groups_modified.emit() def get_item_group_key(self, item): """ Get the group for the given item. If none has been set, return the default. """ group_key = item.data(self.GROUP_ROLE) if group_key is None: return self.__default_group return group_key def is_content(self, item_or_index): """ Returns true if the given item or index is a content item """ return self.__check_type(item_or_index, None) def is_header(self, item_or_index): """ Returns true if the given item or index is a header item """ return self.__check_type(item_or_index, self.ITEM_TYPE_HEADER) def is_footer(self, item_or_index): """ Returns true if the given item or index is a footer item """ return self.__check_type(item_or_index, self.ITEM_TYPE_FOOTER) def __get_items_of_type(self, row_type): # returns all items of a given type start = self.index(0, 0) match_flags = QtCore.Qt.MatchExactly return self.match(start, self.ITEM_TYPE_ROLE, row_type, -1, match_flags) def __check_type(self, item_or_index, check_type): # check the type of the given item or index parent = item_or_index.parent() if isinstance(item_or_index, QtGui.QStandardItem) and parent is not None: # item that is not top level item_type = None elif isinstance(item_or_index, QtCore.QModelIndex) and parent.isValid(): # index that is not top level item_type = None else: # top level item or index, type is stored directly item_type = item_or_index.data(self.ITEM_TYPE_ROLE) # return true if check_type matches if check_type is None: return item_type is None return item_type == check_type # react to data changes def __handle_rows_removed(self, parent, start, end): # if this is not a top level item there is nothing to do if not parent.isValid(): return for row in xrange(start, end + 1): item = self.item(row, 0) group_key = item.data(self.GROUP_ROLE) item_type = item.data(self.ITEM_TYPE_ROLE) # if a header item is removed then remove the group if item_type == self.ITEM_TYPE_HEADER: del self.__groups[group_key] # if item is a footer then just remove the footer if item_type == self.ITEM_TYPE_FOOTER: del self.__groups[group_key]["footer"] self.groups_modified.emit()
class WrappedClass(cls): """ Wrapping class """ # Emitted when something is dropped on the widget something_dropped = QtCore.Signal(list) def __init__(self, *args, **kwargs): """ Instantiate the class and enable drops :param args: Arbitrary list of parameters used in base class init :param args: Arbitrary dictionary of parameters used in base class init """ super(WrappedClass, self).__init__(*args, **kwargs) self.setAcceptDrops(True) self._set_property("dragging", False) self._restrict_to_ext = [] # Override dragEnterEvent def dragEnterEvent(self, event): """ Check if we can handle the drop, basically if we will receive local file path :param event: A QEvent """ # Set a property which can used to change the look of the widget # in a style sheet using a pseudo state, e.g. : # DropAreaLabel[dragging="true"] { # border: 2px solid white; # } if event.mimeData().hasFormat("text/plain"): event.accept() elif event.mimeData().hasFormat("text/uri-list"): for url in event.mimeData().urls(): # We don't activate the dragging state unless ext is valid. self._set_property("dragging", True) # Accept if there is at least one local file if _is_local_file(url): event.accept() break else: event.ignore() else: event.ignore() # Override dragLeaveEvent def dragLeaveEvent(self, event): """ Just unset our dragging property :param event: A QEvent """ self._set_property("dragging", False) # Override dropEvent def dropEvent(self, event): """ Process a drop event, build a list of local files and emit somethingDropped if not empty :param event: A QEvent """ self._set_property("dragging", False) urls = event.mimeData().urls() contents = None if urls: if sgtk.util.is_macos(): # Fix for Yosemite and later, file paths are not actual file paths # but weird /.file/id=6571367.18855673 values # https://bugreports.qt-project.org/browse/QTBUG-24379 # https://gist.github.com/wedesoft/3216298 try: # Custom Python (e.g. brewed ones) might not be able to # import Foundation. In that case we do nothing and keep # urls as they are, assuming the problem should be fixed # in custom Python / PyQt / PySide import Foundation fixed_urls = [] for url in urls: # It is fine to pass a regular file url to this method # e.g. file:///foo/bar/blah.ext fu = Foundation.NSURL.URLWithString_( url.toString()).filePathURL() fixed_urls.append(QtCore.QUrl(str(fu))) urls = fixed_urls except: pass contents = [x.toLocalFile() for x in urls if _is_local_file(x)] elif event.mimeData().hasFormat("text/plain"): contents = [event.mimeData().text()] if contents: event.accept() self.something_dropped.emit(contents) else: event.ignore() def _set_property(self, name, value): """ Helper to set custom properties which can be used in style sheets Set the value and do a unpolish / polish to force an update :param name: A property name :param value: A value for this property """ self.setProperty(name, value) # We are using a custom property in style sheets # we need to force a style sheet re-computation with # unpolish / polish self.style().unpolish(self) self.style().polish(self)
class SnapshotHistoryForm(QtGui.QWidget): restore = QtCore.Signal(QtGui.QWidget, basestring, basestring) snapshot = QtCore.Signal(QtGui.QWidget) closed = QtCore.Signal(QtGui.QWidget) def __init__(self, app, handler, parent=None): """ Construction """ QtGui.QWidget.__init__(self, parent) self._app = app self._handler = handler self._path = "" # set up the UI from .ui.snapshot_history_form import Ui_SnapshotHistoryForm self._ui = Ui_SnapshotHistoryForm() self._ui.setupUi(self) self._ui.snapshot_list.set_app(self._app) self._ui.snapshot_list.selection_changed.connect( self._on_list_selection_changed) self._ui.snapshot_list.action_requested.connect(self._on_restore) self._ui.restore_btn.clicked.connect(self._on_restore) self._ui.snapshot_btn.clicked.connect(self._on_snapshot_btn_clicked) @property def path(self): return self._path def refresh(self): # clear the snapshot list: self._ui.snapshot_list.clear() # get the current path from the handler: self._path = None try: self._path = self._handler.get_current_file_path() except Exception, e: # this only ever happens when the scene operation hook # throws an exception! msg = ("Failed to find the current work file path:\n\n" "%s\n\n" "Unable to continue!" % e) self._ui.snapshot_list.set_message(msg) return # load file list: self._ui.snapshot_list.load({ "handler": self._handler, "file_path": self._path }) # update the browser title: self._ui.snapshot_list.set_label( self._handler.get_history_display_name(self._path))
class WorkerNotifier(QtCore.QObject): work_completed = QtCore.Signal(str, object) work_failure = QtCore.Signal(str, str)
class PublishForm(QtGui.QWidget): """ Implementation of the main publish UI """ # signals publish = QtCore.Signal() def __init__(self, app, handler, parent=None): """ Construction """ QtGui.QWidget.__init__(self, parent) self._app = app # TODO: shouldn't need the handler self._handler = handler self._primary_task = None self._tasks = [] # set up the UI from .ui.publish_form import Ui_PublishForm self._ui = Ui_PublishForm() self._ui.setupUi(self) self._ui.publish_details.publish.connect(self._on_publish) self._ui.publish_details.cancel.connect(self._on_close) self._ui.publish_result.close.connect(self._on_close) expand_single_items = self._app.get_setting("expand_single_items") self._ui.publish_details.expand_single_items = expand_single_items allow_taskless_publishes = self._app.get_setting("allow_taskless_publishes") self._ui.publish_details.allow_no_task = allow_taskless_publishes self._ui.primary_error_label.setVisible(False) # always start with the details page: self.show_publish_details() # initialize: self._initialize() @property def selected_tasks(self): """ The currently selected tasks """ return self._get_selected_tasks() @property def shotgun_task(self): """ The shotgun task that the publish should be linked to """ return self._ui.publish_details.shotgun_task @property def thumbnail(self): """ The thumbnail to use for the publish """ return self._ui.publish_details.thumbnail @property def comment(self): """ The comment to use for the publish """ return self._ui.publish_details.comment def show_publish_details(self): self._ui.pages.setCurrentWidget(self._ui.publish_details) def show_publish_progress(self, title): self._ui.pages.setCurrentWidget(self._ui.publish_progress) self._ui.publish_progress.title = title def set_progress_reporter(self, reporter): self._ui.publish_progress.set_reporter(reporter) def show_publish_result(self, success, errors): """ Show the result of the publish in the UI """ # show page: self._ui.pages.setCurrentWidget(self._ui.publish_result) self._ui.publish_result.status = success self._ui.publish_result.errors = errors def _initialize(self): """ Initialize UI with information provided """ # pull initial data from handler: tasks = self._handler.get_publish_tasks() sg_tasks = self._handler.get_shotgun_tasks() thumbnail = self._handler.get_initial_thumbnail() sg_task = self._app.context.task # split tasks into primary and secondary: primary_task = None secondary_tasks = [] for task in tasks: if task.output.is_primary: if primary_task: # should never get this far but just in case! raise Exception("Found multiple primary tasks - don't know how to handle this!") primary_task = task else: secondary_tasks.append(task) # initialize primary task UI: self._set_primary_task(primary_task) # initialize publish details form: self._ui.publish_details.initialize(secondary_tasks, sg_tasks) # set the initial thumbnail, comment and shotgun task self._ui.publish_details.comment = "" self._ui.publish_details.thumbnail = thumbnail self._ui.publish_details.shotgun_task = sg_task if sg_task: self._ui.publish_details.can_change_shotgun_task = False def _get_selected_tasks(self): """ Get a list of the selected tasks that should be published """ # always publish primary task: selected_tasks = [self._primary_task] # get secondary tasks from details form: selected_tasks.extend(self._ui.publish_details.selected_tasks) return selected_tasks def _set_primary_task(self, task): """ Set the primary task and update the UI accordingly """ self._primary_task = task # connect to the primary tasks modified signal so that we can # update the UI if something changes. self._primary_task.modified.connect(self._on_primary_task_modified) # update UI for primary task: icon_path = self._primary_task.output.icon_path if os.path.isfile(icon_path) and os.path.exists(icon_path): icon = QtGui.QPixmap(icon_path) if not icon.isNull(): self._ui.primary_icon_label.setPixmap(icon) # build details text and set: lines = [] name_str = self._primary_task.output.display_name if self._primary_task.item.name: name_str = "%s - %s" % (name_str, self._primary_task.item.name) lines.append("<span style='font-size: 16px'}><b>%s</b></span><span style='font-size: 12px'}>" % (name_str)) if self._primary_task.output.description: lines.append("%s" % self._primary_task.output.description) if self._primary_task.item.description: lines.append("%s" % self._primary_task.item.description) details_txt = "%s</span>" % "<br>".join(lines) self._ui.primary_details_label.setText(details_txt) # update errors text: self.__update_primary_errors() def _on_primary_task_modified(self): """ Called when the primary task has been modified, e.g. there are new errors to report """ # update the errors display for the primary publish self.__update_primary_errors() def __update_primary_errors(self): """ Update the primary publish UI with any errors that were found during the pre-publish stage """ if self._primary_task and self._primary_task.pre_publish_errors: error_txt = ("<font color='orange'>Validation checks returned some messages for your attention:" "</font><br>%s" % "<br>".join(self._primary_task.pre_publish_errors)) self._ui.primary_error_label.setText(error_txt) self._ui.primary_error_label.setVisible(True) else: self._ui.primary_error_label.setVisible(False) def _on_publish(self): """ Slot called when the publish button in the dialog is clicked """ self.publish.emit() def _on_close(self): """ Slot called when the cancel or close signals in the dialog are recieved """ self.close()
class SnapshotHistoryForm(QtGui.QWidget): restore = QtCore.Signal(QtGui.QWidget, str, str) snapshot = QtCore.Signal(QtGui.QWidget) closed = QtCore.Signal(QtGui.QWidget) def __init__(self, app, handler, parent=None): """ Construction """ QtGui.QWidget.__init__(self, parent) self._app = app self._handler = handler self._path = "" # set up the UI from .ui.snapshot_history_form import Ui_SnapshotHistoryForm self._ui = Ui_SnapshotHistoryForm() self._ui.setupUi(self) self._ui.snapshot_list.set_app(self._app) self._ui.snapshot_list.selection_changed.connect( self._on_list_selection_changed) self._ui.snapshot_list.action_requested.connect(self._on_restore) self._ui.restore_btn.clicked.connect(self._on_restore) self._ui.snapshot_btn.clicked.connect(self._on_snapshot_btn_clicked) @property def path(self): return self._path def refresh(self): # clear the snapshot list: self._ui.snapshot_list.clear() # get the current path from the handler: self._path = None try: self._path = self._handler.get_current_file_path() except Exception as e: # this only ever happens when the scene operation hook # throws an exception! msg = ("Failed to find the current work file path:\n\n" "%s\n\n" "Unable to continue!" % e) self._ui.snapshot_list.set_message(msg) return # load file list: self._ui.snapshot_list.load({ "handler": self._handler, "file_path": self._path }) # update the browser title: self._ui.snapshot_list.set_label( self._handler.get_history_display_name(self._path)) def closeEvent(self, event): """ Called when the widget is closed. """ # make sure the snapshot list BrowserWidget is # cleaned up properly self._ui.snapshot_list.destroy() # emit closed event: self.closed.emit(self) return QtGui.QWidget.closeEvent(self, event) def event(self, event): """ override event to cause UI to reload the first time it is shown: """ if event.type() == QtCore.QEvent.Polish: self.refresh() return QtGui.QWidget.event(self, event) def _on_list_selection_changed(self): self._update_ui() def _on_restore(self): path = self._ui.snapshot_list.get_selected_path() self.restore.emit(self, self._path, path) def _on_snapshot_btn_clicked(self): self.snapshot.emit(self) def _update_ui(self): can_restore = self._ui.snapshot_list.get_selected_item() != None self._ui.restore_btn.setEnabled(can_restore)
class BrowserWidget(QtGui.QWidget): ###################################################################################### # SIGNALS # when the selection changes selection_changed = QtCore.Signal() # when someone double clicks on an item action_requested = QtCore.Signal() # called when the list contents have been modified: list_modified = QtCore.Signal() ###################################################################################### # Init & Destruct def __init__(self, parent=None): QtGui.QWidget.__init__(self, parent) # set up the UI self.ui = Ui_Browser() self.ui.setupUi(self) # hide the overlays self.ui.main_pages.setCurrentWidget(self.ui.items_page) self._app = None self._worker = None self._current_work_id = None self._dynamic_widgets = [] self._multi_select = False self._search = True self._last_item_to_show = None # spinner self._spin_icons = [] self._spin_icons.append(QtGui.QPixmap(":/res/progress_bar_1.png")) self._spin_icons.append(QtGui.QPixmap(":/res/progress_bar_2.png")) self._spin_icons.append(QtGui.QPixmap(":/res/progress_bar_3.png")) self._spin_icons.append(QtGui.QPixmap(":/res/progress_bar_4.png")) self._timer = QtCore.QTimer(self) self._timer.timeout.connect( self._update_spinner ) self._current_spinner_index = 0 # search self.ui.search.textEdited.connect(self._on_search_text_changed) # style: self._title_base_style = { "border":"none", "border-color":"rgb(32,32,32)", "border-top-left-radius":"3px", "border-top-right-radius":"3px", "border-bottom-left-radius":"0px", "border-bottom-right-radius":"0px" } self._title_styles = { "gradient":{"background":"qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 rgba(97, 97, 97, 255), stop:1 rgba(49, 49, 49, 255));"}, "none":{} } self._title_margins = { "gradient":[12,3,12,3], "none":[3,3,3,3] } self._current_title_style = "none" self.title_style = "gradient" @property def title_style(self): return self._current_title_style @title_style.setter def title_style(self, value): if value != self._current_title_style and value in self._title_styles.keys(): # change style sheet: self._current_title_style = value style = self._title_base_style.copy() style.update(self._title_styles[self._current_title_style]) ss = self._style_as_string("#browser_header", style) self.ui.browser_header.setStyleSheet(ss) # change margins: margins = self._title_margins.get(self._current_title_style) if margins: self.ui.browser_header.layout().setContentsMargins(margins[0], margins[1], margins[2], margins[3]) def enable_multi_select(self, enable): """ Should we enable multi select """ self._multi_select = enable def enable_search(self, status): """ Toggle the search bar (on by default) """ self.ui.search.setVisible(status) def destroy(self): if self._worker: self._worker.stop() def set_app(self, app): """ associate with an app object """ self._app = app # set up worker queue self._worker = Worker(app) self._worker.work_completed.connect( self._on_worker_signal) self._worker.work_failure.connect( self._on_worker_failure) self._worker.start() def set_label(self, label): """ Sets the text next to the search button """ self.ui.label.setText("<big>%s</big>" % label) ###################################################################################### # Public Methods def load(self, data): """ Loads data into the browser widget. Called by outside code """ # start spinning self.ui.main_pages.setCurrentWidget(self.ui.loading_page) self._timer.start(100) # queue up work self._current_work_id = self._worker.queue_work(self.get_data, data, asap=True) self.list_modified.emit() def clear(self): """ Clear widget of its contents. """ # hide overlays self.ui.main_pages.setCurrentWidget(self.ui.items_page) # clear search box self.ui.search.setText("") # also reset any jobs that are processing. No point processing them # if their requestors are gone. if self._worker: self._worker.clear() for x in self._dynamic_widgets: # remove widget from layout: self.ui.scroll_area_layout.removeWidget(x) # set it's parent to None so that it is removed from the widget hierarchy x.setParent(None) # mark it to be deleted when event processing returns to the main loop x.deleteLater() self._dynamic_widgets = [] self.list_modified.emit() def set_message(self, message): """ Replace the list of items with a single message """ self.ui.main_pages.setCurrentWidget(self.ui.status_page) self.ui.status_message.setText(message) def clear_selection(self): """ Clears the selection """ for x in self._dynamic_widgets: x.set_selected(False) def get_selected_item(self): """ Gets the last selected item, None if no selection """ for widget in self._dynamic_widgets: if widget.is_selected(): return widget return None def get_selected_items(self): """ Returns entire selection """ selected_items = [] for widget in self._dynamic_widgets: if widget.is_selected(): selected_items.append(widget) return selected_items def get_items(self): return self._dynamic_widgets def select(self, item): """ Select an item and ensure it is visible """ self._on_item_clicked(item) self._ensure_item_is_visible(item) ########################################################################################## # Protected stuff - implemented by deriving classes def get_data(self, data): """ Needs to be implemented by subclasses """ raise Exception("not implemented!") def process_result(self, result): """ Needs to be implemented by subclasses """ raise Exception("not implemented!") ########################################################################################## # Internals def _style_as_string(self, name, style_dict): style_elements = ["%s: %s;" % (key, value) for key, value in style_dict.iteritems()] return "%s { %s }" % (name, "".join(style_elements)) def _on_search_text_changed(self, text): """ Cull based on search box """ if text == "": # show all items for i in self._dynamic_widgets: i.setVisible(True) elif len(text) > 2: # cull by string for strings > 2 chars # if running PyQt, convert QString to str if not isinstance(text, basestring): # convert QString to str text = str(text) # now we have a str or unicode object which has the lower() method lower_text = text.lower() for i in self._dynamic_widgets: details = i.get_details() # if running PyQt, convert QString to str details_lower = details if not isinstance(details_lower, basestring): details_lower = str(details_lower) # now we have a str or unicode object which has the lower() method details_lower = details_lower.lower() if details is None: # header i.setVisible(True) elif lower_text in details_lower: i.setVisible(True) else: i.setVisible(False) def _on_worker_failure(self, uid, msg): """ The worker couldn't execute stuff """ if self._current_work_id != uid: # not our job. ignore return # finally, turn off progress indication and turn on display self.ui.main_pages.setCurrentWidget(self.ui.items_page) self._timer.stop() # show error message self.set_message(msg) def _on_worker_signal(self, uid, data): """ Signalled whenever the worker completes something """ if self._current_work_id != uid: # not our job. ignore return # process! self.process_result(data) # if currently showing progress, switch to items page: if self.ui.main_pages.currentWidget() == self.ui.loading_page: self.ui.main_pages.setCurrentWidget(self.ui.items_page) # stop timer: self._timer.stop() # if something was 'selected' whilst doing work then # ensure it is visible: if self._last_item_to_show: self._ensure_item_is_visible(self._last_item_to_show) self._last_item_to_show = None # and just in case the list has been modified self.list_modified.emit() def _update_spinner(self): """ Animate spinner icon """ self.ui.progress_bar.setPixmap(self._spin_icons[self._current_spinner_index]) self._current_spinner_index += 1 if self._current_spinner_index == 4: self._current_spinner_index = 0 def _ensure_item_is_visible(self, item): """ Ensure the item is visible by scrolling to it. """ # check that the items page is the current page. If # it's not then we just keep track of the item to be # scrolled to when all current work is completed if self.ui.main_pages.currentWidget() != self.ui.items_page: self._last_item_to_show = item else: # in order for the scroll to happen during load, first give # the scroll area chance to resize it self by processing its event queue. QtCore.QCoreApplication.processEvents() # and focus on the selection self.ui.scroll_area.ensureWidgetVisible(item) def _on_item_clicked(self, item): if item.supports_selection() == False: # not all items are selectable return if self._multi_select: # invert selection: item.set_selected(not item.is_selected()) else: # single select self.clear_selection() item.set_selected(True) self.selection_changed.emit() def _on_item_double_clicked(self, item): self.action_requested.emit() def add_item(self, item_class): """ Adds a list item. Returns the created object. """ widget = item_class(self._app, self._worker, self) self.ui.scroll_area_layout.addWidget(widget) self._dynamic_widgets.append(widget) widget.clicked.connect( self._on_item_clicked ) widget.double_clicked.connect( self._on_item_double_clicked ) self.list_modified.emit() return widget
class HotKeyEditor(QtGui.QLineEdit): # Emitted when the key sequence changes. # The first argument is the key sequence. # The second argument is the native modifiers associated with the sequence # The third argument is the native key code associated with the sequence key_sequence_changed = QtCore.Signal(QtGui.QKeySequence, int, int) def __init__(self, parent=None): QtGui.QLineEdit.__init__(self, parent) self.__key_sequence = QtGui.QKeySequence() # self.__line_edit.installEventFilter(self) self.setReadOnly(True) self.setAttribute(QtCore.Qt.WA_InputMethodEnabled) def eventFilter(self, widget, event): if widget == self and event.type() == QtCore.QEvent.ContextMenu: menu = self.createStandardContextMenu() actions = menu.actions() for action in actions: action.setShortcut(QtGui.QKeySequence()) action_string = action.text() pos = action_string.rfind("\t") if pos > 0: action_string = action_string[:pos] action.setText(action_string) action_before = None if len(actions) > 0: action_before = actions[0] clear = QtGui.QAction("Clear Shortcut", menu) menu.insertAction(action_before, clear) menu.insertSeparator(action_before) clear.setEnabled(not self.__key_sequence.isEmpty()) clear.triggered.connect(self.clear_shortcut) menu.exec_(event.globalPos()) del menu event.accept() return True return QtGui.QLineEdit.eventFilter(self, widget, event) def clear_shortcut(self): if self.__key_sequence.isEmpty(): return self.key_sequence = QtGui.QKeySequence() self.key_sequence_changed.emit(self.key_sequence, 0, 0) def handle_key_event(self, event): if event.isAutoRepeat(): return key = event.key() if (key == QtCore.Qt.Key_Control or key == QtCore.Qt.Key_Shift or key == QtCore.Qt.Key_Meta or key == QtCore.Qt.Key_Alt or key == QtCore.Qt.Key_Super_L or key == QtCore.Qt.Key_AltGr): return if not event.modifiers(): return key |= self.translate_modifiers(event.modifiers(), event.text()) self.key_sequence = QtGui.QKeySequence(key) self.key_sequence_changed.emit( self.key_sequence, event.nativeModifiers(), event.nativeVirtualKey(), ) event.accept() @property def key_sequence(self): return self.__key_sequence @key_sequence.setter def key_sequence(self, sequence): if sequence == self.__key_sequence: return self.__key_sequence = sequence self.setText( self.__key_sequence.toString(QtGui.QKeySequence.NativeText)) def translate_modifiers(self, state, text): result = 0 if state & QtCore.Qt.ShiftModifier: if len(text) == 0: result |= QtCore.Qt.SHIFT else: category = unicodedata.category(text[0]) if category[0] in ["L", "N", "P", "Z"]: result |= QtCore.Qt.SHIFT if state & QtCore.Qt.ControlModifier: result |= QtCore.Qt.CTRL if state & QtCore.Qt.MetaModifier: result |= QtCore.Qt.META if state & QtCore.Qt.AltModifier: result |= QtCore.Qt.ALT return result def focusInEvent(self, event): QtGui.QLineEdit.focusInEvent(self, event) self.selectAll() def keyPressEvent(self, event): self.handle_key_event(event) event.accept() def event(self, event): if (event.type() == QtCore.QEvent.Shortcut or event.type() == QtCore.QEvent.ShortcutOverride or event.type() == QtCore.QEvent.KeyRelease): event.accept() return True return QtGui.QLineEdit.event(self, event)
class ShotgunOverlayModel(ShotgunModel): """ Convenience wrapper around the ShotgunModel which adds spinner and error reporting overlay functionality. """ # signal that gets emitted whenever the model deems it appropriate to # indicate that data is being loaded. Note that this signal is not # emitted every time data is loaded from Shotgun, but only when there # is no cached data available to display. This signal can be useful if # an implementation wants to set up a custom overlay system instead # of or in addition to the built in one that is provided via # the set_overlay_parent() method. progress_spinner_start = QtCore.Signal() # conversely, an end signal is being emitted every time a progress spinner # should be deactivated. progress_spinner_end = QtCore.Signal() def __init__(self, parent, overlay_widget, download_thumbs=True, schema_generation=0, bg_load_thumbs=False): """ Constructor. This will create a model which can later be used to load and manage Shotgun data. :param parent: Parent object. :param overlay_widget: Widget on which the spinner/info overlay should be positioned. :param download_thumbs: Boolean to indicate if this model should attempt to download and process thumbnails for the downloaded data. :param schema_generation: Schema generation index. If you are changing the format of the data you are retrieving from Shotgun, and therefore want to invalidate any cache files that may already exist in the system, you can increment this integer. :param bg_load_thumbs: If set to True, thumbnails will be loaded in the background. """ ShotgunModel.__init__(self, parent, download_thumbs, schema_generation, bg_load_thumbs) # set up our spinner UI handling self.__overlay = overlay_module.ShotgunOverlayWidget(overlay_widget) self._is_in_spin_state = False self._cache_loaded = False # set up some model signals etc. self.data_refreshed.connect(self.__on_data_refreshed) self.data_refresh_fail.connect(self.__on_data_refresh_fail) ######################################################################################## # protected methods not meant to be subclassed but meant to be called by subclasses def _load_data(self, entity_type, filters, hierarchy, fields, order=None, seed=None, limit=None): """ Overridden from ShotgunModel. """ # reset overlay self.__overlay.hide(hide_errors=True) # call base class self._cache_loaded = ShotgunModel._load_data(self, entity_type, filters, hierarchy, fields, order, seed, limit) return self._cache_loaded def _refresh_data(self): """ Overridden from ShotgunModel. """ if not self._cache_loaded: # we are doing asynchronous loading into an uncached model. # start spinning self._show_overlay_spinner() # call base class return ShotgunModel._refresh_data(self) def _show_overlay_spinner(self): """ Shows the overlay spinner """ self.__overlay.start_spin() # signal to any external listeners self.progress_spinner_start.emit() self._is_in_spin_state = True def _hide_overlay_info(self): """ Hides any overlay that is currently shown, except for error messages. """ self.__overlay.hide(hide_errors=False) def _show_overlay_pixmap(self, pixmap): """ Show an overlay status message in the form of a pixmap. This is for example useful if a particular query doesn't return any results. If an error message is already being shown, the pixmap will not replace the error message. :param pixmap: QPixmap object containing graphic to show. """ self.__overlay.show_message_pixmap(pixmap) def _show_overlay_info_message(self, msg): """ Show an overlay status message. If an error is already displayed, this info message will not be shown. :param msg: message to display :returns: True if the message was shown, False if not. """ self.__overlay.show_message(msg) def _show_overlay_error_message(self, msg): """ Show an overlay error message. :param msg: error message to display """ self.__overlay.show_error_message(msg) ######################################################################################## # private methods def __on_data_refreshed(self): """ Callback when async data has arrived successfully """ self._cache_loaded = True if self._is_in_spin_state: self.__overlay.hide(hide_errors=True) # we are spinning, so signal the spin to end self._is_in_spin_state = False self.progress_spinner_end.emit() def __on_data_refresh_fail(self, msg): """ Callback when async data has failed to arrive """ self.__overlay.show_error_message(msg) if self._is_in_spin_state: # we are spinning, so signal the spin to end self._is_in_spin_state = False self.progress_spinner_end.emit()
class ShotgunAsyncDataRetriever(QtCore.QThread): """ Note: This is part of the internals of the Shotgun Utils Framework and should not be called directly. Async worker class which is used by the ShotgunModel to retrieve data and thumbnails from Shotgun and from disk thumbnail cache. Tasks are queued up using the execute_find() and request_thumbnail() methods. Tasks are executed in the following priority order: - first any thumbnails that are already cached on disk are handled - next, shotgun find() queries are handled - lastly thumbnail downloads are handled The thread will emit work_completed and work_failure signals when tasks are completed (or fail). The clear() method will clear the current queue. The currently processing item will finish processing and may send out signals even after a clear. Make sure you call the stop() method prior to destruction in order for the system to gracefully shut down. """ # async task types THUMB_CHECK, SG_FIND_QUERY, THUMB_DOWNLOAD = range(3) work_completed = QtCore.Signal(str, dict) work_failure = QtCore.Signal(str, str) def __init__(self, parent=None): """ Construction """ QtCore.QThread.__init__(self, parent) self._app = tank.platform.current_bundle() self._wait_condition = QtCore.QWaitCondition() self._queue_mutex = QtCore.QMutex() self.__sg = None # queue data structures self._thumb_download_queue = [] self._sg_find_queue = [] self._thumb_check_queue = [] # indicates that we should keep processing queue items self._process_queue = True ############################################################################################################ # Public methods def set_shotgun_connection(self, sg): """ Specify the shotgun api instance this model should use to communicate with Shotgun. If not specified, each model instance will instantiante its own connection, via toolkit. The behaviour where each model has its own connection is generally recommended for thread safety reasons since the Shotgun API isn't natively threadsafe. :param sg: Shotgun API instance """ self.__sg = sg def clear(self): """ Clears the queue. Any currently processing item will complete without interruption. """ self._queue_mutex.lock() try: self._app.log_debug( "%s: Clearing queue. Discarded items: SG api requests: [%s] Thumb checks: [%s] " "Thumb downloads: [%s]" % (self, len(self._sg_find_queue), len( self._thumb_check_queue), len(self._thumb_download_queue))) self._thumb_download_queue = [] self._sg_find_queue = [] self._thumb_check_queue = [] finally: self._queue_mutex.unlock() def stop(self): """ Gracefully stop the thread. Will synchronounsly wait until any potential currently processing item is completing. """ self._process_queue = False self._wait_condition.wakeAll() self.wait() def execute_find(self, entity_type, filters, fields, order=None): """ Adds a find query to the queue. :param entity_type: Shotgun entity type :param filters: List of find filters to pass to Shotgun find call :param fields: List of fields to pass to Shotgun find call :param order: List of order dicts to pass to Shotgun find call :returns: A unique identifier representing this request """ uid = uuid.uuid4().hex work = { "id": uid, "entity_type": entity_type, "filters": filters, "fields": fields, "order": order } self._queue_mutex.lock() try: self._sg_find_queue.append(work) finally: self._queue_mutex.unlock() # wake up execution loop! self._wait_condition.wakeAll() return uid def request_thumbnail(self, url, entity_type, entity_id, field): """ Adds a Shotgun thumbnail request to the queue. :param url: The thumbnail url that is associated with this thumbnail :param entity_type: Shotgun entity type with which the thumb is associated. :param entity_id: Shotgun entity id with which the thumb is associated. :param field: Thumbnail field. Normally 'image' but could also for example be a deep link field such as 'sg_sequence.Sequence.image' :returns: A unique identifier representing this request """ uid = uuid.uuid4().hex work = { "id": uid, "url": url, "field": field, "entity_type": entity_type, "entity_id": entity_id } self._queue_mutex.lock() try: self._thumb_check_queue.append(work) finally: self._queue_mutex.unlock() # wake up execution loop! self._wait_condition.wakeAll() return uid ############################################################################################################ # Internal methods def _get_thumbnail_path(self, url): """ Returns the location on disk suitable for a thumbnail given its url. """ url_obj = urlparse.urlparse(url) url_path = url_obj.path path_chunks = url_path.split("/") CHUNK_LEN = 16 # post process the path # old (pre-S3) style result: # path_chunks: [ "", "thumbs", "1", "2", "2.jpg"] # s3 result, form 1: # path_chunks: [u'', # u'9902b5f5f336fae2fb248e8a8748fcd9aedd822e', # u'be4236b8f198ae84df2366920e7ee327cc0a567e', # u'render_0400_t.jpg'] # s3 result, form 2: # path_chunks: [u'', u'thumbnail', u'api_image', u'150'] def _to_chunks(s): #split the string 'abcdefghxx' into ['abcdefgh', 'xx'] chunks = [] for start in range(0, len(s), CHUNK_LEN): chunks.append(s[start:start + CHUNK_LEN]) return chunks new_chunks = [] for folder in path_chunks[:-1]: # skip the file name if folder == "": continue if len(folder) > CHUNK_LEN: # long url path segment like 9902b5f5f336fae2fb248e8a8748fcd9aedd822e # split it into chunks for 4 new_chunks.extend(_to_chunks(folder)) else: new_chunks.append(folder) # establish the root path cache_path_items = [self._app.cache_location, "thumbnails"] # append the folders cache_path_items.extend(new_chunks) # and append the file name # all sg thumbs are jpegs so append extension too - some url forms don't have this. cache_path_items.append("%s.jpeg" % path_chunks[-1]) # join up the path path_to_cached_thumb = os.path.join(*cache_path_items) return path_to_cached_thumb ############################################################################################ # main thread loop def run(self): """ Main thread loop """ if self.__sg is None: # create our own private shotgun connection. This is because # the shotgun API isn't threadsafe, so running multiple models in parallel # (common) may result in side effects if a single connection is shared self.__sg = tank.util.shotgun.create_sg_connection() # set the maximum timeout for this connection for fluency self.__sg.config.timeout_secs = CONNECTION_TIMEOUT_SECS # keep running until thread is terminated while self._process_queue: # Step 1. get the next item to process. # We check things in the following priority order: # - If there is anything in the thumb check queue, do that first # - Then check sg queue # - Lastly, check thumb downloads item_to_process = None item_type = None self._queue_mutex.lock() try: if len(self._thumb_check_queue) > 0: item_to_process = self._thumb_check_queue.pop(0) item_type = ShotgunAsyncDataRetriever.THUMB_CHECK elif len(self._sg_find_queue) > 0: item_to_process = self._sg_find_queue.pop(0) item_type = ShotgunAsyncDataRetriever.SG_FIND_QUERY elif len(self._thumb_download_queue) > 0: item_to_process = self._thumb_download_queue.pop(0) item_type = ShotgunAsyncDataRetriever.THUMB_DOWNLOAD else: # no work to be done! # wait for some more work - this unlocks the mutex # until the wait condition is signalled where it # will then attempt to obtain a lock before returning self._wait_condition.wait(self._queue_mutex) # once the wait condition is triggered (usually by something # inserted into one of the queues), trigger the check to happen again continue finally: self._queue_mutex.unlock() # Step 2. Process next item and send signals. try: # process the item: if item_type == ShotgunAsyncDataRetriever.SG_FIND_QUERY: # get stuff from shotgun sg = self.__sg.find(item_to_process["entity_type"], item_to_process["filters"], item_to_process["fields"], item_to_process["order"]) # need to wrap it in a dict not to confuse pyqt's signals and type system self.work_completed.emit(item_to_process["id"], {"sg": sg}) elif item_type == ShotgunAsyncDataRetriever.THUMB_CHECK: # check if a thumbnail exists on disk. If not, fall back onto # a thumbnail download from shotgun/s3 url = item_to_process["url"] path_to_cached_thumb = self._get_thumbnail_path(url) if os.path.exists(path_to_cached_thumb): # thumbnail already here! yay! self.work_completed.emit( item_to_process["id"], {"thumb_path": path_to_cached_thumb}) else: # no thumb here. Stick the data into the thumb download queue to request download self._queue_mutex.lock() try: self._thumb_download_queue.append(item_to_process) finally: self._queue_mutex.unlock() elif item_type == ShotgunAsyncDataRetriever.THUMB_DOWNLOAD: # download the actual thumbnail. Because of S3, the url # has most likely expired, so need to re-fetch it via a sg find entity_id = item_to_process["entity_id"] entity_type = item_to_process["entity_type"] field = item_to_process["field"] sg_data = self.__sg.find_one(entity_type, [["id", "is", entity_id]], [field]) if sg_data is None or sg_data.get(field) is None: # no thumbnail! This is possible if the thumb has changed # while we were queueing it for download. In this case # simply don't do anything pass else: # download from sg url = sg_data[field] path_to_cached_thumb = self._get_thumbnail_path(url) self._app.ensure_folder_exists( os.path.dirname(path_to_cached_thumb)) tank.util.download_url(self._app.shotgun, url, path_to_cached_thumb) # modify the permissions of the file so it's writeable by others old_umask = os.umask(0) try: os.chmod(path_to_cached_thumb, 0666) finally: os.umask(old_umask) self.work_completed.emit( item_to_process["id"], {"thumb_path": path_to_cached_thumb}) else: raise Exception("Unknown task type!") except Exception, e: self.work_failure.emit(item_to_process["id"], "An error occurred: %s" % e)
class PublishDetailsForm(QtGui.QWidget): """ Implementation of the main publish UI """ # signals publish = QtCore.Signal() cancel = QtCore.Signal() def __init__(self, parent=None): """ Construction """ QtGui.QWidget.__init__(self, parent) self.expand_single_items = False self.allow_no_task = False self._group_widget_info = {} self._tasks = [] # set up the UI from .ui.publish_details_form import Ui_PublishDetailsForm self._ui = Ui_PublishDetailsForm() self._ui.setupUi(self) # create vbox layout for scroll widget: layout = QtGui.QVBoxLayout() layout.setSpacing(0) layout.setContentsMargins(2, 2, 2, 2) self._ui.task_scroll.widget().setLayout(layout) # hook up buttons self._ui.publish_btn.clicked.connect(self._on_publish) self._ui.cancel_btn.clicked.connect(self._on_cancel) self.can_change_shotgun_task = True @property def selected_tasks(self): return self._get_selected_tasks() # @property def __get_shotgun_task(self): return self._get_sg_task_combo_task( self._ui.sg_task_combo.currentIndex()) # @shotgun_task.setter def __set_shotgun_task(self, value): self._set_current_shotgun_task(value) shotgun_task = property(__get_shotgun_task, __set_shotgun_task) # @property def __get_comment(self): return self._safe_to_string( self._ui.comments_edit.toPlainText()).strip() # @comment.setter def __set_comment(self, value): self._ui.comments_edit.setPlainText(value) comment = property(__get_comment, __set_comment) # @property def __get_thumbnail(self): return self._ui.thumbnail_widget.thumbnail # @thumbnail.setter def __set_thumbnail(self, value): self._ui.thumbnail_widget.thumbnail = value thumbnail = property(__get_thumbnail, __set_thumbnail) # @property def __get_can_change_shotgun_task(self): """ Control if the shotgun task can be changed or not """ return self._ui.sg_task_stacked_widget.currenWidget( ) == self._ui.sg_task_menu_page # @can_change_shotgun_task.setter def __set_can_change_shotgun_task(self, value): page = None header_txt = "" if value: page = self._ui.sg_task_menu_page header_txt = "What Shotgun Task are you working on?" else: page = self._ui.sg_task_label_page header_txt = "The Publish will be associated with Shotgun Task:" self._ui.sg_task_stacked_widget.setCurrentWidget(page) self._ui.task_header_label.setText(header_txt) can_change_shotgun_task = property(__get_can_change_shotgun_task, __set_can_change_shotgun_task) def initialize(self, tasks, sg_tasks): """ Initialize UI """ # reset UI to default state: self._ui.sg_task_combo.setEnabled(True) # populate shotgun task list: self._populate_shotgun_tasks(sg_tasks) # connect up modified signal on tasks: self._tasks = tasks # populate outputs list: self._populate_task_list() def _get_sg_task_combo_task(self, index): """ Get the shotgun task for the currently selected item in the task combo """ task = self._ui.sg_task_combo.itemData(index) if index >= 0 else None if task: if hasattr(QtCore, "QVariant") and isinstance( task, QtCore.QVariant): task = task.toPyObject() # task is a wrapped object to avoid PyQt QString conversion fun! if task: task = task.obj return task def _populate_shotgun_tasks(self, sg_tasks): """ Populate the shotgun task combo box with the provided list of shotgun tasks """ current_task = self._get_sg_task_combo_task( self._ui.sg_task_combo.currentIndex()) self._ui.sg_task_combo.clear() # add 'no task' task: if self.allow_no_task: self._ui.sg_task_combo.addItem( "Do not associate this publish with a task") self._ui.sg_task_combo.insertSeparator( self._ui.sg_task_combo.count()) self._ui.sg_task_combo.insertSeparator( self._ui.sg_task_combo.count()) # add tasks: for task in sg_tasks: label = "%s, %s" % (task["step"]["name"], task["content"]) self._ui.sg_task_combo.addItem(label, _ObjWrapper(task)) # reselect selected task if it is still in list: self._set_current_shotgun_task(current_task) def _set_current_shotgun_task(self, task): """ Select the specified task in the shotgun task combo box """ # update the selection combo: found_index = None for ii in range(0, self._ui.sg_task_combo.count()): item_task = self._get_sg_task_combo_task(ii) found = False if not task: found = not item_task elif item_task: found = task["id"] == item_task["id"] if found: found_index = ii break self._ui.sg_task_combo.setCurrentIndex(found_index or 0) # also update the static label: label = "None!" if found_index != None: label = self._ui.sg_task_combo.itemText(found_index) self._ui.sg_task_label.setText(label) def _populate_task_list(self): """ Build the main task list for selection of outputs, items, etc. """ # clear existing widgets: task_scroll_widget = self._ui.task_scroll.widget() self._group_widget_info = {} #TODO if len(self._tasks) == 0: # no tasks so show no tasks text: self._ui.publishes_stacked_widget.setCurrentWidget( self._ui.no_publishes_page) return else: self._ui.publishes_stacked_widget.setCurrentWidget( self._ui.publishes_page) # group tasks by display group: group_order = [] tasks_by_group = {} for task in self._tasks: group = tasks_by_group.setdefault(task.output.display_group, dict()) # track unique outputs for this group maintaining order # respective to task group_outputs = group.setdefault("outputs", list()) if task.output not in group_outputs: group_outputs.append(task.output) # track unique items for this group maintaining order # respective to task group_items = group.setdefault("items", list()) if task.item not in group_items: group_items.append(task.item) # track tasks for this group: group.setdefault("tasks", list()).append(task) if not task.output.display_group in group_order: group_order.append(task.output.display_group) # add widgets to scroll area: layout = task_scroll_widget.layout() for group in group_order: widget_info = {} # add header: header = GroupHeader(group, task_scroll_widget) layout.addWidget(header) widget_info["header"] = header # add output items: output_widgets = [] for output in tasks_by_group[group]["outputs"]: item = OutputItem(output, task_scroll_widget) layout.addWidget(item) output_widgets.append(item) widget_info["output_widgets"] = output_widgets # add item list if more than one item: if self.expand_single_items or len( tasks_by_group[group]["items"]) > 1: item_list = ItemList(tasks_by_group[group]["items"], task_scroll_widget) layout.addWidget(item_list) widget_info["item_list"] = item_list # always add error list: error_list = ErrorList(tasks_by_group[group]["tasks"], task_scroll_widget) #error_list.setVisible(False) layout.addWidget(error_list) widget_info["error_list"] = error_list self._group_widget_info[group] = widget_info # add vertical stretch: layout.addStretch(1) def _get_selected_tasks(self): """ Get the selected tasks from the UI """ selected_tasks = [] # build some indexes: task_index = {} tasks_per_output = {} for task in self._tasks: key = (task.output, task.item) if key in task_index.keys(): raise "Didn't expect to find the same task in the list twice!" task_index[key] = task tasks_per_output.setdefault(task.output, list()).append(task) for info in self._group_widget_info.values(): # go through output widgets for output_widget in info["output_widgets"]: if not output_widget.selected: continue output = output_widget.output # go through item widgets: item_list = info.get("item_list") if item_list: for item in item_list.selected_items: task = task_index.get((output, item)) if task: selected_tasks.append(task) else: # assume all items for this output are selected: tasks = tasks_per_output.get(output) if tasks: selected_tasks.extend(tasks) # finally, ensure that tasks are returned in their # original order: ordered_selected_tasks = [ task for task in self._tasks if task in selected_tasks ] return ordered_selected_tasks def _on_publish(self): self.publish.emit() def _on_cancel(self): self.cancel.emit() def _safe_to_string(self, value): """ safely convert the value to a string - handles QtCore.QString if usign PyQt """ # if isinstance(value, basestring): # it's a string anyway so just return return value if hasattr(QtCore, "QString"): # running PyQt! if isinstance(value, QtCore.QString): # QtCore.QString inherits from str but supports # unicode, go figure! Lets play safe and return # a utf-8 string return str(value.toUtf8()) # For everything else, just return as string return str(value)
class Worker(QtCore.QThread): """ Background worker class """ # Indicates that this worker class has been fixed to stop # gc of QThread from resulting in a crash. This happens # when the mutex object had been gc'd but the thread is # still trying to acces it - the fix is to wait for the # thread to terminate before returning from 'stop()' _SGTK_IMPLEMENTS_QTHREAD_CRASH_FIX_ = True work_completed = QtCore.Signal(str, object) work_failure = QtCore.Signal(str, str) def __init__(self, app, parent=None): """ Construction """ QtCore.QThread.__init__(self, parent) self._execute_tasks = True self._app = app self._queue_mutex = QtCore.QMutex() self._queue = [] self._receivers = {} self._wait_condition = QtCore.QWaitCondition() def stop(self, wait_for_completion=True): """ Stops the worker, run this before shutdown """ self._execute_tasks = False self._wait_condition.wakeAll() if wait_for_completion: self.wait() def clear(self): """ Empties the queue """ self._queue_mutex.lock() try: self._queue = [] finally: self._queue_mutex.unlock() def queue_work(self, worker_fn, params, asap=False): """ Queues up some work. Returns a unique identifier to identify this item """ uid = uuid.uuid4().hex work = {"id": uid, "fn": worker_fn, "params": params} self._queue_mutex.lock() try: if asap: # first in the queue self._queue.insert(0, work) else: self._queue.append(work) finally: self._queue_mutex.unlock() self._wait_condition.wakeAll() return uid ############################################################################################ # def run(self): while self._execute_tasks: # get the next item to process: item_to_process = None self._queue_mutex.lock() try: if len(self._queue) == 0: # wait for some more work - this unlocks the mutex # until the wait condition is signalled where it # will then attempt to obtain a lock before returning self._wait_condition.wait(self._queue_mutex) if len(self._queue) == 0: # still nothing in the queue! continue item_to_process = self._queue.pop(0) finally: self._queue_mutex.unlock() if not self._execute_tasks: break # ok, have something to do so lets do it: data = None try: # process the item: data = item_to_process["fn"](item_to_process["params"]) except Exception, e: if self._execute_tasks: self.work_failure.emit(item_to_process["id"], "An error occured: %s" % e) else: if self._execute_tasks: self.work_completed.emit(item_to_process["id"], data)