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

src/input_handler.rs (30.7K)

  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.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.page_dropdown.take_change() {
191                 let idx = self.page_dropdown.selected;
192                 if idx < Page::ALL.len() {
193                     cce_ui::widget::focus::clear_focus(Some(&mut self.ui_context));
194                     self.focused_section = None;
195                     let new_page = Page::ALL[idx];
196                     self.app.current_page = new_page;
197                     self.current_page_shared.store(idx as u8, std::sync::atomic::Ordering::SeqCst);
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 &mut self.page_buttons[self.scrollable_buttons_start_idx..] {
220             if 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 global focus, the
251         // section-level highlight yields — exactly as when both lived in FOCUSED_WIDGET.
252         if state == cce_ui::widget::ElementState::Pressed && cce_ui::widget::focus::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);
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.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 &mut self.page_buttons[self.scrollable_buttons_start_idx..] {
344             btn.base_mut().y -= actual_dy;
345         }
346         self.last_scroll_y = self.scroll_y;
347         self.page_scroll_bar.scroll_y = self.scroll_y;
348         true
349     }
350 
351     /// The current page's event-dispatch roots (Phase 6w — SectionContainer dissolved):
352     /// the pages' widgets themselves, flattened in the legacy propagate order (sections
353     /// last-to-first, and within a section the container children were visited in
354     /// reverse link order).
355     pub(crate) fn page_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
356         self.app
357             .get_current_page_mut()
358             .section_widgets()
359             .into_iter()
360             .rev()
361             .flat_map(|group| group.into_iter().rev())
362             .collect()
363     }
364 
365     /// Replicates the dissolved Page's event routing: the out-of-bounds gate (events whose
366     /// screen position is outside the page viewport never reach page widgets, unless the
367     /// scrollbar is mid-drag), then the legacy child order reversed — the scrollbar first
368     /// (with Page's y-unshift, its coords are screen-space while the event carries the
369     /// scroll offset), then the sections last-to-first. PointerMove visits everything
370     /// (hover bookkeeping); other events stop at the first handler.
371     pub(crate) fn dispatch_page_event(&mut self, event: &cce_ui::widget::Event) -> bool {
372         use cce_ui::widget::Event;
373         // A page switch takes effect immediately, but widget registration is a side effect
374         // of the view pass and `clear_hierarchy` wipes the registry every rebuild — so
375         // until the new page has been laid out once, none of its `section_widgets()` ids
376         // resolve. Dispatching anyway can't reach a widget; it only emits one
377         // "unregistered/stale root" warning per root (the pages that implement
378         // `register_extra_dispatch_roots` were incidentally immune, which is why only 9 of
379         // the 14 spammed). Suppress across the gap instead — the rebuild is one frame away.
380         if self.laid_out_page != Some(self.app.current_page) {
381             return false;
382         }
383         let is_pointer_event = matches!(
384             event,
385             Event::PointerMove { .. } | Event::MouseButton { .. } | Event::MouseWheel { .. }
386         );
387         if is_pointer_event && !self.page_scroll_bar.dragging && !self.ui_context.is_dragging {
388             if let Event::PointerMove { x, y, .. }
389             | Event::MouseButton { x, y, .. }
390             | Event::MouseWheel { x, y, .. } = event
391             {
392                 let rx = self.sidebar_width;
393                 let ry = self.header_height;
394                 let rw = self.width as f32 - self.sidebar_width;
395                 let mut rh = self.height as f32 - self.header_height - self.status_height;
396                 if self.search_open {
397                     rh -= 42.0;
398                 }
399                 let screen_y = *y - self.scroll_y;
400                 if *x < rx || *x > rx + rw || screen_y < ry || screen_y > ry + rh {
401                     return false;
402                 }
403             }
404         }
405 
406         let is_pointer_move = matches!(event, Event::PointerMove { .. });
407         let mut handled = false;
408 
409         // Open-popover priority: a press inside an open menu goes to its owner
410         // BEFORE positional dispatch — the roots iterate in z-ignorant order,
411         // so a closed sibling whose trigger band sits under the overlaying
412         // popover would otherwise claim the point first (the Default Apps
413         // Terminal dropdown's menu covers the Images row's trigger).
414         if let Event::MouseButton { x, y, .. } = event {
415             if let Some(owner) = self.ui_context.popover_owner_at(*x, *y) {
416                 if self.ui_context.propagate_event(event, owner) {
417                     return true;
418                 }
419             }
420         }
421 
422         if self.page_scroll_bar.content_h > self.page_scroll_bar.viewport_h {
423             let mut sb_event = event.clone();
424             if let Event::PointerMove { y, local_y, .. }
425             | Event::MouseButton { y, local_y, .. }
426             | Event::MouseWheel { y, local_y, .. } = &mut sb_event
427             {
428                 *y -= self.scroll_y;
429                 *local_y -= self.scroll_y;
430             }
431             let sb_root = self.page_scroll_bar.id();
432             if self.ui_context.propagate_event(&sb_event, sb_root) {
433                 if !is_pointer_move {
434                     return true;
435                 }
436                 handled = true;
437             }
438         }
439 
440         // Row widgets of the dissolved lists (Phase 6v): they used to receive events as
441         // ScrollBox children under the sections; now they dispatch directly. Adapted's
442         // hit-gate keeps missed presses falling through, so order vs the sections only
443         // matters for overlap — and the rows sit inside list frames the sections never
444         // claim. Collected fresh per event: the item Vecs get rebuilt across frames.
445         let dbg_t0 = if hover_debug() && is_pointer_move {
446             Some(std::time::Instant::now())
447         } else {
448             None
449         };
450         let extra_roots = {
451             let page = self.app.get_current_page_mut();
452             page.register_extra_dispatch_roots(&mut self.ui_context);
453             page.extra_dispatch_roots()
454         };
455         let t_register = dbg_t0.map(|s| s.elapsed().as_micros());
456         let n_roots = extra_roots.len();
457         for root in extra_roots {
458             if self.ui_context.propagate_event(event, root) {
459                 if !is_pointer_move {
460                     return true;
461                 }
462                 handled = true;
463             }
464         }
465         if let (Some(s), Some(reg)) = (dbg_t0, t_register) {
466             let total = s.elapsed().as_micros();
467             eprintln!(
468                 "[hover]   extra_roots n={} register+collect={}us propagate={}us",
469                 n_roots, reg, total - reg
470             );
471         }
472 
473         let roots = self.page_dispatch_roots();
474         for root in roots {
475             if self.ui_context.propagate_event(event, root) {
476                 if !is_pointer_move {
477                     return true;
478                 }
479                 handled = true;
480             }
481         }
482         handled
483     }
484 
485     pub(crate) fn handle_key_input_internal(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
486         if cce_ui::widget::context_menu::is_visible() {
487             if event.state == cce_ui::widget::ElementState::Pressed
488                 && event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Escape)
489             {
490                 cce_ui::widget::context_menu::hide();
491                 self.needs_rebuild = true;
492                 return true;
493             }
494         }
495 
496         if self.search_open {
497             if event.state == cce_ui::widget::ElementState::Pressed
498                 && event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Escape)
499             {
500                 self.search_open = false;
501                 self.search_query.clear();
502                 self.search_box.set_value_string("");
503                 self.ui_context.clear_focus();
504                 self.needs_rebuild = true;
505                 return true;
506             }
507         }
508 
509         let is_text_box_focused = if let Some(focused) =
510             self.ui_context.focused_widget.and_then(|id| self.ui_context.tree.get_ptr(id))
511         {
512             unsafe { (*focused).as_any().is::<cce_ui::widget::input::TextBox>() }
513         } else {
514             false
515         };
516 
517         if !self.search_open && !is_text_box_focused {
518             if event.state == cce_ui::widget::ElementState::Pressed && !event.repeat {
519                 if cce_ui::widget::match_key_shortcut(event, &settings_keys().open_search) {
520                     self.search_open = true;
521                     self.search_box.set_value_string("");
522                     self.search_query.clear();
523                     cce_ui::widget::WidgetHost::focus(&mut self.search_box);
524                     self.ui_context.set_focused(&mut self.search_box);
525                     self.needs_rebuild = true;
526                     return true;
527                 }
528 
529                 let next = cce_ui::widget::match_key_shortcut(event, &settings_keys().page_next);
530                 let prev = cce_ui::widget::match_key_shortcut(event, &settings_keys().page_prev);
531                 if next || prev {
532                     let n = Page::ALL.len();
533                     let cur = Page::ALL.iter().position(|&p| p == self.app.current_page).unwrap_or(0);
534                     let idx = if next { (cur + 1) % n } else { (cur + n - 1) % n };
535                     cce_ui::widget::focus::clear_focus(Some(&mut self.ui_context));
536                     self.focused_section = None;
537                     self.app.current_page = Page::ALL[idx];
538                     self.current_page_shared.store(idx as u8, std::sync::atomic::Ordering::SeqCst);
539                     self.scroll_y = 0.0;
540                     self.needs_rebuild = true;
541                     return true;
542                 }
543             }
544         }
545 
546         if event.state == cce_ui::widget::ElementState::Pressed && !event.repeat {
547             let keys = settings_keys();
548             let m = |chord: &str| cce_ui::widget::match_key_shortcut(event, chord);
549             let (forward, backward, ascend, descend) = (
550                 m(&keys.focus_next),
551                 m(&keys.focus_prev),
552                 m(&keys.focus_ascend),
553                 m(&keys.focus_descend),
554             );
555             if forward || backward || ascend || descend {
556                 // SectionContainer dissolved (Phase 6w): section-level focus is the
557                 // app-side index, widget-level focus stays in the global focus module,
558                 // and the two are single-slot (as when sections and widgets shared the
559                 // one FOCUSED_WIDGET). Nav within a section walks the page's widget
560                 // group where the container's child list used to be walked.
561                 if cce_ui::widget::focus::has_focus() {
562                     // (`focus::navigate_focus` is gone — it walked an empty dummy context
563                     // and always returned false here; the section machinery below is the
564                     // real ctrl-nav.)
565                     let groups = self.app.get_current_page_mut().section_widgets();
566                     let focused_pos = groups.iter().enumerate().find_map(|(si, g)| {
567                         g.iter()
568                             .position(|&id| cce_ui::widget::focus::is_focused_id(id))
569                             .map(|wi| (si, wi))
570                     });
571                     if let Some((si, wi)) = focused_pos {
572                         if forward || backward {
573                             let group = &groups[si];
574                             let next = if forward {
575                                 (wi + 1) % group.len()
576                             } else if wi == 0 {
577                                 group.len() - 1
578                             } else {
579                                 wi - 1
580                             };
581                             let next_id = group[next];
582                             cce_ui::widget::focus::set_focused_id(next_id, Some(&mut self.ui_context));
583                             if let Some(w) = self.ui_context.get_widget_mut(next_id) {
584                                 w.focus();
585                             }
586                             self.needs_rebuild = true;
587                             return true;
588                         }
589                         if ascend {
590                             cce_ui::widget::focus::clear_focus(Some(&mut self.ui_context));
591                             self.focused_section = Some(si);
592                             self.needs_rebuild = true;
593                             return true;
594                         }
595                     }
596                 } else if let Some(idx) = self.focused_section {
597                     let groups = self.app.get_current_page_mut().section_widgets();
598                     if !groups.is_empty() {
599                         let idx = idx.min(groups.len() - 1);
600                         if forward || backward {
601                             self.focused_section = Some(if forward {
602                                 (idx + 1) % groups.len()
603                             } else if idx == 0 {
604                                 groups.len() - 1
605                             } else {
606                                 idx - 1
607                             });
608                             self.needs_rebuild = true;
609                             return true;
610                         }
611                         if descend {
612                             if let Some(&first) = groups[idx].first() {
613                                 cce_ui::widget::focus::set_focused_id(first, Some(&mut self.ui_context));
614                                 if let Some(w) = self.ui_context.get_widget_mut(first) {
615                                     w.focus();
616                                 }
617                                 self.focused_section = None;
618                                 self.needs_rebuild = true;
619                                 return true;
620                             }
621                         }
622                     }
623                 } else {
624                     // Entry point: focus the first section.
625                     if !self.app.get_current_page_mut().section_widgets().is_empty() {
626                         self.focused_section = Some(0);
627                         self.needs_rebuild = true;
628                         return true;
629                     }
630                 }
631             }
632         }
633 
634         let event_wrapper = cce_ui::widget::Event::KeyInput(event.clone());
635         let mut key_handled = false;
636         if self.dispatch_page_event(&event_wrapper) {
637             let mut actions = Vec::new();
638             self.propagate_widget_changes(&mut actions);
639             for a in actions {
640                 self.handle_action(&a);
641             }
642             self.needs_rebuild = true;
643             key_handled = true;
644         }
645 
646         // The dissolved inner lists' keyboard scrolling (hover/focus-scoped, like the old
647         // ScrollBox::keyboard_input) — before the whole-page fallback so a hovered list
648         // takes the scroll keys first.
649         if !key_handled && self.app.get_current_page_mut().handle_key_input(event) {
650             self.needs_rebuild = true;
651             key_handled = true;
652         }
653 
654         // The dissolved Page's keyboard scrolling: when nothing in the page tree took the
655         // key and the cursor is over the page viewport, scroll keys move the page.
656         if !key_handled && event.state == cce_ui::widget::ElementState::Pressed && self.max_scroll_y > 0.0 {
657             let over_page = {
658                 let ry = self.header_height;
659                 let mut rh = self.height as f32 - self.header_height - self.status_height;
660                 if self.search_open {
661                     rh -= 42.0;
662                 }
663                 self.cursor_x >= self.sidebar_width
664                     && self.cursor_y >= ry
665                     && self.cursor_y <= ry + rh
666             };
667             if over_page {
668                 use cce_ui::widget::{Bounds, Key, NamedKey, LINE_PX};
669                 // Arrow steps ride the same glide as wheel notches (a held
670                 // key accumulates into one motion); Home/End glide to the
671                 // absolute target. `tick_page_scroll` carries the page there.
672                 let s = cce_ui::widget::scroll_motion::scroll_settings();
673                 let b = Bounds::max(self.max_scroll_y);
674                 self.page_scroll_motion.reconcile(0.0, self.scroll_y);
675                 let moved = match &event.logical_key {
676                     Key::Named(NamedKey::ArrowDown) => self.page_scroll_motion.y.wheel(LINE_PX, b, &s),
677                     Key::Named(NamedKey::ArrowUp) => self.page_scroll_motion.y.wheel(-LINE_PX, b, &s),
678                     Key::Named(NamedKey::Home) => self.page_scroll_motion.y.scroll_to(0.0, b, &s),
679                     Key::Named(NamedKey::End) => self.page_scroll_motion.y.scroll_to(self.max_scroll_y, b, &s),
680                     _ => false,
681                 };
682                 if moved {
683                     // With smoothing off the axis jumped: land the page now.
684                     self.shift_page_to(self.page_scroll_motion.y.pos());
685                     // Keyboard scrolling raises the bar like the wheel does.
686                     self.page_scroll_bar.on_scroll();
687                     self.needs_rebuild = true;
688                     key_handled = true;
689                 }
690             }
691         }
692 
693         if self.search_open {
694             if self.search_box.take_change() {
695                 self.search_query = self.search_box.text.clone();
696                 self.needs_rebuild = true;
697             }
698             if !self.ui_context.is_focused(&self.search_box) {
699                 self.search_open = false;
700                 self.search_query.clear();
701                 self.search_box.set_value_string("");
702                 self.needs_rebuild = true;
703             }
704         }
705 
706         key_handled
707     }
708 }
709