chromium/ash/app_list/views/folder_header_view.cc

// 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 "ash/app_list/views/folder_header_view.h"

#include <algorithm>
#include <memory>
#include <utility>

#include "ash/app_list/app_list_util.h"
#include "ash/app_list/model/app_list_folder_item.h"
#include "ash/app_list/views/app_list_folder_view.h"
#include "ash/public/cpp/app_list/app_list_config.h"
#include "ash/public/cpp/app_list/app_list_features.h"
#include "ash/public/cpp/style/color_provider.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/ash_color_id.h"
#include "ash/style/system_textfield.h"
#include "ash/style/system_textfield_controller.h"
#include "base/memory/raw_ptr.h"
#include "base/metrics/histogram_macros.h"
#include "base/strings/utf_string_conversions.h"
#include "chromeos/constants/chromeos_features.h"
#include "ui/base/cursor/cursor.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/resource/resource_bundle.h"
#include "ui/compositor/layer.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/text_elider.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/border.h"
#include "ui/views/controls/button/image_button.h"
#include "ui/views/controls/highlight_path_generator.h"
#include "ui/views/controls/textfield/textfield.h"
#include "ui/views/focus/focus_manager.h"
#include "ui/views/painter.h"
#include "ui/views/view.h"
#include "ui/views/view_targeter_delegate.h"
#include "ui/views/view_utils.h"
#include "ui/views/widget/widget.h"

namespace ash {

namespace {

// The max folder name length.
constexpr int kMaxFolderNameChars = 28;

// Folder header dimensions. The max header width is based on the width of a
// folder with 2 items.
constexpr int kMinFolderHeaderWidth = 24;
constexpr int kMaxFolderHeaderWidth = 168;
constexpr int kFolderHeaderHeight = 32;

// The min width of folder name - ensures the folder name is easily tappable.
constexpr int kFolderHeaderMinTapWidth = 32;

// The border radius for folder name.
constexpr int kFolderNameBorderRadius = 4;

// The border thickness for folder name.
constexpr int kFolderNameBorderThickness = 2;

// The inner padding for folder name.
constexpr int kFolderNamePadding = 8;

SkColor GetFolderBackgroundColor(bool is_active) {
  if (!is_active) {
    return SK_ColorTRANSPARENT;
  }

  const std::pair<SkColor, float> base_color_and_opacity =
      ash::ColorProvider::Get()->GetInkDropBaseColorAndOpacity();

  return SkColorSetA(base_color_and_opacity.first,
                     base_color_and_opacity.second * 255);
}

}  // namespace

class FolderHeaderView::FolderNameView : public views::Textfield,
                                         public views::ViewTargeterDelegate {
  METADATA_HEADER(FolderNameView, views::Textfield)

 public:
  explicit FolderNameView(FolderHeaderView* folder_header_view)
      : folder_header_view_(folder_header_view) {
    DCHECK(folder_header_view_);
    // Make folder name font size 14px.
    SetFontList(
        ui::ResourceBundle::GetSharedInstance().GetFontListWithDelta(2));

    SetEventTargeter(std::make_unique<views::ViewTargeter>(this));
    SetBorder(views::CreateEmptyBorder(gfx::Insets::VH(0, kFolderNamePadding)));
    views::FocusRing::Install(this);
    views::FocusRing::Get(this)->SetOutsetFocusRingDisabled(true);
    views::FocusRing::Get(this)->SetColorId(ui::kColorAshFocusRing);
    views::InstallRoundRectHighlightPathGenerator(this, gfx::Insets(),
                                                  kFolderNameBorderRadius);
  }

  FolderNameView(const FolderNameView&) = delete;
  FolderNameView& operator=(const FolderNameView&) = delete;

  ~FolderNameView() override = default;

  gfx::Size CalculatePreferredSize(
      const views::SizeBounds& available_size) const override {
    return gfx::Size(kMaxFolderHeaderWidth, kFolderHeaderHeight);
  }

  void OnThemeChanged() override {
    Textfield::OnThemeChanged();

    const bool is_active = has_mouse_already_entered_ || HasFocus();
    SetBackground(views::CreateRoundedRectBackground(
        GetFolderBackgroundColor(is_active), kFolderNameBorderRadius,
        kFolderNameBorderThickness));

    const ui::ColorProvider* const color_provider = GetColorProvider();
    set_placeholder_text_color(
        color_provider->GetColor(kColorAshTextColorSecondary));
    const SkColor text_color =
        color_provider->GetColor(kColorAshTextColorPrimary);
    SetTextColor(text_color);
    SetSelectionTextColor(text_color);
    SetSelectionBackgroundColor(
        color_provider->GetColor(kColorAshFocusAuraColor));
    UpdateBackgroundColor(is_active);
  }

  ui::Cursor GetCursor(const ui::MouseEvent& event) override {
    return ui::mojom::CursorType::kIBeam;
  }

  void OnFocus() override {
    UpdateBackgroundColor(/*is_active=*/true);
    SetText(folder_header_view_->GetFolderName());
    starting_name_ = GetText();
    folder_header_view_->previous_folder_name_ = starting_name_;

    if (!defer_select_all_) {
      SelectAll(false);
    }

    Textfield::OnFocus();
  }

  void OnBlur() override {
    UpdateBackgroundColor(/*is_active=*/false);

    folder_header_view_->ContentsChanged(this, GetText());

    // Ensure folder name is truncated when FolderNameView loses focus.
    SetText(folder_header_view_->GetElidedFolderName());

    // Record metric each time a folder is renamed.
    if (GetText() != starting_name_) {
      if (folder_header_view_->is_tablet_mode()) {
        UMA_HISTOGRAM_COUNTS_100("Apps.AppListFolderNameLength.TabletMode",
                                 GetText().length());
      } else {
        UMA_HISTOGRAM_COUNTS_100("Apps.AppListFolderNameLength.ClamshellMode",
                                 GetText().length());
      }
    }

    defer_select_all_ = false;

    Textfield::OnBlur();
  }

  bool DoesMouseEventActuallyIntersect(const ui::MouseEvent& event) {
    // Since hitbox for this view is extended for tap, we need to manually
    // calculate this when checking for mouse events.
    return GetLocalBounds().Contains(event.location());
  }

  bool OnMousePressed(const ui::MouseEvent& event) override {
    // Since hovering changes the background color, only taps should be
    // triggered using the extended event target.
    if (!DoesMouseEventActuallyIntersect(event)) {
      return false;
    }

    if (!HasFocus()) {
      defer_select_all_ = true;
    }

    return Textfield::OnMousePressed(event);
  }

  void OnMouseExited(const ui::MouseEvent& event) override {
    if (!HasFocus()) {
      UpdateBackgroundColor(/*is_active=*/false);
    }

    has_mouse_already_entered_ = false;
  }

  void OnMouseMoved(const ui::MouseEvent& event) override {
    if (DoesMouseEventActuallyIntersect(event) && !has_mouse_already_entered_) {
      // If this is reached, the mouse is entering the view.
      // Recreate border to have custom corner radius.
      UpdateBackgroundColor(/*is_active=*/true);
      has_mouse_already_entered_ = true;
    } else if (!DoesMouseEventActuallyIntersect(event) &&
               has_mouse_already_entered_ && !HasFocus()) {
      // If this is reached, the mouse is exiting the view on its horizontal
      // edges.
      UpdateBackgroundColor(/*is_active=*/false);
      has_mouse_already_entered_ = false;
    }
  }

  void OnMouseReleased(const ui::MouseEvent& event) override {
    if (defer_select_all_) {
      defer_select_all_ = false;

      if (!HasSelection()) {
        SelectAll(false);
      }
    }

    Textfield::OnMouseReleased(event);
  }

  bool DoesIntersectRect(const views::View* target,
                         const gfx::Rect& rect) const override {
    DCHECK_EQ(target, this);
    gfx::Rect textfield_bounds = target->GetLocalBounds();

    // Ensure that the tap target for this view is always at least the view's
    // minimum width.
    int min_width =
        std::max(kFolderHeaderMinTapWidth, textfield_bounds.width());
    int horizontal_padding = -((min_width - textfield_bounds.width()) / 2);
    textfield_bounds.Inset(gfx::Insets::VH(0, horizontal_padding));

    return textfield_bounds.Intersects(rect);
  }

 private:
  void UpdateBackgroundColor(bool is_active) {
    background()->SetNativeControlColor(GetFolderBackgroundColor(is_active));
    SchedulePaint();
  }

  // The parent FolderHeaderView, owns this.
  const raw_ptr<FolderHeaderView> folder_header_view_;

  // Name of the folder when FolderNameView is focused, used to track folder
  // rename metric.
  std::u16string starting_name_;

  // If the view is focused via a mouse press event, then selection will be
  // cleared by its mouse release. To address this, defer selecting all
  // until we receive mouse release.
  bool defer_select_all_ = false;

  // Because of this view's custom event target, this view receives mouse enter
  // events in areas where the view isn't actually occupying. To check whether a
  // user has entered/exited this, we must check every mouse move event. This
  // bool tracks whether the mouse has entered the view, avoiding repainting the
  // background on each mouse move event.
  bool has_mouse_already_entered_ = false;
};

BEGIN_METADATA(FolderHeaderView, FolderNameView)
END_METADATA

class FolderHeaderView::FolderNameJellyView
    : public ash::SystemTextfield,
      public views::ViewTargeterDelegate {
  METADATA_HEADER(FolderNameJellyView, ash::SystemTextfield)

 public:
  explicit FolderNameJellyView(bool tablet_mode)
      : ash::SystemTextfield(ash::SystemTextfield::Type::kMedium),
        tablet_mode_(tablet_mode) {
    SetEventTargeter(std::make_unique<views::ViewTargeter>(this));
  }

  FolderNameJellyView(const FolderNameJellyView&) = delete;
  FolderNameJellyView& operator=(const FolderNameJellyView&) = delete;

  ~FolderNameJellyView() override = default;

  gfx::Size CalculatePreferredSize(
      const views::SizeBounds& available_size) const override {
    return gfx::Size(kMaxFolderHeaderWidth, kFolderHeaderHeight);
  }

  ui::Cursor GetCursor(const ui::MouseEvent& event) override {
    return ui::mojom::CursorType::kIBeam;
  }

  void OnFocus() override {
    starting_name_ = GetText();
    SystemTextfield::OnFocus();
  }

  void OnBlur() override {
    // Record metric each time a folder is renamed.
    if (GetText() != starting_name_) {
      if (tablet_mode_) {
        UMA_HISTOGRAM_COUNTS_100("Apps.AppListFolderNameLength.TabletMode",
                                 GetText().length());
      } else {
        UMA_HISTOGRAM_COUNTS_100("Apps.AppListFolderNameLength.ClamshellMode",
                                 GetText().length());
      }
    }

    SystemTextfield::OnBlur();

    // OnBlur updates background ONLY if the ActiveState is changed. Since the
    // SystemTextField component does not clear focus after changing the
    // ActiveState, there are some instances where removing focus will not
    // trigger a background update.
    // TODO(b/323054951): Clean this code once the SystemTextfield has
    // implemented clearing focus.
    UpdateBackground();
  }

  bool DoesMouseEventActuallyIntersect(const ui::MouseEvent& event) {
    // Since hitbox for this view is extended for tap, we need to manually
    // calculate this when checking for mouse events.
    return GetLocalBounds().Contains(event.location());
  }

  bool DoesIntersectRect(const views::View* target,
                         const gfx::Rect& rect) const override {
    DCHECK_EQ(target, this);
    gfx::Rect textfield_bounds = target->GetLocalBounds();

    // Ensure that the tap target for this view is always at least the view's
    // minimum width.
    int min_width =
        std::max(kFolderHeaderMinTapWidth, textfield_bounds.width());
    int horizontal_padding = -((min_width - textfield_bounds.width()) / 2);
    textfield_bounds.Inset(gfx::Insets::VH(0, horizontal_padding));

    return textfield_bounds.Intersects(rect);
  }

 private:
  const bool tablet_mode_;

  // Name of the folder when FolderNameView is focused, used to track folder
  // rename metric.
  std::u16string starting_name_;
};

BEGIN_METADATA(FolderHeaderView, FolderNameJellyView)
END_METADATA

class FolderHeaderView::FolderNameViewController
    : public SystemTextfieldController {
 public:
  using ContentsChangedCallback =
      base::RepeatingCallback<void(const std::u16string& new_contents)>;
  FolderNameViewController(
      SystemTextfield* textfield,
      const ContentsChangedCallback& contents_changed_callback)
      : SystemTextfieldController(textfield),
        textfield_(textfield),
        contents_changed_callback_(contents_changed_callback) {}

  FolderNameViewController(const FolderNameViewController&) = delete;
  FolderNameViewController& operator=(const FolderNameViewController&) = delete;

  ~FolderNameViewController() override = default;

  // SystemTextfieldController:
  void ContentsChanged(views::Textfield* sender,
                       const std::u16string& new_contents) override {
    contents_changed_callback_.Run(new_contents);
  }
  bool HandleKeyEvent(views::Textfield* sender,
                      const ui::KeyEvent& key_event) override {
    if (SystemTextfieldController::HandleKeyEvent(sender, key_event)) {
      // TODO(b/323054951): Clean this code once the SystemTextfield has
      // implemented clearing focus.
      const bool should_clear_focus =
          key_event.type() == ui::EventType::kKeyPressed &&
          (key_event.key_code() == ui::VKEY_RETURN ||
           key_event.key_code() == ui::VKEY_ESCAPE);

      if (should_clear_focus) {
        textfield_->GetFocusManager()->ClearFocus();
      }
      return true;
    }

    if (IsUnhandledLeftRightKeyEvent(key_event)) {
      return ProcessLeftRightKeyTraversalForTextfield(sender, key_event);
    }

    return false;
  }

 private:
  raw_ptr<SystemTextfield> textfield_ = nullptr;
  const ContentsChangedCallback contents_changed_callback_;
};

FolderHeaderView::FolderHeaderView(FolderHeaderViewDelegate* delegate,
                                   bool tablet_mode)
    : folder_item_(nullptr),
      folder_name_placeholder_text_(
          ui::ResourceBundle::GetSharedInstance().GetLocalizedString(
              IDS_APP_LIST_FOLDER_NAME_PLACEHOLDER)),
      delegate_(delegate),
      folder_name_visible_(true),
      is_tablet_mode_(tablet_mode) {
  SystemTextfield* typed_folder_name_view =
      AddChildView(std::make_unique<FolderNameJellyView>(tablet_mode));
  folder_name_view_ = typed_folder_name_view;
  folder_name_controller_ = std::make_unique<FolderNameViewController>(
      typed_folder_name_view,
      base::BindRepeating(&FolderHeaderView::UpdateFolderName,
                          base::Unretained(this)));
  folder_name_view_->SetPlaceholderText(folder_name_placeholder_text_);

  SetPaintToLayer();
  layer()->SetFillsBoundsOpaquely(false);
}

FolderHeaderView::~FolderHeaderView() {
  if (folder_item_) {
    folder_item_->RemoveObserver(this);
  }
}

void FolderHeaderView::SetFolderItem(AppListFolderItem* folder_item) {
  if (folder_item_) {
    folder_item_->RemoveObserver(this);
  }

  folder_item_ = folder_item;
  if (!folder_item_) {
    return;
  }
  folder_item_->AddObserver(this);

  folder_name_view_->SetEnabled(folder_item_->folder_type() !=
                                AppListFolderItem::FOLDER_TYPE_OEM);

  Update();
}

void FolderHeaderView::UpdateFolderNameVisibility(bool visible) {
  folder_name_visible_ = visible;
  Update();
  SchedulePaint();
}

void FolderHeaderView::SetTextFocus() {
  folder_name_view_->RequestFocus();
}

bool FolderHeaderView::HasTextFocus() const {
  return folder_name_view_->HasFocus();
}

void FolderHeaderView::Update() {
  if (!folder_item_) {
    return;
  }

  folder_name_view_->SetVisible(folder_name_visible_);
  if (folder_name_visible_) {
    std::u16string elided_folder_name = GetElidedFolderName();
    folder_name_view_->SetText(elided_folder_name);
    UpdateFolderNameAccessibleName();
  }

  DeprecatedLayoutImmediately();
}

void FolderHeaderView::UpdateFolderNameAccessibleName() {
  // Sets |folder_name_view_|'s accessible name to the placeholder text if
  // |folder_name_view_| is blank; otherwise, clear the accessible name, the
  // accessible state's value is set to be folder_name_view_->GetText() by
  // TextField.
  std::u16string accessible_name = folder_name_view_->GetText().empty()
                                       ? folder_name_placeholder_text_
                                       : std::u16string();
  folder_name_view_->GetViewAccessibility().SetName(accessible_name);
}

const std::u16string& FolderHeaderView::GetFolderNameForTest() {
  return folder_name_view_->GetText();
}

void FolderHeaderView::SetFolderNameForTest(const std::u16string& name) {
  folder_name_view_->SetText(name);
}

bool FolderHeaderView::IsFolderNameEnabledForTest() const {
  return folder_name_view_->GetEnabled();
}

gfx::Size FolderHeaderView::CalculatePreferredSize(
    const views::SizeBounds& available_size) const {
  return gfx::Size(kMaxFolderHeaderWidth,
                   folder_name_view_->GetPreferredSize().height());
}

void FolderHeaderView::OnBoundsChanged(const gfx::Rect& previous_bounds) {
  Update();
}

views::Textfield* FolderHeaderView::GetFolderNameViewForTest() const {
  return folder_name_view_;
}

bool FolderHeaderView::IsFolderNameViewActiveForTest() const {
  ash::SystemTextfield* const as_system_textfield =
      views::AsViewClass<ash::SystemTextfield>(folder_name_view_);
  if (as_system_textfield) {
    return as_system_textfield->IsActive();
  }
  return folder_name_view_->HasFocus();
}

int FolderHeaderView::GetMaxFolderNameCharLengthForTest() const {
  return kMaxFolderNameChars;
}

std::u16string FolderHeaderView::GetFolderName() const {
  if (!folder_item_) {
    return std::u16string();
  }

  return base::UTF8ToUTF16(folder_item_->name());
}

std::u16string FolderHeaderView::GetElidedFolderName() const {
  if (!folder_item_) {
    return std::u16string();
  }

  // Enforce the maximum folder name length.
  std::u16string folder_name = GetFolderName();
  std::u16string name = folder_name.substr(0, kMaxFolderNameChars);

  // Get maximum text width for fitting into |folder_name_view_|.
  int text_width = std::min(kMaxFolderHeaderWidth, width()) -
                   folder_name_view_->GetCaretBounds().width() -
                   folder_name_view_->GetInsets().width();
  std::u16string elided_name = gfx::ElideText(
      name, folder_name_view_->GetFontList(), text_width, gfx::ELIDE_TAIL);
  return elided_name;
}

void FolderHeaderView::Layout(PassKey) {
  gfx::Rect rect(GetContentsBounds());
  if (rect.IsEmpty()) {
    return;
  }

  gfx::Rect text_bounds(rect);

  std::u16string text = folder_name_view_->GetText().empty()
                            ? folder_name_placeholder_text_
                            : folder_name_view_->GetText();
  int text_width =
      gfx::Canvas::GetStringWidth(text, folder_name_view_->GetFontList()) +
      folder_name_view_->GetCaretBounds().width() +
      folder_name_view_->GetInsets().width();
  text_width = std::min(text_width, kMaxFolderHeaderWidth);
  text_width = std::max(text_width, kMinFolderHeaderWidth);
  text_bounds.set_x(std::max(0, rect.x() + (rect.width() - text_width) / 2));
  text_bounds.set_width(std::min(rect.width(), text_width));

  text_bounds.ClampToCenteredSize(gfx::Size(
      text_bounds.width(), folder_name_view_->GetPreferredSize().height()));
  folder_name_view_->SetBoundsRect(text_bounds);
}

void FolderHeaderView::ContentsChanged(views::Textfield* sender,
                                       const std::u16string& new_contents) {
  UpdateFolderName(new_contents);
}

void FolderHeaderView::UpdateFolderName(
    const std::u16string& textfield_contents) {
  // Temporarily remove from observer to ignore data change caused by us.
  if (!folder_item_) {
    return;
  }

  folder_item_->RemoveObserver(this);

  std::u16string trimmed_name =
      base::CollapseWhitespace(textfield_contents, false);
  // Enforce the maximum folder name length in UI by trimming `new_contents`
  // when it is longer than the max length.
  if (trimmed_name.length() > kMaxFolderNameChars) {
    trimmed_name.resize(kMaxFolderNameChars);
    folder_name_view_->SetText(trimmed_name);
  } else {
    delegate_->SetItemName(folder_item_, base::UTF16ToUTF8(trimmed_name));
  }

  folder_item_->AddObserver(this);

  UpdateFolderNameAccessibleName();

  DeprecatedLayoutImmediately();
}

bool FolderHeaderView::ShouldNameViewClearFocus(const ui::KeyEvent& key_event) {
  return key_event.type() == ui::EventType::kKeyPressed &&
         (key_event.key_code() == ui::VKEY_RETURN ||
          key_event.key_code() == ui::VKEY_ESCAPE);
}

bool FolderHeaderView::HandleKeyEvent(views::Textfield* sender,
                                      const ui::KeyEvent& key_event) {
  if (ShouldNameViewClearFocus(key_event)) {
    // If the user presses the escape key, we should revert the text in
    // `folder_name_view_`.
    if (key_event.key_code() == ui::VKEY_ESCAPE) {
      sender->SetText(previous_folder_name_);
    }

    folder_name_view_->GetFocusManager()->ClearFocus();
    return true;
  }
  if (!IsUnhandledLeftRightKeyEvent(key_event)) {
    return false;
  }
  return ProcessLeftRightKeyTraversalForTextfield(folder_name_view_, key_event);
}

void FolderHeaderView::ItemNameChanged() {
  Update();
}

BEGIN_METADATA(FolderHeaderView)
END_METADATA

}  // namespace ash