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 }