import json from fasthtml.xtend import Script from myfasthtml.core.commands import Command from myfasthtml.core.instances import MultipleInstance class Mouse(MultipleInstance): """ Represents a mechanism to manage mouse event combinations and their associated commands. This class is used to add, manage, and render mouse event sequences with corresponding commands, providing a flexible way to handle mouse interactions programmatically. Combinations can be defined with: - A Command object: mouse.add("click", command) - HTMX parameters: mouse.add("click", hx_post="/url", hx_vals={...}) - Both (named params override command): mouse.add("click", command, hx_target="#other") For dynamic hx_vals, use "js:functionName()" to call a client-side function. Supported base actions: - ``click`` - Left mouse click (detected globally) - ``right_click`` (or alias ``rclick``) - Right mouse click (detected on element only) - ``mousedown>mouseup`` - Left mouse press-and-release (captures data at both phases) - ``rmousedown>mouseup`` - Right mouse press-and-release Modifiers can be combined with ``+``: ``ctrl+click``, ``shift+mousedown>mouseup``. Sequences use space separation: ``click right_click``, ``click mousedown>mouseup``. For ``mousedown>mouseup`` actions with ``hx_vals="js:functionName()"``, the JS function is called at both mousedown and mouseup. Results are suffixed: ``key_mousedown`` and ``key_mouseup`` in the server request. """ VALID_ACTIONS = { 'click', 'right_click', 'rclick', 'mousedown>mouseup', 'rmousedown>mouseup' } VALID_MODIFIERS = {'ctrl', 'shift', 'alt'} def __init__(self, parent, _id=None, combinations=None): super().__init__(parent, _id=_id) self.combinations = combinations or {} def _validate_sequence(self, sequence: str): """ Validate a mouse event sequence string. Checks that all elements in the sequence use valid action names and modifiers. Args: sequence: Mouse event sequence string (e.g., "click", "ctrl+mousedown>mouseup") Raises: ValueError: If any action or modifier is invalid. """ elements = sequence.strip().split() for element in elements: parts = element.split('+') # Last part should be the action, others are modifiers action = parts[-1].lower() modifiers = [p.lower() for p in parts[:-1]] if action not in self.VALID_ACTIONS: raise ValueError( f"Invalid action '{action}' in sequence '{sequence}'. " f"Valid actions: {', '.join(sorted(self.VALID_ACTIONS))}" ) for mod in modifiers: if mod not in self.VALID_MODIFIERS: raise ValueError( f"Invalid modifier '{mod}' in sequence '{sequence}'. " f"Valid modifiers: {', '.join(sorted(self.VALID_MODIFIERS))}" ) def add(self, sequence: str, command: Command = None, *, hx_post: str = None, hx_get: str = None, hx_put: str = None, hx_delete: str = None, hx_patch: str = None, hx_target: str = None, hx_swap: str = None, hx_vals=None, on_move: str = None): """ Add a mouse combination with optional command and HTMX parameters. Args: sequence: Mouse event sequence string. Supports: - Simple actions: ``"click"``, ``"right_click"``, ``"mousedown>mouseup"`` - Modifiers: ``"ctrl+click"``, ``"shift+mousedown>mouseup"`` - Sequences: ``"click right_click"``, ``"click mousedown>mouseup"`` - Aliases: ``"rclick"`` for ``"right_click"`` command: Optional Command object for server-side action hx_post: HTMX post URL (overrides command) hx_get: HTMX get URL (overrides command) hx_put: HTMX put URL (overrides command) hx_delete: HTMX delete URL (overrides command) hx_patch: HTMX patch URL (overrides command) hx_target: HTMX target selector (overrides command) hx_swap: HTMX swap strategy (overrides command) hx_vals: HTMX values dict or "js:functionName()" for dynamic values. For mousedown>mouseup actions, the JS function is called at both mousedown and mouseup, with results suffixed ``_mousedown`` and ``_mouseup``. on_move: Client-side JS function called on each animation frame during a drag, using ``"js:functionName()"`` format. Only valid with ``mousedown>mouseup`` sequences. The function receives ``(event, combination, mousedown_result)`` where ``mousedown_result`` is the raw result of ``hx_vals`` at mousedown, or ``None`` if ``hx_vals`` is not set. Return value is ignored. Returns: self for method chaining Raises: ValueError: If the sequence contains invalid actions or modifiers. """ self._validate_sequence(sequence) self.combinations[sequence] = { "command": command, "hx_post": hx_post, "hx_get": hx_get, "hx_put": hx_put, "hx_delete": hx_delete, "hx_patch": hx_patch, "hx_target": hx_target, "hx_swap": hx_swap, "hx_vals": hx_vals, "on_move": on_move, } return self def _build_htmx_params(self, combination_data: dict) -> dict: """ Build HTMX parameters by merging command params with named overrides. Named parameters take precedence over command parameters. hx_vals is handled separately via hx-vals-extra to preserve command's hx-vals. """ command = combination_data.get("command") # Start with command params if available if command is not None: params = command.get_htmx_params().copy() else: params = {} # Override with named parameters (only if explicitly set) # Note: hx_vals is handled separately below param_mapping = { "hx_post": "hx-post", "hx_get": "hx-get", "hx_put": "hx-put", "hx_delete": "hx-delete", "hx_patch": "hx-patch", "hx_target": "hx-target", "hx_swap": "hx-swap", } for py_name, htmx_name in param_mapping.items(): value = combination_data.get(py_name) if value is not None: params[htmx_name] = value # Handle hx_vals separately - store in hx-vals-extra to not overwrite command's hx-vals hx_vals = combination_data.get("hx_vals") if hx_vals is not None: if isinstance(hx_vals, str) and hx_vals.startswith("js:"): # Dynamic values: extract function name func_name = hx_vals[3:].rstrip("()") params["hx-vals-extra"] = {"js": func_name} elif isinstance(hx_vals, dict): # Static dict values params["hx-vals-extra"] = {"dict": hx_vals} else: # Other string values - try to parse as JSON try: parsed = json.loads(hx_vals) if not isinstance(parsed, dict): raise ValueError(f"hx_vals must be a dict, got {type(parsed).__name__}") params["hx-vals-extra"] = {"dict": parsed} except json.JSONDecodeError as e: raise ValueError(f"hx_vals must be a dict or 'js:functionName()', got invalid JSON: {e}") # Handle on_move - client-side function for real-time drag feedback on_move = combination_data.get("on_move") if on_move is not None: if isinstance(on_move, str) and on_move.startswith("js:"): func_name = on_move[3:].rstrip("()") params["on-move"] = func_name else: raise ValueError(f"on_move must be 'js:functionName()', got: {on_move!r}") return params def render(self): str_combinations = { sequence: self._build_htmx_params(data) for sequence, data in self.combinations.items() } return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')") def __ft__(self): return self.render()