chromium/ash/app_list/views/continue_task_container_view.cc

// Copyright 2021 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/continue_task_container_view.h"

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

#include "ash/app_list/app_list_view_delegate.h"
#include "ash/app_list/model/search/search_model.h"
#include "ash/app_list/views/continue_task_view.h"
#include "ash/public/cpp/app_list/app_list_notifier.h"
#include "ash/strings/grit/ash_strings.h"
#include "base/check.h"
#include "base/memory/raw_ptr.h"
#include "base/ranges/algorithm.h"
#include "base/strings/string_util.h"
#include "base/task/single_thread_task_runner.h"
#include "extensions/common/constants.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/compositor/layer.h"
#include "ui/events/keycodes/keyboard_codes_posix.h"
#include "ui/gfx/geometry/transform_util.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/animation/animation_builder.h"
#include "ui/views/border.h"
#include "ui/views/controls/label.h"
#include "ui/views/focus/focus_manager.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/layout/table_layout.h"
#include "ui/views/view_class_properties.h"

using views::BoxLayout;
using views::FlexLayout;
using views::TableLayout;

namespace ash {
namespace {
// Suggested tasks layout constants.
constexpr int kColumnInnerSpacingClamshell = 8;
constexpr int kColumnOuterSpacingClamshell = 6;
constexpr int kColumnSpacingTablet = 16;
constexpr int kRowSpacing = 8;
constexpr size_t kMaxFilesForContinueSection = 4;

std::vector<SearchResult*> GetTasksResultsForContinueSection(
    SearchModel::SearchResults* results) {
  auto continue_filter = [](const SearchResult& r) -> bool {
    return r.display_type() == SearchResultDisplayType::kContinue;
  };
  std::vector<SearchResult*> continue_results;
  continue_results = SearchModel::FilterSearchResultsByFunction(
      results, base::BindRepeating(continue_filter),
      /*max_results=*/4);

  return continue_results;
}

// Fades out continue task view `view` from the container.
void ScheduleFadeOutAnimation(views::View* view,
                              views::AnimationSequenceBlock* sequence) {
  // Animate views for results that have been removed.
  // Opacity changes 100% -> 0%, while the size changes from 100% -> 75%
  // original size.
  gfx::Transform scale;
  scale.Scale(0.75f, 0.75f);
  sequence->SetTransform(
      view->layer(),
      gfx::TransformAboutPivot(gfx::RectF(view->GetLocalBounds()).CenterPoint(),
                               scale),
      gfx::Tween::FAST_OUT_LINEAR_IN);
  sequence->SetOpacity(view->layer(), 0.0f, gfx::Tween::FAST_OUT_LINEAR_IN);
}

// Slides (and fades) in a new result view into the task container.
// The view is translated from right into target position while animating
// opacity from 1 -> 0. `offfset` is the initial horizontal translation from
// which the view will slide in the target position. The offset direction is
// flipped if `is_rtl` is set.
void ScheduleSlideInAnimation(views::View* view,
                              int offset,
                              bool is_rtl,
                              views::AnimationSequenceBlock* sequence) {
  gfx::Transform initial_translate;
  initial_translate.Translate(offset * (is_rtl ? -1 : 1), 0);
  view->layer()->SetTransform(initial_translate);
  sequence->SetTransform(view->layer(), gfx::Transform(),
                         gfx::Tween::ACCEL_LIN_DECEL_100_3);

  view->layer()->SetOpacity(0.0f);
  sequence->SetOpacity(view->layer(), 1.0f, gfx::Tween::ACCEL_LIN_DECEL_100_3);
}

// Slides (and fades) out an old result views from the task container.
// The view is translated from its current position to the left, while animating
// opacity from 1 -> 0. `offfset` is the target view's horizontal translation
// from the initial position. The offset direction is flipped if `is_rtl` is
// set.
void ScheduleSlideOutAnimation(views::View* view,
                               int offset,
                               bool is_rtl,
                               views::AnimationSequenceBlock* sequence) {
  gfx::Transform target_translate;
  target_translate.Translate(offset * (is_rtl ? -1 : 1), 0);

  sequence->SetTransform(view->layer(), target_translate,
                         gfx::Tween::FAST_OUT_LINEAR_IN);
  sequence->SetOpacity(view->layer(), 0.0f, gfx::Tween::FAST_OUT_LINEAR_IN);
}

}  // namespace

ContinueTaskContainerView::ContinueTaskContainerView(
    AppListViewDelegate* view_delegate,
    int columns,
    OnResultsChanged update_callback,
    bool tablet_mode)
    : view_delegate_(view_delegate),
      update_callback_(update_callback),
      tablet_mode_(tablet_mode) {
  DCHECK(!update_callback_.is_null());

  if (tablet_mode_) {
    InitializeTabletLayout();
  } else {
    columns_ = columns;
    InitializeClamshellLayout();
  }
  GetViewAccessibility().SetRole(ax::mojom::Role::kList);
  GetViewAccessibility().SetName(
      l10n_util::GetStringUTF16(IDS_ASH_LAUNCHER_CONTINUE_SECTION_LABEL),
      ax::mojom::NameFrom::kAttribute);
}

ContinueTaskContainerView::~ContinueTaskContainerView() = default;

void ContinueTaskContainerView::ListItemsAdded(size_t start, size_t count) {
  ScheduleUpdate();
}

void ContinueTaskContainerView::ListItemsRemoved(size_t start, size_t count) {
  ScheduleUpdate();
}

void ContinueTaskContainerView::ListItemMoved(size_t index,
                                              size_t target_index) {
  ScheduleUpdate();
}

void ContinueTaskContainerView::ListItemsChanged(size_t start, size_t count) {
  ScheduleUpdate();
}

void ContinueTaskContainerView::VisibilityChanged(views::View* starting_from,
                                                  bool is_visible) {
  if (!is_visible) {
    AbortTasksUpdateAnimations();
  } else {
    animations_timer_.Start(FROM_HERE, base::Seconds(2), base::DoNothing());
  }

  auto* notifier = view_delegate_->GetNotifier();
  if (notifier) {
    // NOTE: Use `IsDrawn()` instead of `is_visible` to account for parent
    // container visibility - `IsDrawn()` will return false if this view is
    // visible but its parent is not.
    notifier->NotifyContinueSectionVisibilityChanged(
        SearchResultDisplayType::kContinue, IsDrawn());
  }
}

bool ContinueTaskContainerView::OnKeyPressed(const ui::KeyEvent &event) {
  // No special focus handling in tablet mode.
  if (tablet_mode_) {
    return false;
  }
  if (event.key_code() == ui::VKEY_UP) {
    MoveFocusUp();
    return true;
  }
  if (event.key_code() == ui::VKEY_DOWN) {
    MoveFocusDown();
    return true;
  }
  return false;
}

void ContinueTaskContainerView::Update() {
  // Invalidate this callback to cancel a scheduled update.
  update_factory_.InvalidateWeakPtrs();
  AbortTasksUpdateAnimations();

  std::vector<SearchResult*> tasks =
      GetTasksResultsForContinueSection(results_);

  // Collect updated set of result IDs, which will be used to determine which
  // views need to be animated.
  std::vector<std::string> new_ids;
  for (const SearchResult* task : tasks) {
    new_ids.push_back(task->id());
  }

  // Only animate container contents update - when continue section is being
  // initialized, show the contents immediately.
  const bool first_show = animations_timer_.IsRunning() || !GetWidget() ||
                          !IsDrawn() || suggestion_tasks_views_.empty();

  std::set<views::View*> views_to_fade_out;
  std::map<std::string, views::View*> views_to_slide_out;
  std::map<std::string, views::View*> views_remaining_in_place;

  // Determine whether an animation is needed and gather information needed to
  // configure update animation.
  const bool chip_count_changed =
      tasks.size() != suggestion_tasks_views_.size();
  bool needs_animation = !first_show && chip_count_changed;
  if (!first_show) {
    for (size_t i = 0; i < suggestion_tasks_views_.size(); ++i) {
      ContinueTaskView* result_view = suggestion_tasks_views_[i];

      // Some views may be kept around during update animation, so they can
      // animate out - remove the from layout manager so they don't affect new
      // layout.
      RemoveViewFromLayout(result_view);

      TaskViewRemovalAnimation animation =
          GetRemovalAnimationForTaskView(result_view, i, new_ids);
      switch (animation) {
        case TaskViewRemovalAnimation::kFadeOut:
          views_to_fade_out.insert(result_view);
          break;
        case TaskViewRemovalAnimation::kSlideOut:
          views_to_slide_out.emplace(result_view->result()->id(), result_view);
          break;
        case TaskViewRemovalAnimation::kNone:
          // In tablet mode, if the number of chips has changed, the chip bounds
          // and size are likely to change, so slide out existing items even if
          // they remain at the same logical position in the container.
          if (tablet_mode_ && chip_count_changed) {
            views_to_slide_out.emplace(result_view->result()->id(),
                                       result_view);
          } else {
            views_remaining_in_place.emplace(result_view->result()->id(),
                                             result_view);
          }
          break;
      }

      // Unless the result view remains in the same position within the task
      // container, the task update requires animation.
      if (animation != TaskViewRemovalAnimation::kNone)
        needs_animation = true;
    }
  }

  if (needs_animation) {
    views_to_remove_after_animation_.swap(suggestion_tasks_views_);
  } else {
    // When not animating, all views can be removed immediately.
    RemoveAllChildViews();
  }

  suggestion_tasks_views_.clear();

  num_results_ = std::min(kMaxFilesForContinueSection, tasks.size());

  num_file_results_ = 0;
  num_desks_admin_template_results_ = 0;
  for (size_t i = 0; i < num_results_; ++i) {
    if (tasks[i]->result_type() == AppListSearchResultType::kZeroStateFile ||
        tasks[i]->result_type() == AppListSearchResultType::kZeroStateDrive) {
      ++num_file_results_;
    }

    if (tasks[i]->result_type() ==
        AppListSearchResultType::kDesksAdminTemplate) {
      ++num_desks_admin_template_results_;
    }
  }

  // Create new result views.
  for (size_t i = 0; i < num_results_; ++i) {
    auto task =
        std::make_unique<ContinueTaskView>(view_delegate_, tablet_mode_);
    if (i == 0)
      task->SetProperty(views::kMarginsKey, gfx::Insets());
    task->set_index_in_container(i);
    task->SetResult(tasks[i]);
    suggestion_tasks_views_.emplace_back(task.get());
    AddChildView(std::move(task));
  }

  // Layout the container so the task bounds are set to their intended
  // positions, which will be used to configure container update animation
  // sequences when animating.
  DeprecatedLayoutImmediately();

  if (needs_animation) {
    ScheduleContainerUpdateAnimation(views_to_fade_out, views_to_slide_out,
                                     views_remaining_in_place);
  }

  auto* notifier = view_delegate_->GetNotifier();
  if (notifier) {
    std::vector<AppListNotifier::Result> notifier_results;
    for (const auto* task : tasks)
      notifier_results.emplace_back(task->id(), task->metrics_type(),
                                    task->continue_file_suggestion_type());
    notifier->NotifyResultsUpdated(SearchResultDisplayType::kContinue,
                                   notifier_results);
  }
  if (!update_callback_.is_null())
    update_callback_.Run();
}

ContinueTaskContainerView::TaskViewRemovalAnimation
ContinueTaskContainerView::GetRemovalAnimationForTaskView(
    ContinueTaskView* task_view,
    size_t old_index,
    const std::vector<std::string>& new_task_ids) {
  // If the result associated with the result was reset, animate the view
  // out.
  if (!task_view->result())
    return TaskViewRemovalAnimation::kFadeOut;

  const std::string& task_id = task_view->result()->id();
  auto new_ids_it = base::ranges::find(new_task_ids, task_id);

  // If the associated result was removed from the task list, animate it out.
  if (new_ids_it == new_task_ids.end())
    return TaskViewRemovalAnimation::kFadeOut;

  const size_t new_index = (new_ids_it - new_task_ids.begin());

  if (old_index != new_index)
    return TaskViewRemovalAnimation::kSlideOut;

  return TaskViewRemovalAnimation::kNone;
}

void ContinueTaskContainerView::ScheduleContainerUpdateAnimation(
    const std::set<views::View*>& views_to_fade_out,
    const std::map<std::string, views::View*>& views_to_slide_out,
    const std::map<std::string, views::View*>& views_remaining_in_place) {
  views::AnimationBuilder animation_builder;
  animation_builder.OnEnded(base::BindOnce(
      &ContinueTaskContainerView::ClearAnimatingViews, base::Unretained(this)));
  animation_builder.OnAborted(base::BindOnce(
      &ContinueTaskContainerView::ClearAnimatingViews, base::Unretained(this)));

  animation_builder.Once().SetDuration(base::Milliseconds(100));

  // Fade out views for results that got removed.
  for (auto* view : views_to_fade_out)
    ScheduleFadeOutAnimation(view, &animation_builder.GetCurrentSequence());

  // Immediately hide views that remained in place, and for which the new result
  // views will not be animated in.
  for (auto& view : views_remaining_in_place)
    view.second->SetVisible(false);

  const bool is_rtl = base::i18n::IsRTL();

  // Slide out old result views for results whose position changed.
  base::TimeDelta delay =
      views_to_fade_out.empty() ? base::TimeDelta() : base::Milliseconds(200);
  animation_builder.GetCurrentSequence().At(delay).SetDuration(
      base::Milliseconds(100));
  for (auto& view : views_to_slide_out) {
    ScheduleSlideOutAnimation(view.second, tablet_mode_ ? 0 : -34, is_rtl,
                              &animation_builder.GetCurrentSequence());
  }

  // Animate new views in.
  delay = views_to_fade_out.empty() ? base::Milliseconds(100)
                                    : base::Milliseconds(300);
  animation_builder.GetCurrentSequence().At(delay).SetDuration(
      base::Milliseconds(300));

  for (ash::ContinueTaskView* view : suggestion_tasks_views_) {
    const std::string& result_id = view->result()->id();
    // If view remained in place, it does not need to be animated in.
    auto view_remaining_in_place_it = views_remaining_in_place.find(result_id);
    if (view_remaining_in_place_it != views_remaining_in_place.end())
      continue;

    int initial_offset = 60;
    // In tablet mode, direction from which the view slides in depends on
    // whether the view is coming in from left or right - if the result existed
    // before the update, and its old view bounds were left of the new view
    // bounds, slide the view in from the left by flipping offset direction.
    if (tablet_mode_) {
      const auto& old_view_it = views_to_slide_out.find(result_id);
      if (old_view_it != views_to_slide_out.end() &&
          old_view_it->second->x() < view->x()) {
        initial_offset = -initial_offset;
      }
    }
    ScheduleSlideInAnimation(view, initial_offset, is_rtl,
                             &animation_builder.GetCurrentSequence());
  }
}

void ContinueTaskContainerView::AbortTasksUpdateAnimations() {
  for (ash::ContinueTaskView* view : suggestion_tasks_views_) {
    view->layer()->GetAnimator()->StopAnimating();
  }
  ClearAnimatingViews();
}

void ContinueTaskContainerView::ClearAnimatingViews() {
  // Clear `views_to_remove_after_animation_` before starting to remove views in
  // case view removal causes an aborted view animation that calls back into
  // `ClearAnimatingViews()`. Clearing `views_to_remove_after_animation_` mid
  // iteraion over the vector would not be safe.
  std::vector<raw_ptr<ContinueTaskView, VectorExperimental>> views_to_remove;
  views_to_remove_after_animation_.swap(views_to_remove);
  for (ash::ContinueTaskView* view : views_to_remove) {
    RemoveChildViewT(view);
  }

  NotifyAccessibilityEvent(ax::mojom::Event::kChildrenChanged, true);
}

void ContinueTaskContainerView::SetResults(
    SearchModel::SearchResults* results) {
  list_model_observation_.Reset();

  results_ = results;
  if (results_)
    list_model_observation_.Observe(results);

  Update();
}

void ContinueTaskContainerView::DisableFocusForShowingActiveFolder(
    bool disabled) {
  for (views::View* child : suggestion_tasks_views_)
    child->SetEnabled(!disabled);
}

void ContinueTaskContainerView::AnimateSlideInSuggestions(
    int available_space,
    base::TimeDelta duration,
    gfx::Tween::Type tween) {
  SetVisible(true);

  const int rows =
      columns_ ? std::ceil(static_cast<double>(suggestion_tasks_views_.size()) /
                           columns_)
               : 1;
  double space_per_row = static_cast<double>(available_space) / rows;

  for (size_t i = 0; i < suggestion_tasks_views_.size(); i++) {
    views::View* view = suggestion_tasks_views_[i];
    gfx::Transform translation;

    int row_number = columns_ ? ((i / columns_) + 1) : 1;
    // Distribute the space between the elements so that the space between the
    // previous element in the parent view and the first row is the same as the
    // space between rows. The items in the first row will just be translated by
    // `space_per_row`. The items from the second row need to carry over
    // the space translated by the first row and translate again
    // `space_per_row` to have even space between elements.
    translation.Translate(0, space_per_row * row_number);

    view->layer()->SetTransform(translation);
    view->layer()->SetOpacity(0.0f);
  }
  views::AnimationBuilder animation_builder;
  animation_builder.Once().SetDuration(duration);

  for (ash::ContinueTaskView* view : suggestion_tasks_views_) {
    animation_builder.GetCurrentSequence()
        .SetTransform(view, gfx::Transform(), tween)
        .SetOpacity(view, 1.0f, tween);
  }
}

void ContinueTaskContainerView::RemoveViewFromLayout(ContinueTaskView* view) {
  view->SetEnabled(false);
  view->SetProperty(views::kViewIgnoredByLayoutKey, true);
}

void ContinueTaskContainerView::ScheduleUpdate() {
  // When search results are added one by one, each addition generates an update
  // request. Consolidates those update requests into one Update call.
  if (!update_factory_.HasWeakPtrs()) {
    base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
        FROM_HERE, base::BindOnce(&ContinueTaskContainerView::Update,
                                  update_factory_.GetWeakPtr()));
  }
}

void ContinueTaskContainerView::InitializeTabletLayout() {
  DCHECK(tablet_mode_);
  DCHECK(!columns_);

  SetLayoutManager(std::make_unique<FlexLayout>())
      ->SetOrientation(views::LayoutOrientation::kHorizontal)
      .SetMainAxisAlignment(views::LayoutAlignment::kCenter)
      .SetDefault(views::kMarginsKey,
                  gfx::Insets::TLBR(0, kColumnSpacingTablet, 0, 0))
      .SetDefault(views::kFlexBehaviorKey,
                  views::FlexSpecification(
                      views::MinimumFlexSizeRule::kScaleToMinimumSnapToZero,
                      views::MaximumFlexSizeRule::kScaleToMaximum));
}

void ContinueTaskContainerView::InitializeClamshellLayout() {
  DCHECK(!tablet_mode_);
  DCHECK_GT(columns_, 0);

  auto* const table_layout =
      SetLayoutManager(std::make_unique<views::TableLayout>());
  std::vector<size_t> linked_columns;
  for (int i = 0; i < columns_; i++) {
    if (i == 0) {
      table_layout->AddPaddingColumn(views::TableLayout::kFixedSize,
                                     kColumnOuterSpacingClamshell);
    } else {
      table_layout->AddPaddingColumn(views::TableLayout::kFixedSize,
                                     kColumnInnerSpacingClamshell);
    }
    table_layout->AddColumn(
        views::LayoutAlignment::kStretch, views::LayoutAlignment::kCenter,
        /*horizontal_resize=*/1.0f, views::TableLayout::ColumnSize::kFixed,
        /*fixed_width=*/0, /*min_width=*/0);
    linked_columns.push_back(2 * i + 1);
  }
  table_layout->AddPaddingColumn(views::TableLayout::kFixedSize,
                                 kColumnOuterSpacingClamshell);

  table_layout->LinkColumnSizes(linked_columns);
  // Continue section only shows if there are 3 or more suggestions, so there
  // are always 2 rows.
  table_layout->AddRows(1, views::TableLayout::kFixedSize);
  table_layout->AddPaddingRow(views::TableLayout::kFixedSize, kRowSpacing);
  table_layout->AddRows(1, views::TableLayout::kFixedSize);
}

void ContinueTaskContainerView::MoveFocusUp() {
  DVLOG(1) << __FUNCTION__;
  // This function should only run when a child has focus.
  DCHECK(Contains(GetFocusManager()->GetFocusedView()));
  DCHECK(!suggestion_tasks_views_.empty());
  int focused_index = GetIndexOfFocusedTaskView();
  DCHECK_GE(focused_index, 0);
  // Try to move up by one row.
  int target_index = focused_index - columns_;
  // If that would move before the first item, focus the first item and reverse
  // focus out of the section.
  if (target_index < 0) {
    suggestion_tasks_views_[0]->RequestFocus();
    GetFocusManager()->AdvanceFocus(/*reverse=*/true);
    return;
  }
  suggestion_tasks_views_[target_index]->RequestFocus();
}

void ContinueTaskContainerView::MoveFocusDown() {
  DVLOG(1) << __FUNCTION__;
  // This function should only run when a child has focus.
  DCHECK(Contains(GetFocusManager()->GetFocusedView()));
  DCHECK(!suggestion_tasks_views_.empty());
  int focused_index = GetIndexOfFocusedTaskView();
  DCHECK_GE(focused_index, 0);
  // Try to move down by one row.
  int target_index = focused_index + columns_;
  // If that would move past the last item, focus the last item and advance
  // focus out of the section.
  if (target_index >= static_cast<int>(suggestion_tasks_views_.size())) {
    suggestion_tasks_views_.back()->RequestFocus();
    GetFocusManager()->AdvanceFocus(/*reverse=*/false);
    return;
  }
  suggestion_tasks_views_[target_index]->RequestFocus();
}

int ContinueTaskContainerView::GetIndexOfFocusedTaskView() const {
  for (size_t i = 0; i < suggestion_tasks_views_.size(); ++i) {
    if (suggestion_tasks_views_[i]->HasFocus())
      return i;
  }
  return -1;
}

BEGIN_METADATA(ContinueTaskContainerView)
END_METADATA

}  // namespace ash