| // 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 "components/live_caption/views/caption_bubble.h" |
| |
| #include <algorithm> |
| #include <memory> |
| #include <string> |
| #include <utility> |
| #include <vector> |
| |
| #include "base/feature_list.h" |
| #include "base/functional/bind.h" |
| #include "base/i18n/rtl.h" |
| #include "base/memory/raw_ptr.h" |
| #include "base/metrics/histogram_functions.h" |
| #include "base/numerics/safe_conversions.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/time/time.h" |
| #include "build/build_config.h" |
| #include "components/language/core/common/language_util.h" |
| #include "components/live_caption/caption_bubble_context.h" |
| #include "components/live_caption/caption_bubble_settings.h" |
| #include "components/live_caption/views/format_constants.h" |
| #include "components/live_caption/views/translation_view_wrapper_base.h" |
| #include "components/strings/grit/components_strings.h" |
| #include "components/translate/core/browser/translate_download_manager.h" |
| #include "components/translate/core/browser/translate_ui_languages_manager.h" |
| #include "components/vector_icons/vector_icons.h" |
| #include "media/base/media_switches.h" |
| #include "third_party/re2/src/re2/re2.h" |
| #include "ui/accessibility/ax_action_data.h" |
| #include "ui/accessibility/ax_enums.mojom.h" |
| #include "ui/accessibility/ax_node_data.h" |
| #include "ui/accessibility/platform/ax_mode_observer.h" |
| #include "ui/base/buildflags.h" |
| #include "ui/base/cursor/cursor.h" |
| #include "ui/base/hit_test.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/metadata/metadata_impl_macros.h" |
| #include "ui/base/mojom/dialog_button.mojom.h" |
| #include "ui/base/mojom/menu_source_type.mojom-shared.h" |
| #include "ui/color/color_id.h" |
| #include "ui/compositor/layer.h" |
| #include "ui/events/event.h" |
| #include "ui/gfx/animation/slide_animation.h" |
| #include "ui/gfx/font.h" |
| #include "ui/gfx/font_list.h" |
| #include "ui/gfx/geometry/insets.h" |
| #include "ui/gfx/geometry/rounded_corners_f.h" |
| #include "ui/gfx/geometry/vector2d.h" |
| #include "ui/gfx/paint_vector_icon.h" |
| #include "ui/menus/simple_menu_model.h" |
| #include "ui/strings/grit/ui_strings.h" |
| #include "ui/views/accessibility/ax_virtual_view.h" |
| #include "ui/views/accessibility/view_accessibility.h" |
| #include "ui/views/animation/ink_drop.h" |
| #include "ui/views/bubble/bubble_dialog_delegate_view.h" |
| #include "ui/views/bubble/bubble_frame_view.h" |
| #include "ui/views/controls/button/button.h" |
| #include "ui/views/controls/button/checkbox.h" |
| #include "ui/views/controls/button/image_button.h" |
| #include "ui/views/controls/button/image_button_factory.h" |
| #include "ui/views/controls/button/md_text_button.h" |
| #include "ui/views/controls/button/md_text_button_with_down_arrow.h" |
| #include "ui/views/controls/highlight_path_generator.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/controls/scroll_view.h" |
| #include "ui/views/controls/styled_label.h" |
| #include "ui/views/event_monitor.h" |
| #include "ui/views/layout/box_layout.h" |
| #include "ui/views/layout/box_layout_view.h" |
| #include "ui/views/layout/flex_layout.h" |
| #include "ui/views/layout/flex_layout_types.h" |
| #include "ui/views/layout/layout_provider.h" |
| #include "ui/views/layout/layout_types.h" |
| #include "ui/views/style/typography.h" |
| #include "ui/views/vector_icons.h" |
| #include "ui/views/view.h" |
| #include "ui/views/view_class_properties.h" |
| #include "ui/views/widget/widget.h" |
| |
| // The CaptionBubbleLabel needs to be focusable in order for NVDA to enable |
| // document navigation. It is suspected that other screen readers on Windows and |
| // Linux will need this behavior, too. VoiceOver and ChromeVox do not need the |
| // label to be focusable. |
| #if BUILDFLAG(HAS_NATIVE_ACCESSIBILITY) && !BUILDFLAG(IS_MAC) |
| #define NEED_FOCUS_FOR_ACCESSIBILITY |
| #endif |
| |
| #if defined(NEED_FOCUS_FOR_ACCESSIBILITY) |
| #include "base/scoped_observation.h" |
| #include "ui/accessibility/platform/ax_platform.h" |
| #endif |
| |
| namespace captions { |
| namespace { |
| |
| constexpr base::TimeDelta kAnimationDuration = base::Milliseconds(250); |
| |
| std::unique_ptr<views::ImageButton> BuildImageButton( |
| views::Button::PressedCallback callback, |
| const int tooltip_text_id) { |
| auto button = views::CreateVectorImageButton(std::move(callback)); |
| button->SetTooltipText(l10n_util::GetStringUTF16(tooltip_text_id)); |
| button->SizeToPreferredSize(); |
| views::InstallCircleHighlightPathGenerator( |
| button.get(), gfx::Insets(kButtonCircleHighlightPaddingDip)); |
| return button; |
| } |
| |
| // ui::CaptionStyle styles are CSS strings that can sometimes have !important. |
| // This method removes " !important" if it exists at the end of a CSS string. |
| std::string MaybeRemoveCSSImportant(std::string css_string) { |
| RE2::Replace(&css_string, "\\s+!important", ""); |
| return css_string; |
| } |
| |
| gfx::FontList GetNewFontList(const std::vector<std::string>& font_names, |
| int font_style, |
| int font_size, |
| gfx::Font::Weight font_weight) { |
| return gfx::FontList(font_names, font_style, font_size, font_weight); |
| } |
| |
| // Parses a CSS color string that is in the form rgba and has a non-zero alpha |
| // value into an SkColor and sets it to be the value of the passed-in SkColor |
| // address. Returns whether or not the operation was a success. |
| // |
| // `css_string` is the CSS color string passed in. It is in the form |
| // rgba(#,#,#,#). r, g, and b are integers between 0 and 255, inclusive. |
| // a is a double between 0.0 and 1.0. There may be whitespace in between the |
| // commas. |
| // `sk_color` is the address of an SKColor. If the operation is a success, the |
| // function will set sk_color's value to be the parsed SkColor. If the |
| // operation is not a success, sk_color's value will not change. |
| // |
| // As of spring 2021, all OS's use rgba to define the caption style colors. |
| // However, the ui::CaptionStyle spec also allows for the use of any valid CSS |
| // color spec. This function will need to be revisited should ui::CaptionStyle |
| // colors use non-rgba to define their colors. |
| bool ParseNonTransparentRGBACSSColorString( |
| std::string css_string, |
| SkColor* sk_color, |
| const ui::ColorProvider* color_provider) { |
| std::string rgba = MaybeRemoveCSSImportant(css_string); |
| if (rgba.empty()) { |
| return false; |
| } |
| uint16_t r, g, b; |
| double a; |
| bool match = RE2::FullMatch( |
| rgba, "rgba\\((\\d+),\\s*(\\d+),\\s*(\\d+),\\s*(\\d+\\.?\\d*)\\)", &r, &g, |
| &b, &a); |
| // If the opacity is set to 0 (fully transparent), we ignore the user's |
| // preferred style and use our default color. |
| if (!match || a == 0) { |
| return false; |
| } |
| uint16_t a_int = base::ClampRound<uint16_t>(a * 255); |
| #if BUILDFLAG(IS_MAC) |
| // On Mac, any opacity lower than 90% leaves rendering artifacts which make |
| // it appear like there is a layer of faint text beneath the actual text. |
| // TODO(crbug.com/40177817): Fix the rendering issue and then remove this |
| // workaround. |
| a_int = std::max(static_cast<uint16_t>(SkColorGetA(color_provider->GetColor( |
| ui::kColorLiveCaptionBubbleBackgroundDefault))), |
| a_int); |
| #endif |
| *sk_color = SkColorSetARGB(a_int, r, g, b); |
| return match; |
| } |
| |
| } // namespace |
| |
| // Helper class for observing mouse and key events from native window. |
| class CaptionBubbleEventObserver : public ui::EventObserver { |
| public: |
| CaptionBubbleEventObserver(captions::CaptionBubble* caption_bubble, |
| views::Widget* widget) |
| : caption_bubble_(caption_bubble) { |
| CHECK(widget); |
| event_monitor_ = views::EventMonitor::CreateWindowMonitor( |
| this, widget->GetNativeWindow(), |
| {ui::EventType::kMouseMoved, ui::EventType::kMouseExited, |
| ui::EventType::kKeyPressed, ui::EventType::kKeyReleased}); |
| } |
| |
| CaptionBubbleEventObserver(const CaptionBubbleEventObserver&) = delete; |
| CaptionBubbleEventObserver& operator=(const CaptionBubbleEventObserver&) = |
| delete; |
| ~CaptionBubbleEventObserver() override = default; |
| |
| void OnEvent(const ui::Event& event) override { |
| if (event.IsKeyEvent()) { |
| caption_bubble_->UpdateControlsVisibility(true); |
| return; |
| } |
| |
| // We check if the mouse is in bounds rather than strictly |
| // checking mouse enter/exit events because of two reasons: 1. We get |
| // mouse exit/enter events when the mouse moves between client and |
| // non-client areas on Linux and Windows; 2. We get a mouse exit event when |
| // a context menu is brought up, which might cause the caption bubble to be |
| // stuck in the "in" state when some other window is on top of the caption |
| // bubble. |
| caption_bubble_->OnMouseEnteredOrExitedWindow(IsMouseInBounds()); |
| } |
| |
| private: |
| bool IsMouseInBounds() { |
| gfx::Point point = event_monitor_->GetLastMouseLocation(); |
| views::View::ConvertPointFromScreen(caption_bubble_, &point); |
| |
| return caption_bubble_->GetLocalBounds().Contains(point); |
| } |
| |
| raw_ptr<captions::CaptionBubble> caption_bubble_; |
| std::unique_ptr<views::EventMonitor> event_monitor_; |
| }; |
| |
| #if BUILDFLAG(IS_CHROMEOS) |
| DEFINE_UI_CLASS_PROPERTY_KEY(bool, kIsCaptionBubbleKey, false) |
| #endif |
| |
| #if BUILDFLAG(IS_WIN) |
| class MediaFoundationRendererErrorMessageView : public views::StyledLabel { |
| METADATA_HEADER(MediaFoundationRendererErrorMessageView, views::StyledLabel) |
| |
| public: |
| explicit MediaFoundationRendererErrorMessageView( |
| CaptionBubble* caption_bubble) |
| : caption_bubble_(caption_bubble) {} |
| |
| // views::View: |
| bool HandleAccessibleAction(const ui::AXActionData& action_data) override { |
| switch (action_data.action) { |
| case ax::mojom::Action::kDoDefault: |
| caption_bubble_->OnContentSettingsLinkClicked(); |
| return true; |
| default: |
| break; |
| } |
| return views::StyledLabel::HandleAccessibleAction(action_data); |
| } |
| |
| private: |
| const raw_ptr<CaptionBubble> caption_bubble_; // Not owned. |
| }; |
| |
| BEGIN_METADATA(MediaFoundationRendererErrorMessageView) |
| END_METADATA |
| |
| #endif |
| |
| // CaptionBubble implementation of BubbleFrameView. This class takes care |
| // of making the caption draggable. |
| class CaptionBubbleFrameView : public views::BubbleFrameView { |
| METADATA_HEADER(CaptionBubbleFrameView, views::BubbleFrameView) |
| |
| public: |
| CaptionBubbleFrameView( |
| std::vector<raw_ptr<views::View, VectorExperimental>> buttons, |
| raw_ptr<views::View> scrollable) |
| : views::BubbleFrameView(gfx::Insets(), gfx::Insets()), |
| buttons_(buttons), |
| scrollable_(scrollable) { |
| auto border = std::make_unique<views::BubbleBorder>( |
| views::BubbleBorder::FLOAT, views::BubbleBorder::DIALOG_SHADOW); |
| border->set_rounded_corners(gfx::RoundedCornersF(kCornerRadiusDip)); |
| views::BubbleFrameView::SetBubbleBorder(std::move(border)); |
| } |
| |
| ~CaptionBubbleFrameView() override = default; |
| CaptionBubbleFrameView(const CaptionBubbleFrameView&) = delete; |
| CaptionBubbleFrameView& operator=(const CaptionBubbleFrameView&) = delete; |
| |
| // TODO(crbug.com/40119836): This does not work on Linux because the bubble is |
| // not a top-level view, so it doesn't receive events. See crbug.com/1074054 |
| // for more about why it doesn't work. |
| int NonClientHitTest(const gfx::Point& point) override { |
| // Outside of the window bounds, do nothing. |
| if (!bounds().Contains(point)) { |
| return HTNOWHERE; |
| } |
| |
| // |point| is in coordinates relative to CaptionBubbleFrameView, i.e. |
| // (0,0) is the upper left corner of this view. Convert it to screen |
| // coordinates to see whether one of the buttons contains this point. |
| // If it is, return HTCLIENT, so that the click is sent through to be |
| // handled by CaptionBubble::BubblePressed(). |
| gfx::Point point_in_screen = |
| GetBoundsInScreen().origin() + gfx::Vector2d(point.x(), point.y()); |
| for (views::View* button : buttons_) { |
| if (button->GetBoundsInScreen().Contains(point_in_screen)) { |
| return HTCLIENT; |
| } |
| } |
| |
| if (scrollable_ && |
| scrollable_->GetBoundsInScreen().Contains(point_in_screen)) { |
| return HTCLIENT; |
| } |
| |
| // Ensure it's within the BubbleFrameView. This takes into account the |
| // rounded corners and drop shadow of the BubbleBorder. |
| int hit = views::BubbleFrameView::NonClientHitTest(point); |
| |
| // After BubbleFrameView::NonClientHitTest processes the bubble-specific |
| // hits such as the rounded corners, it checks hits to the bubble's client |
| // view. Any hits to ClientFrameView::NonClientHitTest return HTCLIENT or |
| // HTNOWHERE. Override these to return HTCAPTION in order to make the |
| // entire widget draggable. |
| return (hit == HTCLIENT || hit == HTNOWHERE) ? HTCAPTION : hit; |
| } |
| |
| private: |
| const std::vector<raw_ptr<views::View, VectorExperimental>> buttons_; |
| const raw_ptr<views::View> scrollable_; |
| }; |
| |
| BEGIN_METADATA(CaptionBubbleFrameView) |
| END_METADATA |
| |
| #if defined(NEED_FOCUS_FOR_ACCESSIBILITY) |
| // A helper class to the CaptionBubbleLabel which observes AXMode changes and |
| // updates the CaptionBubbleLabel focus behavior in response. |
| // TODO(crbug.com/40756389): Implement a ui::AXModeObserver::OnAXModeRemoved |
| // method which observes the removal of AXModes. Without that, the caption |
| // bubble label will remain focusable once accessibility is enabled, even if |
| // accessibility is later disabled. |
| class CaptionBubbleLabelAXModeObserver : public ui::AXModeObserver { |
| public: |
| explicit CaptionBubbleLabelAXModeObserver(CaptionBubbleLabel* owner) |
| : owner_(owner) { |
| ax_mode_observation_.Observe(&ui::AXPlatform::GetInstance()); |
| } |
| |
| ~CaptionBubbleLabelAXModeObserver() override = default; |
| |
| CaptionBubbleLabelAXModeObserver(const CaptionBubbleLabelAXModeObserver&) = |
| delete; |
| CaptionBubbleLabelAXModeObserver& operator=( |
| const CaptionBubbleLabelAXModeObserver&) = delete; |
| |
| void OnAXModeAdded(ui::AXMode mode) override; |
| |
| private: |
| raw_ptr<CaptionBubbleLabel> owner_; |
| base::ScopedObservation<ui::AXPlatform, ui::AXModeObserver> |
| ax_mode_observation_{this}; |
| }; |
| #endif |
| |
| // CaptionBubble implementation of Label. This class takes care of setting up |
| // the accessible virtual views of the label in order to support braille |
| // accessibility. The CaptionBubbleLabel is a readonly document with a paragraph |
| // inside. Inside the paragraph are staticText nodes, one for each visual line |
| // in the rendered text of the label. These staticText nodes are shown on a |
| // braille display so that a braille user can read the caption text line by |
| // line. |
| class CaptionBubbleLabel : public views::Label { |
| METADATA_HEADER(CaptionBubbleLabel, views::Label) |
| |
| public: |
| CaptionBubbleLabel() { |
| GetViewAccessibility().SetRole(ax::mojom::Role::kDocument); |
| GetViewAccessibility().SetName( |
| std::u16string(), ax::mojom::NameFrom::kAttributeExplicitlyEmpty); |
| GetViewAccessibility().SetReadOnly(true); |
| #if defined(NEED_FOCUS_FOR_ACCESSIBILITY) |
| ax_mode_observer_ = |
| std::make_unique<CaptionBubbleLabelAXModeObserver>(this); |
| SetFocusBehaviorForAccessibility(); |
| #endif |
| } |
| ~CaptionBubbleLabel() override = default; |
| CaptionBubbleLabel(const CaptionBubbleLabel&) = delete; |
| CaptionBubbleLabel& operator=(const CaptionBubbleLabel&) = delete; |
| |
| void SetMinimumHeight(int height) { |
| if (minimum_height_ == height) { |
| return; |
| } |
| |
| minimum_height_ = height; |
| PreferredSizeChanged(); |
| } |
| |
| void SetText(std::u16string_view text) override { |
| views::Label::SetText(text); |
| |
| auto& ax_lines = GetViewAccessibility().virtual_children(); |
| if (text.empty() && !ax_lines.empty()) { |
| GetViewAccessibility().RemoveAllVirtualChildViews(); |
| NotifyAccessibilityEventDeprecated(ax::mojom::Event::kChildrenChanged, |
| true); |
| return; |
| } |
| |
| const size_t num_lines = GetRequiredLines(); |
| size_t start = 0; |
| for (size_t i = 0; i < num_lines - 1; ++i) { |
| size_t end = GetTextIndexOfLine(i + 1); |
| std::u16string_view substring = text.substr(start, end - start); |
| UpdateAXLine(substring, i, gfx::Range(start, end)); |
| start = end; |
| } |
| std::u16string_view substring = text.substr(start, text.size() - start); |
| if (!substring.empty()) { |
| UpdateAXLine(substring, num_lines - 1, gfx::Range(start, text.size())); |
| } |
| |
| // Remove all ax_lines that don't have a corresponding line. |
| size_t num_ax_lines = ax_lines.size(); |
| for (size_t i = num_lines; i < num_ax_lines; ++i) { |
| GetViewAccessibility().RemoveVirtualChildView(ax_lines.back().get()); |
| NotifyAccessibilityEventDeprecated(ax::mojom::Event::kChildrenChanged, |
| true); |
| } |
| } |
| |
| #if defined(NEED_FOCUS_FOR_ACCESSIBILITY) |
| // The CaptionBubbleLabel needs to be focusable in order for NVDA to enable |
| // document navigation. Making the CaptionBubbleLabel focusable means it gets |
| // a tabstop, so it should only be focusable for screen reader users. |
| void SetFocusBehaviorForAccessibility() { |
| SetFocusBehavior(ui::AXPlatform::GetInstance().GetMode().has_mode( |
| ui::AXMode::kExtendedProperties) |
| ? FocusBehavior::ALWAYS |
| : FocusBehavior::NEVER); |
| } |
| #endif |
| |
| private: |
| void UpdateAXLine(std::u16string_view line_text, |
| const size_t line_index, |
| const gfx::Range& text_range) { |
| auto& ax_lines = GetViewAccessibility().virtual_children(); |
| |
| // Add a new virtual child for a new line of text. |
| DCHECK(line_index <= ax_lines.size()); |
| if (line_index == ax_lines.size()) { |
| auto ax_line = std::make_unique<views::AXVirtualView>(); |
| ax_line->SetRole(ax::mojom::Role::kStaticText); |
| GetViewAccessibility().AddVirtualChildView(std::move(ax_line)); |
| NotifyAccessibilityEventDeprecated(ax::mojom::Event::kChildrenChanged, |
| true); |
| } |
| |
| // Set the virtual child's name as line text. |
| if (ax_lines[line_index]->GetCachedName() != line_text) { |
| ax_lines[line_index]->SetName(std::u16string(line_text)); |
| std::vector<gfx::Rect> bounds = GetSubstringBounds(text_range); |
| ax_lines[line_index]->SetBounds(gfx::RectF(bounds[0])); |
| ax_lines[line_index]->NotifyEvent(ax::mojom::Event::kTextChanged, true); |
| } |
| } |
| |
| // Label: |
| gfx::Size CalculatePreferredSize( |
| const views::SizeBounds& available_size) const override { |
| gfx::Size preferred_size = |
| views::Label::CalculatePreferredSize(available_size); |
| preferred_size.set_height( |
| std::max(minimum_height_, preferred_size.height())); |
| return preferred_size; |
| } |
| |
| #if defined(NEED_FOCUS_FOR_ACCESSIBILITY) |
| std::unique_ptr<CaptionBubbleLabelAXModeObserver> ax_mode_observer_; |
| #endif |
| int minimum_height_ = 0; |
| }; |
| |
| BEGIN_METADATA(CaptionBubbleLabel) |
| END_METADATA |
| |
| #if defined(NEED_FOCUS_FOR_ACCESSIBILITY) |
| void CaptionBubbleLabelAXModeObserver::OnAXModeAdded(ui::AXMode mode) { |
| owner_->SetFocusBehaviorForAccessibility(); |
| } |
| #endif |
| |
| class CaptionBubbleScrollView : public views::ScrollView { |
| METADATA_HEADER(CaptionBubbleScrollView, views::ScrollView) |
| |
| public: |
| CaptionBubbleScrollView() { |
| ClipHeightTo(kMinScrollViewHeight, kMaxScrollViewHeightExpanded); |
| SetHorizontalScrollBarMode(views::ScrollView::ScrollBarMode::kDisabled); |
| SetVerticalScrollBarMode(views::ScrollView::ScrollBarMode::kEnabled); |
| } |
| |
| ~CaptionBubbleScrollView() override = default; |
| |
| CaptionBubbleScrollView(const CaptionBubbleScrollView&) = delete; |
| CaptionBubbleScrollView& operator=(const CaptionBubbleScrollView&) = delete; |
| }; |
| |
| BEGIN_METADATA(CaptionBubbleScrollView) |
| END_METADATA |
| |
| class ScrollLockButton : public views::MdTextButton { |
| METADATA_HEADER(ScrollLockButton, views::MdTextButton) |
| |
| public: |
| explicit ScrollLockButton(views::MdTextButton::PressedCallback callback) |
| : views::MdTextButton( |
| std::move(callback), |
| l10n_util::GetStringUTF16( |
| IDS_LIVE_CAPTION_BUBBLE_SCROLL_BUTTON_SCROLLING)) { |
| SetCustomPadding(kScrollLockButtonInsets); |
| label()->SetMultiLine(false); |
| SetImageLabelSpacing(kScrollLockButtonImageLabelSpacing); |
| SetBgColorIdOverride(ui::kColorLiveCaptionBubbleButtonBackground); |
| SetPaintToLayer(); |
| } |
| |
| ScrollLockButton(const ScrollLockButton&) = delete; |
| ScrollLockButton& operator=(const ScrollLockButton&) = delete; |
| ~ScrollLockButton() override = default; |
| |
| ui::Cursor GetCursor(const ui::MouseEvent& event) override { |
| return ui::mojom::CursorType::kHand; |
| } |
| void SetTextScaleFactor(double text_scale_factor) { |
| SetFocusRingCornerRadius(text_scale_factor * kLineHeightDip / 2); |
| } |
| |
| bool IsLocked() const { return locked_; } |
| |
| void FlipLock() { |
| locked_ = !locked_; |
| SetText(l10n_util::GetStringUTF16( |
| locked_ ? IDS_LIVE_CAPTION_BUBBLE_SCROLL_BUTTON_LOCKED |
| : IDS_LIVE_CAPTION_BUBBLE_SCROLL_BUTTON_SCROLLING)); |
| SchedulePaint(); |
| } |
| |
| views::Label* GetLabel() { return label(); } |
| |
| private: |
| bool locked_ = false; // Initially scrolling is allowed. |
| }; |
| |
| BEGIN_METADATA(ScrollLockButton) |
| END_METADATA |
| |
| CaptionBubble::CaptionBubble( |
| CaptionBubbleSettings* caption_bubble_settings, |
| std::unique_ptr<TranslationViewWrapperBase> translation_view_wrapper, |
| const std::string& application_locale, |
| base::OnceClosure destroyed_callback) |
| : views::BubbleDialogDelegateView(nullptr, |
| views::BubbleBorder::TOP_LEFT, |
| views::BubbleBorder::DIALOG_SHADOW, |
| true), |
| caption_bubble_settings_(caption_bubble_settings), |
| translation_view_wrapper_(std::move(translation_view_wrapper)), |
| destroyed_callback_(std::move(destroyed_callback)), |
| application_locale_(application_locale), |
| is_expanded_(caption_bubble_settings_->GetLiveCaptionBubbleExpanded()), |
| controls_animation_(this), |
| new_font_list_getter_(base::BindRepeating(GetNewFontList)) { |
| SetButtons(static_cast<int>(ui::mojom::DialogButton::kNone)); |
| // While not shown, the title is still used to identify the window in the |
| // window switcher. |
| SetShowTitle(false); |
| SetTitle(IDS_LIVE_CAPTION_BUBBLE_TITLE); |
| set_has_parent(false); |
| |
| controls_animation_.SetSlideDuration(kAnimationDuration); |
| controls_animation_.SetTweenType(gfx::Tween::LINEAR); |
| |
| GetViewAccessibility().SetRole(ax::mojom::Role::kDialog); |
| } |
| |
| CaptionBubble::~CaptionBubble() { |
| if (model_) { |
| model_->RemoveObserver(); |
| } |
| } |
| |
| gfx::Rect CaptionBubble::GetBubbleBounds() { |
| // Bubble bounds are what the computed bubble bounds would be, taking into |
| // account the current bubble size. |
| gfx::Rect bubble_bounds = views::BubbleDialogDelegateView::GetBubbleBounds(); |
| // Widget bounds are where the bubble currently is in space. |
| gfx::Rect widget_bounds = GetWidget()->GetWindowBoundsInScreen(); |
| // Use the widget x and y to keep the bubble oriented at its current location, |
| // and use the bubble width and height to set the correct bubble size. |
| return gfx::Rect(widget_bounds.x(), widget_bounds.y(), bubble_bounds.width(), |
| bubble_bounds.height()); |
| } |
| |
| void CaptionBubble::Init() { |
| SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kVertical)) |
| ->set_cross_axis_alignment( |
| views::BoxLayout::CrossAxisAlignment::kStretch); |
| UseCompactMargins(); |
| set_close_on_deactivate(false); |
| SetCanActivate(true); |
| |
| views::View* header_container = new views::View(); |
| header_container->SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kHorizontal)); |
| |
| views::View* right_header_container = new views::View(); |
| views::View* left_header_container = new views::View(); |
| views::View* translate_header_container = new views::View(); |
| |
| views::View* content_container = new views::View(); |
| content_container->SetLayoutManager(std::make_unique<views::FlexLayout>()) |
| ->SetOrientation(views::LayoutOrientation::kVertical) |
| .SetMainAxisAlignment(views::LayoutAlignment::kEnd) |
| .SetCrossAxisAlignment(views::LayoutAlignment::kStretch) |
| .SetInteriorMargin(gfx::Insets::VH(0, kSidePaddingDip)) |
| .SetDefault( |
| views::kFlexBehaviorKey, |
| views::FlexSpecification(views::MinimumFlexSizeRule::kPreferred, |
| views::MaximumFlexSizeRule::kPreferred, |
| /*adjust_height_for_width*/ true)); |
| |
| auto label = std::make_unique<CaptionBubbleLabel>(); |
| label->SetMultiLine(true); |
| label->SetBackgroundColor(SK_ColorTRANSPARENT); |
| label->SetHorizontalAlignment(gfx::HorizontalAlignment::ALIGN_LEFT); |
| label->SetVerticalAlignment(gfx::VerticalAlignment::ALIGN_TOP); |
| label->SetCustomTooltipText(std::u16string()); |
| // Render text truncates the end of text that is greater than 10000 chars. |
| // While it is unlikely that the text will exceed 10000 chars, it is not |
| // impossible, if the speech service sends a very long transcription_result. |
| // In order to guarantee that the caption bubble displays the last lines, and |
| // in order to ensure that caption_bubble_->GetTextIndexOfLine() is correct, |
| // set the truncate_length to 0 to ensure that it never truncates. |
| label->SetTruncateLength(0); |
| |
| auto title = std::make_unique<views::Label>(); |
| title->SetBackgroundColor(SK_ColorTRANSPARENT); |
| title->SetHorizontalAlignment(gfx::HorizontalAlignment::ALIGN_LEFT); |
| title->SetText(l10n_util::GetStringUTF16(IDS_LIVE_CAPTION_BUBBLE_TITLE)); |
| title->GetViewAccessibility().SetIsIgnored(true); |
| |
| // Define an error message that will be displayed in the caption bubble if a |
| // generic error is encountered. |
| auto generic_error_text = std::make_unique<views::Label>(); |
| generic_error_text->SetBackgroundColor(SK_ColorTRANSPARENT); |
| generic_error_text->SetHorizontalAlignment( |
| gfx::HorizontalAlignment::ALIGN_LEFT); |
| generic_error_text->SetText( |
| l10n_util::GetStringUTF16(IDS_LIVE_CAPTION_BUBBLE_ERROR)); |
| auto generic_error_message = std::make_unique<views::View>(); |
| generic_error_message |
| ->SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kHorizontal, gfx::Insets(), |
| kErrorMessageBetweenChildSpacingDip)) |
| ->set_cross_axis_alignment(views::BoxLayout::CrossAxisAlignment::kCenter); |
| generic_error_message->SetVisible(false); |
| auto generic_error_icon = std::make_unique<views::ImageView>(); |
| |
| #if BUILDFLAG(IS_WIN) |
| // Define an error message that will be displayed in the caption bubble if the |
| // renderer is using hardware-based decryption. |
| auto media_foundation_renderer_error_message = |
| std::make_unique<views::View>(); |
| media_foundation_renderer_error_message |
| ->SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kHorizontal, gfx::Insets(), |
| kErrorMessageBetweenChildSpacingDip)) |
| ->set_cross_axis_alignment(views::BoxLayout::CrossAxisAlignment::kStart); |
| media_foundation_renderer_error_message->SetVisible(false); |
| auto media_foundation_renderer_error_icon = |
| std::make_unique<views::ImageView>(); |
| auto media_foundation_renderer_error_text = |
| std::make_unique<MediaFoundationRendererErrorMessageView>(this); |
| media_foundation_renderer_error_text->SetAutoColorReadabilityEnabled(false); |
| media_foundation_renderer_error_text->SetSubpixelRenderingEnabled(false); |
| media_foundation_renderer_error_text->SetFocusBehavior(FocusBehavior::ALWAYS); |
| media_foundation_renderer_error_text->SetTextContext( |
| views::style::CONTEXT_DIALOG_BODY_TEXT); |
| |
| // Make the whole text view behave as a link for accessibility. |
| media_foundation_renderer_error_text->GetViewAccessibility().SetRole( |
| ax::mojom::Role::kLink); |
| |
| const std::u16string link = |
| l10n_util::GetStringUTF16(IDS_LIVE_CAPTION_BUBBLE_CONTENT_SETTINGS); |
| |
| media_foundation_renderer_error_text->SetText(l10n_util::GetStringFUTF16( |
| IDS_LIVE_CAPTION_BUBBLE_MEDIA_FOUNDATION_RENDERER_ERROR, link)); |
| |
| auto media_foundation_renderer_error_checkbox = |
| std::make_unique<views::Checkbox>( |
| l10n_util::GetStringUTF16( |
| IDS_LIVE_CAPTION_BUBBLE_MEDIA_FOUNDATION_RENDERER_ERROR_CHECKBOX), |
| base::BindRepeating( |
| &CaptionBubble::MediaFoundationErrorCheckboxPressed, |
| base::Unretained(this))); |
| #endif |
| |
| base::RepeatingClosure expand_or_collapse_callback = base::BindRepeating( |
| &CaptionBubble::ExpandOrCollapseButtonPressed, base::Unretained(this)); |
| auto expand_button = BuildImageButton(expand_or_collapse_callback, |
| IDS_LIVE_CAPTION_BUBBLE_EXPAND); |
| expand_button->SetVisible(!is_expanded_); |
| |
| auto collapse_button = BuildImageButton( |
| std::move(expand_or_collapse_callback), IDS_LIVE_CAPTION_BUBBLE_COLLAPSE); |
| collapse_button->SetVisible(is_expanded_); |
| |
| auto back_to_tab_button = BuildImageButton( |
| base::BindRepeating(&CaptionBubble::BackToTabButtonPressed, |
| base::Unretained(this)), |
| IDS_LIVE_CAPTION_BUBBLE_BACK_TO_TAB); |
| back_to_tab_button->SetVisible(false); |
| |
| auto close_button = |
| BuildImageButton(base::BindRepeating(&CaptionBubble::CloseButtonPressed, |
| base::Unretained(this)), |
| IDS_LIVE_CAPTION_BUBBLE_CLOSE); |
| |
| back_to_tab_button_ = |
| right_header_container->AddChildView(std::move(back_to_tab_button)); |
| close_button_ = right_header_container->AddChildView(std::move(close_button)); |
| |
| title_ = content_container->AddChildView(std::move(title)); |
| |
| if (IsScrollabilityEnabled()) { |
| scrollable_ = content_container->AddChildView( |
| std::make_unique<CaptionBubbleScrollView>()); |
| label_ = scrollable_->SetContents(std::move(label)); |
| } else { |
| label_ = content_container->AddChildView(std::move(label)); |
| } |
| |
| auto download_progress_label = std::make_unique<views::Label>(); |
| download_progress_label->SetBackgroundColor(SK_ColorTRANSPARENT); |
| download_progress_label->SetHorizontalAlignment( |
| gfx::HorizontalAlignment::ALIGN_CENTER); |
| download_progress_label->SetVerticalAlignment( |
| gfx::VerticalAlignment::ALIGN_MIDDLE); |
| download_progress_label->SetVisible(false); |
| download_progress_label_ = |
| content_container->AddChildView(std::move(download_progress_label)); |
| |
| generic_error_icon_ = |
| generic_error_message->AddChildView(std::move(generic_error_icon)); |
| generic_error_text_ = |
| generic_error_message->AddChildView(std::move(generic_error_text)); |
| generic_error_message_ = |
| content_container->AddChildView(std::move(generic_error_message)); |
| |
| #if BUILDFLAG(IS_WIN) |
| media_foundation_renderer_error_icon_ = |
| media_foundation_renderer_error_message->AddChildView( |
| std::move(media_foundation_renderer_error_icon)); |
| |
| auto inner_box_layout = std::make_unique<views::BoxLayoutView>(); |
| inner_box_layout->SetOrientation(views::BoxLayout::Orientation::kVertical); |
| inner_box_layout->SetBetweenChildSpacing( |
| views::LayoutProvider::Get()->GetDistanceMetric( |
| views::DISTANCE_UNRELATED_CONTROL_VERTICAL)); |
| media_foundation_renderer_error_text_ = inner_box_layout->AddChildView( |
| std::move(media_foundation_renderer_error_text)); |
| media_foundation_renderer_error_checkbox_ = inner_box_layout->AddChildView( |
| std::move(media_foundation_renderer_error_checkbox)); |
| media_foundation_renderer_error_message->AddChildView( |
| std::move(inner_box_layout)); |
| media_foundation_renderer_error_message_ = content_container->AddChildView( |
| std::move(media_foundation_renderer_error_message)); |
| #endif |
| |
| expand_button_ = content_container->AddChildView(std::move(expand_button)); |
| collapse_button_ = |
| content_container->AddChildView(std::move(collapse_button)); |
| |
| translation_view_wrapper_->Init(translate_header_container, |
| /*delegate=*/this); |
| |
| std::unique_ptr<views::BoxLayout> right_header_container_layout = |
| std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kHorizontal); |
| right_header_container_layout->set_main_axis_alignment( |
| views::BoxLayout::MainAxisAlignment::kEnd); |
| right_header_container_layout->set_cross_axis_alignment( |
| views::BoxLayout::CrossAxisAlignment::kCenter); |
| right_header_container->SetLayoutManager( |
| std::move(right_header_container_layout)); |
| std::unique_ptr<views::BoxLayout> translate_header_container_layout = |
| std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kHorizontal, gfx::Insets(), |
| kLanguageButtonImageLabelSpacing); |
| translate_header_container_layout->set_main_axis_alignment( |
| views::BoxLayout::MainAxisAlignment::kCenter); |
| translate_header_container->SetLayoutManager( |
| std::move(translate_header_container_layout)); |
| translate_header_container_ = left_header_container->AddChildViewRaw( |
| std::move(translate_header_container)); |
| |
| std::unique_ptr<views::BoxLayout> left_header_container_layout = |
| std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kHorizontal, |
| gfx::Insets::TLBR( |
| 0, close_button_->GetBorder()->GetInsets().width() / 2, 0, 0)); |
| |
| left_header_container_layout->set_cross_axis_alignment( |
| views::BoxLayout::CrossAxisAlignment::kCenter); |
| left_header_container_layout->set_between_child_spacing( |
| kLeftContainerSpacingDip); |
| left_header_container->SetLayoutManager( |
| std::move(left_header_container_layout)); |
| |
| if (IsScrollabilityEnabled()) { |
| scroll_lock_button_ = left_header_container->AddChildView( |
| std::make_unique<ScrollLockButton>(base::BindRepeating( |
| &CaptionBubble::ScrollLockButtonPressed, |
| base::Unretained( // Safe, since the button is owned by `this` |
| this)))); |
| scroll_lock_button_->GetViewAccessibility().SetIsIgnored(true); |
| scroll_lock_button_->SetVisible(is_expanded_); |
| scroll_lock_button_->SetPaintToLayer(); |
| scroll_lock_button_->layer()->SetFillsBoundsOpaquely(false); |
| scroll_lock_button_->layer()->SetOpacity(0); |
| } |
| |
| left_header_container_ = |
| header_container->AddChildViewRaw(std::move(left_header_container)); |
| header_container->AddChildViewRaw(std::move(right_header_container)); |
| header_container_ = AddChildViewRaw(std::move(header_container)); |
| AddChildViewRaw(std::move(content_container)); |
| |
| std::vector<raw_ptr<views::View, VectorExperimental>> buttons = GetButtons(); |
| for (views::View* button : buttons) { |
| button->SetPaintToLayer(); |
| button->layer()->SetFillsBoundsOpaquely(false); |
| button->layer()->SetOpacity(0); |
| } |
| |
| translate_header_container_->SetPaintToLayer(); |
| translate_header_container_->layer()->SetFillsBoundsOpaquely(false); |
| translate_header_container_->layer()->SetOpacity(0); |
| download_progress_label_->SetPaintToLayer(); |
| download_progress_label_->layer()->SetFillsBoundsOpaquely(false); |
| download_progress_label_->layer()->SetOpacity(0); |
| |
| UpdateContentSize(); |
| UpdateAccessibleName(); |
| title_text_changed_callback_ = |
| title_->AddTextChangedCallback(base::BindRepeating( |
| &CaptionBubble::OnTitleTextChanged, weak_ptr_factory_.GetWeakPtr())); |
| } |
| |
| void CaptionBubble::ResetScrollIfLocked(gfx::PointF current_offset, |
| views::ScrollView* scrollable) { |
| if (scroll_lock_button_->IsLocked()) { |
| scrollable->ScrollToOffset(current_offset); |
| } |
| } |
| |
| void CaptionBubble::OnBeforeBubbleWidgetInit(views::Widget::InitParams* params, |
| views::Widget* widget) const { |
| params->type = views::Widget::InitParams::TYPE_WINDOW; |
| params->z_order = ui::ZOrderLevel::kFloatingWindow; |
| params->visible_on_all_workspaces = true; |
| params->name = "LiveCaptionWindow"; |
| #if BUILDFLAG(IS_CHROMEOS) |
| params->init_properties_container.SetProperty(kIsCaptionBubbleKey, true); |
| #endif |
| } |
| |
| bool CaptionBubble::ShouldShowCloseButton() const { |
| // We draw our own close button so that we can capture the button presses and |
| // so we can customize its appearance. |
| return false; |
| } |
| |
| std::unique_ptr<views::FrameView> CaptionBubble::CreateFrameView( |
| views::Widget* widget) { |
| std::vector<raw_ptr<views::View, VectorExperimental>> buttons = GetButtons(); |
| caption_bubble_event_observer_ = |
| std::make_unique<CaptionBubbleEventObserver>(this, widget); |
| |
| if (IsScrollabilityEnabled()) { |
| buttons.emplace_back(scroll_lock_button_.get()); |
| } |
| auto frame = std::make_unique<CaptionBubbleFrameView>(buttons, scrollable_); |
| frame_ = frame.get(); |
| return frame; |
| } |
| |
| void CaptionBubble::OnWidgetActivationChanged(views::Widget* widget, |
| bool active) { |
| DCHECK_EQ(widget, GetWidget()); |
| |
| if (!active && mouse_inside_window_) { |
| active = true; |
| } |
| |
| UpdateControlsVisibility(active); |
| } |
| |
| std::u16string CaptionBubble::GetAccessibleWindowTitle() const { |
| return std::u16string(title_->GetText()); |
| } |
| |
| void CaptionBubble::OnThemeChanged() { |
| if (ThemeColorsChanged()) { |
| SetCaptionBubbleStyle(); |
| } |
| |
| // Call this after SetCaptionButtonStyle(), not before, since |
| // SetCaptionButtonStyle() calls SetBackgroundColor(), which |
| // OnThemeChanged() will trigger a read of. |
| views::BubbleDialogDelegateView::OnThemeChanged(); |
| } |
| |
| void CaptionBubble::BackToTabButtonPressed() { |
| DCHECK(model_); |
| DCHECK(model_->GetContext()->IsActivatable()); |
| model_->GetContext()->Activate(); |
| } |
| |
| void CaptionBubble::CloseButtonPressed() { |
| LogSessionEvent(SessionEvent::kCloseButtonClicked); |
| if (model_) { |
| model_->CloseButtonPressed(); |
| } |
| |
| #if BUILDFLAG(IS_CHROMEOS) |
| caption_bubble_settings_->SetLiveCaptionEnabled(false); |
| #endif |
| } |
| |
| void CaptionBubble::ExpandOrCollapseButtonPressed() { |
| is_expanded_ = !is_expanded_; |
| caption_bubble_settings_->SetLiveCaptionBubbleExpanded(is_expanded_); |
| base::UmaHistogramBoolean("Accessibility.LiveCaption.ExpandBubble", |
| is_expanded_); |
| |
| SwapButtons(collapse_button_, expand_button_, is_expanded_); |
| |
| if (IsScrollabilityEnabled()) { |
| scroll_lock_button_->SetVisible(is_expanded_); |
| |
| // Adjust scrollable view size. |
| scrollable_->ClipHeightTo(kMinScrollViewHeight, |
| is_expanded_ ? kMaxScrollViewHeightExpanded |
| : kMaxScrollViewHeightCollapsed); |
| } |
| |
| // The change of expanded state may cause the title to change visibility, and |
| // it surely causes the content height to change, so redraw the bubble. |
| Redraw(); |
| if (caption_bubble_settings_->ShouldAdjustPositionOnExpand() && model_ && |
| is_expanded_) { |
| model_->GetContext()->GetBounds( |
| base::BindOnce(&CaptionBubble::AdjustPosition, |
| weak_ptr_factory_.GetWeakPtr(), model_->unique_id())); |
| } |
| } |
| |
| void CaptionBubble::SwapButtons(views::Button* first_button, |
| views::Button* second_button, |
| bool show_first_button) { |
| if (!show_first_button) { |
| std::swap(first_button, second_button); |
| } |
| |
| second_button->SetVisible(false); |
| first_button->SetVisible(true); |
| |
| if (!first_button->HasFocus()) { |
| first_button->RequestFocus(); |
| } |
| } |
| |
| void CaptionBubble::CaptionSettingsButtonPressed() { |
| model_->GetContext()->GetOpenCaptionSettingsCallback().Run(); |
| } |
| |
| void CaptionBubble::ScrollLockButtonPressed() { |
| // Flip scroll lock button state. |
| scroll_lock_button_->FlipLock(); |
| // Capture current position if locked. |
| if (scroll_lock_button_->IsLocked()) { |
| // Layout will reset the scroll offset to 0, therefore, record offset |
| // if scrolling is locked and restore it on layout completion. |
| const auto current_offset = scrollable_->CurrentOffset(); |
| scrollable_->RegisterPostLayoutCallback(base::BindRepeating( |
| &CaptionBubble::ResetScrollIfLocked, |
| base::Unretained(this), // Safe: `scrollable_` is owned by `this` |
| current_offset)); |
| } else { |
| scrollable_->RegisterPostLayoutCallback(base::DoNothing()); |
| } |
| } |
| |
| void CaptionBubble::SetModel(CaptionBubbleModel* model) { |
| if (model_) { |
| model_->RemoveObserver(); |
| } |
| model_ = model; |
| if (model_) { |
| model_->SetObserver(this); |
| back_to_tab_button_->SetVisible(model_->GetContext()->IsActivatable()); |
| translation_view_wrapper_->UpdateLanguageLabel(); |
| } else { |
| UpdateBubbleVisibility(); |
| } |
| } |
| |
| void CaptionBubble::AnimationProgressed(const gfx::Animation* animation) { |
| std::vector<raw_ptr<views::View, VectorExperimental>> buttons = GetButtons(); |
| for (views::View* button : buttons) { |
| button->layer()->SetOpacity(animation->GetCurrentValue()); |
| } |
| translate_header_container_->layer()->SetOpacity( |
| animation->GetCurrentValue()); |
| download_progress_label_->layer()->SetOpacity(animation->GetCurrentValue()); |
| |
| if (IsScrollabilityEnabled()) { |
| scroll_lock_button_->layer()->SetOpacity(animation->GetCurrentValue()); |
| } |
| } |
| |
| void CaptionBubble::OnTextChanged() { |
| DCHECK(model_); |
| |
| if (IsScrollabilityEnabled()) { |
| if (scroll_lock_button_->IsLocked()) { |
| // Record offset to be used later and only if scrolling is still locked. |
| const auto current_offset = scrollable_->CurrentOffset(); |
| scrollable_->RegisterPostLayoutCallback(base::BindRepeating( |
| &CaptionBubble::ResetScrollIfLocked, |
| base::Unretained(this), // Safe: `scrollable_` is owned by `this` |
| current_offset)); |
| } else { |
| scrollable_->RegisterPostLayoutCallback(base::DoNothing()); |
| } |
| } |
| |
| std::string text = model_->GetFullText(); |
| label_->SetText(base::UTF8ToUTF16(text)); |
| |
| UpdateBubbleAndTitleVisibility(); |
| } |
| |
| void CaptionBubble::OnDownloadProgressTextChanged() { |
| if (!caption_bubble_settings_->IsLiveTranslateFeatureEnabled()) { |
| return; |
| } |
| |
| DCHECK(model_); |
| download_progress_label_->SetText(model_->GetDownloadProgressText()); |
| download_progress_label_->SetVisible(true); |
| |
| // Do not display captions while language packs are downloading. |
| label_->SetVisible(false); |
| |
| UpdateBubbleAndTitleVisibility(); |
| |
| if (GetWidget()->IsVisible()) { |
| UpdateControlsVisibility(true); |
| } |
| } |
| |
| void CaptionBubble::OnLanguagePackInstalled() { |
| download_progress_label_->SetVisible(false); |
| label_->SetVisible(true); |
| } |
| |
| void CaptionBubble::OnAutoDetectedLanguageChanged() { |
| std::string auto_detected_language_code = |
| model_->GetAutoDetectedLanguageCode(); |
| translation_view_wrapper_->OnAutoDetectedLanguageChanged( |
| auto_detected_language_code); |
| } |
| |
| bool CaptionBubble::ThemeColorsChanged() { |
| const auto* const color_provider = GetColorProvider(); |
| SkColor text_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleForegroundDefault); |
| SkColor icon_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleButtonIcon); |
| SkColor icon_disabled_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleButtonIconDisabled); |
| SkColor link_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleLink); |
| SkColor checkbox_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleCheckbox); |
| SkColor background_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleBackgroundDefault); |
| |
| bool theme_colors_changed = |
| text_color != text_color_ || icon_color != icon_color_ || |
| icon_disabled_color != icon_disabled_color_ || |
| link_color != link_color_ || checkbox_color != checkbox_color_ || |
| background_color != background_color_; |
| |
| text_color_ = text_color; |
| icon_color_ = icon_color; |
| icon_disabled_color_ = icon_disabled_color; |
| link_color_ = link_color; |
| checkbox_color_ = checkbox_color; |
| background_color_ = background_color; |
| |
| return theme_colors_changed; |
| } |
| |
| void CaptionBubble::OnErrorChanged( |
| CaptionBubbleErrorType error_type, |
| OnErrorClickedCallback callback, |
| OnDoNotShowAgainClickedCallback error_silenced_callback) { |
| DCHECK(model_); |
| error_clicked_callback_ = std::move(callback); |
| error_silenced_callback_ = std::move(error_silenced_callback); |
| bool has_error = model_->HasError(); |
| label_->SetVisible(!has_error); |
| expand_button_->SetVisible(!has_error && !is_expanded_); |
| collapse_button_->SetVisible(!has_error && is_expanded_); |
| if (IsScrollabilityEnabled()) { |
| scroll_lock_button_->SetVisible(!has_error && is_expanded_); |
| } |
| |
| #if BUILDFLAG(IS_WIN) |
| if (error_type == |
| CaptionBubbleErrorType::kMediaFoundationRendererUnsupported) { |
| media_foundation_renderer_error_message_->SetVisible(has_error); |
| generic_error_message_->SetVisible(false); |
| } else { |
| generic_error_message_->SetVisible(has_error); |
| media_foundation_renderer_error_message_->SetVisible(false); |
| } |
| #else |
| generic_error_message_->SetVisible(has_error); |
| #endif |
| |
| Redraw(); |
| } |
| |
| #if BUILDFLAG(IS_WIN) |
| void CaptionBubble::OnContentSettingsLinkClicked() { |
| if (error_clicked_callback_) { |
| error_clicked_callback_.Run(); |
| } |
| } |
| #endif |
| |
| void CaptionBubble::UpdateControlsVisibility(bool show_controls) { |
| if (show_controls) { |
| controls_animation_.Show(); |
| } else { |
| controls_animation_.Hide(); |
| } |
| } |
| |
| void CaptionBubble::OnMouseEnteredOrExitedWindow(bool entered) { |
| mouse_inside_window_ = entered; |
| UpdateControlsVisibility(mouse_inside_window_); |
| } |
| |
| void CaptionBubble::UpdateBubbleAndTitleVisibility() { |
| // Show the title if there is room for it and no error. |
| title_->SetVisible(model_ && !model_->HasError() && |
| GetNumLinesInLabel() < |
| static_cast<size_t>(GetNumLinesVisible())); |
| UpdateBubbleVisibility(); |
| } |
| |
| void CaptionBubble::UpdateBubbleVisibility() { |
| DCHECK(GetWidget()); |
| // If there is no model set, do not show the bubble. |
| if (!model_) { |
| Hide(); |
| return; |
| } |
| |
| // Hide the widget if the model is closed. |
| if (model_->IsClosed()) { |
| Hide(); |
| return; |
| } |
| |
| // Show the widget if it has text or an error or download progress to display. |
| if (!model_->GetFullText().empty() || model_->HasError() || |
| (caption_bubble_settings_->IsLiveTranslateFeatureEnabled() && |
| download_progress_label_->GetVisible())) { |
| ShowInactive(); |
| return; |
| } |
| |
| // No text and no error. Hide it. |
| Hide(); |
| } |
| |
| void CaptionBubble::UpdateCaptionStyle( |
| std::optional<ui::CaptionStyle> caption_style) { |
| caption_style_ = caption_style; |
| SetCaptionBubbleStyle(); |
| Redraw(); |
| } |
| |
| size_t CaptionBubble::GetTextIndexOfLineInLabel(size_t line) const { |
| return label_->GetTextIndexOfLine(line); |
| } |
| |
| size_t CaptionBubble::GetNumLinesInLabel() const { |
| return label_->GetRequiredLines(); |
| } |
| |
| int CaptionBubble::GetNumLinesVisible() { |
| return is_expanded_ ? kNumLinesExpanded : kNumLinesCollapsed; |
| } |
| |
| double CaptionBubble::GetTextScaleFactor() { |
| double textScaleFactor = 1; |
| if (caption_style_) { |
| std::string text_size = MaybeRemoveCSSImportant(caption_style_->text_size); |
| if (!text_size.empty()) { |
| // ui::CaptionStyle states that text_size is percentage as a CSS string. |
| bool match = |
| RE2::FullMatch(text_size, "(\\d+\\.?\\d*)%", &textScaleFactor); |
| textScaleFactor = match ? textScaleFactor / 100 : 1; |
| } |
| } |
| return textScaleFactor; |
| } |
| const gfx::FontList CaptionBubble::GetFontList(int font_size) { |
| std::vector<std::string> font_names; |
| if (caption_style_) { |
| std::string font_family = |
| MaybeRemoveCSSImportant(caption_style_->font_family); |
| if (!font_family.empty()) { |
| font_names.push_back(font_family); |
| } |
| } |
| font_names.push_back(kPrimaryFont); |
| font_names.push_back(kSecondaryFont); |
| font_names.push_back(kTertiaryFont); |
| #if BUILDFLAG(IS_CHROMEOS) |
| font_names.push_back(kArabicFont); |
| #endif |
| |
| const gfx::FontList font_list = new_font_list_getter_.Run( |
| font_names, gfx::Font::FontStyle::NORMAL, |
| font_size * GetTextScaleFactor(), gfx::Font::Weight::NORMAL); |
| return font_list; |
| } |
| |
| void CaptionBubble::SetTextSizeAndFontFamily() { |
| double textScaleFactor = GetTextScaleFactor(); |
| const gfx::FontList font_list = GetFontList(kFontSizePx); |
| label_->SetFontList(font_list); |
| title_->SetFontList(font_list.DeriveWithStyle(gfx::Font::FontStyle::ITALIC)); |
| generic_error_text_->SetFontList(font_list); |
| |
| label_->SetLineHeight(kLineHeightDip * textScaleFactor); |
| label_->SetMaximumWidth(kMaxWidthDip * textScaleFactor - kSidePaddingDip * 2); |
| title_->SetLineHeight(kLineHeightDip * textScaleFactor); |
| |
| download_progress_label_->SetLineHeight(kLiveTranslateLabelLineHeightDip * |
| textScaleFactor); |
| download_progress_label_->SetFontList( |
| GetFontList(kLiveTranslateLabelFontSizePx)); |
| translation_view_wrapper_->SetTextSizeAndFontFamily( |
| textScaleFactor, GetFontList(kLiveTranslateLabelFontSizePx)); |
| generic_error_text_->SetLineHeight(kLineHeightDip * textScaleFactor); |
| generic_error_icon_->SetImageSize( |
| gfx::Size(kErrorImageSizeDip * textScaleFactor, |
| kErrorImageSizeDip * textScaleFactor)); |
| |
| #if BUILDFLAG(IS_WIN) |
| media_foundation_renderer_error_icon_->SetImageSize( |
| gfx::Size(kErrorImageSizeDip, kErrorImageSizeDip)); |
| media_foundation_renderer_error_text_->SizeToFit( |
| kMaxWidthDip * textScaleFactor - kSidePaddingDip * 2); |
| #endif |
| } |
| |
| void CaptionBubble::SetTextColor() { |
| const auto* const color_provider = GetColorProvider(); |
| SkColor primary_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleForegroundDefault); |
| SkColor header_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleButtonIcon); |
| SkColor language_label_color = |
| color_provider->GetColor(ui::kColorRefPrimary80); |
| SkColor language_label_border_color = |
| color_provider->GetColor(ui::kColorRefSecondary50); |
| SkColor icon_disabled_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleButtonIconDisabled); |
| |
| // Update Live Translate label style with the default colors before parsing |
| // the CSS color string. |
| download_progress_label_->SetEnabledColor(primary_color); |
| |
| if (caption_style_) { |
| ParseNonTransparentRGBACSSColorString(caption_style_->text_color, |
| &primary_color, color_provider); |
| ParseNonTransparentRGBACSSColorString(caption_style_->text_color, |
| &header_color, color_provider); |
| ParseNonTransparentRGBACSSColorString( |
| caption_style_->text_color, &language_label_color, color_provider); |
| ParseNonTransparentRGBACSSColorString(caption_style_->text_color, |
| &language_label_border_color, |
| color_provider); |
| } |
| |
| label_->SetEnabledColor(primary_color); |
| title_->SetEnabledColor(primary_color); |
| generic_error_text_->SetEnabledColor(primary_color); |
| |
| generic_error_icon_->SetImage(ui::ImageModel::FromVectorIcon( |
| vector_icons::kErrorOutlineOldIcon, primary_color)); |
| |
| translation_view_wrapper_->SetTextColor( |
| language_label_color, language_label_border_color, header_color); |
| |
| #if BUILDFLAG(IS_WIN) |
| |
| const std::u16string link = |
| l10n_util::GetStringUTF16(IDS_LIVE_CAPTION_BUBBLE_CONTENT_SETTINGS); |
| size_t offset; |
| const std::u16string text = l10n_util::GetStringFUTF16( |
| IDS_LIVE_CAPTION_BUBBLE_MEDIA_FOUNDATION_RENDERER_ERROR, |
| l10n_util::GetStringUTF16(IDS_LIVE_CAPTION_BUBBLE_CONTENT_SETTINGS), |
| &offset); |
| |
| media_foundation_renderer_error_text_->ClearStyleRanges(); |
| views::StyledLabel::RangeStyleInfo error_message_style; |
| error_message_style.override_color = primary_color; |
| media_foundation_renderer_error_text_->AddStyleRange(gfx::Range(0, offset), |
| error_message_style); |
| |
| views::StyledLabel::RangeStyleInfo link_style = |
| views::StyledLabel::RangeStyleInfo::CreateForLink( |
| base::BindRepeating(&CaptionBubble::OnContentSettingsLinkClicked, |
| base::Unretained(this))); |
| link_style.override_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleLink); |
| media_foundation_renderer_error_text_->AddStyleRange( |
| gfx::Range(offset, offset + link.length()), link_style); |
| |
| media_foundation_renderer_error_text_->AddStyleRange( |
| gfx::Range(offset + link.length(), text.length()), error_message_style); |
| media_foundation_renderer_error_icon_->SetImage( |
| ui::ImageModel::FromVectorIcon(vector_icons::kErrorOutlineOldIcon, |
| primary_color)); |
| media_foundation_renderer_error_checkbox_->SetEnabledTextColors( |
| primary_color); |
| media_foundation_renderer_error_checkbox_->SetTextSubpixelRenderingEnabled( |
| false); |
| media_foundation_renderer_error_checkbox_->SetCheckedIconImageColor( |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleCheckbox)); |
| #endif |
| views::SetImageFromVectorIconWithColor( |
| back_to_tab_button_, vector_icons::kBackToTabChromeRefreshOldIcon, |
| kButtonDip, {header_color, icon_disabled_color}); |
| views::SetImageFromVectorIconWithColor( |
| close_button_, vector_icons::kCloseRoundedOldIcon, kButtonDip, |
| {header_color, icon_disabled_color}); |
| views::SetImageFromVectorIconWithColor( |
| expand_button_, vector_icons::kCaretDownOldIcon, kButtonDip, |
| {header_color, icon_disabled_color}); |
| views::SetImageFromVectorIconWithColor( |
| collapse_button_, vector_icons::kCaretUpOldIcon, kButtonDip, |
| {header_color, icon_disabled_color}); |
| } |
| |
| void CaptionBubble::SetBackgroundColor() { |
| const auto* const color_provider = GetColorProvider(); |
| SkColor background_color = |
| color_provider->GetColor(ui::kColorLiveCaptionBubbleBackgroundDefault); |
| if (caption_style_ && |
| !ParseNonTransparentRGBACSSColorString( |
| caption_style_->window_color, &background_color, color_provider)) { |
| ParseNonTransparentRGBACSSColorString(caption_style_->background_color, |
| &background_color, color_provider); |
| } |
| |
| views::BubbleDialogDelegateView::SetBackgroundColor(background_color); |
| GetWidget()->SetColorModeOverride(ui::ColorProviderKey::ColorMode::kDark); |
| } |
| |
| void CaptionBubble::OnLanguageChanged(const std::string& display_language) { |
| UpdateLanguageDirection(display_language); |
| SetTextColor(); |
| Redraw(); |
| } |
| |
| void CaptionBubble::UpdateLanguageDirection( |
| const std::string& display_language) { |
| label_->SetHorizontalAlignment( |
| base::i18n::GetTextDirectionForLocale(display_language.c_str()) == |
| base::i18n::TextDirection::RIGHT_TO_LEFT |
| ? gfx::HorizontalAlignment::ALIGN_RIGHT |
| : gfx::HorizontalAlignment::ALIGN_LEFT); |
| } |
| |
| void CaptionBubble::RepositionInContextRect(CaptionBubbleModel::Id model_id, |
| const gfx::Rect& context_rect) { |
| // We shouldn't reposition ourselves into the context rect of a model that is |
| // no longer active. |
| if (model_ == nullptr || model_->unique_id() != model_id) { |
| return; |
| } |
| |
| gfx::Rect inset_rect = context_rect; |
| inset_rect.Inset(gfx::Insets(kMinAnchorMarginDip)); |
| gfx::Rect bubble_bounds = GetBubbleBounds(); |
| |
| // The placement is based on the ratio between the center of the widget and |
| // the center of the inset_rect. |
| int target_x = inset_rect.x() + inset_rect.width() * kDefaultRatioInParentX - |
| bubble_bounds.width() / 2.0; |
| int target_y = inset_rect.y() + inset_rect.height() * kDefaultRatioInParentY - |
| bubble_bounds.height() / 2.0; |
| gfx::Rect target_bounds = gfx::Rect(target_x, target_y, bubble_bounds.width(), |
| bubble_bounds.height()); |
| if (!inset_rect.Contains(target_bounds)) { |
| target_bounds.AdjustToFit(inset_rect); |
| } |
| |
| if (model_->GetContext()->ShouldAvoidOverlap()) { |
| gfx::Rect intersection = context_rect; |
| intersection.Intersect(target_bounds); |
| if (intersection.size().GetArea() > |
| context_rect.size().GetArea() * kContextSufficientOverlapRatio) { |
| // Place below if there's room, otherwise place above. |
| std::optional<display::Display> display = |
| GetWidget()->GetNearestDisplay(); |
| if (!display.has_value() || |
| context_rect.bottom() + target_bounds.height() < |
| display->bounds().bottom()) { |
| target_bounds.Offset(0, context_rect.height()); |
| } else { |
| target_bounds.Offset(0, |
| -context_rect.height() - kMinAnchorMarginDip * 2); |
| } |
| |
| GetWidget()->SetBoundsConstrained(target_bounds); |
| return; |
| } |
| } |
| |
| GetWidget()->SetBounds(target_bounds); |
| } |
| |
| void CaptionBubble::AdjustPosition(CaptionBubbleModel::Id model_id, |
| const gfx::Rect& context_rect) { |
| // We shouldn't reposition ourselves into the context rect of a model that is |
| // no longer active. |
| if (model_ == nullptr || model_->unique_id() != model_id) { |
| return; |
| } |
| gfx::Rect inset_rect = context_rect; |
| inset_rect.Inset(gfx::Insets(kMinAnchorMarginDip)); |
| gfx::Rect bubble_bounds = GetBubbleBounds(); |
| if (!inset_rect.Contains(bubble_bounds)) { |
| bubble_bounds.AdjustToFit(inset_rect); |
| GetWidget()->SetBounds(bubble_bounds); |
| } |
| } |
| |
| void CaptionBubble::UpdateContentSize() { |
| double text_scale_factor = GetTextScaleFactor(); |
| int width = kMaxWidthDip * text_scale_factor; |
| int content_height = |
| kLineHeightDip * GetNumLinesVisible() * text_scale_factor; |
| |
| // The title takes up 1 line. |
| int label_height = title_->GetVisible() |
| ? content_height - kLineHeightDip * text_scale_factor |
| : content_height; |
| label_->SetMinimumHeight(label_height); |
| |
| auto button_size = close_button_->GetPreferredSize({}); |
| |
| auto left_header_width = width - 2 * button_size.width(); |
| left_header_container_->SetPreferredSize( |
| gfx::Size(left_header_width, button_size.height())); |
| download_progress_label_->SetPreferredSize(gfx::Size(width, content_height)); |
| |
| translation_view_wrapper_->UpdateContentSize(); |
| |
| #if BUILDFLAG(IS_WIN) |
| // The Media Foundation renderer error message should not scale with the |
| // user's caption style preference. |
| if (HasMediaFoundationError()) { |
| width = kMaxWidthDip; |
| content_height = media_foundation_renderer_error_message_ |
| ->GetPreferredSize( |
| views::SizeBounds(width - kSidePaddingDip * 2, {})) |
| .height(); |
| } |
| #endif |
| |
| // The header height is the same as the close button height. The footer height |
| // is the same as the expand button height. |
| SetPreferredSize(gfx::Size( |
| width, content_height + close_button_->GetPreferredSize({}).height() + |
| expand_button_->GetPreferredSize({}).height())); |
| } |
| |
| void CaptionBubble::Redraw() { |
| UpdateBubbleAndTitleVisibility(); |
| UpdateContentSize(); |
| } |
| |
| void CaptionBubble::MaybeScrollToBottom() { |
| if (IsScrollabilityEnabled()) { |
| if (!scroll_lock_button_->IsLocked()) { |
| scrollable_->vertical_scroll_bar()->ScrollByAmount( |
| views::ScrollBar::ScrollAmount::kEnd); |
| } |
| } |
| } |
| |
| void CaptionBubble::ShowInactive() { |
| DCHECK(model_); |
| if (GetWidget()->IsVisible()) { |
| MaybeScrollToBottom(); |
| return; |
| } |
| |
| GetWidget()->ShowInactive(); |
| GetViewAccessibility().AnnounceText(l10n_util::GetStringUTF16( |
| IDS_LIVE_CAPTION_BUBBLE_APPEAR_SCREENREADER_ANNOUNCEMENT)); |
| LogSessionEvent(SessionEvent::kStreamStarted); |
| |
| MaybeScrollToBottom(); |
| |
| // If the caption bubble has already been shown, do not reposition it. |
| if (has_been_shown_) { |
| return; |
| } |
| has_been_shown_ = true; |
| |
| // The first time that the caption bubble is shown, reposition it to the |
| // bottom center of the context widget for the currently set model. |
| model_->GetContext()->GetBounds( |
| base::BindOnce(&CaptionBubble::RepositionInContextRect, |
| weak_ptr_factory_.GetWeakPtr(), model_->unique_id())); |
| } |
| |
| void CaptionBubble::Hide() { |
| if (!GetWidget()->IsVisible()) { |
| return; |
| } |
| GetWidget()->Hide(); |
| LogSessionEvent(SessionEvent::kStreamEnded); |
| } |
| |
| void CaptionBubble::MediaFoundationErrorCheckboxPressed() { |
| #if BUILDFLAG(IS_WIN) |
| error_silenced_callback_.Run( |
| CaptionBubbleErrorType::kMediaFoundationRendererUnsupported, |
| media_foundation_renderer_error_checkbox_->GetChecked()); |
| #endif |
| } |
| |
| bool CaptionBubble::HasMediaFoundationError() { |
| return (model_ && model_->HasError() && |
| model_->ErrorType() == |
| CaptionBubbleErrorType::kMediaFoundationRendererUnsupported); |
| } |
| |
| void CaptionBubble::LogSessionEvent(SessionEvent event) { |
| if (model_ && !model_->HasError()) { |
| base::UmaHistogramEnumeration("Accessibility.LiveCaption.Session2", event); |
| } |
| } |
| |
| std::vector<raw_ptr<views::View, VectorExperimental>> |
| CaptionBubble::GetButtons() { |
| // TODO: the extraction here needs to be removed once the VectorExperimental |
| // alias is removed. |
| std::vector<raw_ptr<views::View, VectorExperimental>> buttons = { |
| back_to_tab_button_.get(), close_button_.get(), expand_button_.get(), |
| collapse_button_.get()}; |
| |
| std::vector<raw_ptr<views::View, VectorExperimental>> language_buttons = |
| translation_view_wrapper_->GetButtons(); |
| buttons.insert(buttons.end(), language_buttons.begin(), |
| language_buttons.end()); |
| |
| return buttons; |
| } |
| |
| views::Label* CaptionBubble::GetLabelForTesting() { |
| return views::AsViewClass<views::Label>(label_); |
| } |
| |
| views::ScrollView* CaptionBubble::GetScrollViewForTesting() { |
| return views::AsViewClass<views::ScrollView>(scrollable_); |
| } |
| |
| views::Label* CaptionBubble::GetDownloadProgressLabelForTesting() { |
| return views::AsViewClass<views::Label>(download_progress_label_); |
| } |
| |
| views::Label* CaptionBubble::GetScrollLockLabelForTesting() { |
| return views::AsViewClass<views::Label>(scroll_lock_button_->GetLabel()); |
| } |
| |
| bool CaptionBubble::IsGenericErrorMessageVisibleForTesting() const { |
| return generic_error_message_->GetVisible(); |
| } |
| |
| void CaptionBubble::SetNewFontListGetterForTesting(NewFontListGetter callback) { |
| new_font_list_getter_ = std::move(callback); |
| } |
| |
| void CaptionBubble::SetCaptionBubbleStyle() { |
| SetTextSizeAndFontFamily(); |
| if (GetWidget()) { |
| SetTextColor(); |
| SetBackgroundColor(); |
| GetWidget()->ThemeChanged(); |
| } |
| } |
| |
| views::Button* CaptionBubble::GetCloseButtonForTesting() { |
| return close_button_.get(); |
| } |
| |
| views::Button* CaptionBubble::GetBackToTabButtonForTesting() { |
| return back_to_tab_button_.get(); |
| } |
| |
| views::MdTextButton* CaptionBubble::GetScrollLockButtonForTesting() { |
| return scroll_lock_button_.get(); |
| } |
| |
| views::View* CaptionBubble::GetHeaderForTesting() { |
| return header_container_.get(); |
| } |
| |
| TranslationViewWrapperBase* |
| CaptionBubble::GetTranslationViewWrapperForTesting() { |
| return translation_view_wrapper_.get(); |
| } |
| |
| void CaptionBubble::OnTitleTextChanged() { |
| UpdateAccessibleName(); |
| if (views::Widget* widget = GetWidget()) { |
| widget->UpdateAccessibleNameForRootView(); |
| } |
| } |
| |
| void CaptionBubble::UpdateAccessibleName() { |
| GetViewAccessibility().SetName(std::u16string(title_->GetText())); |
| } |
| |
| bool CaptionBubble::IsScrollabilityEnabled() const { |
| return base::FeatureList::IsEnabled(captions::kLiveCaptionScrollable); |
| } |
| |
| BEGIN_METADATA(CaptionBubble) |
| END_METADATA |
| |
| } // namespace captions |