repose_platform/
lib.rs

1//! Platform runners
2use repose_core::locals::dp_to_px;
3use repose_core::*;
4use repose_ui::layout_and_paint;
5use repose_ui::textfield::{
6    TF_FONT_DP, TF_PADDING_X_DP, byte_to_char_index, index_for_x_bytes, measure_text,
7};
8use std::cell::RefCell;
9use std::rc::Rc;
10use std::time::Instant;
11
12#[cfg(all(feature = "android", target_os = "android"))]
13pub mod android;
14
15/// Compose a single frame with density and text-scale applied, returning Frame.
16pub fn compose_frame<F>(
17    sched: &mut Scheduler,
18    root_fn: &mut F,
19    scale: f32,
20    size_px_u32: (u32, u32),
21    hover_id: Option<u64>,
22    pressed_ids: &std::collections::HashSet<u64>,
23    tf_states: &std::collections::HashMap<u64, Rc<RefCell<repose_ui::TextFieldState>>>,
24    focused: Option<u64>,
25) -> Frame
26where
27    F: FnMut(&mut Scheduler) -> View,
28{
29    sched.repose(
30        {
31            let scale = scale;
32            move |s: &mut Scheduler| {
33                with_density(Density { scale }, || {
34                    with_text_scale(TextScale(1.0), || (root_fn)(s))
35                })
36            }
37        },
38        {
39            let hover_id = hover_id;
40            let pressed_ids = pressed_ids.clone();
41            move |view, _size| {
42                let interactions = repose_ui::Interactions {
43                    hover: hover_id,
44                    pressed: pressed_ids.clone(),
45                };
46                with_density(Density { scale }, || {
47                    with_text_scale(TextScale(1.0), || {
48                        repose_ui::layout_and_paint(
49                            view,
50                            size_px_u32,
51                            tf_states,
52                            &interactions,
53                            focused,
54                        )
55                    })
56                })
57            }
58        },
59    )
60}
61
62/// Helper: ensure caret visibility for a TextFieldState inside a given rect (px).
63pub fn tf_ensure_visible_in_rect(state: &mut repose_ui::TextFieldState, inner_rect: Rect) {
64    let font_dp = TF_FONT_DP as u32;
65    let m = measure_text(&state.text, font_dp);
66    let caret_x_px = m.positions.get(state.caret_index()).copied().unwrap_or(0.0);
67    state.ensure_caret_visible(caret_x_px, inner_rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP));
68}
69
70#[cfg(feature = "desktop")]
71pub fn run_desktop_app(root: impl FnMut(&mut Scheduler) -> View + 'static) -> anyhow::Result<()> {
72    use std::cell::RefCell;
73    use std::collections::{HashMap, HashSet};
74    use std::rc::Rc;
75    use std::sync::Arc;
76
77    use repose_ui::TextFieldState;
78    use winit::application::ApplicationHandler;
79    use winit::dpi::{LogicalPosition, LogicalSize, PhysicalSize};
80    use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
81    use winit::event_loop::EventLoop;
82    use winit::keyboard::{KeyCode, PhysicalKey};
83    use winit::window::{ImePurpose, Window, WindowAttributes};
84
85    struct App {
86        // App state
87        root: Box<dyn FnMut(&mut Scheduler) -> View>,
88        window: Option<Arc<Window>>,
89        backend: Option<repose_render_wgpu::WgpuBackend>,
90        sched: Scheduler,
91        inspector: repose_devtools::Inspector,
92        frame_cache: Option<Frame>,
93        mouse_pos_px: (f32, f32),
94        modifiers: Modifiers,
95        textfield_states: HashMap<u64, Rc<RefCell<TextFieldState>>>,
96        ime_preedit: bool,
97        hover_id: Option<u64>,
98        capture_id: Option<u64>,
99        pressed_ids: HashSet<u64>,
100        key_pressed_active: Option<u64>, // for Space/Enter press/release activation
101        clipboard: Option<arboard::Clipboard>,
102        a11y: Box<dyn A11yBridge>,
103        last_focus: Option<u64>,
104    }
105
106    impl App {
107        fn new(root: Box<dyn FnMut(&mut Scheduler) -> View>) -> Self {
108            Self {
109                root,
110                window: None,
111                backend: None,
112                sched: Scheduler::new(),
113                inspector: repose_devtools::Inspector::new(),
114                frame_cache: None,
115                mouse_pos_px: (0.0, 0.0),
116                modifiers: Modifiers::default(),
117                textfield_states: HashMap::new(),
118                ime_preedit: false,
119                hover_id: None,
120                capture_id: None,
121                pressed_ids: HashSet::new(),
122                key_pressed_active: None,
123                clipboard: None,
124                a11y: {
125                    #[cfg(target_os = "linux")]
126                    {
127                        Box::new(LinuxAtspiStub) as Box<dyn A11yBridge>
128                    }
129                    #[cfg(not(target_os = "linux"))]
130                    {
131                        Box::new(NoopA11y) as Box<dyn A11yBridge>
132                    }
133                },
134                last_focus: None,
135            }
136        }
137
138        fn request_redraw(&self) {
139            if let Some(w) = &self.window {
140                w.request_redraw();
141            }
142        }
143
144        // Ensure caret is visible after edits/moves (all units in px)
145        fn tf_ensure_caret_visible(st: &mut TextFieldState) {
146            let font_dp = TF_FONT_DP as u32;
147            let m = measure_text(&st.text, font_dp);
148            let caret_x_px = m.positions.get(st.caret_index()).copied().unwrap_or(0.0);
149            st.ensure_caret_visible(caret_x_px, st.inner_width);
150        }
151    }
152
153    impl ApplicationHandler<()> for App {
154        fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
155            self.clipboard = arboard::Clipboard::new().ok();
156            // Create the window once when app resumes.
157            if self.window.is_none() {
158                match el.create_window(
159                    WindowAttributes::default()
160                        .with_title("Repose")
161                        .with_inner_size(PhysicalSize::new(1280, 800)),
162                ) {
163                    Ok(win) => {
164                        let w = Arc::new(win);
165                        let size = w.inner_size();
166                        self.sched.size = (size.width, size.height);
167                        // Create WGPU backend
168                        match repose_render_wgpu::WgpuBackend::new(w.clone()) {
169                            Ok(b) => {
170                                self.backend = Some(b);
171                                self.window = Some(w);
172                                self.request_redraw();
173                            }
174                            Err(e) => {
175                                log::error!("Failed to create WGPU backend: {e:?}");
176                                el.exit();
177                            }
178                        }
179                    }
180                    Err(e) => {
181                        log::error!("Failed to create window: {e:?}");
182                        el.exit();
183                    }
184                }
185            }
186        }
187
188        fn window_event(
189            &mut self,
190            el: &winit::event_loop::ActiveEventLoop,
191            _id: winit::window::WindowId,
192            event: WindowEvent,
193        ) {
194            match event {
195                WindowEvent::CloseRequested => {
196                    use repose_navigation::back;
197
198                    log::info!("Window close requested");
199                    if !back::handle() {
200                        el.exit();
201                    }
202                }
203                WindowEvent::Resized(size) => {
204                    self.sched.size = (size.width, size.height);
205                    if let Some(b) = &mut self.backend {
206                        b.configure_surface(size.width, size.height);
207                    }
208                    if let Some(w) = &self.window {
209                        let sf = w.scale_factor() as f32;
210                        let dp_w = size.width as f32 / sf;
211                        let dp_h = size.height as f32 / sf;
212                        log::info!(
213                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
214                            size.width,
215                            size.height,
216                            sf,
217                            dp_w as i32,
218                            dp_h as i32
219                        );
220                    }
221                    self.request_redraw();
222                }
223                WindowEvent::CursorMoved { position, .. } => {
224                    self.mouse_pos_px = (position.x as f32, position.y as f32);
225
226                    // Inspector hover
227                    if self.inspector.hud.inspector_enabled {
228                        if let Some(f) = &self.frame_cache {
229                            let hover_rect = f
230                                .hit_regions
231                                .iter()
232                                .find(|h| {
233                                    h.rect.contains(Vec2 {
234                                        x: self.mouse_pos_px.0,
235                                        y: self.mouse_pos_px.1,
236                                    })
237                                })
238                                .map(|h| h.rect);
239                            self.inspector.hud.set_hovered(hover_rect);
240                            self.request_redraw();
241                        }
242                    }
243
244                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
245                        if let Some(_sem) = f
246                            .semantics_nodes
247                            .iter()
248                            .find(|n| n.id == cid && n.role == Role::TextField)
249                        {
250                            let key = self.tf_key_of(cid);
251                            if let Some(state_rc) = self.textfield_states.get(&key) {
252                                let mut state = state_rc.borrow_mut();
253                                // inner content left edge in px
254                                let inner_x_px = f
255                                    .hit_regions
256                                    .iter()
257                                    .find(|h| h.id == cid)
258                                    .map(|h| h.rect.x + dp_to_px(TF_PADDING_X_DP))
259                                    .unwrap_or(0.0);
260                                let content_x_px =
261                                    self.mouse_pos_px.0 - inner_x_px + state.scroll_offset;
262                                let font_dp = TF_FONT_DP as u32;
263                                let idx =
264                                    index_for_x_bytes(&state.text, font_dp, content_x_px.max(0.0));
265                                state.drag_to(idx);
266
267                                // Scroll caret into view
268                                let m = measure_text(&state.text, font_dp);
269                                let caret_x_px =
270                                    m.positions.get(state.caret_index()).copied().unwrap_or(0.0);
271                                if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
272                                    state.ensure_caret_visible(
273                                        caret_x_px,
274                                        hit.rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP),
275                                    );
276                                }
277                                self.request_redraw();
278                            }
279                        }
280                    }
281
282                    // Pointer routing: hover + move/capture
283                    if let Some(f) = &self.frame_cache {
284                        // Determine topmost hit
285                        let pos = Vec2 {
286                            x: self.mouse_pos_px.0,
287                            y: self.mouse_pos_px.1,
288                        };
289                        let top = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos));
290                        let new_hover = top.map(|h| h.id);
291
292                        // Enter/Leave
293                        if new_hover != self.hover_id {
294                            if let Some(prev_id) = self.hover_id {
295                                if let Some(prev) = f.hit_regions.iter().find(|h| h.id == prev_id) {
296                                    if let Some(cb) = &prev.on_pointer_leave {
297                                        let pe = repose_core::input::PointerEvent {
298                                            id: repose_core::input::PointerId(0),
299                                            kind: repose_core::input::PointerKind::Mouse,
300                                            event: repose_core::input::PointerEventKind::Leave,
301                                            position: pos,
302                                            pressure: 1.0,
303                                            modifiers: self.modifiers,
304                                        };
305                                        cb(pe);
306                                    }
307                                }
308                            }
309                            if let Some(h) = top {
310                                if let Some(cb) = &h.on_pointer_enter {
311                                    let pe = repose_core::input::PointerEvent {
312                                        id: repose_core::input::PointerId(0),
313                                        kind: repose_core::input::PointerKind::Mouse,
314                                        event: repose_core::input::PointerEventKind::Enter,
315                                        position: pos,
316                                        pressure: 1.0,
317                                        modifiers: self.modifiers,
318                                    };
319                                    cb(pe);
320                                }
321                            }
322                            self.hover_id = new_hover;
323                        }
324
325                        // Build PointerEvent
326                        let pe = repose_core::input::PointerEvent {
327                            id: repose_core::input::PointerId(0),
328                            kind: repose_core::input::PointerKind::Mouse,
329                            event: repose_core::input::PointerEventKind::Move,
330                            position: pos,
331                            pressure: 1.0,
332                            modifiers: self.modifiers,
333                        };
334
335                        // Move delivery (captured first)
336                        if let Some(cid) = self.capture_id {
337                            if let Some(h) = f.hit_regions.iter().find(|h| h.id == cid) {
338                                if let Some(cb) = &h.on_pointer_move {
339                                    cb(pe.clone());
340                                }
341                            }
342                        } else if let Some(h) = &top {
343                            if let Some(cb) = &h.on_pointer_move {
344                                cb(pe);
345                            }
346                        }
347                    }
348                }
349                WindowEvent::MouseWheel { delta, .. } => {
350                    // Convert line deltas (logical) to px; pixel delta is already px
351                    let (dx_px, dy_px) = match delta {
352                        MouseScrollDelta::LineDelta(x, y) => {
353                            let unit_px = dp_to_px(60.0);
354                            (-(x * unit_px), -(y * unit_px))
355                        }
356                        MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
357                    };
358                    log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
359
360                    if let Some(f) = &self.frame_cache {
361                        let pos = Vec2 {
362                            x: self.mouse_pos_px.0,
363                            y: self.mouse_pos_px.1,
364                        };
365
366                        for hit in f.hit_regions.iter().rev().filter(|h| h.rect.contains(pos)) {
367                            if let Some(cb) = &hit.on_scroll {
368                                log::debug!("Calling on_scroll for hit region id={}", hit.id);
369                                let before = Vec2 { x: dx_px, y: dy_px };
370                                let leftover = cb(before);
371                                let consumed_x = (before.x - leftover.x).abs() > 0.001;
372                                let consumed_y = (before.y - leftover.y).abs() > 0.001;
373                                if consumed_x || consumed_y {
374                                    self.request_redraw();
375                                    break; // stop after first consumer
376                                }
377                            }
378                        }
379                    }
380                }
381                WindowEvent::MouseInput {
382                    state: ElementState::Pressed,
383                    button: MouseButton::Left,
384                    ..
385                } => {
386                    let mut need_announce = false;
387                    if let Some(f) = &self.frame_cache {
388                        let pos = Vec2 {
389                            x: self.mouse_pos_px.0,
390                            y: self.mouse_pos_px.1,
391                        };
392                        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
393                        {
394                            // Capture starts on press
395                            self.capture_id = Some(hit.id);
396                            // Pressed visual for mouse
397                            self.pressed_ids.insert(hit.id);
398                            // Repaint for pressed state
399                            self.request_redraw();
400
401                            // Focus & IME first for focusables (so state exists)
402                            if hit.focusable {
403                                self.sched.focused = Some(hit.id);
404                                need_announce = true;
405                                let key = self.tf_key_of(hit.id);
406                                self.textfield_states.entry(key).or_insert_with(|| {
407                                    Rc::new(RefCell::new(
408                                        repose_ui::textfield::TextFieldState::new(),
409                                    ))
410                                });
411                                if let Some(win) = &self.window {
412                                    let sf = win.scale_factor();
413                                    win.set_ime_allowed(true);
414                                    win.set_ime_purpose(ImePurpose::Normal);
415                                    win.set_ime_cursor_area(
416                                        LogicalPosition::new(
417                                            hit.rect.x as f64 / sf,
418                                            hit.rect.y as f64 / sf,
419                                        ),
420                                        LogicalSize::new(
421                                            hit.rect.w as f64 / sf,
422                                            hit.rect.h as f64 / sf,
423                                        ),
424                                    );
425                                }
426                            }
427
428                            // PointerDown callback (legacy)
429                            if let Some(cb) = &hit.on_pointer_down {
430                                let pe = repose_core::input::PointerEvent {
431                                    id: repose_core::input::PointerId(0),
432                                    kind: repose_core::input::PointerKind::Mouse,
433                                    event: repose_core::input::PointerEventKind::Down(
434                                        repose_core::input::PointerButton::Primary,
435                                    ),
436                                    position: pos,
437                                    pressure: 1.0,
438                                    modifiers: self.modifiers,
439                                };
440                                cb(pe);
441                            }
442
443                            // TextField: place caret and start drag selection
444                            if let Some(_sem) = f
445                                .semantics_nodes
446                                .iter()
447                                .find(|n| n.id == hit.id && n.role == Role::TextField)
448                            {
449                                let key = self.tf_key_of(hit.id);
450                                if let Some(state_rc) = self.textfield_states.get(&key) {
451                                    let mut state = state_rc.borrow_mut();
452                                    let inner_x_px = hit.rect.x + dp_to_px(TF_PADDING_X_DP);
453                                    let content_x_px =
454                                        self.mouse_pos_px.0 - inner_x_px + state.scroll_offset;
455                                    let font_dp = TF_FONT_DP as u32;
456                                    let idx = index_for_x_bytes(
457                                        &state.text,
458                                        font_dp,
459                                        content_x_px.max(0.0),
460                                    );
461                                    state.begin_drag(idx, self.modifiers.shift);
462                                    let m = measure_text(&state.text, font_dp);
463                                    let caret_x_px = m
464                                        .positions
465                                        .get(state.caret_index())
466                                        .copied()
467                                        .unwrap_or(0.0);
468                                    state.ensure_caret_visible(
469                                        caret_x_px,
470                                        hit.rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP),
471                                    );
472                                }
473                            }
474                            if need_announce {
475                                self.announce_focus_change();
476                            }
477
478                            self.request_redraw();
479                        } else {
480                            // Click outside: drop focus/IME
481                            if self.ime_preedit {
482                                if let Some(win) = &self.window {
483                                    win.set_ime_allowed(false);
484                                }
485                                self.ime_preedit = false;
486                            }
487                            self.sched.focused = None;
488                            self.request_redraw();
489                        }
490                    }
491                }
492                WindowEvent::MouseInput {
493                    state: ElementState::Released,
494                    button: MouseButton::Left,
495                    ..
496                } => {
497                    if let Some(cid) = self.capture_id {
498                        self.pressed_ids.remove(&cid);
499                        self.request_redraw();
500                    }
501
502                    // Click on release if pointer is still over the captured hit region
503                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
504                        let pos = Vec2 {
505                            x: self.mouse_pos_px.0,
506                            y: self.mouse_pos_px.1,
507                        };
508                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
509                            if hit.rect.contains(pos) {
510                                if let Some(cb) = &hit.on_click {
511                                    cb();
512                                    // A11y: announce activation (mouse)
513                                    if let Some(node) =
514                                        f.semantics_nodes.iter().find(|n| n.id == cid)
515                                    {
516                                        let label = node.label.as_deref().unwrap_or("");
517                                        self.a11y.announce(&format!("Activated {}", label));
518                                    }
519                                }
520                            }
521                        }
522                    }
523                    // TextField drag end
524                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
525                        if let Some(_sem) = f
526                            .semantics_nodes
527                            .iter()
528                            .find(|n| n.id == cid && n.role == Role::TextField)
529                        {
530                            let key = self.tf_key_of(cid);
531                            if let Some(state_rc) = self.textfield_states.get(&key) {
532                                state_rc.borrow_mut().end_drag();
533                            }
534                        }
535                    }
536                    self.capture_id = None;
537                }
538                WindowEvent::ModifiersChanged(new_mods) => {
539                    self.modifiers.shift = new_mods.state().shift_key();
540                    self.modifiers.ctrl = new_mods.state().control_key();
541                    self.modifiers.alt = new_mods.state().alt_key();
542                    self.modifiers.meta = new_mods.state().super_key();
543                }
544                WindowEvent::KeyboardInput {
545                    event: key_event, ..
546                } => {
547                    if key_event.state == ElementState::Pressed && !key_event.repeat {
548                        match key_event.physical_key {
549                            PhysicalKey::Code(KeyCode::BrowserBack)
550                            | PhysicalKey::Code(KeyCode::Escape) => {
551                                use repose_navigation::back;
552
553                                if !back::handle() {
554                                    // el.exit();
555                                }
556                                return;
557                            }
558                            _ => {}
559                        }
560                    }
561                    // Focus traversal: Tab / Shift+Tab
562                    if matches!(key_event.physical_key, PhysicalKey::Code(KeyCode::Tab)) {
563                        // Only act on initial press, ignore repeats
564                        if key_event.state == ElementState::Pressed && !key_event.repeat {
565                            if let Some(f) = &self.frame_cache {
566                                let chain = &f.focus_chain;
567                                if !chain.is_empty() {
568                                    // If a button was “pressed” via keyboard, clear it when we move focus
569                                    if let Some(active) = self.key_pressed_active.take() {
570                                        self.pressed_ids.remove(&active);
571                                    }
572
573                                    let shift = self.modifiers.shift;
574                                    let current = self.sched.focused;
575                                    let next = if let Some(cur) = current {
576                                        if let Some(idx) = chain.iter().position(|&id| id == cur) {
577                                            if shift {
578                                                if idx == 0 {
579                                                    chain[chain.len() - 1]
580                                                } else {
581                                                    chain[idx - 1]
582                                                }
583                                            } else {
584                                                chain[(idx + 1) % chain.len()]
585                                            }
586                                        } else {
587                                            chain[0]
588                                        }
589                                    } else {
590                                        chain[0]
591                                    };
592                                    self.sched.focused = Some(next);
593
594                                    // IME only for TextField
595                                    if let Some(win) = &self.window {
596                                        if f.semantics_nodes
597                                            .iter()
598                                            .any(|n| n.id == next && n.role == Role::TextField)
599                                        {
600                                            win.set_ime_allowed(true);
601                                            win.set_ime_purpose(ImePurpose::Normal);
602                                        } else {
603                                            win.set_ime_allowed(false);
604                                        }
605                                    }
606                                    self.announce_focus_change();
607                                    self.request_redraw();
608                                }
609                            }
610                        }
611                        return; // swallow Tab
612                    }
613
614                    if let Some(fid) = self.sched.focused {
615                        // If focused is NOT a TextField, allow Space/Enter activation
616                        let is_textfield = if let Some(f) = &self.frame_cache {
617                            f.semantics_nodes
618                                .iter()
619                                .any(|n| n.id == fid && n.role == Role::TextField)
620                        } else {
621                            false
622                        };
623
624                        if !is_textfield {
625                            match key_event.physical_key {
626                                PhysicalKey::Code(KeyCode::Space)
627                                | PhysicalKey::Code(KeyCode::Enter) => {
628                                    if key_event.state == ElementState::Pressed && !key_event.repeat
629                                    {
630                                        self.pressed_ids.insert(fid);
631                                        self.key_pressed_active = Some(fid);
632                                        self.request_redraw();
633                                        return;
634                                    }
635                                }
636                                _ => {}
637                            }
638                        }
639                    }
640
641                    // Keyboard activation for focused TextField submit on Enter
642                    if key_event.state == ElementState::Pressed && !key_event.repeat {
643                        if let PhysicalKey::Code(KeyCode::Enter) = key_event.physical_key {
644                            if let Some(focused_id) = self.sched.focused {
645                                if let Some(f) = &self.frame_cache {
646                                    if let Some(hit) =
647                                        f.hit_regions.iter().find(|h| h.id == focused_id)
648                                    {
649                                        if let Some(on_submit) = &hit.on_text_submit {
650                                            let key = self.tf_key_of(focused_id);
651
652                                            if let Some(state) = self.textfield_states.get(&key) {
653                                                let text = state.borrow().text.clone();
654                                                on_submit(text);
655                                                self.request_redraw();
656                                                return; // don’t continue as button activation
657                                            }
658                                        }
659                                    }
660                                }
661                            }
662                        }
663                    }
664
665                    if key_event.state == ElementState::Pressed {
666                        // Inspector hotkey: Ctrl+Shift+I
667                        if self.modifiers.ctrl && self.modifiers.shift {
668                            if let PhysicalKey::Code(KeyCode::KeyI) = key_event.physical_key {
669                                self.inspector.hud.toggle_inspector();
670                                self.request_redraw();
671                                return;
672                            }
673                        }
674
675                        // TextField navigation/edit
676                        if let Some(focused_id) = self.sched.focused {
677                            let key = self.tf_key_of(focused_id);
678                            if let Some(state_rc) = self.textfield_states.get(&key) {
679                                let mut state = state_rc.borrow_mut();
680                                match key_event.physical_key {
681                                    PhysicalKey::Code(KeyCode::Backspace) => {
682                                        state.delete_backward();
683                                        let new_text = state.text.clone();
684                                        self.notify_text_change(focused_id, new_text);
685                                        App::tf_ensure_caret_visible(&mut state);
686                                        self.request_redraw();
687                                    }
688                                    PhysicalKey::Code(KeyCode::Delete) => {
689                                        state.delete_forward();
690                                        let new_text = state.text.clone();
691                                        self.notify_text_change(focused_id, new_text);
692                                        App::tf_ensure_caret_visible(&mut state);
693                                        self.request_redraw();
694                                    }
695                                    PhysicalKey::Code(KeyCode::ArrowLeft) => {
696                                        state.move_cursor(-1, self.modifiers.shift);
697                                        App::tf_ensure_caret_visible(&mut state);
698                                        self.request_redraw();
699                                    }
700                                    PhysicalKey::Code(KeyCode::ArrowRight) => {
701                                        state.move_cursor(1, self.modifiers.shift);
702                                        App::tf_ensure_caret_visible(&mut state);
703                                        self.request_redraw();
704                                    }
705                                    PhysicalKey::Code(KeyCode::Home) => {
706                                        state.selection = 0..0;
707                                        App::tf_ensure_caret_visible(&mut state);
708                                        self.request_redraw();
709                                    }
710                                    PhysicalKey::Code(KeyCode::End) => {
711                                        {
712                                            let end = state.text.len();
713                                            state.selection = end..end;
714                                        }
715                                        App::tf_ensure_caret_visible(&mut state);
716                                        self.request_redraw();
717                                    }
718                                    PhysicalKey::Code(KeyCode::KeyA) if self.modifiers.ctrl => {
719                                        state.selection = 0..state.text.len();
720                                        App::tf_ensure_caret_visible(&mut state);
721                                        self.request_redraw();
722                                    }
723                                    _ => {}
724                                }
725                            }
726                            if self.modifiers.ctrl {
727                                match key_event.physical_key {
728                                    PhysicalKey::Code(KeyCode::KeyC) => {
729                                        if let Some(fid) = self.sched.focused {
730                                            let key = self.tf_key_of(fid);
731                                            if let Some(state) = self.textfield_states.get(&key) {
732                                                let txt = state.borrow().selected_text();
733                                                if !txt.is_empty() {
734                                                    if let Some(cb) = self.clipboard.as_mut() {
735                                                        let _ = cb.set_text(txt);
736                                                    }
737                                                }
738                                            }
739                                        }
740                                        return;
741                                    }
742                                    PhysicalKey::Code(KeyCode::KeyX) => {
743                                        if let Some(fid) = self.sched.focused {
744                                            let key = self.tf_key_of(fid);
745                                            if let Some(state_rc) = self.textfield_states.get(&key)
746                                            {
747                                                // Copy
748                                                let txt = state_rc.borrow().selected_text();
749                                                if !txt.is_empty() {
750                                                    if let Some(cb) = self.clipboard.as_mut() {
751                                                        let _ = cb.set_text(txt.clone());
752                                                    }
753                                                    // Cut (delete selection)
754                                                    {
755                                                        let mut st = state_rc.borrow_mut();
756                                                        st.insert_text(""); // replace selection with empty
757                                                        let new_text = st.text.clone();
758                                                        self.notify_text_change(
759                                                            focused_id, new_text,
760                                                        );
761                                                        App::tf_ensure_caret_visible(&mut st);
762                                                    }
763                                                    self.request_redraw();
764                                                }
765                                            }
766                                        }
767                                        return;
768                                    }
769                                    PhysicalKey::Code(KeyCode::KeyV) => {
770                                        if let Some(fid) = self.sched.focused {
771                                            let key = self.tf_key_of(fid);
772                                            if let Some(state_rc) = self.textfield_states.get(&key)
773                                            {
774                                                if let Some(cb) = self.clipboard.as_mut() {
775                                                    if let Ok(mut txt) = cb.get_text() {
776                                                        // Single-line TextField: strip control/newlines
777                                                        txt.retain(|c| {
778                                                            !c.is_control()
779                                                                && c != '\n'
780                                                                && c != '\r'
781                                                        });
782                                                        if !txt.is_empty() {
783                                                            let mut st = state_rc.borrow_mut();
784                                                            st.insert_text(&txt);
785                                                            let new_text = st.text.clone();
786                                                            self.notify_text_change(
787                                                                focused_id, new_text,
788                                                            );
789                                                            App::tf_ensure_caret_visible(&mut st);
790                                                            self.request_redraw();
791                                                        }
792                                                    }
793                                                }
794                                            }
795                                        }
796                                        return;
797                                    }
798                                    _ => {}
799                                }
800                            }
801                        }
802
803                        // Plain text input when IME is not active
804                        if !self.ime_preedit
805                            && !self.modifiers.ctrl
806                            && !self.modifiers.alt
807                            && !self.modifiers.meta
808                        {
809                            if let Some(raw) = key_event.text.as_deref() {
810                                let text: String = raw
811                                    .chars()
812                                    .filter(|c| !c.is_control() && *c != '\n' && *c != '\r')
813                                    .collect();
814                                if !text.is_empty() {
815                                    if let Some(fid) = self.sched.focused {
816                                        let key = self.tf_key_of(fid);
817                                        if let Some(state_rc) = self.textfield_states.get(&key) {
818                                            let mut st = state_rc.borrow_mut();
819                                            st.insert_text(&text);
820                                            self.notify_text_change(fid, text.clone());
821                                            App::tf_ensure_caret_visible(&mut st);
822                                            self.request_redraw();
823                                        }
824                                    }
825                                }
826                            }
827                        }
828                    } else if key_event.state == ElementState::Released {
829                        // Finish keyboard activation on release (Space/Enter)
830                        if let Some(active_id) = self.key_pressed_active {
831                            match key_event.physical_key {
832                                PhysicalKey::Code(KeyCode::Space)
833                                | PhysicalKey::Code(KeyCode::Enter) => {
834                                    self.pressed_ids.remove(&active_id);
835                                    self.key_pressed_active = None;
836
837                                    if let Some(f) = &self.frame_cache {
838                                        if let Some(hit) =
839                                            f.hit_regions.iter().find(|h| h.id == active_id)
840                                        {
841                                            if let Some(cb) = &hit.on_click {
842                                                cb();
843                                                if let Some(node) = f
844                                                    .semantics_nodes
845                                                    .iter()
846                                                    .find(|n| n.id == active_id)
847                                                {
848                                                    let label = node.label.as_deref().unwrap_or("");
849                                                    self.a11y
850                                                        .announce(&format!("Activated {}", label));
851                                                }
852                                            }
853                                        }
854                                    }
855                                    self.request_redraw();
856                                    return;
857                                }
858                                _ => {}
859                            }
860                        }
861                    }
862                }
863
864                WindowEvent::Ime(ime) => {
865                    use winit::event::Ime;
866                    if let Some(focused_id) = self.sched.focused {
867                        let key = self.tf_key_of(focused_id);
868                        if let Some(state_rc) = self.textfield_states.get(&key) {
869                            let mut state = state_rc.borrow_mut();
870                            match ime {
871                                Ime::Enabled => {
872                                    // IME allowed, but not necessarily composing
873                                    self.ime_preedit = false;
874                                }
875                                Ime::Preedit(text, cursor) => {
876                                    let cursor_usize =
877                                        cursor.map(|(a, b)| (a as usize, b as usize));
878                                    state.set_composition(text.clone(), cursor_usize);
879                                    self.ime_preedit = !text.is_empty();
880                                    if let Some(f) = &self.frame_cache {
881                                        if let Some(hit) =
882                                            f.hit_regions.iter().find(|h| h.id == focused_id)
883                                        {
884                                            let inner = Rect {
885                                                x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
886                                                y: hit.rect.y,
887                                                w: hit.rect.w,
888                                                h: hit.rect.h,
889                                            };
890                                            tf_ensure_visible_in_rect(&mut state, inner);
891                                        }
892                                    }
893                                    // notify on-change if you wired it:
894                                    self.notify_text_change(focused_id, state.text.clone());
895                                    self.request_redraw();
896                                }
897                                Ime::Commit(text) => {
898                                    state.commit_composition(text);
899                                    self.ime_preedit = false;
900                                    if let Some(f) = &self.frame_cache {
901                                        if let Some(hit) =
902                                            f.hit_regions.iter().find(|h| h.id == focused_id)
903                                        {
904                                            let inner = Rect {
905                                                x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
906                                                y: hit.rect.y,
907                                                w: hit.rect.w,
908                                                h: hit.rect.h,
909                                            };
910                                            tf_ensure_visible_in_rect(&mut state, inner);
911                                        }
912                                    }
913                                    self.notify_text_change(focused_id, state.text.clone());
914                                    self.request_redraw();
915                                }
916                                Ime::Disabled => {
917                                    self.ime_preedit = false;
918                                    if state.composition.is_some() {
919                                        state.cancel_composition();
920                                        if let Some(f) = &self.frame_cache {
921                                            if let Some(hit) =
922                                                f.hit_regions.iter().find(|h| h.id == focused_id)
923                                            {
924                                                let inner = Rect {
925                                                    x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
926                                                    y: hit.rect.y,
927                                                    w: hit.rect.w,
928                                                    h: hit.rect.h,
929                                                };
930                                                tf_ensure_visible_in_rect(&mut state, inner);
931                                            }
932                                        }
933                                        self.notify_text_change(focused_id, state.text.clone());
934                                    }
935                                    self.request_redraw();
936                                }
937                            }
938                        }
939                    }
940                }
941                WindowEvent::RedrawRequested => {
942                    if let (Some(backend), Some(win)) =
943                        (self.backend.as_mut(), self.window.as_ref())
944                    {
945                        let t0 = Instant::now();
946                        let scale = win.scale_factor() as f32;
947                        let size_px_u32 = self.sched.size;
948                        let focused = self.sched.focused;
949
950                        let frame = compose_frame(
951                            &mut self.sched,
952                            &mut self.root,
953                            scale,
954                            size_px_u32,
955                            self.hover_id,
956                            &self.pressed_ids,
957                            &self.textfield_states,
958                            focused,
959                        );
960
961                        let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
962
963                        // A11y: publish semantics tree each frame (cheap for now)
964                        self.a11y.publish_tree(&frame.semantics_nodes);
965                        // If focus id changed since last publish, send focused node
966                        if self.last_focus != self.sched.focused {
967                            let focused_node = self
968                                .sched
969                                .focused
970                                .and_then(|id| frame.semantics_nodes.iter().find(|n| n.id == id));
971                            self.a11y.focus_changed(focused_node);
972                            self.last_focus = self.sched.focused;
973                        }
974
975                        // Render
976                        let mut scene = frame.scene.clone();
977                        // Update HUD metrics before overlay draws
978                        self.inspector.hud.metrics = Some(repose_devtools::Metrics {
979                            build_layout_ms,
980                            scene_nodes: scene.nodes.len(),
981                        });
982                        self.inspector.frame(&mut scene);
983                        backend
984                            // .lock()
985                            .frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
986                        self.frame_cache = Some(frame);
987                    }
988                }
989                _ => {}
990            }
991        }
992
993        fn about_to_wait(&mut self, _el: &winit::event_loop::ActiveEventLoop) {
994            self.request_redraw();
995        }
996
997        fn new_events(
998            &mut self,
999            _: &winit::event_loop::ActiveEventLoop,
1000            _: winit::event::StartCause,
1001        ) {
1002        }
1003        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {}
1004        fn device_event(
1005            &mut self,
1006            _: &winit::event_loop::ActiveEventLoop,
1007            _: winit::event::DeviceId,
1008            _: winit::event::DeviceEvent,
1009        ) {
1010        }
1011        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1012        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1013        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
1014    }
1015
1016    impl App {
1017        fn announce_focus_change(&mut self) {
1018            if let Some(f) = &self.frame_cache {
1019                let focused_node = self
1020                    .sched
1021                    .focused
1022                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1023                self.a11y.focus_changed(focused_node);
1024            }
1025        }
1026        fn notify_text_change(&self, id: u64, text: String) {
1027            if let Some(f) = &self.frame_cache {
1028                if let Some(h) = f.hit_regions.iter().find(|h| h.id == id) {
1029                    if let Some(cb) = &h.on_text_change {
1030                        cb(text);
1031                    }
1032                }
1033            }
1034        }
1035        fn tf_key_of(&self, visual_id: u64) -> u64 {
1036            if let Some(f) = &self.frame_cache {
1037                if let Some(hr) = f.hit_regions.iter().find(|h| h.id == visual_id) {
1038                    return hr.tf_state_key.unwrap_or(hr.id);
1039                }
1040            }
1041            visual_id
1042        }
1043    }
1044
1045    let event_loop = EventLoop::new()?;
1046    let mut app = App::new(Box::new(root));
1047    // Install system clock once
1048    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1049    event_loop.run_app(&mut app)?;
1050    Ok(())
1051}
1052
1053// Accessibility bridge stub (Noop by default; logs on Linux for now)
1054/// Bridge from Repose's semantics tree to platform accessibility APIs.
1055///
1056/// Implementations are responsible for:
1057/// - Exposing nodes to the OS (AT‑SPI, Android accessibility, etc.).
1058/// - Updating focus when `focus_changed` is called.
1059/// - Announcing transient messages (e.g. button activation) via screen readers.
1060pub trait A11yBridge: Send {
1061    /// Publish (or update) the full semantics tree for the current frame.
1062    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1063
1064    /// Notify that the focused node has changed. `None` means focus cleared.
1065    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1066
1067    /// Announce a one‑off message via the platform's accessibility channel.
1068    fn announce(&mut self, msg: &str);
1069}
1070
1071struct NoopA11y;
1072impl A11yBridge for NoopA11y {
1073    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1074        // no-op
1075    }
1076    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1077        if let Some(n) = node {
1078            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1079        } else {
1080            log::info!("A11y focus: None");
1081        }
1082    }
1083    fn announce(&mut self, msg: &str) {
1084        log::info!("A11y announce: {msg}");
1085    }
1086}
1087
1088#[cfg(target_os = "linux")]
1089struct LinuxAtspiStub;
1090#[cfg(target_os = "linux")]
1091impl A11yBridge for LinuxAtspiStub {
1092    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1093        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1094    }
1095    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1096        if let Some(n) = node {
1097            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1098        } else {
1099            log::info!("AT-SPI stub focus: None");
1100        }
1101    }
1102    fn announce(&mut self, msg: &str) {
1103        log::info!("AT-SPI stub announce: {msg}");
1104    }
1105}