diff --git a/src/prism/workshop/bands.go b/src/prism/workshop/bands.go new file mode 100644 index 0000000..2b6ef1c --- /dev/null +++ b/src/prism/workshop/bands.go @@ -0,0 +1,151 @@ +package workshop + +import ( + "fmt" + "math" + "strings" + + "charm.land/lipgloss/v2" + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/contract" +) + +// brailleHeights maps a normalised [0,1] value to a +// braille character whose dot pattern corresponds to +// the vertical position (bottom to top). +var brailleHeights = []string{ + "⣀", // 0.0 bottom + "⡠", // 0.25 + "⠤", // 0.5 middle + "⠊", // 0.75 + "⠈", // 1.0 top +} + +// BandsVisualiser renders a flat gradient bar with a +// braille curve indicator row above it showing the +// easing curve shape. +type BandsVisualiser struct { + Width int +} + +// Name returns the display name for this visualiser. +func (v *BandsVisualiser) Name() string { + return "bands" +} + +// Render produces two rows of output: +// - Row 1: braille curve indicator tracing the easing +// curve shape. +// - Row 2: flat gradient bar with evenly distributed +// gradient steps. +func (v *BandsVisualiser) Render( + steps []contract.Color, + curve enums.CurveKind, + easing enums.EasingKind, + animFrame int, +) string { + if len(steps) == 0 || v.Width <= 0 { + return "" + } + + var b strings.Builder + n := len(steps) + + // Row 1: braille curve indicator + mutedColor := "#888888" + mutedStyle := lipgloss.NewStyle().Foreground( + lipgloss.Color(mutedColor), + ) + + for col := 0; col < v.Width; col++ { + t := float64(col) / float64(v.Width) + y := easedCurve(t, curve, easing) + ch := brailleForHeight(y) + b.WriteString(mutedStyle.Render(ch)) + } + + b.WriteString("\n") + + // Row 2: gradient bar + for col := 0; col < v.Width; col++ { + t := float64(col) / float64(v.Width) + stepIdx := stepForPosition(t, n, curve) + + c := steps[stepIdx] + style := lipgloss.NewStyle().Foreground( + lipgloss.Color( + fmt.Sprintf( + "#%02x%02x%02x", c.R, c.G, c.B, + ), + ), + ) + b.WriteString(style.Render("█")) + } + + return b.String() +} + +// brailleForHeight maps a value in [0,1] to a braille +// character. 0 maps to the bottom row, 1 maps to the +// top row. +func brailleForHeight(y float64) string { + n := len(brailleHeights) + if y <= 0 { + return brailleHeights[0] + } + if y >= 1 { + return brailleHeights[n-1] + } + idx := int(math.Round(y * float64(n-1))) + if idx < 0 { + idx = 0 + } + if idx >= n { + idx = n - 1 + } + return brailleHeights[idx] +} + +// easedCurve applies the curve shaping then easing +// distribution to a linear parameter t in [0,1]. +func easedCurve( + t float64, + curve enums.CurveKind, + easing enums.EasingKind, +) float64 { + t = applyCurve(t, curve) + t = applyEasing(t, easing) + return t +} + +// applyCurve applies the curve shaping function to a +// linear parameter t in [0,1]. +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]. +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 + } +} diff --git a/src/prism/workshop/bands_test.go b/src/prism/workshop/bands_test.go new file mode 100644 index 0000000..1126c29 --- /dev/null +++ b/src/prism/workshop/bands_test.go @@ -0,0 +1,81 @@ +package workshop_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/workshop" +) + +var _ = Describe("BandsVisualiser", func() { + var v *workshop.BandsVisualiser + + BeforeEach(func() { + v = &workshop.BandsVisualiser{Width: 40} + }) + + It("returns the name 'bands'", func() { + Expect(v.Name()).To(Equal("bands")) + }) + + DescribeTable("renders gradient steps", + func(entry stepsEntry) { + result := v.Render( + makeSteps(entry.Count), + entry.Curve, + entry.Easing, + entry.AnimFrame, + ) + Expect(result).ToNot(BeEmpty()) + Expect(result).To(ContainSubstring("\x1b[38;2;")) + Expect(result).To(ContainSubstring("\n")) + }, + Entry("2 steps, linear, uniform, frame 0", + stepsEntry{2, enums.CurveKindLinear, + enums.EasingKindUniform, 0}), + Entry("8 steps, sine, ease-in, frame 10", + stepsEntry{8, enums.CurveKindSine, + enums.EasingKindEaseIn, 10}), + Entry("64 steps, cubic, ease-out, frame 100", + stepsEntry{64, enums.CurveKindCubic, + enums.EasingKindEaseOut, 100}), + Entry("256 steps, q-in, ease-in-out, frame 0", + stepsEntry{256, enums.CurveKindQuadraticIn, + enums.EasingKindEaseInOut, 0}), + Entry("8 steps, q-out, uniform, frame 50", + stepsEntry{8, enums.CurveKindQuadraticOut, + enums.EasingKindUniform, 50}), + ) + + DescribeTable("contains braille and block chars", + func(entry stepsEntry) { + result := v.Render( + makeSteps(entry.Count), + entry.Curve, + entry.Easing, + entry.AnimFrame, + ) + + Expect(result).To(Satisfy( + func(s string) bool { + return containsAny(s, []string{ + "⣀", "⡠", "⠤", "⠊", "⠈", "█", + }) + }, + )) + }, + Entry("8 steps, linear", + stepsEntry{8, enums.CurveKindLinear, + enums.EasingKindUniform, 0}), + ) + + It("returns empty for zero steps", func() { + result := v.Render( + makeSteps(0), + enums.CurveKindLinear, + enums.EasingKindUniform, + 0, + ) + Expect(result).To(BeEmpty()) + }) +}) diff --git a/src/prism/workshop/bloom.go b/src/prism/workshop/bloom.go new file mode 100644 index 0000000..eff090f --- /dev/null +++ b/src/prism/workshop/bloom.go @@ -0,0 +1,175 @@ +package workshop + +import ( + "fmt" + "math" + "strings" + + "charm.land/lipgloss/v2" + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/contract" +) + +var bloomChars = []string{" ", "○", "◌", "◎", "●"} + +// BloomVisualiser renders a radial gradient bloom. The +// gradient radiates outward from a central point. Each +// ring takes one gradient step. Pulses slowly using the +// easing preset. +type BloomVisualiser struct { + Width int +} + +// Name returns the display name for this visualiser. +func (v *BloomVisualiser) Name() string { + return "bloom" +} + +// Render produces a diamond-shaped radial bloom where +// each concentric ring is coloured with a gradient step +// and the animFrame offset shifts the mapping. +func (v *BloomVisualiser) Render( + steps []contract.Color, + _ enums.CurveKind, + easing enums.EasingKind, + animFrame int, +) string { + if len(steps) == 0 || v.Width <= 0 { + return "" + } + + n := len(steps) + rings := min(n, maxRings(v.Width)) + + if rings == 0 { + return "" + } + + var b strings.Builder + + // Distance multiplier based on easing. + // Uniform = 1.0, EaseIn pulses inward, + // EaseOut pulses outward. + easeFactor := pulseFactor(easing, animFrame) + + // Upper half of diamond (rings-1 lines) + for row := 0; row < rings; row++ { + spaces := rings - row - 1 + b.WriteString(strings.Repeat(" ", spaces)) + + for ring := 0; ring <= row; ring++ { + stepIdx := ringAt( + ring, n, animFrame, easeFactor, + ) + if stepIdx < 0 || stepIdx >= n { + stepIdx = 0 + } + c := steps[stepIdx] + ch := charForRing(ring, rings) + style := lipgloss.NewStyle().Foreground( + lipgloss.Color( + fmt.Sprintf( + "#%02x%02x%02x", + c.R, c.G, c.B, + ), + ), + ) + b.WriteString(style.Render(ch)) + b.WriteString(" ") + } + b.WriteString("\n") + } + + // Lower half of diamond (rings-1 lines, inverted) + for row := rings - 2; row >= 0; row-- { + spaces := rings - row - 1 + b.WriteString(strings.Repeat(" ", spaces)) + + for ring := 0; ring <= row; ring++ { + stepIdx := ringAt( + ring, n, animFrame, easeFactor, + ) + if stepIdx < 0 || stepIdx >= n { + stepIdx = 0 + } + c := steps[stepIdx] + ch := charForRing(ring, rings) + style := lipgloss.NewStyle().Foreground( + lipgloss.Color( + fmt.Sprintf( + "#%02x%02x%02x", + c.R, c.G, c.B, + ), + ), + ) + b.WriteString(style.Render(ch)) + b.WriteString(" ") + } + if row > 0 { + b.WriteString("\n") + } + } + + return b.String() +} + +// maxRings returns the number of rings that fit in the +// given width. Each ring pair takes 2 characters (char + +// space), so at most floor(width/2) rings, capped at +// rings for a diamond shape. +func maxRings(width int) int { + maxR := width / 2 + if maxR > 8 { + return 8 + } + return maxR +} + +// ringAt returns the gradient step index for the given +// ring, by cycling through steps and pulsing with +// the animFrame offset. +func ringAt( + ring, n, animFrame int, easeFactor float64, +) int { + offset := int(float64(animFrame) * easeFactor) + return (ring + offset) % n +} + +// charForRing maps a ring index to a visual character. +// Outer rings use sparse characters, inner rings use +// denser characters. +func charForRing(ring, total int) string { + if ring == total-1 { + return "●" + } + // Distribute remaining characters across rings + idx := (ring * (len(bloomChars) - 2)) / max(total-1, 1) + if idx >= len(bloomChars)-1 { + idx = len(bloomChars) - 2 + } + return bloomChars[idx+1] +} + +// pulseFactor returns a multiplier based on the easing +// preset and animation frame. EaseIn accelerates toward +// the center, EaseOut decelerates, EaseInOut oscillates. +func pulseFactor( + easing enums.EasingKind, animFrame int, +) float64 { + switch easing { //nolint:exhaustive // ok + case enums.EasingKindEaseIn: + return 0.5 + 0.5*math.Sin( + float64(animFrame)*0.1, + ) + case enums.EasingKindEaseOut: + return 0.5 + 0.5*math.Cos( + float64(animFrame)*0.1, + ) + case enums.EasingKindEaseInOut: + return 0.3 + 0.7*math.Sin( + float64(animFrame)*0.05, + ) + default: + return 1.0 + } +} diff --git a/src/prism/workshop/bloom_test.go b/src/prism/workshop/bloom_test.go new file mode 100644 index 0000000..482b5dd --- /dev/null +++ b/src/prism/workshop/bloom_test.go @@ -0,0 +1,80 @@ +package workshop_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/workshop" +) + +var _ = Describe("BloomVisualiser", func() { + var v *workshop.BloomVisualiser + + BeforeEach(func() { + v = &workshop.BloomVisualiser{Width: 40} + }) + + It("returns the name 'bloom'", func() { + Expect(v.Name()).To(Equal("bloom")) + }) + + DescribeTable("renders gradient steps", + func(entry stepsEntry) { + result := v.Render( + makeSteps(entry.Count), + entry.Curve, + entry.Easing, + entry.AnimFrame, + ) + Expect(result).ToNot(BeEmpty()) + Expect(result).To(ContainSubstring("\x1b[38;2;")) + }, + Entry("2 steps, linear, uniform, frame 0", + stepsEntry{2, enums.CurveKindLinear, + enums.EasingKindUniform, 0}), + Entry("8 steps, sine, ease-in, frame 10", + stepsEntry{8, enums.CurveKindSine, + enums.EasingKindEaseIn, 10}), + Entry("64 steps, cubic, ease-out, frame 100", + stepsEntry{64, enums.CurveKindCubic, + enums.EasingKindEaseOut, 100}), + Entry("256 steps, q-in, ease-in-out, frame 0", + stepsEntry{256, enums.CurveKindQuadraticIn, + enums.EasingKindEaseInOut, 0}), + Entry("8 steps, q-out, uniform, frame 50", + stepsEntry{8, enums.CurveKindQuadraticOut, + enums.EasingKindUniform, 50}), + ) + + DescribeTable("contains bloom characters", + func(entry stepsEntry) { + result := v.Render( + makeSteps(entry.Count), + entry.Curve, + entry.Easing, + entry.AnimFrame, + ) + + Expect(result).To(Satisfy( + func(s string) bool { + return containsAny(s, []string{ + "○", "◌", "◎", "●", + }) + }, + )) + }, + Entry("8 steps, linear", + stepsEntry{8, enums.CurveKindLinear, + enums.EasingKindUniform, 0}), + ) + + It("returns empty for zero steps", func() { + result := v.Render( + makeSteps(0), + enums.CurveKindLinear, + enums.EasingKindUniform, + 0, + ) + Expect(result).To(BeEmpty()) + }) +}) diff --git a/src/prism/workshop/helpers_test.go b/src/prism/workshop/helpers_test.go new file mode 100644 index 0000000..624d6b0 --- /dev/null +++ b/src/prism/workshop/helpers_test.go @@ -0,0 +1,36 @@ +package workshop_test + +import ( + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/contract" + "strings" +) + +func makeSteps(n int) []contract.Color { + steps := make([]contract.Color, n) + for i := 0; i < n; i++ { + t := float64(i) / float64(max(n-1, 1)) + steps[i] = contract.Color{ + R: uint8(255 * (1 - t)), + G: uint8(0), + B: uint8(255 * t), + } + } + return steps +} + +type stepsEntry struct { + Count int + Curve enums.CurveKind + Easing enums.EasingKind + AnimFrame int +} + +func containsAny(s string, chars []string) bool { + for _, ch := range chars { + if strings.Contains(s, ch) { + return true + } + } + return false +} diff --git a/src/prism/workshop/sweep.go b/src/prism/workshop/sweep.go new file mode 100644 index 0000000..97f2e1f --- /dev/null +++ b/src/prism/workshop/sweep.go @@ -0,0 +1,55 @@ +package workshop + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/contract" +) + +// SweepVisualiser renders a plain animated horizontal +// sweep bar. Each character position is coloured with +// the corresponding gradient step. The animFrame offset +// shifts which step maps to the left edge, producing a +// scrolling animation. +type SweepVisualiser struct { + Width int +} + +// Name returns the display name for this visualiser. +func (v *SweepVisualiser) Name() string { + return "sweep" +} + +// Render produces the terminal string for the sweep +// bar. steps is the pre-interpolated colour slice. +// animFrame shifts the starting step for animation. +func (v *SweepVisualiser) Render( + steps []contract.Color, + _ enums.CurveKind, + _ enums.EasingKind, + animFrame int, +) string { + if len(steps) == 0 || v.Width <= 0 { + return "" + } + + var b strings.Builder + n := len(steps) + + for col := 0; col < v.Width; col++ { + stepIdx := (col + animFrame) % n + if stepIdx < 0 { + stepIdx += n + } + c := steps[stepIdx] + style := lipgloss.NewStyle().Foreground( + lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B)), + ) + b.WriteString(style.Render("█")) + } + + return b.String() +} diff --git a/src/prism/workshop/sweep_test.go b/src/prism/workshop/sweep_test.go new file mode 100644 index 0000000..f8bac6d --- /dev/null +++ b/src/prism/workshop/sweep_test.go @@ -0,0 +1,59 @@ +package workshop_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/workshop" +) + +var _ = Describe("SweepVisualiser", func() { + var v *workshop.SweepVisualiser + + BeforeEach(func() { + v = &workshop.SweepVisualiser{Width: 40} + }) + + It("returns the name 'sweep'", func() { + Expect(v.Name()).To(Equal("sweep")) + }) + + DescribeTable("renders gradient steps", + func(entry stepsEntry) { + result := v.Render( + makeSteps(entry.Count), + entry.Curve, + entry.Easing, + entry.AnimFrame, + ) + Expect(result).ToNot(BeEmpty()) + Expect(result).To(ContainSubstring("\x1b[38;2;")) + Expect(result).To(ContainSubstring("█")) + }, + Entry("2 steps, linear, uniform, frame 0", + stepsEntry{2, enums.CurveKindLinear, + enums.EasingKindUniform, 0}), + Entry("8 steps, sine, ease-in, frame 10", + stepsEntry{8, enums.CurveKindSine, + enums.EasingKindEaseIn, 10}), + Entry("64 steps, cubic, ease-out, frame 100", + stepsEntry{64, enums.CurveKindCubic, + enums.EasingKindEaseOut, 100}), + Entry("256 steps, q-in, ease-in-out, frame 0", + stepsEntry{256, enums.CurveKindQuadraticIn, + enums.EasingKindEaseInOut, 0}), + Entry("8 steps, q-out, uniform, frame 50", + stepsEntry{8, enums.CurveKindQuadraticOut, + enums.EasingKindUniform, 50}), + ) + + It("returns empty for zero steps", func() { + result := v.Render( + makeSteps(0), + enums.CurveKindLinear, + enums.EasingKindUniform, + 0, + ) + Expect(result).To(BeEmpty()) + }) +}) diff --git a/src/prism/workshop/visualiser.go b/src/prism/workshop/visualiser.go new file mode 100644 index 0000000..757c203 --- /dev/null +++ b/src/prism/workshop/visualiser.go @@ -0,0 +1,64 @@ +package workshop + +import ( + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/contract" +) + +// GradientVisualiser renders a realtime visual +// representation of a gradient for display in the +// gradient workshop seed screen. Implementations are +// interchangeable — the workshop calls Render on every +// state change and displays the result without knowing +// which visualiser is active. +type GradientVisualiser interface { + // Render produces the terminal string for the + // current gradient. steps is the pre-interpolated + // colour slice from the working state. animFrame + // is the current animation tick counter, used by + // animated visualisers to advance their state. + Render( + steps []contract.Color, + curve enums.CurveKind, + easing enums.EasingKind, + animFrame int, + ) string + + // Name returns the display name shown in the + // visualiser picker. + Name() string +} + +var visualiserRegistry []GradientVisualiser + +// Register adds a visualiser to the package-level +// registry. Intended for use only from +// RegisterVisualisers. +func Register(v GradientVisualiser) { + visualiserRegistry = append(visualiserRegistry, v) +} + +// Visualisers returns a copy of the registered +// visualisers. The slice is safe to iterate; mutations +// do not affect the registry. +func Visualisers() []GradientVisualiser { + out := make([]GradientVisualiser, len(visualiserRegistry)) + copy(out, visualiserRegistry) + return out +} + +// Reset clears the registry. +// Intended for test isolation only. +func Reset() { + visualiserRegistry = visualiserRegistry[:0] +} + +// RegisterVisualisers registers all GradientVisualiser +// implementations for use in the gradient workshop. +// Called only from the tweak bootstrap path. +func RegisterVisualisers() { + Register(&WaveformVisualiser{}) + Register(&SweepVisualiser{}) + Register(&BloomVisualiser{}) + Register(&BandsVisualiser{}) +} diff --git a/src/prism/workshop/visualiser_test.go b/src/prism/workshop/visualiser_test.go new file mode 100644 index 0000000..8882017 --- /dev/null +++ b/src/prism/workshop/visualiser_test.go @@ -0,0 +1,112 @@ +package workshop_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/contract" + "github.com/snivilised/jaywalk/src/prism/workshop" +) + +type spyRenderer struct { + calls []spyCall +} + +type spyCall struct { + Steps []contract.Color + Curve enums.CurveKind + Easing enums.EasingKind + AnimFrame int +} + +func (s *spyRenderer) Render( + steps []contract.Color, + curve enums.CurveKind, + easing enums.EasingKind, + animFrame int, +) string { + s.calls = append(s.calls, spyCall{ + Steps: steps, + Curve: curve, + Easing: easing, + AnimFrame: animFrame, + }) + return "" +} + +func (s *spyRenderer) Name() string { + return "spy" +} + +var _ = Describe("Registry", func() { + BeforeEach(func() { + workshop.Reset() + }) + + It("starts empty", func() { + Expect(workshop.Visualisers()).To(BeEmpty()) + }) + + It("registers a visualiser", func() { + workshop.Register(&spyRenderer{}) + Expect(workshop.Visualisers()).To(HaveLen(1)) + }) + + It("returns a copy of the registry", func() { + workshop.Register(&spyRenderer{}) + workshop.Register(&spyRenderer{}) + + v := workshop.Visualisers() + Expect(v).To(HaveLen(2)) + + workshop.Register(&spyRenderer{}) + Expect(v).To(HaveLen(2), + "previously retrieved slice should be a snapshot", + ) + Expect(workshop.Visualisers()).To(HaveLen(3)) + }) + + It("clears on Reset", func() { + workshop.Register(&spyRenderer{}) + Expect(workshop.Visualisers()).To(HaveLen(1)) + + workshop.Reset() + Expect(workshop.Visualisers()).To(BeEmpty()) + }) + + It("RegisterVisualisers registers all four", func() { + workshop.RegisterVisualisers() + Expect(workshop.Visualisers()).To(HaveLen(4)) + + names := make([]string, 4) + for i, v := range workshop.Visualisers() { + names[i] = v.Name() + } + Expect(names).To(ConsistOf( + "waveform", "sweep", "bloom", "bands", + )) + }) +}) + +var _ = Describe("SpyRenderer", func() { + It("records calls", func() { + spy := &spyRenderer{} + steps := []contract.Color{{R: 255, G: 0, B: 0}} + + result := spy.Render( + steps, + enums.CurveKindSine, + enums.EasingKindEaseIn, + 5, + ) + Expect(result).To(BeEmpty()) + Expect(spy.calls).To(HaveLen(1)) + Expect(spy.calls[0].Curve).To( + Equal(enums.CurveKindSine), + ) + Expect(spy.calls[0].Easing).To( + Equal(enums.EasingKindEaseIn), + ) + Expect(spy.calls[0].AnimFrame).To(Equal(5)) + }) +}) diff --git a/src/prism/workshop/waveform.go b/src/prism/workshop/waveform.go new file mode 100644 index 0000000..5afb7b0 --- /dev/null +++ b/src/prism/workshop/waveform.go @@ -0,0 +1,119 @@ +package workshop + +import ( + "fmt" + "math" + "strings" + + "charm.land/lipgloss/v2" + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/contract" +) + +var heightChars = []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"} + +// WaveformVisualiser renders a sine wave of +// height-varying block characters whose colour follows +// the gradient sweep. A flat sweep bar beneath provides +// the accurate colour reference. +type WaveformVisualiser struct { + Width int +} + +// Name returns the display name for this visualiser. +func (v *WaveformVisualiser) Name() string { + return "waveform" +} + +// Render produces two rows of output: +// - Row 1: waveform of height-varying block characters +// coloured by gradient step. +// - Row 2: flat sweep bar with evenly distributed +// gradient steps. +func (v *WaveformVisualiser) Render( + steps []contract.Color, + curve enums.CurveKind, + easing enums.EasingKind, + animFrame int, +) string { + if len(steps) == 0 || v.Width <= 0 { + return "" + } + + var b strings.Builder + n := len(steps) + + // The curve type affects the colour mapping so that + // higher curve values bunch colours toward one end. + // We apply the curve shape when mapping column + // position to step index. + freq := 3.0 + speed := animationSpeed(easing) + + // Row 1: waveform + for col := 0; col < v.Width; col++ { + t := float64(col) / float64(v.Width) + phase := t * 2 * math.Pi * freq + phase += float64(animFrame) * speed * 0.1 + h := math.Sin(phase) + + idx := int((h + 1) / 2 * 7) + if idx < 0 { + idx = 0 + } + if idx > 7 { + idx = 7 + } + + stepIdx := stepForPosition(t, n, curve) + c := steps[stepIdx] + style := lipgloss.NewStyle().Foreground( + lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B)), + ) + b.WriteString(style.Render(heightChars[idx])) + } + + b.WriteString("\n") + + // Row 2: flat sweep bar + for col := 0; col < v.Width; col++ { + t := float64(col) / float64(v.Width) + stepIdx := stepForPosition(t, n, curve) + + c := steps[stepIdx] + style := lipgloss.NewStyle().Foreground( + lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B)), + ) + b.WriteString(style.Render("█")) + } + + return b.String() +} + +// stepForPosition maps a fractional position [0,1] to a +// gradient step index, applying the curve shape so that +// non-linear curves bunch steps toward one end. +func stepForPosition(t float64, n int, curve enums.CurveKind) int { + curved := applyCurve(t, curve) + stepIdx := int(math.Round(curved * float64(n-1))) + if stepIdx >= n { + stepIdx = n - 1 + } + return stepIdx +} + +// animationSpeed returns a multiplier based on the easing +// preset. EaseIn accelerates, EaseOut decelerates, +// EaseInOut oscillates. +func animationSpeed(easing enums.EasingKind) float64 { + switch easing { //nolint:exhaustive // ok + case enums.EasingKindEaseIn: + return 1.5 + case enums.EasingKindEaseOut: + return 0.5 + case enums.EasingKindEaseInOut: + return 1.0 + math.Sin(float64(0)) + default: + return 1.0 + } +} diff --git a/src/prism/workshop/waveform_test.go b/src/prism/workshop/waveform_test.go new file mode 100644 index 0000000..e677543 --- /dev/null +++ b/src/prism/workshop/waveform_test.go @@ -0,0 +1,82 @@ +package workshop_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/snivilised/jaywalk/src/agenor/enums" + "github.com/snivilised/jaywalk/src/prism/workshop" +) + +var _ = Describe("WaveformVisualiser", func() { + var v *workshop.WaveformVisualiser + + BeforeEach(func() { + v = &workshop.WaveformVisualiser{Width: 40} + }) + + It("returns the name 'waveform'", func() { + Expect(v.Name()).To(Equal("waveform")) + }) + + DescribeTable("renders gradient steps", + func(entry stepsEntry) { + result := v.Render( + makeSteps(entry.Count), + entry.Curve, + entry.Easing, + entry.AnimFrame, + ) + Expect(result).ToNot(BeEmpty()) + Expect(result).To(ContainSubstring("\x1b[38;2;")) + Expect(result).To(ContainSubstring("\n")) + }, + Entry("2 steps, linear, uniform, frame 0", + stepsEntry{2, enums.CurveKindLinear, + enums.EasingKindUniform, 0}), + Entry("8 steps, sine, ease-in, frame 10", + stepsEntry{8, enums.CurveKindSine, + enums.EasingKindEaseIn, 10}), + Entry("64 steps, cubic, ease-out, frame 100", + stepsEntry{64, enums.CurveKindCubic, + enums.EasingKindEaseOut, 100}), + Entry("256 steps, q-in, ease-in-out, frame 0", + stepsEntry{256, enums.CurveKindQuadraticIn, + enums.EasingKindEaseInOut, 0}), + Entry("8 steps, q-out, uniform, frame 50", + stepsEntry{8, enums.CurveKindQuadraticOut, + enums.EasingKindUniform, 50}), + ) + + DescribeTable("contains height-varying chars", + func(entry stepsEntry) { + result := v.Render( + makeSteps(entry.Count), + entry.Curve, + entry.Easing, + entry.AnimFrame, + ) + + Expect(result).To(Satisfy( + func(s string) bool { + return containsAny(s, []string{ + "▁", "▂", "▃", "▄", + "▅", "▆", "▇", "█", + }) + }, + )) + }, + Entry("8 steps, linear", + stepsEntry{8, enums.CurveKindLinear, + enums.EasingKindUniform, 0}), + ) + + It("returns empty for zero steps", func() { + result := v.Render( + makeSteps(0), + enums.CurveKindLinear, + enums.EasingKindUniform, + 0, + ) + Expect(result).To(BeEmpty()) + }) +}) diff --git a/src/prism/workshop/workshop-suite_test.go b/src/prism/workshop/workshop-suite_test.go new file mode 100644 index 0000000..1665248 --- /dev/null +++ b/src/prism/workshop/workshop-suite_test.go @@ -0,0 +1,13 @@ +package workshop_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestWorkshop(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Workshop Suite") +}