Skip to main content

launcher/widgets/
nav_group_button.rs

1use eframe::egui;
2
3use crate::{state::Page, widgets::nav_button::nav_button};
4
5/// Represents a single page within a navigation group.
6pub struct NavGroupItem {
7    pub page: Page,
8    pub icon: &'static str,
9    pub label: &'static str,
10}
11
12/// A compact option chip for the expanded popup.
13///
14/// Deliberately built with `focusable: false`: `egui::Button` always registers
15/// itself for keyboard focus, which let Tab navigation wander into the
16/// transient popup. These chips respond to clicks and hover, but are
17/// invisible to the focus order.
18fn popup_option(
19    ui: &mut egui::Ui,
20    id: egui::Id,
21    label: impl Into<egui::WidgetText>,
22    is_active: bool,
23) -> egui::Response {
24    let text: egui::WidgetText = label.into();
25
26    let galley = text.into_galley(
27        ui,
28        Some(egui::TextWrapMode::Extend),
29        f32::INFINITY,
30        egui::TextStyle::Button,
31    );
32
33    let padding = egui::vec2(12.0, 7.0);
34    let desired_size = galley.size() + padding * 2.0;
35
36    let (_auto_id, rect) = ui.allocate_space(desired_size);
37    let response = ui.interact(rect, id, egui::Sense::click());
38
39    if ui.is_rect_visible(rect) {
40        let visuals = ui.visuals();
41        let (bg, text_color) = if is_active {
42            // Same palette nav_button uses for its selected state
43            (
44                visuals.selection.bg_fill,
45                visuals.widgets.hovered.fg_stroke.color,
46            )
47        } else if response.hovered() {
48            (
49                visuals.widgets.hovered.weak_bg_fill,
50                visuals.widgets.hovered.fg_stroke.color,
51            )
52        } else {
53            (visuals.widgets.inactive.weak_bg_fill, visuals.text_color())
54        };
55        let corner_radius = visuals.widgets.inactive.corner_radius;
56
57        ui.painter().rect_filled(rect, corner_radius, bg);
58
59        let text_pos = egui::Align2::CENTER_CENTER
60            .align_size_within_rect(galley.size(), rect)
61            .min;
62        ui.painter().galley(text_pos, galley, text_color);
63    }
64
65    response
66}
67
68/// Creates a navigation button that represents a group of pages.
69///
70/// On hover, it expands horizontally to show sub-options.
71/// On click, or Enter/Space when focused, it cycles through the pages.
72/// The popup itself never takes keyboard focus.
73pub fn nav_group_button(
74    ui: &mut egui::Ui,
75    group_icon: &'static str,
76    group_label: &'static str,
77    items: &[NavGroupItem],
78    current_page: &mut Page,
79) -> egui::Response {
80    let active_item = items.iter().find(|item| item.page == *current_page);
81    let main_icon = active_item.map(|i| i.icon).unwrap_or(group_icon);
82    let main_label = active_item.map(|i| i.label).unwrap_or(group_label);
83    let is_selected = active_item.is_some();
84
85    let response = nav_button(ui, main_icon, main_label, is_selected, false);
86
87    let popup_id = ui.id().with(group_label).with("group_popup");
88
89    // ── READ PHASE: never call has_focus() inside a memory closure ────────
90    let was_open = ui.memory(|m| m.data.get_temp::<bool>(popup_id).unwrap_or(false));
91    let main_hovered = response.contains_pointer();
92    let main_focused = response.has_focus();
93
94    if was_open || main_hovered || main_focused {
95        let mut popup_hovered = false;
96
97        let area_response = egui::Area::new(popup_id)
98            .order(egui::Order::Foreground)
99            .fixed_pos(egui::pos2(response.rect.right() - 5.0, response.rect.top()))
100            .constrain(true)
101            .show(ui.ctx(), |ui| {
102                egui::Frame::popup(ui.style()).show(ui, |ui| {
103                    ui.horizontal(|ui| {
104                        for (idx, item) in items.iter().enumerate() {
105                            let chip_id = popup_id.with(idx);
106                            let chip_response = popup_option(
107                                ui,
108                                chip_id,
109                                format!("{} {}", item.icon, item.label),
110                                *current_page == item.page,
111                            );
112
113                            popup_hovered |= chip_response.hovered();
114
115                            if chip_response.clicked() {
116                                *current_page = item.page;
117                            }
118                        }
119                    });
120                });
121            })
122            .response;
123
124        // The frame padding between chips also counts as "inside".
125        popup_hovered |= area_response.contains_pointer();
126
127        // Scroll-wheel cycle pages when hovering the button or popup
128        //
129        // We use a threshold accumulator rather than reacting to every
130        // non-zero delta. On high-dpi touchpads (macOS) `smooth_scroll_delta`
131        // can report hundreds of points for a single casual swipe, so a
132        // raw check would fire many times per gesture.
133        //
134        // The accumulator is stored in egui temp memory (keyed off the
135        // popup id) so it persists across frames but is scoped to this
136        // widget. It resets to 0 whenever a page change fires, and is
137        // cleared when the cursor leaves the area.
138        const SCROLL_THRESHOLD: f32 = 20.0;
139        let scroll_accum_id = popup_id.with("scroll_accum");
140
141        if main_hovered || popup_hovered {
142            let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y);
143            if scroll_delta != 0.0 {
144                // Accumulate and check for a threshold crossing.
145                let new_accum = ui.memory(|m| {
146                    m.data.get_temp::<f32>(scroll_accum_id).unwrap_or(0.0) + scroll_delta
147                });
148
149                if new_accum.abs() >= SCROLL_THRESHOLD {
150                    let current_idx = items
151                        .iter()
152                        .position(|item| item.page == *current_page)
153                        .unwrap_or(0);
154                    let n = items.len();
155                    // Positive delta = scroll up → previous page; negative = next.
156                    // Clamp instead of wrapping so we stop at the first/last page.
157                    let next_idx = if new_accum > 0.0 {
158                        current_idx.saturating_sub(1)
159                    } else {
160                        (current_idx + 1).min(n - 1)
161                    };
162                    *current_page = items[next_idx].page;
163                    // Reset accumulator after a page change.
164                    ui.memory_mut(|m| m.data.insert_temp(scroll_accum_id, 0.0f32));
165                } else {
166                    ui.memory_mut(|m| m.data.insert_temp(scroll_accum_id, new_accum));
167                }
168
169                // Consume the scroll so it doesn't propagate (e.g. to a parent scroll area).
170                ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
171            }
172        } else {
173            // Cursor left the area, discard any partial accumulation so a
174            // returning hover starts fresh.
175            ui.memory_mut(|m| m.data.remove::<f32>(scroll_accum_id));
176        }
177
178        // ── WRITE PHASE: single lock, no context reads inside ─────────────
179        let stay_open = main_hovered || main_focused || popup_hovered;
180        ui.memory_mut(|m| m.data.insert_temp(popup_id, stay_open));
181    }
182
183    // Clicking, or pressing Enter/Space on, the main button cycles pages.
184    if response.clicked() {
185        let current_idx = items.iter().position(|item| item.page == *current_page);
186        let next_idx = match current_idx {
187            Some(idx) => (idx + 1) % items.len(),
188            None => 0,
189        };
190        *current_page = items[next_idx].page;
191    }
192
193    response
194}