Skip to main content

agg_gui/widgets/menu/
strip.rs

1//! `MenuBarStrip` — full-width top-of-window strip that auto-sizes its
2//! height to the wrapped child's natural height.
3//!
4//! Most apps put a menu bar at the top of the window inside a small
5//! container that paints a chrome background, draws a 1-px separator
6//! between the bar and the rest of the UI, and lets the bar scroll
7//! horizontally if its content overflows a narrow viewport.  Before this
8//! widget existed every app reinvented that container with a hard-coded
9//! height, which left a visible chrome stripe below the menu bar
10//! whenever the constants drifted (or the bar shrank from a redesign).
11//!
12//! `MenuBarStrip` removes the foot-gun: it queries the child for its
13//! natural height during `layout` and reports exactly that height back
14//! up the tree.  Drop it inside a `FlexColumn` with a wrapped `MenuBar`
15//! (or any other top-bar widget) and the strip claims the right number
16//! of pixels with no per-app tuning.
17//!
18//! Provides:
19//! - Full-width `top_bar_bg` fill behind the wrapped child.
20//! - 1-px bottom separator line matching the `Separator` widget tone.
21//! - Horizontal overflow scroll (wheel + middle-button drag) when the
22//!   child's natural width exceeds the available width.
23
24use crate::draw_ctx::DrawCtx;
25use crate::event::{Event, EventResult, MouseButton};
26use crate::geometry::{Rect, Size};
27use crate::widget::{current_mouse_world, Widget};
28
29pub struct MenuBarStrip {
30    bounds: Rect,
31    children: Vec<Box<dyn Widget>>,
32    h_offset: f64,
33    content_width: f64,
34    content_height: f64,
35    middle_dragging: bool,
36    middle_start_world_x: f64,
37    middle_start_h_offset: f64,
38}
39
40impl MenuBarStrip {
41    pub fn new(inner: Box<dyn Widget>) -> Self {
42        Self {
43            bounds: Rect::default(),
44            children: vec![inner],
45            h_offset: 0.0,
46            content_width: 0.0,
47            content_height: 0.0,
48            middle_dragging: false,
49            middle_start_world_x: 0.0,
50            middle_start_h_offset: 0.0,
51        }
52    }
53
54    fn max_scroll(&self) -> f64 {
55        (self.content_width - self.bounds.width).max(0.0)
56    }
57
58    fn clamp_offset(&mut self) {
59        self.h_offset = self.h_offset.clamp(0.0, self.max_scroll());
60    }
61
62    fn update_child_bounds(&mut self) {
63        if let Some(child) = self.children.first_mut() {
64            child.set_bounds(Rect::new(
65                -self.h_offset.round(),
66                0.0,
67                self.content_width,
68                self.content_height,
69            ));
70        }
71    }
72}
73
74impl Widget for MenuBarStrip {
75    fn type_name(&self) -> &'static str {
76        "MenuBarStrip"
77    }
78
79    fn bounds(&self) -> Rect {
80        self.bounds
81    }
82
83    fn set_bounds(&mut self, bounds: Rect) {
84        self.bounds = bounds;
85    }
86
87    fn children(&self) -> &[Box<dyn Widget>] {
88        &self.children
89    }
90
91    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
92        &mut self.children
93    }
94
95    fn layout(&mut self, available: Size) -> Size {
96        // Ask the child what it wants.  Its reported height becomes the
97        // strip's height — no app-side constant needed.  The strip
98        // remains full-width across `available` so the bg fill runs
99        // edge-to-edge even when the child is narrower (e.g. a
100        // `fit_width` MenuBar).
101        let used = if let Some(child) = self.children.first_mut() {
102            child.layout(available)
103        } else {
104            Size::new(0.0, 0.0)
105        };
106        self.content_height = used.height;
107        self.content_width = used.width.max(available.width);
108        self.bounds = Rect::new(0.0, 0.0, available.width, self.content_height);
109        self.clamp_offset();
110        self.update_child_bounds();
111        Size::new(available.width, self.content_height)
112    }
113
114    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
115        let v = ctx.visuals();
116        ctx.set_fill_color(v.top_bar_bg);
117        ctx.begin_path();
118        ctx.rect(0.0, 0.0, self.bounds.width, self.bounds.height);
119        ctx.fill();
120    }
121
122    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
123        // Y-up: local y=0 is the BOTTOM edge of the strip, which is
124        // exactly where the separator between the strip and the rest of
125        // the UI lives.  Painted in `paint_overlay` so it sits on top of
126        // child widgets — `MenuBar` paints an opaque `top_bar_bg` fill
127        // across its full bounds (to satisfy LCD coverage), which would
128        // otherwise erase a separator drawn in `paint`.
129        let v = ctx.visuals();
130        ctx.set_fill_color(v.separator);
131        ctx.begin_path();
132        ctx.rect(0.0, 0.0, self.bounds.width, 1.0);
133        ctx.fill();
134    }
135
136    fn on_event(&mut self, event: &Event) -> EventResult {
137        match event {
138            Event::MouseWheel {
139                delta_x,
140                delta_y,
141                modifiers,
142                ..
143            } => {
144                // Horizontal wheel is the canonical scroll direction.
145                // Shift + vertical wheel falls back to horizontal so
146                // mice without a horizontal axis can still pan the bar.
147                let delta = if delta_x.abs() > f64::EPSILON {
148                    *delta_x
149                } else if modifiers.shift {
150                    *delta_y
151                } else {
152                    0.0
153                };
154                if delta.abs() <= f64::EPSILON || self.max_scroll() <= 0.0 {
155                    return EventResult::Ignored;
156                }
157                let before = self.h_offset;
158                self.h_offset += delta * 40.0;
159                self.clamp_offset();
160                if (self.h_offset - before).abs() > f64::EPSILON {
161                    self.update_child_bounds();
162                    crate::animation::request_draw();
163                    return EventResult::Consumed;
164                }
165                EventResult::Ignored
166            }
167            Event::MouseDown {
168                button: MouseButton::Middle,
169                ..
170            } if self.max_scroll() > 0.0 => {
171                self.middle_dragging = true;
172                self.middle_start_world_x = current_mouse_world().map(|p| p.x).unwrap_or(0.0);
173                self.middle_start_h_offset = self.h_offset;
174                EventResult::Consumed
175            }
176            Event::MouseMove { pos } if self.middle_dragging => {
177                let world_x = current_mouse_world().map(|p| p.x).unwrap_or(pos.x);
178                self.h_offset = self.middle_start_h_offset - (world_x - self.middle_start_world_x);
179                self.clamp_offset();
180                self.update_child_bounds();
181                crate::animation::request_draw();
182                EventResult::Consumed
183            }
184            Event::MouseUp {
185                button: MouseButton::Middle,
186                ..
187            } if self.middle_dragging => {
188                self.middle_dragging = false;
189                EventResult::Consumed
190            }
191            _ => EventResult::Ignored,
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::geometry::Rect;
200    use crate::widget::Widget;
201
202    /// Tiny stand-in for a menu bar — reports a fixed natural height
203    /// so the strip's "size-to-content" contract is testable without
204    /// pulling in the whole `MenuBar` widget.
205    struct FixedHeightChild {
206        bounds: Rect,
207        children: Vec<Box<dyn Widget>>,
208        natural_height: f64,
209        natural_width: f64,
210    }
211
212    impl Widget for FixedHeightChild {
213        fn type_name(&self) -> &'static str {
214            "FixedHeightChild"
215        }
216        fn bounds(&self) -> Rect {
217            self.bounds
218        }
219        fn set_bounds(&mut self, b: Rect) {
220            self.bounds = b;
221        }
222        fn children(&self) -> &[Box<dyn Widget>] {
223            &self.children
224        }
225        fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
226            &mut self.children
227        }
228        fn layout(&mut self, _available: Size) -> Size {
229            Size::new(self.natural_width, self.natural_height)
230        }
231        fn paint(&mut self, _ctx: &mut dyn crate::DrawCtx) {}
232        fn on_event(&mut self, _: &crate::Event) -> crate::EventResult {
233            crate::EventResult::Ignored
234        }
235    }
236
237    #[test]
238    fn strip_height_matches_child_natural_height() {
239        let child = FixedHeightChild {
240            bounds: Rect::default(),
241            children: Vec::new(),
242            natural_height: 26.0,
243            natural_width: 120.0,
244        };
245        let mut strip = MenuBarStrip::new(Box::new(child));
246        let used = strip.layout(Size::new(800.0, 200.0));
247        assert_eq!(used.height, 26.0, "strip must size to child's height");
248        assert_eq!(used.width, 800.0, "strip must span full available width");
249    }
250
251    #[test]
252    fn strip_overflow_scroll_kicks_in_when_child_wider_than_available() {
253        let child = FixedHeightChild {
254            bounds: Rect::default(),
255            children: Vec::new(),
256            natural_height: 26.0,
257            natural_width: 1000.0,
258        };
259        let mut strip = MenuBarStrip::new(Box::new(child));
260        strip.layout(Size::new(400.0, 200.0));
261        assert!(
262            strip.max_scroll() > 0.0,
263            "child wider than available width must produce scrollable overflow"
264        );
265    }
266}