diff options
| author | Stephen Blott | 2014-12-30 15:19:21 +0000 | 
|---|---|---|
| committer | Stephen Blott | 2014-12-30 15:19:21 +0000 | 
| commit | 2e9b9105601a2ea9f454875e776b00baec4299d8 (patch) | |
| tree | 759d1f88f0dbc39265fa0641dc8f02b6a18d441d | |
| parent | a4a591156f451c1d360530fce6674189f384b452 (diff) | |
| parent | c3df7699527f88c660e0d61fafdd1ad334236d77 (diff) | |
| download | vimium-2e9b9105601a2ea9f454875e776b00baec4299d8.tar.bz2 | |
Merge branch 'smblott-link-hints-overlap' into post-1.46
| -rw-r--r-- | content_scripts/link_hints.coffee | 158 | ||||
| -rw-r--r-- | lib/dom_utils.coffee | 92 | ||||
| -rw-r--r-- | lib/rect.coffee | 82 | ||||
| -rw-r--r-- | manifest.json | 1 | ||||
| -rw-r--r-- | tests/dom_tests/dom_tests.html | 1 | ||||
| -rw-r--r-- | tests/dom_tests/dom_utils_test.coffee | 6 | ||||
| -rw-r--r-- | tests/unit_tests/rect_test.coffee | 232 | 
7 files changed, 494 insertions, 78 deletions
diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index 24bd7126..8d476529 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -36,17 +36,6 @@ LinkHints =    #    init: -> -  # -  # Generate an XPath describing what a clickable element is. -  # The final expression will be something like "//button | //xhtml:button | ..." -  # We use translate() instead of lower-case() because Chrome only supports XPath 1.0. -  # -  clickableElementsXPath: DomUtils.makeXPath( -    ["a", "area[@href]", "textarea", "button", "select", -     "input[not(@type='hidden' or @disabled or @readonly)]", -     "*[@onclick or @tabindex or @role='link' or @role='button' or contains(@class, 'button') or " + -     "@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"]) -    # We need this as a top-level function because our command system doesn't yet support arguments.    activateModeToOpenInNewTab: -> @activateMode(OPEN_IN_NEW_BG_TAB)    activateModeToOpenInNewForegroundTab: -> @activateMode(OPEN_IN_NEW_FG_TAB) @@ -136,45 +125,128 @@ LinkHints =      marker    # -  # Returns all clickable elements that are not hidden and are in the current viewport. -  # We prune invisible elements partly for performance reasons, but moreso it's to decrease the number -  # of digits needed to enumerate all of the links on screen. +  # Determine whether the element is visible and clickable. If it is, return the element and the rect bounding +  # the element in the viewport.  There may be more than one part of element which is clickable (for example, +  # if it's an image), therefore we return a list of element/rect pairs.    # -  getVisibleClickableElements: -> -    resultSet = DomUtils.evaluateXPath(@clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) - +  getVisibleClickable: (element) -> +    tagName = element.tagName.toLowerCase() +    isClickable = false +    onlyHasTabIndex = false      visibleElements = [] -    # Find all visible clickable elements. -    for i in [0...resultSet.snapshotLength] by 1 -      element = resultSet.snapshotItem(i) -      clientRect = DomUtils.getVisibleClientRect(element, clientRect) -      if (clientRect != null) -        visibleElements.push({element: element, rect: clientRect}) - -      if (element.localName == "area") -        map = element.parentElement -        continue unless map -        img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']") -        continue unless img -        imgClientRects = img.getClientRects() -        continue if (imgClientRects.length == 0) -        c = element.coords.split(/,/) -        coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)] -        rect = { -          top: imgClientRects[0].top + coords[1], -          left: imgClientRects[0].left + coords[0], -          right: imgClientRects[0].left + coords[2], -          bottom: imgClientRects[0].top + coords[3], -          width: coords[2] - coords[0], -          height: coords[3] - coords[1] -        } - -        visibleElements.push({element: element, rect: rect}) +    # Insert area elements that provide click functionality to an img. +    if tagName == "img" +      mapName = element.getAttribute "usemap" +      if mapName +        imgClientRects = element.getClientRects() +        mapName = mapName.replace(/^#/, "").replace("\"", "\\\"") +        map = document.querySelector "map[name=\"#{mapName}\"]" +        if map and imgClientRects.length > 0 +          areas = map.getElementsByTagName "area" +          areasAndRects = DomUtils.getClientRectsForAreas imgClientRects[0], areas +          visibleElements.push areasAndRects... + +    # Check aria properties to see if the element should be ignored. +    if (element.getAttribute("aria-hidden")?.toLowerCase() in ["", "true"] or +        element.getAttribute("aria-disabled")?.toLowerCase() in ["", "true"]) +      return [] # This element should never have a link hint. + +    # Check for attributes that make an element clickable regardless of its tagName. +    if (element.hasAttribute("onclick") or +        element.getAttribute("role")?.toLowerCase() in ["button", "link"] or +        element.getAttribute("class")?.toLowerCase().indexOf("button") >= 0 or +        element.getAttribute("contentEditable")?.toLowerCase() in ["", "contentEditable", "true"]) +      isClickable = true + +    # Check for jsaction event listeners on the element. +    if element.hasAttribute "jsaction" +      jsactionRules = element.getAttribute("jsaction").split(";") +      for jsactionRule in jsactionRules +        ruleSplit = jsactionRule.split ":" +        isClickable ||= ruleSplit[0] == "click" or (ruleSplit.length == 1 and ruleSplit[0] != "none") + +    # Check for tagNames which are natively clickable. +    switch tagName +      when "a" +        isClickable = true +      when "textarea" +        isClickable ||= not element.disabled and not element.readOnly +      when "input" +        isClickable ||= not (element.getAttribute("type")?.toLowerCase() == "hidden" or +                             element.disabled or +                             (element.readOnly and DomUtils.isSelectable element)) +      when "button", "select" +        isClickable ||= not element.disabled + +    # Elements with tabindex are sometimes useful, but usually not. We can treat them as second class +    # citizens when it improves UX, so take special note of them. +    tabIndexValue = element.getAttribute("tabindex") +    tabIndex = if tabIndexValue == "" then 0 else parseInt tabIndexValue +    unless isClickable or isNaN(tabIndex) or tabIndex < 0 +      isClickable = onlyHasTabIndex = true + +    if isClickable +      clientRect = DomUtils.getVisibleClientRect element +      if clientRect != null +        visibleElements.push {element: element, rect: clientRect, secondClassCitizen: onlyHasTabIndex}      visibleElements    # +  # Returns all clickable elements that are not hidden and are in the current viewport, along with rectangles +  # at which (parts of) the elements are displayed. +  # In the process, we try to find rects where elements do not overlap so that link hints are unambiguous. +  # Because of this, the rects returned will frequently *NOT* be equivalent to the rects for the whole +  # element. +  # +  getVisibleClickableElements: -> +    elements = document.documentElement.getElementsByTagName "*" +    visibleElements = [] + +    # The order of elements here is important; they should appear in the order they are in the DOM, so that +    # we can work out which element is on top when multiple elements overlap. Detecting elements in this loop +    # is the sensible, efficient way to ensure this happens. +    # NOTE(mrmr1993): Our previous method (combined XPath and DOM traversal for jsaction) couldn't provide +    # this, so it's necessary to check whether elements are clickable in order, as we do below. +    for element in elements +      visibleElement = @getVisibleClickable element +      visibleElements.push visibleElement... + +    # TODO(mrmr1993): Consider z-index. z-index affects behviour as follows: +    #  * The document has a local stacking context. +    #  * An element with z-index specified +    #    - sets its z-order position in the containing stacking context, and +    #    - creates a local stacking context containing its children. +    #  * An element (1) is shown above another element (2) if either +    #    - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), the +    #      ancestor of (1) has a higher z-index than the ancestor of (2); or +    #    - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), +    #        + the ancestors of (1) and (2) have equal z-index, and +    #        + the ancestor of (1) appears later in the DOM than the ancestor of (2). +    # +    # Remove rects from elements where another clickable element lies above it. +    nonOverlappingElements = [] +    # Traverse the DOM from first to last, since later elements show above earlier elements. +    visibleElements = visibleElements.reverse() +    while visibleElement = visibleElements.pop() +      rects = [visibleElement.rect] +      for {rect: negativeRect} in visibleElements +        # Subtract negativeRect from every rect in rects, and concatenate the arrays of rects that result. +        rects = [].concat (rects.map (rect) -> Rect.subtract rect, negativeRect)... +      if rects.length > 0 +        nonOverlappingElements.push {element: visibleElement.element, rect: rects[0]} +      else +        # Every part of the element is covered by some other element, so just insert the whole element's +        # rect. Except for elements with tabIndex set (second class citizens); these are often more trouble +        # than they're worth. +        # TODO(mrmr1993): This is probably the wrong thing to do, but we don't want to stop being able to +        # click some elements that we could click before. +        nonOverlappingElements.push visibleElement unless visibleElement.secondClassCitizen + +    nonOverlappingElements + +  #    # Handles shift and esc keys. The other keys are passed to getMarkerMatcher().matchHintsByKey.    #    onKeyDownInMode: (hintMarkers, event) -> diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 8db71001..ba5e279f 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -50,34 +50,7 @@ DomUtils =    #    getVisibleClientRect: (element) ->      # Note: this call will be expensive if we modify the DOM in between calls. -    clientRects = ({ -      top: clientRect.top, right: clientRect.right, bottom: clientRect.bottom, left: clientRect.left, -      width: clientRect.width, height: clientRect.height -    } for clientRect in element.getClientRects()) - -    for clientRect in clientRects -      if (clientRect.top < 0) -        clientRect.height += clientRect.top -        clientRect.top = 0 - -      if (clientRect.left < 0) -        clientRect.width += clientRect.left -        clientRect.left = 0 - -      if (clientRect.top >= window.innerHeight - 4 || clientRect.left  >= window.innerWidth - 4) -        continue - -      if (clientRect.width < 3 || clientRect.height < 3) -        continue - -      # eliminate invisible elements (see test_harnesses/visibility_test.html) -      computedStyle = window.getComputedStyle(element, null) -      if (computedStyle.getPropertyValue('visibility') != 'visible' || -          computedStyle.getPropertyValue('display') == 'none' || -          computedStyle.getPropertyValue('opacity') == '0') -        continue - -      return clientRect +    clientRects = (Rect.copy clientRect for clientRect in element.getClientRects())      for clientRect in clientRects        # If the link has zero dimensions, it may be wrapping visible @@ -90,11 +63,72 @@ DomUtils =            continue if (computedStyle.getPropertyValue('float') == 'none' &&              computedStyle.getPropertyValue('position') != 'absolute')            childClientRect = @getVisibleClientRect(child) -          continue if (childClientRect == null) +          continue if childClientRect == null or childClientRect.width < 3 or childClientRect.height < 3            return childClientRect + +      else +        clientRect = @cropRectToVisible clientRect + +        continue if clientRect == null or clientRect.width < 3 or clientRect.height < 3 + +        # eliminate invisible elements (see test_harnesses/visibility_test.html) +        computedStyle = window.getComputedStyle(element, null) +        if (computedStyle.getPropertyValue('visibility') != 'visible' || +            computedStyle.getPropertyValue('display') == 'none') +          continue + +        return clientRect +      null    # +  # Bounds the rect by the current viewport dimensions. If the rect is offscreen or has a height or width < 3 +  # then null is returned instead of a rect. +  # +  cropRectToVisible: (rect) -> +    boundedRect = Rect.create( +      Math.max(rect.left, 0) +      Math.max(rect.top, 0) +      rect.right +      rect.bottom +    ) +    if boundedRect.top >= window.innerHeight - 4 or boundedRect.left >= window.innerWidth - 4 +      null +    else +      boundedRect + +  # +  # Get the client rects for the <area> elements in a <map> based on the position of the <img> element using +  # the map. Returns an array of rects. +  # +  getClientRectsForAreas: (imgClientRect, areas) -> +    rects = [] +    for area in areas +      coords = area.coords.split(",").map((coord) -> parseInt(coord, 10)) +      shape = area.shape.toLowerCase() +      if shape in ["rect", "rectangle"] # "rectangle" is an IE non-standard. +        [x1, y1, x2, y2] = coords +      else if shape in ["circle", "circ"] # "circ" is an IE non-standard. +        [x, y, r] = coords +        diff = r / Math.sqrt 2 # Gives us an inner square +        x1 = x - diff +        x2 = x + diff +        y1 = y - diff +        y2 = y + diff +      else if shape == "default" +        [x1, y1, x2, y2] = [0, 0, imgClientRect.width, imgClientRect.height] +      else +        # Just consider the rectangle surrounding the first two points in a polygon. It's possible to do +        # something more sophisticated, but likely not worth the effort. +        [x1, y1, x2, y2] = coords + +      rect = Rect.translate (Rect.create x1, y1, x2, y2), imgClientRect.left, imgClientRect.top +      rect = @cropRectToVisible rect + +      rects.push {element: area, rect: rect} if rect and not isNaN rect.top +    rects + +  #    # Selectable means that we should use the simulateSelect method to activate the element instead of a click.    #    # The html5 input types that should use simulateSelect are: diff --git a/lib/rect.coffee b/lib/rect.coffee new file mode 100644 index 00000000..adc1fc36 --- /dev/null +++ b/lib/rect.coffee @@ -0,0 +1,82 @@ +# Commands for manipulating rects. +Rect = +  # Create a rect given the top left and bottom right corners. +  create: (x1, y1, x2, y2) -> +    bottom: y2 +    top: y1 +    left: x1 +    right: x2 +    width: x2 - x1 +    height: y2 - y1 + +  copy: (rect) -> +    bottom: rect.bottom +    top: rect.top +    left: rect.left +    right: rect.right +    width: rect.width +    height: rect.height + +  # Translate a rect by x horizontally and y vertically. +  translate: (rect, x = 0, y = 0) -> +    bottom: rect.bottom + y +    top: rect.top + y +    left: rect.left + x +    right: rect.right + x +    width: rect.width +    height: rect.height + +  # Subtract rect2 from rect1, returning an array of rects which are in rect1 but not rect2. +  subtract: (rect1, rect2) -> +    # Bound rect2 by rect1 +    rect2 = @create( +      Math.max(rect1.left, rect2.left), +      Math.max(rect1.top, rect2.top), +      Math.min(rect1.right, rect2.right), +      Math.min(rect1.bottom, rect2.bottom) +    ) + +    # If bounding rect2 has made the width or height negative, rect1 does not contain rect2. +    return [Rect.copy rect1] if rect2.width < 0 or rect2.height < 0 + +    # +    # All the possible rects, in the order +    # +-+-+-+ +    # |1|2|3| +    # +-+-+-+ +    # |4| |5| +    # +-+-+-+ +    # |6|7|8| +    # +-+-+-+ +    # where the outer rectangle is rect1 and the inner rectangle is rect 2. Note that the rects may be of +    # width or height 0. +    # +    rects = [ +      # Top row. +      @create rect1.left, rect1.top, rect2.left, rect2.top +      @create rect2.left, rect1.top, rect2.right, rect2.top +      @create rect2.right, rect1.top, rect1.right, rect2.top +      # Middle row. +      @create rect1.left, rect2.top, rect2.left, rect2.bottom +      @create rect2.right, rect2.top, rect1.right, rect2.bottom +      # Bottom row. +      @create rect1.left, rect2.bottom, rect2.left, rect1.bottom +      @create rect2.left, rect2.bottom, rect2.right, rect1.bottom +      @create rect2.right, rect2.bottom, rect1.right, rect1.bottom +    ] + +    rects.filter (rect) -> rect.height > 0 and rect.width > 0 + +  contains: (rect1, rect2) -> +    rect1.right > rect2.left and +    rect1.left < rect2.right and +    rect1.bottom > rect2.top and +    rect1.top < rect2.bottom + +  equals: (rect1, rect2) -> +    for property in ["top", "bottom", "left", "right", "width", "height"] +      return false if rect1[property] != rect2[property] +    true + +root = exports ? window +root.Rect = Rect diff --git a/manifest.json b/manifest.json index 96739d2e..a365f390 100644 --- a/manifest.json +++ b/manifest.json @@ -35,6 +35,7 @@        "js": ["lib/utils.js",               "lib/keyboard_utils.js",               "lib/dom_utils.js", +             "lib/rect.js",               "lib/handler_stack.js",               "lib/clipboard.js",               "content_scripts/ui_component.js", diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html index 7b154d24..a764b42d 100644 --- a/tests/dom_tests/dom_tests.html +++ b/tests/dom_tests/dom_tests.html @@ -32,6 +32,7 @@      <script type="text/javascript" src="../../lib/utils.js"></script>      <script type="text/javascript" src="../../lib/keyboard_utils.js"></script>      <script type="text/javascript" src="../../lib/dom_utils.js"></script> +    <script type="text/javascript" src="../../lib/rect.js"></script>      <script type="text/javascript" src="../../lib/handler_stack.js"></script>      <script type="text/javascript" src="../../lib/clipboard.js"></script>      <script type="text/javascript" src="../../content_scripts/ui_component.js"></script> diff --git a/tests/dom_tests/dom_utils_test.coffee b/tests/dom_tests/dom_utils_test.coffee index 130a3014..ad8bde3c 100644 --- a/tests/dom_tests/dom_utils_test.coffee +++ b/tests/dom_tests/dom_utils_test.coffee @@ -50,12 +50,6 @@ context "Check visibility",      assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null      assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'bar') != null -  should "detect opacity:0 links as hidden", -> -    document.getElementById("test-div").innerHTML = """ -    <a id='foo' style='opacity:0'>test</a> -    """ -    assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' -    should "detect links that contain only floated / absolutely-positioned divs as visible", ->      document.getElementById("test-div").innerHTML = """      <a id='foo'> diff --git a/tests/unit_tests/rect_test.coffee b/tests/unit_tests/rect_test.coffee new file mode 100644 index 00000000..cfb26b05 --- /dev/null +++ b/tests/unit_tests/rect_test.coffee @@ -0,0 +1,232 @@ +require "./test_helper.js" +extend(global, require "../../lib/rect.js") + +context "Rect", +  should "set rect properties correctly", -> +    [x1, y1, x2, y2] = [1, 2, 3, 4] +    rect = Rect.create x1, y1, x2, y2 +    assert.equal rect.left, x1 +    assert.equal rect.top, y1 +    assert.equal rect.right, x2 +    assert.equal rect.bottom, y2 +    assert.equal rect.width, x2 - x1 +    assert.equal rect.height, y2 - y1 + +  should "translate rect horizontally", -> +    [x1, y1, x2, y2] = [1, 2, 3, 4] +    x = 5 +    rect1 = Rect.create x1, y1, x2, y2 +    rect2 = Rect.translate rect1, x + +    assert.equal rect1.left + x, rect2.left +    assert.equal rect1.right + x, rect2.right + +    assert.equal rect1.width, rect2.width +    assert.equal rect1.height, rect2.height +    assert.equal rect1.top, rect2.top +    assert.equal rect1.bottom, rect2.bottom + +  should "translate rect vertically", -> +    [x1, y1, x2, y2] = [1, 2, 3, 4] +    y = 5 +    rect1 = Rect.create x1, y1, x2, y2 +    rect2 = Rect.translate rect1, undefined, y + +    assert.equal rect1.top + y, rect2.top +    assert.equal rect1.bottom + y, rect2.bottom + +    assert.equal rect1.width, rect2.width +    assert.equal rect1.height, rect2.height +    assert.equal rect1.left, rect2.left +    assert.equal rect1.right, rect2.right + +context "Rect subtraction", +  context "unchanged by rects outside", +    should "left, above", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create -2, -2, -1, -1 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "left", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create -2, 0, -1, 1 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "left, below", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create -2, 2, -1, 3 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "right, above", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create 2, -2, 3, -1 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "right", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create 2, 0, 3, 1 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "right, below", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create 2, 2, 3, 3 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "above", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create 0, -2, 1, -1 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "below", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create 0, 2, 1, 3 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +  context "unchanged by rects touching", +    should "left, above", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create -1, -1, 0, 0 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "left", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create -1, 0, 0, 1 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "left, below", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create -1, 1, 0, 2 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "right, above", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create 1, -1, 2, 0 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "right", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create 1, 0, 2, 1 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "right, below", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create 1, 1, 2, 2 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "above", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create 0, -1, 1, 0 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +    should "below", -> +      rect1 = Rect.create 0, 0, 1, 1 +      rect2 = Rect.create 0, 1, 1, 2 + +      rects = Rect.subtract rect1, rect2 +      assert.equal rects.length, 1 +      rect = rects[0] +      assert.isTrue Rect.equals rect1, rect + +  should "have nothing when subtracting itself", -> +    rect = Rect.create 0, 0, 1, 1 +    rects = Rect.subtract rect, rect +    assert.equal rects.length, 0 + +  should "not overlap subtracted rect", -> +    rect = Rect.create 0, 0, 3, 3 +    for x in [-2..2] +      for y in [-2..2] +        for width in [1..3] +          for height in [1..3] +            subtractRect = Rect.create x, y, (x + width), (y + height) +            resultRects = Rect.subtract rect, subtractRect +            for resultRect in resultRects +              assert.isFalse Rect.contains subtractRect, resultRect + +  should "be contained in original rect", -> +    rect = Rect.create 0, 0, 3, 3 +    for x in [-2..2] +      for y in [-2..2] +        for width in [1..3] +          for height in [1..3] +            subtractRect = Rect.create x, y, (x + width), (y + height) +            resultRects = Rect.subtract rect, subtractRect +            for resultRect in resultRects +              assert.isTrue Rect.contains rect, resultRect + +  should "contain the  subtracted rect in the original minus the results", -> +    rect = Rect.create 0, 0, 3, 3 +    for x in [-2..2] +      for y in [-2..2] +        for width in [1..3] +          for height in [1..3] +            subtractRect = Rect.create x, y, (x + width), (y + height) +            resultRects = Rect.subtract rect, subtractRect +            resultComplement = [Rect.copy rect] +            for resultRect in resultRects +              resultComplement = Array::concat.apply [], +                (resultComplement.map (rect) -> Rect.subtract rect, resultRect) +            assert.isTrue (resultComplement.length == 0 or resultComplement.length == 1) +            if resultComplement.length == 1 +              complementRect = resultComplement[0] +              assert.isTrue Rect.contains subtractRect, complementRect  | 
