Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 11 kB View raw
1use std::collections::BTreeMap; 2use std::path::{Path, PathBuf}; 3 4use accesskit::{Node, NodeId, Rect, Role, Toggled, TreeUpdate}; 5use serde::{Deserialize, Serialize}; 6use thiserror::Error; 7 8use crate::scenario::NodeRef; 9 10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 11pub struct TreeDump { 12 pub root: DumpNode, 13} 14 15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 16pub struct DumpNode { 17 #[serde(rename = "ref")] 18 pub node: NodeRef, 19 pub role: Role, 20 #[serde(default, skip_serializing_if = "Option::is_none")] 21 pub label: Option<String>, 22 #[serde(default, skip_serializing_if = "Option::is_none")] 23 pub bounds: Option<Rect>, 24 pub states: DumpStates, 25 #[serde(default, skip_serializing_if = "Vec::is_empty")] 26 pub children: Vec<DumpNode>, 27} 28 29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 30pub struct DumpStates { 31 pub enabled: bool, 32 #[serde(default, skip_serializing_if = "Option::is_none")] 33 pub selected: Option<bool>, 34 #[serde(default, skip_serializing_if = "Option::is_none")] 35 pub expanded: Option<bool>, 36 #[serde(default, skip_serializing_if = "Option::is_none")] 37 pub toggled: Option<Toggled>, 38} 39 40#[derive(Clone, Debug, PartialEq, Eq, Error)] 41pub enum TreeDumpError { 42 #[error("tree update carries no root")] 43 MissingTree, 44 #[error("node {0:?} referenced but absent from update")] 45 MissingNode(NodeId), 46 #[error("node id zero cannot become a ref")] 47 ZeroNodeId, 48} 49 50#[derive(Debug, Error)] 51pub enum TreeLoadError { 52 #[error("read {path}: {error}")] 53 Read { 54 path: PathBuf, 55 error: std::io::Error, 56 }, 57 #[error("parse {path}: {error}")] 58 Parse { 59 path: PathBuf, 60 error: serde_json::Error, 61 }, 62} 63 64impl TreeDump { 65 pub fn from_update(update: &TreeUpdate) -> Result<Self, TreeDumpError> { 66 let nodes: BTreeMap<NodeId, &Node> = 67 update.nodes.iter().map(|(id, node)| (*id, node)).collect(); 68 let root = update.tree.as_ref().ok_or(TreeDumpError::MissingTree)?.root; 69 Ok(Self { 70 root: dump_node(root, &nodes)?, 71 }) 72 } 73 74 pub fn load(path: &Path) -> Result<Self, TreeLoadError> { 75 let text = std::fs::read_to_string(path).map_err(|error| TreeLoadError::Read { 76 path: path.to_path_buf(), 77 error, 78 })?; 79 serde_json::from_str(&text).map_err(|error| TreeLoadError::Parse { 80 path: path.to_path_buf(), 81 error, 82 }) 83 } 84 85 #[must_use] 86 pub fn nodes(&self) -> Vec<&DumpNode> { 87 self.root.subtree() 88 } 89 90 #[must_use] 91 pub fn matching(&self, role: Role, label: &str) -> Vec<&DumpNode> { 92 self.nodes() 93 .into_iter() 94 .filter(|n| n.role == role && n.label.as_deref() == Some(label)) 95 .collect() 96 } 97 98 #[must_use] 99 pub fn to_text(&self) -> String { 100 lines(&self.root, 0).into_iter().map(|l| l + "\n").collect() 101 } 102} 103 104impl DumpNode { 105 #[must_use] 106 pub fn subtree(&self) -> Vec<&Self> { 107 core::iter::once(self) 108 .chain(self.children.iter().flat_map(Self::subtree)) 109 .collect() 110 } 111} 112 113fn dump_node(id: NodeId, nodes: &BTreeMap<NodeId, &Node>) -> Result<DumpNode, TreeDumpError> { 114 let node = nodes.get(&id).ok_or(TreeDumpError::MissingNode(id))?; 115 let node_ref = NodeRef::from_node_id(id).ok_or(TreeDumpError::ZeroNodeId)?; 116 let children = node 117 .children() 118 .iter() 119 .map(|child| dump_node(*child, nodes)) 120 .collect::<Result<Vec<_>, _>>()?; 121 Ok(DumpNode { 122 node: node_ref, 123 role: node.role(), 124 label: node.label().map(str::to_owned), 125 bounds: node.bounds(), 126 states: DumpStates { 127 enabled: !node.is_disabled(), 128 selected: node.is_selected(), 129 expanded: node.is_expanded(), 130 toggled: node.toggled(), 131 }, 132 children, 133 }) 134} 135 136fn lines(node: &DumpNode, depth: usize) -> Vec<String> { 137 std::iter::once(line(node, depth)) 138 .chain( 139 node.children 140 .iter() 141 .flat_map(|child| lines(child, depth + 1)), 142 ) 143 .collect() 144} 145 146fn line(node: &DumpNode, depth: usize) -> String { 147 [ 148 Some(format!( 149 "{}{} {}", 150 " ".repeat(depth), 151 node.node, 152 role_token(node.role), 153 )), 154 node.label.as_ref().map(|label| format!("{label:?}")), 155 node.bounds.map(bounds_token), 156 (!node.states.enabled).then(|| "disabled".to_owned()), 157 node.states.selected.map(|v| format!("selected={v}")), 158 node.states.expanded.map(|v| format!("expanded={v}")), 159 node.states 160 .toggled 161 .map(|t| format!("toggled={}", toggled_token(t))), 162 ] 163 .into_iter() 164 .flatten() 165 .collect::<Vec<_>>() 166 .join(" ") 167} 168 169fn role_token(role: Role) -> String { 170 serde_json::to_string(&role).map_or_else( 171 |_| format!("{role:?}"), 172 |quoted| quoted.trim_matches('"').to_owned(), 173 ) 174} 175 176fn bounds_token(rect: Rect) -> String { 177 format!("({},{})-({},{})", rect.x0, rect.y0, rect.x1, rect.y1) 178} 179 180const fn toggled_token(toggled: Toggled) -> &'static str { 181 match toggled { 182 Toggled::False => "false", 183 Toggled::True => "true", 184 Toggled::Mixed => "mixed", 185 } 186} 187 188#[cfg(test)] 189mod tests { 190 use accesskit::Toggled; 191 use bone_ui::a11y::{AccessNode, AccessTreeBuilder}; 192 use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 193 use bone_ui::strings::StringTable; 194 use bone_ui::widgets::LabelText; 195 use bone_ui::{Role, WidgetId, WidgetKey}; 196 197 use super::*; 198 199 const RIBBON: WidgetKey = WidgetKey::new("ribbon"); 200 const BUTTON: WidgetKey = WidgetKey::new("button"); 201 202 fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 203 LayoutRect::new( 204 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 205 LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 206 ) 207 } 208 209 fn labeled_button(label: &str) -> AccessNode { 210 AccessNode::new(Role::Button).with_label_text(LabelText::Owned(label.to_owned())) 211 } 212 213 fn dumped(builder: &AccessTreeBuilder) -> TreeDump { 214 let update = builder.build(&StringTable::new(), None); 215 match TreeDump::from_update(&update) { 216 Ok(dump) => dump, 217 Err(e) => panic!("dump failed: {e}"), 218 } 219 } 220 221 #[test] 222 fn dump_nests_children_under_the_window_root() { 223 let mut builder = AccessTreeBuilder::new(); 224 builder.push( 225 WidgetId::ROOT.child(RIBBON), 226 rect(10.0, 20.0, 20.0, 20.0), 227 labeled_button("Extrude").with_toggled(Toggled::True), 228 ); 229 builder.push( 230 WidgetId::ROOT.child(BUTTON), 231 rect(40.0, 20.0, 20.0, 20.0), 232 labeled_button("Fillet").with_disabled(true), 233 ); 234 let dump = dumped(&builder); 235 assert_eq!(dump.root.role, Role::Window); 236 assert_eq!(dump.root.node, NodeRef::from_widget(WidgetId::ROOT)); 237 let labels: Vec<Option<&str>> = dump 238 .root 239 .children 240 .iter() 241 .map(|c| c.label.as_deref()) 242 .collect(); 243 assert_eq!(labels, vec![Some("Extrude"), Some("Fillet")]); 244 assert_eq!(dump.root.children[0].states.toggled, Some(Toggled::True)); 245 assert!(!dump.root.children[1].states.enabled); 246 } 247 248 #[test] 249 fn refs_stay_stable_across_frames() { 250 let extrude = WidgetId::ROOT.child(RIBBON).child_named(BUTTON, "extrude"); 251 let mut builder = AccessTreeBuilder::new(); 252 builder.push( 253 extrude, 254 rect(10.0, 20.0, 20.0, 20.0), 255 labeled_button("Extrude"), 256 ); 257 let first = dumped(&builder); 258 builder.begin_frame(); 259 builder.push( 260 WidgetId::ROOT.child(BUTTON), 261 rect(0.0, 0.0, 5.0, 5.0), 262 labeled_button("New"), 263 ); 264 builder.push( 265 extrude, 266 rect(10.0, 60.0, 20.0, 20.0), 267 labeled_button("Extrude"), 268 ); 269 let second = dumped(&builder); 270 let ref_of = |dump: &TreeDump, label: &str| { 271 dump.root 272 .children 273 .iter() 274 .find(|c| c.label.as_deref() == Some(label)) 275 .map(|c| c.node) 276 }; 277 assert_eq!(ref_of(&first, "Extrude"), ref_of(&second, "Extrude")); 278 assert_eq!( 279 ref_of(&first, "Extrude"), 280 Some(NodeRef::from_widget(extrude)) 281 ); 282 } 283 284 #[test] 285 fn text_form_is_one_indented_line_per_node() { 286 let mut builder = AccessTreeBuilder::new(); 287 builder.push( 288 WidgetId::ROOT.child(RIBBON), 289 rect(10.0, 20.0, 20.0, 20.0), 290 labeled_button("Extrude").with_selected(true), 291 ); 292 let dump = dumped(&builder); 293 let child = NodeRef::from_widget(WidgetId::ROOT.child(RIBBON)); 294 let expected = format!( 295 "b0feb0feb0feb0fe window\n {child} button \"Extrude\" (10,20)-(30,40) selected=true\n", 296 ); 297 assert_eq!(dump.to_text(), expected); 298 } 299 300 #[test] 301 fn json_form_carries_refs_roles_bounds_and_states() { 302 let mut builder = AccessTreeBuilder::new(); 303 builder.push( 304 WidgetId::ROOT.child(RIBBON), 305 rect(10.0, 20.0, 20.0, 20.0), 306 labeled_button("Extrude").with_expanded(false), 307 ); 308 let dump = dumped(&builder); 309 let child = NodeRef::from_widget(WidgetId::ROOT.child(RIBBON)); 310 let value = match serde_json::to_value(&dump) { 311 Ok(v) => v, 312 Err(e) => panic!("serialize failed: {e}"), 313 }; 314 assert_eq!( 315 value, 316 serde_json::json!({ 317 "root": { 318 "ref": "b0feb0feb0feb0fe", 319 "role": "window", 320 "states": { "enabled": true }, 321 "children": [{ 322 "ref": child.to_string(), 323 "role": "button", 324 "label": "Extrude", 325 "bounds": { "x0": 10.0, "y0": 20.0, "x1": 30.0, "y1": 40.0 }, 326 "states": { "enabled": true, "expanded": false }, 327 }], 328 }, 329 }), 330 ); 331 } 332 333 #[test] 334 fn missing_root_is_a_typed_error() { 335 let update = TreeUpdate { 336 nodes: vec![], 337 tree: None, 338 tree_id: accesskit::TreeId::ROOT, 339 focus: NodeId(1), 340 }; 341 assert_eq!( 342 TreeDump::from_update(&update), 343 Err(TreeDumpError::MissingTree), 344 ); 345 } 346}