| // Copyright 2020 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/clipboard/clipboard_history_menu_model_adapter.h" |
| |
| #include <memory> |
| #include <optional> |
| #include <string> |
| #include <utility> |
| |
| #include "ash/bubble/bubble_utils.h" |
| #include "ash/clipboard/clipboard_history.h" |
| #include "ash/clipboard/clipboard_history_util.h" |
| #include "ash/clipboard/views/clipboard_history_item_view.h" |
| #include "ash/clipboard/views/clipboard_history_label.h" |
| #include "ash/clipboard/views/clipboard_history_view_constants.h" |
| #include "ash/constants/ash_features.h" |
| #include "ash/public/cpp/clipboard_history_controller.h" |
| #include "ash/public/cpp/clipboard_image_model_factory.h" |
| #include "ash/strings/grit/ash_strings.h" |
| #include "ash/style/typography.h" |
| #include "ash/wm/window_util.h" |
| #include "base/check.h" |
| #include "base/functional/bind.h" |
| #include "base/memory/raw_ptr.h" |
| #include "base/metrics/histogram_macros.h" |
| #include "base/ranges/algorithm.h" |
| #include "base/strings/string_util.h" |
| #include "base/task/sequenced_task_runner.h" |
| #include "chromeos/constants/chromeos_features.h" |
| #include "components/vector_icons/vector_icons.h" |
| #include "ui/accessibility/ax_enums.mojom.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/models/menu_model.h" |
| #include "ui/base/models/simple_menu_model.h" |
| #include "ui/base/ui_base_types.h" |
| #include "ui/chromeos/styles/cros_tokens_color_mappings.h" |
| #include "ui/gfx/geometry/rect.h" |
| #include "ui/strings/grit/ui_strings.h" |
| #include "ui/views/accessibility/view_accessibility.h" |
| #include "ui/views/background.h" |
| #include "ui/views/border.h" |
| #include "ui/views/controls/image_view.h" |
| #include "ui/views/controls/menu/menu_item_view.h" |
| #include "ui/views/controls/menu/menu_model_adapter.h" |
| #include "ui/views/controls/menu/menu_runner.h" |
| #include "ui/views/controls/menu/menu_types.h" |
| #include "ui/views/controls/menu/submenu_view.h" |
| #include "ui/views/controls/separator.h" |
| #include "ui/views/controls/styled_label.h" |
| #include "ui/views/layout/box_layout.h" |
| #include "ui/views/layout/box_layout_view.h" |
| #include "ui/views/layout/flex_layout_types.h" |
| #include "ui/views/view_class_properties.h" |
| #include "ui/views/widget/widget.h" |
| |
| namespace ash { |
| |
| namespace { |
| |
| // Returns a font list resolved from the specified `typography_token`. |
| gfx::FontList Resolve(TypographyToken typography_token) { |
| return TypographyProvider::Get()->ResolveTypographyToken(typography_token); |
| } |
| |
| // Returns the elapsed time since the specified `time`. |
| base::TimeDelta TimeSince(const base::Time& time) { |
| return base::Time::Now() - time; |
| } |
| |
| // Returns whether the clipboard history menu requires a header. |
| bool IsHeaderRequired() { |
| return chromeos::features::IsClipboardHistoryRefreshEnabled(); |
| } |
| |
| // Returns whether the clipboard history menu requires a footer. |
| bool IsFooterRequired( |
| crosapi::mojom::ClipboardHistoryControllerShowSource show_source, |
| const std::optional<base::Time>& menu_last_time_shown, |
| const std::optional<base::Time>& nudge_last_time_shown) { |
| // A footer is always required when the menu is shown via Ctrl+V long press. |
| using crosapi::mojom::ClipboardHistoryControllerShowSource; |
| if (show_source == ClipboardHistoryControllerShowSource::kControlVLongpress) { |
| return true; |
| } |
| |
| // If the menu is not shown via Ctrl+V long press, footers require that the |
| // clipboard history refresh feature be enabled. |
| if (!chromeos::features::IsClipboardHistoryRefreshEnabled()) { |
| return false; |
| } |
| |
| // A footer is required if the menu hasn't been shown in the past 60 days. |
| if (TimeSince(menu_last_time_shown.value_or(base::Time())) >= |
| base::Days(60)) { |
| return true; |
| } |
| |
| // A footer is required if a nudge has been shown in the past 60 seconds. |
| return TimeSince(nudge_last_time_shown.value_or(base::Time())) <= |
| base::Seconds(60); |
| } |
| |
| // Populates `container` with a menu title to appear at the top of the clipboard |
| // history menu. |
| void InsertHeaderContent(views::MenuItemView* container) { |
| container->AddChildView( |
| views::Builder<views::BoxLayoutView>() |
| .SetOrientation(views::BoxLayout::Orientation::kHorizontal) |
| .SetBorder( |
| views::CreateEmptyBorder(ClipboardHistoryViews::kContentsInsets)) |
| .AfterBuild(base::BindOnce([](views::BoxLayoutView* header) { |
| const int width = |
| clipboard_history_util::GetPreferredItemViewWidth(); |
| header->SetPreferredSize( |
| gfx::Size(width, header->GetHeightForWidth(width))); |
| })) |
| .AddChild(views::Builder<views::Label>( |
| bubble_utils::CreateLabel( |
| TypographyToken::kCrosButton1, |
| l10n_util::GetStringUTF16( |
| IDS_ASH_CLIPBOARD_HISTORY_HEADER_TITLE), |
| cros_tokens::kCrosSysOnSurface)) |
| .SetAccessibleRole(ax::mojom::Role::kHeading) |
| .SetHorizontalAlignment(gfx::ALIGN_LEFT)) |
| .Build()); |
| } |
| |
| // TODO(http://b/267694412): Add pixel test. |
| // Populates `styled_label` with educational text to appear at the bottom of the |
| // clipboard history menu. This method may only be called when clipboard history |
| // refresh is enabled. |
| void InsertFooterContentV2LabelStyledText( |
| crosapi::mojom::ClipboardHistoryControllerShowSource show_source, |
| views::StyledLabel* styled_label) { |
| CHECK(chromeos::features::IsClipboardHistoryRefreshEnabled()); |
| |
| // Create text style. |
| views::StyledLabel::RangeStyleInfo text_style; |
| text_style.custom_font = Resolve(TypographyToken::kCrosAnnotation1); |
| text_style.override_color_id = cros_tokens::kCrosSysOnSurfaceVariant; |
| |
| // When the clipboard history menu is shown from a Ctrl+V long press event, a |
| // specific educational text is used which does not require inline icons. |
| using crosapi::mojom::ClipboardHistoryControllerShowSource; |
| if (show_source == ClipboardHistoryControllerShowSource::kControlVLongpress) { |
| styled_label->SetText(l10n_util::GetStringUTF16( |
| IDS_ASH_CLIPBOARD_HISTORY_CONTROL_V_LONGPRESS_FOOTER)); |
| styled_label->AddStyleRange(gfx::Range(0u, styled_label->GetText().size()), |
| std::move(text_style)); |
| return; |
| } |
| |
| // When the clipboard history menu is *not* shown from a Ctrl+V long press |
| // event, set text based on keyboard layout, caching the offset where an |
| // inline icon should be inserted. |
| size_t inline_icon_offset; |
| const auto& shortcut_key = clipboard_history_util::GetShortcutKeyName(); |
| styled_label->SetText(l10n_util::GetStringFUTF16( |
| IDS_ASH_CLIPBOARD_HISTORY_FOOTER, shortcut_key, &inline_icon_offset)); |
| inline_icon_offset += shortcut_key.size(); |
| |
| // Set text styles. |
| styled_label->AddStyleRange(gfx::Range(0u, inline_icon_offset), text_style); |
| styled_label->AddStyleRange( |
| gfx::Range(inline_icon_offset + 1u, styled_label->GetText().size()), |
| std::move(text_style)); |
| |
| // Create inline icon. |
| auto inline_icon = |
| views::Builder<views::ImageView>() |
| .SetBorder(views::CreateEmptyBorder( |
| ClipboardHistoryViews::kInlineIconMargins)) |
| .SetImage(ui::ImageModel::FromVectorIcon( |
| clipboard_history_util::GetShortcutKeyIcon(), |
| cros_tokens::kCrosSysOnSurfaceVariant, |
| ClipboardHistoryViews::kFooterContentV2InlineIconSize)) |
| .Build(); |
| |
| // Insert inline icon. |
| views::StyledLabel::RangeStyleInfo inline_icon_style; |
| inline_icon_style.custom_view = inline_icon.get(); |
| styled_label->AddStyleRange( |
| gfx::Range(inline_icon_offset, inline_icon_offset + 1u), |
| std::move(inline_icon_style)); |
| |
| // Transfer inline icon ownership. |
| styled_label->AddCustomView(std::move(inline_icon)); |
| } |
| |
| // TODO(http://b/267694412): Add pixel test. |
| // Populates `container` with educational content to appear at the bottom of the |
| // clipboard history menu. This method may only be called when clipboard history |
| // refresh is enabled. |
| void InsertFooterContentV2( |
| views::MenuItemView* container, |
| crosapi::mojom::ClipboardHistoryControllerShowSource show_source) { |
| CHECK(chromeos::features::IsClipboardHistoryRefreshEnabled()); |
| |
| // Cache `menu_padding`. |
| const int menu_padding = |
| views::MenuConfig::instance().vertical_touchable_menu_item_padding; |
| |
| // Compute `footer_margins`, accounting for `menu_padding`. |
| gfx::Insets footer_margins = ClipboardHistoryViews::kFooterContentV2Margins; |
| footer_margins -= gfx::Insets::TLBR(0, 0, menu_padding, 0); |
| |
| // Compute `footer_width`. |
| const int footer_width = clipboard_history_util::GetPreferredItemViewWidth() - |
| footer_margins.width(); |
| |
| container->AddChildView( |
| views::Builder<views::BoxLayoutView>() |
| .SetBackground(views::CreateThemedRoundedRectBackground( |
| cros_tokens::kCrosSysSystemOnBase1, |
| ClipboardHistoryViews::kFooterContentV2BackgroundCornerRadius)) |
| .SetBetweenChildSpacing( |
| ClipboardHistoryViews::kFooterContentV2ChildSpacing) |
| .SetCrossAxisAlignment(views::BoxLayout::CrossAxisAlignment::kStart) |
| .SetID(clipboard_history_util::kFooterContentV2ViewID) |
| .SetInsideBorderInsets(ClipboardHistoryViews::kFooterContentV2Insets) |
| .SetOrientation(views::BoxLayout::Orientation::kHorizontal) |
| .SetProperty(views::kMarginsKey, footer_margins) |
| .AddChildren( |
| views::Builder<views::ImageView>().SetImage( |
| ui::ImageModel::FromVectorIcon( |
| vector_icons::kHelpOutlineIcon, |
| cros_tokens::kCrosSysOnSurfaceVariant, |
| ClipboardHistoryViews::kFooterContentV2IconSize)), |
| views::Builder<views::StyledLabel>() |
| .SetAutoColorReadabilityEnabled(false) |
| .SetID(clipboard_history_util::kFooterContentV2LabelID) |
| .SizeToFit( |
| footer_width - |
| ClipboardHistoryViews::kFooterContentV2Insets.width() - |
| ClipboardHistoryViews::kFooterContentV2IconSize - |
| ClipboardHistoryViews::kFooterContentV2ChildSpacing) |
| .CustomConfigure(base::BindOnce( |
| &InsertFooterContentV2LabelStyledText, show_source))) |
| .Build()); |
| } |
| |
| // TODO(http://b/267694412): Add pixel test. |
| // Populates `container` with educational content to appear at the bottom of the |
| // clipboard history menu. |
| void InsertFooterContent( |
| views::MenuItemView* container, |
| crosapi::mojom::ClipboardHistoryControllerShowSource show_source) { |
| if (chromeos::features::IsClipboardHistoryRefreshEnabled()) { |
| InsertFooterContentV2(container, show_source); |
| return; |
| } |
| |
| const int content_width = |
| clipboard_history_util::GetPreferredItemViewWidth() - |
| ClipboardHistoryViews::kContentsInsets.width(); |
| |
| // Introduce a layout view between `container` and the desired separator and |
| // label to circumvent `container` manually laying out its children. |
| container->AddChildView( |
| views::Builder<views::BoxLayoutView>() |
| .SetID(clipboard_history_util::kFooterContentViewID) |
| .SetOrientation(views::BoxLayout::Orientation::kVertical) |
| .AddChildren( |
| views::Builder<views::Separator>() |
| .SetBorder(views::CreateEmptyBorder( |
| ClipboardHistoryViews::kContentsInsets)) |
| .SetColorId(cros_tokens::kCrosSysSeparator) |
| .SetOrientation(views::Separator::Orientation::kHorizontal) |
| .SetPreferredLength(content_width), |
| views::Builder<views::Label>( |
| bubble_utils::CreateLabel( |
| TypographyToken::kCrosAnnotation1, |
| l10n_util::GetStringUTF16( |
| IDS_ASH_CLIPBOARD_HISTORY_CONTROL_V_LONGPRESS_FOOTER), |
| cros_tokens::kCrosSysSecondary)) |
| .SetBorder(views::CreateEmptyBorder( |
| ClipboardHistoryViews::kContentsInsets)) |
| .SetHorizontalAlignment(gfx::ALIGN_LEFT) |
| .SetMultiLine(true) |
| .SizeToFit(/*fixed_width=*/content_width)) |
| .Build()); |
| } |
| |
| } // namespace |
| |
| // ClipboardHistoryMenuModelAdapter::MenuModelWithWillCloseCallback ------------ |
| |
| // Utility class that allows `ClipboardHistoryMenuModelAdapter` to run a task |
| // before its menu closes. |
| class ClipboardHistoryMenuModelAdapter::MenuModelWithWillCloseCallback |
| : public ui::SimpleMenuModel { |
| public: |
| MenuModelWithWillCloseCallback( |
| ui::SimpleMenuModel::Delegate* delegate, |
| ClipboardHistoryController::OnMenuClosingCallback callback) |
| : ui::SimpleMenuModel(delegate), callback_(std::move(callback)) {} |
| |
| // ui::SimpleMenuModel: |
| void MenuWillClose() override { |
| if (callback_) { |
| std::move(callback_).Run(will_paste_item_); |
| } |
| |
| ui::SimpleMenuModel::MenuWillClose(); |
| } |
| |
| void set_will_paste_item(bool will_paste_item) { |
| will_paste_item_ = will_paste_item; |
| } |
| |
| private: |
| ClipboardHistoryController::OnMenuClosingCallback callback_; |
| bool will_paste_item_ = false; |
| }; |
| |
| // ClipboardHistoryMenuModelAdapter::ScopedA11yIgnore -------------------------- |
| |
| // The scoped class to disable a11y for all items views. |
| class ClipboardHistoryMenuModelAdapter::ScopedA11yIgnore { |
| public: |
| explicit ScopedA11yIgnore( |
| ClipboardHistoryMenuModelAdapter* menu_model_adapter) |
| : menu_model_adapter_(menu_model_adapter) { |
| SetIgnoreA11yForAllItemViews(true); |
| } |
| ~ScopedA11yIgnore() { SetIgnoreA11yForAllItemViews(false); } |
| |
| private: |
| void SetIgnoreA11yForAllItemViews(bool ignore) { |
| for (auto& item_view_command_id_pair : |
| menu_model_adapter_->item_views_by_command_id_) { |
| views::View* item_view = item_view_command_id_pair.second; |
| item_view->GetViewAccessibility().OverrideIsIgnored(ignore); |
| } |
| } |
| |
| const raw_ptr<ClipboardHistoryMenuModelAdapter> menu_model_adapter_; |
| }; |
| |
| // ClipboardHistoryMenuModelAdapter -------------------------------------------- |
| |
| // static |
| std::unique_ptr<ClipboardHistoryMenuModelAdapter> |
| ClipboardHistoryMenuModelAdapter::Create( |
| ui::SimpleMenuModel::Delegate* delegate, |
| ClipboardHistoryController::OnMenuClosingCallback on_menu_closing_callback, |
| base::RepeatingClosure menu_closed_callback, |
| const ClipboardHistory* clipboard_history) { |
| return base::WrapUnique(new ClipboardHistoryMenuModelAdapter( |
| std::make_unique<MenuModelWithWillCloseCallback>( |
| delegate, std::move(on_menu_closing_callback)), |
| std::move(menu_closed_callback), clipboard_history)); |
| } |
| |
| ClipboardHistoryMenuModelAdapter::~ClipboardHistoryMenuModelAdapter() = default; |
| |
| void ClipboardHistoryMenuModelAdapter::Run( |
| const gfx::Rect& anchor_rect, |
| ui::MenuSourceType source_type, |
| crosapi::mojom::ClipboardHistoryControllerShowSource show_source, |
| const std::optional<base::Time>& menu_last_time_shown, |
| const std::optional<base::Time>& nudge_last_time_shown) { |
| DCHECK(!root_view_); |
| DCHECK(model_); |
| DCHECK(item_snapshots_.empty()); |
| DCHECK(item_views_by_command_id_.empty()); |
| |
| // `Run()` should be called at most once for an instance. |
| DCHECK(!run_before_); |
| run_before_ = true; |
| |
| menu_open_time_ = base::TimeTicks::Now(); |
| menu_show_source_ = show_source; |
| |
| int command_id = clipboard_history_util::kFirstItemCommandId; |
| const auto& items = clipboard_history_->GetItems(); |
| // Do not include the final kDeleteCommandId item in histograms, because it |
| // is not shown. |
| UMA_HISTOGRAM_COUNTS_100( |
| "Ash.ClipboardHistory.ContextMenu.NumberOfItemsShown", items.size()); |
| |
| size_t index = 0u; |
| if (IsHeaderRequired()) { |
| // Add a placeholder non-interactive item that will contain the clipboard |
| // history menu's header. |
| model_->AddTitle(std::u16string()); |
| header_index_ = index++; |
| } |
| |
| for (const auto& item : items) { |
| model_->AddItem(command_id, std::u16string()); |
| item_snapshots_.emplace(command_id, item); |
| ++command_id; |
| ++index; |
| } |
| |
| if (IsFooterRequired(show_source, menu_last_time_shown, |
| nudge_last_time_shown)) { |
| // Add a placeholder non-interactive item that will contain the clipboard |
| // history menu's footer, consisting of a separator (styled differently from |
| // the context menu separators) and educational text. |
| model_->AddTitle(std::u16string()); |
| footer_index_ = index++; |
| } |
| |
| // Start async rendering of HTML, if any exists. |
| // This factory may be nullptr in tests. |
| if (auto* clipboard_image_factory = ClipboardImageModelFactory::Get()) { |
| clipboard_image_factory->Activate(); |
| } |
| |
| std::unique_ptr<views::MenuItemView> root_view = CreateMenu(); |
| root_view_ = root_view.get(); |
| root_view_->SetTitle( |
| l10n_util::GetStringUTF16(IDS_CLIPBOARD_HISTORY_MENU_TITLE)); |
| menu_runner_ = std::make_unique<views::MenuRunner>( |
| std::move(root_view), views::MenuRunner::CONTEXT_MENU | |
| views::MenuRunner::USE_ASH_SYS_UI_LAYOUT | |
| views::MenuRunner::FIXED_ANCHOR); |
| menu_runner_->RunMenuAt( |
| /*parent=*/nullptr, /*button_controller=*/nullptr, anchor_rect, |
| views::MenuAnchorPosition::kBubbleBottomRight, source_type); |
| } |
| |
| bool ClipboardHistoryMenuModelAdapter::IsRunning() const { |
| return menu_runner_ && menu_runner_->IsRunning(); |
| } |
| |
| void ClipboardHistoryMenuModelAdapter::Cancel(bool will_paste_item) { |
| model_->set_will_paste_item(will_paste_item); |
| DCHECK(menu_runner_); |
| menu_runner_->Cancel(); |
| } |
| |
| std::optional<int> ClipboardHistoryMenuModelAdapter::GetFirstMenuItemCommand() { |
| if (item_views_by_command_id_.empty()) { |
| return std::nullopt; |
| } |
| |
| return base::ranges::min(item_views_by_command_id_, /*comp=*/{}, |
| /*proj=*/[](const auto& kv) { return kv.first; }) |
| .first; |
| } |
| |
| std::optional<int> |
| ClipboardHistoryMenuModelAdapter::GetSelectedMenuItemCommand() const { |
| DCHECK(root_view_); |
| |
| // `root_view_` may be selected if no menu item is under selection. |
| auto* menu_item = root_view_->GetMenuController()->GetSelectedMenuItem(); |
| return menu_item && menu_item != root_view_ |
| ? std::make_optional(menu_item->GetCommand()) |
| : std::nullopt; |
| } |
| |
| const ClipboardHistoryItem& |
| ClipboardHistoryMenuModelAdapter::GetItemFromCommandId(int command_id) const { |
| auto iter = item_snapshots_.find(command_id); |
| DCHECK(iter != item_snapshots_.cend()); |
| return iter->second; |
| } |
| |
| size_t ClipboardHistoryMenuModelAdapter::GetMenuItemsCount() const { |
| // We should not use `root_view_` to retrieve the item count. Because the |
| // menu item view is removed from `root_view_` asynchronously. |
| return item_views_by_command_id_.size(); |
| } |
| |
| void ClipboardHistoryMenuModelAdapter::SelectMenuItemWithCommandId( |
| int command_id) { |
| views::MenuItemView* selected_menu_item = |
| root_view_->GetMenuItemByID(command_id); |
| DCHECK(IsRunning()); |
| views::MenuController::GetActiveInstance()->SelectItemAndOpenSubmenu( |
| selected_menu_item); |
| } |
| |
| void ClipboardHistoryMenuModelAdapter::SelectMenuItemHoveredByMouse() { |
| // Find the menu item hovered by mouse. |
| auto iter = base::ranges::find_if(item_views_by_command_id_, |
| &views::View::IsMouseHovered, |
| &ItemViewsByCommandId::value_type::second); |
| |
| if (iter == item_views_by_command_id_.cend()) { |
| // If no item is hovered by mouse, cancel the selection on the child menu |
| // item by selecting the root menu item. |
| views::MenuController::GetActiveInstance()->SelectItemAndOpenSubmenu( |
| root_view_); |
| } else { |
| SelectMenuItemWithCommandId(iter->first); |
| } |
| } |
| |
| void ClipboardHistoryMenuModelAdapter::RemoveMenuItemWithCommandId( |
| int command_id) { |
| // Calculate `new_selected_command_id` before removing the item specified by |
| // `command_id` from data structures because the item to be removed is |
| // needed in calculation. |
| std::optional<int> new_selected_command_id = |
| CalculateSelectedCommandIdAfterDeletion(command_id); |
| |
| // Disable a11y for all item views. It ensures that when deleting multiple |
| // item views, only the one finally selected is announced. |
| if (!item_deletion_in_progress_count_) { |
| DCHECK(!scoped_ignore_); |
| scoped_ignore_ = std::make_unique<ScopedA11yIgnore>(this); |
| } |
| |
| // Update the menu item selection. |
| if (new_selected_command_id.has_value()) { |
| SelectMenuItemWithCommandId(*new_selected_command_id); |
| } else { |
| views::MenuController::GetActiveInstance()->SelectItemAndOpenSubmenu( |
| root_view_); |
| } |
| |
| auto item_view_to_delete_iter = item_views_by_command_id_.find(command_id); |
| DCHECK(item_view_to_delete_iter != item_views_by_command_id_.cend()); |
| |
| views::View* item_view_to_delete = item_view_to_delete_iter->second; |
| |
| // Configure `item_view_to_delete` to serve a11y features. |
| views::ViewAccessibility& view_accessibility = |
| item_view_to_delete->GetViewAccessibility(); |
| |
| // Polish the a11y announcement for deletion operation. |
| view_accessibility.OverrideDescription( |
| l10n_util::GetStringUTF16(IDS_CLIPBOARD_HISTORY_ITEM_DELETION)); |
| |
| // Enable a11y announcement for the view to be deleted. |
| view_accessibility.OverrideIsIgnored(false); |
| |
| // Disabling `item_view_to_delete` is more like implementation details. |
| // So do not expose it to users. |
| view_accessibility.OverrideIsEnabled(true); |
| |
| // Specify `item_view_to_delete`'s position in the set. Without calling |
| // `OverridePosInSet()`, the menu's size after deletion may be announced. |
| const int pos_in_set = std::distance(item_views_by_command_id_.begin(), |
| item_view_to_delete_iter) + |
| 1; |
| view_accessibility.OverridePosInSet(pos_in_set, |
| item_views_by_command_id_.size()); |
| |
| // Disable views to be removed in order to prevent them from handling |
| // events. |
| root_view_->GetMenuItemByID(command_id)->SetEnabled(false); |
| item_view_to_delete->SetEnabled(false); |
| |
| item_views_by_command_id_.erase(item_view_to_delete_iter); |
| |
| auto item_to_delete = item_snapshots_.find(command_id); |
| DCHECK(item_to_delete != item_snapshots_.end()); |
| item_snapshots_.erase(item_to_delete); |
| |
| // The current selected menu item may be accessed after item deletion. So |
| // postpone the menu item deletion. |
| ++item_deletion_in_progress_count_; |
| base::SequencedTaskRunner::GetCurrentDefault()->PostTask( |
| FROM_HERE, |
| base::BindOnce(&ClipboardHistoryMenuModelAdapter::RemoveItemView, |
| weak_ptr_factory_.GetWeakPtr(), command_id)); |
| } |
| |
| void ClipboardHistoryMenuModelAdapter::AdvancePseudoFocus(bool reverse) { |
| std::optional<int> selected_command = GetSelectedMenuItemCommand(); |
| |
| // If no item is selected, select the topmost or bottom menu item depending |
| // on the focus move direction. |
| if (!selected_command.has_value()) { |
| SelectMenuItemWithCommandId( |
| reverse ? item_views_by_command_id_.rbegin()->first |
| : clipboard_history_util::kFirstItemCommandId); |
| return; |
| } |
| |
| AdvancePseudoFocusFromSelectedItem(reverse); |
| } |
| |
| clipboard_history_util::Action |
| ClipboardHistoryMenuModelAdapter::GetActionForCommandId(int command_id) const { |
| auto selected_item_iter = item_views_by_command_id_.find(command_id); |
| DCHECK(selected_item_iter != item_views_by_command_id_.cend()); |
| |
| return selected_item_iter->second->action(); |
| } |
| |
| gfx::Rect ClipboardHistoryMenuModelAdapter::GetMenuBoundsInScreenForTest() |
| const { |
| DCHECK(root_view_); |
| return root_view_->GetSubmenu()->GetBoundsInScreen(); |
| } |
| |
| const views::MenuItemView* |
| ClipboardHistoryMenuModelAdapter::GetMenuItemViewAtForTest(size_t index) const { |
| DCHECK(root_view_); |
| return root_view_->GetSubmenu()->GetMenuItemAt(index); |
| } |
| |
| views::MenuItemView* ClipboardHistoryMenuModelAdapter::GetMenuItemViewAtForTest( |
| size_t index) { |
| return const_cast<views::MenuItemView*>( |
| const_cast<const ClipboardHistoryMenuModelAdapter*>(this) |
| ->GetMenuItemViewAtForTest(index)); |
| } |
| |
| const ui::SimpleMenuModel* ClipboardHistoryMenuModelAdapter::GetModelForTest() |
| const { |
| return model_.get(); |
| } |
| |
| ClipboardHistoryMenuModelAdapter::ClipboardHistoryMenuModelAdapter( |
| std::unique_ptr<MenuModelWithWillCloseCallback> model, |
| base::RepeatingClosure menu_closed_callback, |
| const ClipboardHistory* clipboard_history) |
| : views::MenuModelAdapter(model.get(), std::move(menu_closed_callback)), |
| model_(std::move(model)), |
| clipboard_history_(clipboard_history) {} |
| |
| void ClipboardHistoryMenuModelAdapter::AdvancePseudoFocusFromSelectedItem( |
| bool reverse) { |
| std::optional<int> selected_item_command = GetSelectedMenuItemCommand(); |
| DCHECK(selected_item_command.has_value()); |
| auto selected_item_iter = |
| item_views_by_command_id_.find(*selected_item_command); |
| DCHECK(selected_item_iter != item_views_by_command_id_.end()); |
| ClipboardHistoryItemView* selected_item_view = selected_item_iter->second; |
| |
| // Move the pseudo focus on the selected item view. Return early if the |
| // focused view does not change. |
| const bool selected_item_has_focus = |
| selected_item_view->AdvancePseudoFocus(reverse); |
| if (selected_item_has_focus) |
| return; |
| |
| int next_selected_item_command = -1; |
| ClipboardHistoryItemView* next_focused_view = nullptr; |
| |
| if (reverse) { |
| auto next_focused_item_iter = |
| selected_item_iter == item_views_by_command_id_.begin() |
| ? item_views_by_command_id_.rbegin() |
| : std::make_reverse_iterator(selected_item_iter); |
| next_selected_item_command = next_focused_item_iter->first; |
| next_focused_view = next_focused_item_iter->second; |
| } else { |
| auto next_focused_item_iter = std::next(selected_item_iter, 1); |
| if (next_focused_item_iter == item_views_by_command_id_.end()) |
| next_focused_item_iter = item_views_by_command_id_.begin(); |
| next_selected_item_command = next_focused_item_iter->first; |
| next_focused_view = next_focused_item_iter->second; |
| } |
| |
| // Advancing pseudo focus should precede the item selection. Because when an |
| // item view is selected, the selected view does not overwrite its pseudo |
| // focus if its pseudo focus is non-empty. It can ensure that the pseudo |
| // focus and the corresponding UI appearance update only once. |
| next_focused_view->AdvancePseudoFocus(reverse); |
| SelectMenuItemWithCommandId(next_selected_item_command); |
| } |
| |
| int ClipboardHistoryMenuModelAdapter::CalculateSelectedCommandIdAfterDeletion( |
| int command_id) const { |
| // If the menu item view to be deleted is the last one, Cancel() |
| // should be called so this function should not be hit. |
| DCHECK_GT(item_snapshots_.size(), 1u); |
| |
| auto item_to_delete = item_snapshots_.find(command_id); |
| DCHECK(item_to_delete != item_snapshots_.cend()); |
| |
| // Use the menu item right after the one to be deleted if any. Otherwise, |
| // select the previous one. |
| |
| auto next_item_iter = item_to_delete; |
| ++next_item_iter; |
| if (next_item_iter != item_snapshots_.cend()) |
| return next_item_iter->first; |
| |
| auto previous_item_iter = item_to_delete; |
| --previous_item_iter; |
| return previous_item_iter->first; |
| } |
| |
| void ClipboardHistoryMenuModelAdapter::RemoveItemView(int command_id) { |
| std::optional<int> original_selected_command_id = |
| GetSelectedMenuItemCommand(); |
| |
| // The menu item view and its corresponding command should be removed at the |
| // same time. Otherwise, it may run into check errors. |
| model_->RemoveItemAt(model_->GetIndexOfCommandId(command_id).value()); |
| root_view_->RemoveMenuItem(root_view_->GetMenuItemByID(command_id)); |
| if (const auto first_item_command_id = GetFirstMenuItemCommand(); |
| first_item_command_id && |
| chromeos::features::IsClipboardHistoryRefreshEnabled()) { |
| item_views_by_command_id_[*first_item_command_id]->ShowCtrlVLabel(); |
| } |
| root_view_->ChildrenChanged(); |
| |
| --item_deletion_in_progress_count_; |
| // Re-enable a11y for all item views when item deletion finally completes. |
| if (!item_deletion_in_progress_count_) { |
| DCHECK(scoped_ignore_); |
| scoped_ignore_.reset(); |
| } |
| |
| // `ChildrenChanged()` clears the selection. So restore the selection. |
| if (original_selected_command_id.has_value()) |
| SelectMenuItemWithCommandId(*original_selected_command_id); |
| } |
| |
| views::MenuItemView* ClipboardHistoryMenuModelAdapter::AppendMenuItem( |
| views::MenuItemView* menu, |
| ui::MenuModel* model, |
| size_t index) { |
| const int command_id = model->GetCommandIdAt(index); |
| |
| views::MenuItemView* container = menu->AppendMenuItem(command_id); |
| |
| // Ignore `container` in accessibility events handling. Let `item_view` |
| // handle. |
| container->GetViewAccessibility().OverrideIsIgnored(true); |
| |
| // Margins are managed by `ClipboardHistoryItemView`. |
| container->set_vertical_margin(0); |
| |
| if (header_index_ == index) { |
| CHECK_EQ(model->GetTypeAt(index), ui::MenuModel::ItemType::TYPE_TITLE); |
| InsertHeaderContent(container); |
| } else if (footer_index_ == index) { |
| CHECK_EQ(model->GetTypeAt(index), ui::MenuModel::ItemType::TYPE_TITLE); |
| InsertFooterContent(container, menu_show_source_.value()); |
| } else { |
| CHECK_EQ(model->GetTypeAt(index), ui::MenuModel::ItemType::TYPE_COMMAND); |
| std::unique_ptr<ClipboardHistoryItemView> item_view = |
| ClipboardHistoryItemView::CreateFromClipboardHistoryItem( |
| GetItemFromCommandId(command_id).id(), clipboard_history_, |
| container); |
| if (chromeos::features::IsClipboardHistoryRefreshEnabled() && |
| command_id == clipboard_history_util::kFirstItemCommandId) { |
| item_view->ShowCtrlVLabel(); |
| } |
| item_views_by_command_id_.insert( |
| std::make_pair(command_id, item_view.get())); |
| container->AddChildView(std::move(item_view)); |
| } |
| |
| return container; |
| } |
| |
| void ClipboardHistoryMenuModelAdapter::OnMenuClosed(views::MenuItemView* menu) { |
| // Terminate alive asynchronous calls on `RemoveItemView()`. It is pointless |
| // to update views when the menu is closed. |
| // Note that data members related to the asynchronous calls, such as |
| // `item_deletion_in_progress_count_` and `scoped_ignore_`, are not reset. |
| // Because when hitting here, this instance is going to be destructed soon. |
| weak_ptr_factory_.InvalidateWeakPtrs(); |
| |
| // This factory may be nullptr in tests. |
| if (auto* clipboard_image_factory = ClipboardImageModelFactory::Get()) { |
| clipboard_image_factory->Deactivate(); |
| } |
| const base::TimeDelta user_journey_time = |
| base::TimeTicks::Now() - menu_open_time_; |
| UMA_HISTOGRAM_TIMES("Ash.ClipboardHistory.ContextMenu.UserJourneyTime", |
| user_journey_time); |
| views::MenuModelAdapter::OnMenuClosed(menu); |
| item_views_by_command_id_.clear(); |
| |
| // This implementation of MenuModelAdapter does not have a widget so we need |
| // to manually notify the accessibility side of the closed menu. |
| aura::Window* active_window = window_util::GetActiveWindow(); |
| if (!active_window) |
| return; |
| views::Widget* active_widget = |
| views::Widget::GetWidgetForNativeView(active_window); |
| DCHECK(active_widget); |
| views::View* focused_view = |
| active_widget->GetFocusManager()->GetFocusedView(); |
| if (focused_view) { |
| focused_view->NotifyAccessibilityEvent(ax::mojom::Event::kMenuEnd, |
| /*send_native_event=*/true); |
| } |
| } |
| |
| } // namespace ash |