lib/ferrum/mouse.rb



# frozen_string_literal: true

module Ferrum
  class Mouse
    CLICK_WAIT = ENV.fetch("FERRUM_CLICK_WAIT", 0.1).to_f
    VALID_BUTTONS = %w[none left middle right back forward].freeze

    def initialize(page)
      @page = page
      @x = @y = 0
    end

    #
    # Scroll page to a given x, y coordinates.
    #
    # @param [Integer] top
    #  The pixel along the horizontal axis of the document that you want
    #  displayed in the upper left.
    #
    # @param [Integer] left
    #   The pixel along the vertical axis of the document that you want
    #   displayed in the upper left.
    #
    # @example
    #   browser.go_to("https://www.google.com/search?q=Ruby+headless+driver+for+Capybara")
    #   browser.mouse.scroll_to(0, 400)
    #
    def scroll_to(top, left)
      tap { @page.execute("window.scrollTo(#{top}, #{left})") }
    end

    #
    # Click given coordinates, fires mouse move, down and up events.
    #
    # @param [Integer] x
    #
    # @param [Integer] y
    #
    # @param [Float] delay
    #   Delay between mouse down and mouse up events.
    #
    # @param [Float] wait
    #
    # @param [Hash{Symbol => Object}] options
    #   Additional keyword arguments.
    #
    # @option options [:left, :right] :button (:left)
    #   The mouse button to click.
    #
    # @option options [Integer] :count (1)
    #
    # @option options [Integer] :modifiers
    #   Bitfield for key modifiers. See`keyboard.modifiers`.
    #
    # @return [self]
    #
    def click(x:, y:, delay: 0, wait: CLICK_WAIT, **options)
      move(x: x, y: y)
      down(**options)
      sleep(delay)
      # Potential wait because if some network event is triggered then we have
      # to wait until it's over and frame is loaded or failed to load.
      up(wait: wait, **options)
      self
    end

    #
    # Mouse down for given coordinates.
    #
    # @param [Hash{Symbol => Object}] options
    #   Additional keyword arguments.
    #
    # @option options [:left, :right] :button (:left)
    #   The mouse button to click.
    #
    # @option options [Integer] :count (1)
    #
    # @option options [Integer] :modifiers
    #   Bitfield for key modifiers. See`keyboard.modifiers`.
    #
    # @return [self]
    #
    def down(**options)
      tap { mouse_event(type: "mousePressed", **options) }
    end

    #
    # Mouse up for given coordinates.
    #
    # @param [Hash{Symbol => Object}] options
    #   Additional keyword arguments.
    #
    # @option options [:left, :right] :button (:left)
    #   The mouse button to click.
    #
    # @option options [Integer] :count (1)
    #
    # @option options [Integer] :modifiers
    #   Bitfield for key modifiers. See`keyboard.modifiers`.
    #
    # @return [self]
    #
    def up(**options)
      tap { mouse_event(type: "mouseReleased", **options) }
    end

    #
    # Mouse move to given x and y.
    #
    # @param [Integer] x
    #
    # @param [Integer] y
    #
    # @param [Integer] steps
    #   Sends intermediate mousemove events.
    #
    # @return [self]
    #
    def move(x:, y:, steps: 1)
      from_x = @x
      from_y = @y
      @x = x
      @y = y

      steps.times do |i|
        new_x = from_x + ((@x - from_x) * ((i + 1) / steps.to_f))
        new_y = from_y + ((@y - from_y) * ((i + 1) / steps.to_f))

        @page.command("Input.dispatchMouseEvent",
                      slowmoable: true,
                      type: "mouseMoved",
                      x: new_x.to_i,
                      y: new_y.to_i)
      end

      self
    end

    private

    def mouse_event(type:, button: :left, count: 1, modifiers: nil, wait: 0)
      button = validate_button(button)
      options = { x: @x, y: @y, type: type, button: button, clickCount: count }
      options.merge!(modifiers: modifiers) if modifiers
      @page.command("Input.dispatchMouseEvent", wait: wait, slowmoable: true, **options)
    end

    def validate_button(button)
      button = button.to_s
      raise "Invalid button: #{button}" unless VALID_BUTTONS.include?(button)

      button
    end
  end
end