GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: Ramp rework — key pad, hover-scroll steering, foam-cell pegs
The value slider becomes a Slider2D key pad driving the selected key's
position and value together. Keys drag with soft-wall resistance at
neighbors (break through past RESIST_BREAK) and resettle by adjacent
swaps — exact identity tracking, where sort_keys' float re-match could
flip the selection onto a passed key. A trackpad scroll starting over a
key latches and steers it on both axes, with an exponential-decay glide
when the fingers lift. Pegs render as glassy discs with rolled rims;
overlapping pegs read as foam cells split by the chord wall. The plot
domain is inset from the opening's walls so the 0/1 gridlines and axis
numbers sit inside. Right-click opens the shared context menu with a
Collapse-controls toggle (graph claims the strip's space) plus the
spec-string Copy/Paste. cce-ramp adopts overflow-margin mode so pegs
at the domain edges render past the window frame onto a transparent
rim, and draws the context menu.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/bin/cce-ramp.rs | 65 +++-
src/context.rs | 12 +
src/widget/core.rs | 2 +
src/widget/input/ramp.rs | 785 +++++++++++++++++++++++++++++++++++++----------
4 files changed, 695 insertions(+), 169 deletions(-)
diff --git a/src/bin/cce-ramp.rs b/src/bin/cce-ramp.rs
index c8b3561..935f359 100644
--- a/src/bin/cce-ramp.rs
+++ b/src/bin/cce-ramp.rs
@@ -14,6 +14,11 @@ use cce_ui::widget::{
};
use wayland_client::QueueHandle;
+/// Transparent rim between the surface edge and the plate: room for the ramp's
+/// key pegs (r=28, +45 selected halo) to render outside the window frame
+/// instead of being clipped at the buffer edge.
+const OVERFLOW_MARGIN: f32 = 40.0;
+
#[derive(Debug, Clone)]
enum RampMsg {
Exit,
@@ -56,14 +61,21 @@ impl Application for RampPopup {
ramp,
last_spec,
ui_context: cce_ui::context::UiContext::new(),
- width: 460,
- height: 340,
+ width: 540,
+ height: 420,
scale_factor: 1.0,
needs_rebuild: true,
registered: false,
}
}
+ // Buffer-larger-than-geometry mode: the runner publishes the plate rect
+ // as the xdg window geometry + input region, so key pegs painted on the
+ // rim render outside the window frame and clicks there fall through.
+ fn overflow_margin(&self) -> u32 {
+ OVERFLOW_MARGIN as u32
+ }
+
fn settings(&self) -> WindowSettings {
WindowSettings {
title: "Ramp".to_string(),
@@ -83,6 +95,8 @@ impl Application for RampPopup {
fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
if self.ui_context.tick(dt) {
+ // Tick-driven edits (hover-scroll glide) log their spec too.
+ self.drain_changes();
*needs_rebuild = true;
self.needs_rebuild = true;
}
@@ -105,8 +119,11 @@ impl Application for RampPopup {
self.scale_factor = scale;
cce_ui::scale::set_scale_factor(scale as f32);
- // One widget, one rect: the ramp fills the plate inside the DE pad.
- let pad = cce_ui::layout::backplate_padding();
+ // One widget, one rect: the ramp fills the plate inside half the
+ // DE pad (this popup runs tighter than a full client). The plate
+ // itself is inset by OVERFLOW_MARGIN so key pegs can render past
+ // the window frame into the transparent surface rim.
+ let pad = OVERFLOW_MARGIN + cce_ui::layout::backplate_padding() / 2.0;
self.ramp.set_rect(
pad,
pad,
@@ -127,15 +144,19 @@ impl Application for RampPopup {
let (w, h) = (self.width as f32, self.height as f32);
// The window plate (the DemoApp idiom): page-low color at the configured
- // opacity, config corner radius, rolled perimeter.
+ // opacity, config corner radius, rolled perimeter — inset by the
+ // overflow margin so widget content (the ramp's key pegs) can spill
+ // past the frame onto the transparent rim.
let mut plate = cce_ui::color::page_low_color();
if plate[3] > 0.001 {
- plate[3] = cce_ui::color::active_backplate_opacity();
+ // Half the DE opacity: this popup reads better mostly-glass.
+ plate[3] = cce_ui::color::active_backplate_opacity() * 0.5;
}
let radius = cce_ui::colors::backplate_corner_radius();
let bevel = cce_ui::layout::bevel_width();
+ let m = OVERFLOW_MARGIN;
pc.plate(
- Rect { x: 0.0, y: 0.0, width: w, height: h },
+ Rect { x: m, y: m, width: w - 2.0 * m, height: h - 2.0 * m },
(radius, radius, radius, radius),
plate,
bevel,
@@ -162,6 +183,25 @@ impl Application for RampPopup {
}
}
+ // The shared context menu (right-click on the graph), drawn last, on
+ // top of everything. Its labels carry the menu rect as bounds — the
+ // runner exempts them from the menu's own text occlusion that way.
+ if self.ui_context.is_context_menu_visible() {
+ for (qx, qy, qw, qh, c) in self.ui_context.context_menu_quads() {
+ pc.quad(Rect { x: qx, y: qy, width: qw, height: qh }, c);
+ }
+ let (mx, my, mw, mh) = (
+ cce_ui::widget::context_menu::x(),
+ cce_ui::widget::context_menu::y(),
+ cce_ui::widget::context_menu::w(),
+ cce_ui::widget::context_menu::h(),
+ );
+ let bounds = Some([mx, my, mx + mw, my + mh]);
+ for l in self.ui_context.context_menu_labels() {
+ pc.text_with(l.text, l.x, l.y, l.font_size, l.color, None, bounds);
+ }
+ }
+
Some(pc.finish())
}
@@ -182,6 +222,10 @@ impl Application for RampPopup {
}
fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ if self.ui_context.cursor_moved_context_menu(pos.x, pos.y) {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
let changed = self.ui_context.propagate_event(&ev, self.ramp.id());
self.drain_changes();
@@ -198,6 +242,13 @@ impl Application for RampPopup {
pos: LogicalPosition,
needs_rebuild: &mut bool,
) -> Option<Self::Message> {
+ // The open context menu owns the press (item dispatch / dismiss).
+ if self.ui_context.mouse_input_context_menu(button, state, pos.x, pos.y) {
+ self.drain_changes();
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ return None;
+ }
let ev = Event::MouseButton {
button,
state,
diff --git a/src/context.rs b/src/context.rs
index 36f29ec..1a6863e 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -720,6 +720,18 @@ impl UiContext {
}
} else if name == "Breadcrumb" {
options.push("Copy Path".to_string());
+ } else if name == "Ramp" {
+ // The graph's menu: the controls-collapse toggle (check state in
+ // the label), then the spec-string clipboard pair.
+ let collapsed = unsafe {
+ (*target)
+ .as_any()
+ .downcast_ref::<crate::widget::input::Ramp>()
+ .map(|r| r.controls_collapsed)
+ .unwrap_or(false)
+ };
+ options.push(if collapsed { "✓ Collapse controls" } else { "Collapse controls" }.to_string());
+ options.extend(vec!["Copy".to_string(), "Paste".to_string()]);
} else {
options.extend(vec!["Copy".to_string(), "Paste".to_string()]);
}
diff --git a/src/widget/core.rs b/src/widget/core.rs
index ac23e02..1c6142a 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -431,6 +431,8 @@ pub mod context_menu {
"Expand All" => Some(CA::ExpandAll),
"Collapse All" => Some(CA::CollapseAll),
"Copy Path" => Some(CA::CopyPath),
+ // The Ramp toggle carries its check state in the label.
+ "✓ Collapse controls" | "Collapse controls" => Some(CA::ToggleRampControls),
_ => None,
};
if let Some(action) = action {
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index 51f456a..0a000b7 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -3,7 +3,7 @@ use crate::scene::layout::{Rect, Size};
use crate::scene::paint::{Cap, PaintCtx};
use crate::widget::model::{EventCtx, Input, Layout, Paint};
use crate::widget::*;
-use crate::widget::input::{Slider, Button};
+use crate::widget::input::{Slider, Slider2D, Button};
// ==========================================
// 1. Color Ramp (renamed from Ramp)
@@ -115,13 +115,26 @@ pub struct Ramp {
pub selected_key_idx: Option<usize>,
pub is_dragging_key: bool,
pub just_changed: bool,
-
- // Child controls for value editing & deletion
- pub val_slider: Adapted<Slider>,
+ /// The key latched by the current hover-scroll gesture: a trackpad
+ /// scroll starting over a key steers that key until the fingers lift
+ /// (a >250ms pause reads as a new gesture and re-latches by hover).
+ scroll_key_idx: Option<usize>,
+ /// Context-menu toggle: hide the bottom control strip and let the graph
+ /// claim its space.
+ pub controls_collapsed: bool,
+ /// Hover-scroll glide velocity (plot units/sec, applied-delta signs) and
+ /// the last scroll-event instant: when the event stream stops, the tick
+ /// keeps the latched key coasting with exponential decay.
+ scroll_vel: (f32, f32),
+ last_key_scroll: Option<std::time::Instant>,
+
+ // Child controls for key editing & deletion. The key pad is a 2-axis
+ // slider driving the selected key's position (x) and value (y).
+ pub key_pad: Adapted<Slider2D>,
pub del_button: Adapted<Button>,
pub preset_dropdown: Adapted<Dropdown>,
pub line_type_dropdown: Adapted<Dropdown>,
-
+
}
impl Ramp {
@@ -133,9 +146,9 @@ impl Ramp {
RampKey { pos: 1.0, value: 0.5 },
];
- // Labeled like the dropdowns: the slider draws "Value" in its own
- // carve-out tab.
- let val_slider = Slider::new().with_label("Value");
+ // The key pad: a 2-axis slider driving the selected key's position
+ // (x) and value (y), labeled like the dropdowns.
+ let key_pad = Slider2D::new().with_label("Key");
// A square x-icon button (cce-icons); label fallback if the icon set
// is missing on this machine.
let del_button = match crate::upload_icon("x", 32) {
@@ -173,7 +186,11 @@ impl Ramp {
selected_key_idx: None,
is_dragging_key: false,
just_changed: false,
- val_slider,
+ scroll_key_idx: None,
+ controls_collapsed: false,
+ scroll_vel: (0.0, 0.0),
+ last_key_scroll: None,
+ key_pad,
del_button,
preset_dropdown,
line_type_dropdown,
@@ -255,16 +272,6 @@ impl Ramp {
self.keys[0].value
}
- fn sort_keys(&mut self) {
- let prev_selected_id = self.selected_key_idx.map(|idx| self.keys[idx].pos);
- self.keys.sort_by(|a, b| a.pos.partial_cmp(&b.pos).unwrap());
- if let Some(pos) = prev_selected_id {
- if let Some(new_idx) = self.keys.iter().position(|k| (k.pos - pos).abs() < 0.0001) {
- self.selected_key_idx = Some(new_idx);
- }
- }
- }
-
/// Whether segments blend with smoothstep (the Bezier line type) vs linearly.
pub fn smooth(&self) -> bool {
self.line_type_dropdown.selected == 1
@@ -760,14 +767,177 @@ impl Input for ColorRamp {
}
impl Ramp {
- /// Vertical reserve under the curve area for the control strip (gap, label
- /// tabs, controls, bottom margin) — the graph gets the rest. Sized so the
- /// graph opening's rim shading stays clear of the label tabs' carves.
- const STRIP_RESERVE: f32 = 82.0;
-
- /// The curve area's height for a widget `h` tall.
- fn graph_h(h: f32) -> f32 {
- (h - Self::STRIP_RESERVE).max(30.0)
+ /// The one spacing value the whole control strip uses — matching the
+ /// visible gap between the graph opening and the window's top edge (the
+ /// widget's 10px graph inset plus the host plate's padding).
+ const STRIP_GAP: f32 = 18.0;
+
+ /// The key pad's square well side.
+ const PAD_SIDE: f32 = 64.0;
+
+ /// Vertical reserve under the curve area — the strip stack at the
+ /// uniform STRIP_GAP rhythm (labeled dropdown row, labeled pad row),
+ /// closed by a bottom margin sized so the VISIBLE bottom gap (widget
+ /// margin + host plate padding, ~8) lands on STRIP_GAP as well.
+ fn strip_reserve() -> f32 {
+ let strip = Self::label_strip();
+ 10.0 + Self::STRIP_GAP + strip + 22.0
+ + Self::STRIP_GAP + strip + Self::PAD_SIDE
+ + 10.0
+ }
+
+ /// Key peg ring stroke centerline radius (the 2px stroke spans ±1px).
+ /// Paint and the grab hit-test share it: a press anywhere inside a ring
+ /// lands on that key.
+ const KEY_RING_R: f32 = 26.0;
+
+ /// Inner margin between the graph opening's walls and the plotted 0..1
+ /// domain, so the 0 and 1 gridlines (and their axis numbers) sit visibly
+ /// inside the opening instead of on the walls.
+ const PLOT_INSET: f32 = 22.0;
+
+ /// The plot rect: where the ramp's 0..1 × 0..1 domain maps on screen —
+ /// the graph opening inset by [`PLOT_INSET`](Self::PLOT_INSET). Every
+ /// t/value ↔ pixel mapping (paint and input alike) goes through this.
+ fn plot_rect(&self) -> Rect {
+ let gh = self.graph_h();
+ Rect {
+ x: self.base.x + 10.0 + Self::PLOT_INSET,
+ y: self.base.y + 10.0 + Self::PLOT_INSET,
+ width: (self.base.w - 20.0 - 2.0 * Self::PLOT_INSET).max(1.0),
+ height: (gh - 2.0 * Self::PLOT_INSET).max(1.0),
+ }
+ }
+
+ /// Neighbor resistance (drag), in track units: the soft wall starts
+ /// RESIST_ZONE before a neighbor's position, and pushing the cursor
+ /// RESIST_BREAK past the neighbor breaks through.
+ const RESIST_ZONE: f32 = 0.10;
+ const RESIST_BREAK: f32 = 0.16;
+
+ /// Where a drag whose cursor sits at `t_raw` actually puts key `idx`:
+ /// 1:1 tracking until the cursor enters a neighbor's resistance zone,
+ /// then the key compresses toward the neighbor with growing resistance
+ /// (slope 1 at the zone edge, flattening at the wall), and once the
+ /// cursor overshoots the neighbor by RESIST_BREAK the key pops through —
+ /// the crossing completes and tracking is free again.
+ fn resisted_pos(&self, idx: usize, t_raw: f32) -> f32 {
+ let cur = self.keys[idx].pos;
+ if t_raw > cur {
+ if let Some(next) = self.keys.get(idx + 1) {
+ return Self::soft_wall(t_raw, next.pos, 1.0);
+ }
+ } else if idx > 0 {
+ return Self::soft_wall(t_raw, self.keys[idx - 1].pos, -1.0);
+ }
+ t_raw
+ }
+
+ /// Restore sort order after `keys[i]` changed position, by adjacent
+ /// swaps, and return the key's new index. Exact identity tracking —
+ /// `sort_keys`' float-pos re-match misidentifies the selection when the
+ /// dragged key sits within ε of the key it is passing (leftward
+ /// crossings flipped the selection onto the passed key).
+ fn resettle_key(&mut self, mut i: usize) -> usize {
+ while i + 1 < self.keys.len() && self.keys[i].pos > self.keys[i + 1].pos {
+ self.keys.swap(i, i + 1);
+ i += 1;
+ }
+ while i > 0 && self.keys[i].pos < self.keys[i - 1].pos {
+ self.keys.swap(i, i - 1);
+ i -= 1;
+ }
+ i
+ }
+
+ /// A key's rolled edge: the disc's own surface curving away at the
+ /// perimeter — NOT a separate border. Each sub-arc blends radially from
+ /// the surface color at the band's inner edge (continuing the flat top
+ /// seamlessly), through a half-rolled tint, to the silhouette — which
+ /// leans toward the light on the lit side and falls into shadow opposite,
+ /// and runs denser than the top the way a glass edge reads. `r` is the
+ /// outer-edge radius; `base`/`top_alpha` are the disc's surface color.
+ #[allow(clippy::too_many_arguments)]
+ fn rolled_rim_arc(
+ pc: &mut PaintCtx,
+ cx: f32,
+ cy: f32,
+ r: f32,
+ thickness: f32,
+ start: f32,
+ end: f32,
+ az: f32,
+ base: [f32; 3],
+ top_alpha: f32,
+ ) {
+ let sweep = end - start;
+ let steps = ((sweep.abs() / 0.18).ceil() as usize).max(1);
+ let tint = |sv: f32, k: f32| -> [f32; 3] {
+ [
+ (base[0] + k * sv).clamp(0.0, 1.0),
+ (base[1] + k * sv).clamp(0.0, 1.0),
+ (base[2] + k * sv).clamp(0.0, 1.0),
+ ]
+ };
+ for i in 0..steps {
+ let a0 = start + sweep * i as f32 / steps as f32;
+ let a1 = start + sweep * (i + 1) as f32 / steps as f32;
+ let sv = ((a0 + a1) / 2.0 + az).cos();
+ let mid = tint(sv, 0.20);
+ let edge = tint(sv, 0.38);
+ let mid_a = (top_alpha + 0.78) / 2.0;
+ pc.arc_shaded(
+ cx,
+ cy,
+ r,
+ thickness,
+ a0,
+ a1,
+ [base[0], base[1], base[2], top_alpha],
+ [mid[0], mid[1], mid[2], mid_a],
+ [edge[0], edge[1], edge[2], 0.78],
+ );
+ }
+ }
+
+ /// Apply the key pad's two axes to the selected key: x is the key's
+ /// track position (order restored by adjacent swaps), y its value.
+ fn apply_pad_to_selected(&mut self) {
+ let Some(idx) = self.selected_key_idx else { return };
+ self.keys[idx].pos = self.key_pad.inner().value_x();
+ self.keys[idx].value = self.key_pad.inner().value_y();
+ let settled = self.resettle_key(idx);
+ self.selected_key_idx = Some(settled);
+ self.preset_dropdown.selected = 0; // Custom
+ self.just_changed = true;
+ }
+
+ /// One soft wall at `wall`, approached along direction `s` (±1). Maps the
+ /// cursor's depth into the zone onto the zone's width with an ease that
+ /// reaches the wall exactly at breakthrough depth — continuous at the
+ /// zone edge, asymptotically stiff at the wall, then a `RESIST_BREAK`
+ /// pop as the mapping hands back to 1:1 tracking.
+ fn soft_wall(t_raw: f32, wall: f32, s: f32) -> f32 {
+ let entry = wall - s * Self::RESIST_ZONE;
+ let depth = s * (t_raw - entry);
+ let full = Self::RESIST_ZONE + Self::RESIST_BREAK;
+ if depth <= 0.0 || depth >= full {
+ return t_raw; // outside the zone, or broken through
+ }
+ let k = full / Self::RESIST_ZONE;
+ let g = 1.0 - (1.0 - depth / full).powf(k);
+ entry + s * Self::RESIST_ZONE * g
+ }
+
+ /// The curve area's height: the widget minus the control strip — or,
+ /// with the controls collapsed (context-menu toggle), minus just the
+ /// top/bottom insets, the graph claiming the strip's space.
+ fn graph_h(&self) -> f32 {
+ if self.controls_collapsed {
+ (self.base.h - 20.0).max(30.0)
+ } else {
+ (self.base.h - Self::strip_reserve()).max(30.0)
+ }
}
/// The detached-label strip height the labeled dropdowns carry
@@ -781,43 +951,59 @@ impl Ramp {
}
/// Lay out the control strip under the curve area. One rhythm: the label
- /// line sits 8px under the graph, the controls 4px under the labels, all
- /// columns one shared height on one shared baseline. The labeled dropdowns
+ /// tabs sit STRIP_GAP under the graph and every other gap shares the
+ /// same rhythm, all columns one shared height on one shared baseline. The labeled dropdowns
/// get rects that INCLUDE their label strip (the adapter carves it off the
/// content); the unlabeled columns get the content band only. The preset
/// column takes the wider share — its options are the strip's longest
/// strings and used to clip.
fn arrange_fields(&mut self) {
let (x, y, w, h) = (self.base.x, self.base.y, self.base.w, self.base.h);
- let gh = Self::graph_h(h);
+ if self.controls_collapsed {
+ self.preset_dropdown.set_rect(-1000.0, -1000.0, 0.0, 0.0);
+ self.line_type_dropdown.set_rect(-1000.0, -1000.0, 0.0, 0.0);
+ self.key_pad.set_rect(-1000.0, -1000.0, 0.0, 0.0);
+ self.del_button.set_rect(-1000.0, -1000.0, 0.0, 0.0);
+ let _ = (x, y, w, h);
+ return;
+ }
+ let gh = self.graph_h();
let graph_bottom = y + 10.0 + gh;
- let ctrl_y = graph_bottom + 40.0;
let ctrl_h = 22.0;
let strip = Self::label_strip();
+ let gap = Self::STRIP_GAP;
+ // One rhythm: every gap in the strip — graph to label tab, row to
+ // row, columns, pad to button — is STRIP_GAP.
+ let ctrl_y = graph_bottom + gap + strip;
let (dd_y, dd_h) = (ctrl_y - strip, ctrl_h + strip);
let track_x = x + 10.0;
let track_w = w - 20.0;
- let gap = 10.0;
if self.selected_key_idx.is_some() {
- // Four columns: preset, line type, value, and the delete button —
- // a square x-icon tile (label fallback runs wider).
+ // Selected: the dropdowns keep their full-width row, and a second
+ // row below carries the square key pad (pos × value) with the
+ // delete button beside it, centered on the pad's well.
+ let pad_side = Self::PAD_SIDE;
let del_w: f32 = if self.del_button.inner().has_icon() { ctrl_h } else { 64.0 };
- let avail = (track_w - del_w - 3.0 * gap).max(120.0);
- let pre_w = (avail * 0.40).max(40.0);
- let line_w = (avail * 0.32).max(40.0);
- let val_w = (avail - pre_w - line_w).max(40.0);
+ let pre_w = ((track_w - gap) * 0.58).max(40.0);
+ let line_w = (track_w - gap - pre_w).max(40.0);
self.preset_dropdown.set_rect(track_x, dd_y, pre_w, dd_h);
self.line_type_dropdown.set_rect(track_x + pre_w + gap, dd_y, line_w, dd_h);
- self.val_slider.set_rect(track_x + pre_w + line_w + 2.0 * gap, dd_y, val_w, dd_h);
- self.del_button.set_rect(track_x + track_w - del_w, ctrl_y, del_w, ctrl_h);
+ let row2_y = ctrl_y + ctrl_h + gap;
+ self.key_pad.set_rect(track_x, row2_y, pad_side, pad_side + strip);
+ self.del_button.set_rect(
+ track_x + pad_side + gap,
+ row2_y + strip + (pad_side - ctrl_h) / 2.0,
+ del_w,
+ ctrl_h,
+ );
} else {
// Two columns, preset the wider share.
let pre_w = ((track_w - gap) * 0.58).max(40.0);
let line_w = (track_w - gap - pre_w).max(40.0);
self.preset_dropdown.set_rect(track_x, dd_y, pre_w, dd_h);
self.line_type_dropdown.set_rect(track_x + pre_w + gap, dd_y, line_w, dd_h);
- self.val_slider.set_rect(-1000.0, -1000.0, 0.0, 0.0);
+ self.key_pad.set_rect(-1000.0, -1000.0, 0.0, 0.0);
self.del_button.set_rect(-1000.0, -1000.0, 0.0, 0.0);
}
}
@@ -872,7 +1058,7 @@ impl Paint for Ramp {
// behind the plate, with the recess wall (drawn after the content, so
// its shading falls across the graph's edges) as the cut's bevel.
let graph = {
- let gh = Self::graph_h(self.base.h);
+ let gh = self.graph_h();
Rect { x: self.base.x + 10.0, y: self.base.y + 10.0, width: self.base.w - 20.0, height: gh }
};
let graph_radius = 6.0f32;
@@ -885,20 +1071,17 @@ impl Paint for Ramp {
let quads: Vec<(f32, f32, f32, f32, [f32; 4])> = {
let mut quads = Vec::new();
- let gh = Self::graph_h(self.base.h);
- let track_x = self.base.x + 10.0;
- let track_w = self.base.w - 20.0;
-
- // Draw grid lines
- for ratio in [0.25, 0.5, 0.75] {
- let gy = self.base.y + 10.0 + gh * (1.0 - ratio);
- quads.push((track_x, gy, track_w, 1.0, [0.25, 0.25, 0.28, 0.5]));
- }
- for ratio in [0.25, 0.5, 0.75] {
- let gx = track_x + track_w * ratio;
- quads.push((gx, self.base.y + 10.0, 1.0, gh, [0.25, 0.25, 0.28, 0.5]));
+ let plot = self.plot_rect();
+
+ // Grid lines over the plotted 0..1 domain — 0 and 1 included, sitting
+ // inside the opening (the plot is inset from the walls).
+ for ratio in [0.0, 0.25, 0.5, 0.75, 1.0] {
+ let gy = plot.y + plot.height * (1.0 - ratio);
+ quads.push((plot.x, gy, plot.width, 1.0, [0.25, 0.25, 0.28, 0.5]));
+ let gx = plot.x + plot.width * ratio;
+ quads.push((gx, plot.y, 1.0, plot.height, [0.25, 0.25, 0.28, 0.5]));
}
-
+
// Curve area fill: translucent columns under the curve. The outline is
// a real vector polyline below — these only tint the area. Columns
// share exact edges (overlap double-blends a translucent fill into
@@ -906,12 +1089,12 @@ impl Paint for Ramp {
let slices = 200;
for i in 0..slices {
let t1 = i as f32 / slices as f32;
- let x0 = track_x + t1 * track_w;
- let x1 = track_x + (i + 1) as f32 / slices as f32 * track_w;
+ let x0 = plot.x + t1 * plot.width;
+ let x1 = plot.x + (i + 1) as f32 / slices as f32 * plot.width;
let v1 = self.get_interpolated_value(t1);
- let slice_h = v1 * gh;
- let sy = self.base.y + 10.0 + gh - slice_h;
+ let slice_h = v1 * plot.height;
+ let sy = plot.y + plot.height - slice_h;
// Faint on purpose: the graph reads as a dark opening behind the
// plate — a strong fill floods the floor and flattens the depth.
quads.push((x0, sy, x1 - x0, slice_h, [0.25, 0.40, 0.55, 0.10]));
@@ -925,10 +1108,12 @@ impl Paint for Ramp {
}
// Axis numbers on the gridlines — small, dim, part of the graph
- // floor (under the curve and keys, inside the opening).
+ // floor (under the curve and keys, inside the opening). They sit in
+ // the wall-side gutters the plot inset leaves free.
+ let plot = self.plot_rect();
let num_color = [0x84u8, 0x84, 0x92];
- for ratio in [0.25f32, 0.5, 0.75] {
- let gy = graph.y + graph.height * (1.0 - ratio);
+ for ratio in [0.0f32, 0.25, 0.5, 0.75, 1.0] {
+ let gy = plot.y + plot.height * (1.0 - ratio);
pc.text_with(
format!("{ratio:.2}"),
graph.x + 5.0,
@@ -938,7 +1123,7 @@ impl Paint for Ramp {
Some("monospace".to_string()),
None,
);
- let gx = graph.x + graph.width * ratio;
+ let gx = plot.x + plot.width * ratio;
pc.text_with(
format!("{ratio:.2}"),
gx - 11.0,
@@ -952,13 +1137,10 @@ impl Paint for Ramp {
// The curve itself: one anti-aliased round-capped polyline — exact
// key-to-key segments in linear mode, dense samples under smoothstep
- // blending. Constant-value extensions reach the graph's side walls.
- let gh = Self::graph_h(self.base.h);
- let track_x = self.base.x + 10.0;
- let track_w = self.base.w - 20.0;
+ // blending. Constant-value extensions reach the plot's 0/1 edges.
let curve_color = [0.5, 0.75, 1.0, 1.0];
let px_of = |t: f32, v: f32| {
- (track_x + t * track_w, self.base.y + 10.0 + gh - v * gh)
+ (plot.x + t * plot.width, plot.y + plot.height * (1.0 - v))
};
let mut pts: Vec<(f32, f32)> = Vec::new();
if self.line_type_dropdown.selected == 1 {
@@ -985,28 +1167,168 @@ impl Paint for Ramp {
for pair in pts.windows(2) {
pc.vector(pair[0].0, pair[0].1, pair[1].0, pair[1].1, 2.0, curve_color, Cap::Round);
}
- let circles: Vec<(f32, f32, f32, [f32; 4])> = {
- let mut circles = Vec::new();
- let gh = Self::graph_h(self.base.h);
- let track_x = self.base.x + 10.0;
- let track_w = self.base.w - 20.0;
-
- for (idx, key) in self.keys.iter().enumerate() {
- let cx = track_x + key.pos * track_w;
- let cy = self.base.y + 10.0 + gh - key.value * gh;
-
- circles.push((cx, cy, 7.0, [0.0, 0.0, 0.0, 0.8]));
- circles.push((cx, cy, 5.0, [0.5, 0.75, 1.0, 1.0]));
- if Some(idx) == self.selected_key_idx {
- circles.push((cx, cy, 9.0, [0.49, 1.0, 1.0, 0.5]));
+ // Key pegs: glassy translucent fills (solid when selected) in thin
+ // white rings. Overlapping pegs render as foam cells: each pair's
+ // shared wall is the chord through the two points where the ring
+ // circles cross (equal radii, so it lies on the perpendicular
+ // bisector of the centers); rings are cut at the wall, the wall is
+ // stroked once, and each fill keeps to its own side.
+ {
+ let plot = self.plot_rect();
+ let ring_r = Self::KEY_RING_R; // roll-band centerline
+ // The disc surface: flat top out to the roll band's inner edge,
+ // then the rolled perimeter out to ring_r + 2.5.
+ let base = [0.5f32, 0.75, 1.0];
+ let fill_r = ring_r - 3.0;
+ let rim_t = 6.0f32;
+ // Bevel light: the DE light azimuth the plate shading uses.
+ let az = crate::layout::light_source_position();
+ let tau = std::f32::consts::TAU;
+
+ let centers: Vec<(f32, f32)> = self
+ .keys
+ .iter()
+ .map(|k| (plot.x + k.pos * plot.width, plot.y + plot.height * (1.0 - k.value)))
+ .collect();
+
+ // Every intersecting pair: wall midpoint M + unit normal n toward
+ // the neighbor per key, and the chord endpoints once per pair.
+ let mut cuts: Vec<Vec<((f32, f32), (f32, f32))>> = vec![Vec::new(); centers.len()];
+ let mut walls: Vec<((f32, f32), (f32, f32), (f32, f32))> = Vec::new();
+ for i in 0..centers.len() {
+ for j in (i + 1)..centers.len() {
+ let (dx, dy) = (centers[j].0 - centers[i].0, centers[j].1 - centers[i].1);
+ let d = (dx * dx + dy * dy).sqrt();
+ if d < 1e-3 || d >= 2.0 * ring_r {
+ continue;
+ }
+ let n = (dx / d, dy / d);
+ let m =
+ ((centers[i].0 + centers[j].0) / 2.0, (centers[i].1 + centers[j].1) / 2.0);
+ cuts[i].push((m, n));
+ cuts[j].push((m, (-n.0, -n.1)));
+ let h = (ring_r * ring_r - (d / 2.0) * (d / 2.0)).sqrt();
+ walls.push((
+ (m.0 - h * n.1, m.1 + h * n.0),
+ (m.0 + h * n.1, m.1 - h * n.0),
+ n,
+ ));
+ }
+ }
+
+ // Fills. Uncut: one disc. Cut: the cell — vertical strips bounded
+ // by the wall half-planes, the round edge from the circle clip.
+ for (idx, &(cx, cy)) in centers.iter().enumerate() {
+ let selected = Some(idx) == self.selected_key_idx;
+ let fill = [base[0], base[1], base[2], if selected { 0.85 } else { 0.22 }];
+ if cuts[idx].is_empty() {
+ pc.circle(cx, cy, fill_r, fill);
+ continue;
+ }
+ pc.push_clip_circle([cx, cy, fill_r]);
+ let step = 1.5f32;
+ let mut x = cx - fill_r;
+ while x < cx + fill_r {
+ let mid = x + step / 2.0;
+ let (mut ylo, mut yhi) = (cy - fill_r, cy + fill_r);
+ let mut visible = true;
+ for &((mx, my), (nx, ny)) in &cuts[idx] {
+ // Keep (p − M)·n ≤ 0 — this key's side of the wall.
+ let c = nx * (mid - mx);
+ if ny.abs() < 1e-4 {
+ if c > 0.0 {
+ visible = false;
+ break;
+ }
+ } else {
+ let yb = my - c / ny;
+ if ny > 0.0 {
+ yhi = yhi.min(yb);
+ } else {
+ ylo = ylo.max(yb);
+ }
+ }
+ }
+ if visible && ylo < yhi {
+ pc.quad(Rect { x, y: ylo, width: step, height: yhi - ylo }, fill);
+ }
+ x += step;
+ }
+ pc.pop_clip_circle();
+ }
+
+ // Walls: the shared boundary as the surface rolling into the
+ // seam and back out — surface-tinted slopes (lit side leans to
+ // the light, far side into shadow) around a slightly lifted
+ // crest, in the discs\' own color like the rims.
+ let (lx, ly) = (az.cos(), -az.sin());
+ let wall_tint = |sv: f32, k: f32| -> [f32; 3] {
+ [
+ (base[0] + k * sv).clamp(0.0, 1.0),
+ (base[1] + k * sv).clamp(0.0, 1.0),
+ (base[2] + k * sv).clamp(0.0, 1.0),
+ ]
+ };
+ for &((x1, y1), (x2, y2), (nx, ny)) in &walls {
+ let facing = nx * lx + ny * ly;
+ let cp = wall_tint(facing, 0.38);
+ let cm = wall_tint(-facing, 0.38);
+ let cc = wall_tint(facing, 0.12);
+ pc.vector(
+ x1 + nx * 1.6, y1 + ny * 1.6, x2 + nx * 1.6, y2 + ny * 1.6,
+ 1.6, [cp[0], cp[1], cp[2], 0.78], Cap::Round,
+ );
+ pc.vector(
+ x1 - nx * 1.6, y1 - ny * 1.6, x2 - nx * 1.6, y2 - ny * 1.6,
+ 1.6, [cm[0], cm[1], cm[2], 0.78], Cap::Round,
+ );
+ pc.vector(x1, y1, x2, y2, 1.8, [cc[0], cc[1], cc[2], 0.85], Cap::Round);
+ }
+
+ // Rims: beveled circles minus the angular span facing each wall
+ // (no drawn border — the shaded edge IS the ring).
+ for (idx, &(cx, cy)) in centers.iter().enumerate() {
+ let top_a = if Some(idx) == self.selected_key_idx { 0.85 } else { 0.22 };
+ if cuts[idx].is_empty() {
+ Self::rolled_rim_arc(pc, cx, cy, ring_r + 2.5, rim_t, 0.0, tau, az, base, top_a);
+ continue;
+ }
+ // Excluded spans [θ−α, θ+α] toward each neighbor, normalized
+ // into [0, τ) (wrapping spans split), then merged.
+ let mut segs: Vec<(f32, f32)> = Vec::new();
+ for &((mx, my), (nx, ny)) in &cuts[idx] {
+ let theta = ny.atan2(nx);
+ let half = (mx - cx) * nx + (my - cy) * ny;
+ let alpha = (half / ring_r).clamp(-1.0, 1.0).acos();
+ let (a, b) = ((theta - alpha).rem_euclid(tau), (theta + alpha).rem_euclid(tau));
+ if a <= b {
+ segs.push((a, b));
+ } else {
+ segs.push((a, tau));
+ segs.push((0.0, b));
+ }
+ }
+ segs.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());
+ let mut merged: Vec<(f32, f32)> = Vec::new();
+ for s in segs {
+ match merged.last_mut() {
+ Some(last) if s.0 <= last.1 => last.1 = last.1.max(s.1),
+ _ => merged.push(s),
+ }
+ }
+ // Stroke the complement (the two pieces meeting at θ=0 join
+ // seamlessly when no span covers 0).
+ let mut prev = 0.0f32;
+ for &(a, b) in &merged {
+ if a > prev + 1e-3 {
+ Self::rolled_rim_arc(pc, cx, cy, ring_r + 2.5, rim_t, prev, a, az, base, top_a);
+ }
+ prev = prev.max(b);
+ }
+ if prev < tau - 1e-3 {
+ Self::rolled_rim_arc(pc, cx, cy, ring_r + 2.5, rim_t, prev, tau, az, base, top_a);
+ }
}
- }
-
- circles
-
- };
- for (cx, cy, r, c) in circles {
- pc.circle(cx, cy, r, c);
}
// The opening's cut edge: drawn after the graph content so the wall's
// shading falls across the curve and keys where they pass behind the
@@ -1019,12 +1341,14 @@ impl Paint for Ramp {
}
let depth = crate::layout::bevel_width().min(graph.height * 0.2);
pc.recess(graph, radii, depth);
- let dummy = UiContext::new();
- self.preset_dropdown.paint_self(&dummy, pc);
- self.line_type_dropdown.paint_self(&dummy, pc);
- if self.selected_key_idx.is_some() {
- self.val_slider.paint_self(&dummy, pc);
- self.del_button.paint_self(&dummy, pc);
+ if !self.controls_collapsed {
+ let dummy = UiContext::new();
+ self.preset_dropdown.paint_self(&dummy, pc);
+ self.line_type_dropdown.paint_self(&dummy, pc);
+ if self.selected_key_idx.is_some() {
+ self.key_pad.paint_self(&dummy, pc);
+ self.del_button.paint_self(&dummy, pc);
+ }
}
}
}
@@ -1034,6 +1358,35 @@ impl Input for Ramp {
true
}
+ /// The graph context menu's actions. Overriding loses the trait-default
+ /// clipboard arms, so Copy/Paste (the spec string) are restated here.
+ fn context_action(&mut self, action: ContextAction) -> bool {
+ match action {
+ ContextAction::ToggleRampControls => {
+ self.controls_collapsed = !self.controls_collapsed;
+ self.just_changed = true;
+ self.arrange_fields();
+ true
+ }
+ ContextAction::Copy => {
+ crate::widget::clipboard::copy_to_clipboard(&self.spec_string());
+ true
+ }
+ ContextAction::Paste => {
+ if let Some(text) = crate::widget::clipboard::read_from_clipboard() {
+ let changed = self.set_spec(&text);
+ if changed {
+ self.just_changed = true;
+ }
+ changed
+ } else {
+ false
+ }
+ }
+ _ => false,
+ }
+ }
+
/// The curve as a ramp spec string ([`format_ramp_spec`]) — the value hosts
/// poll and persist for ramp-valued params.
fn value_string(&self) -> Option<String> {
@@ -1066,7 +1419,32 @@ impl Input for Ramp {
};
let mut changed = self.just_changed;
self.just_changed = false;
-
+
+ // Hover-scroll inertia: once the finger stream stops (>60ms without
+ // an event), the latched key coasts on the estimated velocity with
+ // exponential decay, still resettling and syncing like live scrolls.
+ if let (Some(idx), Some(last)) = (self.scroll_key_idx, self.last_key_scroll) {
+ if last.elapsed().as_secs_f32() > 0.06 && idx < self.keys.len() {
+ let (vx, vy) = self.scroll_vel;
+ if vx.abs() > 0.02 || vy.abs() > 0.02 {
+ self.keys[idx].pos = (self.keys[idx].pos + vx * dt).clamp(0.0, 1.0);
+ self.keys[idx].value = (self.keys[idx].value + vy * dt).clamp(0.0, 1.0);
+ let settled = self.resettle_key(idx);
+ self.scroll_key_idx = Some(settled);
+ self.selected_key_idx = Some(settled);
+ self.key_pad
+ .set_values(self.keys[settled].pos, self.keys[settled].value);
+ self.preset_dropdown.selected = 0; // Custom
+ let f = (-5.0 * dt).exp();
+ self.scroll_vel = (vx * f, vy * f);
+ changed = true;
+ } else {
+ self.scroll_vel = (0.0, 0.0);
+ self.last_key_scroll = None;
+ }
+ }
+ }
+
if self.preset_dropdown.tick(dt, ui) {
let idx = self.preset_dropdown.selected;
self.apply_preset(idx);
@@ -1078,11 +1456,8 @@ impl Input for Ramp {
}
if self.selected_key_idx.is_some() {
- if self.val_slider.tick(dt, ui) {
- if let Some(idx) = self.selected_key_idx {
- self.keys[idx].value = self.val_slider.inner().value();
- self.preset_dropdown.selected = 0; // Custom
- }
+ if self.key_pad.tick(dt, ui) {
+ self.apply_pad_to_selected();
changed = true;
}
if self.del_button.tick(dt, ui) {
@@ -1098,9 +1473,25 @@ impl Input for Ramp {
match event {
Event::MouseButton { button, state, x, y, .. } => {
let (button, state, px, py_event) = (*button, *state, *x, *y);
+ // Right-press in the graph opening → the shared context menu
+ // (the key-crossing toggle lives there). Before the ui borrow:
+ // open_context_menu needs the whole EventCtx.
+ if button == MouseButton::Right {
+ if state == ElementState::Pressed {
+ let gh = self.graph_h();
+ let gx = self.base.x + 10.0;
+ let gw = self.base.w - 20.0;
+ let gy = self.base.y + 10.0;
+ if px >= gx && px <= gx + gw && py_event >= gy && py_event <= gy + gh {
+ ectx.open_context_menu(px, py_event);
+ return true;
+ }
+ }
+ return false;
+ }
let Some(ui) = ectx.ui.as_deref_mut() else { return false; };
if button != MouseButton::Left { return false; }
-
+
if self.preset_dropdown.mouse_input(button, state, px, py_event, ui) {
if self.preset_dropdown.take_change() {
let idx = self.preset_dropdown.selected;
@@ -1113,44 +1504,61 @@ impl Input for Ramp {
return true;
}
- let gh = Self::graph_h(self.base.h);
- let track_x = self.base.x + 10.0;
- let track_w = self.base.w - 20.0;
-
+ let gh = self.graph_h();
+ let plot = self.plot_rect();
+
if state == ElementState::Pressed {
+ // Any press cancels a hover-scroll glide in progress.
+ self.scroll_vel = (0.0, 0.0);
+ self.scroll_key_idx = None;
+ self.last_key_scroll = None;
+ // Grab the NEAREST key whose ring contains the press — the rings
+ // are the pegs' visual extent, and nearest-center also matches the
+ // foam walls (perpendicular bisectors) where rings overlap.
+ let hit_r = Self::KEY_RING_R + 2.5;
+ let mut best: Option<(usize, f32)> = None;
for (idx, key) in self.keys.iter().enumerate() {
- let cx = track_x + key.pos * track_w;
- let cy = self.base.y + 10.0 + gh - key.value * gh;
+ let cx = plot.x + key.pos * plot.width;
+ let cy = plot.y + plot.height * (1.0 - key.value);
let dx = px - cx;
let dy = py_event - cy;
- if (dx*dx + dy*dy) <= 64.0 {
- self.selected_key_idx = Some(idx);
- self.is_dragging_key = true;
- self.val_slider.set_value(key.value);
- self.arrange_fields();
- return true;
+ let d2 = dx * dx + dy * dy;
+ if d2 <= hit_r * hit_r && best.is_none_or(|(_, bd)| d2 < bd) {
+ best = Some((idx, d2));
}
}
-
- if px >= track_x && px <= track_x + track_w && py_event >= self.base.y + 10.0 && py_event <= self.base.y + 10.0 + gh {
- let t = (px - track_x) / track_w;
- let val = 1.0 - (py_event - (self.base.y + 10.0)) / gh;
+ if let Some((idx, _)) = best {
+ self.selected_key_idx = Some(idx);
+ self.is_dragging_key = true;
+ self.key_pad.set_values(self.keys[idx].pos, self.keys[idx].value);
+ self.arrange_fields();
+ return true;
+ }
+
+ // Creation accepts the whole opening (the inset gutters included);
+ // the domain mapping clamps to the plot's 0..1.
+ 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 {
+ let t = ((px - plot.x) / plot.width).clamp(0.0, 1.0);
+ let val = (1.0 - (py_event - plot.y) / plot.height).clamp(0.0, 1.0);
let new_key = RampKey { pos: t, value: val };
self.keys.push(new_key);
- self.sort_keys();
+ let new_idx = self.resettle_key(self.keys.len() - 1);
self.preset_dropdown.selected = 0; // Custom
self.just_changed = true;
-
- if let Some(new_idx) = self.keys.iter().position(|k| (k.pos - t).abs() < 0.0001) {
- self.selected_key_idx = Some(new_idx);
- self.val_slider.set_value(val);
- }
+ self.selected_key_idx = Some(new_idx);
+ self.key_pad.set_values(t, val);
+ // Arm the drag: a fresh key follows the pointer until release,
+ // so create-and-place is one gesture (the grab-branch behavior).
+ self.is_dragging_key = true;
self.arrange_fields();
return true;
}
if self.selected_key_idx.is_some() {
- if self.val_slider.mouse_input(button, state, px, py_event, ui) { return true; }
+ if self.key_pad.mouse_input(button, state, px, py_event, ui) {
+ self.apply_pad_to_selected();
+ return true;
+ }
if self.del_button.mouse_input(button, state, px, py_event, ui) {
if self.del_button.take_click() {
if let Some(idx) = self.selected_key_idx {
@@ -1169,7 +1577,7 @@ impl Input for Ramp {
} else {
self.is_dragging_key = false;
if self.selected_key_idx.is_some() {
- self.val_slider.mouse_input(button, state, px, py_event, ui);
+ self.key_pad.mouse_input(button, state, px, py_event, ui);
if self.del_button.mouse_input(button, state, px, py_event, ui) {
if self.del_button.take_click() {
if let Some(idx) = self.selected_key_idx {
@@ -1200,30 +1608,27 @@ impl Input for Ramp {
}
let mut changed = false;
- let gh = Self::graph_h(self.base.h);
- let track_x = self.base.x + 10.0;
- let track_w = self.base.w - 20.0;
-
+ let plot = self.plot_rect();
+
if self.is_dragging_key {
if let Some(idx) = self.selected_key_idx {
- let t = ((px - track_x) / track_w).clamp(0.0, 1.0);
- let val = (1.0 - (py_event - (self.base.y + 10.0)) / gh).clamp(0.0, 1.0);
+ let t_raw = ((px - plot.x) / plot.width).clamp(0.0, 1.0);
+ let t = self.resisted_pos(idx, t_raw);
+ let val = (1.0 - (py_event - plot.y) / plot.height).clamp(0.0, 1.0);
self.keys[idx].pos = t;
self.keys[idx].value = val;
- self.val_slider.set_value(val);
- self.sort_keys();
+ self.key_pad.set_values(t, val);
+ let settled = self.resettle_key(idx);
+ self.selected_key_idx = Some(settled);
self.preset_dropdown.selected = 0; // Custom
changed = true;
}
}
if self.selected_key_idx.is_some() {
- if self.val_slider.cursor_moved(px, py_event, ui) {
- if let Some(idx) = self.selected_key_idx {
- self.keys[idx].value = self.val_slider.inner().value();
- self.preset_dropdown.selected = 0; // Custom
- changed = true;
- }
+ if self.key_pad.cursor_moved(px, py_event, ui) {
+ self.apply_pad_to_selected();
+ changed = true;
}
if self.del_button.cursor_moved(px, py_event, ui) {
changed = true;
@@ -1250,12 +1655,75 @@ impl Input for Ramp {
if self.line_type_dropdown.mouse_wheel(&delta, px, py, ui) {
return true;
}
- if self.selected_key_idx.is_some() && self.val_slider.mouse_wheel(&delta, px, py, ui) {
- if let Some(idx) = self.selected_key_idx {
- self.keys[idx].value = self.val_slider.inner().value();
+ // Hover-scroll: a gesture STARTING over a key latches it and
+ // steers it on both axes — following the fingers like a drag
+ // — until the stream pauses (fingers lifted). Mid-gesture the
+ // latch holds even if the key slides out from under the
+ // cursor. Latching also selects the key, so the pad tracks.
+ let plot = self.plot_rect();
+ if ui.scroll_gesture_new {
+ let hit_r = Self::KEY_RING_R + 2.5;
+ let mut best: Option<(usize, f32)> = None;
+ for (idx, key) in self.keys.iter().enumerate() {
+ let cx = plot.x + key.pos * plot.width;
+ let cy = plot.y + plot.height * (1.0 - key.value);
+ let dx = px - cx;
+ let dy = py - cy;
+ let d2 = dx * dx + dy * dy;
+ if d2 <= hit_r * hit_r && best.is_none_or(|(_, bd)| d2 < bd) {
+ best = Some((idx, d2));
+ }
+ }
+ self.scroll_key_idx = best.map(|(i, _)| i);
+ self.scroll_vel = (0.0, 0.0);
+ }
+ if let Some(idx) = self.scroll_key_idx {
+ if idx < self.keys.len() {
+ ui.scroll_initiate_widget_id = Some(ectx.id);
+ // Damped well below 1:1 — hover-scroll is for fine
+ // adjustment; the drag paths cover coarse moves.
+ let (dx, dy) = match &delta {
+ MouseScrollDelta::LineDelta(x, y) => (*x * 0.005, *y * 0.005),
+ MouseScrollDelta::PixelDelta(pos) => (
+ 0.2 * pos.x as f32 / plot.width,
+ 0.2 * pos.y as f32 / plot.height,
+ ),
+ };
+ // Direct manipulation: the key moves WITH the scroll
+ // (runner deltas are content-motion negated, so both
+ // axes flip): scroll right → key right, down → down.
+ self.keys[idx].pos = (self.keys[idx].pos - dx).clamp(0.0, 1.0);
+ self.keys[idx].value = (self.keys[idx].value + dy).clamp(0.0, 1.0);
+ // Velocity estimate for the release glide: EMA of
+ // applied delta over inter-event time. A leisurely
+ // wheel produces negligible velocity (big gaps clamp
+ // to 0.1s); fast trackpad streams build real speed.
+ let now = std::time::Instant::now();
+ let dt_ev = self
+ .last_key_scroll
+ .map(|t| now.duration_since(t).as_secs_f32())
+ .unwrap_or(0.016)
+ .clamp(0.004, 0.1);
+ self.last_key_scroll = Some(now);
+ let (ivx, ivy) = (-dx / dt_ev, dy / dt_ev);
+ self.scroll_vel = (
+ self.scroll_vel.0 * 0.65 + ivx * 0.35,
+ self.scroll_vel.1 * 0.65 + ivy * 0.35,
+ );
+ let settled = self.resettle_key(idx);
+ self.scroll_key_idx = Some(settled);
+ self.selected_key_idx = Some(settled);
+ self.key_pad
+ .set_values(self.keys[settled].pos, self.keys[settled].value);
self.preset_dropdown.selected = 0; // Custom
+ self.just_changed = true;
+ self.arrange_fields();
+ return true;
}
- self.just_changed = true;
+ self.scroll_key_idx = None;
+ }
+ if self.selected_key_idx.is_some() && self.key_pad.mouse_wheel(&delta, px, py, ui) {
+ self.apply_pad_to_selected();
return true;
}
false
@@ -1273,7 +1741,7 @@ impl Input for Ramp {
(*self_ptr).line_type_dropdown.as_ptr_mut(),
];
if (*self_ptr).selected_key_idx.is_some() {
- list.push((*self_ptr).val_slider.as_ptr_mut());
+ list.push((*self_ptr).key_pad.as_ptr_mut());
list.push((*self_ptr).del_button.as_ptr_mut());
}
list
@@ -1310,8 +1778,8 @@ impl Input for Ramp {
if ui.is_focused(&self.line_type_dropdown) {
return self.line_type_dropdown.keyboard_input(event, ui);
}
- if ui.is_focused(&self.val_slider) {
- return self.val_slider.keyboard_input(event, ui);
+ if ui.is_focused(&self.key_pad) {
+ return self.key_pad.keyboard_input(event, ui);
}
if ui.is_focused(&self.del_button) {
return self.del_button.keyboard_input(event, ui);
@@ -1329,7 +1797,7 @@ impl Input for Ramp {
self.base.focused = false;
self.preset_dropdown.unfocus();
self.line_type_dropdown.unfocus();
- self.val_slider.unfocus();
+ self.key_pad.unfocus();
self.del_button.unfocus();
false
@@ -1341,27 +1809,20 @@ impl Input for Ramp {
// Field-slider drags forward through the composite (6bd self-routing), with the key
// value sync the old descent path never ran mid-drag.
fn draggable(&self, _rect: Rect) -> bool {
- self.is_dragging_key || self.val_slider.is_dragging()
+ self.is_dragging_key || self.key_pad.is_dragging()
}
fn is_dragging(&self) -> bool {
- self.is_dragging_key || self.val_slider.is_dragging()
+ self.is_dragging_key || self.key_pad.is_dragging()
}
fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
- let mut changed = false;
- if self.val_slider.is_dragging() && self.val_slider.drag_update(px, py) {
- if let Some(idx) = self.selected_key_idx {
- self.keys[idx].value = self.val_slider.inner().value();
- self.preset_dropdown.selected = 0; // Custom
- }
- changed = true;
- }
- if changed {
- self.just_changed = true;
+ if self.key_pad.is_dragging() && self.key_pad.drag_update(px, py) {
+ self.apply_pad_to_selected();
+ return true;
}
- changed
+ false
}
fn drag_end(&mut self) {
- self.val_slider.drag_end();
+ self.key_pad.drag_end();
self.is_dragging_key = false;
}
}