GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/input/ramp.rs (77.5K)
1 use crate::colors;
2 use crate::scene::layout::{Rect, Size};
3 use crate::scene::paint::{Cap, PaintCtx};
4 use crate::widget::model::{EventCtx, Input, Layout, Paint};
5 use crate::widget::*;
6 use crate::widget::input::{Slider, Slider2D, Button};
7
8 // ==========================================
9 // 1. Color Ramp (renamed from Ramp)
10 // ==========================================
11
12 #[derive(Debug, Clone)]
13 pub struct ColorRampKey {
14 pub pos: f32,
15 pub color: [f32; 3],
16 }
17
18 pub struct ColorRamp {
19 pub base: Widget,
20 pub keys: Vec<ColorRampKey>,
21 pub selected_key_idx: Option<usize>,
22 pub is_dragging_key: bool,
23 pub just_changed: bool,
24
25 // Child controls for color editing & deletion
26 pub r_slider: Adapted<Slider>,
27 pub g_slider: Adapted<Slider>,
28 pub b_slider: Adapted<Slider>,
29 pub del_button: Adapted<Button>,
30
31 }
32
33 impl ColorRamp {
34 pub fn new() -> Adapted<ColorRamp> {
35 let keys = vec![
36 ColorRampKey { pos: 0.0, color: [0.0, 0.0, 0.0] },
37 ColorRampKey { pos: 1.0, color: [1.0, 1.0, 1.0] },
38 ];
39
40 let r_slider = Slider::new().with_label("Red");
41 let g_slider = Slider::new().with_label("Green");
42 let b_slider = Slider::new().with_label("Blue");
43 let del_button = Button::new(0.0, 0.0, 70.0, 28.0).with_label("Delete Key");
44
45 Adapted::new(ColorRamp {
46 base: Widget::new(),
47 keys,
48 selected_key_idx: None,
49 is_dragging_key: false,
50 just_changed: false,
51 r_slider,
52 g_slider,
53 b_slider,
54 del_button,
55 })
56 }
57
58 pub fn get_interpolated_color(&self, t: f32) -> [f32; 3] {
59 if self.keys.is_empty() {
60 return [0.0, 0.0, 0.0];
61 }
62 if t <= self.keys[0].pos {
63 return self.keys[0].color;
64 }
65 if t >= self.keys[self.keys.len() - 1].pos {
66 return self.keys[self.keys.len() - 1].color;
67 }
68
69 for i in 0..self.keys.len() - 1 {
70 let k1 = &self.keys[i];
71 let k2 = &self.keys[i+1];
72 if t >= k1.pos && t <= k2.pos {
73 let range = k2.pos - k1.pos;
74 if range.abs() < 0.0001 {
75 return k1.color;
76 }
77 let w = (t - k1.pos) / range;
78 return [
79 k1.color[0] * (1.0 - w) + k2.color[0] * w,
80 k1.color[1] * (1.0 - w) + k2.color[1] * w,
81 k1.color[2] * (1.0 - w) + k2.color[2] * w,
82 ];
83 }
84 }
85 self.keys[0].color
86 }
87
88 fn sort_keys(&mut self) {
89 let prev_selected_id = self.selected_key_idx.map(|idx| self.keys[idx].pos);
90 self.keys.sort_by(|a, b| a.pos.partial_cmp(&b.pos).unwrap());
91 if let Some(pos) = prev_selected_id {
92 if let Some(new_idx) = self.keys.iter().position(|k| (k.pos - pos).abs() < 0.0001) {
93 self.selected_key_idx = Some(new_idx);
94 }
95 }
96 }
97 }
98
99
100
101
102 // ==========================================
103 // 2. Houdini-Style Float Ramp
104 // ==========================================
105
106 #[derive(Debug, Clone)]
107 pub struct RampKey {
108 pub pos: f32,
109 pub value: f32,
110 }
111
112 pub struct Ramp {
113 pub base: Widget,
114 pub keys: Vec<RampKey>,
115 pub selected_key_idx: Option<usize>,
116 pub is_dragging_key: bool,
117 pub just_changed: bool,
118 /// The key latched by the current hover-scroll gesture: a trackpad
119 /// scroll starting over a key steers that key until the fingers lift
120 /// (a >250ms pause reads as a new gesture and re-latches by hover).
121 scroll_key_idx: Option<usize>,
122 /// Context-menu toggle: hide the bottom control strip and let the graph
123 /// claim its space.
124 pub controls_collapsed: bool,
125 /// Hover-scroll glide velocity (plot units/sec, applied-delta signs) and
126 /// the last scroll-event instant: when the event stream stops, the tick
127 /// keeps the latched key coasting with exponential decay.
128 scroll_vel: (f32, f32),
129 last_key_scroll: Option<web_time::Instant>,
130
131 // Child controls for key editing & deletion. The key pad is a 2-axis
132 // slider driving the selected key's position (x) and value (y).
133 pub key_pad: Adapted<Slider2D>,
134 pub del_button: Adapted<Button>,
135 pub preset_dropdown: Adapted<Dropdown>,
136 pub line_type_dropdown: Adapted<Dropdown>,
137 }
138
139 impl Ramp {
140 /// How many fields take the keyboard, in order: the two dropdowns, then the key pad and
141 /// the delete button while a key is selected.
142 fn field_count(&self) -> usize {
143 if self.selected_key_idx.is_some() { 4 } else { 2 }
144 }
145
146 fn field(&mut self, i: usize) -> &mut dyn WidgetHost {
147 match i {
148 0 => &mut self.preset_dropdown,
149 1 => &mut self.line_type_dropdown,
150 2 => &mut self.key_pad,
151 _ => &mut self.del_button,
152 }
153 }
154
155 fn field_id(&self, i: usize) -> WidgetId {
156 match i {
157 0 => self.preset_dropdown.base().id(),
158 1 => self.line_type_dropdown.base().id(),
159 2 => self.key_pad.base().id(),
160 _ => self.del_button.base().id(),
161 }
162 }
163
164 /// The field the window's focus is on, if it is one of the ramp's.
165 fn focused_field(&self, ui: &UiContext) -> Option<usize> {
166 let focused = ui.focused_widget?;
167 (0..4).find(|&i| self.field_id(i) == focused)
168 }
169
170 /// Give field `i` the keyboard: the window's focus record names it (a field checks the
171 /// record before it takes a key), and it is told. The field is the ramp's own value and
172 /// never enters the registry; keys reach the ramp, which hands them to the field the
173 /// record names (`on_event`).
174 fn focus_field(&mut self, i: usize, ui: &mut UiContext) {
175 if let Some(old) = self.focused_field(ui).filter(|&o| o != i) {
176 self.field(old).unfocus();
177 }
178 ui.claim_focus(self.field_id(i));
179 self.field(i).focus();
180 }
181
182 pub fn new() -> Adapted<Ramp> {
183 let keys = vec![
184 RampKey { pos: 0.0, value: 0.5 },
185 RampKey { pos: 0.2, value: 1.0 },
186 RampKey { pos: 0.8, value: 1.0 },
187 RampKey { pos: 1.0, value: 0.5 },
188 ];
189
190 // The key pad: a 2-axis slider driving the selected key's position
191 // (x) and value (y), labeled like the dropdowns.
192 let key_pad = Slider2D::new().with_label("Key");
193 // A square x-icon button (cce-icons); label fallback if the icon set
194 // is missing on this machine. By NAME, not by a captured id: an id
195 // does not survive the renderer rebuild a reconnect performs, and the
196 // widget outlives the renderer (see `Button::icon_name`).
197 let del_button =
198 Button::new(0.0, 0.0, 22.0, 22.0).with_icon_name("x", &crate::l10n::tr("ramp-delete-key"));
199 // Short names on purpose: the strip's columns are narrow, and these
200 // render inside param rows too ("Bevel (Raised)" used to clip).
201 // Labeled: the dropdowns draw their own detached labels, sitting on
202 // the expanded top wall of their inset (the labeled-relief style).
203 // The presets, and nothing else: a curve edited by hand is no
204 // preset, and the trigger says so by going blank (`sync_preset`)
205 // rather than by a "Custom" entry that, picked, did nothing.
206 let preset_dropdown = Dropdown::new(
207 RAMP_PRESETS.iter().map(|(name, _)| name.to_string()).collect(),
208 1,
209 ).with_open_upward(true).with_label("Preset");
210 let line_type_dropdown = Dropdown::new(
211 vec![
212 "Linear".to_string(),
213 "Bezier".to_string(),
214 ],
215 0,
216 ).with_open_upward(true).with_label("Line");
217
218 Adapted::new(Ramp {
219 base: Widget::new(),
220 keys,
221 selected_key_idx: None,
222 is_dragging_key: false,
223 just_changed: false,
224 scroll_key_idx: None,
225 controls_collapsed: false,
226 scroll_vel: (0.0, 0.0),
227 last_key_scroll: None,
228 key_pad,
229 del_button,
230 preset_dropdown,
231 line_type_dropdown,
232 })
233 }
234
235 /// Replace the curve with preset `idx` of [`RAMP_PRESETS`] (an index
236 /// past the end changes nothing) and show it on the trigger.
237 pub fn apply_preset(&mut self, idx: usize) {
238 if let Some((_, keys)) = RAMP_PRESETS.get(idx) {
239 self.keys = keys.iter().map(|&(pos, value)| RampKey { pos, value }).collect();
240 }
241 self.selected_key_idx = None;
242 self.sync_preset();
243 self.just_changed = true;
244 }
245
246 /// Show on the preset trigger the preset the curve IS, or nothing when
247 /// it is none of them — after a hand edit, or a spec that is no preset.
248 /// Blank rather than a stale name, and the dropdown keeps a pick of the
249 /// preset it last showed live, so choosing it again puts it back.
250 pub fn sync_preset(&mut self) {
251 let matches = |keys: &[(f32, f32)]| {
252 self.keys.len() == keys.len()
253 && self.keys.iter().zip(keys).all(|(k, &(pos, value))| {
254 (k.pos - pos).abs() <= 0.0005 && (k.value - value).abs() <= 0.0005
255 })
256 };
257 match RAMP_PRESETS.iter().position(|(_, keys)| matches(keys)) {
258 Some(idx) => {
259 self.preset_dropdown.selected = idx;
260 self.preset_dropdown.custom_display_text = None;
261 }
262 None => self.preset_dropdown.custom_display_text = Some(String::new()),
263 }
264 }
265
266 /// The curve's value at `t` — [`crate::layout::sample_ramp_keys`], the
267 /// DE's one ramp interpolation, so what this widget draws is exactly
268 /// what every consumer of its spec string evaluates.
269 pub fn get_interpolated_value(&self, t: f32) -> f32 {
270 let keys: Vec<(f32, f32)> = self.keys.iter().map(|k| (k.pos, k.value)).collect();
271 crate::layout::sample_ramp_keys(&keys, self.smooth(), t)
272 }
273
274 /// Whether the curve is the smooth (monotone cubic) line type vs straight
275 /// segments — see [`crate::layout::sample_ramp_keys`].
276 pub fn smooth(&self) -> bool {
277 self.line_type_dropdown.selected == 1
278 }
279
280 /// This ramp's state as the DE's ramp spec string ([`format_ramp_spec`]).
281 pub fn spec_string(&self) -> String {
282 let keys: Vec<(f32, f32)> = self.keys.iter().map(|k| (k.pos, k.value)).collect();
283 format_ramp_spec(&keys, self.smooth())
284 }
285
286 /// Apply a spec string ([`parse_ramp_spec`]); returns whether anything changed.
287 /// Unparsable specs are ignored (keeps the current curve).
288 pub fn set_spec(&mut self, spec: &str) -> bool {
289 let Some((keys, smooth)) = parse_ramp_spec(spec) else {
290 return false;
291 };
292 let new_keys: Vec<RampKey> =
293 keys.into_iter().map(|(pos, value)| RampKey { pos, value }).collect();
294 let new_line = if smooth { 1 } else { 0 };
295 let changed = self.line_type_dropdown.selected != new_line
296 || self.keys.len() != new_keys.len()
297 || self
298 .keys
299 .iter()
300 .zip(new_keys.iter())
301 .any(|(a, b)| (a.pos - b.pos).abs() > 0.0005 || (a.value - b.value).abs() > 0.0005);
302 if changed {
303 self.keys = new_keys;
304 self.line_type_dropdown.selected = new_line;
305 self.selected_key_idx = None;
306 self.sync_preset();
307 self.arrange_fields();
308 }
309 changed
310 }
311 }
312
313 /// The ramp editor's presets, in the order its Preset dropdown lists them:
314 /// a name and the keys, `(pos, value)`, the curve is set to.
315 pub const RAMP_PRESETS: &[(&str, &[(f32, f32)])] = &[
316 ("Linear", &[(0.0, 0.0), (1.0, 1.0)]),
317 ("Raised", &[(0.0, 0.5), (0.2, 1.0), (0.8, 1.0), (1.0, 0.5)]),
318 ("Sunken", &[(0.0, 0.5), (0.2, 0.0), (0.8, 0.0), (1.0, 0.5)]),
319 ("Peak", &[(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)]),
320 ("Valley", &[(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)]),
321 ];
322
323 pub use cce_core::ramp::{format_ramp_spec, parse_ramp_spec};
324
325
326
327 impl ColorRamp {
328 fn arrange_fields(&mut self) {
329 let (x, y, w, h) = (self.base.x, self.base.y, self.base.w, self.base.h);
330 self.base.x = x;
331 self.base.y = y;
332 self.base.w = w;
333 self.base.h = h;
334
335
336 let th = crate::layout::ramp_height();
337 let sy = y + th + 55.0;
338 let slider_w = w - 100.0;
339
340 if self.selected_key_idx.is_some() {
341 self.r_slider.set_rect(x + 10.0, sy, slider_w, 20.0);
342 self.g_slider.set_rect(x + 10.0, sy + 25.0, slider_w, 20.0);
343 self.b_slider.set_rect(x + 10.0, sy + 50.0, slider_w, 20.0);
344 self.del_button.set_rect(x + w - 80.0, sy + 20.0, 70.0, 28.0);
345 } else {
346 self.r_slider.set_rect(-1000.0, -1000.0, 0.0, 0.0);
347 self.g_slider.set_rect(-1000.0, -1000.0, 0.0, 0.0);
348 self.b_slider.set_rect(-1000.0, -1000.0, 0.0, 0.0);
349 self.del_button.set_rect(-1000.0, -1000.0, 0.0, 0.0);
350 }
351
352 }
353 }
354
355 impl Layout for ColorRamp {
356 fn rect_assigned(&mut self, rect: Rect) {
357 let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
358 self.base.x = x;
359 self.base.y = y;
360 self.base.w = w;
361 self.base.h = h;
362
363
364 let th = crate::layout::ramp_height();
365 let sy = y + th + 55.0;
366 let slider_w = w - 100.0;
367
368 if self.selected_key_idx.is_some() {
369 self.r_slider.set_rect(x + 10.0, sy, slider_w, 20.0);
370 self.g_slider.set_rect(x + 10.0, sy + 25.0, slider_w, 20.0);
371 self.b_slider.set_rect(x + 10.0, sy + 50.0, slider_w, 20.0);
372 self.del_button.set_rect(x + w - 80.0, sy + 20.0, 70.0, 28.0);
373 } else {
374 self.r_slider.set_rect(-1000.0, -1000.0, 0.0, 0.0);
375 self.g_slider.set_rect(-1000.0, -1000.0, 0.0, 0.0);
376 self.b_slider.set_rect(-1000.0, -1000.0, 0.0, 0.0);
377 self.del_button.set_rect(-1000.0, -1000.0, 0.0, 0.0);
378 }
379
380 }
381
382 }
383
384 impl Paint for ColorRamp {
385 fn color(&self) -> [f32; 4] {
386 colors::ramp_background_color()
387 }
388
389 // Field children are ctx-linked for event propagation but painted here (gated on a
390 // key being selected) — the walk must not also descend.
391 fn paints_own_subtree(&self) -> bool {
392 true
393 }
394
395 fn paint(&self, _rect: Rect, pc: &mut PaintCtx) {
396 let quads: Vec<(f32, f32, f32, f32, [f32; 4])> = {
397 let mut quads = Vec::new();
398 let th = crate::layout::ramp_height();
399 let track_x = self.base.x + 10.0;
400 let track_w = self.base.w - 20.0;
401
402 // Draw outer container border
403 let bx = self.base.x;
404 let by = self.base.y;
405 let bw = self.base.w;
406 let bh = self.base.h;
407 let border_color = colors::ramp_border_color();
408 quads.push((bx, by, bw, 1.0, border_color)); // Top
409 quads.push((bx, by + bh - 1.0, bw, 1.0, border_color)); // Bottom
410 quads.push((bx, by, 1.0, bh, border_color)); // Left
411 quads.push((bx + bw - 1.0, by, 1.0, bh, border_color)); // Right
412
413 // Draw track border
414 quads.push((track_x - 1.0, self.base.y + 10.0 - 1.0, track_w + 2.0, th + 2.0, border_color));
415
416 // Draw interpolated track slices (e.g. 100 slices)
417 let slices = 100;
418 let slice_w = track_w / slices as f32;
419 for i in 0..slices {
420 let t1 = i as f32 / slices as f32;
421 let t2 = (i + 1) as f32 / slices as f32;
422 let center_t = (t1 + t2) / 2.0;
423 let col = self.get_interpolated_color(center_t);
424 let sx = track_x + t1 * track_w;
425 quads.push((sx, self.base.y + 10.0, slice_w, th, [col[0], col[1], col[2], 1.0]));
426 }
427
428 if self.selected_key_idx.is_some() {
429 quads.extend(crate::widget::shown_quads(&self.r_slider));
430 quads.extend(crate::widget::shown_quads(&self.g_slider));
431 quads.extend(crate::widget::shown_quads(&self.b_slider));
432 quads.extend(crate::widget::shown_quads(&self.del_button));
433 }
434
435 quads
436
437 };
438 for (qx, qy, qw, qh, qc) in quads {
439 pc.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
440 }
441 let circles: Vec<(f32, f32, f32, [f32; 4])> = {
442 let mut circles = Vec::new();
443 let th = crate::layout::ramp_height();
444 let track_x = self.base.x + 10.0;
445 let track_w = self.base.w - 20.0;
446 let py = self.base.y + 10.0 + th + 15.0;
447
448 for (idx, key) in self.keys.iter().enumerate() {
449 let cx = track_x + key.pos * track_w;
450 circles.push((cx, py, 7.0, [0.0, 0.0, 0.0, 0.8]));
451 circles.push((cx, py, 6.0, [key.color[0], key.color[1], key.color[2], 1.0]));
452 if Some(idx) == self.selected_key_idx {
453 circles.push((cx, py, 8.0, [0.49, 1.0, 1.0, 0.5]));
454 }
455 }
456
457 circles
458
459 };
460 for (cx, cy, r, c) in circles {
461 pc.circle(cx, cy, r, c);
462 }
463 if self.selected_key_idx.is_some() {
464 let dummy = UiContext::new();
465 self.r_slider.paint_self(&dummy, pc);
466 self.g_slider.paint_self(&dummy, pc);
467 self.b_slider.paint_self(&dummy, pc);
468 self.del_button.paint_self(&dummy, pc);
469 }
470 }
471 }
472
473 impl Input for ColorRamp {
474 fn wants_tick(&self) -> bool {
475 true
476 }
477
478 fn tick_ctx(&mut self, dt: f32, ectx: &mut EventCtx) -> bool {
479 // (The per-tick field-widget re-parenting is gone, 6bd: it was a dummy-ctx
480 // `set_parent` whose every effect was discarded — legacy behaved the same.)
481 let Some(ui) = ectx.ui.as_deref_mut() else {
482 return false;
483 };
484 let mut changed = self.just_changed;
485 self.just_changed = false;
486
487 if self.selected_key_idx.is_some() {
488 if self.r_slider.tick(dt, ui) {
489 if let Some(idx) = self.selected_key_idx {
490 self.keys[idx].color[0] = self.r_slider.inner().value();
491 }
492 changed = true;
493 }
494 if self.g_slider.tick(dt, ui) {
495 if let Some(idx) = self.selected_key_idx {
496 self.keys[idx].color[1] = self.g_slider.inner().value();
497 }
498 changed = true;
499 }
500 if self.b_slider.tick(dt, ui) {
501 if let Some(idx) = self.selected_key_idx {
502 self.keys[idx].color[2] = self.b_slider.inner().value();
503 }
504 changed = true;
505 }
506 if self.del_button.tick(dt, ui) {
507 changed = true;
508 }
509 }
510 changed
511
512 }
513
514 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
515 match event {
516 Event::MouseButton { button, state, x, y, .. } => {
517 let (button, state, px, py_event) = (*button, *state, *x, *y);
518 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
519 if button != MouseButton::Left { return false; }
520
521 let th = crate::layout::ramp_height();
522 let track_x = self.base.x + 10.0;
523 let track_w = self.base.w - 20.0;
524 let py_peg = self.base.y + 10.0 + th + 15.0;
525
526 if state == ElementState::Pressed {
527 for (idx, key) in self.keys.iter().enumerate() {
528 let cx = track_x + key.pos * track_w;
529 let dx = px - cx;
530 let dy = py_event - py_peg;
531 if (dx*dx + dy*dy) <= 64.0 {
532 self.selected_key_idx = Some(idx);
533 self.is_dragging_key = true;
534 self.r_slider.set_value(key.color[0]);
535 self.g_slider.set_value(key.color[1]);
536 self.b_slider.set_value(key.color[2]);
537 self.arrange_fields();
538 return true;
539 }
540 }
541
542 if px >= track_x && px <= track_x + track_w && py_event >= self.base.y + 10.0 && py_event <= self.base.y + 10.0 + th {
543 let t = (px - track_x) / track_w;
544 let col = self.get_interpolated_color(t);
545 let new_key = ColorRampKey { pos: t, color: col };
546 self.keys.push(new_key);
547 self.sort_keys();
548 self.just_changed = true;
549
550 if let Some(new_idx) = self.keys.iter().position(|k| (k.pos - t).abs() < 0.0001) {
551 self.selected_key_idx = Some(new_idx);
552 self.r_slider.set_value(col[0]);
553 self.g_slider.set_value(col[1]);
554 self.b_slider.set_value(col[2]);
555 }
556 self.arrange_fields();
557 return true;
558 }
559
560 if self.selected_key_idx.is_some() {
561 if self.r_slider.mouse_input(button, state, px, py_event, ui) { return true; }
562 if self.g_slider.mouse_input(button, state, px, py_event, ui) { return true; }
563 if self.b_slider.mouse_input(button, state, px, py_event, ui) { return true; }
564 if self.del_button.mouse_input(button, state, px, py_event, ui) {
565 if self.del_button.take_click() {
566 if let Some(idx) = self.selected_key_idx {
567 if self.keys.len() > 2 {
568 self.keys.remove(idx);
569 self.selected_key_idx = None;
570 self.just_changed = true;
571 self.arrange_fields();
572 }
573 }
574 }
575 return true;
576 }
577 }
578 } else {
579 self.is_dragging_key = false;
580 if self.selected_key_idx.is_some() {
581 self.r_slider.mouse_input(button, state, px, py_event, ui);
582 self.g_slider.mouse_input(button, state, px, py_event, ui);
583 self.b_slider.mouse_input(button, state, px, py_event, ui);
584 if self.del_button.mouse_input(button, state, px, py_event, ui)
585 && self.del_button.take_click() {
586 if let Some(idx) = self.selected_key_idx {
587 if self.keys.len() > 2 {
588 self.keys.remove(idx);
589 self.selected_key_idx = None;
590 self.just_changed = true;
591 self.arrange_fields();
592 }
593 }
594 }
595 return true;
596 }
597 }
598 false
599
600 }
601 Event::PointerMove { x, y, .. } => {
602 let (px, py_event) = (*x, *y);
603 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
604 let mut changed = false;
605 let track_x = self.base.x + 10.0;
606 let track_w = self.base.w - 20.0;
607
608 if self.is_dragging_key {
609 if let Some(idx) = self.selected_key_idx {
610 let t = ((px - track_x) / track_w).clamp(0.0, 1.0);
611 self.keys[idx].pos = t;
612 self.sort_keys();
613 changed = true;
614 }
615 }
616
617 if self.selected_key_idx.is_some() {
618 if self.r_slider.cursor_moved(px, py_event, ui) {
619 if let Some(idx) = self.selected_key_idx {
620 self.keys[idx].color[0] = self.r_slider.inner().value();
621 changed = true;
622 }
623 }
624 if self.g_slider.cursor_moved(px, py_event, ui) {
625 if let Some(idx) = self.selected_key_idx {
626 self.keys[idx].color[1] = self.g_slider.inner().value();
627 changed = true;
628 }
629 }
630 if self.b_slider.cursor_moved(px, py_event, ui) {
631 if let Some(idx) = self.selected_key_idx {
632 self.keys[idx].color[2] = self.b_slider.inner().value();
633 changed = true;
634 }
635 }
636 if self.del_button.cursor_moved(px, py_event, ui) {
637 changed = true;
638 }
639 }
640 if changed {
641 self.just_changed = true;
642 }
643 changed
644
645 }
646 Event::MouseWheel { delta, x, y, .. } => {
647 // Wheel forwarding (6bd self-routing): with the field widgets no longer
648 // tree-linked, the sliders' wheel rides this arm — and the key color syncs
649 // immediately (the old descent path left it stale until the next hover flip).
650 let (delta, px, py) = (*delta, *x, *y);
651 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
652 if self.selected_key_idx.is_none() {
653 return false;
654 }
655 let mut changed = false;
656 if self.r_slider.mouse_wheel(&delta, px, py, ui) {
657 if let Some(idx) = self.selected_key_idx {
658 self.keys[idx].color[0] = self.r_slider.inner().value();
659 }
660 changed = true;
661 }
662 if self.g_slider.mouse_wheel(&delta, px, py, ui) {
663 if let Some(idx) = self.selected_key_idx {
664 self.keys[idx].color[1] = self.g_slider.inner().value();
665 }
666 changed = true;
667 }
668 if self.b_slider.mouse_wheel(&delta, px, py, ui) {
669 if let Some(idx) = self.selected_key_idx {
670 self.keys[idx].color[2] = self.b_slider.inner().value();
671 }
672 changed = true;
673 }
674 if changed {
675 self.just_changed = true;
676 }
677 changed
678 }
679 Event::KeyInput(event) => {
680 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
681 if ui.is_focused(&self.r_slider) {
682 return self.r_slider.keyboard_input(event, ui);
683 }
684 if ui.is_focused(&self.g_slider) {
685 return self.g_slider.keyboard_input(event, ui);
686 }
687 if ui.is_focused(&self.b_slider) {
688 return self.b_slider.keyboard_input(event, ui);
689 }
690 if ui.is_focused(&self.del_button) {
691 return self.del_button.keyboard_input(event, ui);
692 }
693 false
694
695 }
696 _ => false,
697 }
698 }
699
700 // Field-slider drags forward through the composite (6bd self-routing): the router
701 // records THIS widget as the drag target once a press is handled here, so the hooks
702 // hand DragUpdate to whichever slider armed itself — and sync the key color, which
703 // the old descent path never did mid-drag.
704 fn draggable(&self, _rect: Rect) -> bool {
705 self.is_dragging_key
706 || self.r_slider.is_dragging()
707 || self.g_slider.is_dragging()
708 || self.b_slider.is_dragging()
709 }
710 fn is_dragging(&self) -> bool {
711 self.is_dragging_key
712 || self.r_slider.is_dragging()
713 || self.g_slider.is_dragging()
714 || self.b_slider.is_dragging()
715 }
716 fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
717 let mut changed = false;
718 if self.r_slider.is_dragging() && self.r_slider.drag_update(px, py) {
719 if let Some(idx) = self.selected_key_idx {
720 self.keys[idx].color[0] = self.r_slider.inner().value();
721 }
722 changed = true;
723 }
724 if self.g_slider.is_dragging() && self.g_slider.drag_update(px, py) {
725 if let Some(idx) = self.selected_key_idx {
726 self.keys[idx].color[1] = self.g_slider.inner().value();
727 }
728 changed = true;
729 }
730 if self.b_slider.is_dragging() && self.b_slider.drag_update(px, py) {
731 if let Some(idx) = self.selected_key_idx {
732 self.keys[idx].color[2] = self.b_slider.inner().value();
733 }
734 changed = true;
735 }
736 if changed {
737 self.just_changed = true;
738 }
739 changed
740 }
741 fn drag_end(&mut self) {
742 self.r_slider.drag_end();
743 self.g_slider.drag_end();
744 self.b_slider.drag_end();
745 self.is_dragging_key = false;
746 }
747 }
748
749 impl Ramp {
750 /// The one spacing value the whole control strip uses — matching the
751 /// visible gap between the graph opening and the window's top edge (the
752 /// widget's 10px graph inset plus the host plate's padding).
753 const STRIP_GAP: f32 = 18.0;
754
755 /// The key pad's square well side.
756 const PAD_SIDE: f32 = 64.0;
757
758 /// Vertical reserve under the curve area — the strip stack at the
759 /// uniform STRIP_GAP rhythm (labeled dropdown row, labeled pad row),
760 /// closed by a bottom margin sized so the VISIBLE bottom gap (widget
761 /// margin + host plate padding, ~8) lands on STRIP_GAP as well.
762 fn strip_reserve() -> f32 {
763 let strip = Self::label_strip();
764 10.0 + Self::STRIP_GAP + strip + 22.0
765 + Self::STRIP_GAP + strip + Self::PAD_SIDE
766 + 10.0
767 }
768
769 /// Key peg ring stroke centerline radius (the 2px stroke spans ±1px).
770 /// Paint and the grab hit-test share it: a press anywhere inside a ring
771 /// lands on that key.
772 const KEY_RING_R: f32 = 26.0;
773
774 /// The key ring radius on THIS plot: the editor's full ring, shrunk so a
775 /// peg never outgrows the plot it sits in (an inline ramp a control high
776 /// draws pegs a few px across, not 26px discs swallowing the curve).
777 fn key_ring_r(&self) -> f32 {
778 let plot = self.plot_rect();
779 Self::KEY_RING_R.min((plot.height * 0.45).max(4.0))
780 }
781
782 /// Inner margin between the graph opening's walls and the plotted 0..1
783 /// domain, so the 0 and 1 gridlines (and their axis numbers) sit visibly
784 /// inside the opening instead of on the walls.
785 const PLOT_INSET: f32 = 22.0;
786
787 /// The plot rect: where the ramp's 0..1 × 0..1 domain maps on screen —
788 /// the graph opening inset by [`PLOT_INSET`](Self::PLOT_INSET). Every
789 /// t/value ↔ pixel mapping (paint and input alike) goes through this.
790 fn plot_rect(&self) -> Rect {
791 let gh = self.graph_h();
792 Rect {
793 x: self.base.x + 10.0 + Self::PLOT_INSET,
794 y: self.base.y + 10.0 + Self::PLOT_INSET,
795 width: (self.base.w - 20.0 - 2.0 * Self::PLOT_INSET).max(1.0),
796 height: (gh - 2.0 * Self::PLOT_INSET).max(1.0),
797 }
798 }
799
800 /// Neighbor resistance (drag), in track units: the soft wall starts
801 /// RESIST_ZONE before a neighbor's position, and pushing the cursor
802 /// RESIST_BREAK past the neighbor breaks through.
803 const RESIST_ZONE: f32 = 0.10;
804 const RESIST_BREAK: f32 = 0.16;
805
806 /// Where a drag whose cursor sits at `t_raw` actually puts key `idx`:
807 /// 1:1 tracking until the cursor enters a neighbor's resistance zone,
808 /// then the key compresses toward the neighbor with growing resistance
809 /// (slope 1 at the zone edge, flattening at the wall), and once the
810 /// cursor overshoots the neighbor by RESIST_BREAK the key pops through —
811 /// the crossing completes and tracking is free again.
812 fn resisted_pos(&self, idx: usize, t_raw: f32) -> f32 {
813 let cur = self.keys[idx].pos;
814 if t_raw > cur {
815 if let Some(next) = self.keys.get(idx + 1) {
816 return Self::soft_wall(t_raw, next.pos, 1.0);
817 }
818 } else if idx > 0 {
819 return Self::soft_wall(t_raw, self.keys[idx - 1].pos, -1.0);
820 }
821 t_raw
822 }
823
824 /// Restore sort order after `keys[i]` changed position, by adjacent
825 /// swaps, and return the key's new index. Exact identity tracking —
826 /// `sort_keys`' float-pos re-match misidentifies the selection when the
827 /// dragged key sits within ε of the key it is passing (leftward
828 /// crossings flipped the selection onto the passed key).
829 fn resettle_key(&mut self, mut i: usize) -> usize {
830 while i + 1 < self.keys.len() && self.keys[i].pos > self.keys[i + 1].pos {
831 self.keys.swap(i, i + 1);
832 i += 1;
833 }
834 while i > 0 && self.keys[i].pos < self.keys[i - 1].pos {
835 self.keys.swap(i, i - 1);
836 i -= 1;
837 }
838 i
839 }
840
841 /// A key's rolled edge: the disc's own surface curving away at the
842 /// perimeter — NOT a separate border. Each sub-arc blends radially from
843 /// the surface color at the band's inner edge (continuing the flat top
844 /// seamlessly), through a half-rolled tint, to the silhouette — which
845 /// leans toward the light on the lit side and falls into shadow opposite,
846 /// and runs denser than the top the way a glass edge reads. `r` is the
847 /// outer-edge radius; `base`/`top_alpha` are the disc's surface color.
848 #[allow(clippy::too_many_arguments)]
849 fn rolled_rim_arc(
850 pc: &mut PaintCtx,
851 cx: f32,
852 cy: f32,
853 r: f32,
854 thickness: f32,
855 start: f32,
856 end: f32,
857 az: f32,
858 base: [f32; 3],
859 top_alpha: f32,
860 ) {
861 let sweep = end - start;
862 let steps = ((sweep.abs() / 0.18).ceil() as usize).max(1);
863 let tint = |sv: f32, k: f32| -> [f32; 3] {
864 [
865 (base[0] + k * sv).clamp(0.0, 1.0),
866 (base[1] + k * sv).clamp(0.0, 1.0),
867 (base[2] + k * sv).clamp(0.0, 1.0),
868 ]
869 };
870 for i in 0..steps {
871 let a0 = start + sweep * i as f32 / steps as f32;
872 let a1 = start + sweep * (i + 1) as f32 / steps as f32;
873 let sv = ((a0 + a1) / 2.0 + az).cos();
874 let mid = tint(sv, 0.20);
875 let edge = tint(sv, 0.38);
876 let mid_a = (top_alpha + 0.78) / 2.0;
877 pc.arc_shaded(
878 cx,
879 cy,
880 r,
881 thickness,
882 a0,
883 a1,
884 [base[0], base[1], base[2], top_alpha],
885 [mid[0], mid[1], mid[2], mid_a],
886 [edge[0], edge[1], edge[2], 0.78],
887 );
888 }
889 }
890
891 /// Apply the key pad's two axes to the selected key: x is the key's
892 /// track position (order restored by adjacent swaps), y its value.
893 fn apply_pad_to_selected(&mut self) {
894 let Some(idx) = self.selected_key_idx else { return };
895 self.keys[idx].pos = self.key_pad.inner().value_x();
896 self.keys[idx].value = self.key_pad.inner().value_y();
897 let settled = self.resettle_key(idx);
898 self.selected_key_idx = Some(settled);
899 self.sync_preset();
900 self.just_changed = true;
901 }
902
903 /// One soft wall at `wall`, approached along direction `s` (±1). Maps the
904 /// cursor's depth into the zone onto the zone's width with an ease that
905 /// reaches the wall exactly at breakthrough depth — continuous at the
906 /// zone edge, asymptotically stiff at the wall, then a `RESIST_BREAK`
907 /// pop as the mapping hands back to 1:1 tracking.
908 fn soft_wall(t_raw: f32, wall: f32, s: f32) -> f32 {
909 let entry = wall - s * Self::RESIST_ZONE;
910 let depth = s * (t_raw - entry);
911 let full = Self::RESIST_ZONE + Self::RESIST_BREAK;
912 if depth <= 0.0 || depth >= full {
913 return t_raw; // outside the zone, or broken through
914 }
915 let k = full / Self::RESIST_ZONE;
916 let g = 1.0 - (1.0 - depth / full).powf(k);
917 entry + s * Self::RESIST_ZONE * g
918 }
919
920 /// The curve area's height: the widget minus the control strip — or,
921 /// with the controls collapsed (context-menu toggle), minus just the
922 /// top/bottom insets, the graph claiming the strip's space.
923 fn graph_h(&self) -> f32 {
924 if self.controls_collapsed {
925 (self.base.h - 20.0).max(30.0)
926 } else {
927 (self.base.h - Self::strip_reserve()).max(30.0)
928 }
929 }
930
931 /// The detached-label strip height the labeled dropdowns carry
932 /// (`Widget::label_offset`'s formula).
933 pub fn label_strip() -> f32 {
934 crate::layout::control_label_strip()
935 }
936
937 /// Lay out the control strip under the curve area. One rhythm: the label
938 /// tabs sit STRIP_GAP under the graph and every other gap shares the
939 /// same rhythm, all columns one shared height on one shared baseline. The labeled dropdowns
940 /// get rects that INCLUDE their label strip (the adapter carves it off the
941 /// content); the unlabeled columns get the content band only. The preset
942 /// column takes the wider share — its options are the strip's longest
943 /// strings and used to clip.
944 fn arrange_fields(&mut self) {
945 let (x, y, w, h) = (self.base.x, self.base.y, self.base.w, self.base.h);
946 if self.controls_collapsed {
947 self.preset_dropdown.set_rect(-1000.0, -1000.0, 0.0, 0.0);
948 self.line_type_dropdown.set_rect(-1000.0, -1000.0, 0.0, 0.0);
949 self.key_pad.set_rect(-1000.0, -1000.0, 0.0, 0.0);
950 self.del_button.set_rect(-1000.0, -1000.0, 0.0, 0.0);
951 let _ = (x, y, w, h);
952 return;
953 }
954 let gh = self.graph_h();
955 let graph_bottom = y + 10.0 + gh;
956 let ctrl_h = 22.0;
957 let strip = Self::label_strip();
958 let gap = Self::STRIP_GAP;
959 // One rhythm: every gap in the strip — graph to label tab, row to
960 // row, columns, pad to button — is STRIP_GAP.
961 let ctrl_y = graph_bottom + gap + strip;
962 let (dd_y, dd_h) = (ctrl_y - strip, ctrl_h + strip);
963 let track_x = x + 10.0;
964 let track_w = w - 20.0;
965
966 if self.selected_key_idx.is_some() {
967 // Selected: the dropdowns keep their full-width row, and a second
968 // row below carries the square key pad (pos × value) with the
969 // delete button beside it, centered on the pad's well.
970 let pad_side = Self::PAD_SIDE;
971 let del_w: f32 = if self.del_button.inner().has_icon() { ctrl_h } else { 64.0 };
972 let pre_w = ((track_w - gap) * 0.58).max(40.0);
973 let line_w = (track_w - gap - pre_w).max(40.0);
974 self.preset_dropdown.set_rect(track_x, dd_y, pre_w, dd_h);
975 self.line_type_dropdown.set_rect(track_x + pre_w + gap, dd_y, line_w, dd_h);
976 let row2_y = ctrl_y + ctrl_h + gap;
977 self.key_pad.set_rect(track_x, row2_y, pad_side, pad_side + strip);
978 self.del_button.set_rect(
979 track_x + pad_side + gap,
980 row2_y + strip + (pad_side - ctrl_h) / 2.0,
981 del_w,
982 ctrl_h,
983 );
984 } else {
985 // Two columns, preset the wider share.
986 let pre_w = ((track_w - gap) * 0.58).max(40.0);
987 let line_w = (track_w - gap - pre_w).max(40.0);
988 self.preset_dropdown.set_rect(track_x, dd_y, pre_w, dd_h);
989 self.line_type_dropdown.set_rect(track_x + pre_w + gap, dd_y, line_w, dd_h);
990 self.key_pad.set_rect(-1000.0, -1000.0, 0.0, 0.0);
991 self.del_button.set_rect(-1000.0, -1000.0, 0.0, 0.0);
992 }
993 }
994 }
995
996 impl Layout for Ramp {
997 fn intrinsic_size(&self) -> Option<Size> {
998 Some(Size::new(0.0, 150.0))
999 }
1000
1001 fn rect_assigned(&mut self, rect: Rect) {
1002 self.base.x = rect.x;
1003 self.base.y = rect.y;
1004 self.base.w = rect.width;
1005 self.base.h = rect.height;
1006 self.arrange_fields();
1007 }
1008
1009 // register_embedded_children: gone entirely (6bd self-routing): the fields are never
1010 // in the registry — the ramp decides which field has the keyboard (`focus_field`), the composite
1011 // itself covers the spatial grid, and an eagerly-registered child DROPDOWN's open
1012 // popover made `is_coordinate_covered` occlude the composite's own hit gate (the
1013 // exclusion is exact-id only), which is why preset-item clicks never landed.
1014 }
1015
1016 impl Paint for Ramp {
1017 fn color(&self) -> [f32; 4] {
1018 [0.15, 0.15, 0.18, 1.0]
1019 }
1020
1021 fn popover(&self, _rect: Rect) -> Option<(f32, f32, f32, f32)> {
1022 self.preset_dropdown.popover_rect()
1023 .or_else(|| self.line_type_dropdown.popover_rect())
1024
1025 }
1026
1027 fn draw_popover(&self, _rect: Rect, pc: &mut dyn crate::layout::RenderTarget) {
1028 self.preset_dropdown.render_popover(pc);
1029 self.line_type_dropdown.render_popover(pc);
1030
1031 }
1032
1033 // Field children are ctx-linked for event propagation but painted here — the walk
1034 // must not also descend (the legacy own-labels rule, now with the children too).
1035 fn paints_own_subtree(&self) -> bool {
1036 true
1037 }
1038
1039 fn paint(&self, _rect: Rect, pc: &mut PaintCtx) {
1040 // No container box: the controls sit directly on the host's plate, and
1041 // the graph area reads as an OPENING cut through it — a dark floor
1042 // behind the plate, with the recess wall (drawn after the content, so
1043 // its shading falls across the graph's edges) as the cut's bevel.
1044 let graph = {
1045 let gh = self.graph_h();
1046 Rect { x: self.base.x + 10.0, y: self.base.y + 10.0, width: self.base.w - 20.0, height: gh }
1047 };
1048 let graph_radius = 6.0f32;
1049 pc.rounded_rect(
1050 graph,
1051 graph_radius,
1052 (true, true, true, true),
1053 [0.08, 0.08, 0.10, 1.0],
1054 );
1055
1056 let quads: Vec<(f32, f32, f32, f32, [f32; 4])> = {
1057 let mut quads = Vec::new();
1058 let plot = self.plot_rect();
1059
1060 // Grid lines over the plotted 0..1 domain — 0 and 1 included, sitting
1061 // inside the opening (the plot is inset from the walls).
1062 for ratio in [0.0, 0.25, 0.5, 0.75, 1.0] {
1063 let gy = plot.y + plot.height * (1.0 - ratio);
1064 quads.push((plot.x, gy, plot.width, 1.0, [0.25, 0.25, 0.28, 0.5]));
1065 let gx = plot.x + plot.width * ratio;
1066 quads.push((gx, plot.y, 1.0, plot.height, [0.25, 0.25, 0.28, 0.5]));
1067 }
1068
1069 // Curve area fill: translucent columns under the curve. The outline is
1070 // a real vector polyline below — these only tint the area. Columns
1071 // share exact edges (overlap double-blends a translucent fill into
1072 // visible banding; found the hard way).
1073 let slices = 200;
1074 for i in 0..slices {
1075 let t1 = i as f32 / slices as f32;
1076 let x0 = plot.x + t1 * plot.width;
1077 let x1 = plot.x + (i + 1) as f32 / slices as f32 * plot.width;
1078 let v1 = self.get_interpolated_value(t1);
1079
1080 let slice_h = v1 * plot.height;
1081 let sy = plot.y + plot.height - slice_h;
1082 // Faint on purpose: the graph reads as a dark opening behind the
1083 // plate — a strong fill floods the floor and flattens the depth.
1084 quads.push((x0, sy, x1 - x0, slice_h, [0.25, 0.40, 0.55, 0.10]));
1085 }
1086
1087 quads
1088
1089 };
1090 for (qx, qy, qw, qh, qc) in quads {
1091 pc.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1092 }
1093
1094 // Axis numbers on the gridlines — small, dim, part of the graph
1095 // floor (under the curve and keys, inside the opening). They sit in
1096 // the wall-side gutters the plot inset leaves free.
1097 let plot = self.plot_rect();
1098 let num_color = [0x84u8, 0x84, 0x92];
1099 for ratio in [0.0f32, 0.25, 0.5, 0.75, 1.0] {
1100 let gy = plot.y + plot.height * (1.0 - ratio);
1101 pc.text_with(
1102 format!("{ratio:.2}"),
1103 graph.x + 5.0,
1104 gy - 11.0,
1105 9.0,
1106 num_color,
1107 Some("monospace".to_string()),
1108 None,
1109 );
1110 let gx = plot.x + plot.width * ratio;
1111 pc.text_with(
1112 format!("{ratio:.2}"),
1113 gx - 11.0,
1114 graph.y + graph.height - 13.0,
1115 9.0,
1116 num_color,
1117 Some("monospace".to_string()),
1118 None,
1119 );
1120 }
1121
1122 // The curve itself: one anti-aliased round-capped polyline — exact
1123 // key-to-key segments in linear mode, dense samples under smoothstep
1124 // blending. Constant-value extensions reach the plot's 0/1 edges.
1125 let curve_color = [0.5, 0.75, 1.0, 1.0];
1126 let px_of = |t: f32, v: f32| {
1127 (plot.x + t * plot.width, plot.y + plot.height * (1.0 - v))
1128 };
1129 let mut pts: Vec<(f32, f32)> = Vec::new();
1130 if self.line_type_dropdown.selected == 1 {
1131 let n = 64;
1132 for i in 0..=n {
1133 let t = i as f32 / n as f32;
1134 pts.push(px_of(t, self.get_interpolated_value(t)));
1135 }
1136 } else {
1137 if let Some(first) = self.keys.first() {
1138 if first.pos > 0.0 {
1139 pts.push(px_of(0.0, first.value));
1140 }
1141 }
1142 for k in &self.keys {
1143 pts.push(px_of(k.pos, k.value));
1144 }
1145 if let Some(last) = self.keys.last() {
1146 if last.pos < 1.0 {
1147 pts.push(px_of(1.0, last.value));
1148 }
1149 }
1150 }
1151 for pair in pts.windows(2) {
1152 pc.vector(pair[0].0, pair[0].1, pair[1].0, pair[1].1, 2.0, curve_color, Cap::Round);
1153 }
1154 // Key pegs: glassy translucent fills (solid when selected) in thin
1155 // white rings. Overlapping pegs render as foam cells: each pair's
1156 // shared wall is the chord through the two points where the ring
1157 // circles cross (equal radii, so it lies on the perpendicular
1158 // bisector of the centers); rings are cut at the wall, the wall is
1159 // stroked once, and each fill keeps to its own side.
1160 {
1161 let plot = self.plot_rect();
1162 let ring_r = self.key_ring_r(); // roll-band centerline
1163 // The disc surface: flat top out to the roll band's inner edge,
1164 // then the rolled perimeter out to ring_r + 2.5. Band and rim
1165 // shrink with the ring so a small peg keeps a flat top.
1166 let base = [0.5f32, 0.75, 1.0];
1167 let fill_r = (ring_r - 3.0).max(ring_r * 0.5);
1168 let rim_t = 6.0f32.min(ring_r * 0.25).max(1.0);
1169 // Bevel light: the DE light azimuth the plate shading uses.
1170 let az = crate::layout::light_source_position();
1171 let tau = std::f32::consts::TAU;
1172
1173 let centers: Vec<(f32, f32)> = self
1174 .keys
1175 .iter()
1176 .map(|k| (plot.x + k.pos * plot.width, plot.y + plot.height * (1.0 - k.value)))
1177 .collect();
1178
1179 // Every intersecting pair: wall midpoint M + unit normal n toward
1180 // the neighbor per key, and the chord endpoints once per pair.
1181 let mut cuts: Vec<Vec<((f32, f32), (f32, f32))>> = vec![Vec::new(); centers.len()];
1182 let mut walls: Vec<((f32, f32), (f32, f32), (f32, f32))> = Vec::new();
1183 for i in 0..centers.len() {
1184 for j in (i + 1)..centers.len() {
1185 let (dx, dy) = (centers[j].0 - centers[i].0, centers[j].1 - centers[i].1);
1186 let d = (dx * dx + dy * dy).sqrt();
1187 if d < 1e-3 || d >= 2.0 * ring_r {
1188 continue;
1189 }
1190 let n = (dx / d, dy / d);
1191 let m =
1192 ((centers[i].0 + centers[j].0) / 2.0, (centers[i].1 + centers[j].1) / 2.0);
1193 cuts[i].push((m, n));
1194 cuts[j].push((m, (-n.0, -n.1)));
1195 let h = (ring_r * ring_r - (d / 2.0) * (d / 2.0)).sqrt();
1196 walls.push((
1197 (m.0 - h * n.1, m.1 + h * n.0),
1198 (m.0 + h * n.1, m.1 - h * n.0),
1199 n,
1200 ));
1201 }
1202 }
1203
1204 // Fills. Uncut: one disc. Cut: the cell — vertical strips bounded
1205 // by the wall half-planes, the round edge from the circle clip.
1206 for (idx, &(cx, cy)) in centers.iter().enumerate() {
1207 let selected = Some(idx) == self.selected_key_idx;
1208 let fill = [base[0], base[1], base[2], if selected { 0.85 } else { 0.22 }];
1209 if cuts[idx].is_empty() {
1210 pc.circle(cx, cy, fill_r, fill);
1211 continue;
1212 }
1213 pc.push_clip_circle([cx, cy, fill_r]);
1214 let step = 1.5f32;
1215 let mut x = cx - fill_r;
1216 while x < cx + fill_r {
1217 let mid = x + step / 2.0;
1218 let (mut ylo, mut yhi) = (cy - fill_r, cy + fill_r);
1219 let mut visible = true;
1220 for &((mx, my), (nx, ny)) in &cuts[idx] {
1221 // Keep (p − M)·n ≤ 0 — this key's side of the wall.
1222 let c = nx * (mid - mx);
1223 if ny.abs() < 1e-4 {
1224 if c > 0.0 {
1225 visible = false;
1226 break;
1227 }
1228 } else {
1229 let yb = my - c / ny;
1230 if ny > 0.0 {
1231 yhi = yhi.min(yb);
1232 } else {
1233 ylo = ylo.max(yb);
1234 }
1235 }
1236 }
1237 if visible && ylo < yhi {
1238 pc.quad(Rect { x, y: ylo, width: step, height: yhi - ylo }, fill);
1239 }
1240 x += step;
1241 }
1242 pc.pop_clip_circle();
1243 }
1244
1245 // Walls: the shared boundary as the surface rolling into the
1246 // seam and back out — surface-tinted slopes (lit side leans to
1247 // the light, far side into shadow) around a slightly lifted
1248 // crest, in the discs\' own color like the rims.
1249 let (lx, ly) = (az.cos(), -az.sin());
1250 let wall_tint = |sv: f32, k: f32| -> [f32; 3] {
1251 [
1252 (base[0] + k * sv).clamp(0.0, 1.0),
1253 (base[1] + k * sv).clamp(0.0, 1.0),
1254 (base[2] + k * sv).clamp(0.0, 1.0),
1255 ]
1256 };
1257 for &((x1, y1), (x2, y2), (nx, ny)) in &walls {
1258 let facing = nx * lx + ny * ly;
1259 let cp = wall_tint(facing, 0.38);
1260 let cm = wall_tint(-facing, 0.38);
1261 let cc = wall_tint(facing, 0.12);
1262 pc.vector(
1263 x1 + nx * 1.6, y1 + ny * 1.6, x2 + nx * 1.6, y2 + ny * 1.6,
1264 1.6, [cp[0], cp[1], cp[2], 0.78], Cap::Round,
1265 );
1266 pc.vector(
1267 x1 - nx * 1.6, y1 - ny * 1.6, x2 - nx * 1.6, y2 - ny * 1.6,
1268 1.6, [cm[0], cm[1], cm[2], 0.78], Cap::Round,
1269 );
1270 pc.vector(x1, y1, x2, y2, 1.8, [cc[0], cc[1], cc[2], 0.85], Cap::Round);
1271 }
1272
1273 // Rims: beveled circles minus the angular span facing each wall
1274 // (no drawn border — the shaded edge IS the ring).
1275 for (idx, &(cx, cy)) in centers.iter().enumerate() {
1276 let top_a = if Some(idx) == self.selected_key_idx { 0.85 } else { 0.22 };
1277 if cuts[idx].is_empty() {
1278 Self::rolled_rim_arc(pc, cx, cy, ring_r + 2.5, rim_t, 0.0, tau, az, base, top_a);
1279 continue;
1280 }
1281 // Excluded spans [θ−α, θ+α] toward each neighbor, normalized
1282 // into [0, τ) (wrapping spans split), then merged.
1283 let mut segs: Vec<(f32, f32)> = Vec::new();
1284 for &((mx, my), (nx, ny)) in &cuts[idx] {
1285 let theta = ny.atan2(nx);
1286 let half = (mx - cx) * nx + (my - cy) * ny;
1287 let alpha = (half / ring_r).clamp(-1.0, 1.0).acos();
1288 let (a, b) = ((theta - alpha).rem_euclid(tau), (theta + alpha).rem_euclid(tau));
1289 if a <= b {
1290 segs.push((a, b));
1291 } else {
1292 segs.push((a, tau));
1293 segs.push((0.0, b));
1294 }
1295 }
1296 segs.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());
1297 let mut merged: Vec<(f32, f32)> = Vec::new();
1298 for s in segs {
1299 match merged.last_mut() {
1300 Some(last) if s.0 <= last.1 => last.1 = last.1.max(s.1),
1301 _ => merged.push(s),
1302 }
1303 }
1304 // Stroke the complement (the two pieces meeting at θ=0 join
1305 // seamlessly when no span covers 0).
1306 let mut prev = 0.0f32;
1307 for &(a, b) in &merged {
1308 if a > prev + 1e-3 {
1309 Self::rolled_rim_arc(pc, cx, cy, ring_r + 2.5, rim_t, prev, a, az, base, top_a);
1310 }
1311 prev = prev.max(b);
1312 }
1313 if prev < tau - 1e-3 {
1314 Self::rolled_rim_arc(pc, cx, cy, ring_r + 2.5, rim_t, prev, tau, az, base, top_a);
1315 }
1316 }
1317 }
1318 // The opening's cut edge: drawn after the graph content so the wall's
1319 // shading falls across the curve and keys where they pass behind the
1320 // plate's rim. Nested translucent border rings first — the contact
1321 // shadow the plate casts down into the opening — then the recess wall
1322 // itself as the cut's bevel.
1323 let radii = (graph_radius, graph_radius, graph_radius, graph_radius);
1324 for (t, a) in [(7.0, 0.08), (4.0, 0.10), (2.0, 0.14)] {
1325 pc.border(graph, radii, [0.0; 4], [0.0, 0.0, 0.0, a], t);
1326 }
1327 let depth = crate::layout::bevel_width().min(graph.height * 0.2);
1328 let (well, radii) = crate::layout::carve_inside(graph, radii, depth);
1329 pc.recess(well, radii, depth);
1330 if !self.controls_collapsed {
1331 let dummy = UiContext::new();
1332 self.preset_dropdown.paint_self(&dummy, pc);
1333 self.line_type_dropdown.paint_self(&dummy, pc);
1334 if self.selected_key_idx.is_some() {
1335 self.key_pad.paint_self(&dummy, pc);
1336 self.del_button.paint_self(&dummy, pc);
1337 }
1338 }
1339 }
1340 }
1341
1342 impl Input for Ramp {
1343 fn wants_tick(&self) -> bool {
1344 true
1345 }
1346
1347 /// The graph context menu's actions. Overriding loses the trait-default
1348 /// clipboard arms, so Copy/Paste (the spec string) are restated here.
1349 fn context_action(&mut self, action: ContextAction) -> bool {
1350 match action {
1351 ContextAction::ToggleRampControls => {
1352 self.controls_collapsed = !self.controls_collapsed;
1353 self.just_changed = true;
1354 self.arrange_fields();
1355 true
1356 }
1357 ContextAction::Copy => {
1358 crate::widget::clipboard::copy_to_clipboard(&self.spec_string());
1359 true
1360 }
1361 ContextAction::Paste => {
1362 if let Some(text) = crate::widget::clipboard::read_from_clipboard() {
1363 let changed = self.set_spec(&text);
1364 if changed {
1365 self.just_changed = true;
1366 }
1367 changed
1368 } else {
1369 false
1370 }
1371 }
1372 _ => false,
1373 }
1374 }
1375
1376 /// The curve as a ramp spec string ([`format_ramp_spec`]) — the value hosts
1377 /// poll and persist for ramp-valued params.
1378 fn value_string(&self) -> Option<String> {
1379 Some(self.spec_string())
1380 }
1381
1382 fn set_value_string(&mut self, val: &str) -> bool {
1383 self.set_spec(val)
1384 }
1385
1386 /// The open dropdown popover extends the hit area (the 5p Dropdown pattern).
1387 fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
1388 let popover = self.preset_dropdown.popover_rect().or_else(|| self.line_type_dropdown.popover_rect());
1389 if let Some((px, py, pw, ph)) = popover {
1390 if x >= px && x <= px + pw && y >= py && y <= py + ph {
1391 return true;
1392 }
1393 }
1394 x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height
1395 }
1396
1397 fn tick_ctx(&mut self, dt: f32, ectx: &mut EventCtx) -> bool {
1398 // (The per-tick field-widget re-parenting is gone, 6bd: it was a dummy-ctx
1399 // `set_parent` whose every effect was discarded — legacy behaved the same.)
1400 let Some(ui) = ectx.ui.as_deref_mut() else {
1401 return false;
1402 };
1403 let mut changed = self.just_changed;
1404 self.just_changed = false;
1405
1406 // A field the window's focus moved away from is told here: the fields are not in
1407 // the registry, so the focus change could not reach them itself.
1408 let focused = self.focused_field(ui);
1409 for i in 0..4 {
1410 if Some(i) != focused && self.field(i).base().focused {
1411 self.field(i).unfocus();
1412 changed = true;
1413 }
1414 }
1415
1416 // Hover-scroll inertia: once the finger stream stops (>60ms without
1417 // an event), the latched key coasts on the estimated velocity with
1418 // exponential decay, still resettling and syncing like live scrolls.
1419 if let (Some(idx), Some(last)) = (self.scroll_key_idx, self.last_key_scroll) {
1420 if last.elapsed().as_secs_f32() > 0.06 && idx < self.keys.len() {
1421 let (vx, vy) = self.scroll_vel;
1422 // Animations off: the key stops where the scroll left it.
1423 if (vx.abs() > 0.02 || vy.abs() > 0.02) && crate::motion::enabled() {
1424 self.keys[idx].pos = (self.keys[idx].pos + vx * dt).clamp(0.0, 1.0);
1425 self.keys[idx].value = (self.keys[idx].value + vy * dt).clamp(0.0, 1.0);
1426 let settled = self.resettle_key(idx);
1427 self.scroll_key_idx = Some(settled);
1428 self.selected_key_idx = Some(settled);
1429 self.key_pad
1430 .set_values(self.keys[settled].pos, self.keys[settled].value);
1431 self.sync_preset();
1432 let f = (-5.0 * dt).exp();
1433 self.scroll_vel = (vx * f, vy * f);
1434 changed = true;
1435 } else {
1436 self.scroll_vel = (0.0, 0.0);
1437 self.last_key_scroll = None;
1438 }
1439 }
1440 }
1441
1442 if self.preset_dropdown.tick(dt, ui) {
1443 let idx = self.preset_dropdown.selected;
1444 self.apply_preset(idx);
1445 changed = true;
1446 }
1447
1448 if self.line_type_dropdown.tick(dt, ui) {
1449 changed = true;
1450 }
1451
1452 if self.selected_key_idx.is_some() {
1453 if self.key_pad.tick(dt, ui) {
1454 self.apply_pad_to_selected();
1455 changed = true;
1456 }
1457 if self.del_button.tick(dt, ui) {
1458 self.sync_preset();
1459 changed = true;
1460 }
1461 }
1462 changed
1463
1464 }
1465
1466 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
1467 match event {
1468 Event::MouseButton { button, state, x, y, .. } => {
1469 let (button, state, px, py_event) = (*button, *state, *x, *y);
1470 // Right-press in the graph opening → the shared context menu
1471 // (the key-crossing toggle lives there). Before the ui borrow:
1472 // open_context_menu needs the whole EventCtx.
1473 if button == MouseButton::Right {
1474 if state == ElementState::Pressed {
1475 let gh = self.graph_h();
1476 let gx = self.base.x + 10.0;
1477 let gw = self.base.w - 20.0;
1478 let gy = self.base.y + 10.0;
1479 if px >= gx && px <= gx + gw && py_event >= gy && py_event <= gy + gh {
1480 ectx.open_context_menu(px, py_event);
1481 return true;
1482 }
1483 }
1484 return false;
1485 }
1486 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
1487 if button != MouseButton::Left { return false; }
1488
1489 if self.preset_dropdown.mouse_input(button, state, px, py_event, ui) {
1490 if self.preset_dropdown.take_change() {
1491 let idx = self.preset_dropdown.selected;
1492 self.apply_preset(idx);
1493 }
1494 return true;
1495 }
1496
1497 if self.line_type_dropdown.mouse_input(button, state, px, py_event, ui) {
1498 return true;
1499 }
1500
1501 let gh = self.graph_h();
1502 let plot = self.plot_rect();
1503
1504 if state == ElementState::Pressed {
1505 // Any press cancels a hover-scroll glide in progress.
1506 self.scroll_vel = (0.0, 0.0);
1507 self.scroll_key_idx = None;
1508 self.last_key_scroll = None;
1509 // Grab the NEAREST key whose ring contains the press — the rings
1510 // are the pegs' visual extent, and nearest-center also matches the
1511 // foam walls (perpendicular bisectors) where rings overlap.
1512 let hit_r = self.key_ring_r() + 2.5;
1513 let mut best: Option<(usize, f32)> = None;
1514 for (idx, key) in self.keys.iter().enumerate() {
1515 let cx = plot.x + key.pos * plot.width;
1516 let cy = plot.y + plot.height * (1.0 - key.value);
1517 let dx = px - cx;
1518 let dy = py_event - cy;
1519 let d2 = dx * dx + dy * dy;
1520 if d2 <= hit_r * hit_r && best.is_none_or(|(_, bd)| d2 < bd) {
1521 best = Some((idx, d2));
1522 }
1523 }
1524 if let Some((idx, _)) = best {
1525 self.selected_key_idx = Some(idx);
1526 self.is_dragging_key = true;
1527 self.key_pad.set_values(self.keys[idx].pos, self.keys[idx].value);
1528 self.arrange_fields();
1529 return true;
1530 }
1531
1532 // Creation accepts the whole opening (the inset gutters included);
1533 // the domain mapping clamps to the plot's 0..1.
1534 if px >= self.base.x + 10.0 && px <= self.base.x + self.base.w - 10.0 && py_event >= self.base.y + 10.0 && py_event <= self.base.y + 10.0 + gh {
1535 let t = ((px - plot.x) / plot.width).clamp(0.0, 1.0);
1536 let val = (1.0 - (py_event - plot.y) / plot.height).clamp(0.0, 1.0);
1537 let new_key = RampKey { pos: t, value: val };
1538 self.keys.push(new_key);
1539 let new_idx = self.resettle_key(self.keys.len() - 1);
1540 self.sync_preset();
1541 self.just_changed = true;
1542 self.selected_key_idx = Some(new_idx);
1543 self.key_pad.set_values(t, val);
1544 // Arm the drag: a fresh key follows the pointer until release,
1545 // so create-and-place is one gesture (the grab-branch behavior).
1546 self.is_dragging_key = true;
1547 self.arrange_fields();
1548 return true;
1549 }
1550
1551 if self.selected_key_idx.is_some() {
1552 if self.key_pad.mouse_input(button, state, px, py_event, ui) {
1553 self.apply_pad_to_selected();
1554 return true;
1555 }
1556 if self.del_button.mouse_input(button, state, px, py_event, ui) {
1557 if self.del_button.take_click() {
1558 if let Some(idx) = self.selected_key_idx {
1559 if self.keys.len() > 2 {
1560 self.keys.remove(idx);
1561 self.selected_key_idx = None;
1562 self.sync_preset();
1563 self.just_changed = true;
1564 self.arrange_fields();
1565 }
1566 }
1567 }
1568 return true;
1569 }
1570 }
1571 } else {
1572 self.is_dragging_key = false;
1573 if self.selected_key_idx.is_some() {
1574 self.key_pad.mouse_input(button, state, px, py_event, ui);
1575 if self.del_button.mouse_input(button, state, px, py_event, ui)
1576 && self.del_button.take_click() {
1577 if let Some(idx) = self.selected_key_idx {
1578 if self.keys.len() > 2 {
1579 self.keys.remove(idx);
1580 self.selected_key_idx = None;
1581 self.sync_preset();
1582 self.just_changed = true;
1583 self.arrange_fields();
1584 }
1585 }
1586 }
1587 return true;
1588 }
1589 }
1590 false
1591
1592 }
1593 Event::PointerMove { x, y, .. } => {
1594 let (px, py_event) = (*x, *y);
1595 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
1596 if self.preset_dropdown.cursor_moved(px, py_event, ui) {
1597 return true;
1598 }
1599 if self.line_type_dropdown.cursor_moved(px, py_event, ui) {
1600 return true;
1601 }
1602
1603 let mut changed = false;
1604 let plot = self.plot_rect();
1605
1606 if self.is_dragging_key {
1607 if let Some(idx) = self.selected_key_idx {
1608 let t_raw = ((px - plot.x) / plot.width).clamp(0.0, 1.0);
1609 let t = self.resisted_pos(idx, t_raw);
1610 let val = (1.0 - (py_event - plot.y) / plot.height).clamp(0.0, 1.0);
1611 self.keys[idx].pos = t;
1612 self.keys[idx].value = val;
1613 self.key_pad.set_values(t, val);
1614 let settled = self.resettle_key(idx);
1615 self.selected_key_idx = Some(settled);
1616 self.sync_preset();
1617 changed = true;
1618 }
1619 }
1620
1621 if self.selected_key_idx.is_some() {
1622 if self.key_pad.cursor_moved(px, py_event, ui) {
1623 self.apply_pad_to_selected();
1624 changed = true;
1625 }
1626 if self.del_button.cursor_moved(px, py_event, ui) {
1627 changed = true;
1628 }
1629 }
1630 if changed {
1631 self.just_changed = true;
1632 }
1633 changed
1634
1635 }
1636 Event::MouseWheel { delta, x, y, .. } => {
1637 // Wheel forwarding (6bd self-routing): dropdowns first (mirroring the press
1638 // order, incl. the preset drain), then the value slider with the key sync.
1639 let (delta, px, py) = (*delta, *x, *y);
1640 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
1641 if self.preset_dropdown.mouse_wheel(&delta, px, py, ui) {
1642 if self.preset_dropdown.take_change() {
1643 let idx = self.preset_dropdown.selected;
1644 self.apply_preset(idx);
1645 }
1646 return true;
1647 }
1648 if self.line_type_dropdown.mouse_wheel(&delta, px, py, ui) {
1649 return true;
1650 }
1651 // Hover-scroll: a gesture STARTING over a key latches it and
1652 // steers it on both axes — following the fingers like a drag
1653 // — until the stream pauses (fingers lifted). Mid-gesture the
1654 // latch holds even if the key slides out from under the
1655 // cursor. Latching also selects the key, so the pad tracks.
1656 let plot = self.plot_rect();
1657 if ui.scroll_gesture_new {
1658 let hit_r = self.key_ring_r() + 2.5;
1659 let mut best: Option<(usize, f32)> = None;
1660 for (idx, key) in self.keys.iter().enumerate() {
1661 let cx = plot.x + key.pos * plot.width;
1662 let cy = plot.y + plot.height * (1.0 - key.value);
1663 let dx = px - cx;
1664 let dy = py - cy;
1665 let d2 = dx * dx + dy * dy;
1666 if d2 <= hit_r * hit_r && best.is_none_or(|(_, bd)| d2 < bd) {
1667 best = Some((idx, d2));
1668 }
1669 }
1670 self.scroll_key_idx = best.map(|(i, _)| i);
1671 self.scroll_vel = (0.0, 0.0);
1672 }
1673 if let Some(idx) = self.scroll_key_idx {
1674 if idx < self.keys.len() {
1675 ui.scroll_initiate_widget_id = Some(ectx.id);
1676 // Damped well below 1:1 — hover-scroll is for fine
1677 // adjustment; the drag paths cover coarse moves.
1678 let (dx, dy) = match &delta {
1679 MouseScrollDelta::LineDelta(x, y) => (*x * 0.005, *y * 0.005),
1680 MouseScrollDelta::PixelDelta(pos) => (
1681 0.2 * pos.x as f32 / plot.width,
1682 0.2 * pos.y as f32 / plot.height,
1683 ),
1684 };
1685 // Direct manipulation: the key moves WITH the scroll
1686 // (runner deltas are content-motion negated, so both
1687 // axes flip): scroll right → key right, down → down.
1688 self.keys[idx].pos = (self.keys[idx].pos - dx).clamp(0.0, 1.0);
1689 self.keys[idx].value = (self.keys[idx].value + dy).clamp(0.0, 1.0);
1690 // Velocity estimate for the release glide: EMA of
1691 // applied delta over inter-event time. A leisurely
1692 // wheel produces negligible velocity (big gaps clamp
1693 // to 0.1s); fast trackpad streams build real speed.
1694 let now = web_time::Instant::now();
1695 let dt_ev = self
1696 .last_key_scroll
1697 .map(|t| now.duration_since(t).as_secs_f32())
1698 .unwrap_or(0.016)
1699 .clamp(0.004, 0.1);
1700 self.last_key_scroll = Some(now);
1701 let (ivx, ivy) = (-dx / dt_ev, dy / dt_ev);
1702 self.scroll_vel = (
1703 self.scroll_vel.0 * 0.65 + ivx * 0.35,
1704 self.scroll_vel.1 * 0.65 + ivy * 0.35,
1705 );
1706 let settled = self.resettle_key(idx);
1707 self.scroll_key_idx = Some(settled);
1708 self.selected_key_idx = Some(settled);
1709 self.key_pad
1710 .set_values(self.keys[settled].pos, self.keys[settled].value);
1711 self.sync_preset();
1712 self.just_changed = true;
1713 self.arrange_fields();
1714 return true;
1715 }
1716 self.scroll_key_idx = None;
1717 }
1718 if self.selected_key_idx.is_some() && self.key_pad.mouse_wheel(&delta, px, py, ui) {
1719 self.apply_pad_to_selected();
1720 return true;
1721 }
1722 false
1723 }
1724 Event::KeyInput(event) => {
1725 let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
1726 if event.state != ElementState::Pressed { return false; }
1727
1728 let count = self.field_count();
1729 let current = self.focused_field(ui).filter(|&i| i < count);
1730
1731 if event.logical_key == Key::Named(NamedKey::Tab) {
1732 let next = match current {
1733 Some(curr) if event.shift => if curr == 0 { count - 1 } else { curr - 1 },
1734 Some(curr) => (curr + 1) % count,
1735 // Tab into the ramp: its first field takes the keyboard.
1736 None => 0,
1737 };
1738 self.focus_field(next, ui);
1739 return true;
1740 }
1741
1742 match current {
1743 Some(0) => self.preset_dropdown.keyboard_input(event, ui),
1744 Some(1) => self.line_type_dropdown.keyboard_input(event, ui),
1745 Some(2) => self.key_pad.keyboard_input(event, ui),
1746 Some(_) => self.del_button.keyboard_input(event, ui),
1747 None => false,
1748 }
1749 }
1750 Event::FocusIn => {
1751 // Focused itself, the ramp gives the keyboard to its preset dropdown.
1752 if let Some(ui) = ectx.ui.as_deref_mut() {
1753 self.focus_field(0, ui);
1754 }
1755 false
1756 }
1757 Event::FocusOut => {
1758 self.base.focused = false;
1759 self.preset_dropdown.unfocus();
1760 self.line_type_dropdown.unfocus();
1761 self.key_pad.unfocus();
1762 self.del_button.unfocus();
1763 false
1764 }
1765 _ => false,
1766 }
1767 }
1768
1769 // Field-slider drags forward through the composite (6bd self-routing), with the key
1770 // value sync the old descent path never ran mid-drag.
1771 fn draggable(&self, _rect: Rect) -> bool {
1772 self.is_dragging_key || self.key_pad.is_dragging()
1773 }
1774 fn is_dragging(&self) -> bool {
1775 self.is_dragging_key || self.key_pad.is_dragging()
1776 }
1777 fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
1778 if self.key_pad.is_dragging() && self.key_pad.drag_update(px, py) {
1779 self.apply_pad_to_selected();
1780 return true;
1781 }
1782 false
1783 }
1784 fn drag_end(&mut self) {
1785 self.key_pad.drag_end();
1786 self.is_dragging_key = false;
1787 }
1788 }
1789
1790 #[cfg(test)]
1791 mod tests {
1792 /// The ramp hands its fields the keyboard itself: the window's focus record names the
1793 /// field (it checks the record before taking a key), Tab walks them, and none of them
1794 /// enters the registry.
1795 #[test]
1796 fn the_ramp_hands_its_fields_the_keyboard() {
1797 use crate::widget::{Event, KeyEvent};
1798 let mut ctx = UiContext::new();
1799 let h = ctx.insert(Ramp::new());
1800 ctx.lend_h(h, |r, _| WidgetHost::set_rect(r, 0.0, 0.0, 300.0, 260.0));
1801 let (preset, line) = (ctx[h].field_id(0), ctx[h].field_id(1));
1802 ctx.set_focused_id(h.id());
1803 assert_eq!(ctx.focused_widget, Some(preset), "focused, the ramp gives the preset dropdown the keys");
1804 assert!(ctx[h].preset_dropdown.base().focused);
1805 let key = |named, shift| Event::KeyInput(KeyEvent {
1806 logical_key: Key::Named(named),
1807 state: ElementState::Pressed,
1808 text: None,
1809 repeat: false,
1810 ctrl: false,
1811 shift,
1812 alt: false,
1813 });
1814 assert!(ctx.propagate_event(&key(NamedKey::Tab, false), h.id()));
1815 assert_eq!(ctx.focused_widget, Some(line), "Tab walks to the line dropdown");
1816 assert!(!ctx[h].preset_dropdown.base().focused && ctx[h].line_type_dropdown.base().focused);
1817 ctx.propagate_event(&key(NamedKey::Tab, false), h.id());
1818 assert_eq!(ctx.focused_widget, Some(preset), "two fields with no key selected: it wraps");
1819 ctx.propagate_event(&key(NamedKey::Tab, true), h.id());
1820 assert_eq!(ctx.focused_widget, Some(line), "Shift+Tab walks back");
1821 assert!(ctx.propagate_event(&key(NamedKey::Enter, false), h.id()), "the focused dropdown takes Enter");
1822 assert!(ctx[h].line_type_dropdown.open, "and opens");
1823 // Focus moving on from the ramp reaches the field at the ramp's next tick.
1824 ctx.clear_focus();
1825 ctx.lend_h(h, |r, ctx| WidgetHost::tick(r, 0.016, ctx));
1826 assert!(!ctx[h].line_type_dropdown.base().focused, "the field let go");
1827 assert!(!ctx.tree.is_registered(line) && !ctx.tree.is_registered(preset), "no field entered the registry");
1828 }
1829
1830 use super::*;
1831
1832 /// The Preset dropdown lists the presets and nothing else. A curve
1833 /// edited by hand leaves the trigger blank rather than naming a preset
1834 /// it no longer is, and picking a preset, the one last shown included,
1835 /// puts it back.
1836 #[test]
1837 fn the_preset_dropdown_lists_only_presets() {
1838 let mut ramp = Ramp::new();
1839 let r = ramp.inner_mut();
1840 let names: Vec<&str> = RAMP_PRESETS.iter().map(|(n, _)| *n).collect();
1841 assert_eq!(r.preset_dropdown.options, names);
1842 assert!(!r.preset_dropdown.options.iter().any(|o| o == "Custom"));
1843 // A new ramp is the Raised curve, and says so.
1844 assert_eq!(r.preset_dropdown.options[r.preset_dropdown.selected], "Raised");
1845 assert_eq!(r.preset_dropdown.custom_display_text, None);
1846
1847 // A hand edit: the curve is no preset, and the trigger is blank.
1848 r.keys[1].value = 0.3;
1849 r.sync_preset();
1850 assert_eq!(r.preset_dropdown.custom_display_text.as_deref(), Some(""));
1851 // Picking Raised again restores it.
1852 r.apply_preset(1);
1853 assert_eq!(r.keys.len(), 4);
1854 assert_eq!(r.preset_dropdown.custom_display_text, None);
1855 assert_eq!(r.preset_dropdown.options[r.preset_dropdown.selected], "Raised");
1856
1857 // Every preset applies to its own curve and names itself.
1858 for (i, (name, keys)) in RAMP_PRESETS.iter().enumerate() {
1859 r.apply_preset(i);
1860 let got: Vec<(f32, f32)> = r.keys.iter().map(|k| (k.pos, k.value)).collect();
1861 assert_eq!(&got[..], *keys, "{name}");
1862 assert_eq!(r.preset_dropdown.options[r.preset_dropdown.selected], *name);
1863 }
1864
1865 // A spec that is a preset shows it; one that is none goes blank.
1866 r.apply_preset(0);
1867 assert!(r.set_spec("linear;0.000:1.000,0.500:0.000,1.000:1.000"));
1868 assert_eq!(r.preset_dropdown.options[r.preset_dropdown.selected], "Valley");
1869 assert_eq!(r.preset_dropdown.custom_display_text, None);
1870 assert!(r.set_spec("linear;0.000:0.100,1.000:0.900"));
1871 assert_eq!(r.preset_dropdown.custom_display_text.as_deref(), Some(""));
1872 }
1873 }