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