Merge branch 'develop' into feature/AY-2218_Plugin-hooks-Loader-and-Scene-Inventory

This commit is contained in:
Petr Kalis 2025-06-19 12:29:24 +02:00 committed by GitHub
commit 61153984a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 612 additions and 280 deletions

View file

@ -8,7 +8,7 @@ targeted by task types and names.
Placeholders are created using placeholder plugins which should care about
logic and data of placeholder items. 'PlaceholderItem' is used to keep track
about it's progress.
about its progress.
"""
import os
@ -17,6 +17,7 @@ import collections
import copy
from abc import ABC, abstractmethod
import ayon_api
from ayon_api import (
get_folders,
get_folder_by_path,
@ -60,6 +61,32 @@ from ayon_core.pipeline.create import (
_NOT_SET = object()
class EntityResolutionError(Exception):
"""Exception raised when entity URI resolution fails."""
def resolve_entity_uri(entity_uri: str) -> str:
"""Resolve AYON entity URI to a filesystem path for local system."""
response = ayon_api.post(
"resolve",
resolveRoots=True,
uris=[entity_uri]
)
if response.status_code != 200:
raise RuntimeError(
f"Unable to resolve AYON entity URI filepath for "
f"'{entity_uri}': {response.text}"
)
entities = response.data[0]["entities"]
if len(entities) != 1:
raise EntityResolutionError(
f"Unable to resolve AYON entity URI '{entity_uri}' to a "
f"single filepath. Received data: {response.data}"
)
return entities[0]["filePath"]
class TemplateNotFound(Exception):
"""Exception raised when template does not exist."""
pass
@ -823,7 +850,6 @@ class AbstractTemplateBuilder(ABC):
"""
host_name = self.host_name
project_name = self.project_name
task_name = self.current_task_name
task_type = self.current_task_type
@ -835,7 +861,6 @@ class AbstractTemplateBuilder(ABC):
"task_names": task_name
}
)
if not profile:
raise TemplateProfileNotFound((
"No matching profile found for task '{}' of type '{}' "
@ -843,6 +868,22 @@ class AbstractTemplateBuilder(ABC):
).format(task_name, task_type, host_name))
path = profile["path"]
if not path:
raise TemplateLoadFailed((
"Template path is not set.\n"
"Path need to be set in {}\\Template Workfile Build "
"Settings\\Profiles"
).format(host_name.title()))
resolved_path = self.resolve_template_path(path)
if not resolved_path or not os.path.exists(resolved_path):
raise TemplateNotFound(
"Template file found in AYON settings for task '{}' with host "
"'{}' does not exists. (Not found : {})".format(
task_name, host_name, resolved_path)
)
self.log.info(f"Found template at: '{resolved_path}'")
# switch to remove placeholders after they are used
keep_placeholder = profile.get("keep_placeholder")
@ -852,44 +893,86 @@ class AbstractTemplateBuilder(ABC):
if keep_placeholder is None:
keep_placeholder = True
if not path:
raise TemplateLoadFailed((
"Template path is not set.\n"
"Path need to be set in {}\\Template Workfile Build "
"Settings\\Profiles"
).format(host_name.title()))
# Try to fill path with environments and anatomy roots
anatomy = Anatomy(project_name)
fill_data = {
key: value
for key, value in os.environ.items()
return {
"path": resolved_path,
"keep_placeholder": keep_placeholder,
"create_first_version": create_first_version
}
fill_data["root"] = anatomy.roots
fill_data["project"] = {
"name": project_name,
"code": anatomy.project_code,
}
def resolve_template_path(self, path, fill_data=None) -> str:
"""Resolve the template path.
path = self.resolve_template_path(path, fill_data)
By default, this:
- Resolves AYON entity URI to a filesystem path
- Returns path directly if it exists on disk.
- Resolves template keys through anatomy and environment variables.
This can be overridden in host integrations to perform additional
resolving over the template. Like, `hou.text.expandString` in Houdini.
It's recommended to still call the super().resolve_template_path()
to ensure the basic resolving is done across all integrations.
Arguments:
path (str): The input path.
fill_data (dict[str, str]): Deprecated. This is computed inside
the method using the current environment and project settings.
Used to be the data to use for template formatting.
Returns:
str: The resolved path.
"""
# If the path is an AYON entity URI, then resolve the filepath
# through the backend
if path.startswith("ayon+entity://") or path.startswith("ayon://"):
# This is a special case where the path is an AYON entity URI
# We need to resolve it to a filesystem path
resolved_path = resolve_entity_uri(path)
return resolved_path
# If the path is set and it's found on disk, return it directly
if path and os.path.exists(path):
self.log.info("Found template at: '{}'".format(path))
return {
"path": path,
"keep_placeholder": keep_placeholder,
"create_first_version": create_first_version
return path
# We may have path for another platform, like C:/path/to/file
# or a path with template keys, like {project[code]} or both.
# Try to fill path with environments and anatomy roots
project_name = self.project_name
anatomy = Anatomy(project_name)
# Simple check whether the path contains any template keys
if "{" in path:
fill_data = {
key: value
for key, value in os.environ.items()
}
fill_data["root"] = anatomy.roots
fill_data["project"] = {
"name": project_name,
"code": anatomy.project_code,
}
solved_path = None
# Format the template using local fill data
result = StringTemplate.format_template(path, fill_data)
if not result.solved:
return path
path = result.normalized()
if os.path.exists(path):
return path
# If the path were set in settings using a Windows path and we
# are now on a Linux system, we try to convert the solved path to
# the current platform.
while True:
try:
solved_path = anatomy.path_remapper(path)
except KeyError as missing_key:
raise KeyError(
"Could not solve key '{}' in template path '{}'".format(
missing_key, path))
f"Could not solve key '{missing_key}'"
f" in template path '{path}'"
)
if solved_path is None:
solved_path = path
@ -898,40 +981,7 @@ class AbstractTemplateBuilder(ABC):
path = solved_path
solved_path = os.path.normpath(solved_path)
if not os.path.exists(solved_path):
raise TemplateNotFound(
"Template found in AYON settings for task '{}' with host "
"'{}' does not exists. (Not found : {})".format(
task_name, host_name, solved_path))
self.log.info("Found template at: '{}'".format(solved_path))
return {
"path": solved_path,
"keep_placeholder": keep_placeholder,
"create_first_version": create_first_version
}
def resolve_template_path(self, path, fill_data) -> str:
"""Resolve the template path.
By default, this does nothing except returning the path directly.
This can be overridden in host integrations to perform additional
resolving over the template. Like, `hou.text.expandString` in Houdini.
Arguments:
path (str): The input path.
fill_data (dict[str, str]): Data to use for template formatting.
Returns:
str: The resolved path.
"""
result = StringTemplate.format_template(path, fill_data)
if result.solved:
path = result.normalized()
return path
return solved_path
def emit_event(self, topic, data=None, source=None) -> Event:
return self._event_system.emit(topic, data, source)

View file

@ -58,7 +58,7 @@ class ExtractOIIOTranscode(publish.Extractor):
optional = True
# Supported extensions
supported_exts = ["exr", "jpg", "jpeg", "png", "dpx"]
supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"}
# Configurable by Settings
profiles = None

View file

@ -135,11 +135,11 @@ class ExtractReview(pyblish.api.InstancePlugin):
]
# Supported extensions
image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"]
video_exts = ["mov", "mp4"]
supported_exts = image_exts + video_exts
image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"}
video_exts = {"mov", "mp4"}
supported_exts = image_exts | video_exts
alpha_exts = ["exr", "png", "dpx"]
alpha_exts = {"exr", "png", "dpx"}
# Preset attributes
profiles = []

View file

@ -1,6 +1,8 @@
from __future__ import annotations
import contextlib
from abc import ABC, abstractmethod
from typing import Dict, Any
from dataclasses import dataclass
import ayon_api
@ -140,6 +142,7 @@ class TaskTypeItem:
)
@dataclass
class ProjectItem:
"""Item representing folder entity on a server.
@ -150,21 +153,14 @@ class ProjectItem:
active (Union[str, None]): Parent folder id. If 'None' then project
is parent.
"""
def __init__(self, name, active, is_library, icon=None):
self.name = name
self.active = active
self.is_library = is_library
if icon is None:
icon = {
"type": "awesome-font",
"name": "fa.book" if is_library else "fa.map",
"color": get_default_entity_icon_color(),
}
self.icon = icon
name: str
active: bool
is_library: bool
icon: dict[str, Any]
is_pinned: bool = False
@classmethod
def from_entity(cls, project_entity):
def from_entity(cls, project_entity: dict[str, Any]) -> "ProjectItem":
"""Creates folder item from entity.
Args:
@ -174,10 +170,16 @@ class ProjectItem:
ProjectItem: Project item.
"""
icon = {
"type": "awesome-font",
"name": "fa.book" if project_entity["library"] else "fa.map",
"color": get_default_entity_icon_color(),
}
return cls(
project_entity["name"],
project_entity["active"],
project_entity["library"],
icon
)
def to_data(self):
@ -208,16 +210,18 @@ class ProjectItem:
return cls(**data)
def _get_project_items_from_entitiy(projects):
def _get_project_items_from_entitiy(
projects: list[dict[str, Any]]
) -> list[ProjectItem]:
"""
Args:
projects (list[dict[str, Any]]): List of projects.
Returns:
ProjectItem: Project item.
"""
list[ProjectItem]: Project item.
"""
return [
ProjectItem.from_entity(project)
for project in projects
@ -428,9 +432,20 @@ class ProjectsModel(object):
self._projects_cache.update_data(project_items)
return self._projects_cache.get_data()
def _query_projects(self):
def _query_projects(self) -> list[ProjectItem]:
projects = ayon_api.get_projects(fields=["name", "active", "library"])
return _get_project_items_from_entitiy(projects)
user = ayon_api.get_user()
pinned_projects = (
user
.get("data", {})
.get("frontendPreferences", {})
.get("pinnedProjects")
) or []
pinned_projects = set(pinned_projects)
project_items = _get_project_items_from_entitiy(list(projects))
for project in project_items:
project.is_pinned = project.name in pinned_projects
return project_items
def _status_items_getter(self, project_entity):
if not project_entity:

View file

@ -1,6 +1,6 @@
from ayon_core.lib import Logger, get_ayon_username
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.settings import get_project_settings
from ayon_core.settings import get_project_settings, get_studio_settings
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
@ -85,7 +85,10 @@ class BaseLauncherController(
def get_project_settings(self, project_name):
if project_name in self._project_settings:
return self._project_settings[project_name]
settings = get_project_settings(project_name)
if project_name:
settings = get_project_settings(project_name)
else:
settings = get_studio_settings()
self._project_settings[project_name] = settings
return settings

View file

@ -1,154 +0,0 @@
from qtpy import QtWidgets, QtCore
from ayon_core.tools.flickcharm import FlickCharm
from ayon_core.tools.utils import (
PlaceholderLineEdit,
RefreshButton,
ProjectsQtModel,
ProjectSortFilterProxy,
)
from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER
class ProjectIconView(QtWidgets.QListView):
"""Styled ListView that allows to toggle between icon and list mode.
Toggling between the two modes is done by Right Mouse Click.
"""
IconMode = 0
ListMode = 1
def __init__(self, parent=None, mode=ListMode):
super(ProjectIconView, self).__init__(parent=parent)
# Workaround for scrolling being super slow or fast when
# toggling between the two visual modes
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.setObjectName("IconView")
self._mode = None
self.set_mode(mode)
def set_mode(self, mode):
if mode == self._mode:
return
self._mode = mode
if mode == self.IconMode:
self.setViewMode(QtWidgets.QListView.IconMode)
self.setResizeMode(QtWidgets.QListView.Adjust)
self.setWrapping(True)
self.setWordWrap(True)
self.setGridSize(QtCore.QSize(151, 90))
self.setIconSize(QtCore.QSize(50, 50))
self.setSpacing(0)
self.setAlternatingRowColors(False)
self.setProperty("mode", "icon")
self.style().polish(self)
self.verticalScrollBar().setSingleStep(30)
elif self.ListMode:
self.setProperty("mode", "list")
self.style().polish(self)
self.setViewMode(QtWidgets.QListView.ListMode)
self.setResizeMode(QtWidgets.QListView.Adjust)
self.setWrapping(False)
self.setWordWrap(False)
self.setIconSize(QtCore.QSize(20, 20))
self.setGridSize(QtCore.QSize(100, 25))
self.setSpacing(0)
self.setAlternatingRowColors(False)
self.verticalScrollBar().setSingleStep(34)
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.RightButton:
self.set_mode(int(not self._mode))
return super(ProjectIconView, self).mousePressEvent(event)
class ProjectsWidget(QtWidgets.QWidget):
"""Projects Page"""
refreshed = QtCore.Signal()
def __init__(self, controller, parent=None):
super(ProjectsWidget, self).__init__(parent=parent)
header_widget = QtWidgets.QWidget(self)
projects_filter_text = PlaceholderLineEdit(header_widget)
projects_filter_text.setPlaceholderText("Filter projects...")
refresh_btn = RefreshButton(header_widget)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(projects_filter_text, 1)
header_layout.addWidget(refresh_btn, 0)
projects_view = ProjectIconView(parent=self)
projects_view.setSelectionMode(QtWidgets.QListView.NoSelection)
flick = FlickCharm(parent=self)
flick.activateOn(projects_view)
projects_model = ProjectsQtModel(controller)
projects_proxy_model = ProjectSortFilterProxy()
projects_proxy_model.setSourceModel(projects_model)
projects_view.setModel(projects_proxy_model)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(header_widget, 0)
main_layout.addWidget(projects_view, 1)
projects_view.clicked.connect(self._on_view_clicked)
projects_model.refreshed.connect(self.refreshed)
projects_filter_text.textChanged.connect(
self._on_project_filter_change)
refresh_btn.clicked.connect(self._on_refresh_clicked)
controller.register_event_callback(
"projects.refresh.finished",
self._on_projects_refresh_finished
)
self._controller = controller
self._projects_view = projects_view
self._projects_model = projects_model
self._projects_proxy_model = projects_proxy_model
def has_content(self):
"""Model has at least one project.
Returns:
bool: True if there is any content in the model.
"""
return self._projects_model.has_content()
def _on_view_clicked(self, index):
if not index.isValid():
return
model = index.model()
flags = model.flags(index)
if not flags & QtCore.Qt.ItemIsEnabled:
return
project_name = index.data(QtCore.Qt.DisplayRole)
self._controller.set_selected_project(project_name)
def _on_project_filter_change(self, text):
self._projects_proxy_model.setFilterFixedString(text)
def _on_refresh_clicked(self):
self._controller.refresh()
def _on_projects_refresh_finished(self, event):
if event["sender"] != PROJECTS_MODEL_SENDER:
self._projects_model.refresh()

View file

@ -3,9 +3,13 @@ from qtpy import QtWidgets, QtCore, QtGui
from ayon_core import style, resources
from ayon_core.tools.launcher.control import BaseLauncherController
from ayon_core.tools.utils import MessageOverlayObject
from ayon_core.tools.utils import (
MessageOverlayObject,
PlaceholderLineEdit,
RefreshButton,
ProjectsWidget,
)
from .projects_widget import ProjectsWidget
from .hierarchy_page import HierarchyPage
from .actions_widget import ActionsWidget
@ -50,7 +54,25 @@ class LauncherWindow(QtWidgets.QWidget):
pages_widget = QtWidgets.QWidget(content_body)
# - First page - Projects
projects_page = ProjectsWidget(controller, pages_widget)
projects_page = QtWidgets.QWidget(pages_widget)
projects_header_widget = QtWidgets.QWidget(projects_page)
projects_filter_text = PlaceholderLineEdit(projects_header_widget)
projects_filter_text.setPlaceholderText("Filter projects...")
refresh_btn = RefreshButton(projects_header_widget)
projects_header_layout = QtWidgets.QHBoxLayout(projects_header_widget)
projects_header_layout.setContentsMargins(0, 0, 0, 0)
projects_header_layout.addWidget(projects_filter_text, 1)
projects_header_layout.addWidget(refresh_btn, 0)
projects_widget = ProjectsWidget(controller, pages_widget)
projects_layout = QtWidgets.QVBoxLayout(projects_page)
projects_layout.setContentsMargins(0, 0, 0, 0)
projects_layout.addWidget(projects_header_widget, 0)
projects_layout.addWidget(projects_widget, 1)
# - Second page - Hierarchy (folders & tasks)
hierarchy_page = HierarchyPage(controller, pages_widget)
@ -102,12 +124,16 @@ class LauncherWindow(QtWidgets.QWidget):
page_slide_anim.setEndValue(1.0)
page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad)
projects_page.refreshed.connect(self._on_projects_refresh)
refresh_btn.clicked.connect(self._on_refresh_request)
projects_widget.refreshed.connect(self._on_projects_refresh)
actions_refresh_timer.timeout.connect(
self._on_actions_refresh_timeout)
page_slide_anim.valueChanged.connect(
self._on_page_slide_value_changed)
page_slide_anim.finished.connect(self._on_page_slide_finished)
projects_filter_text.textChanged.connect(
self._on_project_filter_change)
controller.register_event_callback(
"selection.project.changed",
@ -142,6 +168,7 @@ class LauncherWindow(QtWidgets.QWidget):
self._pages_widget = pages_widget
self._pages_layout = pages_layout
self._projects_page = projects_page
self._projects_widget = projects_widget
self._hierarchy_page = hierarchy_page
self._actions_widget = actions_widget
# self._action_history = action_history
@ -194,6 +221,12 @@ class LauncherWindow(QtWidgets.QWidget):
elif self._is_on_projects_page:
self._go_to_hierarchy_page(project_name)
def _on_project_filter_change(self, text):
self._projects_widget.set_name_filter(text)
def _on_refresh_request(self):
self._controller.refresh()
def _on_projects_refresh(self):
# Refresh only actions on projects page
if self._is_on_projects_page:
@ -201,7 +234,7 @@ class LauncherWindow(QtWidgets.QWidget):
return
# No projects were found -> go back to projects page
if not self._projects_page.has_content():
if not self._projects_widget.has_content():
self._go_to_projects_page()
return
@ -280,6 +313,9 @@ class LauncherWindow(QtWidgets.QWidget):
def _go_to_projects_page(self):
if self._is_on_projects_page:
return
# Deselect project in projects widget
self._projects_widget.set_selected_project(None)
self._is_on_projects_page = True
self._hierarchy_page.set_page_visible(False)

View file

@ -29,6 +29,7 @@ from .widgets import (
from .views import (
DeselectableTreeView,
TreeView,
ListView,
)
from .error_dialog import ErrorMessageBox
from .lib import (
@ -61,6 +62,7 @@ from .dialogs import (
)
from .projects_widget import (
ProjectsCombobox,
ProjectsWidget,
ProjectsQtModel,
ProjectSortFilterProxy,
PROJECT_NAME_ROLE,
@ -114,6 +116,7 @@ __all__ = (
"DeselectableTreeView",
"TreeView",
"ListView",
"ErrorMessageBox",
@ -145,6 +148,7 @@ __all__ = (
"PopupUpdateKeys",
"ProjectsCombobox",
"ProjectsWidget",
"ProjectsQtModel",
"ProjectSortFilterProxy",
"PROJECT_NAME_ROLE",

View file

@ -1,21 +1,69 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
import typing
from typing import Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER
from ayon_core.tools.common_models import (
ProjectItem,
PROJECTS_MODEL_SENDER,
)
from .views import ListView
from .lib import RefreshThread, get_qt_icon
if typing.TYPE_CHECKING:
from typing import TypedDict
class ExpectedProjectSelectionData(TypedDict):
name: Optional[str]
current: Optional[str]
selected: Optional[str]
class ExpectedSelectionData(TypedDict):
project: ExpectedProjectSelectionData
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1
PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2
PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3
PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4
LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5
PROJECT_IS_PINNED_ROLE = QtCore.Qt.UserRole + 5
LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 6
class AbstractProjectController(ABC):
@abstractmethod
def register_event_callback(self, topic: str, callback: Callable):
pass
@abstractmethod
def get_project_items(
self, sender: Optional[str] = None
) -> list[str]:
pass
@abstractmethod
def set_selected_project(self, project_name: str):
pass
# These are required only if widget should handle expected selection
@abstractmethod
def expected_project_selected(self, project_name: str):
pass
@abstractmethod
def get_expected_selection_data(self) -> "ExpectedSelectionData":
pass
class ProjectsQtModel(QtGui.QStandardItemModel):
refreshed = QtCore.Signal()
def __init__(self, controller):
super(ProjectsQtModel, self).__init__()
def __init__(self, controller: AbstractProjectController):
super().__init__()
self._controller = controller
self._project_items = {}
@ -213,7 +261,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel):
else:
self.refreshed.emit()
def _fill_items(self, project_items):
def _fill_items(self, project_items: list[ProjectItem]):
new_project_names = {
project_item.name
for project_item in project_items
@ -252,6 +300,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel):
item.setData(project_name, PROJECT_NAME_ROLE)
item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE)
item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE)
item.setData(project_item.is_pinned, PROJECT_IS_PINNED_ROLE)
is_current = project_name == self._current_context_project
item.setData(is_current, PROJECT_IS_CURRENT_ROLE)
self._project_items[project_name] = item
@ -279,7 +328,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel):
class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
def __init__(self, *args, **kwargs):
super(ProjectSortFilterProxy, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self._filter_inactive = True
self._filter_standard = False
self._filter_library = False
@ -323,26 +372,51 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
return False
# Library separator should be before library projects
result = self._type_sort(left_index, right_index)
if result is not None:
return result
l_is_library = left_index.data(PROJECT_IS_LIBRARY_ROLE)
r_is_library = right_index.data(PROJECT_IS_LIBRARY_ROLE)
l_is_sep = left_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE)
r_is_sep = right_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE)
if l_is_sep:
return bool(r_is_library)
if left_index.data(PROJECT_NAME_ROLE) is None:
if r_is_sep:
return not l_is_library
# Non project items should be on top
l_project_name = left_index.data(PROJECT_NAME_ROLE)
r_project_name = right_index.data(PROJECT_NAME_ROLE)
if l_project_name is None:
return True
if right_index.data(PROJECT_NAME_ROLE) is None:
if r_project_name is None:
return False
left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE)
right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE)
if right_is_active == left_is_active:
return super(ProjectSortFilterProxy, self).lessThan(
left_index, right_index
)
if right_is_active != left_is_active:
return left_is_active
if left_is_active:
l_is_pinned = left_index.data(PROJECT_IS_PINNED_ROLE)
r_is_pinned = right_index.data(PROJECT_IS_PINNED_ROLE)
if l_is_pinned is True and not r_is_pinned:
return True
return False
if r_is_pinned is True and not l_is_pinned:
return False
# Move inactive projects to the end
left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE)
right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE)
if right_is_active != left_is_active:
return left_is_active
# Move library projects after standard projects
if (
l_is_library is not None
and r_is_library is not None
and l_is_library != r_is_library
):
return r_is_library
return super().lessThan(left_index, right_index)
def filterAcceptsRow(self, source_row, source_parent):
index = self.sourceModel().index(source_row, 0, source_parent)
@ -415,15 +489,153 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
self.invalidate()
class ProjectsDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._pin_icon = None
def paint(self, painter, option, index):
is_pinned = index.data(PROJECT_IS_PINNED_ROLE)
if not is_pinned:
super().paint(painter, option, index)
return
opt = QtWidgets.QStyleOptionViewItem(option)
self.initStyleOption(opt, index)
widget = option.widget
if widget is None:
style = QtWidgets.QApplication.style()
else:
style = widget.style()
# CE_ItemViewItem
proxy = style.proxy()
painter.save()
painter.setClipRect(option.rect)
decor_rect = proxy.subElementRect(
QtWidgets.QStyle.SE_ItemViewItemDecoration, opt, widget
)
text_rect = proxy.subElementRect(
QtWidgets.QStyle.SE_ItemViewItemText, opt, widget
)
proxy.drawPrimitive(
QtWidgets.QStyle.PE_PanelItemViewItem, opt, painter, widget
)
mode = QtGui.QIcon.Normal
if not opt.state & QtWidgets.QStyle.State_Enabled:
mode = QtGui.QIcon.Disabled
elif opt.state & QtWidgets.QStyle.State_Selected:
mode = QtGui.QIcon.Selected
state = QtGui.QIcon.Off
if opt.state & QtWidgets.QStyle.State_Open:
state = QtGui.QIcon.On
# Draw project icon
opt.icon.paint(
painter, decor_rect, opt.decorationAlignment, mode, state
)
# Draw pin icon
if index.data(PROJECT_IS_PINNED_ROLE):
pin_icon = self._get_pin_icon()
pin_rect = QtCore.QRect(decor_rect)
diff = option.rect.width() - pin_rect.width()
pin_rect.moveLeft(diff)
pin_icon.paint(
painter, pin_rect, opt.decorationAlignment, mode, state
)
# Draw text
if opt.text:
if not opt.state & QtWidgets.QStyle.State_Enabled:
cg = QtGui.QPalette.Disabled
elif not (opt.state & QtWidgets.QStyle.State_Active):
cg = QtGui.QPalette.Inactive
else:
cg = QtGui.QPalette.Normal
if opt.state & QtWidgets.QStyle.State_Selected:
painter.setPen(
opt.palette.color(cg, QtGui.QPalette.HighlightedText)
)
else:
painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text))
if opt.state & QtWidgets.QStyle.State_Editing:
painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text))
painter.drawRect(text_rect.adjusted(0, 0, -1, -1))
margin = proxy.pixelMetric(
QtWidgets.QStyle.PM_FocusFrameHMargin, None, widget
) + 1
text_rect.adjust(margin, 0, -margin, 0)
# NOTE skipping some steps e.g. word wrapping and elided
# text (adding '...' when too long).
painter.drawText(
text_rect,
opt.displayAlignment,
opt.text
)
# Draw focus rect
if opt.state & QtWidgets.QStyle.State_HasFocus:
focus_opt = QtWidgets.QStyleOptionFocusRect()
focus_opt.state = option.state
focus_opt.direction = option.direction
focus_opt.rect = option.rect
focus_opt.fontMetrics = option.fontMetrics
focus_opt.palette = option.palette
focus_opt.rect = style.subElementRect(
QtWidgets.QCommonStyle.SE_ItemViewItemFocusRect,
option,
option.widget
)
focus_opt.state |= (
QtWidgets.QStyle.State_KeyboardFocusChange
| QtWidgets.QStyle.State_Item
)
focus_opt.backgroundColor = option.palette.color(
(
QtGui.QPalette.Normal
if option.state & QtWidgets.QStyle.State_Enabled
else QtGui.QPalette.Disabled
),
(
QtGui.QPalette.Highlight
if option.state & QtWidgets.QStyle.State_Selected
else QtGui.QPalette.Window
)
)
style.drawPrimitive(
QtWidgets.QCommonStyle.PE_FrameFocusRect,
focus_opt,
painter,
option.widget
)
painter.restore()
def _get_pin_icon(self):
if self._pin_icon is None:
self._pin_icon = get_qt_icon({
"type": "material-symbols",
"name": "keep",
})
return self._pin_icon
class ProjectsCombobox(QtWidgets.QWidget):
refreshed = QtCore.Signal()
selection_changed = QtCore.Signal()
selection_changed = QtCore.Signal(str)
def __init__(self, controller, parent, handle_expected_selection=False):
super(ProjectsCombobox, self).__init__(parent)
def __init__(
self,
controller: AbstractProjectController,
parent: QtWidgets.QWidget,
handle_expected_selection: bool = False,
):
super().__init__(parent)
projects_combobox = QtWidgets.QComboBox(self)
combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox)
combobox_delegate = ProjectsDelegate(projects_combobox)
projects_combobox.setItemDelegate(combobox_delegate)
projects_model = ProjectsQtModel(controller)
projects_proxy_model = ProjectSortFilterProxy()
@ -468,7 +680,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
def refresh(self):
self._projects_model.refresh()
def set_selection(self, project_name):
def set_selection(self, project_name: str):
"""Set selection to a given project.
Selection change is ignored if project is not found.
@ -480,8 +692,8 @@ class ProjectsCombobox(QtWidgets.QWidget):
bool: True if selection was changed, False otherwise. NOTE:
Selection may not be changed if project is not found, or if
project is already selected.
"""
"""
idx = self._projects_combobox.findData(
project_name, PROJECT_NAME_ROLE)
if idx < 0:
@ -491,7 +703,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
return True
return False
def set_listen_to_selection_change(self, listen):
def set_listen_to_selection_change(self, listen: bool):
"""Disable listening to changes of the selection.
Because combobox is triggering selection change when it's model
@ -517,11 +729,11 @@ class ProjectsCombobox(QtWidgets.QWidget):
return None
return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE)
def set_current_context_project(self, project_name):
def set_current_context_project(self, project_name: str):
self._projects_model.set_current_context_project(project_name)
self._projects_proxy_model.invalidateFilter()
def set_select_item_visible(self, visible):
def set_select_item_visible(self, visible: bool):
self._select_item_visible = visible
self._projects_model.set_select_item_visible(visible)
self._update_select_item_visiblity()
@ -559,7 +771,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
idx, PROJECT_NAME_ROLE)
self._update_select_item_visiblity(project_name=project_name)
self._controller.set_selected_project(project_name)
self.selection_changed.emit()
self.selection_changed.emit(project_name or "")
def _on_model_refresh(self):
self._projects_proxy_model.sort(0)
@ -614,5 +826,119 @@ class ProjectsCombobox(QtWidgets.QWidget):
class ProjectsWidget(QtWidgets.QWidget):
# TODO implement
pass
"""Projects widget showing projects in list.
Warnings:
This widget does not support expected selection handling.
"""
refreshed = QtCore.Signal()
selection_changed = QtCore.Signal(str)
double_clicked = QtCore.Signal()
def __init__(
self,
controller: AbstractProjectController,
parent: Optional[QtWidgets.QWidget] = None
):
super().__init__(parent=parent)
projects_view = ListView(parent=self)
projects_view.setResizeMode(QtWidgets.QListView.Adjust)
projects_view.setVerticalScrollMode(
QtWidgets.QAbstractItemView.ScrollPerPixel
)
projects_view.setAlternatingRowColors(False)
projects_view.setWrapping(False)
projects_view.setWordWrap(False)
projects_view.setSpacing(0)
projects_delegate = ProjectsDelegate(projects_view)
projects_view.setItemDelegate(projects_delegate)
projects_view.activate_flick_charm()
projects_view.set_deselectable(True)
projects_model = ProjectsQtModel(controller)
projects_proxy_model = ProjectSortFilterProxy()
projects_proxy_model.setSourceModel(projects_model)
projects_view.setModel(projects_proxy_model)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(projects_view, 1)
projects_view.selectionModel().selectionChanged.connect(
self._on_selection_change
)
projects_view.double_clicked.connect(self.double_clicked)
projects_model.refreshed.connect(self._on_model_refresh)
controller.register_event_callback(
"projects.refresh.finished",
self._on_projects_refresh_finished
)
self._controller = controller
self._projects_view = projects_view
self._projects_model = projects_model
self._projects_proxy_model = projects_proxy_model
self._projects_delegate = projects_delegate
def refresh(self):
self._projects_model.refresh()
def has_content(self) -> bool:
"""Model has at least one project.
Returns:
bool: True if there is any content in the model.
"""
return self._projects_model.has_content()
def set_name_filter(self, text: str):
self._projects_proxy_model.setFilterFixedString(text)
def get_selected_project(self) -> Optional[str]:
selection_model = self._projects_view.selectionModel()
for index in selection_model.selectedIndexes():
project_name = index.data(PROJECT_NAME_ROLE)
if project_name:
return project_name
return None
def set_selected_project(self, project_name: Optional[str]):
if project_name is None:
self._projects_view.clearSelection()
self._projects_view.setCurrentIndex(QtCore.QModelIndex())
return
index = self._projects_model.get_index_by_project_name(project_name)
if not index.isValid():
return
proxy_index = self._projects_proxy_model.mapFromSource(index)
if proxy_index.isValid():
selection_model = self._projects_view.selectionModel()
selection_model.select(
proxy_index,
QtCore.QItemSelectionModel.ClearAndSelect
)
def _on_model_refresh(self):
self._projects_proxy_model.sort(0)
self._projects_proxy_model.invalidateFilter()
self.refreshed.emit()
def _on_selection_change(self, new_selection, _old_selection):
project_name = None
for index in new_selection.indexes():
name = index.data(PROJECT_NAME_ROLE)
if name:
project_name = name
break
self.selection_changed.emit(project_name or "")
self._controller.set_selected_project(project_name)
def _on_projects_refresh_finished(self, event):
if event["sender"] != PROJECTS_MODEL_SENDER:
self._projects_model.refresh()

View file

@ -37,7 +37,7 @@ class TreeView(QtWidgets.QTreeView):
double_clicked = QtCore.Signal(QtGui.QMouseEvent)
def __init__(self, *args, **kwargs):
super(TreeView, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self._deselectable = False
self._flick_charm_activated = False
@ -60,12 +60,64 @@ class TreeView(QtWidgets.QTreeView):
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
super(TreeView, self).mousePressEvent(event)
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
self.double_clicked.emit(event)
return super(TreeView, self).mouseDoubleClickEvent(event)
return super().mouseDoubleClickEvent(event)
def activate_flick_charm(self):
if self._flick_charm_activated:
return
self._flick_charm_activated = True
self._before_flick_scroll_mode = self.verticalScrollMode()
self._flick_charm.activateOn(self)
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
def deactivate_flick_charm(self):
if not self._flick_charm_activated:
return
self._flick_charm_activated = False
self._flick_charm.deactivateFrom(self)
if self._before_flick_scroll_mode is not None:
self.setVerticalScrollMode(self._before_flick_scroll_mode)
class ListView(QtWidgets.QListView):
"""A tree view that deselects on clicking on an empty area in the view"""
double_clicked = QtCore.Signal(QtGui.QMouseEvent)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._deselectable = False
self._flick_charm_activated = False
self._flick_charm = FlickCharm(parent=self)
self._before_flick_scroll_mode = None
def is_deselectable(self):
return self._deselectable
def set_deselectable(self, deselectable):
self._deselectable = deselectable
deselectable = property(is_deselectable, set_deselectable)
def mousePressEvent(self, event):
if self._deselectable:
index = self.indexAt(event.pos())
if not index.isValid():
# clear the selection
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
self.double_clicked.emit(event)
return super().mouseDoubleClickEvent(event)
def activate_flick_charm(self):
if self._flick_charm_activated: