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

src/widget/input/ramp.rs (73.3K)

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