Cleanup: line length
[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.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(
455                 layout, scale_y=scale_y,
456             )
457         else:
458             ui_gen = ToolSelectPanelHelper._layout_generator_multi_columns(
459                 layout, column_count=column_count, scale_y=scale_y,
460             )
461
462         return ui_gen, show_text
463
464     @classmethod
465     def draw_cls(cls, layout, context, detect_layout=True, scale_y=1.75):
466         # Use a classmethod so it can be called outside of a panel context.
467
468         # XXX, this UI isn't very nice.
469         # We might need to create new button types for this.
470         # Since we probably want:
471         # - tool-tips that include multiple key shortcuts.
472         # - ability to click and hold to expose sub-tools.
473
474         space_type = context.space_data.type
475         tool_active_text = getattr(
476             ToolSelectPanelHelper._tool_active_from_context(context, space_type),
477             "name", None,
478         )
479
480         if detect_layout:
481             ui_gen, show_text = cls._layout_generator_detect_from_region(layout, context.region, scale_y)
482         else:
483             ui_gen = ToolSelectPanelHelper._layout_generator_single_column(layout, scale_y)
484             show_text = True
485
486         # Start iteration
487         ui_gen.send(None)
488
489         for item in cls.tools_from_context(context):
490             if item is None:
491                 ui_gen.send(True)
492                 continue
493
494             if type(item) is tuple:
495                 is_active = False
496                 i = 0
497                 for i, sub_item in enumerate(item):
498                     if sub_item is None:
499                         continue
500                     is_active = (sub_item.text == tool_active_text)
501                     if is_active:
502                         index = i
503                         break
504                 del i, sub_item
505
506                 if is_active:
507                     # not ideal, write this every time :S
508                     cls._tool_group_active[item[0].text] = index
509                 else:
510                     index = cls._tool_group_active.get(item[0].text, 0)
511
512                 item = item[index]
513                 use_menu = True
514             else:
515                 index = -1
516                 use_menu = False
517
518             is_active = (item.text == tool_active_text)
519             icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
520
521             sub = ui_gen.send(False)
522
523             if use_menu:
524                 sub.operator_menu_hold(
525                     "wm.tool_set_by_name",
526                     text=item.text if show_text else "",
527                     depress=is_active,
528                     menu="WM_MT_toolsystem_submenu",
529                     icon_value=icon_value,
530                 ).name = item.text
531             else:
532                 sub.operator(
533                     "wm.tool_set_by_name",
534                     text=item.text if show_text else "",
535                     depress=is_active,
536                     icon_value=icon_value,
537                 ).name = item.text
538         # Signal to finish any remaining layout edits.
539         ui_gen.send(None)
540
541     def draw(self, context):
542         self.draw_cls(self.layout, context)
543
544     @staticmethod
545     def tool_active_from_context(context):
546         # BAD DESIGN WARNING: last used tool
547         workspace = context.workspace
548         space_type = workspace.tools_space_type
549         mode = workspace.tools_mode
550         return ToolSelectPanelHelper._tool_active_from_context(context, space_type, mode)
551
552     @staticmethod
553     def draw_active_tool_header(
554             context, layout,
555             *,
556             show_tool_name=False,
557     ):
558         # BAD DESIGN WARNING: last used tool
559         workspace = context.workspace
560         space_type = workspace.tools_space_type
561         mode = workspace.tools_mode
562         item, tool, icon_value = ToolSelectPanelHelper._tool_get_active(context, space_type, mode, with_icon=True)
563         if item is None:
564             return None
565         # Note: we could show 'item.text' here but it makes the layout jitter when switching tools.
566         # Add some spacing since the icon is currently assuming regular small icon size.
567         layout.label(text="    " + item.text if show_tool_name else " ", icon_value=icon_value)
568         draw_settings = item.draw_settings
569         if draw_settings is not None:
570             draw_settings(context, layout, tool)
571         return tool
572
573
574 # The purpose of this menu is to be a generic popup to select between tools
575 # in cases when a single tool allows to select alternative tools.
576 class WM_MT_toolsystem_submenu(Menu):
577     bl_label = ""
578
579     @staticmethod
580     def _tool_group_from_button(context):
581         # Lookup the tool definitions based on the space-type.
582         cls = ToolSelectPanelHelper._tool_class_from_space_type(context.space_data.type)
583         if cls is not None:
584             button_text = ToolSelectPanelHelper._tool_text_from_button(context)
585             for item_group in cls.tools_from_context(context):
586                 if type(item_group) is tuple:
587                     for sub_item in item_group:
588                         if sub_item.text == button_text:
589                             return cls, item_group
590         return None, None
591
592     def draw(self, context):
593         layout = self.layout
594         layout.scale_y = 2.0
595
596         _cls, item_group = self._tool_group_from_button(context)
597         if item_group is None:
598             # Should never happen, just in case
599             layout.label(text="Unable to find toolbar group")
600             return
601
602         for item in item_group:
603             if item is None:
604                 layout.separator()
605                 continue
606             icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
607             layout.operator(
608                 "wm.tool_set_by_name",
609                 text=item.text,
610                 icon_value=icon_value,
611             ).name = item.text
612
613
614 def _activate_by_item(context, space_type, item, index):
615     tool = ToolSelectPanelHelper._tool_active_from_context(context, space_type, create=True)
616     tool.setup(
617         name=item.text,
618         keymap=item.keymap[0] if item.keymap is not None else "",
619         cursor=item.cursor or 'DEFAULT',
620         gizmo_group=item.widget or "",
621         data_block=item.data_block or "",
622         operator=item.operator or "",
623         index=index,
624     )
625
626     WindowManager = bpy.types.WindowManager
627
628     handle_map = _activate_by_item._cursor_draw_handle
629     handle = handle_map.pop(space_type, None)
630     if (handle is not None):
631         WindowManager.draw_cursor_remove(handle)
632     if item.draw_cursor is not None:
633         def handle_fn(context, item, tool, xy):
634             item.draw_cursor(context, tool, xy)
635         handle = WindowManager.draw_cursor_add(handle_fn, (context, item, tool), space_type)
636         handle_map[space_type] = handle
637
638
639 _activate_by_item._cursor_draw_handle = {}
640
641
642 def activate_by_name(context, space_type, text):
643     _cls, item, index = ToolSelectPanelHelper._tool_get_by_name(context, space_type, text)
644     if item is None:
645         return False
646     _activate_by_item(context, space_type, item, index)
647     return True
648
649
650 def activate_by_name_or_cycle(context, space_type, text, offset=1):
651
652     # Only cycle when the active tool is activated again.
653     cls, item, _index = ToolSelectPanelHelper._tool_get_by_name(context, space_type, text)
654     if item is None:
655         return False
656
657     tool_active = ToolSelectPanelHelper._tool_active_from_context(context, space_type)
658     text_active = getattr(tool_active, "name", None)
659
660     text_current = ""
661     for item_group in cls.tools_from_context(context):
662         if type(item_group) is tuple:
663             index_current = cls._tool_group_active.get(item_group[0].text, 0)
664             for sub_item in item_group:
665                 if sub_item.text == text:
666                     text_current = item_group[index_current].text
667                     break
668             if text_current:
669                 break
670
671     if text_current == "":
672         return activate_by_name(context, space_type, text)
673     if text_active != text_current:
674         return activate_by_name(context, space_type, text_current)
675
676     index_found = (tool_active.index + offset) % len(item_group)
677
678     cls._tool_group_active[item_group[0].text] = index_found
679
680     item_found = item_group[index_found]
681     _activate_by_item(context, space_type, item_found, index_found)
682     return True
683
684
685 def description_from_name(context, space_type, text, *, use_operator=True):
686     # Used directly for tooltips.
687     _cls, item, _index = ToolSelectPanelHelper._tool_get_by_name(context, space_type, text)
688     if item is None:
689         return False
690
691     # Custom description.
692     description = item.description
693     if description is not None:
694         if callable(description):
695             km = _keymap_from_item(context, item)
696             return description(context, item, km)
697         return description
698
699     # Extract from the operator.
700     if use_operator:
701         operator = item.operator
702         if operator is None:
703             if item.keymap is not None:
704                 km = _keymap_from_item(context, item)
705                 if km is not None:
706                     for kmi in km.keymap_items:
707                         if kmi.active:
708                             operator = kmi.idname
709                             break
710
711         if operator is not None:
712             import _bpy
713             return _bpy.ops.get_rna_type(operator).description
714     return ""
715
716
717 def keymap_from_name(context, space_type, text):
718     # Used directly for tooltips.
719     _cls, item, _index = ToolSelectPanelHelper._tool_get_by_name(context, space_type, text)
720     if item is None:
721         return False
722
723     keymap = item.keymap
724     # List container of one.
725     if keymap:
726         return keymap[0]
727     return ""
728
729
730 def _keymap_from_item(context, item):
731     if item.keymap is not None:
732         wm = context.window_manager
733         keyconf = wm.keyconfigs.active
734         return keyconf.keymaps.get(item.keymap[0])
735     return None
736
737
738 classes = (
739     WM_MT_toolsystem_submenu,
740 )
741
742 if __name__ == "__main__":  # only for live edit.
743     from bpy.utils import register_class
744     for cls in classes:
745         register_class(cls)