AXElements/AXElements

View on GitHub
lib/ax/element.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# -*- coding: utf-8 -*-

require 'active_support/core_ext/object/blank'
require 'accessibility/core'
require 'accessibility/factory'
require 'accessibility/translator'
require 'accessibility/enumerators'
require 'accessibility/qualifier'
require 'accessibility/errors'
require 'accessibility/pretty_printer'


##
# @abstract
#
# The abstract base class for all accessibility objects. `AX::Element`
# composes low level `AXUIElementRef` objects into a more Rubyish
# interface.
#
# This abstract base class provides generic functionality that all
# accessibility objects require.
class AX::Element
  include Accessibility::PrettyPrinter

  # @param ref [AXUIElementRef]
  def initialize ref
    @ref = ref
  end


  # @group Attributes

  ##
  # Cache of available attributes.
  #
  # @example
  #
  #   window.attributes # => [:size, :position, :title, ...]
  #
  # @return [Array<Symbol>]
  def attributes
    @attrs ||= TRANSLATOR.rubyize @ref.attributes
  end

  ##
  # Get the value of an attribute. This method will return `nil` if
  # the attribute does not have a value or if the element is dead. The
  # execption to the rule is that the `:children` attribute will always
  # return an array unless the element does not have the `:children`
  # attribute.
  #
  # @example
  #
  #   element.attribute :position # => #<CGPoint x=123.0 y=456.0>
  #
  # @param attr [#to_sym]
  def attribute attr
    @ref.attribute(TRANSLATOR.cocoaify(attr)).to_ruby
  end

  ##
  # Get the accessibility description for the element.
  #
  # This overrides the inherited `NSObject#description`. If you want a
  # description of the object then you should use {#inspect} instead.
  #
  # @return [String]
  def description
    attribute(:description).to_ruby
  end

  ##
  # Fetch the children elements for the current element.
  #
  # @return [Array<AX::Element>]
  def children
    @ref.children.to_ruby
  end

  ##
  # Get a list of elements, starting with the receiver and riding
  # the hierarchy up to the top level object (i.e. the {AX::Application})
  #
  # @example
  #
  #   element = AX::DOCK.list.application_dock_item
  #   element.ancestry
  #     # => [#<AX::ApplicationDockItem...>, #<AX::List...>, #<AX::Application...>]
  #
  # @return [Array<AX::Element>]
  def ancestry elements = self
    elements = Array(elements)
    element  = elements.last
    if element.attributes.include? :parent
      ancestry(elements << element.attribute(:parent))
    else
      elements
    end
  end
  alias_method :lineage, :ancestry

  ##
  # Get the process identifier for the application that the element
  # belongs to.
  #
  # @example
  #
  #   element.pid # => 12345
  #
  # @return [Fixnum]
  def pid
    @ref.pid
  end

  ##
  # Return the `#size` of an attribute. This only works for attributes
  # that are a collection. This exists because it is _much_ more
  # efficient to find out how many `children` exist using this API
  # instead of getting the children array and asking for the size.
  #
  # @example
  #
  #   table.size_of  :rows     # => 111
  #   window.size_of :children # => 16
  #
  # @param attr [#to_sym]
  # @return [Number]
  def size_of attr
    @ref.size_of TRANSLATOR.cocoaify attr
  end

  ##
  # Check whether or not an attribute is writable.
  #
  # @example
  #
  #   element.writable? :size  # => true
  #   element.writable? :value # => false
  #
  # @param attr [#to_sym]
  def writable? attr
    @ref.writable? TRANSLATOR.cocoaify attr
  end

  ##
  # Set a writable attribute on the element to the given value.
  #
  # @example
  #
  #   element.set :value, 'Hello, world!'
  #   element.set :size,  [100, 200].to_size
  #
  # @param attr [#to_sym]
  # @return the value that you were setting is returned
  def set attr, value
    unless writable? attr
      raise ArgumentError, "#{attr} is read-only for #{inspect}"
    end
    value = value.relative_to(@ref.value.size) if value.kind_of? Range
    @ref.set TRANSLATOR.cocoaify(attr), value
  end


  # @group Parameterized Attributes

  ##
  # List of available parameterized attributes. Most elements have no
  # parameterized attributes, but the ones that do have many.
  #
  # @example
  #
  #   window.parameterized_attributes     # => []
  #   text_field.parameterized_attributes # => [:string_for_range, :attributed_string, ...]
  #
  # @return [Array<Symbol>]
  def parameterized_attributes
    @param_attrs ||= TRANSLATOR.rubyize @ref.parameterized_attributes
  end

  ##
  # Get the value for a parameterized attribute.
  #
  # @example
  #
  #  text_field.parameterized_attribute :string_for_range, 2..8
  #
  # @param attr [#to_sym]
  # @param param [Object]
  def parameterized_attribute attr, param
    param = param.relative_to(@ref.value.size) if param.kind_of? Range
    @ref.parameterized_attribute(TRANSLATOR.cocoaify(attr), param).to_ruby
  end


  # @group Actions

  ##
  # List of available actions.
  #
  # @example
  #
  #   toolbar.actions # => []
  #   button.actions  # => [:press]
  #   menu.actions    # => [:open, :cancel]
  #
  # @return [Array<Symbol>]
  def actions
    @actions ||= TRANSLATOR.rubyize @ref.actions
  end

  ##
  # Tell an object to trigger an action.
  #
  # For instance, you can tell a button to call the same method that
  # would be called when pressing a button, except that the mouse will
  # not move over to the button to press it, nor will the keyboard be
  # used.
  #
  # @example
  #
  #   button.perform :press    # => true
  #   button.perform :make_pie # => false
  #
  # @param action [#to_sym]
  # @return [Boolean] true if successful
  def perform action
    @ref.perform TRANSLATOR.cocoaify action
  end

  ##
  # @note As of OS X 10.9 (Sea Lion), it is no longer possible to send
  #       keyboard events directly to an element. What we have here is
  #       only an approximation.
  #
  # Send keyboard events to the receiver.
  #
  # @param string [String]
  # @return [Boolean]
  def type string
    set_focus_to self unless focused?
    keyboard_events_for(string).each do |event|
      KeyCoder.post_event event
    end
  end


  # @group Search

  ##
  # Perform a breadth first search through the view hierarchy rooted at
  # the current element. If you are concerned about the return value of
  # this method, you can call {#blank?} on the return object.
  #
  # See the [Searching wiki](http://github.com/Marketcircle/AXElements/wiki/Searching)
  # for the details on search semantics.
  #
  # @example Find the dock icon for the Finder app
  #
  #   AX::DOCK.search(:application_dock_item, title:'Finder')
  #
  # @param kind [#to_s]
  # @param filters [Hash{Symbol=>Object}]
  # @yield Optional block used for filtering
  # @return [AX::Element,nil,Array<AX::Element>,Array<>]
  def search kind, filters = {}, &block
    kind      = kind.to_s
    qualifier = Accessibility::Qualifier.new(kind, filters, &block)
    tree      = Accessibility::Enumerators::BreadthFirst.new(self)

    if TRANSLATOR.singularize(kind) == kind
      tree.find     { |element| qualifier.qualifies? element }
    else
      tree.find_all { |element| qualifier.qualifies? element }
    end
  end

  ##
  # Search for an ancestor of the current element.
  #
  # As the opposite of {#search}, this also takes filters, and can
  # be used to find a specific ancestor for the current element.
  #
  # Returns `nil` if no ancestor is found.
  #
  # @example
  #
  #   button.ancestor :window       # => #<AX::StandardWindow>
  #   row.ancestor    :scroll_area  # => #<AX::ScrollArea>
  #
  # @param kind [#to_s]
  # @param filters [Hash{Symbol=>Object}]
  # @yield Optional block used for search filtering
  # @return [AX::Element,nil]
  def ancestor kind, filters = {}, &block
    qualifier = Accessibility::Qualifier.new(kind, filters, &block)
    element   = self
    until qualifier.qualifies? element
      element = element.attribute :parent
      break unless element
    end
    element
  end

  ##
  # We use {#method_missing} to dynamically handle requests to lookup
  # attributes or search for elements in the view hierarchy. An attribute
  # lookup is always tried first, followed by a parameterized attribute
  # lookup, and then finally a search.
  #
  # Failing all lookups, this method calls `super`, which will probably
  # raise an exception; however, most elements have children and so it
  # is more likely that you will get an {Accessibility::SearchFailure}
  # in cases where you sholud get a `NoMethodError`.
  #
  # @example
  #
  #   mail   = Accessibility.application_with_bundle_identifier 'com.apple.mail'
  #
  #   # attribute lookup
  #   window = mail.focused_window
  #   # is equivalent to
  #   window = mail.attribute :focused_window
  #
  #   # attribute setting
  #   window.position = CGPoint.new(100, 100)
  #   # is equivalent to
  #   window.set :position, CGPoint.new(100, 100)
  #
  #   # parameterized attribute lookup
  #   window.title_ui_element.string_for_range 1..10
  #   # is equivalent to
  #   title = window.attribute :title_ui_element
  #   title.parameterized_attribute :string_for_range, 1..10
  #
  #   # simple single element search
  #   window.button # => You want the first Button that is found
  #   # is equivalent to
  #   window.search :button, {}
  #
  #   # simple multi-element search
  #   window.buttons # => You want all the Button objects found
  #   # is equivalent to
  #   window.search :buttons, {}
  #
  #   # filters for a single element search
  #   window.button(title: 'Log In') # => First Button with a title of 'Log In'
  #   # is equivalent to
  #   window.search :button, title: 'Log In'
  #
  #   # searching from #method_missing will #raise if nothing is found
  #   window.application # => SearchFailure is raised
  #
  def method_missing method, *args, &block
    return set(method.to_s.chomp(EQUALS), args.first) if method[-1] == EQUALS

    key = TRANSLATOR.cocoaify method
    if @ref.attributes.include? key
      return attribute(method)

    elsif @ref.parameterized_attributes.include? key
      return parameterized_attribute(method, args.first)

    elsif @ref.attributes.include? KAXChildrenAttribute
      if (result = search(method, *args, &block)).blank?
        raise Accessibility::SearchFailure.new(self, method, args.first, &block)
      else
        return result
      end

    else
      super

    end
  end

  # @endgroup


  ##
  # Get relevant details about the current object.
  #
  # @return [String]
  def inspect
    "#<#{self.class}" << pp_identifier.to_s <<
                         pp_position << pp_children <<
                         pp_enabled << pp_focused << '>'
  end

  ##
  # @note Since `#inspect` is often overridden by subclasses, this cannot
  #       be an alias.
  #
  # An "alias" for {#inspect}.
  #
  # @return [String]
  def to_s
    inspect
  end

  ##
  #
  # @return [Hash{Symbol=>Object}]
  def to_h
    Hash[attributes.zip attributes.map { |attr| attribute(attr) }]
  end

  ##
  # Get the relevant details about the receiver and also the children
  # and further descendents of the receiver. Each generation down the
  # tree will be indented one level further.
  #
  # @example
  #
  #   puts app.inspect_subtree
  #
  # @return [String]
  def inspect_subtree
    output = self.inspect + "\n"
    enum   = Accessibility::Enumerators::DepthFirst.new self
    enum.each_with_level do |element, depth|
      output << "\t"*depth + element.inspect + "\n"
    end
    output
  end

  ##
  # Take a screen shot of the receiving element and save it to disk. If a
  # file path is not given then the default value will put it on the
  # desktop. The actual file name will automatically generated with a
  # timestamp.
  #
  # @example
  #
  #   app.main_window.screenshot
  #     # => "~/Desktop/AXElements-ScreenShot-20120422184650.png"
  #
  #   app.main_window.screenshot "/Volumes/SecretStash"
  #     # => "/Volumes/SecretStash/AXElements-ScreenShot-20150622032250.png"
  #
  # @param path [#to_s]
  # @return [String] path to the screenshot
  def screenshot path = '~/Desktop'
    capture_screen self, path
  end

  ##
  # Overriden to respond properly with regards to dynamic attribute
  # lookups, but will return false for potential implicit searches.
  #
  # This does not work for predicate methods at the moment.
  def respond_to? name, priv = false
    key = TRANSLATOR.cocoaify name.to_s.chomp(EQUALS)
    @ref.attributes.include?(key)               ||
    @ref.parameterized_attributes.include?(key) ||
    super
  end

  ##
  # Get the center point of the element.
  #
  # @return [CGPoint]
  def to_point
    size  = attribute :size
    point = attribute :position
    point.x += size.width  / 2
    point.y += size.height / 2
    point
  end
  alias_method :hitpoint, :to_point

  ##
  # Get the bounding rectangle for the element.
  #
  # @return [CGRect]
  def bounds
    CGRect.new(attribute(:position), attribute(:size))
  end
  alias_method :to_rect, :bounds

  ##
  # Get the application object for the element.
  #
  # @return [AX::Application]
  def application
    @ref.application.to_ruby
  end

  # (see NilClass#blank?)
  def blank?
    false
  end

  ##
  # Return whether or not the receiver is "dead".
  #
  # A dead element is one that is no longer in the app's view
  # hierarchy. This is not directly related to visibility, but an
  # element that is invalid will not be visible, but an invisible
  # element might not be invalid.
  def invalid?
    @ref.invalid?
  end

  ##
  # Like {#respond_to?}, this is overriden to include attribute methods.
  # Though, it does include dynamic predicate methods at the moment.
  def methods include_super = true
    super.concat(attributes).concat(parameterized_attributes)
  end

  ##
  # Overridden so that equality testing would work.
  #
  # A hack, but the only sane way I can think of to test for equivalency.
  def == other
    @ref == other.instance_variable_get(:@ref)
  end
  alias_method :eql?, :==
  alias_method :equal?, :==


  private

  # @private
  # @return [String]
  EQUALS = '='

  # @private
  # @return [Accessibility::Translator]
  TRANSLATOR = Accessibility::Translator.instance

end