git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/container/paginator.rs (13.3K)

  1 //! Narrow-trait `Paginator` (Phase 5r; pages folded away in Phase 6au) — a vertical sidebar tab
  2 //! strip. The tabs live in an EMBEDDED [`ButtonStrip`] owned by value; every app manages its own
  3 //! page content keyed on `selected_page()`, so the former `Vec<Page>` stack (empty `Page`
  4 //! containers toggled visible/hidden) is gone — its only observable output, the page-area
  5 //! background quad, is painted directly here.
  6 //!
  7 //! Two legacy behaviors ride hooks from the 5r migration:
  8 //! - [`Layout::register_embedded_children`]: legacy `tick`/`layout` re-registered the strip into
  9 //!   the ctx registry every frame — load-bearing for the spatial grid (the registered strip is
 10 //!   what makes the sidebar block root plate drags).
 11 //! - [`Paint::aggregates_child_extra_quads`] + [`Paint::forwarded_highlight`]: legacy
 12 //!   `extra_quads` served the strip's chrome only (cce-layout-interface renders the tab
 13 //!   column through that getter plus `all_rounded_quads`, which carries the strip's rounded
 14 //!   state fills — the paginator's own backgrounds live in `all_rounded_quads` alone), and
 15 //!   legacy `highlight_quad` forwarded to the strip's (the hovered-tab tint
 16 //!   cce-layout-interface draws directly).
 17 
 18 use crate::colors;
 19 use crate::scene::layout::Rect;
 20 use crate::scene::paint::PaintCtx;
 21 use crate::widget::input::ButtonStrip;
 22 use crate::widget::{
 23     Adapted, WidgetHost, Event, EventCtx, Input, Layout, MenuController, PageSelector, Paint,
 24     UiContext, WidgetId,
 25 };
 26 
 27 pub struct Paginator {
 28     pub sidebar_menu: Adapted<ButtonStrip>,
 29     pub selected_page: usize,
 30     pub sidebar_w: f32,
 31     pub page_labels: Vec<String>,
 32     pub on_page_changed_cb: Option<Box<dyn Fn(usize) + Send + Sync>>,
 33     pub just_clicked: Option<usize>,
 34 }
 35 
 36 impl Paginator {
 37     pub fn new(pages: Vec<String>) -> Adapted<Paginator> {
 38         let num_pages = pages.len();
 39 
 40         let temp_paginator = Paginator {
 41             sidebar_menu: Adapted::new(ButtonStrip::new(0.0, 0.0, 0.0, 0.0)),
 42             selected_page: 0,
 43             sidebar_w: 0.0,
 44             page_labels: pages.clone(),
 45             on_page_changed_cb: None,
 46             just_clicked: None,
 47         };
 48         let sidebar_w = temp_paginator.sidebar_w();
 49 
 50         let mut sidebar_menu = Adapted::new(
 51             ButtonStrip::new(0.0, 0.0, sidebar_w, 0.0)
 52                 .with_vertical(true)
 53                 .with_buttons(pages.clone()),
 54         );
 55         if num_pages > 0 {
 56             sidebar_menu.inner_mut().set_selected(Some(0));
 57         }
 58 
 59         Adapted::new(Paginator {
 60             sidebar_menu,
 61             selected_page: 0,
 62             sidebar_w,
 63             page_labels: pages,
 64             on_page_changed_cb: None,
 65             just_clicked: None,
 66         })
 67     }
 68 }
 69 
 70 impl Adapted<Paginator> {
 71     pub fn with_sidebar_mode(self, _enabled: bool) -> Self {
 72         self
 73     }
 74 
 75     pub fn with_tabs_rotated(self, _rotated: bool) -> Self {
 76         self
 77     }
 78 
 79     pub fn with_tabs_at_top(self, _top: bool) -> Self {
 80         self
 81     }
 82 
 83     pub fn with_tab_y_offset(self, _offset: f32) -> Self {
 84         self
 85     }
 86 
 87     pub fn with_title(self, _title: &str) -> Self {
 88         self
 89     }
 90 
 91     pub fn with_vertical(self, _vertical: bool) -> Self {
 92         self
 93     }
 94 
 95     pub fn on_page_changed<F: Fn(usize) + Send + Sync + 'static>(mut self, cb: F) -> Self {
 96         self.on_page_changed_cb = Some(Box::new(cb));
 97         self
 98     }
 99 }
100 
101 impl PageSelector for Paginator {
102     fn selected_page(&self) -> usize {
103         self.selected_page
104     }
105 
106     fn set_selected_page(&mut self, page: usize) {
107         if page < self.page_labels.len() {
108             self.selected_page = page;
109             self.sidebar_menu.inner_mut().set_selected(Some(page));
110             if let Some(ref cb) = self.on_page_changed_cb {
111                 cb(page);
112             }
113         }
114     }
115 
116     fn sidebar_w(&self) -> f32 {
117         if self.page_labels.is_empty() {
118             return self.sidebar_w;
119         }
120         let font_info = crate::layout::menubar_font_parsed();
121         let font_fam = font_info.0;
122         let font_size = font_info.1;
123         let padding = crate::layout::button_padding();
124 
125         let mut max_w = 0.0;
126         for label in &self.page_labels {
127             let trimmed = label.trim();
128             let space_idx = trimmed.find(' ');
129             let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
130             let content_w = if has_icon {
131                 let space_idx = space_idx.unwrap();
132                 let (icon, _) = trimmed.split_at(space_idx);
133                 let icon = icon.trim();
134                 let icon_font_size = 14.0;
135                 let est_icon_w = crate::widget::display::measure_text_width(icon, &font_fam, icon_font_size);
136                 est_icon_w.max(font_size)
137             } else {
138                 font_size
139             };
140             let w = content_w + 2.0 * padding;
141             if w > max_w {
142                 max_w = w;
143             }
144         }
145         max_w.max(1.0)
146     }
147 }
148 
149 impl Layout for Paginator {
150     fn has_container_children(&self) -> bool {
151         true
152     }
153 
154     fn container_children(&self) -> Vec<*mut (dyn WidgetHost + 'static)> {
155         vec![&self.sidebar_menu as &dyn WidgetHost as *const (dyn WidgetHost + 'static) as *mut (dyn WidgetHost + 'static)]
156     }
157 
158     /// The legacy `set_rect` body: strip on the left at its measured width.
159     fn arrange_children(&mut self, rect: Rect, _host: *mut (dyn WidgetHost + 'static)) {
160         let (x, y, h) = (rect.x, rect.y, rect.height);
161         let sidebar_w = self.sidebar_w();
162         self.sidebar_menu.set_rect(x, y, sidebar_w, h);
163     }
164 
165     fn register_embedded_children(&mut self, host_id: WidgetId, ctx: &mut UiContext) {
166         let menu_ptr = self.sidebar_menu.as_ptr_mut();
167         ctx.register_widget(self.sidebar_menu.id(), menu_ptr);
168         ctx.link_ids(host_id, self.sidebar_menu.id());
169     }
170 }
171 
172 impl Paint for Paginator {
173     fn color(&self) -> [f32; 4] {
174         colors::sidebar_bg_color()
175     }
176 
177     /// Own geometry: the sidebar background plus the page-area background — the latter is the
178     /// one visual the former empty `Page` stack contributed (its bg quad over the content
179     /// area), painted directly since the pages folded away (Phase 6au). Both round at the
180     /// plate radius; the page area keeps only its outer corners so the seam with the sidebar
181     /// stays straight.
182     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
183         let r = crate::layout::plate_corner_radius();
184         let c = self.color();
185         if c[3] > 0.0 {
186             ctx.rounded_rect(rect, r, (true, true, true, true), c);
187         }
188         if !self.page_labels.is_empty() {
189             let mut pc = colors::page_color();
190             pc[3] *= crate::layout::page_opacity();
191             if pc[3] > 0.0 {
192                 let sidebar_w = self.sidebar_w();
193                 let page_rect = Rect {
194                     x: rect.x + sidebar_w,
195                     y: rect.y,
196                     width: (rect.width - sidebar_w).max(0.0),
197                     height: rect.height,
198                 };
199                 ctx.rounded_rect(page_rect, r, (false, true, true, false), pc);
200             }
201         }
202     }
203 
204     /// Legacy `extra_quads` served the strip's + selected page's chrome only —
205     /// cce-layout-interface draws the tab column through this getter (and the strip's rounded
206     /// state fills through `all_rounded_quads`), over its own backgrounds.
207     fn aggregates_child_extra_quads(&self) -> bool {
208         true
209     }
210 
211     /// Legacy `highlight_quad` forwarded to the strip's (the hovered-tab tint
212     /// cce-layout-interface draws directly).
213     fn forwarded_highlight(&self, ctx: &UiContext) -> Option<Option<(f32, f32, f32, f32, [f32; 4])>> {
214         Some(self.sidebar_menu.highlight_quad(ctx))
215     }
216 }
217 
218 impl Input for Paginator {
219     fn blocks_root_plate_drag(&self) -> bool {
220         false
221     }
222 
223     /// Legacy `mouse_input` saw every press — the strip and pages ran their own hit checks.
224     fn gates_presses(&self) -> bool {
225         false
226     }
227 
228     fn wants_tick(&self) -> bool {
229         true
230     }
231 
232     /// Event proxying, the legacy forwarding body: the strip, draining its click into the page
233     /// selection. (The former empty pages also received every event, but had nothing to do with
234     /// them — no children, never scrollable.)
235     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
236         let Some(ui) = ectx.ui.as_deref_mut() else {
237             return false;
238         };
239         match event {
240             Event::PointerMove { x: px, y: py, .. } => self.sidebar_menu.cursor_moved(*px, *py, ui),
241             Event::MouseButton { button, state, x: px, y: py, .. } => {
242                 let mut changed = false;
243                 if self.sidebar_menu.mouse_input(*button, *state, *px, *py, ui) {
244                     changed = true;
245                     if let Some(idx) = self.sidebar_menu.inner_mut().take_click() {
246                         self.set_selected_page(idx);
247                         self.just_clicked = Some(idx);
248                     }
249                 }
250                 changed
251             }
252             Event::MouseWheel { delta, x: px, y: py, .. } => self.sidebar_menu.mouse_wheel(delta, *px, *py, ui),
253             Event::KeyInput(key_event) => self.sidebar_menu.keyboard_input(key_event, ui),
254             _ => false,
255         }
256     }
257 
258 }
259 
260 impl MenuController for Paginator {
261     fn menu_click(&mut self) -> Option<(usize, usize)> {
262         self.just_clicked.take().map(|idx| (idx, 0))
263     }
264 
265     fn trigger_menu_click(&mut self, _menu_idx: usize, _item_idx: usize) {}
266     fn set_item_checked(&mut self, _menu_idx: usize, _item_idx: usize, _checked: bool) {}
267     fn set_menu_items(&mut self, _menu_idx: usize, _items: &[String]) {}
268     fn is_menu_bar(&self) -> bool { false }
269     fn is_menu_open(&self) -> bool { false }
270     fn menu_items(&self) -> Vec<String> { Vec::new() }
271     fn menu_item_checked(&self) -> Vec<Option<bool>> { Vec::new() }
272     fn is_vertical(&self) -> bool { true }
273     fn menu_names(&self) -> Vec<String> { Vec::new() }
274     fn menu_items_list(&self) -> Vec<Vec<String>> { Vec::new() }
275     fn menu_checked_list(&self) -> Vec<Vec<Option<bool>>> { Vec::new() }
276     fn take_context_change(&mut self) -> Option<usize> { None }
277     fn set_context_selected(&mut self, _selected: usize) {}
278     fn set_center_items(&mut self, _center: bool) {}
279     fn get_menu_items_at(&self, _px: f32, _py: f32) -> Option<(usize, String, Vec<String>, f32, f32, f32, f32)> { None }
280 }
281 
282 #[cfg(test)]
283 mod tests {
284     use super::*;
285     use crate::context::UiContext;
286     use crate::widget::{ElementState, MouseButton};
287 
288     fn paginator() -> Adapted<Paginator> {
289         let mut p = Paginator::new(vec!["One".to_string(), "Two".to_string()]);
290         WidgetHost::set_rect(&mut p, 0.0, 0.0, 400.0, 300.0);
291         p
292     }
293 
294     #[test]
295     fn sidebar_click_switches_page_and_drains_menu_click() {
296         let mut ctx = UiContext::new();
297         let mut p = paginator();
298         let (id, ptr) = (p.id(), p.as_ptr_mut());
299         ctx.register_widget(id, ptr);
300 
301         // Click the second tab (the strip commits selection on release): the selection moves
302         // and menu_click reports (1, 0) once.
303         let (bx, by, bw, bh) = p.sidebar_menu.item_rect(1);
304         assert!(bw > 0.0, "strip laid out by arrange_children");
305         p.mouse_input(MouseButton::Left, ElementState::Pressed, bx + bw / 2.0, by + bh / 2.0, &mut ctx);
306         p.mouse_input(MouseButton::Left, ElementState::Released, bx + bw / 2.0, by + bh / 2.0, &mut ctx);
307         assert_eq!(p.selected_page, 1);
308         assert_eq!(MenuController::menu_click(&mut *p), Some((1, 0)));
309         assert_eq!(MenuController::menu_click(&mut *p), None, "click drained");
310 
311         // The PageSelector capability is reached through the concrete adapter (the
312         // cce-test-interface downcast shape).
313         assert_eq!(PageSelector::selected_page(&*p), 1);
314         assert!(PageSelector::sidebar_w(&*p) > 0.0);
315     }
316 
317     #[test]
318     fn plain_quads_split_like_legacy_and_registration_heals_on_tick() {
319         let mut ctx = UiContext::new();
320         let mut p = paginator();
321         let (id, ptr) = (p.id(), p.as_ptr_mut());
322         ctx.register_widget(id, ptr);
323 
324         // Legacy split: `extra_quads` is the children's chrome only; the sidebar background
325         // (a rounded rect) lives in `all_rounded_quads` alone (layout-interface draws its
326         // own backgrounds under `extra_quads`).
327         let extra = WidgetHost::extra_quads(&p);
328         let strip_extra = p.sidebar_menu.extra_quads();
329         assert_eq!(extra.len(), strip_extra.len(), "children-only plain view (pages emit none)");
330         let bg = colors::sidebar_bg_color();
331         if bg[3] > 0.0 {
332             let r = crate::layout::plate_corner_radius();
333             let bg_quad = (0.0, 0.0, 400.0, 300.0, r, bg, (true, true, true, true));
334             assert!(!extra.iter().any(|q| q.4 == bg && q.2 == 400.0), "no own bg in extra_quads");
335             assert!(WidgetHost::all_rounded_quads(&p, &ctx).contains(&bg_quad), "own bg in all_rounded_quads");
336         }
337 
338         // The embedded strip + pages land in the registry on tick (the spatial grid feeds off
339         // it — the registered strip is what blocks root plate drags over the sidebar).
340         WidgetHost::tick(&mut p, 0.016, &mut ctx);
341         let strip_id = p.sidebar_menu.id();
342         assert!(
343             ctx.tree.iter_registered().any(|(w_id, _)| w_id == strip_id),
344             "strip registered by the tick-path healing"
345         );
346     }
347 }