1use std::time::{Duration, Instant};
4
5use ailloli_ui_core::math::Scale;
6use ailloli_ui_core::{Color, FontId, Size, TextStyle};
7use ailloli_ui_runtime::app::RuntimeHandle;
8use ailloli_ui_runtime::component::{IntoView, IntoViewKeyExt, View};
9use ailloli_ui_runtime::input::ResizeEdge;
10use ailloli_ui_widgets::chrome::{
11 WindowAffordanceDragPhase, WindowAffordanceEvent, WindowAffordanceFrame, WindowAffordanceKind,
12};
13use ailloli_ui_widgets::controls::Button;
14use ailloli_ui_widgets::layout::{Column, Container, Row, ScrollView};
15use ailloli_ui_widgets::text::Text;
16use openxr as xr;
17
18use crate::math::Vec3;
19
20use super::composer::OpenXrQuadLayerOptions;
21use super::error::OpenXrRuntimeError;
22use super::external_host::{
23 OpenXrExternalUiHost, OpenXrExternalUiHostFrameParts, OpenXrExternalUiHostOptions,
24 OpenXrExternalUiHostRayOptions,
25};
26use super::input::{OpenXrPointerSelectionPolicy, OpenXrUiInputOptions};
27use super::panel::{
28 apply_facing_stable_grab, apply_pointer_depth_delta, logical_point_to_panel_local, rotate_vec3,
29 vec3_from_xr, OpenXrPanelFacingMode, OpenXrPanelFacingOptions, OpenXrPanelGrabState,
30};
31use super::ray_overlay::{OpenXrRayHitKind, OpenXrRayOverlayOptions, OpenXrRaySample};
32use super::session_loop::{OpenXrRuntime, OpenXrRuntimeOptions, ReferenceSpacePreference};
33
34const SMOKE_PIXEL_WIDTH: u32 = 1024;
36const SMOKE_PIXEL_HEIGHT: u32 = 576;
38const SMOKE_QUAD_WIDTH_M: f32 = 1.6;
40const SMOKE_QUAD_HEIGHT_M: f32 = 0.9;
42const DEFAULT_DISTANCE_M: f32 = 2.0;
44const DEFAULT_SCALE: f32 = 1.0;
46const SMOKE_SLATE_MIN_WIDTH_M: f32 = 0.45;
48const SMOKE_SLATE_MIN_HEIGHT_M: f32 = 0.25;
50const SMOKE_SLATE_MAX_WIDTH_M: f32 = 3.0;
52const SMOKE_SLATE_MAX_HEIGHT_M: f32 = 1.8;
54
55#[derive(Debug, Clone)]
56pub struct OpenXrSmokeOptions {
71 pub prefer_left: bool,
73 pub hands: bool,
75 pub distance_m: f32,
77 pub scale: f32,
79 pub timeout_sec: Option<f32>,
81 pub pixel_width: u32,
83 pub pixel_height: u32,
85 pub application_name: String,
87 pub engine_name: String,
89 pub affordance_demo: bool,
91 pub panel_facing: OpenXrPanelFacingOptions,
93}
94
95impl Default for OpenXrSmokeOptions {
96 fn default() -> Self {
97 Self {
98 prefer_left: false,
99 hands: true,
100 distance_m: DEFAULT_DISTANCE_M,
101 scale: DEFAULT_SCALE,
102 timeout_sec: None,
103 pixel_width: SMOKE_PIXEL_WIDTH,
104 pixel_height: SMOKE_PIXEL_HEIGHT,
105 application_name: "ailloli_ui-smoke".to_string(),
106 engine_name: "ailloli_ui".to_string(),
107 affordance_demo: false,
108 panel_facing: OpenXrPanelFacingOptions::default(),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum OpenXrSmokeExitReason {
123 ExitAction,
125 Timeout,
127 Shutdown,
129 XrExit,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct OpenXrSmokeResult {
144 pub exit_reason: OpenXrSmokeExitReason,
146}
147
148pub fn run_openxr_smoke(
168 options: OpenXrSmokeOptions,
169 shutdown: impl Fn() -> bool,
170) -> Result<OpenXrSmokeResult, OpenXrRuntimeError> {
171 let mut app = OpenXrSmokeApp::new(options)?;
172 app.main_loop(shutdown)
173}
174
175struct OpenXrSmokeApp {
177 host: OpenXrExternalUiHost<SmokeAction>,
180 runtime: OpenXrRuntime,
182 slate: SmokeSlateState,
184 options: OpenXrSmokeOptions,
186 started_at: Instant,
188 first_frame_logged: bool,
190 first_pointer_logged: bool,
192 first_ray_overlay_logged: bool,
194}
195
196impl OpenXrSmokeApp {
197 fn new(options: OpenXrSmokeOptions) -> Result<Self, OpenXrRuntimeError> {
204 let runtime = OpenXrRuntime::new(smoke_runtime_options(&options))?;
205 log::info!(
206 "Ailloli UI smoke OpenXR runtime initialized: {} {}",
207 runtime.xr.runtime_name,
208 runtime.xr.runtime_version
209 );
210
211 let initial_layer = smoke_layer_options(&options);
212 let host = OpenXrExternalUiHost::new(
213 &runtime.xr.instance,
214 &runtime.session,
215 runtime.external_vulkan_context(),
216 runtime.input_capabilities(),
217 RuntimeHandle::<SmokeAction>::new(),
218 smoke_view(options.clone()),
219 smoke_host_options(&options),
220 )?;
221 log::info!(
222 "Ailloli UI smoke external UI host created {}x{} distance_m={:.2} scale={:.2}",
223 smoke_pixel_width(&options),
224 smoke_pixel_height(&options),
225 smoke_distance_m(&options),
226 smoke_scale(&options)
227 );
228
229 Ok(Self {
230 host,
231 runtime,
232 slate: SmokeSlateState::new(initial_layer, options.panel_facing),
233 options,
234 started_at: Instant::now(),
235 first_frame_logged: false,
236 first_pointer_logged: false,
237 first_ray_overlay_logged: false,
238 })
239 }
240
241 fn main_loop(
249 &mut self,
250 shutdown: impl Fn() -> bool,
251 ) -> Result<OpenXrSmokeResult, OpenXrRuntimeError> {
252 let mut event_storage = xr::EventDataBuffer::new();
253 let mut session_running = false;
254 let mut exit_reason = None;
255
256 log::info!("entering Ailloli UI Quest smoke frame loop");
257 while !shutdown() && exit_reason.is_none() {
258 while let Some(event) = self
259 .runtime
260 .xr
261 .instance
262 .poll_event(&mut event_storage)
263 .map_err(|result| OpenXrRuntimeError::PollEvent { result })?
264 {
265 use xr::Event::*;
266 match event {
267 SessionStateChanged(event) => match event.state() {
268 xr::SessionState::READY => {
269 if !session_running {
270 self.runtime
271 .session
272 .begin(xr::ViewConfigurationType::PRIMARY_STEREO)
273 .map_err(|result| OpenXrRuntimeError::BeginSession {
274 result,
275 })?;
276 session_running = true;
277 }
278 log::info!("Ailloli UI smoke XR session READY");
279 }
280 xr::SessionState::STOPPING => {
281 if session_running {
282 self.runtime
283 .session
284 .end()
285 .map_err(|result| OpenXrRuntimeError::EndSession { result })?;
286 session_running = false;
287 }
288 self.host.clear_input();
289 log::info!("Ailloli UI smoke XR session STOPPING");
290 }
291 xr::SessionState::EXITING | xr::SessionState::LOSS_PENDING => {
292 self.host.clear_input();
293 log::info!(
294 "Ailloli UI smoke XR session {:?} - leaving frame loop",
295 event.state()
296 );
297 return Ok(OpenXrSmokeResult {
298 exit_reason: OpenXrSmokeExitReason::XrExit,
299 });
300 }
301 other => log::info!("Ailloli UI smoke XR session state {other:?}"),
302 },
303 InstanceLossPending(_) => {
304 self.host.clear_input();
305 return Ok(OpenXrSmokeResult {
306 exit_reason: OpenXrSmokeExitReason::XrExit,
307 });
308 }
309 EventsLost(event) => {
310 log::warn!(
311 "Ailloli UI smoke lost {} XR events",
312 event.lost_event_count()
313 )
314 }
315 InteractionProfileChanged(_) => {
316 self.host.log_interaction_profiles(
317 &self.runtime.xr.instance,
318 &self.runtime.session,
319 );
320 }
321 _ => {}
322 }
323 }
324
325 if !session_running {
326 std::thread::sleep(Duration::from_millis(16));
327 continue;
328 }
329
330 if let Some(timeout_sec) = smoke_timeout_sec(&self.options) {
331 if self.started_at.elapsed() >= Duration::from_secs_f32(timeout_sec) {
332 log::info!(
333 "ailloli_ui smoke action=timeout elapsed_sec={:.1}",
334 self.started_at.elapsed().as_secs_f32()
335 );
336 return Ok(OpenXrSmokeResult {
337 exit_reason: OpenXrSmokeExitReason::Timeout,
338 });
339 }
340 }
341
342 let frame_state = self
343 .runtime
344 .frame_waiter
345 .wait()
346 .map_err(|result| OpenXrRuntimeError::FrameWait { result })?;
347 self.runtime
348 .frame_stream
349 .begin()
350 .map_err(|result| OpenXrRuntimeError::FrameBegin { result })?;
351
352 if !frame_state.should_render {
353 self.runtime
354 .frame_stream
355 .end(
356 frame_state.predicted_display_time,
357 self.runtime.blend_mode,
358 &[],
359 )
360 .map_err(|result| OpenXrRuntimeError::FrameEnd { result })?;
361 continue;
362 }
363
364 let logical_size = self.host.logical_size();
365 let head_pos = self
366 .runtime
367 .locate_view_pose(frame_state.predicted_display_time)?
368 .map(|pose| vec3_from_xr(pose.position));
369 let frame = OpenXrExternalUiHostFrameParts::new(
370 &self.runtime.xr.instance,
371 &self.runtime.session,
372 &self.runtime.reference_space,
373 self.runtime.external_vulkan_context(),
374 frame_state.predicted_display_time,
375 )
376 .with_frame_time_ms(self.started_at.elapsed().as_millis());
377 let mut slate_changed = false;
378 {
379 let result = self.host.render_frame(frame)?;
380
381 if !self.first_frame_logged {
382 self.first_frame_logged = true;
383 log::info!(
384 "ailloli_ui smoke frame rendered rects={} glyphs={} ignored={}",
385 result.stats.rects_rendered,
386 result.stats.glyphs_rendered,
387 result.stats.commands_ignored
388 );
389 }
390
391 if !self.first_pointer_logged {
392 if let Some(source) = result.input.source {
393 self.first_pointer_logged = true;
394 log::info!(
395 "ailloli_ui smoke pointer active source={:?} hand={:?} streaming_hit={:?}",
396 source.source_kind,
397 source.hand,
398 result
399 .input
400 .ray_sample
401 .map(|sample| sample.hit_kind)
402 .unwrap_or(OpenXrRayHitKind::Miss)
403 );
404 }
405 }
406
407 if !self.first_ray_overlay_logged {
408 if let (Some(sample), Some(source)) =
409 (result.input.ray_sample, result.input.source)
410 {
411 self.first_ray_overlay_logged = true;
412 log::info!(
413 "ailloli_ui smoke ray overlay active source={:?} hand={:?} ui_hit={}",
414 source.source_kind,
415 source.hand,
416 sample.hit_kind == OpenXrRayHitKind::Ui
417 );
418 }
419 }
420
421 for action in result.actions.iter().copied() {
422 match action {
423 SmokeAction::Primary => log::info!("ailloli_ui smoke action=primary"),
424 SmokeAction::Secondary => log::info!("ailloli_ui smoke action=secondary"),
425 SmokeAction::Affordance(event) => {
426 let apply = self.slate.apply_affordance_event(
427 event,
428 logical_size,
429 result.input.ray_sample,
430 head_pos,
431 );
432 slate_changed |= apply.changed;
433 log::info!(
434 "ailloli_ui smoke action=affordance kind={:?} phase={:?} delta=({:.1},{:.1}) total_delta=({:.1},{:.1}) applied={} hmd_pose_seen={} grab_world_hit={} panel_facing_mode={:?} panel_facing_applied={} pitch_deg={:.2} pitch_clamped={} grab_point_stable={} fallback_delta={} depth_axis_seen={} depth_delta_m={:.4} depth_applied={} slate_pos=({:.3},{:.3},{:.3}) slate_size=({:.3},{:.3})",
435 event.kind,
436 event.phase,
437 event.delta.x,
438 event.delta.y,
439 event.total_delta.x,
440 event.total_delta.y,
441 apply.changed,
442 apply.hmd_pose_seen,
443 apply.grab_world_hit,
444 self.slate.facing.mode,
445 apply.yaw_applied,
446 apply.pitch_deg,
447 apply.pitch_clamped,
448 apply.grab_point_stable,
449 apply.fallback_delta,
450 apply.depth_axis_seen,
451 apply.depth_delta_m,
452 apply.depth_applied,
453 self.slate.layer.pose.position.x,
454 self.slate.layer.pose.position.y,
455 self.slate.layer.pose.position.z,
456 self.slate.layer.size.width,
457 self.slate.layer.size.height,
458 );
459 }
460 SmokeAction::Exit => {
461 log::info!("ailloli_ui smoke action=exit");
462 exit_reason = Some(OpenXrSmokeExitReason::ExitAction);
463 }
464 }
465 }
466
467 let layer_refs = result.layer_refs();
468 self.runtime
469 .frame_stream
470 .end(
471 frame_state.predicted_display_time,
472 self.runtime.blend_mode,
473 &layer_refs,
474 )
475 .map_err(|result| OpenXrRuntimeError::FrameEnd { result })?;
476 }
477 if slate_changed {
478 self.host.set_layer_options(self.slate.layer);
479 }
480 }
481
482 log::info!("leaving Ailloli UI Quest smoke frame loop");
483 Ok(OpenXrSmokeResult {
484 exit_reason: exit_reason.unwrap_or(OpenXrSmokeExitReason::Shutdown),
485 })
486 }
487}
488
489#[derive(Debug, Clone, Copy)]
490enum SmokeAction {
492 Primary,
494 Secondary,
496 Affordance(WindowAffordanceEvent),
498 Exit,
500}
501
502#[derive(Debug, Clone, Copy, Default, PartialEq)]
503struct SmokeSlateApplyResult {
505 changed: bool,
507 hmd_pose_seen: bool,
509 grab_world_hit: bool,
511 yaw_applied: bool,
513 grab_point_stable: bool,
515 fallback_delta: bool,
517 depth_axis_seen: bool,
519 depth_delta_m: f32,
521 depth_applied: bool,
523 pitch_deg: f32,
525 pitch_clamped: bool,
527}
528
529impl SmokeSlateApplyResult {
530 fn changed() -> Self {
532 Self {
533 changed: true,
534 ..Self::default()
535 }
536 }
537}
538
539#[derive(Clone, Copy)]
540struct SmokeSlateState {
542 layer: OpenXrQuadLayerOptions,
544 min_size_m: xr::Extent2Df,
546 max_size_m: xr::Extent2Df,
548 facing: OpenXrPanelFacingOptions,
550 active_grab: Option<OpenXrPanelGrabState>,
552}
553
554impl SmokeSlateState {
555 fn new(layer: OpenXrQuadLayerOptions, mut facing: OpenXrPanelFacingOptions) -> Self {
557 facing.normalize_pitch_bounds();
558 Self {
559 layer,
560 min_size_m: xr::Extent2Df {
561 width: SMOKE_SLATE_MIN_WIDTH_M,
562 height: SMOKE_SLATE_MIN_HEIGHT_M,
563 },
564 max_size_m: xr::Extent2Df {
565 width: SMOKE_SLATE_MAX_WIDTH_M,
566 height: SMOKE_SLATE_MAX_HEIGHT_M,
567 },
568 facing,
569 active_grab: None,
570 }
571 }
572
573 fn apply_affordance_event(
575 &mut self,
576 event: WindowAffordanceEvent,
577 logical_size: Size,
578 ray_sample: Option<OpenXrRaySample>,
579 head_pos: Option<Vec3>,
580 ) -> SmokeSlateApplyResult {
581 match event.kind {
582 WindowAffordanceKind::Move => {
583 self.apply_move_affordance(event, logical_size, ray_sample, head_pos)
584 }
585 WindowAffordanceKind::ResizeEdge(edge) | WindowAffordanceKind::ResizeCorner(edge) => {
586 if event.phase == WindowAffordanceDragPhase::Drag {
587 self.apply_resize(edge, event, logical_size);
588 SmokeSlateApplyResult::changed()
589 } else {
590 SmokeSlateApplyResult::default()
591 }
592 }
593 WindowAffordanceKind::Close
594 | WindowAffordanceKind::Minimize
595 | WindowAffordanceKind::Follow => SmokeSlateApplyResult::default(),
596 }
597 }
598
599 fn apply_move_affordance(
601 &mut self,
602 event: WindowAffordanceEvent,
603 logical_size: Size,
604 ray_sample: Option<OpenXrRaySample>,
605 head_pos: Option<Vec3>,
606 ) -> SmokeSlateApplyResult {
607 match event.phase {
608 WindowAffordanceDragPhase::Start => {
609 let grab = self.new_grab_state(event, logical_size, ray_sample);
610 let depth_axis_seen = grab.depth_axis.is_some();
611 self.active_grab = Some(grab);
612 SmokeSlateApplyResult {
613 hmd_pose_seen: head_pos.is_some(),
614 depth_axis_seen,
615 ..SmokeSlateApplyResult::default()
616 }
617 }
618 WindowAffordanceDragPhase::Drag => {
619 if self.active_grab.is_none() {
620 self.active_grab = Some(self.new_grab_state(event, logical_size, ray_sample));
621 }
622
623 let hmd_pose_seen = head_pos.is_some();
624 let valid_ray_sample = valid_ui_ray_sample(ray_sample);
625 let grab_world = ray_sample_to_grab_world(valid_ray_sample);
626 let grab_world_hit = grab_world.is_some();
627 let mut depth_axis_seen = self
628 .active_grab
629 .as_ref()
630 .is_some_and(|grab| grab.depth_axis.is_some());
631
632 if matches!(
633 self.facing.mode,
634 OpenXrPanelFacingMode::FaceUserOnDrag
635 | OpenXrPanelFacingMode::FaceUserAlways
636 | OpenXrPanelFacingMode::FaceUserYawPitchOnDrag
637 ) {
638 if let (Some(grab), Some(grab_world)) = (self.active_grab.as_mut(), grab_world)
639 {
640 if grab.depth_axis.is_none() {
641 if let Some(sample) = valid_ray_sample {
642 grab.set_pointer_depth(sample.origin, sample.direction);
643 depth_axis_seen = grab.depth_axis.is_some();
644 }
645 }
646 let depth = apply_pointer_depth_delta(
647 grab_world,
648 grab.depth_axis,
649 grab.last_ray_origin,
650 valid_ray_sample.map(|sample| sample.origin),
651 );
652 if let Some(sample) = valid_ray_sample {
653 grab.last_ray_origin = Some(sample.origin);
654 }
655 let update = apply_facing_stable_grab(
656 self.layer,
657 grab,
658 depth.adjusted_grab_world,
659 head_pos,
660 self.facing,
661 );
662 if update.hmd_pose_seen {
663 self.layer = update.layer;
664 return SmokeSlateApplyResult {
665 changed: true,
666 hmd_pose_seen: update.hmd_pose_seen,
667 grab_world_hit,
668 yaw_applied: update.yaw_applied,
669 grab_point_stable: update.grab_point_stable,
670 fallback_delta: false,
671 depth_axis_seen,
672 depth_delta_m: depth.depth_delta_m,
673 depth_applied: depth.depth_applied,
674 pitch_deg: update.pitch_deg,
675 pitch_clamped: update.pitch_clamped,
676 };
677 }
678 }
679 }
680
681 self.apply_move(event, logical_size);
682 SmokeSlateApplyResult {
683 changed: true,
684 hmd_pose_seen,
685 grab_world_hit,
686 fallback_delta: true,
687 depth_axis_seen,
688 ..SmokeSlateApplyResult::default()
689 }
690 }
691 WindowAffordanceDragPhase::End | WindowAffordanceDragPhase::Click => {
692 self.active_grab = None;
693 SmokeSlateApplyResult {
694 hmd_pose_seen: head_pos.is_some(),
695 ..SmokeSlateApplyResult::default()
696 }
697 }
698 }
699 }
700
701 fn new_grab_state(
703 &self,
704 event: WindowAffordanceEvent,
705 logical_size: Size,
706 ray_sample: Option<OpenXrRaySample>,
707 ) -> OpenXrPanelGrabState {
708 let local_grab_point_m =
709 logical_point_to_panel_local(event.position, logical_size, self.layer.size);
710 let mut grab = OpenXrPanelGrabState::new(local_grab_point_m, self.layer.pose.orientation);
711 if let Some(sample) = valid_ui_ray_sample(ray_sample) {
712 grab.set_pointer_depth(sample.origin, sample.direction);
713 }
714 grab
715 }
716
717 fn apply_move(&mut self, event: WindowAffordanceEvent, logical_size: Size) {
719 let (dx_m, dy_m) = self.logical_delta_to_meters(event, logical_size);
720 let translation = layer_right(self.layer) * dx_m + layer_up(self.layer) * -dy_m;
721 translate_layer(&mut self.layer, translation);
722 }
723
724 fn apply_resize(&mut self, edge: ResizeEdge, event: WindowAffordanceEvent, logical_size: Size) {
726 let (dx_m, dy_m) = self.logical_delta_to_meters(event, logical_size);
727 let old_width = self.layer.size.width.max(self.min_size_m.width);
728 let old_height = self.layer.size.height.max(self.min_size_m.height);
729 let right = layer_right(self.layer);
730 let up = layer_up(self.layer);
731
732 let mut shift_x = 0.0;
733 let mut shift_y = 0.0;
734 let mut new_width = old_width;
735 let mut new_height = old_height;
736
737 match edge {
738 ResizeEdge::E | ResizeEdge::NE | ResizeEdge::SE => {
739 let proposed = old_width + dx_m;
740 new_width = self.clamp_width(proposed);
741 shift_x = (new_width - old_width) * 0.5;
742 }
743 ResizeEdge::W | ResizeEdge::NW | ResizeEdge::SW => {
744 let proposed = old_width - dx_m;
745 new_width = self.clamp_width(proposed);
746 shift_x = (old_width - new_width) * 0.5;
747 }
748 ResizeEdge::N | ResizeEdge::S => {}
749 }
750
751 match edge {
752 ResizeEdge::N | ResizeEdge::NE | ResizeEdge::NW => {
753 let proposed = old_height - dy_m;
754 new_height = self.clamp_height(proposed);
755 shift_y = (new_height - old_height) * 0.5;
756 }
757 ResizeEdge::S | ResizeEdge::SE | ResizeEdge::SW => {
758 let proposed = old_height + dy_m;
759 new_height = self.clamp_height(proposed);
760 shift_y = (old_height - new_height) * 0.5;
761 }
762 ResizeEdge::E | ResizeEdge::W => {}
763 }
764
765 self.layer.size.width = new_width;
766 self.layer.size.height = new_height;
767 translate_layer(&mut self.layer, right * shift_x + up * shift_y);
768 }
769
770 fn logical_delta_to_meters(
772 &self,
773 event: WindowAffordanceEvent,
774 logical_size: Size,
775 ) -> (f32, f32) {
776 let w = logical_size.w.max(1.0);
777 let h = logical_size.h.max(1.0);
778 (
779 event.delta.x / w * self.layer.size.width.max(self.min_size_m.width),
780 event.delta.y / h * self.layer.size.height.max(self.min_size_m.height),
781 )
782 }
783
784 fn clamp_width(&self, width: f32) -> f32 {
786 width.clamp(self.min_size_m.width, self.max_size_m.width)
787 }
788
789 fn clamp_height(&self, height: f32) -> f32 {
791 height.clamp(self.min_size_m.height, self.max_size_m.height)
792 }
793}
794
795fn translate_layer(layer: &mut OpenXrQuadLayerOptions, translation: Vec3) {
797 layer.pose.position.x += translation.x;
798 layer.pose.position.y += translation.y;
799 layer.pose.position.z += translation.z;
800}
801
802fn layer_right(layer: OpenXrQuadLayerOptions) -> Vec3 {
804 rotate_vec3(layer.pose.orientation, Vec3::new(1.0, 0.0, 0.0))
805 .normalize_or(Vec3::new(1.0, 0.0, 0.0))
806}
807
808fn layer_up(layer: OpenXrQuadLayerOptions) -> Vec3 {
810 rotate_vec3(layer.pose.orientation, Vec3::new(0.0, 1.0, 0.0))
811 .normalize_or(Vec3::new(0.0, 1.0, 0.0))
812}
813
814fn ray_sample_to_grab_world(sample: Option<OpenXrRaySample>) -> Option<Vec3> {
816 let sample = valid_ui_ray_sample(sample)?;
817 sample
818 .direction
819 .normalize()
820 .map(|direction| sample.origin + direction * sample.hit_distance)
821}
822
823fn valid_ui_ray_sample(sample: Option<OpenXrRaySample>) -> Option<OpenXrRaySample> {
825 let sample = sample?;
826 if sample.hit_kind == OpenXrRayHitKind::Ui
827 && sample.hit_distance.is_finite()
828 && sample.direction.normalize().is_some()
829 {
830 Some(sample)
831 } else {
832 None
833 }
834}
835
836fn smoke_view(options: OpenXrSmokeOptions) -> View<SmokeAction> {
838 if options.affordance_demo {
839 smoke_affordance_view(options)
840 } else {
841 smoke_default_view(options)
842 }
843}
844
845fn smoke_default_view(options: OpenXrSmokeOptions) -> View<SmokeAction> {
847 let title = TextStyle::new(FontId::Ui, 34, Color::rgb(245, 248, 255));
848 let body = TextStyle::new(FontId::Ui, 17, Color::rgb(218, 226, 238));
849 let dim = TextStyle::new(FontId::Ui, 14, Color::rgb(157, 173, 196));
850 let accent = TextStyle::new(FontId::Ui, 16, Color::rgb(126, 231, 215));
851
852 Container::<SmokeAction>::new()
853 .fill()
854 .background(Color::rgb(10, 16, 28))
855 .padding(28.0)
856 .child(
857 Column::<SmokeAction>::new()
858 .fill()
859 .gap(14.0)
860 .child(Text::new("Ailloli UI Quest Smoke").style(title))
861 .child(
862 Text::new(format!(
863 "native APK mode | distance={:.2}m scale={:.2} pointer={} hands={}",
864 smoke_distance_m(&options),
865 smoke_scale(&options),
866 if options.prefer_left { "left" } else { "right" },
867 if options.hands { "enabled" } else { "disabled" }
868 ))
869 .style(dim),
870 )
871 .child(Text::new("Visual: quad centered, text readable, ray visible.").style(body))
872 .child(Text::new("Interaction: hover, trigger/pinch click, exit.").style(body))
873 .child(
874 Row::<SmokeAction>::new()
875 .gap(12.0)
876 .child(
877 Button::<SmokeAction>::with_label("Primary")
878 .on_click(SmokeAction::Primary)
879 .width(180.0),
880 )
881 .child(
882 Button::<SmokeAction>::with_label("Secondary")
883 .on_click(SmokeAction::Secondary)
884 .width(180.0),
885 )
886 .child(
887 Button::<SmokeAction>::with_label("Exit")
888 .on_click(SmokeAction::Exit)
889 .width(140.0),
890 ),
891 )
892 .child(Text::new("Quest validation checklist").style(accent))
893 .child(
894 Container::<SmokeAction>::new()
895 .fill_width()
896 .height(210.0)
897 .background(Color::rgb(17, 25, 40))
898 .padding(14.0)
899 .child(
900 ScrollView::<SmokeAction>::vertical().child(
901 Column::<SmokeAction>::new()
902 .gap(10.0)
903 .child(
904 Text::new(
905 "1. APK launches without the PC streaming server",
906 )
907 .style(body),
908 )
909 .child(
910 Text::new("2. Quad is visible in the Quest headset")
911 .style(body),
912 )
913 .child(
914 Text::new(
915 "3. Text is readable, not mirrored, not upside down",
916 )
917 .style(body),
918 )
919 .child(
920 Text::new("4. Button hover/press colors are visible")
921 .style(body),
922 )
923 .child(
924 Text::new("5. Ray is gray off UI and green on the quad")
925 .style(body),
926 )
927 .child(
928 Text::new("6. Trigger or pinch logs action=primary")
929 .style(body),
930 )
931 .child(
932 Text::new("7. Exit button closes the smoke loop cleanly")
933 .style(body),
934 ),
935 ),
936 ),
937 )
938 .child(
939 Text::new("Logcat tag: linux-vr | expected: ailloli_ui smoke frame rendered")
940 .style(dim),
941 ),
942 )
943 .into_view()
944}
945
946fn smoke_affordance_view(options: OpenXrSmokeOptions) -> View<SmokeAction> {
948 let title = TextStyle::new(FontId::Ui, 24, Color::rgb(245, 248, 255));
949 let body = TextStyle::new(FontId::Ui, 15, Color::rgb(218, 226, 238));
950 let dim = TextStyle::new(FontId::Ui, 13, Color::rgb(156, 173, 196));
951 let accent = TextStyle::new(FontId::Ui, 14, Color::rgb(126, 231, 215));
952
953 Container::<SmokeAction>::new()
954 .fill()
955 .background(Color::rgb(8, 13, 24))
956 .padding(36.0)
957 .child(
958 WindowAffordanceFrame::<SmokeAction>::new("XR Window Affordances")
959 .logical_window_id("window-affordances-smoke-window")
960 .width(860.0)
961 .height(430.0)
962 .on_affordance(|event| match event.kind {
963 WindowAffordanceKind::Close => SmokeAction::Exit,
964 _ => SmokeAction::Affordance(event),
965 })
966 .content(
967 Container::<SmokeAction>::new()
968 .fill()
969 .background(Color::rgb(15, 23, 42))
970 .padding(18.0)
971 .child(
972 Column::<SmokeAction>::new()
973 .fill()
974 .gap(12.0)
975 .child(Text::new("Framework slate contract").style(title))
976 .child(
977 Text::new(format!(
978 "distance={:.2}m scale={:.2} pointer={} hands={}",
979 smoke_distance_m(&options),
980 smoke_scale(&options),
981 if options.prefer_left { "left" } else { "right" },
982 if options.hands { "enabled" } else { "disabled" }
983 ))
984 .style(dim),
985 )
986 .child(Text::new("Move from the titlebar, resize from edges/corners, and use the chrome buttons without touching LinuxVR streaming.").style(body))
987 .child(
988 Row::<SmokeAction>::new()
989 .gap(12.0)
990 .child(
991 Button::<SmokeAction>::with_label("Primary")
992 .on_click(SmokeAction::Primary)
993 .width(160.0),
994 )
995 .child(
996 Button::<SmokeAction>::with_label("Secondary")
997 .on_click(SmokeAction::Secondary)
998 .width(180.0),
999 )
1000 .child(
1001 Button::<SmokeAction>::with_label("Exit")
1002 .on_click(SmokeAction::Exit)
1003 .width(120.0),
1004 ),
1005 )
1006 .child(Text::new("Validation points").style(accent))
1007 .child(
1008 Container::<SmokeAction>::new()
1009 .fill_width()
1010 .height(155.0)
1011 .background(Color::rgb(11, 18, 32))
1012 .padding(12.0)
1013 .child(
1014 ScrollView::<SmokeAction>::vertical().child(
1015 Column::<SmokeAction>::new()
1016 .gap(8.0)
1017 .child(Text::new("1. Rounded surface, border and shadow are visible in Vulkan").style(body))
1018 .child(Text::new("2. Titlebar hover/drag emits affordance actions").style(body))
1019 .child(Text::new("3. Resize handles react on edges and corners").style(body))
1020 .child(Text::new("4. Inner buttons keep normal click handling").style(body))
1021 .child(Text::new("5. Close chrome exits the smoke loop").style(body)),
1022 ),
1023 ),
1024 ),
1025 ),
1026 ),
1027 )
1028 .key("window-affordances-window-affordances-smoke")
1029 .into_view()
1030}
1031
1032fn smoke_host_options(options: &OpenXrSmokeOptions) -> OpenXrExternalUiHostOptions {
1034 OpenXrExternalUiHostOptions {
1035 pixel_width: smoke_pixel_width(options),
1036 pixel_height: smoke_pixel_height(options),
1037 clear: Color::rgb(10, 16, 28),
1038 scale: Scale::new(smoke_scale(options)),
1039 layer: smoke_layer_options(options),
1040 renderer: Default::default(),
1041 input: smoke_input_options(options),
1042 ray: OpenXrExternalUiHostRayOptions {
1043 enabled: true,
1044 overlay: OpenXrRayOverlayOptions::default(),
1045 },
1046 }
1047}
1048
1049fn smoke_layer_options(options: &OpenXrSmokeOptions) -> OpenXrQuadLayerOptions {
1051 OpenXrQuadLayerOptions {
1052 pose: xr::Posef {
1053 orientation: xr::Quaternionf {
1054 x: 0.0,
1055 y: 0.0,
1056 z: 0.0,
1057 w: 1.0,
1058 },
1059 position: xr::Vector3f {
1060 x: 0.0,
1061 y: 0.0,
1062 z: -smoke_distance_m(options),
1063 },
1064 },
1065 size: xr::Extent2Df {
1066 width: SMOKE_QUAD_WIDTH_M,
1067 height: SMOKE_QUAD_HEIGHT_M,
1068 },
1069 eye_visibility: xr::EyeVisibility::BOTH,
1070 layer_flags: xr::CompositionLayerFlags::EMPTY,
1071 }
1072}
1073
1074fn smoke_input_options(options: &OpenXrSmokeOptions) -> OpenXrUiInputOptions {
1076 OpenXrUiInputOptions {
1077 controllers: true,
1078 hands: options.hands,
1079 pointer_selection: if options.prefer_left {
1080 OpenXrPointerSelectionPolicy::PreferLeftController
1081 } else {
1082 OpenXrPointerSelectionPolicy::PreferRightController
1083 },
1084 ..OpenXrUiInputOptions::default()
1085 }
1086}
1087
1088fn smoke_runtime_options(options: &OpenXrSmokeOptions) -> OpenXrRuntimeOptions {
1090 OpenXrRuntimeOptions {
1091 application_name: options.application_name.clone(),
1092 engine_name: options.engine_name.clone(),
1093 reference_space: ReferenceSpacePreference::LocalThenStage,
1094 }
1095}
1096
1097fn smoke_distance_m(options: &OpenXrSmokeOptions) -> f32 {
1099 positive_or_default(options.distance_m, DEFAULT_DISTANCE_M).abs()
1100}
1101
1102fn smoke_scale(options: &OpenXrSmokeOptions) -> f32 {
1104 positive_or_default(options.scale, DEFAULT_SCALE)
1105}
1106
1107fn smoke_timeout_sec(options: &OpenXrSmokeOptions) -> Option<f32> {
1109 options
1110 .timeout_sec
1111 .filter(|value| value.is_finite() && *value > 0.0)
1112}
1113
1114fn smoke_pixel_width(options: &OpenXrSmokeOptions) -> u32 {
1116 options.pixel_width.max(1)
1117}
1118
1119fn smoke_pixel_height(options: &OpenXrSmokeOptions) -> u32 {
1121 options.pixel_height.max(1)
1122}
1123
1124fn positive_or_default(value: f32, default: f32) -> f32 {
1126 if value.is_finite() && value > 0.0 {
1127 value
1128 } else {
1129 default
1130 }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1136 use super::super::panel::panel_local_to_world;
1137 use super::*;
1138 use ailloli_ui_core::{Offset, Point};
1139
1140 fn options(prefer_left: bool, hands: bool) -> OpenXrSmokeOptions {
1142 OpenXrSmokeOptions {
1143 prefer_left,
1144 hands,
1145 distance_m: 2.0,
1146 scale: 1.0,
1147 timeout_sec: Some(60.0),
1148 application_name: "linux-vr".to_string(),
1149 engine_name: "linux-vr".to_string(),
1150 ..OpenXrSmokeOptions::default()
1151 }
1152 }
1153
1154 #[test]
1155 fn smoke_options_default_match_quest_smoke_contract() {
1156 let options = OpenXrSmokeOptions::default();
1157 assert!(!options.prefer_left);
1158 assert!(options.hands);
1159 assert_eq!(options.distance_m, 2.0);
1160 assert_eq!(options.scale, 1.0);
1161 assert_eq!(options.pixel_width, 1024);
1162 assert_eq!(options.pixel_height, 576);
1163 assert_eq!(options.timeout_sec, None);
1164 assert!(!options.affordance_demo);
1165 assert_eq!(
1166 options.panel_facing.mode,
1167 OpenXrPanelFacingMode::FaceUserOnDrag
1168 );
1169 approx(options.panel_facing.pitch_min_rad.to_degrees(), -45.0);
1170 approx(options.panel_facing.pitch_max_rad.to_degrees(), 45.0);
1171 }
1172
1173 #[test]
1174 fn smoke_host_options_prefer_right_by_default() {
1175 let host = smoke_host_options(&options(false, true));
1176 assert_eq!(host.pixel_width, SMOKE_PIXEL_WIDTH);
1177 assert_eq!(host.pixel_height, SMOKE_PIXEL_HEIGHT);
1178 assert_eq!(host.scale.dpr, 1.0);
1179 assert_eq!(host.layer.pose.position.z, -2.0);
1180 assert_eq!(host.layer.size.width, SMOKE_QUAD_WIDTH_M);
1181 assert_eq!(host.layer.size.height, SMOKE_QUAD_HEIGHT_M);
1182 assert!(host.input.controllers);
1183 assert!(host.input.hands);
1184 assert!(host.ray.enabled);
1185 assert_eq!(
1186 host.input.pointer_selection,
1187 OpenXrPointerSelectionPolicy::PreferRightController
1188 );
1189 }
1190
1191 #[test]
1192 fn smoke_host_options_maps_left_and_no_hands() {
1193 let host = smoke_host_options(&options(true, false));
1194 assert!(host.input.controllers);
1195 assert!(!host.input.hands);
1196 assert_eq!(
1197 host.input.pointer_selection,
1198 OpenXrPointerSelectionPolicy::PreferLeftController
1199 );
1200 }
1201
1202 #[test]
1203 fn smoke_runtime_options_use_application_identity() {
1204 let runtime = smoke_runtime_options(&options(false, true));
1205 assert_eq!(runtime.application_name, "linux-vr");
1206 assert_eq!(runtime.engine_name, "linux-vr");
1207 assert_eq!(
1208 runtime.reference_space,
1209 ReferenceSpacePreference::LocalThenStage
1210 );
1211 }
1212
1213 fn drag_event(kind: WindowAffordanceKind, dx: f32, dy: f32) -> WindowAffordanceEvent {
1215 WindowAffordanceEvent {
1216 kind,
1217 phase: WindowAffordanceDragPhase::Drag,
1218 position: Point::new(dx, dy),
1219 delta: Offset::new(dx, dy),
1220 total_delta: Offset::new(dx, dy),
1221 }
1222 }
1223
1224 fn start_event(kind: WindowAffordanceKind) -> WindowAffordanceEvent {
1226 WindowAffordanceEvent {
1227 kind,
1228 phase: WindowAffordanceDragPhase::Start,
1229 position: Point::new(512.0, 288.0),
1230 delta: Offset::new(100.0, 100.0),
1231 total_delta: Offset::new(100.0, 100.0),
1232 }
1233 }
1234
1235 fn end_event(kind: WindowAffordanceKind) -> WindowAffordanceEvent {
1237 WindowAffordanceEvent {
1238 kind,
1239 phase: WindowAffordanceDragPhase::End,
1240 position: Point::new(512.0, 288.0),
1241 delta: Offset::new(0.0, 0.0),
1242 total_delta: Offset::new(0.0, 0.0),
1243 }
1244 }
1245
1246 fn slate() -> SmokeSlateState {
1248 SmokeSlateState::new(
1249 smoke_layer_options(&OpenXrSmokeOptions::default()),
1250 OpenXrPanelFacingOptions::default(),
1251 )
1252 }
1253
1254 fn slate_with_facing(facing: OpenXrPanelFacingOptions) -> SmokeSlateState {
1256 SmokeSlateState::new(smoke_layer_options(&OpenXrSmokeOptions::default()), facing)
1257 }
1258
1259 fn logical_size() -> Size {
1261 Size::new(SMOKE_PIXEL_WIDTH as f32, SMOKE_PIXEL_HEIGHT as f32)
1262 }
1263
1264 fn apply_delta(
1266 slate: &mut SmokeSlateState,
1267 event: WindowAffordanceEvent,
1268 ) -> SmokeSlateApplyResult {
1269 slate.apply_affordance_event(event, logical_size(), None, None)
1270 }
1271
1272 fn ray_sample(origin: Vec3, direction: Vec3, distance: f32) -> OpenXrRaySample {
1274 OpenXrRaySample::new(origin, direction, OpenXrRayHitKind::Ui, distance)
1275 }
1276
1277 fn approx(a: f32, b: f32) {
1279 assert!(
1280 (a - b).abs() < 1e-5,
1281 "expected {a:?} to be approximately {b:?}"
1282 );
1283 }
1284
1285 fn approx_vec(a: Vec3, b: Vec3) {
1287 approx(a.x, b.x);
1288 approx(a.y, b.y);
1289 approx(a.z, b.z);
1290 }
1291
1292 #[test]
1293 fn smoke_slate_move_right_increases_x() {
1294 let mut slate = slate();
1295 let applied = apply_delta(
1296 &mut slate,
1297 drag_event(WindowAffordanceKind::Move, 128.0, 0.0),
1298 );
1299 assert!(applied.changed);
1300 assert!(applied.fallback_delta);
1301 assert!(slate.layer.pose.position.x > 0.0);
1302 assert_eq!(slate.layer.pose.position.y, 0.0);
1303 assert_eq!(slate.layer.size.width, SMOKE_QUAD_WIDTH_M);
1304 }
1305
1306 #[test]
1307 fn smoke_slate_move_down_decreases_y() {
1308 let mut slate = slate();
1309 let applied = apply_delta(
1310 &mut slate,
1311 drag_event(WindowAffordanceKind::Move, 0.0, 128.0),
1312 );
1313 assert!(applied.changed);
1314 assert!(applied.fallback_delta);
1315 assert!(slate.layer.pose.position.y < 0.0);
1316 assert_eq!(slate.layer.pose.position.x, 0.0);
1317 }
1318
1319 #[test]
1320 fn smoke_slate_move_start_initializes_grab_without_pose_change() {
1321 let mut slate = slate();
1322 let before = slate.layer;
1323 let applied = slate.apply_affordance_event(
1324 start_event(WindowAffordanceKind::Move),
1325 logical_size(),
1326 None,
1327 Some(Vec3::new(0.0, 1.5, 0.0)),
1328 );
1329 assert!(!applied.changed);
1330 assert!(applied.hmd_pose_seen);
1331 assert!(slate.active_grab.is_some());
1332 assert_eq!(slate.layer.pose.position.x, before.pose.position.x);
1333 assert_eq!(slate.layer.pose.position.y, before.pose.position.y);
1334 assert_eq!(slate.layer.pose.position.z, before.pose.position.z);
1335 assert_eq!(slate.layer.size.width, before.size.width);
1336 assert_eq!(slate.layer.size.height, before.size.height);
1337 }
1338
1339 #[test]
1340 fn smoke_slate_move_start_with_ray_initializes_depth_state() {
1341 let mut slate = slate();
1342 let origin = Vec3::new(0.0, 0.0, 0.0);
1343 let direction = Vec3::new(0.0, 0.0, -1.0);
1344 let applied = slate.apply_affordance_event(
1345 start_event(WindowAffordanceKind::Move),
1346 logical_size(),
1347 Some(ray_sample(origin, direction, 2.0)),
1348 Some(Vec3::new(0.0, 1.5, 0.0)),
1349 );
1350 assert!(!applied.changed);
1351 assert!(applied.depth_axis_seen);
1352 let grab = slate.active_grab.unwrap();
1353 approx_vec(grab.depth_axis.unwrap(), direction);
1354 approx_vec(grab.last_ray_origin.unwrap(), origin);
1355 }
1356
1357 #[test]
1358 fn smoke_slate_move_drag_faces_hmd_and_keeps_grab_point_stable() {
1359 let mut slate = slate();
1360 slate.apply_affordance_event(
1361 start_event(WindowAffordanceKind::Move),
1362 logical_size(),
1363 None,
1364 None,
1365 );
1366
1367 let grab_world = Vec3::new(0.65, 0.1, -1.35);
1368 let origin = Vec3::new(0.0, 0.0, 0.0);
1369 let direction = grab_world - origin;
1370 let head_pos = Vec3::new(1.6, 0.2, 0.1);
1371 let applied = slate.apply_affordance_event(
1372 drag_event(WindowAffordanceKind::Move, 40.0, -20.0),
1373 logical_size(),
1374 Some(ray_sample(origin, direction, direction.len())),
1375 Some(head_pos),
1376 );
1377
1378 assert!(applied.changed);
1379 assert!(applied.hmd_pose_seen);
1380 assert!(applied.grab_world_hit);
1381 assert!(applied.yaw_applied);
1382 assert!(applied.grab_point_stable);
1383 assert!(!applied.fallback_delta);
1384
1385 let grab = slate.active_grab.unwrap();
1386 approx_vec(
1387 panel_local_to_world(slate.layer, grab.local_grab_point_m),
1388 grab_world,
1389 );
1390 assert!(slate.layer.pose.orientation.y.abs() > 0.001);
1391 approx(slate.layer.pose.orientation.x, 0.0);
1392 approx(slate.layer.pose.orientation.z, 0.0);
1393 }
1394
1395 #[test]
1396 fn smoke_slate_move_drag_yaw_pitch_faces_hmd_above_and_keeps_grab_point_stable() {
1397 let mut slate = slate_with_facing(OpenXrPanelFacingOptions::new(
1398 OpenXrPanelFacingMode::FaceUserYawPitchOnDrag,
1399 -45.0_f32.to_radians(),
1400 45.0_f32.to_radians(),
1401 ));
1402 slate.apply_affordance_event(
1403 start_event(WindowAffordanceKind::Move),
1404 logical_size(),
1405 None,
1406 None,
1407 );
1408
1409 let grab_world = Vec3::new(0.65, 0.1, -1.35);
1410 let origin = Vec3::new(0.0, 0.0, 0.0);
1411 let direction = grab_world - origin;
1412 let head_pos = Vec3::new(1.6, 1.4, 0.1);
1413 let applied = slate.apply_affordance_event(
1414 drag_event(WindowAffordanceKind::Move, 40.0, -20.0),
1415 logical_size(),
1416 Some(ray_sample(origin, direction, direction.len())),
1417 Some(head_pos),
1418 );
1419
1420 assert!(applied.changed);
1421 assert!(applied.hmd_pose_seen);
1422 assert!(applied.grab_world_hit);
1423 assert!(applied.yaw_applied);
1424 assert!(applied.grab_point_stable);
1425 assert!(applied.pitch_deg > 0.0);
1426 assert!(!applied.fallback_delta);
1427
1428 let grab = slate.active_grab.unwrap();
1429 approx_vec(
1430 panel_local_to_world(slate.layer, grab.local_grab_point_m),
1431 grab_world,
1432 );
1433 assert!(slate.layer.pose.orientation.x.abs() > 0.001);
1434 let right = rotate_vec3(slate.layer.pose.orientation, Vec3::new(1.0, 0.0, 0.0));
1435 approx(right.y, 0.0);
1436 }
1437
1438 #[test]
1439 fn smoke_slate_move_drag_forward_origin_pushes_panel_deeper() {
1440 let mut slate = slate();
1441 let start_origin = Vec3::new(0.0, 0.0, 0.0);
1442 let direction = Vec3::new(0.0, 0.0, -1.0);
1443 slate.apply_affordance_event(
1444 start_event(WindowAffordanceKind::Move),
1445 logical_size(),
1446 Some(ray_sample(start_origin, direction, 2.0)),
1447 None,
1448 );
1449
1450 let applied = slate.apply_affordance_event(
1451 drag_event(WindowAffordanceKind::Move, 0.0, 0.0),
1452 logical_size(),
1453 Some(ray_sample(Vec3::new(0.0, 0.0, -0.25), direction, 1.75)),
1454 Some(Vec3::new(0.0, 0.2, 0.0)),
1455 );
1456
1457 assert!(applied.changed);
1458 assert!(applied.depth_axis_seen);
1459 assert!(applied.depth_applied);
1460 approx(applied.depth_delta_m, 0.25);
1461 approx(slate.layer.pose.position.z, -2.25);
1462 approx_vec(
1463 slate.active_grab.unwrap().last_ray_origin.unwrap(),
1464 Vec3::new(0.0, 0.0, -0.25),
1465 );
1466 }
1467
1468 #[test]
1469 fn smoke_slate_move_drag_backward_origin_pulls_panel_closer() {
1470 let mut slate = slate();
1471 let start_origin = Vec3::new(0.0, 0.0, 0.0);
1472 let direction = Vec3::new(0.0, 0.0, -1.0);
1473 slate.apply_affordance_event(
1474 start_event(WindowAffordanceKind::Move),
1475 logical_size(),
1476 Some(ray_sample(start_origin, direction, 2.0)),
1477 None,
1478 );
1479
1480 let applied = slate.apply_affordance_event(
1481 drag_event(WindowAffordanceKind::Move, 0.0, 0.0),
1482 logical_size(),
1483 Some(ray_sample(Vec3::new(0.0, 0.0, 0.25), direction, 2.25)),
1484 Some(Vec3::new(0.0, 0.2, 0.0)),
1485 );
1486
1487 assert!(applied.changed);
1488 assert!(applied.depth_axis_seen);
1489 assert!(applied.depth_applied);
1490 approx(applied.depth_delta_m, -0.25);
1491 approx(slate.layer.pose.position.z, -1.75);
1492 }
1493
1494 #[test]
1495 fn smoke_slate_move_drag_without_hmd_or_ray_falls_back_to_delta() {
1496 let mut slate = slate();
1497 slate.apply_affordance_event(
1498 start_event(WindowAffordanceKind::Move),
1499 logical_size(),
1500 None,
1501 None,
1502 );
1503 let applied = slate.apply_affordance_event(
1504 drag_event(WindowAffordanceKind::Move, 128.0, 0.0),
1505 logical_size(),
1506 None,
1507 None,
1508 );
1509 assert!(applied.changed);
1510 assert!(!applied.hmd_pose_seen);
1511 assert!(!applied.grab_world_hit);
1512 assert!(!applied.yaw_applied);
1513 assert!(applied.fallback_delta);
1514 assert!(!applied.depth_applied);
1515 assert!(slate.layer.pose.position.x > 0.0);
1516 }
1517
1518 #[test]
1519 fn smoke_slate_move_end_clears_active_grab() {
1520 let mut slate = slate();
1521 slate.apply_affordance_event(
1522 start_event(WindowAffordanceKind::Move),
1523 logical_size(),
1524 None,
1525 None,
1526 );
1527 assert!(slate.active_grab.is_some());
1528 let applied = slate.apply_affordance_event(
1529 end_event(WindowAffordanceKind::Move),
1530 logical_size(),
1531 None,
1532 None,
1533 );
1534 assert!(!applied.changed);
1535 assert!(slate.active_grab.is_none());
1536 }
1537
1538 #[test]
1539 fn smoke_slate_resize_east_grows_width_and_moves_right() {
1540 let mut slate = slate();
1541 let applied = apply_delta(
1542 &mut slate,
1543 drag_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::E), 128.0, 0.0),
1544 );
1545 assert!(applied.changed);
1546 assert!(slate.layer.size.width > SMOKE_QUAD_WIDTH_M);
1547 assert!(slate.layer.pose.position.x > 0.0);
1548 assert_eq!(slate.layer.size.height, SMOKE_QUAD_HEIGHT_M);
1549 }
1550
1551 #[test]
1552 fn smoke_slate_resize_west_keeps_east_edge_stable() {
1553 let mut slate = slate();
1554 let old_east = slate.layer.pose.position.x + slate.layer.size.width * 0.5;
1555 let applied = apply_delta(
1556 &mut slate,
1557 drag_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::W), -128.0, 0.0),
1558 );
1559 assert!(applied.changed);
1560 let new_east = slate.layer.pose.position.x + slate.layer.size.width * 0.5;
1561 assert!((new_east - old_east).abs() < 1e-5);
1562 assert!(slate.layer.size.width > SMOKE_QUAD_WIDTH_M);
1563 assert!(slate.layer.pose.position.x < 0.0);
1564 }
1565
1566 #[test]
1567 fn smoke_slate_resize_north_and_south_use_screen_y_direction() {
1568 let mut north = slate();
1569 apply_delta(
1570 &mut north,
1571 drag_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::N), 0.0, -128.0),
1572 );
1573 assert!(north.layer.size.height > SMOKE_QUAD_HEIGHT_M);
1574 assert!(north.layer.pose.position.y > 0.0);
1575
1576 let mut south = slate();
1577 apply_delta(
1578 &mut south,
1579 drag_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::S), 0.0, 128.0),
1580 );
1581 assert!(south.layer.size.height > SMOKE_QUAD_HEIGHT_M);
1582 assert!(south.layer.pose.position.y < 0.0);
1583 }
1584
1585 #[test]
1586 fn smoke_slate_resize_corner_combines_width_and_height() {
1587 let mut slate = slate();
1588 let applied = apply_delta(
1589 &mut slate,
1590 drag_event(
1591 WindowAffordanceKind::ResizeCorner(ResizeEdge::NE),
1592 128.0,
1593 -128.0,
1594 ),
1595 );
1596 assert!(applied.changed);
1597 assert!(slate.layer.size.width > SMOKE_QUAD_WIDTH_M);
1598 assert!(slate.layer.size.height > SMOKE_QUAD_HEIGHT_M);
1599 assert!(slate.layer.pose.position.x > 0.0);
1600 assert!(slate.layer.pose.position.y > 0.0);
1601 }
1602
1603 #[test]
1604 fn smoke_slate_resize_does_not_force_billboard_rotation() {
1605 let mut slate = slate();
1606 let before = slate.layer.pose.orientation;
1607 let applied = apply_delta(
1608 &mut slate,
1609 drag_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::E), 128.0, 0.0),
1610 );
1611 assert!(applied.changed);
1612 assert_eq!(slate.layer.pose.orientation, before);
1613 assert!(slate.active_grab.is_none());
1614 assert!(!applied.depth_axis_seen);
1615 assert!(!applied.depth_applied);
1616 }
1617
1618 #[test]
1619 fn smoke_slate_resize_clamps_min_and_max() {
1620 let mut min = slate();
1621 apply_delta(
1622 &mut min,
1623 drag_event(
1624 WindowAffordanceKind::ResizeCorner(ResizeEdge::SE),
1625 -100_000.0,
1626 -100_000.0,
1627 ),
1628 );
1629 assert_eq!(min.layer.size.width, SMOKE_SLATE_MIN_WIDTH_M);
1630 assert_eq!(min.layer.size.height, SMOKE_SLATE_MIN_HEIGHT_M);
1631
1632 let mut max = slate();
1633 apply_delta(
1634 &mut max,
1635 drag_event(
1636 WindowAffordanceKind::ResizeCorner(ResizeEdge::SE),
1637 100_000.0,
1638 100_000.0,
1639 ),
1640 );
1641 assert_eq!(max.layer.size.width, SMOKE_SLATE_MAX_WIDTH_M);
1642 assert_eq!(max.layer.size.height, SMOKE_SLATE_MAX_HEIGHT_M);
1643 }
1644
1645 #[test]
1646 fn smoke_slate_start_event_does_not_change_pose_or_size() {
1647 let mut slate = slate();
1648 let before = slate.layer;
1649 let applied = apply_delta(
1650 &mut slate,
1651 start_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::E)),
1652 );
1653 assert!(!applied.changed);
1654 assert_eq!(slate.layer.pose.position.x, before.pose.position.x);
1655 assert_eq!(slate.layer.pose.position.y, before.pose.position.y);
1656 assert_eq!(slate.layer.size.width, before.size.width);
1657 assert_eq!(slate.layer.size.height, before.size.height);
1658 }
1659}