| // Copyright 2013 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 "ui/views/controls/styled_label.h" |
| |
| #include <stddef.h> |
| |
| #include <algorithm> |
| #include <limits> |
| #include <utility> |
| |
| #include "base/i18n/rtl.h" |
| #include "base/ranges/algorithm.h" |
| #include "base/strings/string_util.h" |
| #include "ui/accessibility/ax_enums.mojom.h" |
| #include "ui/accessibility/ax_node_data.h" |
| #include "ui/gfx/font_list.h" |
| #include "ui/gfx/text_elider.h" |
| #include "ui/gfx/text_utils.h" |
| #include "ui/native_theme/native_theme.h" |
| #include "ui/views/controls/label.h" |
| #include "ui/views/metadata/metadata_impl_macros.h" |
| #include "ui/views/view_class_properties.h" |
| |
| namespace views { |
| |
| DEFINE_UI_CLASS_PROPERTY_KEY(bool, kStyledLabelCustomViewKey, false) |
| |
| StyledLabel::RangeStyleInfo::RangeStyleInfo() = default; |
| StyledLabel::RangeStyleInfo::RangeStyleInfo(const RangeStyleInfo&) = default; |
| StyledLabel::RangeStyleInfo& StyledLabel::RangeStyleInfo::operator=( |
| const RangeStyleInfo&) = default; |
| StyledLabel::RangeStyleInfo::~RangeStyleInfo() = default; |
| |
| // static |
| StyledLabel::RangeStyleInfo StyledLabel::RangeStyleInfo::CreateForLink( |
| base::RepeatingClosure callback) { |
| // Adapt this closure to a Link::ClickedCallback by discarding the extra arg. |
| return CreateForLink(base::BindRepeating( |
| [](base::RepeatingClosure closure, const ui::Event&) { closure.Run(); }, |
| std::move(callback))); |
| } |
| |
| // static |
| StyledLabel::RangeStyleInfo StyledLabel::RangeStyleInfo::CreateForLink( |
| Link::ClickedCallback callback) { |
| RangeStyleInfo result; |
| result.callback = std::move(callback); |
| result.disable_line_wrapping = true; |
| result.text_style = style::STYLE_LINK; |
| return result; |
| } |
| |
| StyledLabel::LayoutSizeInfo::LayoutSizeInfo(int max_valid_width) |
| : max_valid_width(max_valid_width) {} |
| |
| StyledLabel::LayoutSizeInfo::LayoutSizeInfo(const LayoutSizeInfo&) = default; |
| StyledLabel::LayoutSizeInfo& StyledLabel::LayoutSizeInfo::operator=( |
| const LayoutSizeInfo&) = default; |
| StyledLabel::LayoutSizeInfo::~LayoutSizeInfo() = default; |
| |
| bool StyledLabel::StyleRange::operator<( |
| const StyledLabel::StyleRange& other) const { |
| return range.start() < other.range.start(); |
| } |
| |
| struct StyledLabel::LayoutViews { |
| // All views to be added as children, line by line. |
| std::vector<std::vector<View*>> views_per_line; |
| |
| // The subset of |views| that are created by StyledLabel itself. Basically, |
| // this is all non-custom views; These appear in the same order as |views|. |
| std::vector<std::unique_ptr<View>> owned_views; |
| }; |
| |
| StyledLabel::StyledLabel() = default; |
| |
| StyledLabel::~StyledLabel() = default; |
| |
| const base::string16& StyledLabel::GetText() const { |
| return text_; |
| } |
| |
| void StyledLabel::SetText(base::string16 text) { |
| // Failing to trim trailing whitespace will cause later confusion when the |
| // text elider tries to do so internally. There's no obvious reason to |
| // preserve trailing whitespace anyway. |
| base::TrimWhitespace(std::move(text), base::TRIM_TRAILING, &text); |
| if (text_ == text) |
| return; |
| |
| text_ = text; |
| style_ranges_.clear(); |
| RemoveOrDeleteAllChildViews(); |
| OnPropertyChanged(&text_, kPropertyEffectsPreferredSizeChanged); |
| } |
| |
| gfx::FontList StyledLabel::GetFontList(const RangeStyleInfo& style_info) const { |
| return style_info.custom_font.value_or(style::GetFont( |
| text_context_, style_info.text_style.value_or(default_text_style_))); |
| } |
| |
| void StyledLabel::AddStyleRange(const gfx::Range& range, |
| const RangeStyleInfo& style_info) { |
| DCHECK(!range.is_reversed()); |
| DCHECK(!range.is_empty()); |
| DCHECK(gfx::Range(0, text_.size()).Contains(range)); |
| |
| // Insert the new range in sorted order. |
| StyleRanges new_range; |
| new_range.push_front(StyleRange(range, style_info)); |
| style_ranges_.merge(new_range); |
| |
| PreferredSizeChanged(); |
| } |
| |
| void StyledLabel::AddCustomView(std::unique_ptr<View> custom_view) { |
| DCHECK(!custom_view->owned_by_client()); |
| custom_view->SetProperty(kStyledLabelCustomViewKey, true); |
| custom_views_.push_back(std::move(custom_view)); |
| } |
| |
| int StyledLabel::GetTextContext() const { |
| return text_context_; |
| } |
| |
| void StyledLabel::SetTextContext(int text_context) { |
| if (text_context_ == text_context) |
| return; |
| |
| text_context_ = text_context; |
| OnPropertyChanged(&text_context_, kPropertyEffectsPreferredSizeChanged); |
| } |
| |
| int StyledLabel::GetDefaultTextStyle() const { |
| return default_text_style_; |
| } |
| |
| void StyledLabel::SetDefaultTextStyle(int text_style) { |
| if (default_text_style_ == text_style) |
| return; |
| |
| default_text_style_ = text_style; |
| OnPropertyChanged(&default_text_style_, kPropertyEffectsPreferredSizeChanged); |
| } |
| |
| int StyledLabel::GetLineHeight() const { |
| return line_height_.value_or( |
| style::GetLineHeight(text_context_, default_text_style_)); |
| } |
| |
| void StyledLabel::SetLineHeight(int line_height) { |
| if (line_height_ == line_height) |
| return; |
| |
| line_height_ = line_height; |
| OnPropertyChanged(&line_height_, kPropertyEffectsPreferredSizeChanged); |
| } |
| |
| base::Optional<SkColor> StyledLabel::GetDisplayedOnBackgroundColor() const { |
| return displayed_on_background_color_; |
| } |
| |
| void StyledLabel::SetDisplayedOnBackgroundColor( |
| const base::Optional<SkColor>& color) { |
| if (displayed_on_background_color_ == color) |
| return; |
| |
| displayed_on_background_color_ = color; |
| |
| if (GetNativeTheme()) |
| UpdateLabelBackgroundColor(); |
| |
| OnPropertyChanged(&displayed_on_background_color_, kPropertyEffectsPaint); |
| } |
| |
| bool StyledLabel::GetAutoColorReadabilityEnabled() const { |
| return auto_color_readability_enabled_; |
| } |
| |
| void StyledLabel::SetAutoColorReadabilityEnabled(bool auto_color_readability) { |
| if (auto_color_readability_enabled_ == auto_color_readability) |
| return; |
| |
| auto_color_readability_enabled_ = auto_color_readability; |
| OnPropertyChanged(&auto_color_readability_enabled_, kPropertyEffectsPaint); |
| } |
| |
| const StyledLabel::LayoutSizeInfo& StyledLabel::GetLayoutSizeInfoForWidth( |
| int w) const { |
| CalculateLayout(w); |
| return layout_size_info_; |
| } |
| |
| void StyledLabel::SizeToFit(int fixed_width) { |
| CalculateLayout(fixed_width == 0 ? std::numeric_limits<int>::max() |
| : fixed_width); |
| gfx::Size size = layout_size_info_.total_size; |
| size.set_width(std::max(size.width(), fixed_width)); |
| SetSize(size); |
| } |
| |
| void StyledLabel::GetAccessibleNodeData(ui::AXNodeData* node_data) { |
| node_data->role = (text_context_ == style::CONTEXT_DIALOG_TITLE) |
| ? ax::mojom::Role::kTitleBar |
| : ax::mojom::Role::kStaticText; |
| node_data->SetName(GetText()); |
| } |
| |
| gfx::Size StyledLabel::CalculatePreferredSize() const { |
| // Respect any existing size. If there is none, default to a single line. |
| CalculateLayout((width() == 0) ? std::numeric_limits<int>::max() : width()); |
| return layout_size_info_.total_size; |
| } |
| |
| int StyledLabel::GetHeightForWidth(int w) const { |
| return GetLayoutSizeInfoForWidth(w).total_size.height(); |
| } |
| |
| void StyledLabel::Layout() { |
| CalculateLayout(width()); |
| |
| // If the layout has been recalculated, add and position all views. |
| if (layout_views_) { |
| // Delete all non-custom views on removal; custom views are temporarily |
| // moved to |custom_views_|. |
| RemoveOrDeleteAllChildViews(); |
| |
| DCHECK_EQ(layout_size_info_.line_sizes.size(), |
| layout_views_->views_per_line.size()); |
| int line_y = GetInsets().top(); |
| auto next_owned_view = layout_views_->owned_views.begin(); |
| for (size_t line = 0; line < layout_views_->views_per_line.size(); ++line) { |
| const auto& line_size = layout_size_info_.line_sizes[line]; |
| int x = StartX(width() - line_size.width()); |
| for (auto* view : layout_views_->views_per_line[line]) { |
| gfx::Size size = view->GetPreferredSize(); |
| size.set_width(std::min(size.width(), width() - x)); |
| // Compute the view y such that the view center y and the line center y |
| // match. Because of added rounding errors, this is not the same as |
| // doing (line_size.height() - size.height()) / 2. |
| const int y = line_size.height() / 2 - size.height() / 2; |
| view->SetBoundsRect({{x, line_y + y}, size}); |
| x += size.width(); |
| |
| // Transfer ownership for any views in layout_views_->owned_views or |
| // custom_views_. The actual pointer is the same in both arms below. |
| if (view->GetProperty(kStyledLabelCustomViewKey)) { |
| auto custom_view = |
| std::find_if(custom_views_.begin(), custom_views_.end(), |
| [view](const auto& current_custom_view) { |
| return current_custom_view.get() == view; |
| }); |
| DCHECK(custom_view != custom_views_.end()); |
| AddChildView(std::move(*custom_view)); |
| custom_views_.erase(custom_view); |
| } else { |
| DCHECK(next_owned_view != layout_views_->owned_views.end()); |
| DCHECK(view == next_owned_view->get()); |
| AddChildView(std::move(*next_owned_view)); |
| ++next_owned_view; |
| } |
| } |
| line_y += line_size.height(); |
| } |
| DCHECK(next_owned_view == layout_views_->owned_views.end()); |
| |
| layout_views_.reset(); |
| } else if (horizontal_alignment_ != gfx::ALIGN_LEFT) { |
| // Recompute all child X coordinates in case the width has shifted, which |
| // will move the children if the label is center/right-aligned. If the |
| // width hasn't changed, all the SetX() calls below will no-op, so this |
| // won't have side effects. |
| int line_bottom = GetInsets().top(); |
| auto i = children().begin(); |
| for (const auto& line_size : layout_size_info_.line_sizes) { |
| DCHECK(i != children().end()); // Should not have an empty trailing line. |
| int x = StartX(width() - line_size.width()); |
| line_bottom += line_size.height(); |
| for (; (i != children().end()) && ((*i)->y() < line_bottom); ++i) { |
| (*i)->SetX(x); |
| x += (*i)->GetPreferredSize().width(); |
| } |
| } |
| DCHECK(i == children().end()); // Should not be short any lines. |
| } |
| } |
| |
| void StyledLabel::PreferredSizeChanged() { |
| layout_size_info_ = LayoutSizeInfo(0); |
| layout_views_.reset(); |
| View::PreferredSizeChanged(); |
| } |
| |
| void StyledLabel::OnThemeChanged() { |
| View::OnThemeChanged(); |
| UpdateLabelBackgroundColor(); |
| } |
| |
| // TODO(wutao): support gfx::ALIGN_TO_HEAD alignment. |
| void StyledLabel::SetHorizontalAlignment(gfx::HorizontalAlignment alignment) { |
| DCHECK_NE(gfx::ALIGN_TO_HEAD, alignment); |
| alignment = gfx::MaybeFlipForRTL(alignment); |
| |
| if (horizontal_alignment_ == alignment) |
| return; |
| horizontal_alignment_ = alignment; |
| PreferredSizeChanged(); |
| } |
| |
| void StyledLabel::ClearStyleRanges() { |
| style_ranges_.clear(); |
| PreferredSizeChanged(); |
| } |
| |
| void StyledLabel::ClickLinkForTesting() { |
| const auto it = |
| base::ranges::find(children(), Link::kViewClassName, &View::GetClassName); |
| DCHECK(it != children().cend()); |
| (*it)->OnKeyPressed( |
| ui::KeyEvent(ui::ET_KEY_PRESSED, ui::VKEY_SPACE, ui::EF_NONE)); |
| } |
| |
| int StyledLabel::StartX(int excess_space) const { |
| int x = GetInsets().left(); |
| if (horizontal_alignment_ == gfx::ALIGN_LEFT) |
| return x; |
| return x + ((horizontal_alignment_ == gfx::ALIGN_CENTER) ? (excess_space / 2) |
| : excess_space); |
| } |
| |
| void StyledLabel::CalculateLayout(int width) const { |
| const gfx::Insets insets = GetInsets(); |
| width = std::max(width, insets.width()); |
| if (width >= layout_size_info_.total_size.width() && |
| width <= layout_size_info_.max_valid_width) |
| return; |
| |
| layout_size_info_ = LayoutSizeInfo(width); |
| layout_views_ = std::make_unique<LayoutViews>(); |
| |
| const int content_width = width - insets.width(); |
| const int line_height = GetLineHeight(); |
| RangeStyleInfo default_style; |
| default_style.text_style = default_text_style_; |
| int max_width = 0, total_height = 0; |
| |
| // Try to preserve leading whitespace on the first line. |
| bool can_trim_leading_whitespace = false; |
| StyleRanges::const_iterator current_range = style_ranges_.begin(); |
| for (base::string16 remaining_string = text_; |
| content_width > 0 && !remaining_string.empty();) { |
| layout_size_info_.line_sizes.emplace_back(0, line_height); |
| auto& line_size = layout_size_info_.line_sizes.back(); |
| layout_views_->views_per_line.emplace_back(); |
| auto& views = layout_views_->views_per_line.back(); |
| while (!remaining_string.empty()) { |
| if (views.empty() && can_trim_leading_whitespace) { |
| if (remaining_string.front() == '\n') { |
| // Wrapped to the next line on \n, remove it. Other whitespace, |
| // e.g. spaces to indent the next line, are preserved. |
| remaining_string.erase(0, 1); |
| } else { |
| // Wrapped on whitespace character or characters in the middle of the |
| // line - none of them are needed at the beginning of the next line. |
| base::TrimWhitespace(remaining_string, base::TRIM_LEADING, |
| &remaining_string); |
| } |
| } |
| |
| gfx::Range range = gfx::Range::InvalidRange(); |
| if (current_range != style_ranges_.end()) |
| range = current_range->range; |
| |
| const size_t position = text_.size() - remaining_string.size(); |
| std::vector<base::string16> substrings; |
| // If the current range is not a custom_view, then we use |
| // ElideRectangleText() to determine the line wrapping. Note: if it is a |
| // custom_view, then the |position| should equal range.start() because the |
| // custom_view is treated as one unit. |
| if (position != range.start() || |
| (current_range != style_ranges_.end() && |
| !current_range->style_info.custom_view)) { |
| const gfx::Rect chunk_bounds(line_size.width(), 0, |
| content_width - line_size.width(), |
| line_height); |
| // If the start of the remaining text is inside a styled range, the font |
| // style may differ from the base font. The font specified by the range |
| // should be used when eliding text. |
| gfx::FontList text_font_list = |
| GetFontList((position >= range.start()) ? current_range->style_info |
| : RangeStyleInfo()); |
| int elide_result = gfx::ElideRectangleText( |
| remaining_string, text_font_list, chunk_bounds.width(), |
| chunk_bounds.height(), gfx::WRAP_LONG_WORDS, &substrings); |
| |
| if (substrings.empty()) { |
| // There is no room for anything. Since wrapping is enabled, this |
| // should only occur if there is insufficient vertical space |
| // remaining. ElideRectangleText() always adds a single character, |
| // even if there is no room horizontally. |
| DCHECK_NE(0, elide_result & gfx::INSUFFICIENT_SPACE_VERTICAL); |
| // There's no way to continue processing; clear |remaining_string| so |
| // the outer loop will terminate after this iteration completes. |
| remaining_string.clear(); |
| break; |
| } |
| |
| // Views are aligned to integer coordinates, but typesetting is not. |
| // This means that it's possible for an ElideRectangleText on a prior |
| // iteration to fit a word on the current line, which does not fit after |
| // that word is wrapped in a View for its chunk at the end of the line. |
| // In most cases, this will just wrap more words on to the next line. |
| // However, if the remaining chunk width is insufficient for the very |
| // _first_ word, that word will be incorrectly split. In this case, |
| // start a new line instead. |
| bool truncated_chunk = |
| line_size.width() != 0 && |
| (elide_result & gfx::INSUFFICIENT_SPACE_FOR_FIRST_WORD) != 0; |
| if (substrings[0].empty() || truncated_chunk) { |
| // The entire line is \n, or nothing else fits on this line. Wrap, |
| // unless this is the first line, in which case we strip leading |
| // whitespace and try again. |
| if ((line_size.width() != 0) || |
| (layout_views_->views_per_line.size() > 1)) |
| break; |
| can_trim_leading_whitespace = true; |
| continue; |
| } |
| } |
| |
| base::string16 chunk; |
| View* custom_view = nullptr; |
| std::unique_ptr<Label> label; |
| if (position >= range.start()) { |
| const RangeStyleInfo& style_info = current_range->style_info; |
| |
| if (style_info.custom_view) { |
| custom_view = style_info.custom_view; |
| // Custom views must be marked as such. |
| DCHECK(custom_view->GetProperty(kStyledLabelCustomViewKey)); |
| // Do not allow wrap in custom view. |
| DCHECK_EQ(position, range.start()); |
| chunk = remaining_string.substr(0, range.end() - position); |
| } else { |
| chunk = substrings[0]; |
| } |
| |
| if (((custom_view && |
| line_size.width() + custom_view->GetPreferredSize().width() > |
| content_width) || |
| (style_info.disable_line_wrapping && |
| chunk.size() < range.length())) && |
| position == range.start() && line_size.width() != 0) { |
| // If the chunk should not be wrapped, try to fit it entirely on the |
| // next line. |
| break; |
| } |
| |
| if (chunk.size() > range.end() - position) |
| chunk = chunk.substr(0, range.end() - position); |
| |
| if (!custom_view) |
| label = CreateLabel(chunk, style_info, range); |
| |
| if (position + chunk.size() >= range.end()) |
| ++current_range; |
| } else { |
| chunk = substrings[0]; |
| if (position + chunk.size() > range.start()) |
| chunk = chunk.substr(0, range.start() - position); |
| |
| // This chunk is normal text. |
| label = CreateLabel(chunk, default_style, range); |
| } |
| |
| View* child_view = custom_view ? custom_view : label.get(); |
| const gfx::Size child_size = child_view->GetPreferredSize(); |
| // A custom view could be wider than the available width. |
| line_size.SetSize( |
| std::min(line_size.width() + child_size.width(), content_width), |
| std::max(line_size.height(), child_size.height())); |
| |
| views.push_back(child_view); |
| if (label) |
| layout_views_->owned_views.push_back(std::move(label)); |
| |
| remaining_string = remaining_string.substr(chunk.size()); |
| |
| // If |gfx::ElideRectangleText| returned more than one substring, that |
| // means the whole text did not fit into remaining line width, with text |
| // after |susbtring[0]| spilling into next line. If whole |substring[0]| |
| // was added to the current line (this may not be the case if part of the |
| // substring has different style), proceed to the next line. |
| if (!custom_view && substrings.size() > 1 && |
| chunk.size() == substrings[0].size()) { |
| break; |
| } |
| } |
| |
| if (views.empty() && remaining_string.empty()) { |
| // Remove an empty last line. |
| layout_size_info_.line_sizes.pop_back(); |
| layout_views_->views_per_line.pop_back(); |
| } else { |
| max_width = std::max(max_width, line_size.width()); |
| total_height += line_size.height(); |
| |
| // Trim whitespace at the start of the next line. |
| can_trim_leading_whitespace = true; |
| } |
| } |
| |
| layout_size_info_.total_size.SetSize(max_width + insets.width(), |
| total_height + insets.height()); |
| } |
| |
| std::unique_ptr<Label> StyledLabel::CreateLabel( |
| const base::string16& text, |
| const RangeStyleInfo& style_info, |
| const gfx::Range& range) const { |
| std::unique_ptr<Label> result; |
| if (style_info.text_style == style::STYLE_LINK) { |
| // Nothing should (and nothing does) use a custom font for links. |
| DCHECK(!style_info.custom_font); |
| |
| // Note this ignores |default_text_style_|, in favor of style::STYLE_LINK. |
| auto link = std::make_unique<Link>(text, text_context_); |
| link->SetCallback(style_info.callback); |
| |
| result = std::move(link); |
| } else if (style_info.custom_font) { |
| result = std::make_unique<Label>( |
| text, Label::CustomFont{style_info.custom_font.value()}); |
| } else { |
| result = std::make_unique<Label>( |
| text, text_context_, |
| style_info.text_style.value_or(default_text_style_)); |
| } |
| |
| if (style_info.override_color) |
| result->SetEnabledColor(style_info.override_color.value()); |
| if (!style_info.tooltip.empty()) |
| result->SetTooltipText(style_info.tooltip); |
| if (displayed_on_background_color_) |
| result->SetBackgroundColor(displayed_on_background_color_.value()); |
| result->SetAutoColorReadabilityEnabled(auto_color_readability_enabled_); |
| |
| return result; |
| } |
| |
| void StyledLabel::UpdateLabelBackgroundColor() { |
| SkColor new_color = |
| displayed_on_background_color_.value_or(GetNativeTheme()->GetSystemColor( |
| ui::NativeTheme::kColorId_DialogBackground)); |
| for (View* child : children()) { |
| if (!child->GetProperty(kStyledLabelCustomViewKey)) { |
| // TODO(kylixrd): Should updating the label background color even be |
| // allowed if there are custom views? |
| DCHECK((child->GetClassName() == Label::kViewClassName) || |
| (child->GetClassName() == Link::kViewClassName)); |
| static_cast<Label*>(child)->SetBackgroundColor(new_color); |
| } |
| } |
| } |
| |
| void StyledLabel::RemoveOrDeleteAllChildViews() { |
| while (children().size() > 0) { |
| std::unique_ptr<View> view = RemoveChildViewT(children()[0]); |
| if (view->GetProperty(kStyledLabelCustomViewKey)) |
| custom_views_.push_back(std::move(view)); |
| } |
| } |
| |
| BEGIN_METADATA(StyledLabel, View) |
| ADD_PROPERTY_METADATA(base::string16, Text) |
| ADD_PROPERTY_METADATA(int, TextContext) |
| ADD_PROPERTY_METADATA(int, DefaultTextStyle) |
| ADD_PROPERTY_METADATA(int, LineHeight) |
| ADD_PROPERTY_METADATA(bool, AutoColorReadabilityEnabled) |
| ADD_PROPERTY_METADATA(base::Optional<SkColor>, DisplayedOnBackgroundColor) |
| END_METADATA |
| |
| } // namespace views |