2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
@@ -0,0 +1,37 @@
bl_info = {
"name": "BL UI Widgets",
"description": "UI Widgets to draw in the 3D view",
"author": "Jayanam",
"version": (0, 6, 4, 2),
"blender": (2, 80, 0),
"location": "View3D",
"category": "Object",
}
# Blender imports
import bpy
from bpy.props import *
addon_keymaps = []
def register():
bpy.utils.register_class(DP_OT_draw_operator)
kcfg = bpy.context.window_manager.keyconfigs.addon
if kcfg:
km = kcfg.keymaps.new(name="3D View", space_type="VIEW_3D")
addon_keymaps.append((km, kmi))
def unregister():
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
bpy.utils.unregister_class(DP_OT_draw_operator)
if __name__ == "__main__":
register()
@@ -0,0 +1,230 @@
import os
import blf
import bpy
import gpu
from .. import image_utils, ui_bgl
from .bl_ui_widget import BL_UI_Widget
class BL_UI_Button(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self._text_color = (1.0, 1.0, 1.0, 1.0)
self._hover_bg_color = (0.5, 0.5, 0.5, 1.0)
self._select_bg_color = (0.7, 0.7, 0.7, 1.0)
self._text = "Button"
self._text_size = 16
self._textpos = (x, y)
self.__state = 0
self.__image = None
self.__image_size = (24, 24)
self.__image_position = (4, 2)
@property
def text_color(self):
return self._text_color
@text_color.setter
def text_color(self, value):
if value != self._text_color:
bpy.context.region.tag_redraw()
self._text_color = value
@property
def text(self):
return self._text
@text.setter
def text(self, value):
if value != self._text:
bpy.context.region.tag_redraw()
self._text = value
@property
def text_size(self):
return self._text_size
@text_size.setter
def text_size(self, value):
if value != self._text_size:
bpy.context.region.tag_redraw()
self._text_size = value
@property
def hover_bg_color(self):
return self._hover_bg_color
@hover_bg_color.setter
def hover_bg_color(self, value):
if value != self._hover_bg_color:
bpy.context.region.tag_redraw()
self._hover_bg_color = value
@property
def select_bg_color(self):
return self._select_bg_color
@select_bg_color.setter
def select_bg_color(self, value):
if value != self._select_bg_color:
bpy.context.region.tag_redraw()
self._select_bg_color = value
def set_image_size(self, image_size):
self.__image_size = image_size
def set_image_position(self, image_position):
self.__image_position = image_position
def check_image_exists(self):
# it's possible image was removed and doesn't exist.
try:
self.__image
self.__image.filepath
# self.__image.pixels
except Exception as e:
self.__image = None
def set_image_colorspace(self, colorspace):
image_utils.set_colorspace(self.__image, colorspace)
def set_image(self, rel_filepath):
# first try to access the image, for cases where it can get removed
self.check_image_exists()
try:
if self.__image is None or self.__image.filepath != rel_filepath:
imgname = f".{os.path.basename(rel_filepath)}"
img = bpy.data.images.get(imgname)
if img is not None:
self.__image = img
else:
self.__image = bpy.data.images.load(
rel_filepath, check_existing=True
)
self.__image.name = imgname
self.__image.gl_load()
if self.__image and len(self.__image.pixels) == 0:
self.__image.reload()
self.__image.gl_load()
except Exception as e:
print(f"BL_UI_BUTTON set_image() error: {e}")
self.__image = None
def get_image_path(self):
self.check_image_exists()
if self.__image is None:
return None
return self.__image.filepath
def update(self, x, y):
super().update(x, y)
self._textpos = [x, y]
def draw(self):
if not self._is_visible:
return
area_height = self.get_area_height()
gpu.state.blend_set("ALPHA")
self.shader.bind()
self.set_colors()
self.batch_panel.draw(self.shader)
self.draw_image()
# Draw text
self.draw_text(area_height)
def set_colors(self):
color = self._bg_color
# pressed
if self.__state == 1:
color = self._select_bg_color
# hover
elif self.__state == 2:
color = self._hover_bg_color
self.shader.uniform_float("color", color)
def draw_text(self, area_height):
font_id = 1
if bpy.app.version < (3, 1, 0):
# Blender 3.0 requires size:int https://docs.blender.org/api/3.0/blf.html#blf.size
# but assetBar's search tab text is float - needs conversion in here
blf.size(font_id, int(self._text_size), 72)
elif bpy.app.version < (4, 0, 0):
blf.size(font_id, self._text_size, 72)
else:
blf.size(font_id, self._text_size)
size = blf.dimensions(font_id, self._text)
textpos_y = area_height - self._textpos[1] - (self.height + size[1]) / 2.0
blf.position(
font_id, self._textpos[0] + (self.width - size[0]) / 2.0, textpos_y + 1, 0
)
r, g, b, a = self._text_color
blf.color(font_id, r, g, b, a)
blf.draw(font_id, self._text)
def draw_image(self):
if self.__image is not None:
y_screen_flip = self.get_area_height() - self.y_screen
off_x, off_y = self.__image_position
sx, sy = self.__image_size
ui_bgl.draw_image(
self.x_screen + off_x,
y_screen_flip - off_y - sy,
sx,
sy,
self.__image,
1.0,
crop=(0, 0, 1, 1),
batch=None,
)
return True
return False
def set_mouse_down(self, mouse_down_func):
self.mouse_down_func = mouse_down_func
def mouse_down(self, x, y):
if self.is_in_rect(x, y):
self.__state = 1
try:
self.mouse_down_func(self)
except Exception as e:
import traceback
traceback.print_exc()
return True
return False
def mouse_move(self, x, y):
if self.is_in_rect(x, y):
if self.__state != 1:
# hover state
self.__state = 2
else:
self.__state = 0
def mouse_up(self, x, y):
if self.is_in_rect(x, y):
self.__state = 2
else:
self.__state = 0
@@ -0,0 +1,58 @@
from .bl_ui_widget import BL_UI_Widget
class BL_UI_Drag_Panel(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self.drag_offset_x = 0
self.drag_offset_y = 0
self.is_drag = False
self.widgets = []
def set_location(self, x, y):
super().set_location(x, y)
self.layout_widgets()
def add_widget(self, widget):
self.widgets.append(widget)
def add_widgets(self, widgets):
self.widgets = widgets
self.layout_widgets()
def layout_widgets(self):
for widget in self.widgets:
widget.update(self.x_screen + widget.x, self.y_screen + widget.y)
def update(self, x, y):
super().update(x - self.drag_offset_x, y + self.drag_offset_y)
def child_widget_focused(self, x, y):
for widget in self.widgets:
if widget.is_in_rect(x, y):
return True
return False
def mouse_down(self, x, y):
if self.child_widget_focused(x, y):
return False
if self.is_in_rect(x, y):
height = self.get_area_height()
self.is_drag = True
self.drag_offset_x = x - self.x_screen
self.drag_offset_y = y - (height - self.y_screen)
return True
return False
def mouse_move(self, x, y):
if self.is_drag:
height = self.get_area_height()
self.update(x, height - y)
self.layout_widgets()
def mouse_up(self, x, y):
self.is_drag = False
self.drag_offset_x = 0
self.drag_offset_y = 0
@@ -0,0 +1,117 @@
import traceback
import bpy
from bpy.types import Operator
class BL_UI_OT_draw_operator(Operator):
bl_idname = "object.bl_ui_ot_draw_operator"
bl_label = "bl ui widgets operator"
bl_description = "Operator for bl ui widgets"
bl_options = {"REGISTER"}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.draw_handle = None
self.draw_event = None
self._finished = False
self.widgets = []
self._timer_interval = 0.1
def init_widgets(self, context, widgets):
self.widgets = widgets
for widget in self.widgets:
widget.init(context)
def on_invoke(self, context, event):
pass
def on_finish(self, context):
self._finished = True
def invoke(self, context, event):
self.on_invoke(context, event)
args = (self, context)
self.register_handlers(args, context, timer_interval=self._timer_interval)
context.window_manager.modal_handler_add(self)
# first set pointers to keep track if the area is still available
self.active_window_pointer = context.window.as_pointer()
self.active_area_pointer = context.area.as_pointer()
self.active_region_pointer = context.region.as_pointer()
context.region.tag_redraw()
return {"RUNNING_MODAL"}
def register_handlers(self, args, context, timer_interval=0.1):
self.draw_handle = bpy.types.SpaceView3D.draw_handler_add(
self.draw_callback_px, args, "WINDOW", "POST_PIXEL"
)
self.draw_event = context.window_manager.event_timer_add(
timer_interval, window=context.window
)
def unregister_handlers(self, context):
context.window_manager.event_timer_remove(self.draw_event)
bpy.types.SpaceView3D.draw_handler_remove(self.draw_handle, "WINDOW")
self.draw_handle = None
self.draw_event = None
def handle_widget_events(self, event):
result = False
# we iterate widgets reversed, so top buttons can get processed first if buttons overlap.
for widget in reversed(self.widgets):
if widget.handle_event(event):
result = True
return True # return prematurely to avoid conflicts.
return result
def modal(self, context, event):
if self._finished:
return {"FINISHED"}
if context.area:
context.region.tag_redraw()
if self.handle_widget_events(event):
return {"RUNNING_MODAL"}
if event.type in {"ESC"}:
self.finish()
return {"PASS_THROUGH"}
def finish(self):
self.unregister_handlers(bpy.context)
# it is possible that the area has been closed, so we check if it is still available
if bpy.context.region is not None:
bpy.context.region.tag_redraw()
self.on_finish(bpy.context)
# Draw handler to paint onto the screen
def draw_callback_px(self, op, context):
draw_callback_px_separated(self, op, context)
def cancel(self, context):
"""Cancel the modal operator and finish. This is called before unregistration on Blender quit. Has to be here, so BL_UI_Button, BL_UI_Drag_Panel, BL_UI_Image and other elements are removed with finish().
We cannot call this during unregister because at that stage Operator is already removed, but BL_UI_Button is kept in memory causing memory leaks. Issue: #770
"""
self.finish()
def draw_callback_px_separated(self, op, context):
# separated only for puprpose of profiling
try:
# hide during animation playback, to improve performance
if context.screen.is_animation_playing:
return
if context.area.as_pointer() == self.active_area_pointer:
for widget in self.widgets:
widget.draw()
except Exception as e:
traceback.print_exc()
@@ -0,0 +1,108 @@
import os
import bpy
from .. import image_utils, ui_bgl
from .bl_ui_widget import BL_UI_Widget
class BL_UI_Image(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self.__state = 0
self.__image = None
self.__image_size = (24, 24)
self.__image_position = (4, 2)
def set_image_size(self, image_size):
self.__image_size = image_size
def set_image_position(self, image_position):
self.__image_position = image_position
def check_image_exists(self):
# it's possible image was removed and doesn't exist.
try:
self.__image
self.__image.filepath
except Exception as e:
self.__image = None
return None
def set_image(self, rel_filepath):
# first try to access the image, for cases where it can get removed
self.check_image_exists()
try:
if self.__image is None or self.__image.filepath != rel_filepath:
imgname = f".{os.path.basename(rel_filepath)}"
img = bpy.data.images.get(imgname)
if img is not None:
self.__image = img
else:
self.__image = bpy.data.images.load(
rel_filepath, check_existing=True
)
self.__image.name = imgname
self.__image.gl_load()
if self.__image and len(self.__image.pixels) == 0:
self.__image.reload()
self.__image.gl_load()
except Exception as e:
print(f"BL_UI_BUTTON: exception in set_image(): {e}")
self.__image = None
def set_image_colorspace(self, colorspace):
image_utils.set_colorspace(self.__image, colorspace)
def get_image_path(self):
self.check_image_exists()
if self.__image is None:
return None
return self.__image.filepath
def update(self, x, y):
super().update(x, y)
def draw(self):
if not self._is_visible:
return
self.shader.bind()
self.batch_panel.draw(self.shader)
self.draw_image()
def draw_image(self):
if self.__image is not None:
y_screen_flip = self.get_area_height() - self.y_screen
off_x, off_y = self.__image_position
sx, sy = self.__image_size
ui_bgl.draw_image(
self.x_screen + off_x,
y_screen_flip - off_y - sy,
sx,
sy,
self.__image,
1.0,
crop=(0, 0, 1, 1),
batch=None,
)
return True
return False
def set_mouse_down(self, mouse_down_func):
self.mouse_down_func = mouse_down_func
def mouse_down(self, x, y):
return False
def mouse_move(self, x, y):
return
def mouse_up(self, x, y):
return
@@ -0,0 +1,91 @@
import blf
import bpy
from .bl_ui_widget import BL_UI_Widget
class BL_UI_Label(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self._text_color = (1.0, 1.0, 1.0, 1.0)
self._text = "Label"
self._text_size = 16
self._halign = "LEFT"
self._valign = "TOP"
# multiline
self.multiline = False
self.row_height = 20
@property
def text_color(self):
return self._text_color
@text_color.setter
def text_color(self, value):
if value != self._text_color:
bpy.context.region.tag_redraw()
self._text_color = value
@property
def text(self):
return self._text
@text.setter
def text(self, value):
if value != self._text:
bpy.context.region.tag_redraw()
self._text = value
@property
def text_size(self):
return self._text_size
@text_size.setter
def text_size(self, value):
if value != self._text_size:
bpy.context.region.tag_redraw()
self._text_size = value
def is_in_rect(self, x, y):
return False
def draw(self):
if not self._is_visible:
return
area_height = self.get_area_height()
font_id = 1
if bpy.app.version < (4, 0, 0):
blf.size(font_id, self._text_size, 72)
else:
blf.size(font_id, self._text_size)
textpos_y = area_height - self.y_screen - self.height
r, g, b, a = self._text_color
x = self.x_screen
y = textpos_y
if self._halign != "LEFT":
width, height = blf.dimensions(font_id, self._text)
if self._halign == "RIGHT":
x -= width
elif self._halign == "CENTER":
x -= width // 2
if self._valign == "CENTER":
y -= height // 2
# bottom could be here but there's no reason for it
if not self.multiline:
blf.position(font_id, x, y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, self._text)
else:
lines = self._text.split("\n")
for line in lines:
blf.position(font_id, x, y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, line)
y -= self.row_height
@@ -0,0 +1,235 @@
import bpy
import gpu
from gpu_extras.batch import batch_for_shader
class BL_UI_Widget:
def __init__(self, x, y, width, height):
self.x = x
self.y = y
self.x_screen = x
self.y_screen = y
self.width = width
self.height = height
self._bg_color = (0.8, 0.8, 0.8, 1.0)
self._tag = None
self.context = None
self.__inrect = False
self._mouse_down = False
self._mouse_down_right = False
self._is_visible = True
self._is_active = True # if the widget needs to be disabled
def set_location(self, x, y):
# if self.x != x or self.y != y or self.x_screen != x or self.y_screen != y:
# bpy.context.region.tag_redraw()
self.x = x
self.y = y
self.x_screen = x
self.y_screen = y
self.update(x, y)
@property
def bg_color(self):
return self._bg_color
@bg_color.setter
def bg_color(self, value):
if value != self._bg_color:
bpy.context.region.tag_redraw()
self._bg_color = value
@property
def visible(self):
return self._is_visible
@visible.setter
def visible(self, value):
if value != self._is_visible:
bpy.context.region.tag_redraw()
self._is_visible = value
@property
def active(self):
return self._is_active
@visible.setter
def active(self, value):
if value != self._is_active:
bpy.context.region.tag_redraw()
self._is_active = value
@property
def tag(self):
return self._tag
@tag.setter
def tag(self, value):
self._tag = value
def draw(self):
if not self._is_visible:
return
self.shader.bind()
self.shader.uniform_float("color", self._bg_color)
self.batch_panel.draw(self.shader)
def init(self, context):
self.context = context
self.update(self.x, self.y)
def update(self, x, y):
area_height = self.get_area_height()
self.x_screen = x
self.y_screen = y
indices = ((0, 1, 2), (0, 2, 3))
y_screen_flip = area_height - self.y_screen
# bottom left, top left, top right, bottom right
vertices = (
(self.x_screen, y_screen_flip),
(self.x_screen, y_screen_flip - self.height),
(self.x_screen + self.width, y_screen_flip - self.height),
(self.x_screen + self.width, y_screen_flip),
)
if bpy.app.version < (4, 0, 0):
self.shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR")
else:
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
self.batch_panel = batch_for_shader(
self.shader, "TRIS", {"pos": vertices}, indices=indices
)
bpy.context.region.tag_redraw()
def handle_event(self, event):
"""
returns True if the event was handled by the widget
# 'handled_pass', if the event was handled but the event should be passed to other widgets
False if the event was not handled by the widget
"""
if not self._is_visible:
return False
if not self._is_active:
return False
x = event.mouse_region_x
y = event.mouse_region_y
if event.type == "LEFTMOUSE":
if event.value == "PRESS":
self._mouse_down = True
bpy.context.region.tag_redraw()
return self.mouse_down(x, y)
else:
self._mouse_down = False
bpy.context.region.tag_redraw()
self.mouse_up(x, y)
return False
elif event.type == "RIGHTMOUSE":
if event.value == "PRESS":
self._mouse_down_right = True
bpy.context.region.tag_redraw()
return self.mouse_down_right(x, y)
else:
self._mouse_down_right = False
bpy.context.region.tag_redraw()
self.mouse_up(x, y)
elif event.type == "MOUSEMOVE":
self.mouse_move(x, y)
inrect = self.is_in_rect(x, y)
# we enter the rect
if not self.__inrect and inrect:
self.__inrect = True
self.mouse_enter(event, x, y)
# we tag redraw since the hover colors are picked in the draw function
bpy.context.region.tag_redraw()
# we are leaving the rect
elif self.__inrect and not inrect:
self.__inrect = False
self.mouse_exit(event, x, y)
bpy.context.region.tag_redraw()
# return always false to enable mouse exit events on other buttons.(would sometimes not hide the tooltip)
return False # self.__inrect
elif (
event.value == "PRESS"
and self.__inrect
and (event.ascii != "" or event.type in self.get_input_keys())
):
return self.text_input(event)
return False
def get_input_keys(self):
return []
def get_area_height(self):
return self.context.area.height
def is_in_rect(self, x, y):
area_height = self.get_area_height()
widget_y = area_height - self.y_screen
if (self.x_screen <= x <= (self.x_screen + self.width)) and (
widget_y >= y >= (widget_y - self.height)
):
# print('is in rect!?')
# print('area height', area_height)
# print ('x sceen ',self.x_screen,'x ', x, 'width', self.width)
# print ('widghet y', widget_y,'y', y, 'height',self.height)
return True
return False
def text_input(self, event):
return False
def mouse_down(self, x, y):
return self.is_in_rect(x, y)
def mouse_down_right(self, x, y):
return self.is_in_rect(x, y)
def mouse_up(self, x, y):
pass
def mouse_enter_func(self, widget):
pass
def mouse_exit_func(self, widget):
pass
def set_mouse_enter(self, mouse_enter_func):
self.mouse_enter_func = mouse_enter_func
def call_mouse_enter(self):
if self.mouse_enter_func:
self.mouse_enter_func(self)
def mouse_enter(self, event, x, y):
self.call_mouse_enter()
def set_mouse_exit(self, mouse_exit_func):
self.mouse_exit_func = mouse_exit_func
def call_mouse_exit(self):
if self.mouse_exit_func:
self.mouse_exit_func(self)
def mouse_exit(self, event, x, y):
self.call_mouse_exit()
def mouse_move(self, x, y):
pass