Files
blender-portable-repo/extensions/user_default/blenderkit/asset_bar_op.py
T
Raincloud 692e200ffe work
save startup blend for animation tab & whatnot
2026-04-08 12:10:18 -06:00

4249 lines
163 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ##### 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 <deleted>")
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)