/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

#include "ScrollbarActivity.h"
#include "nsIScrollbarMediator.h"
#include "nsIContent.h"
#include "nsICSSDeclaration.h"
#include "nsIDOMEvent.h"
#include "nsIDOMCSSStyleDeclaration.h"
#include "nsIFrame.h"
#include "nsContentUtils.h"
#include "nsAString.h"
#include "nsQueryFrame.h"
#include "nsComponentManagerUtils.h"
#include "nsStyledElement.h"
#include "mozilla/LookAndFeel.h"
#include "mozilla/Preferences.h"

namespace mozilla {
namespace layout {

NS_IMPL_ISUPPORTS(ScrollbarActivity, nsIDOMEventListener)

static bool
GetForceAlwaysVisiblePref()
{
  static bool sForceAlwaysVisible;
  static bool sForceAlwaysVisiblePrefCached = false;
  if (!sForceAlwaysVisiblePrefCached) {
    Preferences::AddBoolVarCache(&sForceAlwaysVisible,
                                 "layout.testing.overlay-scrollbars.always-visible");
    sForceAlwaysVisiblePrefCached = true;
  }
  return sForceAlwaysVisible;
}

void
ScrollbarActivity::QueryLookAndFeelVals()
{
  // Fade animation constants
  mScrollbarFadeBeginDelay =
    LookAndFeel::GetInt(LookAndFeel::eIntID_ScrollbarFadeBeginDelay);
  mScrollbarFadeDuration =
    LookAndFeel::GetInt(LookAndFeel::eIntID_ScrollbarFadeDuration);
  // Controls whether we keep the mouse move listener so we can display the
  // scrollbars whenever the user moves the mouse within the scroll area.
  mDisplayOnMouseMove =
    LookAndFeel::GetInt(LookAndFeel::eIntID_ScrollbarDisplayOnMouseMove);
}

void
ScrollbarActivity::Destroy()
{
  StopListeningForScrollbarEvents();
  StopListeningForScrollAreaEvents();
  UnregisterFromRefreshDriver();
  CancelFadeBeginTimer();
}

void
ScrollbarActivity::ActivityOccurred()
{
  ActivityStarted();
  ActivityStopped();
}

void
ScrollbarActivity::ActivityStarted()
{
  mNestedActivityCounter++;
  CancelFadeBeginTimer();
  if (!SetIsFading(false)) {
    return;
  }
  UnregisterFromRefreshDriver();
  StartListeningForScrollbarEvents();
  StartListeningForScrollAreaEvents();
  SetIsActive(true);

  NS_ASSERTION(mIsActive, "need to be active during activity");
  NS_ASSERTION(!mIsFading, "must not be fading during activity");
}

void
ScrollbarActivity::ActivityStopped()
{
  if (!IsActivityOngoing()) {
    // This can happen if there was a frame reconstruction while the activity
    // was ongoing. In this case we just do nothing. We should probably handle
    // this case better.
    return;
  }
  NS_ASSERTION(mIsActive, "need to be active during activity");
  NS_ASSERTION(!mIsFading, "must not be fading during ongoing activity");

  mNestedActivityCounter--;

  if (!IsActivityOngoing()) {
    StartFadeBeginTimer();

    NS_ASSERTION(mIsActive, "need to be active right after activity");
    NS_ASSERTION(!mIsFading, "must not be fading right after activity");
  }
}

NS_IMETHODIMP
ScrollbarActivity::HandleEvent(nsIDOMEvent* aEvent)
{
  if (!mDisplayOnMouseMove && !mIsActive)
    return NS_OK;

  nsAutoString type;
  aEvent->GetType(type);

  if (type.EqualsLiteral("mousemove")) {
    // Mouse motions anywhere in the scrollable frame should keep the
    // scrollbars visible.
    ActivityOccurred();
    return NS_OK;
  }

  nsCOMPtr<nsIDOMEventTarget> target;
  aEvent->GetOriginalTarget(getter_AddRefs(target));
  nsCOMPtr<nsIContent> targetContent = do_QueryInterface(target);

  HandleEventForScrollbar(type, targetContent, GetHorizontalScrollbar(),
                          &mHScrollbarHovered);
  HandleEventForScrollbar(type, targetContent, GetVerticalScrollbar(),
                          &mVScrollbarHovered);

  return NS_OK;
}

void
ScrollbarActivity::WillRefresh(TimeStamp aTime)
{
  NS_ASSERTION(mIsActive, "should only fade while scrollbars are visible");
  NS_ASSERTION(!IsActivityOngoing(), "why weren't we unregistered from the refresh driver when scrollbar activity started?");
  NS_ASSERTION(mIsFading, "should only animate fading during fade");

  if (!UpdateOpacity(aTime)) {
    return;
  }

  if (!IsStillFading(aTime)) {
    EndFade();
  }
}

bool
ScrollbarActivity::IsStillFading(TimeStamp aTime)
{
  return !mFadeBeginTime.IsNull() && (aTime - mFadeBeginTime < FadeDuration());
}

void
ScrollbarActivity::HandleEventForScrollbar(const nsAString& aType,
                                           nsIContent* aTarget,
                                           nsIContent* aScrollbar,
                                           bool* aStoredHoverState)
{
  if (!aTarget || !aScrollbar ||
      !nsContentUtils::ContentIsDescendantOf(aTarget, aScrollbar))
    return;

  if (aType.EqualsLiteral("mousedown")) {
    ActivityStarted();
  } else if (aType.EqualsLiteral("mouseup")) {
    ActivityStopped();
  } else if (aType.EqualsLiteral("mouseover") ||
             aType.EqualsLiteral("mouseout")) {
    bool newHoveredState = aType.EqualsLiteral("mouseover");
    if (newHoveredState && !*aStoredHoverState) {
      ActivityStarted();
      HoveredScrollbar(aScrollbar);
    } else if (*aStoredHoverState && !newHoveredState) {
      ActivityStopped();
      // Don't call HoveredScrollbar(nullptr) here because we want the hover
      // attribute to stick until the scrollbars are hidden.
    }
    *aStoredHoverState = newHoveredState;
  }
}

void
ScrollbarActivity::StartListeningForScrollbarEvents()
{
  if (mListeningForScrollbarEvents)
    return;

  mHorizontalScrollbar = do_QueryInterface(GetHorizontalScrollbar());
  mVerticalScrollbar = do_QueryInterface(GetVerticalScrollbar());

  AddScrollbarEventListeners(mHorizontalScrollbar);
  AddScrollbarEventListeners(mVerticalScrollbar);

  mListeningForScrollbarEvents = true;
}

void
ScrollbarActivity::StopListeningForScrollbarEvents()
{
  if (!mListeningForScrollbarEvents)
    return;

  RemoveScrollbarEventListeners(mHorizontalScrollbar);
  RemoveScrollbarEventListeners(mVerticalScrollbar);

  mHorizontalScrollbar = nullptr;
  mVerticalScrollbar = nullptr;
  mListeningForScrollbarEvents = false;
}

void
ScrollbarActivity::StartListeningForScrollAreaEvents()
{
  if (mListeningForScrollAreaEvents)
    return;

  nsIFrame* scrollArea = do_QueryFrame(mScrollableFrame);
  nsCOMPtr<nsIDOMEventTarget> scrollAreaTarget
    = do_QueryInterface(scrollArea->GetContent());
  if (scrollAreaTarget) {
    scrollAreaTarget->AddEventListener(NS_LITERAL_STRING("mousemove"), this,
                                       true);
  }
  mListeningForScrollAreaEvents = true;
}

void
ScrollbarActivity::StopListeningForScrollAreaEvents()
{
  if (!mListeningForScrollAreaEvents)
    return;

  nsIFrame* scrollArea = do_QueryFrame(mScrollableFrame);
  nsCOMPtr<nsIDOMEventTarget> scrollAreaTarget = do_QueryInterface(scrollArea->GetContent());
  if (scrollAreaTarget) {
    scrollAreaTarget->RemoveEventListener(NS_LITERAL_STRING("mousemove"), this, true);
  }
  mListeningForScrollAreaEvents = false;
}

void
ScrollbarActivity::AddScrollbarEventListeners(nsIDOMEventTarget* aScrollbar)
{
  if (aScrollbar) {
    aScrollbar->AddEventListener(NS_LITERAL_STRING("mousedown"), this, true);
    aScrollbar->AddEventListener(NS_LITERAL_STRING("mouseup"), this, true);
    aScrollbar->AddEventListener(NS_LITERAL_STRING("mouseover"), this, true);
    aScrollbar->AddEventListener(NS_LITERAL_STRING("mouseout"), this, true);
  }
}

void
ScrollbarActivity::RemoveScrollbarEventListeners(nsIDOMEventTarget* aScrollbar)
{
  if (aScrollbar) {
    aScrollbar->RemoveEventListener(NS_LITERAL_STRING("mousedown"), this, true);
    aScrollbar->RemoveEventListener(NS_LITERAL_STRING("mouseup"), this, true);
    aScrollbar->RemoveEventListener(NS_LITERAL_STRING("mouseover"), this, true);
    aScrollbar->RemoveEventListener(NS_LITERAL_STRING("mouseout"), this, true);
  }
}

void
ScrollbarActivity::BeginFade()
{
  NS_ASSERTION(mIsActive, "can't begin fade when we're already inactive");
  NS_ASSERTION(!IsActivityOngoing(), "why wasn't the fade begin timer cancelled when scrollbar activity started?");
  NS_ASSERTION(!mIsFading, "shouldn't be fading just yet");

  CancelFadeBeginTimer();
  mFadeBeginTime = TimeStamp::Now();
  if (!SetIsFading(true)) {
    return;
  }
  RegisterWithRefreshDriver();

  NS_ASSERTION(mIsActive, "only fade while scrollbars are visible");
  NS_ASSERTION(mIsFading, "should be fading now");
}

void
ScrollbarActivity::EndFade()
{
  NS_ASSERTION(mIsActive, "still need to be active at this point");
  NS_ASSERTION(!IsActivityOngoing(), "why wasn't the fade end timer cancelled when scrollbar activity started?");

  if (!SetIsFading(false)) {
    return;
  }
  SetIsActive(false);
  UnregisterFromRefreshDriver();
  StopListeningForScrollbarEvents();
  if (!mDisplayOnMouseMove) {
    StopListeningForScrollAreaEvents();
  }

  NS_ASSERTION(!mIsActive, "should have gone inactive after fade end");
  NS_ASSERTION(!mIsFading, "shouldn't be fading anymore");
}

void
ScrollbarActivity::RegisterWithRefreshDriver()
{
  nsRefreshDriver* refreshDriver = GetRefreshDriver();
  if (refreshDriver) {
    refreshDriver->AddRefreshObserver(this, Flush_Style);
  }
}

void
ScrollbarActivity::UnregisterFromRefreshDriver()
{
  nsRefreshDriver* refreshDriver = GetRefreshDriver();
  if (refreshDriver) {
    refreshDriver->RemoveRefreshObserver(this, Flush_Style);
  }
}

static void
SetBooleanAttribute(nsIContent* aContent, nsIAtom* aAttribute, bool aValue)
{
  if (aContent) {
    if (aValue) {
      aContent->SetAttr(kNameSpaceID_None, aAttribute,
                        NS_LITERAL_STRING("true"), true);
    } else {
      aContent->UnsetAttr(kNameSpaceID_None, aAttribute, true);
    }
  }
}

void
ScrollbarActivity::SetIsActive(bool aNewActive)
{
  if (mIsActive == aNewActive)
    return;

  mIsActive = aNewActive;
  if (!mIsActive) {
    // Clear sticky scrollbar hover status.
    HoveredScrollbar(nullptr);
  }

  SetBooleanAttribute(GetHorizontalScrollbar(), nsGkAtoms::active, mIsActive);
  SetBooleanAttribute(GetVerticalScrollbar(), nsGkAtoms::active, mIsActive);
}

static void
SetOpacityOnElement(nsIContent* aContent, double aOpacity)
{
  nsCOMPtr<nsStyledElement> inlineStyleContent =
    do_QueryInterface(aContent);
  if (inlineStyleContent) {
    nsICSSDeclaration* decl = inlineStyleContent->Style();
    nsAutoString str;
    str.AppendFloat(aOpacity);
    decl->SetProperty(NS_LITERAL_STRING("opacity"), str, EmptyString());
  }
}

bool
ScrollbarActivity::UpdateOpacity(TimeStamp aTime)
{
  // Avoid division by zero if mScrollbarFadeDuration is zero, just jump
  // to the end of the fade animation
  double progress = mScrollbarFadeDuration
    ? ((aTime - mFadeBeginTime) / FadeDuration())
    : 1.0;
  double opacity = 1.0 - std::max(0.0, std::min(1.0, progress));

  // 'this' may be getting destroyed during SetOpacityOnElement calls.
  nsWeakFrame weakFrame((do_QueryFrame(mScrollableFrame)));
  SetOpacityOnElement(GetHorizontalScrollbar(), opacity);
  if (!weakFrame.IsAlive()) {
    return false;
  }
  SetOpacityOnElement(GetVerticalScrollbar(), opacity);
  if (!weakFrame.IsAlive()) {
    return false;
  }
  return true;
}

static void
UnsetOpacityOnElement(nsIContent* aContent)
{
  nsCOMPtr<nsStyledElement> inlineStyleContent =
    do_QueryInterface(aContent);
  if (inlineStyleContent) {
    nsICSSDeclaration* decl = inlineStyleContent->Style();
    nsAutoString dummy;
    decl->RemoveProperty(NS_LITERAL_STRING("opacity"), dummy);
  }
}

bool
ScrollbarActivity::SetIsFading(bool aNewFading)
{
  if (mIsFading == aNewFading)
    return true;

  mIsFading = aNewFading;
  if (!mIsFading) {
    mFadeBeginTime = TimeStamp();
    // 'this' may be getting destroyed during UnsetOpacityOnElement calls.
    nsWeakFrame weakFrame((do_QueryFrame(mScrollableFrame)));
    UnsetOpacityOnElement(GetHorizontalScrollbar());
    if (!weakFrame.IsAlive()) {
      return false;
    }
    UnsetOpacityOnElement(GetVerticalScrollbar());
    if (!weakFrame.IsAlive()) {
      return false;
    }
  }
  return true;
}

void
ScrollbarActivity::StartFadeBeginTimer()
{
  if (GetForceAlwaysVisiblePref()) {
    return;
  }
  if (!mFadeBeginTimer) {
    mFadeBeginTimer = do_CreateInstance("@mozilla.org/timer;1");
  }
  mFadeBeginTimer->InitWithNamedFuncCallback(
    FadeBeginTimerFired, this, mScrollbarFadeBeginDelay,
    nsITimer::TYPE_ONE_SHOT, "ScrollbarActivity::FadeBeginTimerFired");
}

void
ScrollbarActivity::CancelFadeBeginTimer()
{
  if (mFadeBeginTimer) {
    mFadeBeginTimer->Cancel();
  }
}

void
ScrollbarActivity::HoveredScrollbar(nsIContent* aScrollbar)
{
  SetBooleanAttribute(GetHorizontalScrollbar(), nsGkAtoms::hover, false);
  SetBooleanAttribute(GetVerticalScrollbar(), nsGkAtoms::hover, false);
  SetBooleanAttribute(aScrollbar, nsGkAtoms::hover, true);
}

nsRefreshDriver*
ScrollbarActivity::GetRefreshDriver()
{
  nsIFrame* scrollableFrame = do_QueryFrame(mScrollableFrame);
  return scrollableFrame->PresContext()->RefreshDriver();
}

nsIContent*
ScrollbarActivity::GetScrollbarContent(bool aVertical)
{
  nsIFrame* box = mScrollableFrame->GetScrollbarBox(aVertical);
  return box ? box->GetContent() : nullptr;
}

} // namespace layout
} // namespace mozilla
