0

multipaste: Show educational footer when Ctrl+V long-press shows menu

This CL adds a non-interactive item at the end of the clipboard history
context menu when the menu is shown via the experimental Ctrl+V
long-press shortcut. This footer contains a message explaining to users
why the menu has appeared and how they can disable this behavior to
re-gain the keyboard auto-repeat behavior it overrides.

Screenshot: https://screenshot.googleplex.com/B2d6hCfhnn9EhTG

Note that this change makes no explicit checks for the clipboard history
long-press feature flag, because that check is made before the menu can
be shown with the Ctrl+V long-press show source.

Bug: b/267694412, b/267680961
Change-Id: Ibe2e8c347a1cc3bae7ae45f7c10be7f2fc2e5220
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4456521
Commit-Queue: Colin Kincaid <ckincaid@chromium.org>
Reviewed-by: David Black <dmblack@google.com>
Cr-Commit-Position: refs/heads/main@{#1146729}
This commit is contained in:
Colin Kincaid
2023-05-19 21:38:01 +00:00
committed by Chromium LUCI CQ
parent ba3ee2d049
commit e44977c6d0
11 changed files with 254 additions and 14 deletions

@ -3004,6 +3004,7 @@ test("ash_unittests") {
"child_accounts/parent_access_controller_impl_unittest.cc",
"clipboard/clipboard_history_controller_unittest.cc",
"clipboard/clipboard_history_item_unittest.cc",
"clipboard/clipboard_history_menu_model_adapter_unittest.cc",
"clipboard/clipboard_history_resource_manager_unittest.cc",
"clipboard/clipboard_history_unittest.cc",
"clipboard/clipboard_history_util_unittest.cc",

@ -5843,6 +5843,9 @@ Here are some things you can try to get started.
</message>
<!-- Multipaste -->
<message name="IDS_ASH_CLIPBOARD_HISTORY_CONTROL_V_LONGPRESS_FOOTER" desc="The message used to educate users about the clipboard history menu that shows when they hold down Ctrl+V.">
Youll see the clipboard when you press and hold Ctrl + V. You can turn off this shortcut by disabling the #clipboard-history-longpress flag in chrome://flags (os://flags if using Lacros).
</message>
<message name="IDS_ASH_MULTIPASTE_CONTEXTUAL_NUDGE" desc="The label used for the multipaste nudge, to be seen once the user copies and pastes multiple times in a short time span.">
Press <ph name="SHORTCUT_KEY_NAME">$1<ex>Launcher</ex></ph> + V to view your clipboard. The last 5 items you've copied are saved to your clipboard.
</message>

@ -0,0 +1 @@
a0e4cd5c7fddca027bc99748023f40a175549a36

@ -430,7 +430,7 @@ bool ClipboardHistoryControllerImpl::ShowMenu(
base::BindRepeating(&ClipboardHistoryControllerImpl::OnMenuClosed,
base::Unretained(this)),
clipboard_history_.get());
context_menu_->Run(anchor_rect, source_type);
context_menu_->Run(anchor_rect, source_type, show_source);
DCHECK(IsMenuShowing());
accelerator_target_->OnMenuShown();

@ -4,32 +4,86 @@
#include "ash/clipboard/clipboard_history_menu_model_adapter.h"
#include <string>
#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 "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/border.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/layout/box_layout.h"
#include "ui/views/layout/box_layout_view.h"
#include "ui/views/widget/widget.h"
namespace ash {
namespace {
// Populates `container` with a separator and a label containing educational
// content to appear at the bottom of the clipboard history menu.
void InsertFooterContent(views::MenuItemView* container) {
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>()
.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
@ -104,7 +158,8 @@ ClipboardHistoryMenuModelAdapter::~ClipboardHistoryMenuModelAdapter() = default;
void ClipboardHistoryMenuModelAdapter::Run(
const gfx::Rect& anchor_rect,
ui::MenuSourceType source_type) {
ui::MenuSourceType source_type,
crosapi::mojom::ClipboardHistoryControllerShowSource show_source) {
DCHECK(!root_view_);
DCHECK(model_);
DCHECK(item_snapshots_.empty());
@ -131,6 +186,14 @@ void ClipboardHistoryMenuModelAdapter::Run(
++command_id;
}
if (show_source == crosapi::mojom::ClipboardHistoryControllerShowSource::
kControlVLongpress) {
// Add placeholder non-interactive item that will contain a separator
// (styled differently from the context menu separators) and educational
// footer text.
model_->AddTitle(std::u16string());
}
// Start async rendering of HTML, if any exists.
// This factory may be nullptr in tests.
if (auto* clipboard_image_factory = ClipboardImageModelFactory::Get()) {
@ -145,7 +208,7 @@ void ClipboardHistoryMenuModelAdapter::Run(
views::MenuRunner::USE_ASH_SYS_UI_LAYOUT |
views::MenuRunner::FIXED_ANCHOR);
menu_runner_->RunMenuAt(
/*widget_owner=*/nullptr, /*menu_button_controller=*/nullptr, anchor_rect,
/*parent=*/nullptr, /*button_controller=*/nullptr, anchor_rect,
views::MenuAnchorPosition::kBubbleBottomRight, source_type);
}
@ -321,6 +384,11 @@ views::MenuItemView* ClipboardHistoryMenuModelAdapter::GetMenuItemViewAtForTest(
->GetMenuItemViewAtForTest(index));
}
const ui::SimpleMenuModel* ClipboardHistoryMenuModelAdapter::GetModelForTest()
const {
return model_.get();
}
ClipboardHistoryMenuModelAdapter::ClipboardHistoryMenuModelAdapter(
std::unique_ptr<MenuModelWithWillCloseCallback> model,
base::RepeatingClosure menu_closed_callback,
@ -430,12 +498,21 @@ views::MenuItemView* ClipboardHistoryMenuModelAdapter::AppendMenuItem(
// Margins are managed by `ClipboardHistoryItemView`.
container->SetMargins(/*top_margin=*/0, /*bottom_margin=*/0);
std::unique_ptr<ClipboardHistoryItemView> item_view =
ClipboardHistoryItemView::CreateFromClipboardHistoryItem(
GetItemFromCommandId(command_id).id(), clipboard_history_, container);
item_view->Init();
item_views_by_command_id_.insert(std::make_pair(command_id, item_view.get()));
container->AddChildView(std::move(item_view));
size_t num_items = clipboard_history_->GetItems().size();
if (index < num_items) {
std::unique_ptr<ClipboardHistoryItemView> item_view =
ClipboardHistoryItemView::CreateFromClipboardHistoryItem(
GetItemFromCommandId(command_id).id(), clipboard_history_,
container);
item_view->Init();
item_views_by_command_id_.insert(
std::make_pair(command_id, item_view.get()));
container->AddChildView(std::move(item_view));
} else {
CHECK_EQ(index, num_items);
CHECK_EQ(model->GetTypeAt(index), ui::MenuModel::ItemType::TYPE_TITLE);
InsertFooterContent(container);
}
return container;
}

@ -11,6 +11,7 @@
#include "ash/public/cpp/clipboard_history_controller.h"
#include "base/memory/raw_ptr.h"
#include "base/time/time.h"
#include "chromeos/crosapi/mojom/clipboard_history.mojom.h"
#include "third_party/abseil-cpp/absl/types/optional.h"
#include "ui/base/models/simple_menu_model.h"
#include "ui/views/controls/menu/menu_model_adapter.h"
@ -52,10 +53,11 @@ class ASH_EXPORT ClipboardHistoryMenuModelAdapter
const ClipboardHistoryMenuModelAdapter&) = delete;
~ClipboardHistoryMenuModelAdapter() override;
// Shows the menu anchored at `anchor_rect`, `source_type` indicates how the
// menu is triggered.
// Shows the menu anchored at `anchor_rect`. `source_type` and `show_source`
// indicate how the menu was triggered.
void Run(const gfx::Rect& anchor_rect,
ui::MenuSourceType source_type);
ui::MenuSourceType source_type,
crosapi::mojom::ClipboardHistoryControllerShowSource show_source);
// Returns if the menu is currently running.
bool IsRunning() const;
@ -95,6 +97,8 @@ class ASH_EXPORT ClipboardHistoryMenuModelAdapter
const views::MenuItemView* GetMenuItemViewAtForTest(size_t index) const;
views::MenuItemView* GetMenuItemViewAtForTest(size_t index);
const ui::SimpleMenuModel* GetModelForTest() const;
private:
class MenuModelWithWillCloseCallback;
class ScopedA11yIgnore;

@ -0,0 +1,98 @@
// Copyright 2023 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 "ash/clipboard/clipboard_history.h"
#include "ash/clipboard/clipboard_history_controller_impl.h"
#include "ash/shell.h"
#include "ash/test/ash_test_base.h"
#include "base/test/repeating_test_future.h"
#include "chromeos/crosapi/mojom/clipboard_history.mojom.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/base/clipboard/scoped_clipboard_writer.h"
#include "ui/base/models/simple_menu_model.h"
namespace ash {
using crosapi::mojom::ClipboardHistoryControllerShowSource;
namespace {
ClipboardHistoryControllerImpl* GetClipboardHistoryController() {
return Shell::Get()->clipboard_history_controller();
}
std::vector<ClipboardHistoryControllerShowSource>
GetClipboardHistoryShowSources() {
std::vector<ClipboardHistoryControllerShowSource> sources;
for (int i =
static_cast<int>(ClipboardHistoryControllerShowSource::kMinValue);
i <= static_cast<int>(ClipboardHistoryControllerShowSource::kMaxValue);
++i) {
sources.push_back(static_cast<ClipboardHistoryControllerShowSource>(i));
}
return sources;
}
} // namespace
// Base class for `ClipboardHistoryMenuModelAdapter` tests that run with each
// possible `ClipboardHistoryControllerShowSource`.
class ClipboardHistoryMenuModelAdapterShowSourceTest
: public AshTestBase,
public testing::WithParamInterface<ClipboardHistoryControllerShowSource> {
public:
// AshTestBase:
void SetUp() override {
AshTestBase::SetUp();
GetClipboardHistoryController()->set_confirmed_operation_callback_for_test(
operation_confirmed_future_.GetCallback());
}
void WriteTextToClipboardAndConfirm(const std::u16string& str) {
{
ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
scw.WriteText(str);
}
EXPECT_TRUE(operation_confirmed_future_.Take());
}
ClipboardHistoryControllerShowSource GetSource() const { return GetParam(); }
private:
base::test::RepeatingTestFuture<bool> operation_confirmed_future_;
};
INSTANTIATE_TEST_SUITE_P(All,
ClipboardHistoryMenuModelAdapterShowSourceTest,
testing::ValuesIn(GetClipboardHistoryShowSources()));
// Verifies that the clipboard history menu has an educational footer iff it was
// shown by the Ctrl+V long-press shortcut.
TEST_P(ClipboardHistoryMenuModelAdapterShowSourceTest,
ControlVLongpressShowsFooter) {
// Write items to clipboard history so that the menu can show.
WriteTextToClipboardAndConfirm(u"A");
WriteTextToClipboardAndConfirm(u"B");
// Show the clipboard history menu.
auto* controller = GetClipboardHistoryController();
EXPECT_TRUE(controller->ShowMenu(
gfx::Rect(), ui::MenuSourceType::MENU_SOURCE_NONE, GetSource()));
EXPECT_TRUE(controller->IsMenuShowing());
// Verify that iff the menu was shown via Ctrl+V long-press, the menu has an
// educational footer item; otherwise, the number of items in the menu should
// match the number of items in clipboard history.
const auto* model = controller->context_menu_for_test()->GetModelForTest();
EXPECT_EQ(controller->history()->GetItems().size(), 2u);
if (GetSource() == ClipboardHistoryControllerShowSource::kControlVLongpress) {
ASSERT_EQ(model->GetItemCount(), 3u);
EXPECT_EQ(model->GetTypeAt(2u), ui::MenuModel::ItemType::TYPE_TITLE);
} else {
EXPECT_EQ(model->GetItemCount(), 2u);
}
}
} // namespace ash

@ -24,6 +24,7 @@
#include "ui/gfx/canvas.h"
#include "ui/gfx/image/canvas_image_source.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/views/controls/menu/menu_config.h"
namespace ash::clipboard_history_util {
@ -250,4 +251,8 @@ GetItemDescriptorsFrom(const std::list<ClipboardHistoryItem>& items) {
return item_descriptors;
}
int GetPreferredItemViewWidth() {
return views::MenuConfig::instance().touchable_menu_min_width;
}
} // namespace ash::clipboard_history_util

@ -161,6 +161,9 @@ ASH_EXPORT ui::ImageModel GetHtmlPreviewPlaceholder();
std::vector<crosapi::mojom::ClipboardHistoryItemDescriptor>
GetItemDescriptorsFrom(const std::list<ClipboardHistoryItem>& items);
// Calculates the preferred width for clipboard history menu item views.
int GetPreferredItemViewWidth();
} // namespace clipboard_history_util
} // namespace ash

@ -25,7 +25,6 @@
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/animation/ink_drop.h"
#include "ui/views/border.h"
#include "ui/views/controls/menu/menu_config.h"
#include "ui/views/controls/menu/menu_item_view.h"
#include "ui/views/layout/fill_layout.h"
@ -270,7 +269,7 @@ const ClipboardHistoryItem* ClipboardHistoryItemView::GetClipboardHistoryItem()
gfx::Size ClipboardHistoryItemView::CalculatePreferredSize() const {
const int preferred_width =
views::MenuConfig::instance().touchable_menu_min_width;
clipboard_history_util::GetPreferredItemViewWidth();
return gfx::Size(preferred_width, GetHeightForWidth(preferred_width));
}

@ -1503,6 +1503,55 @@ IN_PROC_BROWSER_TEST_F(ClipboardHistoryTextfieldBrowserTest,
EXPECT_TRUE(textfield_->GetText().empty());
}
// Verifies that clicking the clipboard history's menu does nothing and that tab
// and arrow key traversal pass over the footer.
IN_PROC_BROWSER_TEST_F(ClipboardHistoryTextfieldBrowserTest,
FooterNotInteractive) {
// Write some things to the clipboard.
SetClipboardText("A");
SetClipboardText("B");
// Show the clipboard history menu via the Ctrl+V long-press shortcut so that
// the menu's educational footer shows.
EXPECT_TRUE(GetClipboardHistoryController()->ShowMenu(
gfx::Rect(), ui::MenuSourceType::MENU_SOURCE_NONE,
crosapi::mojom::ClipboardHistoryControllerShowSource::
kControlVLongpress));
EXPECT_TRUE(GetClipboardHistoryController()->IsMenuShowing());
// Verify that the menu has two clipboard history items and a third item (the
// footer).
const auto* menu = GetClipboardHistoryController()->context_menu_for_test();
EXPECT_EQ(menu->GetMenuItemsCount(), 2u);
ASSERT_EQ(menu->GetModelForTest()->GetItemCount(), 3u);
// Verify that clicking on the footer does nothing.
EXPECT_TRUE(textfield_->GetText().empty());
const auto* footer = menu->GetMenuItemViewAtForTest(/*index=*/2);
GetEventGenerator()->MoveMouseTo(footer->GetBoundsInScreen().CenterPoint());
GetEventGenerator()->ClickLeftButton();
EXPECT_TRUE(textfield_->GetText().empty());
// Verify that traversing over the menu with arrow keys skips the footer.
const auto* item1 = menu->GetMenuItemViewAtForTest(/*index=*/0);
const auto* item2 = menu->GetMenuItemViewAtForTest(/*index=*/1);
PressAndRelease(ui::VKEY_DOWN);
EXPECT_TRUE(item1->IsSelected());
PressAndRelease(ui::VKEY_DOWN);
EXPECT_TRUE(item2->IsSelected());
PressAndRelease(ui::VKEY_DOWN);
EXPECT_TRUE(item1->IsSelected());
// Verify that traversing over the menu with the Tab key (two presses at a
// time for each item's main button and delete button) skips the footer.
PressAndRelease(ui::VKEY_TAB);
PressAndRelease(ui::VKEY_TAB);
EXPECT_TRUE(item2->IsSelected());
PressAndRelease(ui::VKEY_TAB);
PressAndRelease(ui::VKEY_TAB);
EXPECT_TRUE(item1->IsSelected());
}
class FakeDataTransferPolicyController
: public ui::DataTransferPolicyController {
public: