| // Copyright 2019 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 "chromecast/browser/accessibility/flutter/flutter_semantics_node_wrapper.h" |
| |
| #include "base/check.h" |
| #include "base/logging.h" |
| #include "base/strings/string_util.h" |
| #include "chromecast/browser/accessibility/flutter/ax_tree_source_flutter.h" |
| #include "chromecast/browser/cast_web_contents.h" |
| #include "content/public/browser/render_frame_host.h" |
| #include "content/public/browser/web_contents.h" |
| #include "ui/accessibility/ax_action_handler_registry.h" |
| |
| using gallium::castos::ActionProperties; |
| using gallium::castos::BooleanProperties; |
| |
| namespace chromecast { |
| namespace accessibility { |
| |
| FlutterSemanticsNodeWrapper::FlutterSemanticsNodeWrapper( |
| ui::AXTreeSource<FlutterSemanticsNode*>* tree_source, |
| const SemanticsNode* node) |
| : tree_source_(tree_source), node_ptr_(node) { |
| DCHECK(tree_source_); |
| DCHECK(node_ptr_); |
| } |
| |
| int32_t FlutterSemanticsNodeWrapper::GetId() const { |
| return node_ptr_->node_id(); |
| } |
| |
| const gfx::Rect FlutterSemanticsNodeWrapper::GetBounds() const { |
| if (node_ptr_->has_bounds_in_screen()) { |
| return gfx::Rect(node_ptr_->bounds_in_screen().left(), |
| node_ptr_->bounds_in_screen().top(), |
| node_ptr_->bounds_in_screen().right() - |
| node_ptr_->bounds_in_screen().left(), |
| node_ptr_->bounds_in_screen().bottom() - |
| node_ptr_->bounds_in_screen().top()); |
| } |
| return gfx::Rect(0, 0, 0, 0); |
| } |
| |
| bool FlutterSemanticsNodeWrapper::IsVisibleToUser() const { |
| if (node_ptr_->has_boolean_properties()) { |
| const BooleanProperties& boolean_properties = |
| node_ptr_->boolean_properties(); |
| return !boolean_properties.is_hidden(); |
| } |
| return true; |
| } |
| |
| bool FlutterSemanticsNodeWrapper::IsFocused() const { |
| if (node_ptr_->has_boolean_properties()) { |
| const BooleanProperties& boolean_properties = |
| node_ptr_->boolean_properties(); |
| return boolean_properties.is_focused(); |
| } |
| return false; |
| } |
| |
| bool FlutterSemanticsNodeWrapper::IsLiveRegion() const { |
| const BooleanProperties& boolean_properties = node_ptr_->boolean_properties(); |
| return boolean_properties.is_live_region(); |
| } |
| |
| bool FlutterSemanticsNodeWrapper::HasScopesRoute() const { |
| const BooleanProperties& boolean_properties = node_ptr_->boolean_properties(); |
| return boolean_properties.scopes_route(); |
| } |
| |
| bool FlutterSemanticsNodeWrapper::HasNamesRoute() const { |
| const BooleanProperties& boolean_properties = node_ptr_->boolean_properties(); |
| return boolean_properties.names_route(); |
| } |
| |
| bool FlutterSemanticsNodeWrapper::IsKeyboardNode() const { |
| const BooleanProperties& boolean_properties = node_ptr_->boolean_properties(); |
| return boolean_properties.is_lift_to_type(); |
| } |
| |
| bool FlutterSemanticsNodeWrapper::CanBeAccessibilityFocused() const { |
| // In Chrome, this means: |
| // a node with a non-generic role and: |
| // actionable nodes or top level scrollables with a name |
| ui::AXNodeData data; |
| PopulateAXRole(&data); |
| bool non_generic_role = data.role != ax::mojom::Role::kGenericContainer && |
| data.role != ax::mojom::Role::kGroup; |
| bool actionable = node_ptr_->action_properties().tap() || |
| node_ptr_->action_properties().long_press(); |
| bool top_level_scrollable = |
| HasLabelHint() && (node_ptr_->action_properties().scroll_up() || |
| node_ptr_->action_properties().scroll_down() || |
| node_ptr_->action_properties().scroll_left() || |
| node_ptr_->action_properties().scroll_right()); |
| return non_generic_role && (actionable || top_level_scrollable); |
| } |
| |
| void FlutterSemanticsNodeWrapper::PopulateAXRole( |
| ui::AXNodeData* out_data) const { |
| const BooleanProperties& boolean_properties = node_ptr_->boolean_properties(); |
| const ActionProperties& action_properties = node_ptr_->action_properties(); |
| |
| if (boolean_properties.is_text_field()) { |
| out_data->role = ax::mojom::Role::kTextField; |
| return; |
| } |
| |
| if (boolean_properties.is_header()) { |
| out_data->role = ax::mojom::Role::kHeading; |
| return; |
| } |
| |
| // b/148808637: Flutter allows buttons to be containers but ChromeVox |
| // expects buttons to be atomic. If we allow this node to be marked a |
| // button and it contains other clickable nodes, none of those nodes will |
| // be focusable by the user. This means no button that is also a |
| // container of other actionable items will ever be read as 'button' |
| // by the screen reader. However, buttons that have no actionable |
| // descendants will still say 'Button' as expected. |
| if (boolean_properties.is_button() && !AnyChildIsActionable() && |
| HasLabelHint() && GetLabelHint().length() > 0) { |
| out_data->role = ax::mojom::Role::kButton; |
| return; |
| } |
| |
| if (boolean_properties.is_image() && |
| node_ptr_->child_node_ids().size() == 0) { |
| out_data->role = ax::mojom::Role::kImage; |
| return; |
| } |
| |
| if (action_properties.increase() || action_properties.decrease()) { |
| out_data->role = ax::mojom::Role::kSlider; |
| return; |
| } |
| |
| bool has_checked_state = boolean_properties.has_checked_state(); |
| bool has_toggled_state = boolean_properties.has_toggled_state(); |
| |
| if (has_checked_state) { |
| if (boolean_properties.is_in_mutually_exclusive_group()) { |
| out_data->role = ax::mojom::Role::kRadioButton; |
| } else { |
| out_data->role = ax::mojom::Role::kCheckBox; |
| } |
| return; |
| } |
| if (has_toggled_state) { |
| out_data->role = ax::mojom::Role::kSwitch; |
| return; |
| } |
| |
| // b/149934151 : Flutter sends us nodes with labels that |
| // have children. Don't mark these as static text or |
| // no children will ever be focused properly via swipe |
| // navigation. Only nodes that have labels with no children |
| // should get the static text role. Use kHeader role |
| // instead as it is allowed to be a container. |
| if (HasLabelHint() && GetLabelHint().length() > 0) { |
| if (node_ptr_->child_node_ids().size() == 0) { |
| if (HasTapOrPress()) { |
| out_data->role = ax::mojom::Role::kButton; |
| } else { |
| out_data->role = ax::mojom::Role::kStaticText; |
| } |
| } else { |
| if (IsListItem()) { |
| out_data->role = ax::mojom::Role::kListBoxOption; |
| } else { |
| out_data->role = ax::mojom::Role::kHeader; |
| } |
| } |
| return; |
| } |
| |
| std::vector<FlutterSemanticsNodeWrapper*> actionable_children; |
| GetActionableChildren(&actionable_children); |
| if (node_ptr_->scroll_children() > 0 && actionable_children.size() > 0) { |
| out_data->role = ax::mojom::Role::kList; |
| return; |
| } |
| |
| out_data->role = ax::mojom::Role::kGenericContainer; |
| } |
| |
| FlutterSemanticsNodeWrapper* FlutterSemanticsNodeWrapper::IsListItem() const { |
| // To consider it a list item, the node has to have a ancestor that has scroll |
| // children. |
| std::vector<FlutterSemanticsNodeWrapper*> ancestors; |
| FlutterSemanticsNodeWrapper* node = static_cast<FlutterSemanticsNodeWrapper*>( |
| tree_source_->GetFromId(GetId())); |
| |
| while (node) { |
| FlutterSemanticsNodeWrapper* parent = |
| static_cast<FlutterSemanticsNodeWrapper*>( |
| tree_source_->GetParent(node)); |
| if (parent) |
| ancestors.push_back(parent); |
| node = parent; |
| } |
| |
| // |ancestors| is with order from closest ancestor to root. Find the closest |
| // ancestor that has scroll children and in between there is no actionable |
| // nodes. |
| for (FlutterSemanticsNodeWrapper* ancestor : ancestors) { |
| if (!ancestor->IsActionable()) { |
| if (ancestor->node()->scroll_children() > 0) { |
| return ancestor; |
| } |
| } else { |
| break; |
| } |
| } |
| |
| return nullptr; |
| } |
| |
| void FlutterSemanticsNodeWrapper::GetActionableChildren( |
| std::vector<FlutterSemanticsNodeWrapper*>* out_children) const { |
| std::vector<FlutterSemanticsNode*> children; |
| GetChildren(&children); |
| |
| for (FlutterSemanticsNode* child : children) { |
| FlutterSemanticsNodeWrapper* child_wrapper = |
| static_cast<FlutterSemanticsNodeWrapper*>(child); |
| |
| if (child_wrapper->HasTapOrPress() || |
| child_wrapper->AnyChildIsActionable()) { |
| out_children->push_back(child_wrapper); |
| } |
| } |
| } |
| |
| bool FlutterSemanticsNodeWrapper::IsDescendant( |
| FlutterSemanticsNodeWrapper* ancestor) const { |
| FlutterSemanticsNodeWrapper* parent = |
| static_cast<FlutterSemanticsNodeWrapper*>( |
| tree_source_->GetParent(tree_source_->GetFromId(GetId()))); |
| |
| while (parent) { |
| if (parent == ancestor) |
| return true; |
| parent = static_cast<FlutterSemanticsNodeWrapper*>( |
| tree_source_->GetParent(parent)); |
| } |
| |
| return false; |
| } |
| |
| bool FlutterSemanticsNodeWrapper::IsRapidChangingSlider() const { |
| const float min = node_ptr_->scroll_extent_min(); |
| const float max = node_ptr_->scroll_extent_max(); |
| bool has_scroll_extent = IsScrollable() && (min < max); |
| return has_scroll_extent || (node_ptr_->action_properties().increase() || |
| node_ptr_->action_properties().decrease()); |
| } |
| |
| void FlutterSemanticsNodeWrapper::PopulateAXState( |
| ui::AXNodeData* out_data) const { |
| const BooleanProperties& boolean_properties = node_ptr_->boolean_properties(); |
| |
| if (boolean_properties.is_obscured()) { |
| out_data->AddState(ax::mojom::State::kProtected); |
| } |
| |
| if (boolean_properties.is_text_field()) { |
| out_data->AddState(ax::mojom::State::kEditable); |
| } |
| |
| if (IsFocusable()) { |
| out_data->AddState(ax::mojom::State::kFocusable); |
| } |
| |
| if (boolean_properties.has_checked_state()) { |
| out_data->SetCheckedState(boolean_properties.is_checked() |
| ? ax::mojom::CheckedState::kTrue |
| : ax::mojom::CheckedState::kFalse); |
| } |
| |
| if (boolean_properties.has_toggled_state()) { |
| out_data->SetCheckedState(boolean_properties.is_toggled() |
| ? ax::mojom::CheckedState::kTrue |
| : ax::mojom::CheckedState::kFalse); |
| } |
| |
| if (boolean_properties.has_enabled_state() && |
| !boolean_properties.is_enabled()) { |
| out_data->SetRestriction(ax::mojom::Restriction::kDisabled); |
| } |
| } |
| |
| void FlutterSemanticsNodeWrapper::Serialize(ui::AXNodeData* out_data) const { |
| PopulateAXState(out_data); |
| |
| const BooleanProperties& boolean_properties = node_ptr_->boolean_properties(); |
| const ActionProperties& action_properties = node_ptr_->action_properties(); |
| |
| // b/162311902: For nodes that have scroll extents and can be changed rapidly, |
| // set the name as empty so that ChromeVox will skip speaking them. |
| if (IsRapidChangingSlider()) { |
| out_data->SetName(""); |
| } else if (HasLabelHint()) { |
| out_data->SetName(GetLabelHint()); |
| } else if (IsActionable()) { |
| // Compute the name by joining all nodes with names. |
| std::vector<std::string> names; |
| ComputeNameFromContents(&names); |
| if (!names.empty()) |
| out_data->SetName(base::JoinString(names, " ")); |
| } |
| |
| if (HasValue()) { |
| out_data->SetValue(GetValue()); |
| } |
| |
| if (IsActionable()) { |
| out_data->SetDefaultActionVerb(ax::mojom::DefaultActionVerb::kClick); |
| } |
| out_data->AddBoolAttribute(ax::mojom::BoolAttribute::kScrollable, |
| IsScrollable()); |
| |
| if (boolean_properties.is_selected()) { |
| out_data->AddBoolAttribute(ax::mojom::BoolAttribute::kSelected, true); |
| } |
| |
| if (node_ptr_->has_hint()) { |
| out_data->AddStringAttribute(ax::mojom::StringAttribute::kPlaceholder, |
| node_ptr_->hint()); |
| } |
| |
| // Set bounds |
| if (tree_source_->GetRoot()->GetId() != -1) { |
| // TODO(rmrossi) Pass in nullptr for now for active window. Root node will |
| // get bounds relative to 0,0 anyway since we are full screen. This may |
| // change if flutter is ever not full screen in which case we will have |
| // to pass in the bounds of whatever container it resides in. |
| const gfx::Rect& local_bounds = GetRelativeBounds(); |
| out_data->relative_bounds.bounds.SetRect(local_bounds.x(), local_bounds.y(), |
| local_bounds.width(), |
| local_bounds.height()); |
| } |
| |
| if (node_ptr_->has_text_selection_base()) { |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kTextSelStart, |
| node_ptr_->text_selection_base()); |
| } |
| |
| if (node_ptr_->has_text_selection_extent()) { |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kTextSelEnd, |
| node_ptr_->text_selection_extent()); |
| } |
| |
| if (action_properties.has_scroll_left() || |
| action_properties.has_scroll_up()) { |
| out_data->AddAction(ax::mojom::Action::kScrollBackward); |
| } |
| |
| if (action_properties.has_scroll_right() || |
| action_properties.has_scroll_down()) { |
| out_data->AddAction(ax::mojom::Action::kScrollForward); |
| } |
| |
| if (action_properties.set_selection()) { |
| out_data->AddAction(ax::mojom::Action::kSetSelection); |
| } |
| |
| if (action_properties.increase()) { |
| out_data->AddAction(ax::mojom::Action::kIncrement); |
| } |
| |
| if (action_properties.decrease()) { |
| out_data->AddAction(ax::mojom::Action::kDecrement); |
| } |
| |
| if (IsScrollable()) { |
| const float position = node_ptr_->scroll_position(); |
| const float min = node_ptr_->scroll_extent_min(); |
| const float max = node_ptr_->scroll_extent_max(); |
| if (node_ptr_->action_properties().scroll_up() || |
| node_ptr_->action_properties().scroll_down()) { |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kScrollY, position); |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kScrollYMin, min); |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kScrollYMax, max); |
| } else if (node_ptr_->action_properties().scroll_left() || |
| node_ptr_->action_properties().scroll_right()) { |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kScrollX, position); |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kScrollXMin, min); |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kScrollXMax, max); |
| } |
| } |
| |
| if (node_ptr_->custom_actions_size() > 0) { |
| std::vector<int32_t> ids; |
| std::vector<std::string> labels; |
| for (int i = 0; i < node_ptr_->custom_actions_size(); i++) { |
| ids.push_back(node_ptr_->custom_actions(i).id()); |
| labels.push_back(node_ptr_->custom_actions(i).label()); |
| } |
| out_data->AddAction(ax::mojom::Action::kCustomAction); |
| out_data->AddIntListAttribute(ax::mojom::IntListAttribute::kCustomActionIds, |
| ids); |
| out_data->AddStringListAttribute( |
| ax::mojom::StringListAttribute::kCustomActionDescriptions, labels); |
| } |
| |
| if (node_ptr_->has_plugin_id()) { |
| std::string ax_tree_id = node_ptr_->plugin_id(); |
| if (ax_tree_id.rfind("T:", 0) == 0) { |
| // This is a cast web contents id. Find the matching |
| // CastWebContents and find the ax tree id from there. |
| int web_contents_id; |
| base::StringToInt(ax_tree_id.substr(2), &web_contents_id); |
| std::vector<CastWebContents*> all_contents = CastWebContents::GetAll(); |
| // There will likely only ever be one active at any time. |
| for (CastWebContents* contents : all_contents) { |
| if (contents->id() == web_contents_id) { |
| auto child_tree_id = |
| contents->web_contents()->GetMainFrame()->GetAXTreeID(); |
| if (!child_tree_id.ToString().empty()) { |
| out_data->AddChildTreeId(child_tree_id); |
| } |
| break; |
| } |
| } |
| } else { |
| // Use the value as a tree id. |
| ui::AXTreeID child_ax_tree_id = ui::AXTreeID::FromString(ax_tree_id); |
| out_data->AddChildTreeId(child_ax_tree_id); |
| } |
| } |
| |
| if (boolean_properties.is_live_region()) { |
| out_data->AddStringAttribute( |
| ax::mojom::StringAttribute::kContainerLiveStatus, "polite"); |
| out_data->AddStringAttribute( |
| ax::mojom::StringAttribute::kContainerLiveRelevant, "text"); |
| } |
| |
| if (out_data->role == ax::mojom::Role::kListBoxOption) { |
| // Find the ancestor whose role is kList. |
| FlutterSemanticsNodeWrapper* ancestor = IsListItem(); |
| |
| std::vector<FlutterSemanticsNodeWrapper*> ancestor_actionable_children; |
| ancestor->GetActionableChildren(&ancestor_actionable_children); |
| |
| // kSetSize should be the number of actionable children that the scrollable |
| // ancestor has. |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kSetSize, |
| ancestor_actionable_children.size()); |
| |
| // Find which children tree that this node is in. |
| for (size_t i = 0; i < ancestor_actionable_children.size(); ++i) { |
| if (IsDescendant(ancestor_actionable_children[i])) { |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kPosInSet, i + 1); |
| break; |
| } |
| } |
| } |
| |
| if (out_data->role == ax::mojom::Role::kList) { |
| // Find the size of actionable children. |
| std::vector<FlutterSemanticsNodeWrapper*> actionable_children; |
| GetActionableChildren(&actionable_children); |
| |
| out_data->AddIntAttribute(ax::mojom::IntAttribute::kSetSize, |
| actionable_children.size()); |
| } |
| } |
| |
| void FlutterSemanticsNodeWrapper::GetChildren( |
| std::vector<FlutterSemanticsNode*>* children) const { |
| for (auto id : node_ptr_->child_node_ids()) { |
| FlutterSemanticsNode* node = tree_source_->GetFromId(id); |
| if (node) { |
| children->push_back(tree_source_->GetFromId(id)); |
| } else { |
| LOG(ERROR) << "Node id present for which there is no child node given"; |
| } |
| } |
| } |
| |
| bool FlutterSemanticsNodeWrapper::AnyChildIsActionable() const { |
| // b/156940104 - If this node is host to a child tree, we |
| // can assume at least one of it's child nodes is actionable. |
| // Otherwise, none of it's descendants will be traversed by |
| // the reader. |
| if (node_ptr_->has_plugin_id()) { |
| return true; |
| } |
| for (auto child_id : node_ptr_->child_node_ids()) { |
| FlutterSemanticsNodeWrapper* child = |
| static_cast<FlutterSemanticsNodeWrapper*>( |
| tree_source_->GetFromId(child_id)); |
| if (child->HasTapOrPress()) { |
| return true; |
| } |
| if (child->AnyChildIsActionable()) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| bool FlutterSemanticsNodeWrapper::HasTapOrPress() const { |
| return node_ptr_->boolean_properties().is_button() || |
| node_ptr_->action_properties().tap() || |
| node_ptr_->action_properties().long_press(); |
| } |
| |
| bool FlutterSemanticsNodeWrapper::IsActionable() const { |
| bool actionable = HasTapOrPress(); |
| |
| // If this node is actionable but is also the host for a child tree, |
| // don't make it actionable or else chromevox won't traverse into |
| // any child. |
| if (node_ptr_->has_plugin_id()) { |
| actionable = false; |
| } |
| return actionable; |
| } |
| |
| bool FlutterSemanticsNodeWrapper::IsScrollable() const { |
| return node_ptr_->action_properties().scroll_up() || |
| node_ptr_->action_properties().scroll_down() || |
| node_ptr_->action_properties().scroll_left() || |
| node_ptr_->action_properties().scroll_right(); |
| } |
| |
| bool FlutterSemanticsNodeWrapper::IsFocusable() const { |
| const BooleanProperties& boolean_properties = node_ptr_->boolean_properties(); |
| if (boolean_properties.scopes_route() && !boolean_properties.names_route()) |
| return false; |
| |
| bool focusable_flags = |
| boolean_properties.has_checked_state() || |
| boolean_properties.is_checked() || boolean_properties.is_selected() || |
| HasTapOrPress() || boolean_properties.is_text_field() || |
| boolean_properties.is_focused() || |
| boolean_properties.has_enabled_state() || |
| boolean_properties.is_enabled() || |
| boolean_properties.is_in_mutually_exclusive_group() || |
| boolean_properties.is_header() || boolean_properties.is_obscured() || |
| boolean_properties.names_route() || |
| (boolean_properties.is_image() && |
| node_ptr_->child_node_ids().size() == 0) || |
| boolean_properties.is_live_region() || |
| boolean_properties.has_toggled_state() || boolean_properties.is_toggled(); |
| |
| // b/149934151 : If a node has a label but also has children, don't |
| // mark it focusable, otherwise none of its children will be navigable |
| // via swipe nav. It's role wlil be a generic container (see above). |
| return focusable_flags || (HasLabelHint() && !GetLabelHint().empty() && |
| node_ptr_->child_node_ids().size() == 0); |
| } |
| |
| bool FlutterSemanticsNodeWrapper::HasLabelHint() const { |
| return node_ptr_->has_label() || node_ptr_->has_hint(); |
| } |
| |
| std::string FlutterSemanticsNodeWrapper::GetLabelHint() const { |
| // TODO(rmrossi): Find out whether this order of precedence makes sense |
| if (node_ptr_->has_label() && node_ptr_->label().length() > 0) { |
| return node_ptr_->label(); |
| } |
| if (node_ptr_->has_hint() && node_ptr_->hint().length() > 0) { |
| return node_ptr_->hint(); |
| } |
| return ""; |
| } |
| |
| void FlutterSemanticsNodeWrapper::ComputeNameFromContents( |
| std::vector<std::string>* names) const { |
| DCHECK(names); |
| std::string name; |
| if (HasLabelHint()) { |
| name = GetLabelHint(); |
| if (!name.empty()) { |
| names->push_back(name); |
| return; |
| } |
| } |
| |
| std::vector<FlutterSemanticsNode*> children; |
| GetChildren(&children); |
| for (const FlutterSemanticsNode* child : children) { |
| child->ComputeNameFromContents(names); |
| } |
| } |
| |
| bool FlutterSemanticsNodeWrapper::HasValue() const { |
| return node_ptr_->has_value(); |
| } |
| |
| std::string FlutterSemanticsNodeWrapper::GetValue() const { |
| return node_ptr_->value(); |
| } |
| |
| const gfx::Rect FlutterSemanticsNodeWrapper::GetRelativeBounds() const { |
| FlutterSemanticsNode* root_node = tree_source_->GetRoot(); |
| DCHECK(root_node); |
| |
| gfx::Rect node_bounds = GetBounds(); |
| |
| // TODO(rmrossi): If embedded flutter is ever not full screen, we will have |
| // to pass in the embedded object tag's screen coordinates to this function |
| // and set the offset of the root node here separately from other nodes. |
| // The bounds of the root node are supposed to be relative to its container |
| // but since we are full screen, we leave them alone. See |
| // ax_tree_source_arc.cc for an example. |
| if (GetId() != root_node->GetId()) { |
| // Bounds of non-root node is relative to its tree's root. |
| gfx::Rect root_bounds = root_node->GetBounds(); |
| node_bounds.Offset(-1 * root_bounds.x(), -1 * root_bounds.y()); |
| } |
| |
| return node_bounds; |
| } |
| |
| } // namespace accessibility |
| } // namespace chromecast |