Skip to main content

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