Refactor Browser - #2031
Conversation
…d key validation Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Remove the fetch_versions_page_batch method and simplify to use individual page fetches. Add page_fetched signal handler to process events and keep UI responsive during large data fetches. Signed-off-by: philippe-ynput <philippe@ynput.io>
…-browser Signed-off-by: philippe-ynput <philippe@ynput.io>
- Set tree mode to enabled by default in ReviewController - Remove tree mode toggle checkbox from ReviewTable UI - Remove tree mode toggle handler and related category change logic - Simplifies the UI by always showing hierarchy view Signed-off-by: philippe-ynput <philippe@ynput.io>
Adds a new `log_timing` context manager that logs the execution time of code blocks when the AYON_CORE_TIMERS environment variable is enabled. This helps identify performance bottlenecks in the codebase. Signed-off-by: philippe-ynput <philippe@ynput.io>
Introduce product grouping feature allowing versions to be grouped by product, product type, or status. Add GraphQL query for fetching products, GroupBy enum with icon mappings, and empty row template. Include UI controls (menu, checkbox) in reviews widget. Add log_timing wrapper for thumbnail fetching and comprehensive tests for the review controller. Signed-off-by: philippe-ynput <philippe@ynput.io>
Replace hardcoded "Hierarchy" and "Reviews" strings with ReviewCategory enum values across review_controller.py, reviews_widget.py. This improves type safety and makes category comparisons less error-prone. Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Replace `_visible_proxy_range()` with `_iter_visible_proxy_indices()` that uses QtCore.QPersistentModelIndex for reliable row tracking and deduplication. Also replace string literal "Hierarchy" with ReviewCategory.HIERARCHY enum. Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Implement right-click context menu on the review table that displays loader action items. Added get_action_items and trigger_action_item methods to ReviewController for delegating to the main loader controller. Updated ReviewsWidget to accept a loader_controller and display the actions menu on context menu request. Signed-off-by: philippe-ynput <philippe@ynput.io>
Add featuredVersion query and populate group header rows with version metadata including thumbnail, status, author, and timestamps Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
…`ayon_ui_qt`, branch `browser-ui-phase-2` Signed-off-by: philippe-ynput <philippe@ynput.io>
Change GroupByMenu inheritance from AYButtonMenu to AYFilter and implement custom dropdown popup for group by selection UI. Signed-off-by: philippe-ynput <philippe@ynput.io>
…omize menu - Rename GroupBy enum to GroupByOption for better clarity - Move group-by functionality into new Customize button menu extending AYButtonMenu - Add get_attribute_icon function for mapping attribute types to icons - Fix typo: "deprioritises" to "deprioritize" in VisibilityAwarePaginatedTableModel docstring Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
… (rebase) Signed-off-by: philippe-ynput <philippe@ynput.io>
Introduce a new card view mode alongside the existing table view in the ReviewTable widget. Users can now switch between table and card layouts via the display type selector. The card view uses a custom mapper to display review data with headers, titles, status, versions, and thumbnails. Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Set initial tree mode based on group_by_key and update dynamically when group_by changes, removing redundant controller/model calls. BREAKING CHANGE: tree mode now depends on group_by_key being non-"none" Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
…ng logic Co-authored-by: Copilot <copilot@github.com> Signed-off-by: philippe-ynput <philippe@ynput.io>
… view Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Signed-off-by: philippe-ynput <philippe@ynput.io>
Co-authored-by: Copilot <copilot@github.com> Signed-off-by: philippe-ynput <philippe@ynput.io>
| len(order) != len(options_by_text) | ||
| or set(order) != set(options_by_text) |
There was a problem hiding this comment.
| len(order) != len(options_by_text) | |
| or set(order) != set(options_by_text) | |
| set(order) != set(options_by_text) |
There was a problem hiding this comment.
This was actually added due to copilot describing there's an edge case if we don't. Admittedly I have a hard time finding those Copilot comments now. I'll need to look that up again 🗡️
| ( | ||
| "check" | ||
| if check_style == "checkmark" | ||
| else "check_box" | ||
| if option.checked | ||
| else "check_box_outline_blank" | ||
| ), |
There was a problem hiding this comment.
maybe we can use variable to store this or use if...elif..else store it But it is already many if..elif..elif...elif..else in this PR.
icon_name = (
"check"
if check_style == "checkmark"
else "check_box"
if option.checked
else "check_box_outline_blank"
)or
if check_style == "checkmark":
icon_name = "check"
elif option.checked:
icon_name = "check_box"
else:
icon_name = "check_box_outline_blank"| ] | ||
| self._reset_pagination() | ||
|
|
||
| def _get_query_filters(self) -> dict[str, Any]: |
There was a problem hiding this comment.
@BigRoy Guess we should come up with some clean up with this function to make it more readable?
There was a problem hiding this comment.
At some point - yes, but not a priority honestly - more important that it works and that it's fast. Might do that once we get to the thorough code cleanup here though. Needs the features/bugs resolved first :D
Some of this is a mess - sorry
| color_off=self._icon_color, | ||
| icon_name_on=self._icon_on, | ||
| color_on=self._icon_color, | ||
| color_on=self._icon_checked_color, |
There was a problem hiding this comment.
Shouldn't that be named ._icon_color_on?
| if dropdown_variant is None: | ||
| self._dropdown = ButtonMenuDropdown( | ||
| self, container_margin=dropdown_margin | ||
| ) | ||
| else: | ||
| self._dropdown = ButtonMenuDropdown( | ||
| self, | ||
| frame_variant=dropdown_variant, | ||
| container_variant=dropdown_variant, | ||
| container_margin=dropdown_margin, | ||
| ) |
There was a problem hiding this comment.
| if dropdown_variant is None: | |
| self._dropdown = ButtonMenuDropdown( | |
| self, container_margin=dropdown_margin | |
| ) | |
| else: | |
| self._dropdown = ButtonMenuDropdown( | |
| self, | |
| frame_variant=dropdown_variant, | |
| container_variant=dropdown_variant, | |
| container_margin=dropdown_margin, | |
| ) | |
| kwargs = {} | |
| if dropdown_variant is not None: | |
| kwargs = dict( | |
| frame_variant=dropdown_variant, | |
| container_variant=dropdown_variant, | |
| ) | |
| self._dropdown = ButtonMenuDropdown( | |
| self, | |
| container_margin=dropdown_margin, | |
| **kwargs | |
| ) |
| """Update state when the popup signals it has closed.""" | ||
| self._menu_open = False | ||
| if QtWidgets.QApplication.mouseButtons() & Qt.MouseButton.LeftButton: | ||
| local_pos = self.mapFromGlobal(QtGui.QCursor.pos()) |
There was a problem hiding this comment.
| local_pos = self.mapFromGlobal(QtGui.QCursor.pos()) | |
| local_pos = self.mapFromGlobal(QtGui.QCursor.pos()) | |
| if isinstance(local_pos, QtCore.QPointF): | |
| local_pos = local_pos.toPoint() |
| if isinstance(model, QSortFilterProxyModel): | ||
| source = model.sourceModel() | ||
| if isinstance(source, PaginatedTableModel): | ||
| source.fetch_more(model.mapToSource(index)) | ||
| return |
There was a problem hiding this comment.
| if isinstance(model, QSortFilterProxyModel): | |
| source = model.sourceModel() | |
| if isinstance(source, PaginatedTableModel): | |
| source.fetch_more(model.mapToSource(index)) | |
| return | |
| while isinstance(model, QSortFilterProxyModel): | |
| model = model.sourceModel() |
| label_color = meta_idx.data(Qt.ItemDataRole.ForegroundRole) | ||
| label_icon = meta_idx.data(Qt.ItemDataRole.DecorationRole) | ||
| child_count = model.rowCount(group_idx) | ||
| row_data = group_idx.data(Qt.ItemDataRole.UserRole) or {} |
There was a problem hiding this comment.
Can I use AYCardView with custom model? If yes, then why it's using Qt.ItemDataRole.UserRole to get UI related data?
That is potential danger for cusom models using UserRole for different purposes. Also how do we make sure that the data are actually stored there?
I didn't dig enough to find out what child_count, group_percentage and group_metrics are comming from and what are representing, but this doesn't look good unless the model is strictly defined.
| self._schedule_editor_sync() | ||
|
|
||
| def _maybe_fetch_more(self) -> None: | ||
| def fetch_more_if_needed(self) -> None: |
There was a problem hiding this comment.
I don't think this should be publicly available.
| if near_bottom and root_pmi not in fetched: | ||
| if model.canFetchMore(root): | ||
| model.fetchMore(root) | ||
| self._fetch_more(root) |
There was a problem hiding this comment.
Why to change this to _fetch_more instead of fetchMore defined by Qt's QAbstractItemModel?
| for widget in self.take_items(): | ||
| widget.deleteLater() | ||
|
|
||
| def take_items(self) -> list[QWidget]: | ||
| """Remove and return items without deleting their widgets.""" | ||
| widgets = [widget for widget, _match_fn in self._items] | ||
| for widget in widgets: | ||
| widget.hide() | ||
| self._items_layout.removeWidget(widget) | ||
| self._items.clear() | ||
| self._items_layout.clear() | ||
|
|
||
| while self._items_layout.count(): | ||
| item = self._items_layout.takeAt(0) | ||
| del item | ||
| return widgets |
There was a problem hiding this comment.
| for widget in self.take_items(): | |
| widget.deleteLater() | |
| def take_items(self) -> list[QWidget]: | |
| """Remove and return items without deleting their widgets.""" | |
| widgets = [widget for widget, _match_fn in self._items] | |
| for widget in widgets: | |
| widget.hide() | |
| self._items_layout.removeWidget(widget) | |
| self._items.clear() | |
| self._items_layout.clear() | |
| while self._items_layout.count(): | |
| item = self._items_layout.takeAt(0) | |
| del item | |
| return widgets | |
| self._items.clear() | |
| while self._items_layout.count(): | |
| item = self._items_layout.takeAt(0) | |
| widget = item.widget() | |
| if widget is not None: | |
| widget.setVisible(False) | |
| widget.deleteLater() |
I think this was already suggested.
| if event.type() == QtCore.QEvent.Type.Enter: | ||
| self._frame.set_hovered(True) | ||
| elif event.type() == QtCore.QEvent.Type.Leave: | ||
| local = self._frame.mapFromGlobal(QtGui.QCursor.pos()) |
There was a problem hiding this comment.
| local = self._frame.mapFromGlobal(QtGui.QCursor.pos()) | |
| local = self._frame.mapFromGlobal(QtGui.QCursor.pos()) | |
| if isinstance(local, QtCore.QPointF): | |
| local = local.toPoint() |
| local = self._frame.mapFromGlobal(QtGui.QCursor.pos()) | ||
| if not self._frame.rect().contains(local): | ||
| self._frame.set_hovered(False) | ||
| return False # never consume the event |
There was a problem hiding this comment.
| return False # never consume the event | |
| # never consume the event | |
| return False |
| self._variant_str = variant.value | ||
| self._bg_tint = bg_tint | ||
| self._bg_color = None | ||
| self._hover_enabled = hover_enabled |
There was a problem hiding this comment.
No reason to store the value.
Considering what it does, I'd rather have helper method that would do that instead of achieve it using args.
| # each axis independently against half its own dimension, | ||
| # which turns an oversized radius on a wide-but-short rect | ||
| # (e.g. a pill chip) into an ellipse instead of a stadium. | ||
| radius = min(radius, draw_rect.width() / 2.0, draw_rect.height() / 2.0) |
There was a problem hiding this comment.
Multiply is faster then divide
| radius = min(radius, draw_rect.width() / 2.0, draw_rect.height() / 2.0) | |
| radius = min(radius, draw_rect.width() * 0.5, draw_rect.height() * 0.5) |
| ) | ||
| # print(f"Final ratio: {final_ratio}: {result.name()}") | ||
| return result | ||
| return tuple(result.toTuple()) |
There was a problem hiding this comment.
This could just return the color? The compute_color_for_contrast takes the tuple and converts it to QColor.
| @@ -195,10 +195,8 @@ def clear_layout(layout): | |||
| if widget: | |||
| # Recursively clear any layouts this widget might have | |||
| return workfiles_tool | ||
|
|
||
| def get_loader_tool(self, parent): | ||
| def get_loader_tool(self, parent, *, use_context: bool = False): |
| if use_legacy is None: | ||
| from ayon_core.pipeline import get_current_project_name | ||
| from ayon_core.settings import ( | ||
| get_project_settings, | ||
| get_studio_settings, | ||
| ) | ||
|
|
||
| project_name = get_current_project_name() | ||
| if project_name: | ||
| settings = get_project_settings(project_name) | ||
| else: | ||
| settings = get_studio_settings() | ||
| use_legacy = settings["core"]["tools"]["loader"].get( | ||
| "use_legacy_loader", False | ||
| ) | ||
| return use_legacy |
There was a problem hiding this comment.
| if use_legacy is None: | |
| from ayon_core.pipeline import get_current_project_name | |
| from ayon_core.settings import ( | |
| get_project_settings, | |
| get_studio_settings, | |
| ) | |
| project_name = get_current_project_name() | |
| if project_name: | |
| settings = get_project_settings(project_name) | |
| else: | |
| settings = get_studio_settings() | |
| use_legacy = settings["core"]["tools"]["loader"].get( | |
| "use_legacy_loader", False | |
| ) | |
| return use_legacy | |
| if use_legacy is not None: | |
| return use_legacy | |
| from ayon_core.pipeline import get_current_project_name | |
| from ayon_core.settings import ( | |
| get_project_settings, | |
| get_studio_settings, | |
| ) | |
| project_name = get_current_project_name() | |
| if project_name: | |
| settings = get_project_settings(project_name) | |
| else: | |
| settings = get_studio_settings() | |
| return settings["core"]["tools"]["loader"].get( | |
| "use_legacy_loader", False | |
| ) |
| controller: AbstractProjectController, | ||
| parent: QtWidgets.QWidget, | ||
| handle_expected_selection: bool = False, | ||
| variant: AYComboBox.Variants = AYComboBox.Variants.Default, |
There was a problem hiding this comment.
Does it make sense to allow change of the variant on project combobox?
| # visual freeze rendering is added in Phase 3. | ||
| self._pinned_keys: set[str] = set() |
There was a problem hiding this comment.
What is this for? The comments describes "column" the variable is name "keys" and AYTableHeader does store it but does not use it.
Changelog Description
AYON_USE_LEGACY_LOADER=1env var to use the legacy loader (we may want to reverse this at first)Additional info
This won't be an easy one to review because well, it's massive. And it still requires a lot of cleanup - but I've focused on just throwing things together to get the UI to a functional state with admittedly spending a small amount of time only on cleanup.
It'd be great to review it by:
Also, I'm sure the tests are shit ;) I've not read them at all and I let AI do that along the side just to give it some wheels.
And, I'm so sorry!
TODO
not query groups separately,just query the versions and group them UI side.LoaderController + BrowserController should not both exist in the browserseparated between BrowserController & BrowserWidgetController (QObject for signals) for now; needs more @iLLiCiTiT review.Change the columns interface around so that addon doesn't need to expose a method that requires Qt - avoid the Qt requirement + maybe change it around for something else to 'register something with core', so there someRemoved the public interface API for nowcore.x.y.register_column_handler()Add a means to group by 'productGroup'Possibly better with a server-side migration script that moves it to a regular attribute.Add a hardcore limit of never displaying more than e.g. 1000 versions with a little notice to recommend the user to perform tighter filtering to avoid tons of paginated queries?Skipping for now because we're paginating when we can.Testing notes:
Only then after this PR has gone through initial tests, then we should go over thorough code review.