Skip to main content

embedded_gui/context/
input.rs

1#[cfg(not(feature = "std"))]
2use crate::math::F32Ext as _;
3use crate::{
4    geometry::Rect,
5    haptics::HapticPattern,
6    input::{
7        InputEvent, PointerState, UiEvent, UiEventFilter, WidgetDispatchPolicy, WidgetEvent,
8        WidgetEventKind,
9    },
10    state::{FeedTimelineState, ListState, ScrollState, SliderState, TabsState},
11    style::{VisualState, WidgetStyle},
12    widget::{EventPhase, EventPolicy, WidgetId},
13    widgets::{KeyboardLayout, TEXTAREA_CAPACITY, WidgetKind, WidgetNode},
14};
15
16use super::*;
17
18impl<'a, const NODES: usize, const EVENTS: usize, const DIRTY: usize>
19    GuiContext<'a, NODES, EVENTS, DIRTY>
20{
21    pub fn handle_input(&mut self, event: InputEvent) -> Result<(), GuiError> {
22        match event {
23            InputEvent::Home => {
24                if let Some(id) = self.focus {
25                    #[cfg(feature = "rich-widgets")]
26                    if matches!(
27                        self.node(id).map(|n| n.kind),
28                        Some(WidgetKind::TextArea { .. })
29                    ) {
30                        self.set_textarea_cursor_line_home(id)?;
31                        return Ok(());
32                    }
33                }
34                Ok(())
35            }
36            InputEvent::End => {
37                if let Some(id) = self.focus {
38                    #[cfg(feature = "rich-widgets")]
39                    if matches!(
40                        self.node(id).map(|n| n.kind),
41                        Some(WidgetKind::TextArea { .. })
42                    ) {
43                        self.set_textarea_cursor_line_end(id)?;
44                        return Ok(());
45                    }
46                }
47                Ok(())
48            }
49            InputEvent::WordLeft => {
50                if let Some(id) = self.focus {
51                    #[cfg(feature = "rich-widgets")]
52                    if matches!(
53                        self.node(id).map(|n| n.kind),
54                        Some(WidgetKind::TextArea { .. })
55                    ) {
56                        self.move_textarea_cursor_word(id, -1)?;
57                        return Ok(());
58                    }
59                }
60                Ok(())
61            }
62            InputEvent::WordRight => {
63                if let Some(id) = self.focus {
64                    #[cfg(feature = "rich-widgets")]
65                    if matches!(
66                        self.node(id).map(|n| n.kind),
67                        Some(WidgetKind::TextArea { .. })
68                    ) {
69                        self.move_textarea_cursor_word(id, 1)?;
70                        return Ok(());
71                    }
72                }
73                Ok(())
74            }
75            InputEvent::Undo => {
76                if let Some(id) = self.focus {
77                    #[cfg(feature = "rich-widgets")]
78                    if matches!(
79                        self.node(id).map(|n| n.kind),
80                        Some(WidgetKind::TextArea { .. })
81                    ) {
82                        self.textarea_undo(id)?;
83                    }
84                }
85                Ok(())
86            }
87            InputEvent::Redo => {
88                if let Some(id) = self.focus {
89                    #[cfg(feature = "rich-widgets")]
90                    if matches!(
91                        self.node(id).map(|n| n.kind),
92                        Some(WidgetKind::TextArea { .. })
93                    ) {
94                        self.textarea_redo(id)?;
95                    }
96                }
97                Ok(())
98            }
99            InputEvent::SelectLeft => {
100                if let Some(id) = self.focus {
101                    #[cfg(feature = "rich-widgets")]
102                    if matches!(
103                        self.node(id).map(|n| n.kind),
104                        Some(WidgetKind::TextArea { .. })
105                    ) {
106                        self.move_textarea_cursor_select(id, -1)?;
107                        return Ok(());
108                    }
109                }
110                Ok(())
111            }
112            InputEvent::SelectRight => {
113                if let Some(id) = self.focus {
114                    #[cfg(feature = "rich-widgets")]
115                    if matches!(
116                        self.node(id).map(|n| n.kind),
117                        Some(WidgetKind::TextArea { .. })
118                    ) {
119                        self.move_textarea_cursor_select(id, 1)?;
120                        return Ok(());
121                    }
122                }
123                Ok(())
124            }
125            InputEvent::SelectHome => {
126                if let Some(id) = self.focus {
127                    #[cfg(feature = "rich-widgets")]
128                    if matches!(
129                        self.node(id).map(|n| n.kind),
130                        Some(WidgetKind::TextArea { .. })
131                    ) {
132                        self.set_textarea_cursor_line_home_select(id)?;
133                        return Ok(());
134                    }
135                }
136                Ok(())
137            }
138            InputEvent::SelectEnd => {
139                if let Some(id) = self.focus {
140                    #[cfg(feature = "rich-widgets")]
141                    if matches!(
142                        self.node(id).map(|n| n.kind),
143                        Some(WidgetKind::TextArea { .. })
144                    ) {
145                        self.set_textarea_cursor_line_end_select(id)?;
146                        return Ok(());
147                    }
148                }
149                Ok(())
150            }
151            InputEvent::SelectWordLeft => {
152                if let Some(id) = self.focus {
153                    #[cfg(feature = "rich-widgets")]
154                    if matches!(
155                        self.node(id).map(|n| n.kind),
156                        Some(WidgetKind::TextArea { .. })
157                    ) {
158                        self.move_textarea_cursor_word_select(id, -1)?;
159                        return Ok(());
160                    }
161                }
162                Ok(())
163            }
164            InputEvent::SelectWordRight => {
165                if let Some(id) = self.focus {
166                    #[cfg(feature = "rich-widgets")]
167                    if matches!(
168                        self.node(id).map(|n| n.kind),
169                        Some(WidgetKind::TextArea { .. })
170                    ) {
171                        self.move_textarea_cursor_word_select(id, 1)?;
172                        return Ok(());
173                    }
174                }
175                Ok(())
176            }
177            InputEvent::Up => {
178                if !self.adjust_focused_selection(-1)? {
179                    self.focus_prev()?;
180                }
181                Ok(())
182            }
183            InputEvent::Down => {
184                if !self.adjust_focused_selection(1)? {
185                    self.focus_next()?;
186                }
187                Ok(())
188            }
189            InputEvent::Left => {
190                if !self.adjust_focused_scalar(-1.0)? {
191                    self.focus_prev()?;
192                }
193                Ok(())
194            }
195            InputEvent::Right => {
196                if !self.adjust_focused_scalar(1.0)? {
197                    self.focus_next()?;
198                }
199                Ok(())
200            }
201            InputEvent::Encoder { delta } if delta > 0 => {
202                if !self.adjust_focused_selection(1)? {
203                    self.focus_next()?;
204                }
205                Ok(())
206            }
207            InputEvent::Encoder { delta } if delta < 0 => {
208                if !self.adjust_focused_selection(-1)? {
209                    self.focus_prev()?;
210                }
211                Ok(())
212            }
213            InputEvent::Select => {
214                if let Some(id) = self.focus {
215                    match self.key_bindings_for(id).select {
216                        KeyBindingAction::Default | KeyBindingAction::Activate => {
217                            self.handle_select_activation(id)?
218                        }
219                        KeyBindingAction::Back => self.handle_back_action()?,
220                        KeyBindingAction::Ignore => {}
221                    }
222                }
223                Ok(())
224            }
225            InputEvent::SelectPressed => {
226                if let Some(id) = self.focus {
227                    if self.key_input_policy_for(id).raw_select {
228                        self.dispatch_key_pressed(id)?;
229                    }
230                }
231                Ok(())
232            }
233            InputEvent::SelectReleased => {
234                if let Some(id) = self.focus {
235                    if self.key_input_policy_for(id).raw_select {
236                        self.dispatch_key_released(id)?;
237                        self.handle_select_activation(id)?;
238                    }
239                }
240                Ok(())
241            }
242            InputEvent::Back => {
243                if let Some(id) = self.focus {
244                    match self.key_bindings_for(id).back {
245                        KeyBindingAction::Default | KeyBindingAction::Back => {
246                            self.handle_back_action()
247                        }
248                        KeyBindingAction::Activate => self.handle_select_activation(id),
249                        KeyBindingAction::Ignore => Ok(()),
250                    }
251                } else {
252                    self.handle_back_action()
253                }
254            }
255            InputEvent::BackPressed => {
256                if let Some(id) = self.focus {
257                    if self.key_input_policy_for(id).raw_back {
258                        self.dispatch_key_pressed(id)?;
259                    }
260                }
261                Ok(())
262            }
263            InputEvent::BackReleased => {
264                if let Some(id) = self.focus {
265                    if self.key_input_policy_for(id).raw_back {
266                        self.dispatch_key_released(id)?;
267                        return self.handle_back_action();
268                    }
269                }
270                Ok(())
271            }
272            InputEvent::Pointer {
273                x,
274                y,
275                state: PointerState::Pressed,
276                ..
277            } => self.handle_pointer_pressed(x, y),
278            InputEvent::Pointer {
279                x,
280                y,
281                state: PointerState::Released,
282                ..
283            } => self.handle_pointer_released(x, y),
284            InputEvent::Pointer {
285                x,
286                y,
287                state: PointerState::Moved,
288                ..
289            } => self.handle_pointer_moved(x, y),
290            _ => Ok(()),
291        }
292    }
293
294    pub fn tick_input(&mut self, dt_ms: u32) -> Result<(), GuiError> {
295        if self.last_select_id.is_some() {
296            self.select_elapsed_ms = self.select_elapsed_ms.saturating_add(dt_ms);
297            if self.select_elapsed_ms > self.select_double_window_ms {
298                self.last_select_id = None;
299                self.select_elapsed_ms = 0;
300            }
301        }
302        if self.last_pointer_id.is_some() {
303            self.pointer_elapsed_ms = self.pointer_elapsed_ms.saturating_add(dt_ms);
304            if self.pointer_elapsed_ms > self.pointer_double_window_ms {
305                self.last_pointer_id = None;
306                self.pointer_elapsed_ms = 0;
307            }
308        }
309        self.tick_state_transitions(dt_ms)?;
310        self.tick_theme_transition(dt_ms)?;
311        self.haptic_sequencer.tick(dt_ms);
312        self.tick_rle_animations(dt_ms)?;
313        #[cfg(feature = "rich-widgets")]
314        if let Some(mut inertia) = self.inertia_scroll {
315            if inertia.velocity.abs() < self.scroll_physics.velocity_threshold {
316                self.inertia_scroll = None;
317            } else {
318                let current = self.scroll_offset(inertia.id).unwrap_or(0);
319                let delta = (inertia.velocity * (dt_ms as f32 / 16.0)).round() as i32;
320                if delta != 0 {
321                    let next = current.saturating_sub(delta);
322                    if next != current {
323                        self.set_scroll_offset(inertia.id, next)?;
324                        self.push_event(UiEvent::Scroll {
325                            id: inertia.id,
326                            delta: next - current,
327                        })?;
328                    }
329                }
330                inertia.velocity *= self
331                    .scroll_physics
332                    .velocity_decay
333                    .powf((dt_ms as f32 / 16.0).max(1.0));
334                self.inertia_scroll = Some(inertia);
335            }
336        }
337        #[cfg(feature = "rich-widgets")]
338        self.tick_textarea_cursor_blink(dt_ms)?;
339        let Some(mut pressed) = self.pressed else {
340            return Ok(());
341        };
342        if !self.effective_visible(pressed.id) || !self.effective_enabled(pressed.id) {
343            self.pressed = None;
344            return Ok(());
345        }
346        let timing = self.press_timing_for(pressed.id);
347        pressed.elapsed_ms = pressed.elapsed_ms.saturating_add(dt_ms);
348        pressed.repeat_elapsed_ms = pressed.repeat_elapsed_ms.saturating_add(dt_ms);
349        if !pressed.long_emitted && pressed.elapsed_ms >= timing.long_press_ms {
350            let mut events = heapless::Vec::<WidgetEvent, NODES>::new();
351            self.dispatch_widget_event(
352                pressed.id,
353                WidgetEventKind::LongPressed,
354                &mut events,
355                |_| EventPolicy::Continue,
356            )?;
357            self.push_event(UiEvent::LongPressed(pressed.id))?;
358            self.play_haptic(HapticPattern::LongPress);
359            pressed.long_emitted = true;
360        }
361        if pressed.repeat_elapsed_ms >= timing.repeat_delay_ms
362            && self.repeatable_widget(pressed.id)
363            && pressed.long_emitted
364        {
365            let intervals =
366                (pressed.repeat_elapsed_ms - timing.repeat_delay_ms) / timing.repeat_interval_ms;
367            if intervals > 0 {
368                self.dispatch_repeat_activation(pressed.id)?;
369                pressed.repeat_elapsed_ms = timing.repeat_delay_ms;
370            }
371        }
372        self.pressed = Some(pressed);
373        Ok(())
374    }
375
376    pub fn pop_event(&mut self) -> Option<UiEvent> {
377        if self.events.is_empty() {
378            None
379        } else {
380            Some(self.events.remove(0))
381        }
382    }
383
384    pub fn set_event_filter(
385        &mut self,
386        id: WidgetId,
387        filter: UiEventFilter,
388    ) -> Result<(), GuiError> {
389        self.node(id).ok_or(GuiError::NotFound)?;
390        if let Some((_, current)) = self
391            .subscriptions
392            .iter_mut()
393            .find(|(sub_id, _)| *sub_id == id)
394        {
395            *current = filter;
396            return Ok(());
397        }
398        self.subscriptions
399            .push((id, filter))
400            .map_err(|_| GuiError::WidgetsFull)
401    }
402
403    pub fn event_filter(&self, id: WidgetId) -> Result<UiEventFilter, GuiError> {
404        self.node(id).ok_or(GuiError::NotFound)?;
405        Ok(self
406            .subscriptions
407            .iter()
408            .find(|(sub_id, _)| *sub_id == id)
409            .map(|(_, filter)| *filter)
410            .unwrap_or(UiEventFilter::ALL))
411    }
412
413    pub fn clear_event_filter(&mut self, id: WidgetId) -> Result<(), GuiError> {
414        self.node(id).ok_or(GuiError::NotFound)?;
415        if let Some(pos) = self
416            .subscriptions
417            .iter()
418            .position(|(sub_id, _)| *sub_id == id)
419        {
420            self.subscriptions.remove(pos);
421        }
422        Ok(())
423    }
424
425    pub fn set_dispatch_policy(
426        &mut self,
427        id: WidgetId,
428        policy: WidgetDispatchPolicy,
429    ) -> Result<(), GuiError> {
430        self.node(id).ok_or(GuiError::NotFound)?;
431        if let Some((_, current)) = self
432            .dispatch_policies
433            .iter_mut()
434            .find(|(policy_id, _)| *policy_id == id)
435        {
436            *current = policy;
437            return Ok(());
438        }
439        self.dispatch_policies
440            .push((id, policy))
441            .map_err(|_| GuiError::WidgetsFull)
442    }
443
444    pub fn dispatch_policy(&self, id: WidgetId) -> Result<Option<WidgetDispatchPolicy>, GuiError> {
445        self.node(id).ok_or(GuiError::NotFound)?;
446        Ok(self
447            .dispatch_policies
448            .iter()
449            .find(|(policy_id, _)| *policy_id == id)
450            .map(|(_, policy)| *policy))
451    }
452
453    pub fn clear_dispatch_policy(&mut self, id: WidgetId) -> Result<(), GuiError> {
454        self.node(id).ok_or(GuiError::NotFound)?;
455        if let Some(pos) = self
456            .dispatch_policies
457            .iter()
458            .position(|(policy_id, _)| *policy_id == id)
459        {
460            self.dispatch_policies.remove(pos);
461        }
462        Ok(())
463    }
464
465    pub(crate) fn add_widget<S>(
466        &mut self,
467        rect: impl Into<Rect>,
468        kind: WidgetKind<'a>,
469        style: S,
470    ) -> Result<WidgetId, GuiError>
471    where
472        S: Into<WidgetStyle>,
473    {
474        let rect = rect.into();
475        let id = WidgetId::new(self.next_id);
476        self.next_id = self.next_id.saturating_add(1).max(1);
477        let node = WidgetNode::new(id, rect, kind, style);
478        self.widgets.push(node).map_err(|_| GuiError::WidgetsFull)?;
479        self.dirty.add(rect)?;
480        Ok(id)
481    }
482
483    pub(crate) fn node(&self, id: WidgetId) -> Option<&WidgetNode<'a>> {
484        self.widgets.iter().find(|node| node.id == id)
485    }
486
487    pub(crate) fn intrinsic_size(&self, id: WidgetId) -> Option<(u32, u32)> {
488        let node = self.node(id)?;
489        let style = node.style.resolve(VisualState::Normal);
490        let pad_x = style.padding.left.max(0) as u32 + style.padding.right.max(0) as u32;
491        let pad_y = style.padding.top.max(0) as u32 + style.padding.bottom.max(0) as u32;
492        let border = style.border.width as u32 * 2;
493        let text_width = |text: &str| text.chars().count() as u32 * style.font.advance();
494        let text_height = style.font.line_height();
495
496        let content = match node.kind {
497            WidgetKind::Label(text) => (text_width(text), text_height),
498            WidgetKind::Button(text) => (text_width(text).saturating_add(6), text_height),
499            #[cfg(feature = "rich-widgets")]
500            WidgetKind::Toggle { label, .. } => (text_width(label).saturating_add(12), text_height),
501            #[cfg(feature = "rich-widgets")]
502            WidgetKind::Checkbox { label, .. } => {
503                (text_width(label).saturating_add(10), text_height)
504            }
505            #[cfg(feature = "rich-widgets")]
506            WidgetKind::ValueLabel { label, .. } => {
507                (text_width(label).saturating_add(16), text_height)
508            }
509            #[cfg(feature = "rich-widgets")]
510            WidgetKind::IconButton { label, .. } => {
511                (text_width(label).saturating_add(10), text_height)
512            }
513            #[cfg(feature = "rich-widgets")]
514            WidgetKind::Tabs { labels, .. } => {
515                let max = labels.iter().map(|s| text_width(s)).max().unwrap_or(0);
516                (
517                    max.saturating_mul(labels.len() as u32).saturating_add(4),
518                    text_height,
519                )
520            }
521            #[cfg(feature = "rich-widgets")]
522            WidgetKind::Dialog { title, body } => {
523                let w = text_width(title).max(text_width(body)).saturating_add(8);
524                (w, text_height.saturating_mul(3))
525            }
526            #[cfg(feature = "rich-widgets")]
527            WidgetKind::Toast { text, .. } => (
528                text_width(text).saturating_add(8),
529                text_height.saturating_add(2),
530            ),
531            #[cfg(feature = "rich-widgets")]
532            WidgetKind::Dropdown {
533                items, selected, ..
534            } => (
535                text_width(items.get(selected).copied().unwrap_or("-")).saturating_add(10),
536                text_height.saturating_add(2),
537            ),
538            #[cfg(feature = "rich-widgets")]
539            WidgetKind::TextArea {
540                text_buf,
541                text_len,
542                placeholder,
543                ..
544            } => (
545                text_width(if text_len == 0 {
546                    placeholder
547                } else {
548                    textarea_text(&text_buf, text_len)
549                })
550                .saturating_add(10),
551                text_height.saturating_add(4),
552            ),
553            #[cfg(feature = "rich-widgets")]
554            WidgetKind::Keyboard { keys, cols, .. } => {
555                let cols = cols.max(1) as u32;
556                let rows = (keys.len() as u32).div_ceil(cols).max(1);
557                (
558                    cols.saturating_mul(style.font.advance().saturating_add(4)),
559                    rows.saturating_mul(style.font.line_height().saturating_add(4)),
560                )
561            }
562            #[cfg(feature = "rich-widgets")]
563            WidgetKind::List {
564                items,
565                visible_rows,
566                ..
567            }
568            | WidgetKind::CircularList {
569                items,
570                visible_rows,
571                ..
572            } => {
573                let max = items.iter().map(|s| text_width(s)).max().unwrap_or(0);
574                (
575                    max.saturating_add(6),
576                    (text_height.saturating_add(2))
577                        .saturating_mul(visible_rows as u32)
578                        .max(text_height),
579                )
580            }
581            #[cfg(feature = "rich-widgets")]
582            WidgetKind::Menu { items, .. } => {
583                let max = items.iter().map(|s| text_width(s)).max().unwrap_or(0);
584                (
585                    max.saturating_add(6),
586                    (text_height.saturating_add(2))
587                        .saturating_mul(items.len() as u32)
588                        .max(text_height),
589                )
590            }
591            #[cfg(feature = "rich-widgets")]
592            WidgetKind::FeedTimeline {
593                items,
594                visible_rows,
595                expanded,
596                ..
597            } => {
598                let max = items.iter().map(|s| text_width(s)).max().unwrap_or(0);
599                let row_h = if expanded {
600                    text_height.saturating_mul(2).saturating_add(2)
601                } else {
602                    text_height.saturating_add(2)
603                };
604                (
605                    max.saturating_add(8),
606                    row_h.saturating_mul(visible_rows as u32).max(text_height),
607                )
608            }
609            _ => (node.rect.w.max(1), node.rect.h.max(1)),
610        };
611
612        Some((
613            content
614                .0
615                .saturating_add(pad_x)
616                .saturating_add(border)
617                .max(1),
618            content
619                .1
620                .saturating_add(pad_y)
621                .saturating_add(border)
622                .max(1),
623        ))
624    }
625
626    pub(crate) fn node_mut(&mut self, id: WidgetId) -> Option<&mut WidgetNode<'a>> {
627        self.widgets.iter_mut().find(|node| node.id == id)
628    }
629
630    pub(crate) fn effective_visible(&self, id: WidgetId) -> bool {
631        let mut current = Some(id);
632        let mut depth = 0;
633        while let Some(widget_id) = current {
634            if depth >= NODES {
635                return false;
636            }
637            let Some(node) = self.node(widget_id) else {
638                return false;
639            };
640            if node.hidden() {
641                return false;
642            }
643            current = node.parent;
644            depth += 1;
645        }
646        true
647    }
648
649    pub(crate) fn inherited_clip(&self, id: WidgetId) -> Option<Rect> {
650        let mut clip = self.viewport;
651        let mut chain = heapless::Vec::<WidgetId, NODES>::new();
652        let mut current = Some(id);
653        while let Some(widget_id) = current {
654            chain.push(widget_id).ok()?;
655            current = self.node(widget_id)?.parent;
656        }
657        for widget_id in chain.iter().rev().copied() {
658            let node = self.node(widget_id)?;
659            if widget_id == id || node.clips_children() {
660                clip = clip.intersection(self.absolute_rect(widget_id)?);
661            }
662            if clip.is_empty() {
663                return None;
664            }
665        }
666        Some(clip)
667    }
668
669    pub(crate) fn effective_enabled(&self, id: WidgetId) -> bool {
670        let mut current = Some(id);
671        let mut depth = 0;
672        while let Some(widget_id) = current {
673            if depth >= NODES {
674                return false;
675            }
676            let Some(node) = self.node(widget_id) else {
677                return false;
678            };
679            if node.disabled() {
680                return false;
681            }
682            current = node.parent;
683            depth += 1;
684        }
685        true
686    }
687
688    pub(crate) fn effective_focusable(&self, id: WidgetId) -> bool {
689        self.node(id).is_some_and(|node| {
690            self.node_in_active_group(node)
691                && node.focusable()
692                && self.effective_visible(id)
693                && self.effective_enabled(id)
694        })
695    }
696
697    pub(crate) fn ensure_focus(&mut self) {
698        if self.focus.is_none() {
699            self.focus = self
700                .widgets
701                .iter()
702                .find(|node| self.effective_focusable(node.id))
703                .map(|n| n.id);
704        }
705    }
706
707    pub(crate) fn focus_next(&mut self) -> Result<(), GuiError> {
708        self.move_focus(1)
709    }
710
711    pub(crate) fn focus_prev(&mut self) -> Result<(), GuiError> {
712        self.move_focus(-1)
713    }
714
715    pub(crate) fn move_focus(&mut self, delta: i8) -> Result<(), GuiError> {
716        let focusable = self
717            .widgets
718            .iter()
719            .filter(|node| self.effective_focusable(node.id))
720            .count();
721        if focusable == 0 {
722            return Ok(());
723        }
724
725        let current_pos = self
726            .widgets
727            .iter()
728            .filter(|node| self.effective_focusable(node.id))
729            .position(|node| Some(node.id) == self.focus)
730            .unwrap_or(0);
731
732        let next_pos = if delta >= 0 {
733            (current_pos + 1) % focusable
734        } else if current_pos == 0 {
735            focusable - 1
736        } else {
737            current_pos - 1
738        };
739
740        let next = self
741            .widgets
742            .iter()
743            .filter(|node| self.effective_focusable(node.id))
744            .nth(next_pos)
745            .map(|node| node.id);
746        self.set_focus(next)
747    }
748
749    /// Stub for the `rich-widgets`-off build: none of the gated kinds this
750    /// handles (List/Menu/Dropdown/Roller/ScrollView/FeedTimeline/Keyboard)
751    /// exist, so there's nothing to adjust -- always fall through to the
752    /// caller's plain focus_prev()/focus_next().
753    #[cfg(not(feature = "rich-widgets"))]
754    pub(crate) fn adjust_focused_selection(&mut self, _delta: i8) -> Result<bool, GuiError> {
755        Ok(false)
756    }
757
758    #[cfg(feature = "rich-widgets")]
759    pub(crate) fn adjust_focused_selection(&mut self, delta: i8) -> Result<bool, GuiError> {
760        let Some(id) = self.focus else {
761            return Ok(false);
762        };
763        let wrap_navigation = self.menu_contract.wrap_navigation;
764
765        let mut changed_rect = None;
766        let mut changed = false;
767
768        if let Some(node) = self.node_mut(id) {
769            match node.kind {
770                WidgetKind::Menu {
771                    items,
772                    selected: ref mut current,
773                } => {
774                    if items.is_empty() {
775                        return Ok(true);
776                    }
777                    changed = bump_index_with_wrap(current, items.len(), delta, wrap_navigation);
778                    changed_rect = changed.then_some(node.rect);
779                }
780                WidgetKind::Dropdown {
781                    items,
782                    selected: ref mut current,
783                    open,
784                } => {
785                    if !open {
786                        return Ok(false);
787                    }
788                    if items.is_empty() {
789                        return Ok(true);
790                    }
791                    changed = bump_index_with_wrap(current, items.len(), delta, wrap_navigation);
792                    changed_rect = changed.then_some(node.rect);
793                }
794                WidgetKind::Roller {
795                    items,
796                    selected: ref mut current,
797                } => {
798                    if items.is_empty() {
799                        return Ok(true);
800                    }
801                    changed = bump_index_with_wrap(current, items.len(), delta, wrap_navigation);
802                    changed_rect = changed.then_some(node.rect);
803                }
804                WidgetKind::Keyboard {
805                    keys,
806                    selected: ref mut current,
807                    ..
808                } => {
809                    if keys.is_empty() {
810                        return Ok(true);
811                    }
812                    changed = bump_index_with_wrap(current, keys.len(), delta, wrap_navigation);
813                    changed_rect = changed.then_some(node.rect);
814                }
815                WidgetKind::List {
816                    items,
817                    selected: ref mut current,
818                    ref mut offset,
819                    visible_rows,
820                }
821                | WidgetKind::CircularList {
822                    items,
823                    selected: ref mut current,
824                    ref mut offset,
825                    visible_rows,
826                } => {
827                    if items.is_empty() {
828                        return Ok(true);
829                    }
830                    let mut state = ListState::new(*current, *offset, visible_rows);
831                    let mut next = state.selected;
832                    changed = bump_index_with_wrap(&mut next, items.len(), delta, wrap_navigation);
833                    if changed {
834                        let _ = state.set_selected(next, items.len());
835                    }
836                    *current = state.selected;
837                    *offset = state.offset;
838                    changed_rect = changed.then_some(node.rect);
839                }
840                WidgetKind::FeedTimeline {
841                    items,
842                    selected: ref mut current,
843                    ref mut offset,
844                    visible_rows,
845                    expanded,
846                } => {
847                    if items.is_empty() {
848                        return Ok(true);
849                    }
850                    let mut state =
851                        FeedTimelineState::new(*current, *offset, visible_rows, expanded);
852                    let mut next = state.selected;
853                    changed = bump_index_with_wrap(&mut next, items.len(), delta, wrap_navigation);
854                    if changed {
855                        let _ = state.set_selected(next, items.len());
856                    }
857                    *current = state.selected;
858                    *offset = state.offset;
859                    changed_rect = changed.then_some(node.rect);
860                }
861                WidgetKind::ScrollView {
862                    offset_y: ref mut offset,
863                    content_h,
864                } => {
865                    let mut state = ScrollState::new(*offset, content_h);
866                    changed = state.scroll_by(delta as i32 * 8);
867                    *offset = state.offset_y;
868                    changed_rect = changed.then_some(node.rect);
869                }
870                WidgetKind::AutoComplete {
871                    filtered: _,
872                    filter_count,
873                    selected: ref mut current,
874                    expanded,
875                    ..
876                } => {
877                    if !expanded || filter_count == 0 {
878                        return Ok(false);
879                    }
880                    let idx = current.unwrap_or(0);
881                    let mut next = idx;
882                    changed = bump_index_with_wrap(
883                        &mut next,
884                        filter_count as usize,
885                        delta,
886                        wrap_navigation,
887                    );
888                    *current = Some(next);
889                    changed_rect = changed.then_some(node.rect);
890                }
891                _ => return Ok(false),
892            }
893        }
894
895        if let Some(rect) = changed_rect {
896            self.dirty.add(rect)?;
897        }
898        if changed {
899            self.push_event(UiEvent::ValueChanged(id))?;
900        }
901        Ok(true)
902    }
903
904    /// Stub for the `rich-widgets`-off build: none of the gated kinds this
905    /// handles (Slider/Tabs/TextArea) exist, so always fall through to the
906    /// caller's plain focus_prev()/focus_next().
907    #[cfg(not(feature = "rich-widgets"))]
908    pub(crate) fn adjust_focused_scalar(&mut self, _direction: f32) -> Result<bool, GuiError> {
909        Ok(false)
910    }
911
912    #[cfg(feature = "rich-widgets")]
913    pub(crate) fn adjust_focused_scalar(&mut self, direction: f32) -> Result<bool, GuiError> {
914        let Some(id) = self.focus else {
915            return Ok(false);
916        };
917
918        let mut changed_rect = None;
919        let mut changed = false;
920
921        if let Some(node) = self.node_mut(id) {
922            match node.kind {
923                WidgetKind::Slider {
924                    value: ref mut current,
925                    min,
926                    max,
927                } => {
928                    let mut state = SliderState::new(*current, min, max);
929                    changed = state.step_by(direction);
930                    *current = state.value;
931                    changed_rect = changed.then_some(node.rect);
932                }
933                WidgetKind::Tabs {
934                    labels,
935                    selected: ref mut current,
936                } => {
937                    if labels.is_empty() {
938                        return Ok(true);
939                    }
940                    let mut state = TabsState::new(*current);
941                    changed = state.bump(labels.len(), if direction >= 0.0 { 1 } else { -1 });
942                    *current = state.selected;
943                    changed_rect = changed.then_some(node.rect);
944                }
945                WidgetKind::TextArea {
946                    text_buf,
947                    text_len,
948                    cursor: ref mut current,
949                    ..
950                } => {
951                    let text = textarea_text(&text_buf, text_len);
952                    let len = text.chars().count();
953                    if direction >= 0.0 {
954                        let next = (*current + 1).min(len);
955                        changed = next != *current;
956                        *current = next;
957                    } else {
958                        let next = current.saturating_sub(1);
959                        changed = next != *current;
960                        *current = next;
961                    }
962                    changed_rect = changed.then_some(node.rect);
963                }
964                WidgetKind::Dial {
965                    value: ref mut current,
966                    min,
967                    max,
968                } => {
969                    let mut state = SliderState::new(*current, min, max);
970                    changed = state.step_by(direction);
971                    *current = state.value;
972                    changed_rect = changed.then_some(node.rect);
973                }
974                _ => return Ok(false),
975            }
976        }
977
978        if let Some(rect) = changed_rect {
979            self.dirty.add(rect)?;
980        }
981        if changed {
982            self.push_event(UiEvent::ValueChanged(id))?;
983        }
984        Ok(true)
985    }
986
987    /// Stub for the `rich-widgets`-off build: none of the gated kinds this
988    /// handles (Checkbox/Dropdown/Keyboard/Toggle) exist, so activation for
989    /// whatever's focused is just the generic click dispatch already done
990    /// by the caller.
991    #[cfg(not(feature = "rich-widgets"))]
992    pub(crate) fn activate_focused(&mut self, _id: WidgetId) -> Result<(), GuiError> {
993        Ok(())
994    }
995
996    #[cfg(feature = "rich-widgets")]
997    pub(crate) fn activate_focused(&mut self, id: WidgetId) -> Result<(), GuiError> {
998        let mut changed_rect = None;
999        let mut changed = false;
1000        let mut dropdown_state_event = None;
1001        let select_opens_dropdown = self.menu_contract.select_opens_dropdown;
1002
1003        if let Some(node) = self.node_mut(id) {
1004            match node.kind {
1005                WidgetKind::Toggle { on: ref mut v, .. } => {
1006                    *v = !*v;
1007                    changed = true;
1008                    changed_rect = Some(node.rect);
1009                }
1010                WidgetKind::Checkbox {
1011                    checked: ref mut v, ..
1012                } => {
1013                    *v = !*v;
1014                    changed = true;
1015                    changed_rect = Some(node.rect);
1016                }
1017                WidgetKind::Keyboard {
1018                    keys,
1019                    alt_keys,
1020                    selected,
1021                    layout,
1022                    target,
1023                    ..
1024                } => {
1025                    if let Some(ch) = keyboard_char_for_layout(keys, alt_keys, selected, layout) {
1026                        changed = true;
1027                        changed_rect = Some(node.rect);
1028                        if let Some(target) = target {
1029                            let _ = self.push_event(UiEvent::TextInput { id: target, ch });
1030                            let _ = self.push_event(UiEvent::ValueChanged(target));
1031                        }
1032                    }
1033                }
1034                WidgetKind::Dropdown {
1035                    open: ref mut is_open,
1036                    ..
1037                } if select_opens_dropdown => {
1038                    *is_open = !*is_open;
1039                    changed = true;
1040                    changed_rect = Some(node.rect);
1041                    dropdown_state_event = Some(*is_open);
1042                }
1043                _ => {}
1044            }
1045        }
1046
1047        if let Some(open) = dropdown_state_event {
1048            let mut events = heapless::Vec::<WidgetEvent, NODES>::new();
1049            self.dispatch_widget_event(
1050                id,
1051                if open {
1052                    WidgetEventKind::Opened
1053                } else {
1054                    WidgetEventKind::Closed
1055                },
1056                &mut events,
1057                |_| EventPolicy::Continue,
1058            )?;
1059            self.push_event(if open {
1060                UiEvent::Opened(id)
1061            } else {
1062                UiEvent::Closed(id)
1063            })?;
1064        }
1065
1066        if let Some(rect) = changed_rect {
1067            self.dirty.add(rect)?;
1068        }
1069        if changed {
1070            self.push_event(UiEvent::ValueChanged(id))?;
1071        }
1072        Ok(())
1073    }
1074
1075    pub(crate) fn node_in_active_group(&self, node: &WidgetNode<'_>) -> bool {
1076        self.active_focus_group
1077            .is_none_or(|group| node.focus_group == group)
1078    }
1079
1080    pub(crate) fn handle_pointer_pressed(&mut self, x: i32, y: i32) -> Result<(), GuiError> {
1081        let hit = self.pointer_hit(x, y, true);
1082
1083        if let Some(id) = hit {
1084            self.dispatch_activation(id, true)?;
1085            self.update_dial_value_at_pointer(id, x, y)?;
1086            self.pressed = Some(PressTracker {
1087                id,
1088                start_x: x,
1089                start_y: y,
1090                last_x: x,
1091                last_y: y,
1092                elapsed_ms: 0,
1093                long_emitted: false,
1094                gesture_emitted: false,
1095                repeat_elapsed_ms: 0,
1096                scroll_velocity: 0.0,
1097            });
1098            self.inertia_scroll = None;
1099        }
1100        Ok(())
1101    }
1102
1103    pub(crate) fn handle_pointer_released(&mut self, _x: i32, _y: i32) -> Result<(), GuiError> {
1104        let mut released_id = None;
1105        if let Some(pressed) = self.pressed {
1106            if let Some(scroll_id) = self.scrollable_ancestor(pressed.id) {
1107                if pressed.scroll_velocity.abs() > self.scroll_physics.velocity_threshold {
1108                    self.inertia_scroll = Some(InertiaScroll {
1109                        id: scroll_id,
1110                        velocity: pressed.scroll_velocity,
1111                    });
1112                }
1113            }
1114            released_id = Some(pressed.id);
1115        }
1116        self.pressed = None;
1117        if let Some(id) = released_id {
1118            let to = if !self.effective_enabled(id) {
1119                VisualState::Disabled
1120            } else if Some(id) == self.focus {
1121                VisualState::Focused
1122            } else {
1123                VisualState::Normal
1124            };
1125            self.start_state_transition(id, VisualState::Pressed, to);
1126            let mut events = heapless::Vec::<WidgetEvent, NODES>::new();
1127            self.dispatch_widget_event(id, WidgetEventKind::Released, &mut events, |_| {
1128                EventPolicy::Continue
1129            })?;
1130            self.push_event(UiEvent::Released(id))?;
1131            self.push_event(UiEvent::PointerReleased(id))?;
1132            let double_pointer = self.last_pointer_id == Some(id)
1133                && self.pointer_elapsed_ms <= self.pointer_double_window_ms;
1134            if double_pointer {
1135                self.dispatch_double_clicked(id)?;
1136                self.last_pointer_id = None;
1137                self.pointer_elapsed_ms = 0;
1138            } else {
1139                self.last_pointer_id = Some(id);
1140                self.pointer_elapsed_ms = 0;
1141            }
1142        }
1143        Ok(())
1144    }
1145
1146    pub(crate) fn handle_pointer_moved(&mut self, x: i32, y: i32) -> Result<(), GuiError> {
1147        let Some(mut pressed) = self.pressed else {
1148            return Ok(());
1149        };
1150        self.update_dial_value_at_pointer(pressed.id, x, y)?;
1151        let dy = y - pressed.last_y;
1152        pressed.last_x = x;
1153        pressed.last_y = y;
1154
1155        let moved_from_start =
1156            (x - pressed.start_x).unsigned_abs() + (y - pressed.start_y).unsigned_abs();
1157        if !pressed.gesture_emitted && moved_from_start >= 6 {
1158            let mut events = heapless::Vec::<WidgetEvent, NODES>::new();
1159            self.dispatch_widget_event(pressed.id, WidgetEventKind::Gesture, &mut events, |_| {
1160                EventPolicy::Continue
1161            })?;
1162            self.push_event(UiEvent::Gesture(pressed.id))?;
1163            pressed.gesture_emitted = true;
1164        }
1165
1166        #[cfg(feature = "rich-widgets")]
1167        if let Some(scroll_id) = self.scrollable_ancestor(pressed.id) {
1168            let current = self.scroll_offset(scroll_id).unwrap_or(0);
1169            let next = current.saturating_sub(dy);
1170            if next != current {
1171                self.set_scroll_offset(scroll_id, next)?;
1172                self.push_event(UiEvent::Scroll {
1173                    id: scroll_id,
1174                    delta: next - current,
1175                })?;
1176                let mut events = heapless::Vec::<WidgetEvent, NODES>::new();
1177                self.dispatch_widget_event(
1178                    scroll_id,
1179                    WidgetEventKind::Scroll {
1180                        delta: next - current,
1181                    },
1182                    &mut events,
1183                    |_| EventPolicy::Continue,
1184                )?;
1185            }
1186            let blend = self.scroll_physics.drag_velocity_blend;
1187            pressed.scroll_velocity = pressed.scroll_velocity * (1.0 - blend) + (dy as f32) * blend;
1188        }
1189        self.pressed = Some(pressed);
1190        Ok(())
1191    }
1192
1193    pub(crate) fn dispatch_activation(
1194        &mut self,
1195        id: WidgetId,
1196        is_pointer: bool,
1197    ) -> Result<(), GuiError> {
1198        let is_autocomplete = matches!(
1199            self.node(id).map(|n| &n.kind),
1200            Some(WidgetKind::AutoComplete { .. })
1201        );
1202        if is_autocomplete {
1203            self.autocomplete_confirm_selection(id)?;
1204            return Ok(());
1205        }
1206
1207        let mut events = heapless::Vec::<WidgetEvent, NODES>::new();
1208        self.dispatch_widget_event(id, WidgetEventKind::Pressed, &mut events, |_| {
1209            EventPolicy::Continue
1210        })?;
1211        if self.effective_focusable(id) {
1212            self.set_focus(Some(id))?;
1213        }
1214        self.push_event(UiEvent::Pressed(id))?;
1215        if is_pointer {
1216            self.push_event(UiEvent::PointerPressed(id))?;
1217        }
1218        let from = if Some(id) == self.focus {
1219            VisualState::Focused
1220        } else {
1221            VisualState::Normal
1222        };
1223        self.start_state_transition(id, from, VisualState::Pressed);
1224
1225        self.activate_focused(id)?;
1226        self.dispatch_widget_event(id, WidgetEventKind::Clicked, &mut events, |_| {
1227            EventPolicy::Continue
1228        })?;
1229        self.push_event(UiEvent::Clicked(id))?;
1230        self.play_haptic(HapticPattern::Click);
1231        self.push_event(UiEvent::Activate(id))?;
1232        Ok(())
1233    }
1234
1235    pub(crate) fn dispatch_repeat_activation(&mut self, id: WidgetId) -> Result<(), GuiError> {
1236        let mut events = heapless::Vec::<WidgetEvent, NODES>::new();
1237        self.dispatch_widget_event(id, WidgetEventKind::Clicked, &mut events, |_| {
1238            EventPolicy::Continue
1239        })?;
1240        self.push_event(UiEvent::Clicked(id))?;
1241        self.play_haptic(HapticPattern::Click);
1242        self.push_event(UiEvent::Activate(id))
1243    }
1244
1245    pub(crate) fn dispatch_double_clicked(&mut self, id: WidgetId) -> Result<(), GuiError> {
1246        let mut events = heapless::Vec::<WidgetEvent, NODES>::new();
1247        self.dispatch_widget_event(id, WidgetEventKind::DoubleClicked, &mut events, |_| {
1248            EventPolicy::Continue
1249        })?;
1250        self.push_event(UiEvent::DoubleClicked(id))?;
1251        self.play_haptic(HapticPattern::DoubleClick);
1252        Ok(())
1253    }
1254
1255    pub(crate) fn dispatch_key_pressed(&mut self, id: WidgetId) -> Result<(), GuiError> {
1256        let mut events = heapless::Vec::<WidgetEvent, NODES>::new();
1257        self.dispatch_widget_event(id, WidgetEventKind::Pressed, &mut events, |_| {
1258            EventPolicy::Continue
1259        })?;
1260        self.push_event(UiEvent::Pressed(id))
1261    }
1262
1263    pub(crate) fn dispatch_key_released(&mut self, id: WidgetId) -> Result<(), GuiError> {
1264        let mut events = heapless::Vec::<WidgetEvent, NODES>::new();
1265        self.dispatch_widget_event(id, WidgetEventKind::Released, &mut events, |_| {
1266            EventPolicy::Continue
1267        })?;
1268        self.push_event(UiEvent::Released(id))
1269    }
1270
1271    pub(crate) fn repeatable_widget(&self, id: WidgetId) -> bool {
1272        self.node(id).is_some_and(|node| {
1273            #[cfg(feature = "rich-widgets")]
1274            if matches!(node.kind, WidgetKind::IconButton { .. }) {
1275                return true;
1276            }
1277            matches!(node.kind, WidgetKind::Button(_))
1278        })
1279    }
1280
1281    pub(crate) fn pointer_hit(&self, x: i32, y: i32, clickable_only: bool) -> Option<WidgetId> {
1282        self.widgets
1283            .iter()
1284            .rev()
1285            .find(|node| {
1286                (!clickable_only || node.clickable())
1287                    && self.effective_visible(node.id)
1288                    && self.effective_enabled(node.id)
1289                    && self
1290                        .absolute_rect(node.id)
1291                        .is_some_and(|rect| rect.contains(x, y))
1292            })
1293            .map(|node| node.id)
1294    }
1295
1296    pub(crate) fn scrollable_ancestor(&self, id: WidgetId) -> Option<WidgetId> {
1297        let mut current = Some(id);
1298        let mut depth = 0usize;
1299        while let Some(widget_id) = current {
1300            if depth >= NODES {
1301                return None;
1302            }
1303            let node = self.node(widget_id)?;
1304            if node.scrollable() {
1305                return Some(widget_id);
1306            }
1307            current = node.parent;
1308            depth += 1;
1309        }
1310        None
1311    }
1312
1313    pub(crate) fn mark_focus_pair(
1314        &mut self,
1315        old: Option<WidgetId>,
1316        new: Option<WidgetId>,
1317    ) -> Result<(), GuiError> {
1318        if let Some(id) = old {
1319            if let Some(rect) = self.absolute_rect(id) {
1320                self.dirty.add(rect)?;
1321            }
1322        }
1323        if let Some(id) = new {
1324            if let Some(rect) = self.absolute_rect(id) {
1325                self.dirty.add(rect)?;
1326            }
1327        }
1328        Ok(())
1329    }
1330
1331    pub(crate) fn start_focus_transitions(&mut self, old: Option<WidgetId>, new: Option<WidgetId>) {
1332        if self.state_transition_ms == 0 {
1333            return;
1334        }
1335        if let Some(id) = old {
1336            self.start_state_transition(id, VisualState::Focused, VisualState::Normal);
1337        }
1338        if let Some(id) = new {
1339            self.start_state_transition(id, VisualState::Normal, VisualState::Focused);
1340        }
1341    }
1342
1343    pub(crate) fn start_state_transition(
1344        &mut self,
1345        id: WidgetId,
1346        from: VisualState,
1347        to: VisualState,
1348    ) {
1349        if self.state_transition_ms == 0 || from == to {
1350            return;
1351        }
1352        if let Some(entry) = self
1353            .state_transitions
1354            .iter_mut()
1355            .find(|entry| entry.id == id)
1356        {
1357            *entry = StateTransition {
1358                id,
1359                from,
1360                to,
1361                elapsed_ms: 0,
1362            };
1363            return;
1364        }
1365        if self.state_transitions.len() == self.state_transitions.capacity() {
1366            self.state_transitions.remove(0);
1367        }
1368        let _ = self.state_transitions.push(StateTransition {
1369            id,
1370            from,
1371            to,
1372            elapsed_ms: 0,
1373        });
1374    }
1375
1376    pub(crate) fn tick_state_transitions(&mut self, dt_ms: u32) -> Result<(), GuiError> {
1377        if self.state_transitions.is_empty() || self.state_transition_ms == 0 {
1378            return Ok(());
1379        }
1380        let mut i = 0usize;
1381        let mut completed_pressed = heapless::Vec::<WidgetId, NODES>::new();
1382        while i < self.state_transitions.len() {
1383            let mut remove = false;
1384            let id;
1385            let to;
1386            {
1387                let entry = &mut self.state_transitions[i];
1388                entry.elapsed_ms = entry.elapsed_ms.saturating_add(dt_ms);
1389                if entry.elapsed_ms >= self.state_transition_ms {
1390                    remove = true;
1391                }
1392                id = entry.id;
1393                to = entry.to;
1394            }
1395            if let Some(rect) = self.absolute_rect(id) {
1396                self.dirty.add(rect)?;
1397            }
1398            if remove {
1399                if to == VisualState::Pressed {
1400                    let _ = completed_pressed.push(id);
1401                }
1402                self.state_transitions.remove(i);
1403            } else {
1404                i += 1;
1405            }
1406        }
1407        for id in completed_pressed {
1408            // Pointer-held presses keep visual pressed state until release.
1409            if self.pressed.is_some_and(|pressed| pressed.id == id) {
1410                continue;
1411            }
1412            let to = self.resting_visual_state(id);
1413            self.start_state_transition(id, VisualState::Pressed, to);
1414        }
1415        Ok(())
1416    }
1417
1418    pub(crate) fn state_transition_progress(
1419        &self,
1420        id: WidgetId,
1421    ) -> Option<(VisualState, VisualState, f32)> {
1422        let duration = self.state_transition_ms.max(1);
1423        self.state_transitions
1424            .iter()
1425            .find(|entry| entry.id == id)
1426            .map(|entry| {
1427                let t = (entry.elapsed_ms as f32 / duration as f32).clamp(0.0, 1.0);
1428                (entry.from, entry.to, t)
1429            })
1430    }
1431
1432    /// Stub for the `rich-widgets`-off build: TextArea doesn't exist, so
1433    /// there's no cursor blink state to update.
1434    #[cfg(not(feature = "rich-widgets"))]
1435    pub(crate) fn set_textarea_cursor_visible(&mut self, _id: Option<WidgetId>, _visible: bool) {}
1436
1437    #[cfg(feature = "rich-widgets")]
1438    pub(crate) fn set_textarea_cursor_visible(&mut self, id: Option<WidgetId>, visible: bool) {
1439        let Some(id) = id else {
1440            return;
1441        };
1442        let Some(rect) = self.absolute_rect(id) else {
1443            return;
1444        };
1445        let Some(node) = self.node_mut(id) else {
1446            return;
1447        };
1448        if let WidgetKind::TextArea {
1449            cursor_visible: ref mut current,
1450            ..
1451        } = node.kind
1452        {
1453            *current = visible;
1454            let _ = self.dirty.add(rect);
1455        }
1456    }
1457
1458    pub(crate) fn tick_theme_transition(&mut self, dt_ms: u32) -> Result<(), GuiError> {
1459        if let (Some(from), Some(to)) = (self.theme_transition_from, self.theme_transition_to) {
1460            let elapsed = self.theme_transition_elapsed_ms.saturating_add(dt_ms);
1461            self.theme_transition_elapsed_ms = elapsed;
1462            if elapsed >= self.theme_transition_duration_ms {
1463                self.theme = to;
1464                self.theme_transition_from = None;
1465                self.theme_transition_to = None;
1466            } else {
1467                let t = elapsed as f32 / self.theme_transition_duration_ms as f32;
1468                self.theme = crate::style::lerp_theme(from, to, t);
1469            }
1470            self.dirty.mark_all(self.viewport)?;
1471        }
1472        Ok(())
1473    }
1474
1475    #[cfg(feature = "rich-widgets")]
1476    pub(crate) fn tick_textarea_cursor_blink(&mut self, dt_ms: u32) -> Result<(), GuiError> {
1477        let Some(id) = self.focus else {
1478            return Ok(());
1479        };
1480        let is_textarea = matches!(
1481            self.node(id).map(|n| n.kind),
1482            Some(WidgetKind::TextArea { .. })
1483        );
1484        if !is_textarea {
1485            return Ok(());
1486        }
1487        self.textarea_cursor_blink_elapsed_ms =
1488            self.textarea_cursor_blink_elapsed_ms.saturating_add(dt_ms);
1489        if self.textarea_cursor_blink_elapsed_ms < self.textarea_cursor_blink_ms {
1490            return Ok(());
1491        }
1492        self.textarea_cursor_blink_elapsed_ms = 0;
1493        let rect = self.absolute_rect(id).ok_or(GuiError::NotFound)?;
1494        let node = self.node_mut(id).ok_or(GuiError::NotFound)?;
1495        if let WidgetKind::TextArea {
1496            cursor_visible: ref mut visible,
1497            ..
1498        } = node.kind
1499        {
1500            *visible = !*visible;
1501            self.dirty.add(rect)?;
1502        }
1503        Ok(())
1504    }
1505
1506    pub(crate) fn push_event(&mut self, event: UiEvent) -> Result<(), GuiError> {
1507        if self.should_emit_event(event)? {
1508            self.events.push(event).map_err(|_| GuiError::EventsFull)?;
1509        }
1510        Ok(())
1511    }
1512
1513    pub(crate) fn should_emit_event(&self, event: UiEvent) -> Result<bool, GuiError> {
1514        let Some(target) = event.target() else {
1515            return Ok(true);
1516        };
1517        let filter = self.event_filter(target)?;
1518        Ok(filter.contains(event.filter()))
1519    }
1520
1521    #[cfg(feature = "rich-widgets")]
1522    pub(crate) fn stop_due_to_builtin_widget_behavior(&self, event: WidgetEvent) -> bool {
1523        if event.phase != EventPhase::Capture || event.current == event.target {
1524            return false;
1525        }
1526        let is_pointer_kind = matches!(
1527            event.kind,
1528            WidgetEventKind::Pressed | WidgetEventKind::Released | WidgetEventKind::Clicked
1529        );
1530        is_pointer_kind
1531            && self
1532                .node(event.current)
1533                .is_some_and(|node| matches!(node.kind, WidgetKind::ScrollView { .. }))
1534    }
1535
1536    /// Stub for the `rich-widgets`-off build: ScrollView doesn't exist, so
1537    /// no event ever needs stopping for this reason.
1538    #[cfg(not(feature = "rich-widgets"))]
1539    pub(crate) fn stop_due_to_builtin_widget_behavior(&self, _event: WidgetEvent) -> bool {
1540        false
1541    }
1542
1543    pub(crate) fn stop_due_to_registered_policy(&self, event: WidgetEvent) -> bool {
1544        self.dispatch_policies
1545            .iter()
1546            .find(|(id, _)| *id == event.current)
1547            .is_some_and(|(_, policy)| policy.stop && policy.allows(event.kind, event.phase))
1548    }
1549
1550    pub(crate) fn resting_visual_state(&self, id: WidgetId) -> VisualState {
1551        if !self.effective_enabled(id) {
1552            VisualState::Disabled
1553        } else if Some(id) == self.focus {
1554            VisualState::Focused
1555        } else {
1556            VisualState::Normal
1557        }
1558    }
1559
1560    pub(crate) fn current_visual_state(&self, id: WidgetId) -> VisualState {
1561        if self.pressed.is_some_and(|pressed| pressed.id == id) {
1562            VisualState::Pressed
1563        } else {
1564            self.resting_visual_state(id)
1565        }
1566    }
1567
1568    pub(crate) fn press_timing_for(&self, id: WidgetId) -> PressTiming {
1569        self.widget_press_timings
1570            .iter()
1571            .find(|(timing_id, _)| *timing_id == id)
1572            .map(|(_, timing)| *timing)
1573            .unwrap_or(PressTiming {
1574                long_press_ms: self.long_press_ms,
1575                repeat_delay_ms: self.press_repeat_delay_ms,
1576                repeat_interval_ms: self.press_repeat_interval_ms,
1577            })
1578    }
1579
1580    pub(crate) fn key_input_policy_for(&self, id: WidgetId) -> WidgetKeyInputPolicy {
1581        self.widget_key_policies
1582            .iter()
1583            .find(|(policy_id, _)| *policy_id == id)
1584            .map(|(_, policy)| *policy)
1585            .unwrap_or_default()
1586    }
1587
1588    pub(crate) fn key_bindings_for(&self, id: WidgetId) -> WidgetKeyBindings {
1589        self.widget_key_bindings
1590            .iter()
1591            .find(|(binding_id, _)| *binding_id == id)
1592            .map(|(_, bindings)| *bindings)
1593            .unwrap_or_default()
1594    }
1595
1596    pub(crate) fn handle_select_activation(&mut self, id: WidgetId) -> Result<(), GuiError> {
1597        #[cfg(feature = "rich-widgets")]
1598        if let Some(node) = self.node(id) {
1599            if self.menu_contract.select_toggles_feed_expanded
1600                && matches!(node.kind, WidgetKind::FeedTimeline { .. })
1601            {
1602                let expanded = if let WidgetKind::FeedTimeline { expanded, .. } = node.kind {
1603                    expanded
1604                } else {
1605                    false
1606                };
1607                self.set_feed_expanded(id, !expanded)?;
1608                self.push_event(UiEvent::ValueChanged(id))?;
1609            }
1610        }
1611        let double_select = self.last_select_id == Some(id)
1612            && self.select_elapsed_ms <= self.select_double_window_ms;
1613        self.dispatch_activation(id, false)?;
1614        if double_select {
1615            self.dispatch_double_clicked(id)?;
1616            self.last_select_id = None;
1617            self.select_elapsed_ms = 0;
1618        } else {
1619            self.last_select_id = Some(id);
1620            self.select_elapsed_ms = 0;
1621        }
1622        Ok(())
1623    }
1624
1625    pub(crate) fn handle_back_action(&mut self) -> Result<(), GuiError> {
1626        #[cfg(feature = "rich-widgets")]
1627        if let Some(id) = self.focus {
1628            if matches!(
1629                self.node(id).map(|n| n.kind),
1630                Some(WidgetKind::TextArea { .. })
1631            ) {
1632                self.textarea_backspace(id)?;
1633                return Ok(());
1634            }
1635            if matches!(
1636                self.node(id).map(|n| n.kind),
1637                Some(WidgetKind::AutoComplete { .. })
1638            ) {
1639                let expanded = if let Some(WidgetKind::AutoComplete { expanded, .. }) =
1640                    self.node(id).map(|n| n.kind)
1641                {
1642                    expanded
1643                } else {
1644                    false
1645                };
1646                if expanded {
1647                    let has_chars = if let Some(WidgetKind::AutoComplete { text_len, .. }) =
1648                        self.node(id).map(|n| n.kind)
1649                    {
1650                        text_len > 0
1651                    } else {
1652                        false
1653                    };
1654                    if has_chars {
1655                        self.delete_autocomplete_char(id)?;
1656                    } else {
1657                        let rect = self.absolute_rect(id).ok_or(GuiError::NotFound)?;
1658                        let node = self.node_mut(id).ok_or(GuiError::NotFound)?;
1659                        if let WidgetKind::AutoComplete { expanded, .. } = &mut node.kind {
1660                            *expanded = false;
1661                        }
1662                        self.dirty.add(rect)?;
1663                    }
1664                    return Ok(());
1665                }
1666            }
1667            if matches!(
1668                self.node(id).map(|n| n.kind),
1669                Some(WidgetKind::Dropdown { open: true, .. })
1670            ) && self.menu_contract.back_closes_dropdown
1671            {
1672                self.set_dropdown_open(id, false)?;
1673                return Ok(());
1674            }
1675            if matches!(
1676                self.node(id).map(|n| n.kind),
1677                Some(WidgetKind::NotificationActionSheet { open: true, .. })
1678            ) && self.menu_contract.back_closes_notification_sheet
1679            {
1680                self.set_notification_sheet_open(id, false)?;
1681                return Ok(());
1682            }
1683        }
1684        self.push_event(UiEvent::Back)
1685    }
1686
1687    pub(crate) fn update_dial_value_at_pointer(
1688        &mut self,
1689        id: WidgetId,
1690        x: i32,
1691        y: i32,
1692    ) -> Result<(), GuiError> {
1693        let rect = self.absolute_rect(id).ok_or(GuiError::NotFound)?;
1694        let node = self.node_mut(id).ok_or(GuiError::NotFound)?;
1695        if let WidgetKind::Dial { value, min, max } = &mut node.kind {
1696            let cx = rect.x + rect.w as i32 / 2;
1697            let cy = rect.y + rect.h as i32 / 2;
1698            let dx = (x - cx) as f32;
1699            let dy = (y - cy) as f32;
1700            if dx != 0.0 || dy != 0.0 {
1701                #[cfg(not(feature = "std"))]
1702                use crate::math::F32Ext as _;
1703
1704                let angle_rad = dy.atan2(dx);
1705                let mut angle_norm = angle_rad + core::f32::consts::PI;
1706                if angle_norm < 0.0 {
1707                    angle_norm += 2.0 * core::f32::consts::PI;
1708                }
1709                let progress = (angle_norm / (2.0 * core::f32::consts::PI)).clamp(0.0, 1.0);
1710                let next_val = *min + progress * (*max - *min);
1711                if (*value - next_val).abs() > 0.001 {
1712                    *value = next_val;
1713                    self.dirty.add(rect)?;
1714                    self.push_event(UiEvent::ValueChanged(id))?;
1715                }
1716            }
1717        }
1718        Ok(())
1719    }
1720
1721    pub(crate) fn tick_rle_animations(&mut self, dt_ms: u32) -> Result<(), GuiError> {
1722        let mut dirty_rects = heapless::Vec::<Rect, NODES>::new();
1723        for node in self.widgets.iter_mut() {
1724            if let WidgetKind::RlePlayer {
1725                total_frames,
1726                ref mut current_frame,
1727                ref mut elapsed_ms,
1728                frame_duration_ms,
1729                ..
1730            } = node.kind
1731            {
1732                if total_frames > 1 && frame_duration_ms > 0 {
1733                    *elapsed_ms = elapsed_ms.saturating_add(dt_ms);
1734                    if *elapsed_ms >= frame_duration_ms {
1735                        let frames_to_advance = *elapsed_ms / frame_duration_ms;
1736                        *elapsed_ms %= frame_duration_ms;
1737                        *current_frame =
1738                            (*current_frame + frames_to_advance as usize) % total_frames;
1739                        let _ = dirty_rects.push(node.rect);
1740                    }
1741                }
1742            }
1743        }
1744        for rect in dirty_rects {
1745            self.dirty.add(rect)?;
1746        }
1747        Ok(())
1748    }
1749}
1750
1751#[cfg(feature = "rich-widgets")]
1752pub(crate) fn bump_index_with_wrap(current: &mut usize, len: usize, delta: i8, wrap: bool) -> bool {
1753    if len == 0 {
1754        return false;
1755    }
1756    let next = if delta >= 0 {
1757        if *current + 1 >= len {
1758            if wrap { 0 } else { *current }
1759        } else {
1760            *current + 1
1761        }
1762    } else if *current == 0 {
1763        if wrap { len - 1 } else { *current }
1764    } else {
1765        *current - 1
1766    };
1767    if next != *current {
1768        *current = next;
1769        true
1770    } else {
1771        false
1772    }
1773}
1774
1775#[cfg(feature = "rich-widgets")]
1776pub(crate) fn keyboard_char_for_layout(
1777    keys: &[char],
1778    alt_keys: Option<&[char]>,
1779    selected: usize,
1780    layout: KeyboardLayout,
1781) -> Option<char> {
1782    let base = keys.get(selected).copied()?;
1783    Some(match layout {
1784        KeyboardLayout::Normal => base,
1785        KeyboardLayout::Shift => {
1786            if base.is_ascii_alphabetic() {
1787                base.to_ascii_uppercase()
1788            } else {
1789                base
1790            }
1791        }
1792        KeyboardLayout::Symbols => alt_keys
1793            .and_then(|keys| keys.get(selected).copied())
1794            .unwrap_or('#'),
1795    })
1796}
1797
1798#[cfg(feature = "rich-widgets")]
1799pub(crate) fn textarea_text(buf: &[u8; TEXTAREA_CAPACITY], len: u8) -> &str {
1800    let used = (len as usize).min(TEXTAREA_CAPACITY);
1801    core::str::from_utf8(&buf[..used]).unwrap_or("")
1802}
1803
1804#[cfg(feature = "rich-widgets")]
1805pub(crate) fn textarea_storage_from_str(text: &str) -> ([u8; TEXTAREA_CAPACITY], u8) {
1806    let mut out = [0u8; TEXTAREA_CAPACITY];
1807    let mut len = 0usize;
1808    for ch in text.chars() {
1809        let mut tmp = [0u8; 4];
1810        let enc = ch.encode_utf8(&mut tmp).as_bytes();
1811        if len + enc.len() > TEXTAREA_CAPACITY {
1812            break;
1813        }
1814        out[len..len + enc.len()].copy_from_slice(enc);
1815        len += enc.len();
1816    }
1817    (out, len as u8)
1818}
1819
1820#[cfg(feature = "rich-widgets")]
1821pub(crate) fn textarea_storage_from_chars(
1822    chars: &heapless::Vec<char, TEXTAREA_CAPACITY>,
1823) -> ([u8; TEXTAREA_CAPACITY], u8) {
1824    let mut out = [0u8; TEXTAREA_CAPACITY];
1825    let mut len = 0usize;
1826    for ch in chars {
1827        let mut tmp = [0u8; 4];
1828        let enc = ch.encode_utf8(&mut tmp).as_bytes();
1829        if len + enc.len() > TEXTAREA_CAPACITY {
1830            break;
1831        }
1832        out[len..len + enc.len()].copy_from_slice(enc);
1833        len += enc.len();
1834    }
1835    (out, len as u8)
1836}
1837
1838#[cfg(feature = "rich-widgets")]
1839pub(crate) fn char_at(text: &str, idx: usize) -> Option<char> {
1840    text.chars().nth(idx)
1841}
1842
1843#[cfg(feature = "rich-widgets")]
1844pub(crate) fn prev_word_boundary(text: &str, cursor: usize) -> usize {
1845    let mut pos = cursor.min(text.chars().count());
1846    while pos > 0 && char_at(text, pos - 1).is_some_and(|ch| ch.is_whitespace()) {
1847        pos -= 1;
1848    }
1849    while pos > 0 && char_at(text, pos - 1).is_some_and(|ch| !ch.is_whitespace()) {
1850        pos -= 1;
1851    }
1852    pos
1853}
1854
1855#[cfg(feature = "rich-widgets")]
1856pub(crate) fn next_word_boundary(text: &str, cursor: usize) -> usize {
1857    let len = text.chars().count();
1858    let mut pos = cursor.min(len);
1859    while pos < len && char_at(text, pos).is_some_and(|ch| !ch.is_whitespace()) {
1860        pos += 1;
1861    }
1862    while pos < len && char_at(text, pos).is_some_and(|ch| ch.is_whitespace()) {
1863        pos += 1;
1864    }
1865    pos
1866}
1867
1868#[cfg(feature = "rich-widgets")]
1869pub(crate) fn delete_selection_if_any(
1870    chars: &mut heapless::Vec<char, TEXTAREA_CAPACITY>,
1871    cursor: &mut usize,
1872    selection: &mut Option<(usize, usize)>,
1873) -> bool {
1874    let Some((start, end)) = *selection else {
1875        return false;
1876    };
1877    let start = start.min(end).min(chars.len());
1878    let end = end.max(start).min(chars.len());
1879    if end <= start {
1880        *selection = None;
1881        *cursor = start;
1882        return false;
1883    }
1884    for _ in start..end {
1885        chars.remove(start);
1886    }
1887    *cursor = start;
1888    *selection = None;
1889    true
1890}
1891
1892#[cfg(feature = "rich-widgets")]
1893pub(crate) fn textarea_row_col_at_cursor(
1894    text: &str,
1895    cursor: usize,
1896    wrap_cols: usize,
1897) -> (usize, usize) {
1898    let mut row = 0usize;
1899    let mut col = 0usize;
1900    for ch in text.chars().take(cursor) {
1901        if ch == '\n' {
1902            row += 1;
1903            col = 0;
1904            continue;
1905        }
1906        col += 1;
1907        if col >= wrap_cols {
1908            row += 1;
1909            col = 0;
1910        }
1911    }
1912    (row, col)
1913}
1914
1915#[cfg(feature = "rich-widgets")]
1916pub(crate) fn textarea_cursor_from_row_col(
1917    text: &str,
1918    target_row: usize,
1919    target_col: usize,
1920    wrap_cols: usize,
1921) -> usize {
1922    let mut row = 0usize;
1923    let mut col = 0usize;
1924    let mut idx = 0usize;
1925    for ch in text.chars() {
1926        if row == target_row && col >= target_col {
1927            break;
1928        }
1929        if ch == '\n' {
1930            if row == target_row {
1931                break;
1932            }
1933            row += 1;
1934            col = 0;
1935            idx += 1;
1936            continue;
1937        }
1938        idx += 1;
1939        col += 1;
1940        if col >= wrap_cols {
1941            if row == target_row {
1942                break;
1943            }
1944            row += 1;
1945            col = 0;
1946        }
1947    }
1948    idx
1949}
1950
1951#[cfg(feature = "rich-widgets")]
1952pub(crate) fn textarea_row_end_col(text: &str, target_row: usize, wrap_cols: usize) -> usize {
1953    let mut row = 0usize;
1954    let mut col = 0usize;
1955    for ch in text.chars() {
1956        if row == target_row {
1957            if ch == '\n' {
1958                break;
1959            }
1960            col += 1;
1961            if col >= wrap_cols {
1962                break;
1963            }
1964        } else if ch == '\n' {
1965            row += 1;
1966            col = 0;
1967        } else {
1968            col += 1;
1969            if col >= wrap_cols {
1970                row += 1;
1971                col = 0;
1972            }
1973        }
1974    }
1975    col
1976}