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