chromium/ash/wm/overview/overview_focus_cycler.cc

// Copyright 2024 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/wm/overview/overview_focus_cycler.h"

#include "ash/shell.h"
#include "ash/style/rounded_label_widget.h"
#include "ash/wm/desks/desk_preview_view.h"
#include "ash/wm/overview/overview_grid.h"
#include "ash/wm/overview/overview_item_view.h"
#include "ash/wm/overview/overview_session.h"
#include "ash/wm/window_properties.h"
#include "ash/wm/window_util.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/focus/focus_manager.h"
#include "ui/views/view_utils.h"
#include "ui/views/widget/widget.h"
#include "ui/views/widget/widget_delegate.h"
#include "ui/views/widget/widget_observer.h"

namespace ash {

namespace {

// Returns true if any child is focusable in `view`'s tree.
bool IsViewFocusable(const views::View* view, bool for_accessibility) {
  CHECK(view);

  // A regular focusable view is focusable for ChromeVox as well.
  if (view->IsFocusable()) {
    return true;
  }

  if (for_accessibility &&
      view->GetViewAccessibility().IsAccessibilityFocusable()) {
    return true;
  }

  // If any of the children are focusable we are done.
  for (const views::View* child : view->children()) {
    if (IsViewFocusable(child, for_accessibility)) {
      return true;
    }
  }

  return false;
}

views::View* GetFirstOrLastFocusableView(views::Widget* widget, bool reverse) {
  views::View* view = widget->GetFocusManager()->GetNextFocusableView(
      /*starting_view=*/nullptr, widget, reverse, /*dont_loop=*/false);
  CHECK(view);
  return view;
}

// Determines whether we should rotate focus to the next widget. We rotate focus
// if we are forward tabbing and the current focused view is the last focusable
// view of the widget, or if we are reverse tabbing and the current focused view
// is the first focusable view of the widget.
bool ShouldRotateFocus(views::View* current_focused_view, bool reverse) {
  views::Widget* widget = current_focused_view->GetWidget();
  return current_focused_view == GetFirstOrLastFocusableView(widget, !reverse);
}

int AdvanceIndex(int previous_index, int size, bool reverse) {
  if (reverse) {
    return previous_index == 0 ? (size - 1) : (previous_index - 1);
  }
  return previous_index == (size - 1) ? 0 : (previous_index + 1);
}

// Class that temporary makes a widget activatable so that we can focus it. This
// is meant to be used while keyboard traversing through overview item widgets.
// These widgets are not activatable normally for both historical reasons, and
// to prevent activation change while mouse dragging.
class ScopedActivatable : public views::WidgetObserver {
 public:
  explicit ScopedActivatable(views::Widget* widget) {
    views::WidgetDelegate* delegate = widget->widget_delegate();
    if (!delegate->CanActivate()) {
      observation_.Observe(widget);
      delegate->SetCanActivate(true);
    }
  }
  ScopedActivatable(const ScopedActivatable&) = delete;
  ScopedActivatable& operator=(const ScopedActivatable&) = delete;
  ~ScopedActivatable() override {
    if (observation_.IsObserving()) {
      observation_.GetSource()->widget_delegate()->SetCanActivate(false);
    }
  }

  void OnWidgetDestroying(views::Widget* widget) override {
    observation_.Reset();
  }

  void OnWidgetActivationChanged(views::Widget* widget, bool active) override {
    if (!active) {
      return;
    }

    // If an overview item received focus, we need to restack the original
    // window above the overview item widget, otherwise the overview backdrop
    // would end up covering the original window.
    auto* item_view =
        views::AsViewClass<OverviewItemView>(widget->GetContentsView());
    if (!item_view) {
      return;
    }

    OverviewItemBase* item = item_view->overview_item();
    if (!item) {
      return;
    }

    aura::Window* parent = widget->GetNativeWindow()->parent();
    if (parent == item->GetWindow()->parent()) {
      parent->StackChildAbove(item->GetWindow(), widget->GetNativeWindow());
    }
  }

 private:
  base::ScopedObservation<views::Widget, views::WidgetObserver> observation_{
      this};
};

}  // namespace

OverviewFocusCycler::OverviewFocusCycler(OverviewSession* overview_session)
    : overview_session_(overview_session) {}

OverviewFocusCycler::~OverviewFocusCycler() = default;

void OverviewFocusCycler::MoveFocus(bool reverse) {
  views::View* focused_view = GetOverviewFocusedView();
  if (focused_view && !ShouldRotateFocus(focused_view, reverse)) {
    // If we don't need to rotate focus to the next widget, let the focus
    // manager advance focus.
    focused_view->GetWidget()->GetFocusManager()->AdvanceFocus(reverse);
    return;
  }

  const std::vector<views::Widget*> widgets =
      GetTraversableWidgets(/*for_accessibility=*/false);
  // `widgets` can be empty when there are only non traversable overview widgets
  // shown (ex. "No recent items" label).
  if (widgets.empty()) {
    return;
  }

  // If there is no current focused view request either the last focusable view
  // of the last widget in the traversal or the first focusable view of the
  // first widget, depending on `reverse`.
  if (!focused_view) {
    views::Widget* widget = reverse ? widgets.back() : widgets.front();
    ScopedActivatable scoped_activatable(widget);
    GetFirstOrLastFocusableView(widget, reverse)->RequestFocus();
    return;
  }

  auto it = base::ranges::find(widgets, focused_view->GetWidget());
  CHECK(it != widgets.end());

  const int previous_index = std::distance(widgets.begin(), it);
  const int size = static_cast<int>(widgets.size());

  // Jump to the desk removal toast if it exists. We introduce special logic
  // here since it's not an overview UI.
  if ((reverse && previous_index == 0) ||
      (!reverse && previous_index == size - 1)) {
    const bool ignore_activations = overview_session_->ignore_activations();
    overview_session_->set_ignore_activations(true);
    const bool focused_toast =
        DesksController::Get()->RequestFocusOnUndoDeskRemovalToast();
    overview_session_->set_ignore_activations(ignore_activations);
    if (focused_toast) {
      return;
    }
  }

  // Focus the last focusable view of the previous widget if `reverse`, or the
  // first focusable view of the next widget otherwise.
  const int next_index = AdvanceIndex(previous_index, size, reverse);
  ScopedActivatable scoped_activatable(widgets[next_index]);
  GetFirstOrLastFocusableView(widgets[next_index], reverse)->RequestFocus();
}

bool OverviewFocusCycler::AcceptSelection() {
  views::View* focused_view = GetOverviewFocusedView();
  if (!focused_view) {
    return false;
  }

  if (auto* preview_view = views::AsViewClass<DeskPreviewView>(focused_view)) {
    preview_view->AcceptSelection();
    return true;
  }

  if (auto* item_view = views::AsViewClass<OverviewItemView>(focused_view)) {
    item_view->AcceptSelection(overview_session_);
    return true;
  }

  return false;
}

views::View* OverviewFocusCycler::GetOverviewFocusedView() {
  aura::Window* active_window = window_util::GetActiveWindow();
  if (!active_window) {
    return nullptr;
  }

  if (!active_window->GetProperty(kOverviewUiKey)) {
    return nullptr;
  }

  views::Widget* widget =
      views::Widget::GetWidgetForNativeWindow(active_window);
  if (!widget) {
    return nullptr;
  }

  return widget->GetFocusManager()->GetFocusedView();
}

void OverviewFocusCycler::UpdateAccessibilityFocus() {
  const std::vector<views::Widget*> a11y_widgets =
      GetTraversableWidgets(/*for_accessibility=*/true);
  if (a11y_widgets.empty()) {
    return;
  }

  auto get_view_a11y = [&a11y_widgets](int index) -> views::ViewAccessibility& {
    return a11y_widgets[index]->GetContentsView()->GetViewAccessibility();
  };

  // If there is only one widget left, clear the focus overrides so that they
  // do not point to deleted objects.
  if (a11y_widgets.size() == 1) {
    get_view_a11y(/*index=*/0).SetPreviousFocus(nullptr);
    get_view_a11y(/*index=*/0).SetNextFocus(nullptr);
    a11y_widgets[0]->GetContentsView()->NotifyAccessibilityEvent(
        ax::mojom::Event::kTreeChanged, true);
    return;
  }

  int size = a11y_widgets.size();
  for (int i = 0; i < size; ++i) {
    int previous_index = (i + size - 1) % size;
    int next_index = (i + 1) % size;
    get_view_a11y(i).SetPreviousFocus(a11y_widgets[previous_index]);
    get_view_a11y(i).SetNextFocus(a11y_widgets[next_index]);
    a11y_widgets[i]->GetContentsView()->NotifyAccessibilityEvent(
        ax::mojom::Event::kTreeChanged, true);
  }
}

std::vector<views::Widget*> OverviewFocusCycler::GetTraversableWidgets(
    bool for_accessibility) const {
  std::vector<views::Widget*> traversable_widgets;
  traversable_widgets.reserve(40);  // Conservative default.

  auto maybe_add_widget = [for_accessibility,
                           &traversable_widgets](views::Widget* widget) {
    if (!widget ||
        widget->GetNativeWindow()->layer()->GetTargetOpacity() == 0.f) {
      return;
    }

    // Focus is tied to activation except in ChromeVox where labels and other
    // normally unfocusable elements can be ChromeVox focused.
    if (!for_accessibility && !widget->CanActivate() &&
        !widget->GetNativeWindow()->GetProperty(kIsOverviewItemKey)) {
      return;
    }

    // Skip this widget if it has no focusable views. (i.e. Saved desks library
    // with all saved desks deleted or saved desk button container with all
    // buttons disabled.)
    if (!IsViewFocusable(widget->GetContentsView(), for_accessibility)) {
      return;
    }

    traversable_widgets.push_back(widget);
  };

  maybe_add_widget(overview_session_->overview_focus_widget());

  for (const auto& grid : overview_session_->grid_list()) {
    for (const auto& item : grid->item_list()) {
      // There may be two widgets if the item is a snap group item.
      for (views::Widget* item_widget : item->GetFocusableWidgets()) {
        maybe_add_widget(item_widget);
      }
    }
    maybe_add_widget(grid->saved_desk_library_widget());
    maybe_add_widget(grid->desks_widget());
    maybe_add_widget(grid->save_desk_button_container_widget());
    maybe_add_widget(grid->informed_restore_widget());
    maybe_add_widget(grid->birch_bar_widget());
    maybe_add_widget(grid->split_view_setup_widget());
    maybe_add_widget(grid->no_windows_widget());
  }
  return traversable_widgets;
}

}  // namespace ash