StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | use super::*; |
| 2 | use crate::{ |
| 3 | elements::{ |
| 4 | ChildAnchor, ConstrainedBox, DispatchEventResult, EventHandler, Hoverable, |
| 5 | MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Rect, |
| 6 | Stack, ZIndex, |
| 7 | }, |
| 8 | platform::WindowStyle, |
| 9 | App, AppContext, Entity, Event, Presenter, TypedActionView, ViewContext, WindowInvalidation, |
| 10 | }; |
| 11 | use pathfinder_geometry::vector::vec2f; |
| 12 | use std::{ |
| 13 | cell::RefCell, |
| 14 | collections::{HashMap, HashSet}, |
| 15 | rc::Rc, |
| 16 | }; |
| 17 | |
| 18 | #[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)] |
| 19 | enum ElementIdentifier { |
| 20 | BottomStack, |
| 21 | TopStackBase, |
| 22 | TopStackOverlay, |
| 23 | Hoverable, |
| 24 | } |
| 25 | |
| 26 | #[derive(Default)] |
| 27 | struct View { |
| 28 | // Maps identifier to number of mouse down events |
| 29 | mouse_downs: HashMap<ElementIdentifier, usize>, |
| 30 | mouse_state: MouseStateHandle, |
| 31 | } |
| 32 | |
| 33 | pub fn init(app: &mut AppContext) { |
| 34 | app.add_action("clipped_test:mouse_down", View::mouse_down); |
| 35 | } |
| 36 | |
| 37 | impl View { |
| 38 | fn mouse_down(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool { |
| 39 | log::info!("Recording mouse_down on element {identifier:?}"); |
| 40 | let entry = self.mouse_downs.entry(*identifier).or_insert(0); |
| 41 | *entry += 1; |
| 42 | true |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | impl Entity for View { |
| 47 | type Event = (); |
| 48 | } |
| 49 | |
| 50 | impl crate::core::View for View { |
| 51 | fn ui_name() -> &'static str { |
| 52 | "clipped_test_view" |
| 53 | } |
| 54 | |
| 55 | // The element tree looks like the following: |
| 56 | // - Scene |
| 57 | // - Stack |
| 58 | // - Bottom Stack |
| 59 | // - Base (This acts as the base layer for the scene) |
| 60 | // - BottomStack |
| 61 | // - Top Stack |
| 62 | // - TopStackBase (Bottom) |
| 63 | // - TopStackOverlay (Top) |
| 64 | // |
| 65 | // -------------------------------- |
| 66 | // | TopStackBase | Hoverable | | |
| 67 | // | (Clipped) | | | | |
| 68 | // | --|---------- | |
| 69 | // | | | |
| 70 | // | -----------|------- | |
| 71 | // | | Overlap | | | |
| 72 | // |----------------- | | |
| 73 | // | | | | |
| 74 | // | | | | |
| 75 | // | |TopStackOverlay | | |
| 76 | // | ------------------- | |
| 77 | // | | |
| 78 | // | | |
| 79 | // |-------------- | |
| 80 | // | | | |
| 81 | // | | | |
| 82 | // | | | |
| 83 | // | | | |
| 84 | // |BottomStack | | |
| 85 | // -------------------------------- |
| 86 | fn render(&self, _: &AppContext) -> Box<dyn Element> { |
| 87 | let mut bottom_stack = Stack::new(); |
| 88 | |
| 89 | bottom_stack.add_child( |
| 90 | ConstrainedBox::new(Rect::new().finish()) |
| 91 | .with_height(100.) |
| 92 | .with_width(50.) |
| 93 | .finish(), |
| 94 | ); |
| 95 | bottom_stack.add_positioned_child( |
| 96 | EventHandler::new( |
| 97 | ConstrainedBox::new(Rect::new().finish()) |
| 98 | .with_height(25.) |
| 99 | .with_width(25.) |
| 100 | .finish(), |
| 101 | ) |
| 102 | .on_left_mouse_down(|evt, _, _| { |
| 103 | evt.dispatch_action("clipped_test:mouse_down", ElementIdentifier::BottomStack); |
| 104 | DispatchEventResult::StopPropagation |
| 105 | }) |
| 106 | .finish(), |
| 107 | OffsetPositioning::offset_from_parent( |
| 108 | vec2f(0., 75.), |
| 109 | ParentOffsetBounds::ParentByPosition, |
| 110 | ParentAnchor::TopLeft, |
| 111 | ChildAnchor::TopLeft, |
| 112 | ), |
| 113 | ); |
| 114 | |
| 115 | let mut top_stack = Stack::new(); |
| 116 | |
| 117 | top_stack.add_positioned_child( |
| 118 | EventHandler::new( |
| 119 | ConstrainedBox::new(Rect::new().finish()) |
| 120 | .with_height(25.) |
| 121 | .with_width(25.) |
| 122 | .finish(), |
| 123 | ) |
| 124 | .on_left_mouse_down(|evt, _, _| { |
| 125 | evt.dispatch_action("clipped_test:mouse_down", ElementIdentifier::TopStackBase); |
| 126 | DispatchEventResult::StopPropagation |
| 127 | }) |
| 128 | .finish(), |
| 129 | OffsetPositioning::offset_from_parent( |
| 130 | vec2f(0., 0.), |
| 131 | ParentOffsetBounds::ParentByPosition, |
| 132 | ParentAnchor::TopLeft, |
| 133 | ChildAnchor::TopLeft, |
| 134 | ), |
| 135 | ); |
| 136 | |
| 137 | top_stack.add_positioned_child( |
| 138 | EventHandler::new( |
| 139 | ConstrainedBox::new(Rect::new().finish()) |
| 140 | .with_height(25.) |
| 141 | .with_width(25.) |
| 142 | .finish(), |
| 143 | ) |
| 144 | .on_left_mouse_down(|evt, _, _| { |
| 145 | evt.dispatch_action( |
| 146 | "clipped_test:mouse_down", |
| 147 | ElementIdentifier::TopStackOverlay, |
| 148 | ); |
| 149 | DispatchEventResult::StopPropagation |
| 150 | }) |
| 151 | .finish(), |
| 152 | OffsetPositioning::offset_from_parent( |
| 153 | vec2f(15., 15.), |
| 154 | ParentOffsetBounds::ParentByPosition, |
| 155 | ParentAnchor::TopLeft, |
| 156 | ChildAnchor::TopLeft, |
| 157 | ), |
| 158 | ); |
| 159 | |
| 160 | top_stack.add_positioned_child( |
| 161 | Hoverable::new(self.mouse_state.clone(), |_| { |
| 162 | ConstrainedBox::new(Rect::new().finish()) |
| 163 | .with_height(20.) |
| 164 | .with_height(8.) |
| 165 | .finish() |
| 166 | }) |
| 167 | .on_click(|evt, _, _| { |
| 168 | evt.dispatch_action("clipped_test:mouse_down", ElementIdentifier::Hoverable); |
| 169 | }) |
| 170 | .finish(), |
| 171 | OffsetPositioning::offset_from_parent( |
| 172 | vec2f(15., 0.), |
| 173 | ParentOffsetBounds::ParentByPosition, |
| 174 | ParentAnchor::TopLeft, |
| 175 | ChildAnchor::TopLeft, |
| 176 | ), |
| 177 | ); |
| 178 | |
| 179 | let mut stack = Stack::new(); |
| 180 | stack.add_child(bottom_stack.finish()); |
| 181 | stack.add_child(Clipped::sized(top_stack.finish(), Vector2F::new(25., 25.)).finish()); |
| 182 | |
| 183 | // Force the Stack to take up the full size of the window by pulling |
| 184 | // the minimum size constraint up to the size of the window. |
| 185 | ConstrainedBox::new(stack.finish()) |
| 186 | .with_min_width(f32::MAX) |
| 187 | .with_min_height(f32::MAX) |
| 188 | .finish() |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | impl TypedActionView for View { |
| 193 | type Action = (); |
| 194 | } |
| 195 | |
| 196 | #[test] |
| 197 | fn test_clipped_element_click_handling() { |
| 198 | App::test((), |mut app| async move { |
| 199 | let app = &mut app; |
| 200 | app.update(init); |
| 201 | let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default()); |
| 202 | |
| 203 | let mut presenter = Presenter::new(window_id); |
| 204 | |
| 205 | let mut updated = HashSet::new(); |
| 206 | updated.insert(app.root_view_id(window_id).unwrap()); |
| 207 | let invalidation = WindowInvalidation { |
| 208 | updated, |
| 209 | ..Default::default() |
| 210 | }; |
| 211 | |
| 212 | app.update(move |ctx| { |
| 213 | presenter.invalidate(invalidation, ctx); |
| 214 | let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx); |
| 215 | assert_eq!(scene.z_index(), ZIndex::new(0)); |
| 216 | assert_eq!(scene.layer_count(), 9); |
| 217 | let presenter = Rc::new(RefCell::new(presenter)); |
| 218 | |
| 219 | // Click on the bottom stack. This should work because the bottom |
| 220 | // stack is not clipped. |
| 221 | ctx.simulate_window_event( |
| 222 | Event::LeftMouseDown { |
| 223 | position: vec2f(10., 90.), |
| 224 | modifiers: Default::default(), |
| 225 | click_count: 1, |
| 226 | is_first_mouse: false, |
| 227 | }, |
| 228 | window_id, |
| 229 | presenter.clone(), |
| 230 | ); |
| 231 | |
| 232 | // Click on the top stack base. This should work because the |
| 233 | // click is within the clipped range of the top stack. |
| 234 | ctx.simulate_window_event( |
| 235 | Event::LeftMouseDown { |
| 236 | position: vec2f(10., 10.), |
| 237 | modifiers: Default::default(), |
| 238 | click_count: 1, |
| 239 | is_first_mouse: false, |
| 240 | }, |
| 241 | window_id, |
| 242 | presenter.clone(), |
| 243 | ); |
| 244 | |
| 245 | // Click on the overlap between top stack base and top stack overlay. |
| 246 | // This should work because the click is still within the clipped |
| 247 | // range of the top stack. |
| 248 | ctx.simulate_window_event( |
| 249 | Event::LeftMouseDown { |
| 250 | position: vec2f(20., 20.), |
| 251 | modifiers: Default::default(), |
| 252 | click_count: 1, |
| 253 | is_first_mouse: false, |
| 254 | }, |
| 255 | window_id, |
| 256 | presenter.clone(), |
| 257 | ); |
| 258 | |
| 259 | // Click on the part of top stack overlay not overlapping with base. |
| 260 | // This should not work because it is outside of the clip bound of the |
| 261 | // base stack. |
| 262 | ctx.simulate_window_event( |
| 263 | Event::LeftMouseDown { |
| 264 | position: vec2f(30., 30.), |
| 265 | modifiers: Default::default(), |
| 266 | click_count: 1, |
| 267 | is_first_mouse: false, |
| 268 | }, |
| 269 | window_id, |
| 270 | presenter.clone(), |
| 271 | ); |
| 272 | |
| 273 | // Click on the overlap between hoverable and top stack overlay. |
| 274 | // This should work because the click is still within the clipped |
| 275 | // range of the top stack. |
| 276 | // Note Hoverable needs both mouse down and mouse up to fire the |
| 277 | // on click event. |
| 278 | ctx.simulate_window_event( |
| 279 | Event::LeftMouseDown { |
| 280 | position: vec2f(20., 5.), |
| 281 | modifiers: Default::default(), |
| 282 | click_count: 1, |
| 283 | is_first_mouse: false, |
| 284 | }, |
| 285 | window_id, |
| 286 | presenter.clone(), |
| 287 | ); |
| 288 | |
| 289 | ctx.simulate_window_event( |
| 290 | Event::LeftMouseUp { |
| 291 | position: vec2f(20., 5.), |
| 292 | modifiers: Default::default(), |
| 293 | }, |
| 294 | window_id, |
| 295 | presenter.clone(), |
| 296 | ); |
| 297 | |
| 298 | // Click on the part of hoverable not overlapping with base. |
| 299 | // This should not work because it is outside of the clip bound of the |
| 300 | // base stack. |
| 301 | ctx.simulate_window_event( |
| 302 | Event::LeftMouseDown { |
| 303 | position: vec2f(30., 5.), |
| 304 | modifiers: Default::default(), |
| 305 | click_count: 1, |
| 306 | is_first_mouse: false, |
| 307 | }, |
| 308 | window_id, |
| 309 | presenter.clone(), |
| 310 | ); |
| 311 | |
| 312 | ctx.simulate_window_event( |
| 313 | Event::LeftMouseUp { |
| 314 | position: vec2f(30., 5.), |
| 315 | modifiers: Default::default(), |
| 316 | }, |
| 317 | window_id, |
| 318 | presenter, |
| 319 | ); |
| 320 | }); |
| 321 | |
| 322 | view.read(app, |view, _| { |
| 323 | assert_eq!( |
| 324 | 1, |
| 325 | *view |
| 326 | .mouse_downs |
| 327 | .get(&ElementIdentifier::BottomStack) |
| 328 | .unwrap() |
| 329 | ); |
| 330 | assert_eq!( |
| 331 | 1, |
| 332 | *view |
| 333 | .mouse_downs |
| 334 | .get(&ElementIdentifier::TopStackBase) |
| 335 | .unwrap() |
| 336 | ); |
| 337 | assert_eq!( |
| 338 | 1, |
| 339 | *view |
| 340 | .mouse_downs |
| 341 | .get(&ElementIdentifier::TopStackOverlay) |
| 342 | .unwrap() |
| 343 | ); |
| 344 | assert_eq!( |
| 345 | 1, |
| 346 | *view.mouse_downs.get(&ElementIdentifier::Hoverable).unwrap() |
| 347 | ); |
| 348 | }); |
| 349 | }); |
| 350 | } |
| 351 |