chromium/ui/native_theme/native_theme_mac.mm

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

#include "ui/native_theme/native_theme_mac.h"

#import <Cocoa/Cocoa.h>
#include <MediaAccessibility/MediaAccessibility.h>
#include <stddef.h>

#include <vector>

#include "base/command_line.h"
#include "base/mac/mac_util.h"
#include "base/no_destructor.h"
#include "cc/paint/paint_shader.h"
#include "ui/base/cocoa/defaults_utils.h"
#include "ui/base/ui_base_features.h"
#include "ui/base/ui_base_switches.h"
#include "ui/color/color_provider.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/color_palette.h"
#include "ui/gfx/color_utils.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/geometry/skia_conversions.h"
#include "ui/native_theme/native_theme.h"
#include "ui/native_theme/native_theme_aura.h"
#include "ui/native_theme/native_theme_features.h"

namespace {

bool IsDarkMode() {
  NSAppearanceName appearance =
      [NSApp.effectiveAppearance bestMatchFromAppearancesWithNames:@[
        NSAppearanceNameAqua, NSAppearanceNameDarkAqua
      ]];
  return [appearance isEqual:NSAppearanceNameDarkAqua];
}

bool PrefersReducedTransparency() {
  return NSWorkspace.sharedWorkspace
      .accessibilityDisplayShouldReduceTransparency;
}

bool IsHighContrast() {
  return NSWorkspace.sharedWorkspace.accessibilityDisplayShouldIncreaseContrast;
}

bool InvertedColors() {
  return NSWorkspace.sharedWorkspace.accessibilityDisplayShouldInvertColors;
}

}  // namespace

// Helper object to respond to light mode/dark mode changeovers.
@interface NativeThemeEffectiveAppearanceObserver : NSObject
@end

@implementation NativeThemeEffectiveAppearanceObserver {
  void (^_handler)() __strong;
}

- (instancetype)initWithHandler:(void (^)())handler {
  self = [super init];
  if (self) {
    _handler = handler;
    [NSApp addObserver:self
            forKeyPath:@"effectiveAppearance"
               options:0
               context:nullptr];
  }
  return self;
}

- (void)dealloc {
  [NSApp removeObserver:self forKeyPath:@"effectiveAppearance"];
}

- (void)observeValueForKeyPath:(NSString*)forKeyPath
                      ofObject:(id)object
                        change:(NSDictionary*)change
                       context:(void*)context {
  _handler();
}

@end

namespace {

// Helper to make indexing an array by an enum class easier.
template <class KEY, class VALUE>
struct EnumArray {
  VALUE& operator[](const KEY& key) { return array[static_cast<size_t>(key)]; }
  VALUE array[static_cast<size_t>(KEY::COUNT)];
};

}  // namespace

namespace ui {

// static
NativeTheme* NativeTheme::GetInstanceForWeb() {
  return NativeThemeMacWeb::instance();
}

// static
NativeTheme* NativeTheme::GetInstanceForNativeUi() {
  return NativeThemeMac::instance();
}

NativeTheme* NativeTheme::GetInstanceForDarkUI() {
  static base::NoDestructor<NativeThemeMac> s_native_theme(
      /*configure_web_instance=*/false, /*should_only_use_dark_colors=*/true);
  return s_native_theme.get();
}

// static
bool NativeTheme::SystemDarkModeSupported() {
  return true;
}

// static
NativeThemeMac* NativeThemeMac::instance() {
  static base::NoDestructor<NativeThemeMac> s_native_theme(
      /*configure_web_instance=*/true, /*should_only_use_dark_colors=*/false);
  return s_native_theme.get();
}

NativeThemeAura::PreferredContrast NativeThemeMac::CalculatePreferredContrast()
    const {
  return IsHighContrast() ? NativeThemeAura::PreferredContrast::kMore
                          : NativeThemeAura::PreferredContrast::kNoPreference;
}

void NativeThemeMac::Paint(cc::PaintCanvas* canvas,
                           const ColorProvider* color_provider,
                           Part part,
                           State state,
                           const gfx::Rect& rect,
                           const ExtraParams& extra,
                           ColorScheme color_scheme,
                           bool in_forced_colors,
                           const std::optional<SkColor>& accent_color) const {
  ColorScheme color_scheme_updated = color_scheme;
  if (color_scheme_updated == ColorScheme::kDefault)
    color_scheme_updated = GetDefaultSystemColorScheme();

  if (rect.IsEmpty())
    return;

  switch (part) {
    case kScrollbarHorizontalThumb:
    case kScrollbarVerticalThumb:
      PaintMacScrollbarThumb(canvas, part, state, rect,
                             absl::get<ScrollbarExtraParams>(extra),
                             color_scheme_updated);
      break;
    case kScrollbarHorizontalTrack:
    case kScrollbarVerticalTrack:
      PaintMacScrollBarTrackOrCorner(canvas, part, state,
                                     absl::get<ScrollbarExtraParams>(extra),
                                     rect, color_scheme_updated, false);
      break;
    case kScrollbarCorner:
      PaintMacScrollBarTrackOrCorner(canvas, part, state,
                                     absl::get<ScrollbarExtraParams>(extra),
                                     rect, color_scheme_updated, true);
      break;
    default:
      NativeThemeBase::Paint(canvas, color_provider, part, state, rect, extra,
                             color_scheme, in_forced_colors, accent_color);
      break;
  }
}

void ConstrainInsets(int old_width, int min_width, int* left, int* right) {
  int requested_total_inset = *left + *right;
  if (requested_total_inset == 0)
    return;
  int max_total_inset = old_width - min_width;
  if (requested_total_inset < max_total_inset)
    return;
  if (max_total_inset < 0) {
    *left = *right = 0;
    return;
  }
  // Multiply the right/bottom inset by the ratio by which we need to shrink the
  // total inset. This has the effect of rounding down the right/bottom inset,
  // if the two sides are to be affected unevenly.
  // This is done instead of using inset scale functions to maintain expected
  // behavior and to map to how it looks like other scrollbars work on MacOS.
  *right *= max_total_inset * 1.0f / requested_total_inset;
  *left = max_total_inset - *right;
}

void ConstrainedInset(gfx::Rect* rect,
                      gfx::Size min_size,
                      gfx::Insets initial_insets) {
  int inset_left = initial_insets.left();
  int inset_right = initial_insets.right();
  int inset_top = initial_insets.top();
  int inset_bottom = initial_insets.bottom();

  ConstrainInsets(rect->width(), min_size.width(), &inset_left, &inset_right);
  ConstrainInsets(rect->height(), min_size.height(), &inset_top, &inset_bottom);
  rect->Inset(
      gfx::Insets::TLBR(inset_top, inset_left, inset_bottom, inset_right));
}

void NativeThemeMac::PaintMacScrollBarTrackOrCorner(
    cc::PaintCanvas* canvas,
    Part part,
    State state,
    const ScrollbarExtraParams& extra_params,
    const gfx::Rect& rect,
    ColorScheme color_scheme,
    bool is_corner) const {
  if (is_corner && extra_params.is_overlay)
    return;
  PaintScrollBarTrackGradient(canvas, rect, extra_params, is_corner,
                              color_scheme);
  PaintScrollbarTrackInnerBorder(canvas, rect, extra_params, is_corner,
                                 color_scheme);
  PaintScrollbarTrackOuterBorder(canvas, rect, extra_params, is_corner,
                                 color_scheme);
}

void NativeThemeMac::PaintScrollBarTrackGradient(
    cc::PaintCanvas* canvas,
    const gfx::Rect& rect,
    const ScrollbarExtraParams& extra_params,
    bool is_corner,
    ColorScheme color_scheme) const {
  gfx::Canvas paint_canvas(canvas, 1.0f);
  // Select colors.
  std::vector<SkColor4f> gradient_colors;
  bool dark_mode = color_scheme == ColorScheme::kDark;
  if (extra_params.is_overlay) {
    if (dark_mode) {
      gradient_colors = {SkColor4f{0.847f, 0.847f, 0.847f, 0.157f},
                         SkColor4f{0.8f, 0.8f, 0.8f, 0.149f},
                         SkColor4f{0.8f, 0.8f, 0.8f, 0.149f},
                         SkColor4f{0.8f, 0.8f, 0.8f, 0.149f}};
    } else {
      gradient_colors = {SkColor4f{0.973f, 0.973f, 0.973f, 0.776f},
                         SkColor4f{0.973f, 0.973f, 0.973f, 0.761f},
                         SkColor4f{0.973f, 0.973f, 0.973f, 0.761f},
                         SkColor4f{0.973f, 0.973f, 0.973f, 0.761f}};
    }
  } else {
    // Non-overlay scroller track colors are not transparent. On Safari, they
    // are, but on all other macOS applications they are not.
    if (dark_mode) {
      gradient_colors = {SkColor4f{0.176f, 0.176f, 0.176f, 1.0f},
                         SkColor4f{0.169f, 0.169f, 0.169f, 1.0f}};
    } else {
      gradient_colors = {SkColor4f{0.98f, 0.98f, 0.98f, 1.0f},
                         SkColor4f{0.98f, 0.98f, 0.98f, 1.0f}};
    }
  }

  // Set the gradient direction.
  std::vector<SkPoint> gradient_bounds;
  if (is_corner) {
    if (extra_params.orientation == ScrollbarOrientation::kVerticalOnRight) {
      gradient_bounds = {gfx::PointToSkPoint(rect.origin()),
                         gfx::PointToSkPoint(rect.bottom_right())};
    } else {
      gradient_bounds = {gfx::PointToSkPoint(rect.top_right()),
                         gfx::PointToSkPoint(rect.bottom_left())};
    }
  } else {
    if (extra_params.orientation == ScrollbarOrientation::kHorizontal) {
      gradient_bounds = {gfx::PointToSkPoint(rect.origin()),
                         gfx::PointToSkPoint(rect.top_right())};
    } else {
      gradient_bounds = {gfx::PointToSkPoint(rect.origin()),
                         gfx::PointToSkPoint(rect.bottom_left())};
    }
  }

  // And draw.
  cc::PaintFlags flags;
  std::optional<SkColor> track_color =
      GetScrollbarColor(ScrollbarPart::kTrack, color_scheme, extra_params);
  if (track_color.has_value()) {
    flags.setAntiAlias(true);
    flags.setStyle(cc::PaintFlags::kFill_Style);
    flags.setColor(track_color.value());
  } else {
    flags.setShader(cc::PaintShader::MakeLinearGradient(
        gradient_bounds.data(), gradient_colors.data(), nullptr,
        gradient_colors.size(), SkTileMode::kClamp));
  }
  paint_canvas.DrawRect(rect, flags);
}

void NativeThemeMac::PaintScrollbarTrackInnerBorder(
    cc::PaintCanvas* canvas,
    const gfx::Rect& rect,
    const ScrollbarExtraParams& extra_params,
    bool is_corner,
    ColorScheme color_scheme) const {
  gfx::Canvas paint_canvas(canvas, 1.0f);

  // Compute the rect for the border.
  gfx::Rect inner_border(rect);
  if (extra_params.orientation == ScrollbarOrientation::kVerticalOnLeft)
    inner_border.set_x(rect.right() -
                       ScrollbarTrackBorderWidth(extra_params.scale_from_dip));
  if (is_corner ||
      extra_params.orientation == ScrollbarOrientation::kHorizontal)
    inner_border.set_height(
        ScrollbarTrackBorderWidth(extra_params.scale_from_dip));
  if (is_corner ||
      extra_params.orientation != ScrollbarOrientation::kHorizontal)
    inner_border.set_width(
        ScrollbarTrackBorderWidth(extra_params.scale_from_dip));

  // And draw.
  cc::PaintFlags flags;
  SkColor inner_border_color =
      GetScrollbarColor(ScrollbarPart::kTrackInnerBorder, color_scheme,
                        extra_params)
          .value();
  flags.setColor(inner_border_color);
  paint_canvas.DrawRect(inner_border, flags);
}

void NativeThemeMac::PaintScrollbarTrackOuterBorder(
    cc::PaintCanvas* canvas,
    const gfx::Rect& rect,
    const ScrollbarExtraParams& extra_params,
    bool is_corner,
    ColorScheme color_scheme) const {
  gfx::Canvas paint_canvas(canvas, 1.0f);
  cc::PaintFlags flags;
  SkColor outer_border_color =
      GetScrollbarColor(ScrollbarPart::kTrackOuterBorder, color_scheme,
                        extra_params)
          .value();
  flags.setColor(outer_border_color);

  // Draw the horizontal outer border.
  if (is_corner ||
      extra_params.orientation == ScrollbarOrientation::kHorizontal) {
    gfx::Rect outer_border(rect);
    outer_border.set_height(
        ScrollbarTrackBorderWidth(extra_params.scale_from_dip));
    outer_border.set_y(rect.bottom() -
                       ScrollbarTrackBorderWidth(extra_params.scale_from_dip));
    paint_canvas.DrawRect(outer_border, flags);
  }

  // Draw the vertical outer border.
  if (is_corner ||
      extra_params.orientation != ScrollbarOrientation::kHorizontal) {
    gfx::Rect outer_border(rect);
    outer_border.set_width(
        ScrollbarTrackBorderWidth(extra_params.scale_from_dip));
    if (extra_params.orientation == ScrollbarOrientation::kVerticalOnRight)
      outer_border.set_x(rect.right() - ScrollbarTrackBorderWidth(
                                            extra_params.scale_from_dip));
    paint_canvas.DrawRect(outer_border, flags);
  }
}

gfx::Size NativeThemeMac::GetThumbMinSize(bool vertical, float scale) const {
  const int kLength = 18 * scale;
  const int kGirth = 6 * scale;

  return vertical ? gfx::Size(kGirth, kLength) : gfx::Size(kLength, kGirth);
}

void NativeThemeMac::PaintMacScrollbarThumb(
    cc::PaintCanvas* canvas,
    Part part,
    State state,
    const gfx::Rect& rect,
    const ScrollbarExtraParams& scroll_thumb,
    ColorScheme color_scheme) const {
  gfx::Canvas paint_canvas(canvas, 1.0f);

  // Compute the bounds for the rounded rect for the thumb from the bounds of
  // the thumb.
  gfx::Rect bounds(rect);
  {
    // Shrink the thumb evenly in length and girth to fit within the track.
    gfx::Insets thumb_insets(GetScrollbarThumbInset(
        scroll_thumb.is_overlay, scroll_thumb.scale_from_dip));

    // Also shrink the thumb in girth to not touch the border.
    if (scroll_thumb.orientation == ScrollbarOrientation::kHorizontal) {
      thumb_insets.set_top(
          thumb_insets.top() +
          ScrollbarTrackBorderWidth(scroll_thumb.scale_from_dip));
      ConstrainedInset(&bounds,
                       GetThumbMinSize(false, scroll_thumb.scale_from_dip),
                       thumb_insets);
    } else {
      thumb_insets.set_left(
          thumb_insets.left() +
          ScrollbarTrackBorderWidth(scroll_thumb.scale_from_dip));
      ConstrainedInset(&bounds,
                       GetThumbMinSize(true, scroll_thumb.scale_from_dip),
                       thumb_insets);
    }
  }

  // Draw.
  cc::PaintFlags flags;
  flags.setAntiAlias(true);
  flags.setStyle(cc::PaintFlags::kFill_Style);
  SkColor thumb_color =
      GetScrollbarColor(ScrollbarPart::kThumb, color_scheme, scroll_thumb)
          .value();
  flags.setColor(thumb_color);
  const SkScalar radius = std::min(bounds.width(), bounds.height());
  paint_canvas.DrawRoundRect(bounds, radius, flags);
}

std::optional<SkColor> NativeThemeMac::GetScrollbarColor(
    ScrollbarPart part,
    ColorScheme color_scheme,
    const ScrollbarExtraParams& extra_params) const {
  // This function is called from the renderer process through the scrollbar
  // drawing functions. Due to this, it cannot use any of the dynamic NS system
  // colors.
  bool dark_mode = color_scheme == ColorScheme::kDark;
  if (part == ScrollbarPart::kThumb) {
    if (extra_params.thumb_color.has_value()) {
      return extra_params.thumb_color.value();
    }
    if (extra_params.is_overlay)
      return dark_mode ? SkColorSetARGB(0x80, 0xFF, 0xFF, 0xFF)
                       : SkColorSetARGB(0x80, 0, 0, 0);

    if (dark_mode)
      return extra_params.is_hovering ? SkColorSetRGB(0x93, 0x93, 0x93)
                                      : SkColorSetRGB(0x6B, 0x6B, 0x6B);

    return extra_params.is_hovering ? SkColorSetARGB(0x80, 0, 0, 0)
                                    : SkColorSetARGB(0x3A, 0, 0, 0);
  } else if (part == ScrollbarPart::kTrackInnerBorder) {
    if (extra_params.track_color.has_value()) {
      return extra_params.track_color.value();
    }

    if (extra_params.is_overlay)
      return dark_mode ? SkColorSetARGB(0x33, 0xE5, 0xE5, 0xE5)
                       : SkColorSetARGB(0xF9, 0xDF, 0xDF, 0xDF);

    return dark_mode ? SkColorSetRGB(0x3D, 0x3D, 0x3D)
                     : SkColorSetRGB(0xE8, 0xE8, 0xE8);
  } else if (part == ScrollbarPart::kTrackOuterBorder) {
    if (extra_params.track_color.has_value()) {
      return extra_params.track_color.value();
    }
    if (extra_params.is_overlay)
      return dark_mode ? SkColorSetARGB(0x28, 0xD8, 0xD8, 0xD8)
                       : SkColorSetARGB(0xC6, 0xE8, 0xE8, 0xE8);

    return dark_mode ? SkColorSetRGB(0x51, 0x51, 0x51)
                     : SkColorSetRGB(0xED, 0xED, 0xED);
  } else if (part == ScrollbarPart::kTrack) {
    if (extra_params.track_color.has_value()) {
      return extra_params.track_color.value();
    }
  }

  return std::nullopt;
}

SkColor NativeThemeMac::GetSystemButtonPressedColor(SkColor base_color) const {
  // TODO crbug.com/1003612: This should probably be replaced with a color
  // transform.
  // Mac has a different "pressed button" styling because it doesn't use
  // ripples.
  return color_utils::GetResultingPaintColor(SkColorSetA(SK_ColorBLACK, 0x10),
                                             base_color);
}

void NativeThemeMac::PaintMenuPopupBackground(
    cc::PaintCanvas* canvas,
    const ColorProvider* color_provider,
    const gfx::Size& size,
    const MenuBackgroundExtraParams& menu_background,
    ColorScheme color_scheme) const {
  DCHECK(color_provider);
  cc::PaintFlags flags;
  flags.setAntiAlias(true);
  flags.setColor(color_provider->GetColor(kColorMenuBackground));
  const SkScalar radius = SkIntToScalar(menu_background.corner_radius);
  SkRect rect = gfx::RectToSkRect(gfx::Rect(size));
  canvas->drawRoundRect(rect, radius, radius, flags);
}

void NativeThemeMac::PaintMenuItemBackground(
    cc::PaintCanvas* canvas,
    const ColorProvider* color_provider,
    State state,
    const gfx::Rect& rect,
    const MenuItemExtraParams& menu_item,
    ColorScheme color_scheme) const {
  switch (state) {
    case NativeTheme::kNormal:
    case NativeTheme::kDisabled:
      // Draw nothing over the regular background.
      break;
    case NativeTheme::kHovered:
      PaintSelectedMenuItem(canvas, color_provider, rect, menu_item);
      break;
    default:
      NOTREACHED_IN_MIGRATION();
      break;
  }
}

// static
static void CaptionSettingsChangedNotificationCallback(CFNotificationCenterRef,
                                                       void*,
                                                       CFStringRef,
                                                       const void*,
                                                       CFDictionaryRef) {
  NativeTheme::GetInstanceForWeb()->NotifyOnCaptionStyleUpdated();
}

NativeThemeMac::NativeThemeMac(bool configure_web_instance,
                               bool should_only_use_dark_colors)
    : NativeThemeBase(should_only_use_dark_colors) {
  if (!should_only_use_dark_colors)
    InitializeDarkModeStateAndObserver();

  set_prefers_reduced_transparency(PrefersReducedTransparency());
  set_inverted_colors(InvertedColors());
  if (!IsForcedHighContrast()) {
    SetPreferredContrast(CalculatePreferredContrast());
  }
  __block auto theme = this;
  display_accessibility_notification_token_ =
      [NSWorkspace.sharedWorkspace.notificationCenter
          addObserverForName:
              NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification
                      object:nil
                       queue:nil
                  usingBlock:^(NSNotification* notification) {
                    if (!IsForcedHighContrast()) {
                      theme->SetPreferredContrast(CalculatePreferredContrast());
                    }
                    theme->set_prefers_reduced_transparency(
                        PrefersReducedTransparency());
                    theme->set_inverted_colors(InvertedColors());
                    theme->NotifyOnNativeThemeUpdated();
                  }];

  if (configure_web_instance)
    ConfigureWebInstance();
}

NativeThemeMac::~NativeThemeMac() {
  [NSNotificationCenter.defaultCenter
      removeObserver:display_accessibility_notification_token_];
}

std::optional<base::TimeDelta> NativeThemeMac::GetPlatformCaretBlinkInterval()
    const {
  // If there's insertion point flash rate info in NSUserDefaults, use the
  // blink period derived from that.
  return ui::TextInsertionCaretBlinkPeriodFromDefaults();
}

void NativeThemeMac::PaintSelectedMenuItem(
    cc::PaintCanvas* canvas,
    const ColorProvider* color_provider,
    const gfx::Rect& rect,
    const MenuItemExtraParams& extra_params) const {
  DCHECK(color_provider);
  // Draw the background.
  cc::PaintFlags flags;
  flags.setAntiAlias(true);
  flags.setColor(color_provider->GetColor(kColorMenuItemBackgroundSelected));
  const SkScalar radius = SkIntToScalar(extra_params.corner_radius);
  canvas->drawRoundRect(gfx::RectToSkRect(rect), radius, radius, flags);
}

void NativeThemeMac::InitializeDarkModeStateAndObserver() {
  __block auto theme = this;
  set_use_dark_colors(IsDarkMode());
  set_preferred_color_scheme(CalculatePreferredColorScheme());
  appearance_observer_ =
      [[NativeThemeEffectiveAppearanceObserver alloc] initWithHandler:^{
        theme->set_use_dark_colors(IsDarkMode());
        theme->set_preferred_color_scheme(CalculatePreferredColorScheme());
        theme->NotifyOnNativeThemeUpdated();
      }];
}

void NativeThemeMac::ConfigureWebInstance() {
  // NativeThemeAura is used as web instance so we need to initialize its state.
  NativeTheme* web_instance = NativeTheme::GetInstanceForWeb();
  web_instance->set_use_dark_colors(IsDarkMode());
  web_instance->set_preferred_color_scheme(CalculatePreferredColorScheme());
  web_instance->SetPreferredContrast(CalculatePreferredContrast());
  web_instance->set_prefers_reduced_transparency(PrefersReducedTransparency());
  web_instance->set_inverted_colors(InvertedColors());

  // Add the web native theme as an observer to stay in sync with color scheme
  // changes.
  color_scheme_observer_ =
      std::make_unique<NativeTheme::ColorSchemeNativeThemeObserver>(
          NativeTheme::GetInstanceForWeb());
  AddObserver(color_scheme_observer_.get());

  // Observe caption style changes.
  CFNotificationCenterAddObserver(
      CFNotificationCenterGetLocalCenter(), this,
      CaptionSettingsChangedNotificationCallback,
      kMACaptionAppearanceSettingsChangedNotification, nullptr,
      CFNotificationSuspensionBehaviorDeliverImmediately);
}

NativeThemeMacWeb::NativeThemeMacWeb()
    : NativeThemeAura(/*use_overlay_scrollbars=*/IsOverlayScrollbarEnabled(),
                      /*should_only_use_dark_colors=*/false) {}

// static
NativeThemeMacWeb* NativeThemeMacWeb::instance() {
  static base::NoDestructor<NativeThemeMacWeb> s_native_theme;
  return s_native_theme.get();
}

}  // namespace ui