chromium/chrome/browser/ui/views/frame/immersive_mode_controller_mac.mm

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chrome/browser/ui/views/frame/immersive_mode_controller_mac.h"

#include <AppKit/AppKit.h>

#include <vector>

#include "base/apple/foundation_util.h"
#include "base/check.h"
#include "base/ranges/algorithm.h"
#include "chrome/browser/ui/find_bar/find_bar.h"
#include "chrome/browser/ui/find_bar/find_bar_controller.h"
#include "chrome/browser/ui/fullscreen_util_mac.h"
#include "chrome/browser/ui/lens/lens_overlay_controller.h"
#include "chrome/browser/ui/views/frame/browser_non_client_frame_view_mac.h"
#include "chrome/browser/ui/views/frame/browser_view_layout.h"
#include "chrome/browser/ui/views/frame/tab_strip_region_view.h"
#include "chrome/browser/ui/views/frame/top_container_view.h"
#include "chrome/browser/ui/views/infobars/infobar_container_view.h"
#include "chrome/common/chrome_features.h"
#include "components/constrained_window/constrained_window_views.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/geometry/size.h"
#include "ui/views/border.h"
#include "ui/views/bubble/bubble_dialog_delegate_view.h"
#include "ui/views/cocoa/native_widget_mac_ns_window_host.h"
#include "ui/views/focus/focus_search.h"
#include "ui/views/widget/native_widget.h"

namespace {

// The width of the traffic lights. Used to layout the tab strip leaving a hole
// for the traffic lights.
// TODO(crbug.com/40892148): Get this dynamically. Unfortunately the
// values in BrowserNonClientFrameViewMac::GetCaptionButtonInsets don't account
// for a window with an NSToolbar.
const int kTrafficLightsWidth = 70;

class ImmersiveModeFocusSearchMac : public views::FocusSearch {
 public:
  explicit ImmersiveModeFocusSearchMac(BrowserView* browser_view);
  ImmersiveModeFocusSearchMac(const ImmersiveModeFocusSearchMac&) = delete;
  ImmersiveModeFocusSearchMac& operator=(const ImmersiveModeFocusSearchMac&) =
      delete;
  ~ImmersiveModeFocusSearchMac() override;

  // views::FocusSearch:
  views::View* FindNextFocusableView(
      views::View* starting_view,
      SearchDirection search_direction,
      TraversalDirection traversal_direction,
      StartingViewPolicy check_starting_view,
      AnchoredDialogPolicy can_go_into_anchored_dialog,
      views::FocusTraversable** focus_traversable,
      views::View** focus_traversable_view) override;

 private:
  raw_ptr<BrowserView> browser_view_;
};

}  // namespace

ImmersiveModeControllerMac::RevealedLock::RevealedLock(
    base::WeakPtr<ImmersiveModeControllerMac> controller)
    : controller_(std::move(controller)) {}

ImmersiveModeControllerMac::RevealedLock::~RevealedLock() {
  if (auto* controller = controller_.get())
    controller->LockDestroyed();
}

ImmersiveModeControllerMac::ImmersiveModeControllerMac(bool separate_tab_strip)
    : separate_tab_strip_(separate_tab_strip), weak_ptr_factory_(this) {}

ImmersiveModeControllerMac::~ImmersiveModeControllerMac() {
  CHECK(!views::WidgetObserver::IsInObserverList());
}

void ImmersiveModeControllerMac::Init(BrowserView* browser_view) {
  browser_view_ = browser_view;
  focus_search_ = std::make_unique<ImmersiveModeFocusSearchMac>(browser_view);
}

void ImmersiveModeControllerMac::SetEnabled(bool enabled) {
  if (enabled_ == enabled) {
    return;
  }
  enabled_ = enabled;
  if (enabled) {
    if (separate_tab_strip_) {
      tab_widget_height_ = browser_view_->tab_strip_region_view()->height();
      tab_widget_height_ += static_cast<BrowserNonClientFrameViewMac*>(
                                browser_view_->frame()->GetFrameView())
                                ->GetTopInset(false);

      browser_view_->tab_overlay_widget()->SetSize(gfx::Size(
          browser_view_->top_container()->size().width(), tab_widget_height_));
      browser_view_->tab_overlay_widget()->Show();

      // Move the tab strip to the `tab_overlay_widget`, the host of the
      // `tab_overlay_view`.
      browser_view_->tab_overlay_view()->AddChildView(
          browser_view_->tab_strip_region_view());

      // Inset the start of |tab_strip_region_view()| by |kTrafficLightsWidth|.
      // This will leave a hole for the traffic light to appear.
      // Without this +1 top inset the tabs sit 1px too high. I assume this is
      // because in fullscreen there is no resize handle.
      gfx::Insets insets =
          browser_view_->frame()->GetFrameView()->CaptionButtonsOnLeadingEdge()
              ? gfx::Insets::TLBR(1, kTrafficLightsWidth, 0, 0)
              : gfx::Insets::TLBR(1, 0, 0, kTrafficLightsWidth);
      browser_view_->tab_strip_region_view()->SetBorder(
          views::CreateEmptyBorder(insets));

      views::NativeWidgetMacNSWindowHost* tab_overlay_host =
          views::NativeWidgetMacNSWindowHost::GetFromNativeWindow(
              browser_view_->tab_overlay_widget()->GetNativeWindow());
      SetTabNativeWidgetID(tab_overlay_host->bridged_native_widget_id());
    }
    top_container_observation_.Observe(browser_view_->top_container());
    browser_frame_observation_.Observe(browser_view_->GetWidget());
    overlay_widget_observation_.Observe(browser_view_->overlay_widget());

    // Capture the overlay content view before enablement. Once enabled the view
    // is moved to an AppKit window leaving us otherwise without a reference.
    NSView* content_view = browser_view_->overlay_widget()
                               ->GetNativeWindow()
                               .GetNativeNSWindow()
                               .contentView;
    browser_view_->overlay_widget()->SetNativeWindowProperty(
        views::NativeWidgetMacNSWindowHost::kMovedContentNSView,
        (__bridge void*)content_view);

    views::NativeWidgetMacNSWindowHost::GetFromNativeWindow(
        browser_view_->GetWidget()->GetNativeWindow())
        ->set_immersive_mode_reveal_client(this);

    // Move the appropriate children from the browser widget to the overlay
    // widget, unless we are entering content fullscreen. Make sure to call
    // `Show()` on the overlay widget before enabling immersive fullscreen. The
    // call to `Show()` actually performs the underlying window reparenting.
    if (!fullscreen_utils::IsInContentFullscreen(browser_view_->browser())) {
      MoveChildren(browser_view_->GetWidget(), browser_view_->overlay_widget());
    }

    // `Show()` is needed because the overlay widget's compositor is still being
    // used, even though its content view has been moved to the AppKit
    // controlled fullscreen NSWindow.
    browser_view_->overlay_widget()->Show();

    // Set revealed to be true when entering immersive fullscreen so the toolbar
    // and bookmarks bar heights are accounted for during the fullscreen
    // transition.
    OnImmersiveModeToolbarRevealChanged(true);

    // Move top chrome to the overlay view.
    browser_view_->OnImmersiveRevealStarted();
    browser_view_->InvalidateLayout();

    views::NativeWidgetMacNSWindowHost* overlay_host =
        views::NativeWidgetMacNSWindowHost::GetFromNativeWindow(
            browser_view_->overlay_widget()->GetNativeWindow());
    if (auto* window = GetNSWindowMojo()) {
      window->EnableImmersiveFullscreen(
          overlay_host->bridged_native_widget_id(), tab_native_widget_id_);
    }
    browser_view_->GetWidget()->GetFocusManager()->AddFocusChangeListener(this);
    // Set up a root FocusTraversable that handles focus cycles between overlay
    // widgets and the browser widget.
    browser_view_->GetWidget()->SetFocusTraversableParent(this);
    browser_view_->GetWidget()->SetFocusTraversableParentView(browser_view_);
    browser_view_->overlay_widget()->SetFocusTraversableParent(this);
    browser_view_->overlay_widget()->SetFocusTraversableParentView(
        browser_view_->overlay_view());
    if (browser_view_->tab_overlay_widget()) {
      browser_view_->tab_overlay_widget()->SetFocusTraversableParent(this);
      browser_view_->tab_overlay_widget()->SetFocusTraversableParentView(
          browser_view_->tab_overlay_view());
    }

    // If the window is maximized OnViewBoundsChanged will not be called
    // when transitioning to full screen. Call it now.
    OnViewBoundsChanged(browser_view_->top_container());
  } else {
    if (separate_tab_strip_) {
      browser_view_->tab_overlay_widget()->Hide();
      browser_view_->tab_strip_region_view()->SetBorder(nullptr);
      browser_view_->top_container()->AddChildViewAt(
          browser_view_->tab_strip_region_view(), 0);
    }
    top_container_observation_.Reset();
    browser_frame_observation_.Reset();
    overlay_widget_observation_.Reset();

    // Notify BrowserView about the fullscreen exit so that the top container
    // can be reparented, otherwise it might be destroyed along with the
    // overlay widget.
    for (Observer& observer : observers_)
      observer.OnImmersiveFullscreenExited();

    // Rollback the view shuffling from enablement.
    MoveChildren(browser_view_->overlay_widget(), browser_view_->GetWidget());
    browser_view_->overlay_widget()->Hide();
    if (auto* window = GetNSWindowMojo()) {
      window->DisableImmersiveFullscreen();
    }
    browser_view_->overlay_widget()->SetNativeWindowProperty(
        views::NativeWidgetMacNSWindowHost::kMovedContentNSView, nullptr);

    browser_view_->GetWidget()->GetFocusManager()->RemoveFocusChangeListener(
        this);
    focus_lock_.reset();
    // Remove the root FocusTraversable.
    browser_view_->GetWidget()->SetFocusTraversableParent(nullptr);
    browser_view_->GetWidget()->SetFocusTraversableParentView(nullptr);
    browser_view_->overlay_widget()->SetFocusTraversableParent(nullptr);
    browser_view_->overlay_widget()->SetFocusTraversableParentView(nullptr);
    if (browser_view_->tab_overlay_widget()) {
      browser_view_->tab_overlay_widget()->SetFocusTraversableParent(nullptr);
      browser_view_->tab_overlay_widget()->SetFocusTraversableParentView(
          nullptr);
    }
  }
}

bool ImmersiveModeControllerMac::IsEnabled() const {
  return enabled_;
}

bool ImmersiveModeControllerMac::ShouldHideTopViews() const {
  // Always return false to ensure the top UI is pre-rendered and ready for
  // display. We don't have full control over the visibility of the top UI. For
  // instance, in auto-hide mode, the top UI is revealed when the user hovers
  // over the screen's upper border. Notifications about this visibility change
  // arrive only after the UI is already displayed, so it's crucial to have the
  // top UI fully rendered by then.
  return false;
}

bool ImmersiveModeControllerMac::IsRevealed() const {
  return enabled_ && is_revealed_;
}

int ImmersiveModeControllerMac::GetTopContainerVerticalOffset(
    const gfx::Size& top_container_size) const {
  return 0;
}

std::unique_ptr<ImmersiveRevealedLock>
ImmersiveModeControllerMac::GetRevealedLock(AnimateReveal animate_reveal) {
  if (auto* window = GetNSWindowMojo()) {
    window->ImmersiveFullscreenRevealLock();
  }
  return std::make_unique<RevealedLock>(weak_ptr_factory_.GetWeakPtr());
}

void ImmersiveModeControllerMac::OnFindBarVisibleBoundsChanged(
    const gfx::Rect& new_visible_bounds_in_screen) {
  bool was_visible =
      std::exchange(find_bar_visible_, !new_visible_bounds_in_screen.IsEmpty());
  if (enabled_ && was_visible != find_bar_visible_) {
    // Ensure web content is fully visible if find bar is showing.
    browser_view_->InvalidateLayout();
  }
}

bool ImmersiveModeControllerMac::ShouldStayImmersiveAfterExitingFullscreen() {
  return false;
}

void ImmersiveModeControllerMac::OnWidgetActivationChanged(
    views::Widget* widget,
    bool active) {}

int ImmersiveModeControllerMac::GetMinimumContentOffset() const {
  if (find_bar_visible_ &&
      !fullscreen_utils::IsAlwaysShowToolbarEnabled(browser_view_->browser()) &&
      !fullscreen_utils::IsInContentFullscreen(browser_view_->browser())) {
    return overlay_height_;
  }
  return 0;
}

int ImmersiveModeControllerMac::GetExtraInfobarOffset() const {
  if (fullscreen_utils::IsInContentFullscreen(browser_view_->browser())) {
    return 0;
  }
  if (fullscreen_utils::IsAlwaysShowToolbarEnabled(browser_view_->browser())) {
    return reveal_amount_ * menu_bar_height_;
  }
  return reveal_amount_ * (menu_bar_height_ + overlay_height_);
}

void ImmersiveModeControllerMac::OnContentFullscreenChanged(
    bool is_content_fullscreen) {
  // Ignore this call if we are not in browser fullscreen.
  if (!IsEnabled()) {
    return;
  }

  if (is_content_fullscreen) {
    // When in content fullscreen the overlay widget is not displayed. Move all
    // the child widgets from the overlay widget to the browser widget. This is
    // particularly important for sticky children like the find bar or
    // permission dialogs.
    MoveChildren(browser_view_->overlay_widget(), browser_view_->GetWidget());
  } else {
    // Put the children back when transitioning from content fullscreen back to
    // browser fullscreen.
    MoveChildren(browser_view_->GetWidget(), browser_view_->overlay_widget());
  }
}

void ImmersiveModeControllerMac::OnWillChangeFocus(views::View* focused_before,
                                                   views::View* focused_now) {}

void ImmersiveModeControllerMac::OnDidChangeFocus(views::View* focused_before,
                                                  views::View* focused_now) {
  if (browser_view_->top_container()->Contains(focused_now) ||
      browser_view_->tab_overlay_view()->Contains(focused_now)) {
    if (!focus_lock_)
      focus_lock_ = GetRevealedLock(ANIMATE_REVEAL_NO);
  } else {
    focus_lock_.reset();
  }
}

void ImmersiveModeControllerMac::OnViewBoundsChanged(
    views::View* observed_view) {
  gfx::Rect bounds = observed_view->bounds();
  if (bounds.IsEmpty()) {
    return;
  }
  overlay_height_ = bounds.height();
  if (separate_tab_strip_) {
    gfx::Size new_size(bounds.width(), tab_widget_height_);
    browser_view_->tab_overlay_widget()->SetSize(new_size);
    browser_view_->tab_overlay_view()->SetSize(new_size);
    browser_view_->tab_strip_region_view()->SetSize(gfx::Size(
        new_size.width(), browser_view_->tab_strip_region_view()->height()));
    overlay_height_ += tab_widget_height_;
  }
  browser_view_->overlay_widget()->SetSize(bounds.size());
  if (auto* window = GetNSWindowMojo()) {
    window->OnTopContainerViewBoundsChanged(bounds);
  }
}

void ImmersiveModeControllerMac::OnWidgetDestroying(views::Widget* widget) {
  SetEnabled(false);
}

void ImmersiveModeControllerMac::LockDestroyed() {
  if (auto* window = GetNSWindowMojo()) {
    window->ImmersiveFullscreenRevealUnlock();
  }
}

void ImmersiveModeControllerMac::SetTabNativeWidgetID(uint64_t widget_id) {
  tab_native_widget_id_ = widget_id;
}

void ImmersiveModeControllerMac::MoveChildren(views::Widget* from_widget,
                                              views::Widget* to_widget) {
  CHECK(from_widget && to_widget);

  // If the browser window is closing the native view is removed. Don't attempt
  // to move children.
  if (!from_widget->GetNativeView() || !to_widget->GetNativeView()) {
    return;
  }

  views::Widget::Widgets widgets;
  views::Widget::GetAllChildWidgets(from_widget->GetNativeView(), &widgets);
  for (views::Widget* widget : widgets) {
    if (ShouldMoveChild(widget)) {
      views::Widget::ReparentNativeView(widget->GetNativeView(),
                                        to_widget->GetNativeView());
    }
  }
}

bool ImmersiveModeControllerMac::ShouldMoveChild(views::Widget* child) {
  // Filter out widgets that should not be reparented.
  // The browser, overlay and tab overlay widgets all stay put.
  if (child == browser_view_->GetWidget() ||
      child == browser_view_->overlay_widget() ||
      child == browser_view_->tab_overlay_widget()) {
    return false;
  }

  // The find bar should be reparented if it exists.
  if (browser_view_->browser()->HasFindBarController()) {
    FindBarController* find_bar_controller =
        browser_view_->browser()->GetFindBarController();
    if (child == find_bar_controller->find_bar()->GetHostWidget()) {
      return true;
    }
  }

  const void* widget_identifier =
      child->GetNativeWindowProperty(views::kWidgetIdentifierKey);
  if (widget_identifier ==
          constrained_window::kConstrainedWindowWidgetIdentifier ||
      widget_identifier == kLensOverlayPreselectionWidgetIdentifier) {
    return true;
  }

  // Widgets that have an anchor view contained within top chrome should be
  // reparented.
  views::WidgetDelegate* widget_delegate = child->widget_delegate();
  if (!widget_delegate) {
    return false;
  }
  views::BubbleDialogDelegate* bubble_dialog =
      widget_delegate->AsBubbleDialogDelegate();
  if (!bubble_dialog) {
    return false;
  }
  // Both `top_container` and `tab_strip_region_view` are checked individually
  // because `tab_strip_region_view` is pulled out of `top_container` to be
  // displayed in the titlebar.
  views::View* anchor_view = bubble_dialog->GetAnchorView();
  if (anchor_view &&
      (browser_view_->top_container()->Contains(anchor_view) ||
       browser_view_->tab_strip_region_view()->Contains(anchor_view))) {
    return true;
  }

  // All other widgets will stay put.
  return false;
}

void ImmersiveModeControllerMac::OnImmersiveModeToolbarRevealChanged(
    bool is_revealed) {
  is_revealed_ = is_revealed;
  // TODO(crbug.com/40892148): update tabstrip position so that it occupies full
  // width when the traffic lights are hidden.
}

void ImmersiveModeControllerMac::OnImmersiveModeMenuBarRevealChanged(
    float reveal_amount) {
  reveal_amount_ = reveal_amount;
  if (!browser_view_->infobar_container()->IsEmpty()) {
    browser_view_->InvalidateLayout();
  }
  // TODO(crbug.com/40892148): update tabstrip position so that it occupies full
  // width when the traffic lights are hidden.
}

void ImmersiveModeControllerMac::OnAutohidingMenuBarHeightChanged(
    int menu_bar_height) {
  menu_bar_height_ = menu_bar_height;
  if (!browser_view_->infobar_container()->IsEmpty()) {
    browser_view_->InvalidateLayout();
  }
}

views::FocusSearch* ImmersiveModeControllerMac::GetFocusSearch() {
  return focus_search_.get();
}

views::FocusTraversable*
ImmersiveModeControllerMac::GetFocusTraversableParent() {
  return nullptr;
}

views::View* ImmersiveModeControllerMac::GetFocusTraversableParentView() {
  return nullptr;
}

remote_cocoa::mojom::NativeWidgetNSWindow*
ImmersiveModeControllerMac::GetNSWindowMojo() {
  return views::NativeWidgetMacNSWindowHost::GetFromNativeWindow(
             browser_view_->GetWidget()->GetNativeWindow())
      ->GetNSWindowMojo();
}

ImmersiveModeFocusSearchMac::ImmersiveModeFocusSearchMac(
    BrowserView* browser_view)
    : views::FocusSearch(browser_view, true, true),
      browser_view_(browser_view) {}

ImmersiveModeFocusSearchMac::~ImmersiveModeFocusSearchMac() = default;

views::View* ImmersiveModeFocusSearchMac::FindNextFocusableView(
    views::View* starting_view,
    SearchDirection search_direction,
    TraversalDirection traversal_direction,
    StartingViewPolicy check_starting_view,
    AnchoredDialogPolicy can_go_into_anchored_dialog,
    views::FocusTraversable** focus_traversable,
    views::View** focus_traversable_view) {
  // Search in the `starting_view` traversable tree.
  views::FocusTraversable* starting_focus_traversable =
      starting_view->GetFocusTraversable();
  if (!starting_focus_traversable) {
    starting_focus_traversable =
        starting_view->GetWidget()->GetFocusTraversable();
  }

  views::View* v =
      starting_focus_traversable->GetFocusSearch()->FindNextFocusableView(
          starting_view, search_direction, traversal_direction,
          check_starting_view, can_go_into_anchored_dialog, focus_traversable,
          focus_traversable_view);

  if (v) {
    return v;
  }

  // If no next focusable view in the `starting_view` traversable tree,
  // jumps to the next widget.
  views::FocusManager* focus_manager =
      browser_view_->GetWidget()->GetFocusManager();

  // The focus cycles between overlay widget(s) and the browser widget.
  std::vector<views::Widget*> traverse_order = {browser_view_->overlay_widget(),
                                                browser_view_->GetWidget()};
  if (browser_view_->tab_overlay_widget()) {
    traverse_order.push_back(browser_view_->tab_overlay_widget());
  }

  auto current_widget_it = base::ranges::find_if(
      traverse_order, [starting_view](const views::Widget* widget) {
        return widget->GetRootView()->Contains(starting_view);
      });
  CHECK(current_widget_it != traverse_order.end());
  int current_widget_ind = current_widget_it - traverse_order.begin();

  bool reverse = search_direction == SearchDirection::kBackwards;
  int next_widget_ind =
      (current_widget_ind + (reverse ? -1 : 1) + traverse_order.size()) %
      traverse_order.size();
  return focus_manager->GetNextFocusableView(
      nullptr, traverse_order[next_widget_ind], reverse, true);
}

std::unique_ptr<ImmersiveModeController> CreateImmersiveModeControllerMac(
    const BrowserView* browser_view) {
  return std::make_unique<ImmersiveModeControllerMac>(
      /*separate_tab_strip=*/browser_view->UsesImmersiveFullscreenTabbedMode());
}

ImmersiveModeOverlayWidgetObserver::ImmersiveModeOverlayWidgetObserver(
    ImmersiveModeControllerMac* controller)
    : controller_(controller) {}

ImmersiveModeOverlayWidgetObserver::~ImmersiveModeOverlayWidgetObserver() =
    default;

void ImmersiveModeOverlayWidgetObserver::OnWidgetBoundsChanged(
    views::Widget* widget,
    const gfx::Rect& new_bounds) {
  // Update web dialog position when the overlay widget moves by invalidating
  // the browse view layout.
  controller_->browser_view()->InvalidateLayout();
}