Skip to content

tweak gradient model extension #653

Description

@plastikfan

Gradient Model Extension - Implementation Plan (Issue 2)

Context

The current gradient model performs a pure linear interpolation between two
colour endpoints (Hi/Lo). Issue 2 extends it with two orthogonal concepts:

  • Curve - controls the shape of the interpolation path (linear, sine,
    quadratic-in, quadratic-out, cubic).
  • Easing - controls the distribution of steps along that path (uniform,
    ease-in, ease-out, ease-in-out).

This is a pure data-model extension. No TUI, no new commands, no new cobra
wiring.

The banner config cascade - where BannerSubConfig.Steps can override the
gradient's own Steps for the banner widget only - already exists
structurally. This issue extends that same cascade pattern to carry Curve
and Easing through the same path.

Broader context: This issue is part of the jay tweak feature set
described in docs/jay-tweak.design.md (sections 8, 10, 17). The curve and
easing concepts defined here feed into the gradient workshop (Issue 6) and
visualiser (Issue 3) in that design.


Resolved Decisions

Option A for threading curve/easing through ApplyGradient. The
hiCol, loCol color.Color parameters are replaced by a single
contract.ResolvedGradient parameter. Callers already hold a
ResolvedGradient at every call site, so no extra state is required. This
reduces parameter count and makes the interface cleaner.

GradientState requires no new fields. It is a pure animation-tick
counter. Curve and easing are resolved at the point where the colour array is
computed, not during tick advancement.

Default values produce identical output to pre-issue behaviour. Both
CurveKind and EasingKind are string types. Their zero value ("") is
treated as the default (linear/uniform respectively). Every existing call site
that never sets these fields will produce byte-for-byte identical gradient
output after the change. No backwards compatibility concern - the project is
unreleased.

Consolidate the two InterpolateBetweenRGBA functions. There are currently
two functions with the same name:

  • contract.InterpolateBetweenRGBA returns []color.Color
  • effects.InterpolateBetweenRGBA returns []contract.Color

These are near-duplicates doing the same interpolation with different return
types. The effects variant is eliminated. The contract variant is updated
to return []contract.Color directly, since contract.Color is the
project's own colour type and all consumers ultimately need it. This avoids
a redundant conversion layer and removes the image/color dependency from
the interpolation hot path.


Scope

Primary file changes

File Change
src/prism/contract/gradients.go Add CurveKind/EasingKind enums, easedT helper; update InterpolateBetween and InterpolateBetweenRGBA signatures; change both return types to []contract.Color
src/prism/contract/palette.go Add Curve and Easing fields to GradientDef and ResolvedGradient
src/prism/effects/gradient-state.go Delete local InterpolateBetweenRGBA; update ApplyGradient and ApplyGradientStatic to accept contract.ResolvedGradient (Option A)
src/app/bedrock/types.go Add Curve, Easing, and Animate fields to BannerSubConfig
src/prism/contract/theme.go Forward Curve/Easing through gradient construction; change GradientCaches type to map[string][]Color
src/app/ui/registry.go Add CurveOverride, EasingOverride, AnimateOverride to BannerConfig; wire resolveBannerConfig
src/app/ui/highway.go Apply cascade overrides in buildBannerInfo
src/app/ui/porthole.go Apply cascade overrides in buildBannerInfo
src/app/ui/linear.go Apply cascade overrides in buildBannerInfo

Caller updates (required by signature changes)

These files must be updated because the functions they call change signature.
The changes are mechanical: pass the ResolvedGradient directly instead of
extracting .Hi/.Lo, and forward Curve/Easing where needed.

File Call site Change
src/prism/contract/theme.go:324 InterpolateBetween(hiCol, loCol, steps) Forward gd.Curve, gd.Easing; cache type changes to []contract.Color
src/prism/widgets/activity/activity.go:36 effects.ApplyGradient(effect.Gradient.Hi, effect.Gradient.Lo, ...) Pass effect.Gradient directly
src/prism/widgets/periscope/periscope.go:36 effects.ApplyGradient(effect.Gradient.Hi, effect.Gradient.Lo, ...) Pass effect.Gradient directly
src/prism/widgets/periscope/periscope.go:47 effects.ApplyGradientStatic(effect.Gradient.Hi, effect.Gradient.Lo, ...) Pass effect.Gradient directly
src/prism/widgets/banner/banner.go:292 effects.InterpolateBetweenRGBA(...) Remove; use pre-computed cache from ResolvedGradient or call contract.InterpolateBetweenRGBA directly
src/prism/views/porthole/model.go:139 effects.InterpolateBetweenRGBA(...) Remove; use pre-computed cache or call contract.InterpolateBetweenRGBA directly

Test file updates

File Change
src/prism/effects/gradient-state_test.go:26 Update ApplyGradient call to pass ResolvedGradient
src/prism/widgets/banner/model_test.go:28 Update effects.InterpolateBetweenRGBA call to contract.InterpolateBetweenRGBA
src/prism/views/highway/banner_test.go:45 Update effects.InterpolateBetweenRGBA call to contract.InterpolateBetweenRGBA

New test file

File Change
src/prism/contract/easing_test.go New file. DescribeTable specs for easedT covering all curve/easing combinations

Step 1 - gradients.go: Enums, Easing Helper, and Interpolation Consolidation

1.1 Enum types

// CurveKind controls the shape of the interpolation path between the
// Hi and Lo gradient endpoints. The zero value is treated as CurveLinear.
type CurveKind string

const (
    // CurveLinear interpolates each channel at a constant rate.
    // This is the default and matches pre-issue behaviour exactly.
    CurveLinear       CurveKind = "linear"
    // CurveSine applies a sine-wave shape to the interpolation.
    CurveSine         CurveKind = "sine"
    // CurveQuadraticIn accelerates toward the Lo endpoint.
    CurveQuadraticIn  CurveKind = "quadratic-in"
    // CurveQuadraticOut decelerates toward the Lo endpoint.
    CurveQuadraticOut CurveKind = "quadratic-out"
    // CurveCubic applies a cubic (S-curve) shape.
    CurveCubic        CurveKind = "cubic"
)

// EasingKind controls the distribution of steps along the curve.
// The zero value is treated as EasingUniform.
type EasingKind string

const (
    // EasingUniform distributes steps evenly. Default; matches
    // pre-issue behaviour exactly.
    EasingUniform   EasingKind = "uniform"
    // EasingEaseIn clusters steps toward the start of the gradient.
    EasingEaseIn    EasingKind = "ease-in"
    // EasingEaseOut clusters steps toward the end of the gradient.
    EasingEaseOut   EasingKind = "ease-out"
    // EasingEaseInOut clusters steps toward both ends.
    EasingEaseInOut EasingKind = "ease-in-out"
)

1.2 easedT helper

Package-private function. Accepts a linear t in [0,1] and returns a
shaped value. Curve is applied first, easing second. Both zero values
("") pass through without modification.

// easedT applies curve shaping then easing distribution to a linear
// parameter t in [0,1]. Either parameter may be empty (zero value),
// in which case that stage is a no-op passthrough.
func easedT(t float64, curve CurveKind, easing EasingKind) float64 {
    t = applyCurve(t, curve)
    t = applyEasing(t, easing)
    return t
}

1.3 Mathematical definitions

Curve functions (applied to raw t):

Curve Formula Behaviour
CurveLinear ("") t Passthrough
CurveSine (1 - cos(π * t)) / 2 Smooth S-ramp, slow at both ends
CurveQuadraticIn Accelerates (slow start, fast end)
CurveQuadraticOut 1 - (1-t)² Decelerates (fast start, slow end)
CurveCubic 3t² - 2t³ Smoothstep S-curve

Easing functions (applied to curve-shaped t):

Easing Formula Behaviour
EasingUniform ("") t Passthrough
EasingEaseIn Clusters steps toward start (hi end)
EasingEaseOut 1 - (1-t)² Clusters steps toward end (lo end)
EasingEaseInOut (1 - cos(π * t)) / 2 Clusters toward both ends

All functions satisfy the boundary contract: f(0) = 0.0 and f(1) = 1.0.

1.4 Consolidate InterpolateBetweenRGBA

The existing InterpolateBetweenRGBA in gradients.go currently returns
[]color.Color. Change it to return []contract.Color and add
curve CurveKind, easing EasingKind trailing parameters:

// InterpolateBetweenRGBA creates a gradient of 'steps' colours between
// hi and lo RGB values. Returns nil if steps <= 0.
func InterpolateBetweenRGBA(hiR, hiG, hiB, loR, loG, loB uint8, steps int,
    curve CurveKind, easing EasingKind) []contract.Color {

    steps = max(steps, 2)
    gradient := make([]contract.Color, steps)
    for i := 0; i < steps; i++ {
        t := float64(i) / float64(steps-1)
        t = easedT(t, curve, easing)

        r := uint8(math.Max(0, math.Min(255, float64(hiR)+(float64(loR)-float64(hiR))*t)))
        g := uint8(math.Max(0, math.Min(255, float64(hiG)+(float64(loG)-float64(hiG))*t)))
        b := uint8(math.Max(0, math.Min(255, float64(hiB)+(float64(loB)-float64(hiB))*t)))

        gradient[i] = contract.Color{R: r, G: g, B: b}
    }
    return gradient
}

1.5 Update InterpolateBetween

Add curve CurveKind, easing EasingKind trailing parameters and apply
easedT per step. The return type stays []color.Color since theme.go
uses it for the GradientCaches which stores []color.Color.

func InterpolateBetween(hiCol, loCol color.Color, steps int,
    curve CurveKind, easing EasingKind) []color.Color {
    // ... existing setup ...
    for i := 0; i < steps; i++ {
        t := float64(i) / float64(steps-1)
        t = easedT(t, curve, easing)
        // ... existing interpolation using t ...
    }
}

Step 2 - palette.go: Extend GradientDef and ResolvedGradient

type GradientDef struct {
    Steps   int             `mapstructure:"steps,omitempty"   yaml:"steps,omitempty"`
    Hi      *SemanticColour `mapstructure:"hi,omitempty"      yaml:"hi,omitempty"`
    Lo      *SemanticColour `mapstructure:"lo,omitempty"      yaml:"lo,omitempty"`
    Animate *bool           `mapstructure:"animate,omitempty" yaml:"animate,omitempty"`
    // Curve controls the interpolation shape between Hi and Lo.
    // Omit to use linear interpolation (default).
    Curve   CurveKind       `mapstructure:"curve,omitempty"   yaml:"curve,omitempty"`
    // Easing controls step distribution along the curve.
    // Omit for uniform distribution (default).
    Easing  EasingKind      `mapstructure:"easing,omitempty"  yaml:"easing,omitempty"`
}

type ResolvedGradient struct {
    Steps   int
    Hi      color.Color
    Lo      color.Color
    Animate bool
    // Curve and Easing are copied from GradientDef during theme construction.
    Curve   CurveKind
    Easing  EasingKind
}

Step 3 - theme.go: Wire Curve/Easing Through Theme Construction

In NewTheme, the gradient-building loop (lines 283-330) is updated:

  1. Copy Curve and Easing from GradientDef into ResolvedGradient.
  2. Forward Curve and Easing to the InterpolateBetween call that
    pre-fills highlightCaches.
  3. The cache type changes from map[string][]color.Color to
    map[string][]contract.Color (matching the new InterpolateBetweenRGBA
    return type). Verify that Theme.GradientCaches field type is updated
    accordingly.
highlightGradients[name] = ResolvedGradient{
    Steps:   steps,
    Hi:      hiCol,
    Lo:      loCol,
    Animate: animate,
    Curve:   gd.Curve,
    Easing:  gd.Easing,
}
if steps > 0 {
    cached := InterpolateBetween(hiCol, loCol, steps, gd.Curve, gd.Easing)
    highlightCaches[name] = cached
}

Step 4 - gradient-state.go: Thread Curve and Easing Through

4.1 Delete local InterpolateBetweenRGBA

The effects.InterpolateBetweenRGBA function (lines 262-279) is deleted
entirely. All callers switch to contract.InterpolateBetweenRGBA.

4.2 Update ApplyGradient (Option A)

Replace hiCol, loCol color.Color with gradient contract.ResolvedGradient:

func ApplyGradient(gradient contract.ResolvedGradient, frameContent string,
    state *GradientState) []RunWithColor {

    hiR, hiG, hiB, _ := gradient.Hi.RGBA()
    loR, loG, loB, _ := gradient.Lo.RGBA()

    steps := contract.InterpolateBetweenRGBA(
        uint8(hiR>>8), uint8(hiG>>8), uint8(hiB>>8),
        uint8(loR>>8), uint8(loG>>8), uint8(loB>>8),
        state.TotalSteps,
        gradient.Curve, gradient.Easing,
    )
    // ... rest unchanged ...
}

4.3 Update ApplyGradientStatic (Option A)

Replace hiCol, loCol color.Color with gradient contract.ResolvedGradient:

func ApplyGradientStatic(gradient contract.ResolvedGradient, content string,
    totalSteps int) []RunWithColor {

    hiR, hiG, hiB, _ := gradient.Hi.RGBA()
    loR, loG, loB, _ := gradient.Lo.RGBA()

    steps := contract.InterpolateBetweenRGBA(
        uint8(hiR>>8), uint8(hiG>>8), uint8(hiB>>8),
        uint8(loR>>8), uint8(loG>>8), uint8(loB>>8),
        totalSteps,
        gradient.Curve, gradient.Easing,
    )
    // ... rest unchanged ...
}

4.4 GradientState is not modified


Step 5 - types.go: Extend BannerSubConfig

type BannerSubConfig struct {
    Disable  bool       `mapstructure:"disable"            yaml:"disable"`
    Position string     `mapstructure:"position,omitempty" yaml:"position,omitempty"`
    Tick     int        `mapstructure:"tick,omitempty"     yaml:"tick,omitempty"`
    Justify  string     `mapstructure:"justify,omitempty"  yaml:"justify,omitempty"`
    Steps    int        `mapstructure:"steps,omitempty"    yaml:"steps,omitempty"`
    // Curve overrides the gradient's interpolation shape for the banner
    // widget only. Omit to inherit the bound gradient's curve.
    Curve    CurveKind  `mapstructure:"curve,omitempty"    yaml:"curve,omitempty"`
    // Easing overrides the gradient's step distribution for the banner
    // widget only. Omit to inherit the bound gradient's easing.
    Easing   EasingKind `mapstructure:"easing,omitempty"   yaml:"easing,omitempty"`
    // Animate overrides the gradient's animate flag for the banner
    // widget only. Omit to inherit the bound gradient's animate value.
    Animate  *bool      `mapstructure:"animate,omitempty"  yaml:"animate,omitempty"`
}

The Animate field is added because the design doc (docs/jay-tweak.design.md
section 8.5) specifies it as a banner cascade override. Using *bool with
omitempty matches the pattern in GradientDef and allows distinguishing
"not set" (inherit) from "explicitly false".

The cascade rule mirrors the existing Steps override: a non-empty
BannerSubConfig.Curve or Easing overrides the bound gradient's own value
for the banner only.

Cascade resolution is in scope for this issue. The resolution point is
resolveBannerConfig in src/app/ui/registry.go. The BannerConfig struct
gains Curve, Easing, and Animate fields, and resolveBannerConfig
copies the override values (non-empty BannerSubConfig fields win over the
gradient's defaults).


Step 6 - Update Callers

6.1 src/prism/widgets/activity/activity.go

// Before:
gradientRuns := effects.ApplyGradient(
    effect.Gradient.Hi,
    effect.Gradient.Lo,
    inner,
    effect.State,
)

// After:
gradientRuns := effects.ApplyGradient(
    effect.Gradient,
    inner,
    effect.State,
)

6.2 src/prism/widgets/periscope/periscope.go

// Before (animated):
runs := effects.ApplyGradient(
    effect.Gradient.Hi,
    effect.Gradient.Lo,
    barContent,
    effect.State,
)

// After:
runs := effects.ApplyGradient(
    effect.Gradient,
    barContent,
    effect.State,
)

// Before (static):
runs := effects.ApplyGradientStatic(
    effect.Gradient.Hi,
    effect.Gradient.Lo,
    barContent,
    effect.Gradient.Steps,
)

// After:
runs := effects.ApplyGradientStatic(
    effect.Gradient,
    barContent,
    effect.Gradient.Steps,
)

6.3 src/prism/widgets/banner/banner.go

The paletteSteps function (line 280) currently calls
effects.InterpolateBetweenRGBA directly. Replace with
contract.InterpolateBetweenRGBA:

func paletteSteps(g *contract.ResolvedGradient, st *effects.GradientState) []contract.Color {
    if g == nil || st == nil {
        return nil
    }
    if st.TotalSteps <= 0 {
        st.TotalSteps = g.Steps
    }
    if st.TotalSteps <= 0 {
        st.TotalSteps = contract.DefaultStepCount()
    }
    hiR, hiG, hiB, _ := g.Hi.RGBA()
    loR, loG, loB, _ := g.Lo.RGBA()
    steps := contract.InterpolateBetweenRGBA(
        uint8(hiR>>8), uint8(hiG>>8), uint8(hiB>>8),
        uint8(loR>>8), uint8(loG>>8), uint8(loB>>8),
        st.TotalSteps,
        g.Curve, g.Easing,
    )
    if len(steps) > 0 {
        st.SetSteps(steps)
    }
    return steps
}

6.4 src/prism/views/porthole/model.go

In SetActivity (line 130), replace effects.InterpolateBetweenRGBA with
contract.InterpolateBetweenRGBA:

func (m *Model) SetActivity(frameFn contract.FrameFunc, gradient *contract.ResolvedGradient) {
    m.frameFn = frameFn
    m.activityGradient = gradient
    if gradient != nil && gradient.Steps > 0 {
        hiR, hiG, hiB, _ := gradient.Hi.RGBA()
        loR, loG, loB, _ := gradient.Lo.RGBA()
        steps := contract.InterpolateBetweenRGBA(
            uint8(hiR>>8), uint8(hiG>>8), uint8(hiB>>8),
            uint8(loR>>8), uint8(loG>>8), uint8(loB>>8),
            gradient.Steps,
            gradient.Curve, gradient.Easing,
        )
        gs := effects.NewGradientState()
        gs.SetSteps(steps)
        m.gradientState = gs
    }
}

Step 7 - Tests

7.1 New easing_test.go in src/prism/contract/

package contract

import (
    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
)

var _ = Describe("easedT", func() {
    DescribeTable("boundary values",
        func(curve CurveKind, easing EasingKind, tIn, expected float64) {
            result := easedT(tIn, curve, easing)
            Expect(result).To(BeNumerically("~", expected, 1e-10))
        },
        // t=0 must always return 0 regardless of curve/easing
        Entry("linear/uniform t=0", CurveLinear, EasingUniform, 0.0, 0.0),
        Entry("sine/ease-in t=0", CurveSine, EasingEaseIn, 0.0, 0.0),
        Entry("cubic/ease-in-out t=0", CurveCubic, EasingEaseInOut, 0.0, 0.0),

        // t=1 must always return 1 regardless of curve/easing
        Entry("linear/uniform t=1", CurveLinear, EasingUniform, 1.0, 1.0),
        Entry("sine/ease-out t=1", CurveSine, EasingEaseOut, 1.0, 1.0),
        Entry("cubic/ease-in-out t=1", CurveCubic, EasingEaseInOut, 1.0, 1.0),

        // t=0.5 midpoints (shape-dependent, not exact 0.5)
        Entry("linear/uniform t=0.5", CurveLinear, EasingUniform, 0.5, 0.5),
        Entry("sine t=0.5", CurveSine, EasingUniform, 0.5, 0.5),
        Entry("quadratic-in t=0.5", CurveQuadraticIn, EasingUniform, 0.5, 0.25),
        Entry("quadratic-out t=0.5", CurveQuadraticOut, EasingUniform, 0.5, 0.75),
        Entry("cubic t=0.5", CurveCubic, EasingUniform, 0.5, 0.5),

        // empty curve/easing (zero values) are passthrough
        Entry("empty curve/easing t=0.3", "", "", 0.3, 0.3),
    )

    DescribeTable("linear passthrough",
        func(tIn float64) {
            result := easedT(tIn, CurveLinear, EasingUniform)
            Expect(result).To(BeNumerically("~", tIn, 1e-10))
        },
        Entry("t=0", 0.0),
        Entry("t=0.25", 0.25),
        Entry("t=0.5", 0.5),
        Entry("t=0.75", 0.75),
        Entry("t=1", 1.0),
    )
})

7.2 Updated test files

All existing test call sites that use the old signatures must be updated.
The changes are mechanical - no test logic changes, only parameter
adjustments:

  • gradient-state_test.go:26: ApplyGradient(hi, lo, ...) becomes
    ApplyGradient(contract.ResolvedGradient{Hi: hi, Lo: lo}, ...)
  • banner/model_test.go:28: effects.InterpolateBetweenRGBA(...) becomes
    contract.InterpolateBetweenRGBA(...) with trailing curve, easing args
  • views/highway/banner_test.go:45: same pattern as above

Acceptance Criteria

  • go test ./src/prism/... and go test ./src/app/bedrock/... and
    go test ./src/app/ui/... pass.
  • InterpolateBetween called with CurveLinear/EasingUniform (or empty
    strings) produces output byte-for-byte identical to pre-issue output.
  • GradientDef and ResolvedGradient each carry Curve and Easing with
    correct mapstructure and yaml tags.
  • BannerSubConfig carries Curve, Easing, and Animate with correct
    mapstructure and yaml tags.
  • GradientState is unchanged.
  • effects.InterpolateBetweenRGBA is deleted; all callers use
    contract.InterpolateBetweenRGBA.
  • ApplyGradient and ApplyGradientStatic accept contract.ResolvedGradient
    instead of separate hiCol/loCol parameters.
  • easedT is tested directly in easing_test.go with a DescribeTable
    covering t=0.0, t=0.5, and t=1.0 for each CurveKind value,
    verifying that CurveLinear returns t unchanged and that boundary
    values (t=0, t=1) always return 0.0 and 1.0 respectively
    regardless of curve or easing.
  • BannerConfig in src/app/ui/registry.go carries CurveOverride,
    EasingOverride, and AnimateOverride fields; resolveBannerConfig
    copies override values from BannerSubConfig.
  • All three view presenters (highway.go, porthole.go, linear.go)
    apply cascade overrides in buildBannerInfo: non-empty
    BannerConfig.CurveOverride/EasingOverride win over the gradient's
    own Curve/Easing.
  • No cyclic package imports. contract does not import effects or widgets.
    effects imports contract (one-directional).

Metadata

Metadata

Assignees

Labels

featureNew feature or request

Fields

No fields configured for Feature.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions