git.lucas.co / cce-ui
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 }