# ##### BEGIN GPL LICENSE BLOCK ##### # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # ##### END GPL LICENSE BLOCK ##### import logging import math import os import re import time from functools import partial from collections import Counter from types import SimpleNamespace from typing import Any, Dict, Optional, Union import bpy from bpy.props import BoolProperty, StringProperty from . import ( colors, comments_utils, global_vars, paths, ratings_utils, search, ui, ui_bgl, ui_panels, utils, viewport_utils, ) from .bl_ui_widgets.bl_ui_button import BL_UI_Button from .bl_ui_widgets.bl_ui_drag_panel import BL_UI_Drag_Panel from .bl_ui_widgets.bl_ui_draw_op import BL_UI_OT_draw_operator from .bl_ui_widgets.bl_ui_image import BL_UI_Image from .bl_ui_widgets.bl_ui_label import BL_UI_Label, BL_UI_DuoLabel from .bl_ui_widgets.bl_ui_widget import BL_UI_Widget bk_logger = logging.getLogger(__name__) # Maximum label length for manufacturer chips (e.g. "Ford motor company") MAX_MANUFACTURER_LABEL_LEN = 17 # Maximum label length for generic active filter chips (term + value) MAX_FILTER_LABEL_LEN = 24 THUMBNAIL_TYPES = [ "THUMBNAIL", "PHOTO", "WIREFRAME", ] active_area_pointer = 0 ROUNDING_RADIUS = 20 TOOLTIP_SIZE_PX = 512 def get_area_height(self): ctx = getattr(self, "context", None) if isinstance(ctx, dict): ctx_dict = ctx elif isinstance(ctx, SimpleNamespace): ctx_dict = { "window": getattr(ctx, "window", None), "area": getattr(ctx, "area", None), "region": getattr(ctx, "region", None), } else: if ctx is None: ctx = bpy.context ctx_dict = { "window": getattr(ctx, "window", None), "area": getattr(ctx, "area", None), "region": getattr(ctx, "region", None), } self.context = ctx_dict region = ctx_dict.get("region") if region is not None: return region.height area = ctx_dict.get("area") if area is not None: return area.height return 100 BL_UI_Widget.get_area_height = get_area_height # type: ignore[method-assign] def modal_inside(self, context, event): ui_props = bpy.context.window_manager.blenderkitUI # Initialize mouse coordinates early so shortcut handling in the first modal # events does not fail on fresh operators (Blender 3.0 lacks these attrs). if not hasattr(self, "mouse_x") or not hasattr(self, "mouse_y"): self.mouse_x, self.mouse_y = self._event_coords_in_active_region(event) if ui_props.turn_off: ui_props.turn_off = False self.finish() if self._finished: return {"FINISHED"} user_preferences = bpy.context.preferences.addons[__package__].preferences if self.context: context = self.context # HANDLE PHOTO THUMBNAIL SWITCH if hasattr(self, "needs_tooltip_update") and self.needs_tooltip_update: self.needs_tooltip_update = False sr = search.get_search_results() if sr and self.active_index < len(sr): asset_data = sr[self.active_index] if asset_data["assetType"].lower() in {"printable", "model", "scene"}: if self.show_thumbnail_variant == "PHOTO": photo_img = ui.get_full_photo_thumbnail(asset_data) if photo_img: self.tooltip_image.set_image(photo_img.filepath) self.tooltip_image.set_image_colorspace("") else: self.tooltip_image.set_image( paths.get_addon_thumbnail_path("thumbnail_notready.jpg") ) elif self.show_thumbnail_variant == "THUMBNAIL": wire_img = ui.get_full_wire_thumbnail(asset_data) if wire_img: self.tooltip_image.set_image(wire_img.filepath) self.tooltip_image.set_image_colorspace("") else: self.tooltip_image.set_image( paths.get_addon_thumbnail_path("thumbnail_notready.jpg") ) else: set_thumb_check( self.tooltip_image, asset_data, thumb_type="thumbnail" ) if not context.area: self.finish() w, a, r = utils.get_largest_area(area_type="VIEW_3D") if a is not None: bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False) return {"FINISHED"} is_quad_view = self._is_quad_view(context) if getattr(self, "_quad_view_state", None) != is_quad_view: self._quad_view_state = is_quad_view self.active_area_pointer = None # force refresh of area/region pointers self.active_region_pointer = None self.finish() bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False) return {"FINISHED"} # Update active viewport based on cursor location so the asset bar follows the # region the user is interacting with. self.update_active_view_from_cursor(context, event) sr = search.get_search_results() if sr is not None: # this check runs more search, useful especially for first search. Could be moved to a better place where the check # doesn't run that often. # Calculate current max rows based on expanded state if user_preferences.assetbar_expanded: current_max_rows = user_preferences.maximized_assetbar_rows else: current_max_rows = 1 if len(sr) - ui_props.scroll_offset < (ui_props.wcount * current_max_rows) + 15: self.search_more() # Toggle overlay stats/text with animation playback is_playing = getattr(bpy.context.screen, "is_animation_playing", False) if is_playing != getattr(self, "_animation_playing", False): self._animation_playing = is_playing self._set_overlays(context, show=is_playing) time_diff = time.time() - self.update_timer_start if time_diff > self.update_timer_limit: self.update_timer_start = time.time() self.update_buttons() # progress bar # change - let's try to optimize and redraw only when needed change = False for asset_button in self.asset_buttons: if not asset_button.visible: continue if sr is not None and len(sr) > asset_button.asset_index: asset_data = sr[asset_button.asset_index] self.update_progress_bar(asset_button, asset_data) if change: context.region.tag_redraw() # Check for tab shortcut keys directly in the modal function if ( event.ctrl and event.value == "PRESS" and self.panel.is_in_rect(self.mouse_x, self.mouse_y) ): if event.type == "T" and not event.shift: bk_logger.info("Ctrl+T pressed - add new tab") if hasattr(self, "new_tab_button"): # Only if we can add more tabs self.add_new_tab(None) return {"RUNNING_MODAL"} elif event.type == "W" and not event.shift: bk_logger.info("Ctrl+W pressed - close tab") if len(global_vars.TABS["tabs"]) > 1: # Don't close last tab self.remove_tab(self.close_tab_buttons[global_vars.TABS["active_tab"]]) return {"RUNNING_MODAL"} elif event.type == "TAB": bk_logger.info("Ctrl+Tab pressed - switch tab") tabs = global_vars.TABS["tabs"] current = global_vars.TABS["active_tab"] if event.shift: # Go to previous tab new_index = (current - 1) % len(tabs) else: # Go to next tab new_index = (current + 1) % len(tabs) self.switch_to_history_step(new_index, tabs[new_index]["history_index"]) return {"RUNNING_MODAL"} elif event.type in { "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", }: # Convert numkey to index (0-based) tab_idx = { "ONE": 0, "TWO": 1, "THREE": 2, "FOUR": 3, "FIVE": 4, "SIX": 5, "SEVEN": 6, "EIGHT": 7, "NINE": 8, }[event.type] if tab_idx < len(global_vars.TABS["tabs"]): bk_logger.info( "Ctrl+%d pressed - go to tab %d", tab_idx + 1, tab_idx + 1 ) self.switch_to_history_step( tab_idx, global_vars.TABS["tabs"][tab_idx]["history_index"] ) return {"RUNNING_MODAL"} # Handle Alt+Left/Right for history navigation elif ( event.alt and event.value == "PRESS" and self.panel.is_in_rect(self.mouse_x, self.mouse_y) ): if event.type == "LEFT_ARROW": bk_logger.info("Alt+Left pressed - history back") active_tab = global_vars.TABS["tabs"][global_vars.TABS["active_tab"]] if active_tab["history_index"] > 0: self.history_back(None) # None instead of widget return {"RUNNING_MODAL"} elif event.type == "RIGHT_ARROW": bk_logger.info("Alt+Right pressed - history forward") active_tab = global_vars.TABS["tabs"][global_vars.TABS["active_tab"]] if active_tab["history_index"] < len(active_tab["history"]) - 1: self.history_forward(None) # None instead of widget return {"RUNNING_MODAL"} # ANY EVENT ACTIVATED = DON'T LET EVENTS THROUGH # While asset drag operator is active, do not consume pointer events here. # Otherwise the assetbar can starve the drag operator from MOUSEMOVE events, # especially with multiple windows/monitors. if ui_props.dragging: return {"PASS_THROUGH"} if self.handle_widget_events(event): return {"RUNNING_MODAL"} if event.type in {"ESC"} and event.value == "PRESS": # just escape dragging when dragging, not appending. if not ui_props.dragging: self.finish() # return {"FINISHED"} # we can jump out immediately self.mouse_x, self.mouse_y = self._event_coords_in_active_region(event) # TRACKPAD SCROLL if event.type == "TRACKPADPAN" and self.panel.is_in_rect( self.mouse_x, self.mouse_y ): # accumulate trackpad inputs self.trackpad_x_accum -= event.mouse_x - event.mouse_prev_x self.trackpad_y_accum += event.mouse_y - event.mouse_prev_y step = 0 multiplier = 30 if abs(self.trackpad_x_accum) > abs(self.trackpad_y_accum) or self.hcount < 2: step = math.floor(self.trackpad_x_accum / multiplier) self.trackpad_x_accum -= step * multiplier # reset the other axis not to accidentally scroll it if step != 0: self.trackpad_y_accum = 0 if abs(self.trackpad_y_accum) > 0 and self.hcount > 1: step = self.wcount * math.floor(self.trackpad_x_accum / multiplier) self.trackpad_y_accum -= step * multiplier # reset the other axis not to accidentally scroll it if step != 0: self.trackpad_x_accum = 0 if step != 0: self.scroll_offset += step self.scroll_update() return {"RUNNING_MODAL"} # MOUSEWHEEL SCROLL if event.type == "WHEELUPMOUSE" and self.panel.is_in_rect( self.mouse_x, self.mouse_y ): if self.hcount > 1: self.scroll_offset -= self.wcount else: self.scroll_offset -= 2 self.scroll_update() return {"RUNNING_MODAL"} elif event.type == "WHEELDOWNMOUSE" and self.panel.is_in_rect( self.mouse_x, self.mouse_y ): if self.hcount > 1: self.scroll_offset += self.wcount else: self.scroll_offset += 2 self.scroll_update() return {"RUNNING_MODAL"} if self.check_ui_resized(context): # Force a clean rebuild when the viewport size changes. if not getattr(self, "_restart_pending", False): self._restart_pending = True ui_props = bpy.context.window_manager.blenderkitUI ui_props.assetbar_on = False ui_props.turn_off = True self.restart_asset_bar() return {"FINISHED"} if self.check_new_search_results(context) or self.check_region_changed(context): self._refresh_layout(context) # also update tooltip visibility # if there's less results and active button is not visible, hide tooltip # happened only when e.g. running new search from web browser (copying assetbaseid to clipboard) # fixes issue #1766 if self.active_index >= len(search.get_search_results()): self.hide_tooltip() self.scroll_update( always=True ) # one extra update for scroll for correct redraw, updates all buttons # this was here to check if sculpt stroke is running, but obviously that didn't help, # since the RELEASE event is caught by operator and thus there is no way to detect a stroke has ended... if bpy.context.mode in ("SCULPT", "PAINT_TEXTURE"): if ( event.type == "MOUSEMOVE" ): # ASSUME THAT SCULPT OPERATOR ACTUALLY STEALS THESE EVENTS, # SO WHEN THERE ARE SOME WE CAN APPEND BRUSH... bpy.context.window_manager["appendable"] = True if event.type == "LEFTMOUSE": if event.value == "PRESS": bpy.context.window_manager["appendable"] = False return {"PASS_THROUGH"} def asset_bar_modal(self, context, event): return modal_inside(self, context, event) def asset_bar_invoke(self, context, event): # sprinkling of black magic result = self.on_invoke(context, event) if not result: return {"CANCELLED"} if not context.window: return {"CANCELLED"} if not context.area: return {"CANCELLED"} args = (self, context) self.register_handlers(args, context) self.update_timer_limit = 0.5 self.update_timer_start = time.time() self._timer = context.window_manager.event_timer_add(0.5, window=context.window) context.window_manager.modal_handler_add(self) global active_area_pointer self.active_window_pointer = context.window.as_pointer() self.active_area_pointer = context.area.as_pointer() active_area_pointer = self.active_area_pointer self.active_region_pointer = context.region.as_pointer() self.operator_area_pointer = self.active_area_pointer self.operator_region_pointer = self.active_region_pointer self._active_area_ref = context.area self._active_region_ref = context.region return {"RUNNING_MODAL"} asset_bar_operator = None def get_author_tooltip_data(asset_data): """Build tooltip_data for an author-type search result.""" author_id = int(asset_data.get("id", asset_data.get("author", {}).get("id", 0))) author = global_vars.BKIT_AUTHORS.get(author_id) display_name = asset_data.get("displayName", "") about_me = "" if author: about_me = getattr(author, "aboutMe", "") or "" # Fallback: get aboutMe directly from the author sub-object in the result if not about_me: about_me = asset_data.get("author", {}).get("aboutMe", "") or "" # Asset count from ratingsSum ratings_sum = asset_data.get("ratingsSum", {}) asset_count = ratings_sum.get("assetCount", 0) asset_data["tooltip_data"] = { "aname": display_name, "author_text": "", "quality": "-", "about_me": about_me, "asset_count": asset_count, "is_author": True, "user_price_text": "", "base_price_text": "", "user_price_color": colors.WHITE, "base_price_color": colors.WHITE, "user_price_bg_color": colors.GRAY, "base_price_bg_color": colors.GRAY, } def get_tooltip_data(asset_data): tooltip_data = asset_data.get("tooltip_data") if tooltip_data is not None: return if asset_data.get("assetType") == "author": return get_author_tooltip_data(asset_data) author_text = "" if global_vars.BKIT_AUTHORS: author_id = int(asset_data["author"]["id"]) author = global_vars.BKIT_AUTHORS.get(author_id) if author: if len(author.firstName) > 0 or len(author.lastName) > 0: author_text = f"by {author.firstName} {author.lastName}" else: bk_logger.warning("get_tooltip_data() AUTHOR NOT FOUND: %s", author_id) aname = asset_data["displayName"] if len(aname) == 0: # this shouldn't happen, but obviously did on server in addons section. aname = "" else: aname = aname[0].upper() + aname[1:] if len(aname) > 36: aname = f"{aname[:33]}..." rc = asset_data.get("ratingsCount") show_rating_threshold = 0 rcount = 0 quality = "-" if rc: rcount = min(rc.get("quality", 0), rc.get("workingHours", 0)) if rcount > show_rating_threshold: quality = str(round(asset_data["ratingsAverage"].get("quality"))) # Add pricing information base_price_text = "" user_price_text = "" user_price_color = colors.WHITE base_price_color = colors.WHITE user_price_bg_color = colors.GRAY base_price_bg_color = colors.GRAY # Check if asset is free or paid (works for all asset types) is_free = asset_data.get("isFree", True) can_download = asset_data.get("canDownload", True) if asset_data.get("assetType") == "addon": # Get pricing info from extensions cache. # Pricing info is shown only for add-ons. base_price_text = asset_data.get("basePrice") user_price_text = asset_data.get("userPrice") is_for_sale = asset_data.get("isForSale") # for debug show both prices always if utils.profile_is_validator(): if ( user_price_text and base_price_text and str(user_price_text) != str(base_price_text) ): # Sale: show discounted price + strikethrough original user_price_text = f" ${user_price_text} " user_price_color = colors.WHITE user_price_bg_color = colors.PURPLE_PRICE base_price_text = f" ${base_price_text} " base_price_color = colors.TEXT_DIM base_price_bg_color = colors.PURPLE_PRICE elif base_price_text: # No sale or only base price available base_price_text = f" ${base_price_text} " base_price_color = colors.WHITE base_price_bg_color = colors.PURPLE_PRICE user_price_text = "" elif user_price_text: user_price_text = f" ${user_price_text} " user_price_color = colors.WHITE user_price_bg_color = colors.PURPLE_PRICE base_price_text = "" else: user_price_text = "" base_price_text = "" else: if is_for_sale and not can_download and user_price_text and base_price_text: user_price_text = f" ${user_price_text} " user_price_bg_color = colors.PURPLE_PRICE user_price_color = colors.WHITE base_price_text = f" (${base_price_text}) " base_price_bg_color = colors.PURPLE_PRICE base_price_color = colors.TEXT_DIM elif is_for_sale and not can_download and base_price_text: base_price_text = f" ${base_price_text} " base_price_bg_color = colors.PURPLE_PRICE base_price_color = colors.WHITE user_price_text = "" elif not is_free and not is_for_sale: base_price_text = " Full Plan " base_price_bg_color = colors.ORANGE_FULL base_price_color = colors.WHITE user_price_text = "" elif is_for_sale and can_download: # purchased, so we dont show price anymore base_price_text = f" Purchased " base_price_bg_color = colors.PURPLE_PRICE base_price_color = colors.WHITE user_price_text = "" tooltip_data = { "aname": aname, "author_text": author_text, "quality": quality, # --- colors for price texts and backgrounds "user_price_text": user_price_text, "base_price_text": base_price_text, "user_price_color": user_price_color, "base_price_color": base_price_color, "user_price_bg_color": user_price_bg_color, "base_price_bg_color": base_price_bg_color, } asset_data["tooltip_data"] = tooltip_data def set_thumb_check( element: Union[BL_UI_Button, BL_UI_Image], asset: Dict[str, Any], thumb_type: str = "thumbnail_small", ) -> None: """Set image in case it is loaded in search results. Checks global_vars.DATA["images available"]. - if image download failed, it will be set to 'thumbnail_not_available.jpg' - if image doesn't exist, it will be set to 'thumbnail_notready.jpg' """ # Author assets have no server thumbnails, use gravatar from BKIT_AUTHORS if asset.get("assetType") == "author": author_id = int(asset.get("id", asset.get("author", {}).get("id", 0))) author = global_vars.BKIT_AUTHORS.get(author_id) if author and author.gravatarImg: if element.get_image_path() != author.gravatarImg: element.set_image(author.gravatarImg) element.set_image_colorspace("") return tpath = paths.get_addon_thumbnail_path("thumbnail_notready.jpg") if element.get_image_path() != tpath: element.set_image(tpath) element.set_image_colorspace("") return directory = paths.get_temp_dir("%s_search" % asset["assetType"]) tpath = os.path.join(directory, asset[thumb_type]) if element.get_image_path() == tpath: return # no need to update image_ready = global_vars.DATA["images available"].get(tpath) if image_ready is None: tpath = paths.get_addon_thumbnail_path("thumbnail_notready.jpg") if image_ready is False or asset[thumb_type] == "": tpath = paths.get_addon_thumbnail_path("thumbnail_not_available.jpg") if element.get_image_path() == tpath: return element.set_image(tpath) element.set_image_colorspace("") class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator): """BlenderKit Asset Bar Operator.""" bl_idname = "view3d.blenderkit_asset_bar_widget" bl_label = "BlenderKit asset bar refresh" bl_description = "BlenderKit asset bar refresh" bl_options = {"REGISTER"} instances = [] do_search: BoolProperty( # type: ignore[valid-type] name="Run Search", description="", default=True, options={"SKIP_SAVE"} ) keep_running: BoolProperty( # type: ignore[valid-type] name="Keep Running", description="", default=True, options={"SKIP_SAVE"} ) category: StringProperty( # type: ignore[valid-type] name="Category", description="search only subtree of this category", default="", options={"SKIP_SAVE"}, ) tooltip: bpy.props.StringProperty( # type: ignore[valid-type] default="Runs search and displays the asset bar at the same time" ) show_thumbnail_variant: bpy.props.EnumProperty( # type: ignore[valid-type] name="Show Thumbnail Variant", description="Toggle between normal, photo and wireframe thumbnail - use [ or ] to cycle through thumbnails. Currently used only for printables, models, and scenes.", default="THUMBNAIL", items=[ ("THUMBNAIL", "Thumbnail", "Normal thumbnail"), ("PHOTO", "Photo", "Photo thumbnail"), ("WIREFRAME", "Wireframe", "Wireframe thumbnail"), ], options={"SKIP_SAVE"}, ) # ------------------------------------------------------------------ # Context helpers # ------------------------------------------------------------------ def _resolve_window(self, context): return getattr(context, "window", None) or bpy.context.window def _validated_area(self, area): if area is None: return None try: area.as_pointer() except ReferenceError: return None return area def _validated_region(self, region): if region is None: return None try: region.as_pointer() except ReferenceError: return None return region def _safe_space_data(self, area): if area is None: return None try: return area.spaces.active except ReferenceError: return None def _build_context_snapshot(self, context, area=None, region=None): area = self._validated_area(area) or self._validated_area( getattr(context, "area", None) ) region = self._validated_region(region) or self._validated_region( getattr(context, "region", None) ) return SimpleNamespace( window=self._resolve_window(context), area=area, region=region, space_data=self._safe_space_data(area), ) def _unwrap_area_region(self, ctx): if isinstance(ctx, dict): return ctx.get("area"), ctx.get("region") return getattr(ctx, "area", None), getattr(ctx, "region", None) def _current_area_region(self): """Return the currently active area and region with sensible fallbacks.""" area = self._validated_area(getattr(self, "_active_area_ref", None)) region = self._validated_region(getattr(self, "_active_region_ref", None)) ctx_area, ctx_region = self._unwrap_area_region(getattr(self, "context", None)) if area is None: area = self._validated_area(ctx_area) if region is None: region = self._validated_region(ctx_region) if area is None: area = self._validated_area(getattr(bpy.context, "area", None)) if region is None: region = self._validated_region(getattr(bpy.context, "region", None)) return area, region def _event_window_coords(self, event): if not hasattr(event, "mouse_x") or not hasattr(event, "mouse_y"): return None, None return getattr(event, "mouse_x", None), getattr(event, "mouse_y", None) def _apply_widget_context(self, override_ctx): """Apply the given override context to all widgets in the asset bar. All widgets get the new context, except for the main panel and tooltip panel (and their children). """ self._override_context = override_ctx if not hasattr(self, "widgets"): return panel = getattr(self, "panel", None) panel_children = ( set(panel.widgets) if isinstance(panel, BL_UI_Drag_Panel) else set() ) tooltip_panel = getattr(self, "tooltip_panel", None) tooltip_children = ( set(self.tooltip_widgets) if hasattr(self, "tooltip_widgets") else set() ) for widget in self.widgets: widget.context = override_ctx if ( widget is panel or widget in panel_children or widget is tooltip_panel or widget in tooltip_children ): continue widget.update(widget.x, widget.y) if isinstance(panel, BL_UI_Drag_Panel): panel.update(panel.x, panel.y) panel.layout_widgets() if isinstance(tooltip_panel, BL_UI_Drag_Panel): tooltip_panel.update(tooltip_panel.x, tooltip_panel.y) tooltip_panel.layout_widgets() def _find_area_region_from_event(self, context, event): x, y = self._event_window_coords(event) if x is None or y is None: return None, None screen = getattr(self._resolve_window(context), "screen", None) if screen is None: return None, None for area in screen.areas: if getattr(area, "type", None) != "VIEW_3D": continue if not ( area.x <= x < area.x + area.width and area.y <= y < area.y + area.height ): continue target_region = None fallback_region = None for region in viewport_utils.iter_view3d_window_regions(area): if fallback_region is None: fallback_region = region if ( region.x <= x < region.x + region.width and region.y <= y < region.y + region.height ): target_region = region break return area, target_region or fallback_region return None, None def _cursor_inside_active_area(self, event): # return False area = self._validated_area(getattr(self, "_active_area_ref", None)) if area is None: return False x, y = self._event_window_coords(event) if x is None or y is None: return False if not ( area.x <= x < area.x + area.width and area.y <= y < area.y + area.height ): return False region = self._validated_region(getattr(self, "_active_region_ref", None)) if region is None: return True return ( region.x <= x < region.x + region.width and region.y <= y < region.y + region.height ) def _view_changed( self, area_pointer, region_pointer, previous_area_pointer, previous_region_pointer, ): return ( previous_area_pointer != area_pointer or previous_region_pointer != region_pointer ) def _store_active_view(self, context, area, region, area_pointer, region_pointer): self.active_area_pointer = area_pointer self.active_region_pointer = region_pointer global active_area_pointer active_area_pointer = area_pointer self._active_area_ref = area self._active_region_ref = region self.context = context def _refresh_layout(self, override_ctx): self.update_assetbar_sizes(override_ctx) self.update_tooltip_size(override_ctx) try: self.update_assetbar_layout(override_ctx) self.update_tooltip_layout(override_ctx) if hasattr(self, "tooltip_panel"): self.tooltip_panel.layout_widgets() except Exception as e: bk_logger.log( 1, "Error updating asset bar layout, some objects may be missing. %s", e ) def _safe_tag_redraw(self, region): if region is None: return try: region.tag_redraw() except ReferenceError: pass def _tag_regions_for_redraw(self, current_region, previous_region_ref): self._safe_tag_redraw(current_region) if previous_region_ref and previous_region_ref is not current_region: self._safe_tag_redraw(previous_region_ref) def _switch_active_view(self, context, area, region, *, force=True): """Switch the active area/region the asset bar is attached to. If `force` is True, the switch is applied even if the area/region pointers match the previously stored ones. """ area = self._validated_area(area) region = self._validated_region(region) if area is None or region is None: return self.area = area self.region = region area_pointer = area.as_pointer() region_pointer = region.as_pointer() previous_area_pointer = getattr(self, "active_area_pointer", None) previous_region_pointer = getattr(self, "active_region_pointer", None) previous_region_ref = getattr(self, "_active_region_ref", None) self._store_active_view(context, area, region, area_pointer, region_pointer) override_ctx = self._build_context_snapshot(context, area, region) self._apply_widget_context(override_ctx) if force or self._view_changed( area_pointer, region_pointer, previous_area_pointer, previous_region_pointer, ): # Rebuild sizes/layout whenever we truly jump to a different area (or # the caller explicitly requests it) so the bar respects the new # region dimensions/UI scale. Plain cursor moves within the same view # keep the old layout for performance. self._refresh_layout(override_ctx) # Explicitly request redraws so the handler updates in the new region and # the previous one releases its stale widgets. self._tag_regions_for_redraw(region, previous_region_ref) def _redraw_tracked_regions(self): """Request redraw on any region the operator stored explicitly.""" _, stored_region = self._unwrap_area_region(getattr(self, "context", None)) regions = { self._validated_region(getattr(self, "_active_region_ref", None)), self._validated_region( getattr(getattr(self, "_override_context", None), "region", None) ), self._validated_region(stored_region), } for region in regions: self._safe_tag_redraw(region) def update_active_view_from_cursor(self, context, event): """Update the active area/region based on the current cursor location. This makes the asset bar follow the user's cursor as they move between different 3D viewports. """ if event.type not in {"TIMER", "MOUSEMOVE"}: return user_prefs = bpy.context.preferences.addons[__package__].preferences follow_cursor = getattr(user_prefs, "assetbar_follows_cursor", True) if not follow_cursor and not self._is_quad_view(context): return area, region = self._find_area_region_from_event(context, event) if area is None or region is None: return # In quad view, only the perspective pane should host the asset bar. Keep the # legacy "follow any quad" behavior here for potential reuse: # if self._is_quad_view(context) and self._cursor_inside_active_area(event): # return if self._is_quad_view(context) and not self._is_perspective_region(region): return # When follow is disabled, allow switching only within the same area; in quad # view this lets the bar appear in the hovered quad without jumping across # different 3D view areas. if not follow_cursor: active_area = self._validated_area(getattr(self, "_active_area_ref", None)) if ( active_area is not None and area.as_pointer() != active_area.as_pointer() ): return if self._cursor_inside_active_area(event): return self._switch_active_view(context, area, region) def _event_coords_in_active_region(self, event): region = self._validated_region(getattr(self, "_active_region_ref", None)) if region is not None: x, y = self._event_window_coords(event) if x is not None and y is not None: return x - region.x, y - region.y return getattr(event, "mouse_region_x", 0), getattr(event, "mouse_region_y", 0) @classmethod def description(cls, context, properties): """Get the description for the asset bar operator.""" return properties.tooltip def new_text(self, text, x, y, width=100, height=15, text_size=None, halign="LEFT"): """Create a new text label widget.""" label = BL_UI_Label(x, y, width, height) label.text = text if text_size is None: text_size = 14 label.text_size = text_size label.text_color = self.text_color label._halign = halign return label def new_duo_text( self, text_a, x, y, text_b="", width=100, height=15, text_size=None, halign="LEFT", ): """Create a new text label widget.""" label = BL_UI_DuoLabel(x, y, width, height) label.use_rounded_background = True label.background_corner_radius = "50%" label.background_padding = (4, 4) label.text_a = text_a label.text_b = text_b if text_size is None: text_size = 14 label.text_size = text_size label.text_a_color = self.text_color label.text_b_color = self.text_color label._halign = halign return label # region tooltip def init_tooltip(self): """Initialize the tooltip panel and its widgets.""" self.tooltip_widgets = [] self._tooltip_available_height = None if not hasattr(self, "tooltip_size"): self.tooltip_size = int(self.tooltip_base_size_pixels) self.tooltip_scale = getattr(self, "tooltip_scale", 1.0) # Fallbacks in case update_tooltip_size was not called yet self.tooltip_width = getattr(self, "tooltip_width", self.tooltip_size) image_height = getattr(self, "tooltip_image_height", self.tooltip_size) info_height = getattr( self, "tooltip_info_height", max( int(image_height * self.bottom_panel_fraction), self.asset_name_text_size * 3, ), ) self.tooltip_image_height = image_height self.tooltip_info_height = info_height self.tooltip_height = self.tooltip_image_height + self.tooltip_info_height self.labels_start = self.tooltip_image_height # total_size = tooltip# + 2 * self.margin self.tooltip_panel = BL_UI_Drag_Panel( 0, 0, self.tooltip_width, self.tooltip_height ) self.tooltip_panel.bg_color = (0.0, 0.0, 0.0, 0.5) self.tooltip_panel.use_rounded_background = True self.tooltip_panel.background_corner_radius = ROUNDING_RADIUS self.tooltip_panel.visible = False tooltip_image = BL_UI_Image(0, 0, 1, 1) img_path = paths.get_addon_thumbnail_path("thumbnail_notready.jpg") tooltip_image.set_image(img_path) tooltip_image.set_image_size((self.tooltip_width, self.tooltip_image_height)) tooltip_image.set_image_position((0, 0)) tooltip_image.set_image_colorspace("") tooltip_image.background = False tooltip_image.bg_color = (0.0, 0.0, 0.0, 0.0) tooltip_image.use_rounded_background = True tooltip_image.background_corner_radius = ( ROUNDING_RADIUS, ROUNDING_RADIUS, 0, 0, ) self.tooltip_image = tooltip_image self.tooltip_widgets.append(tooltip_image) tooltip_image_help = self.new_text( "Left click to append. Right click for menu.", self.tooltip_margin, self.tooltip_image_height - self.tooltip_margin - self.author_text_size, height=self.author_text_size, text_size=self.author_text_size, ) tooltip_image_help.text_color = (1.0, 0.6, 0.6, 0.9) tooltip_image_help.visible = False self.tooltip_image_help = tooltip_image_help self.tooltip_widgets.append(self.tooltip_image_help) dark_panel = BL_UI_Widget( 0, self.labels_start, self.tooltip_width, self.tooltip_info_height, ) dark_panel.bg_color = (0.0, 0.0, 0.0, 0.7) dark_panel.use_rounded_background = True dark_panel.background_corner_radius = (0, 0, ROUNDING_RADIUS, ROUNDING_RADIUS) self.tooltip_dark_panel = dark_panel self.tooltip_widgets.append(dark_panel) name_label = self.new_text( "", self.tooltip_margin, self.labels_start + self.tooltip_margin, height=self.asset_name_text_size, text_size=self.asset_name_text_size, ) self.asset_name = name_label self.tooltip_widgets.append(name_label) self.gravatar_size = max( int(self.tooltip_info_height - 2 * self.tooltip_margin), self.asset_name_text_size, ) authors_name = self.new_text( "author", self.tooltip_width - self.gravatar_size - self.tooltip_margin, self.tooltip_height - self.author_text_size - self.tooltip_margin, self.labels_start, height=self.author_text_size, text_size=self.author_text_size, halign="RIGHT", ) self.authors_name = authors_name self.tooltip_widgets.append(authors_name) gravatar_image = BL_UI_Image( self.tooltip_width - self.gravatar_size - self.tooltip_margin, self.tooltip_height - self.gravatar_size - self.tooltip_margin, 1, 1, ) img_path = paths.get_addon_thumbnail_path("thumbnail_notready.jpg") gravatar_image.set_image(img_path) gravatar_image.set_image_size( ( self.gravatar_size, self.gravatar_size, ) ) gravatar_image.set_image_position((0, 0)) gravatar_image.set_image_colorspace("") gravatar_image.background_corner_radius = ( 0, 0, ROUNDING_RADIUS / 2, 0, ) self.gravatar_image = gravatar_image self.tooltip_widgets.append(gravatar_image) quality_star = BL_UI_Image( self.tooltip_margin, self.tooltip_height - self.tooltip_margin - self.asset_name_text_size, 1, 1, ) img_path = paths.get_addon_thumbnail_path("star_grey.png") quality_star.set_image(img_path) quality_star.set_image_size( (self.asset_name_text_size, self.asset_name_text_size) ) quality_star.set_image_position((0, 0)) self.quality_star = quality_star self.tooltip_widgets.append(quality_star) quality_label = self.new_text( "", 2 * self.tooltip_margin + self.asset_name_text_size, self.tooltip_height - int(self.asset_name_text_size + self.tooltip_margin), height=self.asset_name_text_size, text_size=self.asset_name_text_size, ) self.tooltip_widgets.append(quality_label) self.quality_label = quality_label # Add user/base price label for addons multi_price_label = self.new_duo_text( "", self.tooltip_margin, self.tooltip_height - int(self.asset_name_text_size + 2 * self.tooltip_margin), height=self.asset_name_text_size, text_size=self.asset_name_text_size, ) multi_price_label.use_rounded_background = True multi_price_label.background_corner_radius = "50%" multi_price_label.text_a_color = colors.WHITE multi_price_label.text_b_color = colors.TEXT_DIM self.multi_price_label = multi_price_label self.tooltip_widgets.append(self.multi_price_label) # Author about-me label (multiline, used only for author-type results) author_about_me = self.new_text( "", self.tooltip_margin, self.labels_start + self.tooltip_margin + self.asset_name_text_size + self.tooltip_margin, height=self.author_text_size, text_size=self.author_text_size, ) author_about_me.multiline = True author_about_me.visible = False self.author_about_me = author_about_me self.tooltip_widgets.append(author_about_me) user_preferences = bpy.context.preferences.addons[__package__].preferences offset = 0 if ( user_preferences.asset_popup_counter < user_preferences.asset_popup_counter_max ) or utils.profile_is_validator(): # this is shown only to users who don't know yet about the popup card. label = self.new_text( "Right click for menu.", self.tooltip_margin, self.tooltip_height + self.tooltip_margin, height=self.author_text_size, text_size=self.author_text_size, ) self.tooltip_widgets.append(label) self.comments = label offset += 1 if utils.profile_is_validator(): label.multiline = True label.text = "No comments yet." # version warning version_warning = self.new_text( "", self.tooltip_margin, self.tooltip_height + self.tooltip_margin + int(self.author_text_size * offset), height=self.author_text_size, text_size=self.author_text_size, ) version_warning.text_color = self.warning_color self.tooltip_widgets.append(version_warning) self.version_warning = version_warning def hide_tooltip(self): """Hide the tooltip panel and its widgets.""" self.tooltip_panel.visible = False for w in self.tooltip_widgets: w.visible = False self._redraw_tracked_regions() def show_tooltip(self): """Show the tooltip panel and its widgets.""" self.tooltip_panel.visible = True self.tooltip_panel.active = False for w in self.tooltip_widgets: w.visible = True self._redraw_tracked_regions() def _reset_tooltip_dimensions(self): """Restore tooltip scale and panel size before recomputing layout. Prevents a previously downscaled tooltip from keeping a shrunken size on subsequent openings (e.g. after tight vertical space). """ ui_scale = ui_bgl.get_ui_scale() self.tooltip_scale = ui_scale self._tooltip_available_height = None base_size = int(self.tooltip_base_size_pixels * ui_scale) base_height = int(base_size * (1 + self.bottom_panel_fraction)) self.tooltip_size = base_size self.tooltip_height = base_height if hasattr(self, "tooltip_panel"): self.tooltip_panel.width = base_size self.tooltip_panel.height = base_height def update_tooltip_size(self, context): """Calculate all important sizes for the tooltip""" region = context.region ui_props = bpy.context.window_manager.blenderkitUI ui_scale = ui_bgl.get_ui_scale() desired_size = int(self.tooltip_base_size_pixels * ui_scale) desired_full_height = int(desired_size * (1 + self.bottom_panel_fraction)) available_height = getattr(self, "_tooltip_available_height", None) if available_height is None: tooltip_panel = getattr(self, "tooltip_panel", None) anchor_y = None if tooltip_panel is not None: anchor_y = getattr(tooltip_panel, "y_screen", None) if anchor_y is None: anchor_y = self.bar_y + self.bar_height anchor_y = max(-region.height, min(region.height, anchor_y)) if anchor_y < 0: available_height = anchor_y + region.height else: available_height = region.height - anchor_y available_height = max(64, int(available_height)) if desired_full_height > available_height: scale_factor = available_height / max(desired_full_height, 1) else: scale_factor = 1.0 final_size = max(32, int(desired_size * scale_factor)) self.tooltip_scale = final_size / self.tooltip_base_size_pixels self.tooltip_size = final_size self.asset_name_text_size = int( 0.039 * self.tooltip_base_size_pixels * self.tooltip_scale ) self.author_text_size = int(self.asset_name_text_size * 0.8) self.tooltip_margin = int( 0.017 * self.tooltip_base_size_pixels * self.tooltip_scale ) # Check if the active asset is an author for tooltip sizing self._is_author_tooltip = False active_idx = getattr(self, "active_index", -1) sr = search.get_search_results() if sr and 0 <= active_idx < len(sr): self._is_author_tooltip = sr[active_idx].get("assetType") == "author" if ui_props.asset_type == "HDR": self.tooltip_width = self.tooltip_size * 2 self.tooltip_image_height = self.tooltip_size else: self.tooltip_width = self.tooltip_size self.tooltip_image_height = self.tooltip_size if self._is_author_tooltip: # Author tooltip: sized to actual content (name + about lines) # Count how many about_me lines we'll actually show about_line_count = 0 active_idx = getattr(self, "active_index", -1) if sr and 0 <= active_idx < len(sr): ad = sr[active_idx] td = ad.get("tooltip_data") if td: about_me = td.get("about_me", "") if about_me: chars_per_line = max( 20, int(self.tooltip_size / (self.author_text_size * 0.65)) ) wrapped = 0 for paragraph in about_me.split("\n"): words = paragraph.split() cur = "" for word in words: if cur and len(cur) + 1 + len(word) > chars_per_line: wrapped += 1 cur = word else: cur = f"{cur} {word}" if cur else word if cur: wrapped += 1 about_line_count += min(wrapped, 8) self.tooltip_info_height = ( 4 * self.tooltip_margin + self.asset_name_text_size + max(1, about_line_count) * int(self.author_text_size * 1.4) ) else: self.tooltip_info_height = max( int(self.tooltip_image_height * self.bottom_panel_fraction), self.asset_name_text_size * 3, ) self.labels_start = self.tooltip_image_height self.comments_text_size = max( 15, int(0.034 * self.tooltip_base_size_pixels * self.tooltip_scale), int(self.author_text_size), ) self.tooltip_height = self.tooltip_image_height + self.tooltip_info_height self.gravatar_size = max( int(self.tooltip_info_height - 2 * self.tooltip_margin), self.asset_name_text_size, ) self._tooltip_available_height = None def update_tooltip_layout(self, context): """Update the layout of the tooltip""" # update Tooltip size /scale for HDR or if area too small self.tooltip_panel.width = self.tooltip_width self.tooltip_panel.height = self.tooltip_height self.tooltip_image_help.set_location( self.tooltip_margin, self.tooltip_image_height - self.tooltip_margin - self.author_text_size, ) self.tooltip_image_help.text_size = self.author_text_size self.tooltip_image.width = self.tooltip_width self.tooltip_image.height = self.tooltip_image_height self.labels_start = self.tooltip_image_height self.tooltip_image.set_image_size( (self.tooltip_width, self.tooltip_image_height) ) self.tooltip_image.set_location(0, 0) self.gravatar_image.set_location( self.tooltip_width - self.gravatar_size - self.tooltip_margin, self.tooltip_height - self.gravatar_size - self.tooltip_margin, ) self.gravatar_image.set_image_size( ( self.gravatar_size, self.gravatar_size, ) ) if self._is_author_tooltip: # Asset count right-aligned on the same row as the author name self.authors_name.set_location( self.tooltip_width - self.tooltip_margin, self.labels_start + self.tooltip_margin, ) self.authors_name.text_size = self.asset_name_text_size self.authors_name.height = self.asset_name_text_size else: self.authors_name.set_location( self.tooltip_width - self.gravatar_size - (self.tooltip_margin * 2), self.tooltip_height - self.author_text_size - self.tooltip_margin, ) self.authors_name.text_size = self.author_text_size self.authors_name.height = self.author_text_size self.asset_name.set_location( self.tooltip_margin, self.labels_start + self.tooltip_margin, ) self.asset_name.text_size = self.asset_name_text_size self.asset_name.height = self.asset_name_text_size self.tooltip_dark_panel.set_location( 0, self.labels_start, ) self.tooltip_dark_panel.height = self.tooltip_info_height self.tooltip_dark_panel.width = self.tooltip_width self.quality_label.set_location( 2 * self.tooltip_margin + self.asset_name_text_size, self.tooltip_height - int(self.asset_name_text_size + self.tooltip_margin), ) self.quality_label.text_size = self.asset_name_text_size self.quality_label.height = self.asset_name_text_size self.quality_star.set_location( self.tooltip_margin, self.tooltip_height - self.tooltip_margin - self.asset_name_text_size, ) self.quality_star.set_image_size( (self.asset_name_text_size, self.asset_name_text_size) ) # right after the asset name self.multi_price_label.set_location( self.tooltip_margin, self.labels_start + (self.tooltip_margin * 3) + self.asset_name.height, ) self.multi_price_label.width = self.tooltip_width - 2 * self.tooltip_margin self.multi_price_label.height = self.asset_name_text_size self.multi_price_label.text_size = self.asset_name_text_size # Author about-me label: positioned directly below asset name if hasattr(self, "author_about_me"): about_y = ( self.labels_start + self.tooltip_margin + self.asset_name_text_size + self.tooltip_margin ) self.author_about_me.set_location( self.tooltip_margin, about_y, ) self.author_about_me.text_size = self.author_text_size self.author_about_me.height = self.author_text_size self.author_about_me.width = self.tooltip_width - 2 * self.tooltip_margin if hasattr(self, "comments"): self.comments.set_location( self.tooltip_margin, self.tooltip_height + self.tooltip_margin, ) self.comments.text_size = self.comments_text_size def update_tooltip_image(self, asset_id): """Update tooltip image when it finishes downloading and the downloaded image matches the active one.""" search_results = search.get_search_results() if search_results is None: return if self.active_index == -1: # prev search got no results return if self.active_index >= len(search_results): return asset_data = search_results[self.active_index] if asset_data["assetBaseId"] == asset_id: set_thumb_check(self.tooltip_image, asset_data, thumb_type="thumbnail") def update_comments_for_validators(self, asset_data): """Update the comments section in the tooltip for validator profiles.""" if not utils.profile_is_validator(): return if asset_data.get("assetType") == "author": self.comments.text = "" return comments = global_vars.DATA.get("asset comments", {}) comments = comments.get(asset_data["assetBaseId"], []) comment_text = "No comments yet." if comments is not None: comment_text = "" # iterate comments from last to first for comment in reversed(comments): comment_text += f"{comment['userName']}:\n" # strip urls and stuff comment_lines = comment["comment"].split("\n") for line in comment_lines: urls, text = utils.has_url(line) if urls: comment_text += f"{text}{urls[0][0]}\n" else: comment_text += f"{text}\n" comment_text += "\n" self.comments.text = comment_text # endregion tooltip # region panel def asset_button_init(self, asset_x, asset_y, button_idx): """Initialize an asset button at the given position with the given index.""" button_bg_color = (0.2, 0.2, 0.2, 0.1) button_hover_color = (0.8, 0.8, 0.8, 0.2) fully_transparent_color = (0.2, 0.2, 0.2, 0.0) new_button = BL_UI_Button(asset_x, asset_y, self.button_size, self.button_size) new_button.bg_color = button_bg_color new_button.hover_bg_color = button_hover_color new_button.text = "" # asset_data['name'] new_button.set_image_size((self.thumb_size, self.thumb_size)) new_button.set_image_position((self.button_margin, self.button_margin)) new_button.button_index = button_idx new_button.search_index = button_idx new_button.set_mouse_down(self.drag_drop_asset) new_button.set_mouse_down_right(self.asset_menu) new_button.set_mouse_enter(self.enter_button) new_button.set_mouse_exit(self.exit_button) new_button.text_input = self.handle_key_input # add validation icon to button validation_icon = BL_UI_Image( asset_x + self.button_size - self.icon_size - self.button_margin - self.validation_icon_margin, asset_y + self.button_size - self.icon_size - self.button_margin - self.validation_icon_margin, 0, 0, ) validation_icon.set_image_size((self.icon_size, self.icon_size)) validation_icon.set_image_position((0, 0)) self.validation_icons.append(validation_icon) new_button.validation_icon = validation_icon bookmark_button = BL_UI_Button( asset_x + self.button_size - self.icon_size - self.button_margin - self.validation_icon_margin, asset_y + self.button_margin + self.validation_icon_margin, self.icon_size, self.icon_size, ) bookmark_button.set_image_size((self.icon_size, self.icon_size)) bookmark_button.set_image_position((0, 0)) bookmark_button.button_index = button_idx bookmark_button.search_index = button_idx bookmark_button.text = "" bookmark_button.set_mouse_down(self.bookmark_asset) img_fp = paths.get_addon_thumbnail_path("bookmark_empty.png") bookmark_button.set_image(img_fp) bookmark_button.bg_color = fully_transparent_color bookmark_button.hover_bg_color = self.button_bg_color bookmark_button.select_bg_color = fully_transparent_color bookmark_button.visible = False new_button.bookmark_button = bookmark_button self.bookmark_buttons.append(bookmark_button) # Author type icon (top-left corner) author_button = BL_UI_Button( asset_x + self.button_margin + self.validation_icon_margin, asset_y + self.button_margin + self.validation_icon_margin, self.icon_size, self.icon_size, ) author_button.set_image_size((self.icon_size, self.icon_size)) author_button.set_image_position((0, 0)) img_fp = paths.get_addon_thumbnail_path("asset_type_author.png") author_button.set_image(img_fp) author_button.bg_color = fully_transparent_color author_button.hover_bg_color = self.button_bg_color author_button.select_bg_color = fully_transparent_color author_button.visible = False author_button.button_index = button_idx author_button.search_index = button_idx author_button.text = "" author_button.set_mouse_down(self.show_author_profile) new_button.author_button = author_button self.author_buttons.append(author_button) progress_bar = BL_UI_Widget( asset_x, asset_y + self.button_size - 6, self.button_size, 6 ) progress_bar.bg_color = (0.0, 1.0, 0.0, 0.3) new_button.progress_bar = progress_bar self.progress_bars.append(progress_bar) if utils.profile_is_validator(): red_alert = BL_UI_Widget( asset_x - self.validation_icon_margin, asset_y - self.validation_icon_margin, self.button_size + 2 * self.validation_icon_margin, self.button_size + 2 * self.validation_icon_margin, ) red_alert.bg_color = (1.0, 0.0, 0.0, 0.0) red_alert.visible = False red_alert.active = False new_button.red_alert = red_alert self.red_alerts.append(red_alert) return new_button def init_ui(self): """Initialize the asset bar UI and its widgets.""" self.button_bg_color = (0.2, 0.2, 0.2, 1.0) self.button_hover_color = (0.8, 0.8, 0.8, 1.0) self.button_selected_color = (0.5, 0.5, 0.5, 1.0) self.button_selected_color_dim = (0.3, 0.3, 0.3, 1.0) self.buttons = [] self.asset_buttons = [] self.validation_icons = [] self.bookmark_buttons = [] self.author_buttons = [] self.progress_bars = [] self.red_alerts = [] self.widgets_panel = [] self.tab_buttons = [] self.close_tab_buttons = [] self.active_filter_buttons = [] self.manufacturer_buttons = [] # Create panel with extended height self.panel = BL_UI_Drag_Panel( 0, 0, self.bar_width, self.bar_height, # Use total height including tabs ) self.panel.bg_color = (0.0, 0.0, 0.0, 0.9) # Create tab area background self.tab_area_bg = BL_UI_Widget( 0, # x position will be set in update_assetbar_layout -self.other_button_size, # Position at top where tabs are self.bar_width, # Same width as asset bar self.other_button_size, # Same height as tab buttons ) # dark blue self.tab_area_bg.bg_color = colors.TOP_BAR_BLUE self.tab_area_bg.use_rounded_background = True self.tab_area_bg.background_corner_radius = ( ROUNDING_RADIUS, ROUNDING_RADIUS, 0, 0, ) # Add widgets to panel - add tab background first so it's behind everything self.widgets_panel.append(self.tab_area_bg) # we init max possible buttons. button_idx = 0 for x in range(0, self.max_wcount): for y in range(0, self.max_hcount): # asset_x = self.assetbar_margin + a * (self.button_size) # asset_y = self.assetbar_margin + b * (self.button_size) # button_idx = x + y * self.max_wcount asset_idx = button_idx + self.scroll_offset # if asset_idx < len(sr): new_button = self.asset_button_init(0, 0, button_idx) new_button.asset_index = asset_idx self.asset_buttons.append(new_button) button_idx += 1 # add close button self.button_close = BL_UI_Button( self.bar_width - self.other_button_size, -self.other_button_size, self.other_button_size, self.other_button_size, ) self.button_close.bg_color = self.button_bg_color self.button_close.hover_bg_color = self.button_hover_color self.button_close.use_rounded_background = True self.button_close.background_corner_radius = ( 0, ROUNDING_RADIUS, 0, 0, ) self.button_close.text = "×" self.button_close.text_size = self.other_button_size * 0.8 self.button_close.set_image_position((0, 0)) self.button_close.set_image_size( (self.other_button_size, self.other_button_size) ) self.button_close.set_mouse_down(self.cancel_press) self.widgets_panel.append(self.button_close) # Expand/collapse button (positioned at bottom of assetbar) self.button_expand = BL_UI_Button( self.bar_width - self.other_button_size, self.bar_height, self.other_button_size, self.other_button_size, ) self.button_expand.bg_color = self.button_bg_color self.button_expand.hover_bg_color = self.button_hover_color self.button_expand.text = "" self.button_expand.text_size = self.other_button_size * 0.8 self.button_expand.use_rounded_background = True self.button_expand.background_corner_radius = ( 0, 0, ROUNDING_RADIUS, ROUNDING_RADIUS, ) self.button_expand.set_image_position((0, 0)) self.button_expand.set_image_size( (self.other_button_size, self.other_button_size) ) self.button_expand.set_mouse_down(self.toggle_expand) self.widgets_panel.append(self.button_expand) self.scroll_width = 30 self.button_scroll_down = BL_UI_Button( -self.scroll_width, 0, self.scroll_width, self.bar_height ) self.button_scroll_down.bg_color = self.button_bg_color self.button_scroll_down.hover_bg_color = self.button_hover_color self.button_scroll_down.use_rounded_background = True self.button_scroll_down.background_corner_radius = ( ROUNDING_RADIUS, 0, 0, ROUNDING_RADIUS, ) self.button_scroll_down.text = "" self.button_scroll_down.set_image_size((self.scroll_width, self.button_size)) self.button_scroll_down.set_image_position( (0, int((self.bar_height - self.button_size) / 2)) ) self.button_scroll_down.set_mouse_down(self.scroll_down) self.widgets_panel.append(self.button_scroll_down) self.button_scroll_up = BL_UI_Button( self.bar_width, 0, self.scroll_width, self.bar_height ) self.button_scroll_up.bg_color = self.button_bg_color self.button_scroll_up.hover_bg_color = self.button_hover_color self.button_scroll_up.use_rounded_background = True self.button_scroll_up.background_corner_radius = ( 0, ROUNDING_RADIUS, ROUNDING_RADIUS, 0, ) self.button_scroll_up.text = "" self.button_scroll_up.set_image_size((self.scroll_width, self.button_size)) self.button_scroll_up.set_image_position( (0, int((self.bar_height - self.button_size) / 2)) ) self.button_scroll_up.set_mouse_down(self.scroll_up) self.widgets_panel.append(self.button_scroll_up) # Add tab navigation elements button_size = self.other_button_size margin = int(button_size * 0.05) space = int(button_size * 0.4) tab_icon_size = int(button_size * 0.7) # Size for the asset type icon tab_width = ( button_size * 4 + tab_icon_size ) # Widen the tabs to accommodate type icon # Back/Forward history buttons self.history_back_button = BL_UI_Button( margin, -button_size, button_size, button_size ) self.history_back_button.bg_color = self.button_bg_color self.history_back_button.hover_bg_color = self.button_hover_color self.history_back_button.use_rounded_background = True self.history_back_button.background_corner_radius = ( ROUNDING_RADIUS, ROUNDING_RADIUS, 0, 0, ) self.history_back_button.text = "" icon_size = int(button_size * 0.6) margin_lr = int((button_size - icon_size) / 2) self.history_back_button.set_image( paths.get_addon_thumbnail_path("history_back.png") ) self.history_back_button.set_image_size((icon_size, icon_size)) self.history_back_button.set_image_position((margin_lr, margin_lr)) self.history_forward_button = BL_UI_Button( margin * 2 + button_size, -button_size, button_size, button_size, ) self.history_forward_button.bg_color = self.button_bg_color self.history_forward_button.hover_bg_color = self.button_hover_color self.history_forward_button.use_rounded_background = True self.history_forward_button.background_corner_radius = ( ROUNDING_RADIUS, ROUNDING_RADIUS, 0, 0, ) self.history_forward_button.text = "" self.history_forward_button.set_image( paths.get_addon_thumbnail_path("history_forward.png") ) self.history_forward_button.set_image_size((icon_size, icon_size)) self.history_forward_button.set_image_position((margin_lr, margin_lr)) # Tab buttons tabs = global_vars.TABS["tabs"] tab_x_start = margin * 4 + button_size * 3 # Starting x position of first tab tabs_end_x = 0 for i, tab in enumerate(tabs): is_active = i == global_vars.TABS["active_tab"] # Calculate positions tab_x = tab_x_start + i * ( tab_width + button_size + margin + space ) # Space for tab and close button # Tab button tab_button = BL_UI_Button( tab_x, # Position with spacing for close buttons -button_size, tab_width, # Width of tab button_size, ) tab_button.hover_bg_color = self.button_hover_color tab_button.text = tab["name"] tab_button.text_size = button_size * 0.5 tab_button.text_color = self.text_color tab_button.bg_color = self.button_bg_color tab_button.use_rounded_background = True tab_button.background_padding = (margin, 0) # extra margin tab_button.background_corner_radius = ( ROUNDING_RADIUS, 0, 0, 0, ) if is_active: tab_button.bg_color = self.button_selected_color tab_button.tab_index = i # Store tab index tab_button.set_mouse_down(self.switch_tab) # Add click handler self.tab_buttons.append(tab_button) # Set asset type icon as tab button image tab_button.set_image_size((tab_icon_size, tab_icon_size)) tab_button.set_image_position( (margin * 2, (button_size - tab_icon_size) / 2) ) # Center vertically # Only create close button if there's more than one tab close_x = tab_x + tab_width + margin # Position right after tab close_tab = BL_UI_Button( close_x, -button_size, button_size, button_size, ) close_tab.bg_color = self.button_bg_color # slightly red close_tab.hover_bg_color = (0.8, 0.0, 0.0, 0.2) close_tab.text = "×" # Set text after creation close_tab.text_size = button_size * 0.8 close_tab.text_color = self.text_color close_tab.use_rounded_background = True close_tab.background_corner_radius = (0, ROUNDING_RADIUS, 0, 0) if is_active: close_tab.bg_color = self.button_selected_color_dim close_tab.tab_index = i # Store tab index # if there's only one tab, the button closes asset bar instead of closing tab if len(tabs) > 1: close_tab.set_mouse_down(self.remove_tab) # Add click handler else: close_tab.set_mouse_down(self.cancel_press) self.close_tab_buttons.append(close_tab) tabs_end_x = close_x + button_size # New tab button - position after all tabs and close buttons if len(tabs) > 0: new_tab_x = ( space + tabs_end_x + margin * 2 ) # After last tab and its close button else: new_tab_x = tab_x_start # If no tabs, start at the beginning # if too close to the right side, let's not create this button if new_tab_x + button_size < self.bar_width: self.new_tab_button = BL_UI_Button( new_tab_x, -button_size, button_size, button_size, ) # Change from default button color to slightly green self.new_tab_button.bg_color = (0.2, 0.5, 0.2, 1.0) # Green tint # Slightly lighter green on hover self.new_tab_button.hover_bg_color = (0.3, 0.7, 0.3, 0.5) self.new_tab_button.text = "+" self.new_tab_button.text_size = button_size * 0.8 self.new_tab_button.text_color = self.text_color self.new_tab_button.use_rounded_background = True self.new_tab_button.background_corner_radius = ROUNDING_RADIUS self.new_tab_button.set_mouse_down(self.add_new_tab) self.widgets_panel.append(self.new_tab_button) # Then add all other widgets self.widgets_panel.extend( [ self.history_back_button, self.history_forward_button, ] ) self.widgets_panel.extend(self.tab_buttons) self.widgets_panel.extend(self.close_tab_buttons) # Back/Forward history buttons self.history_back_button.set_mouse_down(self.history_back) self.history_forward_button.set_mouse_down(self.history_forward) # Set initial visibility based on history active_tab = global_vars.TABS["tabs"][global_vars.TABS["active_tab"]] self.history_back_button.visible = active_tab["history_index"] > 0 self.history_forward_button.visible = ( active_tab["history_index"] < len(active_tab["history"]) - 1 ) # Manufacturer filter buttons (stay hidden until populated) # Active filter chips (generic, per tab) for _ in range(self.max_active_filter_chips): chip_button = BL_UI_Button(0, 0, 100, self.filter_button_height) chip_button.bg_color = (0.18, 0.18, 0.18, 0.9) chip_button.hover_bg_color = (0.22, 0.22, 0.22, 1.0) chip_button.text = "" chip_button.text_size = self.filter_button_text_size chip_button.text_color = self.text_color chip_button.visible = False chip_button.use_rounded_background = True chip_button.background_corner_radius = "50%" chip_button.background_border = True chip_button.background_border_color = colors.ACTIVE_BLUE chip_button.background_border_thickness = 2 chip_button.set_mouse_down(self.remove_active_filter_chip) chip_button.active_filter = None self.active_filter_buttons.append(chip_button) self.widgets_panel.extend(self.active_filter_buttons) # Manufacturer filter buttons (stay hidden until populated) for _ in range(self.max_manufacturer_filters): filter_button = BL_UI_Button(0, 0, 80, self.filter_button_height) # start with a neutral gray; exact shade is updated when buttons are positioned base_gray = 50 / 255 filter_button.bg_color = (base_gray, base_gray, base_gray, 0.85) filter_button.hover_bg_color = ( base_gray + 0.1, base_gray + 0.1, base_gray + 0.1, 1.0, ) filter_button.text = "" filter_button.text_size = self.filter_button_text_size filter_button.text_color = self.text_color filter_button.visible = False filter_button.use_rounded_background = True filter_button.background_corner_radius = "50%" filter_button.set_mouse_down( partial(self.apply_term_filter, term="manufacturer") ) filter_button.manufacturer_name = "" self.manufacturer_buttons.append(filter_button) # Clear manufacturer filter bubble (red cross) self.widgets_panel.extend(self.manufacturer_buttons) # endregion panel def show_notifications(self, widget): """Show notifications on the asset bar.""" bpy.ops.wm.show_notifications() if comments_utils.check_notifications_read(): widget.visible = False # region checks def check_new_search_results(self, context): """checks if results were replaced. this can happen from search, but also by switching results. We should rather trigger that update from search. maybe let's add a uuid to the results? """ # Get search results from history sr = search.get_search_results() current_id = id(sr) if sr is not None else None if not hasattr(self, "search_results_count"): self.search_results_count = len(sr) if sr else 0 self.last_asset_type = sr[0]["assetType"] if sr else "" self._last_search_results_id = current_id return True if current_id != getattr(self, "_last_search_results_id", None): self._last_search_results_id = current_id self.search_results_count = len(sr) if sr else 0 if sr: self.last_asset_type = sr[0]["assetType"] return True if sr is not None and len(sr) != self.search_results_count: self.search_results_count = len(sr) return True return False def get_region_size(self, context): """Get the size of the region.""" # just check the size of region.. region = context.region area = context.area ui_width = 0 tools_width = 0 for r in area.regions: if r.type == "UI": ui_width = r.width if r.type == "TOOLS": tools_width = r.width total_width = region.width - tools_width - ui_width return total_width, region.height def check_region_changed(self, context): """Check if the region has changed.""" region_width, region_height = self.get_region_size(context) if not hasattr(self, "total_width"): self.total_width = region_width self.region_height = region_height changed = ( region_height != self.region_height or region_width != self.total_width ) if changed: self.region_height = region_height self.total_width = region_width return True return False def check_ui_resized(self, context): """Check if the UI has been resized.""" scaling = ui_bgl.get_ui_scale() if not hasattr(self, "_ui_scale_state"): self._ui_scale_state = scaling scale_changed = scaling != self._ui_scale_state if scale_changed: self._ui_scale_state = scaling return True return False # endregion checks # region updates def update_assetbar_sizes(self, context): """Calculate all important sizes for the asset bar""" region = context.region area = context.area ui_props = bpy.context.window_manager.blenderkitUI user_preferences = bpy.context.preferences.addons[__package__].preferences scale = ui_bgl.get_ui_scale() self._ui_scale_factor = scale # assetbar sizing (fixed, not scaled) self.button_margin = int(round(0 * scale)) self.assetbar_margin = int(round(2 * scale)) # user preference thumb size is in logical pixels; scale to match Blender UI scaling self.thumb_size = int(round(user_preferences.thumb_size * scale)) self.button_size = int(2 * self.button_margin + self.thumb_size) self.other_button_size = int(round(30 * scale)) self.filter_button_height = int(round(25 * scale)) self.filter_button_text_size = int(round(20 * scale)) self.free_button_margin = int(self.button_size * 0.05) self.free_button_text_size = int(self.other_button_size * 0.4) self.icon_size = int(round(24 * scale)) self.validation_icon_margin = int(round(3 * scale)) reg_multiplier = 1 if not bpy.context.preferences.system.use_region_overlap: reg_multiplier = 0 ui_width = 0 tools_width = 0 reg_multiplier = 1 if not bpy.context.preferences.system.use_region_overlap: reg_multiplier = 0 for r in area.regions: if r.type == "UI": ui_width = r.width * reg_multiplier if r.type == "TOOLS": tools_width = r.width * reg_multiplier self.bar_x = int( tools_width + self.button_margin + ui_props.bar_x_offset * scale ) base_bar_y = int(self.button_margin + ui_props.bar_y_offset * scale) self.bar_y = base_bar_y self.bar_end = int(ui_width + 180 + self.other_button_size) self.bar_width = int(region.width - self.bar_x - self.bar_end) # Quad view and very small regions can shrink the available width below a single # thumbnail. Keep the bar wide enough to host at least one column and keep the # math stable so the buttons do not disappear entirely. self.bar_width = max(1, self.bar_width) effective_bar_width = max(self.bar_width, self.button_size) self.wcount = max(1, math.floor(effective_bar_width / self.button_size)) self.max_hcount = math.floor( max(region.width, context.window.width) / self.button_size ) self.max_wcount = user_preferences.maximized_assetbar_rows history_step = search.get_active_history_step() search_results = history_step.get("search_results") self.position_active_filter_buttons() bubble_offset = 0 if self.active_filter_height: bubble_offset = self.active_filter_height self.bar_y = base_bar_y + bubble_offset # we need to init all possible thumb previews in advance/ # Calculate hcount based on expanded state if search_results is not None and self.wcount > 0: if user_preferences.assetbar_expanded: max_rows = user_preferences.maximized_assetbar_rows available_height = ( region.height - self.bar_y - 2 * self.assetbar_margin - self.other_button_size ) max_rows_by_height = math.floor(available_height / self.button_size) max_rows = ( min(max_rows, max_rows_by_height) if max_rows_by_height > 0 else 1 ) else: max_rows = 1 self.hcount = min( max_rows, math.ceil(len(search_results) / self.wcount), ) self.hcount = max(self.hcount, 1) else: self.hcount = 1 self.base_bar_height = self.button_size * self.hcount + 2 * self.assetbar_margin self._update_manufacturer_data(search_results) self.bar_height = self.base_bar_height + self.manufacturer_section_height if ui_props.down_up == "UPLOAD": self.reports_y = region.height - self.bar_y - 600 ui_props.reports_y = region.height - self.bar_y - 600 self.reports_x = self.bar_x ui_props.reports_x = self.bar_x else: # ui.bar_y - ui.bar_height - 100 self.reports_y = region.height - self.bar_y - self.bar_height - 50 ui_props.reports_y = int(region.height - self.bar_y - self.bar_height - 50) self.reports_x = self.bar_x ui_props.reports_x = self.bar_x def update_ui_size(self, context): """Calculate all important sizes for the asset bar and tooltip""" self._refresh_layout(context) def update_assetbar_layout(self, context): """Update the layout of the asset bar""" self.scroll_update(always=True) self.position_and_hide_buttons() self.button_close.set_location( self.bar_width - self.other_button_size, -self.other_button_size ) self.button_scroll_up.set_location(self.bar_width, 0) self.panel.width = self.bar_width self.panel.height = self.bar_height # Update tab area background position self.tab_area_bg.width = self.bar_width self.panel.set_location(self.bar_x, self.bar_y) self.position_manufacturer_buttons() def update_layout(self, context, event): """update UI sizes after their recalculation""" self.update_assetbar_layout(context) self.update_tooltip_layout(context) def _is_quad_view(self, context): """Return True when the current 3D view runs in quad-view layout.""" space_data = getattr(context, "space_data", None) if not space_data or getattr(space_data, "type", "") != "VIEW_3D": return False quadviews = getattr(space_data, "region_quadviews", None) if quadviews is None: return False try: return len(quadviews) > 0 except TypeError: return bool(quadviews) def _is_perspective_region(self, region): """Return True if the given region is a perspective (or camera) view.""" r3d = getattr(region, "data", None) or getattr(region, "regiondata", None) if r3d is None: return False return getattr(r3d, "view_perspective", "") in {"PERSP", "CAMERA"} def set_element_images(self): """set ui elements images, has to be done after init of UI.""" # img_fp = paths.get_addon_thumbnail_path("vs_rejected.png") # self.button_close.set_image(img_fp) self.button_scroll_down.set_image( paths.get_addon_thumbnail_path("arrow_left.png") ) self.button_scroll_up.set_image( paths.get_addon_thumbnail_path("arrow_right.png") ) # if not comments_utils.check_notifications_read(): # img_fp = paths.get_addon_thumbnail_path('bell.png') # self.button_notifications.set_image(img_fp) # Update tab icons self.update_tab_icons() # Update expand button icon self.update_expand_button_icon() def update_tab_icons(self): """Update tab icons based on the active history step's asset type""" tabs = global_vars.TABS["tabs"] for i, tab_button in enumerate(self.tab_buttons): if i >= len(tabs): continue tab = tabs[i] history_index = tab["history_index"] if history_index >= 0 and history_index < len(tab["history"]): history_step = tab["history"][history_index] ui_state = history_step.get("ui_state", {}) ui_props = ui_state.get("ui_props", {}) asset_type = ui_props.get("asset_type", "").lower() # Set the icon based on asset type if asset_type: icon_path = paths.get_addon_thumbnail_path( f"asset_type_{asset_type}.png" ) if not paths.icon_path_exists(icon_path): icon_path = paths.get_addon_thumbnail_path( "asset_type_model.png" ) tab_button.set_image(icon_path) tab_button.set_image_colorspace("") def update_expand_button_icon(self): """Update expand button icon based on current expanded state.""" user_preferences = bpy.context.preferences.addons[__package__].preferences if user_preferences.assetbar_expanded: # Show up arrow when expanded (to collapse) self.button_expand.text = "▲" else: # Show down arrow when collapsed (to expand) self.button_expand.text = "▼" # region active filters def position_active_filter_buttons(self): self.active_filter_height = 0 if not self.active_filter_buttons: return active_filters = search.get_active_filters() raw_available = self.bar_width - 2 * self.assetbar_margin if not active_filters or raw_available <= 0: for button in self.active_filter_buttons: button.visible = False button.active_filter = None return max_x = self.bar_width - self.assetbar_margin current_left_offset = ( self.bar_x + self.assetbar_margin + self.free_button_margin ) current_row = 0 # Keep chips below the toolbar but above the asset bar when space is tight base_y = -( self.other_button_size + self.filter_button_height + (self.free_button_margin * 2) ) for idx in range(self.max_active_filter_chips): button = self.active_filter_buttons[idx] if idx >= len(active_filters): button.visible = False break flt = active_filters[idx] term = flt.get("term", "") value = flt.get("value", "") label_source = flt.get("label") or value label = f"{label_source} ×" width = ( ui_bgl.get_text_size( font_id=1, text=label, text_size=self.free_button_text_size, )[0] + self.free_button_margin * 4 ) # Pre-calculate icon space for author chips if term == "author_id": icon_size = int(self.filter_button_height * 0.7) icon_pad = int((self.filter_button_height - icon_size) / 2) icon_space = icon_size + icon_pad * 2 width += icon_space img_fp = paths.get_addon_thumbnail_path("asset_type_author.png") button.set_image(img_fp) button.set_image_size((icon_size, icon_size)) button.set_image_position((icon_pad, icon_pad)) else: button.set_image(None) button.set_image_size((0, 0)) if current_left_offset + width > max_x: current_row += 1 current_left_offset = ( self.bar_x + self.assetbar_margin + self.free_button_margin ) button.set_location( current_left_offset, ( base_y - ( current_row * (self.filter_button_height + self.free_button_margin) ) ), # offset up from bar ) button.width = width button.height = self.filter_button_height button.text = label button.text_size = self.free_button_text_size button.visible = True button.active_filter = {"term": term, "value": value} current_left_offset += width + (self.free_button_margin * 2) self.active_filter_height = (current_row + 1) * ( self.filter_button_height + self.free_button_margin ) # endregion active filters # region manufacturer def _extract_manufacturer_name(self, asset_data): manufacturer = asset_data.get("dictParameters", {}).get("manufacturer") if not manufacturer: return "" return self._sanitize_manufacturer_name(str(manufacturer)) or "" def _sanitize_manufacturer_name(self, name: str) -> Optional[str]: cleaned = name.strip() if not cleaned: return None lowered = cleaned.lower() is_url = lowered.startswith(("http://", "https://", "www.")) or "://" in lowered if is_url: return None tokens = re.split(r"[\s,\\/|_-]+", lowered) invalid_tokens = {"me", "unknown", "self", "none", "n/a", "na", "null", "nil"} if any(t in invalid_tokens for t in tokens if t): return None # also use regex to disqualify names without any letters, or with only special characters if not re.search(r"[a-zA-Z]", cleaned): return None return cleaned def _format_manufacturer_label(self, name: str) -> str: if len(name) <= MAX_MANUFACTURER_LABEL_LEN: return name return name[: MAX_MANUFACTURER_LABEL_LEN - 3].rstrip() + "..." def _refresh_manufacturer_names(self, search_results): if not search_results: self.manufacturer_names = [] self.manufacturer_counts = Counter() return # Case-insensitive grouping: count by lowercase key, display first-seen casing counts_lower: Counter = Counter() display_names: dict = {} for asset_data in search_results: name = self._extract_manufacturer_name(asset_data) if name: key = name.lower() counts_lower[key] += 1 if key not in display_names: display_names[key] = name most_common = counts_lower.most_common(self.max_manufacturer_filters) self.manufacturer_names = [display_names[key] for key, _ in most_common] self.manufacturer_counts = Counter( {display_names[k]: v for k, v in counts_lower.items()} ) def _update_manufacturer_data(self, search_results: Optional[list[dict]] = None): if not utils.experimental_enabled(): self.manufacturer_names = [] self.manufacturer_counts = Counter() self.manufacturer_section_height = 0 for btn in self.manufacturer_buttons: btn.visible = False return self._refresh_manufacturer_names(search_results) self.position_manufacturer_buttons() def _calculate_manufacturer_gray(self, count, min_count, max_count): """Map manufacturer usage count to a gray value between 50 and 120.""" min_gray = 50 / 255 max_gray = 120 / 255 if max_count <= min_count: return min_gray factor = (count - min_count) / (max_count - min_count) gray = min_gray + factor * (max_gray - min_gray) return min(max_gray, max(min_gray, gray)) def position_manufacturer_buttons(self): """Position manufacturer buttons in the asset bar. The number of manufacturer buttons is determined by the number of unique manufacturers in the search results, up to a maximum defined by self.max_manufacturer_filters. The buttons are sized based on the length of the manufacturer name""" self.manufacturer_section_height = 0 names = self.manufacturer_names[: self.max_manufacturer_filters] content_width = max(0, self.bar_width - 2 * self.assetbar_margin) if not utils.experimental_enabled() or not names or content_width <= 0: for button in self.manufacturer_buttons: button.visible = False return max_x = self.bar_width - self.assetbar_margin # if needed expand this to multiple rows calculation, # but not now, let's just hide buttons that don't fit in one row self.manufacturer_section_height = self.filter_button_height + ( self.free_button_margin * 2 ) if not self.manufacturer_buttons: return counts = self.manufacturer_counts base_y = ( self.active_filter_height + self.bar_height + self.other_button_size + self.filter_button_height + (self.free_button_margin * 2) ) displayed_counts = [counts.get(name, 0) for name in self.manufacturer_names] min_count = min(displayed_counts) if displayed_counts else 1 max_count = max(displayed_counts) if displayed_counts else 1 current_left_offset = ( self.bar_x + self.assetbar_margin + self.free_button_margin ) for idx, button in enumerate(self.manufacturer_buttons): if idx >= len(names): button.visible = False continue name = self.manufacturer_names[idx] label = self._format_manufacturer_label(name) # shift to the right so we leave space for the clear bubble # button.x += clear_slot width = ( ui_bgl.get_text_size( font_id=1, text=label.upper(), text_size=self.free_button_text_size, )[0] + self.free_button_margin * 4 ) if self.free_button_margin + width > max_x: button.visible = False continue button.set_location( current_left_offset, self.filter_button_height + base_y, ) button.width = width button.height = self.filter_button_height button.text = label.upper() button.text_size = self.free_button_text_size button.visible = True button.manufacturer_name = name count = counts.get(name, min_count) gray = self._calculate_manufacturer_gray(count, min_count, max_count) hover_gray = min(gray + 0.1, 1.0) button.bg_color = (gray, gray, gray, 0.85) button.hover_bg_color = (hover_gray, hover_gray, hover_gray, 1.0) current_left_offset += width + (self.free_button_margin * 2) # endregion manufacturer def position_and_hide_buttons(self): """Position asset buttons in the asset bar and hide unused buttons.""" # position and layout buttons sr = search.get_search_results() if sr is None: sr = [] i = 0 for y in range(0, self.hcount): for x in range(0, self.wcount): asset_x = self.assetbar_margin + x * (self.button_size) asset_y = self.assetbar_margin + y * (self.button_size) button_idx = x + y * self.wcount asset_idx = button_idx + self.scroll_offset if len(self.asset_buttons) <= button_idx: break button = self.asset_buttons[button_idx] button.set_location(asset_x, asset_y) button.validation_icon.set_location( asset_x + self.button_size - self.icon_size - self.button_margin - self.validation_icon_margin, asset_y + self.button_size - self.icon_size - self.button_margin - self.validation_icon_margin, ) button.bookmark_button.set_location( asset_x + self.button_size - self.icon_size - self.button_margin - self.validation_icon_margin, asset_y + self.button_margin + self.validation_icon_margin, ) button.progress_bar.set_location( asset_x, asset_y + self.button_size - 6 ) button.author_button.set_location( asset_x + self.button_margin + self.validation_icon_margin, asset_y + self.button_margin + self.validation_icon_margin, ) if asset_idx < len(sr): button.visible = True button.validation_icon.visible = True button.bookmark_button.visible = False button.author_button.visible = False # button.progress_bar.visible = True else: button.visible = False button.validation_icon.visible = False button.bookmark_button.visible = False button.author_button.visible = False button.progress_bar.visible = False if utils.profile_is_validator(): button.red_alert.set_location( asset_x - self.validation_icon_margin, asset_y - self.validation_icon_margin, ) i += 1 for a in range(i, len(self.asset_buttons)): button = self.asset_buttons[a] button.visible = False button.validation_icon.visible = False button.bookmark_button.visible = False button.author_button.visible = False button.progress_bar.visible = False self.position_active_filter_buttons() # Position expand button and hide it when all results fit in a single row self.button_expand.set_location( self.bar_width - self.other_button_size, self.bar_height ) self.button_expand.visible = len(sr) > self.wcount self.button_scroll_down.height = self.bar_height self.button_scroll_down.set_image_position( (0, int((self.bar_height - self.button_size) / 2)) ) self.button_scroll_up.height = self.bar_height self.button_scroll_up.set_image_position( (0, int((self.bar_height - self.button_size) / 2)) ) # endregion updates # region setup def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._quad_view_state = None self._restart_pending = False self.scroll_offset = 0 self._tooltip_available_height = None self.base_bar_height = 0 self._last_search_results_id = None self.max_active_filter_chips = 12 self.active_filter_buttons = [] self.active_filter_height = 0 self.max_manufacturer_filters = 10 self.manufacturer_buttons = [] self.manufacturer_names = [] self.manufacturer_counts = Counter() self.manufacturer_section_height = 0 def on_init(self, context): """Initialize the asset bar operator.""" self.tooltip_base_size_pixels = TOOLTIP_SIZE_PX self.tooltip_scale = 1.0 self.bottom_panel_fraction = 0.18 self.needs_tooltip_update = False self.update_ui_size(context) self._quad_view_state = self._is_quad_view(context) # todo move all this to update UI size ui_props = context.window_manager.blenderkitUI self.draw_tooltip = False # let's take saved scroll offset and use it to keep scroll between operator runs self.last_scroll_offset = -10 # set to -10 so it updates on first run self.scroll_offset = ui_props.scroll_offset self.text_color = (0.9, 0.9, 0.9, 1.0) self.warning_color = (0.9, 0.5, 0.5, 1.0) self.init_ui() self.init_tooltip() self.hide_tooltip() self.trackpad_x_accum = 0 self.trackpad_y_accum = 0 def setup_widgets(self, context, event): """Set up all widgets for the asset bar and tooltip.""" widgets_panel = [] widgets_panel.extend(self.widgets_panel) widgets_panel.extend(self.buttons) widgets_panel.extend(self.asset_buttons) widgets_panel.extend(self.red_alerts) # we try to put bookmark_buttons before others, because they're on top widgets_panel.extend(self.bookmark_buttons) widgets_panel.extend(self.validation_icons) widgets_panel.extend(self.author_buttons) widgets_panel.extend(self.progress_bars) widgets = [self.panel] widgets += widgets_panel widgets.append(self.tooltip_panel) widgets += self.tooltip_widgets self.init_widgets(context, widgets) self.panel.add_widgets(widgets_panel) self.tooltip_panel.add_widgets(self.tooltip_widgets) stored_area, stored_region = self._unwrap_area_region( getattr(self, "context", None) ) override_ctx = self._build_context_snapshot( context, stored_area or context.area, stored_region or context.region, ) self._apply_widget_context(override_ctx) # endregion setup # region events def on_invoke(self, context, event): """Invoke the asset bar operator.""" self.instances.append(self) if not context.area: return False ui_props = context.window_manager.blenderkitUI self.on_init(context) self.context = context # start search if there isn't a search result yet if not search.get_search_results(): search.search() if ui_props.assetbar_on: # keep_running=True means "reuse" the existing instance instead of toggling it off if not self.keep_running: ui_props.turn_off = True # if there was an error, reset the flag so next invocation can start cleanly ui_props.assetbar_on = False return False # Clear stale shutdown flag from previous sessions (e.g. undo or addon reload) ui_props.turn_off = False ui_props.assetbar_on = True global asset_bar_operator asset_bar_operator = self self.active_index = -1 self.check_new_search_results(context) self.setup_widgets(context, event) self.set_element_images() self.position_and_hide_buttons() self.hide_tooltip() self.panel.set_location(self.bar_x, self.bar_y) self.scroll_update(always=True) self.window = context.window self.area = context.area self.scene = bpy.context.scene self._save_and_hide_overlays(context) return True def _save_and_hide_overlays(self, context): """Save and disable viewport overlay stats/text while the asset bar is open.""" self._saved_overlays = {} screen = getattr(context.window, "screen", None) if context.window else None if screen is None: return for area in screen.areas: if area.type != "VIEW_3D": continue for space in area.spaces: if space.type != "VIEW_3D": continue try: ptr = space.as_pointer() self._saved_overlays[ptr] = { "show_stats": space.overlay.show_stats, "show_text": space.overlay.show_text, } space.overlay.show_stats = False space.overlay.show_text = False except Exception: pass break def _set_overlays(self, context, show: bool): """Set or restore viewport overlay stats/text. When *show* is True, values are restored from the saved state. When *show* is False, both flags are forced off (saved state unchanged). """ saved = getattr(self, "_saved_overlays", {}) screen = getattr(context.window, "screen", None) if context.window else None if screen is None: return for area in screen.areas: if area.type != "VIEW_3D": continue for space in area.spaces: if space.type != "VIEW_3D": continue try: ptr = space.as_pointer() if show: state = saved.get(ptr) if state: space.overlay.show_stats = state["show_stats"] space.overlay.show_text = state["show_text"] else: space.overlay.show_stats = False space.overlay.show_text = False except Exception: pass break def _restore_overlays(self, context): """Restore viewport overlay stats/text to their saved state.""" self._set_overlays(context, show=True) def on_finish(self, context): # redraw all areas, since otherwise it stays to hang for some more time. # bpy.types.SpaceView3D.draw_handler_remove(self._handle_2d_tooltip, 'WINDOW') # to pass the operator to validation icons global asset_bar_operator asset_bar_operator = None context.window_manager.event_timer_remove(self._timer) ui_props = bpy.context.window_manager.blenderkitUI ui_props.assetbar_on = False ui_props.scroll_offset = self.scroll_offset self._restore_overlays(context) self._finished = True # to ensure the asset buttons are removed from screen self._redraw_tracked_regions() # handlers def enter_button(self, widget): """Handle mouse enter on an asset button.""" if not hasattr(widget, "button_index") or widget.button_index < 0: return # click on left/right arrow button gave no attr button_index # we should detect on which button_index scroll/left/right happened to refresh shown thumbnail bpy.context.window.cursor_set("HAND") search_index = widget.button_index + self.scroll_offset if search_index < self.search_results_count: self.show_tooltip() if self.active_index != search_index: self.active_index = search_index sr = search.get_search_results() if search_index >= len(sr): return # issue #1481 - index can be sometimes over the length of search results asset_data = sr[search_index] self.draw_tooltip = True # self.tooltip = asset_data['tooltip'] ui_props = bpy.context.window_manager.blenderkitUI ui_props.active_index = search_index # + self.scroll_offset # Update tooltip size based on asset type thumbnail_found = False self.tooltip_image_help.visible = False if asset_data["assetType"].lower() in { "printable", "model", "scene", } and self.show_thumbnail_variant in {"PHOTO", "WIREFRAME"}: t_type = self.show_thumbnail_variant.lower() if t_type == "photo": photo_img = ui.get_full_photo_thumbnail(asset_data) if photo_img: self.tooltip_image.set_image(photo_img.filepath) self.tooltip_image.set_image_colorspace("") thumbnail_found = True else: self.tooltip_image.set_image( paths.get_addon_thumbnail_path("thumbnail_notready.jpg") ) self.tooltip_image_help.text = "Photo thumbnail not ready yet." self.tooltip_image_help.visible = True elif t_type == "wireframe": wire_img = ui.get_full_wire_thumbnail(asset_data) if wire_img: self.tooltip_image.set_image(wire_img.filepath) self.tooltip_image.set_image_colorspace("") thumbnail_found = True else: self.tooltip_image.set_image( paths.get_addon_thumbnail_path("thumbnail_notready.jpg") ) self.tooltip_image_help.text = ( "Wireframe thumbnail not ready yet." ) self.tooltip_image_help.visible = True if not thumbnail_found: set_thumb_check(self.tooltip_image, asset_data, thumb_type="thumbnail") get_tooltip_data(asset_data) is_author = asset_data.get("assetType") == "author" if is_author: # --- Author-specific tooltip content --- an = asset_data.get("displayName", "") max_name_length = 30 if len(an) > max_name_length + 3: an = an[:30] + "..." self.asset_name.text = an # Show asset count next to name using authors_name (right-aligned) asset_count = asset_data["tooltip_data"].get("asset_count", 0) if asset_count > 0: self.authors_name.text = f"{asset_count} assets" self.authors_name.text_color = (0.7, 0.7, 0.7, 0.8) self.authors_name.visible = True else: self.authors_name.visible = False # Show aboutMe in the dedicated multiline label with word wrapping about_me = asset_data["tooltip_data"].get("about_me", "") max_about_lines = 8 # Estimate chars per line from tooltip width and font size chars_per_line = max( 20, int(self.tooltip_width / (self.author_text_size * 0.65)) ) lines = [] if about_me: # Word-wrap aboutMe text all_wrapped = [] for paragraph in about_me.split("\n"): words = paragraph.split() current_line = "" for word in words: if ( current_line and len(current_line) + 1 + len(word) > chars_per_line ): all_wrapped.append(current_line) current_line = word else: current_line = ( f"{current_line} {word}" if current_line else word ) if current_line: all_wrapped.append(current_line) # Limit to max_about_lines, add ellipsis if truncated if len(all_wrapped) > max_about_lines: all_wrapped = all_wrapped[:max_about_lines] last = all_wrapped[-1] if len(last) + 3 > chars_per_line: last = last[: chars_per_line - 3] all_wrapped[-1] = last + "..." lines.extend(all_wrapped) about_text = "\n".join(lines) self.author_about_me.text = about_text self.author_about_me.visible = True # Set tooltip image to gravatar author_id = int( asset_data.get("id", asset_data.get("author", {}).get("id", 0)) ) author = global_vars.BKIT_AUTHORS.get(author_id) if author is not None and author.gravatarImg: self.tooltip_image.set_image(author.gravatarImg) else: img_path = paths.get_addon_thumbnail_path("thumbnail_notready.jpg") self.tooltip_image.set_image(img_path) self.tooltip_image.set_image_colorspace("") # Show help text self.tooltip_image_help.text = "Click to search assets by this author" self.tooltip_image_help.visible = True # Hide widgets that don't apply to authors self.gravatar_image.visible = False self.quality_label.visible = False self.quality_star.visible = False self.multi_price_label.visible = False self.version_warning.text = "" else: # --- Regular asset tooltip content --- self.author_about_me.visible = False an = asset_data["displayName"] max_name_length = 30 if len(an) > max_name_length + 3: an = an[:30] + "..." search_props = utils.get_search_props() # if in top nodegroup category, show which type the nodegroup is if ( asset_data["assetType"] == "nodegroup" and search_props.search_category == "nodegroup" ): an = f"{an} - {asset_data['dictParameters']['nodeType']} nodes" self.asset_name.text = an self.authors_name.text = asset_data["tooltip_data"]["author_text"] self.authors_name.visible = True self.gravatar_image.visible = True # Hide ratings for addons is_addon = asset_data.get("assetType") == "addon" if not is_addon: quality_text = asset_data["tooltip_data"]["quality"] if utils.profile_is_validator(): quality_text += f" / {int(asset_data['score'])}" self.quality_label.text = quality_text self.quality_label.visible = True self.quality_star.visible = True else: self.quality_label.visible = False self.quality_star.visible = False # Update price labels for addons user_price_text = asset_data["tooltip_data"].get("user_price_text", "") base_price_text = asset_data["tooltip_data"].get("base_price_text", "") user_price_text_color = asset_data["tooltip_data"].get( "user_price_color", "" ) base_price_text_color = asset_data["tooltip_data"].get( "base_price_color", "" ) user_price_background_color = asset_data["tooltip_data"].get( "user_price_bg_color", "" ) base_price_background_color = asset_data["tooltip_data"].get( "base_price_bg_color", "" ) self.multi_price_label.text_a = user_price_text self.multi_price_label.text_a_color = user_price_text_color self.multi_price_label.segment_background_color_a = ( user_price_background_color ) self.multi_price_label.text_b = base_price_text self.multi_price_label.text_b_color = base_price_text_color self.multi_price_label.segment_background_color_b = ( base_price_background_color ) self.multi_price_label.multiline = True if user_price_text and base_price_text: self.multi_price_label.strikethrough_b = True self.multi_price_label.visible = True self.multi_price_label.segment_backgrounds = True elif user_price_text or base_price_text: self.multi_price_label.visible = True self.multi_price_label.strikethrough_b = False self.multi_price_label.segment_backgrounds = True else: self.multi_price_label.visible = False self.multi_price_label.strikethrough_b = False self.multi_price_label.segment_backgrounds = False # preview comments for validators self.update_comments_for_validators(asset_data) from_newer, difference = utils.asset_from_newer_blender_version( asset_data ) if from_newer: if difference == "major": self.version_warning.text = f"Made in Blender {asset_data['sourceAppVersion']}! Use at your own risk." elif difference == "minor": self.version_warning.text = f"Made in Blender {asset_data['sourceAppVersion']}! Caution advised." else: self.version_warning.text = f"Made in Blender {asset_data['sourceAppVersion']}! Some features may not work." else: self.version_warning.text = "" author_id = int(asset_data["author"]["id"]) author = global_vars.BKIT_AUTHORS.get(author_id) if author is None: bk_logger.info( "\n\n\nget_tooltip_data() AUTHOR NOT FOUND", author_id ) if author is not None and author.gravatarImg: self.gravatar_image.set_image(author.gravatarImg) else: img_path = paths.get_addon_thumbnail_path("thumbnail_notready.jpg") self.gravatar_image.set_image(img_path) self.gravatar_image.set_image_colorspace("") area, region = self._current_area_region() properties_width = 0 if area is not None: for r in getattr(area, "regions", []): if r.type == "UI": properties_width = r.width break # reset tooltip sizing so each spawn starts from base values self._reset_tooltip_dimensions() fallback_region = getattr(bpy.context, "region", None) active_region = region or fallback_region region_width = getattr(active_region, "width", None) if region_width is None: region_width = self.tooltip_panel.width + properties_width region_height = getattr(active_region, "height", None) if region_height is None: region_height = self.tooltip_panel.height + widget.height tooltip_x = min( int(widget.x_screen), int(region_width - self.tooltip_panel.width - properties_width), ) tooltip_x = max(0, tooltip_x) # Calculate space above and below the button to decide tooltip placement full_tooltip_height = self.tooltip_panel.height space_above = widget.y_screen space_below = region_height - (widget.y_screen + widget.height) place_above = ( space_below < full_tooltip_height and space_below < full_tooltip_height * 0.7 and space_below < space_above ) if place_above: available_height = space_above else: available_height = space_below self._tooltip_available_height = max(64, int(available_height)) # need to set image here because of context issues. img_path = paths.get_addon_thumbnail_path("star_grey.png") self.quality_star.set_image(img_path) tooltip_context = self._build_context_snapshot( bpy.context, area, active_region ) self.update_tooltip_size(tooltip_context) self.update_tooltip_layout(tooltip_context) tooltip_width = self.tooltip_width max_x = max(0, int(region_width - tooltip_width - properties_width)) tooltip_x = min(max(0, int(widget.x_screen)), max_x) if place_above: tooltip_y = int(widget.y_screen - self.tooltip_height) else: tooltip_y = int(widget.y_screen + widget.height) max_y = max(0, int(region_height - self.tooltip_height)) tooltip_y = min(max(0, tooltip_y), max_y) self.tooltip_panel.set_location(tooltip_x, tooltip_y) self.tooltip_panel.layout_widgets() # show bookmark button - always on mouse enter (but not for authors) if widget.bookmark_button and not is_author: widget.bookmark_button.visible = True # bpy.ops.wm.blenderkit_asset_popup('INVOKE_DEFAULT') def exit_button(self, widget): """Handle mouse exit from an asset button.""" # this condition checks if there wasn't another button already entered, which can happen with small button gaps if self.active_index == widget.button_index + self.scroll_offset: ui_props = bpy.context.window_manager.blenderkitUI ui_props.draw_tooltip = False self.draw_tooltip = False self.hide_tooltip() self.active_index = -1 ui_props = bpy.context.window_manager.blenderkitUI ui_props.active_index = self.active_index bpy.context.window.cursor_set("DEFAULT") # hide bookmark button - only when Not bookmarked # make sure to transfer some data, to prevent missing attribute widget.bookmark_button.asset_index = widget.button_index widget.author_button.asset_index = widget.button_index self.update_bookmark_icon(widget.bookmark_button) def bookmark_asset(self, widget): """Bookmark the asset linked to this button.""" # bookmark the asset linked to this button if not utils.user_logged_in(): bpy.ops.wm.blenderkit_login_dialog( "INVOKE_DEFAULT", message="Please login to bookmark your favorite assets.", ) return sr = search.get_search_results() asset_data = sr[widget.asset_index] # + self.scroll_offset] if asset_data.get("assetType") == "author": return bpy.ops.wm.blenderkit_bookmark_asset(asset_id=asset_data["id"]) self.update_bookmark_icon(widget) def show_author_profile(self, widget): """Show the author profile linked to this button.""" sr = search.get_search_results() asset_data = sr[widget.asset_index] # + self.scroll_offset] author_id = asset_data["id"] int_id = int(author_id) author = global_vars.BKIT_AUTHORS.get(int_id) if author is None: bk_logger.warning("author is none") return # personal site >> # url = author.aboutMeUrl # blenderkit site profile >> url = paths.get_author_gallery_url(author_id) if url is None: bk_logger.warning("url is none") return bpy.ops.wm.url_open(url=url) def drag_drop_asset(self, widget): """Start drag and drop operation for the asset linked to this button.""" now = time.time() # avoid double click to download assets under panels, mainly category panel if now - ui_panels.last_time_overlay_panel_active < 0.5: return ui_props = bpy.context.window_manager.blenderkitUI if ui_props.dragging: return # Author assets: clicking/dragging triggers search-by-author instead search_index = widget.search_index + self.scroll_offset sr = search.get_search_results() if sr and 0 <= search_index < len(sr): if sr[search_index].get("assetType") == "author": self.search_by_author(search_index) return # start drag drop bpy.ops.view3d.asset_drag_drop( "INVOKE_DEFAULT", asset_search_index=search_index, ) def cancel_press(self, widget): """Handle cancel/close button press.""" self.finish() def toggle_expand(self, widget): """Toggle the expanded state of the assetbar.""" user_preferences = bpy.context.preferences.addons[__package__].preferences user_preferences.assetbar_expanded = not user_preferences.assetbar_expanded # Update the button icon self.update_expand_button_icon() # Restart the asset bar to apply the new layout self.restart_asset_bar() def handle_key_input(self, event): """Handle keyboard shortcuts for asset bar operations.""" # Check if enough time has passed since last popup/text input activity # to prevent shortcuts from triggering while typing in text fields now = time.time() if now - ui_panels.last_time_overlay_panel_active < 0.5: return False # Shortcut: Toggle between normal, photo and wireframe thumbnail if event.type in {"ONE"}: if self.show_thumbnail_variant != "THUMBNAIL": self.show_thumbnail_variant = "THUMBNAIL" self.needs_tooltip_update = True if event.type in {"TWO"}: if self.show_thumbnail_variant != "PHOTO": self.show_thumbnail_variant = "PHOTO" self.needs_tooltip_update = True if event.type in {"THREE"}: if self.show_thumbnail_variant != "WIREFRAME": self.show_thumbnail_variant = "WIREFRAME" self.needs_tooltip_update = True if ( event.type in {"LEFT_BRACKET", "RIGHT_BRACKET"} and not event.shift and self.active_index > -1 ): # iterate index and update tooltip c_idx = 0 was_thumbnail_variant = self.show_thumbnail_variant if self.show_thumbnail_variant in THUMBNAIL_TYPES: c_idx = THUMBNAIL_TYPES.index(self.show_thumbnail_variant) if event.type == "LEFT_BRACKET": c_idx -= 1 elif event.type == "RIGHT_BRACKET": c_idx += 1 # clamp index - no rollover c_idx = min(max(c_idx, 0), len(THUMBNAIL_TYPES) - 1) if was_thumbnail_variant == THUMBNAIL_TYPES[c_idx]: return True # else update self.show_thumbnail_variant = THUMBNAIL_TYPES[c_idx] self.needs_tooltip_update = True return True # Check if active asset is an author (for guarding shortcuts below) _is_author_active = False if self.active_index > -1: _sr = search.get_search_results() if _sr and self.active_index < len(_sr): _is_author_active = _sr[self.active_index].get("assetType") == "author" # Shortcut: Search by author if event.type == "A": self.search_by_author(self.active_index) return True # Shortcut: Delete asset from hard-drive (not for authors) if event.type == "X" and self.active_index > -1 and not _is_author_active: # delete downloaded files for this asset sr = search.get_search_results() asset_data = sr[self.active_index] bk_logger.info("deleting asset from local drive: %s", asset_data["name"]) paths.delete_asset_debug(asset_data) asset_data["downloaded"] = 0 return True # Shortcut: Open Author's personal Webpage if event.type == "W" and self.active_index > -1: sr = search.get_search_results() asset_data = sr[self.active_index] author_id = int(asset_data["author"]["id"]) author = global_vars.BKIT_AUTHORS.get(author_id) if author is None: bk_logger.warning("author is none") return True utils.p("author:", author) url = author.aboutMeUrl if url is None: bk_logger.warning("url is none") return True bpy.ops.wm.url_open(url=url) return True # Shortcut: Search Similar (not for authors) if event.type == "S" and self.active_index > -1 and not _is_author_active: self.search_similar(self.active_index) return True if event.type == "C" and self.active_index > -1 and not _is_author_active: self.search_in_category(self.active_index) return True if event.type == "B" and self.active_index > -1 and not _is_author_active: sr = search.get_search_results() asset_data = sr[self.active_index] bpy.ops.wm.blenderkit_bookmark_asset(asset_id=asset_data["id"]) return True # Shortcut: Open Author's profile on BlenderKit if event.type == "P" and self.active_index > -1: sr = search.get_search_results() asset_data = sr[self.active_index] author_id = int(asset_data["author"]["id"]) author = global_vars.BKIT_AUTHORS.get(author_id) if author is None: return True utils.p("author:", author) url = paths.get_author_gallery_url(author.id) bpy.ops.wm.url_open(url=url) return True # FastRateMenu (not for authors) if ( event.type == "R" and self.active_index > -1 and not event.shift and not _is_author_active ): sr = search.get_search_results() asset_data = sr[self.active_index] if not utils.user_is_owner(asset_data=asset_data): bpy.ops.wm.blenderkit_menu_rating_upload( asset_name=asset_data["name"], asset_id=asset_data["id"], asset_type=asset_data["assetType"], ) return True if ( event.type == "V" and event.shift and self.active_index > -1 and not _is_author_active and utils.profile_is_validator() ): sr = search.get_search_results() asset_data = sr[self.active_index] bpy.ops.object.blenderkit_change_status( asset_id=asset_data["id"], state="validated" ) return True if ( event.type == "H" and event.shift and self.active_index > -1 and not _is_author_active and utils.profile_is_validator() ): sr = search.get_search_results() asset_data = sr[self.active_index] bpy.ops.object.blenderkit_change_status( asset_id=asset_data["id"], state="on_hold" ) return True if ( event.type == "U" and event.shift and self.active_index > -1 and not _is_author_active and utils.profile_is_validator() ): sr = search.get_search_results() asset_data = sr[self.active_index] bpy.ops.object.blenderkit_change_status( asset_id=asset_data["id"], state="uploaded" ) return True if ( event.type == "R" and event.shift and self.active_index > -1 and not _is_author_active and utils.profile_is_validator() ): sr = search.get_search_results() asset_data = sr[self.active_index] bpy.ops.object.blenderkit_change_status( asset_id=asset_data["id"], state="rejected" ) return True return False # Let other shortcuts be handled def scroll_up(self, widget): """Scroll up in the asset bar.""" self.scroll_offset += self.wcount * self.hcount self.scroll_update() self.enter_button(widget) def scroll_down(self, widget): """Scroll down in the asset bar.""" self.scroll_offset -= self.wcount * self.hcount self.scroll_update() self.enter_button(widget) # endregion events # region actions def apply_term_filter(self, widget: BL_UI_Button, *, term: str): """Apply term filter based on the clicked bubble.""" value = getattr(widget, f"{term}_name", "") if not value: self.clear_term_filter(widget, term=term) return label = getattr(widget, "text", value) # Mark data-driven filters so we can drop them when the asset type changes search.set_active_filter(term=term, value=value, label=label, origin="data") search.update_filters() search.create_history_step(search.get_active_tab()) search.search() self.update_ui_size(bpy.context) self.scroll_update(always=True) def clear_term_filter(self, widget: BL_UI_Button, *, term: str): """Remove term filter from the search keywords.""" search.remove_active_filter(term=term) search.update_filters() search.create_history_step(search.get_active_tab()) search.search() self.update_ui_size(bpy.context) self.scroll_update(always=True) def _filter_out_term(self, term: str, keywords: str): """Remove term:* tokens using regex; return clean token list without extra +/spaces.""" # strip term segments that may contain spaces until the next '+' or string end without_term = re.sub( rf"(?:^|\+)\s*{term}:[^+]+", "", keywords, flags=re.IGNORECASE ) # normalize plus separators and drop empty pieces tokens = [part for part in without_term.replace(" ", "").split("+") if part] return tokens def remove_active_filter_chip(self, widget: BL_UI_Button): active_filter = getattr(widget, "active_filter", None) if not active_filter: return search.remove_active_filter( term=active_filter.get("term", ""), value=active_filter.get("value") ) search.update_filters() search.create_history_step(search.get_active_tab()) search.search() self.update_ui_size(bpy.context) self.scroll_update(always=True) def asset_menu(self, widget): """Open the asset menu for the asset linked to this button.""" self.hide_tooltip() target_index = getattr(widget, "button_index", None) if target_index is None: target_index = getattr(widget, "asset_index", None) if target_index is not None: search_index = target_index + self.scroll_offset history_step = search.get_active_history_step() sr = history_step.get("search_results") or [] if 0 <= search_index < len(sr): # No context menu for author results if sr[search_index].get("assetType") == "author": return self.active_index = search_index ui_props = bpy.context.window_manager.blenderkitUI ui_props.active_index = search_index bpy.ops.wm.blenderkit_asset_popup("INVOKE_DEFAULT") # bpy.ops.wm.call_menu(name='OBJECT_MT_blenderkit_asset_menu') def search_more(self): """Search for more assets.""" history_step = search.get_active_history_step() sro = history_step.get("search_results_orig") if sro is None: return if sro.get("next") is None: return search_props = utils.get_search_props() active_history_step = search.get_active_history_step() if active_history_step.get("is_searching"): return search.search(get_next=True) def update_bookmark_icon(self, bookmark_button: BL_UI_Button): """Update the bookmark icon for a given bookmark button.""" history_step = search.get_active_history_step() sr = history_step.get("search_results", []) asset_index = bookmark_button.asset_index # type: ignore # sometimes happened here that the asset_index was out of range if asset_index >= len(sr): return asset_data = sr[asset_index] if asset_data.get("assetType") == "author": bookmark_button.visible = False return rating = ratings_utils.get_rating_local(asset_data["id"]) if rating is not None and rating.bookmarks == 1: icon = "bookmark_full.png" visible = True else: icon = "bookmark_empty.png" if self.active_index == bookmark_button.asset_index: # type: ignore visible = True else: visible = False bookmark_button.visible = visible img_fp = paths.get_addon_thumbnail_path(icon) bookmark_button.set_image(img_fp) def update_progress_bar(self, asset_button, asset_data): """Update the progress bar for each button in asset bar. Enabled addons are shown in green, disabled but installed in blue.""" pb = asset_button.progress_bar if pb is None: return if asset_data["downloaded"] > 0: pb.bg_color = colors.GREEN # For addons, always show full bar when installed, with color based on enabled status if asset_data.get("assetType") == "addon": w = self.button_size # Full width for installed addons is_enabled = asset_data.get("enabled", False) if not is_enabled: # Pale blue for installed but disabled addons pb.bg_color = colors.BLUE w = int(self.button_size * asset_data["downloaded"] / 100.0) pb.width = w pb.update(pb.x_screen, pb.y_screen) pb.visible = True else: pb.visible = False return self._safe_tag_redraw(bpy.context.region) def update_validation_icon(self, asset_button, asset_data: dict): """Update the validation icon for each button in asset bar.""" if asset_data.get("assetType") == "author": asset_button.validation_icon.visible = False return if utils.profile_is_validator(): rating = global_vars.RATINGS.get(asset_data["id"]) v_icon = ui.verification_icons[ asset_data.get("verificationStatus", "validated") ] if v_icon is not None: img_fp = paths.get_addon_thumbnail_path(v_icon) asset_button.validation_icon.set_image(img_fp) asset_button.validation_icon.visible = True elif rating is None or rating.quality is None: v_icon = "star_grey.png" img_fp = paths.get_addon_thumbnail_path(v_icon) asset_button.validation_icon.set_image(img_fp) asset_button.validation_icon.visible = True else: asset_button.validation_icon.visible = False else: if asset_data.get("canDownload", True) == 0: img_fp = paths.get_addon_thumbnail_path("locked.png") asset_button.validation_icon.set_image(img_fp) asset_button.validation_icon.visible = True else: asset_button.validation_icon.visible = False def update_image(self, asset_id): """should be run after thumbs are retrieved so they can be updated""" sr = search.get_search_results() if not sr: return for asset_button in self.asset_buttons: if asset_button.asset_index < len(sr): asset_data = sr[asset_button.asset_index] if asset_data["assetBaseId"] == asset_id: set_thumb_check( asset_button, asset_data, thumb_type="thumbnail_small" ) def update_buttons(self): """Update asset buttons in the asset bar based on current search results and scroll offset.""" history_step = search.get_active_history_step() sr = history_step.get("search_results") if not sr: return visible_results = [] # remember also position for manufacturer buttons for asset_button in self.asset_buttons: if asset_button.visible: asset_button.asset_index = ( asset_button.button_index + self.scroll_offset ) if asset_button.asset_index < len(sr): asset_button.visible = True asset_data = sr[asset_button.asset_index] if asset_data is None: continue # update bookmark buttons asset_button.bookmark_button.asset_index = asset_button.asset_index # update author profile buttons asset_button.author_button.asset_index = asset_button.asset_index set_thumb_check( asset_button, asset_data, thumb_type="thumbnail_small" ) # Distinguish author cards with a blue border and author icon if asset_data.get("assetType") == "author": asset_button.background_border_thickness = 3.0 asset_button.author_button.visible = True asset_button.background_corner_radius = 12.0 asset_button.image_corner_radius = 12.0 asset_button.background_padding = [-1.0, -1.0] asset_button.background_border = True asset_button.background_border_color = colors.ACTIVE_BLUE asset_button.use_rounded_background = True asset_button.image_padding = ( asset_button.background_border_thickness ) else: asset_button.background_border = False asset_button.background_border_color = None asset_button.author_button.visible = False asset_button.use_rounded_background = False asset_button.background_corner_radius = 0.0 asset_button.image_corner_radius = None asset_button.background_padding = [0.0, 0.0] asset_button.image_padding = 0.0 self.update_validation_icon(asset_button, asset_data) self.update_bookmark_icon(asset_button.bookmark_button) self.update_progress_bar(asset_button, asset_data) if ( utils.profile_is_validator() and asset_data["verificationStatus"] == "uploaded" ): over_limit = utils.is_upload_old( asset_data.get("lastBlendUpload") ) if over_limit: redness = min(over_limit * 0.05, 0.7) asset_button.red_alert.bg_color = (1, 0, 0, redness) asset_button.red_alert.visible = True else: asset_button.red_alert.visible = False elif utils.profile_is_validator(): asset_button.red_alert.visible = False visible_results.append(asset_data) else: asset_button.visible = False asset_button.validation_icon.visible = False asset_button.bookmark_button.visible = False asset_button.author_button.visible = False asset_button.progress_bar.visible = False if utils.profile_is_validator(): asset_button.red_alert.visible = False # Refresh manufacturer chips to match currently visible assets self._update_manufacturer_data(visible_results) def scroll_update(self, always=False): """Update scroll position and visibility of scroll buttons.""" self.hide_tooltip() history_step = search.get_active_history_step() sr = history_step.get("search_results") sro = history_step.get("search_results_orig") # orig_offset = self.scroll_offset # empty results if sr is None: self.button_scroll_down.visible = False self.button_scroll_up.visible = False return self.scroll_offset = min( self.scroll_offset, len(sr) - (self.wcount * self.hcount) ) self.scroll_offset = max(self.scroll_offset, 0) # only update if scroll offset actually changed, otherwise this is unnecessary if ( sro["count"] > len(sr) and len(sr) - self.scroll_offset < (self.wcount * self.hcount) + 15 ): self.search_more() if self.scroll_offset == 0: self.button_scroll_down.visible = False else: self.button_scroll_down.visible = True if self.scroll_offset >= sro["count"] - (self.wcount * self.hcount): self.button_scroll_up.visible = False else: self.button_scroll_up.visible = True # here we save some time by only updating the images if the scroll offset actually changed if self.last_scroll_offset == self.scroll_offset and not always: return self.last_scroll_offset = self.scroll_offset self.update_buttons() def search_by_author(self, asset_index): """Search for assets by the author of the selected asset.""" history_step = search.get_active_history_step() sr = history_step.get("search_results", []) asset_data = sr[asset_index] author_id = asset_data["author"]["id"] if author_id is None: return True # For author-type results, prefer displayName for the filter label author_name = "" if asset_data.get("assetType") == "author": author_name = asset_data.get("displayName", "") # For author-type cards, check if asset type picker is enabled if asset_data.get("assetType") == "author": preferences = bpy.context.preferences.addons[__package__].preferences if ( preferences.experimental_features and preferences.author_asset_type_picker ): # Extract per-type asset counts from author data (assetTypeCounts or ratingsCount) asset_type_counts = asset_data.get("assetTypeCounts", {}) if not asset_type_counts: asset_type_counts = asset_data.get("ratingsCount", {}) search.AuthorAssetTypePopup._asset_type_counts = asset_type_counts or {} bpy.ops.view3d.blenderkit_author_asset_type_popup( "INVOKE_DEFAULT", author_id=str(author_id), author_name=author_name, ) return True # Picker not enabled — search by author in current tab search.search_by_author_id(author_id, author_name) self.update_ui_size(bpy.context) self.scroll_update(always=True) return True search.search_by_author_id(author_id, author_name) self.update_ui_size(bpy.context) self.scroll_update(always=True) return True def search_similar(self, asset_index): """Search for similar assets to the selected asset.""" history_step = search.get_active_history_step() sr = history_step.get("search_results", []) asset_data = sr[asset_index] keywords = search.get_search_similar_keywords(asset_data) ui_props = bpy.context.window_manager.blenderkitUI ui_props.search_keywords = keywords search.search() def search_in_category(self, asset_index): """Search for assets in the same category as the selected asset.""" history_step = search.get_active_history_step() sr = history_step.get("search_results", []) asset_data = sr[asset_index] category = asset_data.get("category") if category is None: return True ui_props = bpy.context.window_manager.blenderkitUI ui_props.search_category = category search.search() # endregion actions # region main operations @classmethod def unregister(cls): """Unregister the asset bar operator and clean up instances.""" bk_logger.debug("unregistering class %s", cls) instances_copy = cls.instances.copy() for instance in instances_copy: try: bk_logger.debug("- instance %s", instance) except ReferenceError: bk_logger.debug("- instance ") try: instance.unregister_handlers(instance.context) except Exception as e: bk_logger.debug("-- error unregister_handlers(): %s", e) try: instance.on_finish(instance.context) except Exception as e: bk_logger.debug("-- error calling on_finish(): %s", e) cls.instances.remove(instance) def restart_asset_bar(self): """Restart the asset bar UI.""" ui_props = bpy.context.window_manager.blenderkitUI self.finish() w, a, r = utils.get_largest_area(area_type="VIEW_3D") if a is not None: bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False) # endregion main operations # region tab management def add_new_tab(self, widget): """Add a new tab when the + button is clicked.""" tabs = global_vars.TABS["tabs"] new_tab = { "name": f"Tab {len(tabs) + 1}", # Default name with incremented number "history": [], # Empty history list "history_index": -1, # No history yet "active_filters": [], } tabs.append(new_tab) # Get current history step to copy its state and results current_history_step = search.get_active_history_step() # Create history step for the new tab, copying the current UI state new_history_step = search.create_history_step(new_tab) # Copy search results from current history step if they exist if current_history_step.get("search_results"): new_history_step["search_results"] = current_history_step["search_results"] new_history_step["search_results_orig"] = current_history_step[ "search_results_orig" ] new_history_step["is_searching"] = False # Update search results count to trigger UI refresh self.search_results_count = len(new_history_step["search_results"]) # Force scroll update to show results self.scroll_update(always=True) new_active_tab = len(tabs) - 1 self.switch_to_history_step(new_active_tab, 0) # Write history step to tab # Restart asset bar to show new tab self.restart_asset_bar() def remove_tab(self, widget): """Remove a tab when its close button is clicked.""" tabs = global_vars.TABS["tabs"] # Don't remove the last tab if len(tabs) <= 1: return tab_index = widget.tab_index # If removing active tab, switch to previous tab if global_vars.TABS["active_tab"] == tab_index: global_vars.TABS["active_tab"] = max(0, tab_index - 1) # If removing tab before active tab, adjust active tab index elif global_vars.TABS["active_tab"] > tab_index: global_vars.TABS["active_tab"] -= 1 # Remove the tab tabs.pop(tab_index) # Restart asset bar to update UI self.restart_asset_bar() def update_history_buttons_rounding(self): """Update the rounding of history navigation buttons based on their visibility.""" if self.history_back_button.visible and self.history_forward_button.visible: self.history_back_button.background_corner_radius = ( ROUNDING_RADIUS, 0, 0, 0, ) self.history_forward_button.background_corner_radius = ( 0, ROUNDING_RADIUS, 0, 0, ) else: self.history_back_button.background_corner_radius = ( ROUNDING_RADIUS, ROUNDING_RADIUS, 0, 0, ) self.history_forward_button.background_corner_radius = ( ROUNDING_RADIUS, ROUNDING_RADIUS, 0, 0, ) def switch_to_history_step(self, tab_index, history_index): """Switch to a specific tab and history step.""" # Update UI properties without triggering update callbacks ui_props = bpy.context.window_manager.blenderkitUI # lock the search ui_props.search_lock = True if ( tab_index == global_vars.TABS["active_tab"] and history_index == global_vars.TABS["tabs"][tab_index]["history_index"] ): return # Already on this tab and history step # make original tab original background color self.tab_buttons[global_vars.TABS["active_tab"]].bg_color = self.button_bg_color # make also tab close button original background color self.close_tab_buttons[global_vars.TABS["active_tab"]].bg_color = ( self.button_bg_color ) global_vars.TABS["active_tab"] = tab_index global_vars.TABS["tabs"][tab_index]["history_index"] = history_index # Get active history step of the selected tab history_step = search.get_active_history_step() ui_state = history_step["ui_state"] # Update UI properties for prop_name, value in ui_state["ui_props"].items(): if hasattr(ui_props, prop_name): # strings need to be quoted if isinstance(value, str): exec(f"ui_props.{prop_name} = '{value}'") else: exec(f"ui_props.{prop_name} = {value}") # Update search type specific properties search_props = utils.get_search_props() for prop_name, value in ui_state["search_props"].items(): if hasattr(search_props, prop_name): # strings need to be quoted if isinstance(value, str): exec(f"search_props.{prop_name} = '{value}'") else: exec(f"search_props.{prop_name} = {value}") # Restore active filter chips for this tab active_tab = global_vars.TABS["tabs"][tab_index] search.set_active_filters_for_tab( active_tab, ui_state.get("active_filters", []) ) # update tab label # only if the button exists if len(self.tab_buttons) > tab_index: search.update_tab_name(global_vars.TABS["tabs"][tab_index]) # Restore scroll position self.scroll_offset = history_step.get("scroll_offset", 0) # Update history button visibility self.history_back_button.visible = active_tab["history_index"] > 0 self.history_forward_button.visible = ( active_tab["history_index"] < len(active_tab["history"]) - 1 ) self.update_history_buttons_rounding() # Recalculate layout to reflect active filter chip changes on this history step self.update_ui_size(bpy.context) # set colors and rounding for tabs for tab_button in self.tab_buttons: c_tab_index = tab_button.tab_index if c_tab_index == tab_index: tab_button.bg_color = self.button_selected_color self.close_tab_buttons[tab_index].bg_color = ( self.button_selected_color_dim ) else: tab_button.bg_color = self.button_bg_color self.close_tab_buttons[c_tab_index].bg_color = self.button_bg_color # update filters search.update_filters() # Update UI to show current tab's search results self.scroll_update(always=True) # Update tab icons to reflect the current asset type self.update_tab_icons() # unlock the search ui_props.search_lock = False def history_back(self, widget): """Navigate to previous history step.""" active_tab = global_vars.TABS["tabs"][global_vars.TABS["active_tab"]] if active_tab["history_index"] > 0: self.switch_to_history_step( global_vars.TABS["active_tab"], active_tab["history_index"] - 1 ) def history_forward(self, widget): """Navigate to next history step.""" active_tab = global_vars.TABS["tabs"][global_vars.TABS["active_tab"]] if active_tab["history_index"] < len(active_tab["history"]) - 1: self.switch_to_history_step( global_vars.TABS["active_tab"], active_tab["history_index"] + 1 ) def switch_tab(self, widget): """Switch to the clicked tab and restore its UI state.""" self.switch_to_history_step( widget.tab_index, global_vars.TABS["tabs"][widget.tab_index]["history_index"], ) # endregion tab management def handle_bkclientjs_get_asset(task: search.client_tasks.Task): """Handle incoming bkclientjs/get_asset task after the user asked for download in online gallery. How it goes: 1. set search in the history 2. set the results in the history step 3. open the asset bar We handle the task in asset_bar_op because we need access to the asset_bar_operator without circular import from search. """ bk_logger.info("handle_bkclientjs_get_asset: %s", task.result["asset_data"]["name"]) # Get asset data from task result asset_data = task.result.get("asset_data") if not asset_data: bk_logger.error("No asset data found in task") return # Parse the asset data parsed_asset_data = search.parse_result(asset_data) if not parsed_asset_data: bk_logger.error("Failed to parse asset data") return search.append_history_step( search_keywords=f"asset_base_id:{asset_data['assetBaseId']}", search_results=[parsed_asset_data], asset_type=asset_data.get("assetType", "").upper(), search_results_orig={"results": [asset_data], "count": 1}, ) # If asset bar is not open, try to open it if asset_bar_operator is None: try: bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False) # type: ignore[attr-defined] except Exception as e: bk_logger.error("Failed to open asset bar: %s", e) return # Force redraw of the region if asset bar exists if asset_bar_operator and asset_bar_operator.area: search.load_preview(parsed_asset_data) asset_bar_operator.update_image(parsed_asset_data["assetBaseId"]) asset_bar_operator.area.tag_redraw() BlenderKitAssetBarOperator.modal = asset_bar_modal # type: ignore[method-assign] BlenderKitAssetBarOperator.invoke = asset_bar_invoke # type: ignore[method-assign] def register(): bpy.utils.register_class(BlenderKitAssetBarOperator) def unregister(): bpy.utils.unregister_class(BlenderKitAssetBarOperator)