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 }