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 Example")
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                    log::info!("Window close requested");
197                    el.exit();
198                }
199                WindowEvent::Resized(size) => {
200                    self.sched.size = (size.width, size.height);
201                    if let Some(b) = &mut self.backend {
202                        b.configure_surface(size.width, size.height);
203                    }
204                    if let Some(w) = &self.window {
205                        let sf = w.scale_factor() as f32;
206                        let dp_w = size.width as f32 / sf;
207                        let dp_h = size.height as f32 / sf;
208                        log::info!(
209                            "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
210                            size.width,
211                            size.height,
212                            sf,
213                            dp_w as i32,
214                            dp_h as i32
215                        );
216                    }
217                    self.request_redraw();
218                }
219                WindowEvent::CursorMoved { position, .. } => {
220                    self.mouse_pos_px = (position.x as f32, position.y as f32);
221
222                    // Inspector hover
223                    if self.inspector.hud.inspector_enabled {
224                        if let Some(f) = &self.frame_cache {
225                            let hover_rect = f
226                                .hit_regions
227                                .iter()
228                                .find(|h| {
229                                    h.rect.contains(Vec2 {
230                                        x: self.mouse_pos_px.0,
231                                        y: self.mouse_pos_px.1,
232                                    })
233                                })
234                                .map(|h| h.rect);
235                            self.inspector.hud.set_hovered(hover_rect);
236                            self.request_redraw();
237                        }
238                    }
239
240                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
241                        if let Some(_sem) = f
242                            .semantics_nodes
243                            .iter()
244                            .find(|n| n.id == cid && n.role == Role::TextField)
245                        {
246                            let key = self.tf_key_of(cid);
247                            if let Some(state_rc) = self.textfield_states.get(&key) {
248                                let mut state = state_rc.borrow_mut();
249                                // inner content left edge in px
250                                let inner_x_px = f
251                                    .hit_regions
252                                    .iter()
253                                    .find(|h| h.id == cid)
254                                    .map(|h| h.rect.x + dp_to_px(TF_PADDING_X_DP))
255                                    .unwrap_or(0.0);
256                                let content_x_px =
257                                    self.mouse_pos_px.0 - inner_x_px + state.scroll_offset;
258                                let font_dp = TF_FONT_DP as u32;
259                                let idx =
260                                    index_for_x_bytes(&state.text, font_dp, content_x_px.max(0.0));
261                                state.drag_to(idx);
262
263                                // Scroll caret into view
264                                let m = measure_text(&state.text, font_dp);
265                                let caret_x_px =
266                                    m.positions.get(state.caret_index()).copied().unwrap_or(0.0);
267                                if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
268                                    state.ensure_caret_visible(
269                                        caret_x_px,
270                                        hit.rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP),
271                                    );
272                                }
273                                self.request_redraw();
274                            }
275                        }
276                    }
277
278                    // Pointer routing: hover + move/capture
279                    if let Some(f) = &self.frame_cache {
280                        // Determine topmost hit
281                        let pos = Vec2 {
282                            x: self.mouse_pos_px.0,
283                            y: self.mouse_pos_px.1,
284                        };
285                        let top = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos));
286                        let new_hover = top.map(|h| h.id);
287
288                        // Enter/Leave
289                        if new_hover != self.hover_id {
290                            if let Some(prev_id) = self.hover_id {
291                                if let Some(prev) = f.hit_regions.iter().find(|h| h.id == prev_id) {
292                                    if let Some(cb) = &prev.on_pointer_leave {
293                                        let pe = repose_core::input::PointerEvent {
294                                            id: repose_core::input::PointerId(0),
295                                            kind: repose_core::input::PointerKind::Mouse,
296                                            event: repose_core::input::PointerEventKind::Leave,
297                                            position: pos,
298                                            pressure: 1.0,
299                                            modifiers: self.modifiers,
300                                        };
301                                        cb(pe);
302                                    }
303                                }
304                            }
305                            if let Some(h) = top {
306                                if let Some(cb) = &h.on_pointer_enter {
307                                    let pe = repose_core::input::PointerEvent {
308                                        id: repose_core::input::PointerId(0),
309                                        kind: repose_core::input::PointerKind::Mouse,
310                                        event: repose_core::input::PointerEventKind::Enter,
311                                        position: pos,
312                                        pressure: 1.0,
313                                        modifiers: self.modifiers,
314                                    };
315                                    cb(pe);
316                                }
317                            }
318                            self.hover_id = new_hover;
319                        }
320
321                        // Build PointerEvent
322                        let pe = repose_core::input::PointerEvent {
323                            id: repose_core::input::PointerId(0),
324                            kind: repose_core::input::PointerKind::Mouse,
325                            event: repose_core::input::PointerEventKind::Move,
326                            position: pos,
327                            pressure: 1.0,
328                            modifiers: self.modifiers,
329                        };
330
331                        // Move delivery (captured first)
332                        if let Some(cid) = self.capture_id {
333                            if let Some(h) = f.hit_regions.iter().find(|h| h.id == cid) {
334                                if let Some(cb) = &h.on_pointer_move {
335                                    cb(pe.clone());
336                                }
337                            }
338                        } else if let Some(h) = &top {
339                            if let Some(cb) = &h.on_pointer_move {
340                                cb(pe);
341                            }
342                        }
343                    }
344                }
345                WindowEvent::MouseWheel { delta, .. } => {
346                    // Convert line deltas (logical) to px; pixel delta is already px
347                    let (dx_px, dy_px) = match delta {
348                        MouseScrollDelta::LineDelta(x, y) => {
349                            let unit_px = dp_to_px(60.0);
350                            (-(x * unit_px), -(y * unit_px))
351                        }
352                        MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
353                    };
354                    log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
355
356                    if let Some(f) = &self.frame_cache {
357                        let pos = Vec2 {
358                            x: self.mouse_pos_px.0,
359                            y: self.mouse_pos_px.1,
360                        };
361
362                        for hit in f.hit_regions.iter().rev().filter(|h| h.rect.contains(pos)) {
363                            if let Some(cb) = &hit.on_scroll {
364                                log::debug!("Calling on_scroll for hit region id={}", hit.id);
365                                let before = Vec2 { x: dx_px, y: dy_px };
366                                let leftover = cb(before);
367                                let consumed_x = (before.x - leftover.x).abs() > 0.001;
368                                let consumed_y = (before.y - leftover.y).abs() > 0.001;
369                                if consumed_x || consumed_y {
370                                    self.request_redraw();
371                                    break; // stop after first consumer
372                                }
373                            }
374                        }
375                    }
376                }
377                WindowEvent::MouseInput {
378                    state: ElementState::Pressed,
379                    button: MouseButton::Left,
380                    ..
381                } => {
382                    let mut need_announce = false;
383                    if let Some(f) = &self.frame_cache {
384                        let pos = Vec2 {
385                            x: self.mouse_pos_px.0,
386                            y: self.mouse_pos_px.1,
387                        };
388                        if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
389                        {
390                            // Capture starts on press
391                            self.capture_id = Some(hit.id);
392                            // Pressed visual for mouse
393                            self.pressed_ids.insert(hit.id);
394                            // Repaint for pressed state
395                            self.request_redraw();
396
397                            // Focus & IME first for focusables (so state exists)
398                            if hit.focusable {
399                                self.sched.focused = Some(hit.id);
400                                need_announce = true;
401                                let key = self.tf_key_of(hit.id);
402                                self.textfield_states.entry(key).or_insert_with(|| {
403                                    Rc::new(RefCell::new(
404                                        repose_ui::textfield::TextFieldState::new(),
405                                    ))
406                                });
407                                if let Some(win) = &self.window {
408                                    let sf = win.scale_factor();
409                                    win.set_ime_allowed(true);
410                                    win.set_ime_purpose(ImePurpose::Normal);
411                                    win.set_ime_cursor_area(
412                                        LogicalPosition::new(
413                                            hit.rect.x as f64 / sf,
414                                            hit.rect.y as f64 / sf,
415                                        ),
416                                        LogicalSize::new(
417                                            hit.rect.w as f64 / sf,
418                                            hit.rect.h as f64 / sf,
419                                        ),
420                                    );
421                                }
422                            }
423
424                            // PointerDown callback (legacy)
425                            if let Some(cb) = &hit.on_pointer_down {
426                                let pe = repose_core::input::PointerEvent {
427                                    id: repose_core::input::PointerId(0),
428                                    kind: repose_core::input::PointerKind::Mouse,
429                                    event: repose_core::input::PointerEventKind::Down(
430                                        repose_core::input::PointerButton::Primary,
431                                    ),
432                                    position: pos,
433                                    pressure: 1.0,
434                                    modifiers: self.modifiers,
435                                };
436                                cb(pe);
437                            }
438
439                            // TextField: place caret and start drag selection
440                            if let Some(_sem) = f
441                                .semantics_nodes
442                                .iter()
443                                .find(|n| n.id == hit.id && n.role == Role::TextField)
444                            {
445                                let key = self.tf_key_of(hit.id);
446                                if let Some(state_rc) = self.textfield_states.get(&key) {
447                                    let mut state = state_rc.borrow_mut();
448                                    let inner_x_px = hit.rect.x + dp_to_px(TF_PADDING_X_DP);
449                                    let content_x_px =
450                                        self.mouse_pos_px.0 - inner_x_px + state.scroll_offset;
451                                    let font_dp = TF_FONT_DP as u32;
452                                    let idx = index_for_x_bytes(
453                                        &state.text,
454                                        font_dp,
455                                        content_x_px.max(0.0),
456                                    );
457                                    state.begin_drag(idx, self.modifiers.shift);
458                                    let m = measure_text(&state.text, font_dp);
459                                    let caret_x_px = m
460                                        .positions
461                                        .get(state.caret_index())
462                                        .copied()
463                                        .unwrap_or(0.0);
464                                    state.ensure_caret_visible(
465                                        caret_x_px,
466                                        hit.rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP),
467                                    );
468                                }
469                            }
470                            if need_announce {
471                                self.announce_focus_change();
472                            }
473
474                            self.request_redraw();
475                        } else {
476                            // Click outside: drop focus/IME
477                            if self.ime_preedit {
478                                if let Some(win) = &self.window {
479                                    win.set_ime_allowed(false);
480                                }
481                                self.ime_preedit = false;
482                            }
483                            self.sched.focused = None;
484                            self.request_redraw();
485                        }
486                    }
487                }
488                WindowEvent::MouseInput {
489                    state: ElementState::Released,
490                    button: MouseButton::Left,
491                    ..
492                } => {
493                    if let Some(cid) = self.capture_id {
494                        self.pressed_ids.remove(&cid);
495                        self.request_redraw();
496                    }
497
498                    // Click on release if pointer is still over the captured hit region
499                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
500                        let pos = Vec2 {
501                            x: self.mouse_pos_px.0,
502                            y: self.mouse_pos_px.1,
503                        };
504                        if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
505                            if hit.rect.contains(pos) {
506                                if let Some(cb) = &hit.on_click {
507                                    cb();
508                                    // A11y: announce activation (mouse)
509                                    if let Some(node) =
510                                        f.semantics_nodes.iter().find(|n| n.id == cid)
511                                    {
512                                        let label = node.label.as_deref().unwrap_or("");
513                                        self.a11y.announce(&format!("Activated {}", label));
514                                    }
515                                }
516                            }
517                        }
518                    }
519                    // TextField drag end
520                    if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
521                        if let Some(_sem) = f
522                            .semantics_nodes
523                            .iter()
524                            .find(|n| n.id == cid && n.role == Role::TextField)
525                        {
526                            let key = self.tf_key_of(cid);
527                            if let Some(state_rc) = self.textfield_states.get(&key) {
528                                state_rc.borrow_mut().end_drag();
529                            }
530                        }
531                    }
532                    self.capture_id = None;
533                }
534                WindowEvent::ModifiersChanged(new_mods) => {
535                    self.modifiers.shift = new_mods.state().shift_key();
536                    self.modifiers.ctrl = new_mods.state().control_key();
537                    self.modifiers.alt = new_mods.state().alt_key();
538                    self.modifiers.meta = new_mods.state().super_key();
539                }
540                WindowEvent::KeyboardInput {
541                    event: key_event, ..
542                } => {
543                    // Focus traversal: Tab / Shift+Tab
544                    if matches!(key_event.physical_key, PhysicalKey::Code(KeyCode::Tab)) {
545                        // Only act on initial press, ignore repeats
546                        if key_event.state == ElementState::Pressed && !key_event.repeat {
547                            if let Some(f) = &self.frame_cache {
548                                let chain = &f.focus_chain;
549                                if !chain.is_empty() {
550                                    // If a button was “pressed” via keyboard, clear it when we move focus
551                                    if let Some(active) = self.key_pressed_active.take() {
552                                        self.pressed_ids.remove(&active);
553                                    }
554
555                                    let shift = self.modifiers.shift;
556                                    let current = self.sched.focused;
557                                    let next = if let Some(cur) = current {
558                                        if let Some(idx) = chain.iter().position(|&id| id == cur) {
559                                            if shift {
560                                                if idx == 0 {
561                                                    chain[chain.len() - 1]
562                                                } else {
563                                                    chain[idx - 1]
564                                                }
565                                            } else {
566                                                chain[(idx + 1) % chain.len()]
567                                            }
568                                        } else {
569                                            chain[0]
570                                        }
571                                    } else {
572                                        chain[0]
573                                    };
574                                    self.sched.focused = Some(next);
575
576                                    // IME only for TextField
577                                    if let Some(win) = &self.window {
578                                        if f.semantics_nodes
579                                            .iter()
580                                            .any(|n| n.id == next && n.role == Role::TextField)
581                                        {
582                                            win.set_ime_allowed(true);
583                                            win.set_ime_purpose(ImePurpose::Normal);
584                                        } else {
585                                            win.set_ime_allowed(false);
586                                        }
587                                    }
588                                    self.announce_focus_change();
589                                    self.request_redraw();
590                                }
591                            }
592                        }
593                        return; // swallow Tab
594                    }
595
596                    if let Some(fid) = self.sched.focused {
597                        // If focused is NOT a TextField, allow Space/Enter activation
598                        let is_textfield = if let Some(f) = &self.frame_cache {
599                            f.semantics_nodes
600                                .iter()
601                                .any(|n| n.id == fid && n.role == Role::TextField)
602                        } else {
603                            false
604                        };
605
606                        if !is_textfield {
607                            match key_event.physical_key {
608                                PhysicalKey::Code(KeyCode::Space)
609                                | PhysicalKey::Code(KeyCode::Enter) => {
610                                    if key_event.state == ElementState::Pressed && !key_event.repeat
611                                    {
612                                        self.pressed_ids.insert(fid);
613                                        self.key_pressed_active = Some(fid);
614                                        self.request_redraw();
615                                        return;
616                                    }
617                                }
618                                _ => {}
619                            }
620                        }
621                    }
622
623                    // Keyboard activation for focused TextField submit on Enter
624                    if key_event.state == ElementState::Pressed && !key_event.repeat {
625                        if let PhysicalKey::Code(KeyCode::Enter) = key_event.physical_key {
626                            if let Some(focused_id) = self.sched.focused {
627                                if let Some(f) = &self.frame_cache {
628                                    if let Some(hit) =
629                                        f.hit_regions.iter().find(|h| h.id == focused_id)
630                                    {
631                                        if let Some(on_submit) = &hit.on_text_submit {
632                                            let key = self.tf_key_of(focused_id);
633
634                                            if let Some(state) = self.textfield_states.get(&key) {
635                                                let text = state.borrow().text.clone();
636                                                on_submit(text);
637                                                self.request_redraw();
638                                                return; // don’t continue as button activation
639                                            }
640                                        }
641                                    }
642                                }
643                            }
644                        }
645                    }
646
647                    if key_event.state == ElementState::Pressed {
648                        // Inspector hotkey: Ctrl+Shift+I
649                        if self.modifiers.ctrl && self.modifiers.shift {
650                            if let PhysicalKey::Code(KeyCode::KeyI) = key_event.physical_key {
651                                self.inspector.hud.toggle_inspector();
652                                self.request_redraw();
653                                return;
654                            }
655                        }
656
657                        // TextField navigation/edit
658                        if let Some(focused_id) = self.sched.focused {
659                            let key = self.tf_key_of(focused_id);
660                            if let Some(state_rc) = self.textfield_states.get(&key) {
661                                let mut state = state_rc.borrow_mut();
662                                match key_event.physical_key {
663                                    PhysicalKey::Code(KeyCode::Backspace) => {
664                                        state.delete_backward();
665                                        let new_text = state.text.clone();
666                                        self.notify_text_change(focused_id, new_text);
667                                        App::tf_ensure_caret_visible(&mut state);
668                                        self.request_redraw();
669                                    }
670                                    PhysicalKey::Code(KeyCode::Delete) => {
671                                        state.delete_forward();
672                                        let new_text = state.text.clone();
673                                        self.notify_text_change(focused_id, new_text);
674                                        App::tf_ensure_caret_visible(&mut state);
675                                        self.request_redraw();
676                                    }
677                                    PhysicalKey::Code(KeyCode::ArrowLeft) => {
678                                        state.move_cursor(-1, self.modifiers.shift);
679                                        App::tf_ensure_caret_visible(&mut state);
680                                        self.request_redraw();
681                                    }
682                                    PhysicalKey::Code(KeyCode::ArrowRight) => {
683                                        state.move_cursor(1, self.modifiers.shift);
684                                        App::tf_ensure_caret_visible(&mut state);
685                                        self.request_redraw();
686                                    }
687                                    PhysicalKey::Code(KeyCode::Home) => {
688                                        state.selection = 0..0;
689                                        App::tf_ensure_caret_visible(&mut state);
690                                        self.request_redraw();
691                                    }
692                                    PhysicalKey::Code(KeyCode::End) => {
693                                        {
694                                            let end = state.text.len();
695                                            state.selection = end..end;
696                                        }
697                                        App::tf_ensure_caret_visible(&mut state);
698                                        self.request_redraw();
699                                    }
700                                    PhysicalKey::Code(KeyCode::KeyA) if self.modifiers.ctrl => {
701                                        state.selection = 0..state.text.len();
702                                        App::tf_ensure_caret_visible(&mut state);
703                                        self.request_redraw();
704                                    }
705                                    _ => {}
706                                }
707                            }
708                            if self.modifiers.ctrl {
709                                match key_event.physical_key {
710                                    PhysicalKey::Code(KeyCode::KeyC) => {
711                                        if let Some(fid) = self.sched.focused {
712                                            let key = self.tf_key_of(fid);
713                                            if let Some(state) = self.textfield_states.get(&key) {
714                                                let txt = state.borrow().selected_text();
715                                                if !txt.is_empty() {
716                                                    if let Some(cb) = self.clipboard.as_mut() {
717                                                        let _ = cb.set_text(txt);
718                                                    }
719                                                }
720                                            }
721                                        }
722                                        return;
723                                    }
724                                    PhysicalKey::Code(KeyCode::KeyX) => {
725                                        if let Some(fid) = self.sched.focused {
726                                            let key = self.tf_key_of(fid);
727                                            if let Some(state_rc) = self.textfield_states.get(&key)
728                                            {
729                                                // Copy
730                                                let txt = state_rc.borrow().selected_text();
731                                                if !txt.is_empty() {
732                                                    if let Some(cb) = self.clipboard.as_mut() {
733                                                        let _ = cb.set_text(txt.clone());
734                                                    }
735                                                    // Cut (delete selection)
736                                                    {
737                                                        let mut st = state_rc.borrow_mut();
738                                                        st.insert_text(""); // replace selection with empty
739                                                        let new_text = st.text.clone();
740                                                        self.notify_text_change(
741                                                            focused_id, new_text,
742                                                        );
743                                                        App::tf_ensure_caret_visible(&mut st);
744                                                    }
745                                                    self.request_redraw();
746                                                }
747                                            }
748                                        }
749                                        return;
750                                    }
751                                    PhysicalKey::Code(KeyCode::KeyV) => {
752                                        if let Some(fid) = self.sched.focused {
753                                            let key = self.tf_key_of(fid);
754                                            if let Some(state_rc) = self.textfield_states.get(&key)
755                                            {
756                                                if let Some(cb) = self.clipboard.as_mut() {
757                                                    if let Ok(mut txt) = cb.get_text() {
758                                                        // Single-line TextField: strip control/newlines
759                                                        txt.retain(|c| {
760                                                            !c.is_control()
761                                                                && c != '\n'
762                                                                && c != '\r'
763                                                        });
764                                                        if !txt.is_empty() {
765                                                            let mut st = state_rc.borrow_mut();
766                                                            st.insert_text(&txt);
767                                                            let new_text = st.text.clone();
768                                                            self.notify_text_change(
769                                                                focused_id, new_text,
770                                                            );
771                                                            App::tf_ensure_caret_visible(&mut st);
772                                                            self.request_redraw();
773                                                        }
774                                                    }
775                                                }
776                                            }
777                                        }
778                                        return;
779                                    }
780                                    _ => {}
781                                }
782                            }
783                        }
784
785                        // Plain text input when IME is not active
786                        if !self.ime_preedit
787                            && !self.modifiers.ctrl
788                            && !self.modifiers.alt
789                            && !self.modifiers.meta
790                        {
791                            if let Some(raw) = key_event.text.as_deref() {
792                                let text: String = raw
793                                    .chars()
794                                    .filter(|c| !c.is_control() && *c != '\n' && *c != '\r')
795                                    .collect();
796                                if !text.is_empty() {
797                                    if let Some(fid) = self.sched.focused {
798                                        let key = self.tf_key_of(fid);
799                                        if let Some(state_rc) = self.textfield_states.get(&key) {
800                                            let mut st = state_rc.borrow_mut();
801                                            st.insert_text(&text);
802                                            self.notify_text_change(fid, text.clone());
803                                            App::tf_ensure_caret_visible(&mut st);
804                                            self.request_redraw();
805                                        }
806                                    }
807                                }
808                            }
809                        }
810                    } else if key_event.state == ElementState::Released {
811                        // Finish keyboard activation on release (Space/Enter)
812                        if let Some(active_id) = self.key_pressed_active {
813                            match key_event.physical_key {
814                                PhysicalKey::Code(KeyCode::Space)
815                                | PhysicalKey::Code(KeyCode::Enter) => {
816                                    self.pressed_ids.remove(&active_id);
817                                    self.key_pressed_active = None;
818
819                                    if let Some(f) = &self.frame_cache {
820                                        if let Some(hit) =
821                                            f.hit_regions.iter().find(|h| h.id == active_id)
822                                        {
823                                            if let Some(cb) = &hit.on_click {
824                                                cb();
825                                                if let Some(node) = f
826                                                    .semantics_nodes
827                                                    .iter()
828                                                    .find(|n| n.id == active_id)
829                                                {
830                                                    let label = node.label.as_deref().unwrap_or("");
831                                                    self.a11y
832                                                        .announce(&format!("Activated {}", label));
833                                                }
834                                            }
835                                        }
836                                    }
837                                    self.request_redraw();
838                                    return;
839                                }
840                                _ => {}
841                            }
842                        }
843                    }
844                }
845
846                WindowEvent::Ime(ime) => {
847                    use winit::event::Ime;
848                    if let Some(focused_id) = self.sched.focused {
849                        let key = self.tf_key_of(focused_id);
850                        if let Some(state_rc) = self.textfield_states.get(&key) {
851                            let mut state = state_rc.borrow_mut();
852                            match ime {
853                                Ime::Enabled => {
854                                    // IME allowed, but not necessarily composing
855                                    self.ime_preedit = false;
856                                }
857                                Ime::Preedit(text, cursor) => {
858                                    let cursor_usize =
859                                        cursor.map(|(a, b)| (a as usize, b as usize));
860                                    state.set_composition(text.clone(), cursor_usize);
861                                    self.ime_preedit = !text.is_empty();
862                                    if let Some(f) = &self.frame_cache {
863                                        if let Some(hit) =
864                                            f.hit_regions.iter().find(|h| h.id == focused_id)
865                                        {
866                                            let inner = Rect {
867                                                x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
868                                                y: hit.rect.y,
869                                                w: hit.rect.w,
870                                                h: hit.rect.h,
871                                            };
872                                            tf_ensure_visible_in_rect(&mut state, inner);
873                                        }
874                                    }
875                                    // notify on-change if you wired it:
876                                    self.notify_text_change(focused_id, state.text.clone());
877                                    self.request_redraw();
878                                }
879                                Ime::Commit(text) => {
880                                    state.commit_composition(text);
881                                    self.ime_preedit = false;
882                                    if let Some(f) = &self.frame_cache {
883                                        if let Some(hit) =
884                                            f.hit_regions.iter().find(|h| h.id == focused_id)
885                                        {
886                                            let inner = Rect {
887                                                x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
888                                                y: hit.rect.y,
889                                                w: hit.rect.w,
890                                                h: hit.rect.h,
891                                            };
892                                            tf_ensure_visible_in_rect(&mut state, inner);
893                                        }
894                                    }
895                                    self.notify_text_change(focused_id, state.text.clone());
896                                    self.request_redraw();
897                                }
898                                Ime::Disabled => {
899                                    self.ime_preedit = false;
900                                    if state.composition.is_some() {
901                                        state.cancel_composition();
902                                        if let Some(f) = &self.frame_cache {
903                                            if let Some(hit) =
904                                                f.hit_regions.iter().find(|h| h.id == focused_id)
905                                            {
906                                                let inner = Rect {
907                                                    x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
908                                                    y: hit.rect.y,
909                                                    w: hit.rect.w,
910                                                    h: hit.rect.h,
911                                                };
912                                                tf_ensure_visible_in_rect(&mut state, inner);
913                                            }
914                                        }
915                                        self.notify_text_change(focused_id, state.text.clone());
916                                    }
917                                    self.request_redraw();
918                                }
919                            }
920                        }
921                    }
922                }
923                WindowEvent::RedrawRequested => {
924                    if let (Some(backend), Some(win)) =
925                        (self.backend.as_mut(), self.window.as_ref())
926                    {
927                        let t0 = Instant::now();
928                        let scale = win.scale_factor() as f32;
929                        let size_px_u32 = self.sched.size;
930                        let focused = self.sched.focused;
931
932                        let frame = compose_frame(
933                            &mut self.sched,
934                            &mut self.root,
935                            scale,
936                            size_px_u32,
937                            self.hover_id,
938                            &self.pressed_ids,
939                            &self.textfield_states,
940                            focused,
941                        );
942
943                        let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
944
945                        // A11y: publish semantics tree each frame (cheap for now)
946                        self.a11y.publish_tree(&frame.semantics_nodes);
947                        // If focus id changed since last publish, send focused node
948                        if self.last_focus != self.sched.focused {
949                            let focused_node = self
950                                .sched
951                                .focused
952                                .and_then(|id| frame.semantics_nodes.iter().find(|n| n.id == id));
953                            self.a11y.focus_changed(focused_node);
954                            self.last_focus = self.sched.focused;
955                        }
956
957                        // Render
958                        let mut scene = frame.scene.clone();
959                        // Update HUD metrics before overlay draws
960                        self.inspector.hud.metrics = Some(repose_devtools::Metrics {
961                            build_layout_ms,
962                            scene_nodes: scene.nodes.len(),
963                        });
964                        self.inspector.frame(&mut scene);
965                        backend
966                            // .lock()
967                            .frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
968                        self.frame_cache = Some(frame);
969                    }
970                }
971                _ => {}
972            }
973        }
974
975        fn about_to_wait(&mut self, _el: &winit::event_loop::ActiveEventLoop) {
976            self.request_redraw();
977        }
978
979        fn new_events(
980            &mut self,
981            _: &winit::event_loop::ActiveEventLoop,
982            _: winit::event::StartCause,
983        ) {
984        }
985        fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {}
986        fn device_event(
987            &mut self,
988            _: &winit::event_loop::ActiveEventLoop,
989            _: winit::event::DeviceId,
990            _: winit::event::DeviceEvent,
991        ) {
992        }
993        fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
994        fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
995        fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
996    }
997
998    impl App {
999        fn announce_focus_change(&mut self) {
1000            if let Some(f) = &self.frame_cache {
1001                let focused_node = self
1002                    .sched
1003                    .focused
1004                    .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1005                self.a11y.focus_changed(focused_node);
1006            }
1007        }
1008        fn notify_text_change(&self, id: u64, text: String) {
1009            if let Some(f) = &self.frame_cache {
1010                if let Some(h) = f.hit_regions.iter().find(|h| h.id == id) {
1011                    if let Some(cb) = &h.on_text_change {
1012                        cb(text);
1013                    }
1014                }
1015            }
1016        }
1017        fn tf_key_of(&self, visual_id: u64) -> u64 {
1018            if let Some(f) = &self.frame_cache {
1019                if let Some(hr) = f.hit_regions.iter().find(|h| h.id == visual_id) {
1020                    return hr.tf_state_key.unwrap_or(hr.id);
1021                }
1022            }
1023            visual_id
1024        }
1025    }
1026
1027    let event_loop = EventLoop::new()?;
1028    let mut app = App::new(Box::new(root));
1029    // Install system clock once
1030    repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1031    event_loop.run_app(&mut app)?;
1032    Ok(())
1033}
1034
1035// Accessibility bridge stub (Noop by default; logs on Linux for now)
1036pub trait A11yBridge: Send {
1037    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1038    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1039    fn announce(&mut self, msg: &str);
1040}
1041
1042struct NoopA11y;
1043impl A11yBridge for NoopA11y {
1044    fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1045        // no-op
1046    }
1047    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1048        if let Some(n) = node {
1049            log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1050        } else {
1051            log::info!("A11y focus: None");
1052        }
1053    }
1054    fn announce(&mut self, msg: &str) {
1055        log::info!("A11y announce: {msg}");
1056    }
1057}
1058
1059#[cfg(target_os = "linux")]
1060struct LinuxAtspiStub;
1061#[cfg(target_os = "linux")]
1062impl A11yBridge for LinuxAtspiStub {
1063    fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1064        log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1065    }
1066    fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1067        if let Some(n) = node {
1068            log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1069        } else {
1070            log::info!("AT-SPI stub focus: None");
1071        }
1072    }
1073    fn announce(&mut self, msg: &str) {
1074        log::info!("AT-SPI stub announce: {msg}");
1075    }
1076}