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

// Copyright 2015 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/browser_non_client_frame_view_mac.h"

#include "base/command_line.h"
#include "base/containers/fixed_flat_map.h"
#include "base/functional/bind.h"
#include "base/metrics/histogram_macros.h"
#include "base/numerics/safe_conversions.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/themes/theme_properties.h"
#include "chrome/browser/themes/theme_service.h"
#include "chrome/browser/themes/theme_service_factory.h"
#include "chrome/browser/ui/cocoa/fullscreen/fullscreen_menubar_tracker.h"
#include "chrome/browser/ui/cocoa/fullscreen/fullscreen_toolbar_controller.h"
#include "chrome/browser/ui/exclusive_access/exclusive_access_manager.h"
#include "chrome/browser/ui/exclusive_access/fullscreen_controller.h"
#include "chrome/browser/ui/fullscreen_util_mac.h"
#include "chrome/browser/ui/layout_constants.h"
#include "chrome/browser/ui/tabs/tab_style.h"
#include "chrome/browser/ui/view_ids.h"
#include "chrome/browser/ui/views/bookmarks/bookmark_bar_view.h"
#include "chrome/browser/ui/views/frame/browser_frame.h"
#include "chrome/browser/ui/views/frame/browser_view.h"
#include "chrome/browser/ui/views/frame/browser_view_layout.h"
#include "chrome/browser/ui/views/frame/caption_button_placeholder_container.h"
#include "chrome/browser/ui/views/frame/tab_strip_region_view.h"
#include "chrome/browser/ui/views/tabs/tab_strip.h"
#include "chrome/browser/ui/views/toolbar/toolbar_view.h"
#include "chrome/browser/ui/views/web_apps/frame_toolbar/web_app_frame_toolbar_utils.h"
#include "chrome/browser/ui/web_applications/app_browser_controller.h"
#include "chrome/browser/web_applications/web_app_provider.h"
#include "chrome/common/chrome_switches.h"
#include "chrome/common/pref_names.h"
#include "components/prefs/pref_service.h"
#include "components/remote_cocoa/common/native_widget_ns_window.mojom-shared.h"
#include "components/remote_cocoa/common/native_widget_ns_window.mojom.h"
#include "ui/base/hit_test.h"
#include "ui/base/theme_provider.h"
#include "ui/base/ui_base_features.h"
#include "ui/gfx/canvas.h"
#include "ui/views/cocoa/native_widget_mac_ns_window_host.h"

namespace {

// Keep in sync with web_app_frame_toolbar_browsertest.cc
constexpr double kTitlePaddingWidthFraction = 0.1;

// Empirical measurements of the traffic lights.
constexpr int kCaptionButtonsWidth = 52;
constexpr int kCaptionButtonsLeadingPadding = 20;

FullscreenToolbarStyle GetUserPreferredToolbarStyle(bool always_show) {
  // In Kiosk mode, we don't show top Chrome UI.
  if (base::CommandLine::ForCurrentProcess()->HasSwitch(switches::kKioskMode))
    return FullscreenToolbarStyle::TOOLBAR_NONE;
  return always_show ? FullscreenToolbarStyle::TOOLBAR_PRESENT
                     : FullscreenToolbarStyle::TOOLBAR_HIDDEN;
}

}  // namespace

///////////////////////////////////////////////////////////////////////////////
// BrowserNonClientFrameViewMac, public:

BrowserNonClientFrameViewMac::BrowserNonClientFrameViewMac(
    BrowserFrame* frame,
    BrowserView* browser_view)
    : BrowserNonClientFrameView(frame, browser_view),
      fullscreen_session_timer_(std::make_unique<base::OneShotTimer>()) {
  if (web_app::AppBrowserController::IsWebApp(browser_view->browser())) {
    auto* provider =
        web_app::WebAppProvider::GetForWebApps(browser_view->GetProfile());
    always_show_toolbar_in_fullscreen_observation_.Observe(
        &provider->registrar_unsafe());
  } else {
    show_fullscreen_toolbar_.Init(
        prefs::kShowFullscreenToolbar, browser_view->GetProfile()->GetPrefs(),
        base::BindRepeating(
            &BrowserNonClientFrameViewMac::UpdateFullscreenTopUI,
            base::Unretained(this)));
  }
  if (!browser_view->UsesImmersiveFullscreenMode()) {
    fullscreen_toolbar_controller_ =
        [[FullscreenToolbarController alloc] initWithBrowserView:browser_view];
    [fullscreen_toolbar_controller_
        setToolbarStyle:GetUserPreferredToolbarStyle(
                            fullscreen_utils::IsAlwaysShowToolbarEnabled(
                                browser_view->browser()))];
  }

  if (browser_view->GetIsWebAppType()) {
    if (browser_view->IsWindowControlsOverlayEnabled()) {
      caption_button_placeholder_container_ =
          AddChildView(std::make_unique<CaptionButtonPlaceholderContainer>());
    }
  }
}

BrowserNonClientFrameViewMac::~BrowserNonClientFrameViewMac() {
  if ([fullscreen_toolbar_controller_ isInFullscreen]) {
    [fullscreen_toolbar_controller_ exitFullscreenMode];
  }
  EmitFullscreenSessionHistograms();
}

///////////////////////////////////////////////////////////////////////////////
// BrowserNonClientFrameViewMac, BrowserNonClientFrameView implementation:

void BrowserNonClientFrameViewMac::OnFullscreenStateChanged() {
  // Record the start of a browser fullscreen session. Content fullscreen is
  // ignored.
  if (browser_view()->IsFullscreen() &&
      !fullscreen_utils::IsInContentFullscreen(browser_view()->browser())) {
    fullscreen_session_start_ = base::TimeTicks::Now();

    // Add a backstop to emit the metric 24 hours from now. Any session lasting
    // more than 24 hours would be counted in the overflow bucket, so emit at 24
    // hours to get the count emitted faster.
    fullscreen_session_timer_->Start(
        FROM_HERE, base::Days(1),
        base::BindOnce(
            &BrowserNonClientFrameViewMac::EmitFullscreenSessionHistograms,
            base::Unretained(this)));
  } else {
    fullscreen_session_timer_->Stop();
    EmitFullscreenSessionHistograms();
  }

  if (browser_view()->UsesImmersiveFullscreenMode()) {
    browser_view()->immersive_mode_controller()->SetEnabled(
        browser_view()->IsFullscreen());
    UpdateFullscreenTopUI();

    // browser_view()->DeprecatedLayoutImmediately() is not needed since top
    // chrome is in another widget.
    return;
  }

  if (browser_view()->IsFullscreen()) {
    [fullscreen_toolbar_controller_ enterFullscreenMode];
  } else {
    // Exiting tab fullscreen requires updating Top UI.
    // Called from here so we can capture exiting tab fullscreen both by
    // pressing 'ESC' key and by clicking green traffic light button.

    UpdateFullscreenTopUI();
    [fullscreen_toolbar_controller_ exitFullscreenMode];
  }
  browser_view()->DeprecatedLayoutImmediately();
}

bool BrowserNonClientFrameViewMac::CaptionButtonsOnLeadingEdge() const {
  // In "partial" RTL mode (where the OS is in LTR mode while Chrome is in RTL
  // mode, or vice versa), the traffic lights are on the trailing edge rather
  // than the leading edge.
  return base::i18n::IsRTL() == (NSApp.userInterfaceLayoutDirection ==
                                 NSUserInterfaceLayoutDirectionRightToLeft);
}

gfx::Rect BrowserNonClientFrameViewMac::GetBoundsForTabStripRegion(
    const gfx::Size& tabstrip_minimum_size) const {
  // TODO(weili): In the future, we should hide the title bar, and show the
  // tab strip directly under the menu bar. For now, just lay our content
  // under the native title bar. Use the default title bar height to avoid
  // calling through private APIs.
  const bool restored = !frame()->IsMaximized() && !frame()->IsFullscreen();
  gfx::Rect bounds(0, GetTopInset(restored), width(),
                   tabstrip_minimum_size.height());

  // If we do not inset, the leftmost tab doesn't blend well with the bottom of
  // the tab strip. Normally, we would naturally have an inset from either the
  // caption buttons or the tab search button.
  if (frame()->IsFullscreen()) {
    if (!browser_view()->UsesImmersiveFullscreenMode()) {
      bounds.Inset(
          gfx::Insets::TLBR(0, GetLayoutConstant(TOOLBAR_CORNER_RADIUS), 0, 0));
    }
  } else {
    bounds.Inset(GetCaptionButtonInsets());
  }

  return bounds;
}

gfx::Rect BrowserNonClientFrameViewMac::GetBoundsForWebAppFrameToolbar(
    const gfx::Size& toolbar_preferred_size) const {
  if (ShouldHideTopUIForFullscreen()) {
    return gfx::Rect();
  }
  gfx::Rect bounds(0, 0, width(),
                   toolbar_preferred_size.height() + kWebAppMenuMargin * 2);

  // Do not draw caption buttons on fullscreen.
  if (!frame()->IsFullscreen()) {
    bounds.Inset(GetCaptionButtonInsets());
  }

  return bounds;
}

void BrowserNonClientFrameViewMac::LayoutWebAppWindowTitle(
    const gfx::Rect& available_space,
    views::Label& window_title_label) const {
  gfx::Rect toolbar_bounds(0, 0, width(), available_space.height());
  gfx::Rect title_bounds = available_space;
  const int title_padding =
      base::ClampRound(width() * kTitlePaddingWidthFraction);
  title_bounds.Inset(gfx::Insets::VH(0, title_padding));
  window_title_label.SetBoundsRect(GetCenteredTitleBounds(
      toolbar_bounds, title_bounds,
      window_title_label
          .GetPreferredSize(views::SizeBounds(window_title_label.width(), {}))
          .width()));
  // The background of the title area is always opaquely drawn, but when in
  // immersive fullscreen, it is drawn in a way that isn't detected by the
  // DCHECK in Label. As such, disable the DCHECK.
  window_title_label.SetSkipSubpixelRenderingOpacityCheck(
      browser_view()->IsImmersiveModeEnabled());
}

int BrowserNonClientFrameViewMac::GetTopInset(bool restored) const {
  return 0;
}

void BrowserNonClientFrameViewMac::UpdateFullscreenTopUI() {
  Browser* browser = browser_view()->browser();
  // Update to the new toolbar style if needed.
  FullscreenToolbarStyle new_style;
  if (fullscreen_utils::IsInContentFullscreen(browser)) {
    browser_view()->HideDownloadShelf();
    new_style = FullscreenToolbarStyle::TOOLBAR_NONE;
  } else {
    bool always_show = fullscreen_utils::IsAlwaysShowToolbarEnabled(browser);
    new_style = GetUserPreferredToolbarStyle(always_show);
    browser_view()->UnhideDownloadShelf();
  }

  if (browser_view()->UsesImmersiveFullscreenMode()) {
    remote_cocoa::mojom::NativeWidgetNSWindow* ns_window_mojo =
        views::NativeWidgetMacNSWindowHost::GetFromNativeWindow(
            browser_view()->GetWidget()->GetNativeWindow())
            ->GetNSWindowMojo();
    static constexpr auto kStyleMap =
        base::MakeFixedFlatMap<FullscreenToolbarStyle,
                               remote_cocoa::mojom::ToolbarVisibilityStyle>(
            {{FullscreenToolbarStyle::TOOLBAR_PRESENT,
              remote_cocoa::mojom::ToolbarVisibilityStyle::kAlways},
             {FullscreenToolbarStyle::TOOLBAR_HIDDEN,
              remote_cocoa::mojom::ToolbarVisibilityStyle::kAutohide},
             {FullscreenToolbarStyle::TOOLBAR_NONE,
              remote_cocoa::mojom::ToolbarVisibilityStyle::kNone}});
    const auto it = kStyleMap.find(new_style);
    remote_cocoa::mojom::ToolbarVisibilityStyle mapped_style =
        it != kStyleMap.end()
            ? it->second
            : remote_cocoa::mojom::ToolbarVisibilityStyle::kAutohide;
    std::optional<remote_cocoa::mojom::ToolbarVisibilityStyle> old_style =
        std::exchange(current_toolbar_style_, mapped_style);
    ns_window_mojo->UpdateToolbarVisibility(mapped_style);

    // Update the immersive controller about content fullscreen changes.
    if (mapped_style == remote_cocoa::mojom::ToolbarVisibilityStyle::kNone) {
      browser_view()->immersive_mode_controller()->OnContentFullscreenChanged(
          true);
    } else if (old_style.has_value() &&
               old_style ==
                   remote_cocoa::mojom::ToolbarVisibilityStyle::kNone) {
      browser_view()->immersive_mode_controller()->OnContentFullscreenChanged(
          false);
    }

    // The layout changes further down are not needed in immersive fullscreen.
    return;
  }

  FullscreenToolbarStyle old_style =
      [fullscreen_toolbar_controller_ toolbarStyle];
  [fullscreen_toolbar_controller_ setToolbarStyle:new_style];
  if (![fullscreen_toolbar_controller_ isInFullscreen] ||
      old_style == new_style) {
    return;
  }

  // Notify browser that top ui state has been changed so that we can update
  // the bookmark bar state as well.
  browser->FullscreenTopUIStateChanged();

  // Re-layout if toolbar style changes in fullscreen mode.
  if (frame()->IsFullscreen()) {
    browser_view()->DeprecatedLayoutImmediately();
  }
}

void BrowserNonClientFrameViewMac::OnAlwaysShowToolbarInFullscreenChanged(
    const webapps::AppId& app_id,
    bool show) {
  if (web_app::AppBrowserController::IsForWebApp(browser_view()->browser(),
                                                 app_id)) {
    UpdateFullscreenTopUI();
  }
}

void BrowserNonClientFrameViewMac::OnAppRegistrarDestroyed() {
  always_show_toolbar_in_fullscreen_observation_.Reset();
}

bool BrowserNonClientFrameViewMac::ShouldHideTopUIForFullscreen() const {
  if (frame()->IsFullscreen()) {
    return [fullscreen_toolbar_controller_ toolbarStyle] !=
           FullscreenToolbarStyle::TOOLBAR_PRESENT;
  }
  return false;
}

void BrowserNonClientFrameViewMac::UpdateThrobber(bool running) {
}

void BrowserNonClientFrameViewMac::PaintAsActiveChanged() {
  UpdateCaptionButtonPlaceholderContainerBackground();
  BrowserNonClientFrameView::PaintAsActiveChanged();
}

void BrowserNonClientFrameViewMac::OnThemeChanged() {
  UpdateCaptionButtonPlaceholderContainerBackground();
  BrowserNonClientFrameView::OnThemeChanged();
}
///////////////////////////////////////////////////////////////////////////////
// BrowserNonClientFrameViewMac, views::NonClientFrameView implementation:

gfx::Rect BrowserNonClientFrameViewMac::GetBoundsForClientView() const {
  return bounds();
}

gfx::Rect BrowserNonClientFrameViewMac::GetWindowBoundsForClientBounds(
    const gfx::Rect& client_bounds) const {
  int top_inset = GetTopInset(false);

  // If the operating system is handling drawing the window titlebar then the
  // titlebar height will not be included in |GetTopInset|, so we have to
  // explicitly add it. If a custom titlebar is being drawn, this calculation
  // will be zero.
  NSWindow* window = GetWidget()->GetNativeWindow().GetNativeNSWindow();
  DCHECK(window);
  top_inset += window.frame.size.height -
               [window contentRectForFrameRect:window.frame].size.height;

  return gfx::Rect(client_bounds.x(), client_bounds.y() - top_inset,
                   client_bounds.width(), client_bounds.height() + top_inset);
}

int BrowserNonClientFrameViewMac::NonClientHitTest(const gfx::Point& point) {
  int super_component = BrowserNonClientFrameView::NonClientHitTest(point);
  if (super_component != HTNOWHERE)
    return super_component;

  // BrowserView::NonClientHitTest will return HTNOWHERE for points that hit
  // the native title bar. On Mac, we need to explicitly return HTCAPTION for
  // those points.
  const int component = frame()->client_view()->NonClientHitTest(point);
  return (component == HTNOWHERE && bounds().Contains(point)) ? HTCAPTION
                                                              : component;
}

void BrowserNonClientFrameViewMac::UpdateMinimumSize() {
  GetWidget()->OnSizeConstraintsChanged();
}

void BrowserNonClientFrameViewMac::WindowControlsOverlayEnabledChanged() {
  if (browser_view()->IsWindowControlsOverlayEnabled()) {
    caption_button_placeholder_container_ =
        AddChildView(std::make_unique<CaptionButtonPlaceholderContainer>());
    UpdateCaptionButtonPlaceholderContainerBackground();
  } else {
    RemoveChildView(caption_button_placeholder_container_);
    caption_button_placeholder_container_ = nullptr;
  }
}
///////////////////////////////////////////////////////////////////////////////
// BrowserNonClientFrameViewMac, views::View implementation:

gfx::Size BrowserNonClientFrameViewMac::GetMinimumSize() const {
  gfx::Size client_size = frame()->client_view()->GetMinimumSize();
  if (browser_view()->browser()->is_type_normal())
    client_size.SetToMax(
        browser_view()->tab_strip_region_view()->GetMinimumSize());

  // macOS apps generally don't allow their windows to get shorter than a
  // certain height, which empirically seems to be related to their *minimum*
  // width rather than their current width. This 4:3 ratio was chosen
  // empirically because it looks decent for both tabbed and untabbed browsers.
  client_size.SetToMax(gfx::Size(0, (client_size.width() * 3) / 4));

  return client_size;
}

void BrowserNonClientFrameViewMac::PaintChildren(const views::PaintInfo& info) {
  // In immersive fullscreen, the browser view's top container relies on the
  // non-client frame view to paint the frame (see comment in
  // TopContainerView::PaintChildren). We want the frame view to paint *only*
  // the frame but not its child (i.e. the BrowserView).
  // TODO(kerenzhu): we need this workaround due to the design of NonClientView,
  // that the frame part is not an independent child view. If it is an
  // independent view, overriding PaintChildren() will not be necessary.
  //
  // Tabbed immersive fullscreen paints its own background. In this case we
  // allow painting of the frame's children, which fixes a flickering bug:
  // 1400287.
  if (browser_view()->UsesImmersiveFullscreenTabbedMode() ||
      !browser_view()->immersive_mode_controller()->IsRevealed()) {
    BrowserNonClientFrameView::PaintChildren(info);
  }
}

gfx::Insets BrowserNonClientFrameViewMac::GetCaptionButtonInsets() const {
  const int kCaptionButtonInset = kCaptionButtonsWidth +
                                  (kCaptionButtonsLeadingPadding * 2) -
                                  TabStyle::Get()->GetBottomCornerRadius();
  if (CaptionButtonsOnLeadingEdge()) {
    return gfx::Insets::TLBR(0, kCaptionButtonInset, 0, 0);
  } else {
    return gfx::Insets::TLBR(0, 0, 0, kCaptionButtonInset);
  }
}

///////////////////////////////////////////////////////////////////////////////
// BrowserNonClientFrameViewMac, protected:

// views::View:

void BrowserNonClientFrameViewMac::OnPaint(gfx::Canvas* canvas) {
  if (!browser_view()->GetIsNormalType() &&
      !browser_view()->GetIsWebAppType()) {
    return;
  }

  SkColor frame_color = GetFrameColor(BrowserFrameActiveState::kUseCurrent);
  canvas->DrawColor(frame_color);

  auto* theme_service =
      ThemeServiceFactory::GetForProfile(browser_view()->browser()->profile());
  if (!theme_service->UsingSystemTheme())
    PaintThemedFrame(canvas);
}

void BrowserNonClientFrameViewMac::Layout(PassKey) {
  if (browser_view()->IsWindowControlsOverlayEnabled())
    LayoutWindowControlsOverlay();
  LayoutSuperclass<NonClientFrameView>(this);
}

///////////////////////////////////////////////////////////////////////////////
// BrowserNonClientFrameViewMac, private:

gfx::Rect BrowserNonClientFrameViewMac::GetCenteredTitleBounds(
    gfx::Rect frame,
    gfx::Rect available_space,
    int preferred_title_width) {
  // Center in container.
  frame.ClampToCenteredSize(gfx::Size(preferred_title_width, frame.height()));

  // Make it fit in available space.
  frame.AdjustToFit(available_space);

  return frame;
}

void BrowserNonClientFrameViewMac::PaintThemedFrame(gfx::Canvas* canvas) {
  // On macOS the origin of the BrowserNonClientFrameViewMac is (0,0) so no
  // further modification is necessary. See
  // TopContainerBackground::PaintThemeCustomImage for details.
  gfx::Point theme_image_offset =
      browser_view()->GetThemeOffsetFromBrowserView();

  gfx::ImageSkia image = GetFrameImage();
  canvas->TileImageInt(image, theme_image_offset.x(), theme_image_offset.y(), 0,
                       TopUIFullscreenYOffset(), width(), image.height(),
                       /*tile_scale=*/1.0f, SkTileMode::kRepeat,
                       SkTileMode::kMirror);
  gfx::ImageSkia overlay = GetFrameOverlayImage();
  canvas->DrawImageInt(overlay, 0, 0);
}

int BrowserNonClientFrameViewMac::TopUIFullscreenYOffset() const {
  if (!browser_view()->GetTabStripVisible() ||
      !browser_view()->IsFullscreen() ||
      browser_view()->UsesImmersiveFullscreenMode()) {
    return 0;
  }

  CGFloat menu_bar_height =
      [[[NSApplication sharedApplication] mainMenu] menuBarHeight];
  // If there's a camera notch, the window is already below where the menu bar
  // will be, so we shouldn't account for it.
  if (@available(macos 12.0.1, *)) {
    id screen = [GetWidget()->GetNativeWindow().GetNativeNSWindow() screen];
    NSEdgeInsets insets = [screen safeAreaInsets];
    if (insets.top != 0)
      menu_bar_height = 0;
  }
  CGFloat title_bar_height =
      NSHeight([NSWindow frameRectForContentRect:NSZeroRect
                                       styleMask:NSWindowStyleMaskTitled]);
  if (browser_view()->UsesImmersiveFullscreenMode())
    return menu_bar_height == 0 ? 0 : menu_bar_height + title_bar_height;
  return [[fullscreen_toolbar_controller_ menubarTracker] menubarFraction] *
         (menu_bar_height + title_bar_height);
}

gfx::Rect BrowserNonClientFrameViewMac::GetCaptionButtonPlaceholderBounds(
    const gfx::Rect& frame,
    const gfx::Insets& caption_button_insets) {
  DCHECK(caption_button_insets.left() == 0 ||
         caption_button_insets.right() == 0);
  gfx::Rect non_caption_bounds = frame;
  non_caption_bounds.Inset(caption_button_insets);
  gfx::Rect bounds = frame;
  bounds.Subtract(non_caption_bounds);
  return bounds;
}

void BrowserNonClientFrameViewMac::LayoutWindowControlsOverlay() {
  int frame_available_height =
      browser_view()->GetWebAppFrameToolbarPreferredSize().height() +
      2 * kWebAppMenuMargin;
  gfx::Rect frame_available_bounds(0, 0, width(), frame_available_height);

  // Pad the width of caption_button_placeholder_container so the button on the
  // inner edge doesn't look like it's touching the overlay, but rather has a
  // little bit of space between them.
  gfx::Insets caption_button_insets = GetCaptionButtonInsets();
  gfx::Rect caption_button_container_bounds = GetCaptionButtonPlaceholderBounds(
      frame_available_bounds, caption_button_insets);

  // Layout CaptionButtonPlaceholderContainer which would have the traffic
  // lights.
  caption_button_placeholder_container_->SetBoundsRect(
      caption_button_container_bounds);
}

void BrowserNonClientFrameViewMac::
    UpdateCaptionButtonPlaceholderContainerBackground() {
  if (caption_button_placeholder_container_) {
    caption_button_placeholder_container_->SetBackground(
        views::CreateSolidBackground(
            GetFrameColor(BrowserFrameActiveState::kUseCurrent)));
  }
}

void BrowserNonClientFrameViewMac::EmitFullscreenSessionHistograms() {
  if (!fullscreen_session_start_.has_value()) {
    return;
  }
  base::TimeDelta delta =
      base::TimeTicks::Now() - fullscreen_session_start_.value();
  fullscreen_session_start_.reset();

  // Max duration of 1 day.
  UMA_HISTOGRAM_CUSTOM_TIMES("Session.BrowserFullscreen.DurationUpTo24H", delta,
                             base::Milliseconds(1), base::Days(1), 100);
}