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 }