From 6b4be0c9c2fde717cb16fdd399d2b5b186f57a5e Mon Sep 17 00:00:00 2001
From: Joshua Fehler <jsfehler@gmail.com>
Date: Tue, 11 Jun 2024 11:20:50 -0400
Subject: [PATCH] Add keyboard class and Element.press()

---
 docs/index.rst                         |   1 +
 docs/keyboard.rst                      |  99 ++++++++++++++++++++
 splinter/driver/__init__.py            |  14 +++
 splinter/driver/webdriver/__init__.py  |   6 ++
 splinter/driver/webdriver/keyboard.py  | 122 +++++++++++++++++++++++++
 tests/static/index.html                |  16 ++++
 tests/tests_webdriver/test_keyboard.py |  66 +++++++++++++
 7 files changed, 324 insertions(+)
 create mode 100644 docs/keyboard.rst
 create mode 100644 splinter/driver/webdriver/keyboard.py
 create mode 100644 tests/tests_webdriver/test_keyboard.py

diff --git a/docs/index.rst b/docs/index.rst
index 30c857991..7146506a7 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -38,6 +38,7 @@
   cookies
   screenshot
   javascript
+  keyboard
   selenium-keys
   iframes-and-alerts
   http-status-code-and-exception
diff --git a/docs/keyboard.rst b/docs/keyboard.rst
new file mode 100644
index 000000000..620daacf9
--- /dev/null
+++ b/docs/keyboard.rst
@@ -0,0 +1,99 @@
+.. Copyright 2024 splinter authors. All rights reserved.
+   Use of this source code is governed by a BSD-style
+   license that can be found in the LICENSE file.
+
+.. meta::
+    :description: Keyboard
+    :keywords: splinter, python, tutorial, documentation, selenium integration, selenium keys, keyboard events
+
+++++++++
+Keyboard
+++++++++
+
+The browser provides an interface for using the keyboard.
+
+However, input is limited to the page. You cannot control the browser or your
+operating system using this.
+
+Down
+----
+
+Hold a key down.
+
+.. code-block:: python
+
+    from splinter import Browser
+
+
+    browser = Browser()
+    browser.visit("https://duckduckgo.com/")
+    browser.keyboard.down("CONTROL")
+
+
+Up
+--
+
+Release a key. If the key is not held down, this will do nothing.
+
+.. code-block:: python
+
+    from splinter import Browser
+
+
+    browser = Browser()
+    browser.visit("https://duckduckgo.com/")
+    browser.keyboard.down("CONTROL")
+    browser.keyboard.up("CONTROL")
+
+
+Press
+-----
+
+Hold and then release a key pattern.
+
+.. code-block:: python
+
+    from splinter import Browser
+
+
+    browser = Browser()
+    browser.visit("https://duckduckgo.com/")
+    browser.keyboard.press("CONTROL")
+
+Key patterns are keys separated by the '+' symbol.
+This allows multiple presses to be chained together:
+
+.. code-block:: python
+
+    from splinter import Browser
+
+
+    browser = Browser()
+    browser.visit("https://duckduckgo.com/")
+    browser.keyboard.press("CONTROL+a")
+
+.. warning::
+  Although a key pattern such as "SHIFT+awesome" will be accepted,
+  the press method is designed for single keys. There may be unintended
+  side effects to using it in place of Element.fill() or Element.type().
+
+Element.press()
+~~~~~~~~~~~~~~~
+
+Elements can be pressed directly.
+
+.. code-block:: python
+
+    from splinter import Browser
+
+
+    browser = Browser()
+    browser.visit("https://duckduckgo.com/")
+    elem = browser.find_by_css("#searchbox_input")
+    elem.fill("splinter python")
+    elem.press("ENTER")
+
+    results = browser.find_by_xpath("//section[@data-testid='mainline']/ol/li")
+
+    # Open in a new tab behind the current one.
+    results.first.press("CONTROL+ENTER")
diff --git a/splinter/driver/__init__.py b/splinter/driver/__init__.py
index a57ea263f..44f8fed7c 100644
--- a/splinter/driver/__init__.py
+++ b/splinter/driver/__init__.py
@@ -871,6 +871,20 @@ def type(self, value: str, slowly: bool = False) -> str:  # NOQA: A003
         """
         raise NotImplementedError
 
+    def press(self, key_pattern: str, delay: int = 0) -> None:
+        """Focus the element, hold, and then release the specified key pattern.
+
+        Arguments:
+            key_pattern: Pattern of keys to hold and release.
+            delay: Time, in seconds, to wait between key down and key up.
+
+        Example:
+
+            >>> browser.find_by_css('.my_element').press('CONTROL+a')
+
+        """
+        raise NotImplementedError
+
     def select(self, value: str, slowly: bool = False) -> None:
         """
         Select an ``<option>`` element in the element using the ``value`` of the ``<option>``.
diff --git a/splinter/driver/webdriver/__init__.py b/splinter/driver/webdriver/__init__.py
index cd8049f9c..fac392b1c 100644
--- a/splinter/driver/webdriver/__init__.py
+++ b/splinter/driver/webdriver/__init__.py
@@ -25,6 +25,7 @@
 from splinter.driver import ElementAPI
 from splinter.driver.find_links import FindLinks
 from splinter.driver.webdriver.cookie_manager import CookieManager
+from splinter.driver.webdriver.keyboard import Keyboard
 from splinter.driver.xpath_utils import _concat_xpath_from_str
 from splinter.element_list import ElementList
 from splinter.exceptions import ElementDoesNotExist
@@ -266,6 +267,7 @@ def __init__(self, driver=None, wait_time=2):
         self.wait_time = wait_time
 
         self.links = FindLinks(self)
+        self.keyboard = Keyboard(driver)
 
         self.driver = driver
         self._find_elements = self.driver.find_elements
@@ -792,6 +794,10 @@ def type(self, value, slowly=False):  # NOQA: A003
         self._element.send_keys(value)
         return value
 
+    def press(self, key_pattern: str, delay: int = 0) -> None:
+        keyboard = Keyboard(self.driver, self._element)
+        keyboard.press(key_pattern, delay)
+
     def click(self):
         """Click an element.
 
diff --git a/splinter/driver/webdriver/keyboard.py b/splinter/driver/webdriver/keyboard.py
new file mode 100644
index 000000000..c8ffe990a
--- /dev/null
+++ b/splinter/driver/webdriver/keyboard.py
@@ -0,0 +1,122 @@
+from typing import Union
+
+from selenium.webdriver.common.action_chains import ActionChains
+from selenium.webdriver.common.keys import Keys
+from selenium.webdriver.remote.webelement import WebElement
+
+
+class Keyboard:
+    """Representation of a keyboard.
+
+    Requires a WebDriver instance to use.
+
+    Arguments:
+        driver: The WebDriver instance to use.
+        element: Optionally, a WebElement to act on.
+    """
+
+    def __init__(self, driver, element: Union[WebElement, None] = None) -> None:
+        self.driver = driver
+
+        self.element = element
+
+    def _resolve_key_down_action(self, action_chain: ActionChains, key: str) -> ActionChains:
+        """Given the string <key>, select the correct action for key down.
+
+        For modifier keys, use ActionChains.key_down().
+        For other keys, use ActionChains.send_keys() or ActionChains.send_keys_to_element()
+        """
+        key_value = getattr(Keys, key, None)
+
+        if key_value:
+            chain = action_chain.key_down(key_value, self.element)
+        elif self.element:
+            chain = action_chain.send_keys_to_element(self.element, key)
+        else:
+            chain = action_chain.send_keys(key)
+
+        return chain
+
+    def _resolve_key_up_action(self, action_chain: ActionChains, key: str) -> ActionChains:
+        """Given the string <key>, select the correct action for key up.
+
+        For modifier keys, use ActionChains.key_up().
+        For other keys, use ActionChains.send_keys() or ActionChains.send_keys_to_element()
+        """
+        key_value = getattr(Keys, key, None)
+
+        chain = action_chain
+        if key_value:
+            chain = action_chain.key_up(key_value, self.element)
+
+        return chain
+
+    def down(self, key: str) -> "Keyboard":
+        """Hold down on a key.
+
+        Arguments:
+            key: The name of a key to hold.
+
+        Example:
+
+            >>> b = Browser()
+            >>> Keyboard(b.driver).down('SHIFT')
+        """
+        chain = ActionChains(self.driver)
+        chain = self._resolve_key_down_action(chain, key)
+        chain.perform()
+        return self
+
+    def up(self, key: str) -> "Keyboard":
+        """Release a held key.
+
+        If <key> is not held down, this method has no effect.
+
+        Arguments:
+            key: The name of a key to release.
+
+        Example:
+
+            >>> b = Browser()
+            >>> Keyboard(b.driver).down('SHIFT')
+            >>> Keyboard(b.driver).up('SHIFT')
+        """
+        chain = ActionChains(self.driver)
+        chain = self._resolve_key_up_action(chain, key)
+        chain.perform()
+        return self
+
+    def press(self, key_pattern: str, delay: int = 0) -> "Keyboard":
+        """Hold and release a key pattern.
+
+        Key patterns are strings of key names separated by '+'.
+        The following are examples of key patterns:
+        - 'CONTROL'
+        - 'CONTROL+a'
+        - 'CONTROL+a+BACKSPACE+b'
+
+        Arguments:
+            key_pattern: Pattern of keys to hold and release.
+            delay: Time, in seconds, to wait between the hold and release.
+
+        Example:
+
+            >>> b = Browser()
+            >>> Keyboard(b.driver).press('CONTROL+a')
+        """
+        keys_names = key_pattern.split("+")
+
+        chain = ActionChains(self.driver)
+
+        for item in keys_names:
+            chain = self._resolve_key_down_action(chain, item)
+
+        if delay:
+            chain = chain.pause(delay)
+
+        for item in keys_names:
+            chain = self._resolve_key_up_action(chain, item)
+
+        chain.perform()
+
+        return self
diff --git a/tests/static/index.html b/tests/static/index.html
index a8265e73e..9e547c1a1 100644
--- a/tests/static/index.html
+++ b/tests/static/index.html
@@ -32,6 +32,22 @@
             addShadowRoot();
         }, false);
 
+        document.onkeydown = function (e) {
+            if (e.key === "Control") {
+                $('body').append('<div id="keypress_detect">Added when "Control" key is pressed down.</div>');
+            }
+            if (e.key === "a") {
+                $('body').append('<div id="keypress_detect_a">Added when "a" key is pressed down.</div>');
+            }
+        };
+
+        document.onkeyup = function (e) {
+            e = e || window.event;
+            if (e.key === "Control") {
+                $('body').append('<div id="keyup_detect">Added when "Control" key is released.</div>');
+            }
+        };
+
         $(document).ready(function() {
             $(".draggable").draggable();
             $(".droppable").droppable({
diff --git a/tests/tests_webdriver/test_keyboard.py b/tests/tests_webdriver/test_keyboard.py
new file mode 100644
index 000000000..e29d1fb42
--- /dev/null
+++ b/tests/tests_webdriver/test_keyboard.py
@@ -0,0 +1,66 @@
+import platform
+
+from splinter.driver.webdriver import Keyboard
+
+
+def test_keyboard_down_modifier(browser, app_url):
+    browser.visit(app_url)
+
+    keyboard = Keyboard(browser.driver)
+
+    keyboard.down("CONTROL")
+
+    elem = browser.find_by_css("#keypress_detect")
+    assert elem.first
+
+
+def test_keyboard_up_modifier(browser, app_url):
+    browser.visit(app_url)
+
+    keyboard = Keyboard(browser.driver)
+
+    keyboard.down("CONTROL")
+    keyboard.up("CONTROL")
+
+    elem = browser.find_by_css("#keyup_detect")
+    assert elem.first
+
+
+def test_keyboard_press_modifier(browser, app_url):
+    browser.visit(app_url)
+
+    keyboard = Keyboard(browser.driver)
+
+    keyboard.press("CONTROL")
+
+    elem = browser.find_by_css("#keyup_detect")
+    assert elem.first
+
+
+def test_element_press_combo(browser, app_url):
+    browser.visit(app_url)
+
+    keyboard = Keyboard(browser.driver)
+
+    keyboard.press("CONTROL+a")
+
+    elem = browser.find_by_css("#keypress_detect_a")
+    assert elem.first
+
+
+def test_element_copy_paste(browser, app_url):
+    control_key = "META" if platform.system() == "Darwin" else "CONTROL"
+
+    browser.visit(app_url)
+
+    elem = browser.find_by_name("q")
+    elem.fill("Copy this value")
+    elem.press(f"{control_key}+a")
+    elem.press(f"{control_key}+c")
+    elem.clear()
+
+    assert elem.first.value == ""
+
+    elem.press(f"{control_key}+v")
+
+    assert elem.first.value == "Copy this Value"