Skip to main content

ply_engine/
lib.rs

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