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

src/widget/display/float3.rs (14.9K)

  1 //! Narrow-trait `Float3` — a labeled group of three STANDARD [`Slider`]s (X/Y/Z), each with
  2 //! the toolkit's readout, embedded by value inside `ParametersBg` (its only consumer), which
  3 //! drives it through direct `WidgetHost` calls. The group label is the ordinary detached
  4 //! control label (the adapter's, exactly like a slider row's); below it sit three `Adapted<Slider>` children in
  5 //! whatever style the DE config gives every other slider (the band that swallowed the rodent,
  6 //! the recessed well, the square track), each fronted by its axis letter. The model caches its
  7 //! laid-out rect ([`Layout::rect_assigned`]) and lays the children out from it; paint and input
  8 //! delegate to them, so the rows look and feel like a plain slider row rather than the bespoke
  9 //! flat track + square thumb this widget used to draw.
 10 //!
 11 //! Hosts reading this panel through the legacy flat views get the children's quads, rounded
 12 //! rects and text through the adapter's prim bridges (the sub-sliders paint into this widget's
 13 //! own [`Paint::paint`]); their relief prims (recessed-track carve, thumb sphere) cannot ride
 14 //! those views and travel through [`Float3::sliders`] + [`Float3::get_row_rects`] instead, the
 15 //! way `ParametersBg::reliefs` / `spheres` read a slider row.
 16 
 17 use crate::context::UiContext;
 18 use crate::scene::layout::{Rect, Size};
 19 use crate::scene::paint::PaintCtx;
 20 use crate::widget::input::Slider;
 21 use crate::widget::{
 22     Adapted, ElementState, Event, EventCtx, Input, Layout, MouseButton, MouseScrollDelta,
 23     Paint, WidgetHost,
 24 };
 25 
 26 /// Vertical gap between the three slider rows.
 27 const ROW_GAP: f32 = 4.0;
 28 /// The axis-letter column left of each slider.
 29 const AXIS_W: f32 = 16.0;
 30 /// Readout / edit-buffer precision of the rows, and of [`Float3::value_string`].
 31 const DECIMALS: usize = 2;
 32 
 33 pub struct Float3 {
 34     /// The assigned (label-inclusive) rect.
 35     rect: Rect,
 36     sliders: [Adapted<Slider>; 3],
 37     axes: [&'static str; 3],
 38     label: Option<String>,
 39     dragging_idx: Option<usize>,
 40 }
 41 
 42 impl Float3 {
 43     pub fn new() -> Adapted<Float3> {
 44         let row = || Slider::new().with_readout(true).with_decimals(DECIMALS);
 45         Adapted::new(Float3 {
 46             rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
 47             sliders: [row(), row(), row()],
 48             axes: ["X", "Y", "Z"],
 49             label: None,
 50             dragging_idx: None,
 51         })
 52     }
 53 
 54     /// The height a labeled (`labeled`) group lays out to: the detached label band plus three slider rows and their gaps — the row-height table entry.
 55     pub fn preferred_height(labeled: bool) -> f32 {
 56         let top = if labeled { crate::layout::control_label_strip() } else { 0.0 };
 57         top + 3.0 * crate::layout::slider_height() + 2.0 * ROW_GAP
 58     }
 59 
 60     /// Normalized (0..1) values, X/Y/Z.
 61     pub fn values(&self) -> [f32; 3] {
 62         [self.sliders[0].value, self.sliders[1].value, self.sliders[2].value]
 63     }
 64 
 65     /// Normalized values in; each row clamps to 0..1.
 66     pub fn set_values(&mut self, values: [f32; 3]) {
 67         for (s, v) in self.sliders.iter_mut().zip(values) {
 68             s.set_value(v);
 69         }
 70     }
 71 
 72     /// The row whose readout is open for typing, if any.
 73     pub fn editing_idx(&self) -> Option<usize> {
 74         self.sliders.iter().position(|s| s.editing)
 75     }
 76 
 77     /// The scaled values as the `x:y:z` row string (`DECIMALS` places) the parameter pane
 78     /// stores — the one formatter for every host sync.
 79     pub fn value_string(&self) -> String {
 80         let v = |i: usize| self.sliders[i].get_scaled_value();
 81         format!("{:.*}:{:.*}:{:.*}", DECIMALS, v(0), DECIMALS, v(1), DECIMALS, v(2))
 82     }
 83 
 84     /// The three rows, X/Y/Z — for hosts that draw this group through the legacy flat views
 85     /// and need each row's relief prims (`track_relief`, `thumb_sphere`) over its
 86     /// [`Self::get_row_rects`] rect.
 87     pub fn sliders(&self) -> &[Adapted<Slider>; 3] {
 88         &self.sliders
 89     }
 90 
 91     /// Detached-label band above the rows (zero unlabeled) — the adapter's
 92     /// `Widget::label_offset` over the synced label.
 93     fn label_top(&self) -> f32 {
 94         crate::widget::input::slider::detached_strip(&self.label)
 95     }
 96 
 97     /// The three slider rows' rects (`(x, y, w, h)`, X/Y/Z), laid out below the label band and
 98     /// right of the axis-letter column. Each is exactly the rect its sub-slider is assigned.
 99     pub fn get_row_rects(&self) -> Vec<(f32, f32, f32, f32)> {
100         let top = self.rect.y + self.label_top();
101         let x = self.rect.x + AXIS_W;
102         let w = (self.rect.width - AXIS_W).max(10.0);
103         let h = crate::layout::slider_height();
104         (0..3).map(|i| (x, top + i as f32 * (h + ROW_GAP), w, h)).collect()
105     }
106 
107     fn layout_rows(&mut self) {
108         let rows = self.get_row_rects();
109         for (s, r) in self.sliders.iter_mut().zip(rows) {
110             s.set_rect(r.0, r.1, r.2, r.3);
111         }
112     }
113 
114     /// Wheel over the rows, the parameter pane's slider-row contract: under the band style the
115     /// capture zone is each row's shape halo ([`Slider::scroll_hit`]) or its gesture latch,
116     /// otherwise the row's rect; a row in zone takes the wheel ungated (the halo already gated
117     /// spatially, and the adapter's rect gate would clip its fringe). Returns whether a row took
118     /// it, whether or not the value string ticked over.
119     pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ui: &mut UiContext) -> bool {
120         let rows = self.get_row_rects();
121         for (s, r) in self.sliders.iter_mut().zip(rows) {
122             let rect = Rect { x: r.0, y: r.1, width: r.2, height: r.3 };
123             let latched = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(s.base().id());
124             let in_zone = latched || s.inner().scroll_hit(rect, px, py);
125             if !in_zone {
126                 continue;
127             }
128             let was_scroll = s.scroll_enabled;
129             s.set_scroll(true);
130             let taken = s.mouse_wheel_ungated(delta, px, py, ui);
131             s.set_scroll(was_scroll);
132             if taken {
133                 return true;
134             }
135         }
136         false
137     }
138 }
139 
140 impl Adapted<Float3> {
141     pub fn with_values(mut self, values: [f32; 3]) -> Self {
142         self.set_values(values);
143         self
144     }
145 
146     pub fn with_range(mut self, min: f32, max: f32) -> Self {
147         for s in self.sliders.iter_mut() {
148             s.set_range(min, max);
149         }
150         self
151     }
152 }
153 
154 impl Layout for Float3 {
155     /// The Slider convention: the label eats into the assigned rect, the host sizes the row
156     /// for it ([`Float3::preferred_height`]).
157 
158     /// The three rows alone: the adapter adds the detached-label strip itself
159     /// (`Adapted::preferred_height`), as it does for every non-inflating widget.
160     fn intrinsic_size(&self) -> Option<Size> {
161         Some(Size::new(0.0, Float3::preferred_height(false)))
162     }
163 
164     fn rect_assigned(&mut self, rect: Rect) {
165         self.rect = rect;
166         self.layout_rows();
167     }
168 }
169 
170 impl Paint for Float3 {
171     fn color(&self) -> [f32; 4] {
172         [0.0, 0.0, 0.0, 0.0]
173     }
174 
175     fn widget_font(&self) -> Option<String> {
176         Some(crate::layout::control_label_font_detached())
177     }
178 
179     fn sync_label(&mut self, label: &str) {
180         self.label = Some(label.to_string());
181         self.layout_rows();
182     }
183 
184     /// The axis letters and the three rows, each painted by its own slider over its row rect
185     /// (an unlabeled slider's content rect is its whole rect). The group label is the adapter's
186     /// detached label, like a slider row's.
187     fn paint(&self, _rect: Rect, ctx: &mut PaintCtx) {
188         let rows = self.get_row_rects();
189         for (i, r) in rows.into_iter().enumerate() {
190             let rect = Rect { x: r.0, y: r.1, width: r.2, height: r.3 };
191             // Bounded to the row PLUS its gutter: the axis letter is drawn to
192             // the left of the row rect by design, so the row alone would clip
193             // it away entirely.
194             ctx.text_with(
195                 self.axes[i].to_string(),
196                 rect.x - AXIS_W + 2.0,
197                 crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
198                 12.0,
199                 [0xaa, 0xaa, 0xbb],
200                 None,
201                 Some([rect.x - AXIS_W, rect.y, rect.x + rect.width, rect.y + rect.height]),
202             );
203             Paint::paint(&*self.sliders[i], rect, ctx);
204         }
205     }
206 }
207 
208 impl Input for Float3 {
209     fn draggable(&self, _rect: Rect) -> bool {
210         self.dragging_idx.is_some()
211     }
212 
213     fn is_dragging(&self) -> bool {
214         self.dragging_idx.is_some()
215     }
216 
217     /// A host-driven drag begins on the row under the pointer — unless a press already
218     /// started one (the pane's press path), in which case that row keeps it.
219     fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
220         if self.dragging_idx.is_some() {
221             return;
222         }
223         let rows = self.get_row_rects();
224         for (i, r) in rows.into_iter().enumerate() {
225             if py >= r.1 && py <= r.1 + r.3 {
226                 self.sliders[i].drag_begin(px, py);
227                 self.dragging_idx = Some(i);
228                 return;
229             }
230         }
231     }
232 
233     fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
234         match self.dragging_idx {
235             Some(i) => self.sliders[i].drag_update(px, py),
236             None => false,
237         }
238     }
239 
240     fn drag_end(&mut self) {
241         if let Some(i) = self.dragging_idx.take() {
242             self.sliders[i].drag_end();
243         }
244     }
245 
246     /// The rows' wheel-glide inertia.
247     fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
248         let mut dummy = UiContext::new();
249         let mut changed = false;
250         for s in self.sliders.iter_mut() {
251             changed |= WidgetHost::tick(s, dt, &mut dummy);
252         }
253         changed
254     }
255 
256     fn take_change(&mut self) -> bool {
257         let mut any = false;
258         for s in self.sliders.iter_mut() {
259             any |= s.take_change();
260         }
261         any
262     }
263 
264     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
265         match event {
266             Event::MouseButton { button, state, x: px, y: py, .. } => {
267                 if *button != MouseButton::Left {
268                     return false;
269                 }
270                 let mut dummy = UiContext::new();
271                 match state {
272                     ElementState::Pressed => {
273                         let rows = self.get_row_rects();
274                         for (i, r) in rows.into_iter().enumerate() {
275                             if *py < r.1 || *py > r.1 + r.3 {
276                                 continue;
277                             }
278                             // The child's own readout click claims focus through the
279                             // dummy ctx (a no-op beyond the thread-local slot); the
280                             // GROUP is the host's focus target, as before.
281                             if !self.sliders[i].mouse_input(*button, *state, *px, *py, &mut dummy) {
282                                 continue;
283                             }
284                             if self.sliders[i].is_dragging() {
285                                 self.dragging_idx = Some(i);
286                             }
287                             if self.sliders[i].editing {
288                                 for (j, s) in self.sliders.iter_mut().enumerate() {
289                                     if j != i && s.editing {
290                                         s.unfocus();
291                                     }
292                                 }
293                                 ectx.request_focus();
294                             }
295                             return true;
296                         }
297                         false
298                     }
299                     ElementState::Released => {
300                         let mut any = false;
301                         for s in self.sliders.iter_mut() {
302                             any |= s.mouse_input(*button, *state, *px, *py, &mut dummy);
303                         }
304                         if self.dragging_idx.take().is_some() {
305                             any = true;
306                         }
307                         any
308                     }
309                 }
310             }
311             Event::MouseWheel { delta, x: px, y: py, .. } => {
312                 let mut dummy = UiContext::new();
313                 let ui = ectx.ui.as_deref_mut();
314                 match ui {
315                     Some(ui) => self.wheel(delta, *px, *py, ui),
316                     None => self.wheel(delta, *px, *py, &mut dummy),
317                 }
318             }
319             Event::PointerMove { x: px, y: py, .. } => {
320                 let mut dummy = UiContext::new();
321                 let mut changed = false;
322                 for s in self.sliders.iter_mut() {
323                     changed |= s.on_cursor_moved(*px, *py, &mut dummy);
324                 }
325                 changed
326             }
327             Event::KeyInput(key_event) => {
328                 let mut dummy = UiContext::new();
329                 for s in self.sliders.iter_mut() {
330                     if s.editing {
331                         return s.keyboard_input(key_event, &mut dummy);
332                     }
333                 }
334                 false
335             }
336             // Focus loss commits every open readout edit (each row's own FocusOut).
337             Event::FocusOut => {
338                 for s in self.sliders.iter_mut() {
339                     s.unfocus();
340                 }
341                 true
342             }
343             _ => false,
344         }
345     }
346 }
347 
348 #[cfg(test)]
349 mod tests {
350     use super::*;
351 
352     /// The ParametersBg drive pattern: readout click opens the row's edit, Enter/unfocus
353     /// commits back into the normalized value, a track press starts a drag.
354     #[test]
355     fn readout_edit_commits_on_unfocus() {
356         let mut ctx = UiContext::new();
357         let mut f = Float3::new().with_values([0.5, 0.5, 0.5]).with_range(0.0, 10.0);
358         WidgetHost::set_rect(&mut f, 0.0, 0.0, 300.0, Float3::preferred_height(false));
359 
360         let rows = f.get_row_rects();
361         assert_eq!(rows.len(), 3);
362         assert_eq!(f.value_string(), "5.00:5.00:5.00");
363         // Click row 1's readout (the 60px box at the row's right end).
364         let rx = rows[1].0 + rows[1].2 - 30.0;
365         let ry = rows[1].1 + rows[1].3 * 0.5;
366         assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, rx, ry, &mut ctx));
367         assert_eq!(f.editing_idx(), Some(1));
368 
369         f.sliders[1].set_value_string("7.5");
370         WidgetHost::unfocus(&mut f);
371         assert_eq!(f.editing_idx(), None);
372         assert!((f.values()[1] - 0.75).abs() < 1e-4, "7.5 of 0..10 normalizes to 0.75");
373 
374         // Track press starts a drag; drag_update moves the value; release ends it.
375         let track_x = rows[0].0 + 20.0;
376         let track_y = rows[0].1 + rows[0].3 * 0.5;
377         assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, track_x, track_y, &mut ctx));
378         assert!(f.is_dragging());
379         f.drag_update(track_x + 100.0, track_y);
380         assert!(f.values()[0] > 0.5, "drag right raises the value");
381         f.drag_end();
382         assert!(!f.is_dragging());
383     }
384 }