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