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