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

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

  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 //! The strip is an [`Embedded`] child: held by value until the paginator is inserted, the
  8 //! context's after that ([`Layout::register_embedded_children`] attaches it and links it
  9 //! under the paginator, so the walks reach it and the spatial grid holds it — the registered
 10 //! strip is what makes the sidebar block root plate drags). The paginator keeps the strip's
 11 //! place and selection and pushes them down there, on every layout and tick.
 12 
 13 use crate::colors;
 14 use crate::scene::layout::Rect;
 15 use crate::scene::paint::PaintCtx;
 16 use crate::widget::input::ButtonStrip;
 17 use crate::widget::{Adapted, Embedded, WidgetHost, Event, EventCtx, Input, Layout, MenuController, PageSelector, Paint, UiContext, WidgetId};
 18 
 19 pub struct Paginator {
 20     /// The tab strip, the context's once the paginator is (see the module doc).
 21     pub sidebar_menu: Embedded<Adapted<ButtonStrip>>,
 22     /// The paginator's content rect, where the strip is placed from.
 23     rect: Rect,
 24     /// Whether a page wears a glyph (`with_icons`): the strip's width allows for it.
 25     has_icons: bool,
 26     /// A page chosen through `set_selected_page` that the strip (the context's) has not
 27     /// been shown yet.
 28     push_page: bool,
 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: Embedded::new(Adapted::new(ButtonStrip::new(0.0, 0.0, 0.0, 0.0))),
 42             rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
 43             has_icons: false,
 44             push_page: false,
 45             selected_page: 0,
 46             sidebar_w: 0.0,
 47             page_labels: pages.clone(),
 48             on_page_changed_cb: None,
 49             just_clicked: None,
 50         };
 51         let sidebar_w = temp_paginator.sidebar_w();
 52 
 53         let mut sidebar_menu = Adapted::new(
 54             ButtonStrip::new(0.0, 0.0, sidebar_w, 0.0)
 55                 .with_vertical(true)
 56                 .with_buttons(pages.clone()),
 57         );
 58         if num_pages > 0 {
 59             sidebar_menu.inner_mut().set_selected(Some(0));
 60         }
 61 
 62         Adapted::new(Paginator {
 63             sidebar_menu: Embedded::new(sidebar_menu),
 64             rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
 65             has_icons: false,
 66             push_page: false,
 67             selected_page: 0,
 68             sidebar_w,
 69             page_labels: pages,
 70             on_page_changed_cb: None,
 71             just_clicked: None,
 72         })
 73     }
 74 }
 75 
 76 impl Adapted<Paginator> {
 77     /// A cce-icons glyph per page, worn at the top of its sidebar tab — see
 78     /// [`ButtonStrip::icons`].
 79     pub fn with_icons(mut self, icons: Vec<Option<String>>) -> Self {
 80         let p = self.inner_mut();
 81         p.has_icons = icons.iter().any(|i| i.is_some());
 82         let strip = p.sidebar_menu.here_mut().expect("with_icons builds a paginator not yet inserted");
 83         strip.inner_mut().set_icons(icons);
 84         p.sidebar_w = p.sidebar_w();
 85         let w = p.sidebar_w;
 86         let strip = p.sidebar_menu.here_mut().expect("with_icons builds a paginator not yet inserted");
 87         let (x, y, _, h) = strip.rect();
 88         strip.set_rect(x, y, w, h);
 89         self
 90     }
 91 
 92     pub fn with_sidebar_mode(self, _enabled: bool) -> Self {
 93         self
 94     }
 95 
 96     pub fn with_tabs_rotated(self, _rotated: bool) -> Self {
 97         self
 98     }
 99 
100     pub fn with_tabs_at_top(self, _top: bool) -> Self {
101         self
102     }
103 
104     pub fn with_tab_y_offset(self, _offset: f32) -> Self {
105         self
106     }
107 
108     pub fn with_title(self, _title: &str) -> Self {
109         self
110     }
111 
112     pub fn with_vertical(self, _vertical: bool) -> Self {
113         self
114     }
115 
116     pub fn on_page_changed<F: Fn(usize) + Send + Sync + 'static>(mut self, cb: F) -> Self {
117         self.on_page_changed_cb = Some(Box::new(cb));
118         self
119     }
120 }
121 
122 impl PageSelector for Paginator {
123     fn selected_page(&self) -> usize {
124         self.selected_page
125     }
126 
127     fn set_selected_page(&mut self, page: usize) {
128         if page < self.page_labels.len() {
129             self.selected_page = page;
130             // The strip takes it now when it is held here, else at the next layout or tick.
131             match self.sidebar_menu.here_mut() {
132                 Some(strip) => strip.inner_mut().set_selected(Some(page)),
133                 None => self.push_page = true,
134             }
135             if let Some(ref cb) = self.on_page_changed_cb {
136                 cb(page);
137             }
138         }
139     }
140 
141     fn sidebar_w(&self) -> f32 {
142         if self.page_labels.is_empty() {
143             return self.sidebar_w;
144         }
145         let font_info = crate::layout::menubar_font_parsed();
146         let font_fam = font_info.0;
147         let font_size = font_info.1;
148         let padding = crate::layout::button_padding();
149 
150         let _ = font_fam;
151         // A page with a glyph (`with_icons`) needs the glyph's width; one
152         // without, a line of its rotated label.
153         let content_w = if self.has_icons { ButtonStrip::ICON_SIDE.max(font_size) } else { font_size };
154         let max_w = content_w + 2.0 * padding;
155         max_w.max(1.0)
156     }
157 }
158 
159 impl Layout for Paginator {
160     /// Keep the rect the strip is placed from (the strip is the context's, and `set_rect`
161     /// has none: it is placed in `register_embedded_children`, or here while held by value).
162     fn arrange_children(&mut self, rect: Rect) {
163         self.rect = rect;
164         let sidebar_w = self.sidebar_w();
165         if let Some(strip) = self.sidebar_menu.here_mut() {
166             strip.set_rect(rect.x, rect.y, sidebar_w, rect.height);
167         }
168     }
169 
170     /// The strip in the context, linked under the paginator, on the left at its measured
171     /// width. A page chosen through the paginator is shown on it; a tab chosen on the strip
172     /// itself (the router reaches the linked strip before the paginator, and its click can
173     /// end there) becomes the paginator's page.
174     fn register_embedded_children(&mut self, host_id: WidgetId, ctx: &mut UiContext) {
175         self.sidebar_menu.attach(ctx);
176         let (rect, sidebar_w) = (self.rect, self.sidebar_w());
177         let strip = self.sidebar_menu.get_mut(ctx);
178         strip.set_rect(rect.x, rect.y, sidebar_w, rect.height);
179         if std::mem::take(&mut self.push_page) {
180             strip.inner_mut().set_selected(Some(self.selected_page));
181         } else if let Some(page) = strip.inner().selected {
182             self.selected_page = page;
183         }
184         ctx.link_ids(host_id, self.sidebar_menu.id());
185     }
186 
187     fn release_embedded_children(&mut self, ctx: &mut UiContext) {
188         self.sidebar_menu.detach(ctx);
189     }
190 }
191 
192 impl Paint for Paginator {
193     fn color(&self) -> [f32; 4] {
194         colors::sidebar_bg_color()
195     }
196 
197     /// Own geometry: the sidebar background plus the page-area background — the latter is the
198     /// one visual the former empty `Page` stack contributed (its bg quad over the content
199     /// area), painted directly since the pages folded away (Phase 6au). Both round at the
200     /// plate radius; the page area keeps only its outer corners so the seam with the sidebar
201     /// stays straight.
202     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
203         let r = crate::layout::plate_corner_radius();
204         let c = self.color();
205         if c[3] > 0.0 {
206             ctx.rounded_rect(rect, r, (true, true, true, true), c);
207         }
208         if !self.page_labels.is_empty() {
209             let mut pc = colors::page_color();
210             pc[3] *= crate::layout::page_opacity();
211             if pc[3] > 0.0 {
212                 let sidebar_w = self.sidebar_w();
213                 let page_rect = Rect {
214                     x: rect.x + sidebar_w,
215                     y: rect.y,
216                     width: (rect.width - sidebar_w).max(0.0),
217                     height: rect.height,
218                 };
219                 ctx.rounded_rect(page_rect, r, (false, true, true, false), pc);
220             }
221         }
222     }
223 }
224 
225 impl Input for Paginator {
226     fn blocks_root_plate_drag(&self) -> bool {
227         false
228     }
229 
230     /// Legacy `mouse_input` saw every press — the strip and pages ran their own hit checks.
231     fn gates_presses(&self) -> bool {
232         false
233     }
234 
235     fn wants_tick(&self) -> bool {
236         true
237     }
238 
239     /// Event proxying, the legacy forwarding body: the strip, draining its click into the page
240     /// selection. (The former empty pages also received every event, but had nothing to do with
241     /// them — no children, never scrollable.)
242     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
243         let Some(ui) = ectx.ui.as_deref_mut() else {
244             return false;
245         };
246         match event {
247             Event::PointerMove { x: px, y: py, .. } => {
248                 self.sidebar_menu.lend(ui, |w, ui| w.cursor_moved(*px, *py, ui)).unwrap_or(false)
249             }
250             Event::MouseButton { button, state, x: px, y: py, .. } => {
251                 let mut changed = false;
252                 let clicked = self.sidebar_menu.lend(ui, |w, ui| {
253                     w.mouse_input(*button, *state, *px, *py, ui).then(|| w.inner_mut().take_click())
254                 });
255                 if let Some(Some(click)) = clicked {
256                     changed = true;
257                     if let Some(idx) = click {
258                         self.set_selected_page(idx);
259                         self.just_clicked = Some(idx);
260                     }
261                 }
262                 changed
263             }
264             Event::MouseWheel { delta, x: px, y: py, .. } => {
265                 self.sidebar_menu.lend(ui, |w, ui| w.mouse_wheel(delta, *px, *py, ui)).unwrap_or(false)
266             }
267             Event::KeyInput(key_event) => self.sidebar_menu.lend(ui, |w, ui| w.keyboard_input(key_event, ui)).unwrap_or(false),
268             _ => false,
269         }
270     }
271 
272 }
273 
274 impl MenuController for Paginator {
275     fn menu_click(&mut self) -> Option<(usize, usize)> {
276         self.just_clicked.take().map(|idx| (idx, 0))
277     }
278 
279     fn trigger_menu_click(&mut self, _menu_idx: usize, _item_idx: usize) {}
280     fn set_item_checked(&mut self, _menu_idx: usize, _item_idx: usize, _checked: bool) {}
281     fn set_menu_items(&mut self, _menu_idx: usize, _items: &[String]) {}
282     fn is_menu_bar(&self) -> bool { false }
283     fn is_menu_open(&self) -> bool { false }
284     fn menu_items(&self) -> Vec<String> { Vec::new() }
285     fn menu_item_checked(&self) -> Vec<Option<bool>> { Vec::new() }
286     fn is_vertical(&self) -> bool { true }
287     fn menu_names(&self) -> Vec<String> { Vec::new() }
288     fn menu_items_list(&self) -> Vec<Vec<String>> { Vec::new() }
289     fn menu_checked_list(&self) -> Vec<Vec<Option<bool>>> { Vec::new() }
290     fn take_context_change(&mut self) -> Option<usize> { None }
291     fn set_context_selected(&mut self, _selected: usize) {}
292     fn set_center_items(&mut self, _center: bool) {}
293     fn get_menu_items_at(&self, _px: f32, _py: f32) -> Option<(usize, String, Vec<String>, f32, f32, f32, f32)> { None }
294 }
295 
296 #[cfg(test)]
297 mod tests {
298     use super::*;
299     use crate::context::UiContext;
300     use crate::widget::{ElementState, Handle, MouseButton};
301 
302     /// A paginator in `ctx`, placed: inserting it puts its strip in too, and the embedded
303     /// hook places the strip.
304     fn paginator(ctx: &mut UiContext) -> Handle<Adapted<Paginator>> {
305         let h = ctx.insert(Paginator::new(vec!["One".to_string(), "Two".to_string()]));
306         ctx.lend_h(h, |p, ctx| {
307             WidgetHost::set_rect(p, 0.0, 0.0, 400.0, 300.0);
308             p.attach_embedded(ctx);
309         });
310         h
311     }
312 
313     #[test]
314     fn sidebar_click_switches_page_and_drains_menu_click() {
315         let mut ctx = UiContext::new();
316         let h = paginator(&mut ctx);
317 
318         // Click the second tab (the strip commits selection on release): the selection moves
319         // and menu_click reports (1, 0) once.
320         let (bx, by, bw, bh) = ctx[h].sidebar_menu.get(&ctx).item_rect(1);
321         assert!(bw > 0.0, "strip placed by the embedded hook");
322         let (cx, cy) = (bx + bw / 2.0, by + bh / 2.0);
323         ctx.lend_h(h, |p, ctx| {
324             p.mouse_input(MouseButton::Left, ElementState::Pressed, cx, cy, ctx);
325             p.mouse_input(MouseButton::Left, ElementState::Released, cx, cy, ctx);
326         });
327         let p = &mut ctx[h];
328         assert_eq!(p.selected_page, 1);
329         assert_eq!(MenuController::menu_click(&mut **p), Some((1, 0)));
330         assert_eq!(MenuController::menu_click(&mut **p), None, "click drained");
331 
332         // The PageSelector capability is reached through the concrete adapter (the
333         // cce-test-interface downcast shape).
334         assert_eq!(PageSelector::selected_page(&**p), 1);
335         assert!(PageSelector::sidebar_w(&**p) > 0.0);
336     }
337 
338     #[test]
339     fn the_sidebar_bg_is_rounded_and_the_strip_is_the_contexts() {
340         let mut ctx = UiContext::new();
341         let h = paginator(&mut ctx);
342 
343         // The sidebar background is a rounded rect of the paginator's own paint, never a
344         // plain quad.
345         let bg = colors::sidebar_bg_color();
346         if bg[3] > 0.0 {
347             let r = crate::layout::plate_corner_radius();
348             let bg_quad = (0.0, 0.0, 400.0, 300.0, r, bg, (true, true, true, true));
349             assert!(!crate::widget::shown_quads(&ctx[h]).iter().any(|q| q.4 == bg && q.2 == 400.0), "no plain bg");
350             assert!(crate::widget::shown_rounded_quads(&ctx[h]).contains(&bg_quad), "a rounded bg");
351         }
352 
353         // The strip is an entry of the context's own, linked under the paginator (the spatial
354         // grid feeds off it — the registered strip is what blocks root plate drags over the
355         // sidebar, and the walks reach it through the link).
356         let strip_id = ctx[h].sidebar_menu.id();
357         assert!(ctx.tree.is_registered(strip_id), "the strip is the context's");
358         assert!(ctx.tree.child_ids(h.id()).contains(&strip_id), "linked under the paginator");
359     }
360 
361     /// A page chosen through the paginator reaches its strip at the next layout or tick, and
362     /// a paginator taken out of the context comes back with its strip in it.
363     #[test]
364     fn the_strip_follows_the_page_and_leaves_with_the_paginator() {
365         let mut ctx = UiContext::new();
366         let h = paginator(&mut ctx);
367         PageSelector::set_selected_page(&mut *ctx[h], 1);
368         ctx.lend_h(h, |p, ctx| WidgetHost::tick(p, 0.016, ctx));
369         assert_eq!(ctx[h].sidebar_menu.get(&ctx).inner().selected, Some(1));
370 
371         // A tab clicked on the strip itself (the router reaches the linked strip before the
372         // paginator) stays chosen, and becomes the paginator's page.
373         let strip = ctx[h].sidebar_menu.handle().expect("the strip is the context's");
374         let (bx, by, bw, bh) = ctx[strip].item_rect(0);
375         let (cx, cy) = (bx + bw / 2.0, by + bh / 2.0);
376         ctx.lend_h(strip, |s, ctx| {
377             s.mouse_input(MouseButton::Left, ElementState::Pressed, cx, cy, ctx);
378             s.mouse_input(MouseButton::Left, ElementState::Released, cx, cy, ctx);
379         });
380         ctx.lend_h(h, |p, ctx| WidgetHost::tick(p, 0.016, ctx));
381         assert_eq!(ctx[strip].inner().selected, Some(0), "the tick left the strip's choice alone");
382         assert_eq!(ctx[h].selected_page, 0);
383 
384         let strip_id = ctx[h].sidebar_menu.id();
385         let p = ctx.remove(h).expect("given back");
386         assert!(!ctx.tree.is_registered(strip_id), "the strip left with it");
387         assert_eq!(p.sidebar_menu.here().map(|s| s.inner().selected), Some(Some(0)));
388     }
389 }