blender/release/scripts/startup/bl_ui/space_toolsystem_common.py
Campbell Barton 1889eec918 UI: use regular size icons for toolbar popup
Test this since the popup feels disruptive/flashing when its too large
when set smaller it looks closer to a menu w/ key-accelerators which is
the intention in this case.

It's also more likely the active tool can be placed under the cursor.
2018-06-06 08:32:25 +02:00

684 lines
23 KiB
Python

# ##### 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 #####
# <pep8 compliant>
import bpy
from bpy.types import (
Menu,
)
__all__ = (
"ToolSelectPanelHelper",
"ToolDef",
)
# Support reloading icons.
if "_icon_cache" in locals():
release = bpy.app.icons.release
for icon_value in _icon_cache.values():
if icon_value != 0:
release(icon_value)
del release
# (filename -> icon_value) map
_icon_cache = {}
def _keymap_fn_from_seq(keymap_data):
# standalone
def _props_assign_recursive(rna_props, py_props):
for prop_id, value in py_props.items():
if isinstance(value, dict):
_props_assign_recursive(getattr(rna_props, prop_id), value)
else:
setattr(rna_props, prop_id, value)
def keymap_fn(km):
for op_idname, op_props_dict, kmi_kwargs in keymap_fn.keymap_data:
kmi = km.keymap_items.new(op_idname, **kmi_kwargs)
kmi_props = kmi.properties
if op_props_dict:
_props_assign_recursive(kmi.properties, op_props_dict)
keymap_fn.keymap_data = keymap_data
return keymap_fn
def _item_is_fn(item):
return (not (type(item) is ToolDef) and callable(item))
from collections import namedtuple
ToolDef = namedtuple(
"ToolDef",
(
# The name to display in the interface.
"text",
# The name of the icon to use (found in ``release/datafiles/icons``) or None for no icon.
"icon",
# An optional cursor to use when this tool is active.
"cursor",
# An optional manipulator group to activate when the tool is set or None for no widget.
"widget",
# Optional keymap for tool, either:
# - A function that populates a keymaps passed in as an argument.
# - A tuple filled with triple's of:
# ``(operator_id, operator_properties, keymap_item_args)``.
#
# Warning: currently 'from_dict' this is a list of one item,
# so internally we can swap the keymap function for the keymap it's self.
# This isn't very nice and may change, tool definitions shouldn't care about this.
"keymap",
# Optional data-block assosiated with this tool.
# (Typically brush name, usage depends on mode, we could use for non-brush ID's in other modes).
"data_block",
# Optional draw settings (operator options, toolsettings).
"draw_settings",
)
)
del namedtuple
def from_dict(kw_args):
"""
Use so each tool can avoid defining all members of the named tuple.
Also convert the keymap from a tuple into a function
(since keymap is a callback).
"""
kw = {
"icon": None,
"cursor": None,
"widget": None,
"keymap": None,
"data_block": None,
"draw_settings": None,
}
kw.update(kw_args)
keymap = kw["keymap"]
if kw["keymap"] is None:
pass
elif type(keymap) is tuple:
keymap = [_keymap_fn_from_seq(keymap)]
else:
keymap = [keymap]
kw["keymap"] = keymap
return ToolDef(**kw)
def from_fn(fn):
"""
Use as decorator so we can define functions.
"""
return ToolDef.from_dict(fn())
ToolDef.from_dict = from_dict
ToolDef.from_fn = from_fn
del from_dict
del from_fn
class ToolSelectPanelHelper:
"""
Generic Class, can be used for any toolbar.
- keymap_prefix:
The text prefix for each key-map for this spaces tools.
- tools_all():
Returns (context_mode, tools) tuple pair for all tools defined.
- tools_from_context(context, mode=None):
Returns tools available in this context.
Each tool is a 'ToolDef' or None for a separator in the toolbar, use ``None``.
"""
@staticmethod
def _tool_class_from_space_type(space_type):
return next(
(cls for cls in ToolSelectPanelHelper.__subclasses__()
if cls.bl_space_type == space_type),
None
)
@staticmethod
def _icon_value_from_icon_handle(icon_name):
import os
if icon_name is not None:
assert(type(icon_name) is str)
icon_value = _icon_cache.get(icon_name)
if icon_value is None:
dirname = bpy.utils.resource_path('LOCAL')
if not os.path.exists(dirname):
# TODO(campbell): use a better way of finding datafiles.
dirname = bpy.utils.resource_path('SYSTEM')
filename = os.path.join(dirname, "datafiles", "icons", icon_name + ".dat")
try:
icon_value = bpy.app.icons.new_triangles_from_file(filename)
except Exception as ex:
if not os.path.exists(filename):
print("Missing icons:", filename, ex)
else:
print("Corrupt icon:", filename, ex)
# Use none as a fallback (avoids layout issues).
if icon_name != "none":
icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle("none")
else:
icon_value = 0
_icon_cache[icon_name] = icon_value
return icon_value
else:
return 0
@staticmethod
def _tools_flatten(tools):
"""
Flattens, skips None and calls generators.
"""
for item in tools:
if item is None:
yield None
elif type(item) is tuple:
for sub_item in item:
if sub_item is None:
yield None
elif _item_is_fn(sub_item):
yield from sub_item(context)
else:
yield sub_item
else:
if _item_is_fn(item):
yield from item(context)
else:
yield item
@staticmethod
def _tools_flatten_with_tool_index(tools):
for item in tools:
if item is None:
yield None, -1
elif type(item) is tuple:
i = 0
for sub_item in item:
if sub_item is None:
yield None
elif _item_is_fn(sub_item):
for item_dyn in sub_item(context):
yield item_dyn, i
i += 1
else:
yield sub_item, i
i += 1
else:
if _item_is_fn(item):
for item_dyn in item(context):
yield item_dyn, -1
else:
yield item, -1
@staticmethod
def _tool_get_active(context, space_type, mode, with_icon=False):
"""
Return the active Python tool definition and icon name.
"""
workspace = context.workspace
cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
if cls is not None:
tool_active = ToolSelectPanelHelper._tool_active_from_context(context, space_type, mode)
tool_active_text = getattr(tool_active, "name", None)
for item in ToolSelectPanelHelper._tools_flatten(cls.tools_from_context(context, mode)):
if item is not None:
if item.text == tool_active_text:
if with_icon:
icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
else:
icon_value = 0
return (item, tool_active, icon_value)
return None, None, 0
@staticmethod
def _tool_get_by_name(context, space_type, text):
"""
Return the active Python tool definition and index (if in sub-group, else -1).
"""
cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
if cls is not None:
for item, index in ToolSelectPanelHelper._tools_flatten_with_tool_index(cls.tools_from_context(context)):
if item is not None:
if item.text == text:
return (cls, item, index)
return None, None, -1
@staticmethod
def _tool_active_from_context(context, space_type, mode=None, create=False):
if space_type == 'VIEW_3D':
if mode is None:
mode = context.mode
tool = context.workspace.tools.from_space_view3d_mode(mode, create)
if tool is not None:
return tool
elif space_type == 'IMAGE_EDITOR':
space_data = context.space_data
if mode is None:
mode = space_data.mode
tool = context.workspace.tools.from_space_image_mode(mode, create)
if tool is not None:
return tool
return None
@staticmethod
def _tool_text_from_button(context):
return context.button_operator.name
@classmethod
def _km_action_simple(cls, kc, context_mode, text, keymap_fn):
if context_mode is None:
context_mode = "All"
km_idname = f"{cls.keymap_prefix} {context_mode}, {text}"
km = kc.keymaps.get(km_idname)
if km is None:
km = kc.keymaps.new(km_idname, space_type=cls.bl_space_type, region_type='WINDOW')
keymap_fn[0](km)
keymap_fn[0] = km
@classmethod
def register(cls):
wm = bpy.context.window_manager
# XXX, should we be manipulating the user-keyconfig on load?
# Perhaps this should only add when keymap items don't already exist.
#
# This needs some careful consideration.
kc = wm.keyconfigs.user
# Track which tool-group was last used for non-active groups.
# Blender stores the active tool-group index.
#
# {tool_name_first: index_in_group, ...}
cls._tool_group_active = {}
# ignore in background mode
if kc is None:
return
for context_mode, tools in cls.tools_all():
for item_parent in tools:
if item_parent is None:
continue
for item in item_parent if (type(item_parent) is tuple) else (item_parent,):
# skip None or generator function
if item is None or _item_is_fn(item):
continue
keymap_data = item.keymap
if keymap_data is not None and callable(keymap_data[0]):
text = item.text
icon_name = item.icon
cls._km_action_simple(kc, context_mode, text, keymap_data)
# -------------------------------------------------------------------------
# Layout Generators
#
# Meaning of recieved values:
# - Bool: True for a separator, otherwise False for regular tools.
# - None: Signal to finish (complete any final operations, e.g. add padding).
@staticmethod
def _layout_generator_single_column(layout, scale_y):
col = layout.column(align=True)
col.scale_y = scale_y
is_sep = False
while True:
if is_sep is True:
col = layout.column(align=True)
col.scale_y = scale_y
elif is_sep is None:
yield None
return
is_sep = yield col
@staticmethod
def _layout_generator_multi_columns(layout, column_count, scale_y):
scale_x = scale_y * 1.1
column_last = column_count - 1
col = layout.column(align=True)
row = col.row(align=True)
row.scale_x = scale_x
row.scale_y = scale_y
is_sep = False
column_index = 0
while True:
if is_sep is True:
if column_index != column_last:
row.label("")
col = layout.column(align=True)
row = col.row(align=True)
row.scale_x = scale_x
row.scale_y = scale_y
column_index = 0
is_sep = yield row
if is_sep is None:
if column_index == column_last:
row.label("")
yield None
return
if column_index == column_count:
column_index = 0
row = col.row(align=True)
row.scale_x = scale_x
row.scale_y = scale_y
column_index += 1
@staticmethod
def _layout_generator_detect_from_region(layout, region, scale_y):
"""
Choose an appropriate layout for the toolbar.
"""
# Currently this just checks the width,
# we could have different layouts as preferences too.
system = bpy.context.user_preferences.system
view2d = region.view2d
view2d_scale = (
view2d.region_to_view(1.0, 0.0)[0] -
view2d.region_to_view(0.0, 0.0)[0]
)
width_scale = region.width * view2d_scale / system.ui_scale
if width_scale > 120.0:
show_text = True
column_count = 1
else:
show_text = False
# 2 column layout, disabled
if width_scale > 80.0:
column_count = 2
use_columns = True
else:
column_count = 1
if column_count == 1:
ui_gen = ToolSelectPanelHelper._layout_generator_single_column(layout, scale_y=scale_y)
else:
ui_gen = ToolSelectPanelHelper._layout_generator_multi_columns(layout, column_count=column_count, scale_y=scale_y)
return ui_gen, show_text
@classmethod
def draw_cls(cls, layout, context, detect_layout=True, scale_y=2.0):
# Use a classmethod so it can be called outside of a panel context.
# XXX, this UI isn't very nice.
# We might need to create new button types for this.
# Since we probably want:
# - tool-tips that include multiple key shortcuts.
# - ability to click and hold to expose sub-tools.
space_type = context.space_data.type
tool_active_text = getattr(
ToolSelectPanelHelper._tool_active_from_context(context, space_type),
"name", None,
)
if detect_layout:
ui_gen, show_text = cls._layout_generator_detect_from_region(layout, context.region, scale_y)
else:
ui_gen = ToolSelectPanelHelper._layout_generator_single_column(layout, scale_y)
show_text = True
# Start iteration
ui_gen.send(None)
for item in cls.tools_from_context(context):
if item is None:
ui_gen.send(True)
continue
if type(item) is tuple:
is_active = False
i = 0
for i, sub_item in enumerate(item):
if sub_item is None:
continue
is_active = (sub_item.text == tool_active_text)
if is_active:
index = i
break
del i, sub_item
if is_active:
# not ideal, write this every time :S
cls._tool_group_active[item[0].text] = index
else:
index = cls._tool_group_active.get(item[0].text, 0)
item = item[index]
use_menu = True
else:
index = -1
use_menu = False
is_active = (item.text == tool_active_text)
icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
sub = ui_gen.send(False)
if use_menu:
sub.operator_menu_hold(
"wm.tool_set_by_name",
text=item.text if show_text else "",
depress=is_active,
menu="WM_MT_toolsystem_submenu",
icon_value=icon_value,
).name = item.text
else:
sub.operator(
"wm.tool_set_by_name",
text=item.text if show_text else "",
depress=is_active,
icon_value=icon_value,
).name = item.text
# Signal to finish any remaining layout edits.
ui_gen.send(None)
def draw(self, context):
self.draw_cls(self.layout, context)
@staticmethod
def draw_active_tool_header(context, layout):
# BAD DESIGN WARNING: last used tool
workspace = context.workspace
space_type = workspace.tools_space_type
mode = workspace.tools_mode
item, tool, icon_value = ToolSelectPanelHelper._tool_get_active(context, space_type, mode, with_icon=True)
if item is None:
return
# Note: we could show 'item.text' here but it makes the layout jitter when switcuing tools.
layout.label(" ", icon_value=icon_value)
draw_settings = item.draw_settings
if draw_settings is not None:
draw_settings(context, layout, tool)
# The purpose of this menu is to be a generic popup to select between tools
# in cases when a single tool allows to select alternative tools.
class WM_MT_toolsystem_submenu(Menu):
bl_label = ""
@staticmethod
def _tool_group_from_button(context):
# Lookup the tool definitions based on the space-type.
cls = ToolSelectPanelHelper._tool_class_from_space_type(context.space_data.type)
if cls is not None:
button_text = ToolSelectPanelHelper._tool_text_from_button(context)
for item_group in cls.tools_from_context(context):
if type(item_group) is tuple:
for sub_item in item_group:
if sub_item.text == button_text:
return cls, item_group
return None, None
def draw(self, context):
layout = self.layout
layout.scale_y = 2.0
cls, item_group = self._tool_group_from_button(context)
if item_group is None:
# Should never happen, just in case
layout.label("Unable to find toolbar group")
return
for item in item_group:
if item is None:
layout.separator()
continue
icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
layout.operator(
"wm.tool_set_by_name",
text=item.text,
icon_value=icon_value,
).name = item.text
def _activate_by_item(context, space_type, item, index):
tool = ToolSelectPanelHelper._tool_active_from_context(context, space_type, create=True)
tool.setup(
name=item.text,
keymap=item.keymap[0].name if item.keymap is not None else "",
cursor=item.cursor or 'DEFAULT',
manipulator_group=item.widget or "",
data_block=item.data_block or "",
index=index,
)
def activate_by_name(context, space_type, text):
cls, item, index = ToolSelectPanelHelper._tool_get_by_name(context, space_type, text)
if item is None:
return False
_activate_by_item(context, space_type, item, index)
return True
def activate_by_name_or_cycle(context, space_type, text, offset=1):
# Only cycle when the active tool is activated again.
cls, item, index = ToolSelectPanelHelper._tool_get_by_name(context, space_type, text)
if item is None:
return False
tool_active = ToolSelectPanelHelper._tool_active_from_context(context, space_type)
text_active = getattr(tool_active, "name", None)
text_current = ""
for item_group in cls.tools_from_context(context):
if type(item_group) is tuple:
index_current = cls._tool_group_active.get(item_group[0].text, 0)
ok = False
for i, sub_item in enumerate(item_group):
if sub_item.text == text:
text_current = item_group[index_current].text
break
if text_current:
break
if text_current == "":
return activate_by_name(context, space_type, text)
if text_active != text_current:
return activate_by_name(context, space_type, text_current)
index_found = (tool_active.index + offset) % len(item_group)
cls._tool_group_active[item_group[0].text] = index_found
item_found = item_group[index_found]
_activate_by_item(context, space_type, item_found, index_found)
return True
def keymap_from_context(context, space_type):
"""
Keymap for popup toolbar, currently generated each time.
"""
use_simple_keymap = False
km_name = "Toolbar Popup"
wm = context.window_manager
keyconf = wm.keyconfigs.active
keymap = keyconf.keymaps.get(km_name)
if keymap is None:
keymap = keyconf.keymaps.new(km_name, space_type='EMPTY', region_type='TEMPORARY')
for kmi in keymap.keymap_items:
keymap.keymap_items.remove(kmi)
items = []
cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
for i, item in enumerate(
ToolSelectPanelHelper._tools_flatten(cls.tools_from_context(context))
):
if item is not None:
if use_simple_keymap:
# Simply assign a key from A-Z
items.append(((chr(ord('A') + i)), item.text))
kmi = keymap.keymap_items.new("wm.tool_set_by_name", key, 'PRESS')
kmi.properties.name = item.text
continue
if not item.keymap:
continue
# Only check the first item in the tools key-map (a little arbitrary).
kmi_first = item.keymap[0].keymap_items[0]
kmi_found = wm.keyconfigs.find_item_from_operator(
idname=kmi_first.idname,
# properties=kmi_first.properties, # prevents matches, don't use.
)[1]
if kmi_found is not None:
kmi_found_type = kmi_found.type
# Only for single keys.
if len(kmi_found_type) == 1:
kmi = keymap.keymap_items.new(
idname="wm.tool_set_by_name",
type=kmi_found_type,
value='PRESS',
any=kmi_found.any,
shift=kmi_found.shift,
ctrl=kmi_found.ctrl,
alt=kmi_found.alt,
oskey=kmi_found.oskey,
key_modifier=kmi_found.key_modifier,
)
kmi.properties.name = item.text
wm.keyconfigs.update()
return keymap
classes = (
WM_MT_toolsystem_submenu,
)
if __name__ == "__main__": # only for live edit.
from bpy.utils import register_class
for cls in classes:
register_class(cls)