# Copyright (c) 2020-2020, NVIDIA CORPORATION.  All rights reserved.
#
# NVIDIA CORPORATION and its licensors retain all intellectual property
# and proprietary rights in and to this software, related documentation
# and any modifications thereto.  Any use, reproduction, disclosure or
# distribution of this software and related documentation without an express
# license agreement from NVIDIA CORPORATION is strictly prohibited.
#
"""The context menus"""
import os, sys
import weakref
import copy
import asyncio
import carb
import carb.events
import omni.kit.ui
import omni.kit.usd.layers as layers

from omni import ui
from functools import partial
from typing import Callable, List, Union, Tuple
from pathlib import Path
from pxr import Usd, Sdf, Gf, Tf, UsdShade, UsdGeom, Trace, UsdUtils, Kind
from .singleton import Singleton


_extension_instance = None
TEST_DATA_PATH = ""
SETTING_HIDE_CREATE_MENU = "/exts/omni.kit.context_menu/hideCreateMenu"


class ContextMenuEventType:
    ADDED = 0  # New menu entry is added
    REMOVED = 0  # Menu entry is removed


@Singleton
class _CustomMenuDict:
    """
    The singleton object that holds custom context menu which is private
    """

    def __init__(self) -> None:
        self.__custom_menu_dict = {}
        self.__counter = 0
        self.__event_stream = carb.events.get_events_interface().create_event_stream()

    def add_menu(self, menu: Union[str, list], index: str, extension_id: str) -> int:
        menu_id = self.__counter
        if not index in self.__custom_menu_dict:
            self.__custom_menu_dict[index] = {}
        if not extension_id in self.__custom_menu_dict[index]:
            self.__custom_menu_dict[index][extension_id] = {}

        self.__custom_menu_dict[index][extension_id][menu_id] = menu
        self.__counter += 1
        self.__event_stream.dispatch(ContextMenuEventType.ADDED, payload={"index": index, "extension_id": extension_id})
        return menu_id

    def remove_menu(self, menu_id: int, index: str, extension_id: str) -> None:
        # NOTE: removing a menu dictionary that does not exist is not valid
        if (
            index in self.__custom_menu_dict
            and extension_id in self.__custom_menu_dict[index]
            and menu_id in self.__custom_menu_dict[index][extension_id]
        ):
            del self.__custom_menu_dict[index][extension_id][menu_id]
            self.__event_stream.dispatch(ContextMenuEventType.REMOVED, payload={"index": index, "extension_id": extension_id})
            return
        carb.log_error(f"remove_menu index:{index} extension_id:{extension_id} doesn't exist.")

    def get_menu_dict(self, index: str, extension_id: str) -> List[dict]:
        # NOTE: getting a menu dictionary that does not exist is valid and will return empty list
        if index in self.__custom_menu_dict and extension_id in self.__custom_menu_dict[index]:
            return self._merge_submenus(list(self.__custom_menu_dict[index][extension_id].values()))
        return []

    ## merge submenus ##

    def _get_duplicate_item(self, compare_item: dict, item_list: list) -> bool:
        for item in item_list:
            if isinstance(compare_item["name"], str) and isinstance(item["name"], str):
                if item["name"] != "" and compare_item["name"] == item["name"]:
                    return item
            elif isinstance(compare_item["name"], dict) and isinstance(item["name"], dict):
                if compare_item["name"].keys() == item["name"].keys():
                    return item
        return None

    def __merge_submenu(self, keys: list, main_item: dict, merge_item: dict) -> dict:
        for key in keys:
            if isinstance(main_item[key], str) and isinstance(merge_item[key], str):
                main_item[key] = copy.copy(main_item[key]) + merge_item[key]
            elif isinstance(main_item[key], list) and isinstance(merge_item[key], list):
                main_item[key] = main_item[key].copy()
                for item in merge_item[key]:
                    duplicate_item = self._get_duplicate_item(item, main_item[key])
                    if duplicate_item:
                        dup_item_name = duplicate_item["name"]
                        if (not dup_item_name) or not (hasattr(dup_item_name, "keys")):
                            if dup_item_name != "":
                                carb.log_warn(f"_merge_submenu: failed to merge duplicate item {dup_item_name}")
                        else:
                            duplicate_item["name"] = self.__merge_submenu(
                                dup_item_name.keys(), copy.copy(dup_item_name), item["name"]
                            )
                    else:
                        main_item[key].append(item)
        return main_item

    def _merge_submenus(self, main_list: list) -> list:
        """ merge submenus into new dict without changing the original """
        new_list = []
        for item in main_list:
            duplicate_item = self._get_duplicate_item(item, new_list)
            if duplicate_item:
                dup_item_name = duplicate_item["name"]
                if (not dup_item_name) or not (hasattr(dup_item_name, "keys")):
                    if dup_item_name != "":
                        carb.log_warn(f"_merge_submenus: failed to merge duplicate item {dup_item_name}")
                else:
                    duplicate_item["name"] = self.__merge_submenu(
                        dup_item_name.keys(), copy.copy(dup_item_name), item["name"]
                    )
            else:
                new_list.append(copy.copy(item))
        return new_list

    def get_event_stream(self):
        return self.__event_stream


class ContextMenuExtension(omni.ext.IExt):
    """Context menu core functionality. See omni.kit.viewport_legacy.context_menu for datailed usage.

    Example using viewport mouse event to trigger:

    .. code-block:: python

        class ContextMenu:
            def on_startup(self):
                # get window event stream
                import omni.kit.viewport_legacy
                viewport_win = get_viewport_interface().get_viewport_window()
                # on_mouse_event called when event dispatched
                self._stage_event_sub = viewport_win.get_mouse_event_stream().create_subscription_to_pop(self.on_mouse_event)

            def on_shutdown(self):
                # remove event
                self._stage_event_sub = None

            def on_mouse_event(self, event):
                # check its expected event
                if event.type != int(omni.kit.ui.MenuEventType.ACTIVATE):
                    return

                # get context menu core functionality & check its enabled
                context_menu = omni.kit.context_menu.get_instance()
                if context_menu is None:
                    carb.log_error("context_menu is disabled!")
                    return

                # get parameters passed by event
                objects = {}
                objects["test_path"] = event.payload["test_path"]
                # setup objects, this is passed to all functions
                objects["test"] = "this is a test"

                # setup menu
                menu_list = [
                    # name is name shown on menu. (if name is "" then a menu spacer is added. Can be combined with show_fn)
                    # glyph is icon shown on menu
                    # name_fn funcion to get menu item name
                    # show_fn funcion or list of functions used to decide if menu item is shown. All functions must return True to show
                    # enabled_fn funcion or list of functions used to decide if menu item is enabled. All functions must return True to be enabled
                    # onclick_fn function called when user clicks menu item
                    # onclick_action action called when user clicks menu item
                    # populate_fn a fucntion to be called to populate the menu. Can be combined with show_fn
                    # appear_after a identifier of menu name. Used by custom menus and will allow cusom menu to change order

                    {"name": "Test Menu", "glyph": "question.svg", "show_fn": [ContextMenu.has_reason_to_show, ContextMenu.has_another_reason_to_show],
                             "onclick_fn": ContextMenu.clear_default_prim },
                    {"name": "", "show_fn": ContextMenu.has_another_reason_to_show},
                    {"populate_fn": context_menu.show_create_menu},
                    {"name": ""},
                    {"name": "Copy URL Link", "glyph": "menu_link.svg", "onclick_fn": ContextMenu.copy_prim_url},
                ]

                # add custom menus
                menu_list += omni.kit.context_menu.get_menu_dict("MENU", "")
                menu_list += omni.kit.context_menu.get_menu_dict("MENU", "stagewindow")
                omni.kit.context_menu.reorder_menu_dict(menu_dict)

                # show menu
                context_menu.show_context_menu("stagewindow", objects, menu_list)

            # show_fn functions
            def has_reason_to_show(objects: dict):
                if not "test_path" in objects:
                    return False
                return True

            def has_another_reason_to_show(objects: dict):
                if not "test_path" in objects:
                    return False
                return True

            def copy_prim_url(objects: dict):
                try:
                    import omni.kit.clipboard

                    omni.kit.clipboard.copy("My hovercraft is full of eels")
                except ImportError:
                    carb.log_warn("Could not import omni.kit.clipboard.")

    """

    # ---------------------------------------------- menu global populate functions ----------------------------------------------

    class uiMenu(ui.Menu):
        def __init__(self, *args, **kwargs):
            self.glyph = None
            self.submenu = False

            if "glyph" in kwargs:
                self.glyph = kwargs["glyph"]
                del kwargs["glyph"]

            if "submenu" in kwargs:
                self.submenu = kwargs["submenu"]
                del kwargs["submenu"]

            tearable = False
            if "tearable" in kwargs:
                tearable = kwargs["tearable"]
                del kwargs["tearable"]

            super().__init__(*args, **kwargs, menu_compatibility=False, tearable=tearable)

    class uiMenuItem(ui.MenuItem):
        def __init__(self, *args, **kwargs):
            self.glyph = None
            self.submenu = False

            if "glyph" in kwargs:
                self.glyph = kwargs["glyph"]
                del kwargs["glyph"]

            super().__init__(*args, **kwargs, menu_compatibility=False)

    class DefaultMenuDelegate(ui.MenuDelegate):
        EXTENSION_FOLDER_PATH = Path()
        ICON_PATH  = EXTENSION_FOLDER_PATH.joinpath("data/icons")
        ICON_SIZE = 14
        TEXT_SIZE = 18
        ICON_SPACING = [7, 7]
        MARGIN_SIZE = [4, 4]
        SUBMENU_SPACING = 4
        SUBMENU_ICON_SIZE = 11.7
        COLOR_LABEL_ENABLED = 0xFFCCCCCC
        COLOR_LABEL_DISABLED = 0xFF6F6F6F
        COLOR_TICK_ENABLED = 0xFFCCCCCC
        COLOR_TICK_DISABLED = 0xFF4F4F4F
        COLOR_SEPARATOR = 0xFF6F6F6F

        # doc compiler breaks if `omni.kit.app.get_app()` or `carb.settings.get_settings()` is called
        try:
            EXTENSION_FOLDER_PATH = Path(omni.kit.app.get_app().get_extension_manager().get_extension_path_by_module(__name__))
            ICON_PATH  = EXTENSION_FOLDER_PATH.joinpath("data/icons")
            ICON_SIZE = carb.settings.get_settings().get("exts/omni.kit.context_menu/icon_size")
            ICON_SPACING = carb.settings.get_settings().get("exts/omni.kit.context_menu/icon_spacing")
            TEXT_SIZE = carb.settings.get_settings().get("exts/omni.kit.context_menu/text_size")
            MARGIN_SIZE = carb.settings.get_settings().get("exts/omni.kit.context_menu/margin_size")
            SUBMENU_SPACING = carb.settings.get_settings().get("exts/omni.kit.context_menu/submenu_spacing")
            SUBMENU_ICON_SIZE = carb.settings.get_settings().get("exts/omni.kit.context_menu/submenu_icon_size")
            COLOR_LABEL_ENABLED = carb.settings.get_settings().get("exts/omni.kit.context_menu/color_label_enabled")
            COLOR_LABEL_DISABLED = carb.settings.get_settings().get("exts/omni.kit.context_menu/color_label_disabled")
            COLOR_TICK_ENABLED = carb.settings.get_settings().get("exts/omni.kit.context_menu/color_tick_enabled")
            COLOR_TICK_DISABLED = carb.settings.get_settings().get("exts/omni.kit.context_menu/color_tick_disabled")
            COLOR_SEPARATOR = carb.settings.get_settings().get("exts/omni.kit.context_menu/color_separator")
        except Exception as exc:
            carb.log_warn(f"DefaultMenuDelegate: failed to get EXTENSION_FOLDER_PATH and deafults {exc}")


        def get_style(self):
            return {"Label::Enabled":      {"margin_width": self.MARGIN_SIZE[0],
                                            "margin_height": self.MARGIN_SIZE[1],
                                            "color": self.COLOR_LABEL_ENABLED},
                    "Label::Disabled":     {"margin_width": self.MARGIN_SIZE[0],
                                            "margin_height": self.MARGIN_SIZE[1],
                                            "color": self.COLOR_LABEL_DISABLED},
                    "Image::Icon":         {"margin_width": 0,
                                            "margin_height": 0,
                                            "color": self.COLOR_LABEL_ENABLED},
                    "Image::SubMenu":      {"image_url": f"{self.ICON_PATH}/subdir.svg",
                                            "margin_width": 0,
                                            "margin_height": 0,
                                            "color": self.COLOR_LABEL_ENABLED},
                    "Image::TickEnabled":  {"image_url": f"{self.ICON_PATH}/checked.svg",
                                            "margin_width": 0,
                                            "margin_height": 0,
                                            "color": self.COLOR_TICK_ENABLED},
                    "Image::TickDisabled": {"image_url": f"{self.ICON_PATH}/checked.svg",
                                            "margin_width": 0,
                                            "margin_height": 0,
                                            "color": self.COLOR_TICK_DISABLED},
                    "Menu.Separator":      {"color": self.COLOR_SEPARATOR}
                   }

        def build_item(self, item: ui.Menu):
            if isinstance(item, ui.Separator):
                return super().build_item(item)
            elif not isinstance(item, ContextMenuExtension.uiMenu) and not isinstance(item, ContextMenuExtension.uiMenuItem):
                carb.log_warn(f"build_item: bad context menu item {item.text} - Should be uiMenu/uiMenuItem")
                return super().build_item(item)

            style = ContextMenuExtension.default_delegate.get_style()
            if item.delegate and hasattr(item.delegate, "get_style"):
                style |= item.delegate.get_style()

            with ui.HStack(height=0, style=style):
                ui.Spacer(width=4)
                # build tick
                if item.checkable:
                    ui.Image("", width=self.ICON_SIZE, name="TickEnabled" if item.checked else "TickDisabled")

                # build glyph
                if item.glyph:
                    glyph_path = item.glyph if "/" in item.glyph.replace("\\", "/") else carb.tokens.get_tokens_interface().resolve("${glyphs}/"+ item.glyph)
                    ui.Spacer(width=self.ICON_SPACING[0])
                    ui.Image(glyph_path, width=self.ICON_SIZE, name="Icon")
                    ui.Spacer(width=self.ICON_SPACING[1])

                # build label
                ui.Label(f"{item.text}        ", height=self.TEXT_SIZE, name="Enabled" if item.enabled else "Disabled")

                # build subdir marker
                if item.submenu:
                    ui.Image("", width=self.SUBMENU_ICON_SIZE, name="SubMenu")
                    ui.Spacer(width=self.SUBMENU_SPACING)

    default_delegate = DefaultMenuDelegate()

    def _set_prim_translation(stage, prim_path: Sdf.Path, translate: Gf.Vec3d):
        prim = stage.GetPrimAtPath(prim_path)
        if not prim or not prim.IsA(UsdGeom.Xformable):
            return

        xformable = UsdGeom.Xformable(prim)
        for op in xformable.GetOrderedXformOps():
            if op.GetOpType() == UsdGeom.XformOp.TypeTranslate and "pivot" not in op.SplitName():
                precision = op.GetPrecision()
                desired_translate = (
                    translate
                    if precision == UsdGeom.XformOp.Precision
                    else Gf.Vec3f(translate[0], translate[1], translate[2])
                )
                omni.kit.commands.execute("ChangeProperty", prop_path=op.GetAttr().GetPath().pathString, value=desired_translate, prev=None)
                break
            elif op.GetOpType() == UsdGeom.XformOp.TypeTransform:
                matrix = op.Get()
                matrix.SetTranslate(translate)
                omni.kit.commands.execute("ChangeProperty", prop_path=op.GetAttr().GetPath().pathString, value=matrix, prev=None)
                break

    def create_prim(objects: dict, prim_type: str, attributes: dict, create_group_xform: bool = False) -> None:
        """
        create prims

        Args:
            objects: context_menu data
            prim_type: created prim's type
            attributes: created prim's cutsom attributes
            create_group_xform: passed to CreatePrimWithDefaultXformCommand

        Returns:
            None
        """
        usd_context = omni.usd.get_context()
        stage = usd_context.get_stage()
        with omni.kit.undo.group():
            with layers.active_authoring_layer_context(usd_context):
                if "mouse_pos" in objects and not create_group_xform:
                    mouse_pos = objects["mouse_pos"]
                    omni.kit.commands.execute(
                        "CreatePrimWithDefaultXform",
                        prim_type=prim_type,
                        attributes=attributes,
                        select_new_prim=True
                    )
                    paths = usd_context.get_selection().get_selected_prim_paths()
                    if stage:
                        for path in paths:
                            ContextMenuExtension._set_prim_translation(
                                stage, path, Gf.Vec3d(mouse_pos[0], mouse_pos[1], mouse_pos[2])
                            )
                elif create_group_xform:
                    paths = usd_context.get_selection().get_selected_prim_paths()
                    if stage.HasDefaultPrim() and stage.GetDefaultPrim().GetPath().pathString in paths:
                        post_notification("Cannot Group default prim")
                        return

                    omni.kit.commands.execute("GroupPrims", prim_paths=paths)
                else:
                    prim_path = None
                    if "use_hovered" in objects and objects["use_hovered"]:
                        prim = get_hovered_prim(objects)
                        if prim:
                            new_path = omni.usd.get_stage_next_free_path(stage, prim.GetPrimPath().pathString + "/" + prim_type, False)
                            future_prim = Usd.SchemaRegistry.GetTypeFromName(prim_type)
                            if omni.usd.can_prim_have_children(stage, Sdf.Path(new_path), future_prim):
                                prim_path = new_path
                            else:
                                post_notification(f"Cannot create prim below {prim.GetPrimPath().pathString} as nested prims are not supported for this type.")

                    omni.kit.commands.execute(
                        "CreatePrimWithDefaultXform",
                        prim_type=prim_type,
                        prim_path=prim_path,
                        attributes=attributes,
                        select_new_prim=True
                    )

    def create_mesh_prim(objects: dict, prim_type: str) -> None:
        """
        create mesh prims

        Args:
            objects: context_menu data
            prim_type: created prim's type

        Returns:
            None
        """
        usd_context = omni.usd.get_context()
        stage = usd_context.get_stage()

        prim_path = None
        if "use_hovered" in objects and objects["use_hovered"]:
            prim = get_hovered_prim(objects)
            if prim:
                # mesh prims are always UsdGeom.Gprim so just check for ancestor prim_type
                new_path = omni.usd.get_stage_next_free_path(stage, prim.GetPrimPath().pathString + "/" + prim_type, False)
                if omni.usd.is_ancestor_prim_type(stage, Sdf.Path(new_path), UsdGeom.Gprim):
                    post_notification(f"Cannot create prim below {prim.GetPrimPath().pathString} as nested prims are not supported for this type.")
                else:
                    prim_path = new_path

        with omni.kit.undo.group():
            with layers.active_authoring_layer_context(usd_context):
                omni.kit.commands.execute(
                    "CreateMeshPrimWithDefaultXform", prim_type=prim_type,
                    prim_path=prim_path, select_new_prim=True, prepend_default_prim=False if prim_path else True
                )

                if "mouse_pos" in objects:
                    mouse_pos = objects["mouse_pos"]
                    paths = usd_context.get_selection().get_selected_prim_paths()
                    if stage:
                        for path in paths:
                            ContextMenuExtension._set_prim_translation(
                                stage, path, Gf.Vec3d(mouse_pos[0], mouse_pos[1], mouse_pos[2])
                            )

    def show_selected_prims_names(self, objects: dict, delegate=None) -> None:
        """
        populate function that builds menu items with selected prim info

        Args:
            objects: context_menu data

        Returns:
            None
        """
        context_menu = omni.kit.context_menu.get_instance()
        if context_menu is None:
            carb.log_error("context_menu is disabled!")
            return

        paths = omni.usd.get_context().get_selection().get_selected_prim_paths()
        hovered = None
        if "use_hovered" in objects and objects["use_hovered"]:
            hovered = omni.kit.context_menu.get_hovered_prim(objects)

        if not "prim_list" in objects and len(paths) == 0:
            menu_name = f"(nothing selected)"
        else:
            if "prim_list" in objects:
                paths = objects["prim_list"]

            if len(paths) > 1:
                menu_name = f"({len(paths)} models selected)"
            elif "prim_list" in objects:
                if paths[0].GetPath() == hovered.GetPrimPath() if hovered else None:
                    menu_name = f"({paths[0].GetPath().name} selected & hovered)"
                    hovered = None
                else:
                    menu_name = f"({paths[0].GetPath().name} selected)"

            else:
                menu_name = f"({os.path.basename(paths[0])} selected)"
        if hovered:
            menu_name += f"\n({hovered.GetPath().name} hovered)"

        context_menu._menu_title = ContextMenuExtension.uiMenuItem(
            f'{menu_name}',
            triggered_fn=None,
            enabled=False if delegate else True,
            delegate=delegate if delegate else ContextMenuExtension.default_delegate,
            tearable=True if delegate else False,
            glyph="menu_prim.svg"
        )
        if not carb.settings.get_settings().get(SETTING_HIDE_CREATE_MENU):
            ui.Separator()

    def show_create_menu(self, objects: dict):
        """
        populate function that builds create menu

        Args:
            objects: context_menu data

        Returns:
            None
        """
        prim = None
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]

        self.build_create_menu(objects, prim_list)

    def build_create_menu(self, objects: dict, prim_list: list, custom_menu: dict = [], delegate=None):
        def is_create_type_enabled(type: str):
            settings = carb.settings.get_settings()
            if type == "Shape":
                if settings.get("/app/primCreation/hideShapes") == True or \
                   settings.get("/app/primCreation/enableMenuShape") == False:
                       return False
                return True
            enabled = settings.get(f"/app/primCreation/enableMenu{type}")
            if enabled == True or enabled == False:
                return enabled
            return True


        if carb.settings.get_settings().get(SETTING_HIDE_CREATE_MENU):
            return

        custom_menu += omni.kit.context_menu.get_menu_dict("CREATE", "")
        usd_context = omni.usd.get_context()
        paths = usd_context.get_selection().get_selected_prim_paths()

        item_delegate = delegate if delegate else ContextMenuExtension.default_delegate

        style = delegate.get_style() if delegate and hasattr(delegate, "get_style") else ContextMenuExtension.default_delegate.get_style()
        item = ContextMenuExtension.uiMenu(
            f'Create',
            style=style,
            delegate=item_delegate,
            glyph="menu_create.svg",
            submenu=True
        )
        self._context_menu_items.append(item)
        with item:
            try:
                geometry_mesh_prim_list = omni.kit.primitive.mesh.get_geometry_mesh_prim_list()
                item = ContextMenuExtension.uiMenu(f'Mesh', delegate=item_delegate, glyph="menu_prim.svg", submenu=True)
                self._context_menu_items.append(item)
                with item:
                    for mesh in geometry_mesh_prim_list:
                        self.menu_item(mesh, triggered_fn=partial(ContextMenuExtension.create_mesh_prim, objects, mesh), delegate=item_delegate)
            except Exception:
                pass

            if is_create_type_enabled("Shape"):
                def on_create_shape(objects, prim_type):
                    shapes = omni.kit.menu.create.get_geometry_standard_prim_list()
                    shape_attrs = shapes.get(prim_type, {})
                    ContextMenuExtension.create_prim(objects, prim_type, shape_attrs)

                item = ContextMenuExtension.uiMenu(f'Shape', delegate=item_delegate, glyph="menu_prim.svg", submenu=True)
                self._context_menu_items.append(item)
                with item:
                    for geom in omni.kit.menu.create.get_geometry_standard_prim_list().keys():
                        self.menu_item(
                            geom, triggered_fn=partial(on_create_shape, objects, geom), delegate=item_delegate
                        )

            if is_create_type_enabled("Light"):
                item = ContextMenuExtension.uiMenu(f'Light', delegate=item_delegate, glyph="menu_light.svg", submenu=True)
                self._context_menu_items.append(item)
                with item:
                    for light in omni.kit.menu.create.get_light_prim_list():
                        self.menu_item(
                            light[0], triggered_fn=partial(ContextMenuExtension.create_prim, objects, light[1], light[2]), delegate=item_delegate
                        )

            if is_create_type_enabled("Audio"):
                item = ContextMenuExtension.uiMenu(f'Audio', delegate=item_delegate, glyph="menu_audio.svg", submenu=True)
                self._context_menu_items.append(item)
                with item:
                    for sound in omni.kit.menu.create.get_audio_prim_list():
                        self.menu_item(
                            sound[0], triggered_fn=partial(ContextMenuExtension.create_prim, objects, sound[1], sound[2]), delegate=item_delegate
                        )

            if is_create_type_enabled("Camera"):
                self.menu_item(
                    f'Camera',
                    triggered_fn=partial(ContextMenuExtension.create_prim, objects, "Camera", {}),
                    glyph="menu_camera.svg",
                    delegate=item_delegate
                ),

            if is_create_type_enabled("Scope"):
                self.menu_item(
                    f'Scope',
                    triggered_fn=partial(ContextMenuExtension.create_prim, objects, "Scope", {}),
                    glyph="menu_scope.svg",
                    delegate=item_delegate
                ),

            if is_create_type_enabled("Xform"):
                self.menu_item(
                        f'Xform',
                        triggered_fn=partial(ContextMenuExtension.create_prim, objects, "Xform", {}),
                        glyph="menu_xform.svg",
                        delegate=item_delegate
                    ),

            if custom_menu:
                for menu_entry in custom_menu:
                    if isinstance(menu_entry, list):
                        for item in menu_entry:
                            if self._build_menu(item, objects, delegate=item_delegate):
                                self.menu_item_count += 1
                    elif self._build_menu(menu_entry, objects, delegate=item_delegate):
                        self.menu_item_count += 1

    def build_add_menu(self, objects: dict, prim_list: list, custom_menu: list = None, delegate = None):
        if carb.settings.get_settings().get(SETTING_HIDE_CREATE_MENU):
            return

        menu = omni.kit.context_menu.get_menu_dict("ADD", "")
        menu = menu + custom_menu
        if menu:
            root_menu = ContextMenuExtension.uiMenu(
                f'Add', delegate=delegate if delegate else ContextMenuExtension.default_delegate, glyph="menu_add.svg", submenu=True)
            self._context_menu_items.append(root_menu)
            with root_menu:
                menu_item_count = self.menu_item_count
                for menu_entry in menu:
                    if isinstance(menu_entry, list):
                        for item in menu_entry:
                            if self._build_menu(item, objects, None):
                                self.menu_item_count += 1
                    elif self._build_menu(menu_entry, objects, None):
                        self.menu_item_count += 1
            if self.menu_item_count == menu_item_count:
                root_menu.visible = False

    def select_prims_using_material(self, objects: dict):
        """
        select stage prims using material

        Args:
            objects: context_menu data

        Returns:
            None
        """
        if not any(item in objects for item in ["prim", "prim_list"]):
            return
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]

        omni.usd.get_context().get_selection().clear_selected_prim_paths()
        paths = []
        for mat_prim in prim_list:
            for prim in objects["stage"].Traverse():
                if omni.usd.is_prim_material_supported(prim):
                    mat, rel = UsdShade.MaterialBindingAPI(prim).ComputeBoundMaterial()
                    if mat and mat.GetPrim().GetPath() == mat_prim.GetPath():
                        paths.append(prim.GetPath().pathString)
        omni.usd.get_context().get_selection().set_selected_prim_paths(paths, True)

    def find_in_browser(self, objects: dict) -> None:
        """
        select prim in content_browser

        Args:
            objects: context_menu data

        Returns:
            None
        """
        prim = objects["prim_list"][0]
        url_path = omni.usd.get_url_from_prim(prim)

        try:
            from omni.kit.window.content_browser import get_content_window

            content_browser = get_content_window()
            if content_browser:
                content_browser.navigate_to(url_path)
        except Exception as exc:
            carb.log_warn(f"find_in_browser error {exc}")

    def duplicate_prim(self, objects: dict):
        """
        duplicate prims

        Args:
            objects: context_menu data

        Returns:
            None
        """
        paths = []
        usd_context = omni.usd.get_context()
        usd_context.get_selection().clear_selected_prim_paths()
        edit_mode = layers.get_layers(usd_context).get_edit_mode()
        is_auto_authoring = edit_mode == layers.LayerEditMode.AUTO_AUTHORING
        omni.kit.undo.begin_group()
        for prim in objects["prim_list"]:
            old_prim_path = prim.GetPath().pathString
            new_prim_path = omni.usd.get_stage_next_free_path(objects["stage"], old_prim_path, False)
            omni.kit.commands.execute(
                "CopyPrim", path_from=old_prim_path, path_to=new_prim_path, exclusive_select=False,
                copy_to_introducing_layer=is_auto_authoring
            )
            paths.append(new_prim_path)
        omni.kit.undo.end_group()
        omni.usd.get_context().get_selection().set_selected_prim_paths(paths, True)

    def delete_prim(self, objects: dict, destructive=False):
        """
        delete prims

        Args:
            objects: context_menu data
            destructive: If it's true, it will remove all corresponding prims in all layers.
                         Otherwise, it will deactivate the prim in current edit target if its def is not in the current edit target.
                         By default, it will be non-destructive.
        Returns:
            None
        """
        prims = objects.get("prim_list", [])
        if prims:
            prim_paths = [prim.GetPath() for prim in prims]
            omni.kit.commands.execute("DeletePrims", paths=prim_paths, destructive=destructive)

    def copy_prim_url(self, objects: dict):
        """
        Copies url of Prim in USDA references format.
        @planet.usda@</Planet>
        """
        paths = ""
        for prim in objects["prim_list"]:
            if paths:
                paths += " "
            paths += omni.usd.get_url_from_prim(prim)

        omni.kit.clipboard.copy(paths)

    def copy_prim_path(self, objects: dict) -> None:
        """
        copy prims path to clipboard

        Args:
            objects: context_menu data

        Returns:
            None
        """
        paths = ""
        for prim in objects["prim_list"]:
            if paths:
                paths += " "
            paths += f"{prim.GetPath()}"

        omni.kit.clipboard.copy(paths)

    def group_selected_prims(self, objects: dict):
        """
        group prims

        Args:
            prims: list of prims

        Returns:
            None
        """
        # because widget.layer and widget.stage can get confused if prims are just renamed. clear selection and wait a frame, then do the rename to allow for this
        omni.usd.get_context().get_selection().clear_selected_prim_paths()
        asyncio.ensure_future(self._group_list(objects))

    def ungroup_selected_prims(self, objects: dict):
        """
        ungroup prims

        Args:
            prims: list of prims

        Returns:
            None
        """
        # because widget.layer and widget.stage can get confused if prims are just renamed. clear selection and wait a frame, then do the rename to allow for this
        omni.usd.get_context().get_selection().clear_selected_prim_paths()
        asyncio.ensure_future(self._ungroup_list(objects))

    async def _group_list(self, objects: dict):
        await omni.kit.app.get_app().next_update_async()
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]
        paths = []
        for prim in prim_list:
            paths.append(prim.GetPath().pathString)
        omni.usd.get_context().get_selection().set_selected_prim_paths(paths, True)
        omni.usd.get_context().get_selection().set_selected_prim_paths(paths, True)
        ContextMenuExtension.create_prim(objects, prim_type="Xform", attributes={}, create_group_xform=True)

    async def _ungroup_list(self, objects: dict):
        await omni.kit.app.get_app().next_update_async()
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]
        paths = []
        for prim in prim_list:
            paths.append(prim.GetPath().pathString)
        omni.usd.get_context().get_selection().set_selected_prim_paths(paths, True)
        omni.usd.get_context().get_selection().set_selected_prim_paths(paths, True)

        usd_context = omni.usd.get_context()
        stage = usd_context.get_stage()
        with omni.kit.undo.group():
            with layers.active_authoring_layer_context(usd_context):
                omni.kit.commands.execute("UngroupPrims", prim_paths=paths)

    def refresh_payload_or_reference(self, objects: dict):
        async def reload(layer_handles):
            context = omni.usd.get_context()
            for layer in layer_handles:
                (all_layers, _, _) = UsdUtils.ComputeAllDependencies(layer.identifier)
                if all_layers:
                    layers_state = layers.get_layers_state(context)
                    all_dirty_layers = []
                    for dep_layer in all_layers:
                        if layers_state.is_layer_outdated(dep_layer.identifier):
                            all_dirty_layers.append(dep_layer)

                    if all_dirty_layers:
                        Sdf.Layer.ReloadLayers(all_dirty_layers)

        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]
        layer_handles = set()
        for prim in prim_list:
            for (ref, intro_layer) in omni.usd.get_composed_references_from_prim(prim):
                layer = Sdf.Find(intro_layer.ComputeAbsolutePath(ref.assetPath)) if ref.assetPath else None
                if layer:
                    layer_handles.add(layer)
            for (ref, intro_layer) in omni.usd.get_composed_payloads_from_prim(prim):
                layer = Sdf.Find(intro_layer.ComputeAbsolutePath(ref.assetPath)) if ref.assetPath else None
                if layer:
                    layer_handles.add(layer)

        asyncio.ensure_future(reload(layer_handles))

    def convert_payload_to_reference(self, objects: dict):
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]

        async def convert(prim_list):
            await omni.kit.app.get_app().next_update_async()
            with omni.kit.undo.group():
                with Sdf.ChangeBlock():
                    for prim in prim_list:
                        stage = prim.GetStage()
                        ref_and_layers = omni.usd.get_composed_payloads_from_prim(prim)
                        for payload, _ in ref_and_layers:
                            reference = Sdf.Reference(assetPath=payload.assetPath.replace("\\", "/"), primPath=payload.primPath, layerOffset=payload.layerOffset)
                            omni.kit.commands.execute("RemovePayload", stage=stage, prim_path=prim.GetPath(), payload=payload)
                            omni.kit.commands.execute("AddReference", stage=stage, prim_path=prim.GetPath(), reference=reference)

            try:
                property_window = omni.kit.window.property.get_window()
                if property_window:
                    property_window._window.frame.rebuild()
            except Exception:
                pass

        asyncio.ensure_future(convert(prim_list))

    def convert_reference_to_payload(self, objects: dict):
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]

        async def convert(prim_list):
            await omni.kit.app.get_app().next_update_async()
            with omni.kit.undo.group():
                with Sdf.ChangeBlock():
                    for prim in prim_list:
                        stage = prim.GetStage()
                        ref_and_layers = omni.usd.get_composed_references_from_prim(prim)
                        for reference, _ in ref_and_layers:
                            payload = Sdf.Payload(assetPath=reference.assetPath.replace("\\", "/"), primPath=reference.primPath, layerOffset=reference.layerOffset)
                            omni.kit.commands.execute("RemoveReference", stage=stage, prim_path=prim.GetPath(), reference=reference)
                            omni.kit.commands.execute("AddPayload", stage=stage, prim_path=prim.GetPath(), payload=payload)

            try:
                property_window = omni.kit.window.property.get_window()
                if property_window:
                    property_window._window.frame.rebuild()
            except Exception:
                pass

        asyncio.ensure_future(convert(prim_list))

    def refresh_reference_payload_name(self, objects: dict):
        """
        checks if prims have references/payload and returns name
        """
        if not any(item in objects for item in ["prim", "prim_list"]):
            return None
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]
        if not prim_list:
            return None

        has_payload = self._prims_have_payloads(prim_list)
        has_reference = self._prims_have_references(prim_list)

        if has_payload and has_reference:
            return "Refresh Payload & Reference"
        elif has_payload:
            return "Refresh Payload"
        return "Refresh Reference"

    def get_prim_group(self, prim):
        """
        if the prim is or has a parent prim that is a group Kind, returns that prim otherwise None
        """
        model_api = Usd.ModelAPI(prim)
        if model_api and Kind.Registry.IsA(model_api.GetKind(), "group"):
            return prim
        else:
            if prim.GetParent().IsValid():
                return self.get_prim_group(prim.GetParent())
            else:
                return None

    # ---------------------------------------------- menu show test functions ----------------------------------------------

    def is_one_prim_selected(self, objects: dict):
        """
        checks if one prim is selected
        """
        if not "prim_list" in objects:
            return False
        return len(objects["prim_list"]) == 1

    def is_material(self, objects: dict):
        """
        checks if prims are UsdShade.Material
        """
        if not any(item in objects for item in ["prim", "prim_list"]):
            return
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]

        for prim in prim_list:
            if not prim.IsA(UsdShade.Material):
                return False
        return True

    def has_payload_or_reference(self, objects: dict):
        """
        checks if prims have payloads or references
        """
        if not any(item in objects for item in ["prim", "prim_list"]):
            return False
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]
        if not prim_list:
            return False

        return self._prims_have_references(prim_list) or self._prims_have_payloads(prim_list)

    def can_convert_references_or_payloads(self, objects):
        """
        Checkes if references can be converted into payloads or vice versa.
        """

        if not any(item in objects for item in ["prim", "prim_list"]):
            return False
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]
        if not prim_list:
            return False

        for prim in prim_list:
            ref_and_layers = omni.usd.get_composed_references_from_prim(prim)
            if ref_and_layers:
                return True

            payload_and_layers = omni.usd.get_composed_payloads_from_prim(prim)
            if payload_and_layers:
                return True

        return False

    def _prims_have_payloads(self, prim_list):
        for prim in prim_list:
            for prim_spec in prim.GetPrimStack():
                if prim_spec.HasInfo(Sdf.PrimSpec.PayloadKey) and prim_spec.GetInfo(Sdf.PrimSpec.PayloadKey).ApplyOperations([]):
                    return True
        return False

    def _prims_have_references(self, prim_list):
        for prim in prim_list:
            for prim_spec in prim.GetPrimStack():
                if prim_spec.HasInfo(Sdf.PrimSpec.ReferencesKey) and prim_spec.GetInfo(Sdf.PrimSpec.ReferencesKey).ApplyOperations([]):
                    return True
        return False

    def has_payload(self, objects: dict):
        """
        checks if prims have payloads
        """
        if not any(item in objects for item in ["prim", "prim_list"]):
            return False
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]
        if not prim_list:
            return False
        return self._prims_have_payloads(prim_list)

    def has_reference(self, objects: dict):
        """
        checks if prims have references
        """
        if not any(item in objects for item in ["prim", "prim_list"]):
            return False
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]
        if not prim_list:
            return False
        return self._prims_have_references(prim_list)

    def can_be_copied(self, objects: dict):
        """
        checks if prims can be copied
        """
        if not "prim_list" in objects:
            return False
        for prim in objects["prim_list"]:
            if not omni.usd.can_be_copied(prim):
                return False
        return True

    def can_use_find_in_browser(self, objects: dict):
        """
        checks if prims are authored
        """
        prim = objects["prim_list"][0]
        layer = omni.usd.get_sdf_layer(prim)
        authored_prim = omni.usd.get_authored_prim(prim)
        return authored_prim and authored_prim != prim

    def can_show_find_in_browser(self, objects: dict):
        """
        checks if one prim is selected and have URL (authored)
        """
        if not any(item in objects for item in ["prim", "prim_list"]):
            return False
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]
        if len(prim_list) != 1:
            return False

        prim = prim_list[0]
        url_path = omni.usd.get_url_from_prim(prim)
        if url_path is not None and url_path[0:5] == "anon:":
            url_path = None
        return url_path != None

    def can_delete(self, objects: dict):
        """
        checks if prims can be deleted
        """
        if not any(item in objects for item in ["prim", "prim_list"]):
            return False
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]

        for prim in prim_list:
            if not prim.IsValid():
                return False
            no_delete = prim.GetMetadata("no_delete")
            if no_delete is not None and no_delete is True:
                return False
        return True

    async def can_assign_material_async(self, objects: dict, menu_item: ui.Widget):
        """
        async show function. The `menu_item` is created but not visible, if this item is shown then `menu_item.visible = True`
        This scans all the prims in the stage looknig for a material, if one is found then it can "assign material"
        and `menu_item.visible = True`
        """
        if carb.settings.get_settings().get_as_bool("/exts/omni.kit.context_menu/show_assign_material") == False:
            return

        if not self.is_prim_selected(objects) or not self.is_material_bindable(objects):
            return

        menu_item.visible = True
        return

    def is_prim_selected(self, objects: dict):
        """
        checks if any prims are selected
        """
        if not any(item in objects for item in ["prim", "prim_list"]):
            return False
        return True

    def is_prim_in_group(self, objects: dict):
        """
        checks if any prims are in group(s)
        """
        if not "stage" in objects or not "prim_list" in objects or not objects["stage"]:
            return False

        stage = objects["stage"]
        if not stage:
            return False

        prim_list = objects["prim_list"]
        for path in prim_list:
            if isinstance(path, Usd.Prim):
                prim = path
            else:
                prim = stage.GetPrimAtPath(path)
            if prim:
                if not self.get_prim_group(prim):
                    return False
        return True

    def is_material_bindable(self, objects: dict):
        """
        checks if prims support matrial binding
        """
        if not any(item in objects for item in ["prim", "prim_list"]):
            return False
        prim_list = [objects["prim"]] if "prim" in objects else objects["prim_list"]

        for prim in prim_list:
            if not omni.usd.is_prim_material_supported(prim):
                return False
        return True

    def prim_is_type(self, objects: dict, type: Tf.Type) -> bool:
        """
        checks if prims are given class/schema
        """
        if not "stage" in objects or not "prim_list" in objects or not objects["stage"]:
            return False

        stage = objects["stage"]
        if not stage:
            return False

        prim_list = objects["prim_list"]
        for path in prim_list:
            if isinstance(path, Usd.Prim):
                prim = path
            else:
                prim = stage.GetPrimAtPath(path)
            if prim:
                if not prim.IsA(type):
                    return False

        return len(prim_list) > 0

    # ---------------------------------------------- core functions ----------------------------------------------

    def __init__(self):
        super().__init__()
        self._clipboard = {}

    def on_startup(self, ext_id):
        global _extension_instance
        _extension_instance = self

        self._context_menu = None
        self._context_menu_items = []
        self._content_window = None
        self._listeners = []

        manager = omni.kit.app.get_app().get_extension_manager()
        extension_path = manager.get_extension_path(ext_id)
        global TEST_DATA_PATH
        TEST_DATA_PATH = Path(extension_path).joinpath("data").joinpath("tests")

    def on_shutdown(self):
        global _extension_instance
        _extension_instance = None
        self._listeners = None

        if self._context_menu:
            self._context_menu.destroy()
        self._context_menu = None
        self._context_menu_items = []

    @property
    def name(self) -> str:
        if self._context_menu:
            return self._context_menu.text

        return None

    def close_menu(self):
        if self._context_menu:
            self._context_menu.destroy()
        self._context_menu = None
        self._context_menu_items = []
        self._content_window = None
        self._menu_title = None
        self._clipboard = []

    def separator(self, name: str="") -> bool:
        if self.menu_item_count > 1:
            if isinstance(self.menu_item_prev, ui.Separator) and self.menu_item_prev.text == name:
                return False
            self.menu_item_prev = ui.Separator(name)
            return True
        return False


    def menu_item(self, name: str, triggered_fn: Callable = None, enabled: bool = True, checkable: bool = False, checked: bool = False, is_async_func=False, delegate=None, additional_kwargs=None, glyph=""):
        menuitem_kwargs = {"triggered_fn": triggered_fn, "enabled": enabled, "checkable": checkable, "checked": checked}
        if additional_kwargs:
            menuitem_kwargs.update(additional_kwargs)
        if delegate and hasattr(delegate, "get_parameters"):
            delegate.get_parameters(name, menuitem_kwargs)

        # async functions are created but are not visible, so exclude from prev logic
        if is_async_func:
            item = ContextMenuExtension.uiMenuItem(name, **menuitem_kwargs, delegate=delegate if delegate else ContextMenuExtension.default_delegate, glyph=glyph)
            self._context_menu_items.append(item)
            return item

        item = ContextMenuExtension.uiMenuItem(name, **menuitem_kwargs, delegate=delegate if delegate else ContextMenuExtension.default_delegate, glyph=glyph)
        self._context_menu_items.append(item)
        self.menu_item_prev = item
        self.menu_item_count += 1
        return self.menu_item_prev

    def _get_fn_result(self, menu_entry: dict, name: str, objects: list):
        try:
            if not name in menu_entry:
                return True
            if menu_entry[name] is None:
                return True
            if isinstance(menu_entry[name], list):
                for show_fn in menu_entry[name]:
                    if not show_fn(objects):
                        return False
            else:
                if not menu_entry[name](objects):
                    return False
            return True
        except Exception as exc:
            carb.log_warn(f"_get_fn_result error for {name}: {exc}")
            return False

    def _has_click_fn(self, menu_entry):
        if "onclick_fn" in menu_entry and menu_entry["onclick_fn"] is not None:
            return True
        if "onclick_action" in menu_entry and menu_entry["onclick_action"] is not None:
            return True
        return False

    def _execute_action(self, action: Tuple, objects):
        if not action:
            return

        try:
            import omni.kit.actions.core
            import omni.kit.app

            async def execute_action(action):
                await omni.kit.app.get_app().next_update_async()

                # only forward the accepted parameters for this action
                action_obj = omni.kit.actions.core.acquire_action_registry().get_action(action[0], action[1])
                params = {key: objects[key] for key in action_obj.parameters if key in objects}
                omni.kit.actions.core.execute_action(*action, **params)

            # omni.ui can sometimes crash is menu callback does ui calls.
            # To avoid this, use async function with frame delay
            asyncio.ensure_future(execute_action(action))
        except ModuleNotFoundError:
            carb.log_warn(f"execute_action: error omni.kit.actions.core not loaded")
        except Exception as exc:
            carb.log_warn(f"execute_action: error {exc}")

    def _set_hotkey_for_action(self, menu_entry: dict, menu_item: ui.MenuItem):
        try:
            from omni.kit.hotkeys.core import get_hotkey_registry

            onclick_action = menu_entry["onclick_action"]
            hotkey_registry = get_hotkey_registry()
            if not hotkey_registry:
                raise ImportError
            for hk in hotkey_registry.get_all_hotkeys_for_extension(onclick_action[0]):
                menu_item.hotkey_text = hk.key_text if hk.action_id == onclick_action[1] else None
        except ImportError:
            pass

    def _build_menu(self, menu_entry: dict, objects: dict, delegate) -> bool:
        if "name" in menu_entry and isinstance(menu_entry["name"], dict):
            menu_entry_name = menu_entry["name"]
            if "name_fn" in menu_entry:
                menu_entry_name = menu_entry["name_fn"](objects)

            for item in menu_entry_name:
                menu_item_count = self.menu_item_count

                glyph = None
                if "glyph" in menu_entry and menu_entry["glyph"]:
                    glyph = menu_entry["glyph"]

                menu = ContextMenuExtension.uiMenu(f"{item}", tearable=menu_entry.get("tearable", True if delegate else False), delegate=delegate if delegate else ContextMenuExtension.default_delegate, glyph=glyph, submenu=True)
                self._context_menu_items.append(menu)
                with menu:
                    for menu_entry in menu_entry_name[item]:
                        if isinstance(menu_entry, list):
                            for item in menu_entry:
                                self._build_menu(item, objects, delegate)
                        else:
                            self._build_menu(menu_entry, objects, delegate)

                # no submenu items created, remove
                if menu_item_count == self.menu_item_count:
                    menu.visible = False
                    return False
            return True

        if not self._get_fn_result(menu_entry, "show_fn", objects):
            return False

        if "populate_fn" in menu_entry:
            self.menu_item_prev = None
            menu_entry["populate_fn"](objects)
            return True

        menu_entry_name = menu_entry["name"] if "name" in menu_entry else ""
        if "name_fn" in menu_entry and menu_entry["name_fn"] is not None:
            menu_entry_name = menu_entry["name_fn"](objects)

        if menu_entry_name == "" or menu_entry_name.endswith("/"):
            header = menu_entry["header"] if "header" in menu_entry else ""
            if "show_fn_async" in menu_entry:
                menu_item = ui.Separator(header, visible=False)
                asyncio.ensure_future(menu_entry["show_fn_async"](objects, menu_item))
            return self.separator(header)

        checked = False
        checkable = False
        if "checked_fn" in menu_entry and self._has_click_fn(menu_entry):
            checkable = True
            checked = self._get_fn_result(menu_entry, "checked_fn", objects)

        menu_item = None
        is_async_func = bool("show_fn_async" in menu_entry)
        additional_kwargs = menu_entry.get("additional_kwargs", None)

        glyph = None
        if "glyph" in menu_entry and menu_entry["glyph"]:
            glyph = menu_entry["glyph"]

        if self._has_click_fn(menu_entry):
            enabled = self._get_fn_result(menu_entry, "enabled_fn", objects)
            if "onclick_action" in menu_entry and menu_entry["onclick_action"]:
                triggered_fn=partial(self._execute_action, menu_entry["onclick_action"], objects)
            else:
                triggered_fn=partial(menu_entry["onclick_fn"], objects)

            menu_item = self.menu_item(
                menu_entry_name,
                triggered_fn=triggered_fn,
                enabled=enabled,
                checkable=checkable,
                checked=checked,
                is_async_func=is_async_func,
                delegate=delegate,
                glyph=glyph,
                additional_kwargs=additional_kwargs,
            )
        else:
            menu_item = self.menu_item(menu_entry_name, enabled=False, checkable=checkable, checked=checked, is_async_func=is_async_func, delegate=delegate, glyph=glyph, additional_kwargs=additional_kwargs)

        if "onclick_action" in menu_entry:
            self._set_hotkey_for_action(menu_entry, menu_item)

        if menu_item and is_async_func:
            menu_item.visible = False
            asyncio.ensure_future(menu_entry["show_fn_async"](objects, menu_item))

        return True

    def _is_menu_visible(self, menu_entry: dict, objects: dict):
        if not self._get_fn_result(menu_entry, "show_fn", objects):
            return False
        if "populate_fn" in menu_entry:
            return True
        return True

    def show_context_menu(self, menu_name: str, objects: dict, menu_list: List[dict], min_menu_entries: int = 1, delegate = None) -> None:
        """
        build context menu from menu_list

        Args:
            menu_name: menu name
            objects: context_menu data
            menu_list: list of dictonaries containing context menu values
            min_menu_entries: minimal number of menu needed for menu to be visible

        Returns:
            None
        """
        try:
            if self._context_menu:
                self._context_menu.destroy()
            self._context_menu_items = []

            style = delegate.get_style() if delegate and hasattr(delegate, "get_style") else ContextMenuExtension.default_delegate.get_style()
            menuitem_kwargs = { "tearable": True if delegate else False }
            if delegate and hasattr(delegate, "get_parameters"):
                delegate.get_parameters("tearable", menuitem_kwargs)

            self._context_menu = ContextMenuExtension.uiMenu(f"Context menu {menu_name}",
                                         delegate=delegate if delegate else ContextMenuExtension.default_delegate,
                                         style=style,
                                         **menuitem_kwargs)

            # setup globals
            objects["clipboard"] = self._clipboard
            if "stage" not in objects:
                # TODO: We can't use `context.get_stage()` because this context
                # menu can be called for Stage Viewer and it has own stage.
                objects["stage"] = omni.usd.get_context().get_stage()

            self.menu_item_count = 0
            top_level_menu_count = 0
            self.menu_item_prev = None

            with self._context_menu:
                for menu_entry in menu_list:
                    if isinstance(menu_entry, list):
                        for item in menu_entry:
                            if self._build_menu(item, objects, delegate):
                                self.menu_item_count += 1
                                top_level_menu_count += 1
                    elif self._build_menu(menu_entry, objects, delegate):
                        self.menu_item_count += 1
                        top_level_menu_count += 1

            # Show it
            if top_level_menu_count >= min_menu_entries:
                # if the last menu item was a Separator, hide it
                if isinstance(self.menu_item_prev, ui.Separator):
                    self.menu_item_prev.visible = False
                if all(k in objects for k in ("menu_xpos", "menu_ypos")):
                    self._context_menu.show_at(objects["menu_xpos"], objects["menu_ypos"])
                else:
                    self._context_menu.show()
        except Exception as exc:
            import traceback
            carb.log_error(f"show_context_menu: error {traceback.format_exc()}")

    def bind_material_to_prims_dialog(self, stage: Usd.Stage, prims: list):
        carb.log_warn("omni.kit.context_menu.get_instance().bind_material_to_prims_dialog() is deprecated. Use omni.kit.material.library.bind_material_to_prims_dialog() instead")

        import omni.kit.material.library
        omni.kit.material.library.bind_material_to_prims_dialog(stage, prims)

################## public static functions ##################


def get_instance():
    """
    get instance of context menu class
    """
    return _extension_instance


def close_menu():
    if _extension_instance:
        _extension_instance.close_menu()


def add_menu(menu_dict, index: str = "MENU", extension_id: str = ""):
    """add custom menu to any context_menu

    Examples
        menu = {"name": "Open in Material Editor", "onclick_fn": open_material}
        # add to all context menus
        self._my_custom_menu = omni.kit.context_menu.add_menu(menu, "MENU", "")
        # add to omni.kit.widget.stage context menu
        self._my_custom_menu = omni.kit.context_menu.add_menu(menu, "MENU", "omni.kit.widget.stage")

    Args:
        menu_dict: a dictonary containing menu settings. See ContextMenuExtension docs for infomation on values
        index: name of the menu EG. "MENU"
        extension_id: name of the target EG. "" or "omni.kit.widget.stage"

    NOTE: index and extension_id are extension arbitary values. add_menu(menu, "MENU", "omni.kit.widget.stage") works
          as omni.kit.widget.stage retreives custom context_menus with get_menu_dict("MENU", "omni.kit.widget.stage")
          Adding a menu to an extension that doesn't support context_menus would have no effect.

    Returns:
        MenuSubscription. Keep a copy of this as the custom menu will be removed when `release()` is explictly called or
        this is garbage collected
    """

    class MenuSubscription:
        def __init__(self, menu_id):
            self.__id = menu_id

        def release(self):
            if self.__id is not None:
                _CustomMenuDict().remove_menu(self.__id, index, extension_id)
                self.__id = None

        def __del__(self):
            self.release()

    menu_id = _CustomMenuDict().add_menu(menu_dict, index, extension_id)
    return MenuSubscription(menu_id)


def get_menu_dict(index: str = "MENU", extension_id: str = "") -> List[dict]:
    """get custom menus

    see add_menu for dictonary info

    Args:
        index: name of the menu
        extension_id: name of the target

    Returns:
        a list of dictonaries containing custom menu settings. See ContextMenuExtension docs for infomation on values
    """
    return _CustomMenuDict().get_menu_dict(index, extension_id)


def reorder_menu_dict(menu_dict: List[dict]):
    """reorder menus using "appear_after" value in menu

    Args:
        menu_dict: list of dictonaries

    Returns:
        None
    """

    def find_entry(menu_dict, name):
        for index, menu_entry in enumerate(menu_dict):
            if "name" in menu_entry:
                if menu_entry["name"] == name:
                    return index
        return -1

    for index, menu_entry in enumerate(menu_dict.copy()):
        if "appear_after" in menu_entry:
            new_index = find_entry(menu_dict, menu_entry["appear_after"])
            if new_index != -1:
                menu_dict.remove(menu_entry)
                menu_dict.insert(new_index + 1, menu_entry)


def get_menu_event_stream():
    """
    Gets menu event stream
    """
    return _CustomMenuDict().get_event_stream()


def post_notification(message: str, info: bool = False, duration: int = 3):
    try:
        import omni.kit.notification_manager as nm
        if info:
            type = nm.NotificationStatus.INFO
        else:
            type = nm.NotificationStatus.WARNING

        nm.post_notification(message, status=type, duration=duration)
    except ModuleNotFoundError:
        carb.log_warn(message)


def get_hovered_prim(objects):
    if "hovered_prim" in objects:
        return objects["hovered_prim"]

    if "prim_list" in objects and len(objects['prim_list'])==1:
        return objects['prim_list'][0]

    return None
