blob: c0a09f783e4bb3327164e893d66adb64866f6cb0 [file] [log] [blame]
// 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 "ui/views/bubble/bubble_dialog_model_host.h"
#include <utility>
#include "base/memory/raw_ptr.h"
#include "ui/accessibility/ax_enums.mojom.h"
#include "ui/base/class_property.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/combobox_model.h"
#include "ui/base/models/dialog_model.h"
#include "ui/base/models/dialog_model_field.h"
#include "ui/base/models/simple_combobox_model.h"
#include "ui/base/mojom/dialog_button.mojom.h"
#include "ui/base/mojom/ui_base_types.mojom-shared.h"
#include "ui/base/ui_base_types.h"
#include "ui/gfx/color_utils.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/strings/grit/ui_strings.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/border.h"
#include "ui/views/bubble/bubble_dialog_delegate_view.h"
#include "ui/views/bubble/bubble_dialog_utils.h"
#include "ui/views/controls/button/checkbox.h"
#include "ui/views/controls/button/label_button_border.h"
#include "ui/views/controls/button/md_text_button.h"
#include "ui/views/controls/combobox/combobox.h"
#include "ui/views/controls/editable_combobox/editable_password_combobox.h"
#include "ui/views/controls/label.h"
#include "ui/views/controls/scroll_view.h"
#include "ui/views/controls/separator.h"
#include "ui/views/controls/styled_label.h"
#include "ui/views/controls/textfield/textfield.h"
#include "ui/views/controls/theme_tracking_image_view.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/layout/box_layout_view.h"
#include "ui/views/layout/fill_layout.h"
#include "ui/views/layout/layout_provider.h"
#include "ui/views/style/typography.h"
#include "ui/views/view_class_properties.h"
namespace views {
namespace {
// Extra margin to be added to contents view when it's inside a scroll view.
constexpr int kScrollViewVerticalMargin = 2;
BubbleDialogModelHost::FieldType GetFieldTypeForField(
ui::DialogModelField* field) {
DCHECK(field);
switch (field->type()) {
case ui::DialogModelField::kParagraph:
return BubbleDialogModelHost::FieldType::kText;
case ui::DialogModelField::kCheckbox:
return BubbleDialogModelHost::FieldType::kControl;
case ui::DialogModelField::kTextfield:
return BubbleDialogModelHost::FieldType::kControl;
case ui::DialogModelField::kPasswordField:
return BubbleDialogModelHost::FieldType::kControl;
case ui::DialogModelField::kCombobox:
return BubbleDialogModelHost::FieldType::kControl;
case ui::DialogModelField::kMenuItem:
return BubbleDialogModelHost::FieldType::kMenuItem;
case ui::DialogModelField::kTitleItem:
// No need to handle titles.
NOTREACHED();
case ui::DialogModelField::kSection:
// TODO(pbos): Handle nested/multiple sections.
NOTREACHED();
case ui::DialogModelField::kSeparator:
return BubbleDialogModelHost::FieldType::kMenuItem;
case ui::DialogModelField::kCustom:
return static_cast<BubbleDialogModelHost::CustomView*>(
field->AsCustomField()->field())
->field_type();
}
}
int GetFieldTopMargin(LayoutProvider* layout_provider,
const BubbleDialogModelHost::FieldType& field_type,
const BubbleDialogModelHost::FieldType& last_field_type) {
// Menu item preceded by non-menu item should have margin
if (field_type == BubbleDialogModelHost::FieldType::kMenuItem &&
last_field_type == BubbleDialogModelHost::FieldType::kMenuItem) {
return 0;
}
const bool is_control_list =
field_type == BubbleDialogModelHost::FieldType::kControl &&
last_field_type == BubbleDialogModelHost::FieldType::kControl;
return layout_provider->GetDistanceMetric(
is_control_list ? DISTANCE_CONTROL_LIST_VERTICAL
: DISTANCE_UNRELATED_CONTROL_VERTICAL);
}
int GetDialogTopMargins(LayoutProvider* layout_provider,
ui::DialogModelField* first_field) {
CHECK(first_field);
const BubbleDialogModelHost::FieldType field_type =
GetFieldTypeForField(first_field);
switch (field_type) {
case BubbleDialogModelHost::FieldType::kMenuItem:
return 0;
case BubbleDialogModelHost::FieldType::kControl:
return layout_provider->GetDistanceMetric(
DISTANCE_DIALOG_CONTENT_MARGIN_TOP_CONTROL);
case BubbleDialogModelHost::FieldType::kText:
return layout_provider->GetDistanceMetric(
DISTANCE_DIALOG_CONTENT_MARGIN_TOP_TEXT);
}
}
int GetDialogBottomMargins(LayoutProvider* layout_provider,
ui::DialogModelField* last_field,
bool has_buttons) {
CHECK(last_field);
const BubbleDialogModelHost::FieldType field_type =
GetFieldTypeForField(last_field);
switch (field_type) {
case BubbleDialogModelHost::FieldType::kMenuItem:
return has_buttons ? layout_provider->GetDistanceMetric(
DISTANCE_DIALOG_CONTENT_MARGIN_BOTTOM_CONTROL)
: 0;
case BubbleDialogModelHost::FieldType::kControl:
return layout_provider->GetDistanceMetric(
DISTANCE_DIALOG_CONTENT_MARGIN_BOTTOM_CONTROL);
case BubbleDialogModelHost::FieldType::kText:
return layout_provider->GetDistanceMetric(
DISTANCE_DIALOG_CONTENT_MARGIN_BOTTOM_TEXT);
}
}
// A subclass of Checkbox that allows using an external Label/StyledLabel view
// instead of LabelButton's internal label. This is required for the
// Label/StyledLabel to be clickable, while supporting Links which requires a
// StyledLabel.
class CheckboxControl : public Checkbox {
METADATA_HEADER(CheckboxControl, Checkbox)
public:
CheckboxControl(std::unique_ptr<View> label, int label_line_height)
: label_line_height_(label_line_height) {
auto* layout = SetLayoutManager(std::make_unique<BoxLayout>());
layout->set_between_child_spacing(LayoutProvider::Get()->GetDistanceMetric(
DISTANCE_RELATED_LABEL_HORIZONTAL));
layout->set_cross_axis_alignment(BoxLayout::CrossAxisAlignment::kStart);
// TODO(accessibility): There is no `SetAccessibilityProperties` which takes
// a labelling view to set the accessible name.
GetViewAccessibility().SetName(*label.get());
AddChildView(std::move(label));
}
gfx::Size CalculatePreferredSize(
const SizeBounds& available_size) const override {
// Skip LabelButton to use LayoutManager.
return View::CalculatePreferredSize(available_size);
}
void OnThemeChanged() override {
Checkbox::OnThemeChanged();
// This offsets the image to align with the first line of text. See
// LabelButton::Layout().
image_container_view()->SetBorder(CreateEmptyBorder(gfx::Insets::TLBR(
(label_line_height_ -
image_container_view()->GetPreferredSize({}).height()) /
2,
0, 0, 0)));
}
const int label_line_height_;
};
BEGIN_METADATA(CheckboxControl)
END_METADATA
struct DialogModelHostField {
raw_ptr<ui::DialogModelField> dialog_model_field = nullptr;
// View representing the entire field.
raw_ptr<View> field_view = nullptr;
// Child view to |field_view|, if any, that's used for focus. For instance,
// a textfield row would be a container that contains both a
// views::Textfield and a descriptive label. In this case |focusable_view|
// would refer to the views::Textfield which is also what would gain focus.
raw_ptr<View> focusable_view = nullptr;
};
View* GetTargetView(const DialogModelHostField& field_view_info) {
return field_view_info.focusable_view ? field_view_info.focusable_view.get()
: field_view_info.field_view.get();
}
// TODO(pbos): Consider externalizing this functionality into a different
// format that could feasibly be adopted by LayoutManagers. This is used for
// BoxLayouts (but could be others) to agree on columns' preferred width as a
// replacement for using GridLayout.
class LayoutConsensusGroup {
public:
LayoutConsensusGroup() = default;
~LayoutConsensusGroup() { DCHECK(children_.empty()); }
void AddView(View* view) {
children_.insert(view);
// Because this may change the max preferred/min size, invalidate all child
// layouts.
for (View* child : children_) {
child->InvalidateLayout();
}
}
void RemoveView(View* view) { children_.erase(view); }
// Get the union of all preferred sizes within the group.
gfx::Size GetMaxPreferredSize(const SizeBounds& available_size) const {
gfx::Size size;
for (View* child : children_) {
DCHECK_EQ(1u, child->children().size());
size.SetToMax(
child->children().front()->GetPreferredSize(available_size));
}
return size;
}
// Get the union of all minimum sizes within the group.
gfx::Size GetMaxMinimumSize() const {
gfx::Size size;
for (View* child : children_) {
DCHECK_EQ(1u, child->children().size());
size.SetToMax(child->children().front()->GetMinimumSize());
}
return size;
}
private:
base::flat_set<raw_ptr<View, CtnExperimental>> children_;
};
class LayoutConsensusView : public View {
METADATA_HEADER(LayoutConsensusView, View)
public:
LayoutConsensusView(LayoutConsensusGroup* group, std::unique_ptr<View> view)
: group_(group) {
group->AddView(this);
SetLayoutManager(std::make_unique<FillLayout>());
AddChildView(std::move(view));
}
~LayoutConsensusView() override { group_->RemoveView(this); }
gfx::Size CalculatePreferredSize(
const SizeBounds& available_size) const override {
const gfx::Size group_preferred_size =
group_->GetMaxPreferredSize(available_size);
DCHECK_EQ(1u, children().size());
const gfx::Size child_preferred_size =
children()[0]->GetPreferredSize(available_size);
// TODO(pbos): This uses the max width, but could be configurable to use
// either direction.
return gfx::Size(group_preferred_size.width(),
child_preferred_size.height());
}
gfx::Size GetMinimumSize() const override {
const gfx::Size group_minimum_size = group_->GetMaxMinimumSize();
DCHECK_EQ(1u, children().size());
const gfx::Size child_minimum_size = children()[0]->GetMinimumSize();
// TODO(pbos): This uses the max width, but could be configurable to use
// either direction.
return gfx::Size(group_minimum_size.width(), child_minimum_size.height());
}
private:
const raw_ptr<LayoutConsensusGroup> group_;
};
BEGIN_METADATA(LayoutConsensusView)
END_METADATA
} // namespace
BubbleDialogModelHost::CustomView::CustomView(std::unique_ptr<View> view,
FieldType field_type,
View* focusable_view)
: view_(std::move(view)),
field_type_(field_type),
focusable_view_(focusable_view) {}
BubbleDialogModelHost::CustomView::~CustomView() = default;
std::unique_ptr<View> BubbleDialogModelHost::CustomView::TransferView() {
DCHECK(view_);
return std::move(view_);
}
class BubbleDialogModelHostContentsView final : public DialogModelSectionHost {
METADATA_HEADER(BubbleDialogModelHostContentsView, DialogModelSectionHost)
public:
// TODO(pbos): Break this dependency on BubbleDialogModelHost once most of
// OnFieldAdded etc. has moved into this class.
BubbleDialogModelHostContentsView(
ui::DialogModelSection* contents,
ui::ElementIdentifier initially_focused_field_id)
: contents_(contents),
initially_focused_field_id_(initially_focused_field_id),
on_field_added_subscription_(
contents->AddOnFieldAddedCallback(base::BindRepeating(
&BubbleDialogModelHostContentsView::OnFieldAdded,
base::Unretained(this)))),
on_field_changed_subscription_(
contents->AddOnFieldChangedCallback(base::BindRepeating(
&BubbleDialogModelHostContentsView::OnFieldChanged,
base::Unretained(this)))) {
// Note that between-child spacing is manually handled using kMarginsKey.
SetOrientation(views::BoxLayout::Orientation::kVertical);
// Add all fields from the model.
for (const auto& field : contents_->fields()) {
OnFieldAdded(field.get());
}
}
[[nodiscard]] base::CallbackListSubscription AddOnContentsChangedCallback(
base::RepeatingClosure on_contents_changed) {
return on_contents_changed_.Add(std::move(on_contents_changed));
}
// TODO(pbos): Move (most) of the host's OnFieldAdded stuff into here. If
// anything remains try to have BubbleDialogModelHost observe it directly.
void OnFieldAdded(ui::DialogModelField* field) {
switch (field->type()) {
case ui::DialogModelField::kParagraph:
AddOrUpdateParagraph(field->AsParagraph());
break;
case ui::DialogModelField::kCheckbox:
AddOrUpdateCheckbox(field->AsCheckbox());
break;
case ui::DialogModelField::kCombobox:
AddOrUpdateCombobox(field->AsCombobox());
break;
case ui::DialogModelField::kMenuItem:
AddOrUpdateMenuItem(field->AsMenuItem());
break;
case ui::DialogModelField::kTitleItem:
// No need to handle titles.
NOTREACHED();
case ui::DialogModelField::kSection:
// TODO(pbos): Handle nested/multiple sections.
NOTREACHED();
case ui::DialogModelField::kSeparator:
AddOrUpdateSeparator(field);
break;
case ui::DialogModelField::kTextfield:
AddOrUpdateTextfield(field->AsTextfield());
break;
case ui::DialogModelField::kPasswordField:
AddOrUpdatePasswordField(field->AsPasswordField());
break;
case ui::DialogModelField::kCustom:
AddOrUpdateCustomField(field->AsCustomField());
break;
}
OnFieldChanged(field);
}
void OnFieldChanged(ui::DialogModelField* field) {
const DialogModelHostField host_field = FindDialogModelHostField(field);
if (host_field.field_view) {
host_field.field_view->SetVisible(field->is_visible());
}
on_contents_changed_.Notify();
}
// TODO(pbos): Remove the need for this method by making sure the host always
// outlives us. Currently we do outlive them. Widget, WidgetDelegate and
// RootView lifetimes are complicated.
void Detach() {
fields_.clear();
RemoveAllChildViews();
contents_ = nullptr;
}
void AddOrUpdateParagraph(ui::DialogModelParagraph* model_field) {
// TODO(pbos): Handle updating existing field.
std::unique_ptr<View> view =
model_field->header().empty()
? CreateViewForLabel(model_field->label())
: CreateViewForParagraphWithHeader(model_field->label(),
model_field->header());
DialogModelHostField info{model_field, view.get(), nullptr};
view->SetProperty(kElementIdentifierKey, model_field->id());
AddDialogModelHostField(std::move(view), info);
}
void AddOrUpdateCheckbox(ui::DialogModelCheckbox* model_field) {
// TODO(pbos): Handle updating existing field.
std::unique_ptr<CheckboxControl> checkbox;
if (DialogModelLabelRequiresStyledLabel(model_field->label())) {
auto label = CreateStyledLabelForDialogModelLabel(model_field->label());
const int line_height = label->GetLineHeight();
checkbox =
std::make_unique<CheckboxControl>(std::move(label), line_height);
} else {
auto label = CreateLabelForDialogModelLabel(model_field->label());
const int line_height = label->GetLineHeight();
checkbox =
std::make_unique<CheckboxControl>(std::move(label), line_height);
}
checkbox->SetChecked(model_field->is_checked());
checkbox->SetCallback(base::BindRepeating(
[](ui::DialogModelCheckbox* model_field,
base::PassKey<DialogModelFieldHost> pass_key, Checkbox* checkbox,
const ui::Event& event) {
model_field->OnChecked(pass_key, checkbox->GetChecked());
},
model_field, GetPassKey(), checkbox.get()));
DialogModelHostField info{model_field, checkbox.get(), nullptr};
AddDialogModelHostField(std::move(checkbox), info);
}
void AddOrUpdateCombobox(ui::DialogModelCombobox* model_field) {
// TODO(pbos): Handle updating existing field.
auto combobox = std::make_unique<Combobox>(model_field->combobox_model());
combobox->GetViewAccessibility().SetName(
model_field->accessible_name().empty()
? model_field->label()
: model_field->accessible_name());
combobox->SetCallback(base::BindRepeating(
[](ui::DialogModelCombobox* model_field,
base::PassKey<DialogModelFieldHost> pass_key,
Combobox* combobox) { model_field->OnPerformAction(pass_key); },
model_field, GetPassKey(), combobox.get()));
combobox->SetSelectedIndex(model_field->selected_index());
property_changed_subscriptions_.push_back(
combobox->AddSelectedIndexChangedCallback(base::BindRepeating(
[](ui::DialogModelCombobox* model_field,
base::PassKey<DialogModelFieldHost> pass_key,
Combobox* combobox) {
model_field->OnSelectedIndexChanged(
pass_key, combobox->GetSelectedIndex().value());
},
model_field, GetPassKey(), combobox.get())));
AddViewForLabelAndField(model_field, model_field->label(),
std::move(combobox));
}
void AddOrUpdateMenuItem(ui::DialogModelMenuItem* model_field) {
// TODO(pbos): Handle updating existing field.
// TODO(crbug.com/40224983): Implement this for enabled items. Sorry!
DCHECK(!model_field->is_enabled());
auto item = std::make_unique<LabelButton>(
base::BindRepeating(
[](ui::DialogModelMenuItem* model_field,
base::PassKey<DialogModelFieldHost> pass_key,
const ui::Event& event) {
model_field->OnActivated(pass_key, event.flags());
},
model_field, GetPassKey()),
model_field->label());
item->SetImageModel(Button::STATE_NORMAL, model_field->icon());
const auto* const layout_provider = LayoutProvider::Get();
item->SetBorder(views::CreateEmptyBorder(gfx::Insets::VH(
layout_provider->GetDistanceMetric(DISTANCE_CONTROL_LIST_VERTICAL) / 2,
layout_provider->GetDistanceMetric(
DISTANCE_BUTTON_HORIZONTAL_PADDING))));
item->SetEnabled(model_field->is_enabled());
item->SetProperty(kElementIdentifierKey, model_field->id());
DialogModelHostField info{model_field, item.get(), nullptr};
AddDialogModelHostField(std::move(item), info);
}
void AddOrUpdatePasswordField(ui::DialogModelPasswordField* model_field) {
// TODO(pbos): Support updates to the existing model.
auto view = std::make_unique<BoxLayoutView>();
view->SetOrientation(BoxLayout::Orientation::kVertical);
std::unique_ptr<Label> error_label;
if (!model_field->incorrect_password_text().empty()) {
// TODO(droger): Implement the supporting text directly in Textfield.
error_label = std::make_unique<Label>(
model_field->incorrect_password_text(),
style::CONTEXT_TEXTFIELD_SUPPORTING_TEXT, style::STYLE_INVALID);
error_label->SetMultiLine(true);
error_label->SetHorizontalAlignment(gfx::ALIGN_LEFT);
error_label->SetVisible(false);
}
// Use a combobox with empty model, rather than a Textfield, so that it
// supports the "reveal" functionality (eye icon).
// TODO(droger): add better support for passwords in the regular Textfield.
auto password_combobox = std::make_unique<views::EditablePasswordCombobox>(
std::make_unique<ui::SimpleComboboxModel>(
std::vector<ui::SimpleComboboxModel::Item>()),
views::style::CONTEXT_TEXTFIELD, style::STYLE_PRIMARY_MONOSPACED,
false);
password_combobox->SetPasswordIconTooltips(
l10n_util::GetStringUTF16(IDS_SETTINGS_PASSWORD_SHOW),
l10n_util::GetStringUTF16(IDS_SETTINGS_PASSWORD_HIDE));
password_combobox->GetViewAccessibility().SetName(
model_field->accessible_name().empty()
? model_field->label()
: model_field->accessible_name());
password_combobox->SetCallback(base::BindRepeating(
[](ui::DialogModelPasswordField* model_field,
base::PassKey<DialogModelFieldHost> pass_key,
EditablePasswordCombobox* combobox, Label* error_label) {
model_field->OnTextChanged(pass_key, combobox->GetText());
combobox->SetInvalid(false);
if (error_label) {
error_label->SetVisible(false);
}
},
model_field, GetPassKey(), password_combobox.get(), error_label.get()));
property_changed_subscriptions_.push_back(
model_field->AddOnInvalidateCallback(
GetPassKey(),
base::BindRepeating(
[](EditablePasswordCombobox* combobox, Label* error_label) {
combobox->SetText(std::u16string());
combobox->SetInvalid(true);
if (error_label) {
error_label->SetVisible(true);
error_label->GetViewAccessibility().AnnounceAlert(
error_label->GetText());
}
},
password_combobox.get(), error_label.get())));
view->AddChildView(std::move(password_combobox));
if (error_label) {
view->AddChildView(std::move(error_label));
}
AddViewForLabelAndField(model_field, model_field->label(), std::move(view));
}
void AddOrUpdateTextfield(ui::DialogModelTextfield* model_field) {
// TODO(pbos): Support updates to the existing model.
auto textfield = std::make_unique<Textfield>();
textfield->GetViewAccessibility().SetName(
model_field->accessible_name().empty()
? model_field->label()
: model_field->accessible_name());
textfield->SetText(model_field->text());
// If this textfield is initially focused the text should be initially
// selected as well.
// TODO(pbos): Fix this for non-unique IDs. This should not select all text
// for all textfields with that ID.
if (initially_focused_field_id_ &&
model_field->id() == initially_focused_field_id_) {
textfield->SelectAll(true);
}
property_changed_subscriptions_.push_back(
textfield->AddTextChangedCallback(base::BindRepeating(
[](ui::DialogModelTextfield* model_field,
base::PassKey<DialogModelFieldHost> pass_key,
Textfield* textfield) {
model_field->OnTextChanged(pass_key,
std::u16string(textfield->GetText()));
},
model_field, GetPassKey(), textfield.get())));
AddViewForLabelAndField(model_field, model_field->label(),
std::move(textfield));
}
void AddOrUpdateCustomField(ui::DialogModelCustomField* model_field) {
auto* custom_view =
static_cast<BubbleDialogModelHost::CustomView*>(model_field->field());
std::unique_ptr<View> view = custom_view->TransferView();
View* focusable_view = custom_view->TransferFocusableView();
if (!focusable_view) {
CHECK(view);
view->SetProperty(kElementIdentifierKey, model_field->id());
}
DialogModelHostField info{model_field, view.get(), focusable_view};
AddDialogModelHostField(std::move(view), info);
}
void AddOrUpdateSeparator(ui::DialogModelField* model_field) {
DCHECK_EQ(ui::DialogModelField::Type::kSeparator, model_field->type());
// TODO(pbos): Support updates to the existing model.
auto separator = std::make_unique<Separator>();
DialogModelHostField info{model_field, separator.get(), nullptr};
AddDialogModelHostField(std::move(separator), info);
}
void AddViewForLabelAndField(ui::DialogModelField* model_field,
const std::u16string& label_text,
std::unique_ptr<View> field) {
auto box_layout = std::make_unique<BoxLayoutView>();
box_layout->SetBetweenChildSpacing(LayoutProvider::Get()->GetDistanceMetric(
DISTANCE_RELATED_CONTROL_HORIZONTAL));
DialogModelHostField info{model_field, box_layout.get(), field.get()};
auto label = std::make_unique<Label>(label_text, style::CONTEXT_LABEL,
style::STYLE_PRIMARY);
label->SetHorizontalAlignment(gfx::ALIGN_LEFT);
box_layout->AddChildView(std::make_unique<LayoutConsensusView>(
&textfield_first_column_group_, std::move(label)));
box_layout->SetFlexForView(
box_layout->AddChildView(std::make_unique<LayoutConsensusView>(
&textfield_second_column_group_, std::move(field))),
1);
AddDialogModelHostField(std::move(box_layout), info);
}
static bool DialogModelLabelRequiresStyledLabel(
const ui::DialogModelLabel& dialog_label) {
return !dialog_label.replacements().empty();
}
static std::unique_ptr<View> CreateViewForLabel(
const ui::DialogModelLabel& dialog_label) {
if (DialogModelLabelRequiresStyledLabel(dialog_label)) {
return CreateStyledLabelForDialogModelLabel(dialog_label);
}
return CreateLabelForDialogModelLabel(dialog_label);
}
static std::unique_ptr<StyledLabel> CreateStyledLabelForDialogModelLabel(
const ui::DialogModelLabel& dialog_label) {
DCHECK(DialogModelLabelRequiresStyledLabel(dialog_label));
const std::vector<ui::DialogModelLabel::TextReplacement>& replacements =
dialog_label.replacements();
// Retrieve the replacements strings to create the text.
std::vector<std::u16string> string_replacements;
for (auto replacement : replacements) {
string_replacements.push_back(replacement.text());
}
std::vector<size_t> offsets;
const std::u16string text = l10n_util::GetStringFUTF16(
dialog_label.message_id(), string_replacements, &offsets);
auto styled_label = std::make_unique<StyledLabel>();
styled_label->SetText(text);
styled_label->SetDefaultTextStyle(dialog_label.is_secondary()
? style::STYLE_SECONDARY
: style::STYLE_PRIMARY);
// Style the replacements as needed.
DCHECK_EQ(string_replacements.size(), offsets.size());
for (size_t i = 0; i < replacements.size(); ++i) {
auto replacement = replacements[i];
// No styling needed if replacement is neither a link nor emphasized text.
if (!replacement.callback().has_value() && !replacement.is_emphasized()) {
continue;
}
StyledLabel::RangeStyleInfo style_info;
if (replacement.callback().has_value()) {
style_info = StyledLabel::RangeStyleInfo::CreateForLink(
replacement.callback().value());
style_info.accessible_name = replacement.accessible_name().value();
} else if (replacement.is_emphasized()) {
style_info.text_style = views::style::STYLE_EMPHASIZED;
}
auto offset = offsets[i];
styled_label->AddStyleRange(
gfx::Range(offset, offset + replacement.text().length()), style_info);
}
return styled_label;
}
static std::unique_ptr<Label> CreateLabelForDialogModelLabel(
const ui::DialogModelLabel& dialog_label) {
DCHECK(!DialogModelLabelRequiresStyledLabel(dialog_label));
auto text_label = std::make_unique<Label>(
dialog_label.GetString(), style::CONTEXT_DIALOG_BODY_TEXT,
dialog_label.is_secondary() ? style::STYLE_SECONDARY
: style::STYLE_PRIMARY);
text_label->SetMultiLine(true);
text_label->SetHorizontalAlignment(gfx::ALIGN_LEFT);
return text_label;
}
std::unique_ptr<View> CreateViewForParagraphWithHeader(
const ui::DialogModelLabel& dialog_label,
const std::u16string& header) {
auto view = std::make_unique<BoxLayoutView>();
view->SetOrientation(BoxLayout::Orientation::kVertical);
auto* header_label = view->AddChildView(std::make_unique<Label>(
header, style::CONTEXT_DIALOG_BODY_TEXT, style::STYLE_PRIMARY));
header_label->SetHorizontalAlignment(gfx::ALIGN_LEFT);
view->AddChildView(CreateViewForLabel(dialog_label));
return view;
}
void AddDialogModelHostField(std::unique_ptr<View> view,
const DialogModelHostField& field_view_info) {
DCHECK_EQ(view.get(), field_view_info.field_view);
AddChildView(std::move(view));
DCHECK(field_view_info.dialog_model_field);
DCHECK(field_view_info.field_view);
#if DCHECK_IS_ON()
// Make sure none of the info is already in use.
for (const auto& info : fields_) {
DCHECK_NE(info.field_view, field_view_info.field_view);
DCHECK_NE(info.dialog_model_field, field_view_info.dialog_model_field);
if (info.focusable_view) {
DCHECK_NE(info.focusable_view, field_view_info.focusable_view);
}
}
#endif // DCHECK_IS_ON()
fields_.push_back(field_view_info);
View* const target = GetTargetView(field_view_info);
target->SetProperty(kElementIdentifierKey,
field_view_info.dialog_model_field->id());
for (const auto& accelerator :
field_view_info.dialog_model_field->accelerators()) {
target->AddAccelerator(accelerator);
}
}
DialogModelHostField FindDialogModelHostField(ui::DialogModelField* field) {
for (const auto& info : fields_) {
if (info.dialog_model_field == field) {
return info;
}
}
// TODO(pbos): `field` could correspond to a button.
return {};
}
DialogModelHostField FindDialogModelHostField(View* view) {
for (const auto& info : fields_) {
if (info.field_view == view) {
return info;
}
}
NOTREACHED();
}
private:
// TODO(pbos): These should be const as we should never outlive our parent or
// DialogModelSection. Currently this isn't true because WidgetDelegate gets
// destroyed by Widget in OnNativeWidgetDestroyed before the content gets
// destroyed inside DestroyRootView in ~Widget.
raw_ptr<ui::DialogModelSection> contents_;
const ui::ElementIdentifier initially_focused_field_id_;
const base::CallbackListSubscription on_field_added_subscription_;
const base::CallbackListSubscription on_field_changed_subscription_;
std::vector<DialogModelHostField> fields_;
// TODO(pbos): Try to work away this list if the parent can observe enough
// things as a ViewObserver of us.
base::RepeatingClosureList on_contents_changed_;
std::vector<base::CallbackListSubscription> property_changed_subscriptions_;
LayoutConsensusGroup textfield_first_column_group_;
LayoutConsensusGroup textfield_second_column_group_;
};
BEGIN_METADATA(BubbleDialogModelHostContentsView)
END_METADATA
std::unique_ptr<DialogModelSectionHost> DialogModelSectionHost::Create(
ui::DialogModelSection* section,
ui::ElementIdentifier initially_focused_field_id) {
return std::make_unique<BubbleDialogModelHostContentsView>(
section, initially_focused_field_id);
}
BEGIN_METADATA(DialogModelSectionHost)
END_METADATA
BubbleDialogModelHost::ThemeChangedObserver::ThemeChangedObserver(
BubbleDialogModelHost* parent,
BubbleDialogModelHostContentsView* contents_view)
: parent_(parent) {
observation_.Observe(contents_view);
}
BubbleDialogModelHost::ThemeChangedObserver::~ThemeChangedObserver() = default;
void BubbleDialogModelHost::ThemeChangedObserver::OnViewThemeChanged(
View* view) {
parent_->UpdateWindowIcon(view->GetColorProvider());
}
BubbleDialogModelHost::BubbleDialogModelHost(
std::unique_ptr<ui::DialogModel> model,
View* anchor_view,
BubbleBorder::Arrow arrow,
bool autosize)
: BubbleDialogModelHost(base::PassKey<BubbleDialogModelHost>(),
std::move(model),
anchor_view,
arrow,
ui::mojom::ModalType::kNone,
autosize) {}
BubbleDialogModelHost::BubbleDialogModelHost(
base::PassKey<BubbleDialogModelHost>,
std::unique_ptr<ui::DialogModel> model,
View* anchor_view,
BubbleBorder::Arrow arrow,
ui::mojom::ModalType modal_type,
bool autosize)
: BubbleDialogDelegate(anchor_view,
arrow,
views::BubbleBorder::DIALOG_SHADOW,
autosize),
model_(std::move(model)),
// Make sure the modal type is set before calling InitContentsView which
// uses IsModalDialog().
contents_view_(
(SetModalType(modal_type), InitContentsView(model_->contents()))),
on_contents_changed_subscription_(
contents_view_->AddOnContentsChangedCallback(
base::BindRepeating(&BubbleDialogModelHost::OnContentsViewChanged,
base::Unretained(this)))),
theme_observer_(this, contents_view_) {
SetOwnedByWidget(OwnedByWidgetPassKey());
model_->set_host(DialogModelHost::GetPassKey(), this);
// Dialog callbacks can safely refer to |model_|, they can't be called after
// Widget::Close() calls WidgetWillClose() synchronously so there shouldn't
// be any dangling references after model removal.
SetAcceptCallbackWithClose(base::BindRepeating(
&ui::DialogModel::OnDialogAcceptAction, base::Unretained(model_.get()),
DialogModelHost::GetPassKey()));
SetCancelCallbackWithClose(base::BindRepeating(
&ui::DialogModel::OnDialogCancelAction, base::Unretained(model_.get()),
DialogModelHost::GetPassKey()));
SetCloseCallback(base::BindOnce(&ui::DialogModel::OnDialogCloseAction,
base::Unretained(model_.get()),
DialogModelHost::GetPassKey()));
// WindowClosingCallback happens on native widget destruction which is after
// |model_| reset. Hence routing this callback through |this| so that we
// only forward the call to DialogModel::OnWindowClosing if we haven't
// already been closed.
RegisterWindowClosingCallback(base::BindOnce(
&BubbleDialogModelHost::OnWindowClosing, base::Unretained(this)));
int button_mask = static_cast<int>(ui::mojom::DialogButton::kNone);
auto* ok_button = model_->ok_button(DialogModelHost::GetPassKey());
if (ok_button) {
button_mask |= static_cast<int>(ui::mojom::DialogButton::kOk);
ConfigureBubbleButtonForParams(*this, /*button_view=*/nullptr,
ui::mojom::DialogButton::kOk, *ok_button);
}
auto* cancel_button = model_->cancel_button(DialogModelHost::GetPassKey());
if (cancel_button) {
button_mask |= static_cast<int>(ui::mojom::DialogButton::kCancel);
ConfigureBubbleButtonForParams(*this, /*button_view=*/nullptr,
ui::mojom::DialogButton::kCancel,
*cancel_button);
}
// TODO(pbos): Consider refactoring ::SetExtraView() so it can be called
// after the Widget is created and still be picked up. Moving this to
// OnWidgetInitialized() will not work until then.
if (ui::DialogModel::Button* extra_button =
model_->extra_button(DialogModelHost::GetPassKey())) {
DCHECK(!model_->extra_link(DialogModelHost::GetPassKey()));
auto builder =
views::Builder<MdTextButton>()
.SetCallback(base::BindRepeating(
&ui::DialogModel::Button::OnPressed,
base::Unretained(extra_button), DialogModelHost::GetPassKey()))
.SetText(extra_button->label());
if (extra_button->style()) {
builder.SetStyle(extra_button->style().value());
}
builder.SetEnabled(extra_button->is_enabled());
SetExtraView(std::move(builder).Build());
} else if (ui::DialogModelLabel::TextReplacement* extra_link =
model_->extra_link(DialogModelHost::GetPassKey())) {
DCHECK(extra_link->callback().has_value());
auto link = std::make_unique<views::Link>(extra_link->text());
link->SetCallback(extra_link->callback().value());
SetExtraView(std::move(link));
}
SetButtons(button_mask);
if (model_->override_default_button(DialogModelHost::GetPassKey())) {
SetDefaultButton(static_cast<int>(
model_->override_default_button(DialogModelHost::GetPassKey())
.value()));
}
SetTitle(model_->title(DialogModelHost::GetPassKey()));
if (!model_->accessible_title(DialogModelHost::GetPassKey()).empty()) {
SetAccessibleTitle(model_->accessible_title(DialogModelHost::GetPassKey()));
}
SetSubtitle(model_->subtitle(DialogModelHost::GetPassKey()));
// This is added due to crbug.com/1518993 which adds a subtitle that needs
// line wrapping. If any future dialogs need the subtitle to not line wrap,
// this behavior will need to be configurable via the builder.
SetSubtitleAllowCharacterBreak(true);
if (!model_->main_image(DialogModelHost::GetPassKey()).IsEmpty()) {
SetMainImage(model_->main_image(DialogModelHost::GetPassKey()));
}
if (model_->override_show_close_button(DialogModelHost::GetPassKey())) {
SetShowCloseButton(
*model_->override_show_close_button(DialogModelHost::GetPassKey()));
} else {
SetShowCloseButton(!IsModalDialog());
}
if (!model_->icon(DialogModelHost::GetPassKey()).IsEmpty()) {
SetIcon(model_->icon(DialogModelHost::GetPassKey()));
SetShowIcon(true);
}
if (model_->is_alert_dialog(DialogModelHost::GetPassKey())) {
#if BUILDFLAG(IS_WIN)
// This is taken from LocationBarBubbleDelegateView. See
// GetAccessibleRoleForReason(). crbug.com/1125118: Windows ATs only
// announce these bubbles if the alert role is used, despite it not being
// the most appropriate choice.
// TODO(accessibility): review the role mappings for alerts and dialogs,
// making sure they are translated to the best candidate in each flatform
// without resorting to hacks like this.
SetAccessibleWindowRole(ax::mojom::Role::kAlert);
#else
SetAccessibleWindowRole(ax::mojom::Role::kAlertDialog);
#endif
}
set_internal_name(model_->internal_name(DialogModelHost::GetPassKey()));
set_close_on_deactivate(
model_->close_on_deactivate(DialogModelHost::GetPassKey()));
// TODO(pbos): Reconsider this for dialogs which have no actions (look like
// menus). This is probably too wide for the TabGroupEditorBubbleView which is
// currently being converted.
set_fixed_width(LayoutProvider::Get()->GetDistanceMetric(
anchor_view ? DISTANCE_BUBBLE_PREFERRED_WIDTH
: DISTANCE_MODAL_DIALOG_PREFERRED_WIDTH));
if (model_->footnote_label()) {
SetFootnoteView(BubbleDialogModelHostContentsView::CreateViewForLabel(
*model_->footnote_label()));
}
// Make sure we're up to date with initial contents state.
UpdateSpacingAndMargins();
}
BubbleDialogModelHost::~BubbleDialogModelHost() {
// Detach ContentsView as it's referring to state that's about to be
// destroyed.
contents_view_->Detach();
}
std::unique_ptr<BubbleDialogModelHost> BubbleDialogModelHost::CreateModal(
std::unique_ptr<ui::DialogModel> model,
ui::mojom::ModalType modal_type,
bool autosize) {
DCHECK_NE(modal_type, ui::mojom::ModalType::kNone);
return std::make_unique<BubbleDialogModelHost>(
base::PassKey<BubbleDialogModelHost>(), std::move(model), nullptr,
BubbleBorder::Arrow::NONE, modal_type, autosize);
}
View* BubbleDialogModelHost::GetInitiallyFocusedView() {
// TODO(pbos): Migrate this override to use
// WidgetDelegate::SetInitiallyFocusedView() in constructor once it exists.
// TODO(pbos): Try to prevent uses of GetInitiallyFocusedView() after Close()
// and turn this in to a DCHECK for |model_| existence. This should fix
// https://crbug.com/1130181 for now.
if (!model_) {
return BubbleDialogDelegate::GetInitiallyFocusedView();
}
// TODO(pbos): Reconsider the uniqueness requirement, maybe this should select
// the first one? If so add corresponding GetFirst query to DialogModel.
ui::ElementIdentifier unique_id =
model_->initially_focused_field(DialogModelHost::GetPassKey());
if (!unique_id) {
return BubbleDialogDelegate::GetInitiallyFocusedView();
}
if (ui::DialogModel::Button* const ok_button =
model_->ok_button(DialogModelHost::GetPassKey());
ok_button && unique_id == ok_button->id()) {
return GetOkButton();
}
if (ui::DialogModel::Button* const cancel_button =
model_->cancel_button(DialogModelHost::GetPassKey());
cancel_button && unique_id == cancel_button->id()) {
return GetCancelButton();
}
if (ui::DialogModel::Button* const extra_button =
model_->extra_button(DialogModelHost::GetPassKey());
extra_button && unique_id == extra_button->id()) {
return GetExtraView();
}
return GetTargetView(contents_view_->FindDialogModelHostField(
model_->GetFieldByUniqueId(unique_id)));
}
void BubbleDialogModelHost::OnWidgetInitialized() {
// Dialog buttons are added on dialog initialization.
UpdateDialogButtons();
if (const ui::ImageModel& banner =
model_->banner(DialogModelHost::GetPassKey());
!banner.IsEmpty()) {
const ui::ImageModel& dark_mode_banner =
model_->dark_mode_banner(DialogModelHost::GetPassKey());
auto banner_view = std::make_unique<ThemeTrackingImageView>(
banner.Rasterize(contents_view_->GetColorProvider()),
(dark_mode_banner.IsEmpty() ? banner : dark_mode_banner)
.Rasterize(contents_view_->GetColorProvider()),
base::BindRepeating(&views::BubbleDialogDelegate::background_color,
base::Unretained(this)));
// The banner is supposed to be purely decorative.
banner_view->GetViewAccessibility().SetIsIgnored(true);
GetBubbleFrameView()->SetHeaderView(std::move(banner_view));
}
}
void BubbleDialogModelHost::Close() {
DCHECK(model_);
DCHECK(GetWidget());
GetWidget()->Close();
// Synchronously destroy |model_|. Widget::Close() being asynchronous should
// not be observable by the model or client code.
// Notify the model of window closing before destroying it (as if
// Widget::Close)
model_->OnDialogDestroying(DialogModelHost::GetPassKey());
// Detach ContentsView as it's referring to state that's about to be
// destroyed.
contents_view_->Detach();
model_.reset();
}
BubbleDialogModelHostContentsView* BubbleDialogModelHost::InitContentsView(
ui::DialogModelSection* contents) {
auto contents_view_unique =
std::make_unique<BubbleDialogModelHostContentsView>(
contents,
model_->initially_focused_field(DialogModelHost::GetPassKey()));
BubbleDialogModelHostContentsView* const contents_view =
contents_view_unique.get();
if (IsModalDialog()) {
// Margins are added directly in the dialog. When the dialog is modal, these
// contents are wrapped by a scroll view and margins are added outside of it
// (instead of outside this contents). This causes some items (e.g
// emphasized buttons) to be cut by the scroll view margins (see
// crbug.com/1360772). Since we do want the margins outside the scroll view
// (so they are always present when scrolling), we add
// `kScrollViewVerticalMargin` inside the contents view and later remove it
// from the dialog margins.
// TODO(crbug.com/40855129): Remove this workaround when contents view
// directly supports a scroll view.
contents_view_unique->SetInsideBorderInsets(
gfx::Insets::VH(kScrollViewVerticalMargin, 0));
// TODO(crbug.com/40855129): Non modal dialogs size is not dependent on its
// content. Thus, the content has to be manually set by the view inside a
// scroll view. Modal dialogs handle their own size via constrained windows,
// so we can add a scroll view to the DialogModel directly.
const int kMaxDialogHeight = LayoutProvider::Get()->GetDistanceMetric(
DISTANCE_MODAL_DIALOG_SCROLLABLE_AREA_MAX_HEIGHT);
auto scroll_view = std::make_unique<views::ScrollView>();
scroll_view->ClipHeightTo(0, kMaxDialogHeight);
scroll_view->SetHorizontalScrollBarMode(
views::ScrollView::ScrollBarMode::kDisabled);
scroll_view->SetContents(std::move(contents_view_unique));
SetContentsView(std::move(scroll_view));
} else {
SetContentsView(std::move(contents_view_unique));
}
return contents_view;
}
void BubbleDialogModelHost::OnContentsViewChanged() {
UpdateSpacingAndMargins();
}
void BubbleDialogModelHost::OnDialogButtonChanged() {
UpdateDialogButtons();
}
void BubbleDialogModelHost::UpdateWindowIcon(
const ui::ColorProvider* color_provider) {
if (!ShouldShowWindowIcon()) {
return;
}
const ui::ImageModel dark_mode_icon =
model_->dark_mode_icon(DialogModelHost::GetPassKey());
if (!dark_mode_icon.IsEmpty() &&
color_utils::IsDark(
background_color().ResolveToSkColor(color_provider))) {
SetIcon(dark_mode_icon);
return;
}
SetIcon(model_->icon(DialogModelHost::GetPassKey()));
}
void BubbleDialogModelHost::UpdateSpacingAndMargins() {
LayoutProvider* const layout_provider = LayoutProvider::Get();
gfx::Insets dialog_side_insets =
layout_provider->GetInsetsMetric(InsetsMetric::INSETS_DIALOG);
if (GetWindowTitle().empty()) {
// If there is no title, increase the margin at the top to match the title
// margin, so that the text is not too close to the top edge.
dialog_side_insets.set_top(
layout_provider->GetInsetsMetric(InsetsMetric::INSETS_DIALOG_TITLE)
.top());
} else {
dialog_side_insets.set_top(0);
}
dialog_side_insets.set_bottom(0);
ui::DialogModelField* first_field = nullptr;
ui::DialogModelField* last_field = nullptr;
auto* scroll_view =
views::ScrollView::GetScrollViewForContents(contents_view_);
const views::View::Views& children = scroll_view
? scroll_view->contents()->children()
: contents_view_->children();
if (children.empty()) {
// TODO(pbos): Copied from the BubbleDialogDelegate constructor. Maybe there
// should be a way to reset them if we become empty?
set_margins(layout_provider->GetDialogInsetsForContentType(
DialogContentType::kText, DialogContentType::kText));
return;
}
for (View* const view : children) {
ui::DialogModelField* const field =
contents_view_->FindDialogModelHostField(view).dialog_model_field;
const FieldType field_type = GetFieldTypeForField(field);
gfx::Insets side_insets =
field_type == FieldType::kMenuItem ? gfx::Insets() : dialog_side_insets;
if (!first_field) {
first_field = field;
view->SetProperty(kMarginsKey, side_insets);
} else {
DCHECK(last_field);
const FieldType last_field_type = GetFieldTypeForField(last_field);
side_insets.set_top(
GetFieldTopMargin(layout_provider, field_type, last_field_type));
view->SetProperty(kMarginsKey, side_insets);
}
last_field = field;
}
contents_view_->InvalidateLayout();
// Set margins based on the first and last item. Note that we remove margins
// that were already added to contents view at construction.
// TODO(crbug.com/40855129): Remove the extra margin workaround when contents
// view directly supports a scroll view.
const int extra_margin = scroll_view ? kScrollViewVerticalMargin : 0;
const int top_margin =
GetDialogTopMargins(layout_provider, first_field) - extra_margin;
const int bottom_margin =
GetDialogBottomMargins(
layout_provider, last_field,
buttons() != static_cast<int>(ui::mojom::DialogButton::kNone)) -
extra_margin;
set_margins(gfx::Insets::TLBR(top_margin >= 0 ? top_margin : 0, 0,
bottom_margin >= 0 ? bottom_margin : 0, 0));
}
void BubbleDialogModelHost::OnWindowClosing() {
// If the model has been removed we have already notified it of closing on the
// ::Close() stack.
if (!model_) {
return;
}
model_->OnDialogDestroying(DialogModelHost::GetPassKey());
// TODO(pbos): Do we need to reset `model_` and destroy contents? See Close().
}
void BubbleDialogModelHost::UpdateDialogButtons() {
if (ui::DialogModel::Button* const ok_button =
model_->ok_button(DialogModelHost::GetPassKey())) {
ConfigureBubbleButtonForParams(*this, GetOkButton(),
ui::mojom::DialogButton::kOk, *ok_button);
}
if (ui::DialogModel::Button* const cancel_button =
model_->cancel_button(DialogModelHost::GetPassKey())) {
ConfigureBubbleButtonForParams(*this, GetCancelButton(),
ui::mojom::DialogButton::kCancel,
*cancel_button);
}
if (ui::DialogModel::Button* const extra_button =
model_->extra_button(DialogModelHost::GetPassKey())) {
auto* const extra_button_view = static_cast<MdTextButton*>(GetExtraView());
extra_button_view->SetText(extra_button->label());
extra_button_view->SetVisible(extra_button->is_visible());
extra_button_view->SetEnabled(extra_button->is_enabled());
extra_button_view->SetProperty(kElementIdentifierKey, extra_button->id());
}
}
bool BubbleDialogModelHost::IsModalDialog() const {
return GetModalType() != ui::mojom::ModalType::kNone;
}
} // namespace views