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