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