Tool System: per space/mode tool support
[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 manipulator group to activate when the tool is set or None for no widget.
76         "widget",
77         # Optional keymap for tool, either:
78         # - A function that populates a keymaps passed in as an argument.
79         # - A tuple filled with triple's of:
80         #   ``(operator_id, operator_properties, keymap_item_args)``.
81         #
82         # Warning: currently 'from_dict' this is a list of one item,
83         # so internally we can swap the keymap function for the keymap it's self.
84         # This isn't very nice and may change, tool definitions shouldn't care about this.
85         "keymap",
86         # Optional data-block assosiated with this tool.
87         # (Typically brush name, usage depends on mode, we could use for non-brush ID's in other modes).
88         "data_block",
89         # Optional draw settings (operator options, toolsettings).
90         "draw_settings",
91     )
92 )
93 del namedtuple
94
95
96 def from_dict(kw_args):
97     """
98     Use so each tool can avoid defining all members of the named tuple.
99     Also convert the keymap from a tuple into a function
100     (since keymap is a callback).
101     """
102     kw = {
103         "icon": None,
104         "widget": None,
105         "keymap": None,
106         "data_block": None,
107         "draw_settings": None,
108     }
109     kw.update(kw_args)
110
111     keymap = kw["keymap"]
112     if kw["keymap"] is None:
113         pass
114     elif type(keymap) is tuple:
115         keymap = [_keymap_fn_from_seq(keymap)]
116     else:
117         keymap = [keymap]
118     kw["keymap"] = keymap
119     return ToolDef(**kw)
120
121
122 def from_fn(fn):
123     """
124     Use as decorator so we can define functions.
125     """
126     return ToolDef.from_dict(fn())
127
128
129 ToolDef.from_dict = from_dict
130 ToolDef.from_fn = from_fn
131 del from_dict
132 del from_fn
133
134
135 class ToolSelectPanelHelper:
136     """
137     Generic Class, can be used for any toolbar.
138
139     - keymap_prefix:
140       The text prefix for each key-map for this spaces tools.
141     - tools_all():
142       Returns (context_mode, tools) tuple pair for all tools defined.
143     - tools_from_context(context, mode=None):
144       Returns tools available in this context.
145
146     Each tool is a 'ToolDef' or None for a separator in the toolbar, use ``None``.
147     """
148
149     @staticmethod
150     def _tool_class_from_space_type(space_type):
151         return next(
152             (cls for cls in ToolSelectPanelHelper.__subclasses__()
153              if cls.bl_space_type == space_type),
154             None
155         )
156
157     @staticmethod
158     def _icon_value_from_icon_handle(icon_name):
159         import os
160         if icon_name is not None:
161             assert(type(icon_name) is str)
162             icon_value = _icon_cache.get(icon_name)
163             if icon_value is None:
164                 dirname = bpy.utils.resource_path('LOCAL')
165                 if not os.path.exists(dirname):
166                     # TODO(campbell): use a better way of finding datafiles.
167                     dirname = bpy.utils.resource_path('SYSTEM')
168                 filename = os.path.join(dirname, "datafiles", "icons", icon_name + ".dat")
169                 try:
170                     icon_value = bpy.app.icons.new_triangles_from_file(filename)
171                 except Exception as ex:
172                     if not os.path.exists(filename):
173                         print("Missing icons:", filename, ex)
174                     else:
175                         print("Corrupt icon:", filename, ex)
176                     # Use none as a fallback (avoids layout issues).
177                     if icon_name != "none":
178                         icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle("none")
179                     else:
180                         icon_value = 0
181                 _icon_cache[icon_name] = icon_value
182             return icon_value
183         else:
184             return 0
185
186     @staticmethod
187     def _tools_flatten(tools):
188         """
189         Flattens, skips None and calls generators.
190         """
191         for item in tools:
192             if item is None:
193                 yield None
194             elif type(item) is tuple:
195                 for sub_item in item:
196                     if sub_item is None:
197                         yield None
198                     elif _item_is_fn(sub_item):
199                         yield from sub_item(context)
200                     else:
201                         yield sub_item
202             else:
203                 if _item_is_fn(item):
204                     yield from item(context)
205                 else:
206                     yield item
207
208     @staticmethod
209     def _tools_flatten_with_tool_index(tools):
210         for item in tools:
211             if item is None:
212                 yield None, -1
213             elif type(item) is tuple:
214                 i = 0
215                 for sub_item in item:
216                     if sub_item is None:
217                         yield None
218                     elif _item_is_fn(sub_item):
219                         for item_dyn in sub_item(context):
220                             yield item_dyn, i
221                             i += 1
222                     else:
223                         yield sub_item, i
224                         i += 1
225             else:
226                 if _item_is_fn(item):
227                     for item_dyn in item(context):
228                         yield item_dyn, -1
229                 else:
230                     yield item, -1
231
232     @staticmethod
233     def _tool_get_active(context, space_type, mode, with_icon=False):
234         """
235         Return the active Python tool definition and icon name.
236         """
237
238         workspace = context.workspace
239         cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
240         if cls is not None:
241             tool_active_text = getattr(
242                 ToolSelectPanelHelper._tool_active_from_context(context, space_type, mode),
243                 "name", None)
244
245             for item in ToolSelectPanelHelper._tools_flatten(cls.tools_from_context(context, mode)):
246                 if item is not None:
247                     if item.text == tool_active_text:
248                         if with_icon:
249                             icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
250                         else:
251                             icon_value = 0
252                         return (item, icon_value)
253         return None, 0
254
255     @staticmethod
256     def _tool_get_by_name(context, space_type, text):
257         """
258         Return the active Python tool definition and index (if in sub-group, else -1).
259         """
260         cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
261         if cls is not None:
262             for item, index in ToolSelectPanelHelper._tools_flatten_with_tool_index(cls.tools_from_context(context)):
263                 if item is not None:
264                     if item.text == text:
265                         return (item, index)
266         return None, -1
267
268     @staticmethod
269     def _tool_vars_from_def(item):
270         # For now be strict about whats in this dict
271         # prevent accidental adding unknown keys.
272         text = item.text
273         icon_name = item.icon
274         mp_idname = item.widget
275         datablock_idname = item.data_block
276         keymap = item.keymap
277         if keymap is None:
278             km_idname = None
279         else:
280             km_idname = keymap[0].name
281         return (km_idname, mp_idname, datablock_idname), icon_name
282
283     @staticmethod
284     def _tool_active_from_context(context, space_type, mode=None, create=False):
285         if space_type == 'VIEW_3D':
286             if mode is None:
287                 obj = context.active_object
288                 mode = obj.mode if obj is not None else 'OBJECT'
289             tool = context.workspace.tools.from_space_view3d_mode(mode, create)
290             if tool is not None:
291                 return tool
292         elif space_type == 'IMAGE_EDITOR':
293             space_data = context.space_data
294             if mode is None:
295                 mode = space_data.mode
296             tool = context.workspace.tools.from_space_image_mode(mode, create)
297             if tool is not None:
298                 return tool
299         return None
300
301     @staticmethod
302     def _tool_text_from_button(context):
303         return context.button_operator.name
304
305     @classmethod
306     def _km_action_simple(cls, kc, context_mode, text, keymap_fn):
307         if context_mode is None:
308             context_mode = "All"
309         km_idname = f"{cls.keymap_prefix} {context_mode}, {text}"
310         km = kc.keymaps.get(km_idname)
311         if km is None:
312             km = kc.keymaps.new(km_idname, space_type=cls.bl_space_type, region_type='WINDOW')
313             keymap_fn[0](km)
314         keymap_fn[0] = km
315
316     @classmethod
317     def register(cls):
318         wm = bpy.context.window_manager
319
320         # XXX, should we be manipulating the user-keyconfig on load?
321         # Perhaps this should only add when keymap items don't already exist.
322         #
323         # This needs some careful consideration.
324         kc = wm.keyconfigs.user
325
326         # Track which tool-group was last used for non-active groups.
327         # Blender stores the active tool-group index.
328         #
329         # {tool_name_first: index_in_group, ...}
330         cls._tool_group_active = {}
331
332         # ignore in background mode
333         if kc is None:
334             return
335
336         for context_mode, tools in cls.tools_all():
337             for item_parent in tools:
338                 if item_parent is None:
339                     continue
340                 for item in item_parent if (type(item_parent) is tuple) else (item_parent,):
341                     # skip None or generator function
342                     if item is None or _item_is_fn(item):
343                         continue
344                     keymap_data = item.keymap
345                     if keymap_data is not None and callable(keymap_data[0]):
346                         text = item.text
347                         icon_name = item.icon
348                         cls._km_action_simple(kc, context_mode, text, keymap_data)
349
350     # -------------------------------------------------------------------------
351     # Layout Generators
352     #
353     # Meaning of recieved values:
354     # - Bool: True for a separator, otherwise False for regular tools.
355     # - None: Signal to finish (complete any final operations, e.g. add padding).
356
357     @staticmethod
358     def _layout_generator_single_column(layout):
359         scale_y = 2.0
360
361         col = layout.column(align=True)
362         col.scale_y = scale_y
363         is_sep = False
364         while True:
365             if is_sep is True:
366                 col = layout.column(align=True)
367                 col.scale_y = scale_y
368             elif is_sep is None:
369                 yield None
370                 return
371             is_sep = yield col
372
373     @staticmethod
374     def _layout_generator_multi_columns(layout, column_count):
375         scale_y = 2.0
376         scale_x = 2.2
377         column_last = column_count - 1
378
379         col = layout.column(align=True)
380
381         row = col.row(align=True)
382
383         row.scale_x = scale_x
384         row.scale_y = scale_y
385
386         is_sep = False
387         column_index = 0
388         while True:
389             if is_sep is True:
390                 if column_index != column_last:
391                     row.label("")
392                 col = layout.column(align=True)
393                 row = col.row(align=True)
394                 row.scale_x = scale_x
395                 row.scale_y = scale_y
396                 column_index = 0
397
398             is_sep = yield row
399             if is_sep is None:
400                 if column_index == column_last:
401                     row.label("")
402                     yield None
403                     return
404
405             if column_index == column_count:
406                 column_index = 0
407                 row = col.row(align=True)
408                 row.scale_x = scale_x
409                 row.scale_y = scale_y
410             column_index += 1
411
412     @staticmethod
413     def _layout_generator_detect_from_region(layout, region):
414         """
415         Choose an appropriate layout for the toolbar.
416         """
417         # Currently this just checks the width,
418         # we could have different layouts as preferences too.
419         system = bpy.context.user_preferences.system
420         view2d = region.view2d
421         view2d_scale = (
422             view2d.region_to_view(1.0, 0.0)[0] -
423             view2d.region_to_view(0.0, 0.0)[0]
424         )
425         width_scale = region.width * view2d_scale / system.ui_scale
426
427         if width_scale > 120.0:
428             show_text = True
429             column_count = 1
430         else:
431             show_text = False
432             # 2 column layout, disabled
433             if width_scale > 80.0:
434                 column_count = 2
435                 use_columns = True
436             else:
437                 column_count = 1
438
439         if column_count == 1:
440             ui_gen = ToolSelectPanelHelper._layout_generator_single_column(layout)
441         else:
442             ui_gen = ToolSelectPanelHelper._layout_generator_multi_columns(layout, column_count=column_count)
443
444         return ui_gen, show_text
445
446     def draw(self, context):
447         # XXX, this UI isn't very nice.
448         # We might need to create new button types for this.
449         # Since we probably want:
450         # - tool-tips that include multiple key shortcuts.
451         # - ability to click and hold to expose sub-tools.
452
453         space_type = context.space_data.type
454         tool_active_text = getattr(
455             ToolSelectPanelHelper._tool_active_from_context(context, space_type),
456             "name", None,
457         )
458
459         ui_gen, show_text = self._layout_generator_detect_from_region(self.layout, context.region)
460
461         # Start iteration
462         ui_gen.send(None)
463
464         for item in self.tools_from_context(context):
465             if item is None:
466                 ui_gen.send(True)
467                 continue
468
469             if type(item) is tuple:
470                 is_active = False
471                 i = 0
472                 for i, sub_item in enumerate(item):
473                     if sub_item is None:
474                         continue
475                     is_active = (sub_item.text == tool_active_text)
476                     if is_active:
477                         index = i
478                         break
479                 del i, sub_item
480
481                 if is_active:
482                     # not ideal, write this every time :S
483                     self._tool_group_active[item[0].text] = index
484                 else:
485                     index = self._tool_group_active.get(item[0].text, 0)
486
487                 item = item[index]
488                 use_menu = True
489             else:
490                 index = -1
491                 use_menu = False
492
493             tool_def, icon_name = ToolSelectPanelHelper._tool_vars_from_def(item)
494             is_active = (item.text == tool_active_text)
495
496             icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(icon_name)
497
498             sub = ui_gen.send(False)
499
500             if use_menu:
501                 sub.operator_menu_hold(
502                     "wm.tool_set_by_name",
503                     text=item.text if show_text else "",
504                     depress=is_active,
505                     menu="WM_MT_toolsystem_submenu",
506                     icon_value=icon_value,
507                 ).name = item.text
508             else:
509                 sub.operator(
510                     "wm.tool_set_by_name",
511                     text=item.text if show_text else "",
512                     depress=is_active,
513                     icon_value=icon_value,
514                 ).name = item.text
515         # Signal to finish any remaining layout edits.
516         ui_gen.send(None)
517
518     @staticmethod
519     def draw_active_tool_header(context, layout):
520         # BAD DESIGN WARNING: last used tool
521         workspace = context.workspace
522         space_type = workspace.tools_space_type
523         mode = workspace.tools_mode
524         item, icon_value = ToolSelectPanelHelper._tool_get_active(context, space_type, mode, with_icon=True)
525         if item is None:
526             return
527         # Note: we could show 'item.text' here but it makes the layout jitter when switcuing tools.
528         layout.label(" ", icon_value=icon_value)
529         draw_settings = item.draw_settings
530         if draw_settings is not None:
531             draw_settings(context, layout)
532
533
534 # The purpose of this menu is to be a generic popup to select between tools
535 # in cases when a single tool allows to select alternative tools.
536 class WM_MT_toolsystem_submenu(Menu):
537     bl_label = ""
538
539     @staticmethod
540     def _tool_group_from_button(context):
541         # Lookup the tool definitions based on the space-type.
542         cls = ToolSelectPanelHelper._tool_class_from_space_type(context.space_data.type)
543         if cls is not None:
544             button_text = ToolSelectPanelHelper._tool_text_from_button(context)
545             for item_group in cls.tools_from_context(context):
546                 if type(item_group) is tuple:
547                     for sub_item in item_group:
548                         if sub_item.text == button_text:
549                             return cls, item_group
550         return None, None
551
552     def draw(self, context):
553         layout = self.layout
554         layout.scale_y = 2.0
555
556         cls, item_group = self._tool_group_from_button(context)
557         if item_group is None:
558             # Should never happen, just in case
559             layout.label("Unable to find toolbar group")
560             return
561
562         for item in item_group:
563             if item is None:
564                 layout.separator()
565                 continue
566             tool_def, icon_name = ToolSelectPanelHelper._tool_vars_from_def(item)
567             icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(icon_name)
568             layout.operator(
569                 "wm.tool_set_by_name",
570                 text=item.text,
571                 icon_value=icon_value,
572             ).name = item.text
573
574
575 def activate_by_name(context, space_type, text):
576     item, index = ToolSelectPanelHelper._tool_get_by_name(context, space_type, text)
577     if item is not None:
578         tool = ToolSelectPanelHelper._tool_active_from_context(context, space_type, create=True)
579         tool_def, icon_name = ToolSelectPanelHelper._tool_vars_from_def(item)
580         tool.setup(
581             name=text,
582             keymap=tool_def[0] or "",
583             manipulator_group=tool_def[1] or "",
584             data_block=tool_def[2] or "",
585             index=index,
586         )
587         return True
588     return False
589
590
591 classes = (
592     WM_MT_toolsystem_submenu,
593 )
594
595 if __name__ == "__main__":  # only for live edit.
596     from bpy.utils import register_class
597     for cls in classes:
598         register_class(cls)