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 mut dy = match delta {
431 MouseScrollDelta::LineDelta(_x, y) => -y * 40.0,
432 MouseScrollDelta::PixelDelta(lp) => -(lp.y as f32),
433 };
434
435 if let Some(f) = &self.frame_cache {
436 let pos = Vec2 {
437 x: self.mouse_pos.0,
438 y: self.mouse_pos.1,
439 };
440 // Nested routing: from topmost to deeper ancestors under cursor
441 let mut consumed_any = false;
442 for hit in f.hit_regions.iter().rev().filter(|h| h.rect.contains(pos)) {
443 if let Some(cb) = &hit.on_scroll {
444 let before = dy;
445 dy = cb(dy); // returns leftover
446 if (before - dy).abs() > 0.001 {
447 consumed_any = true;
448 }
449 if dy.abs() <= 0.001 {
450 break;
451 }
452 }
453 }
454 if consumed_any {
455 self.request_redraw();
456 }
457 }
458 }
459 WindowEvent::ModifiersChanged(new_mods) => {
460 self.modifiers.shift = new_mods.state().shift_key();
461 self.modifiers.ctrl = new_mods.state().control_key();
462 self.modifiers.alt = new_mods.state().alt_key();
463 self.modifiers.meta = new_mods.state().super_key();
464 }
465 WindowEvent::KeyboardInput {
466 event: key_event, ..
467 } => {
468 // Focus traversal: Tab / Shift+Tab
469 if matches!(
470 key_event.physical_key,
471 winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Tab)
472 ) {
473 if let Some(f) = &self.frame_cache {
474 let chain = &f.focus_chain;
475 if !chain.is_empty() {
476 let shift = self.modifiers.shift;
477 let current = self.sched.focused;
478 let next = if let Some(cur) = current {
479 if let Some(idx) = chain.iter().position(|&id| id == cur) {
480 if shift {
481 if idx == 0 {
482 chain[chain.len() - 1]
483 } else {
484 chain[idx - 1]
485 }
486 } else {
487 chain[(idx + 1) % chain.len()]
488 }
489 } else {
490 chain[0]
491 }
492 } else {
493 chain[0]
494 };
495 self.sched.focused = Some(next);
496 // IME on TextField focus; off otherwise
497 if let Some(win) = &self.window {
498 if f.semantics_nodes
499 .iter()
500 .any(|n| n.id == next && n.role == Role::TextField)
501 {
502 win.set_ime_allowed(true);
503 win.set_ime_purpose(winit::window::ImePurpose::Normal);
504 } else {
505 win.set_ime_allowed(false);
506 }
507 }
508 self.announce_focus_change();
509 self.request_redraw();
510 }
511 }
512 return;
513 }
514
515 // Keyboard activation for focused widgets (Space/Enter)
516 if let Some(fid) = self.sched.focused {
517 match key_event.physical_key {
518 winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Space)
519 | winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Enter) =>
520 {
521 // pressed visual and remember which to release
522 self.pressed_ids.insert(fid);
523 self.key_pressed_active = Some(fid);
524 self.request_redraw();
525 return; // don't fall through to text input path
526 }
527 _ => {}
528 }
529 }
530
531 if key_event.state == ElementState::Pressed {
532 // Inspector hotkey: Ctrl+Shift+I
533 if self.modifiers.ctrl && self.modifiers.shift {
534 if let PhysicalKey::Code(KeyCode::KeyI) = key_event.physical_key {
535 self.inspector.hud.toggle_inspector();
536 self.request_redraw();
537 return;
538 }
539 }
540
541 // TextField navigation/edit
542 if let Some(focused_id) = self.sched.focused {
543 if let Some(state) = self.textfield_states.get(&focused_id) {
544 let mut state = state.borrow_mut();
545 match key_event.physical_key {
546 PhysicalKey::Code(KeyCode::Backspace) => {
547 state.delete_backward();
548 let new_text = state.text.clone();
549 self.notify_text_change(focused_id, new_text);
550 App::tf_ensure_caret_visible(&mut state);
551 self.request_redraw();
552 }
553 PhysicalKey::Code(KeyCode::Delete) => {
554 state.delete_forward();
555 let new_text = state.text.clone();
556 self.notify_text_change(focused_id, new_text);
557 App::tf_ensure_caret_visible(&mut state);
558 self.request_redraw();
559 }
560 PhysicalKey::Code(KeyCode::ArrowLeft) => {
561 state.move_cursor(-1, self.modifiers.shift);
562 App::tf_ensure_caret_visible(&mut state);
563 self.request_redraw();
564 }
565 PhysicalKey::Code(KeyCode::ArrowRight) => {
566 state.move_cursor(1, self.modifiers.shift);
567 App::tf_ensure_caret_visible(&mut state);
568 self.request_redraw();
569 }
570 PhysicalKey::Code(KeyCode::Home) => {
571 state.selection = 0..0;
572 App::tf_ensure_caret_visible(&mut state);
573 self.request_redraw();
574 }
575 PhysicalKey::Code(KeyCode::End) => {
576 {
577 let end = state.text.len();
578 state.selection = end..end;
579 }
580 App::tf_ensure_caret_visible(&mut state);
581 self.request_redraw();
582 }
583 PhysicalKey::Code(KeyCode::KeyA) if self.modifiers.ctrl => {
584 state.selection = 0..state.text.len();
585 App::tf_ensure_caret_visible(&mut state);
586 self.request_redraw();
587 }
588 _ => {}
589 }
590 }
591 if self.modifiers.ctrl {
592 match key_event.physical_key {
593 PhysicalKey::Code(KeyCode::KeyC) => {
594 if let Some(fid) = self.sched.focused {
595 if let Some(state) = self.textfield_states.get(&fid) {
596 let txt = state.borrow().selected_text();
597 if !txt.is_empty() {
598 if let Some(cb) = self.clipboard.as_mut() {
599 let _ = cb.set_text(txt);
600 }
601 }
602 }
603 }
604 return;
605 }
606 PhysicalKey::Code(KeyCode::KeyX) => {
607 if let Some(fid) = self.sched.focused {
608 if let Some(state_rc) = self.textfield_states.get(&fid)
609 {
610 // Copy
611 let txt = state_rc.borrow().selected_text();
612 if !txt.is_empty() {
613 if let Some(cb) = self.clipboard.as_mut() {
614 let _ = cb.set_text(txt.clone());
615 }
616 // Cut (delete selection)
617 {
618 let mut st = state_rc.borrow_mut();
619 st.insert_text(""); // replace selection with empty
620 let new_text = st.text.clone();
621 self.notify_text_change(
622 focused_id, new_text,
623 );
624 App::tf_ensure_caret_visible(&mut st);
625 }
626 self.request_redraw();
627 }
628 }
629 }
630 return;
631 }
632 PhysicalKey::Code(KeyCode::KeyV) => {
633 if let Some(fid) = self.sched.focused {
634 if let Some(state_rc) = self.textfield_states.get(&fid)
635 {
636 if let Some(cb) = self.clipboard.as_mut() {
637 if let Ok(mut txt) = cb.get_text() {
638 // Single-line TextField: strip control/newlines
639 txt.retain(|c| {
640 !c.is_control()
641 && c != '\n'
642 && c != '\r'
643 });
644 if !txt.is_empty() {
645 let mut st = state_rc.borrow_mut();
646 st.insert_text(&txt);
647 let new_text = st.text.clone();
648 self.notify_text_change(
649 focused_id, new_text,
650 );
651 App::tf_ensure_caret_visible(&mut st);
652 self.request_redraw();
653 }
654 }
655 }
656 }
657 }
658 return;
659 }
660 _ => {}
661 }
662 }
663 }
664
665 // Plain text input when IME is not active
666 if !self.ime_preedit
667 && !self.modifiers.ctrl
668 && !self.modifiers.alt
669 && !self.modifiers.meta
670 {
671 if let Some(raw) = key_event.text.as_deref() {
672 let text: String = raw
673 .chars()
674 .filter(|c| !c.is_control() && *c != '\n' && *c != '\r')
675 .collect();
676 if !text.is_empty() {
677 if let Some(fid) = self.sched.focused {
678 if let Some(state_rc) = self.textfield_states.get(&fid) {
679 let mut st = state_rc.borrow_mut();
680 st.insert_text(&text);
681 self.notify_text_change(fid, text.clone());
682 App::tf_ensure_caret_visible(&mut st);
683 self.request_redraw();
684 }
685 }
686 }
687 }
688 }
689 } else if key_event.state == ElementState::Released {
690 // Finish keyboard activation on release (Space/Enter)
691 if let Some(active) = self.key_pressed_active.take() {
692 if matches!(
693 key_event.physical_key,
694 winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Space)
695 | winit::keyboard::PhysicalKey::Code(
696 winit::keyboard::KeyCode::Enter
697 )
698 ) {
699 self.pressed_ids.remove(&active);
700 // Fire on_click if the focused item has it
701 if let Some(f) = &self.frame_cache {
702 if let Some(h) = f.hit_regions.iter().find(|h| h.id == active) {
703 if let Some(cb) = &h.on_click {
704 cb();
705 // A11y: announce activation
706 if let Some(node) =
707 f.semantics_nodes.iter().find(|n| n.id == active)
708 {
709 let label = node.label.as_deref().unwrap_or("");
710 self.a11y.announce(&format!("Activated {}", label));
711 }
712 }
713 }
714 }
715 self.request_redraw();
716 return;
717 }
718 }
719 }
720 }
721
722 WindowEvent::Ime(ime) => {
723 use winit::event::Ime;
724
725 if let Some(focused_id) = self.sched.focused {
726 if let Some(state) = self.textfield_states.get(&focused_id) {
727 let mut state = state.borrow_mut();
728 match ime {
729 Ime::Enabled => {
730 // IME allowed, but not necessarily composing
731 self.ime_preedit = false;
732 }
733 Ime::Preedit(text, cursor) => {
734 {
735 state.set_composition(text.clone(), cursor);
736 }
737 self.ime_preedit = !text.is_empty();
738 let new_text = state.text.clone();
739 self.notify_text_change(focused_id, new_text);
740 App::tf_ensure_caret_visible(&mut state);
741 self.request_redraw();
742 }
743 Ime::Commit(text) => {
744 {
745 state.commit_composition(text);
746 }
747 self.ime_preedit = false;
748 App::tf_ensure_caret_visible(&mut state);
749 let new_text = state.text.clone();
750 self.notify_text_change(focused_id, new_text);
751 self.request_redraw();
752 }
753 Ime::Disabled => {
754 self.ime_preedit = false;
755 if state.composition.is_some() {
756 {
757 state.cancel_composition();
758 let new_text = state.text.clone();
759 self.notify_text_change(focused_id, new_text);
760 }
761 App::tf_ensure_caret_visible(&mut state);
762 }
763 self.request_redraw();
764 }
765 }
766 }
767 }
768 }
769 WindowEvent::RedrawRequested => {
770 if let (Some(backend), Some(_win)) =
771 (self.backend.as_mut(), self.window.as_ref())
772 {
773 let t0 = Instant::now();
774 // Compose
775 let focused = self.sched.focused;
776 let hover_id = self.hover_id;
777 let pressed_ids = self.pressed_ids.clone();
778 let tf_states = &self.textfield_states;
779
780 let frame = self.sched.repose(&mut self.root, move |view, size| {
781 let interactions = repose_ui::Interactions {
782 hover: hover_id,
783 pressed: pressed_ids.clone(),
784 };
785
786 layout_and_paint(view, size, tf_states, &interactions, focused)
787 });
788
789 let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
790
791 // A11y: publish semantics tree each frame (cheap for now)
792 self.a11y.publish_tree(&frame.semantics_nodes);
793 // If focus id changed since last publish, send focused node
794 if self.last_focus != self.sched.focused {
795 let focused_node = self
796 .sched
797 .focused
798 .and_then(|id| frame.semantics_nodes.iter().find(|n| n.id == id));
799 self.a11y.focus_changed(focused_node);
800 self.last_focus = self.sched.focused;
801 }
802
803 // Render
804 let mut scene = frame.scene.clone();
805 // Update HUD metrics before overlay draws
806 self.inspector.hud.metrics = Some(repose_devtools::Metrics {
807 build_layout_ms,
808 scene_nodes: scene.nodes.len(),
809 });
810 self.inspector.frame(&mut scene);
811 backend.frame(&scene, GlyphRasterConfig { px: 18.0 });
812 self.frame_cache = Some(frame);
813 }
814 }
815 _ => {}
816 }
817 }
818
819 fn about_to_wait(&mut self, _el: &winit::event_loop::ActiveEventLoop) {
820 self.request_redraw();
821 }
822
823 fn new_events(
824 &mut self,
825 _: &winit::event_loop::ActiveEventLoop,
826 _: winit::event::StartCause,
827 ) {
828 }
829 fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {}
830 fn device_event(
831 &mut self,
832 _: &winit::event_loop::ActiveEventLoop,
833 _: winit::event::DeviceId,
834 _: winit::event::DeviceEvent,
835 ) {
836 }
837 fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
838 fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
839 fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
840 }
841
842 impl App {
843 fn announce_focus_change(&mut self) {
844 if let Some(f) = &self.frame_cache {
845 let focused_node = self
846 .sched
847 .focused
848 .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
849 self.a11y.focus_changed(focused_node);
850 }
851 }
852 fn notify_text_change(&self, id: u64, text: String) {
853 if let Some(f) = &self.frame_cache {
854 if let Some(h) = f.hit_regions.iter().find(|h| h.id == id) {
855 if let Some(cb) = &h.on_text_change {
856 cb(text);
857 }
858 }
859 }
860 }
861 }
862
863 let event_loop = EventLoop::new()?;
864 let mut app = App::new(Box::new(root));
865 // Install system clock once
866 repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
867 event_loop.run_app(&mut app)?;
868 Ok(())
869}
870
871// #[cfg(feature = "android")]
872// pub mod android {
873// use super::*;
874// use std::rc::Rc;
875// use std::sync::Arc;
876// use winit::application::ApplicationHandler;
877// use winit::dpi::PhysicalSize;
878// use winit::event::{ElementState, MouseScrollDelta, WindowEvent};
879// use winit::event_loop::EventLoop;
880// use winit::keyboard::{KeyCode, PhysicalKey};
881// use winit::platform::android::activity::AndroidApp;
882// use winit::window::{ImePurpose, Window, WindowAttributes};
883
884// pub fn run_android_app(
885// app: AndroidApp,
886// mut root: impl FnMut(&mut Scheduler) -> View + 'static,
887// ) -> anyhow::Result<()> {
888// repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
889// let event_loop = winit::event_loop::EventLoopBuilder::new()
890// .with_android_app(app)
891// .build()?;
892
893// struct A {
894// root: Box<dyn FnMut(&mut Scheduler) -> View>,
895// window: Option<Arc<Window>>,
896// backend: Option<repose_render_wgpu::WgpuBackend>,
897// sched: Scheduler,
898// inspector: repose_devtools::Inspector,
899// frame_cache: Option<Frame>,
900// mouse_pos: (f32, f32),
901// modifiers: Modifiers,
902// textfield_states: HashMap<u64, Rc<std::cell::RefCell<TextFieldState>>>,
903// ime_preedit: bool,
904// hover_id: Option<u64>,
905// capture_id: Option<u64>,
906// pressed_ids: HashSet<u64>,
907// key_pressed_active: Option<u64>,
908// last_scale: f64,
909// }
910// impl A {
911// fn new(root: Box<dyn FnMut(&mut Scheduler) -> View>) -> Self {
912// Self {
913// root,
914// window: None,
915// backend: None,
916// sched: Scheduler::new(),
917// inspector: repose_devtools::Inspector::new(),
918// frame_cache: None,
919// mouse_pos: (0.0, 0.0),
920// modifiers: Modifiers::default(),
921// textfield_states: HashMap::new(),
922// ime_preedit: false,
923// hover_id: None,
924// capture_id: None,
925// pressed_ids: HashSet::new(),
926// key_pressed_active: None,
927// last_scale: 1.0,
928// }
929// }
930// fn request_redraw(&self) {
931// if let Some(w) = &self.window {
932// w.request_redraw();
933// }
934// }
935// }
936// impl ApplicationHandler<()> for A {
937// fn resumed(&mut self, el: &winit::event_loop::ActiveEventLoop) {
938// if self.window.is_none() {
939// match el.create_window(WindowAttributes::default().with_title("Repose android")) {
940// Ok(win) => {
941// let w = Arc::new(win);
942// let size = w.inner_size();
943// self.sched.size = (size.width, size.height);
944// self.last_scale = w.scale_factor();
945// match repose_render_wgpu::WgpuBackend::new(w.clone()) {
946// Ok(b) => {
947// self.backend = Some(b);
948// self.window = Some(w);
949// self.request_redraw();
950// }
951// Err(e) => {
952// log::error!("WGPU backend init failed: {e:?}");
953// el.exit();
954// }
955// }
956// }
957// Err(e) => {
958// log::error!("Window create failed: {e:?}");
959// el.exit();
960// }
961// }
962// }
963// }
964// fn window_event(
965// &mut self,
966// el: &winit::event_loop::ActiveEventLoop,
967// _id: winit::window::WindowId,
968// event: WindowEvent,
969// ) {
970// match event {
971// WindowEvent::Ime(ime) => {
972// use winit::event::Ime;
973// if let Some(focused_id) = self.sched.focused {
974// if let Some(state) = self.textfield_states.get(&focused_id) {
975// let mut state = state.borrow_mut();
976// match ime {
977// Ime::Enabled => {
978// self.ime_preedit = false;
979// }
980// Ime::Preedit(text, cursor) => {
981// state.set_composition(text.clone(), cursor);
982// self.ime_preedit = !text.is_empty();
983// self.request_redraw();
984// }
985// Ime::Commit(text) => {
986// state.commit_composition(text);
987// self.ime_preedit = false;
988// self.request_redraw();
989// }
990// Ime::Disabled => {
991// self.ime_preedit = false;
992// if state.composition.is_some() {
993// state.cancel_composition();
994// }
995// self.request_redraw();
996// }
997// }
998// }
999// }
1000// }
1001// WindowEvent::CloseRequested => el.exit(),
1002// WindowEvent::Resized(size) => {
1003// self.sched.size = (size.width, size.height);
1004// if let Some(b) = &mut self.backend {
1005// b.configure_surface(size.width, size.height);
1006// }
1007// self.request_redraw();
1008// }
1009// WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
1010// self.last_scale = scale_factor;
1011// self.request_redraw();
1012// }
1013// WindowEvent::CursorMoved { position, .. } => {
1014// self.mouse_pos = (position.x as f32, position.y as f32);
1015// // hover/move same as desktop (omitted for brevity; reuse desktop branch)
1016// if let Some(f) = &self.frame_cache {
1017// let pos = Vec2 {
1018// x: self.mouse_pos.0,
1019// y: self.mouse_pos.1,
1020// };
1021// let top = f
1022// .hit_regions
1023// .iter()
1024// .rev()
1025// .find(|h| h.rect.contains(pos))
1026// .cloned();
1027// let new_hover = top.as_ref().map(|h| h.id);
1028// if new_hover != self.hover_id {
1029// if let Some(prev_id) = self.hover_id {
1030// if let Some(prev) =
1031// f.hit_regions.iter().find(|h| h.id == prev_id)
1032// {
1033// if let Some(cb) = &prev.on_pointer_leave {
1034// cb(repose_core::input::PointerEvent {
1035// id: repose_core::input::PointerId(0),
1036// kind: repose_core::input::PointerKind::Touch,
1037// event: repose_core::input::PointerEventKind::Leave,
1038// position: pos,
1039// pressure: 1.0,
1040// modifiers: self.modifiers,
1041// });
1042// }
1043// }
1044// }
1045// if let Some(h) = &top {
1046// if let Some(cb) = &h.on_pointer_enter {
1047// cb(repose_core::input::PointerEvent {
1048// id: repose_core::input::PointerId(0),
1049// kind: repose_core::input::PointerKind::Touch,
1050// event: repose_core::input::PointerEventKind::Enter,
1051// position: pos,
1052// pressure: 1.0,
1053// modifiers: self.modifiers,
1054// });
1055// }
1056// }
1057// self.hover_id = new_hover;
1058// }
1059// let pe = repose_core::input::PointerEvent {
1060// id: repose_core::input::PointerId(0),
1061// kind: repose_core::input::PointerKind::Touch,
1062// event: repose_core::input::PointerEventKind::Move,
1063// position: pos,
1064// pressure: 1.0,
1065// modifiers: self.modifiers,
1066// };
1067// if let Some(cid) = self.capture_id {
1068// if let Some(h) = f.hit_regions.iter().find(|h| h.id == cid) {
1069// if let Some(cb) = &h.on_pointer_move {
1070// cb(pe.clone());
1071// }
1072// }
1073// } else if let Some(h) = top {
1074// if let Some(cb) = &h.on_pointer_move {
1075// cb(pe);
1076// }
1077// }
1078// }
1079// }
1080// WindowEvent::MouseInput {
1081// state,
1082// button: winit::event::MouseButton::Left,
1083// ..
1084// } => {
1085// if state == ElementState::Pressed {
1086// if let Some(f) = &self.frame_cache {
1087// let pos = Vec2 {
1088// x: self.mouse_pos.0,
1089// y: self.mouse_pos.1,
1090// };
1091// if let Some(hit) =
1092// f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
1093// {
1094// self.capture_id = Some(hit.id);
1095// self.pressed_ids.insert(hit.id);
1096// if hit.focusable {
1097// self.sched.focused = Some(hit.id);
1098// if let Some(win) = &self.window {
1099// win.set_ime_allowed(true);
1100// win.set_ime_purpose(ImePurpose::Normal);
1101// }
1102// }
1103// if let Some(cb) = &hit.on_pointer_down {
1104// cb(repose_core::input::PointerEvent {
1105// id: repose_core::input::PointerId(0),
1106// kind: repose_core::input::PointerKind::Touch,
1107// event: repose_core::input::PointerEventKind::Down(
1108// repose_core::input::PointerButton::Primary,
1109// ),
1110// position: pos,
1111// pressure: 1.0,
1112// modifiers: self.modifiers,
1113// });
1114// }
1115// self.request_redraw();
1116// }
1117// }
1118// } else {
1119// if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
1120// self.pressed_ids.remove(&cid);
1121// let pos = Vec2 {
1122// x: self.mouse_pos.0,
1123// y: self.mouse_pos.1,
1124// };
1125// if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
1126// if hit.rect.contains(pos) {
1127// if let Some(cb) = &hit.on_click {
1128// cb();
1129// }
1130// }
1131// }
1132// }
1133// self.capture_id = None;
1134// self.request_redraw();
1135// }
1136// }
1137// WindowEvent::MouseWheel { delta, .. } => {
1138// let mut dy = match delta {
1139// MouseScrollDelta::LineDelta(_x, y) => -y * 40.0,
1140// MouseScrollDelta::PixelDelta(lp) => -(lp.y as f32),
1141// };
1142// if let Some(f) = &self.frame_cache {
1143// let pos = Vec2 {
1144// x: self.mouse_pos.0,
1145// y: self.mouse_pos.1,
1146// };
1147// for hit in f.hit_regions.iter().rev().filter(|h| h.rect.contains(pos)) {
1148// if let Some(cb) = &hit.on_scroll {
1149// dy = cb(dy);
1150// if dy.abs() <= 0.001 {
1151// break;
1152// }
1153// }
1154// }
1155// self.request_redraw();
1156// }
1157// }
1158// WindowEvent::RedrawRequested => {
1159// if let (Some(backend), Some(win)) =
1160// (self.backend.as_mut(), self.window.as_ref())
1161// {
1162// let scale = win.scale_factor();
1163// self.last_scale = scale;
1164// let t0 = Instant::now();
1165// let frame = self.sched.repose(&mut self.root, |view, size| {
1166// let interactions = repose_ui::Interactions {
1167// hover: self.hover_id,
1168// pressed: self.pressed_ids.clone(),
1169// };
1170// // Density from scale factor (Android DPI / 160 roughly equals scale)
1171// with_density(
1172// Density {
1173// scale: scale as f32,
1174// },
1175// || {
1176// layout_and_paint(
1177// view,
1178// size,
1179// &self.textfield_states,
1180// &interactions,
1181// self.sched.focused,
1182// )
1183// },
1184// )
1185// });
1186// let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
1187// let mut scene = frame.scene.clone();
1188// // HUD (opt-in via inspector hotkey; on Android you can toggle via programmatic flag later)
1189// super::App::new(Box::new(|_| View::new(0, ViewKind::Surface))); // no-op; placeholder to keep structure similar
1190// backend.frame(&scene, GlyphRasterConfig { px: 18.0 });
1191// self.frame_cache = Some(frame);
1192// }
1193// }
1194
1195// _ => {}
1196// }
1197// }
1198// }
1199// let mut app_state = A::new(Box::new(root));
1200// event_loop.run_app(&mut app_state)?;
1201// Ok(())
1202// }
1203// }
1204
1205// Accessibility bridge stub (Noop by default; logs on Linux for now)
1206pub trait A11yBridge: Send {
1207 fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1208 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1209 fn announce(&mut self, msg: &str);
1210}
1211
1212struct NoopA11y;
1213impl A11yBridge for NoopA11y {
1214 fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1215 // no-op
1216 }
1217 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1218 if let Some(n) = node {
1219 log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1220 } else {
1221 log::info!("A11y focus: None");
1222 }
1223 }
1224 fn announce(&mut self, msg: &str) {
1225 log::info!("A11y announce: {msg}");
1226 }
1227}
1228
1229#[cfg(target_os = "linux")]
1230struct LinuxAtspiStub;
1231#[cfg(target_os = "linux")]
1232impl A11yBridge for LinuxAtspiStub {
1233 fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1234 log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1235 }
1236 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1237 if let Some(n) = node {
1238 log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1239 } else {
1240 log::info!("AT-SPI stub focus: None");
1241 }
1242 }
1243 fn announce(&mut self, msg: &str) {
1244 log::info!("AT-SPI stub announce: {msg}");
1245 }
1246}