Skip to main content

ply_engine/
lib.rs

1pub mod accessibility;
2#[cfg(all(feature = "a11y", target_arch = "wasm32"))]
3pub mod accessibility_web;
4#[cfg(all(feature = "a11y", not(target_arch = "wasm32")))]
5pub mod accessibility_native;
6pub mod align;
7pub mod color;
8pub mod elements;
9pub mod engine;
10pub mod id;
11pub mod layout;
12pub mod math;
13pub mod render_commands;
14pub mod shader_build;
15pub mod shaders;
16pub mod text;
17pub mod text_input;
18pub mod renderer;
19#[cfg(feature = "text-styling")]
20pub mod text_styling;
21#[cfg(feature = "built-in-shaders")]
22pub mod built_in_shaders;
23#[cfg(feature = "net")]
24pub mod net;
25pub mod prelude;
26
27use id::Id;
28use math::{Dimensions, Vector2};
29use render_commands::RenderCommand;
30#[cfg(feature = "a11y")]
31use rustc_hash::FxHashMap;
32use text::TextConfig;
33
34pub use color::Color;
35
36#[allow(dead_code)]
37pub struct Ply<CustomElementData: Clone + Default + std::fmt::Debug = ()> {
38    context: engine::PlyContext<CustomElementData>,
39    headless: bool,
40    /// Key repeat tracking for text input control keys
41    text_input_repeat_key: u32,
42    text_input_repeat_first: f64,
43    text_input_repeat_last: f64,
44    /// Which element was focused when the current repeat started.
45    /// Used to clear stale repeat state on focus change.
46    text_input_repeat_focus_id: u32,
47    /// Track virtual keyboard state to avoid redundant show/hide calls
48    was_text_input_focused: bool,
49    #[cfg(all(feature = "a11y", target_arch = "wasm32"))]
50    web_a11y_state: accessibility_web::WebAccessibilityState,
51    #[cfg(all(feature = "a11y", not(target_arch = "wasm32")))]
52    native_a11y_state: accessibility_native::NativeAccessibilityState,
53}
54
55pub struct Ui<'ply, CustomElementData: Clone + Default + std::fmt::Debug = ()> {
56    ply: &'ply mut Ply<CustomElementData>,
57}
58
59/// Builder for creating elements with closure-based syntax.
60/// Methods return `self` by value for chaining. Finalize with `.children()` or `.empty()`.
61#[must_use]
62pub struct ElementBuilder<'ply, CustomElementData: Clone + Default + std::fmt::Debug = ()> {
63    ply: &'ply mut Ply<CustomElementData>,
64    inner: engine::ElementDeclaration<CustomElementData>,
65    id: Option<Id>,
66    on_hover_fn: Option<Box<dyn FnMut(Id, engine::PointerData) + 'static>>,
67    on_press_fn: Option<Box<dyn FnMut(Id, engine::PointerData) + 'static>>,
68    on_release_fn: Option<Box<dyn FnMut(Id, engine::PointerData) + 'static>>,
69    on_focus_fn: Option<Box<dyn FnMut(Id) + 'static>>,
70    on_unfocus_fn: Option<Box<dyn FnMut(Id) + 'static>>,
71    text_input_on_changed_fn: Option<Box<dyn FnMut(&str) + 'static>>,
72    text_input_on_submit_fn: Option<Box<dyn FnMut(&str) + 'static>>,
73}
74
75impl<'ply, CustomElementData: Clone + Default + std::fmt::Debug>
76    ElementBuilder<'ply, CustomElementData>
77{
78    /// Sets the width of the element.
79    #[inline]
80    pub fn width(mut self, width: layout::Sizing) -> Self {
81        self.inner.layout.sizing.width = width.into();
82        self
83    }
84
85    /// Sets the height of the element.
86    #[inline]
87    pub fn height(mut self, height: layout::Sizing) -> Self {
88        self.inner.layout.sizing.height = height.into();
89        self
90    }
91
92    /// Sets the background color of the element.
93    #[inline]
94    pub fn background_color(mut self, color: impl Into<Color>) -> Self {
95        self.inner.background_color = color.into();
96        self
97    }
98
99    /// Sets the corner radius.
100    /// Accepts `f32` (all corners) or `(f32, f32, f32, f32)` in CSS order (top-left, top-right, bottom-right, bottom-left).
101    #[inline]
102    pub fn corner_radius(mut self, radius: impl Into<layout::CornerRadius>) -> Self {
103        self.inner.corner_radius = radius.into();
104        self
105    }
106
107    /// Sets the element's ID.
108    ///
109    /// Accepts an `Id` or a `&'static str` label.
110    #[inline]
111    pub fn id(mut self, id: impl Into<Id>) -> Self {
112        self.id = Some(id.into());
113        self
114    }
115
116    /// Sets the aspect ratio of the element.
117    #[inline]
118    pub fn aspect_ratio(mut self, aspect_ratio: f32) -> Self {
119        self.inner.aspect_ratio = aspect_ratio;
120        self
121    }
122
123    /// Configures overflow (clip and scroll) properties.
124    #[inline]
125    pub fn overflow(mut self, f: impl for<'a> FnOnce(&'a mut elements::OverflowBuilder) -> &'a mut elements::OverflowBuilder) -> Self {
126        let mut builder = elements::OverflowBuilder { config: self.inner.clip };
127        f(&mut builder);
128        self.inner.clip = builder.config;
129        self
130    }
131
132    /// Sets custom element data.
133    #[inline]
134    pub fn custom_element(mut self, data: CustomElementData) -> Self {
135        self.inner.custom_data = Some(data);
136        self
137    }
138
139    /// Configures layout properties using a closure.
140    #[inline]
141    pub fn layout(mut self, f: impl for<'a> FnOnce(&'a mut layout::LayoutBuilder) -> &'a mut layout::LayoutBuilder) -> Self {
142        let mut builder = layout::LayoutBuilder { config: self.inner.layout };
143        f(&mut builder);
144        self.inner.layout = builder.config;
145        self
146    }
147
148    /// Configures floating properties using a closure.
149    #[inline]
150    pub fn floating(mut self, f: impl for<'a> FnOnce(&'a mut elements::FloatingBuilder) -> &'a mut elements::FloatingBuilder) -> Self {
151        let mut builder = elements::FloatingBuilder { config: self.inner.floating };
152        f(&mut builder);
153        self.inner.floating = builder.config;
154        self
155    }
156
157    /// Configures border properties using a closure.
158    #[inline]
159    pub fn border(mut self, f: impl for<'a> FnOnce(&'a mut elements::BorderBuilder) -> &'a mut elements::BorderBuilder) -> Self {
160        let mut builder = elements::BorderBuilder { config: self.inner.border };
161        f(&mut builder);
162        self.inner.border = builder.config;
163        self
164    }
165
166    /// Sets the image data for this element.
167    ///
168    /// Accepts anything that implements `Into<ImageSource>`:
169    /// - `&'static GraphicAsset`: static file path or embedded bytes
170    /// - `Texture2D`: pre-existing GPU texture handle
171    /// - `tinyvg::format::Image`: procedural TinyVG scene graph (requires `tinyvg` feature)
172    #[inline]
173    pub fn image(mut self, data: impl Into<renderer::ImageSource>) -> Self {
174        self.inner.image_data = Some(data.into());
175        self
176    }
177
178    /// Adds a per-element shader effect.
179    ///
180    /// The shader modifies the fragment output of the element's draw call directly.
181    /// Multiple `.effect()` calls are supported.
182    ///
183    /// # Example
184    /// ```rust,ignore
185    /// ui.element()
186    ///     .effect(&MY_SHADER, |s| s
187    ///         .uniform("time", time)
188    ///         .uniform("intensity", 0.5f32)
189    ///     )
190    ///     .empty();
191    /// ```
192    #[inline]
193    pub fn effect(mut self, asset: &shaders::ShaderAsset, f: impl FnOnce(&mut shaders::ShaderBuilder<'_>)) -> Self {
194        let mut builder = shaders::ShaderBuilder::new(asset);
195        f(&mut builder);
196        self.inner.effects.push(builder.into_config());
197        self
198    }
199
200    /// Adds a group shader that captures the lement and its children to an offscreen buffer,
201    /// then applies a fragment shader as a post-process.
202    ///
203    /// Multiple `.shader()` calls are supported, each adds a nesting level.
204    /// The first shader is applied innermost (directly to children), subsequent
205    /// shaders wrap earlier ones.
206    ///
207    /// # Example
208    /// ```rust,ignore
209    /// ui.element()
210    ///     .shader(&FOIL_EFFECT, |s| s
211    ///         .uniform("time", time)
212    ///         .uniform("seed", card_seed)
213    ///     )
214    ///     .children(|ui| {
215    ///         // All children captured to offscreen buffer
216    ///     });
217    /// ```
218    #[inline]
219    pub fn shader(mut self, asset: &shaders::ShaderAsset, f: impl FnOnce(&mut shaders::ShaderBuilder<'_>)) -> Self {
220        let mut builder = shaders::ShaderBuilder::new(asset);
221        f(&mut builder);
222        self.inner.shaders.push(builder.into_config());
223        self
224    }
225
226    /// Applies a visual rotation to the element and all its children.
227    ///
228    /// This renders the element to an offscreen buffer and draws it back with
229    /// rotation, flip, and pivot applied.
230    ///
231    /// It does not affect layout.
232    ///
233    /// When combined with `.shader()`, the rotation shares the same render
234    /// target (no extra GPU cost).
235    ///
236    /// # Example
237    /// ```rust,ignore
238    /// ui.element()
239    ///     .rotate_visual(|r| r
240    ///         .degrees(15.0)
241    ///         .pivot(0.5, 0.5)
242    ///         .flip_x()
243    ///     )
244    ///     .children(|ui| { /* ... */ });
245    /// ```
246    #[inline]
247    pub fn rotate_visual(mut self, f: impl for<'a> FnOnce(&'a mut elements::VisualRotationBuilder) -> &'a mut elements::VisualRotationBuilder) -> Self {
248        let mut builder = elements::VisualRotationBuilder {
249            config: engine::VisualRotationConfig::default(),
250        };
251        f(&mut builder);
252        self.inner.visual_rotation = Some(builder.config);
253        self
254    }
255
256    /// Applies vertex-level shape rotation to this element's geometry.
257    ///
258    /// Rotates the element's own rectangle / image / border at the vertex level
259    /// and adjusts its layout bounding box.
260    ///
261    /// Children, text, and shaders are **not** affected.
262    ///
263    /// There is no pivot.
264    ///
265    /// # Example
266    /// ```rust,ignore
267    /// ui.element()
268    ///     .rotate_shape(|r| r.degrees(45.0).flip_x())
269    ///     .empty();
270    /// ```
271    #[inline]
272    pub fn rotate_shape(mut self, f: impl for<'a> FnOnce(&'a mut elements::ShapeRotationBuilder) -> &'a mut elements::ShapeRotationBuilder) -> Self {
273        let mut builder = elements::ShapeRotationBuilder {
274            config: engine::ShapeRotationConfig::default(),
275        };
276        f(&mut builder);
277        self.inner.shape_rotation = Some(builder.config);
278        self
279    }
280
281    /// Configures accessibility properties and focus ring styling.
282    ///
283    /// # Example
284    /// ```rust,ignore
285    /// ui.element()
286    ///     .id("submit_btn")
287    ///     .accessibility(|a| a
288    ///         .button("Submit")
289    ///         .tab_index(1)
290    ///     )
291    ///     .empty();
292    /// ```
293    #[inline]
294    pub fn accessibility(
295        mut self,
296        f: impl for<'a> FnOnce(&'a mut accessibility::AccessibilityBuilder) -> &'a mut accessibility::AccessibilityBuilder,
297    ) -> Self {
298        let mut builder = accessibility::AccessibilityBuilder::new();
299        f(&mut builder);
300        self.inner.accessibility = Some(builder.config);
301        self
302    }
303
304    /// When set, clicking this element will not steal focus.
305    /// Use this for toolbar buttons that modify a text input's content without unfocusing it.
306    #[inline]
307    pub fn preserve_focus(mut self) -> Self {
308        self.inner.preserve_focus = true;
309        self
310    }
311
312    /// Registers a callback invoked every frame the pointer is over this element.
313    #[inline]
314    pub fn on_hover<F>(mut self, callback: F) -> Self
315    where
316        F: FnMut(Id, engine::PointerData) + 'static,
317    {
318        self.on_hover_fn = Some(Box::new(callback));
319        self
320    }
321
322    /// Registers a callback that fires once when the element is pressed
323    /// (pointer click or Enter/Space on focused element).
324    #[inline]
325    pub fn on_press<F>(mut self, callback: F) -> Self
326    where
327        F: FnMut(Id, engine::PointerData) + 'static,
328    {
329        self.on_press_fn = Some(Box::new(callback));
330        self
331    }
332
333    /// Registers a callback that fires once when the element is released
334    /// (pointer release or key release on focused element).
335    #[inline]
336    pub fn on_release<F>(mut self, callback: F) -> Self
337    where
338        F: FnMut(Id, engine::PointerData) + 'static,
339    {
340        self.on_release_fn = Some(Box::new(callback));
341        self
342    }
343
344    /// Registers a callback that fires when this element receives focus
345    /// (via Tab navigation, arrow keys, or programmatic `set_focus`).
346    #[inline]
347    pub fn on_focus<F>(mut self, callback: F) -> Self
348    where
349        F: FnMut(Id) + 'static,
350    {
351        self.on_focus_fn = Some(Box::new(callback));
352        self
353    }
354
355    /// Registers a callback that fires when this element loses focus.
356    #[inline]
357    pub fn on_unfocus<F>(mut self, callback: F) -> Self
358    where
359        F: FnMut(Id) + 'static,
360    {
361        self.on_unfocus_fn = Some(Box::new(callback));
362        self
363    }
364
365    /// Configures this element as a text input.
366    ///
367    /// The element will capture keyboard input when focused and render
368    /// text, cursor, and selection internally.
369    ///
370    /// # Example
371    /// ```rust,ignore
372    /// ui.element()
373    ///     .id("username")
374    ///     .text_input(|t| t
375    ///         .placeholder("Enter username")
376    ///         .max_length(32)
377    ///         .font_size(18)
378    ///         .on_changed(|text| println!("Text changed: {}", text))
379    ///         .on_submit(|text| println!("Submitted: {}", text))
380    ///     )
381    ///     .empty();
382    /// ```
383    #[inline]
384    pub fn text_input(
385        mut self,
386        f: impl for<'a> FnOnce(&'a mut text_input::TextInputBuilder) -> &'a mut text_input::TextInputBuilder,
387    ) -> Self {
388        let mut builder = text_input::TextInputBuilder::new();
389        f(&mut builder);
390        self.inner.text_input = Some(builder.config);
391        self.text_input_on_changed_fn = builder.on_changed_fn;
392        self.text_input_on_submit_fn = builder.on_submit_fn;
393        self
394    }
395
396    /// Finalizes the element with children defined in a closure.
397    pub fn children(self, f: impl FnOnce(&mut Ui<'_, CustomElementData>)) -> Id {
398        let ElementBuilder {
399            ply, inner, id,
400            on_hover_fn, on_press_fn, on_release_fn, on_focus_fn, on_unfocus_fn,
401            text_input_on_changed_fn, text_input_on_submit_fn,
402        } = self;
403        if let Some(ref id) = id {
404            ply.context.open_element_with_id(id);
405        } else {
406            ply.context.open_element();
407        }
408        ply.context.configure_open_element(&inner);
409        let element_id = ply.context.get_open_element_id();
410
411        if let Some(hover_fn) = on_hover_fn {
412            ply.context.on_hover(hover_fn);
413        }
414        if on_press_fn.is_some() || on_release_fn.is_some() {
415            ply.context.set_press_callbacks(on_press_fn, on_release_fn);
416        }
417        if on_focus_fn.is_some() || on_unfocus_fn.is_some() {
418            ply.context.set_focus_callbacks(on_focus_fn, on_unfocus_fn);
419        }
420        if text_input_on_changed_fn.is_some() || text_input_on_submit_fn.is_some() {
421            ply.context.set_text_input_callbacks(text_input_on_changed_fn, text_input_on_submit_fn);
422        }
423
424        let mut ui = Ui { ply };
425        f(&mut ui);
426        ui.ply.context.close_element();
427
428        Id { id: element_id, ..Default::default() }
429    }
430
431    /// Finalizes the element with no children.
432    pub fn empty(self) -> Id {
433        self.children(|_| {})
434    }
435}
436
437impl<'ply, CustomElementData: Clone + Default + std::fmt::Debug> core::ops::Deref
438    for Ui<'ply, CustomElementData>
439{
440    type Target = Ply<CustomElementData>;
441
442    fn deref(&self) -> &Self::Target {
443        self.ply
444    }
445}
446
447impl<'ply, CustomElementData: Clone + Default + std::fmt::Debug> core::ops::DerefMut
448    for Ui<'ply, CustomElementData>
449{
450    fn deref_mut(&mut self) -> &mut Self::Target {
451        self.ply
452    }
453}
454
455impl<'ply, CustomElementData: Clone + Default + std::fmt::Debug> Ui<'ply, CustomElementData> {
456    /// Creates a new element builder for configuring and adding an element.
457    /// Finalize with `.children(|ui| {...})` or `.empty()`.
458    pub fn element(&mut self) -> ElementBuilder<'_, CustomElementData> {
459        ElementBuilder {
460            ply: &mut *self.ply,
461            inner: engine::ElementDeclaration::default(),
462            id: None,
463            on_hover_fn: None,
464            on_press_fn: None,
465            on_release_fn: None,
466            on_focus_fn: None,
467            on_unfocus_fn: None,
468            text_input_on_changed_fn: None,
469            text_input_on_submit_fn: None,
470        }
471    }
472
473    /// Adds a text element to the current open element or to the root layout.
474    pub fn text(&mut self, text: &str, config_fn: impl FnOnce(&mut TextConfig) -> &mut TextConfig) {
475        let mut config = TextConfig::new();
476        config_fn(&mut config);
477        let text_config_index = self.ply.context.store_text_element_config(config);
478        self.ply.context.open_text_element(text, text_config_index);
479    }
480
481    /// Returns the current scroll offset of the open scroll container.
482    pub fn scroll_offset(&self) -> Vector2 {
483        self.ply.context.get_scroll_offset()
484    }
485
486    /// Returns if the current element you are creating is hovered
487    pub fn hovered(&self) -> bool {
488        self.ply.context.hovered()
489    }
490
491    /// Returns if the current element you are creating is pressed
492    /// (pointer held down on it, or Enter/Space held on focused element)
493    pub fn pressed(&self) -> bool {
494        self.ply.context.pressed()
495    }
496
497    /// Returns if the current element you are creating has focus.
498    pub fn focused(&self) -> bool {
499        self.ply.context.focused()
500    }
501}
502
503impl<CustomElementData: Clone + Default + std::fmt::Debug> Ply<CustomElementData> {
504    #[cfg(feature = "a11y")]
505    fn accessibility_bounds(&self) -> FxHashMap<u32, math::BoundingBox> {
506        let mut accessibility_bounds = FxHashMap::default();
507        for &elem_id in &self.context.accessibility_element_order {
508            if let Some(bounds) = self.context.get_element_data(Id {
509                id: elem_id,
510                ..Default::default()
511            }) {
512                accessibility_bounds.insert(elem_id, bounds);
513            }
514        }
515        accessibility_bounds
516    }
517
518    /// Starts a new frame, returning a [`Ui`] handle for building the element tree.
519    pub fn begin(
520        &mut self,
521    ) -> Ui<'_, CustomElementData> {
522        if !self.headless {
523            self.context.set_layout_dimensions(Dimensions::new(
524                macroquad::prelude::screen_width(),
525                macroquad::prelude::screen_height(),
526            ));
527
528            // Update timing
529            self.context.current_time = macroquad::prelude::get_time();
530            self.context.frame_delta_time = macroquad::prelude::get_frame_time();
531        }
532
533        // Update blink timers for text inputs
534        self.context.update_text_input_blink_timers();
535
536        // Auto-update pointer state from macroquad
537        if !self.headless {
538            let (mx, my) = macroquad::prelude::mouse_position();
539            let is_down = macroquad::prelude::is_mouse_button_down(
540                macroquad::prelude::MouseButton::Left,
541            );
542
543            // Check shift state for text input click-to-cursor
544            // Must happen AFTER set_pointer_state, since that's what creates pending_text_click.
545            self.context.set_pointer_state(Vector2::new(mx, my), is_down);
546
547            {
548                use macroquad::prelude::{is_key_down, KeyCode};
549                let shift = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift);
550                if shift {
551                    // If shift is held and there's a pending text click, update it
552                    if let Some(ref mut pending) = self.context.pending_text_click {
553                        pending.3 = true;
554                    }
555                }
556            }
557
558            let (scroll_x, scroll_y) = macroquad::prelude::mouse_wheel();
559            #[cfg(target_arch = "wasm32")]
560            const SCROLL_SPEED: f32 = 1.0;
561            #[cfg(not(target_arch = "wasm32"))]
562            const SCROLL_SPEED: f32 = 20.0;
563            // Shift+scroll wheel swaps vertical to horizontal scrolling
564            let scroll_shift = {
565                use macroquad::prelude::{is_key_down, KeyCode};
566                is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift)
567            };
568            let scroll_delta = if scroll_shift {
569                // Shift held: vertical scroll becomes horizontal
570                Vector2::new(
571                    (scroll_x + scroll_y) * SCROLL_SPEED,
572                    0.0,
573                )
574            } else {
575                Vector2::new(scroll_x * SCROLL_SPEED, scroll_y * SCROLL_SPEED)
576            };
577
578            // Text input pointer scrolling (scroll wheel + drag) — consumes scroll if applicable
579            let text_consumed_scroll = self.context.update_text_input_pointer_scroll(scroll_delta);
580            self.context.clamp_text_input_scroll();
581
582            // Only pass scroll to scroll containers if text input didn't consume it
583            let container_scroll = if text_consumed_scroll {
584                Vector2::new(0.0, 0.0)
585            } else {
586                scroll_delta
587            };
588            self.context.update_scroll_containers(
589                true,
590                container_scroll,
591                macroquad::prelude::get_frame_time(),
592            );
593
594            // Keyboard input handling
595            use macroquad::prelude::{is_key_pressed, is_key_down, is_key_released, KeyCode};
596
597            let text_input_focused = self.context.is_text_input_focused();
598            let current_focused_id = self.context.focused_element_id;
599
600            // Clear key-repeat state when focus changes (prevents stale
601            // repeat from one text input bleeding into another).
602            if current_focused_id != self.text_input_repeat_focus_id {
603                self.text_input_repeat_key = 0;
604                self.text_input_repeat_focus_id = current_focused_id;
605            }
606
607            // Tab always cycles focus (even when text input is focused)
608            if is_key_pressed(KeyCode::Tab) {
609                let shift = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift);
610                self.context.cycle_focus(shift);
611            } else if text_input_focused {
612                // Route keyboard input to text editing
613                let shift = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift);
614                let ctrl = is_key_down(KeyCode::LeftControl) || is_key_down(KeyCode::RightControl);
615                let time = self.context.current_time;
616
617                // Key repeat constants
618                const INITIAL_DELAY: f64 = 0.5;
619                const REPEAT_INTERVAL: f64 = 0.033;
620
621                // Helper: check if a key should fire (pressed or repeating)
622                macro_rules! key_fires {
623                    ($key:expr, $id:expr) => {{
624                        if is_key_pressed($key) {
625                            self.text_input_repeat_key = $id;
626                            self.text_input_repeat_first = time;
627                            self.text_input_repeat_last = time;
628                            true
629                        } else if is_key_down($key) && self.text_input_repeat_key == $id {
630                            let since_first = time - self.text_input_repeat_first;
631                            let since_last = time - self.text_input_repeat_last;
632                            if since_first > INITIAL_DELAY && since_last > REPEAT_INTERVAL {
633                                self.text_input_repeat_last = time;
634                                true
635                            } else {
636                                false
637                            }
638                        } else {
639                            false
640                        }
641                    }};
642                }
643
644                // Handle special keys with repeat support
645                let mut cursor_moved = false;
646                if key_fires!(KeyCode::Left, 1) {
647                    if ctrl {
648                        self.context.process_text_input_action(engine::TextInputAction::MoveWordLeft { shift });
649                    } else {
650                        self.context.process_text_input_action(engine::TextInputAction::MoveLeft { shift });
651                    }
652                    cursor_moved = true;
653                }
654                if key_fires!(KeyCode::Right, 2) {
655                    if ctrl {
656                        self.context.process_text_input_action(engine::TextInputAction::MoveWordRight { shift });
657                    } else {
658                        self.context.process_text_input_action(engine::TextInputAction::MoveRight { shift });
659                    }
660                    cursor_moved = true;
661                }
662                if key_fires!(KeyCode::Backspace, 3) {
663                    if ctrl {
664                        self.context.process_text_input_action(engine::TextInputAction::BackspaceWord);
665                    } else {
666                        self.context.process_text_input_action(engine::TextInputAction::Backspace);
667                    }
668                    cursor_moved = true;
669                }
670                if key_fires!(KeyCode::Delete, 4) {
671                    if ctrl {
672                        self.context.process_text_input_action(engine::TextInputAction::DeleteWord);
673                    } else {
674                        self.context.process_text_input_action(engine::TextInputAction::Delete);
675                    }
676                    cursor_moved = true;
677                }
678                if key_fires!(KeyCode::Home, 5) {
679                    self.context.process_text_input_action(engine::TextInputAction::MoveHome { shift });
680                    cursor_moved = true;
681                }
682                if key_fires!(KeyCode::End, 6) {
683                    self.context.process_text_input_action(engine::TextInputAction::MoveEnd { shift });
684                    cursor_moved = true;
685                }
686
687                // Up/Down arrows for multiline
688                if self.context.is_focused_text_input_multiline() {
689                    if key_fires!(KeyCode::Up, 7) {
690                        self.context.process_text_input_action(engine::TextInputAction::MoveUp { shift });
691                        cursor_moved = true;
692                    }
693                    if key_fires!(KeyCode::Down, 8) {
694                        self.context.process_text_input_action(engine::TextInputAction::MoveDown { shift });
695                        cursor_moved = true;
696                    }
697                }
698
699                // Non-repeating keys
700                if is_key_pressed(KeyCode::Enter) {
701                    self.context.process_text_input_action(engine::TextInputAction::Submit);
702                    cursor_moved = true;
703                }
704                if ctrl && is_key_pressed(KeyCode::A) {
705                    self.context.process_text_input_action(engine::TextInputAction::SelectAll);
706                }
707                if ctrl && is_key_pressed(KeyCode::Z) {
708                    if shift {
709                        self.context.process_text_input_action(engine::TextInputAction::Redo);
710                    } else {
711                        self.context.process_text_input_action(engine::TextInputAction::Undo);
712                    }
713                    cursor_moved = true;
714                }
715                if ctrl && is_key_pressed(KeyCode::Y) {
716                    self.context.process_text_input_action(engine::TextInputAction::Redo);
717                    cursor_moved = true;
718                }
719                if ctrl && is_key_pressed(KeyCode::C) {
720                    // Copy selected text to clipboard
721                    let elem_id = self.context.focused_element_id;
722                    if let Some(state) = self.context.text_edit_states.get(&elem_id) {
723                        #[cfg(feature = "text-styling")]
724                        let selected = state.selected_text_styled();
725                        #[cfg(not(feature = "text-styling"))]
726                        let selected = state.selected_text().to_string();
727                        if !selected.is_empty() {
728                            macroquad::miniquad::window::clipboard_set(&selected);
729                        }
730                    }
731                }
732                if ctrl && is_key_pressed(KeyCode::X) {
733                    // Cut: copy then delete selection
734                    let elem_id = self.context.focused_element_id;
735                    if let Some(state) = self.context.text_edit_states.get(&elem_id) {
736                        #[cfg(feature = "text-styling")]
737                        let selected = state.selected_text_styled();
738                        #[cfg(not(feature = "text-styling"))]
739                        let selected = state.selected_text().to_string();
740                        if !selected.is_empty() {
741                            macroquad::miniquad::window::clipboard_set(&selected);
742                        }
743                    }
744                    self.context.process_text_input_action(engine::TextInputAction::Cut);
745                    cursor_moved = true;
746                }
747                if ctrl && is_key_pressed(KeyCode::V) {
748                    // Paste from clipboard
749                    if let Some(text) = macroquad::miniquad::window::clipboard_get() {
750                        self.context.process_text_input_action(engine::TextInputAction::Paste { text });
751                        cursor_moved = true;
752                    }
753                }
754
755                // Escape unfocuses the text input
756                if is_key_pressed(KeyCode::Escape) {
757                    self.context.clear_focus();
758                }
759
760                // Clear repeat state if the tracked key was released
761                if self.text_input_repeat_key != 0 {
762                    let still_down = match self.text_input_repeat_key {
763                        1 => is_key_down(KeyCode::Left),
764                        2 => is_key_down(KeyCode::Right),
765                        3 => is_key_down(KeyCode::Backspace),
766                        4 => is_key_down(KeyCode::Delete),
767                        5 => is_key_down(KeyCode::Home),
768                        6 => is_key_down(KeyCode::End),
769                        7 => is_key_down(KeyCode::Up),
770                        8 => is_key_down(KeyCode::Down),
771                        _ => false,
772                    };
773                    if !still_down {
774                        self.text_input_repeat_key = 0;
775                    }
776                }
777
778                // Drain character input queue
779                while let Some(ch) = macroquad::prelude::get_char_pressed() {
780                    // Filter out control characters and Ctrl-key combos
781                    if !ch.is_control() && !ctrl {
782                        self.context.process_text_input_char(ch);
783                        cursor_moved = true;
784                    }
785                }
786
787                // Update scroll to keep cursor visible (only when cursor moved, not every frame,
788                // so that manual scrolling via scroll wheel / drag isn't immediately undone).
789                if cursor_moved {
790                    self.context.update_text_input_scroll();
791                }
792                self.context.clamp_text_input_scroll();
793            } else {
794                // Normal keyboard navigation (non-text-input)
795                if is_key_pressed(KeyCode::Right) { self.context.arrow_focus(engine::ArrowDirection::Right); }
796                if is_key_pressed(KeyCode::Left)  { self.context.arrow_focus(engine::ArrowDirection::Left); }
797                if is_key_pressed(KeyCode::Up)    { self.context.arrow_focus(engine::ArrowDirection::Up); }
798                if is_key_pressed(KeyCode::Down)  { self.context.arrow_focus(engine::ArrowDirection::Down); }
799
800                let activate_pressed = is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::Space);
801                let activate_released = is_key_released(KeyCode::Enter) || is_key_released(KeyCode::Space);
802                self.context.handle_keyboard_activation(activate_pressed, activate_released);
803            }
804        }
805
806        // Show/hide virtual keyboard when text input focus changes (mobile)
807        {
808            let text_input_focused = self.context.is_text_input_focused();
809            if text_input_focused != self.was_text_input_focused {
810                #[cfg(not(any(target_arch = "wasm32", target_os = "linux")))]
811                {
812                    macroquad::miniquad::window::show_keyboard(text_input_focused);
813                }
814                #[cfg(target_arch = "wasm32")]
815                {
816                    unsafe { ply_show_virtual_keyboard(text_input_focused); }
817                }
818                self.was_text_input_focused = text_input_focused;
819            }
820        }
821
822        self.context.begin_layout();
823        Ui {
824            ply: self,
825        }
826    }
827
828    /// Create a new Ply engine with the given default font.
829    pub async fn new(default_font: &'static renderer::FontAsset) -> Self {
830        renderer::FontManager::load_default(default_font).await;
831
832        let dimensions = Dimensions::new(
833            macroquad::prelude::screen_width(),
834            macroquad::prelude::screen_height(),
835        );
836        let mut ply = Self {
837            context: engine::PlyContext::new(dimensions),
838            headless: false,
839            text_input_repeat_key: 0,
840            text_input_repeat_first: 0.0,
841            text_input_repeat_last: 0.0,
842            text_input_repeat_focus_id: 0,
843            was_text_input_focused: false,
844            #[cfg(all(feature = "a11y", target_arch = "wasm32"))]
845            web_a11y_state: accessibility_web::WebAccessibilityState::default(),
846            #[cfg(all(feature = "a11y", not(target_arch = "wasm32")))]
847            native_a11y_state: accessibility_native::NativeAccessibilityState::default(),
848        };
849        ply.context.default_font_key = default_font.key();
850        ply.set_measure_text_function(renderer::create_measure_text_function());
851        ply
852    }
853
854    /// Create a new Ply engine without text measurement.
855    ///
856    /// Use [`Ply::set_measure_text_function`] to configure text measurement
857    /// before rendering any text elements.
858    pub fn new_headless(dimensions: Dimensions) -> Self {
859        Self {
860            context: engine::PlyContext::new(dimensions),
861            headless: true,
862            text_input_repeat_key: 0,
863            text_input_repeat_first: 0.0,
864            text_input_repeat_last: 0.0,
865            text_input_repeat_focus_id: 0,
866            was_text_input_focused: false,
867            #[cfg(all(feature = "a11y", target_arch = "wasm32"))]
868            web_a11y_state: accessibility_web::WebAccessibilityState::default(),
869            #[cfg(all(feature = "a11y", not(target_arch = "wasm32")))]
870            native_a11y_state: accessibility_native::NativeAccessibilityState::default(),
871        }
872    }
873
874    /// Returns `true` if the pointer is currently over the element with the given ID.
875    pub fn pointer_over(&self, cfg: impl Into<Id>) -> bool {
876        self.context.pointer_over(cfg.into())
877    }
878
879    /// Z-sorted list of element IDs that the cursor is currently over
880    pub fn pointer_over_ids(&self) -> Vec<Id> {
881        self.context.get_pointer_over_ids().to_vec()
882    }
883
884    /// Set the callback for text measurement
885    pub fn set_measure_text_function<F>(&mut self, callback: F)
886    where
887        F: Fn(&str, &TextConfig) -> Dimensions + 'static,
888    {
889        self.context.set_measure_text_function(Box::new(
890            move |text: &str, config: &TextConfig| -> Dimensions {
891                callback(text, config)
892            },
893        ));
894    }
895
896    /// Sets the maximum number of elements that ply supports
897    /// **Use only if you know what you are doing or you're getting errors from ply**
898    pub fn max_element_count(&mut self, max_element_count: u32) {
899        self.context.set_max_element_count(max_element_count as i32);
900    }
901
902    /// Sets the capacity of the cache used for text in the measure text function
903    /// **Use only if you know what you are doing or you're getting errors from ply**
904    pub fn max_measure_text_cache_word_count(&mut self, count: u32) {
905        self.context.set_max_measure_text_cache_word_count(count as i32);
906    }
907
908    /// Enables or disables the debug mode of ply
909    pub fn set_debug_mode(&mut self, enable: bool) {
910        self.context.set_debug_mode_enabled(enable);
911    }
912
913    /// Returns if debug mode is enabled
914    pub fn is_debug_mode(&self) -> bool {
915        self.context.is_debug_mode_enabled()
916    }
917
918    /// Enables or disables culling
919    pub fn set_culling(&mut self, enable: bool) {
920        self.context.set_culling_enabled(enable);
921    }
922
923    /// Sets the dimensions of the global layout.
924    /// Use if, for example the window size you render changed.
925    pub fn set_layout_dimensions(&mut self, dimensions: Dimensions) {
926        self.context.set_layout_dimensions(dimensions);
927    }
928
929    /// Updates the state of the pointer for ply.
930    /// Used to update scroll containers and for interactions functions.
931    pub fn pointer_state(&mut self, position: Vector2, is_down: bool) {
932        self.context.set_pointer_state(position, is_down);
933    }
934
935    /// Processes scroll containers using the current pointer state and scroll delta.
936    pub fn update_scroll_containers(
937        &mut self,
938        drag_scrolling_enabled: bool,
939        scroll_delta: Vector2,
940        delta_time: f32,
941    ) {
942        self.context
943            .update_scroll_containers(drag_scrolling_enabled, scroll_delta, delta_time);
944    }
945
946    /// Returns the ID of the currently focused element, or None.
947    pub fn focused_element(&self) -> Option<Id> {
948        self.context.focused_element()
949    }
950
951    /// Sets focus to the element with the given ID.
952    pub fn set_focus(&mut self, id: impl Into<Id>) {
953        self.context.set_focus(id.into().id);
954    }
955
956    /// Clears focus (no element is focused).
957    pub fn clear_focus(&mut self) {
958        self.context.clear_focus();
959    }
960
961    /// Returns the text value of a text input element.
962    /// Returns an empty string if the element is not a text input or doesn't exist.
963    pub fn get_text_value(&self, id: impl Into<Id>) -> &str {
964        self.context.get_text_value(id.into().id)
965    }
966
967    /// Sets the text value of a text input element.
968    pub fn set_text_value(&mut self, id: impl Into<Id>, value: &str) {
969        self.context.set_text_value(id.into().id, value);
970    }
971
972    /// Returns the cursor position of a text input element.
973    /// Returns 0 if the element is not a text input or doesn't exist.
974    pub fn get_cursor_pos(&self, id: impl Into<Id>) -> usize {
975        self.context.get_cursor_pos(id.into().id)
976    }
977
978    /// Sets the cursor position of a text input element.
979    /// Clamps to the text length and clears any selection.
980    pub fn set_cursor_pos(&mut self, id: impl Into<Id>, pos: usize) {
981        self.context.set_cursor_pos(id.into().id, pos);
982    }
983
984    /// Returns the selection range (start, end) for a text input element, or None.
985    pub fn get_selection_range(&self, id: impl Into<Id>) -> Option<(usize, usize)> {
986        self.context.get_selection_range(id.into().id)
987    }
988
989    /// Sets the selection range for a text input element.
990    /// `anchor` is where selection started, `cursor` is where it ends.
991    pub fn set_selection(&mut self, id: impl Into<Id>, anchor: usize, cursor: usize) {
992        self.context.set_selection(id.into().id, anchor, cursor);
993    }
994
995    /// Returns true if the given element is currently pressed.
996    pub fn is_pressed(&self, id: impl Into<Id>) -> bool {
997        self.context.is_element_pressed(id.into().id)
998    }
999
1000    /// Returns the bounding box of the element with the given ID, if it exists.
1001    pub fn bounding_box(&self, id: impl Into<Id>) -> Option<math::BoundingBox> {
1002        self.context.get_element_data(id.into())
1003    }
1004
1005    /// Returns scroll container state for the element with the given ID, if it is a scroll container.
1006    pub fn scroll_container_data(&self, id: impl Into<Id>) -> Option<engine::ScrollContainerData> {
1007        let data = self.context.get_scroll_container_data(id.into());
1008        if data.found {
1009            Some(data)
1010        } else {
1011            None
1012        }
1013    }
1014
1015    /// Evaluate the layout and return all render commands.
1016    pub fn eval(&mut self) -> Vec<RenderCommand<CustomElementData>> {
1017        // Clean up stale networking entries (feature-gated)
1018        #[cfg(feature = "net")]
1019        net::NET_MANAGER.lock().unwrap().clean();
1020
1021        let commands = self.context.end_layout();
1022        let mut result = Vec::new();
1023        for cmd in commands {
1024            result.push(RenderCommand::from_engine_render_command(cmd));
1025        }
1026
1027        // Sync the hidden DOM accessibility tree (web/WASM only)
1028        #[cfg(all(feature = "a11y", target_arch = "wasm32"))]
1029        {
1030            let accessibility_bounds = self.accessibility_bounds();
1031
1032            accessibility_web::sync_accessibility_tree(
1033                &mut self.web_a11y_state,
1034                &self.context.accessibility_configs,
1035                &accessibility_bounds,
1036                &self.context.accessibility_element_order,
1037                self.context.focused_element_id,
1038                self.context.layout_dimensions,
1039            );
1040        }
1041
1042        // Sync accessibility tree via AccessKit (native platforms)
1043        #[cfg(all(feature = "a11y", not(target_arch = "wasm32")))]
1044        {
1045            let accessibility_bounds = self.accessibility_bounds();
1046
1047            let a11y_actions = accessibility_native::sync_accessibility_tree(
1048                &mut self.native_a11y_state,
1049                &self.context.accessibility_configs,
1050                &accessibility_bounds,
1051                &self.context.accessibility_element_order,
1052                self.context.focused_element_id,
1053                self.context.layout_dimensions,
1054            );
1055            for action in a11y_actions {
1056                match action {
1057                    accessibility_native::PendingA11yAction::Focus(target_id) => {
1058                        self.context.change_focus(target_id);
1059                    }
1060                    accessibility_native::PendingA11yAction::Click(target_id) => {
1061                        self.context.fire_press(target_id);
1062                    }
1063                }
1064            }
1065        }
1066
1067        result
1068    }
1069
1070    /// Evaluate the layout and render all commands.
1071    pub async fn show(
1072        &mut self,
1073        handle_custom_command: impl Fn(&RenderCommand<CustomElementData>),
1074    ) {
1075        let commands = self.eval();
1076        renderer::render(commands, handle_custom_command).await;
1077    }
1078}
1079
1080#[cfg(target_arch = "wasm32")]
1081extern "C" {
1082    fn ply_show_virtual_keyboard(show: bool);
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088    use color::Color;
1089    use layout::{Padding, Sizing};
1090
1091    #[rustfmt::skip]
1092    #[test]
1093    fn test_begin() {
1094        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1095
1096        ply.set_measure_text_function(|_, _| {
1097            Dimensions::new(100.0, 24.0)
1098        });
1099
1100        let mut ui = ply.begin();
1101
1102        ui.element().width(fixed!(100.0)).height(fixed!(100.0))
1103            .background_color(0xFFFFFF)
1104            .children(|ui| {
1105                ui.element().width(fixed!(100.0)).height(fixed!(100.0))
1106                    .background_color(0xFFFFFF)
1107                    .children(|ui| {
1108                        ui.element().width(fixed!(100.0)).height(fixed!(100.0))
1109                            .background_color(0xFFFFFF)
1110                            .children(|ui| {
1111                                ui.text("test", |t| t
1112                                    .color(0xFFFFFF)
1113                                    .font_size(24)
1114                                );
1115                            });
1116                    });
1117            });
1118
1119        ui.element()
1120            .border(|b| b
1121                .color(0xFFFF00)
1122                .all(2)
1123            )
1124            .corner_radius(10.0)
1125            .children(|ui| {
1126                ui.element().width(fixed!(50.0)).height(fixed!(50.0))
1127                    .background_color(0x00FFFF)
1128                    .empty();
1129            });
1130
1131        let items = ui.eval();
1132
1133        for item in &items {
1134            println!(
1135                "id: {}\nbbox: {:?}\nconfig: {:?}",
1136                item.id, item.bounding_box, item.config,
1137            );
1138        }
1139
1140        assert_eq!(items.len(), 6);
1141        
1142        assert_eq!(items[0].bounding_box.x, 0.0);
1143        assert_eq!(items[0].bounding_box.y, 0.0);
1144        assert_eq!(items[0].bounding_box.width, 100.0);
1145        assert_eq!(items[0].bounding_box.height, 100.0);
1146        match &items[0].config {
1147            render_commands::RenderCommandConfig::Rectangle(rect) => {
1148                assert_eq!(rect.color.r, 255.0);
1149                assert_eq!(rect.color.g, 255.0);
1150                assert_eq!(rect.color.b, 255.0);
1151                assert_eq!(rect.color.a, 255.0);
1152            }
1153            _ => panic!("Expected Rectangle config for item 0"),
1154        }
1155        
1156        assert_eq!(items[1].bounding_box.x, 0.0);
1157        assert_eq!(items[1].bounding_box.y, 0.0);
1158        assert_eq!(items[1].bounding_box.width, 100.0);
1159        assert_eq!(items[1].bounding_box.height, 100.0);
1160        match &items[1].config {
1161            render_commands::RenderCommandConfig::Rectangle(rect) => {
1162                assert_eq!(rect.color.r, 255.0);
1163                assert_eq!(rect.color.g, 255.0);
1164                assert_eq!(rect.color.b, 255.0);
1165                assert_eq!(rect.color.a, 255.0);
1166            }
1167            _ => panic!("Expected Rectangle config for item 1"),
1168        }
1169        
1170        assert_eq!(items[2].bounding_box.x, 0.0);
1171        assert_eq!(items[2].bounding_box.y, 0.0);
1172        assert_eq!(items[2].bounding_box.width, 100.0);
1173        assert_eq!(items[2].bounding_box.height, 100.0);
1174        match &items[2].config {
1175            render_commands::RenderCommandConfig::Rectangle(rect) => {
1176                assert_eq!(rect.color.r, 255.0);
1177                assert_eq!(rect.color.g, 255.0);
1178                assert_eq!(rect.color.b, 255.0);
1179                assert_eq!(rect.color.a, 255.0);
1180            }
1181            _ => panic!("Expected Rectangle config for item 2"),
1182        }
1183        
1184        assert_eq!(items[3].bounding_box.x, 0.0);
1185        assert_eq!(items[3].bounding_box.y, 0.0);
1186        assert_eq!(items[3].bounding_box.width, 100.0);
1187        assert_eq!(items[3].bounding_box.height, 24.0);
1188        match &items[3].config {
1189            render_commands::RenderCommandConfig::Text(text) => {
1190                assert_eq!(text.text, "test");
1191                assert_eq!(text.color.r, 255.0);
1192                assert_eq!(text.color.g, 255.0);
1193                assert_eq!(text.color.b, 255.0);
1194                assert_eq!(text.color.a, 255.0);
1195                assert_eq!(text.font_size, 24);
1196            }
1197            _ => panic!("Expected Text config for item 3"),
1198        }
1199        
1200        assert_eq!(items[4].bounding_box.x, 100.0);
1201        assert_eq!(items[4].bounding_box.y, 0.0);
1202        assert_eq!(items[4].bounding_box.width, 50.0);
1203        assert_eq!(items[4].bounding_box.height, 50.0);
1204        match &items[4].config {
1205            render_commands::RenderCommandConfig::Rectangle(rect) => {
1206                assert_eq!(rect.color.r, 0.0);
1207                assert_eq!(rect.color.g, 255.0);
1208                assert_eq!(rect.color.b, 255.0);
1209                assert_eq!(rect.color.a, 255.0);
1210            }
1211            _ => panic!("Expected Rectangle config for item 4"),
1212        }
1213        
1214        assert_eq!(items[5].bounding_box.x, 100.0);
1215        assert_eq!(items[5].bounding_box.y, 0.0);
1216        assert_eq!(items[5].bounding_box.width, 50.0);
1217        assert_eq!(items[5].bounding_box.height, 50.0);
1218        match &items[5].config {
1219            render_commands::RenderCommandConfig::Border(border) => {
1220                assert_eq!(border.color.r, 255.0);
1221                assert_eq!(border.color.g, 255.0);
1222                assert_eq!(border.color.b, 0.0);
1223                assert_eq!(border.color.a, 255.0);
1224                assert_eq!(border.corner_radii.top_left, 10.0);
1225                assert_eq!(border.corner_radii.top_right, 10.0);
1226                assert_eq!(border.corner_radii.bottom_left, 10.0);
1227                assert_eq!(border.corner_radii.bottom_right, 10.0);
1228                assert_eq!(border.width.left, 2);
1229                assert_eq!(border.width.right, 2);
1230                assert_eq!(border.width.top, 2);
1231                assert_eq!(border.width.bottom, 2);
1232            }
1233            _ => panic!("Expected Border config for item 5"),
1234        }
1235    }
1236
1237    #[rustfmt::skip]
1238    #[test]
1239    fn test_example() {
1240        let mut ply = Ply::<()>::new_headless(Dimensions::new(1000.0, 1000.0));
1241
1242        let mut ui = ply.begin();
1243
1244        ui.set_measure_text_function(|_, _| {
1245            Dimensions::new(100.0, 24.0)
1246        });
1247
1248        for &(label, level) in &[("Road", 1), ("Wall", 2), ("Tower", 3)] {
1249            ui.element().width(grow!()).height(fixed!(36.0))
1250                .layout(|l| l
1251                    .direction(crate::layout::LayoutDirection::LeftToRight)
1252                    .gap(12)
1253                    .align(crate::align::AlignX::Left, crate::align::AlignY::CenterY)
1254                )
1255                .children(|ui| {
1256                    ui.text(label, |t| t
1257                        .font_size(18)
1258                        .color(0xFFFFFF)
1259                    );
1260                    ui.element().width(grow!()).height(fixed!(18.0))
1261                        .corner_radius(9.0)
1262                        .background_color(0x555555)
1263                        .children(|ui| {
1264                            ui.element()
1265                                .width(fixed!(300.0 * level as f32 / 3.0))
1266                                .height(grow!())
1267                                .corner_radius(9.0)
1268                                .background_color(0x45A85A)
1269                                .empty();
1270                        });
1271                });
1272        }
1273
1274        let items = ui.eval();
1275
1276        for item in &items {
1277            println!(
1278                "id: {}\nbbox: {:?}\nconfig: {:?}",
1279                item.id, item.bounding_box, item.config,
1280            );
1281        }
1282
1283        assert_eq!(items.len(), 9);
1284
1285        // Road label
1286        assert_eq!(items[0].bounding_box.x, 0.0);
1287        assert_eq!(items[0].bounding_box.y, 6.0);
1288        assert_eq!(items[0].bounding_box.width, 100.0);
1289        assert_eq!(items[0].bounding_box.height, 24.0);
1290        match &items[0].config {
1291            render_commands::RenderCommandConfig::Text(text) => {
1292                assert_eq!(text.text, "Road");
1293                assert_eq!(text.color.r, 255.0);
1294                assert_eq!(text.color.g, 255.0);
1295                assert_eq!(text.color.b, 255.0);
1296                assert_eq!(text.color.a, 255.0);
1297                assert_eq!(text.font_size, 18);
1298            }
1299            _ => panic!("Expected Text config for item 0"),
1300        }
1301
1302        // Road background box
1303        assert_eq!(items[1].bounding_box.x, 112.0);
1304        assert_eq!(items[1].bounding_box.y, 9.0);
1305        assert_eq!(items[1].bounding_box.width, 163.99142);
1306        assert_eq!(items[1].bounding_box.height, 18.0);
1307        match &items[1].config {
1308            render_commands::RenderCommandConfig::Rectangle(rect) => {
1309                assert_eq!(rect.color.r, 85.0);
1310                assert_eq!(rect.color.g, 85.0);
1311                assert_eq!(rect.color.b, 85.0);
1312                assert_eq!(rect.color.a, 255.0);
1313                assert_eq!(rect.corner_radii.top_left, 9.0);
1314                assert_eq!(rect.corner_radii.top_right, 9.0);
1315                assert_eq!(rect.corner_radii.bottom_left, 9.0);
1316                assert_eq!(rect.corner_radii.bottom_right, 9.0);
1317            }
1318            _ => panic!("Expected Rectangle config for item 1"),
1319        }
1320
1321        // Road progress bar
1322        assert_eq!(items[2].bounding_box.x, 112.0);
1323        assert_eq!(items[2].bounding_box.y, 9.0);
1324        assert_eq!(items[2].bounding_box.width, 100.0);
1325        assert_eq!(items[2].bounding_box.height, 18.0);
1326        match &items[2].config {
1327            render_commands::RenderCommandConfig::Rectangle(rect) => {
1328                assert_eq!(rect.color.r, 69.0);
1329                assert_eq!(rect.color.g, 168.0);
1330                assert_eq!(rect.color.b, 90.0);
1331                assert_eq!(rect.color.a, 255.0);
1332                assert_eq!(rect.corner_radii.top_left, 9.0);
1333                assert_eq!(rect.corner_radii.top_right, 9.0);
1334                assert_eq!(rect.corner_radii.bottom_left, 9.0);
1335                assert_eq!(rect.corner_radii.bottom_right, 9.0);
1336            }
1337            _ => panic!("Expected Rectangle config for item 2"),
1338        }
1339
1340        // Wall label
1341        assert_eq!(items[3].bounding_box.x, 275.99142);
1342        assert_eq!(items[3].bounding_box.y, 6.0);
1343        assert_eq!(items[3].bounding_box.width, 100.0);
1344        assert_eq!(items[3].bounding_box.height, 24.0);
1345        match &items[3].config {
1346            render_commands::RenderCommandConfig::Text(text) => {
1347                assert_eq!(text.text, "Wall");
1348                assert_eq!(text.color.r, 255.0);
1349                assert_eq!(text.color.g, 255.0);
1350                assert_eq!(text.color.b, 255.0);
1351                assert_eq!(text.color.a, 255.0);
1352                assert_eq!(text.font_size, 18);
1353            }
1354            _ => panic!("Expected Text config for item 3"),
1355        }
1356
1357        // Wall background box
1358        assert_eq!(items[4].bounding_box.x, 387.99142);
1359        assert_eq!(items[4].bounding_box.y, 9.0);
1360        assert_eq!(items[4].bounding_box.width, 200.0);
1361        assert_eq!(items[4].bounding_box.height, 18.0);
1362        match &items[4].config {
1363            render_commands::RenderCommandConfig::Rectangle(rect) => {
1364                assert_eq!(rect.color.r, 85.0);
1365                assert_eq!(rect.color.g, 85.0);
1366                assert_eq!(rect.color.b, 85.0);
1367                assert_eq!(rect.color.a, 255.0);
1368                assert_eq!(rect.corner_radii.top_left, 9.0);
1369                assert_eq!(rect.corner_radii.top_right, 9.0);
1370                assert_eq!(rect.corner_radii.bottom_left, 9.0);
1371                assert_eq!(rect.corner_radii.bottom_right, 9.0);
1372            }
1373            _ => panic!("Expected Rectangle config for item 4"),
1374        }
1375
1376        // Wall progress bar
1377        assert_eq!(items[5].bounding_box.x, 387.99142);
1378        assert_eq!(items[5].bounding_box.y, 9.0);
1379        assert_eq!(items[5].bounding_box.width, 200.0);
1380        assert_eq!(items[5].bounding_box.height, 18.0);
1381        match &items[5].config {
1382            render_commands::RenderCommandConfig::Rectangle(rect) => {
1383                assert_eq!(rect.color.r, 69.0);
1384                assert_eq!(rect.color.g, 168.0);
1385                assert_eq!(rect.color.b, 90.0);
1386                assert_eq!(rect.color.a, 255.0);
1387                assert_eq!(rect.corner_radii.top_left, 9.0);
1388                assert_eq!(rect.corner_radii.top_right, 9.0);
1389                assert_eq!(rect.corner_radii.bottom_left, 9.0);
1390                assert_eq!(rect.corner_radii.bottom_right, 9.0);
1391            }
1392            _ => panic!("Expected Rectangle config for item 5"),
1393        }
1394
1395        // Tower label
1396        assert_eq!(items[6].bounding_box.x, 587.99146);
1397        assert_eq!(items[6].bounding_box.y, 6.0);
1398        assert_eq!(items[6].bounding_box.width, 100.0);
1399        assert_eq!(items[6].bounding_box.height, 24.0);
1400        match &items[6].config {
1401            render_commands::RenderCommandConfig::Text(text) => {
1402                assert_eq!(text.text, "Tower");
1403                assert_eq!(text.color.r, 255.0);
1404                assert_eq!(text.color.g, 255.0);
1405                assert_eq!(text.color.b, 255.0);
1406                assert_eq!(text.color.a, 255.0);
1407                assert_eq!(text.font_size, 18);
1408            }
1409            _ => panic!("Expected Text config for item 6"),
1410        }
1411
1412        // Tower background box
1413        assert_eq!(items[7].bounding_box.x, 699.99146);
1414        assert_eq!(items[7].bounding_box.y, 9.0);
1415        assert_eq!(items[7].bounding_box.width, 300.0);
1416        assert_eq!(items[7].bounding_box.height, 18.0);
1417        match &items[7].config {
1418            render_commands::RenderCommandConfig::Rectangle(rect) => {
1419                assert_eq!(rect.color.r, 85.0);
1420                assert_eq!(rect.color.g, 85.0);
1421                assert_eq!(rect.color.b, 85.0);
1422                assert_eq!(rect.color.a, 255.0);
1423                assert_eq!(rect.corner_radii.top_left, 9.0);
1424                assert_eq!(rect.corner_radii.top_right, 9.0);
1425                assert_eq!(rect.corner_radii.bottom_left, 9.0);
1426                assert_eq!(rect.corner_radii.bottom_right, 9.0);
1427            }
1428            _ => panic!("Expected Rectangle config for item 7"),
1429        }
1430
1431        // Tower progress bar
1432        assert_eq!(items[8].bounding_box.x, 699.99146);
1433        assert_eq!(items[8].bounding_box.y, 9.0);
1434        assert_eq!(items[8].bounding_box.width, 300.0);
1435        assert_eq!(items[8].bounding_box.height, 18.0);
1436        match &items[8].config {
1437            render_commands::RenderCommandConfig::Rectangle(rect) => {
1438                assert_eq!(rect.color.r, 69.0);
1439                assert_eq!(rect.color.g, 168.0);
1440                assert_eq!(rect.color.b, 90.0);
1441                assert_eq!(rect.color.a, 255.0);
1442                assert_eq!(rect.corner_radii.top_left, 9.0);
1443                assert_eq!(rect.corner_radii.top_right, 9.0);
1444                assert_eq!(rect.corner_radii.bottom_left, 9.0);
1445                assert_eq!(rect.corner_radii.bottom_right, 9.0);
1446            }
1447            _ => panic!("Expected Rectangle config for item 8"),
1448        }
1449    }
1450
1451    #[rustfmt::skip]
1452    #[test]
1453    fn test_floating() {
1454        let mut ply = Ply::<()>::new_headless(Dimensions::new(1000.0, 1000.0));
1455
1456        let mut ui = ply.begin();
1457
1458        ui.set_measure_text_function(|_, _| {
1459            Dimensions::new(100.0, 24.0)
1460        });
1461
1462        ui.element().width(fixed!(20.0)).height(fixed!(20.0))
1463            .layout(|l| l.align(crate::align::AlignX::CenterX, crate::align::AlignY::CenterY))
1464            .floating(|f| f
1465                .attach_root()
1466                .anchor((crate::align::AlignX::CenterX, crate::align::AlignY::CenterY), (crate::align::AlignX::Left, crate::align::AlignY::Top))
1467                .offset(100.0, 150.0)
1468                .passthrough()
1469                .z_index(110)
1470            )
1471            .corner_radius(10.0)
1472            .background_color(0x4488DD)
1473            .children(|ui| {
1474                ui.text("Re", |t| t
1475                    .font_size(6)
1476                    .color(0xFFFFFF)
1477                );
1478            });
1479
1480        let items = ui.eval();
1481
1482        for item in &items {
1483            println!(
1484                "id: {}\nbbox: {:?}\nconfig: {:?}",
1485                item.id, item.bounding_box, item.config,
1486            );
1487        }
1488
1489        assert_eq!(items.len(), 2);
1490
1491        assert_eq!(items[0].bounding_box.x, 90.0);
1492        assert_eq!(items[0].bounding_box.y, 140.0);
1493        assert_eq!(items[0].bounding_box.width, 20.0);
1494        assert_eq!(items[0].bounding_box.height, 20.0);
1495        match &items[0].config {
1496            render_commands::RenderCommandConfig::Rectangle(rect) => {
1497                assert_eq!(rect.color.r, 68.0);
1498                assert_eq!(rect.color.g, 136.0);
1499                assert_eq!(rect.color.b, 221.0);
1500                assert_eq!(rect.color.a, 255.0);
1501                assert_eq!(rect.corner_radii.top_left, 10.0);
1502                assert_eq!(rect.corner_radii.top_right, 10.0);
1503                assert_eq!(rect.corner_radii.bottom_left, 10.0);
1504                assert_eq!(rect.corner_radii.bottom_right, 10.0);
1505            }
1506            _ => panic!("Expected Rectangle config for item 0"),
1507        }
1508
1509        assert_eq!(items[1].bounding_box.x, 50.0);
1510        assert_eq!(items[1].bounding_box.y, 138.0);
1511        assert_eq!(items[1].bounding_box.width, 100.0);
1512        assert_eq!(items[1].bounding_box.height, 24.0);
1513        match &items[1].config {
1514            render_commands::RenderCommandConfig::Text(text) => {
1515                assert_eq!(text.text, "Re");
1516                assert_eq!(text.color.r, 255.0);
1517                assert_eq!(text.color.g, 255.0);
1518                assert_eq!(text.color.b, 255.0);
1519                assert_eq!(text.color.a, 255.0);
1520                assert_eq!(text.font_size, 6);
1521            }
1522            _ => panic!("Expected Text config for item 1"),
1523        }
1524    }
1525
1526    #[rustfmt::skip]
1527    #[test]
1528    fn test_simple_text_measure() {
1529        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1530
1531        ply.set_measure_text_function(|_text, _config| {
1532            Dimensions::default()
1533        });
1534
1535        let mut ui = ply.begin();
1536
1537        ui.element()
1538            .id("parent_rect")
1539            .width(Sizing::Fixed(100.0))
1540            .height(Sizing::Fixed(100.0))
1541            .layout(|l| l
1542                .padding(Padding::all(10))
1543            )
1544            .background_color(Color::rgb(255., 255., 255.))
1545            .children(|ui| {
1546                ui.text(&format!("{}", 1234), |t| t
1547                    .color(Color::rgb(255., 255., 255.))
1548                    .font_size(24)
1549                );
1550            });
1551
1552        let _items = ui.eval();
1553    }
1554
1555    #[rustfmt::skip]
1556    #[test]
1557    fn test_shader_begin_end() {
1558        use shaders::ShaderAsset;
1559
1560        let test_shader = ShaderAsset::Source {
1561            file_name: "test_effect.glsl",
1562            fragment: "#version 100\nprecision lowp float;\nvarying vec2 uv;\nuniform sampler2D Texture;\nvoid main() { gl_FragColor = texture2D(Texture, uv); }",
1563        };
1564
1565        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1566        ply.set_measure_text_function(|_, _| Dimensions::new(100.0, 24.0));
1567
1568        let mut ui = ply.begin();
1569
1570        // Element with a group shader containing children
1571        ui.element()
1572            .width(fixed!(200.0)).height(fixed!(200.0))
1573            .background_color(0xFF0000)
1574            .shader(&test_shader, |s| {
1575                s.uniform("time", 1.0f32);
1576            })
1577            .children(|ui| {
1578                ui.element()
1579                    .width(fixed!(100.0)).height(fixed!(100.0))
1580                    .background_color(0x00FF00)
1581                    .empty();
1582            });
1583
1584        let items = ui.eval();
1585
1586        for (i, item) in items.iter().enumerate() {
1587            println!(
1588                "[{}] config: {:?}, bbox: {:?}",
1589                i, item.config, item.bounding_box,
1590            );
1591        }
1592
1593        // Expected order (GroupBegin now wraps the entire element group):
1594        // 0: GroupBegin
1595        // 1: Rectangle (parent background)
1596        // 2: Rectangle (child)
1597        // 3: GroupEnd
1598        assert!(items.len() >= 4, "Expected at least 4 items, got {}", items.len());
1599
1600        match &items[0].config {
1601            render_commands::RenderCommandConfig::GroupBegin { shader, visual_rotation } => {
1602                let config = shader.as_ref().expect("GroupBegin should have shader config");
1603                assert!(!config.fragment.is_empty(), "GroupBegin should have fragment source");
1604                assert_eq!(config.uniforms.len(), 1);
1605                assert_eq!(config.uniforms[0].name, "time");
1606                assert!(visual_rotation.is_none(), "Shader-only group should have no visual_rotation");
1607            }
1608            other => panic!("Expected GroupBegin for item 0, got {:?}", other),
1609        }
1610
1611        match &items[1].config {
1612            render_commands::RenderCommandConfig::Rectangle(rect) => {
1613                assert_eq!(rect.color.r, 255.0);
1614                assert_eq!(rect.color.g, 0.0);
1615                assert_eq!(rect.color.b, 0.0);
1616            }
1617            other => panic!("Expected Rectangle for item 1, got {:?}", other),
1618        }
1619
1620        match &items[2].config {
1621            render_commands::RenderCommandConfig::Rectangle(rect) => {
1622                assert_eq!(rect.color.r, 0.0);
1623                assert_eq!(rect.color.g, 255.0);
1624                assert_eq!(rect.color.b, 0.0);
1625            }
1626            other => panic!("Expected Rectangle for item 2, got {:?}", other),
1627        }
1628
1629        match &items[3].config {
1630            render_commands::RenderCommandConfig::GroupEnd => {}
1631            other => panic!("Expected GroupEnd for item 3, got {:?}", other),
1632        }
1633    }
1634
1635    #[rustfmt::skip]
1636    #[test]
1637    fn test_multiple_shaders_nested() {
1638        use shaders::ShaderAsset;
1639
1640        let shader_a = ShaderAsset::Source {
1641            file_name: "shader_a.glsl",
1642            fragment: "#version 100\nprecision lowp float;\nvoid main() { gl_FragColor = vec4(1.0); }",
1643        };
1644        let shader_b = ShaderAsset::Source {
1645            file_name: "shader_b.glsl",
1646            fragment: "#version 100\nprecision lowp float;\nvoid main() { gl_FragColor = vec4(0.5); }",
1647        };
1648
1649        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1650        ply.set_measure_text_function(|_, _| Dimensions::new(100.0, 24.0));
1651
1652        let mut ui = ply.begin();
1653
1654        // Element with two group shaders
1655        ui.element()
1656            .width(fixed!(200.0)).height(fixed!(200.0))
1657            .background_color(0xFFFFFF)
1658            .shader(&shader_a, |s| { s.uniform("val", 1.0f32); })
1659            .shader(&shader_b, |s| { s.uniform("val", 2.0f32); })
1660            .children(|ui| {
1661                ui.element()
1662                    .width(fixed!(50.0)).height(fixed!(50.0))
1663                    .background_color(0x0000FF)
1664                    .empty();
1665            });
1666
1667        let items = ui.eval();
1668
1669        for (i, item) in items.iter().enumerate() {
1670            println!("[{}] config: {:?}", i, item.config);
1671        }
1672
1673        // Expected order (GroupBegin wraps before element drawing):
1674        // 0: GroupBegin(shader_b) — outermost, wraps everything
1675        // 1: GroupBegin(shader_a) — innermost, wraps element + children
1676        // 2: Rectangle (parent)
1677        // 3: Rectangle (child)
1678        // 4: GroupEnd — closes shader_a
1679        // 5: GroupEnd — closes shader_b
1680        assert!(items.len() >= 6, "Expected at least 6 items, got {}", items.len());
1681
1682        match &items[0].config {
1683            render_commands::RenderCommandConfig::GroupBegin { shader, .. } => {
1684                let config = shader.as_ref().unwrap();
1685                // shader_b is outermost
1686                assert!(config.fragment.contains("0.5"), "Expected shader_b fragment");
1687            }
1688            other => panic!("Expected GroupBegin(shader_b) for item 0, got {:?}", other),
1689        }
1690        match &items[1].config {
1691            render_commands::RenderCommandConfig::GroupBegin { shader, .. } => {
1692                let config = shader.as_ref().unwrap();
1693                // shader_a is innermost
1694                assert!(config.fragment.contains("1.0"), "Expected shader_a fragment");
1695            }
1696            other => panic!("Expected GroupBegin(shader_a) for item 1, got {:?}", other),
1697        }
1698        match &items[2].config {
1699            render_commands::RenderCommandConfig::Rectangle(_) => {}
1700            other => panic!("Expected Rectangle for item 2, got {:?}", other),
1701        }
1702        match &items[3].config {
1703            render_commands::RenderCommandConfig::Rectangle(_) => {}
1704            other => panic!("Expected Rectangle for item 3, got {:?}", other),
1705        }
1706        match &items[4].config {
1707            render_commands::RenderCommandConfig::GroupEnd => {}
1708            other => panic!("Expected GroupEnd for item 4, got {:?}", other),
1709        }
1710        match &items[5].config {
1711            render_commands::RenderCommandConfig::GroupEnd => {}
1712            other => panic!("Expected GroupEnd for item 5, got {:?}", other),
1713        }
1714    }
1715
1716    #[rustfmt::skip]
1717    #[test]
1718    fn test_effect_on_render_command() {
1719        use shaders::ShaderAsset;
1720
1721        let effect_shader = ShaderAsset::Source {
1722            file_name: "gradient.glsl",
1723            fragment: "#version 100\nprecision lowp float;\nvoid main() { gl_FragColor = vec4(1.0); }",
1724        };
1725
1726        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1727
1728        let mut ui = ply.begin();
1729
1730        ui.element()
1731            .width(fixed!(200.0)).height(fixed!(100.0))
1732            .background_color(0xFF0000)
1733            .effect(&effect_shader, |s| {
1734                s.uniform("color_a", [1.0f32, 0.0, 0.0, 1.0])
1735                 .uniform("color_b", [0.0f32, 0.0, 1.0, 1.0]);
1736            })
1737            .empty();
1738
1739        let items = ui.eval();
1740
1741        assert_eq!(items.len(), 1, "Expected 1 item, got {}", items.len());
1742        assert_eq!(items[0].effects.len(), 1, "Expected 1 effect");
1743        assert_eq!(items[0].effects[0].uniforms.len(), 2);
1744        assert_eq!(items[0].effects[0].uniforms[0].name, "color_a");
1745        assert_eq!(items[0].effects[0].uniforms[1].name, "color_b");
1746    }
1747
1748    #[rustfmt::skip]
1749    #[test]
1750    fn test_visual_rotation_emits_group() {
1751        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1752        let mut ui = ply.begin();
1753
1754        ui.element()
1755            .width(fixed!(100.0)).height(fixed!(50.0))
1756            .background_color(0xFF0000)
1757            .rotate_visual(|r| r.degrees(45.0))
1758            .empty();
1759
1760        let items = ui.eval();
1761
1762        // Expected: GroupBegin, Rectangle, GroupEnd
1763        assert_eq!(items.len(), 3, "Expected 3 items, got {}", items.len());
1764
1765        match &items[0].config {
1766            render_commands::RenderCommandConfig::GroupBegin { shader, visual_rotation } => {
1767                assert!(shader.is_none(), "Rotation-only group should have no shader");
1768                let vr = visual_rotation.as_ref().expect("Should have visual_rotation");
1769                assert!((vr.rotation_radians - 45.0_f32.to_radians()).abs() < 0.001);
1770                assert_eq!(vr.pivot_x, 0.5);
1771                assert_eq!(vr.pivot_y, 0.5);
1772                assert!(!vr.flip_x);
1773                assert!(!vr.flip_y);
1774            }
1775            other => panic!("Expected GroupBegin for item 0, got {:?}", other),
1776        }
1777
1778        match &items[1].config {
1779            render_commands::RenderCommandConfig::Rectangle(_) => {}
1780            other => panic!("Expected Rectangle for item 1, got {:?}", other),
1781        }
1782
1783        match &items[2].config {
1784            render_commands::RenderCommandConfig::GroupEnd => {}
1785            other => panic!("Expected GroupEnd for item 2, got {:?}", other),
1786        }
1787    }
1788
1789    #[rustfmt::skip]
1790    #[test]
1791    fn test_visual_rotation_with_shader_merged() {
1792        use shaders::ShaderAsset;
1793
1794        let test_shader = ShaderAsset::Source {
1795            file_name: "merge_test.glsl",
1796            fragment: "#version 100\nprecision lowp float;\nvoid main() { gl_FragColor = vec4(1.0); }",
1797        };
1798
1799        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1800        let mut ui = ply.begin();
1801
1802        // Both shader and visual rotation — should emit ONE GroupBegin
1803        ui.element()
1804            .width(fixed!(100.0)).height(fixed!(100.0))
1805            .background_color(0xFF0000)
1806            .shader(&test_shader, |s| { s.uniform("v", 1.0f32); })
1807            .rotate_visual(|r| r.degrees(30.0).pivot(0.0, 0.0))
1808            .empty();
1809
1810        let items = ui.eval();
1811
1812        // Expected: GroupBegin (with shader + rotation), Rectangle, GroupEnd
1813        assert_eq!(items.len(), 3, "Expected 3 items (merged), got {}", items.len());
1814
1815        match &items[0].config {
1816            render_commands::RenderCommandConfig::GroupBegin { shader, visual_rotation } => {
1817                assert!(shader.is_some(), "Merged group should have shader");
1818                let vr = visual_rotation.as_ref().expect("Merged group should have visual_rotation");
1819                assert!((vr.rotation_radians - 30.0_f32.to_radians()).abs() < 0.001);
1820                assert_eq!(vr.pivot_x, 0.0);
1821                assert_eq!(vr.pivot_y, 0.0);
1822            }
1823            other => panic!("Expected GroupBegin for item 0, got {:?}", other),
1824        }
1825    }
1826
1827    #[rustfmt::skip]
1828    #[test]
1829    fn test_visual_rotation_with_multiple_shaders() {
1830        use shaders::ShaderAsset;
1831
1832        let shader_a = ShaderAsset::Source {
1833            file_name: "vr_a.glsl",
1834            fragment: "#version 100\nprecision lowp float;\nvoid main() { gl_FragColor = vec4(1.0); }",
1835        };
1836        let shader_b = ShaderAsset::Source {
1837            file_name: "vr_b.glsl",
1838            fragment: "#version 100\nprecision lowp float;\nvoid main() { gl_FragColor = vec4(0.5); }",
1839        };
1840
1841        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1842        let mut ui = ply.begin();
1843
1844        ui.element()
1845            .width(fixed!(100.0)).height(fixed!(100.0))
1846            .background_color(0xFF0000)
1847            .shader(&shader_a, |s| { s.uniform("v", 1.0f32); })
1848            .shader(&shader_b, |s| { s.uniform("v", 2.0f32); })
1849            .rotate_visual(|r| r.degrees(90.0))
1850            .empty();
1851
1852        let items = ui.eval();
1853
1854        // Expected: GroupBegin(shader_b + rotation), GroupBegin(shader_a), Rect, GroupEnd, GroupEnd
1855        assert!(items.len() >= 5, "Expected at least 5 items, got {}", items.len());
1856
1857        // Outermost GroupBegin carries both shader_b and visual_rotation
1858        match &items[0].config {
1859            render_commands::RenderCommandConfig::GroupBegin { shader, visual_rotation } => {
1860                assert!(shader.is_some(), "Outermost should have shader");
1861                assert!(visual_rotation.is_some(), "Outermost should have visual_rotation");
1862            }
1863            other => panic!("Expected GroupBegin for item 0, got {:?}", other),
1864        }
1865
1866        // Inner GroupBegin has shader only, no rotation
1867        match &items[1].config {
1868            render_commands::RenderCommandConfig::GroupBegin { shader, visual_rotation } => {
1869                assert!(shader.is_some(), "Inner should have shader");
1870                assert!(visual_rotation.is_none(), "Inner should NOT have visual_rotation");
1871            }
1872            other => panic!("Expected GroupBegin for item 1, got {:?}", other),
1873        }
1874    }
1875
1876    #[rustfmt::skip]
1877    #[test]
1878    fn test_visual_rotation_noop_skipped() {
1879        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1880        let mut ui = ply.begin();
1881
1882        // 0° rotation with no flips — should be optimized away
1883        ui.element()
1884            .width(fixed!(100.0)).height(fixed!(100.0))
1885            .background_color(0xFF0000)
1886            .rotate_visual(|r| r.degrees(0.0))
1887            .empty();
1888
1889        let items = ui.eval();
1890
1891        // Should be just the rectangle, no GroupBegin/End
1892        assert_eq!(items.len(), 1, "Noop rotation should produce 1 item, got {}", items.len());
1893        match &items[0].config {
1894            render_commands::RenderCommandConfig::Rectangle(_) => {}
1895            other => panic!("Expected Rectangle, got {:?}", other),
1896        }
1897    }
1898
1899    #[rustfmt::skip]
1900    #[test]
1901    fn test_visual_rotation_flip_only() {
1902        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1903        let mut ui = ply.begin();
1904
1905        // 0° but flip_x — NOT a noop, should emit group
1906        ui.element()
1907            .width(fixed!(100.0)).height(fixed!(100.0))
1908            .background_color(0xFF0000)
1909            .rotate_visual(|r| r.flip_x())
1910            .empty();
1911
1912        let items = ui.eval();
1913
1914        // GroupBegin, Rectangle, GroupEnd
1915        assert_eq!(items.len(), 3, "Flip-only should produce 3 items, got {}", items.len());
1916        match &items[0].config {
1917            render_commands::RenderCommandConfig::GroupBegin { visual_rotation, .. } => {
1918                let vr = visual_rotation.as_ref().expect("Should have rotation config");
1919                assert!(vr.flip_x);
1920                assert!(!vr.flip_y);
1921                assert_eq!(vr.rotation_radians, 0.0);
1922            }
1923            other => panic!("Expected GroupBegin, got {:?}", other),
1924        }
1925    }
1926
1927    #[rustfmt::skip]
1928    #[test]
1929    fn test_visual_rotation_preserves_bounding_box() {
1930        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1931        let mut ui = ply.begin();
1932
1933        ui.element()
1934            .width(fixed!(200.0)).height(fixed!(100.0))
1935            .background_color(0xFF0000)
1936            .rotate_visual(|r| r.degrees(45.0))
1937            .empty();
1938
1939        let items = ui.eval();
1940
1941        // The rectangle inside should keep original dimensions (layout unaffected)
1942        let rect = &items[1]; // Rectangle is after GroupBegin
1943        assert_eq!(rect.bounding_box.width, 200.0);
1944        assert_eq!(rect.bounding_box.height, 100.0);
1945    }
1946
1947    #[rustfmt::skip]
1948    #[test]
1949    fn test_visual_rotation_config_values() {
1950        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1951        let mut ui = ply.begin();
1952
1953        ui.element()
1954            .width(fixed!(100.0)).height(fixed!(100.0))
1955            .background_color(0xFF0000)
1956            .rotate_visual(|r| r
1957                .radians(std::f32::consts::FRAC_PI_2)
1958                .pivot(0.25, 0.75)
1959                .flip_x()
1960                .flip_y()
1961            )
1962            .empty();
1963
1964        let items = ui.eval();
1965
1966        match &items[0].config {
1967            render_commands::RenderCommandConfig::GroupBegin { visual_rotation, .. } => {
1968                let vr = visual_rotation.as_ref().unwrap();
1969                assert!((vr.rotation_radians - std::f32::consts::FRAC_PI_2).abs() < 0.001);
1970                assert_eq!(vr.pivot_x, 0.25);
1971                assert_eq!(vr.pivot_y, 0.75);
1972                assert!(vr.flip_x);
1973                assert!(vr.flip_y);
1974            }
1975            other => panic!("Expected GroupBegin, got {:?}", other),
1976        }
1977    }
1978
1979    #[rustfmt::skip]
1980    #[test]
1981    fn test_shape_rotation_emits_with_rotation() {
1982        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
1983        let mut ui = ply.begin();
1984
1985        ui.element()
1986            .width(fixed!(100.0)).height(fixed!(50.0))
1987            .background_color(0xFF0000)
1988            .rotate_shape(|r| r.degrees(45.0))
1989            .empty();
1990
1991        let items = ui.eval();
1992
1993        // Should produce a single Rectangle with shape_rotation
1994        assert_eq!(items.len(), 1, "Expected 1 item, got {}", items.len());
1995        let sr = items[0].shape_rotation.as_ref().expect("Should have shape_rotation");
1996        assert!((sr.rotation_radians - 45.0_f32.to_radians()).abs() < 0.001);
1997        assert!(!sr.flip_x);
1998        assert!(!sr.flip_y);
1999    }
2000
2001    #[rustfmt::skip]
2002    #[test]
2003    fn test_shape_rotation_aabb_90_degrees() {
2004        // 90° rotation of a 200×100 rect → AABB should be 100×200
2005        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2006        let mut ui = ply.begin();
2007
2008        ui.element().width(grow!()).height(grow!())
2009            .layout(|l| l)
2010            .children(|ui| {
2011                ui.element()
2012                    .width(fixed!(200.0)).height(fixed!(100.0))
2013                    .background_color(0xFF0000)
2014                    .rotate_shape(|r| r.degrees(90.0))
2015                    .empty();
2016            });
2017
2018        let items = ui.eval();
2019
2020        // Find the rectangle
2021        let rect = items.iter().find(|i| matches!(i.config, render_commands::RenderCommandConfig::Rectangle(_))).unwrap();
2022        // The bounding box should have original dims (centered in AABB)
2023        assert!((rect.bounding_box.width - 200.0).abs() < 0.1, "width should be 200, got {}", rect.bounding_box.width);
2024        assert!((rect.bounding_box.height - 100.0).abs() < 0.1, "height should be 100, got {}", rect.bounding_box.height);
2025    }
2026
2027    #[rustfmt::skip]
2028    #[test]
2029    fn test_shape_rotation_aabb_45_degrees_sharp() {
2030        // 45° rotation of a 100×100 sharp rect → AABB ≈ 141.4×141.4
2031        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2032        let mut ui = ply.begin();
2033
2034        // We need a parent to see the AABB effect on sibling positioning
2035        ui.element().width(grow!()).height(grow!())
2036            .layout(|l| l.direction(layout::LayoutDirection::LeftToRight))
2037            .children(|ui| {
2038                ui.element()
2039                    .width(fixed!(100.0)).height(fixed!(100.0))
2040                    .background_color(0xFF0000)
2041                    .rotate_shape(|r| r.degrees(45.0))
2042                    .empty();
2043
2044                // Second element — its x-position should be offset by ~141.4
2045                ui.element()
2046                    .width(fixed!(50.0)).height(fixed!(50.0))
2047                    .background_color(0x00FF00)
2048                    .empty();
2049            });
2050
2051        let items = ui.eval();
2052
2053        // Find the green rectangle (second one)
2054        let rects: Vec<_> = items.iter()
2055            .filter(|i| matches!(i.config, render_commands::RenderCommandConfig::Rectangle(_)))
2056            .collect();
2057        assert!(rects.len() >= 2, "Expected at least 2 rectangles, got {}", rects.len());
2058
2059        let expected_aabb_w = (2.0_f32.sqrt()) * 100.0; // ~141.42
2060        let green_x = rects[1].bounding_box.x;
2061        // Green rect starts at AABB width (since parent starts at x=0)
2062        assert!((green_x - expected_aabb_w).abs() < 1.0,
2063            "Green rect x should be ~{}, got {}", expected_aabb_w, green_x);
2064    }
2065
2066    #[rustfmt::skip]
2067    #[test]
2068    fn test_shape_rotation_aabb_45_degrees_rounded() {
2069        // 45° rotation of a 100×100 rect with corner radius 10 →
2070        // AABB = |(100-20)cos45| + |(100-20)sin45| + 20 ≈ 133.14
2071        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2072        let mut ui = ply.begin();
2073
2074        ui.element().width(grow!()).height(grow!())
2075            .layout(|l| l.direction(layout::LayoutDirection::LeftToRight))
2076            .children(|ui| {
2077                ui.element()
2078                    .width(fixed!(100.0)).height(fixed!(100.0))
2079                    .corner_radius(10.0)
2080                    .background_color(0xFF0000)
2081                    .rotate_shape(|r| r.degrees(45.0))
2082                    .empty();
2083
2084                ui.element()
2085                    .width(fixed!(50.0)).height(fixed!(50.0))
2086                    .background_color(0x00FF00)
2087                    .empty();
2088            });
2089
2090        let items = ui.eval();
2091
2092        let rects: Vec<_> = items.iter()
2093            .filter(|i| matches!(i.config, render_commands::RenderCommandConfig::Rectangle(_)))
2094            .collect();
2095        assert!(rects.len() >= 2);
2096
2097        // Expected: |(100-20)·cos45| + |(100-20)·sin45| + 20 = 80·√2 + 20 ≈ 133.14
2098        let expected_aabb_w = 80.0 * 2.0_f32.sqrt() + 20.0;
2099        let green_x = rects[1].bounding_box.x;
2100        // Green rect starts at AABB width (since parent starts at x=0)
2101        assert!((green_x - expected_aabb_w).abs() < 1.0,
2102            "Green rect x should be ~{}, got {}", expected_aabb_w, green_x);
2103    }
2104
2105    #[rustfmt::skip]
2106    #[test]
2107    fn test_shape_rotation_noop_no_aabb_change() {
2108        // 0° with no flip = noop, should not change dimensions
2109        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2110        let mut ui = ply.begin();
2111
2112        ui.element()
2113            .width(fixed!(100.0)).height(fixed!(50.0))
2114            .background_color(0xFF0000)
2115            .rotate_shape(|r| r.degrees(0.0))
2116            .empty();
2117
2118        let items = ui.eval();
2119        assert_eq!(items.len(), 1);
2120        assert_eq!(items[0].bounding_box.width, 100.0);
2121        assert_eq!(items[0].bounding_box.height, 50.0);
2122        // shape_rotation should still be present (renderer filters noop)
2123        // Actually noop is filtered at engine level, so it should be None
2124        assert!(items[0].shape_rotation.is_none(), "Noop shape rotation should be filtered");
2125    }
2126
2127    #[rustfmt::skip]
2128    #[test]
2129    fn test_shape_rotation_flip_only() {
2130        // flip_x with 0° — NOT noop, but doesn't change AABB
2131        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2132        let mut ui = ply.begin();
2133
2134        ui.element()
2135            .width(fixed!(100.0)).height(fixed!(50.0))
2136            .background_color(0xFF0000)
2137            .rotate_shape(|r| r.flip_x())
2138            .empty();
2139
2140        let items = ui.eval();
2141        assert_eq!(items.len(), 1);
2142        let sr = items[0].shape_rotation.as_ref().expect("flip_x should produce shape_rotation");
2143        assert!(sr.flip_x);
2144        assert!(!sr.flip_y);
2145        // AABB unchanged for flip-only
2146        assert_eq!(items[0].bounding_box.width, 100.0);
2147        assert_eq!(items[0].bounding_box.height, 50.0);
2148    }
2149
2150    #[rustfmt::skip]
2151    #[test]
2152    fn test_shape_rotation_180_no_aabb_change() {
2153        // 180° rotation → AABB same as original
2154        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2155        let mut ui = ply.begin();
2156
2157        ui.element()
2158            .width(fixed!(200.0)).height(fixed!(100.0))
2159            .background_color(0xFF0000)
2160            .rotate_shape(|r| r.degrees(180.0))
2161            .empty();
2162
2163        let items = ui.eval();
2164        assert_eq!(items.len(), 1);
2165        assert_eq!(items[0].bounding_box.width, 200.0);
2166        assert_eq!(items[0].bounding_box.height, 100.0);
2167    }
2168
2169    #[test]
2170    fn test_classify_angle() {
2171        use math::{classify_angle, AngleType};
2172        assert_eq!(classify_angle(0.0), AngleType::Zero);
2173        assert_eq!(classify_angle(std::f32::consts::TAU), AngleType::Zero);
2174        assert_eq!(classify_angle(-std::f32::consts::TAU), AngleType::Zero);
2175        assert_eq!(classify_angle(std::f32::consts::FRAC_PI_2), AngleType::Right90);
2176        assert_eq!(classify_angle(std::f32::consts::PI), AngleType::Straight180);
2177        assert_eq!(classify_angle(3.0 * std::f32::consts::FRAC_PI_2), AngleType::Right270);
2178        match classify_angle(1.0) {
2179            AngleType::Arbitrary(v) => assert!((v - 1.0).abs() < 0.01),
2180            other => panic!("Expected Arbitrary, got {:?}", other),
2181        }
2182    }
2183
2184    #[test]
2185    fn test_compute_rotated_aabb_zero() {
2186        use math::compute_rotated_aabb;
2187        use layout::CornerRadius;
2188        let cr = CornerRadius::default();
2189        let (w, h) = compute_rotated_aabb(100.0, 50.0, &cr, 0.0);
2190        assert_eq!(w, 100.0);
2191        assert_eq!(h, 50.0);
2192    }
2193
2194    #[test]
2195    fn test_compute_rotated_aabb_90() {
2196        use math::compute_rotated_aabb;
2197        use layout::CornerRadius;
2198        let cr = CornerRadius::default();
2199        let (w, h) = compute_rotated_aabb(200.0, 100.0, &cr, std::f32::consts::FRAC_PI_2);
2200        assert!((w - 100.0).abs() < 0.1, "w should be 100, got {}", w);
2201        assert!((h - 200.0).abs() < 0.1, "h should be 200, got {}", h);
2202    }
2203
2204    #[test]
2205    fn test_compute_rotated_aabb_45_sharp() {
2206        use math::compute_rotated_aabb;
2207        use layout::CornerRadius;
2208        let cr = CornerRadius::default();
2209        let theta = std::f32::consts::FRAC_PI_4;
2210        let (w, h) = compute_rotated_aabb(100.0, 100.0, &cr, theta);
2211        let expected = 100.0 * 2.0_f32.sqrt();
2212        assert!((w - expected).abs() < 0.5, "w should be ~{}, got {}", expected, w);
2213        assert!((h - expected).abs() < 0.5, "h should be ~{}, got {}", expected, h);
2214    }
2215
2216    #[test]
2217    fn test_compute_rotated_aabb_45_rounded() {
2218        use math::compute_rotated_aabb;
2219        use layout::CornerRadius;
2220        let cr = CornerRadius { top_left: 10.0, top_right: 10.0, bottom_left: 10.0, bottom_right: 10.0 };
2221        let theta = std::f32::consts::FRAC_PI_4;
2222        let (w, h) = compute_rotated_aabb(100.0, 100.0, &cr, theta);
2223        let expected = 80.0 * 2.0_f32.sqrt() + 20.0; // ~133.14
2224        assert!((w - expected).abs() < 0.5, "w should be ~{}, got {}", expected, w);
2225        assert!((h - expected).abs() < 0.5, "h should be ~{}, got {}", expected, h);
2226    }
2227
2228    #[test]
2229    fn test_on_press_callback_fires() {
2230        use std::cell::RefCell;
2231        use std::rc::Rc;
2232
2233        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2234        let press_count = Rc::new(RefCell::new(0u32));
2235        let release_count = Rc::new(RefCell::new(0u32));
2236
2237        // Frame 1: lay out a 100x100 element and eval to establish bounding boxes
2238        {
2239            let mut ui = ply.begin();
2240            ui.element()
2241                .id("btn")
2242                .width(fixed!(100.0))
2243                .height(fixed!(100.0))
2244                .empty();
2245            ui.eval();
2246        }
2247
2248        // Frame 2: add press callbacks
2249        {
2250            let pc = press_count.clone();
2251            let rc = release_count.clone();
2252            let mut ui = ply.begin();
2253            ui.element()
2254                .id("btn")
2255                .width(fixed!(100.0))
2256                .height(fixed!(100.0))
2257                .on_press(move |_, _| { *pc.borrow_mut() += 1; })
2258                .on_release(move |_, _| { *rc.borrow_mut() += 1; })
2259                .empty();
2260            ui.eval();
2261        }
2262
2263        // Simulate pointer press at (50, 50) — inside the element
2264        ply.context.set_pointer_state(Vector2::new(50.0, 50.0), true);
2265        assert_eq!(*press_count.borrow(), 1, "on_press should fire once");
2266        assert_eq!(*release_count.borrow(), 0, "on_release should not fire yet");
2267
2268        // Simulate pointer release
2269        ply.context.set_pointer_state(Vector2::new(50.0, 50.0), false);
2270        assert_eq!(*release_count.borrow(), 1, "on_release should fire once");
2271    }
2272
2273    #[test]
2274    fn test_pressed_query() {
2275        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2276
2277        // Frame 1: layout
2278        {
2279            let mut ui = ply.begin();
2280            ui.element()
2281                .id("btn")
2282                .width(fixed!(100.0))
2283                .height(fixed!(100.0))
2284                .empty();
2285            ui.eval();
2286        }
2287
2288        // Simulate pointer press at (50, 50)
2289        ply.context.set_pointer_state(Vector2::new(50.0, 50.0), true);
2290
2291        // Frame 2: check pressed() during layout
2292        {
2293            let mut ui = ply.begin();
2294            ui.element()
2295                .id("btn")
2296                .width(fixed!(100.0))
2297                .height(fixed!(100.0))
2298                .children(|ui| {
2299                    assert!(ui.pressed(), "element should report as pressed");
2300                });
2301            ui.eval();
2302        }
2303    }
2304
2305    #[test]
2306    fn test_tab_navigation_cycles_focus() {
2307        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2308
2309        // Frame 1: create 3 focusable elements
2310        {
2311            let mut ui = ply.begin();
2312            ui.element()
2313                .id("a")
2314                .width(fixed!(100.0))
2315                .height(fixed!(50.0))
2316                .accessibility(|a| a.button("A"))
2317                .empty();
2318            ui.element()
2319                .id("b")
2320                .width(fixed!(100.0))
2321                .height(fixed!(50.0))
2322                .accessibility(|a| a.button("B"))
2323                .empty();
2324            ui.element()
2325                .id("c")
2326                .width(fixed!(100.0))
2327                .height(fixed!(50.0))
2328                .accessibility(|a| a.button("C"))
2329                .empty();
2330            ui.eval();
2331        }
2332
2333        let id_a = Id::from("a").id;
2334        let id_b = Id::from("b").id;
2335        let id_c = Id::from("c").id;
2336
2337        // No focus initially
2338        assert_eq!(ply.focused_element(), None);
2339
2340        // Tab → focus A
2341        ply.context.cycle_focus(false);
2342        assert_eq!(ply.context.focused_element_id, id_a);
2343
2344        // Tab → focus B
2345        ply.context.cycle_focus(false);
2346        assert_eq!(ply.context.focused_element_id, id_b);
2347
2348        // Tab → focus C
2349        ply.context.cycle_focus(false);
2350        assert_eq!(ply.context.focused_element_id, id_c);
2351
2352        // Tab → wrap to A
2353        ply.context.cycle_focus(false);
2354        assert_eq!(ply.context.focused_element_id, id_a);
2355
2356        // Shift+Tab → wrap to C
2357        ply.context.cycle_focus(true);
2358        assert_eq!(ply.context.focused_element_id, id_c);
2359    }
2360
2361    #[test]
2362    fn test_tab_index_ordering() {
2363        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2364
2365        // Frame 1: create elements with explicit tab indices (reverse order)
2366        {
2367            let mut ui = ply.begin();
2368            ui.element()
2369                .id("third")
2370                .width(fixed!(100.0))
2371                .height(fixed!(50.0))
2372                .accessibility(|a| a.button("Third").tab_index(3))
2373                .empty();
2374            ui.element()
2375                .id("first")
2376                .width(fixed!(100.0))
2377                .height(fixed!(50.0))
2378                .accessibility(|a| a.button("First").tab_index(1))
2379                .empty();
2380            ui.element()
2381                .id("second")
2382                .width(fixed!(100.0))
2383                .height(fixed!(50.0))
2384                .accessibility(|a| a.button("Second").tab_index(2))
2385                .empty();
2386            ui.eval();
2387        }
2388
2389        let id_first = Id::from("first").id;
2390        let id_second = Id::from("second").id;
2391        let id_third = Id::from("third").id;
2392
2393        // Tab ordering should follow tab_index, not insertion order
2394        ply.context.cycle_focus(false);
2395        assert_eq!(ply.context.focused_element_id, id_first);
2396        ply.context.cycle_focus(false);
2397        assert_eq!(ply.context.focused_element_id, id_second);
2398        ply.context.cycle_focus(false);
2399        assert_eq!(ply.context.focused_element_id, id_third);
2400    }
2401
2402    #[test]
2403    fn test_arrow_key_navigation() {
2404        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2405        use engine::ArrowDirection;
2406
2407        let id_a = Id::from("a").id;
2408        let id_b = Id::from("b").id;
2409
2410        // Frame 1: create two elements with arrow overrides
2411        {
2412            let mut ui = ply.begin();
2413            ui.element()
2414                .id("a")
2415                .width(fixed!(100.0))
2416                .height(fixed!(50.0))
2417                .accessibility(|a| a.button("A").focus_right("b"))
2418                .empty();
2419            ui.element()
2420                .id("b")
2421                .width(fixed!(100.0))
2422                .height(fixed!(50.0))
2423                .accessibility(|a| a.button("B").focus_left("a"))
2424                .empty();
2425            ui.eval();
2426        }
2427
2428        // Focus A first
2429        ply.context.set_focus(id_a);
2430        assert_eq!(ply.context.focused_element_id, id_a);
2431
2432        // Arrow right → B
2433        ply.context.arrow_focus(ArrowDirection::Right);
2434        assert_eq!(ply.context.focused_element_id, id_b);
2435
2436        // Arrow left → A
2437        ply.context.arrow_focus(ArrowDirection::Left);
2438        assert_eq!(ply.context.focused_element_id, id_a);
2439
2440        // Arrow up → no override, stays on A
2441        ply.context.arrow_focus(ArrowDirection::Up);
2442        assert_eq!(ply.context.focused_element_id, id_a);
2443    }
2444
2445    #[test]
2446    fn test_focused_query() {
2447        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2448
2449        let id_a = Id::from("a").id;
2450
2451        // Frame 1: layout + set focus
2452        {
2453            let mut ui = ply.begin();
2454            ui.element()
2455                .id("a")
2456                .width(fixed!(100.0))
2457                .height(fixed!(50.0))
2458                .accessibility(|a| a.button("A"))
2459                .empty();
2460            ui.eval();
2461        }
2462
2463        ply.context.set_focus(id_a);
2464
2465        // Frame 2: check focused() during layout
2466        {
2467            let mut ui = ply.begin();
2468            ui.element()
2469                .id("a")
2470                .width(fixed!(100.0))
2471                .height(fixed!(50.0))
2472                .accessibility(|a| a.button("A"))
2473                .children(|ui| {
2474                    assert!(ui.focused(), "element should report as focused");
2475                });
2476            ui.eval();
2477        }
2478    }
2479
2480    #[test]
2481    fn test_on_focus_callback_fires_on_tab() {
2482        use std::cell::RefCell;
2483        use std::rc::Rc;
2484
2485        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2486        let focus_a = Rc::new(RefCell::new(0u32));
2487        let unfocus_a = Rc::new(RefCell::new(0u32));
2488        let focus_b = Rc::new(RefCell::new(0u32));
2489
2490        // Frame 1: create focusable elements with on_focus/on_unfocus
2491        {
2492            let fa = focus_a.clone();
2493            let ua = unfocus_a.clone();
2494            let fb = focus_b.clone();
2495            let mut ui = ply.begin();
2496            ui.element()
2497                .id("a")
2498                .width(fixed!(100.0))
2499                .height(fixed!(50.0))
2500                .accessibility(|a| a.button("A"))
2501                .on_focus(move |_| { *fa.borrow_mut() += 1; })
2502                .on_unfocus(move |_| { *ua.borrow_mut() += 1; })
2503                .empty();
2504            ui.element()
2505                .id("b")
2506                .width(fixed!(100.0))
2507                .height(fixed!(50.0))
2508                .accessibility(|a| a.button("B"))
2509                .on_focus(move |_| { *fb.borrow_mut() += 1; })
2510                .empty();
2511            ui.eval();
2512        }
2513
2514        // Tab → focus A
2515        ply.context.cycle_focus(false);
2516        assert_eq!(*focus_a.borrow(), 1, "on_focus should fire for A");
2517        assert_eq!(*unfocus_a.borrow(), 0, "on_unfocus should not fire yet");
2518
2519        // Tab → focus B (unfocus A)
2520        ply.context.cycle_focus(false);
2521        assert_eq!(*unfocus_a.borrow(), 1, "on_unfocus should fire for A");
2522        assert_eq!(*focus_b.borrow(), 1, "on_focus should fire for B");
2523    }
2524
2525    #[test]
2526    fn test_on_focus_callback_fires_on_set_focus() {
2527        use std::cell::RefCell;
2528        use std::rc::Rc;
2529
2530        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2531        let focus_count = Rc::new(RefCell::new(0u32));
2532        let unfocus_count = Rc::new(RefCell::new(0u32));
2533
2534        let id_a = Id::from("a").id;
2535
2536        // Frame 1
2537        {
2538            let fc = focus_count.clone();
2539            let uc = unfocus_count.clone();
2540            let mut ui = ply.begin();
2541            ui.element()
2542                .id("a")
2543                .width(fixed!(100.0))
2544                .height(fixed!(50.0))
2545                .accessibility(|a| a.button("A"))
2546                .on_focus(move |_| { *fc.borrow_mut() += 1; })
2547                .on_unfocus(move |_| { *uc.borrow_mut() += 1; })
2548                .empty();
2549            ui.eval();
2550        }
2551
2552        // Programmatic set_focus
2553        ply.context.set_focus(id_a);
2554        assert_eq!(*focus_count.borrow(), 1, "on_focus should fire on set_focus");
2555
2556        // clear_focus
2557        ply.context.clear_focus();
2558        assert_eq!(*unfocus_count.borrow(), 1, "on_unfocus should fire on clear_focus");
2559    }
2560
2561    #[test]
2562    fn test_focus_ring_render_command() {
2563        use render_commands::RenderCommandConfig;
2564
2565        let mut ply = Ply::<()>::new_headless(Dimensions::new(800.0, 600.0));
2566        let id_a = Id::from("a").id;
2567
2568        // Frame 1: layout
2569        {
2570            let mut ui = ply.begin();
2571            ui.element()
2572                .id("a")
2573                .width(fixed!(100.0))
2574                .height(fixed!(50.0))
2575                .corner_radius(8.0)
2576                .accessibility(|a| a.button("A"))
2577                .empty();
2578            ui.eval();
2579        }
2580
2581        // Set focus via keyboard
2582        ply.context.focus_from_keyboard = true;
2583        ply.context.set_focus(id_a);
2584
2585        // Frame 2: eval to get render commands with focus ring
2586        {
2587            let mut ui = ply.begin();
2588            ui.element()
2589                .id("a")
2590                .width(fixed!(100.0))
2591                .height(fixed!(50.0))
2592                .corner_radius(8.0)
2593                .accessibility(|a| a.button("A"))
2594                .empty();
2595            let items = ui.eval();
2596
2597            // Look for a border render command with z_index 32764 (the focus ring)
2598            let focus_ring = items.iter().find(|cmd| {
2599                cmd.z_index == 32764 && matches!(cmd.config, RenderCommandConfig::Border(_))
2600            });
2601            assert!(focus_ring.is_some(), "Focus ring border should be in render commands");
2602
2603            let ring = focus_ring.unwrap();
2604            // Focus ring should be expanded by 2px per side
2605            assert!(ring.bounding_box.width > 100.0, "Focus ring should be wider than element");
2606            assert!(ring.bounding_box.height > 50.0, "Focus ring should be taller than element");
2607        }
2608    }
2609}