OverviewFilesCommitsIssuesDocsReleasesPeers

hardops / ui_framework / form_ui / form.py

706 lines · python ref:
import bpy
import gpu, time
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
from math import sin, cos
from ...utility.screen import dpi_factor
from ... utility import addon
from ... utils.cursor_warp import get_screen_warp_padding
from .. graphics.draw import draw_border_lines
from .. graphics.draw import render_quad
from .. utils.checks import is_mouse_in_quad
from .. utils.geo import get_blf_text_dims
from . elements import Dims, Tips, Stats


class Row:
    def __init__(self):
        self.elements = []
        self.label = ''
        self.active = True
    

    def add_element(self, element):
        self.elements.append(element)


class DB:
    def __init__(self, context, event):
        prefs = addon.preference()
        # Colors
        self.color = Color()
        # Screen
        self.screen_width = context.area.width
        self.screen_height = context.area.height
        self.warp_pad = get_screen_warp_padding()
        # States
        self.mouse_over_form = False
        self.move_locked = False
        self.over_dot = False
        self.dot_open = False
        self.dot_dragging = False
        self.menu_just_opened = False
        self.operator_stop_building = False
        # Left
        self.form_bot_left = prefs.ui.form_pos
        # Event
        self.mouse_pos = (0,0)
        self.clicked = False
        self.click_release = True
        self.increment = 0
        self.shift = False
        self.ctrl = False
        self.alt = False
        # Locked elem
        self.locked_element = None
        self.locked_popup = None
        # Maps
        self.increment_maps = {"WHEELUPMOUSE", 'NUMPAD_PLUS', 'EQUAL', 'UP_ARROW'}
        self.decrement_maps = {"WHEELDOWNMOUSE", 'NUMPAD_MINUS', 'DOWN_ARROW', 'MINUS'}

        self.update(context, event)


    def update(self, context, event):
        self.mouse_pos = (event.mouse_region_x, event.mouse_region_y)
        self.clicked = True if event.type == 'LEFTMOUSE' and event.value == 'PRESS' else False
        self.click_release = True if event.value == 'RELEASE' else False
        self.shift = event.shift
        self.ctrl = event.ctrl
        self.alt = event.alt

        self.increment = 0
        if event.type in self.increment_maps and event.value == 'PRESS': self.increment = 1
        elif event.type in self.decrement_maps and event.value == 'PRESS': self.increment = -1


class Color:
    def __init__(self):
        c = addon.preference().color
        self.text            = c.Hops_UI_text_color
        self.secondary_text  = c.Hops_UI_secondary_text_color
        self.highlight       = c.Hops_UI_highlight_color
        self.highlight_drag  = c.Hops_UI_highlight_drag_color
        self.background      = c.Hops_UI_background_color
        self.cell_background = c.Hops_UI_cell_background_color
        self.tips_background = c.tips_background
        self.dropshadow      = c.Hops_UI_dropshadow_color
        self.border          = c.Hops_UI_border_color
        self.mouse_over      = c.Hops_UI_mouse_over_color
        self.text_highlight  = c.Hops_UI_text_highlight_color
        self.mods_highlight  = c.Hops_UI_mods_highlight_color


class Form:
    def __init__(self, context, event, dot_open=True):
        self.db = DB(context, event)
        self.db.dot_open = dot_open
        self.dims = Dims()
        self.bracket = Move_Bracket(context, self.db)
        self.dot = Dot(self.db)
        self.rows = []
        self.row_presets = {}
        self.clamped_first_launch = False
        self.menu_tollerance = 8 * dpi_factor(min=0.5)
        self.set_FAS = False
        self.FAS_OG_option = addon.preference().ui.Hops_modal_fast_ui_loc_options
        self.stats = None

    # --- ROW --- #

    def row(self):
        return Row()


    def row_insert(self, row, label='', active=True):
        self.rows.append(row)
        row.label = label
        row.active = active


    def row_activation(self, label='', active=True):
        for row in self.rows:
            if row.label == label:
                row.active = active


    def get_row_status(self, label=''):
        for row in self.rows:
            if row.label == label:
                return row.active
        return False

    # --- DOT --- #

    def dot_calls(self, LR_callback=None, LR_args=None, UD_callback=None, UD_args=None, scroll_func=None, scroll_pos_args=None, scroll_neg_args=None, tips=[], font_size=12):
        '''Pass in functions and tuples for dot callbacks.'''

        self.dot.tips = Tips(tips, font_size)
        self.dot.drag_LR_func = LR_callback
        self.dot.drag_LR_args = LR_args
        self.dot.drag_UD_func = UD_callback
        self.dot.drag_UD_args = UD_args
        self.dot.scroll_func = scroll_func
        self.dot.scroll_pos_args = scroll_pos_args
        self.dot.scroll_neg_args = scroll_neg_args


    def insert_stats(self, stats=[], font_size=12):
        self.stats = Stats(stats=stats, font_size=font_size)


    def close_dot(self):
        self.db.dot_open = False


    def open_dot(self):
        self.db.dot_open = True


    def is_dot_open(self):
        return self.db.dot_open


    def active(self):
        # Just when dot is showing
        if self.db.dot_open == False:
            if self.db.over_dot: return True
            if self.db.dot_dragging: return True
            return False

        # Full open menu
        if self.db.locked_element: return True
        if self.db.locked_popup: return True
        if self.db.move_locked: return True
        if self.db.mouse_over_form: return True
        if self.db.over_dot: return True
        return False

    # --- OP --- #

    def operator_stop_building(self):
        return self.db.operator_stop_building

    # --- BASE --- #

    def build(self, preserve_top_left=False):

        self.db.operator_stop_building = True

        # Preserve
        top_left_y = self.dims.top_left[1]

        bot_left = self.db.form_bot_left

        h_offset = bot_left[1]
        for row in reversed(self.rows):
            if row.active == False: continue

            x_offset = bot_left[0]

            max_height = 0
            for elem in row.elements:
                elem.build(self.db, x_offset, h_offset)
                x_offset += elem.dims.max_width
                max_height = elem.dims.max_height if elem.dims.max_height > max_height else max_height

            h_offset += max_height

        # Get overall dims
        max_w = 0
        max_h = 0
        for row in reversed(self.rows):
            if row.active == False: continue

            w = sum([e.dims.max_width for e in row.elements])
            if w > max_w: max_w = w

            h = 0
            for elem in row.elements:
                h = elem.dims.max_height if elem.dims.max_height > h else h

            max_w = w if w > max_w else max_w
            max_h += h

        # Set dims
        pad = self.dims.padding
        self.dims.bot_left  = (bot_left[0] - pad        , bot_left[1] - pad)
        self.dims.top_left  = (bot_left[0] - pad        , bot_left[1] + max_h + pad)
        self.dims.top_right = (bot_left[0] + pad + max_w, bot_left[1] + max_h + pad)
        self.dims.bot_right = (bot_left[0] + pad + max_w, bot_left[1] - pad)

        self.dims.max_width = abs(self.dims.bot_right[0] - self.dims.bot_left[0])
        self.dims.max_height = abs(self.dims.top_left[1] - self.dims.bot_left[1]) + self.dot.radius * 2

        if self.clamped_first_launch == False:
            self.clamped_first_launch = True
            self.bracket.clamp(self.db, self.dims)
            self.build()

        self.bracket.build(self.dims)
        self.dot.build(self.db, self.dims)

        if self.stats:
            y = self.dims.top_right[1] - get_blf_text_dims("XyZ", self.stats.font_size)[1]
            self.stats.build(self.db, top_left_x=self.dims.top_right[0] + pad, top_left_y=y)

        # Preserve
        if preserve_top_left:
            x = self.db.form_bot_left[0]
            y = self.db.form_bot_left[1]
            diff = top_left_y - self.dims.top_left[1]
            addon.preference().ui.form_pos = (x, y + diff)
            self.build()


    def update(self, context, event, return_on_timer=True):

        self.db.operator_stop_building = False

        self.db.update(context, event)

        # Closed menu update
        if self.db.dot_open == False:
            # FAS
            if self.set_FAS:
                self.set_FAS = False
                addon.preference().ui.Hops_modal_fast_ui_loc_options = self.FAS_OG_option
            # DOT
            self.dot.update(event, self.db)
            return

        # Locked : Dot
        if self.dot.drag_locked:
            self.dot.update(event, self.db)
            return

        # Locked : Popup
        if self.db.locked_popup:
            self.db.locked_popup.locked_update(context, event, self.db)
            return

        # Locked : Element
        if self.db.locked_element:
            self.db.locked_element.locked_update(context, event, self.db)
            return

        # Locked : Move
        elif self.db.move_locked:
            self.bracket.move(self.db)
            self.bracket.clamp(self.db, self.dims)
            self.build()
            return

        # Clamp Window Location
        if self.bracket.clamp(self.db, self.dims):
            self.build()

        # Change FAS UI Loc
        if self.db.dot_open:
            self.set_FAS = True
            addon.preference().ui.Hops_modal_fast_ui_loc_options = 1

        if return_on_timer:
            if event.type == 'TIMER': return

        self.dot.update(event, self.db)

        self.bracket.update(self.db)

        self.db.mouse_over_form = is_mouse_in_quad(self.dims.quad(), self.db.mouse_pos, tolerance=self.menu_tollerance)

        for row in self.rows:
            if row.active == False: continue

            for elem in row.elements:
                elem.update(context, event, self.db)


    def draw(self):
        # Dot
        self.dot.draw()
        if self.db.dot_open == False: return

        # Stats
        if self.stats:
            self.stats.draw()

        # Background
        render_quad(self.dims.quad(), color=self.db.color.cell_background)
        draw_border_lines(self.dims.quad(), color=self.db.color.border)
        # Bracket
        self.bracket.draw()
        # Elements
        for row in self.rows:
            if row.active == False: continue

            for elem in row.elements:
                if elem != self.db.locked_element:
                    elem.draw(self.db)
        
        # Tips
        if not self.db.locked_popup:
            for row in self.rows:
                if row.active == False: continue
                for elem in row.elements:
                    if elem != self.db.locked_element:
                        elem.draw_tips(self.db)

        # Locked : Popup
        if self.db.locked_popup:
            self.db.locked_popup.draw(self.db)
            return

        # Locked : Element
        if self.db.locked_element:
            self.db.locked_element.draw(self.db)
            return


    def shut_down(self, context):
        for row in self.rows:
            for elem in row.elements:
                elem.shut_down(context)


class Move_Bracket:
    def __init__(self, context, db):
        self.v_dims = Dims()
        self.h_dims = Dims()
        self.pad = 6 * dpi_factor(min=0.5)
        self.bg_color = db.color.cell_background
        self.border_color = db.color.border
        self.mouse_move_offset = (0,0)

    
    def build(self, form_dims):

        thick = 4 * dpi_factor(min=0.5)
        bot_x = form_dims.bot_left[0] - self.pad - thick
        bot_y = form_dims.bot_left[1] - self.pad - thick
        
        h = abs(form_dims.top_left[1] - form_dims.bot_left[1]) * .75
        w = abs(form_dims.bot_right[0] - form_dims.bot_left[0]) * .75

        self.v_dims.bot_left  = (bot_x, bot_y)
        self.v_dims.top_left  = (bot_x, bot_y + h)
        self.v_dims.top_right = (bot_x + thick, bot_y + h)
        self.v_dims.bot_right = (bot_x + thick, bot_y)

        bot_x += thick
        self.h_dims.bot_left  = (bot_x, bot_y)
        self.h_dims.top_left  = (bot_x, bot_y + thick)
        self.h_dims.top_right = (bot_x + w, bot_y + thick)
        self.h_dims.bot_right = (bot_x + w, bot_y)


    def update(self, db):
        over_v = is_mouse_in_quad(self.v_dims.quad(), db.mouse_pos, tolerance=self.pad)
        over_h = is_mouse_in_quad(self.h_dims.quad(), db.mouse_pos, tolerance=self.pad)
        if over_v or over_h:
            self.bg_color = db.color.mouse_over
            if db.clicked:
                db.move_locked = True
                self.mouse_move_offset = (
                    db.form_bot_left[0] - db.mouse_pos[0],
                    db.form_bot_left[1] - db.mouse_pos[1])
        else:
            self.bg_color = db.color.cell_background
            db.move_locked = False


    def move(self, db):
        if db.click_release:
            db.move_locked = False
            return

        x = db.mouse_pos[0] + self.mouse_move_offset[0]
        y = db.mouse_pos[1] + self.mouse_move_offset[1]
        addon.preference().ui.form_pos = (x, y)


    def clamp(self, db, dims):

        x, y = addon.preference().ui.form_pos
        new_x = x
        new_y = y

        dot_offset = addon.preference().ui.Hops_form_dot_offset * dpi_factor(min=0.5) 
        dot_diam = 20 * dpi_factor(min=0.5) 

        max_x = db.screen_width - db.warp_pad - dims.max_width
        max_y = db.screen_height - db.warp_pad - dims.max_height - dot_offset

        min_x = db.warp_pad + dot_offset + dot_diam

        # Left
        if x < min_x:
            new_x = min_x
        # Right
        if x > max_x:
            new_x = max_x
        # Top
        if y > max_y:
            new_y = max_y
        # Bottom
        if y < db.warp_pad:
            new_y = db.warp_pad

        addon.preference().ui.form_pos = (new_x, new_y)

        if (new_x != x) or (new_y != y):
            return True
        else:
            return False


    def draw(self):
        render_quad(self.v_dims.quad(), color=self.bg_color, bevel_corners=False)
        render_quad(self.h_dims.quad(), color=self.bg_color, bevel_corners=False)


class Dot:
    def __init__(self, db):
        # Dims
        self.pos = (0,0)
        self.radius = 10 * dpi_factor(min=0.5)
        # Shader / Settings
        built_in_shader = 'UNIFORM_COLOR' if bpy.app.version[0] >=4 else '2D_UNIFORM_COLOR'
        self.shader = gpu.shader.from_builtin(built_in_shader)
        self.segments = 32
        # Outer
        self.outer_color = db.color.border
        self.outer_verts = []
        self.outer_batch = None
        # Inner
        self.inner_color = db.color.cell_background
        self.inner_verts = []
        self.inner_indices = []
        self.inner_batch = None
        # State
        self.drag_locked = False
        self.mouse_over = False
        self.mouse_start_pos = (0,0)
        self.mouse_distance = 0
        self.mouse_step = 0
        self.drag_testing = False
        # Tips
        self.tips = None
        # Drag States : Left Right
        self.drag_LR_running = False
        self.drag_LR_func = None
        self.drag_LR_args = None
        # Drag States : Up Down
        self.drag_UD_running = False
        self.drag_UD_func = None
        self.drag_UD_args = None
        # Scroll
        self.scroll_func = None
        self.scroll_pos_args = None
        self.scroll_neg_args = None
        # Dot Detection
        self.dot_detection_padding = addon.preference().ui.Hops_dot_detection_padding
        self.dot_offset = addon.preference().ui.Hops_form_dot_offset * dpi_factor(min=0.5) 


    def build(self, db, dims):
        # Update
        self.pos = (
            dims.top_left[0] - self.dot_offset,
            dims.top_left[1] + self.dot_offset)
        # Build
        self.__outer_batch()
        self.__inner_batch()

        if not self.tips: return
        bot_x = self.pos[0] + self.radius
        bot_y = self.pos[1] + self.radius
        self.tips.build(db, bot_x, bot_y)


    def __outer_batch(self):
        # Clear
        self.outer_verts = []
        self.outer_batch = None
        # Build
        for i in range(self.segments):
            index = i + 1
            angle = i * 3.14159 * 2 / self.segments
            x = (cos(angle) * self.radius) + self.pos[0]
            y = (sin(angle) * self.radius) + self.pos[1]
            self.outer_verts.append((x, y))
        self.outer_verts.append(self.outer_verts[0])
        # Batch
        self.outer_batch = batch_for_shader(self.shader, 'LINE_STRIP', {"pos": self.outer_verts})


    def __inner_batch(self):
        # Clear
        self.inner_batch = None
        self.inner_indices = []
        # Build
        self.inner_verts = self.outer_verts[:]
        for i in range(self.segments):
            if i == len(self.inner_verts): break
            self.inner_indices.append((0, i, i + 1))
        # Batch
        self.inner_batch = batch_for_shader(self.shader, 'TRIS', {'pos': self.inner_verts}, indices=self.inner_indices)


    def update(self, event, db):
        
        # Reset
        self.mouse_over = False
        db.menu_just_opened = False
        db.dot_dragging = False

        # Drag locked
        if self.drag_locked:
            db.dot_dragging = True
            self.__drag_event(event, db)
            return

        # Testing for drag / regular click
        if self.drag_testing:
            db.over_dot = True
            self.__drag_test(event, db)
            return

        # Mouse over
        radius = self.radius
        if db.dot_open == False: # Increase detection size when closed
            radius += self.dot_detection_padding
        if abs(db.mouse_pos[0] - self.pos[0]) < radius:
            if abs(db.mouse_pos[1] - self.pos[1]) < radius:
                self.mouse_over = True

        # Color
        if self.mouse_over: self.outer_color = db.color.mods_highlight
        else: self.outer_color = db.color.border

        # Start click
        self.drag_locked = False
        self.drag_testing = False
        if self.mouse_over:
            db.over_dot = True
            self.__click_event(event, db)
            self.__scroll_event(event, db)
        else:
            db.over_dot = False


    def __click_event(self, event, db):
        if db.clicked:
            self.drag_testing = True
            self.mouse_start_pos = db.mouse_pos
            self.mouse_distance = 0
            self.mouse_step = 0


    def __scroll_event(self, event, db):
        if self.drag_testing: return
        if not db.increment: return
        if not self.scroll_func: return

        if db.increment > 0:
            if self.scroll_pos_args:
                self.scroll_func(*self.scroll_pos_args)
            else: self.scroll_func()
        else:
            if self.scroll_neg_args:
                self.scroll_func(*self.scroll_neg_args)
            else: self.scroll_func()


    def __drag_test(self, event, db):

        self.mouse_distance = (Vector(self.mouse_start_pos) - Vector(db.mouse_pos)).magnitude

        # No turning back now
        if self.mouse_distance > self.radius:
            self.drag_locked = True
            self.drag_LR_running = False
            self.drag_UD_running = False

            # Test for angle
            up = Vector((0, 1))
            down = Vector((0,-1))
            left = Vector((-1, 0))
            right = Vector((1, 0))

            mouse_vec = Vector(self.mouse_start_pos) - Vector(db.mouse_pos)
            mouse_vec.normalize()

            angles = [
                up.angle(mouse_vec),
                down.angle(mouse_vec),
                left.angle(mouse_vec),
                right.angle(mouse_vec)]

            minpos = angles.index(min(angles))

            if minpos in (0, 1): self.drag_UD_running = True
            else: self.drag_LR_running = True
            return

        # Just a click and exit
        if db.click_release:
            self.drag_testing = False
            self.drag_locked = False
            db.menu_just_opened = True
            db.dot_open = not db.dot_open


    def __drag_event(self, event, db):

        self.mouse_distance = (Vector(self.mouse_start_pos) - Vector(db.mouse_pos)).magnitude

        # Finished
        if db.click_release:
            self.drag_testing = False
            self.drag_locked = False
            return

        # Step amount
        step = 75 * dpi_factor(min=0.5) if event.shift else 50 * dpi_factor(min=0.5)
        if self.mouse_distance > step:
            self.mouse_start_pos = db.mouse_pos
        else: return

        # Drag : Shift
        if self.drag_LR_running:
            if self.drag_LR_func:
                if self.drag_LR_args:
                    self.drag_LR_func(*self.drag_LR_args)
                else:
                    self.drag_LR_func()

        # Drag : Default
        else:
            if self.drag_UD_func:
                if self.drag_UD_args:
                    self.drag_UD_func(*self.drag_UD_args)
                else:
                    self.drag_UD_func()


    def draw(self):
        #Enable(bgl.GL_LINE_SMOOTH)
        gpu.state.blend_set('ALPHA')

        self.shader.bind()

        if self.inner_batch:
            self.shader.uniform_float("color", self.inner_color)
            self.inner_batch.draw(self.shader)

        if self.outer_batch:
            gpu.state.line_width_set(3)
            self.shader.uniform_float("color", self.outer_color)
            self.outer_batch.draw(self.shader)

        if self.mouse_over and self.tips:
            self.tips.draw()

        #Disable(bgl.GL_LINE_SMOOTH)
        gpu.state.blend_set('NONE')