repose_platform/
lib.rs

1//! Platform runners
2use crate::a11y::ReposeActionHandler;
3use accesskit_winit::Adapter;
4use repose_core::locals::dp_to_px;
5use repose_core::*;
6use repose_ui::textfield::{
7    self, TF_FONT_DP, TF_PADDING_X_DP, TextFieldState, caret_xy_for_byte, index_for_x_bytes,
8    index_for_xy_bytes, measure_text,
9};
10use std::cell::RefCell;
11use std::rc::Rc;
12use std::sync::{Arc, Mutex};
13use web_time::Instant;
14
15#[cfg(all(feature = "android", target_os = "android"))]
16pub mod android;
17
18#[cfg(all(target_arch = "wasm32"))]
19pub mod web;
20
21pub mod a11y;
22mod common;
23pub mod render;
24
25pub use render::{ImageHandleGuard, RenderCommand, RenderContext};
26
27#[derive(Clone)]
28struct DragSession {
29    source_id: u64,
30    payload: repose_core::dnd::DragPayload,
31    start_px: (f32, f32),
32    over_id: Option<u64>,
33}
34
35/// Compose a single frame with density and text-scale applied, returning Frame.
36pub fn compose_frame<F>(
37    sched: &mut Scheduler,
38    root_fn: &mut F,
39    scale: f32,
40    size_px_u32: (u32, u32),
41    hover_id: Option<u64>,
42    pressed_ids: &std::collections::HashSet<u64>,
43    tf_states: &std::collections::HashMap<u64, Rc<RefCell<repose_ui::TextFieldState>>>,
44    focused: Option<u64>,
45) -> Frame
46where
47    F: FnMut(&mut Scheduler) -> View,
48{
49    set_density_default(Density { scale });
50
51    sched.repose(
52        {
53            let scale = scale;
54            move |s: &mut Scheduler| with_density(Density { scale }, || (root_fn)(s))
55        },
56        {
57            let hover_id = hover_id;
58            let pressed_ids = pressed_ids.clone();
59            move |view, _size| {
60                let interactions = repose_ui::Interactions {
61                    hover: hover_id,
62                    pressed: pressed_ids.clone(),
63                };
64
65                with_density(Density { scale }, || {
66                    repose_ui::layout_and_paint(
67                        view,
68                        size_px_u32,
69                        tf_states,
70                        &interactions,
71                        focused,
72                    )
73                })
74            }
75        },
76    )
77}
78
79/// Helper: ensure caret visibility for a TextFieldState inside a given rect (px).
80pub fn tf_ensure_visible_in_rect(state: &mut repose_ui::TextFieldState, inner_rect: Rect) {
81    let font_px = dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
82    let m = measure_text(&state.text, font_px);
83    let caret_x_px = m.positions.get(state.caret_index()).copied().unwrap_or(0.0);
84    state.ensure_caret_visible(
85        caret_x_px,
86        inner_rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP),
87        dp_to_px(2.0),
88    );
89}
90
91#[cfg(feature = "desktop")]
92fn map_cursor(c: repose_core::CursorIcon) -> winit::window::CursorIcon {
93    use winit::window::CursorIcon as W;
94    match c {
95        repose_core::CursorIcon::Default => W::Default,
96        repose_core::CursorIcon::Pointer => W::Pointer,
97        repose_core::CursorIcon::Text => W::Text,
98        repose_core::CursorIcon::EwResize => W::EwResize,
99        repose_core::CursorIcon::NsResize => W::NsResize,
100        repose_core::CursorIcon::Grab => W::Grab,
101        repose_core::CursorIcon::Grabbing => W::Grabbing,
102    }
103}
104
105#[cfg(feature = "desktop")]
106pub fn run_desktop_app(
107    root: impl FnMut(&mut Scheduler, &RenderContext) -> View + 'static,
108) -> anyhow::Result<()> {
109    use std::collections::{HashMap, HashSet};
110    use winit::application::ApplicationHandler;
111    use winit::dpi::{LogicalPosition, LogicalSize, PhysicalSize};
112    use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
113    use winit::event_loop::EventLoop;
114    use winit::keyboard::{KeyCode, PhysicalKey};
115    use winit::window::{ImePurpose, Window, WindowAttributes};
116
117    use crate::a11y::A11yTree;
118
119    struct ReposeActivationHandler {
120        initial_tree: Option<accesskit::TreeUpdate>,
121    }
122
123    impl accesskit::ActivationHandler for ReposeActivationHandler {
124        fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
125            self.initial_tree.take()
126        }
127    }
128
129    struct ReposeDeactivationHandler;
130
131    impl accesskit::DeactivationHandler for ReposeDeactivationHandler {
132        fn deactivate_accessibility(&mut self) {
133            // Nothing to clean up for now
134        }
135    }
136
137    struct App {
138        root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>,
139        render: RenderContext,
140        window: Option<Arc<Window>>,
141        backend: Option<repose_render_wgpu::WgpuBackend>,
142        sched: Scheduler,
143        inspector: repose_devtools::Inspector,
144        frame_cache: Option<Frame>,
145        mouse_pos_px: (f32, f32),
146        modifiers: Modifiers,
147        textfield_states: HashMap<u64, Rc<RefCell<TextFieldState>>>,
148        ime_preedit: bool,
149        hover_id: Option<u64>,
150        capture_id: Option<u64>,
151        pressed_ids: HashSet<u64>,
152
153        // Drag & Drop (internal)
154        mouse_down_pos_px: Option<(f32, f32)>,
155        drag: Option<DragSession>,
156
157        // Files
158        pending_dropped_files: Vec<std::path::PathBuf>,
159        pending_drop_pos_px: Option<(f32, f32)>,
160
161        // External file drag hover (HoveredFile / Cancelled)
162        external_file_drag: bool,
163        hovered_files: Vec<std::path::PathBuf>,
164
165        key_pressed_active: Option<u64>,
166        clipboard: Option<clipawl::Clipboard>,
167        a11y: Box<dyn A11yBridge>,
168        last_focus: Option<u64>,
169
170        accesskit_adapter: Option<Adapter>,
171        a11y_actions: Arc<Mutex<Vec<accesskit::ActionRequest>>>,
172        a11y_tree: A11yTree,
173
174        last_redraw: Instant,
175        pending_redraw: bool,
176    }
177
178    impl App {
179        fn process_a11y_actions(&mut self) {
180            let mut actions = self.a11y_actions.lock().unwrap();
181            if actions.is_empty() {
182                return;
183            }
184            let pending = actions.drain(..).collect::<Vec<_>>();
185            drop(actions);
186
187            let Some(f) = &self.frame_cache else {
188                return;
189            };
190
191            for req in pending {
192                let target_id = req.target.0;
193                match req.action {
194                    accesskit::Action::Click => {
195                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id) {
196                            if let Some(cb) = &hit.on_click {
197                                cb();
198                                self.request_redraw();
199                            }
200                        }
201                    }
202                    accesskit::Action::Focus => {
203                        self.sched.focused = Some(target_id);
204                        self.request_redraw();
205                    }
206                    _ => {}
207                }
208            }
209        }
210
211        fn new(root: Box<dyn FnMut(&mut Scheduler, &RenderContext) -> View>) -> Self {
212            Self {
213                root,
214                render: RenderContext::new(),
215                window: None,
216                backend: None,
217                sched: Scheduler::new(),
218                inspector: repose_devtools::Inspector::new(),
219                frame_cache: None,
220                mouse_pos_px: (0.0, 0.0),
221                modifiers: Modifiers::default(),
222                textfield_states: HashMap::new(),
223                ime_preedit: false,
224                hover_id: None,
225                capture_id: None,
226                pressed_ids: HashSet::new(),
227                mouse_down_pos_px: None,
228                drag: None,
229                pending_dropped_files: Vec::new(),
230                pending_drop_pos_px: None,
231
232                external_file_drag: false,
233                hovered_files: Vec::new(),
234
235                key_pressed_active: None,
236                clipboard: None,
237                a11y: {
238                    #[cfg(target_os = "linux")]
239                    {
240                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
241                    }
242                    #[cfg(not(target_os = "linux"))]
243                    {
244                        Box::new(NoopA11y) as Box<dyn A11yBridge>
245                    }
246                },
247                last_focus: None,
248
249                accesskit_adapter: None,
250                a11y_actions: Arc::new(Mutex::new(Vec::new())),
251                a11y_tree: A11yTree::default(),
252
253                last_redraw: Instant::now(),
254                pending_redraw: false,
255            }
256        }
257
258        fn request_redraw(&self) {
259            if let Some(w) = &self.window {
260                w.request_redraw();
261            }
262        }
263
264        // Ensure caret is visible after edits/moves (all units in px)
265        fn tf_ensure_caret_visible(st: &mut TextFieldState) {
266            let font_px = dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
267            let m = measure_text(&st.text, font_px);
268            let caret_x_px = m.positions.get(st.caret_index()).copied().unwrap_or(0.0);
269            let iw = st.inner_width;
270            st.ensure_caret_visible(caret_x_px, iw, dp_to_px(2.0));
271        }
272
273        fn copy_to_clipboard(&mut self, text: String) {
274            if let Some(cb) = &mut self.clipboard {
275                let _ = pollster::block_on(cb.set_text(&text));
276            }
277        }
278
279        fn paste_from_clipboard(&mut self) -> Option<String> {
280            if let Some(cb) = &mut self.clipboard {
281                match pollster::block_on(cb.get_text()) {
282                    Ok(t) => Some(t),
283                    Err(e) => {
284                        eprintln!("Paste error: {}", e);
285                        None
286                    }
287                }
288            } else {
289                None
290            }
291        }
292
293        fn process_render_commands(&mut self) {
294            let Some(backend) = &mut self.backend else {
295                return;
296            };
297
298            for cmd in self.render.drain() {
299                match cmd {
300                    RenderCommand::SetImageEncoded {
301                        handle,
302                        bytes,
303                        srgb,
304                    } => {
305                        let _ = backend.set_image_from_bytes(handle, &bytes, srgb);
306                    }
307                    RenderCommand::SetImageRgba8 {
308                        handle,
309                        w,
310                        h,
311                        rgba,
312                        srgb,
313                    } => {
314                        let _ = backend.set_image_rgba8(handle, w, h, &rgba, srgb);
315                    }
316                    RenderCommand::SetImageNv12 {
317                        handle,
318                        w,
319                        h,
320                        y,
321                        uv,
322                        full_range,
323                    } => {
324                        let _ = backend.set_image_nv12(handle, w, h, &y, &uv, full_range);
325                    }
326                    RenderCommand::RemoveImage { handle } => {
327                        backend.remove_image(handle);
328                    }
329                }
330            }
331        }
332
333        fn reset_pointer_state(&mut self) {
334            self.capture_id = None;
335            self.pressed_ids.clear();
336            self.mouse_down_pos_px = None;
337            self.drag = None;
338            self.hover_id = None;
339        }
340
341        fn overlay_drag_indicator(&self, scene: &mut Scene) {
342            let dragging_internal = self.drag.is_some();
343            let dragging_files = self.external_file_drag;
344
345            if !(dragging_internal || dragging_files) {
346                return;
347            }
348
349            let pos = Vec2 {
350                x: self.mouse_pos_px.0,
351                y: self.mouse_pos_px.1,
352            };
353
354            // Highlight best drop target under cursor (if we have a frame)
355            if let Some(f) = &self.frame_cache {
356                if let Some(tid) = Self::dnd_target_id_at(f, pos) {
357                    if let Some(hit) = f.hit_regions.iter().find(|h| h.id == tid) {
358                        let color = if dragging_files {
359                            Color::from_hex("#FFAA00")
360                        } else {
361                            Color::from_hex("#44AAFF")
362                        };
363                        scene.nodes.push(SceneNode::Border {
364                            rect: hit.rect,
365                            color,
366                            width: dp_to_px(2.0),
367                            radius: dp_to_px(8.0),
368                        });
369                    }
370                }
371            }
372
373            // Cursor badge
374            let label = if dragging_files {
375                "FILE DROP"
376            } else {
377                "DRAGGING"
378            };
379            let bg = if dragging_files {
380                Color::from_hex("#FFAA0077")
381            } else {
382                Color::from_hex("#44AAFF77")
383            };
384
385            let badge = Rect {
386                x: pos.x + dp_to_px(12.0),
387                y: pos.y + dp_to_px(12.0),
388                w: dp_to_px(110.0),
389                h: dp_to_px(24.0),
390            };
391
392            scene.nodes.push(SceneNode::Rect {
393                rect: badge,
394                brush: Brush::Solid(bg),
395                radius: dp_to_px(8.0),
396            });
397            scene.nodes.push(SceneNode::Text {
398                rect: Rect {
399                    x: badge.x + dp_to_px(8.0),
400                    y: badge.y + dp_to_px(6.0),
401                    w: 0.0,
402                    h: dp_to_px(14.0),
403                },
404                text: Arc::<str>::from(label),
405                color: Color::WHITE,
406                size: dp_to_px(12.0),
407            });
408        }
409
410        fn is_textfield(&self, id: u64) -> bool {
411            if let Some(f) = &self.frame_cache {
412                f.semantics_nodes
413                    .iter()
414                    .any(|n| n.id == id && n.role == Role::TextField)
415            } else {
416                false
417            }
418        }
419
420        fn is_multiline_id(&self, id: u64) -> bool {
421            if let Some(f) = &self.frame_cache {
422                f.hit_regions
423                    .iter()
424                    .find(|h| h.id == id)
425                    .map(|h| h.tf_multiline)
426                    .unwrap_or(false)
427            } else {
428                false
429            }
430        }
431
432        fn hit_by_id(f: &Frame, id: u64) -> Option<&HitRegion> {
433            f.hit_regions.iter().find(|h| h.id == id)
434        }
435
436        fn padding_px(&self) -> f32 {
437            dp_to_px(TF_PADDING_X_DP)
438        }
439
440        fn dp_px(&self, dp: f32) -> f32 {
441            dp_to_px(dp)
442        }
443    }
444
445    impl ApplicationHandler<()> for App {
446        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
447            self.clipboard = clipawl::Clipboard::new().ok();
448
449            if self.window.is_none() {
450                match el.create_window(
451                    WindowAttributes::default()
452                        .with_title("Repose")
453                        .with_inner_size(PhysicalSize::new(1280, 800))
454                        .with_visible(false),
455                ) {
456                    Ok(win) => {
457                        let w = Arc::new(win);
458
459                        let activation_handler = ReposeActivationHandler {
460                            initial_tree: Some(A11yTree::initial_tree()),
461                        };
462
463                        let action_handler = ReposeActionHandler {
464                            pending_actions: self.a11y_actions.clone(),
465                        };
466
467                        let deactivation_handler = ReposeDeactivationHandler;
468
469                        let adapter = Adapter::with_direct_handlers(
470                            el,
471                            &w,
472                            activation_handler,
473                            action_handler,
474                            deactivation_handler,
475                        );
476
477                        self.accesskit_adapter = Some(adapter);
478
479                        w.set_visible(true);
480
481                        let size = w.inner_size();
482                        self.sched.size = (size.width, size.height);
483
484                        match repose_render_wgpu::WgpuBackend::new(w.clone()) {
485                            Ok(b) => {
486                                self.backend = Some(b);
487                                self.window = Some(w);
488                                self.request_redraw();
489                            }
490                            Err(e) => {
491                                log::error!("Failed to create WGPU backend: {e:?}");
492                                el.exit();
493                            }
494                        }
495                    }
496                    Err(e) => {
497                        log::error!("Failed to create window: {e:?}");
498                        el.exit();
499                    }
500                }
501            }
502        }
503
504        fn window_event(
505            &mut self,
506            el: &winit::event_loop::ActiveEventLoop,
507            _id: winit::window::WindowId,
508            event: WindowEvent,
509        ) {
510            // Process AccessKit events first!
511            if let Some(adapter) = &mut self.accesskit_adapter {
512                adapter.process_event(self.window.as_ref().unwrap(), &event);
513            }
514
515            match event {
516                WindowEvent::CloseRequested => {
517                    el.exit();
518                }
519
520                WindowEvent::Focused(false) => {
521                    // Defensive reset: Wayland/KDE can "eat" releases during DnD.
522                    self.external_file_drag = false;
523                    self.hovered_files.clear();
524                    self.reset_pointer_state();
525
526                    if let Some(w) = &self.window {
527                        w.set_ime_allowed(false);
528                    }
529                    self.ime_preedit = false;
530
531                    self.request_redraw();
532                }
533
534                WindowEvent::HoveredFile(path) => {
535                    // Mark external drag active and keep a small bounded list
536                    self.external_file_drag = true;
537                    if self.hovered_files.len() < 32 {
538                        self.hovered_files.push(path);
539                    }
540                    // Update drop position (best effort)
541                    if self.pending_drop_pos_px.is_none() {
542                        self.pending_drop_pos_px = Some(self.mouse_pos_px);
543                    }
544                    self.request_redraw();
545                }
546
547                WindowEvent::HoveredFileCancelled => {
548                    self.external_file_drag = false;
549                    self.hovered_files.clear();
550
551                    // Defensive: cancel any internal capture/drag that might be left stuck
552                    self.reset_pointer_state();
553
554                    self.request_redraw();
555                }
556
557                WindowEvent::DroppedFile(path) => {
558                    // DroppedFile is emitted once per file. Batch them.
559                    self.pending_dropped_files.push(path);
560                    if self.pending_drop_pos_px.is_none() {
561                        self.pending_drop_pos_px = Some(self.mouse_pos_px);
562                    }
563
564                    // Drop ends the external file drag session.
565                    self.external_file_drag = false;
566                    self.hovered_files.clear();
567
568                    self.request_redraw();
569                }
570
571                WindowEvent::Resized(size) => {
572                    self.sched.size = (size.width, size.height);
573                    if let Some(b) = &mut self.backend {
574                        b.configure_surface(size.width, size.height);
575                    }
576                    if let Some(w) = &self.window {
577                        let sf = w.scale_factor() as f32;
578                        let dp_w = size.width as f32 / sf;
579                        let dp_h = size.height as f32 / sf;
580                        log::info!(
581                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
582                            size.width,
583                            size.height,
584                            sf,
585                            dp_w as i32,
586                            dp_h as i32
587                        );
588                    }
589                    self.request_redraw();
590                }
591
592                WindowEvent::CursorMoved { position, .. } => {
593                    self.mouse_pos_px = (position.x as f32, position.y as f32);
594
595                    let pos = Vec2 {
596                        x: self.mouse_pos_px.0,
597                        y: self.mouse_pos_px.1,
598                    };
599
600                    if self.drag.is_some() {
601                        self.dnd_update_over(pos);
602                        self.request_redraw();
603                        return;
604                    }
605
606                    if self.dnd_try_begin(pos) {
607                        self.dnd_update_over(pos);
608                        return;
609                    }
610
611                    // Inspector hover
612                    if self.inspector.hud.inspector_enabled
613                        && let Some(f) = &self.frame_cache
614                    {
615                        let hover_rect = f
616                            .hit_regions
617                            .iter()
618                            .find(|h| {
619                                h.rect.contains(Vec2 {
620                                    x: self.mouse_pos_px.0,
621                                    y: self.mouse_pos_px.1,
622                                })
623                            })
624                            .map(|h| h.rect);
625                        self.inspector.hud.set_hovered(hover_rect);
626                        self.request_redraw();
627                    }
628
629                    // TextField/TextArea drag selection (if captured)
630                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id)
631                        && self.is_textfield(cid)
632                    {
633                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
634                            let key = self.tf_key_of(cid);
635                            if let Some(state_rc) = self.textfield_states.get(&key) {
636                                let mut st = state_rc.borrow_mut();
637
638                                let pad_x = dp_to_px(TF_PADDING_X_DP);
639                                let inner_x = hit.rect.x + pad_x;
640                                let inner_y = hit.rect.y + dp_to_px(8.0);
641                                let inner_w = (hit.rect.w - 2.0 * pad_x).max(1.0);
642                                let inner_h = (hit.rect.h - dp_to_px(16.0)).max(1.0);
643
644                                st.set_inner_width(inner_w);
645                                st.set_inner_height(inner_h);
646
647                                let content_x =
648                                    (self.mouse_pos_px.0 - inner_x + st.scroll_offset).max(0.0);
649                                let content_y =
650                                    (self.mouse_pos_px.1 - inner_y + st.scroll_offset_y).max(0.0);
651
652                                let font_px =
653                                    dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
654
655                                let idx = if hit.tf_multiline {
656                                    index_for_xy_bytes(
657                                        &st.text, font_px, inner_w, content_x, content_y,
658                                    )
659                                } else {
660                                    index_for_x_bytes(&st.text, font_px, content_x)
661                                };
662
663                                st.drag_to(idx);
664
665                                // Ensure caret visible
666                                if hit.tf_multiline {
667                                    let (cx, cy, _) = caret_xy_for_byte(
668                                        &st.text,
669                                        font_px,
670                                        inner_w,
671                                        st.caret_index(),
672                                    );
673                                    st.ensure_caret_visible_xy(
674                                        cx,
675                                        cy,
676                                        inner_w,
677                                        inner_h,
678                                        dp_to_px(2.0),
679                                    );
680                                } else {
681                                    let m = measure_text(&st.text, font_px);
682                                    let cx =
683                                        m.positions.get(st.caret_index()).copied().unwrap_or(0.0);
684                                    st.ensure_caret_visible(cx, inner_w, dp_to_px(2.0));
685                                }
686
687                                self.request_redraw();
688                            }
689                        }
690                    }
691
692                    // Pointer routing: hover + move/capture
693                    if let Some(f) = &self.frame_cache {
694                        // Determine topmost hit
695                        let pos = Vec2 {
696                            x: self.mouse_pos_px.0,
697                            y: self.mouse_pos_px.1,
698                        };
699                        let top = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos));
700
701                        // Update cursor icon based on hit
702                        if let Some(win) = &self.window {
703                            let c = top
704                                .and_then(|h| h.cursor)
705                                .unwrap_or(repose_core::CursorIcon::Default);
706                            win.set_cursor(winit::window::Cursor::Icon(map_cursor(c)));
707                        }
708
709                        let new_hover = top.map(|h| h.id);
710
711                        // Enter/Leave
712                        if new_hover != self.hover_id {
713                            if let Some(prev_id) = self.hover_id
714                                && let Some(prev) = f.hit_regions.iter().find(|h| h.id == prev_id)
715                                && let Some(cb) = &prev.on_pointer_leave
716                            {
717                                let pe = repose_core::input::PointerEvent {
718                                    id: repose_core::input::PointerId(0),
719                                    kind: repose_core::input::PointerKind::Mouse,
720                                    event: repose_core::input::PointerEventKind::Leave,
721                                    position: pos,
722                                    pressure: 1.0,
723                                    modifiers: self.modifiers,
724                                };
725                                cb(pe);
726                            }
727                            if let Some(h) = top
728                                && let Some(cb) = &h.on_pointer_enter
729                            {
730                                let pe = repose_core::input::PointerEvent {
731                                    id: repose_core::input::PointerId(0),
732                                    kind: repose_core::input::PointerKind::Mouse,
733                                    event: repose_core::input::PointerEventKind::Enter,
734                                    position: pos,
735                                    pressure: 1.0,
736                                    modifiers: self.modifiers,
737                                };
738                                cb(pe);
739                            }
740                            self.hover_id = new_hover;
741                        }
742
743                        // Build PointerEvent
744                        let pe = repose_core::input::PointerEvent {
745                            id: repose_core::input::PointerId(0),
746                            kind: repose_core::input::PointerKind::Mouse,
747                            event: repose_core::input::PointerEventKind::Move,
748                            position: pos,
749                            pressure: 1.0,
750                            modifiers: self.modifiers,
751                        };
752
753                        // Move delivery (captured first)
754                        if let Some(cid) = self.capture_id {
755                            if let Some(h) = f.hit_regions.iter().find(|h| h.id == cid)
756                                && let Some(cb) = &h.on_pointer_move
757                            {
758                                cb(pe.clone());
759                            }
760                        } else if let Some(h) = &top
761                            && let Some(cb) = &h.on_pointer_move
762                        {
763                            cb(pe);
764                        }
765                    }
766                }
767
768                WindowEvent::MouseWheel { delta, .. } => {
769                    // Convert line deltas (logical) to px; pixel delta is already px
770                    let (dx_px, dy_px) = match delta {
771                        MouseScrollDelta::LineDelta(x, y) => {
772                            let unit_px = dp_to_px(60.0);
773                            (-(x * unit_px), -(y * unit_px))
774                        }
775                        MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
776                    };
777                    log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
778
779                    if let Some(f) = &self.frame_cache {
780                        let pos = Vec2 {
781                            x: self.mouse_pos_px.0,
782                            y: self.mouse_pos_px.1,
783                        };
784
785                        for hit in f.hit_regions.iter().rev().filter(|h| h.rect.contains(pos)) {
786                            if let Some(cb) = &hit.on_scroll {
787                                log::debug!("Calling on_scroll for hit region id={}", hit.id);
788                                let before = Vec2 { x: dx_px, y: dy_px };
789                                let leftover = cb(before);
790                                let consumed_x = (before.x - leftover.x).abs() > 0.001;
791                                let consumed_y = (before.y - leftover.y).abs() > 0.001;
792                                if consumed_x || consumed_y {
793                                    self.request_redraw();
794                                    break; // stop after first consumer
795                                }
796                            }
797                        }
798                    }
799                }
800
801                WindowEvent::MouseInput {
802                    state: ElementState::Pressed,
803                    button: MouseButton::Left,
804                    ..
805                } => {
806                    let mut need_announce = false;
807                    if let Some(f) = &self.frame_cache {
808                        let pos = Vec2 {
809                            x: self.mouse_pos_px.0,
810                            y: self.mouse_pos_px.1,
811                        };
812                        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
813                        {
814                            self.mouse_down_pos_px = Some(self.mouse_pos_px);
815                            self.drag = None;
816
817                            // Capture starts on press
818                            self.capture_id = Some(hit.id);
819
820                            // Text input caret placement + begin drag selection
821                            if self.is_textfield(hit.id) {
822                                let key = self.tf_key_of(hit.id);
823                                self.textfield_states.entry(key).or_insert_with(|| {
824                                    Rc::new(RefCell::new(TextFieldState::new()))
825                                });
826                                if let Some(st_rc) = self.textfield_states.get(&key) {
827                                    let mut st = st_rc.borrow_mut();
828                                    let pad = self.padding_px();
829                                    let inner_x = hit.rect.x + pad;
830                                    let inner_y = hit.rect.y + self.dp_px(8.0);
831                                    let content_x =
832                                        (self.mouse_pos_px.0 - inner_x + st.scroll_offset).max(0.0);
833                                    let content_y = (self.mouse_pos_px.1 - inner_y
834                                        + st.scroll_offset_y)
835                                        .max(0.0);
836                                    let font_px = self.dp_px(TF_FONT_DP)
837                                        * repose_core::locals::text_scale().0;
838
839                                    let idx = if hit.tf_multiline {
840                                        textfield::index_for_xy_bytes(
841                                            &st.text,
842                                            font_px,
843                                            hit.rect.w - 2.0 * pad,
844                                            content_x,
845                                            content_y,
846                                        )
847                                    } else {
848                                        textfield::index_for_x_bytes(&st.text, font_px, content_x)
849                                    };
850
851                                    st.begin_drag(idx, self.modifiers.shift);
852
853                                    // Ensure caret visible
854                                    let caret_idx = st.caret_index();
855                                    let iw = st.inner_width;
856                                    let ih = st.inner_height;
857                                    let wrap_w = hit.rect.w - 2.0 * pad;
858                                    if hit.tf_multiline {
859                                        let (cx, cy, _) = textfield::caret_xy_for_byte(
860                                            &st.text, font_px, wrap_w, caret_idx,
861                                        );
862                                        st.ensure_caret_visible_xy(cx, cy, iw, ih, self.dp_px(2.0));
863                                    } else {
864                                        let m = measure_text(&st.text, font_px);
865                                        let cx = m.positions.get(caret_idx).copied().unwrap_or(0.0);
866                                        st.ensure_caret_visible(cx, iw, self.dp_px(2.0));
867                                    }
868                                }
869                            }
870                            // Pressed visual for mouse
871                            self.pressed_ids.insert(hit.id);
872                            // Repaint for pressed state
873                            self.request_redraw();
874
875                            // Focus & IME first for focusables (so state exists)
876                            if hit.focusable {
877                                self.sched.focused = Some(hit.id);
878                                need_announce = true;
879                                let key = self.tf_key_of(hit.id);
880                                self.textfield_states.entry(key).or_insert_with(|| {
881                                    Rc::new(RefCell::new(TextFieldState::new()))
882                                });
883                                if let Some(win) = &self.window {
884                                    let sf = win.scale_factor();
885                                    win.set_ime_allowed(true);
886                                    win.set_ime_purpose(ImePurpose::Normal);
887                                    win.set_ime_cursor_area(
888                                        LogicalPosition::new(
889                                            hit.rect.x as f64 / sf,
890                                            hit.rect.y as f64 / sf,
891                                        ),
892                                        LogicalSize::new(
893                                            hit.rect.w as f64 / sf,
894                                            hit.rect.h as f64 / sf,
895                                        ),
896                                    );
897                                }
898                            }
899
900                            // PointerDown callback (legacy)
901                            if let Some(cb) = &hit.on_pointer_down {
902                                let pe = repose_core::input::PointerEvent {
903                                    id: repose_core::input::PointerId(0),
904                                    kind: repose_core::input::PointerKind::Mouse,
905                                    event: repose_core::input::PointerEventKind::Down(
906                                        repose_core::input::PointerButton::Primary,
907                                    ),
908                                    position: pos,
909                                    pressure: 1.0,
910                                    modifiers: self.modifiers,
911                                };
912                                cb(pe);
913                            }
914
915                            if need_announce {
916                                self.announce_focus_change();
917                            }
918
919                            self.request_redraw();
920                        } else {
921                            // Click outside: drop focus/IME
922                            if self.ime_preedit {
923                                if let Some(win) = &self.window {
924                                    win.set_ime_allowed(false);
925                                }
926                                self.ime_preedit = false;
927                            }
928                            self.sched.focused = None;
929                            self.request_redraw();
930                        }
931                    }
932                }
933
934                WindowEvent::MouseInput {
935                    state: ElementState::Released,
936                    button: MouseButton::Left,
937                    ..
938                } => {
939                    let pos = Vec2 {
940                        x: self.mouse_pos_px.0,
941                        y: self.mouse_pos_px.1,
942                    };
943
944                    if self.drag.is_some() {
945                        self.dnd_finish(pos, true);
946                        self.capture_id = None;
947                        self.pressed_ids.clear();
948                        repose_core::request_frame();
949                        return;
950                    }
951
952                    if let Some(cid) = self.capture_id {
953                        self.pressed_ids.remove(&cid);
954                        self.request_redraw();
955                    }
956
957                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
958                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
959                            if let Some(cb) = &hit.on_pointer_up {
960                                let pos = Vec2 {
961                                    x: self.mouse_pos_px.0,
962                                    y: self.mouse_pos_px.1,
963                                };
964                                let pe = repose_core::input::PointerEvent {
965                                    id: repose_core::input::PointerId(0),
966                                    kind: repose_core::input::PointerKind::Mouse,
967                                    event: repose_core::input::PointerEventKind::Up(
968                                        repose_core::input::PointerButton::Primary,
969                                    ),
970                                    position: pos,
971                                    pressure: 1.0,
972                                    modifiers: self.modifiers,
973                                };
974                                cb(pe);
975                            }
976                        }
977                    }
978
979                    // Click on release if pointer is still over the captured hit region
980                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
981                        let pos = Vec2 {
982                            x: self.mouse_pos_px.0,
983                            y: self.mouse_pos_px.1,
984                        };
985                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid)
986                            && hit.rect.contains(pos)
987                            && let Some(cb) = &hit.on_click
988                        {
989                            cb();
990                            // A11y: announce activation (mouse)
991                            if let Some(node) = f.semantics_nodes.iter().find(|n| n.id == cid) {
992                                let label = node.label.as_deref().unwrap_or("");
993                                self.a11y.announce(&format!("Activated {}", label));
994                            }
995                        }
996                    }
997                    // TextField drag end
998                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id)
999                        && let Some(_sem) = f
1000                            .semantics_nodes
1001                            .iter()
1002                            .find(|n| n.id == cid && n.role == Role::TextField)
1003                    {
1004                        let key = self.tf_key_of(cid);
1005                        if let Some(state_rc) = self.textfield_states.get(&key) {
1006                            state_rc.borrow_mut().end_drag();
1007                        }
1008                    }
1009
1010                    self.capture_id = None;
1011
1012                    repose_core::request_frame();
1013                }
1014
1015                WindowEvent::ModifiersChanged(new_mods) => {
1016                    self.modifiers.shift = new_mods.state().shift_key();
1017                    self.modifiers.ctrl = new_mods.state().control_key();
1018                    self.modifiers.alt = new_mods.state().alt_key();
1019                    self.modifiers.meta = new_mods.state().super_key();
1020                    self.modifiers.command = if cfg!(target_os = "macos") {
1021                        self.modifiers.meta
1022                    } else {
1023                        self.modifiers.ctrl
1024                    };
1025                }
1026
1027                WindowEvent::KeyboardInput {
1028                    event: key_event, ..
1029                } => {
1030                    if key_event.state == ElementState::Pressed && !key_event.repeat {
1031                        match key_event.physical_key {
1032                            PhysicalKey::Code(KeyCode::BrowserBack)
1033                            | PhysicalKey::Code(KeyCode::Escape) => {
1034                                use repose_navigation::back;
1035
1036                                if self.drag.is_some() {
1037                                    self.dnd_cancel();
1038                                    return;
1039                                }
1040
1041                                if !back::handle() {
1042                                    // el.exit();
1043                                }
1044                                return;
1045                            }
1046                            _ => {}
1047                        }
1048                    }
1049                    // Focus traversal: Tab / Shift+Tab
1050                    if matches!(key_event.physical_key, PhysicalKey::Code(KeyCode::Tab)) {
1051                        // Only act on initial press, ignore repeats
1052                        if key_event.state == ElementState::Pressed
1053                            && !key_event.repeat
1054                            && let Some(f) = &self.frame_cache
1055                        {
1056                            let chain = &f.focus_chain;
1057                            if !chain.is_empty() {
1058                                // If a button was "pressed" via keyboard, clear it when we move focus
1059                                if let Some(active) = self.key_pressed_active.take() {
1060                                    self.pressed_ids.remove(&active);
1061                                }
1062
1063                                let shift = self.modifiers.shift;
1064                                let current = self.sched.focused;
1065                                let next = if let Some(cur) = current {
1066                                    if let Some(idx) = chain.iter().position(|&id| id == cur) {
1067                                        if shift {
1068                                            if idx == 0 {
1069                                                chain[chain.len() - 1]
1070                                            } else {
1071                                                chain[idx - 1]
1072                                            }
1073                                        } else {
1074                                            chain[(idx + 1) % chain.len()]
1075                                        }
1076                                    } else {
1077                                        chain[0]
1078                                    }
1079                                } else {
1080                                    chain[0]
1081                                };
1082                                self.sched.focused = Some(next);
1083
1084                                // IME only for TextField
1085                                if let Some(win) = &self.window {
1086                                    if f.semantics_nodes
1087                                        .iter()
1088                                        .any(|n| n.id == next && n.role == Role::TextField)
1089                                    {
1090                                        win.set_ime_allowed(true);
1091                                        win.set_ime_purpose(ImePurpose::Normal);
1092                                    } else {
1093                                        win.set_ime_allowed(false);
1094                                    }
1095                                }
1096                                self.announce_focus_change();
1097                                self.request_redraw();
1098                            }
1099                        }
1100                        return; // swallow Tab
1101                    }
1102
1103                    if key_event.state == ElementState::Pressed
1104                        && !key_event.repeat
1105                        && self.modifiers.command
1106                    {
1107                        use repose_core::shortcuts::Action;
1108
1109                        let handled = match key_event.physical_key {
1110                            PhysicalKey::Code(KeyCode::KeyC) => self.dispatch_action(Action::Copy),
1111                            PhysicalKey::Code(KeyCode::KeyX) => self.dispatch_action(Action::Cut),
1112                            PhysicalKey::Code(KeyCode::KeyV) => self.dispatch_action(Action::Paste),
1113                            PhysicalKey::Code(KeyCode::KeyA) => {
1114                                self.dispatch_action(Action::SelectAll)
1115                            }
1116                            PhysicalKey::Code(KeyCode::KeyZ) => {
1117                                self.dispatch_action(if self.modifiers.shift {
1118                                    Action::Redo
1119                                } else {
1120                                    Action::Undo
1121                                })
1122                            }
1123                            PhysicalKey::Code(KeyCode::KeyF) => self.dispatch_action(Action::Find),
1124                            PhysicalKey::Code(KeyCode::KeyS) => self.dispatch_action(Action::Save),
1125                            _ => false,
1126                        };
1127
1128                        if handled {
1129                            self.request_redraw();
1130                            return;
1131                        }
1132                    }
1133
1134                    if let Some(fid) = self.sched.focused {
1135                        // If focused is NOT a TextField, allow Space/Enter activation
1136                        let is_textfield = if let Some(f) = &self.frame_cache {
1137                            f.semantics_nodes
1138                                .iter()
1139                                .any(|n| n.id == fid && n.role == Role::TextField)
1140                        } else {
1141                            false
1142                        };
1143
1144                        if !is_textfield {
1145                            match key_event.physical_key {
1146                                PhysicalKey::Code(KeyCode::Space)
1147                                | PhysicalKey::Code(KeyCode::Enter) => {
1148                                    if key_event.state == ElementState::Pressed && !key_event.repeat
1149                                    {
1150                                        self.pressed_ids.insert(fid);
1151                                        self.key_pressed_active = Some(fid);
1152                                        self.request_redraw();
1153                                        return;
1154                                    }
1155                                }
1156                                _ => {}
1157                            }
1158                        }
1159                    }
1160
1161                    // Keyboard activation for focused TextField submit on Enter
1162                    if key_event.state == ElementState::Pressed
1163                        && !key_event.repeat
1164                        && let PhysicalKey::Code(KeyCode::Enter) = key_event.physical_key
1165                        && let Some(focused_id) = self.sched.focused
1166                        && let Some(f) = &self.frame_cache
1167                        && let Some(hit) = f.hit_regions.iter().find(|h| h.id == focused_id)
1168                        && let Some(on_submit) = &hit.on_text_submit
1169                    {
1170                        let key = self.tf_key_of(focused_id);
1171
1172                        if let Some(state) = self.textfield_states.get(&key) {
1173                            let text = state.borrow().text.clone();
1174                            on_submit(text);
1175                            self.request_redraw();
1176                            return; // don't continue as button activation
1177                        }
1178                    }
1179
1180                    if key_event.state == ElementState::Pressed {
1181                        // Inspector hotkey: Ctrl+Shift+I
1182                        if self.modifiers.ctrl
1183                            && self.modifiers.shift
1184                            && let PhysicalKey::Code(KeyCode::KeyI) = key_event.physical_key
1185                        {
1186                            self.inspector.hud.toggle_inspector();
1187                            self.request_redraw();
1188                            return;
1189                        }
1190
1191                        // TextField navigation/edit
1192                        if let Some(focused_id) = self.sched.focused {
1193                            let key = self.tf_key_of(focused_id);
1194                            if let Some(state_rc) = self.textfield_states.get(&key) {
1195                                let mut state = state_rc.borrow_mut();
1196                                match key_event.physical_key {
1197                                    PhysicalKey::Code(KeyCode::Backspace) => {
1198                                        state.delete_backward();
1199                                        let new_text = state.text.clone();
1200                                        self.notify_text_change(focused_id, new_text);
1201                                        App::tf_ensure_caret_visible(&mut state);
1202                                        self.request_redraw();
1203                                    }
1204                                    PhysicalKey::Code(KeyCode::Delete) => {
1205                                        state.delete_forward();
1206                                        let new_text = state.text.clone();
1207                                        self.notify_text_change(focused_id, new_text);
1208                                        App::tf_ensure_caret_visible(&mut state);
1209                                        self.request_redraw();
1210                                    }
1211                                    PhysicalKey::Code(KeyCode::ArrowLeft) => {
1212                                        state.move_cursor(-1, self.modifiers.shift);
1213                                        App::tf_ensure_caret_visible(&mut state);
1214                                        self.request_redraw();
1215                                    }
1216                                    PhysicalKey::Code(KeyCode::ArrowRight) => {
1217                                        state.move_cursor(1, self.modifiers.shift);
1218                                        App::tf_ensure_caret_visible(&mut state);
1219                                        self.request_redraw();
1220                                    }
1221                                    PhysicalKey::Code(KeyCode::Home) => {
1222                                        state.selection = 0..0;
1223                                        App::tf_ensure_caret_visible(&mut state);
1224                                        self.request_redraw();
1225                                    }
1226                                    PhysicalKey::Code(KeyCode::End) => {
1227                                        {
1228                                            let end = state.text.len();
1229                                            state.selection = end..end;
1230                                        }
1231                                        App::tf_ensure_caret_visible(&mut state);
1232                                        self.request_redraw();
1233                                    }
1234                                    PhysicalKey::Code(KeyCode::KeyA) if self.modifiers.ctrl => {
1235                                        state.selection = 0..state.text.len();
1236                                        App::tf_ensure_caret_visible(&mut state);
1237                                        self.request_redraw();
1238                                    }
1239                                    _ => {}
1240                                }
1241                            }
1242                            if self.modifiers.ctrl {
1243                                match key_event.physical_key {
1244                                    PhysicalKey::Code(KeyCode::KeyC) => {
1245                                        if let Some(fid) = self.sched.focused {
1246                                            let key = self.tf_key_of(fid);
1247                                            if let Some(state) = self.textfield_states.get(&key) {
1248                                                let txt = state.borrow().selected_text();
1249                                                if !txt.is_empty() {
1250                                                    let _ = self.copy_to_clipboard(txt);
1251                                                }
1252                                            }
1253                                        }
1254                                        return;
1255                                    }
1256                                    PhysicalKey::Code(KeyCode::KeyX) => {
1257                                        if let Some(fid) = self.sched.focused {
1258                                            let key = self.tf_key_of(fid);
1259                                            if let Some(state_rc) =
1260                                                self.textfield_states.get(&key).cloned()
1261                                            {
1262                                                // Copy
1263                                                let txt = state_rc.borrow().selected_text();
1264                                                if !txt.is_empty() {
1265                                                    {
1266                                                        let _ = self.copy_to_clipboard(txt.clone());
1267                                                    }
1268                                                    // Cut (delete selection)
1269                                                    {
1270                                                        let mut st = state_rc.borrow_mut();
1271                                                        st.insert_text(""); // replace selection with empty
1272                                                        let new_text = st.text.clone();
1273                                                        self.notify_text_change(
1274                                                            focused_id, new_text,
1275                                                        );
1276                                                        App::tf_ensure_caret_visible(&mut st);
1277                                                    }
1278                                                    self.request_redraw();
1279                                                }
1280                                            }
1281                                        }
1282                                        return;
1283                                    }
1284                                    PhysicalKey::Code(KeyCode::KeyV) => {
1285                                        if let Some(fid) = self.sched.focused {
1286                                            let key = self.tf_key_of(fid);
1287                                            if let Some(state_rc) =
1288                                                self.textfield_states.get(&key).cloned()
1289                                                && let Some(mut txt) = self.paste_from_clipboard()
1290                                            {
1291                                                // Single-line TextField: strip control/newlines
1292                                                txt.retain(|c| {
1293                                                    !c.is_control() && c != '\n' && c != '\r'
1294                                                });
1295                                                if !txt.is_empty() {
1296                                                    let mut st = state_rc.borrow_mut();
1297                                                    st.insert_text(&txt);
1298                                                    let new_text = st.text.clone();
1299                                                    self.notify_text_change(focused_id, new_text);
1300                                                    App::tf_ensure_caret_visible(&mut st);
1301                                                    self.request_redraw();
1302                                                }
1303                                            }
1304                                        }
1305                                        return;
1306                                    }
1307                                    _ => {}
1308                                }
1309                            }
1310                        }
1311
1312                        // Plain text input when IME is not active
1313                        if !self.ime_preedit
1314                            && !self.modifiers.ctrl
1315                            && !self.modifiers.alt
1316                            && !self.modifiers.meta
1317                            && let Some(raw) = key_event.text.as_deref()
1318                        {
1319                            let text: String = raw
1320                                .chars()
1321                                .filter(|c| !c.is_control() && *c != '\n' && *c != '\r')
1322                                .collect();
1323                            if !text.is_empty()
1324                                && let Some(fid) = self.sched.focused
1325                            {
1326                                let key = self.tf_key_of(fid);
1327                                if let Some(state_rc) = self.textfield_states.get(&key) {
1328                                    let mut st = state_rc.borrow_mut();
1329                                    st.insert_text(&text);
1330                                    self.notify_text_change(fid, st.text.clone());
1331                                    App::tf_ensure_caret_visible(&mut st);
1332                                    self.request_redraw();
1333                                }
1334                            }
1335                        }
1336                    } else if key_event.state == ElementState::Released {
1337                        // Finish keyboard activation on release (Space/Enter)
1338                        if let Some(active_id) = self.key_pressed_active {
1339                            match key_event.physical_key {
1340                                PhysicalKey::Code(KeyCode::Space)
1341                                | PhysicalKey::Code(KeyCode::Enter) => {
1342                                    self.pressed_ids.remove(&active_id);
1343                                    self.key_pressed_active = None;
1344
1345                                    if let Some(f) = &self.frame_cache
1346                                        && let Some(hit) =
1347                                            f.hit_regions.iter().find(|h| h.id == active_id)
1348                                        && let Some(cb) = &hit.on_click
1349                                    {
1350                                        cb();
1351                                        if let Some(node) =
1352                                            f.semantics_nodes.iter().find(|n| n.id == active_id)
1353                                        {
1354                                            let label = node.label.as_deref().unwrap_or("");
1355                                            self.a11y.announce(&format!("Activated {}", label));
1356                                        }
1357                                    }
1358                                    self.request_redraw();
1359                                }
1360                                _ => {}
1361                            }
1362                        }
1363                    }
1364                }
1365
1366                WindowEvent::Ime(ime) => {
1367                    use winit::event::Ime;
1368                    if let Some(focused_id) = self.sched.focused {
1369                        let key = self.tf_key_of(focused_id);
1370                        if let Some(state_rc) = self.textfield_states.get(&key) {
1371                            let mut state = state_rc.borrow_mut();
1372                            match ime {
1373                                Ime::Enabled => {
1374                                    // IME allowed, but not necessarily composing
1375                                    self.ime_preedit = false;
1376                                }
1377                                Ime::Preedit(text, cursor) => {
1378                                    let cursor_usize = cursor.map(|(a, b)| (a, b));
1379                                    state.set_composition(text.clone(), cursor_usize);
1380                                    self.ime_preedit = !text.is_empty();
1381                                    if let Some(f) = &self.frame_cache
1382                                        && let Some(hit) =
1383                                            f.hit_regions.iter().find(|h| h.id == focused_id)
1384                                    {
1385                                        let inner = Rect {
1386                                            x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
1387                                            y: hit.rect.y,
1388                                            w: hit.rect.w,
1389                                            h: hit.rect.h,
1390                                        };
1391                                        tf_ensure_visible_in_rect(&mut state, inner);
1392                                    }
1393                                    // notify on-change if you wired it:
1394                                    self.notify_text_change(focused_id, state.text.clone());
1395                                    self.request_redraw();
1396                                }
1397                                Ime::Commit(text) => {
1398                                    state.commit_composition(text);
1399                                    self.ime_preedit = false;
1400                                    if let Some(f) = &self.frame_cache
1401                                        && let Some(hit) =
1402                                            f.hit_regions.iter().find(|h| h.id == focused_id)
1403                                    {
1404                                        let inner = Rect {
1405                                            x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
1406                                            y: hit.rect.y,
1407                                            w: hit.rect.w,
1408                                            h: hit.rect.h,
1409                                        };
1410                                        tf_ensure_visible_in_rect(&mut state, inner);
1411                                    }
1412                                    self.notify_text_change(focused_id, state.text.clone());
1413                                    self.request_redraw();
1414                                }
1415                                Ime::Disabled => {
1416                                    self.ime_preedit = false;
1417                                    if state.composition.is_some() {
1418                                        state.cancel_composition();
1419                                        if let Some(f) = &self.frame_cache
1420                                            && let Some(hit) =
1421                                                f.hit_regions.iter().find(|h| h.id == focused_id)
1422                                        {
1423                                            let inner = Rect {
1424                                                x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
1425                                                y: hit.rect.y,
1426                                                w: hit.rect.w,
1427                                                h: hit.rect.h,
1428                                            };
1429                                            tf_ensure_visible_in_rect(&mut state, inner);
1430                                        }
1431                                        self.notify_text_change(focused_id, state.text.clone());
1432                                    }
1433                                    self.request_redraw();
1434                                }
1435                            }
1436                        }
1437                    }
1438                }
1439
1440                WindowEvent::RedrawRequested => {
1441                    // 1. Process any pending A11y actions (clicks from screen reader)
1442                    self.process_a11y_actions();
1443                    self.dispatch_file_drop_now();
1444                    self.process_render_commands();
1445
1446                    let Some(win) = self.window.as_ref() else {
1447                        return;
1448                    };
1449                    if self.backend.is_none() {
1450                        return;
1451                    }
1452
1453                    let t0 = Instant::now();
1454                    let scale = win.scale_factor() as f32;
1455                    let size_px_u32 = self.sched.size;
1456                    let focused = self.sched.focused;
1457
1458                    let rc = self.render.clone();
1459                    let root_fn = &mut self.root;
1460                    let mut composed_root = |s: &mut Scheduler| (root_fn)(s, &rc);
1461
1462                    let frame = compose_frame(
1463                        &mut self.sched,
1464                        &mut composed_root,
1465                        scale,
1466                        size_px_u32,
1467                        self.hover_id,
1468                        &self.pressed_ids,
1469                        &self.textfield_states,
1470                        focused,
1471                    );
1472
1473                    let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1474
1475                    // UPDATE ACCESSIBILITY TREE
1476                    if let Some(adapter) = &mut self.accesskit_adapter {
1477                        let win = self.window.as_ref().unwrap();
1478                        let scale = win.scale_factor();
1479                        if let Some(update) =
1480                            self.a11y_tree
1481                                .update(&frame.semantics_nodes, scale, self.sched.focused)
1482                        {
1483                            adapter.update_if_active(|| update);
1484                        }
1485                    }
1486
1487                    // Render
1488                    let mut scene = frame.scene.clone();
1489                    // Update HUD metrics before overlay draws
1490                    self.inspector.hud.metrics = Some(repose_devtools::Metrics {
1491                        build_layout_ms,
1492                        scene_nodes: scene.nodes.len(),
1493                    });
1494                    self.inspector.frame(&mut scene);
1495
1496                    // Drag indicator overlay (internal + file drop)
1497                    self.overlay_drag_indicator(&mut scene);
1498
1499                    // Now borrow backend mutably only for the frame() call
1500                    let win = self.window.as_ref().unwrap();
1501                    let scale = win.scale_factor() as f32;
1502                    if let Some(backend) = self.backend.as_mut() {
1503                        backend.frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
1504                    }
1505
1506                    self.frame_cache = Some(frame);
1507                    self.last_redraw = Instant::now();
1508                }
1509
1510                _ => {}
1511            }
1512        }
1513
1514        fn about_to_wait(&mut self, el: &winit::event_loop::ActiveEventLoop) {
1515            if take_frame_request() {
1516                self.pending_redraw = true;
1517            }
1518            if !self.pending_redraw {
1519                return;
1520            }
1521
1522            let now = Instant::now();
1523            let interval = web_time::Duration::from_millis(16);
1524
1525            if now.saturating_duration_since(self.last_redraw) >= interval {
1526                self.pending_redraw = false;
1527                self.request_redraw();
1528                self.last_redraw = now;
1529            } else {
1530                el.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
1531                    self.last_redraw + interval,
1532                ));
1533            }
1534        }
1535
1536        fn new_events(
1537            &mut self,
1538            _: &winit::event_loop::ActiveEventLoop,
1539            _: winit::event::StartCause,
1540        ) {
1541        }
1542        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {}
1543        fn device_event(
1544            &mut self,
1545            _: &winit::event_loop::ActiveEventLoop,
1546            _: winit::event::DeviceId,
1547            _: winit::event::DeviceEvent,
1548        ) {
1549        }
1550        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1551        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1552        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1553    }
1554
1555    impl App {
1556        fn announce_focus_change(&mut self) {
1557            if let Some(f) = &self.frame_cache {
1558                let focused_node = self
1559                    .sched
1560                    .focused
1561                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1562                self.a11y.focus_changed(focused_node);
1563            }
1564        }
1565
1566        fn notify_text_change(&self, id: u64, text: String) {
1567            if let Some(f) = &self.frame_cache
1568                && let Some(h) = f.hit_regions.iter().find(|h| h.id == id)
1569                && let Some(cb) = &h.on_text_change
1570            {
1571                cb(text);
1572            }
1573        }
1574
1575        fn tf_key_of(&self, visual_id: u64) -> u64 {
1576            if let Some(f) = &self.frame_cache
1577                && let Some(hr) = f.hit_regions.iter().find(|h| h.id == visual_id)
1578            {
1579                return hr.tf_state_key.unwrap_or(hr.id);
1580            }
1581            visual_id
1582        }
1583
1584        fn dispatch_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
1585            use repose_core::shortcuts;
1586
1587            if let (Some(f), Some(fid)) = (&self.frame_cache, self.sched.focused) {
1588                if let Some(hit) = f.hit_regions.iter().find(|h| h.id == fid) {
1589                    if let Some(cb) = &hit.on_action {
1590                        if cb(action.clone()) {
1591                            return true;
1592                        }
1593                    }
1594                }
1595            }
1596
1597            if shortcuts::handle(action.clone()) {
1598                return true;
1599            }
1600
1601            self.dispatch_default_action(action)
1602        }
1603
1604        fn dispatch_default_action(&mut self, action: repose_core::shortcuts::Action) -> bool {
1605            use repose_core::shortcuts::Action;
1606
1607            let Some(fid) = self.sched.focused else {
1608                return false;
1609            };
1610            let key = self.tf_key_of(fid);
1611            let Some(state_rc) = self.textfield_states.get(&key).cloned() else {
1612                return false;
1613            };
1614
1615            match action {
1616                Action::Copy => {
1617                    let txt = state_rc.borrow().selected_text();
1618                    if txt.is_empty() {
1619                        return false;
1620                    }
1621                    self.copy_to_clipboard(txt);
1622                    true
1623                }
1624                Action::Cut => {
1625                    let txt = state_rc.borrow().selected_text();
1626                    if txt.is_empty() {
1627                        return false;
1628                    }
1629                    self.copy_to_clipboard(txt);
1630                    {
1631                        let mut st = state_rc.borrow_mut();
1632                        st.insert_text("");
1633                        self.notify_text_change(fid, st.text.clone());
1634                        App::tf_ensure_caret_visible(&mut st);
1635                    }
1636                    true
1637                }
1638                Action::Paste => {
1639                    let Some(mut txt) = self.paste_from_clipboard() else {
1640                        return false;
1641                    };
1642                    txt.retain(|c| !c.is_control() && c != '\n' && c != '\r');
1643                    if txt.is_empty() {
1644                        return false;
1645                    }
1646                    {
1647                        let mut st = state_rc.borrow_mut();
1648                        st.insert_text(&txt);
1649                        self.notify_text_change(fid, st.text.clone());
1650                        App::tf_ensure_caret_visible(&mut st);
1651                    }
1652                    true
1653                }
1654                Action::SelectAll => {
1655                    {
1656                        let mut st = state_rc.borrow_mut();
1657                        st.selection = 0..st.text.len();
1658                        App::tf_ensure_caret_visible(&mut st);
1659                    }
1660                    true
1661                }
1662                _ => false,
1663            }
1664        }
1665
1666        fn is_dnd_target(hit: &HitRegion) -> bool {
1667            hit.on_drop.is_some()
1668                || hit.on_drag_enter.is_some()
1669                || hit.on_drag_over.is_some()
1670                || hit.on_drag_leave.is_some()
1671        }
1672
1673        fn dnd_slop_px(&self) -> f32 {
1674            dp_to_px(6.0)
1675        }
1676
1677        fn dnd_target_id_at(f: &Frame, pos: Vec2) -> Option<u64> {
1678            f.hit_regions
1679                .iter()
1680                .rev()
1681                .filter(|h| h.rect.contains(pos))
1682                .find(|h| Self::is_dnd_target(h))
1683                .map(|h| h.id)
1684        }
1685
1686        fn dnd_update_over(&mut self, pos: Vec2) {
1687            let Some(f) = &self.frame_cache else {
1688                return;
1689            };
1690            let Some(session) = self.drag.as_mut() else {
1691                return;
1692            };
1693
1694            let new_over = Self::dnd_target_id_at(f, pos);
1695
1696            if new_over != session.over_id {
1697                if let Some(prev) = session.over_id {
1698                    if let Some(hit) = f.hit_regions.iter().find(|h| h.id == prev) {
1699                        if let Some(cb) = &hit.on_drag_leave {
1700                            cb(repose_core::dnd::DragOver {
1701                                source_id: session.source_id,
1702                                target_id: prev,
1703                                position: pos,
1704                                modifiers: self.modifiers,
1705                                payload: session.payload.clone(),
1706                            });
1707                        }
1708                    }
1709                }
1710
1711                if let Some(now) = new_over {
1712                    if let Some(hit) = f.hit_regions.iter().find(|h| h.id == now) {
1713                        if let Some(cb) = &hit.on_drag_enter {
1714                            cb(repose_core::dnd::DragOver {
1715                                source_id: session.source_id,
1716                                target_id: now,
1717                                position: pos,
1718                                modifiers: self.modifiers,
1719                                payload: session.payload.clone(),
1720                            });
1721                        }
1722                    }
1723                }
1724
1725                session.over_id = new_over;
1726            }
1727
1728            if let Some(over) = session.over_id {
1729                if let Some(hit) = f.hit_regions.iter().find(|h| h.id == over) {
1730                    if let Some(cb) = &hit.on_drag_over {
1731                        cb(repose_core::dnd::DragOver {
1732                            source_id: session.source_id,
1733                            target_id: over,
1734                            position: pos,
1735                            modifiers: self.modifiers,
1736                            payload: session.payload.clone(),
1737                        });
1738                    }
1739                }
1740            }
1741        }
1742
1743        fn dnd_try_begin(&mut self, pos: Vec2) -> bool {
1744            if self.drag.is_some() {
1745                return true;
1746            }
1747
1748            let Some((sx, sy)) = self.mouse_down_pos_px else {
1749                return false;
1750            };
1751            let Some(cid) = self.capture_id else {
1752                return false;
1753            };
1754            if !self.pressed_ids.contains(&cid) {
1755                return false;
1756            }
1757
1758            let dx = pos.x - sx;
1759            let dy = pos.y - sy;
1760            let dist = (dx * dx + dy * dy).sqrt();
1761            if dist < self.dnd_slop_px() {
1762                return false;
1763            }
1764
1765            let Some(f) = &self.frame_cache else {
1766                return false;
1767            };
1768            let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) else {
1769                return false;
1770            };
1771
1772            let Some(cb) = &hit.on_drag_start else {
1773                return false;
1774            };
1775
1776            let payload = cb(repose_core::dnd::DragStart {
1777                source_id: cid,
1778                position: pos,
1779                modifiers: self.modifiers,
1780            });
1781            let Some(payload) = payload else {
1782                return false;
1783            };
1784
1785            self.drag = Some(DragSession {
1786                source_id: cid,
1787                payload,
1788                start_px: (sx, sy),
1789                over_id: None,
1790            });
1791
1792            // Don't keep "pressed" visuals once dragging
1793            self.pressed_ids.remove(&cid);
1794            self.request_redraw();
1795            true
1796        }
1797
1798        fn dnd_finish(&mut self, pos: Vec2, accept_if_possible: bool) {
1799            let Some(f) = &self.frame_cache else {
1800                self.drag = None;
1801                self.capture_id = None;
1802                self.mouse_down_pos_px = None;
1803                self.request_redraw();
1804                return;
1805            };
1806
1807            let Some(session) = self.drag.take() else {
1808                return;
1809            };
1810
1811            let mut accepted = false;
1812
1813            if accept_if_possible {
1814                let drop_target = Self::dnd_target_id_at(f, pos);
1815                if let Some(tid) = drop_target {
1816                    if let Some(hit) = f.hit_regions.iter().find(|h| h.id == tid) {
1817                        if let Some(cb) = &hit.on_drop {
1818                            accepted = cb(repose_core::dnd::DropEvent {
1819                                source_id: session.source_id,
1820                                target_id: tid,
1821                                position: pos,
1822                                modifiers: self.modifiers,
1823                                payload: session.payload.clone(),
1824                            });
1825                        }
1826                    }
1827                }
1828            }
1829
1830            // Notify source end
1831            if let Some(source_hit) = f.hit_regions.iter().find(|h| h.id == session.source_id) {
1832                if let Some(cb) = &source_hit.on_drag_end {
1833                    cb(repose_core::dnd::DragEnd { accepted });
1834                }
1835            }
1836
1837            self.capture_id = None;
1838            self.mouse_down_pos_px = None;
1839            self.request_redraw();
1840        }
1841
1842        fn dnd_cancel(&mut self) {
1843            let pos = Vec2 {
1844                x: self.mouse_pos_px.0,
1845                y: self.mouse_pos_px.1,
1846            };
1847            self.dnd_finish(pos, false);
1848        }
1849
1850        fn dispatch_file_drop_now(&mut self) {
1851            let Some(f) = &self.frame_cache else {
1852                self.pending_dropped_files.clear();
1853                self.pending_drop_pos_px = None;
1854                return;
1855            };
1856
1857            if self.pending_dropped_files.is_empty() {
1858                return;
1859            }
1860
1861            let pos_px = self.pending_drop_pos_px.unwrap_or(self.mouse_pos_px);
1862            let pos = Vec2 {
1863                x: pos_px.0,
1864                y: pos_px.1,
1865            };
1866
1867            let mut files = Vec::new();
1868            for p in self.pending_dropped_files.drain(..) {
1869                let name = p
1870                    .file_name()
1871                    .and_then(|s| s.to_str())
1872                    .unwrap_or("file")
1873                    .to_string();
1874                files.push(repose_core::dnd::DroppedFile {
1875                    name,
1876                    path: Some(p),
1877                });
1878            }
1879
1880            let payload: repose_core::dnd::DragPayload =
1881                std::rc::Rc::new(repose_core::dnd::DroppedFiles { files });
1882
1883            let Some(target_id) = Self::dnd_target_id_at(f, pos) else {
1884                self.pending_drop_pos_px = None;
1885                return;
1886            };
1887
1888            if let Some(hit) = f.hit_regions.iter().find(|h| h.id == target_id) {
1889                if let Some(cb) = &hit.on_drop {
1890                    let accepted = cb(repose_core::dnd::DropEvent {
1891                        source_id: 0, // external source (OS)
1892                        target_id,
1893                        position: pos,
1894                        modifiers: self.modifiers,
1895                        payload: payload.clone(),
1896                    });
1897
1898                    if accepted {
1899                        if let Some(node) = f.semantics_nodes.iter().find(|n| n.id == target_id) {
1900                            let label = node.label.as_deref().unwrap_or("");
1901                            self.a11y.announce(&format!("Dropped files on {}", label));
1902                        }
1903                    }
1904                }
1905            }
1906
1907            self.pending_drop_pos_px = None;
1908            self.request_redraw();
1909        }
1910    }
1911
1912    let event_loop = EventLoop::new()?;
1913    let mut app = App::new(Box::new(root));
1914    // Install system clock once
1915    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1916    event_loop.run_app(&mut app)?;
1917    Ok(())
1918}
1919
1920// Accessibility bridge stub (Noop by default; logs on Linux for now)
1921/// Bridge from Repose's semantics tree to platform accessibility APIs.
1922///
1923/// Implementations are responsible for:
1924/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1925/// - Updating focus when `focus_changed` is called.
1926/// - Announcing transient messages (e.g. button activation) via screen readers.
1927pub trait A11yBridge: Send {
1928    /// Publish (or update) the full semantics tree for the current frame.
1929    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1930
1931    /// Notify that the focused node has changed. `None` means focus cleared.
1932    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1933
1934    /// Announce a one‑off message via the platform's accessibility channel.
1935    fn announce(&mut self, msg: &str);
1936}
1937
1938struct NoopA11y;
1939impl A11yBridge for NoopA11y {
1940    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1941        // no-op
1942    }
1943    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1944        if let Some(n) = node {
1945            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1946        } else {
1947            log::info!("A11y focus: None");
1948        }
1949    }
1950    fn announce(&mut self, msg: &str) {
1951        log::info!("A11y announce: {msg}");
1952    }
1953}
1954
1955#[cfg(target_os = "linux")]
1956struct LinuxAtspiStub;
1957#[cfg(target_os = "linux")]
1958impl A11yBridge for LinuxAtspiStub {
1959    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1960        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1961    }
1962    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1963        if let Some(n) = node {
1964            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1965        } else {
1966            log::info!("AT-SPI stub focus: None");
1967        }
1968    }
1969    fn announce(&mut self, msg: &str) {
1970        log::info!("AT-SPI stub announce: {msg}");
1971    }
1972}