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