use accesskit_consumer::Tree;
use crate::tree::{self, Exclusion, Query, QueryFilter, Widget, query};
const SCREEN_SIZE: egui::Vec2 = egui::vec2(1280.0, 900.0);
const LONG_LABEL: &str = "This paragraph is here to be long: it wraps over several lines in the central panel, so the frame has to lay it out as more than one run of text. An agent reading the tree should still get every word of it, because the text it can read is the only thing telling it what this part of the app is for.";
struct DemoApp {
search: String,
wrap_lines: bool,
volume: f32,
project: usize,
gallery: egui_demo_lib::WidgetGallery,
}
impl Default for DemoApp {
fn default() -> Self {
Self {
search: "tree".to_owned(),
wrap_lines: true,
volume: 42.0,
project: 1,
gallery: egui_demo_lib::WidgetGallery::default(),
}
}
}
impl DemoApp {
fn ui(&mut self, ui: &mut egui::Ui) {
egui_extras::install_image_loaders(ui.ctx());
egui::Panel::top("toolbar").show(ui, |ui| {
ui.horizontal(|ui| {
let label = ui.label("Search");
ui.text_edit_singleline(&mut self.search)
.labelled_by(label.id);
ui.button("Run").clicked();
});
});
egui::Panel::left("projects").show(ui, |ui| {
ui.heading("Projects");
for (i, name) in ["egui", "kittest", "rerun"].iter().enumerate() {
ui.selectable_value(&mut self.project, i, *name);
}
});
egui::Panel::right("agent chat").show(ui, |ui| {
ui.heading("Chat");
ui.label("where is the volume slider?");
ui.label("Second row of the settings panel.");
});
egui::CentralPanel::default().show(ui, |ui| {
ui.label("Settings");
ui.label(LONG_LABEL);
ui.checkbox(&mut self.wrap_lines, "Wrap long lines");
ui.add(egui::Slider::new(&mut self.volume, 0.0..=100.0).text("Volume"));
egui::CollapsingHeader::new("Advanced")
.default_open(true)
.show(ui, |ui| {
ui.label("Nothing to see here");
ui.add_enabled(false, egui::Button::new("Reset"));
});
ui.separator();
ui.add_space(16.0);
egui_demo_lib::View::ui(&mut self.gallery, ui);
});
}
}
fn demo_app_tree() -> Tree {
let ctx = egui::Context::default();
ctx.enable_accesskit();
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, SCREEN_SIZE)),
..Default::default()
};
let mut app = DemoApp::default();
ctx.run_ui(input.clone(), |ui| app.ui(ui))
.drop_without_applying_deltas();
let mut output = ctx.run_ui(input, |ui| app.ui(ui));
let update = output
.platform_output
.accesskit_update
.take()
.expect("accesskit is enabled, so every frame carries an update");
output.drop_without_applying_deltas();
Tree::new(update, false)
}
fn query_app(filter: &QueryFilter) -> String {
let nodes = query(&demo_app_tree(), filter, 1.0).expect("no missing `root`");
serde_json::to_string_pretty(&nodes).expect("serialize")
}
#[test]
fn the_demo_app_on_screen() {
let mut app = DemoApp::default();
let mut harness = egui_kittest::Harness::builder()
.with_size(SCREEN_SIZE)
.wgpu()
.build_ui(move |ui| app.ui(ui));
harness.run();
harness.snapshot("demo_app");
}
#[test]
fn the_whole_app() {
insta::assert_snapshot!(query_app(&QueryFilter {
limit: usize::MAX,
..Default::default()
}));
}
#[test]
fn every_button() {
insta::assert_snapshot!(query_app(&QueryFilter {
query: Query {
role: Some("Button".to_owned()),
..Default::default()
},
..Default::default()
}));
}
#[test]
fn one_widget_by_its_text() {
insta::assert_snapshot!(query_app(&QueryFilter {
query: Query {
content_contains: Some("volume".to_owned()),
..Default::default()
},
..Default::default()
}));
}
#[test]
fn a_long_label_is_cut_short_but_still_matchable() {
let matched = query(
&demo_app_tree(),
&QueryFilter {
query: Query {
content_contains: Some("every word of it".to_owned()),
..Default::default()
},
..Default::default()
},
1.0,
)
.expect("no missing `root`");
let label = matched.first().expect("the long label matched");
let text = label.value.as_deref().expect("a label's text is its value");
assert!(
LONG_LABEL.starts_with(text.trim_end_matches(tree::TRUNCATION_MARKER)),
"what came back is the start of the label: {text}"
);
assert!(
text.ends_with(tree::TRUNCATION_MARKER),
"and it is marked as cut: {text}"
);
assert!(text.chars().count() < LONG_LABEL.chars().count());
assert!(
label.children.is_empty(),
"the per-line runs repeat the label, so they are folded into it"
);
}
#[test]
fn get_widget_returns_the_text_the_tree_cut_short() {
let app_tree = demo_app_tree();
let matched = query(
&app_tree,
&QueryFilter {
query: Query {
content_contains: Some("every word of it".to_owned()),
..Default::default()
},
..Default::default()
},
1.0,
)
.expect("no missing `root`");
let label = matched.first().expect("the long label matched");
let cut = label.value.as_deref().expect("a label's text is its value");
assert!(
cut.ends_with(tree::TRUNCATION_MARKER),
"the tree cut it short: {cut}"
);
let node = tree::resolve_unique(&app_tree, &tree::Locator::Id { id: label.id }, 1.0)
.expect("the id the tree just handed out resolves");
let full = tree::widget_detail(&node, 1.0)
.value
.expect("the same text, in full");
assert_eq!(full, LONG_LABEL);
assert!(tree::MAX_TEXT_CHARS < full.chars().count(), "past the cut");
}
#[test]
fn a_tight_limit_keeps_the_top_of_the_tree() {
insta::assert_snapshot!(query_app(&QueryFilter {
limit: 12,
..Default::default()
}));
}
#[test]
fn an_excluded_panel_takes_its_text_with_it() {
let filter = |exclude| QueryFilter {
query: Query {
content_contains: Some("volume".to_owned()),
..Default::default()
},
exclude,
..Default::default()
};
let tree = demo_app_tree();
let unfiltered = query(&tree, &filter(None), 1.0).expect("no missing `root`");
assert!(
unfiltered
.iter()
.any(|node| node.value.as_deref() == Some("where is the volume slider?")),
"the chat's echo of the question is in the way"
);
let chat_panel = find_container_of(
&query(&tree, &QueryFilter::default(), 1.0).expect("no missing `root`"),
"Chat",
)
.expect("the chat panel is in the tree");
let excluded = query(
&tree,
&filter(Some(Exclusion {
id: Some(chat_panel),
..Default::default()
})),
1.0,
);
insta::assert_snapshot!(serde_json::to_string_pretty(&excluded).expect("serialize"));
}
fn find_container_of(nodes: &[Widget], heading: &str) -> Option<tree::Id> {
nodes.iter().find_map(|node| {
let holds_heading = node
.children
.iter()
.any(|child| child.value.as_deref() == Some(heading));
if holds_heading {
Some(node.id)
} else {
find_container_of(&node.children, heading)
}
})
}
#[test]
fn every_returned_node_is_actionable() {
fn check(nodes: &[Widget]) {
for node in nodes {
assert!(
node.role.is_some()
|| node.label.is_some()
|| node.value.is_some()
|| !node.children.is_empty(),
"a node that says nothing should have been pruned: {node:?}"
);
check(&node.children);
}
}
let nodes = query(&demo_app_tree(), &QueryFilter::default(), 1.0).expect("no missing `root`");
assert!(!nodes.is_empty(), "the app has widgets");
check(&nodes);
}