Another project
0

Configure Feed

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

at main 10 kB View raw
1use core::fmt; 2use core::num::NonZeroU64; 3use core::str::FromStr; 4use std::path::PathBuf; 5 6use accesskit::NodeId; 7use bone_app::{FrameCount, ScrollDelta}; 8use bone_render::ViewportPx; 9use bone_ui::WidgetId; 10use bone_ui::input::{KeyCode, ModifierMask, PointerButton}; 11use bone_ui::theme::ThemeMode; 12use serde::{Deserialize, Serialize}; 13use thiserror::Error; 14 15#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 16#[serde(try_from = "String", into = "String")] 17pub struct NodeRef(NonZeroU64); 18 19impl NodeRef { 20 #[must_use] 21 pub const fn from_widget(id: WidgetId) -> Self { 22 Self(id.raw()) 23 } 24 25 #[must_use] 26 pub const fn node_id(self) -> NodeId { 27 NodeId(self.0.get()) 28 } 29 30 #[must_use] 31 pub const fn from_node_id(id: NodeId) -> Option<Self> { 32 match NonZeroU64::new(id.0) { 33 Some(raw) => Some(Self(raw)), 34 None => None, 35 } 36 } 37} 38 39impl fmt::Display for NodeRef { 40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 41 write!(f, "{:016x}", self.0) 42 } 43} 44 45#[derive(Clone, Debug, PartialEq, Eq, Error)] 46#[error("node ref {text:?} is not a non-zero 64-bit hex value")] 47pub struct NodeRefParseError { 48 text: String, 49} 50 51impl FromStr for NodeRef { 52 type Err = NodeRefParseError; 53 54 fn from_str(s: &str) -> Result<Self, Self::Err> { 55 u64::from_str_radix(s, 16) 56 .ok() 57 .and_then(NonZeroU64::new) 58 .map(Self) 59 .ok_or_else(|| NodeRefParseError { text: s.to_owned() }) 60 } 61} 62 63impl TryFrom<String> for NodeRef { 64 type Error = NodeRefParseError; 65 66 fn try_from(value: String) -> Result<Self, Self::Error> { 67 value.parse() 68 } 69} 70 71impl From<NodeRef> for String { 72 fn from(value: NodeRef) -> Self { 73 value.to_string() 74 } 75} 76 77#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 78#[serde(try_from = "String", into = "String")] 79pub struct ArtifactName(String); 80 81impl fmt::Display for ArtifactName { 82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 83 f.write_str(&self.0) 84 } 85} 86 87#[derive(Clone, Debug, PartialEq, Eq, Error)] 88#[error("artifact name {text:?} must be non-empty ascii alphanumeric with '-' or '_'")] 89pub struct ArtifactNameError { 90 text: String, 91} 92 93impl TryFrom<String> for ArtifactName { 94 type Error = ArtifactNameError; 95 96 fn try_from(value: String) -> Result<Self, Self::Error> { 97 let valid = !value.is_empty() 98 && value 99 .chars() 100 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); 101 if valid { 102 Ok(Self(value)) 103 } else { 104 Err(ArtifactNameError { text: value }) 105 } 106 } 107} 108 109impl From<ArtifactName> for String { 110 fn from(value: ArtifactName) -> Self { 111 value.0 112 } 113} 114 115#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 116pub enum Modifier { 117 Ctrl, 118 Shift, 119 Alt, 120 Meta, 121} 122 123#[must_use] 124pub fn modifier_mask(modifiers: &[Modifier]) -> ModifierMask { 125 modifiers.iter().fold(ModifierMask::NONE, |acc, m| { 126 acc.union(match m { 127 Modifier::Ctrl => ModifierMask::CTRL, 128 Modifier::Shift => ModifierMask::SHIFT, 129 Modifier::Alt => ModifierMask::ALT, 130 Modifier::Meta => ModifierMask::META, 131 }) 132 }) 133} 134 135#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 136pub enum PointerTarget { 137 At { x: f64, y: f64 }, 138 Node(NodeRef), 139 Label(String), 140} 141 142fn primary_button() -> PointerButton { 143 PointerButton::Primary 144} 145 146#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 147pub enum Step { 148 Resize { 149 width: ViewportPx, 150 height: ViewportPx, 151 }, 152 Theme(ThemeMode), 153 PointerMove(PointerTarget), 154 Click { 155 target: PointerTarget, 156 #[serde(default = "primary_button")] 157 button: PointerButton, 158 }, 159 Drag { 160 from: PointerTarget, 161 to: PointerTarget, 162 #[serde(default = "primary_button")] 163 button: PointerButton, 164 }, 165 Wheel(ScrollDelta), 166 Key { 167 code: KeyCode, 168 #[serde(default)] 169 modifiers: Vec<Modifier>, 170 }, 171 Text(String), 172 Advance(FrameCount), 173 OpenDocument(PathBuf), 174 Snapshot(ArtifactName), 175 DumpTree(ArtifactName), 176} 177 178#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 179pub struct Scenario { 180 pub steps: Vec<Step>, 181} 182 183#[derive(Clone, Debug, PartialEq, Eq)] 184pub enum ScenarioSource { 185 Path(PathBuf), 186 Stdin, 187} 188 189impl fmt::Display for ScenarioSource { 190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 191 match self { 192 Self::Path(path) => write!(f, "{}", path.display()), 193 Self::Stdin => f.write_str("stdin"), 194 } 195 } 196} 197 198#[derive(Debug, Error)] 199pub enum ScenarioError { 200 #[error("read scenario from {from}: {error}")] 201 Read { 202 from: ScenarioSource, 203 error: std::io::Error, 204 }, 205 #[error("parse scenario: {0}")] 206 Parse(#[from] ron::error::SpannedError), 207} 208 209impl Scenario { 210 pub fn parse(text: &str) -> Result<Self, ron::error::SpannedError> { 211 ron::de::from_str(text) 212 } 213 214 pub fn load(source: &ScenarioSource) -> Result<Self, ScenarioError> { 215 let text = match source { 216 ScenarioSource::Path(path) => std::fs::read_to_string(path), 217 ScenarioSource::Stdin => std::io::read_to_string(std::io::stdin()), 218 } 219 .map_err(|error| ScenarioError::Read { 220 from: source.clone(), 221 error, 222 })?; 223 Ok(Self::parse(&text)?) 224 } 225} 226 227#[cfg(test)] 228mod tests { 229 use bone_ui::input::NamedKey; 230 231 use super::*; 232 233 fn parsed(text: &str) -> Scenario { 234 match Scenario::parse(text) { 235 Ok(scenario) => scenario, 236 Err(e) => panic!("parse failed: {e}"), 237 } 238 } 239 240 fn frames(n: u32) -> FrameCount { 241 match FrameCount::try_from(n) { 242 Ok(count) => count, 243 Err(e) => panic!("frame count: {e}"), 244 } 245 } 246 247 #[test] 248 fn full_step_vocabulary_parses() { 249 let scenario = parsed( 250 r#"Scenario( 251 steps: [ 252 Resize(width: 1280, height: 800), 253 OpenDocument("parts/bracket.step"), 254 Advance(3), 255 PointerMove(Label("Extrude")), 256 Click(target: At(x: 640.0, y: 400.0)), 257 Click(target: Node("00000000000000ff"), button: Secondary), 258 Drag(from: At(x: 100.0, y: 100.0), to: At(x: 220.0, y: 160.0)), 259 Wheel(Lines(x: 0.0, y: 1.0)), 260 Key(code: Named(Escape)), 261 Key(code: Char('z'), modifiers: [Ctrl, Shift]), 262 Text("12.5"), 263 Snapshot("after-extrude"), 264 DumpTree("final"), 265 ], 266 )"#, 267 ); 268 assert_eq!(scenario.steps.len(), 13); 269 assert_eq!( 270 scenario.steps[4], 271 Step::Click { 272 target: PointerTarget::At { x: 640.0, y: 400.0 }, 273 button: PointerButton::Primary, 274 }, 275 "click button defaults to primary", 276 ); 277 assert_eq!( 278 scenario.steps[8], 279 Step::Key { 280 code: KeyCode::Named(NamedKey::Escape), 281 modifiers: vec![], 282 }, 283 "key modifiers default to empty", 284 ); 285 assert_eq!(scenario.steps[2], Step::Advance(frames(3))); 286 } 287 288 #[test] 289 fn advance_rejects_zero_frames() { 290 assert!( 291 Scenario::parse("Scenario(steps: [Advance(0)])").is_err(), 292 "a zero-frame advance is not representable", 293 ); 294 assert!(Scenario::parse("Scenario(steps: [Advance(1)])").is_ok()); 295 } 296 297 #[test] 298 fn scenario_round_trips_through_ron() { 299 let scenario = parsed( 300 r#"Scenario( 301 steps: [ 302 Click(target: Label("OK")), 303 Wheel(Pixels(x: 0.0, y: -40.0)), 304 Snapshot("dialog"), 305 ], 306 )"#, 307 ); 308 let text = match ron::ser::to_string(&scenario) { 309 Ok(t) => t, 310 Err(e) => panic!("serialize failed: {e}"), 311 }; 312 assert_eq!(parsed(&text), scenario); 313 } 314 315 #[test] 316 fn node_ref_round_trips_as_hex() { 317 let node = NodeRef::from_widget(WidgetId::ROOT); 318 assert_eq!(node.to_string(), "b0feb0feb0feb0fe"); 319 assert_eq!("b0feb0feb0feb0fe".parse(), Ok(node)); 320 } 321 322 #[test] 323 fn node_ref_rejects_zero_and_garbage() { 324 assert!(NodeRef::from_str("0").is_err()); 325 assert!(NodeRef::from_str("not-hex").is_err()); 326 assert!(NodeRef::from_str("").is_err()); 327 assert!(NodeRef::from_node_id(NodeId(0)).is_none()); 328 } 329 330 #[test] 331 fn artifact_name_rejects_path_traversal() { 332 assert!(ArtifactName::try_from("../escape".to_owned()).is_err()); 333 assert!(ArtifactName::try_from(String::new()).is_err()); 334 assert!(ArtifactName::try_from("a/b".to_owned()).is_err()); 335 assert!(ArtifactName::try_from("cold_shell-01".to_owned()).is_ok()); 336 } 337 338 #[test] 339 fn modifier_mask_folds_all_flags() { 340 let mask = modifier_mask(&[Modifier::Ctrl, Modifier::Shift]); 341 assert!(mask.contains(ModifierMask::CTRL)); 342 assert!(mask.contains(ModifierMask::SHIFT)); 343 assert!(!mask.contains(ModifierMask::ALT)); 344 assert_eq!(modifier_mask(&[]), ModifierMask::NONE); 345 } 346 347 #[test] 348 fn load_reads_a_scenario_file() { 349 let dir = match tempfile::tempdir() { 350 Ok(d) => d, 351 Err(e) => panic!("tempdir: {e}"), 352 }; 353 let path = dir.path().join("smoke.ron"); 354 if let Err(e) = std::fs::write(&path, "Scenario(steps: [Advance(1)])") { 355 panic!("write: {e}"); 356 } 357 let scenario = match Scenario::load(&ScenarioSource::Path(path)) { 358 Ok(s) => s, 359 Err(e) => panic!("load: {e}"), 360 }; 361 assert_eq!(scenario.steps, vec![Step::Advance(FrameCount::ONE)]); 362 } 363 364 #[test] 365 fn load_reports_the_missing_path() { 366 let source = ScenarioSource::Path(PathBuf::from("/nonexistent/limpet.ron")); 367 match Scenario::load(&source) { 368 Err(ScenarioError::Read { from, .. }) => assert_eq!(from, source), 369 other => panic!("expected read error, got {other:?}"), 370 } 371 } 372}