Skip to main content

iced_core/widget/operation/
focusable.rs

1//! Operate on widgets that can be focused.
2use crate::widget::Id;
3use crate::widget::operation::accessible::Accessible;
4use crate::widget::operation::scrollable::{AbsoluteOffset, RelativeOffset, Scrollable};
5use crate::widget::operation::{self, Operation, Outcome};
6use crate::{Rectangle, Vector};
7
8/// The internal state of a widget that can be focused.
9pub trait Focusable {
10    /// Returns whether the widget is focused or not.
11    fn is_focused(&self) -> bool;
12
13    /// Focuses the widget.
14    fn focus(&mut self);
15
16    /// Unfocuses the widget.
17    fn unfocus(&mut self);
18}
19
20/// A summary of the focusable widgets present on a widget tree.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub struct Count {
23    /// The index of the current focused widget, if any.
24    pub focused: Option<usize>,
25
26    /// The total amount of focusable widgets.
27    pub total: usize,
28}
29
30/// Produces an [`Operation`] that focuses the widget with the given [`Id`].
31pub fn focus<T>(target: Id) -> impl Operation<T> {
32    struct Focus {
33        target: Id,
34    }
35
36    impl<T> Operation<T> for Focus {
37        fn focusable(&mut self, id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
38            match id {
39                Some(id) if id == &self.target => {
40                    state.focus();
41                }
42                _ => {
43                    state.unfocus();
44                }
45            }
46        }
47
48        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
49            operate(self);
50        }
51    }
52
53    Focus { target }
54}
55
56/// Produces an [`Operation`] that unfocuses the focused widget.
57pub fn unfocus<T>() -> impl Operation<T> {
58    struct Unfocus;
59
60    impl<T> Operation<T> for Unfocus {
61        fn focusable(&mut self, _id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
62            state.unfocus();
63        }
64
65        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
66            operate(self);
67        }
68    }
69
70    Unfocus
71}
72
73/// Produces an [`Operation`] that generates a [`Count`] and chains it with the
74/// provided function to build a new [`Operation`].
75pub fn count() -> impl Operation<Count> {
76    struct CountFocusable {
77        count: Count,
78    }
79
80    impl Operation<Count> for CountFocusable {
81        fn focusable(&mut self, _id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
82            if state.is_focused() {
83                self.count.focused = Some(self.count.total);
84            }
85
86            self.count.total += 1;
87        }
88
89        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<Count>)) {
90            operate(self);
91        }
92
93        fn finish(&self) -> Outcome<Count> {
94            Outcome::Some(self.count)
95        }
96    }
97
98    CountFocusable {
99        count: Count::default(),
100    }
101}
102
103/// A [`Count`] paired with the [`Id`] of the scope it was computed within.
104struct ScopedCountResult {
105    target: Id,
106    count: Count,
107}
108
109/// Produces an [`Operation`] that generates a [`Count`] of focusable widgets
110/// within the container identified by `target`.
111///
112/// The result carries both the count and the target [`Id`] so that a
113/// subsequent [`Operation`] can reuse it without capturing state.
114fn scoped_count(target: Id) -> impl Operation<ScopedCountResult> {
115    struct ScopedCount {
116        target: Id,
117        pending_scope: bool,
118        inside_scope: bool,
119        count: Count,
120    }
121
122    impl Operation<ScopedCountResult> for ScopedCount {
123        fn container(&mut self, id: Option<&Id>, _bounds: Rectangle) {
124            if id.is_some_and(|id| *id == self.target) {
125                self.pending_scope = true;
126            }
127        }
128
129        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<ScopedCountResult>)) {
130            let was_inside = self.inside_scope;
131            if self.pending_scope {
132                self.inside_scope = true;
133                self.pending_scope = false;
134            }
135            operate(self);
136            self.inside_scope = was_inside;
137        }
138
139        fn focusable(&mut self, _id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
140            if !self.inside_scope {
141                return;
142            }
143
144            if state.is_focused() {
145                self.count.focused = Some(self.count.total);
146            }
147
148            self.count.total += 1;
149        }
150
151        fn finish(&self) -> Outcome<ScopedCountResult> {
152            Outcome::Some(ScopedCountResult {
153                target: self.target.clone(),
154                count: self.count,
155            })
156        }
157    }
158
159    ScopedCount {
160        target,
161        pending_scope: false,
162        inside_scope: false,
163        count: Count::default(),
164    }
165}
166
167/// Produces an [`Operation`] that searches for the current focused widget, and
168/// - if found, focuses the previous focusable widget.
169/// - if not found, focuses the last focusable widget.
170pub fn focus_previous<T>() -> impl Operation<T>
171where
172    T: Send + 'static,
173{
174    struct FocusPrevious {
175        count: Count,
176        current: usize,
177    }
178
179    impl<T> Operation<T> for FocusPrevious {
180        fn focusable(&mut self, _id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
181            if self.count.total == 0 {
182                return;
183            }
184
185            match self.count.focused {
186                None if self.current == self.count.total - 1 => state.focus(),
187                Some(0) if self.current == self.count.total - 1 => {
188                    state.focus();
189                }
190                Some(0) if self.current == 0 => state.unfocus(),
191                Some(0) => {}
192                Some(focused) if focused == self.current => state.unfocus(),
193                Some(focused) if focused - 1 == self.current => state.focus(),
194                _ => {}
195            }
196
197            self.current += 1;
198        }
199
200        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
201            operate(self);
202        }
203    }
204
205    operation::then(count(), |count| FocusPrevious { count, current: 0 })
206}
207
208/// Produces an [`Operation`] that searches for the current focused widget, and
209/// - if found, focuses the next focusable widget.
210/// - if not found, focuses the first focusable widget.
211pub fn focus_next<T>() -> impl Operation<T>
212where
213    T: Send + 'static,
214{
215    struct FocusNext {
216        count: Count,
217        current: usize,
218    }
219
220    impl<T> Operation<T> for FocusNext {
221        fn focusable(&mut self, _id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
222            match self.count.focused {
223                None if self.current == 0 => state.focus(),
224                Some(focused) if focused == self.count.total - 1 && self.current == 0 => {
225                    state.focus();
226                }
227                Some(focused) if focused == self.current => state.unfocus(),
228                Some(focused) if focused + 1 == self.current => state.focus(),
229                _ => {}
230            }
231
232            self.current += 1;
233        }
234
235        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
236            operate(self);
237        }
238    }
239
240    operation::then(count(), |count| FocusNext { count, current: 0 })
241}
242
243/// Produces an [`Operation`] that cycles focus to the previous focusable
244/// widget within the container identified by `target`.
245///
246/// Behaves like [`focus_previous`] but only considers widgets that are
247/// descendants of `target`. Widgets outside the scope are not counted and
248/// their focus state is not changed.
249pub fn focus_previous_within<T>(target: Id) -> impl Operation<T>
250where
251    T: Send + 'static,
252{
253    struct ScopedFocusPrevious {
254        target: Id,
255        pending_scope: bool,
256        inside_scope: bool,
257        count: Count,
258        current: usize,
259    }
260
261    impl<T> Operation<T> for ScopedFocusPrevious {
262        fn container(&mut self, id: Option<&Id>, _bounds: Rectangle) {
263            if id.is_some_and(|id| *id == self.target) {
264                self.pending_scope = true;
265            }
266        }
267
268        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
269            let was_inside = self.inside_scope;
270            if self.pending_scope {
271                self.inside_scope = true;
272                self.pending_scope = false;
273            }
274            operate(self);
275            self.inside_scope = was_inside;
276        }
277
278        fn focusable(&mut self, _id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
279            if !self.inside_scope {
280                return;
281            }
282
283            if self.count.total == 0 {
284                return;
285            }
286
287            match self.count.focused {
288                None if self.current == self.count.total - 1 => state.focus(),
289                Some(0) if self.current == self.count.total - 1 => {
290                    state.focus();
291                }
292                Some(0) if self.current == 0 => state.unfocus(),
293                Some(0) => {}
294                Some(focused) if focused == self.current => state.unfocus(),
295                Some(focused) if focused - 1 == self.current => state.focus(),
296                _ => {}
297            }
298
299            self.current += 1;
300        }
301    }
302
303    operation::then(scoped_count(target), |result| ScopedFocusPrevious {
304        target: result.target,
305        pending_scope: false,
306        inside_scope: false,
307        count: result.count,
308        current: 0,
309    })
310}
311
312/// Produces an [`Operation`] that cycles focus to the next focusable widget
313/// within the container identified by `target`.
314///
315/// Behaves like [`focus_next`] but only considers widgets that are
316/// descendants of `target`. Widgets outside the scope are not counted and
317/// their focus state is not changed.
318pub fn focus_next_within<T>(target: Id) -> impl Operation<T>
319where
320    T: Send + 'static,
321{
322    struct ScopedFocusNext {
323        target: Id,
324        pending_scope: bool,
325        inside_scope: bool,
326        count: Count,
327        current: usize,
328    }
329
330    impl<T> Operation<T> for ScopedFocusNext {
331        fn container(&mut self, id: Option<&Id>, _bounds: Rectangle) {
332            if id.is_some_and(|id| *id == self.target) {
333                self.pending_scope = true;
334            }
335        }
336
337        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
338            let was_inside = self.inside_scope;
339            if self.pending_scope {
340                self.inside_scope = true;
341                self.pending_scope = false;
342            }
343            operate(self);
344            self.inside_scope = was_inside;
345        }
346
347        fn focusable(&mut self, _id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
348            if !self.inside_scope {
349                return;
350            }
351
352            match self.count.focused {
353                None if self.current == 0 => state.focus(),
354                Some(focused) if focused == self.count.total - 1 && self.current == 0 => {
355                    state.focus();
356                }
357                Some(focused) if focused == self.current => state.unfocus(),
358                Some(focused) if focused + 1 == self.current => state.focus(),
359                _ => {}
360            }
361
362            self.current += 1;
363        }
364    }
365
366    operation::then(scoped_count(target), |result| ScopedFocusNext {
367        target: result.target,
368        pending_scope: false,
369        inside_scope: false,
370        count: result.count,
371        current: 0,
372    })
373}
374
375/// Produces an [`Operation`] that searches for the current focused widget
376/// and stores its ID. This ignores widgets that do not have an ID.
377pub fn find_focused() -> impl Operation<Id> {
378    struct FindFocused {
379        focused: Option<Id>,
380    }
381
382    impl Operation<Id> for FindFocused {
383        fn focusable(&mut self, id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
384            if state.is_focused() && id.is_some() {
385                self.focused = id.cloned();
386            }
387        }
388
389        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<Id>)) {
390            operate(self);
391        }
392
393        fn finish(&self) -> Outcome<Id> {
394            if let Some(id) = &self.focused {
395                Outcome::Some(id.clone())
396            } else {
397                Outcome::None
398            }
399        }
400    }
401
402    FindFocused { focused: None }
403}
404
405/// Produces an [`Operation`] that searches for the focusable widget
406/// and stores whether it is focused or not. This ignores widgets that
407/// do not have an ID.
408pub fn is_focused(target: Id) -> impl Operation<bool> {
409    struct IsFocused {
410        target: Id,
411        is_focused: Option<bool>,
412    }
413
414    impl Operation<bool> for IsFocused {
415        fn focusable(&mut self, id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
416            if id.is_some_and(|id| *id == self.target) {
417                self.is_focused = Some(state.is_focused());
418            }
419        }
420
421        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<bool>)) {
422            if self.is_focused.is_some() {
423                return;
424            }
425
426            operate(self);
427        }
428
429        fn finish(&self) -> Outcome<bool> {
430            self.is_focused.map_or(Outcome::None, Outcome::Some)
431        }
432    }
433
434    IsFocused {
435        target,
436        is_focused: None,
437    }
438}
439
440/// Positional information about a scrollable.
441#[derive(Debug, Clone, Copy)]
442struct ScrollableInfo {
443    bounds: Rectangle,
444    content_bounds: Rectangle,
445    translation: Vector,
446}
447
448impl ScrollableInfo {
449    /// Whether this scrollable can scroll further in the given direction.
450    fn can_scroll(&self, action: ScrollAction) -> bool {
451        let max_y = (self.content_bounds.height - self.bounds.height).max(0.0);
452        let max_x = (self.content_bounds.width - self.bounds.width).max(0.0);
453
454        match action {
455            ScrollAction::PageDown | ScrollAction::LineDown => self.translation.y < max_y - 0.5,
456            ScrollAction::PageUp | ScrollAction::LineUp => self.translation.y > 0.5,
457            ScrollAction::LineRight | ScrollAction::PageRight => self.translation.x < max_x - 0.5,
458            ScrollAction::LineLeft | ScrollAction::PageLeft => self.translation.x > 0.5,
459            ScrollAction::Home => self.translation.y > 0.5 || self.translation.x > 0.5,
460            ScrollAction::End => {
461                self.translation.y < max_y - 0.5 || self.translation.x < max_x - 0.5
462            }
463            ScrollAction::ShiftHome => self.translation.x > 0.5,
464            ScrollAction::ShiftEnd => self.translation.x < max_x - 0.5,
465        }
466    }
467}
468
469/// A scroll adjustment to apply to a specific scrollable.
470#[derive(Debug, Clone, Copy)]
471struct ScrollAdjustment {
472    scrollable_bounds: Rectangle,
473    offset: AbsoluteOffset<Option<f32>>,
474}
475
476/// Margin added when scrolling a focused widget into view. Ensures the
477/// widget isn't flush against the scrollable edge or hidden behind a
478/// scrollbar.
479const SCROLL_MARGIN: f32 = 12.0;
480
481/// Computes the scroll offset needed to bring `target` into view within
482/// a scrollable defined by `sb` (bounds), `content_bounds`, and `t`
483/// (current translation / scroll offset).
484///
485/// Subtracts scrollbar thickness from the visible area when scrollbars
486/// are present (detected via content overflow). Adds [`SCROLL_MARGIN`]
487/// around the target so it isn't flush against the viewport edge.
488///
489/// Returns `None` if `target` is already fully visible.
490fn compute_scroll_to(
491    sb: Rectangle,
492    content_bounds: Rectangle,
493    t: Vector,
494    target: Rectangle,
495) -> Option<AbsoluteOffset<Option<f32>>> {
496    // Convert target position to content-space coordinates.
497    // layout.bounds() in operate() returns absolute positions WITHOUT
498    // scroll translation (translation is only applied during draw).
499    // Subtracting the scrollable origin gives the content-space position.
500    let cx = target.x - sb.x;
501    let cy = target.y - sb.y;
502
503    // Compute visible viewport dimensions. When content overflows on one
504    // axis, a scrollbar appears on the OTHER axis and reduces the viewport.
505    // Default scrollbar width in iced is 10px + margin; we use a
506    // conservative estimate that covers common configurations.
507    let scrollbar_reserved = 12.0;
508
509    let has_h_scrollbar = content_bounds.width > sb.width;
510    let has_v_scrollbar = content_bounds.height > sb.height;
511
512    let visible_w = if has_v_scrollbar {
513        sb.width - scrollbar_reserved
514    } else {
515        sb.width
516    };
517    let visible_h = if has_h_scrollbar {
518        sb.height - scrollbar_reserved
519    } else {
520        sb.height
521    };
522
523    let mut offset_x = None;
524    let mut offset_y = None;
525
526    // Check if target is outside the visible viewport [t, t + visible].
527    // cx/cy is the content-space position; t is the current scroll offset.
528    if target.width >= visible_w || cx < t.x {
529        offset_x = Some((cx - SCROLL_MARGIN).max(0.0));
530    } else if cx + target.width > t.x + visible_w {
531        offset_x = Some(cx + target.width - visible_w + SCROLL_MARGIN);
532    }
533
534    if target.height >= visible_h || cy < t.y {
535        offset_y = Some((cy - SCROLL_MARGIN).max(0.0));
536    } else if cy + target.height > t.y + visible_h {
537        offset_y = Some(cy + target.height - visible_h + SCROLL_MARGIN);
538    }
539
540    if offset_x.is_some() || offset_y.is_some() {
541        Some(AbsoluteOffset {
542            x: offset_x,
543            y: offset_y,
544        })
545    } else {
546        None
547    }
548}
549
550/// Produces an [`Operation`] that scrolls the currently focused widget into
551/// view by adjusting any scrollable ancestors whose viewport does not fully
552/// contain it.
553///
554/// Uses a cascade approach for nested scrollables: the innermost scrollable
555/// targets the focused widget, each outer scrollable targets the next inner
556/// scrollable's bounds. This ensures proper framing at each nesting level.
557pub fn scroll_focused_into_view<T>() -> impl Operation<T>
558where
559    T: Send + 'static,
560{
561    struct FindFocusedScrollContext {
562        pending_scrollable: Option<ScrollableInfo>,
563        scrollable_stack: Vec<ScrollableInfo>,
564        focused_bounds: Option<Rectangle>,
565        focused_ancestors: Vec<ScrollableInfo>,
566    }
567
568    impl Operation<Vec<ScrollAdjustment>> for FindFocusedScrollContext {
569        fn scrollable(
570            &mut self,
571            _id: Option<&Id>,
572            bounds: Rectangle,
573            content_bounds: Rectangle,
574            translation: Vector,
575            _state: &mut dyn Scrollable,
576        ) {
577            self.pending_scrollable = Some(ScrollableInfo {
578                bounds,
579                content_bounds,
580                translation,
581            });
582        }
583
584        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<Vec<ScrollAdjustment>>)) {
585            if let Some(info) = self.pending_scrollable.take() {
586                self.scrollable_stack.push(info);
587            }
588
589            let depth = self.scrollable_stack.len();
590            operate(self);
591            self.scrollable_stack.truncate(depth);
592        }
593
594        fn focusable(&mut self, _id: Option<&Id>, bounds: Rectangle, state: &mut dyn Focusable) {
595            if state.is_focused() {
596                self.focused_bounds = Some(bounds);
597                self.focused_ancestors = self.scrollable_stack.clone();
598            }
599        }
600
601        fn finish(&self) -> Outcome<Vec<ScrollAdjustment>> {
602            let Some(focused) = self.focused_bounds else {
603                return Outcome::None;
604            };
605
606            let mut adjustments = Vec::new();
607
608            // Cascade: innermost scrollable targets the focused widget,
609            // each outer scrollable targets the next inner scrollable.
610            let mut target_bounds = focused;
611
612            for ancestor in self.focused_ancestors.iter().rev() {
613                if let Some(offset) = compute_scroll_to(
614                    ancestor.bounds,
615                    ancestor.content_bounds,
616                    ancestor.translation,
617                    target_bounds,
618                ) {
619                    adjustments.push(ScrollAdjustment {
620                        scrollable_bounds: ancestor.bounds,
621                        offset,
622                    });
623                }
624
625                // Outer ancestors target this scrollable, not the focused widget
626                target_bounds = ancestor.bounds;
627            }
628
629            if adjustments.is_empty() {
630                Outcome::None
631            } else {
632                Outcome::Some(adjustments)
633            }
634        }
635    }
636
637    struct ApplyScrollAdjustments {
638        adjustments: Vec<ScrollAdjustment>,
639        applied: usize,
640    }
641
642    impl<T> Operation<T> for ApplyScrollAdjustments {
643        fn scrollable(
644            &mut self,
645            _id: Option<&Id>,
646            bounds: Rectangle,
647            _content_bounds: Rectangle,
648            _translation: Vector,
649            state: &mut dyn Scrollable,
650        ) {
651            if let Some(adj) = self
652                .adjustments
653                .iter()
654                .find(|a| a.scrollable_bounds == bounds)
655            {
656                state.scroll_to(adj.offset);
657                self.applied += 1;
658            }
659        }
660
661        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
662            if self.applied < self.adjustments.len() {
663                operate(self);
664            }
665        }
666    }
667
668    operation::then(
669        FindFocusedScrollContext {
670            pending_scrollable: None,
671            scrollable_stack: Vec::new(),
672            focused_bounds: None,
673            focused_ancestors: Vec::new(),
674        },
675        |adjustments| ApplyScrollAdjustments {
676            adjustments,
677            applied: 0,
678        },
679    )
680}
681
682/// A keyboard scroll action to apply to a scrollable.
683#[derive(Debug, Clone, Copy)]
684pub enum ScrollAction {
685    /// Scroll up by viewport height.
686    PageUp,
687    /// Scroll down by viewport height.
688    PageDown,
689    /// Scroll up by one line.
690    LineUp,
691    /// Scroll down by one line.
692    LineDown,
693    /// Scroll left by one line.
694    LineLeft,
695    /// Scroll right by one line.
696    LineRight,
697    /// Scroll to the start.
698    Home,
699    /// Scroll to the end.
700    End,
701    /// Scroll left by viewport width (Shift+Page Up).
702    PageLeft,
703    /// Scroll right by viewport width (Shift+Page Down).
704    PageRight,
705    /// Scroll to horizontal start (Shift+Home).
706    ShiftHome,
707    /// Scroll to horizontal end (Shift+End).
708    ShiftEnd,
709}
710
711/// Result from phase 1 of [`scroll_focused_ancestor`].
712struct ScrollTarget {
713    scrollable_bounds: Rectangle,
714    action: ScrollAction,
715}
716
717/// Produces an [`Operation`] that scrolls a scrollable related to the
718/// currently focused widget by the given [`ScrollAction`].
719///
720/// Uses scroll bubbling: tries the innermost scrollable ancestor first,
721/// bubbles to outer ancestors if at the scroll limit, and falls back to
722/// any scrollable in the tree if no ancestor can scroll.
723pub fn scroll_focused_ancestor<T>(action: ScrollAction) -> impl Operation<T>
724where
725    T: Send + 'static,
726{
727    struct FindTarget {
728        action: ScrollAction,
729        pending_scrollable: Option<ScrollableInfo>,
730        scrollable_stack: Vec<ScrollableInfo>,
731        focused_ancestors: Option<Vec<ScrollableInfo>>,
732        all_scrollables: Vec<ScrollableInfo>,
733    }
734
735    impl Operation<ScrollTarget> for FindTarget {
736        fn scrollable(
737            &mut self,
738            _id: Option<&Id>,
739            bounds: Rectangle,
740            content_bounds: Rectangle,
741            translation: Vector,
742            _state: &mut dyn Scrollable,
743        ) {
744            let info = ScrollableInfo {
745                bounds,
746                content_bounds,
747                translation,
748            };
749
750            self.pending_scrollable = Some(info);
751            self.all_scrollables.push(info);
752        }
753
754        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<ScrollTarget>)) {
755            if let Some(info) = self.pending_scrollable.take() {
756                self.scrollable_stack.push(info);
757            }
758
759            let depth = self.scrollable_stack.len();
760            operate(self);
761            self.scrollable_stack.truncate(depth);
762        }
763
764        fn focusable(&mut self, _id: Option<&Id>, _bounds: Rectangle, state: &mut dyn Focusable) {
765            if state.is_focused() && self.focused_ancestors.is_none() {
766                self.focused_ancestors = Some(self.scrollable_stack.clone());
767            }
768        }
769
770        fn finish(&self) -> Outcome<ScrollTarget> {
771            // Try ancestors innermost to outermost (scroll bubbling)
772            if let Some(ancestors) = &self.focused_ancestors {
773                for ancestor in ancestors.iter().rev() {
774                    if ancestor.can_scroll(self.action) {
775                        return Outcome::Some(ScrollTarget {
776                            scrollable_bounds: ancestor.bounds,
777                            action: self.action,
778                        });
779                    }
780                }
781            }
782
783            // Fall back to any scrollable in the tree.
784            // Reverse search order for upward/backward actions so
785            // scrolling back through sibling scrollables is symmetric
786            // with scrolling forward.
787            let reverse = matches!(
788                self.action,
789                ScrollAction::PageUp
790                    | ScrollAction::LineUp
791                    | ScrollAction::LineLeft
792                    | ScrollAction::Home
793                    | ScrollAction::PageLeft
794                    | ScrollAction::ShiftHome
795            );
796
797            let find = |scrollables: &[ScrollableInfo]| {
798                scrollables.iter().position(|s| s.can_scroll(self.action))
799            };
800
801            let index = if reverse {
802                // Search from the end: find last scrollable that can scroll
803                self.all_scrollables
804                    .iter()
805                    .rposition(|s| s.can_scroll(self.action))
806            } else {
807                find(&self.all_scrollables)
808            };
809
810            if let Some(i) = index {
811                return Outcome::Some(ScrollTarget {
812                    scrollable_bounds: self.all_scrollables[i].bounds,
813                    action: self.action,
814                });
815            }
816
817            Outcome::None
818        }
819    }
820
821    struct ApplyScroll {
822        target: ScrollTarget,
823    }
824
825    impl<T> Operation<T> for ApplyScroll {
826        fn scrollable(
827            &mut self,
828            _id: Option<&Id>,
829            bounds: Rectangle,
830            content_bounds: Rectangle,
831            _translation: Vector,
832            state: &mut dyn Scrollable,
833        ) {
834            if bounds != self.target.scrollable_bounds {
835                return;
836            }
837
838            // Default line height for framework-level keyboard scrolling.
839            // The widget-level handler uses the renderer's text size instead.
840            let line_height = 16.0;
841
842            match self.target.action {
843                ScrollAction::PageDown => {
844                    state.scroll_by(
845                        AbsoluteOffset {
846                            x: 0.0,
847                            y: bounds.height,
848                        },
849                        bounds,
850                        content_bounds,
851                    );
852                }
853                ScrollAction::PageUp => {
854                    state.scroll_by(
855                        AbsoluteOffset {
856                            x: 0.0,
857                            y: -bounds.height,
858                        },
859                        bounds,
860                        content_bounds,
861                    );
862                }
863                ScrollAction::LineDown => {
864                    state.scroll_by(
865                        AbsoluteOffset {
866                            x: 0.0,
867                            y: line_height,
868                        },
869                        bounds,
870                        content_bounds,
871                    );
872                }
873                ScrollAction::LineUp => {
874                    state.scroll_by(
875                        AbsoluteOffset {
876                            x: 0.0,
877                            y: -line_height,
878                        },
879                        bounds,
880                        content_bounds,
881                    );
882                }
883                ScrollAction::LineRight => {
884                    state.scroll_by(
885                        AbsoluteOffset {
886                            x: line_height,
887                            y: 0.0,
888                        },
889                        bounds,
890                        content_bounds,
891                    );
892                }
893                ScrollAction::LineLeft => {
894                    state.scroll_by(
895                        AbsoluteOffset {
896                            x: -line_height,
897                            y: 0.0,
898                        },
899                        bounds,
900                        content_bounds,
901                    );
902                }
903                ScrollAction::Home => {
904                    let overflows_x = content_bounds.width > bounds.width;
905                    let overflows_y = content_bounds.height > bounds.height;
906
907                    state.snap_to(RelativeOffset {
908                        x: if overflows_x && !overflows_y {
909                            Some(0.0)
910                        } else {
911                            None
912                        },
913                        y: if overflows_y || !overflows_x {
914                            Some(0.0)
915                        } else {
916                            None
917                        },
918                    });
919                }
920                ScrollAction::End => {
921                    let overflows_x = content_bounds.width > bounds.width;
922                    let overflows_y = content_bounds.height > bounds.height;
923
924                    state.snap_to(RelativeOffset {
925                        x: if overflows_x && !overflows_y {
926                            Some(1.0)
927                        } else {
928                            None
929                        },
930                        y: if overflows_y || !overflows_x {
931                            Some(1.0)
932                        } else {
933                            None
934                        },
935                    });
936                }
937                ScrollAction::PageRight => {
938                    state.scroll_by(
939                        AbsoluteOffset {
940                            x: bounds.width,
941                            y: 0.0,
942                        },
943                        bounds,
944                        content_bounds,
945                    );
946                }
947                ScrollAction::PageLeft => {
948                    state.scroll_by(
949                        AbsoluteOffset {
950                            x: -bounds.width,
951                            y: 0.0,
952                        },
953                        bounds,
954                        content_bounds,
955                    );
956                }
957                ScrollAction::ShiftHome => {
958                    state.snap_to(RelativeOffset {
959                        x: Some(0.0),
960                        y: None,
961                    });
962                }
963                ScrollAction::ShiftEnd => {
964                    state.snap_to(RelativeOffset {
965                        x: Some(1.0),
966                        y: None,
967                    });
968                }
969            }
970        }
971
972        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
973            operate(self);
974        }
975    }
976
977    operation::then(
978        FindTarget {
979            action,
980            pending_scrollable: None,
981            scrollable_stack: Vec::new(),
982            focused_ancestors: None,
983            all_scrollables: Vec::new(),
984        },
985        |target| ApplyScroll { target },
986    )
987}
988
989/// The result of a successful mnemonic lookup.
990#[derive(Debug, Clone)]
991pub struct MnemonicTarget {
992    /// The bounding rectangle of the matched widget.
993    pub bounds: Rectangle,
994    /// The widget [`Id`], if it has one.
995    pub id: Option<Id>,
996}
997
998/// Produces an [`Operation`] that walks the widget tree and finds the
999/// first enabled widget whose [`mnemonic`] matches `key`
1000/// (case-insensitive).
1001///
1002/// [`mnemonic`]: Accessible::mnemonic
1003pub fn find_mnemonic(key: char) -> impl Operation<MnemonicTarget> {
1004    struct FindMnemonic {
1005        key: char,
1006        found: Option<MnemonicTarget>,
1007    }
1008
1009    impl Operation<MnemonicTarget> for FindMnemonic {
1010        fn accessible(&mut self, id: Option<&Id>, bounds: Rectangle, accessible: &Accessible<'_>) {
1011            if self.found.is_some() {
1012                return;
1013            }
1014
1015            if let Some(mnemonic) = accessible.mnemonic
1016                && mnemonic.eq_ignore_ascii_case(&self.key)
1017                && !accessible.disabled
1018            {
1019                self.found = Some(MnemonicTarget {
1020                    bounds,
1021                    id: id.cloned(),
1022                });
1023            }
1024        }
1025
1026        fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<MnemonicTarget>)) {
1027            if self.found.is_none() {
1028                operate(self);
1029            }
1030        }
1031
1032        fn finish(&self) -> Outcome<MnemonicTarget> {
1033            match &self.found {
1034                Some(target) => Outcome::Some(target.clone()),
1035                None => Outcome::None,
1036            }
1037        }
1038    }
1039
1040    FindMnemonic { key, found: None }
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046    use crate::{Point, Size};
1047
1048    fn rect(x: f32, y: f32, w: f32, h: f32) -> Rectangle {
1049        Rectangle::new(Point::new(x, y), Size::new(w, h))
1050    }
1051
1052    fn vec2(x: f32, y: f32) -> Vector {
1053        Vector::new(x, y)
1054    }
1055
1056    // All tests use content-space coordinates for target bounds, matching
1057    // what the real widget tree provides: layout.bounds() in operate()
1058    // returns positions WITHOUT scroll translation applied.
1059
1060    #[test]
1061    fn both_scrollbars_reduce_visible_area() {
1062        // Content overflows both axes -> both scrollbars present.
1063        // Target at right edge: fits in full viewport but is hidden
1064        // behind the vertical scrollbar's reserved space.
1065        let sb = rect(0.0, 0.0, 400.0, 300.0);
1066        let content = rect(0.0, 0.0, 600.0, 800.0);
1067
1068        // 400 - 12 (scrollbar) - 50 (target width) + 1 = just past the edge
1069        let target = rect(339.0, 50.0, 50.0, 40.0);
1070        let result = compute_scroll_to(sb, content, vec2(0.0, 0.0), target);
1071
1072        assert!(
1073            result.is_some(),
1074            "should scroll when target is behind scrollbar"
1075        );
1076        assert!(result.unwrap().x.expect("horizontal scroll") > 0.0);
1077    }
1078
1079    #[test]
1080    fn sequential_tab_forward_and_backward() {
1081        // Both-direction scrollable (400x200) with content (600x800).
1082        // Both scrollbars present. Buttons at content y=10, y=300, y=600.
1083        //
1084        // Target bounds are content-space positions (layout.bounds()
1085        // in operate() does NOT include scroll translation).
1086
1087        let sb = rect(0.0, 0.0, 400.0, 200.0);
1088        let content = rect(0.0, 0.0, 600.0, 800.0);
1089
1090        let btn1 = rect(50.0, 10.0, 100.0, 40.0);
1091        let btn2 = rect(50.0, 300.0, 100.0, 40.0);
1092        let btn3 = rect(50.0, 600.0, 100.0, 40.0);
1093
1094        // Tab to btn1 at scroll=0: content y=10 is within [0, 188]
1095        let r1 = compute_scroll_to(sb, content, vec2(0.0, 0.0), btn1);
1096        assert!(r1.is_none(), "btn1 should be visible at scroll=0");
1097
1098        // Tab to btn2: content y=300 is below [0, 188]
1099        let r2 = compute_scroll_to(sb, content, vec2(0.0, 0.0), btn2).unwrap();
1100        let scroll_y = r2.y.expect("should scroll to btn2");
1101        // trailing: 300 + 40 - 188 + 12 = 164
1102        assert!(
1103            (scroll_y - 164.0).abs() < 0.1,
1104            "btn2: expected ~164, got {scroll_y}"
1105        );
1106
1107        // Tab to btn3: content y=600 is below [164, 352]
1108        let r3 = compute_scroll_to(sb, content, vec2(0.0, scroll_y), btn3).unwrap();
1109        let scroll_y = r3.y.expect("should scroll to btn3");
1110        // trailing: 600 + 40 - 188 + 12 = 464
1111        assert!(
1112            (scroll_y - 464.0).abs() < 0.1,
1113            "btn3: expected ~464, got {scroll_y}"
1114        );
1115
1116        // Shift+Tab back to btn2: content y=300 is ABOVE [464, 652]
1117        let r4 = compute_scroll_to(sb, content, vec2(0.0, scroll_y), btn2).unwrap();
1118        let scroll_y = r4.y.expect("should scroll back to btn2");
1119        // leading: (300 - 12).max(0) = 288
1120        assert!(
1121            (scroll_y - 288.0).abs() < 0.1,
1122            "back to btn2: expected ~288, got {scroll_y}"
1123        );
1124
1125        // Shift+Tab back to btn1: content y=10 is ABOVE [288, 476]
1126        let r5 = compute_scroll_to(sb, content, vec2(0.0, scroll_y), btn1).unwrap();
1127        let scroll_y = r5.y.expect("should scroll back to btn1");
1128        // leading: (10 - 12).max(0) = 0
1129        assert!(
1130            (scroll_y - 0.0).abs() < 0.1,
1131            "back to btn1: expected ~0, got {scroll_y}"
1132        );
1133    }
1134}