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