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