aa9e0bf979fd380dabfba2ab5d752a0763b560c2
[blender.git] / release / scripts / startup / bl_ui / space_toolsystem_common.py
1 # ##### BEGIN GPL LICENSE BLOCK #####
2 #
3 #  This program is free software; you can redistribute it and/or
4 #  modify it under the terms of the GNU General Public License
5 #  as published by the Free Software Foundation; either version 2
6 #  of the License, or (at your option) any later version.
7 #
8 #  This program is distributed in the hope that it will be useful,
9 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
10 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 #  GNU General Public License for more details.
12 #
13 #  You should have received a copy of the GNU General Public License
14 #  along with this program; if not, write to the Free Software Foundation,
15 #  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 #
17 # ##### END GPL LICENSE BLOCK #####
18
19 # <pep8 compliant>
20 import bpy
21 from bpy.types import (
22     Menu,
23 )
24
25 __all__ = (
26     "ToolDef",
27     "ToolSelectPanelHelper",
28     "activate_by_id",
29     "activate_by_id_or_cycle",
30     "description_from_id",
31     "keymap_from_id",
32 )
33
34 # Support reloading icons.
35 if "_icon_cache" in locals():
36     release = bpy.app.icons.release
37     for icon_value in _icon_cache.values():
38         if icon_value != 0:
39             release(icon_value)
40     del release
41
42
43 # (filename -> icon_value) map
44 _icon_cache = {}
45
46
47 def _keymap_fn_from_seq(keymap_data):
48
49     def keymap_fn(km):
50         if keymap_fn.keymap_data:
51             from bl_keymap_utils.io import keymap_init_from_data
52             keymap_init_from_data(km, keymap_fn.keymap_data)
53     keymap_fn.keymap_data = keymap_data
54     return keymap_fn
55
56
57 def _item_is_fn(item):
58     return (not (type(item) is ToolDef) and callable(item))
59
60
61 from collections import namedtuple
62 ToolDef = namedtuple(
63     "ToolDef",
64     (
65         # Unique tool name (withing space & mode context).
66         "idname",
67         # The name to display in the interface.
68         "label",
69         # Description (for tooltip), when not set, use the description of 'operator',
70         # may be a string or a 'function(context, item, keymap) -> string'.
71         "description",
72         # The name of the icon to use (found in ``release/datafiles/icons``) or None for no icon.
73         "icon",
74         # An optional cursor to use when this tool is active.
75         "cursor",
76         # An optional gizmo group to activate when the tool is set or None for no gizmo.
77         "widget",
78         # Optional keymap for tool, either:
79         # - A function that populates a keymaps passed in as an argument.
80         # - A tuple filled with triple's of:
81         #   ``(operator_id, operator_properties, keymap_item_args)``.
82         #
83         # Warning: currently 'from_dict' this is a list of one item,
84         # so internally we can swap the keymap function for the keymap it's self.
85         # This isn't very nice and may change, tool definitions shouldn't care about this.
86         "keymap",
87         # Optional data-block assosiated with this tool.
88         # (Typically brush name, usage depends on mode, we could use for non-brush ID's in other modes).
89         "data_block",
90         # Optional primary operator (for introspection only).
91         "operator",
92         # Optional draw settings (operator options, tool_settings).
93         "draw_settings",
94         # Optional draw cursor.
95         "draw_cursor",
96     )
97 )
98 del namedtuple
99
100
101 def from_dict(kw_args):
102     """
103     Use so each tool can avoid defining all members of the named tuple.
104     Also convert the keymap from a tuple into a function
105     (since keymap is a callback).
106     """
107     kw = {
108         "description": None,
109         "icon": None,
110         "cursor": None,
111         "widget": None,
112         "keymap": None,
113         "data_block": None,
114         "operator": None,
115         "draw_settings": None,
116         "draw_cursor": None,
117     }
118     kw.update(kw_args)
119
120     keymap = kw["keymap"]
121     if keymap is None:
122         pass
123     elif type(keymap) is tuple:
124         keymap = [_keymap_fn_from_seq(keymap)]
125     else:
126         keymap = [keymap]
127     kw["keymap"] = keymap
128     return ToolDef(**kw)
129
130
131 def from_fn(fn):
132     """
133     Use as decorator so we can define functions.
134     """
135     return ToolDef.from_dict(fn())
136
137
138 def with_args(**kw):
139     def from_fn(fn):
140         return ToolDef.from_dict(fn(**kw))
141     return from_fn
142
143
144 from_fn.with_args = with_args
145 ToolDef.from_dict = from_dict
146 ToolDef.from_fn = from_fn
147 del from_dict, from_fn, with_args
148
149
150 class ToolSelectPanelHelper:
151     """
152     Generic Class, can be used for any toolbar.
153
154     - keymap_prefix:
155       The text prefix for each key-map for this spaces tools.
156     - tools_all():
157       Returns (context_mode, tools) tuple pair for all tools defined.
158     - tools_from_context(context, mode=None):
159       Returns tools available in this context.
160
161     Each tool is a 'ToolDef' or None for a separator in the toolbar, use ``None``.
162     """
163
164     @staticmethod
165     def _tool_class_from_space_type(space_type):
166         return next(
167             (cls for cls in ToolSelectPanelHelper.__subclasses__()
168              if cls.bl_space_type == space_type),
169             None
170         )
171
172     @staticmethod
173     def _icon_value_from_icon_handle(icon_name):
174         import os
175         if icon_name is not None:
176             assert(type(icon_name) is str)
177             icon_value = _icon_cache.get(icon_name)
178             if icon_value is None:
179                 dirname = bpy.utils.resource_path('LOCAL')
180                 if not os.path.exists(dirname):
181                     # TODO(campbell): use a better way of finding datafiles.
182                     dirname = bpy.utils.resource_path('SYSTEM')
183                 filename = os.path.join(dirname, "datafiles", "icons", icon_name + ".dat")
184                 try:
185                     icon_value = bpy.app.icons.new_triangles_from_file(filename)
186                 except Exception as ex:
187                     if not os.path.exists(filename):
188                         print("Missing icons:", filename, ex)
189                     else:
190                         print("Corrupt icon:", filename, ex)
191                     # Use none as a fallback (avoids layout issues).
192                     if icon_name != "none":
193                         icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle("none")
194                     else:
195                         icon_value = 0
196                 _icon_cache[icon_name] = icon_value
197             return icon_value
198         else:
199             return 0
200
201     @staticmethod
202     def _tools_flatten(tools):
203         """
204         Flattens, skips None and calls generators.
205         """
206         for item in tools:
207             if item is None:
208                 yield None
209             elif type(item) is tuple:
210                 for sub_item in item:
211                     if sub_item is None:
212                         yield None
213                     elif _item_is_fn(sub_item):
214                         yield from sub_item(context)
215                     else:
216                         yield sub_item
217             else:
218                 if _item_is_fn(item):
219                     yield from item(context)
220                 else:
221                     yield item
222
223     @staticmethod
224     def _tools_flatten_with_tool_index(tools):
225         for item in tools:
226             if item is None:
227                 yield None, -1
228             elif type(item) is tuple:
229                 i = 0
230                 for sub_item in item:
231                     if sub_item is None:
232                         yield None, -1
233                     elif _item_is_fn(sub_item):
234                         for item_dyn in sub_item(context):
235                             yield item_dyn, i
236                             i += 1
237                     else:
238                         yield sub_item, i
239                         i += 1
240             else:
241                 if _item_is_fn(item):
242                     for item_dyn in item(context):
243                         yield item_dyn, -1
244                 else:
245                     yield item, -1
246
247     @staticmethod
248     def _tool_get_active(context, space_type, mode, with_icon=False):
249         """
250         Return the active Python tool definition and icon name.
251         """
252         cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
253         if cls is not None:
254             tool_active = ToolSelectPanelHelper._tool_active_from_context(context, space_type, mode)
255             tool_active_id = getattr(tool_active, "idname", None)
256             for item in ToolSelectPanelHelper._tools_flatten(cls.tools_from_context(context, mode)):
257                 if item is not None:
258                     if item.idname == tool_active_id:
259                         if with_icon:
260                             icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
261                         else:
262                             icon_value = 0
263                         return (item, tool_active, icon_value)
264         return None, None, 0
265
266     @staticmethod
267     def _tool_get_by_id(context, space_type, idname):
268         """
269         Return the active Python tool definition and index (if in sub-group, else -1).
270         """
271         cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
272         if cls is not None:
273             for item, index in ToolSelectPanelHelper._tools_flatten_with_tool_index(cls.tools_from_context(context)):
274                 if item is not None:
275                     if item.idname == idname:
276                         return (cls, item, index)
277         return None, None, -1
278
279     @staticmethod
280     def _tool_active_from_context(context, space_type, mode=None, create=False):
281         if space_type == 'VIEW_3D':
282             if mode is None:
283                 mode = context.mode
284             tool = context.workspace.tools.from_space_view3d_mode(mode, create=create)
285             if tool is not None:
286                 tool.refresh_from_context()
287                 return tool
288         elif space_type == 'IMAGE_EDITOR':
289             space_data = context.space_data
290             if mode is None:
291                 if space_data is None:
292                     mode = 'VIEW'
293                 else:
294                     mode = space_data.mode
295             tool = context.workspace.tools.from_space_image_mode(mode, create=create)
296             if tool is not None:
297                 tool.refresh_from_context()
298                 return tool
299         elif space_type == 'NODE_EDITOR':
300             space_data = context.space_data
301             tool = context.workspace.tools.from_space_node(create=create)
302             if tool is not None:
303                 tool.refresh_from_context()
304                 return tool
305         return None
306
307     @staticmethod
308     def _tool_identifier_from_button(context):
309         return context.button_operator.name
310
311     @classmethod
312     def _km_action_simple(cls, kc, context_descr, label, keymap_fn):
313         km_idname = f"{cls.keymap_prefix:s} {context_descr:s}, {label:s}"
314         km = kc.keymaps.get(km_idname)
315         if km is None:
316             km = kc.keymaps.new(km_idname, space_type=cls.bl_space_type, region_type='WINDOW', tool=True)
317             keymap_fn[0](km)
318         keymap_fn[0] = km.name
319
320     # Special internal function, gives use items that contain keymaps.
321     @staticmethod
322     def _tools_flatten_with_keymap(tools):
323         for item_parent in tools:
324             if item_parent is None:
325                 continue
326             for item in item_parent if (type(item_parent) is tuple) else (item_parent,):
327                 # skip None or generator function
328                 if item is None or _item_is_fn(item):
329                     continue
330                 if item.keymap is not None:
331                     yield item
332
333     @classmethod
334     def register(cls):
335         wm = bpy.context.window_manager
336         # Write into defaults, users may modify in preferences.
337         kc = wm.keyconfigs.default
338
339         # Track which tool-group was last used for non-active groups.
340         # Blender stores the active tool-group index.
341         #
342         # {tool_name_first: index_in_group, ...}
343         cls._tool_group_active = {}
344
345         # ignore in background mode
346         if kc is None:
347             return
348
349         for context_mode, tools in cls.tools_all():
350             if context_mode is None:
351                 context_descr = "All"
352             else:
353                 context_descr = context_mode.replace("_", " ").title()
354
355             for item in cls._tools_flatten_with_keymap(tools):
356                 keymap_data = item.keymap
357                 if callable(keymap_data[0]):
358                     cls._km_action_simple(kc, context_descr, item.label, keymap_data)
359
360     @classmethod
361     def keymap_ui_hierarchy(cls, context_mode):
362         # See: bpy_extras.keyconfig_utils
363         for context_mode_test, tools in cls.tools_all():
364             if context_mode_test == context_mode:
365                 for item in cls._tools_flatten_with_keymap(tools):
366                     km_name = item.keymap[0]
367                     # print((km.name, cls.bl_space_type, 'WINDOW', []))
368                     yield (km_name, cls.bl_space_type, 'WINDOW', [])
369
370     # -------------------------------------------------------------------------
371     # Layout Generators
372     #
373     # Meaning of recieved values:
374     # - Bool: True for a separator, otherwise False for regular tools.
375     # - None: Signal to finish (complete any final operations, e.g. add padding).
376
377     @staticmethod
378     def _layout_generator_single_column(layout, scale_y):
379         col = layout.column(align=True)
380         col.scale_y = scale_y
381         is_sep = False
382         while True:
383             if is_sep is True:
384                 col = layout.column(align=True)
385                 col.scale_y = scale_y
386             elif is_sep is None:
387                 yield None
388                 return
389             is_sep = yield col
390
391     @staticmethod
392     def _layout_generator_multi_columns(layout, column_count, scale_y):
393         scale_x = scale_y * 1.1
394         column_last = column_count - 1
395
396         col = layout.column(align=True)
397
398         row = col.row(align=True)
399
400         row.scale_x = scale_x
401         row.scale_y = scale_y
402
403         is_sep = False
404         column_index = 0
405         while True:
406             if is_sep is True:
407                 if column_index != column_last:
408                     row.label(text="")
409                 col = layout.column(align=True)
410                 row = col.row(align=True)
411                 row.scale_x = scale_x
412                 row.scale_y = scale_y
413                 column_index = 0
414
415             is_sep = yield row
416             if is_sep is None:
417                 if column_index == column_last:
418                     row.label(text="")
419                     yield None
420                     return
421
422             if column_index == column_count:
423                 column_index = 0
424                 row = col.row(align=True)
425                 row.scale_x = scale_x
426                 row.scale_y = scale_y
427             column_index += 1
428
429     @staticmethod
430     def _layout_generator_detect_from_region(layout, region, scale_y):
431         """
432         Choose an appropriate layout for the toolbar.
433         """
434         # Currently this just checks the width,
435         # we could have different layouts as preferences too.
436         system = bpy.context.preferences.system
437         view2d = region.view2d
438         view2d_scale = (
439             view2d.region_to_view(1.0, 0.0)[0] -
440             view2d.region_to_view(0.0, 0.0)[0]
441         )
442         width_scale = region.width * view2d_scale / system.ui_scale
443
444         if width_scale > 120.0:
445             show_text = True
446             column_count = 1
447         else:
448             show_text = False
449             # 2 column layout, disabled
450             if width_scale > 80.0:
451                 column_count = 2
452             else:
453                 column_count = 1
454
455         if column_count == 1:
456             ui_gen = ToolSelectPanelHelper._layout_generator_single_column(
457                 layout, scale_y=scale_y,
458             )
459         else:
460             ui_gen = ToolSelectPanelHelper._layout_generator_multi_columns(
461                 layout, column_count=column_count, scale_y=scale_y,
462             )
463
464         return ui_gen, show_text
465
466     @classmethod
467     def draw_cls(cls, layout, context, detect_layout=True, scale_y=1.75):
468         # Use a classmethod so it can be called outside of a panel context.
469
470         # XXX, this UI isn't very nice.
471         # We might need to create new button types for this.
472         # Since we probably want:
473         # - tool-tips that include multiple key shortcuts.
474         # - ability to click and hold to expose sub-tools.
475
476         space_type = context.space_data.type
477         tool_active_id = getattr(
478             ToolSelectPanelHelper._tool_active_from_context(context, space_type),
479             "idname", None,
480         )
481
482         if detect_layout:
483             ui_gen, show_text = cls._layout_generator_detect_from_region(layout, context.region, scale_y)
484         else:
485             ui_gen = ToolSelectPanelHelper._layout_generator_single_column(layout, scale_y)
486             show_text = True
487
488         # Start iteration
489         ui_gen.send(None)
490
491         for item in cls.tools_from_context(context):
492             if item is None:
493                 ui_gen.send(True)
494                 continue
495
496             if type(item) is tuple:
497                 is_active = False
498                 i = 0
499                 for i, sub_item in enumerate(item):
500                     if sub_item is None:
501                         continue
502                     is_active = (sub_item.idname == tool_active_id)
503                     if is_active:
504                         index = i
505                         break
506                 del i, sub_item
507
508                 if is_active:
509                     # not ideal, write this every time :S
510                     cls._tool_group_active[item[0].idname] = index
511                 else:
512                     index = cls._tool_group_active.get(item[0].idname, 0)
513
514                 item = item[index]
515                 use_menu = True
516             else:
517                 index = -1
518                 use_menu = False
519
520             is_active = (item.idname == tool_active_id)
521             icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
522
523             sub = ui_gen.send(False)
524
525             if use_menu:
526                 sub.operator_menu_hold(
527                     "wm.tool_set_by_id",
528                     text=item.label if show_text else "",
529                     depress=is_active,
530                     menu="WM_MT_toolsystem_submenu",
531                     icon_value=icon_value,
532                 ).name = item.idname
533             else:
534                 sub.operator(
535                     "wm.tool_set_by_id",
536                     text=item.label if show_text else "",
537                     depress=is_active,
538                     icon_value=icon_value,
539                 ).name = item.idname
540         # Signal to finish any remaining layout edits.
541         ui_gen.send(None)
542
543     def draw(self, context):
544         self.draw_cls(self.layout, context)
545
546     @staticmethod
547     def tool_active_from_context(context):
548         # BAD DESIGN WARNING: last used tool
549         workspace = context.workspace
550         space_type = workspace.tools_space_type
551         mode = workspace.tools_mode
552         return ToolSelectPanelHelper._tool_active_from_context(context, space_type, mode)
553
554     @staticmethod
555     def draw_active_tool_header(
556             context, layout,
557             *,
558             show_tool_name=False,
559     ):
560         # BAD DESIGN WARNING: last used tool
561         workspace = context.workspace
562         space_type = workspace.tools_space_type
563         mode = workspace.tools_mode
564         item, tool, icon_value = ToolSelectPanelHelper._tool_get_active(context, space_type, mode, with_icon=True)
565         if item is None:
566             return None
567         # Note: we could show 'item.text' here but it makes the layout jitter when switching tools.
568         # Add some spacing since the icon is currently assuming regular small icon size.
569         layout.label(text="    " + item.label if show_tool_name else " ", icon_value=icon_value)
570         if show_tool_name:
571             layout.separator()
572         draw_settings = item.draw_settings
573         if draw_settings is not None:
574             draw_settings(context, layout, tool)
575         return tool
576
577
578 # The purpose of this menu is to be a generic popup to select between tools
579 # in cases when a single tool allows to select alternative tools.
580 class WM_MT_toolsystem_submenu(Menu):
581     bl_label = ""
582
583     @staticmethod
584     def _tool_group_from_button(context):
585         # Lookup the tool definitions based on the space-type.
586         cls = ToolSelectPanelHelper._tool_class_from_space_type(context.space_data.type)
587         if cls is not None:
588             button_identifier = ToolSelectPanelHelper._tool_identifier_from_button(context)
589             for item_group in cls.tools_from_context(context):
590                 if type(item_group) is tuple:
591                     for sub_item in item_group:
592                         if (sub_item is not None) and (sub_item.idname == button_identifier):
593                             return cls, item_group
594         return None, None
595
596     def draw(self, context):
597         layout = self.layout
598         layout.scale_y = 2.0
599
600         _cls, item_group = self._tool_group_from_button(context)
601         if item_group is None:
602             # Should never happen, just in case
603             layout.label(text="Unable to find toolbar group")
604             return
605
606         for item in item_group:
607             if item is None:
608                 layout.separator()
609                 continue
610             icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
611             layout.operator(
612                 "wm.tool_set_by_id",
613                 text=item.label,
614                 icon_value=icon_value,
615             ).name = item.idname
616
617
618 def _activate_by_item(context, space_type, item, index):
619     tool = ToolSelectPanelHelper._tool_active_from_context(context, space_type, create=True)
620     tool.setup(
621         idname=item.idname,
622         keymap=item.keymap[0] if item.keymap is not None else "",
623         cursor=item.cursor or 'DEFAULT',
624         gizmo_group=item.widget or "",
625         data_block=item.data_block or "",
626         operator=item.operator or "",
627         index=index,
628     )
629
630     WindowManager = bpy.types.WindowManager
631
632     handle_map = _activate_by_item._cursor_draw_handle
633     handle = handle_map.pop(space_type, None)
634     if (handle is not None):
635         WindowManager.draw_cursor_remove(handle)
636     if item.draw_cursor is not None:
637         def handle_fn(context, item, tool, xy):
638             item.draw_cursor(context, tool, xy)
639         handle = WindowManager.draw_cursor_add(handle_fn, (context, item, tool), space_type)
640         handle_map[space_type] = handle
641
642
643 _activate_by_item._cursor_draw_handle = {}
644
645
646 def activate_by_id(context, space_type, text):
647     _cls, item, index = ToolSelectPanelHelper._tool_get_by_id(context, space_type, text)
648     if item is None:
649         return False
650     _activate_by_item(context, space_type, item, index)
651     return True
652
653
654 def activate_by_id_or_cycle(context, space_type, idname, offset=1):
655
656     # Only cycle when the active tool is activated again.
657     cls, item, _index = ToolSelectPanelHelper._tool_get_by_id(context, space_type, idname)
658     if item is None:
659         return False
660
661     tool_active = ToolSelectPanelHelper._tool_active_from_context(context, space_type)
662     id_active = getattr(tool_active, "idname", None)
663
664     id_current = ""
665     for item_group in cls.tools_from_context(context):
666         if type(item_group) is tuple:
667             index_current = cls._tool_group_active.get(item_group[0].idname, 0)
668             for sub_item in item_group:
669                 if sub_item.idname == idname:
670                     id_current = item_group[index_current].idname
671                     break
672             if id_current:
673                 break
674
675     if id_current == "":
676         return activate_by_id(context, space_type, idname)
677     if id_active != id_current:
678         return activate_by_id(context, space_type, id_current)
679
680     index_found = (tool_active.index + offset) % len(item_group)
681
682     cls._tool_group_active[item_group[0].idname] = index_found
683
684     item_found = item_group[index_found]
685     _activate_by_item(context, space_type, item_found, index_found)
686     return True
687
688
689 def description_from_id(context, space_type, idname, *, use_operator=True):
690     # Used directly for tooltips.
691     _cls, item, _index = ToolSelectPanelHelper._tool_get_by_id(context, space_type, idname)
692     if item is None:
693         return False
694
695     # Custom description.
696     description = item.description
697     if description is not None:
698         if callable(description):
699             km = _keymap_from_item(context, item)
700             return description(context, item, km)
701         return description
702
703     # Extract from the operator.
704     if use_operator:
705         operator = item.operator
706         if operator is None:
707             if item.keymap is not None:
708                 km = _keymap_from_item(context, item)
709                 if km is not None:
710                     for kmi in km.keymap_items:
711                         if kmi.active:
712                             operator = kmi.idname
713                             break
714
715         if operator is not None:
716             import _bpy
717             return _bpy.ops.get_rna_type(operator).description
718     return ""
719
720
721 def item_from_id(context, space_type, idname):
722     # Used directly for tooltips.
723     _cls, item, _index = ToolSelectPanelHelper._tool_get_by_id(context, space_type, idname)
724     return item
725
726
727 def keymap_from_id(context, space_type, idname):
728     # Used directly for tooltips.
729     _cls, item, _index = ToolSelectPanelHelper._tool_get_by_id(context, space_type, idname)
730     if item is None:
731         return False
732
733     keymap = item.keymap
734     # List container of one.
735     if keymap:
736         return keymap[0]
737     return ""
738
739
740 def _keymap_from_item(context, item):
741     if item.keymap is not None:
742         wm = context.window_manager
743         keyconf = wm.keyconfigs.active
744         return keyconf.keymaps.get(item.keymap[0])
745     return None
746
747
748 classes = (
749     WM_MT_toolsystem_submenu,
750 )
751
752 if __name__ == "__main__":  # only for live edit.
753     from bpy.utils import register_class
754     for cls in classes:
755         register_class(cls)