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 Example")
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 log::info!("Window close requested");
197 el.exit();
198 }
199 WindowEvent::Resized(size) => {
200 self.sched.size = (size.width, size.height);
201 if let Some(b) = &mut self.backend {
202 b.configure_surface(size.width, size.height);
203 }
204 if let Some(w) = &self.window {
205 let sf = w.scale_factor() as f32;
206 let dp_w = size.width as f32 / sf;
207 let dp_h = size.height as f32 / sf;
208 log::info!(
209 "Resized: fb={}x{} px, scale_factor={}, ~{}x{} dp",
210 size.width,
211 size.height,
212 sf,
213 dp_w as i32,
214 dp_h as i32
215 );
216 }
217 self.request_redraw();
218 }
219 WindowEvent::CursorMoved { position, .. } => {
220 self.mouse_pos_px = (position.x as f32, position.y as f32);
221
222 // Inspector hover
223 if self.inspector.hud.inspector_enabled {
224 if let Some(f) = &self.frame_cache {
225 let hover_rect = f
226 .hit_regions
227 .iter()
228 .find(|h| {
229 h.rect.contains(Vec2 {
230 x: self.mouse_pos_px.0,
231 y: self.mouse_pos_px.1,
232 })
233 })
234 .map(|h| h.rect);
235 self.inspector.hud.set_hovered(hover_rect);
236 self.request_redraw();
237 }
238 }
239
240 if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
241 if let Some(_sem) = f
242 .semantics_nodes
243 .iter()
244 .find(|n| n.id == cid && n.role == Role::TextField)
245 {
246 let key = self.tf_key_of(cid);
247 if let Some(state_rc) = self.textfield_states.get(&key) {
248 let mut state = state_rc.borrow_mut();
249 // inner content left edge in px
250 let inner_x_px = f
251 .hit_regions
252 .iter()
253 .find(|h| h.id == cid)
254 .map(|h| h.rect.x + dp_to_px(TF_PADDING_X_DP))
255 .unwrap_or(0.0);
256 let content_x_px =
257 self.mouse_pos_px.0 - inner_x_px + state.scroll_offset;
258 let font_dp = TF_FONT_DP as u32;
259 let idx =
260 index_for_x_bytes(&state.text, font_dp, content_x_px.max(0.0));
261 state.drag_to(idx);
262
263 // Scroll caret into view
264 let m = measure_text(&state.text, font_dp);
265 let caret_x_px =
266 m.positions.get(state.caret_index()).copied().unwrap_or(0.0);
267 if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
268 state.ensure_caret_visible(
269 caret_x_px,
270 hit.rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP),
271 );
272 }
273 self.request_redraw();
274 }
275 }
276 }
277
278 // Pointer routing: hover + move/capture
279 if let Some(f) = &self.frame_cache {
280 // Determine topmost hit
281 let pos = Vec2 {
282 x: self.mouse_pos_px.0,
283 y: self.mouse_pos_px.1,
284 };
285 let top = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos));
286 let new_hover = top.map(|h| h.id);
287
288 // Enter/Leave
289 if new_hover != self.hover_id {
290 if let Some(prev_id) = self.hover_id {
291 if let Some(prev) = f.hit_regions.iter().find(|h| h.id == prev_id) {
292 if let Some(cb) = &prev.on_pointer_leave {
293 let pe = repose_core::input::PointerEvent {
294 id: repose_core::input::PointerId(0),
295 kind: repose_core::input::PointerKind::Mouse,
296 event: repose_core::input::PointerEventKind::Leave,
297 position: pos,
298 pressure: 1.0,
299 modifiers: self.modifiers,
300 };
301 cb(pe);
302 }
303 }
304 }
305 if let Some(h) = top {
306 if let Some(cb) = &h.on_pointer_enter {
307 let pe = repose_core::input::PointerEvent {
308 id: repose_core::input::PointerId(0),
309 kind: repose_core::input::PointerKind::Mouse,
310 event: repose_core::input::PointerEventKind::Enter,
311 position: pos,
312 pressure: 1.0,
313 modifiers: self.modifiers,
314 };
315 cb(pe);
316 }
317 }
318 self.hover_id = new_hover;
319 }
320
321 // Build PointerEvent
322 let pe = repose_core::input::PointerEvent {
323 id: repose_core::input::PointerId(0),
324 kind: repose_core::input::PointerKind::Mouse,
325 event: repose_core::input::PointerEventKind::Move,
326 position: pos,
327 pressure: 1.0,
328 modifiers: self.modifiers,
329 };
330
331 // Move delivery (captured first)
332 if let Some(cid) = self.capture_id {
333 if let Some(h) = f.hit_regions.iter().find(|h| h.id == cid) {
334 if let Some(cb) = &h.on_pointer_move {
335 cb(pe.clone());
336 }
337 }
338 } else if let Some(h) = &top {
339 if let Some(cb) = &h.on_pointer_move {
340 cb(pe);
341 }
342 }
343 }
344 }
345 WindowEvent::MouseWheel { delta, .. } => {
346 // Convert line deltas (logical) to px; pixel delta is already px
347 let (dx_px, dy_px) = match delta {
348 MouseScrollDelta::LineDelta(x, y) => {
349 let unit_px = dp_to_px(60.0);
350 (-(x * unit_px), -(y * unit_px))
351 }
352 MouseScrollDelta::PixelDelta(lp) => (-(lp.x as f32), -(lp.y as f32)),
353 };
354 log::debug!("MouseWheel: dx={}, dy={}", dx_px, dy_px);
355
356 if let Some(f) = &self.frame_cache {
357 let pos = Vec2 {
358 x: self.mouse_pos_px.0,
359 y: self.mouse_pos_px.1,
360 };
361
362 for hit in f.hit_regions.iter().rev().filter(|h| h.rect.contains(pos)) {
363 if let Some(cb) = &hit.on_scroll {
364 log::debug!("Calling on_scroll for hit region id={}", hit.id);
365 let before = Vec2 { x: dx_px, y: dy_px };
366 let leftover = cb(before);
367 let consumed_x = (before.x - leftover.x).abs() > 0.001;
368 let consumed_y = (before.y - leftover.y).abs() > 0.001;
369 if consumed_x || consumed_y {
370 self.request_redraw();
371 break; // stop after first consumer
372 }
373 }
374 }
375 }
376 }
377 WindowEvent::MouseInput {
378 state: ElementState::Pressed,
379 button: MouseButton::Left,
380 ..
381 } => {
382 let mut need_announce = false;
383 if let Some(f) = &self.frame_cache {
384 let pos = Vec2 {
385 x: self.mouse_pos_px.0,
386 y: self.mouse_pos_px.1,
387 };
388 if let Some(hit) = f.hit_regions.iter().rev().find(|h| h.rect.contains(pos))
389 {
390 // Capture starts on press
391 self.capture_id = Some(hit.id);
392 // Pressed visual for mouse
393 self.pressed_ids.insert(hit.id);
394 // Repaint for pressed state
395 self.request_redraw();
396
397 // Focus & IME first for focusables (so state exists)
398 if hit.focusable {
399 self.sched.focused = Some(hit.id);
400 need_announce = true;
401 let key = self.tf_key_of(hit.id);
402 self.textfield_states.entry(key).or_insert_with(|| {
403 Rc::new(RefCell::new(
404 repose_ui::textfield::TextFieldState::new(),
405 ))
406 });
407 if let Some(win) = &self.window {
408 let sf = win.scale_factor();
409 win.set_ime_allowed(true);
410 win.set_ime_purpose(ImePurpose::Normal);
411 win.set_ime_cursor_area(
412 LogicalPosition::new(
413 hit.rect.x as f64 / sf,
414 hit.rect.y as f64 / sf,
415 ),
416 LogicalSize::new(
417 hit.rect.w as f64 / sf,
418 hit.rect.h as f64 / sf,
419 ),
420 );
421 }
422 }
423
424 // PointerDown callback (legacy)
425 if let Some(cb) = &hit.on_pointer_down {
426 let pe = repose_core::input::PointerEvent {
427 id: repose_core::input::PointerId(0),
428 kind: repose_core::input::PointerKind::Mouse,
429 event: repose_core::input::PointerEventKind::Down(
430 repose_core::input::PointerButton::Primary,
431 ),
432 position: pos,
433 pressure: 1.0,
434 modifiers: self.modifiers,
435 };
436 cb(pe);
437 }
438
439 // TextField: place caret and start drag selection
440 if let Some(_sem) = f
441 .semantics_nodes
442 .iter()
443 .find(|n| n.id == hit.id && n.role == Role::TextField)
444 {
445 let key = self.tf_key_of(hit.id);
446 if let Some(state_rc) = self.textfield_states.get(&key) {
447 let mut state = state_rc.borrow_mut();
448 let inner_x_px = hit.rect.x + dp_to_px(TF_PADDING_X_DP);
449 let content_x_px =
450 self.mouse_pos_px.0 - inner_x_px + state.scroll_offset;
451 let font_dp = TF_FONT_DP as u32;
452 let idx = index_for_x_bytes(
453 &state.text,
454 font_dp,
455 content_x_px.max(0.0),
456 );
457 state.begin_drag(idx, self.modifiers.shift);
458 let m = measure_text(&state.text, font_dp);
459 let caret_x_px = m
460 .positions
461 .get(state.caret_index())
462 .copied()
463 .unwrap_or(0.0);
464 state.ensure_caret_visible(
465 caret_x_px,
466 hit.rect.w - 2.0 * dp_to_px(TF_PADDING_X_DP),
467 );
468 }
469 }
470 if need_announce {
471 self.announce_focus_change();
472 }
473
474 self.request_redraw();
475 } else {
476 // Click outside: drop focus/IME
477 if self.ime_preedit {
478 if let Some(win) = &self.window {
479 win.set_ime_allowed(false);
480 }
481 self.ime_preedit = false;
482 }
483 self.sched.focused = None;
484 self.request_redraw();
485 }
486 }
487 }
488 WindowEvent::MouseInput {
489 state: ElementState::Released,
490 button: MouseButton::Left,
491 ..
492 } => {
493 if let Some(cid) = self.capture_id {
494 self.pressed_ids.remove(&cid);
495 self.request_redraw();
496 }
497
498 // Click on release if pointer is still over the captured hit region
499 if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
500 let pos = Vec2 {
501 x: self.mouse_pos_px.0,
502 y: self.mouse_pos_px.1,
503 };
504 if let Some(hit) = f.hit_regions.iter().find(|h| h.id == cid) {
505 if hit.rect.contains(pos) {
506 if let Some(cb) = &hit.on_click {
507 cb();
508 // A11y: announce activation (mouse)
509 if let Some(node) =
510 f.semantics_nodes.iter().find(|n| n.id == cid)
511 {
512 let label = node.label.as_deref().unwrap_or("");
513 self.a11y.announce(&format!("Activated {}", label));
514 }
515 }
516 }
517 }
518 }
519 // TextField drag end
520 if let (Some(f), Some(cid)) = (&self.frame_cache, self.capture_id) {
521 if let Some(_sem) = f
522 .semantics_nodes
523 .iter()
524 .find(|n| n.id == cid && n.role == Role::TextField)
525 {
526 let key = self.tf_key_of(cid);
527 if let Some(state_rc) = self.textfield_states.get(&key) {
528 state_rc.borrow_mut().end_drag();
529 }
530 }
531 }
532 self.capture_id = None;
533 }
534 WindowEvent::ModifiersChanged(new_mods) => {
535 self.modifiers.shift = new_mods.state().shift_key();
536 self.modifiers.ctrl = new_mods.state().control_key();
537 self.modifiers.alt = new_mods.state().alt_key();
538 self.modifiers.meta = new_mods.state().super_key();
539 }
540 WindowEvent::KeyboardInput {
541 event: key_event, ..
542 } => {
543 // Focus traversal: Tab / Shift+Tab
544 if matches!(key_event.physical_key, PhysicalKey::Code(KeyCode::Tab)) {
545 // Only act on initial press, ignore repeats
546 if key_event.state == ElementState::Pressed && !key_event.repeat {
547 if let Some(f) = &self.frame_cache {
548 let chain = &f.focus_chain;
549 if !chain.is_empty() {
550 // If a button was “pressed” via keyboard, clear it when we move focus
551 if let Some(active) = self.key_pressed_active.take() {
552 self.pressed_ids.remove(&active);
553 }
554
555 let shift = self.modifiers.shift;
556 let current = self.sched.focused;
557 let next = if let Some(cur) = current {
558 if let Some(idx) = chain.iter().position(|&id| id == cur) {
559 if shift {
560 if idx == 0 {
561 chain[chain.len() - 1]
562 } else {
563 chain[idx - 1]
564 }
565 } else {
566 chain[(idx + 1) % chain.len()]
567 }
568 } else {
569 chain[0]
570 }
571 } else {
572 chain[0]
573 };
574 self.sched.focused = Some(next);
575
576 // IME only for TextField
577 if let Some(win) = &self.window {
578 if f.semantics_nodes
579 .iter()
580 .any(|n| n.id == next && n.role == Role::TextField)
581 {
582 win.set_ime_allowed(true);
583 win.set_ime_purpose(ImePurpose::Normal);
584 } else {
585 win.set_ime_allowed(false);
586 }
587 }
588 self.announce_focus_change();
589 self.request_redraw();
590 }
591 }
592 }
593 return; // swallow Tab
594 }
595
596 if let Some(fid) = self.sched.focused {
597 // If focused is NOT a TextField, allow Space/Enter activation
598 let is_textfield = if let Some(f) = &self.frame_cache {
599 f.semantics_nodes
600 .iter()
601 .any(|n| n.id == fid && n.role == Role::TextField)
602 } else {
603 false
604 };
605
606 if !is_textfield {
607 match key_event.physical_key {
608 PhysicalKey::Code(KeyCode::Space)
609 | PhysicalKey::Code(KeyCode::Enter) => {
610 if key_event.state == ElementState::Pressed && !key_event.repeat
611 {
612 self.pressed_ids.insert(fid);
613 self.key_pressed_active = Some(fid);
614 self.request_redraw();
615 return;
616 }
617 }
618 _ => {}
619 }
620 }
621 }
622
623 // Keyboard activation for focused TextField submit on Enter
624 if key_event.state == ElementState::Pressed && !key_event.repeat {
625 if let PhysicalKey::Code(KeyCode::Enter) = key_event.physical_key {
626 if let Some(focused_id) = self.sched.focused {
627 if let Some(f) = &self.frame_cache {
628 if let Some(hit) =
629 f.hit_regions.iter().find(|h| h.id == focused_id)
630 {
631 if let Some(on_submit) = &hit.on_text_submit {
632 let key = self.tf_key_of(focused_id);
633
634 if let Some(state) = self.textfield_states.get(&key) {
635 let text = state.borrow().text.clone();
636 on_submit(text);
637 self.request_redraw();
638 return; // don’t continue as button activation
639 }
640 }
641 }
642 }
643 }
644 }
645 }
646
647 if key_event.state == ElementState::Pressed {
648 // Inspector hotkey: Ctrl+Shift+I
649 if self.modifiers.ctrl && self.modifiers.shift {
650 if let PhysicalKey::Code(KeyCode::KeyI) = key_event.physical_key {
651 self.inspector.hud.toggle_inspector();
652 self.request_redraw();
653 return;
654 }
655 }
656
657 // TextField navigation/edit
658 if let Some(focused_id) = self.sched.focused {
659 let key = self.tf_key_of(focused_id);
660 if let Some(state_rc) = self.textfield_states.get(&key) {
661 let mut state = state_rc.borrow_mut();
662 match key_event.physical_key {
663 PhysicalKey::Code(KeyCode::Backspace) => {
664 state.delete_backward();
665 let new_text = state.text.clone();
666 self.notify_text_change(focused_id, new_text);
667 App::tf_ensure_caret_visible(&mut state);
668 self.request_redraw();
669 }
670 PhysicalKey::Code(KeyCode::Delete) => {
671 state.delete_forward();
672 let new_text = state.text.clone();
673 self.notify_text_change(focused_id, new_text);
674 App::tf_ensure_caret_visible(&mut state);
675 self.request_redraw();
676 }
677 PhysicalKey::Code(KeyCode::ArrowLeft) => {
678 state.move_cursor(-1, self.modifiers.shift);
679 App::tf_ensure_caret_visible(&mut state);
680 self.request_redraw();
681 }
682 PhysicalKey::Code(KeyCode::ArrowRight) => {
683 state.move_cursor(1, self.modifiers.shift);
684 App::tf_ensure_caret_visible(&mut state);
685 self.request_redraw();
686 }
687 PhysicalKey::Code(KeyCode::Home) => {
688 state.selection = 0..0;
689 App::tf_ensure_caret_visible(&mut state);
690 self.request_redraw();
691 }
692 PhysicalKey::Code(KeyCode::End) => {
693 {
694 let end = state.text.len();
695 state.selection = end..end;
696 }
697 App::tf_ensure_caret_visible(&mut state);
698 self.request_redraw();
699 }
700 PhysicalKey::Code(KeyCode::KeyA) if self.modifiers.ctrl => {
701 state.selection = 0..state.text.len();
702 App::tf_ensure_caret_visible(&mut state);
703 self.request_redraw();
704 }
705 _ => {}
706 }
707 }
708 if self.modifiers.ctrl {
709 match key_event.physical_key {
710 PhysicalKey::Code(KeyCode::KeyC) => {
711 if let Some(fid) = self.sched.focused {
712 let key = self.tf_key_of(fid);
713 if let Some(state) = self.textfield_states.get(&key) {
714 let txt = state.borrow().selected_text();
715 if !txt.is_empty() {
716 if let Some(cb) = self.clipboard.as_mut() {
717 let _ = cb.set_text(txt);
718 }
719 }
720 }
721 }
722 return;
723 }
724 PhysicalKey::Code(KeyCode::KeyX) => {
725 if let Some(fid) = self.sched.focused {
726 let key = self.tf_key_of(fid);
727 if let Some(state_rc) = self.textfield_states.get(&key)
728 {
729 // Copy
730 let txt = state_rc.borrow().selected_text();
731 if !txt.is_empty() {
732 if let Some(cb) = self.clipboard.as_mut() {
733 let _ = cb.set_text(txt.clone());
734 }
735 // Cut (delete selection)
736 {
737 let mut st = state_rc.borrow_mut();
738 st.insert_text(""); // replace selection with empty
739 let new_text = st.text.clone();
740 self.notify_text_change(
741 focused_id, new_text,
742 );
743 App::tf_ensure_caret_visible(&mut st);
744 }
745 self.request_redraw();
746 }
747 }
748 }
749 return;
750 }
751 PhysicalKey::Code(KeyCode::KeyV) => {
752 if let Some(fid) = self.sched.focused {
753 let key = self.tf_key_of(fid);
754 if let Some(state_rc) = self.textfield_states.get(&key)
755 {
756 if let Some(cb) = self.clipboard.as_mut() {
757 if let Ok(mut txt) = cb.get_text() {
758 // Single-line TextField: strip control/newlines
759 txt.retain(|c| {
760 !c.is_control()
761 && c != '\n'
762 && c != '\r'
763 });
764 if !txt.is_empty() {
765 let mut st = state_rc.borrow_mut();
766 st.insert_text(&txt);
767 let new_text = st.text.clone();
768 self.notify_text_change(
769 focused_id, new_text,
770 );
771 App::tf_ensure_caret_visible(&mut st);
772 self.request_redraw();
773 }
774 }
775 }
776 }
777 }
778 return;
779 }
780 _ => {}
781 }
782 }
783 }
784
785 // Plain text input when IME is not active
786 if !self.ime_preedit
787 && !self.modifiers.ctrl
788 && !self.modifiers.alt
789 && !self.modifiers.meta
790 {
791 if let Some(raw) = key_event.text.as_deref() {
792 let text: String = raw
793 .chars()
794 .filter(|c| !c.is_control() && *c != '\n' && *c != '\r')
795 .collect();
796 if !text.is_empty() {
797 if let Some(fid) = self.sched.focused {
798 let key = self.tf_key_of(fid);
799 if let Some(state_rc) = self.textfield_states.get(&key) {
800 let mut st = state_rc.borrow_mut();
801 st.insert_text(&text);
802 self.notify_text_change(fid, text.clone());
803 App::tf_ensure_caret_visible(&mut st);
804 self.request_redraw();
805 }
806 }
807 }
808 }
809 }
810 } else if key_event.state == ElementState::Released {
811 // Finish keyboard activation on release (Space/Enter)
812 if let Some(active_id) = self.key_pressed_active {
813 match key_event.physical_key {
814 PhysicalKey::Code(KeyCode::Space)
815 | PhysicalKey::Code(KeyCode::Enter) => {
816 self.pressed_ids.remove(&active_id);
817 self.key_pressed_active = None;
818
819 if let Some(f) = &self.frame_cache {
820 if let Some(hit) =
821 f.hit_regions.iter().find(|h| h.id == active_id)
822 {
823 if let Some(cb) = &hit.on_click {
824 cb();
825 if let Some(node) = f
826 .semantics_nodes
827 .iter()
828 .find(|n| n.id == active_id)
829 {
830 let label = node.label.as_deref().unwrap_or("");
831 self.a11y
832 .announce(&format!("Activated {}", label));
833 }
834 }
835 }
836 }
837 self.request_redraw();
838 return;
839 }
840 _ => {}
841 }
842 }
843 }
844 }
845
846 WindowEvent::Ime(ime) => {
847 use winit::event::Ime;
848 if let Some(focused_id) = self.sched.focused {
849 let key = self.tf_key_of(focused_id);
850 if let Some(state_rc) = self.textfield_states.get(&key) {
851 let mut state = state_rc.borrow_mut();
852 match ime {
853 Ime::Enabled => {
854 // IME allowed, but not necessarily composing
855 self.ime_preedit = false;
856 }
857 Ime::Preedit(text, cursor) => {
858 let cursor_usize =
859 cursor.map(|(a, b)| (a as usize, b as usize));
860 state.set_composition(text.clone(), cursor_usize);
861 self.ime_preedit = !text.is_empty();
862 if let Some(f) = &self.frame_cache {
863 if let Some(hit) =
864 f.hit_regions.iter().find(|h| h.id == focused_id)
865 {
866 let inner = Rect {
867 x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
868 y: hit.rect.y,
869 w: hit.rect.w,
870 h: hit.rect.h,
871 };
872 tf_ensure_visible_in_rect(&mut state, inner);
873 }
874 }
875 // notify on-change if you wired it:
876 self.notify_text_change(focused_id, state.text.clone());
877 self.request_redraw();
878 }
879 Ime::Commit(text) => {
880 state.commit_composition(text);
881 self.ime_preedit = false;
882 if let Some(f) = &self.frame_cache {
883 if let Some(hit) =
884 f.hit_regions.iter().find(|h| h.id == focused_id)
885 {
886 let inner = Rect {
887 x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
888 y: hit.rect.y,
889 w: hit.rect.w,
890 h: hit.rect.h,
891 };
892 tf_ensure_visible_in_rect(&mut state, inner);
893 }
894 }
895 self.notify_text_change(focused_id, state.text.clone());
896 self.request_redraw();
897 }
898 Ime::Disabled => {
899 self.ime_preedit = false;
900 if state.composition.is_some() {
901 state.cancel_composition();
902 if let Some(f) = &self.frame_cache {
903 if let Some(hit) =
904 f.hit_regions.iter().find(|h| h.id == focused_id)
905 {
906 let inner = Rect {
907 x: hit.rect.x + dp_to_px(TF_PADDING_X_DP),
908 y: hit.rect.y,
909 w: hit.rect.w,
910 h: hit.rect.h,
911 };
912 tf_ensure_visible_in_rect(&mut state, inner);
913 }
914 }
915 self.notify_text_change(focused_id, state.text.clone());
916 }
917 self.request_redraw();
918 }
919 }
920 }
921 }
922 }
923 WindowEvent::RedrawRequested => {
924 if let (Some(backend), Some(win)) =
925 (self.backend.as_mut(), self.window.as_ref())
926 {
927 let t0 = Instant::now();
928 let scale = win.scale_factor() as f32;
929 let size_px_u32 = self.sched.size;
930 let focused = self.sched.focused;
931
932 let frame = compose_frame(
933 &mut self.sched,
934 &mut self.root,
935 scale,
936 size_px_u32,
937 self.hover_id,
938 &self.pressed_ids,
939 &self.textfield_states,
940 focused,
941 );
942
943 let build_layout_ms = (Instant::now() - t0).as_secs_f32() * 1000.0;
944
945 // A11y: publish semantics tree each frame (cheap for now)
946 self.a11y.publish_tree(&frame.semantics_nodes);
947 // If focus id changed since last publish, send focused node
948 if self.last_focus != self.sched.focused {
949 let focused_node = self
950 .sched
951 .focused
952 .and_then(|id| frame.semantics_nodes.iter().find(|n| n.id == id));
953 self.a11y.focus_changed(focused_node);
954 self.last_focus = self.sched.focused;
955 }
956
957 // Render
958 let mut scene = frame.scene.clone();
959 // Update HUD metrics before overlay draws
960 self.inspector.hud.metrics = Some(repose_devtools::Metrics {
961 build_layout_ms,
962 scene_nodes: scene.nodes.len(),
963 });
964 self.inspector.frame(&mut scene);
965 backend
966 // .lock()
967 .frame(&scene, GlyphRasterConfig { px: 18.0 * scale });
968 self.frame_cache = Some(frame);
969 }
970 }
971 _ => {}
972 }
973 }
974
975 fn about_to_wait(&mut self, _el: &winit::event_loop::ActiveEventLoop) {
976 self.request_redraw();
977 }
978
979 fn new_events(
980 &mut self,
981 _: &winit::event_loop::ActiveEventLoop,
982 _: winit::event::StartCause,
983 ) {
984 }
985 fn user_event(&mut self, _: &winit::event_loop::ActiveEventLoop, _: ()) {}
986 fn device_event(
987 &mut self,
988 _: &winit::event_loop::ActiveEventLoop,
989 _: winit::event::DeviceId,
990 _: winit::event::DeviceEvent,
991 ) {
992 }
993 fn suspended(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
994 fn exiting(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
995 fn memory_warning(&mut self, _: &winit::event_loop::ActiveEventLoop) {}
996 }
997
998 impl App {
999 fn announce_focus_change(&mut self) {
1000 if let Some(f) = &self.frame_cache {
1001 let focused_node = self
1002 .sched
1003 .focused
1004 .and_then(|id| f.semantics_nodes.iter().find(|n| n.id == id));
1005 self.a11y.focus_changed(focused_node);
1006 }
1007 }
1008 fn notify_text_change(&self, id: u64, text: String) {
1009 if let Some(f) = &self.frame_cache {
1010 if let Some(h) = f.hit_regions.iter().find(|h| h.id == id) {
1011 if let Some(cb) = &h.on_text_change {
1012 cb(text);
1013 }
1014 }
1015 }
1016 }
1017 fn tf_key_of(&self, visual_id: u64) -> u64 {
1018 if let Some(f) = &self.frame_cache {
1019 if let Some(hr) = f.hit_regions.iter().find(|h| h.id == visual_id) {
1020 return hr.tf_state_key.unwrap_or(hr.id);
1021 }
1022 }
1023 visual_id
1024 }
1025 }
1026
1027 let event_loop = EventLoop::new()?;
1028 let mut app = App::new(Box::new(root));
1029 // Install system clock once
1030 repose_core::animation::set_clock(Box::new(repose_core::animation::SystemClock));
1031 event_loop.run_app(&mut app)?;
1032 Ok(())
1033}
1034
1035// Accessibility bridge stub (Noop by default; logs on Linux for now)
1036pub trait A11yBridge: Send {
1037 fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]);
1038 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>);
1039 fn announce(&mut self, msg: &str);
1040}
1041
1042struct NoopA11y;
1043impl A11yBridge for NoopA11y {
1044 fn publish_tree(&mut self, _nodes: &[repose_core::runtime::SemNode]) {
1045 // no-op
1046 }
1047 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1048 if let Some(n) = node {
1049 log::info!("A11y focus: {:?} {:?}", n.role, n.label);
1050 } else {
1051 log::info!("A11y focus: None");
1052 }
1053 }
1054 fn announce(&mut self, msg: &str) {
1055 log::info!("A11y announce: {msg}");
1056 }
1057}
1058
1059#[cfg(target_os = "linux")]
1060struct LinuxAtspiStub;
1061#[cfg(target_os = "linux")]
1062impl A11yBridge for LinuxAtspiStub {
1063 fn publish_tree(&mut self, nodes: &[repose_core::runtime::SemNode]) {
1064 log::debug!("AT-SPI stub: publish {} nodes", nodes.len());
1065 }
1066 fn focus_changed(&mut self, node: Option<&repose_core::runtime::SemNode>) {
1067 if let Some(n) = node {
1068 log::info!("AT-SPI stub focus: {:?} {:?}", n.role, n.label);
1069 } else {
1070 log::info!("AT-SPI stub focus: None");
1071 }
1072 }
1073 fn announce(&mut self, msg: &str) {
1074 log::info!("AT-SPI stub announce: {msg}");
1075 }
1076}