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