Repository: robocorp/rpaframework

Issue:
Add "element path" type locator strategy to the `RPA.Windows` library
Robot Framework usage example
```robotframework
*** Settings ***
Library     ExtendedWindows.py


*** Tasks ***
Minimal task
    Control Window    name:Calculator
    FOR    ${idx}    IN RANGE    2    11
        ${element}=    Get Element With Path    2|3|2|8|2|2|5|${idx}
        IF    ${idx}==2    Log To Console    ${{dir($element.item)}}
        Click    ${element}
    END
    Log    Done.
```

Custom Python library implementation
```python
from RPA.Windows import Windows
from RPA.core.windows.locators import WindowsElement
from robot.api.deco import keyword
import logging


class ExtendedWindows(Windows):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @keyword
    def get_element_with_path(self, element_path: str, log_elements: bool = False) -> WindowsElement:
        root = self.window_element.item
        element_index_path = [item.strip() for item in element_path.split("|")]
        child_elements = []
        target = None
        for idx in element_index_path:
            child_idx = int(idx) - 1
            if log_elements:
                logging.warning(root)
            child_elements = root.GetChildren()
            if child_idx < len(child_elements):
                root = child_elements[child_idx]
                root.robocorp_click_offset = None
                target = WindowsElement(root, element_path)
        return target
```

An example how element path indexes are calculate

![image](https://user-images.githubusercontent.com/1019528/217810058-aa83d366-167c-4686-bacd-c7bda9be4d82.png)



Discussion:
Relates to: https://github.com/robocorp/rpaframework/issues/779

Resolving patch:
diff --git a/packages/core/src/RPA/core/windows/locators.py b/packages/core/src/RPA/core/windows/locators.py
--- a/packages/core/src/RPA/core/windows/locators.py
+++ b/packages/core/src/RPA/core/windows/locators.py
@@ -20,6 +20,7 @@
 
 
 Locator = Union["WindowsElement", str]
+SearchType = Dict[str, Union[str, int, List[int]]]
 
 
 @dataclass
@@ -149,8 +150,10 @@ class MatchObject:
         "process": "process",
         "handle": "handle",
         "executable": "executable",
+        "path": "path",
     }
     TREE_SEP = " > "
+    PATH_SEP = "|"  # path locator index separator
     QUOTE = '"'  # enclosing quote character
     _LOCATOR_REGEX = re.compile(rf"\S*{QUOTE}[^{QUOTE}]+{QUOTE}|\S+", re.IGNORECASE)
     _LOGGER = logging.getLogger(__name__)
@@ -222,6 +225,8 @@ def add_locator(self, control_strategy: str, value: str, level: int = 0) -> None
             value = value if value.endswith("Control") else f"{value}Control"
         elif control_strategy == "ClassName":
             self._classes.add(value.lower())  # pylint: disable=no-member
+        elif control_strategy == "path":
+            value = [int(idx) for idx in value.split(self.PATH_SEP)]
         self.locators.append(  # pylint: disable=no-member
             (control_strategy, value, level)
         )
@@ -238,19 +243,6 @@ def __init__(self, ctx, locators_path: Optional[str] = None):
         super().__init__(ctx)
         self._locators_path = locators_path
 
-    @staticmethod
-    def _get_control_from_params(
-        search_params: Dict[str, str], root_control: Optional["Control"] = None
-    ) -> "Control":
-        search_params = search_params.copy()
-        offset = search_params.pop("offset", None)
-        control_type = search_params.pop("ControlType", "Control")
-        ElementControl = getattr(root_control, control_type, Control)
-        control = ElementControl(**search_params)
-        new_control = Control.CreateControlFromControl(control)
-        new_control.robocorp_click_offset = offset
-        return new_control
-
     @staticmethod
     def _get_desktop_control() -> "Control":
         root_control = auto.GetRootControl()
@@ -263,9 +255,23 @@ def get_desktop_element(cls, locator: Optional[Locator] = None) -> WindowsElemen
         desktop_control = cls._get_desktop_control()
         return WindowsElement(desktop_control, locator)
 
+    @staticmethod
+    def _get_control_from_params(
+        search_params: SearchType, root_control: Optional["Control"] = None
+    ) -> "Control":
+        search_params = search_params.copy()  # to keep idempotent behaviour
+        offset = search_params.pop("offset", None)
+        control_type = search_params.pop("ControlType", "Control")
+        ElementControl = getattr(root_control, control_type, Control)
+        control = ElementControl(**search_params)
+        new_control = Control.CreateControlFromControl(control)
+        new_control.robocorp_click_offset = offset
+        return new_control
+
     def _get_control_from_listed_windows(
-        self, search_params: Dict[str, str], *, param_type: str, win_type: str
+        self, search_params: SearchType, *, param_type: str, win_type: str
     ) -> "Control":
+        search_params = search_params.copy()  # to keep idempotent behaviour
         win_value = search_params.pop(param_type)
         window_list = self.ctx.list_windows()
         matches = [win for win in window_list if win[win_type] == win_value]
@@ -281,6 +287,30 @@ def _get_control_from_listed_windows(
         search_params["Name"] = matches[0]["title"]
         return self._get_control_from_params(search_params)
 
+    def _get_control_from_path(
+        self, search_params: SearchType, root_control: Optional["Control"] = None
+    ) -> "Control":
+        # Follow a path in the tree of controls until reaching the final target.
+        search_params = search_params.copy()  # to keep idempotent behaviour
+        path = search_params["path"]
+        current = root_control
+        to_path = lambda index: MatchObject.PATH_SEP.join(path[:index])
+
+        for idx, pos in enumerate(path):
+            children = current.GetChildren()
+            if pos > len(children):
+                raise ElementNotFound(
+                    f"Unable to retrieve child on position {pos!r} under a parent with"
+                    f" partial path {to_path(idx)!r}"
+                )
+
+            current = children[pos - 1]
+            self.logger.debug("On child position %d found control: %s", pos, current)
+
+        offset = search_params.get("offset")
+        current.robocorp_click_offset = offset
+        return current
+
     def _get_control_with_locator_part(
         self, locator: str, search_depth: int, root_control: "Control"
     ) -> "Control":
@@ -307,6 +337,9 @@ def _get_control_with_locator_part(
                 search_params, param_type="handle", win_type="handle"
             )
 
+        if "path" in search_params:
+            return self._get_control_from_path(search_params, root_control=root_control)
+
         return self._get_control_from_params(search_params, root_control=root_control)
 
     def _load_by_alias(self, criteria: str) -> str:


Test patch:
diff --git a/packages/core/tests/python/test_windows.py b/packages/core/tests/python/test_windows.py
--- a/packages/core/tests/python/test_windows.py
+++ b/packages/core/tests/python/test_windows.py
@@ -84,6 +84,13 @@
                 ("foundIndex", 4, 1),
             ],
         ),
+        (
+            "Calculator > path:2|3|2|8|2",
+            [
+                ("Name", "Calculator", 0),
+                ("path", [2, 3, 2, 8, 2], 1)
+            ],
+        ),
     ],
 )
 def test_match_object(locator, locators):