use std::cell::{Cell, RefCell};
use std::rc::Rc;
use accesskit::{Live, NodeId, Role};
use teksilo_canvas::SizeProposal;
use crate::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
use crate::announcer::Politeness;
use crate::test_widgets::{FillWidget, StackWidget};
use crate::widget::{LayoutContext, LayoutResponse, Widget};
use crate::widget_builder::WidgetBuilder;
use crate::widget_id::WidgetId;
use crate::widget_tree::WidgetTree;
use crate::widget_tree::accessibility_description_impl::AnnouncementOnFocusMove;
use AnnouncementOnFocusMove::{Cut, Kept};
const READERS: [AnnouncementOnFocusMove; 2] = [Kept, Cut];
fn for_reader(tree: &mut WidgetTree, reader: AnnouncementOnFocusMove) {
tree.description_memory.on_focus_move = reader;
}
#[derive(Debug)]
struct Line {
text: Rc<RefCell<String>>,
live: Rc<Cell<Live>>,
}
impl Widget for Line {
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(Role::Status);
let text = self.text.borrow();
if text.is_empty() {
builder.set_live(Live::Off);
} else {
builder.set_name(text.as_str());
builder.set_live(self.live.get());
}
}
}
struct Form {
tree: WidgetTree,
field: WidgetId,
other: WidgetId,
line: WidgetId,
message: Rc<RefCell<String>>,
live: Rc<Cell<Live>>,
}
impl Form {
fn new(live: Live) -> Self {
Self::for_reader(live, AnnouncementOnFocusMove::PLATFORM)
}
fn for_reader(live: Live, reader: AnnouncementOnFocusMove) -> Self {
let mut tree = WidgetTree::new();
for_reader(&mut tree, reader);
let message = Rc::new(RefCell::new(String::new()));
let live = Rc::new(Cell::new(live));
let line = tree.add(Line {
text: message.clone(),
live: live.clone(),
});
let field = tree.add(
FillWidget::new()
.focusable()
.access_role(Role::TextInput)
.access_label("Title")
.access_described_by(line),
);
let other = tree.add(
FillWidget::new()
.focusable()
.access_role(Role::Button)
.access_label("Save"),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
Self {
tree,
field,
other,
line,
message,
live,
}
}
fn say(&mut self, text: &str) {
*self.message.borrow_mut() = text.to_owned();
self.tree.request_accessibility_update();
}
fn description(&mut self) -> Option<String> {
let update = self.tree.sync_accessibility();
description_of(&update, self.field)
}
}
fn description_of(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<String> {
let nid = widget_id_to_node_id(id);
fn find(node: accesskit_consumer::NodeRef<'_>, nid: NodeId) -> Option<String> {
if node.locate().0 == nid {
return node.description();
}
node.children().find_map(|child| find(child, nid))
}
let tree = accesskit_consumer::Tree::new(update.clone(), true);
find(tree.state().root(), nid)
}
struct Ears {
reader: AnnouncementOnFocusMove,
heard: Vec<String>,
cut: Vec<String>,
seq: u64,
focus: Option<NodeId>,
focus_description: Option<String>,
}
impl Ears {
fn new(reader: AnnouncementOnFocusMove) -> Self {
Self {
reader,
heard: Vec::new(),
cut: Vec::new(),
seq: 0,
focus: None,
focus_description: None,
}
}
fn hear(&mut self, tree: &mut WidgetTree) {
let update = tree.sync_accessibility();
let description_of_node = |id: NodeId| {
update
.nodes
.iter()
.find(|(node_id, _)| *node_id == id)
.and_then(|(_, node)| node.description())
.map(str::to_owned)
};
let mut before_focus: Vec<String> = Vec::new();
for announcement in tree.announcements_since(self.seq) {
self.seq = announcement.seq;
before_focus.push(announcement.text);
}
if let Some(held) = self.focus {
let description = description_of_node(held);
if description != self.focus_description {
before_focus.extend(description);
}
}
if self.focus != Some(update.focus) {
match self.reader {
Kept => self.heard.append(&mut before_focus),
Cut => self.cut.append(&mut before_focus),
}
let node = update
.nodes
.iter()
.find(|(id, _)| *id == update.focus)
.map(|(_, node)| node);
if let Some(name) = node.and_then(|node| node.label()) {
self.heard.push(name.to_owned());
}
self.heard.extend(description_of_node(update.focus));
} else {
self.heard.append(&mut before_focus);
}
self.focus = Some(update.focus);
self.focus_description = description_of_node(update.focus);
}
fn times(&self, text: &str) -> usize {
self.heard.iter().filter(|said| said.contains(text)).count()
}
fn cut_times(&self, text: &str) -> usize {
self.cut.iter().filter(|said| said.contains(text)).count()
}
}
#[test]
fn the_line_a_field_points_at_is_its_description() {
let mut form = Form::new(Live::Off);
form.say("Must be unique");
assert_eq!(form.description().as_deref(), Some("Must be unique"));
let update = form.tree.sync_accessibility();
let field = update
.nodes
.iter()
.find(|(id, _)| *id == widget_id_to_node_id(form.field))
.map(|(_, node)| node)
.expect("the field is in the tree");
assert_eq!(field.described_by(), &[widget_id_to_node_id(form.line)]);
}
#[test]
fn the_description_follows_the_line_while_focus_is_elsewhere() {
let mut form = Form::new(Live::Assertive);
form.tree.focus(form.other);
assert_eq!(form.description(), None, "an empty line says nothing");
form.say("Too short");
assert_eq!(form.description().as_deref(), Some("Too short"));
form.say("Already taken");
assert_eq!(form.description().as_deref(), Some("Already taken"));
form.say("");
assert_eq!(form.description(), None);
}
#[test]
fn arriving_at_a_field_reads_what_its_line_says() {
for reader in READERS {
let mut form = Form::for_reader(Live::Assertive, reader);
let mut ears = Ears::new(reader);
form.tree.focus(form.other);
ears.hear(&mut form.tree);
form.say("Too short");
ears.hear(&mut form.tree);
ears.hear(&mut form.tree);
assert_eq!(
ears.times("Too short"),
1,
"the line announced itself, {reader:?}: {:?}",
ears.heard
);
form.tree.focus(form.field);
ears.hear(&mut form.tree);
assert_eq!(
ears.times("Too short"),
2,
"arriving at the field reads its reason, {reader:?}: {:?}",
ears.heard
);
}
}
#[test]
fn a_message_that_appears_while_the_field_has_focus_is_said_once() {
for reader in READERS {
let mut form = Form::for_reader(Live::Assertive, reader);
let mut ears = Ears::new(reader);
form.tree.focus(form.field);
ears.hear(&mut form.tree);
form.say("Too short");
ears.hear(&mut form.tree);
form.tree.request_accessibility_update();
ears.hear(&mut form.tree);
form.tree.request_accessibility_update();
ears.hear(&mut form.tree);
assert_eq!(
ears.times("Too short"),
1,
"{reader:?}: heard {:?}",
ears.heard
);
assert_eq!(form.description(), None, "it waits for the next arrival");
form.tree.focus(form.other);
ears.hear(&mut form.tree);
form.tree.focus(form.field);
ears.hear(&mut form.tree);
assert_eq!(
ears.times("Too short"),
2,
"and is read on that arrival, {reader:?}: {:?}",
ears.heard
);
}
}
#[test]
fn leaving_a_field_does_not_start_saying_its_message() {
for reader in READERS {
let mut form = Form::for_reader(Live::Assertive, reader);
let mut ears = Ears::new(reader);
form.tree.focus(form.field);
ears.hear(&mut form.tree);
form.say("Too short");
ears.hear(&mut form.tree);
form.tree.focus(form.other);
ears.hear(&mut form.tree);
assert_eq!(
(ears.times("Too short"), ears.cut_times("Too short")),
(1, 0),
"said by the line, and not begun again on the way out, {reader:?}; \
heard {:?}, cut {:?}",
ears.heard,
ears.cut
);
assert_eq!(
form.description().as_deref(),
Some("Too short"),
"the field has its message once focus is elsewhere, {reader:?}"
);
}
}
#[test]
fn a_message_announced_as_focus_arrives_is_said_once() {
for reader in READERS {
let mut form = Form::for_reader(Live::Assertive, reader);
let mut ears = Ears::new(reader);
form.tree.focus(form.other);
ears.hear(&mut form.tree);
form.say("Too short");
form.tree.focus(form.field);
ears.hear(&mut form.tree);
form.tree.request_accessibility_update();
ears.hear(&mut form.tree);
assert_eq!(
ears.times("Too short"),
1,
"{reader:?}: heard {:?}",
ears.heard
);
assert_eq!(ears.times("Title"), 1, "the arrival itself was heard");
}
}
#[test]
fn an_announcement_saying_the_line_as_focus_arrives_is_said_once() {
for reader in READERS {
let mut form = Form::for_reader(Live::Off, reader);
let mut ears = Ears::new(reader);
form.tree.focus(form.other);
ears.hear(&mut form.tree);
form.say("Enter a title");
form.tree
.announce_with("Enter a title", Politeness::Assertive);
form.tree.focus(form.field);
ears.hear(&mut form.tree);
ears.hear(&mut form.tree);
assert_eq!(
ears.times("Enter a title"),
1,
"{reader:?}: heard {:?}",
ears.heard
);
assert_eq!(
ears.cut_times("Enter a title"),
0,
"{reader:?}: the announcement went out with the focus move, and \
the arrival cut it: {:?}",
ears.cut
);
form.tree.focus(form.other);
ears.hear(&mut form.tree);
form.tree
.announce_with("Enter a title", Politeness::Assertive);
form.tree.focus(form.field);
ears.hear(&mut form.tree);
ears.hear(&mut form.tree);
assert_eq!(
ears.times("Enter a title"),
2,
"{reader:?}: heard {:?}",
ears.heard
);
form.tree.focus(form.other);
ears.hear(&mut form.tree);
form.tree.focus(form.field);
ears.hear(&mut form.tree);
assert_eq!(
ears.times("Enter a title"),
3,
"{reader:?}: heard {:?}",
ears.heard
);
}
}
#[test]
fn a_message_that_goes_away_leaves_the_description_while_focus_stays() {
let mut form = Form::new(Live::Assertive);
form.tree.focus(form.other);
form.say("Too short");
let _ = form.description();
form.tree.focus(form.field);
assert_eq!(form.description().as_deref(), Some("Too short"));
form.say("");
assert_eq!(form.description(), None);
}
#[test]
fn a_replaced_message_is_announced_and_not_described_while_focus_stays() {
let mut form = Form::new(Live::Assertive);
let mut ears = Ears::new(AnnouncementOnFocusMove::PLATFORM);
form.tree.focus(form.other);
form.say("Too short");
ears.hear(&mut form.tree);
form.tree.focus(form.field);
ears.hear(&mut form.tree);
form.say("Already taken");
ears.hear(&mut form.tree);
form.tree.request_accessibility_update();
ears.hear(&mut form.tree);
assert_eq!(ears.times("Already taken"), 1, "heard {:?}", ears.heard);
assert_eq!(form.description(), None);
}
#[test]
fn the_nodes_own_description_comes_first_and_its_name_is_not_repeated() {
let mut tree = WidgetTree::new();
let error = tree.add(FillWidget::new().label("Too short"));
let name = tree.add(FillWidget::new().label("Password"));
let field = tree.add(
FillWidget::new()
.focusable()
.access_role(Role::PasswordInput)
.access_label("Password")
.access_description_literal("At least eight characters")
.access_described_by(name)
.access_described_by(error),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let update = tree.sync_accessibility();
assert_eq!(
description_of(&update, field).as_deref(),
Some("At least eight characters Too short")
);
}
#[test]
fn a_target_says_what_it_contains_and_counts_even_hidden() {
let mut tree = WidgetTree::new();
let first = tree.add(FillWidget::new().label("Between 1 and 31."));
let secret = tree.add(FillWidget::new().label("never read").access_hidden(true));
let second = tree.add(FillWidget::new().label("Check the month."));
let group = tree.add(StackWidget::new().child(first).child(secret).child(second));
let hidden = tree.add(
FillWidget::new()
.label("Day of the month")
.access_hidden(true),
);
let field = tree.add(
FillWidget::new()
.focusable()
.access_role(Role::SpinButton)
.access_label("Day")
.access_described_by(hidden)
.access_described_by(group),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let update = tree.sync_accessibility();
assert_eq!(
description_of(&update, field).as_deref(),
Some("Day of the month Between 1 and 31. Check the month.")
);
}
#[derive(Debug)]
struct Document(&'static str);
impl Widget for Document {
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(Role::Document);
builder.push_text_run_child_on_self(0, self.0.to_owned(), vec![1; self.0.len()], None);
}
}
#[test]
fn a_document_a_field_points_at_is_not_read_out_as_its_description() {
let mut tree = WidgetTree::new();
let log = tree.add(Document("error: expected one of `;` or `}`"));
let hint = tree.add(FillWidget::new().label("See the build log."));
let field = tree.add(
FillWidget::new()
.focusable()
.access_role(Role::TextInput)
.access_label("Command")
.access_described_by(log)
.access_described_by(hint),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let update = tree.sync_accessibility();
assert_eq!(
description_of(&update, field).as_deref(),
Some("See the build log.")
);
}
#[test]
fn a_live_region_nobody_can_hear_does_not_keep_its_text_out_of_the_arrival() {
let mut tree = WidgetTree::new();
for_reader(&mut tree, Kept);
let message = Rc::new(RefCell::new(String::new()));
let line = tree.add(Line {
text: message.clone(),
live: Rc::new(Cell::new(Live::Assertive)),
});
let panel = tree.add(StackWidget::new().child(line).access_hidden(true));
let field = tree.add(
FillWidget::new()
.focusable()
.access_role(Role::TextInput)
.access_label("Title")
.access_described_by(line),
);
let other = tree.add(FillWidget::new().focusable().access_label("Save"));
let _ = panel;
tree.layout(SizeProposal::exact(200.0, 100.0));
tree.focus(other);
let _ = tree.sync_accessibility();
*message.borrow_mut() = "Too short".to_owned();
tree.focus(field);
let update = tree.sync_accessibility();
assert_eq!(description_of(&update, field).as_deref(), Some("Too short"));
}
#[derive(Debug)]
struct Quiet {
text: Rc<RefCell<String>>,
role: Role,
}
impl Widget for Quiet {
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(self.role);
let text = self.text.borrow();
if !text.is_empty() {
builder.inner_mut().set_value(text.as_str());
}
}
}
fn field_described_by(tree: &mut WidgetTree, target: WidgetId) -> (WidgetId, WidgetId) {
let field = tree.add(
FillWidget::new()
.focusable()
.access_role(Role::TextInput)
.access_label("Title")
.access_described_by(target),
);
let other = tree.add(FillWidget::new().focusable().access_label("Save"));
tree.layout(SizeProposal::exact(200.0, 100.0));
tree.focus(other);
let _ = tree.sync_accessibility();
(field, other)
}
#[test]
fn a_status_holding_its_text_as_a_value_does_not_keep_it_out_of_the_arrival() {
for reader in READERS {
let mut tree = WidgetTree::new();
for_reader(&mut tree, reader);
let message = Rc::new(RefCell::new(String::new()));
let line = tree.add(
Quiet {
text: message.clone(),
role: Role::Status,
}
.access_live(Live::Assertive),
);
let (field, _) = field_described_by(&mut tree, line);
*message.borrow_mut() = "Too short".to_owned();
tree.focus(field);
let update = tree.sync_accessibility();
assert_eq!(
description_of(&update, field).as_deref(),
Some("Too short"),
"{reader:?}"
);
}
}
#[test]
fn a_label_live_through_its_region_is_said_once_as_focus_arrives() {
for (reader, arrival) in [(Kept, None), (Cut, Some("Too short"))] {
let mut tree = WidgetTree::new();
for_reader(&mut tree, reader);
let message = Rc::new(RefCell::new(String::new()));
let line = tree.add(Quiet {
text: message.clone(),
role: Role::Label,
});
let _region = tree.add(StackWidget::new().child(line).access_live(Live::Assertive));
let (field, _) = field_described_by(&mut tree, line);
*message.borrow_mut() = "Too short".to_owned();
tree.request_accessibility_update();
tree.focus(field);
let update = tree.sync_accessibility();
assert_eq!(
description_of(&update, field).as_deref(),
arrival,
"{reader:?}"
);
}
}
#[derive(Debug)]
struct Named {
text: Rc<RefCell<String>>,
role: Role,
}
impl Widget for Named {
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(self.role);
let text = self.text.borrow();
if !text.is_empty() {
builder.inner_mut().set_label(text.as_str());
}
}
}
#[test]
fn a_live_container_no_adapter_announces_does_not_keep_its_text_out_of_the_arrival() {
for reader in READERS {
let mut tree = WidgetTree::new();
for_reader(&mut tree, reader);
let message = Rc::new(RefCell::new(String::new()));
let line = tree.add(
Named {
text: message.clone(),
role: Role::GenericContainer,
}
.access_live(Live::Assertive),
);
let (field, _) = field_described_by(&mut tree, line);
*message.borrow_mut() = "Too short".to_owned();
tree.request_accessibility_update();
tree.focus(field);
let update = tree.sync_accessibility();
assert_eq!(
description_of(&update, field).as_deref(),
Some("Too short"),
"{reader:?}"
);
}
}
#[derive(Debug)]
struct Region {
live: Rc<Cell<Live>>,
child: WidgetId,
}
impl Widget for Region {
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn children(&self) -> Vec<WidgetId> {
vec![self.child]
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_live(self.live.get());
}
}
#[test]
fn a_region_turning_assertive_does_not_say_its_label_again() {
for reader in READERS {
let mut tree = WidgetTree::new();
for_reader(&mut tree, reader);
let message = Rc::new(RefCell::new("Check the date".to_owned()));
let live = Rc::new(Cell::new(Live::Polite));
let line = tree.add(Quiet {
text: message.clone(),
role: Role::Label,
});
let _region = tree.add(Region {
live: live.clone(),
child: line,
});
let (field, _) = field_described_by(&mut tree, line);
live.set(Live::Assertive);
tree.request_accessibility_update();
tree.focus(field);
let update = tree.sync_accessibility();
assert_eq!(
description_of(&update, field).as_deref(),
Some("Check the date"),
"{reader:?}"
);
}
}
#[test]
fn a_message_raised_to_assertive_as_focus_arrives_is_said_once() {
for (reader, arrival) in [(Kept, None), (Cut, Some("Check the date"))] {
let mut form = Form::for_reader(Live::Polite, reader);
form.tree.focus(form.other);
form.say("Check the date");
let _ = form.description();
form.live.set(Live::Assertive);
form.tree.request_accessibility_update();
form.tree.focus(form.field);
assert_eq!(form.description().as_deref(), arrival, "{reader:?}");
}
}
#[test]
fn a_dialog_focus_enters_follows_the_same_rules_as_the_field() {
for (reader, entry) in [(Kept, None), (Cut, Some("This cannot be undone"))] {
let mut tree = WidgetTree::new();
for_reader(&mut tree, reader);
let message = Rc::new(RefCell::new(String::new()));
let line = tree.add(Line {
text: message.clone(),
live: Rc::new(Cell::new(Live::Assertive)),
});
let first = tree.add(FillWidget::new().focusable().access_label("Yes"));
let second = tree.add(FillWidget::new().focusable().access_label("No"));
let dialog = tree.add(
StackWidget::new()
.child(first)
.child(second)
.access_role(Role::Dialog)
.access_label("Delete?")
.access_described_by(line),
);
let outside = tree.add(FillWidget::new().focusable().access_label("Delete"));
tree.layout(SizeProposal::exact(200.0, 100.0));
tree.focus(outside);
let _ = tree.sync_accessibility();
*message.borrow_mut() = "This cannot be undone".to_owned();
tree.focus(first);
let update = tree.sync_accessibility();
assert_eq!(
description_of(&update, dialog).as_deref(),
entry,
"announced as focus entered, {reader:?}"
);
tree.focus(second);
let update = tree.sync_accessibility();
assert_eq!(
description_of(&update, dialog).as_deref(),
entry,
"focus stayed inside, {reader:?}"
);
tree.focus(outside);
let _ = tree.sync_accessibility();
tree.focus(first);
let update = tree.sync_accessibility();
assert_eq!(
description_of(&update, dialog).as_deref(),
Some("This cannot be undone"),
"{reader:?}"
);
}
}