diff --git a/src/agenor/enums/curve-kind-en-auto.go b/src/agenor/enums/curve-kind-en-auto.go new file mode 100644 index 00000000..838e0929 --- /dev/null +++ b/src/agenor/enums/curve-kind-en-auto.go @@ -0,0 +1,28 @@ +// Code generated by "stringer -type=CurveKind -linecomment -trimprefix=CurveKind -output curve-kind-en-auto.go"; DO NOT EDIT. + +package enums + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[CurveKindLinear-0] + _ = x[CurveKindSine-1] + _ = x[CurveKindQuadraticIn-2] + _ = x[CurveKindQuadraticOut-3] + _ = x[CurveKindCubic-4] +} + +const _CurveKind_name = "linearsinequadratic-inquadratic-outcubic" + +var _CurveKind_index = [...]uint8{0, 6, 10, 22, 35, 40} + +func (i CurveKind) String() string { + idx := int(i) - 0 + if i < 0 || idx >= len(_CurveKind_index)-1 { + return "CurveKind(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _CurveKind_name[_CurveKind_index[idx]:_CurveKind_index[idx+1]] +} diff --git a/src/agenor/enums/curve-kind-en.go b/src/agenor/enums/curve-kind-en.go new file mode 100644 index 00000000..44f3ba79 --- /dev/null +++ b/src/agenor/enums/curve-kind-en.go @@ -0,0 +1,47 @@ +package enums + +import "fmt" + +//go:generate stringer -type=CurveKind -linecomment -trimprefix=CurveKind -output curve-kind-en-auto.go + +// CurveKind controls the shape of the interpolation path between the +// Hi and Lo gradient endpoints. The zero value is treated as CurveKindLinear. +type CurveKind uint + +const ( + // CurveKindLinear interpolates each channel at a constant rate. + // This is the default and matches pre-issue behaviour exactly. + CurveKindLinear CurveKind = iota // linear + + // CurveKindSine applies a sine-wave shape to the interpolation. + CurveKindSine // sine + + // CurveKindQuadraticIn accelerates toward the Lo endpoint. + CurveKindQuadraticIn // quadratic-in + + // CurveKindQuadraticOut decelerates toward the Lo endpoint. + CurveKindQuadraticOut // quadratic-out + + // CurveKindCubic applies a cubic (S-curve) shape. + CurveKindCubic // cubic +) + +// UnmarshalText implements encoding.TextUnmarshaler so that CurveKind +// can be decoded from YAML/mapstructure string values. +func (k *CurveKind) UnmarshalText(data []byte) error { + switch string(data) { + case "linear": + *k = CurveKindLinear + case "sine": + *k = CurveKindSine + case "quadratic-in": + *k = CurveKindQuadraticIn + case "quadratic-out": + *k = CurveKindQuadraticOut + case "cubic": + *k = CurveKindCubic + default: + return fmt.Errorf("unknown curve kind %q: valid values are linear, sine, quadratic-in, quadratic-out, cubic", string(data)) + } + return nil +} diff --git a/src/agenor/enums/easing-kind-en-auto.go b/src/agenor/enums/easing-kind-en-auto.go new file mode 100644 index 00000000..9979c3b0 --- /dev/null +++ b/src/agenor/enums/easing-kind-en-auto.go @@ -0,0 +1,27 @@ +// Code generated by "stringer -type=EasingKind -linecomment -trimprefix=EasingKind -output easing-kind-en-auto.go"; DO NOT EDIT. + +package enums + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[EasingKindUniform-0] + _ = x[EasingKindEaseIn-1] + _ = x[EasingKindEaseOut-2] + _ = x[EasingKindEaseInOut-3] +} + +const _EasingKind_name = "uniformease-inease-outease-in-out" + +var _EasingKind_index = [...]uint8{0, 7, 14, 22, 33} + +func (i EasingKind) String() string { + idx := int(i) - 0 + if i < 0 || idx >= len(_EasingKind_index)-1 { + return "EasingKind(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _EasingKind_name[_EasingKind_index[idx]:_EasingKind_index[idx+1]] +} diff --git a/src/agenor/enums/easing-kind-en.go b/src/agenor/enums/easing-kind-en.go new file mode 100644 index 00000000..6665a482 --- /dev/null +++ b/src/agenor/enums/easing-kind-en.go @@ -0,0 +1,42 @@ +package enums + +import "fmt" + +//go:generate stringer -type=EasingKind -linecomment -trimprefix=EasingKind -output easing-kind-en-auto.go + +// EasingKind controls the distribution of steps along the curve. +// The zero value is treated as EasingKindUniform. +type EasingKind uint + +const ( + // EasingKindUniform distributes steps evenly. Default; matches + // pre-issue behaviour exactly. + EasingKindUniform EasingKind = iota // uniform + + // EasingKindEaseIn clusters steps toward the start of the gradient. + EasingKindEaseIn // ease-in + + // EasingKindEaseOut clusters steps toward the end of the gradient. + EasingKindEaseOut // ease-out + + // EasingKindEaseInOut clusters steps toward both ends. + EasingKindEaseInOut // ease-in-out +) + +// UnmarshalText implements encoding.TextUnmarshaler so that EasingKind +// can be decoded from YAML/mapstructure string values. +func (k *EasingKind) UnmarshalText(data []byte) error { + switch string(data) { + case "uniform": + *k = EasingKindUniform + case "ease-in": + *k = EasingKindEaseIn + case "ease-out": + *k = EasingKindEaseOut + case "ease-in-out": + *k = EasingKindEaseInOut + default: + return fmt.Errorf("unknown easing kind %q: valid values are uniform, ease-in, ease-out, ease-in-out", string(data)) + } + return nil +} diff --git a/src/agenor/enums/navigation-kind-en-auto.go b/src/agenor/enums/navigation-kind-en-auto.go new file mode 100644 index 00000000..8e8d3a5e --- /dev/null +++ b/src/agenor/enums/navigation-kind-en-auto.go @@ -0,0 +1,25 @@ +// Code generated by "stringer -type=NavigationKind -linecomment -trimprefix=NavigationKind -output navigation-kind-en-auto.go"; DO NOT EDIT. + +package enums + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[NavigationKindPrime-0] + _ = x[NavigationKindResume-1] +} + +const _NavigationKind_name = "primeresume" + +var _NavigationKind_index = [...]uint8{0, 5, 11} + +func (i NavigationKind) String() string { + idx := int(i) - 0 + if i < 0 || idx >= len(_NavigationKind_index)-1 { + return "NavigationKind(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _NavigationKind_name[_NavigationKind_index[idx]:_NavigationKind_index[idx+1]] +} diff --git a/src/agenor/enums/navigation-kind-en.go b/src/agenor/enums/navigation-kind-en.go new file mode 100644 index 00000000..c20366df --- /dev/null +++ b/src/agenor/enums/navigation-kind-en.go @@ -0,0 +1,31 @@ +package enums + +import "fmt" + +//go:generate stringer -type=NavigationKind -linecomment -trimprefix=NavigationKind -output navigation-kind-en-auto.go + +// NavigationKind identifies whether a traversal is fresh or a +// continuation from a checkpoint. +type NavigationKind uint + +const ( + // NavigationKindPrime is a fresh traversal from the root. + NavigationKindPrime NavigationKind = iota // prime + + // NavigationKindResume is a continuation from a saved checkpoint. + NavigationKindResume // resume +) + +// UnmarshalText implements encoding.TextUnmarshaler so that NavigationKind +// can be decoded from string values. +func (k *NavigationKind) UnmarshalText(data []byte) error { + switch string(data) { + case "prime": + *k = NavigationKindPrime + case "resume": + *k = NavigationKindResume + default: + return fmt.Errorf("unknown navigation kind %q: valid values are prime, resume", string(data)) + } + return nil +} diff --git a/src/agenor/enums/view-kind-en-auto.go b/src/agenor/enums/view-kind-en-auto.go new file mode 100644 index 00000000..59892d0e --- /dev/null +++ b/src/agenor/enums/view-kind-en-auto.go @@ -0,0 +1,26 @@ +// Code generated by "stringer -type=ViewKind -linecomment -trimprefix=ViewKind -output view-kind-en-auto.go"; DO NOT EDIT. + +package enums + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[ViewKindLinear-0] + _ = x[ViewKindPorthole-1] + _ = x[ViewKindHighway-2] +} + +const _ViewKind_name = "linearportholehighway" + +var _ViewKind_index = [...]uint8{0, 6, 14, 21} + +func (i ViewKind) String() string { + idx := int(i) - 0 + if i < 0 || idx >= len(_ViewKind_index)-1 { + return "ViewKind(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _ViewKind_name[_ViewKind_index[idx]:_ViewKind_index[idx+1]] +} diff --git a/src/agenor/enums/view-kind-en.go b/src/agenor/enums/view-kind-en.go new file mode 100644 index 00000000..ea144258 --- /dev/null +++ b/src/agenor/enums/view-kind-en.go @@ -0,0 +1,37 @@ +package enums + +import "fmt" + +//go:generate stringer -type=ViewKind -linecomment -trimprefix=ViewKind -output view-kind-en-auto.go + +// ViewKind identifies the rendering view to use. +type ViewKind uint + +const ( + // ViewKindLinear is a linear scrolling output view rendered with lipgloss. + ViewKindLinear ViewKind = iota // linear + + // ViewKindPorthole is a bubbletea view with a static header and footer + // and vertically scrolling content between them. + ViewKindPorthole // porthole + + // ViewKindHighway is a bubbletea view showing parallel lanes of activity, + // suited to concurrent worker output. + ViewKindHighway // highway +) + +// UnmarshalText implements encoding.TextUnmarshaler so that ViewKind +// can be decoded from string values. +func (k *ViewKind) UnmarshalText(data []byte) error { + switch string(data) { + case "linear": + *k = ViewKindLinear + case "porthole": + *k = ViewKindPorthole + case "highway": + *k = ViewKindHighway + default: + return fmt.Errorf("unknown view kind %q: valid values are linear, porthole, highway", string(data)) + } + return nil +} diff --git a/src/app/bedrock/types.go b/src/app/bedrock/types.go index 33646022..8c5d39d0 100644 --- a/src/app/bedrock/types.go +++ b/src/app/bedrock/types.go @@ -2,6 +2,8 @@ package bedrock import ( "time" + + "github.com/snivilised/jaywalk/src/agenor/enums" ) // --------------------------------------------------------------------------- @@ -207,6 +209,18 @@ type BannerSubConfig struct { // colour sweep on the banner only, without redefining the shared // gradient. Zero or omitted means "use the gradient's own steps". 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 enums.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 enums.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"` } // HighwayAnimationConfig holds animation data configuration for Highway view. diff --git a/src/app/ui/highway.go b/src/app/ui/highway.go index 31c6ae9f..c54fbb2c 100644 --- a/src/app/ui/highway.go +++ b/src/app/ui/highway.go @@ -350,7 +350,18 @@ func (h *highwayPresenter) buildBannerInfo() banner.Info { if h.cfg.Banner.StepsOverride > 0 { steps = h.cfg.Banner.StepsOverride } - grad = &contract.ResolvedGradient{Steps: steps, Hi: g.Hi, Lo: g.Lo} + curve := g.Curve + if h.cfg.Banner.CurveOverride != 0 { + curve = h.cfg.Banner.CurveOverride + } + easing := g.Easing + if h.cfg.Banner.EasingOverride != 0 { + easing = h.cfg.Banner.EasingOverride + } + grad = &contract.ResolvedGradient{ + Steps: steps, Hi: g.Hi, Lo: g.Lo, + Curve: curve, Easing: easing, + } } info.Gradient = grad diff --git a/src/app/ui/linear.go b/src/app/ui/linear.go index e134ad27..a7e53e10 100644 --- a/src/app/ui/linear.go +++ b/src/app/ui/linear.go @@ -24,7 +24,7 @@ import ( type linearPresenter struct { mux sync.Mutex renderer contract.Renderer - kind contract.NavigationKind // remembered from OnBegin for use in OnComplete + kind enums.NavigationKind // remembered from OnBegin for use in OnComplete subscription enums.Subscription lastParent string peerInfo map[string]*core.PeerInfo @@ -44,8 +44,8 @@ func (l *linearPresenter) OnBegin(e *report.BeginEvent) { defer l.mux.Unlock() kind := lo.Ternary(e.IsPrime, - contract.PrimeNavigation, - contract.ResumeNavigation, + enums.NavigationKindPrime, + enums.NavigationKindResume, ) l.kind = kind @@ -261,7 +261,18 @@ func (l *linearPresenter) buildBannerInfo() *contract.BannerInfo { if l.cfg.Banner.StepsOverride > 0 { steps = l.cfg.Banner.StepsOverride } - grad = &contract.ResolvedGradient{Steps: steps, Hi: g.Hi, Lo: g.Lo} + curve := g.Curve + if l.cfg.Banner.CurveOverride != 0 { + curve = l.cfg.Banner.CurveOverride + } + easing := g.Easing + if l.cfg.Banner.EasingOverride != 0 { + easing = l.cfg.Banner.EasingOverride + } + grad = &contract.ResolvedGradient{ + Steps: steps, Hi: g.Hi, Lo: g.Lo, + Curve: curve, Easing: easing, + } } info.Gradient = grad diff --git a/src/app/ui/linear_test.go b/src/app/ui/linear_test.go index 7e84010a..8ae8f61e 100644 --- a/src/app/ui/linear_test.go +++ b/src/app/ui/linear_test.go @@ -115,7 +115,7 @@ var _ = Describe("linear", Ordered, func() { Expect(spy.overture.Root).To(Equal("/home/user/docs")) Expect(spy.overture.Caption).To(Equal("files and folders")) Expect(spy.overture.StartedAt).To(Equal(now)) - Expect(spy.overture.Kind).To(Equal(contract.PrimeNavigation)) + Expect(spy.overture.Kind).To(Equal(enums.NavigationKindPrime)) Expect(spy.overture.ResumeFrom).To(BeEmpty()) }) }) @@ -131,7 +131,7 @@ var _ = Describe("linear", Ordered, func() { DateFormat: "Mon, 02 Jan 2006 15:04:05 MST", }) - Expect(spy.overture.Kind).To(Equal(contract.ResumeNavigation)) + Expect(spy.overture.Kind).To(Equal(enums.NavigationKindResume)) Expect(spy.overture.ResumeFrom).To(Equal("/home/user/docs/subdir")) }) }) @@ -376,7 +376,7 @@ var _ = Describe("linear", Ordered, func() { Expect(s.DirsVisited).To(Equal(core.MetricValue(7))) Expect(s.Elapsed).To(Equal(3 * time.Second)) Expect(s.Errors).To(BeEmpty()) - Expect(s.Kind).To(Equal(contract.PrimeNavigation)) + Expect(s.Kind).To(Equal(enums.NavigationKindPrime)) }) }) @@ -411,7 +411,7 @@ var _ = Describe("linear", Ordered, func() { presenter.OnComplete(&report.Traversal{}) - Expect(spy.summary.Kind).To(Equal(contract.ResumeNavigation)) + Expect(spy.summary.Kind).To(Equal(enums.NavigationKindResume)) }) }) }) diff --git a/src/app/ui/porthole.go b/src/app/ui/porthole.go index a1599b51..d09d68ce 100644 --- a/src/app/ui/porthole.go +++ b/src/app/ui/porthole.go @@ -384,7 +384,18 @@ func (p *portholePresenter) buildBannerInfo() banner.Info { if p.cfg.Banner.StepsOverride > 0 { steps = p.cfg.Banner.StepsOverride } - grad = &contract.ResolvedGradient{Steps: steps, Hi: g.Hi, Lo: g.Lo} + curve := g.Curve + if p.cfg.Banner.CurveOverride != 0 { + curve = p.cfg.Banner.CurveOverride + } + easing := g.Easing + if p.cfg.Banner.EasingOverride != 0 { + easing = p.cfg.Banner.EasingOverride + } + grad = &contract.ResolvedGradient{ + Steps: steps, Hi: g.Hi, Lo: g.Lo, + Curve: curve, Easing: easing, + } } info.Gradient = grad diff --git a/src/app/ui/registry.go b/src/app/ui/registry.go index 193c354b..baf3c2da 100644 --- a/src/app/ui/registry.go +++ b/src/app/ui/registry.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "github.com/snivilised/jaywalk/src/agenor/enums" "github.com/snivilised/jaywalk/src/app/bedrock" "github.com/snivilised/jaywalk/src/app/report" "github.com/snivilised/jaywalk/src/prism/contract" @@ -220,6 +221,18 @@ type BannerConfig struct { // (so they keep a common colour scheme) but tune the banner's // smoothness/abruptness of the colour sweep independently. StepsOverride int + + // CurveOverride replaces the gradient's interpolation shape for + // the banner widget only. Zero value means "use the gradient's own curve". + CurveOverride enums.CurveKind + + // EasingOverride replaces the gradient's step distribution for + // the banner widget only. Zero value means "use the gradient's own easing". + EasingOverride enums.EasingKind + + // AnimateOverride replaces the gradient's animate flag for the + // banner widget only. Nil means "use the gradient's own animate value". + AnimateOverride *bool } // ------------------------------------------------------------------ @@ -331,12 +344,15 @@ func normaliseFlagsRowPosition(raw string) string { // banner misconfiguration, since the banner is a decorative element. func resolveBannerConfig(raw bedrock.BannerSubConfig, palette contract.Palette) BannerConfig { return BannerConfig{ - Disable: raw.Disable, - Position: normaliseBannerPosition(raw.Position), - Tick: normaliseBannerTick(raw.Tick), - Justify: normaliseBannerJustify(raw.Justify), - GradientName: nameFromPalette(palette, contract.GradientComponentBanner), - StepsOverride: normaliseBannerSteps(raw.Steps), + Disable: raw.Disable, + Position: normaliseBannerPosition(raw.Position), + Tick: normaliseBannerTick(raw.Tick), + Justify: normaliseBannerJustify(raw.Justify), + GradientName: nameFromPalette(palette, contract.GradientComponentBanner), + StepsOverride: normaliseBannerSteps(raw.Steps), + CurveOverride: raw.Curve, + EasingOverride: raw.Easing, + AnimateOverride: raw.Animate, } } diff --git a/src/prism/contract/easing-internal_test.go b/src/prism/contract/easing-internal_test.go new file mode 100644 index 00000000..9016fddd --- /dev/null +++ b/src/prism/contract/easing-internal_test.go @@ -0,0 +1,47 @@ +package contract + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/snivilised/jaywalk/src/agenor/enums" +) + +var _ = Describe("easedT", func() { + DescribeTable("boundary values", + func(curve enums.CurveKind, easing enums.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", enums.CurveKindLinear, enums.EasingKindUniform, 0.0, 0.0), + Entry("sine/ease-in t=0", enums.CurveKindSine, enums.EasingKindEaseIn, 0.0, 0.0), + Entry("cubic/ease-in-out t=0", enums.CurveKindCubic, enums.EasingKindEaseInOut, 0.0, 0.0), + + // t=1 must always return 1 regardless of curve/easing + Entry("linear/uniform t=1", enums.CurveKindLinear, enums.EasingKindUniform, 1.0, 1.0), + Entry("sine/ease-out t=1", enums.CurveKindSine, enums.EasingKindEaseOut, 1.0, 1.0), + Entry("cubic/ease-in-out t=1", enums.CurveKindCubic, enums.EasingKindEaseInOut, 1.0, 1.0), + + // t=0.5 midpoints (shape-dependent, not exact 0.5) + Entry("linear/uniform t=0.5", enums.CurveKindLinear, enums.EasingKindUniform, 0.5, 0.5), + Entry("sine t=0.5", enums.CurveKindSine, enums.EasingKindUniform, 0.5, 0.5), + Entry("quadratic-in t=0.5", enums.CurveKindQuadraticIn, enums.EasingKindUniform, 0.5, 0.25), + Entry("quadratic-out t=0.5", enums.CurveKindQuadraticOut, enums.EasingKindUniform, 0.5, 0.75), + Entry("cubic t=0.5", enums.CurveKindCubic, enums.EasingKindUniform, 0.5, 0.5), + + // zero values are passthrough + Entry("zero values t=0.3", enums.CurveKind(0), enums.EasingKind(0), 0.3, 0.3), + ) + + DescribeTable("linear passthrough", + func(tIn float64) { + result := easedT(tIn, enums.CurveKindLinear, enums.EasingKindUniform) + 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), + ) +}) diff --git a/src/prism/contract/gradients.go b/src/prism/contract/gradients.go index 6aee5aba..269dc832 100644 --- a/src/prism/contract/gradients.go +++ b/src/prism/contract/gradients.go @@ -3,66 +3,113 @@ package contract import ( "image/color" "math" + + "github.com/snivilised/jaywalk/src/agenor/enums" ) +// applyCurve applies the curve shaping function to a linear parameter t +// in [0,1]. The zero value (CurveKindLinear) is a passthrough. +func applyCurve(t float64, curve enums.CurveKind) float64 { + switch curve { //nolint:exhaustive // ok + case enums.CurveKindSine: + return (1 - math.Cos(math.Pi*t)) / 2 + case enums.CurveKindQuadraticIn: + return t * t + case enums.CurveKindQuadraticOut: + return 1 - (1-t)*(1-t) + case enums.CurveKindCubic: + return 3*t*t - 2*t*t*t + default: + return t + } +} + +// applyEasing applies the easing distribution function to a parameter t +// in [0,1]. The zero value (EasingKindUniform) is a passthrough. +func applyEasing(t float64, easing enums.EasingKind) float64 { + switch easing { //nolint:exhaustive // ok + case enums.EasingKindEaseIn: + return t * t + case enums.EasingKindEaseOut: + return 1 - (1-t)*(1-t) + case enums.EasingKindEaseInOut: + return (1 - math.Cos(math.Pi*t)) / 2 + default: + return t + } +} + +// easedT applies curve shaping then easing distribution to a linear +// parameter t in [0,1]. Either parameter may be zero value, in which +// case that stage is a no-op passthrough. +func easedT(t float64, curve enums.CurveKind, easing enums.EasingKind) float64 { + t = applyCurve(t, curve) + t = applyEasing(t, easing) + return t +} + // InterpolateBetween creates a gradient of 'steps' colours between hiCol and loCol -// by linearly interpolating each RGB channel independently. +// by interpolating each RGB channel independently using the given curve and easing. // If steps is 0 or negative, it uses DefaultStepCount(). -func InterpolateBetween(hiCol, loCol color.Color, steps int) []color.Color { +func InterpolateBetween(hiCol, loCol color.Color, steps int, curve enums.CurveKind, easing enums.EasingKind) []Color { if steps <= 0 { steps = DefaultStepCount() } steps = max(steps, 2) // ensure at least 2 steps for a visible gradient - hiR, hiG, hiB, hiA := hiCol.RGBA() - loR, loG, loB, loA := loCol.RGBA() + hiR, hiG, hiB, _ := hiCol.RGBA() + loR, loG, loB, _ := loCol.RGBA() - // Convert to float for linear interpolation (already in 0-255 range) + // Convert to float for interpolation (already in 0-255 range) rStart := float64(hiR >> 8) gStart := float64(hiG >> 8) bStart := float64(hiB >> 8) - aStart := float64(hiA >> 8) rEnd := float64(loR >> 8) gEnd := float64(loG >> 8) bEnd := float64(loB >> 8) - aEnd := float64(loA >> 8) - gradient := make([]color.Color, steps) + gradient := make([]Color, steps) for i := 0; i < steps; i++ { t := float64(i) / float64(steps-1) // 0.0 to 1.0 + t = easedT(t, curve, easing) r := rStart + (rEnd-rStart)*t g := gStart + (gEnd-gStart)*t b := bStart + (bEnd-bStart)*t - a := aStart + (aEnd-aStart)*t - gradient[i] = color.RGBA{ + gradient[i] = Color{ R: uint8(math.Max(0, math.Min(255, r))), G: uint8(math.Max(0, math.Min(255, g))), B: uint8(math.Max(0, math.Min(255, b))), - A: uint8(math.Max(0, math.Min(255, a))), } } return gradient } -// InterpolateBetweenRGBA is a variant that works directly with RGBA values. +// InterpolateBetweenRGBA creates a gradient of 'steps' colours between +// hi and lo RGB values using the given curve and easing. // Returns nil if steps <= 0. -func InterpolateBetweenRGBA(hiR, hiG, hiB, loR, loG, loB uint8, steps int) []color.Color { +func InterpolateBetweenRGBA( + hiR, hiG, hiB, loR, loG, loB uint8, + steps int, + curve enums.CurveKind, + easing enums.EasingKind, +) []Color { steps = max(steps, 2) - gradient := make([]color.Color, steps) + gradient := make([]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] = color.RGBA{R: r, G: g, B: b, A: 255} + gradient[i] = Color{R: r, G: g, B: b} } return gradient diff --git a/src/prism/contract/navigation.go b/src/prism/contract/navigation.go deleted file mode 100644 index 0a883b28..00000000 --- a/src/prism/contract/navigation.go +++ /dev/null @@ -1,13 +0,0 @@ -package contract - -// NavigationKind identifies whether a traversal is fresh or a -// continuation from a checkpoint. -type NavigationKind string - -const ( - // PrimeNavigation is a fresh traversal from the root. - PrimeNavigation NavigationKind = "prime" - - // ResumeNavigation is a continuation from a saved checkpoint. - ResumeNavigation NavigationKind = "resume" -) diff --git a/src/prism/contract/overture.go b/src/prism/contract/overture.go index cfc4fe3d..7c73a396 100644 --- a/src/prism/contract/overture.go +++ b/src/prism/contract/overture.go @@ -2,6 +2,8 @@ package contract import ( "time" + + "github.com/snivilised/jaywalk/src/agenor/enums" ) // Overture carries the metadata known at the start of a traversal. @@ -18,7 +20,7 @@ type Overture struct { StartedAt time.Time // Kind indicates whether this is a prime or resume traversal. - Kind NavigationKind + Kind enums.NavigationKind // ResumeFrom is the path from which a resume traversal continues. // Populated only when Kind == ResumeNavigation. diff --git a/src/prism/contract/palette.go b/src/prism/contract/palette.go index a393f685..f1340455 100644 --- a/src/prism/contract/palette.go +++ b/src/prism/contract/palette.go @@ -5,6 +5,7 @@ import ( "image/color" "charm.land/lipgloss/v2" + "github.com/snivilised/jaywalk/src/agenor/enums" ) // ansi16Names maps human-friendly colour names to their ANSI-16 number @@ -119,6 +120,14 @@ type GradientDef struct { // Animate controls whether the gradient sweeps over time (true) or // is applied statically (false). When nil, defaults to true. Animate *bool `mapstructure:"animate,omitempty" yaml:"animate,omitempty"` + + // Curve controls the interpolation shape between Hi and Lo. + // Omit to use linear interpolation (default). + Curve enums.CurveKind `mapstructure:"curve,omitempty" yaml:"curve,omitempty"` + + // Easing controls step distribution along the curve. + // Omit for uniform distribution (default). + Easing enums.EasingKind `mapstructure:"easing,omitempty" yaml:"easing,omitempty"` } // HighlightsConfig holds gradient definitions and their component bindings. @@ -143,6 +152,10 @@ type ResolvedGradient struct { // Animate controls whether the gradient sweeps over time. // True means animate using GradientState; false means static. Animate bool + + // Curve and Easing are copied from GradientDef during theme construction. + Curve enums.CurveKind + Easing enums.EasingKind } // Gradient component names - used in theme YAML under diff --git a/src/prism/contract/summary.go b/src/prism/contract/summary.go index a0e20894..737bb3a5 100644 --- a/src/prism/contract/summary.go +++ b/src/prism/contract/summary.go @@ -4,6 +4,7 @@ import ( "time" "github.com/snivilised/jaywalk/src/agenor/core" + "github.com/snivilised/jaywalk/src/agenor/enums" ) // Summary carries the result of a completed traversal. Passed to @@ -26,5 +27,5 @@ type Summary struct { // Kind mirrors Overture.Kind so the renderer can label counts // appropriately in the closing summary. - Kind NavigationKind + Kind enums.NavigationKind } diff --git a/src/prism/contract/theme.go b/src/prism/contract/theme.go index b59770c3..d77b4be2 100644 --- a/src/prism/contract/theme.go +++ b/src/prism/contract/theme.go @@ -106,7 +106,7 @@ type Theme struct { // GradientCaches caches the pre-computed interpolated colour steps per // gradient name, keyed by gradient name. Empty means compute on-demand // using InterpolateBetween(). Only used for highway animation overlays. - GradientCaches map[string][]color.Color `json:"-"` + GradientCaches map[string][]Color `json:"-"` // HighlightsComponents maps a component name (e.g. "activity-control") // to a gradient name. Populated from highlights.components in the @@ -282,7 +282,7 @@ func NewTheme(palette Palette, writer io.Writer) (Theme, error) { // Resolve gradients (silently skip entries with neither hi nor lo). highlightGradients := make(map[string]ResolvedGradient) - highlightCaches := make(map[string][]color.Color) + highlightCaches := make(map[string][]Color) for name, gd := range palette.Highlights.Gradients { if gd.Hi == nil && gd.Lo == nil { continue @@ -318,10 +318,12 @@ func NewTheme(palette Palette, writer io.Writer) (Theme, error) { Hi: hiCol, Lo: loCol, Animate: animate, + Curve: gd.Curve, + Easing: gd.Easing, } // Pre-compute gradient steps and cache them (on-demand when empty) if steps > 0 { - cached := InterpolateBetween(hiCol, loCol, steps) + cached := InterpolateBetween(hiCol, loCol, steps, gd.Curve, gd.Easing) highlightCaches[name] = cached } else { // Cache nil to indicate compute on-demand during rendering diff --git a/src/prism/contract/view.go b/src/prism/contract/view.go deleted file mode 100644 index 8d492399..00000000 --- a/src/prism/contract/view.go +++ /dev/null @@ -1,19 +0,0 @@ -package contract - -// ViewKind identifies the rendering view to use. Defined as a typed -// string rather than an iota so that the set remains open - new views -// can be added in future without modifying this file. -type ViewKind string - -const ( - // LinearView is a linear scrolling output view rendered with lipgloss. - LinearView ViewKind = "linear" - - // PortholeView is a bubbletea view with a static header and footer - // and vertically scrolling content between them. - PortholeView ViewKind = "porthole" - - // HighwayView is a bubbletea view showing parallel lanes of activity, - // suited to concurrent worker output. - HighwayView ViewKind = "highway" -) diff --git a/src/prism/effects/gradient-state.go b/src/prism/effects/gradient-state.go index d56415aa..a0bb1c2f 100644 --- a/src/prism/effects/gradient-state.go +++ b/src/prism/effects/gradient-state.go @@ -4,13 +4,10 @@ package effects import ( "fmt" - "image/color" - "math" "strings" "charm.land/lipgloss/v2" "github.com/snivilised/jaywalk/src/prism/contract" - "github.com/snivilised/jaywalk/src/prism/contract/ansi" ) // GradientState holds in-memory state for a single lane's gradient animation. @@ -106,11 +103,11 @@ func (s *GradientState) GetEffectiveIndex(pos int) int { // The caller must use ApplyGradientStyled() to convert this to a lipgloss-compatible string. // //nolint:all -func ApplyGradient(hiCol, loCol color.Color, frameContent string, state *GradientState) []RunWithColor { - hiR, hiG, hiB, _ := hiCol.RGBA() - loR, loG, loB, _ := loCol.RGBA() +func ApplyGradient(gradient contract.ResolvedGradient, frameContent string, state *GradientState) []RunWithColor { + hiR, hiG, hiB, _ := gradient.Hi.RGBA() + loR, loG, loB, _ := gradient.Lo.RGBA() - steps := InterpolateBetweenRGBA( + steps := contract.InterpolateBetweenRGBA( uint8(hiR>>8), uint8(hiG>>8), uint8(hiB>>8), @@ -118,6 +115,8 @@ func ApplyGradient(hiCol, loCol color.Color, frameContent string, state *Gradien uint8(loG>>8), uint8(loB>>8), state.TotalSteps, + gradient.Curve, + gradient.Easing, ) if len(steps) == 0 { @@ -160,11 +159,11 @@ func ApplyGradient(hiCol, loCol color.Color, frameContent string, state *Gradien // this does not use GradientState and produces a fixed, non-animated output. // //nolint:all -func ApplyGradientStatic(hiCol, loCol color.Color, content string, totalSteps int) []RunWithColor { - hiR, hiG, hiB, _ := hiCol.RGBA() - loR, loG, loB, _ := loCol.RGBA() +func ApplyGradientStatic(gradient contract.ResolvedGradient, content string, totalSteps int) []RunWithColor { + hiR, hiG, hiB, _ := gradient.Hi.RGBA() + loR, loG, loB, _ := gradient.Lo.RGBA() - steps := InterpolateBetweenRGBA( + steps := contract.InterpolateBetweenRGBA( uint8(hiR>>8), uint8(hiG>>8), uint8(hiB>>8), @@ -172,6 +171,8 @@ func ApplyGradientStatic(hiCol, loCol color.Color, content string, totalSteps in uint8(loG>>8), uint8(loB>>8), totalSteps, + gradient.Curve, + gradient.Easing, ) if len(steps) == 0 { @@ -208,32 +209,6 @@ type RunWithColor struct { // Each character is rendered with its interpolated foreground colour using ANSI escape codes. // The function builds an ANSI-style string where each non-whitespace character gets // its own RGB foreground colour set before rendering. -func ApplyGradientStyledL(runs []RunWithColor) string { - if len(runs) == 0 { - return "" - } - - var result strings.Builder - - for _, rc := range runs { - r := rc.Rune - - // Skip whitespace - keep original formatting - if r == ' ' || r == '\t' || r == '\n' { - result.WriteByte(byte(r)) - continue - } - - // Apply ANSI escape code with RGB foreground colour using standard decimal values - ansiColor := ansi.EscapedTrueColor(rc.Color.R, rc.Color.G, rc.Color.B) - result.WriteString(ansiColor) - result.WriteRune(r) - result.WriteString("\x1b[0m") // reset - } - - return result.String() -} - func ApplyGradientStyled(runs []RunWithColor) string { if len(runs) == 0 { return "" @@ -258,22 +233,3 @@ func ApplyGradientStyled(runs []RunWithColor) string { return b.String() } - -// InterpolateBetweenRGBA creates a gradient of 'steps' colours between hi and lo RGB values. -// Returns nil if steps <= 0. Each colour has alpha=255 (opaque). -func InterpolateBetweenRGBA(hiR, hiG, hiB, loR, loG, loB uint8, steps int) []contract.Color { - steps = max(steps, 2) - - gradient := make([]contract.Color, steps) - for i := 0; i < steps; i++ { - t := float64(i) / float64(steps-1) - - 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 -} diff --git a/src/prism/effects/gradient-state_test.go b/src/prism/effects/gradient-state_test.go index d04a4c13..99ae778f 100644 --- a/src/prism/effects/gradient-state_test.go +++ b/src/prism/effects/gradient-state_test.go @@ -21,9 +21,10 @@ var _ = Describe("Highway Gradient Rendering", func() { // Perform an interpolation with 4 steps hi := color.RGBA{R: 255, G: 0, B: 0, A: 255} // Red lo := color.RGBA{R: 0, G: 0, B: 255, A: 255} // Blue + grad := contract.ResolvedGradient{Hi: hi, Lo: lo} state.TotalSteps = 4 - runs := ApplyGradient(hi, lo, "test", state) + runs := ApplyGradient(grad, "test", state) Expect(runs).To(HaveLen(4)) // Verify steps array was populated diff --git a/src/prism/views/highway/banner_test.go b/src/prism/views/highway/banner_test.go index cb12a3c4..e8a479b7 100644 --- a/src/prism/views/highway/banner_test.go +++ b/src/prism/views/highway/banner_test.go @@ -42,10 +42,11 @@ func makeBannerInfo(position string) banner.Info { st := effects.NewGradientState() hiR, hiG, hiB, _ := grad.Hi.RGBA() loR, loG, loB, _ := grad.Lo.RGBA() - steps := effects.InterpolateBetweenRGBA( + steps := contract.InterpolateBetweenRGBA( uint8(hiR>>8), uint8(hiG>>8), uint8(hiB>>8), //nolint:gosec // safe: 16-bit value >> 8 fits in 8 bits uint8(loR>>8), uint8(loG>>8), uint8(loB>>8), //nolint:gosec // safe: 16-bit value >> 8 fits in 8 bits grad.Steps, + grad.Curve, grad.Easing, ) st.SetSteps(steps) return banner.Info{ diff --git a/src/prism/views/linear/linear-renderer.go b/src/prism/views/linear/linear-renderer.go index b59f4a99..7faee51f 100644 --- a/src/prism/views/linear/linear-renderer.go +++ b/src/prism/views/linear/linear-renderer.go @@ -13,6 +13,7 @@ import ( "charm.land/lipgloss/v2" "github.com/snivilised/jaywalk/src/agenor/core" + "github.com/snivilised/jaywalk/src/agenor/enums" "github.com/snivilised/jaywalk/src/prism/contract" "github.com/snivilised/jaywalk/src/prism/effects" "github.com/snivilised/jaywalk/src/prism/layout" @@ -75,7 +76,7 @@ func (r *renderer) Begin(overture contract.Overture) { // Build caption for intro widget caption := overture.Caption - if overture.Kind == contract.ResumeNavigation && overture.ResumeFrom != "" { + if overture.Kind == enums.NavigationKindResume && overture.ResumeFrom != "" { caption += fmt.Sprintf(" - from: %s", overture.ResumeFrom) } diff --git a/src/prism/views/linear/linear-renderer_test.go b/src/prism/views/linear/linear-renderer_test.go index 2585b69b..344f8f25 100644 --- a/src/prism/views/linear/linear-renderer_test.go +++ b/src/prism/views/linear/linear-renderer_test.go @@ -8,6 +8,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/snivilised/jaywalk/src/agenor/enums" "github.com/snivilised/jaywalk/src/prism/contract" ) @@ -94,7 +95,7 @@ var _ = Describe("LinearRenderer", func() { Expect(err).To(Succeed()) renderer.End(contract.Summary{ - Kind: contract.PrimeNavigation, + Kind: enums.NavigationKindPrime, FilesVisited: 12, DirsVisited: 3, Elapsed: 2 * time.Second, @@ -119,7 +120,7 @@ var _ = Describe("LinearRenderer", func() { Expect(err).To(Succeed()) renderer.End(contract.Summary{ - Kind: contract.PrimeNavigation, + Kind: enums.NavigationKindPrime, FilesVisited: 55, DirsVisited: 7, Skipped: 0, @@ -153,7 +154,7 @@ var _ = Describe("LinearRenderer", func() { Expect(err).To(Succeed()) renderer.Begin(contract.Overture{ - Kind: contract.PrimeNavigation, + Kind: enums.NavigationKindPrime, Root: "./src/app", Caption: "files and folders", StartedAt: time.Date(2026, time.May, 10, 11, 31, 7, 0, time.UTC), diff --git a/src/prism/views/porthole/model.go b/src/prism/views/porthole/model.go index b793b2de..849da92d 100644 --- a/src/prism/views/porthole/model.go +++ b/src/prism/views/porthole/model.go @@ -136,10 +136,11 @@ func (m *Model) SetActivity(frameFn contract.FrameFunc, gradient *contract.Resol if gradient != nil && gradient.Steps > 0 { hiR, hiG, hiB, _ := gradient.Hi.RGBA() loR, loG, loB, _ := gradient.Lo.RGBA() - steps := effects.InterpolateBetweenRGBA( + steps := contract.InterpolateBetweenRGBA( uint8(hiR>>8), uint8(hiG>>8), uint8(hiB>>8), //nolint:gosec // channel values are 0-65535, >>8 yields 0-255 uint8(loR>>8), uint8(loG>>8), uint8(loB>>8), //nolint:gosec // channel values are 0-65535, >>8 yields 0-255 gradient.Steps, + gradient.Curve, gradient.Easing, ) gs := effects.NewGradientState() gs.SetSteps(steps) diff --git a/src/prism/widgets/activity/activity.go b/src/prism/widgets/activity/activity.go index 9366f9a2..1805327a 100644 --- a/src/prism/widgets/activity/activity.go +++ b/src/prism/widgets/activity/activity.go @@ -34,8 +34,7 @@ func Render(cfg Config, inner, withBars := stripOuterBars(cfg.Content) gradientRuns := effects.ApplyGradient( - effect.Gradient.Hi, - effect.Gradient.Lo, + *effect.Gradient, inner, effect.State, ) diff --git a/src/prism/widgets/banner/banner.go b/src/prism/widgets/banner/banner.go index b60bf470..ea921e4c 100644 --- a/src/prism/widgets/banner/banner.go +++ b/src/prism/widgets/banner/banner.go @@ -289,10 +289,11 @@ func paletteSteps(g *contract.ResolvedGradient, st *effects.GradientState) []con } hiR, hiG, hiB, _ := g.Hi.RGBA() loR, loG, loB, _ := g.Lo.RGBA() - steps := effects.InterpolateBetweenRGBA( + steps := contract.InterpolateBetweenRGBA( uint8(hiR>>8), uint8(hiG>>8), uint8(hiB>>8), //nolint:gosec // safe: 16-bit value >> 8 fits in 8 bits uint8(loR>>8), uint8(loG>>8), uint8(loB>>8), //nolint:gosec // safe: 16-bit value >> 8 fits in 8 bits st.TotalSteps, + g.Curve, g.Easing, ) if len(steps) > 0 { st.SetSteps(steps) diff --git a/src/prism/widgets/banner/model_test.go b/src/prism/widgets/banner/model_test.go index bda5631a..781c510a 100644 --- a/src/prism/widgets/banner/model_test.go +++ b/src/prism/widgets/banner/model_test.go @@ -25,10 +25,11 @@ func makeModelInfo(width int) banner.Info { st.TotalSteps = grad.Steps hiR, hiG, hiB, _ := grad.Hi.RGBA() loR, loG, loB, _ := grad.Lo.RGBA() - steps := effects.InterpolateBetweenRGBA( + steps := contract.InterpolateBetweenRGBA( uint8(hiR>>8), uint8(hiG>>8), uint8(hiB>>8), //nolint:gosec // safe: 16-bit value >> 8 fits in 8 bits uint8(loR>>8), uint8(loG>>8), uint8(loB>>8), //nolint:gosec // safe: 16-bit value >> 8 fits in 8 bits grad.Steps, + grad.Curve, grad.Easing, ) st.SetSteps(steps) return banner.Info{ diff --git a/src/prism/widgets/periscope/periscope.go b/src/prism/widgets/periscope/periscope.go index 6060379c..bf1222e9 100644 --- a/src/prism/widgets/periscope/periscope.go +++ b/src/prism/widgets/periscope/periscope.go @@ -34,8 +34,7 @@ func Render(cfg Config, styles Styles, effect Effect) string { if effect.Gradient.Animate { if effect.State != nil && fill > 0 { runs := effects.ApplyGradient( - effect.Gradient.Hi, - effect.Gradient.Lo, + *effect.Gradient, barContent, effect.State, ) @@ -45,8 +44,7 @@ func Render(cfg Config, styles Styles, effect Effect) string { } } else { runs := effects.ApplyGradientStatic( - effect.Gradient.Hi, - effect.Gradient.Lo, + *effect.Gradient, barContent, effect.Gradient.Steps, )