repose_platform/
lib.rs

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