git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

src/input_handler.rs (30.8K)

  1 use crate::SystemInterface;
  2 use cce_settings::app::AppAction;
  3 use cce_settings::pages::Page;
  4 use cce_ui::widget::WidgetHost;
  5 
  6 /// App shortcuts, resolved once per process from input.kdl
  7 /// (`cce-system-interface` domain → `cce-ui` domain).
  8 struct SettingsKeys {
  9     open_search: String,
 10     focus_next: String,
 11     focus_prev: String,
 12     focus_ascend: String,
 13     focus_descend: String,
 14     page_next: String,
 15     page_prev: String,
 16 }
 17 
 18 fn settings_keys() -> &'static SettingsKeys {
 19     static KEYS: std::sync::OnceLock<SettingsKeys> = std::sync::OnceLock::new();
 20     KEYS.get_or_init(|| {
 21         let get = cce_ui::input::app_chord;
 22         SettingsKeys {
 23             open_search: get("open_search", "/"),
 24             focus_next: get("focus_next", "ctrl+j"),
 25             focus_prev: get("focus_prev", "ctrl+k"),
 26             focus_ascend: get("focus_ascend", "ctrl+u"),
 27             focus_descend: get("focus_descend", "ctrl+i"),
 28             page_next: get("page_next", "pagedown"),
 29             page_prev: get("page_prev", "pageup"),
 30         }
 31     })
 32 }
 33 
 34 /// `CCE_HOVER_DEBUG`, resolved once. The move handler runs ~60/s, so the old
 35 /// per-event `std::env::var` (which allocates and scans the environment) had no
 36 /// business being on this path.
 37 fn hover_debug() -> bool {
 38     static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
 39     *FLAG.get_or_init(|| std::env::var_os("CCE_HOVER_DEBUG").is_some())
 40 }
 41 
 42 impl SystemInterface {
 43 
 44     pub(crate) fn handle_cursor_moved(&mut self, x: f32, y: f32) -> bool {
 45         // Runs once per pointer motion event — ~60/s while the mouse moves — and
 46         // the loop trace puts a single dispatch of ~60 of them at ~839ms, i.e.
 47         // ~14ms each. Time the stages to find which scales with the 1359-row list.
 48         let hov_t0 = if hover_debug() {
 49             Some(std::time::Instant::now())
 50         } else {
 51             None
 52         };
 53         self.cursor_x = x;
 54         self.cursor_y = y;
 55         let s = 1.0f32;
 56         let lx_no_scroll = x / s;
 57         let ly_no_scroll = y / s;
 58 
 59         let sh_logical = self.height as f32 / s;
 60         if self.search_open && ly_no_scroll >= (sh_logical - 42.0) {
 61             // Routed (6bd): chrome coords, no scroll offset — same as the direct call.
 62             let ev = cce_ui::widget::Event::PointerMove { x: lx_no_scroll, y: ly_no_scroll, local_x: lx_no_scroll, local_y: ly_no_scroll };
 63             let sb = self.search_box.id();
 64             if self.ui_context.propagate_event(&ev, sb) {
 65                 self.needs_rebuild = true;
 66             }
 67             return true;
 68         }
 69         
 70         if cce_ui::widget::context_menu::is_visible() {
 71             if cce_ui::widget::context_menu::cursor_moved(lx_no_scroll, ly_no_scroll) {
 72                 self.needs_rebuild = true;
 73                 return true;
 74             }
 75             return false;
 76         }
 77 
 78         let lx = self.cursor_x / s;
 79         let ly = self.cursor_y / s + self.scroll_y;
 80         cce_ui::widget::hover_animation::set_cursor_pos(lx, ly_no_scroll);
 81         let mut changed = false;
 82         {
 83             let ev = cce_ui::widget::Event::PointerMove { x: lx_no_scroll, y: ly_no_scroll, local_x: lx_no_scroll, local_y: ly_no_scroll };
 84             let dd = self.page_dropdown.id();
 85             if self.ui_context.propagate_event(&ev, dd) {
 86                 changed = true;
 87             }
 88         }
 89         let t_dropdown = hov_t0.map(|s| s.elapsed().as_micros());
 90 
 91         // Drag updates are high-priority overrides
 92         let mut drag_handled = false;
 93         let mut drag_actions = Vec::new();
 94         if self.app.get_current_page_mut().handle_pointer_move(lx, ly, &mut drag_actions, &mut self.ui_context) {
 95             drag_handled = true;
 96             changed = true;
 97             for action in drag_actions {
 98                 self.handle_action(&action);
 99             }
100         }
101 
102         let t_pagemove = hov_t0.map(|s| s.elapsed().as_micros());
103 
104         if !drag_handled {
105             let event = cce_ui::widget::Event::PointerMove { x: lx, y: ly, local_x: lx, local_y: ly };
106             if self.dispatch_page_event(&event) {
107                 changed = true;
108             }
109             // Routed drags (6bd): a DragUpdate delivered inside the dispatch surfaces as
110             // widget take_change — drain and act per move, as the old page drag hooks did.
111             let mut move_actions = Vec::new();
112             self.propagate_widget_changes(&mut move_actions);
113             if !move_actions.is_empty() {
114                 changed = true;
115                 for action in move_actions {
116                     self.handle_action(&action);
117                 }
118             }
119         }
120 
121         let t_dispatch = hov_t0.map(|s| s.elapsed().as_micros());
122 
123         let phys_x = x;
124         let phys_y = y;
125 
126         // Check if cursor hover state changed on any widget
127         for w in &self.widgets {
128             let is_hovered = phys_x >= w.x && phys_x <= w.x + w.w && phys_y >= w.y && phys_y <= w.y + w.h;
129             if w.hovering != is_hovered {
130                 changed = true;
131                 break;
132             }
133         }
134 
135         if changed {
136             self.needs_rebuild = true;
137         }
138         if let (Some(s), Some(dd), Some(pm), Some(dp)) = (hov_t0, t_dropdown, t_pagemove, t_dispatch) {
139             let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
140             eprintln!(
141                 "[hover] t={} move ({:.0},{:.0}) changed={} total={}us dropdown={}us page={}us dispatch={}us widgets={}us n_widgets={}",
142                 t, self.cursor_x, self.cursor_y, changed,
143                 s.elapsed().as_micros(), dd, pm - dd, dp - pm, s.elapsed().as_micros() - dp,
144                 self.widgets.len()
145             );
146         }
147         changed
148     }
149 
150     pub(crate) fn handle_mouse_input_internal(&mut self, button: cce_ui::widget::MouseButton, state: cce_ui::widget::ElementState) -> bool {
151         let s = 1.0f32;
152         let lx_no_scroll = self.cursor_x / s;
153         let ly_no_scroll = self.cursor_y / s;
154 
155         let sh_logical = self.height as f32 / s;
156         if self.search_open && ly_no_scroll >= (sh_logical - 42.0) {
157             let ev = cce_ui::widget::Event::MouseButton { button, state, x: lx_no_scroll, y: ly_no_scroll, local_x: lx_no_scroll, local_y: ly_no_scroll };
158             let sb = self.search_box.id();
159             if self.ui_context.propagate_event(&ev, sb) {
160                 self.needs_rebuild = true;
161             }
162             return true;
163         }
164 
165         if self.search_open && state == cce_ui::widget::ElementState::Pressed && ly_no_scroll < (sh_logical - 42.0) {
166             self.search_open = false;
167             self.search_query.clear();
168             self.ui_context[self.search_box].set_value_string("");
169             self.ui_context.clear_focus();
170             self.needs_rebuild = true;
171         }
172 
173         // CSD Close Button Interaction removed
174 
175         if cce_ui::widget::context_menu::is_visible() {
176             if cce_ui::widget::context_menu::mouse_input(button, state, lx_no_scroll, ly_no_scroll, Some(&mut self.ui_context)) {
177                 let mut actions = Vec::new();
178                 self.propagate_widget_changes(&mut actions);
179                 for action in actions {
180                     self.handle_action(&action);
181                 }
182                 self.needs_rebuild = true;
183                 return true;
184             }
185         }
186 
187         let dd_ev = cce_ui::widget::Event::MouseButton { button, state, x: lx_no_scroll, y: ly_no_scroll, local_x: lx_no_scroll, local_y: ly_no_scroll };
188         let dd_root = self.page_dropdown.id();
189         if self.ui_context.propagate_event(&dd_ev, dd_root) {
190             if self.ui_context[self.page_dropdown].take_change() {
191                 let idx = self.ui_context[self.page_dropdown].selected;
192                 if idx < Page::ALL.len() {
193                     self.ui_context.clear_focus();
194                     self.focused_section = None;
195                     let new_page = Page::ALL[idx];
196                     self.app.current_page = new_page;
197                     self.current_page_shared.send_replace(idx as u8);
198                     self.scroll_y = 0.0;
199                 }
200             }
201             self.needs_rebuild = true;
202             return true;
203         }
204 
205         if button != cce_ui::widget::MouseButton::Left && button != cce_ui::widget::MouseButton::Right { return false; }
206 
207         let phys_x = self.cursor_x;
208         let phys_y = self.cursor_y;
209 
210         let mut button_handled = false;
211         let mut clicked_action = None;
212         let btn_ev = cce_ui::widget::Event::MouseButton { button, state, x: phys_x, y: phys_y, local_x: phys_x, local_y: phys_y };
213         let btn_roots: Vec<_> = self.page_buttons[self.scrollable_buttons_start_idx..].iter().map(|(b, _)| b.id()).collect();
214         for root in btn_roots {
215             if self.ui_context.propagate_event(&btn_ev, root) {
216                 button_handled = true;
217             }
218         }
219         for (btn, action) in &self.page_buttons[self.scrollable_buttons_start_idx..] {
220             if self.ui_context[*btn].take_click() {
221                 clicked_action = Some(action.clone());
222                 button_handled = true;
223                 self.needs_rebuild = true;
224             }
225         }
226         if let Some(action) = clicked_action {
227             self.handle_action(&action);
228         }
229         if button_handled {
230             return true;
231         }
232         let mut actions = Vec::new();
233         if button == cce_ui::widget::MouseButton::Left && state == cce_ui::widget::ElementState::Released {
234             if self.app.get_current_page_mut().handle_pointer_up(&mut self.ui_context) {
235                 self.needs_rebuild = true;
236             }
237         }
238 
239         let lx = self.cursor_x / s;
240         let ly = self.cursor_y / s + self.scroll_y;
241         let event = cce_ui::widget::Event::MouseButton { button, state, x: lx, y: ly, local_x: lx, local_y: ly };
242         self.dispatch_page_event(&event);
243 
244         if state == cce_ui::widget::ElementState::Pressed {
245             self.app.get_current_page_mut().handle_pointer_down(lx, ly, &mut self.ui_context);
246         }
247 
248         self.propagate_widget_changes(&mut actions);
249 
250         // Single-slot focus (Phase 6w): if a widget click took the window's focus, the
251         // section-level highlight yields.
252         if state == cce_ui::widget::ElementState::Pressed && self.ui_context.has_focus() {
253             self.focused_section = None;
254         }
255 
256         for a in &actions {
257             self.handle_action(a);
258         }
259         if !actions.is_empty() {
260             self.needs_rebuild = true;
261             return true;
262         }
263         self.needs_rebuild = true;
264         true
265     }
266 
267     pub(crate) fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
268         self.app.get_current_page_mut().propagate_widget_changes(actions, &mut self.ui_context);
269     }
270 
271     pub(crate) fn handle_mouse_wheel_internal(&mut self, delta: &cce_ui::widget::MouseScrollDelta, px: f32, py: f32) -> bool {
272         self.scroll_logs.push(format!(
273             "handle_mouse_wheel_internal: px={}, py={}, delta={:?}, sidebar_w={}",
274             px, py, delta, self.sidebar_width
275         ));
276         let s = 1.0f32;
277         if px >= self.sidebar_width * s {
278             let lx = px / s;
279             let ly = py / s + self.scroll_y;
280             
281 
282 
283             let event = cce_ui::widget::Event::MouseWheel { delta: delta.clone(), x: lx, y: ly, local_x: lx, local_y: ly };
284             // Page dissolved (6u): one dispatch path for every page — scrollbar, then
285             // sections, then the dissolved inner lists (the app-owned ScrollRegions,
286             // hit-scoped like the old inner ScrollBoxes), then the manual page scroll
287             // below as the fallback, exactly as the non-System pages worked.
288             let mut handled = self.dispatch_page_event(&event);
289             if !handled {
290                 handled = self.app.get_current_page_mut().handle_mouse_wheel(delta, lx, ly);
291             }
292 
293             let mut actions = Vec::new();
294             self.propagate_widget_changes(&mut actions);
295             for a in &actions {
296                 self.handle_action(a);
297             }
298             if !actions.is_empty() {
299                 self.needs_rebuild = true;
300                 return true;
301             }
302             if handled {
303                 self.needs_rebuild = true;
304                 return true;
305             }
306 
307             // The whole-page scroll: a wheel notch moves the motion's target
308             // and `tick_page_scroll` glides the page there; a trackpad finger
309             // moves it now. Either way the bar raises in the same frame.
310             use cce_ui::widget::{Bounds, LINE_PX};
311             self.page_scroll_motion.reconcile(0.0, self.scroll_y);
312             let moved = self.page_scroll_motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll_y));
313             if moved {
314                 self.shift_page_to(self.page_scroll_motion.y.pos());
315                 self.ui_context[self.page_scroll_bar].on_scroll();
316                 return true;
317             }
318         }
319         false
320     }
321 
322     /// Move the page to `new_scroll_y` WITHOUT a rebuild — the wheel fast
323     /// path: the cached widget/text/button geometry shifts in place by the
324     /// delta from the current drawn offset, and the scrollbar thumb follows
325     /// (display_list emits the bar fresh each frame from this state). False
326     /// when the offset did not actually change.
327     pub(crate) fn shift_page_to(&mut self, new_scroll_y: f32) -> bool {
328         let actual_dy = new_scroll_y - self.scroll_y;
329         if actual_dy.abs() <= 0.01 {
330             return false;
331         }
332         self.scroll_y = new_scroll_y;
333         for w in &mut self.widgets[self.scrollable_widgets_start_idx..] {
334             w.y -= actual_dy;
335         }
336         for (_, _, _, ty, _, _, bounds) in &mut self.texts[self.scrollable_text_items_start_idx..] {
337             *ty -= actual_dy;
338             if let Some(ref mut b) = bounds {
339                 b[1] -= actual_dy;
340                 b[3] -= actual_dy;
341             }
342         }
343         for (btn, _) in &self.page_buttons[self.scrollable_buttons_start_idx..] {
344             self.ui_context[*btn].base_mut().y -= actual_dy;
345         }
346         // The glyphs ride with the page too — a button's icon face and the
347         // page's own icons — or they stand still while their rows scroll
348         // past them until something rebuilds.
349         for icon in &mut self.page_icon_images {
350             icon.y -= actual_dy;
351             if let Some(c) = icon.clip.as_mut() {
352                 c[1] -= actual_dy;
353             }
354         }
355         self.last_scroll_y = self.scroll_y;
356         self.ui_context[self.page_scroll_bar].scroll_y = self.scroll_y;
357         true
358     }
359 
360     /// The current page's event-dispatch roots (Phase 6w — SectionContainer dissolved):
361     /// the pages' widgets themselves, flattened in the legacy propagate order (sections
362     /// last-to-first, and within a section the container children were visited in
363     /// reverse link order).
364     pub(crate) fn page_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
365         self.app
366             .get_current_page_mut()
367             .section_widgets()
368             .into_iter()
369             .rev()
370             .flat_map(|group| group.into_iter().rev())
371             .collect()
372     }
373 
374     /// Replicates the dissolved Page's event routing: the out-of-bounds gate (events whose
375     /// screen position is outside the page viewport never reach page widgets, unless the
376     /// scrollbar is mid-drag), then the legacy child order reversed — the scrollbar first
377     /// (with Page's y-unshift, its coords are screen-space while the event carries the
378     /// scroll offset), then the sections last-to-first. PointerMove visits everything
379     /// (hover bookkeeping); other events stop at the first handler.
380     pub(crate) fn dispatch_page_event(&mut self, event: &cce_ui::widget::Event) -> bool {
381         use cce_ui::widget::Event;
382         // A page switch takes effect immediately, but widget registration is a side effect
383         // of the view pass and `clear_hierarchy` wipes the registry every rebuild — so
384         // until the new page has been laid out once, none of its `section_widgets()` ids
385         // resolve. Dispatching anyway can't reach a widget; it only emits one
386         // "unregistered/stale root" warning per root (the pages that implement
387         // `register_extra_dispatch_roots` were incidentally immune, which is why only 9 of
388         // the 14 spammed). Suppress across the gap instead — the rebuild is one frame away.
389         if self.laid_out_page != Some(self.app.current_page) {
390             return false;
391         }
392         let is_pointer_event = matches!(
393             event,
394             Event::PointerMove { .. } | Event::MouseButton { .. } | Event::MouseWheel { .. }
395         );
396         if is_pointer_event && !self.ui_context[self.page_scroll_bar].dragging && !self.ui_context.is_dragging {
397             if let Event::PointerMove { x, y, .. }
398             | Event::MouseButton { x, y, .. }
399             | Event::MouseWheel { x, y, .. } = event
400             {
401                 let rx = self.sidebar_width;
402                 let ry = self.header_height;
403                 let rw = self.width as f32 - self.sidebar_width;
404                 let mut rh = self.height as f32 - self.header_height - self.status_height;
405                 if self.search_open {
406                     rh -= 42.0;
407                 }
408                 let screen_y = *y - self.scroll_y;
409                 if *x < rx || *x > rx + rw || screen_y < ry || screen_y > ry + rh {
410                     return false;
411                 }
412             }
413         }
414 
415         let is_pointer_move = matches!(event, Event::PointerMove { .. });
416         let mut handled = false;
417 
418         // Open-popover priority: a press inside an open menu goes to its owner
419         // BEFORE positional dispatch — the roots iterate in z-ignorant order,
420         // so a closed sibling whose trigger band sits under the overlaying
421         // popover would otherwise claim the point first (the Default Apps
422         // Terminal dropdown's menu covers the Images row's trigger).
423         if let Event::MouseButton { x, y, .. } = event {
424             if let Some(owner) = self.ui_context.popover_owner_at(*x, *y) {
425                 if self.ui_context.propagate_event(event, owner) {
426                     return true;
427                 }
428             }
429         }
430 
431         if self.ui_context[self.page_scroll_bar].content_h > self.ui_context[self.page_scroll_bar].viewport_h {
432             let mut sb_event = event.clone();
433             if let Event::PointerMove { y, local_y, .. }
434             | Event::MouseButton { y, local_y, .. }
435             | Event::MouseWheel { y, local_y, .. } = &mut sb_event
436             {
437                 *y -= self.scroll_y;
438                 *local_y -= self.scroll_y;
439             }
440             let sb_root = self.page_scroll_bar.id();
441             if self.ui_context.propagate_event(&sb_event, sb_root) {
442                 if !is_pointer_move {
443                     return true;
444                 }
445                 handled = true;
446             }
447         }
448 
449         // Row widgets of the dissolved lists (Phase 6v): they used to receive events as
450         // ScrollBox children under the sections; now they dispatch directly. Adapted's
451         // hit-gate keeps missed presses falling through, so order vs the sections only
452         // matters for overlap — and the rows sit inside list frames the sections never
453         // claim. Collected fresh per event: the item Vecs get rebuilt across frames.
454         let dbg_t0 = if hover_debug() && is_pointer_move {
455             Some(std::time::Instant::now())
456         } else {
457             None
458         };
459         let extra_roots = {
460             let page = self.app.get_current_page_mut();
461             page.register_extra_dispatch_roots(&mut self.ui_context);
462             page.extra_dispatch_roots()
463         };
464         let t_register = dbg_t0.map(|s| s.elapsed().as_micros());
465         let n_roots = extra_roots.len();
466         for root in extra_roots {
467             if self.ui_context.propagate_event(event, root) {
468                 if !is_pointer_move {
469                     return true;
470                 }
471                 handled = true;
472             }
473         }
474         if let (Some(s), Some(reg)) = (dbg_t0, t_register) {
475             let total = s.elapsed().as_micros();
476             eprintln!(
477                 "[hover]   extra_roots n={} register+collect={}us propagate={}us",
478                 n_roots, reg, total - reg
479             );
480         }
481 
482         let roots = self.page_dispatch_roots();
483         for root in roots {
484             if self.ui_context.propagate_event(event, root) {
485                 if !is_pointer_move {
486                     return true;
487                 }
488                 handled = true;
489             }
490         }
491         handled
492     }
493 
494     pub(crate) fn handle_key_input_internal(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
495         if cce_ui::widget::context_menu::is_visible() {
496             if event.state == cce_ui::widget::ElementState::Pressed
497                 && event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Escape)
498             {
499                 cce_ui::widget::context_menu::hide();
500                 self.needs_rebuild = true;
501                 return true;
502             }
503         }
504 
505         if self.search_open {
506             if event.state == cce_ui::widget::ElementState::Pressed
507                 && event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Escape)
508             {
509                 self.search_open = false;
510                 self.search_query.clear();
511                 self.ui_context[self.search_box].set_value_string("");
512                 self.ui_context.clear_focus();
513                 self.needs_rebuild = true;
514                 return true;
515             }
516         }
517 
518         let is_text_box_focused = self
519             .ui_context
520             .focused_widget
521             .and_then(|id| self.ui_context.get_widget(id))
522             .is_some_and(|focused| focused.as_any().is::<cce_ui::widget::input::TextBox>());
523 
524         if !self.search_open && !is_text_box_focused {
525             if event.state == cce_ui::widget::ElementState::Pressed && !event.repeat {
526                 if cce_ui::widget::match_key_shortcut(event, &settings_keys().open_search) {
527                     self.search_open = true;
528                     self.ui_context[self.search_box].set_value_string("");
529                     self.search_query.clear();
530                     cce_ui::widget::WidgetHost::focus(&mut self.ui_context[self.search_box]);
531                     self.ui_context.set_focused_id(self.search_box.id());
532                     self.needs_rebuild = true;
533                     return true;
534                 }
535 
536                 let next = cce_ui::widget::match_key_shortcut(event, &settings_keys().page_next);
537                 let prev = cce_ui::widget::match_key_shortcut(event, &settings_keys().page_prev);
538                 if next || prev {
539                     let n = Page::ALL.len();
540                     let cur = Page::ALL.iter().position(|&p| p == self.app.current_page).unwrap_or(0);
541                     let idx = if next { (cur + 1) % n } else { (cur + n - 1) % n };
542                     self.ui_context.clear_focus();
543                     self.focused_section = None;
544                     self.app.current_page = Page::ALL[idx];
545                     self.current_page_shared.send_replace(idx as u8);
546                     self.scroll_y = 0.0;
547                     self.needs_rebuild = true;
548                     return true;
549                 }
550             }
551         }
552 
553         if event.state == cce_ui::widget::ElementState::Pressed && !event.repeat {
554             let keys = settings_keys();
555             let m = |chord: &str| cce_ui::widget::match_key_shortcut(event, chord);
556             let (forward, backward, ascend, descend) = (
557                 m(&keys.focus_next),
558                 m(&keys.focus_prev),
559                 m(&keys.focus_ascend),
560                 m(&keys.focus_descend),
561             );
562             if forward || backward || ascend || descend {
563                 // SectionContainer dissolved (Phase 6w): section-level focus is the
564                 // app-side index, widget-level focus is the window's (`UiContext`),
565                 // and the two are single-slot (as when sections and widgets shared the
566                 // one FOCUSED_WIDGET). Nav within a section walks the page's widget
567                 // group where the container's child list used to be walked.
568                 if self.ui_context.has_focus() {
569                     // (`focus::navigate_focus` is gone — it walked an empty dummy context
570                     // and always returned false here; the section machinery below is the
571                     // real ctrl-nav.)
572                     let groups = self.app.get_current_page_mut().section_widgets();
573                     let focused_pos = groups.iter().enumerate().find_map(|(si, g)| {
574                         g.iter()
575                             .position(|&id| self.ui_context.is_focused_id(id))
576                             .map(|wi| (si, wi))
577                     });
578                     if let Some((si, wi)) = focused_pos {
579                         if forward || backward {
580                             let group = &groups[si];
581                             let next = if forward {
582                                 (wi + 1) % group.len()
583                             } else if wi == 0 {
584                                 group.len() - 1
585                             } else {
586                                 wi - 1
587                             };
588                             let next_id = group[next];
589                             // The context tells the widget (FocusIn), as the Tab walk does.
590                             self.ui_context.set_focused_id(next_id);
591                             self.needs_rebuild = true;
592                             return true;
593                         }
594                         if ascend {
595                             self.ui_context.clear_focus();
596                             self.focused_section = Some(si);
597                             self.needs_rebuild = true;
598                             return true;
599                         }
600                     }
601                 } else if let Some(idx) = self.focused_section {
602                     let groups = self.app.get_current_page_mut().section_widgets();
603                     if !groups.is_empty() {
604                         let idx = idx.min(groups.len() - 1);
605                         if forward || backward {
606                             self.focused_section = Some(if forward {
607                                 (idx + 1) % groups.len()
608                             } else if idx == 0 {
609                                 groups.len() - 1
610                             } else {
611                                 idx - 1
612                             });
613                             self.needs_rebuild = true;
614                             return true;
615                         }
616                         if descend {
617                             if let Some(&first) = groups[idx].first() {
618                                 self.ui_context.set_focused_id(first);
619                                 self.focused_section = None;
620                                 self.needs_rebuild = true;
621                                 return true;
622                             }
623                         }
624                     }
625                 } else {
626                     // Entry point: focus the first section.
627                     if !self.app.get_current_page_mut().section_widgets().is_empty() {
628                         self.focused_section = Some(0);
629                         self.needs_rebuild = true;
630                         return true;
631                     }
632                 }
633             }
634         }
635 
636         let event_wrapper = cce_ui::widget::Event::KeyInput(event.clone());
637         let mut key_handled = false;
638         if self.dispatch_page_event(&event_wrapper) {
639             let mut actions = Vec::new();
640             self.propagate_widget_changes(&mut actions);
641             for a in actions {
642                 self.handle_action(&a);
643             }
644             self.needs_rebuild = true;
645             key_handled = true;
646         }
647 
648         // The dissolved inner lists' keyboard scrolling (hover/focus-scoped, like the old
649         // ScrollBox::keyboard_input) — before the whole-page fallback so a hovered list
650         // takes the scroll keys first.
651         if !key_handled && self.app.get_current_page_mut().handle_key_input(event) {
652             self.needs_rebuild = true;
653             key_handled = true;
654         }
655 
656         // The dissolved Page's keyboard scrolling: when nothing in the page tree took the
657         // key and the cursor is over the page viewport, scroll keys move the page.
658         if !key_handled && event.state == cce_ui::widget::ElementState::Pressed && self.max_scroll_y > 0.0 {
659             let over_page = {
660                 let ry = self.header_height;
661                 let mut rh = self.height as f32 - self.header_height - self.status_height;
662                 if self.search_open {
663                     rh -= 42.0;
664                 }
665                 self.cursor_x >= self.sidebar_width
666                     && self.cursor_y >= ry
667                     && self.cursor_y <= ry + rh
668             };
669             if over_page {
670                 use cce_ui::widget::{Bounds, Key, NamedKey, LINE_PX};
671                 // Arrow steps ride the same glide as wheel notches (a held
672                 // key accumulates into one motion); Home/End glide to the
673                 // absolute target. `tick_page_scroll` carries the page there.
674                 let s = cce_ui::widget::scroll_motion::scroll_settings();
675                 let b = Bounds::max(self.max_scroll_y);
676                 self.page_scroll_motion.reconcile(0.0, self.scroll_y);
677                 let moved = match &event.logical_key {
678                     Key::Named(NamedKey::ArrowDown) => self.page_scroll_motion.y.wheel(LINE_PX, b, &s),
679                     Key::Named(NamedKey::ArrowUp) => self.page_scroll_motion.y.wheel(-LINE_PX, b, &s),
680                     Key::Named(NamedKey::Home) => self.page_scroll_motion.y.scroll_to(0.0, b, &s),
681                     Key::Named(NamedKey::End) => self.page_scroll_motion.y.scroll_to(self.max_scroll_y, b, &s),
682                     _ => false,
683                 };
684                 if moved {
685                     // With smoothing off the axis jumped: land the page now.
686                     self.shift_page_to(self.page_scroll_motion.y.pos());
687                     // Keyboard scrolling raises the bar like the wheel does.
688                     self.ui_context[self.page_scroll_bar].on_scroll();
689                     self.needs_rebuild = true;
690                     key_handled = true;
691                 }
692             }
693         }
694 
695         if self.search_open {
696             if self.ui_context[self.search_box].take_change() {
697                 self.search_query = self.ui_context[self.search_box].text.clone();
698                 self.needs_rebuild = true;
699             }
700             if !self.ui_context.is_focused(&self.ui_context[self.search_box]) {
701                 self.search_open = false;
702                 self.search_query.clear();
703                 self.ui_context[self.search_box].set_value_string("");
704                 self.needs_rebuild = true;
705             }
706         }
707 
708         key_handled
709     }
710 }
711