Give the control the shape of the value

SkillDev tools

When the value being edited is a curve, draw a draggable curve instead of N sliders and embed it in the settings list instead of pushing a screen — with a raw pointer loop rather than a drag-gesture helper, a draft that commits once per gesture, and smoothing that never overshoots a handle the user placed. Use when building a multi-point editor, or when a curve control ignores taps, snaps back on release, or wipes the saved value.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Give the control the shape of the value skill

What this skill tells your AI

The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/edit-a-shape-as-a-shape/SKILL.md and read by ahel’s review.

Ten sliders show ten unrelated numbers and make the user picture the result. A plot shows the result. The control should have the shape of the value it edits — and it should sit in the settings list, because the user adjusts it while listening and every navigation step between them and the curve is a step they take twice.

The skeleton is a draft, a raw pointer loop, and one commit:

var draft by remember { mutableStateOf<List<Float>?>(null) }
val shown = draft ?: bands

// The gesture handler is installed once, so it must not close over the bands of the composition
// that installed it — that is the flat placeholder, before the stored curve has loaded.
val currentBands by rememberUpdatedState(bands)
val commit by rememberUpdatedState(onBandsChange)

// Hands the curve back to the stored value once the commit has been through storage.
LaunchedEffect(bands) { draft = null }

Modifier.pointerInput(count) {
    awaitEachGesture {
        val down = awaitFirstDown(requireUnconsumed = false)
        var working = currentBands.withPointAt(down.position, size, count)
        draft = working
        down.consume()
        while (true) {
            val change = awaitPointerEvent().changes.firstOrNull() ?: break
            if (!change.pressed) break
            working = working.withPointAt(change.position, size, count)
            draft = working
            change.consume()
        }
        commit(working)          // one write per gesture
    }
}

Traps

The drag-gesture helper eats the first part of every gesture, including all of a tap. It waits for the pointer to travel past a slop threshold before reporting anything, so a plain click on a handle sets nothing at all and the first few pixels of a real drag are swallowed. On a plot the press is the edit — the point under the finger should move on touch-down. That is why the raw awaitEachGesture loop, not detectDragGestures.

Commit once per gesture, never per event. Every write here lands in storage and from there in the audio engine, which typically drains and rebuilds its whole graph to take it. Applying per pointer event does that at frame rate for the duration of a drag. The draft exists precisely so the line can follow the finger without any of it reaching storage on the way.

A handler installed once holds the values from that composition forever. pointerInput does not recompose with the enclosing function, so the lambda's captured bands is whatever existed when the block was installed — typically the flat placeholder shown before the stored value has loaded. A drag started from that stale copy commits it and wipes the saved shape. rememberUpdatedState for every value the handler reads, including the callback.

Drop the draft when the stored value arrives, not at release. Clearing it in the release branch snaps the curve back to its pre-drag shape for the length of the storage round trip, which reads as the control rejecting the edit and then accepting it. LaunchedEffect(storedValue) { draft = null } sequences it correctly with no timing assumption.

Key pointerInput on the layout, never on the value. pointerInput(bands) looks like the fix for the stale-capture trap above and is much worse: the block is torn down and reinstalled the moment the value changes, which is every frame of the drag you are currently performing. The gesture is cancelled mid-stroke and the curve stops following the finger. Key it on the point count — the only input that actually changes the gesture's geometry — and take the values through rememberUpdatedState.

Produce a new list per update; do not mutate. The draft is read by a draw pass on a later frame, so a list mutated in place changes underneath the frame that is drawing it. Building a fresh list also gives you the place to normalise its length, so a value saved by a build with fewer points still drags instead of throwing at the first index past the end.

Naive smoothing flattens the curve at every handle. Putting both control points of a segment on the current point's own y makes the line leave and arrive horizontally, pushing the whole climb into the middle of the gap — a row of bevelled steps rather than a curve. Aim each control point along the direction set by a point's neighbours (Catmull-Rom converted to cubic Bézier) so the line arrives at the slope the shape implies.

Then clamp the control points to their own segment's span, or the curve disobeys the user. Catmull-Rom overshoots after a steep change and dips past a handle that was deliberately placed. On an editor — as opposed to a chart — that is not a cosmetic wobble; it says the control did something other than what it was told:

val lo = minOf(p1.y, p2.y); val hi = maxOf(p1.y, p2.y)
cubicTo(p1.x + (p2.x - p0.x) / 6f, (p1.y + (p2.y - p0.y) / 6f).coerceIn(lo, hi), …)

Scales and labels go beside the canvas, never inside it. Text drawn into the plot ends up under the finger that is dragging. Once the axis lives in a gutter of its own, the labels below have to be offset by the same gutter width — two places computing one number, so hoist it into a constant or every label sits off its own gridline.

Assemble the readouts in Kotlin. "+12dB", "−15 dB", "03:00 – 04:00" are numbers plus units, and a resource format string is the wrong place for padding, rounding and symbols — see string-resource-format-limits. A string resource should join already-formatted pieces, or nothing at all.

Keep the block on one surface. A curve, a trim slider and a reset button laid straight into a settings list read as three unrelated rows; behind one container they read as one control, which is what they are.

Verifying it

Measure the control rather than looking at it — bounds and write counts, not screenshots:

# every commit path — expect exactly one, at the end of the gesture loop
grep -rn --include='*.kt' 'commit(\|onBandsChange(' . | grep -v 'val commit'
# and no stale capture: one rememberUpdatedState per value the handler reads (here, 2)
grep -rn --include='*.kt' -B12 -A20 'pointerInput(' . | grep -c 'rememberUpdatedState'

Behaviourally, in this order — the first is the one people skip because it "isn't a drag":

  • tap a single point, no movement, and assert the value changed;
  • drag across the whole plot and assert exactly one write reached storage;
  • open the screen and drag immediately, before the stored value has loaded, and assert the saved curve is not replaced by the placeholder;
  • set two adjacent points far apart and assert the rendered path never leaves the span between them — that is the overshoot check, and it needs the path, not a screenshot.

Signals

GitHub stars
315
Forks
6
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
edit-a-shape-as-a-shape
Source
github.com/maxrave-dev/kotlin-footguns