initial commit
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
# Licensed to the Software Freedom Conservancy (SFC) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The SFC licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from selenium.webdriver.common.bidi.common import command_builder
|
||||
from selenium.webdriver.common.bidi.session import Session
|
||||
|
||||
|
||||
class PointerType:
|
||||
"""Represents the possible pointer types."""
|
||||
|
||||
MOUSE = "mouse"
|
||||
PEN = "pen"
|
||||
TOUCH = "touch"
|
||||
|
||||
VALID_TYPES = {MOUSE, PEN, TOUCH}
|
||||
|
||||
|
||||
class Origin:
|
||||
"""Represents the possible origin types."""
|
||||
|
||||
VIEWPORT = "viewport"
|
||||
POINTER = "pointer"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ElementOrigin:
|
||||
"""Represents an element origin for input actions."""
|
||||
|
||||
type: str
|
||||
element: dict
|
||||
|
||||
def __init__(self, element_reference: dict):
|
||||
self.type = "element"
|
||||
self.element = element_reference
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the ElementOrigin to a dictionary."""
|
||||
return {"type": self.type, "element": self.element}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerParameters:
|
||||
"""Represents pointer parameters for pointer actions."""
|
||||
|
||||
pointer_type: str = PointerType.MOUSE
|
||||
|
||||
def __post_init__(self):
|
||||
if self.pointer_type not in PointerType.VALID_TYPES:
|
||||
raise ValueError(f"Invalid pointer type: {self.pointer_type}. Must be one of {PointerType.VALID_TYPES}")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerParameters to a dictionary."""
|
||||
return {"pointerType": self.pointer_type}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerCommonProperties:
|
||||
"""Common properties for pointer actions."""
|
||||
|
||||
width: int = 1
|
||||
height: int = 1
|
||||
pressure: float = 0.0
|
||||
tangential_pressure: float = 0.0
|
||||
twist: int = 0
|
||||
altitude_angle: float = 0.0
|
||||
azimuth_angle: float = 0.0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.width < 1:
|
||||
raise ValueError("width must be at least 1")
|
||||
if self.height < 1:
|
||||
raise ValueError("height must be at least 1")
|
||||
if not (0.0 <= self.pressure <= 1.0):
|
||||
raise ValueError("pressure must be between 0.0 and 1.0")
|
||||
if not (0.0 <= self.tangential_pressure <= 1.0):
|
||||
raise ValueError("tangential_pressure must be between 0.0 and 1.0")
|
||||
if not (0 <= self.twist <= 359):
|
||||
raise ValueError("twist must be between 0 and 359")
|
||||
if not (0.0 <= self.altitude_angle <= math.pi / 2):
|
||||
raise ValueError("altitude_angle must be between 0.0 and π/2")
|
||||
if not (0.0 <= self.azimuth_angle <= 2 * math.pi):
|
||||
raise ValueError("azimuth_angle must be between 0.0 and 2π")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerCommonProperties to a dictionary."""
|
||||
result: dict[str, Any] = {}
|
||||
if self.width != 1:
|
||||
result["width"] = self.width
|
||||
if self.height != 1:
|
||||
result["height"] = self.height
|
||||
if self.pressure != 0.0:
|
||||
result["pressure"] = self.pressure
|
||||
if self.tangential_pressure != 0.0:
|
||||
result["tangentialPressure"] = self.tangential_pressure
|
||||
if self.twist != 0:
|
||||
result["twist"] = self.twist
|
||||
if self.altitude_angle != 0.0:
|
||||
result["altitudeAngle"] = self.altitude_angle
|
||||
if self.azimuth_angle != 0.0:
|
||||
result["azimuthAngle"] = self.azimuth_angle
|
||||
return result
|
||||
|
||||
|
||||
# Action classes
|
||||
@dataclass
|
||||
class PauseAction:
|
||||
"""Represents a pause action."""
|
||||
|
||||
duration: int | None = None
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "pause"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PauseAction to a dictionary."""
|
||||
result: dict[str, Any] = {"type": self.type}
|
||||
if self.duration is not None:
|
||||
result["duration"] = self.duration
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyDownAction:
|
||||
"""Represents a key down action."""
|
||||
|
||||
value: str = ""
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "keyDown"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the KeyDownAction to a dictionary."""
|
||||
return {"type": self.type, "value": self.value}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyUpAction:
|
||||
"""Represents a key up action."""
|
||||
|
||||
value: str = ""
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "keyUp"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the KeyUpAction to a dictionary."""
|
||||
return {"type": self.type, "value": self.value}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerDownAction:
|
||||
"""Represents a pointer down action."""
|
||||
|
||||
button: int = 0
|
||||
properties: PointerCommonProperties | None = None
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "pointerDown"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerDownAction to a dictionary."""
|
||||
result: dict[str, Any] = {"type": self.type, "button": self.button}
|
||||
if self.properties:
|
||||
result.update(self.properties.to_dict())
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerUpAction:
|
||||
"""Represents a pointer up action."""
|
||||
|
||||
button: int = 0
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "pointerUp"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerUpAction to a dictionary."""
|
||||
return {"type": self.type, "button": self.button}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerMoveAction:
|
||||
"""Represents a pointer move action."""
|
||||
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
duration: int | None = None
|
||||
origin: str | ElementOrigin | None = None
|
||||
properties: PointerCommonProperties | None = None
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "pointerMove"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerMoveAction to a dictionary."""
|
||||
result: dict[str, Any] = {"type": self.type, "x": self.x, "y": self.y}
|
||||
if self.duration is not None:
|
||||
result["duration"] = self.duration
|
||||
if self.origin is not None:
|
||||
if isinstance(self.origin, ElementOrigin):
|
||||
result["origin"] = self.origin.to_dict()
|
||||
else:
|
||||
result["origin"] = self.origin
|
||||
if self.properties:
|
||||
result.update(self.properties.to_dict())
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class WheelScrollAction:
|
||||
"""Represents a wheel scroll action."""
|
||||
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
delta_x: int = 0
|
||||
delta_y: int = 0
|
||||
duration: int | None = None
|
||||
origin: str | ElementOrigin | None = Origin.VIEWPORT
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "scroll"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the WheelScrollAction to a dictionary."""
|
||||
result: dict[str, Any] = {
|
||||
"type": self.type,
|
||||
"x": self.x,
|
||||
"y": self.y,
|
||||
"deltaX": self.delta_x,
|
||||
"deltaY": self.delta_y,
|
||||
}
|
||||
if self.duration is not None:
|
||||
result["duration"] = self.duration
|
||||
if self.origin is not None:
|
||||
if isinstance(self.origin, ElementOrigin):
|
||||
result["origin"] = self.origin.to_dict()
|
||||
else:
|
||||
result["origin"] = self.origin
|
||||
return result
|
||||
|
||||
|
||||
# Source Actions
|
||||
@dataclass
|
||||
class NoneSourceActions:
|
||||
"""Represents a sequence of none actions."""
|
||||
|
||||
id: str = ""
|
||||
actions: list[PauseAction] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "none"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the NoneSourceActions to a dictionary."""
|
||||
return {"type": self.type, "id": self.id, "actions": [action.to_dict() for action in self.actions]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeySourceActions:
|
||||
"""Represents a sequence of key actions."""
|
||||
|
||||
id: str = ""
|
||||
actions: list[PauseAction | KeyDownAction | KeyUpAction] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "key"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the KeySourceActions to a dictionary."""
|
||||
return {"type": self.type, "id": self.id, "actions": [action.to_dict() for action in self.actions]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerSourceActions:
|
||||
"""Represents a sequence of pointer actions."""
|
||||
|
||||
id: str = ""
|
||||
parameters: PointerParameters | None = None
|
||||
actions: list[PauseAction | PointerDownAction | PointerUpAction | PointerMoveAction] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.parameters is None:
|
||||
self.parameters = PointerParameters()
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "pointer"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerSourceActions to a dictionary."""
|
||||
result: dict[str, Any] = {
|
||||
"type": self.type,
|
||||
"id": self.id,
|
||||
"actions": [action.to_dict() for action in self.actions],
|
||||
}
|
||||
if self.parameters:
|
||||
result["parameters"] = self.parameters.to_dict()
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class WheelSourceActions:
|
||||
"""Represents a sequence of wheel actions."""
|
||||
|
||||
id: str = ""
|
||||
actions: list[PauseAction | WheelScrollAction] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "wheel"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the WheelSourceActions to a dictionary."""
|
||||
return {"type": self.type, "id": self.id, "actions": [action.to_dict() for action in self.actions]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileDialogInfo:
|
||||
"""Represents file dialog information from input.fileDialogOpened event."""
|
||||
|
||||
context: str
|
||||
multiple: bool
|
||||
element: dict | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "FileDialogInfo":
|
||||
"""Creates a FileDialogInfo instance from a dictionary.
|
||||
|
||||
Args:
|
||||
data: A dictionary containing the file dialog information.
|
||||
|
||||
Returns:
|
||||
FileDialogInfo: A new instance of FileDialogInfo.
|
||||
"""
|
||||
return cls(context=data["context"], multiple=data["multiple"], element=data.get("element"))
|
||||
|
||||
|
||||
# Event Class
|
||||
class FileDialogOpened:
|
||||
"""Event class for input.fileDialogOpened event."""
|
||||
|
||||
event_class = "input.fileDialogOpened"
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json):
|
||||
"""Create FileDialogInfo from JSON data."""
|
||||
return FileDialogInfo.from_dict(json)
|
||||
|
||||
|
||||
class Input:
|
||||
"""BiDi implementation of the input module."""
|
||||
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
self.subscriptions = {}
|
||||
self.callbacks = {}
|
||||
|
||||
def perform_actions(
|
||||
self,
|
||||
context: str,
|
||||
actions: list[NoneSourceActions | KeySourceActions | PointerSourceActions | WheelSourceActions],
|
||||
) -> None:
|
||||
"""Performs a sequence of user input actions.
|
||||
|
||||
Args:
|
||||
context: The browsing context ID where actions should be performed.
|
||||
actions: A list of source actions to perform.
|
||||
"""
|
||||
params = {"context": context, "actions": [action.to_dict() for action in actions]}
|
||||
self.conn.execute(command_builder("input.performActions", params))
|
||||
|
||||
def release_actions(self, context: str) -> None:
|
||||
"""Releases all input state for the given context.
|
||||
|
||||
Args:
|
||||
context: The browsing context ID to release actions for.
|
||||
"""
|
||||
params = {"context": context}
|
||||
self.conn.execute(command_builder("input.releaseActions", params))
|
||||
|
||||
def set_files(self, context: str, element: dict, files: list[str]) -> None:
|
||||
"""Sets files for a file input element.
|
||||
|
||||
Args:
|
||||
context: The browsing context ID.
|
||||
element: The element reference (script.SharedReference).
|
||||
files: A list of file paths to set.
|
||||
"""
|
||||
params = {"context": context, "element": element, "files": files}
|
||||
self.conn.execute(command_builder("input.setFiles", params))
|
||||
|
||||
def add_file_dialog_handler(self, handler) -> int:
|
||||
"""Add a handler for file dialog opened events.
|
||||
|
||||
Args:
|
||||
handler: Callback function that takes a FileDialogInfo object.
|
||||
|
||||
Returns:
|
||||
int: Callback ID for removing the handler later.
|
||||
"""
|
||||
# Subscribe to the event if not already subscribed
|
||||
if FileDialogOpened.event_class not in self.subscriptions:
|
||||
session = Session(self.conn)
|
||||
self.conn.execute(session.subscribe(FileDialogOpened.event_class))
|
||||
self.subscriptions[FileDialogOpened.event_class] = []
|
||||
|
||||
# Add callback - the callback receives the parsed FileDialogInfo directly
|
||||
callback_id = self.conn.add_callback(FileDialogOpened, handler)
|
||||
|
||||
self.subscriptions[FileDialogOpened.event_class].append(callback_id)
|
||||
self.callbacks[callback_id] = handler
|
||||
|
||||
return callback_id
|
||||
|
||||
def remove_file_dialog_handler(self, callback_id: int) -> None:
|
||||
"""Remove a file dialog handler.
|
||||
|
||||
Args:
|
||||
callback_id: The callback ID returned by add_file_dialog_handler.
|
||||
"""
|
||||
if callback_id in self.callbacks:
|
||||
del self.callbacks[callback_id]
|
||||
|
||||
if FileDialogOpened.event_class in self.subscriptions:
|
||||
if callback_id in self.subscriptions[FileDialogOpened.event_class]:
|
||||
self.subscriptions[FileDialogOpened.event_class].remove(callback_id)
|
||||
|
||||
# If no more callbacks for this event, unsubscribe
|
||||
if not self.subscriptions[FileDialogOpened.event_class]:
|
||||
session = Session(self.conn)
|
||||
self.conn.execute(session.unsubscribe(FileDialogOpened.event_class))
|
||||
del self.subscriptions[FileDialogOpened.event_class]
|
||||
|
||||
self.conn.remove_callback(FileDialogOpened, callback_id)
|
||||
Reference in New Issue
Block a user