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