# SPDX-FileCopyrightText: 2017-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

import bpy
from bpy.types import (
    Menu,
)

from bpy.app.translations import pgettext_tip as tip_
from bpy.app.translations import pgettext_iface as iface_

__all__ = (
    "ToolDef",
    "ToolSelectPanelHelper",
    "activate_by_id",
    "activate_by_id_or_cycle",
    "description_from_id",
    "keymap_from_id",
)

# Support reloading icons.
if "_icon_cache" in locals():
    release = bpy.app.icons.release
    for icon_value in set(_icon_cache.values()):
        if icon_value != 0:
            release(icon_value)
    del release


# (icon_name -> icon_value) map
_icon_cache = {}


def _keymap_fn_from_seq(keymap_data):

    def keymap_fn(km):
        if keymap_fn.keymap_data:
            from bl_keymap_utils.io import keymap_init_from_data
            keymap_init_from_data(km, keymap_fn.keymap_data)
    keymap_fn.keymap_data = keymap_data
    return keymap_fn


def _item_is_fn(item):
    return (not (type(item) is ToolDef) and callable(item))


from collections import namedtuple
ToolDef = namedtuple(
    "ToolDef",
    (
        # Unique tool name (within space & mode context).
        "idname",
        # The name to display in the interface.
        "label",
        # Description (for tool-tip), when not set, use the description of `operator`,
        # may be a string or a `function(context, item, key-map) -> string`.
        "description",
        # The name of the icon to use (found in `release/datafiles/icons`) or None for no icon.
        "icon",
        # An optional cursor to use when this tool is active.
        "cursor",
        # The properties to use for the widget.
        "widget_properties",
        # An optional gizmo group to activate when the tool is set or None for no gizmo.
        "widget",
        # Optional key-map for tool, possible values are:
        #
        # - `None` when the tool doesn't have a key-map.
        #   Also the default value when no key-map value is defined.
        #
        # - A string literal for the key-map name, the key-map items are located in the default key-map.
        #
        # - `()` an empty tuple for a default name.
        #   This is convenience functionality for generating a key-map name.
        #   So if a tool name is "Bone Size", in "Edit Armature" mode for the "3D View",
        #   All of these values are combined into an id, e.g:
        #     "3D View Tool: Edit Armature, Bone Envelope"
        #
        #   Typically searching for a string ending with the tool name
        #   in the default key-map will lead you to the key-map for a tool.
        #
        # - A function that populates a key-maps passed in as an argument.
        #
        # - A tuple filled with triple's of:
        #   `(operator_id, operator_properties, keymap_item_args)`.
        #
        #   Use this to define the key-map in-line.
        #
        #   Note that this isn't used for Blender's built in tools which use the built-in key-map.
        #   Keep this functionality since it's likely useful for add-on key-maps.
        #
        # Warning: currently `from_dict` this is a list of one item,
        # so internally we can swap the key-map function for the key-map itself.
        # This isn't very nice and may change, tool definitions shouldn't care about this.
        "keymap",
        # Optional data-block associated with this tool.
        # (Typically brush name, usage depends on mode, we could use for non-brush ID's in other modes).
        "data_block",
        # Optional primary operator (for introspection only).
        "operator",
        # Optional draw settings (operator options, tool_settings).
        "draw_settings",
        # Optional draw cursor.
        "draw_cursor",
        # Various options, see: `bpy.types.WorkSpaceTool.setup` options argument.
        "options",
    )
)
del namedtuple


def from_dict(kw_args):
    """
    Use so each tool can avoid defining all members of the named tuple.
    Also convert the keymap from a tuple into a function
    (since keymap is a callback).
    """
    kw = {
        "description": None,
        "icon": None,
        "cursor": None,
        "options": None,
        "widget": None,
        "widget_properties": None,
        "keymap": None,
        "data_block": None,
        "operator": None,
        "draw_settings": None,
        "draw_cursor": None,
    }
    kw.update(kw_args)

    keymap = kw["keymap"]
    if keymap is None:
        pass
    elif type(keymap) is tuple:
        keymap = [_keymap_fn_from_seq(keymap)]
    else:
        keymap = [keymap]
    kw["keymap"] = keymap
    return ToolDef(**kw)


def from_fn(fn):
    """
    Use as decorator so we can define functions.
    """
    return ToolDef.from_dict(fn())


def with_args(**kw):
    def from_fn(fn):
        return ToolDef.from_dict(fn(**kw))
    return from_fn


from_fn.with_args = with_args
ToolDef.from_dict = from_dict
ToolDef.from_fn = from_fn
del from_dict, from_fn, with_args


class ToolActivePanelHelper:
    # Sub-class must define.
    # bl_space_type = 'VIEW_3D'
    # bl_region_type = 'UI'
    bl_label = "Active Tool"
    # bl_category = "Tool"

    def draw(self, context):
        layout = self.layout
        layout.use_property_split = True
        layout.use_property_decorate = False
        ToolSelectPanelHelper.draw_active_tool_header(
            context,
            layout.column(),
            show_tool_icon_always=True,
            tool_key=ToolSelectPanelHelper._tool_key_from_context(context, space_type=self.bl_space_type),
        )


class ToolSelectPanelHelper:
    """
    Generic Class, can be used for any toolbar.

    - keymap_prefix:
      The text prefix for each key-map for this spaces tools.
    - tools_all():
      Generator (context_mode, tools) tuple pairs for all tools defined.
    - tools_from_context(context, mode=None):
      A generator for all tools available in the current context.

    Tool Sequence Structure
    =======================

    Sequences of tools as returned by tools_all() and tools_from_context() are comprised of:

    - A `ToolDef` instance (representing a tool that can be activated).
    - None (a visual separator in the tool list).
    - A tuple of `ToolDef` or None values
      (representing a group of tools that can be selected between using a click-drag action).
      Note that only a single level of nesting is supported (groups cannot contain sub-groups).
    - A callable which takes a single context argument and returns a tuple of values described above.
      When the context is None, all potential tools must be returned.
    """

    @classmethod
    def tools_all(cls):
        """
        Return all tools for this toolbar, this must include all available tools ignoring the current context.
        The value is must be a sequence of (mode, tool_list) pairs, where mode may be object-mode edit-mode etc.
        The mode may be None for tool-bars that don't make use of sub-modes.
        """
        raise Exception("Sub-class %r must implement this method!" % cls)

    @classmethod
    def tools_from_context(cls, context, mode=None):
        """
        Return all tools for the current context,
        this result is used at run-time and may filter out tools to display.
        """
        raise Exception("Sub-class %r must implement this method!" % cls)

    @staticmethod
    def _tool_class_from_space_type(space_type):
        return next(
            (cls for cls in ToolSelectPanelHelper.__subclasses__()
             if cls.bl_space_type == space_type),
            None,
        )

    @staticmethod
    def _icon_value_from_icon_handle(icon_name):
        import os
        if icon_name is not None:
            assert type(icon_name) is str
            icon_value = _icon_cache.get(icon_name)
            if icon_value is None:
                dirname = bpy.utils.system_resource('DATAFILES', path="icons")
                filepath = os.path.join(dirname, icon_name + ".dat")
                try:
                    icon_value = bpy.app.icons.new_triangles_from_file(filepath)
                except BaseException as ex:
                    if not os.path.exists(filepath):
                        print("Missing icons:", filepath, ex)
                    else:
                        print("Corrupt icon:", filepath, ex)
                    # Use none as a fallback (avoids layout issues).
                    if icon_name != "none":
                        icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle("none")
                    else:
                        icon_value = 0
                _icon_cache[icon_name] = icon_value
            return icon_value
        else:
            return 0

    # tool flattening
    #
    # usually 'tools' is already expanded into `ToolDef`
    # but when registering a tool, this can still be a function
    # (_tools_flatten is usually called with cls.tools_from_context(context)
    # [that already yields from the function])
    # so if item is still a function (e.g._defs_XXX.generate_from_brushes)
    # seems like we cannot expand here (have no context yet)
    # if we yield None here, this will risk running into duplicate tool bl_idname [in register_tool()]
    # but still better than raising an error to the user.
    @staticmethod
    def _tools_flatten(tools):
        for item_parent in tools:
            if item_parent is None:
                yield None
            for item in item_parent if (type(item_parent) is tuple) else (item_parent,):
                if item is None or _item_is_fn(item):
                    yield None
                else:
                    yield item

    @staticmethod
    def _tools_flatten_with_tool_index(tools):
        for item_parent in tools:
            if item_parent is None:
                yield None, -1
            i = 0
            for item in item_parent if (type(item_parent) is tuple) else (item_parent,):
                if item is None or _item_is_fn(item):
                    yield None, -1
                else:
                    yield item, i
                    i += 1

    @staticmethod
    def _tools_flatten_with_dynamic(tools, *, context):
        """
        Expands dynamic items, indices aren't aligned with other flatten functions.
        The context may be None, use as signal to return all items.
        """
        for item_parent in tools:
            if item_parent is None:
                yield None
            for item in item_parent if (type(item_parent) is tuple) else (item_parent,):
                if item is None:
                    yield None
                elif _item_is_fn(item):
                    yield from ToolSelectPanelHelper._tools_flatten_with_dynamic(item(context), context=context)
                else:
                    yield item

    @classmethod
    def _tool_get_active(cls, context, space_type, mode, with_icon=False):
        """
        Return the active Python tool definition and icon name.
        """
        tool_active = ToolSelectPanelHelper._tool_active_from_context(context, space_type, mode)
        tool_active_id = getattr(tool_active, "idname", None)
        for item in ToolSelectPanelHelper._tools_flatten(cls.tools_from_context(context, mode)):
            if item is not None:
                if item.idname == tool_active_id:
                    if with_icon:
                        icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
                    else:
                        icon_value = 0
                    return (item, tool_active, icon_value)
        return None, None, 0

    @classmethod
    def _tool_get_by_id(cls, context, idname):
        """
        Return the active Python tool definition and index (if in sub-group, else -1).
        """
        for item, index in ToolSelectPanelHelper._tools_flatten_with_tool_index(cls.tools_from_context(context)):
            if item is not None:
                if item.idname == idname:
                    return (item, index)
        return None, -1

    @classmethod
    def _tool_get_by_id_active(cls, context, idname):
        """
        Return the active Python tool definition and index (if in sub-group, else -1).
        """
        for item in cls.tools_from_context(context):
            if item is not None:
                if type(item) is tuple:
                    if item[0].idname == idname:
                        index = cls._tool_group_active_get_from_item(item)
                        return (item[index], index)
                else:
                    if item.idname == idname:
                        return (item, -1)
        return None, -1

    @classmethod
    def _tool_get_by_id_active_with_group(cls, context, idname):
        """
        Return the active Python tool definition and index (if in sub-group, else -1).
        """
        for item in cls.tools_from_context(context):
            if item is not None:
                if type(item) is tuple:
                    if item[0].idname == idname:
                        index = cls._tool_group_active_get_from_item(item)
                        return (item[index], index, item)
                else:
                    if item.idname == idname:
                        return (item, -1, None)
        return None, -1, None

    @classmethod
    def _tool_get_group_by_id(cls, context, idname, *, coerce=False):
        """
        Return the group which contains idname, or None.
        """
        for item in cls.tools_from_context(context):
            if item is not None:
                if type(item) is tuple:
                    for subitem in item:
                        if subitem.idname == idname:
                            return item
                else:
                    if item.idname == idname:
                        if coerce:
                            return (item,)
                        else:
                            return None
        return None

    @classmethod
    def _tool_get_by_flat_index(cls, context, tool_index):
        """
        Return the active Python tool definition and index (if in sub-group, else -1).

        Return the index of the expanded list.
        """
        i = 0
        for item, index in ToolSelectPanelHelper._tools_flatten_with_tool_index(cls.tools_from_context(context)):
            if item is not None:
                if i == tool_index:
                    return (item, index)
                i += 1
        return None, -1

    @classmethod
    def _tool_get_active_by_index(cls, context, tool_index):
        """
        Return the active Python tool definition and index (if in sub-group, else -1).

        Return the index of the list without expanding.
        """
        i = 0
        for item in cls.tools_from_context(context):
            if item is not None:
                if i == tool_index:
                    if type(item) is tuple:
                        index = cls._tool_group_active_get_from_item(item)
                        item = item[index]
                    else:
                        index = -1
                    return (item, index)
                i += 1
        return None, -1

    @classmethod
    def _tool_group_active_get_from_item(cls, item):
        index = cls._tool_group_active.get(item[0].idname, 0)
        # Can happen in the case a group is dynamic.
        #
        # NOTE(Campbell): that in this case it's possible the order could change too,
        # So if we want to support this properly we will need to switch away from using
        # an index and instead use an ID.
        # Currently this is such a rare case occurrence that a range check is OK for now.
        if index >= len(item):
            index = 0
        return index

    @classmethod
    def _tool_group_active_set_by_id(cls, context, idname_group, idname):
        item_group = cls._tool_get_group_by_id(context, idname_group, coerce=True)
        if item_group:
            for i, item in enumerate(item_group):
                if item and item.idname == idname:
                    cls._tool_group_active[item_group[0].idname] = i
                    return True
        return False

    @staticmethod
    def _tool_active_from_context(context, space_type, mode=None, create=False):
        if space_type in {'VIEW_3D', 'PROPERTIES'}:
            if mode is None:
                mode = context.mode
            tool = context.workspace.tools.from_space_view3d_mode(mode, create=create)
            if tool is not None:
                tool.refresh_from_context()
                return tool
        elif space_type == 'IMAGE_EDITOR':
            space_data = context.space_data
            if mode is None:
                if space_data is None:
                    mode = 'VIEW'
                else:
                    mode = space_data.mode
            tool = context.workspace.tools.from_space_image_mode(mode, create=create)
            if tool is not None:
                tool.refresh_from_context()
                return tool
        elif space_type == 'NODE_EDITOR':
            space_data = context.space_data
            tool = context.workspace.tools.from_space_node(create=create)
            if tool is not None:
                tool.refresh_from_context()
                return tool
        elif space_type == 'SEQUENCE_EDITOR':
            space_data = context.space_data
            if mode is None:
                mode = space_data.view_type
            tool = context.workspace.tools.from_space_sequencer(mode, create=create)
            if tool is not None:
                tool.refresh_from_context()
                return tool
        return None

    @staticmethod
    def _tool_identifier_from_button(context):
        return context.button_operator.name

    @classmethod
    def _km_action_simple(cls, kc_default, kc, context_descr, label, keymap_fn):
        km_idname = "%s %s, %s" % (cls.keymap_prefix, context_descr, label)
        km = kc.keymaps.get(km_idname)
        km_kwargs = dict(space_type=cls.bl_space_type, region_type='WINDOW', tool=True)
        if km is None:
            km = kc.keymaps.new(km_idname, **km_kwargs)
            keymap_fn[0](km)
        keymap_fn[0] = km.name

        # Ensure we have a default key map, so the add-ons keymap is properly overlayed.
        if kc_default is not kc:
            kc_default.keymaps.new(km_idname, **km_kwargs)

    @classmethod
    def register(cls):
        wm = bpy.context.window_manager
        # Write into defaults, users may modify in preferences.
        kc_default = wm.keyconfigs.default

        # Track which tool-group was last used for non-active groups.
        # Blender stores the active tool-group index.
        #
        # {tool_name_first: index_in_group, ...}
        cls._tool_group_active = {}

        # ignore in background mode
        if kc_default is None:
            return

        for context_mode, tools in cls.tools_all():
            if context_mode is None:
                context_descr = "All"
            else:
                context_descr = context_mode.replace("_", " ").title()

            for item in cls._tools_flatten_with_dynamic(tools, context=None):
                if item is None:
                    continue
                keymap_data = item.keymap
                if keymap_data is None:
                    continue
                if callable(keymap_data[0]):
                    cls._km_action_simple(kc_default, kc_default, context_descr, item.label, keymap_data)

    @classmethod
    def keymap_ui_hierarchy(cls, context_mode):
        # See: bpy_extras.keyconfig_utils

        # Key-maps may be shared, don't show them twice.
        visited = set()

        for context_mode_test, tools in cls.tools_all():
            if context_mode_test == context_mode:
                for item in cls._tools_flatten(tools):
                    if item is None:
                        continue
                    keymap_data = item.keymap
                    if keymap_data is None:
                        continue
                    km_name = keymap_data[0]
                    # print((km.name, cls.bl_space_type, 'WINDOW', []))

                    if km_name in visited:
                        continue
                    visited.add(km_name)

                    yield (km_name, cls.bl_space_type, 'WINDOW', [])
                    # Callable types don't use fall-backs.
                    if isinstance(km_name, str):
                        yield (km_name + " (fallback)", cls.bl_space_type, 'WINDOW', [])

    # -------------------------------------------------------------------------
    # Layout Generators
    #
    # Meaning of received values:
    # - Bool: True for a separator, otherwise False for regular tools.
    # - None: Signal to finish (complete any final operations, e.g. add padding).

    @staticmethod
    def _layout_generator_single_column(layout, scale_y):
        col = layout.column(align=True)
        col.scale_y = scale_y
        is_sep = False
        while True:
            if is_sep is True:
                col = layout.column(align=True)
                col.scale_y = scale_y
            elif is_sep is None:
                yield None
                return
            is_sep = yield col

    @staticmethod
    def _layout_generator_multi_columns(layout, column_count, scale_y):
        scale_x = scale_y * 1.1
        column_last = column_count - 1

        col = layout.column(align=True)

        row = col.row(align=True)

        row.scale_x = scale_x
        row.scale_y = scale_y

        is_sep = False
        column_index = 0
        while True:
            if is_sep is True:
                if column_index != column_last:
                    row.label(text="")
                col = layout.column(align=True)
                row = col.row(align=True)
                row.scale_x = scale_x
                row.scale_y = scale_y
                column_index = 0

            is_sep = yield row
            if is_sep is None:
                if column_index == column_last:
                    row.label(text="")
                    yield None
                    return

            if column_index == column_count:
                column_index = 0
                row = col.row(align=True)
                row.scale_x = scale_x
                row.scale_y = scale_y
            column_index += 1

    @staticmethod
    def _layout_generator_detect_from_region(layout, region, scale_y):
        """
        Choose an appropriate layout for the toolbar.
        """
        # Currently this just checks the width,
        # we could have different layouts as preferences too.
        system = bpy.context.preferences.system
        view2d = region.view2d
        view2d_scale = (
            view2d.region_to_view(1.0, 0.0)[0] -
            view2d.region_to_view(0.0, 0.0)[0]
        )
        width_scale = region.width * view2d_scale / system.ui_scale

        if width_scale > 120.0:
            show_text = True
            column_count = 1
        else:
            show_text = False
            # 2 column layout, disabled
            if width_scale > 80.0:
                column_count = 2
            else:
                column_count = 1

        if column_count == 1:
            ui_gen = ToolSelectPanelHelper._layout_generator_single_column(
                layout, scale_y=scale_y,
            )
        else:
            ui_gen = ToolSelectPanelHelper._layout_generator_multi_columns(
                layout, column_count=column_count, scale_y=scale_y,
            )

        return ui_gen, show_text

    @classmethod
    def draw_cls(cls, layout, context, detect_layout=True, scale_y=1.75):
        # Use a classmethod so it can be called outside of a panel context.

        # XXX, this UI isn't very nice.
        # We might need to create new button types for this.
        # Since we probably want:
        # - tool-tips that include multiple key shortcuts.
        # - ability to click and hold to expose sub-tools.

        space_type = context.space_data.type
        tool_active_id = getattr(
            ToolSelectPanelHelper._tool_active_from_context(context, space_type),
            "idname", None,
        )

        if detect_layout:
            ui_gen, show_text = cls._layout_generator_detect_from_region(layout, context.region, scale_y)
        else:
            ui_gen = ToolSelectPanelHelper._layout_generator_single_column(layout, scale_y)
            show_text = True

        # Start iteration
        ui_gen.send(None)

        for item in cls.tools_from_context(context):
            if item is None:
                ui_gen.send(True)
                continue

            if type(item) is tuple:
                is_active = False
                i = 0
                for i, sub_item in enumerate(item):
                    if sub_item is None:
                        continue
                    is_active = (sub_item.idname == tool_active_id)
                    if is_active:
                        index = i
                        break
                del i, sub_item

                if is_active:
                    # not ideal, write this every time :S
                    cls._tool_group_active[item[0].idname] = index
                else:
                    index = cls._tool_group_active_get_from_item(item)

                item = item[index]
                use_menu = True
            else:
                index = -1
                use_menu = False

            is_active = (item.idname == tool_active_id)
            icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)

            sub = ui_gen.send(False)

            if use_menu:
                sub.operator_menu_hold(
                    "wm.tool_set_by_id",
                    text=item.label if show_text else "",
                    depress=is_active,
                    menu="WM_MT_toolsystem_submenu",
                    icon_value=icon_value,
                ).name = item.idname
            else:
                sub.operator(
                    "wm.tool_set_by_id",
                    text=item.label if show_text else "",
                    depress=is_active,
                    icon_value=icon_value,
                ).name = item.idname
        # Signal to finish any remaining layout edits.
        ui_gen.send(None)

    def draw(self, context):
        self.draw_cls(self.layout, context)

    @staticmethod
    def _tool_key_from_context(context, *, space_type=None):
        if space_type is None:
            space_data = context.space_data
            space_type = space_data.type
        else:
            space_data = None

        if space_type == 'VIEW_3D':
            return space_type, context.mode
        elif space_type == 'IMAGE_EDITOR':
            if space_data is None:
                space_data = context.space_data
            return space_type, space_data.mode
        elif space_type == 'NODE_EDITOR':
            return space_type, None
        elif space_type == 'SEQUENCE_EDITOR':
            return space_type, context.space_data.view_type
        else:
            return None, None

    @staticmethod
    def tool_active_from_context(context):
        space_type = context.space_data.type
        return ToolSelectPanelHelper._tool_active_from_context(context, space_type)

    @staticmethod
    def draw_active_tool_fallback(
            context, layout, tool,
            *,
            is_horizontal_layout=False,
    ):
        idname_fallback = tool.idname_fallback
        space_type = tool.space_type
        cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
        item_fallback, _index = cls._tool_get_by_id(context, idname_fallback)
        if item_fallback is not None:
            draw_settings = item_fallback.draw_settings
            if draw_settings is not None:
                if not is_horizontal_layout:
                    layout.separator()
                draw_settings(context, layout, tool)

    @staticmethod
    def draw_active_tool_header(
            context, layout,
            *,
            show_tool_icon_always=False,
            tool_key=None,
    ):
        if tool_key is None:
            space_type, mode = ToolSelectPanelHelper._tool_key_from_context(context)
        else:
            space_type, mode = tool_key

        if space_type is None:
            return None

        cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
        item, tool, icon_value = cls._tool_get_active(context, space_type, mode, with_icon=True)
        if item is None:
            return None
        # NOTE: we could show `item.text` here but it makes the layout jitter when switching tools.
        # Add some spacing since the icon is currently assuming regular small icon size.
        if show_tool_icon_always:
            layout.label(text="    " + iface_(item.label, "Operator"), icon_value=icon_value)
            layout.separator()
        else:
            if not context.space_data.show_region_toolbar:
                layout.template_icon(icon_value=icon_value, scale=0.5)
                layout.separator()

        draw_settings = item.draw_settings
        if draw_settings is not None:
            draw_settings(context, layout, tool)

        idname_fallback = tool.idname_fallback
        if idname_fallback and idname_fallback != item.idname:
            tool_settings = context.tool_settings

            # Show popover which looks like an enum but isn't one.
            if tool_settings.workspace_tool_type == 'FALLBACK':
                tool_fallback_id = cls.tool_fallback_id
                item, _select_index = cls._tool_get_by_id_active(context, tool_fallback_id)
                label = item.label
            else:
                label = "Active Tool"

            split = layout.split(factor=0.33)
            row = split.row()
            row.alignment = 'RIGHT'
            row.label(text="Drag:")
            row = split.row()
            row.context_pointer_set("tool", tool)
            row.popover(panel="TOPBAR_PT_tool_fallback", text=iface_(label, "Operator"))

        return tool

    # Show a list of tools in the popover.
    @staticmethod
    def draw_fallback_tool_items(layout, context):
        space_type = context.space_data.type
        if space_type == 'PROPERTIES':
            space_type = 'VIEW_3D'

        cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
        tool_fallback_id = cls.tool_fallback_id

        _item, _select_index, item_group = cls._tool_get_by_id_active_with_group(context, tool_fallback_id)

        if item_group is None:
            # Could print comprehensive message - listing available items.
            raise Exception("Fallback tool doesn't exist")

        col = layout.column(align=True)
        tool_settings = context.tool_settings
        col.prop_enum(
            tool_settings,
            "workspace_tool_type",
            value='DEFAULT',
            text="Active Tool",
        )
        is_active_tool = (tool_settings.workspace_tool_type == 'DEFAULT')

        col = layout.column(align=True)
        if is_active_tool:
            index_current = -1
        else:
            index_current = cls._tool_group_active_get_from_item(item_group)

        for i, sub_item in enumerate(item_group):
            is_active = (i == index_current)

            props = col.operator(
                "wm.tool_set_by_id",
                text=sub_item.label,
                depress=is_active,
            )
            props.name = sub_item.idname
            props.as_fallback = True
            props.space_type = space_type

    @staticmethod
    def draw_fallback_tool_items_for_pie_menu(layout, context):
        space_type = context.space_data.type
        if space_type == 'PROPERTIES':
            space_type = 'VIEW_3D'

        cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
        tool_fallback_id = cls.tool_fallback_id

        _item, _select_index, item_group = cls._tool_get_by_id_active_with_group(context, tool_fallback_id)

        if item_group is None:
            # Could print comprehensive message - listing available items.
            raise Exception("Fallback tool doesn't exist")

        # Allow changing the active tool,
        # even though this isn't the purpose of the pie menu
        # it's confusing from a user perspective if we don't allow it.
        is_fallback_group_active = getattr(
            ToolSelectPanelHelper._tool_active_from_context(context, space_type),
            "idname", None,
        ) in (item.idname for item in item_group)

        pie = layout.menu_pie()
        tool_settings = context.tool_settings
        pie.prop_enum(
            tool_settings,
            "workspace_tool_type",
            value='DEFAULT',
            text="Active Tool",
            # Could use a less generic icon.
            icon='TOOL_SETTINGS',
        )
        is_active_tool = (tool_settings.workspace_tool_type == 'DEFAULT')

        if is_active_tool:
            index_current = -1
        else:
            index_current = cls._tool_group_active_get_from_item(item_group)
        for i, sub_item in enumerate(item_group):
            is_active = (i == index_current)
            props = pie.operator(
                "wm.tool_set_by_id",
                text=sub_item.label,
                depress=is_active,
                icon_value=ToolSelectPanelHelper._icon_value_from_icon_handle(sub_item.icon),
            )
            props.name = sub_item.idname
            props.space_type = space_type
            if not is_fallback_group_active:
                props.as_fallback = True


# The purpose of this menu is to be a generic popup to select between tools
# in cases when a single tool allows to select alternative tools.
class WM_MT_toolsystem_submenu(Menu):
    bl_label = ""

    @staticmethod
    def _tool_group_from_button(context):
        # Lookup the tool definitions based on the space-type.
        cls = ToolSelectPanelHelper._tool_class_from_space_type(context.space_data.type)
        if cls is not None:
            button_identifier = ToolSelectPanelHelper._tool_identifier_from_button(context)
            for item_group in cls.tools_from_context(context):
                if type(item_group) is tuple:
                    for sub_item in item_group:
                        if (sub_item is not None) and (sub_item.idname == button_identifier):
                            return cls, item_group
        return None, None

    def draw(self, context):
        layout = self.layout
        layout.scale_y = 2.0

        _cls, item_group = self._tool_group_from_button(context)
        if item_group is None:
            # Should never happen, just in case
            layout.label(text="Unable to find toolbar group")
            return

        for item in item_group:
            if item is None:
                layout.separator()
                continue
            icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
            layout.operator(
                "wm.tool_set_by_id",
                text=item.label,
                icon_value=icon_value,
            ).name = item.idname


def _activate_by_item(context, space_type, item, index, *, as_fallback=False):
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    tool = ToolSelectPanelHelper._tool_active_from_context(context, space_type, create=True)
    tool_fallback_id = cls.tool_fallback_id

    if as_fallback:
        # To avoid complicating logic too much, isolate all fallback logic to this block.
        # This will set the tool again, using the item for the fallback instead of the primary tool.
        #
        # If this ends up needing to be more complicated,
        # it would be better to split it into a separate function.

        _item, _select_index, item_group = cls._tool_get_by_id_active_with_group(context, tool_fallback_id)

        if item_group is None:
            # Could print comprehensive message - listing available items.
            raise Exception("Fallback tool doesn't exist")
        index_new = -1
        for i, sub_item in enumerate(item_group):
            if sub_item.idname == item.idname:
                index_new = i
                break
        if index_new == -1:
            raise Exception("Fallback tool not found in group")

        cls._tool_group_active[tool_fallback_id] = index_new

        # Done, now get the current tool to replace the item & index.
        tool_active = ToolSelectPanelHelper._tool_active_from_context(context, space_type)
        item, index = cls._tool_get_by_id(context, getattr(tool_active, "idname", None))
    else:
        # Ensure the active fallback tool is read from saved state (even if the fallback tool is not in use).
        stored_idname_fallback = tool.idname_fallback
        if stored_idname_fallback:
            cls._tool_group_active_set_by_id(context, tool_fallback_id, stored_idname_fallback)
        del stored_idname_fallback

    # Find fallback keymap.
    item_fallback = None
    _item, select_index = cls._tool_get_by_id(context, tool_fallback_id)
    if select_index != -1:
        item_fallback, _index = cls._tool_get_active_by_index(context, select_index)
    # End calculating fallback.

    gizmo_group = item.widget or ""

    idname_fallback = (item_fallback and item_fallback.idname) or ""
    keymap_fallback = (item_fallback and item_fallback.keymap and item_fallback.keymap[0]) or ""
    if keymap_fallback:
        keymap_fallback = keymap_fallback + " (fallback)"

    tool.setup(
        idname=item.idname,
        keymap=item.keymap[0] if item.keymap is not None else "",
        cursor=item.cursor or 'DEFAULT',
        options=item.options or set(),
        gizmo_group=gizmo_group,
        data_block=item.data_block or "",
        operator=item.operator or "",
        index=index,
        idname_fallback=idname_fallback,
        keymap_fallback=keymap_fallback,
    )

    if (
            (gizmo_group != "") and
            (props := tool.gizmo_group_properties(gizmo_group))
    ):
        if props is None:
            print("Error:", gizmo_group, "could not access properties!")
        else:
            gizmo_properties = item.widget_properties
            if gizmo_properties is not None:
                if not isinstance(gizmo_properties, list):
                    raise Exception("expected a list, not a %r" % type(gizmo_properties))

                from bl_keymap_utils.io import _init_properties_from_data
                _init_properties_from_data(props, gizmo_properties)

    WindowManager = bpy.types.WindowManager

    handle_map = _activate_by_item._cursor_draw_handle
    handle = handle_map.pop(space_type, None)
    if handle is not None:
        WindowManager.draw_cursor_remove(handle)
    if item.draw_cursor is not None:
        def handle_fn(context, item, tool, xy):
            item.draw_cursor(context, tool, xy)
        handle = WindowManager.draw_cursor_add(handle_fn, (context, item, tool), space_type, 'WINDOW')
        handle_map[space_type] = handle


_activate_by_item._cursor_draw_handle = {}


def activate_by_id(context, space_type, idname, *, as_fallback=False):
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    if cls is None:
        return False
    item, index = cls._tool_get_by_id(context, idname)
    if item is None:
        return False
    _activate_by_item(context, space_type, item, index, as_fallback=as_fallback)
    return True


def activate_by_id_or_cycle(context, space_type, idname, *, offset=1, as_fallback=False):

    # Only cycle when the active tool is activated again.
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    item, _index = cls._tool_get_by_id(context, idname)
    if item is None:
        return False

    tool_active = ToolSelectPanelHelper._tool_active_from_context(context, space_type)
    id_active = getattr(tool_active, "idname", None)

    id_current = ""
    for item_group in cls.tools_from_context(context):
        if type(item_group) is tuple:
            index_current = cls._tool_group_active_get_from_item(item_group)
            for sub_item in item_group:
                if sub_item.idname == idname:
                    id_current = item_group[index_current].idname
                    break
            if id_current:
                break

    if id_current == "":
        return activate_by_id(context, space_type, idname)
    if id_active != id_current:
        return activate_by_id(context, space_type, id_current)

    index_found = (tool_active.index + offset) % len(item_group)

    cls._tool_group_active[item_group[0].idname] = index_found

    item_found = item_group[index_found]
    _activate_by_item(context, space_type, item_found, index_found)
    return True


def description_from_id(context, space_type, idname, *, use_operator=True):
    # Used directly for tooltips.
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    item, _index = cls._tool_get_by_id(context, idname)
    if item is None:
        return False

    # Custom description.
    description = item.description
    if description is not None:
        if callable(description):
            km = _keymap_from_item(context, item)
            return description(context, item, km)
        return tip_(description)

    # Extract from the operator.
    if use_operator:
        operator = item.operator
        if operator is None:
            if item.keymap is not None:
                km = _keymap_from_item(context, item)
                if km is not None:
                    for kmi in km.keymap_items:
                        if kmi.active:
                            operator = kmi.idname
                            break

        if operator is not None:
            import _bpy
            return tip_(_bpy.ops.get_rna_type(operator).description)
    return ""


def item_from_id(context, space_type, idname):
    # Used directly for tooltips.
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    if cls is None:
        return None
    item, _index = cls._tool_get_by_id(context, idname)
    return item


def item_from_id_active(context, space_type, idname):
    # Used directly for tooltips.
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    if cls is None:
        return None
    item, _index = cls._tool_get_by_id_active(context, idname)
    return item


def item_from_id_active_with_group(context, space_type, idname):
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    if cls is None:
        return None
    cls, item, _index = cls._tool_get_by_id_active_with_group(context, idname)
    return item


def item_group_from_id(context, space_type, idname, *, coerce=False):
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    if cls is None:
        return None
    return cls._tool_get_group_by_id(context, idname, coerce=coerce)


def item_from_flat_index(context, space_type, index):
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    if cls is None:
        return None
    item, _index = cls._tool_get_by_flat_index(context, index)
    return item


def item_from_index_active(context, space_type, index):
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    if cls is None:
        return None
    item, _index = cls._tool_get_active_by_index(context, index)
    return item


def keymap_from_id(context, space_type, idname):
    # Used directly for tooltips.
    cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
    if cls is None:
        return None
    item, _index = cls._tool_get_by_id(context, idname)
    if item is None:
        return False

    keymap = item.keymap
    # List container of one.
    if keymap:
        return keymap[0]
    return ""


def _keymap_from_item(context, item):
    if item.keymap is not None:
        wm = context.window_manager
        keyconf = wm.keyconfigs.active
        return keyconf.keymaps.get(item.keymap[0])
    return None


classes = (
    WM_MT_toolsystem_submenu,
)

if __name__ == "__main__":  # only for live edit.
    from bpy.utils import register_class
    for cls in classes:
        register_class(cls)
