blob: 5e0a2b23c11a455b76b9516dfdeeaaf07a0223a2 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// 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/search_result_tile_item_view.h"
#include <utility>
#include "ash/app_list/app_list_metrics.h"
#include "ash/app_list/app_list_view_delegate.h"
#include "ash/app_list/model/app_list_model.h"
#include "ash/app_list/model/search/search_model.h"
#include "ash/app_list/model/search/search_result.h"
#include "ash/app_list/views/app_list_item_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/app_list/vector_icons/vector_icons.h"
#include "ash/public/cpp/pagination/pagination_model.h"
#include "ash/public/interfaces/app_list.mojom.h"
#include "ash/public/interfaces/app_list_view.mojom.h"
#include "base/bind.h"
#include "base/i18n/number_formatting.h"
#include "base/metrics/histogram_macros.h"
#include "base/metrics/user_metrics.h"
#include "base/strings/utf_string_conversions.h"
#include "ui/accessibility/ax_node_data.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/color_palette.h"
#include "ui/gfx/image/image_skia_operations.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/strings/grit/ui_strings.h"
#include "ui/views/background.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.h"
#include "ui/views/controls/menu/menu_runner.h"
#include "ui/views/focus/focus_manager.h"
namespace app_list {
namespace {
constexpr int kSearchTileWidth = 80;
constexpr int kSearchTileTopPadding = 4;
constexpr int kSearchTitleSpacing = 7;
constexpr int kSearchPriceSize = 37;
constexpr int kSearchRatingSize = 26;
constexpr int kSearchRatingStarSize = 12;
constexpr int kSearchRatingStarHorizontalSpacing = 1;
constexpr int kSearchRatingStarVerticalSpacing = 2;
// Text line height in the search result tile.
constexpr int kTileTextLineHeight = 16;
// Delta applied to the font size of SearchResultTile title.
constexpr int kSearchResultTileTitleTextSizeDelta = 1;
constexpr int kIconSelectedSize = 56;
constexpr int kIconSelectedCornerRadius = 4;
// Icon selected color, Google Grey 900 8%.
constexpr int kIconSelectedColor = SkColorSetA(gfx::kGoogleGrey900, 0x14);
// Offset for centering star rating when there is no price.
constexpr int kSearchRatingCenteringOffset =
((kSearchTileWidth -
(kSearchRatingSize + kSearchRatingStarHorizontalSpacing +
kSearchRatingStarSize)) /
2);
constexpr SkColor kSearchTitleColor = gfx::kGoogleGrey900;
constexpr SkColor kSearchAppRatingColor = gfx::kGoogleGrey700;
constexpr SkColor kSearchAppPriceColor = gfx::kGoogleGreen600;
constexpr SkColor kSearchRatingStarColor = gfx::kGoogleGrey700;
} // namespace
SearchResultTileItemView::SearchResultTileItemView(
AppListViewDelegate* view_delegate,
ash::PaginationModel* pagination_model,
bool show_in_apps_page)
: view_delegate_(view_delegate),
pagination_model_(pagination_model),
is_play_store_app_search_enabled_(
app_list_features::IsPlayStoreAppSearchEnabled()),
is_app_reinstall_recommendation_enabled_(
app_list_features::IsAppReinstallZeroStateEnabled()),
show_in_apps_page_(show_in_apps_page),
weak_ptr_factory_(this) {
SetFocusBehavior(FocusBehavior::ALWAYS);
// When |result_| is null, the tile is invisible. Calling SetSearchResult with
// a non-null item makes the tile visible.
SetVisible(false);
// Prevent the icon view from interfering with our mouse events.
icon_ = new views::ImageView;
icon_->set_can_process_events_within_subtree(false);
icon_->SetVerticalAlignment(views::ImageView::Alignment::kLeading);
AddChildView(icon_);
badge_ = new views::ImageView;
badge_->set_can_process_events_within_subtree(false);
badge_->SetVerticalAlignment(views::ImageView::Alignment::kLeading);
badge_->SetVisible(false);
AddChildView(badge_);
title_ = new views::Label;
title_->SetAutoColorReadabilityEnabled(false);
title_->SetEnabledColor(AppListConfig::instance().grid_title_color());
title_->SetLineHeight(kTileTextLineHeight);
title_->SetHorizontalAlignment(gfx::ALIGN_CENTER);
title_->SetHandlesTooltips(false);
title_->SetAllowCharacterBreak(true);
AddChildView(title_);
if (is_play_store_app_search_enabled_ ||
is_app_reinstall_recommendation_enabled_) {
rating_ = new views::Label;
rating_->SetEnabledColor(kSearchAppRatingColor);
rating_->SetLineHeight(kTileTextLineHeight);
rating_->SetHorizontalAlignment(gfx::ALIGN_RIGHT);
rating_->SetVisible(false);
AddChildView(rating_);
rating_star_ = new views::ImageView;
rating_star_->set_can_process_events_within_subtree(false);
rating_star_->SetVerticalAlignment(views::ImageView::Alignment::kLeading);
rating_star_->SetImage(gfx::CreateVectorIcon(
kBadgeRatingIcon, kSearchRatingStarSize, kSearchRatingStarColor));
rating_star_->SetVisible(false);
AddChildView(rating_star_);
price_ = new views::Label;
price_->SetEnabledColor(kSearchAppPriceColor);
price_->SetLineHeight(kTileTextLineHeight);
price_->SetHorizontalAlignment(gfx::ALIGN_LEFT);
price_->SetVisible(false);
AddChildView(price_);
}
set_context_menu_controller(this);
}
SearchResultTileItemView::~SearchResultTileItemView() {
ClearResult();
}
void SearchResultTileItemView::OnResultChanged() {
// Handle the case where this may be called from a nested run loop while its
// context menu is showing. This cancels the menu (it's for the old item).
context_menu_.reset();
SetVisible(!!result());
if (!result())
return;
SetTitle(result()->title());
SetRating(result()->rating());
SetPrice(result()->formatted_price());
const gfx::FontList& font = AppListConfig::instance().app_title_font();
if (IsSuggestedAppTileShownInAppPage()) {
title_->SetFontList(font);
title_->SetEnabledColor(AppListConfig::instance().grid_title_color());
} else {
// Set solid color background to avoid broken text. See crbug.com/746563.
if (rating_) {
rating_->SetBackground(views::CreateSolidBackground(
AppListConfig::instance().card_background_color()));
if (!IsSuggestedAppTile()) {
// App search results use different fonts than AppList apps.
rating_->SetFontList(
ui::ResourceBundle::GetSharedInstance().GetFontList(
AppListConfig::instance().search_result_title_font_style()));
} else {
rating_->SetFontList(font);
}
}
if (price_) {
price_->SetBackground(views::CreateSolidBackground(
AppListConfig::instance().card_background_color()));
if (!IsSuggestedAppTile()) {
// App search results use different fonts than AppList apps.
price_->SetFontList(ui::ResourceBundle::GetSharedInstance().GetFontList(
AppListConfig::instance().search_result_title_font_style()));
} else {
price_->SetFontList(font);
}
}
title_->SetBackground(views::CreateSolidBackground(
AppListConfig::instance().card_background_color()));
if (!IsSuggestedAppTile()) {
// App search results use different fonts than AppList apps.
title_->SetFontList(
ui::ResourceBundle::GetSharedInstance()
.GetFontList(
AppListConfig::instance().search_result_title_font_style())
.DeriveWithSizeDelta(kSearchResultTileTitleTextSizeDelta));
} else {
title_->SetFontList(font);
}
title_->SetEnabledColor(kSearchTitleColor);
}
title_->SetMaxLines(2);
title_->SetMultiLine(
(result()->display_type() == ash::SearchResultDisplayType::kTile ||
(IsSuggestedAppTile() && !show_in_apps_page_)) &&
result()->result_type() == ash::SearchResultType::kInstalledApp);
// If the new icon is null, it's being decoded asynchronously. Not updating it
// now to prevent flickering from showing an empty icon while decoding.
if (!result()->icon().isNull())
OnMetadataChanged();
UpdateAccessibleName();
}
base::string16 SearchResultTileItemView::ComputeAccessibleName() const {
base::string16 accessible_name;
if (!result()->accessible_name().empty())
accessible_name = result()->accessible_name();
else
accessible_name = title_->text();
if (rating_ && rating_->GetVisible()) {
accessible_name +=
base::UTF8ToUTF16(", ") +
l10n_util::GetStringFUTF16(IDS_APP_ACCESSIBILITY_STAR_RATING_ARC,
rating_->text());
}
if (price_ && price_->GetVisible())
accessible_name += base::UTF8ToUTF16(", ") + price_->text();
if (result()->result_type() ==
ash::SearchResultType::kPlayStoreReinstallApp) {
accessible_name +=
base::UTF8ToUTF16(", ") +
l10n_util::GetStringUTF16(IDS_APP_ACCESSIBILITY_APP_RECOMMENDATION_ARC);
}
return accessible_name;
}
void SearchResultTileItemView::SetParentBackgroundColor(SkColor color) {
parent_background_color_ = color;
UpdateBackgroundColor();
}
void SearchResultTileItemView::ButtonPressed(views::Button* sender,
const ui::Event& event) {
ActivateResult(event.flags());
}
void SearchResultTileItemView::GetAccessibleNodeData(
ui::AXNodeData* node_data) {
views::Button::GetAccessibleNodeData(node_data);
// Specify |ax::mojom::StringAttribute::kDescription| with an empty string, so
// that long truncated names are not read twice. Details of this issue: - The
// Play Store app's name is shown in a label |title_|. - If the name is too
// long, it'll get truncated and the full name will
// go to the label's tooltip.
// - SearchResultTileItemView uses that label's tooltip as its tooltip.
// - If a view doesn't have |ax::mojom::StringAttribute::kDescription| defined
// in the
// |AXNodeData|, |AXViewObjWrapper::Serialize| will use the tooltip text
// as its description.
// - We're customizing this view's accessible name, so it get focused
// ChromeVox will read its accessible name and then its description.
node_data->AddStringAttribute(ax::mojom::StringAttribute::kDescription, "");
}
bool SearchResultTileItemView::OnKeyPressed(const ui::KeyEvent& event) {
// Return early if |result()| was deleted due to the search result list
// changing. see crbug.com/801142
if (!result())
return true;
if (event.key_code() == ui::VKEY_RETURN) {
ActivateResult(event.flags());
return true;
}
return false;
}
void SearchResultTileItemView::OnFocus() {
if (pagination_model_ && IsSuggestedAppTile() &&
view_delegate_->GetModel()->state() == ash::AppListState::kStateApps) {
// Go back to first page when app in suggestions container is focused.
pagination_model_->SelectPage(0, false);
} else {
ScrollRectToVisible(GetLocalBounds());
}
SetBackgroundHighlighted(true);
UpdateBackgroundColor();
}
void SearchResultTileItemView::OnBlur() {
SetBackgroundHighlighted(false);
UpdateBackgroundColor();
}
void SearchResultTileItemView::StateChanged(ButtonState old_state) {
UpdateBackgroundColor();
}
void SearchResultTileItemView::PaintButtonContents(gfx::Canvas* canvas) {
if (!result() || !background_highlighted())
return;
gfx::Rect rect(GetContentsBounds());
cc::PaintFlags flags;
flags.setAntiAlias(true);
flags.setStyle(cc::PaintFlags::kFill_Style);
if (IsSuggestedAppTileShownInAppPage()) {
rect.ClampToCenteredSize(AppListConfig::instance().grid_focus_size());
flags.setColor(AppListConfig::instance().grid_selected_color());
canvas->DrawRoundRect(gfx::RectF(rect),
AppListConfig::instance().grid_focus_corner_radius(),
flags);
} else {
const int kLeftRightPadding = (rect.width() - kIconSelectedSize) / 2;
rect.Inset(kLeftRightPadding, 0);
rect.set_height(kIconSelectedSize);
flags.setColor(kIconSelectedColor);
canvas->DrawRoundRect(gfx::RectF(rect), kIconSelectedCornerRadius, flags);
}
}
void SearchResultTileItemView::OnMetadataChanged() {
SetIcon(result()->icon());
SetTitle(result()->title());
SetBadgeIcon(result()->badge_icon());
SetRating(result()->rating());
SetPrice(result()->formatted_price());
Layout();
}
void SearchResultTileItemView::ShowContextMenuForViewImpl(
views::View* source,
const gfx::Point& point,
ui::MenuSourceType source_type) {
// |result()| could be null when result list is changing.
if (!result())
return;
view_delegate_->GetSearchResultContextMenuModel(
result()->id(),
base::BindOnce(&SearchResultTileItemView::OnGetContextMenuModel,
weak_ptr_factory_.GetWeakPtr(), source, point,
source_type));
}
void SearchResultTileItemView::OnGetContextMenuModel(
views::View* source,
const gfx::Point& point,
ui::MenuSourceType source_type,
std::vector<ash::mojom::MenuItemPtr> menu) {
if (menu.empty() || (context_menu_ && context_menu_->IsShowingMenu()))
return;
gfx::Rect anchor_rect = source->GetBoundsInScreen();
// Anchor the menu to the same rect that is used for selection highlight.
anchor_rect.ClampToCenteredSize(AppListConfig::instance().grid_focus_size());
context_menu_ = std::make_unique<AppListMenuModelAdapter>(
result()->id(), GetWidget(), source_type, this, GetAppType(),
base::BindOnce(&SearchResultTileItemView::OnMenuClosed,
weak_ptr_factory_.GetWeakPtr()),
view_delegate_->GetSearchModel()->tablet_mode());
context_menu_->Build(std::move(menu));
context_menu_->Run(anchor_rect, views::MenuAnchorPosition::kBubbleRight,
views::MenuRunner::HAS_MNEMONICS |
views::MenuRunner::USE_TOUCHABLE_LAYOUT |
views::MenuRunner::CONTEXT_MENU |
views::MenuRunner::FIXED_ANCHOR);
source->RequestFocus();
}
void SearchResultTileItemView::OnMenuClosed() {
OnBlur();
}
void SearchResultTileItemView::ExecuteCommand(int command_id, int event_flags) {
if (result()) {
view_delegate_->SearchResultContextMenuItemSelected(
result()->id(), command_id, event_flags,
ash::mojom::AppListLaunchType::kAppSearchResult);
}
}
void SearchResultTileItemView::ActivateResult(int event_flags) {
LogAppLaunchForSuggestedApp();
RecordSearchResultOpenSource(result(), view_delegate_->GetModel(),
view_delegate_->GetSearchModel());
view_delegate_->OpenSearchResult(
result()->id(), event_flags,
ash::mojom::AppListLaunchedFrom::kLaunchedFromSearchBox,
ash::mojom::AppListLaunchType::kAppSearchResult, index_in_container());
view_delegate_->LogResultLaunchHistogram(
SearchResultLaunchLocation::kTileList, index_in_container());
}
void SearchResultTileItemView::SetIcon(const gfx::ImageSkia& icon) {
const int icon_size = AppListConfig::instance().search_tile_icon_dimension();
gfx::ImageSkia resized(gfx::ImageSkiaOperations::CreateResizedImage(
icon, skia::ImageOperations::RESIZE_BEST,
gfx::Size(icon_size, icon_size)));
icon_->SetImage(resized);
}
void SearchResultTileItemView::SetBadgeIcon(const gfx::ImageSkia& badge_icon) {
if (badge_icon.isNull()) {
badge_->SetVisible(false);
return;
}
gfx::ImageSkia resized_badge_icon(
gfx::ImageSkiaOperations::CreateResizedImage(
badge_icon, skia::ImageOperations::RESIZE_BEST,
AppListConfig::instance().search_tile_badge_icon_size()));
gfx::ShadowValues shadow_values;
shadow_values.push_back(
gfx::ShadowValue(gfx::Vector2d(0, 1), 0, SkColorSetARGB(0x33, 0, 0, 0)));
shadow_values.push_back(
gfx::ShadowValue(gfx::Vector2d(0, 1), 2, SkColorSetARGB(0x33, 0, 0, 0)));
badge_->SetImage(gfx::ImageSkiaOperations::CreateImageWithDropShadow(
resized_badge_icon, shadow_values));
badge_->SetVisible(true);
}
void SearchResultTileItemView::SetTitle(const base::string16& title) {
title_->SetText(title);
title_->NotifyAccessibilityEvent(ax::mojom::Event::kTextChanged, true);
}
void SearchResultTileItemView::SetRating(float rating) {
if (!rating_)
return;
if (rating < 0) {
rating_->SetVisible(false);
rating_star_->SetVisible(false);
return;
}
rating_->SetText(base::FormatDouble(rating, 1));
rating_->SetVisible(true);
rating_star_->SetVisible(true);
}
void SearchResultTileItemView::SetPrice(const base::string16& price) {
if (!price_)
return;
if (price.empty()) {
price_->SetVisible(false);
return;
}
price_->SetText(price);
price_->SetVisible(true);
}
AppListMenuModelAdapter::AppListViewAppType
SearchResultTileItemView::GetAppType() const {
if (IsSuggestedAppTile()) {
if (view_delegate_->GetModel()->state_fullscreen() ==
ash::mojom::AppListViewState::kPeeking) {
return AppListMenuModelAdapter::PEEKING_SUGGESTED;
} else {
return AppListMenuModelAdapter::FULLSCREEN_SUGGESTED;
}
} else {
if (view_delegate_->GetModel()->state_fullscreen() ==
ash::mojom::AppListViewState::kHalf) {
return AppListMenuModelAdapter::HALF_SEARCH_RESULT;
} else if (view_delegate_->GetModel()->state_fullscreen() ==
ash::mojom::AppListViewState::kFullscreenSearch) {
return AppListMenuModelAdapter::FULLSCREEN_SEARCH_RESULT;
}
}
NOTREACHED();
return AppListMenuModelAdapter::APP_LIST_APP_TYPE_LAST;
}
bool SearchResultTileItemView::IsSuggestedAppTile() const {
return result() && result()->display_type() ==
ash::SearchResultDisplayType::kRecommendation;
}
bool SearchResultTileItemView::IsSuggestedAppTileShownInAppPage() const {
return IsSuggestedAppTile() && show_in_apps_page_;
}
void SearchResultTileItemView::LogAppLaunchForSuggestedApp() const {
// Only log the app launch if the class is being used as a suggested app.
if (!IsSuggestedAppTile())
return;
// We only need to record opening the installed app in zero state, no need to
// record the opening of a fast re-installed app, since the latter is already
// recorded in ArcAppReinstallAppResult::Open.
if (result()->result_type() !=
ash::SearchResultType::kPlayStoreReinstallApp) {
base::RecordAction(
base::UserMetricsAction("AppList_ZeroStateOpenInstalledApp"));
}
}
void SearchResultTileItemView::UpdateBackgroundColor() {
// Tell the label what color it will be drawn onto. It will use whether the
// background color is opaque or transparent to decide whether to use subpixel
// rendering. Does not actually set the label's background color.
title_->SetBackgroundColor(parent_background_color_);
SchedulePaint();
}
void SearchResultTileItemView::Layout() {
gfx::Rect rect(GetContentsBounds());
if (rect.IsEmpty() || !result())
return;
if (IsSuggestedAppTileShownInAppPage()) {
icon_->SetBoundsRect(AppListItemView::GetIconBoundsForTargetViewBounds(
rect, icon_->GetImage().size()));
title_->SetBoundsRect(AppListItemView::GetTitleBoundsForTargetViewBounds(
rect, title_->GetPreferredSize()));
} else {
gfx::Rect icon_rect(rect);
icon_rect.ClampToCenteredSize(icon_->GetImage().size());
icon_rect.set_y(kSearchTileTopPadding);
icon_->SetBoundsRect(icon_rect);
const int badge_icon_dimension =
AppListConfig::instance().search_tile_badge_icon_dimension();
const int badge_icon_offset =
AppListConfig::instance().search_tile_badge_icon_offset();
const gfx::Rect badge_rect(
icon_rect.right() - badge_icon_dimension + badge_icon_offset,
icon_rect.bottom() - badge_icon_dimension + badge_icon_offset,
badge_icon_dimension, badge_icon_dimension);
badge_->SetBoundsRect(badge_rect);
rect.set_y(icon_rect.bottom() + kSearchTitleSpacing);
rect.set_height(title_->GetPreferredSize().height());
title_->SetBoundsRect(rect);
// If there is no price set, we center the rating.
const bool center_rating =
rating_ && rating_star_ && price_ && price_->text().empty();
const int rating_horizontal_offset =
center_rating ? kSearchRatingCenteringOffset : 0;
if (rating_) {
gfx::Rect rating_rect(rect);
rating_rect.Inset(rating_horizontal_offset,
title_->GetPreferredSize().height(), 0, 0);
rating_rect.set_size(rating_->GetPreferredSize());
rating_rect.set_width(kSearchRatingSize);
rating_->SetBoundsRect(rating_rect);
}
if (rating_star_) {
gfx::Rect rating_star_rect(rect);
rating_star_rect.Inset(rating_horizontal_offset + kSearchRatingSize +
kSearchRatingStarHorizontalSpacing,
title_->GetPreferredSize().height() +
kSearchRatingStarVerticalSpacing,
0, 0);
rating_star_rect.set_size(rating_star_->GetPreferredSize());
rating_star_->SetBoundsRect(rating_star_rect);
}
if (price_) {
gfx::Rect price_rect(rect);
price_rect.Inset(rect.width() - kSearchPriceSize,
title_->GetPreferredSize().height(), 0, 0);
price_rect.set_size(price_->GetPreferredSize());
price_->SetBoundsRect(price_rect);
}
}
}
const char* SearchResultTileItemView::GetClassName() const {
return "SearchResultTileItemView";
}
gfx::Size SearchResultTileItemView::CalculatePreferredSize() const {
if (!result())
return gfx::Size();
if (IsSuggestedAppTileShownInAppPage()) {
return gfx::Size(AppListConfig::instance().grid_tile_width(),
AppListConfig::instance().grid_tile_height());
}
return gfx::Size(kSearchTileWidth,
AppListConfig::instance().search_tile_height());
}
base::string16 SearchResultTileItemView::GetTooltipText(
const gfx::Point& p) const {
// Use the label to generate a tooltip, so that it will consider its text
// truncation in making the tooltip. We do not want the label itself to have a
// tooltip, so we only temporarily enable it to get the tooltip text from the
// label, then disable it again.
title_->SetHandlesTooltips(true);
base::string16 tooltip = title_->GetTooltipText(p);
title_->SetHandlesTooltips(false);
return tooltip;
}
} // namespace app_list