diff --git a/event.go b/event.go new file mode 100644 index 0000000..dc7ca62 --- /dev/null +++ b/event.go @@ -0,0 +1,150 @@ +package pulse + +import ( + "fmt" + "image/color" + "strings" + + "charm.land/lipgloss/v2" +) + +// DefaultEventColor is the default foreground color for timeline event pins and guidelines (#7dcfff). +var DefaultEventColor color.Color = lipgloss.Color("#7dcfff") + +// Event represents a discrete timeline annotation or event marker. +type Event struct { + ID string // Optional identifier for programmatic lookup or removal + Label string // Human-readable description (e.g. "Deploy v1.4.2") + Glyph string // Marker glyph (e.g. "â–Œ", "🚀", "▲", "◆", "!", "⚠"). Defaults to "â–Œ". + Style lipgloss.Style // Style for the marker glyph and vertical guideline + NoLine bool // If true, omits the vertical guideline across the chart +} + +// VisibleEvent pairs an Event with its current visible column in the chart window. +type VisibleEvent struct { + Col int // Column index in current plot area (0 to Width()-1) + Age int // How many time steps ago this event occurred + Event Event // The event metadata +} + +func eventsByColumn(events []VisibleEvent) map[int]VisibleEvent { + m := make(map[int]VisibleEvent, len(events)) + for _, ve := range events { + m[ve.Col] = ve + } + return m +} + +func renderEventPin(ve VisibleEvent) string { + glyph := ve.Event.Glyph + if glyph == "" { + glyph = "â–Œ" + } + st := ve.Event.Style + if st.GetForeground() == nil || st.GetForeground() == (lipgloss.NoColor{}) { + st = st.Foreground(DefaultEventColor).Bold(true) + } + return st.Render(glyph) +} + +func renderEventGuideline(ve VisibleEvent, bg color.Color) (string, bool) { + if ve.Event.NoLine { + return "", false + } + st := ve.Event.Style + if st.GetForeground() == nil || st.GetForeground() == (lipgloss.NoColor{}) { + st = st.Foreground(DefaultEventColor).Faint(true) + } + if bg != nil && bg != (lipgloss.NoColor{}) { + st = st.Background(bg) + } + return st.Render("┆"), true +} + +type eventRecord struct { + event Event + step int +} + +// AddEvent records an event at the current newest point on the timeline. +// As new points are pushed, the event moves left across the window. +func (m *Model) AddEvent(ev Event) { + m.AddEventAt(0, ev) +} + +// AddEventAt records an event at a historical offset (0 = newest point, 10 = 10 points ago). +func (m *Model) AddEventAt(offset int, ev Event) { + if offset < 0 { + offset = 0 + } + m.events = append(m.events, eventRecord{ + event: ev, + step: m.step - offset, + }) +} + +// ClearEvents removes all timeline events from the chart. +func (m *Model) ClearEvents() { + m.events = nil +} + +// VisibleEvents returns all timeline events currently visible within the chart window. +func (m *Model) VisibleEvents() []VisibleEvent { + if len(m.events) == 0 { + return nil + } + w := m.w + out := make([]VisibleEvent, 0, len(m.events)) + pruned := make([]eventRecord, 0, len(m.events)) + for _, rec := range m.events { + age := m.step - rec.step + col := (w - 1) - age + if col >= 0 && col < w { + out = append(out, VisibleEvent{ + Col: col, + Age: age, + Event: rec.event, + }) + } + if col >= 0 { + pruned = append(pruned, rec) + } + } + m.events = pruned + return out +} + +// EventsBox returns a boxed card listing all currently visible timeline events. +// Returns an empty string if there are no visible events. +func (m *Model) EventsBox() string { + events := m.VisibleEvents() + if len(events) == 0 { + return "" + } + lines := make([]string, 0, len(events)) + for _, ve := range events { + glyph := ve.Event.Glyph + if glyph == "" { + glyph = "â–Œ" + } + st := ve.Event.Style + if st.GetForeground() == nil || st.GetForeground() == (lipgloss.NoColor{}) { + st = st.Foreground(DefaultEventColor).Bold(true) + } + badge := st.Render(glyph) + label := ve.Event.Label + if label == "" { + label = "Event" + } + timeAgo := fmt.Sprintf("(-%d)", ve.Age) + if ve.Age == 0 { + timeAgo = "(now)" + } + line := badge + " " + m.axisStyle.Render(label) + " " + m.axisStyle.Faint(true).Render(timeAgo) + lines = append(lines, line) + } + return lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + Padding(0, 1). + Render(strings.Join(lines, "\n")) +} diff --git a/event_test.go b/event_test.go new file mode 100644 index 0000000..b50c5ff --- /dev/null +++ b/event_test.go @@ -0,0 +1,132 @@ +package pulse_test + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/ingvarch/pulse" +) + +func TestLineChart_TimelineEventsSlidingWindow(t *testing.T) { + m := pulse.New(40, 8) + for i := 0; i < 40; i++ { + m.Push(50) + } + // Add event at newest point + m.AddEvent(pulse.Event{Label: "Deploy", Glyph: "🚀"}) + events := m.VisibleEvents() + if len(events) != 1 { + t.Fatalf("expected 1 visible event, got %d", len(events)) + } + if events[0].Col != 39 { + t.Fatalf("expected event at col 39, got %d", events[0].Col) + } + if events[0].Age != 0 { + t.Fatalf("expected age 0, got %d", events[0].Age) + } + + // Push 10 points + for i := 0; i < 10; i++ { + m.Push(55) + } + events = m.VisibleEvents() + if len(events) != 1 { + t.Fatalf("expected 1 visible event, got %d", len(events)) + } + if events[0].Col != 29 { + t.Fatalf("expected event at col 29, got %d", events[0].Col) + } + if events[0].Age != 10 { + t.Fatalf("expected age 10, got %d", events[0].Age) + } + + // Push 30 more points -> event scrolls off screen + for i := 0; i < 30; i++ { + m.Push(60) + } + events = m.VisibleEvents() + if len(events) != 0 { + t.Fatalf("expected 0 visible events after scrolling off screen, got %d", len(events)) + } +} + +func TestLineChart_EventsBoxFormatting(t *testing.T) { + m := pulse.New(40, 8) + if box := m.EventsBox(); box != "" { + t.Fatalf("expected empty EventsBox() when no events exist, got %q", box) + } + for i := 0; i < 40; i++ { + m.Push(50) + } + m.AddEvent(pulse.Event{Label: "Deploy v1.2", Glyph: "🚀"}) + m.AddEventAt(10, pulse.Event{Label: "Alert Triggered", Glyph: "⚠"}) + + box := m.EventsBox() + if !strings.Contains(box, "Deploy v1.2") || !strings.Contains(box, "🚀") { + t.Fatalf("EventsBox missing Deploy event: %s", box) + } + if !strings.Contains(box, "Alert Triggered") || !strings.Contains(box, "⚠") { + t.Fatalf("EventsBox missing Alert event: %s", box) + } + if !strings.Contains(box, "(now)") || !strings.Contains(box, "(-10)") { + t.Fatalf("EventsBox missing age offsets: %s", box) + } + + m.ClearEvents() + if box := m.EventsBox(); box != "" { + t.Fatalf("expected empty EventsBox() after ClearEvents(), got %q", box) + } +} + +func TestLineChart_EventsRenderInLinesAndBraille(t *testing.T) { + cyan := lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")) + m := pulse.New(40, 8, + pulse.WithRange(0, 100), + pulse.WithLineStyle(cyan), + pulse.WithFill(true), + pulse.WithTintedFill(true), + ) + for i := 0; i < 40; i++ { + m.Push(50) + } + m.AddEventAt(15, pulse.Event{Label: "Deploy", Glyph: "🚀"}) + + // Lines renderer check + viewLines := m.View() + if !strings.Contains(viewLines, "🚀") { + t.Fatalf("ModeLines View() must render event glyph 🚀") + } + if !strings.Contains(viewLines, "┆") { + t.Fatalf("ModeLines View() must render event guideline ┆") + } + + // Braille renderer check + m.SetMode(pulse.ModeBraille) + viewBraille := m.View() + if !strings.Contains(viewBraille, "🚀") { + t.Fatalf("ModeBraille View() must render event glyph 🚀") + } + if !strings.Contains(viewBraille, "┆") { + t.Fatalf("ModeBraille View() must render event guideline ┆") + } +} + +func TestLineChart_WithEventOption(t *testing.T) { + m := pulse.New(30, 8, + pulse.WithEvent(5, pulse.Event{Label: "InitEvent", Glyph: "★"}), + ) + for i := 0; i < 10; i++ { + m.Push(42) + } + events := m.VisibleEvents() + if len(events) != 1 { + t.Fatalf("expected 1 event from WithEvent option, got %d", len(events)) + } + if events[0].Event.Label != "InitEvent" || events[0].Event.Glyph != "★" { + t.Fatalf("unexpected event metadata: %+v", events[0]) + } + if events[0].Col != 14 { + t.Fatalf("expected col 14 (29 - 15), got %d", events[0].Col) + } +} diff --git a/legend.go b/legend.go new file mode 100644 index 0000000..40ddcc2 --- /dev/null +++ b/legend.go @@ -0,0 +1,37 @@ +package pulse + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// Legend returns a formatted legend for named series, or empty string if none exist. +func (m *Model) Legend() string { + parts := make([]string, 0, len(m.order)) + for _, name := range m.order { + swatch := m.SeriesStyle(name).Render("■") + parts = append(parts, swatch+" "+name) + } + return strings.Join(parts, " ") +} + +// LegendBox returns a boxed legend with borders, swatches, series names, and recent values. +func (m *Model) LegendBox() string { + if len(m.order) == 0 { + return "" + } + lines := make([]string, 0, len(m.order)) + for _, name := range m.order { + swatch := m.SeriesStyle(name).Render("■") + value := "" + if v, ok := m.Last(name); ok { + value = m.formatValue(v) + } + lines = append(lines, swatch+" "+m.axisStyle.Render(name+" "+value)) + } + return lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + Padding(0, 1). + Render(strings.Join(lines, "\n")) +} diff --git a/legend_test.go b/legend_test.go new file mode 100644 index 0000000..274b06e --- /dev/null +++ b/legend_test.go @@ -0,0 +1,39 @@ +package pulse_test + +import ( + "strings" + "testing" + + "github.com/ingvarch/pulse" +) + +func TestLineChart_LegendEmptyWithoutNamedSeries(t *testing.T) { + lc := pulse.New(10, 5, pulse.WithRange(0, 100)) + lc.Push(50) + if got := lc.Legend(); strings.TrimSpace(got) != "" { + t.Fatalf("Legend() must be empty without named series, got %q", got) + } +} + +func TestLineChart_LegendBox(t *testing.T) { + lc := pulse.New(20, 8, pulse.WithRange(0, 100)) + lc.PushSeries("cpu", 12.5) + lc.PushSeries("mem", 45) + box := lc.LegendBox() + if strings.TrimSpace(box) == "" { + t.Fatal("LegendBox() is empty") + } + for _, want := range []string{"cpu", "12.5", "mem", "45", "╭", "╰"} { + if !strings.Contains(box, want) { + t.Fatalf("LegendBox() must contain %q, got:\n%s", want, box) + } + } +} + +func TestLineChart_LegendBoxEmptyWithoutNamedSeries(t *testing.T) { + lc := pulse.New(10, 5, pulse.WithRange(0, 100)) + lc.Push(50) + if got := lc.LegendBox(); strings.TrimSpace(got) != "" { + t.Fatalf("LegendBox() must be empty without named series, got %q", got) + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..8d95b8a --- /dev/null +++ b/options.go @@ -0,0 +1,98 @@ +package pulse + +import ( + "image/color" + + "charm.land/lipgloss/v2" +) + +// Option configures a Model. +type Option func(*Model) + +// WithRange sets the fixed Y range. For example, WithRange(0, 100) for CPU/RAM. +func WithRange(min, max float64) Option { + return func(m *Model) { m.SetRange(min, max) } +} + +// WithLineStyle sets the default line style. +func WithLineStyle(s lipgloss.Style) Option { + return func(m *Model) { m.SetLineStyle(s) } +} + +// WithAxisStyle sets the axes and labels style. +func WithAxisStyle(s lipgloss.Style) Option { + return func(m *Model) { m.SetAxisStyle(s) } +} + +// WithSeriesStyle sets the style for a named series. +func WithSeriesStyle(name string, s lipgloss.Style) Option { + return func(m *Model) { m.SetSeriesStyle(name, s) } +} + +// WithGrid enables or disables gridlines at tick positions. +func WithGrid(on bool) Option { + return func(m *Model) { m.SetGrid(on) } +} + +// WithFill enables or disables area fill under the line. +func WithFill(on bool) Option { + return func(m *Model) { m.SetFill(on) } +} + +// WithTintedFill enables or disables tinted background fill under the line, +// seamlessly bridging the gap between box-drawing characters and area fill. +func WithTintedFill(on bool) Option { + return func(m *Model) { m.SetTintedFill(on) } +} + +// WithSolidFill enables or disables solid background fill (spaces with background color) +// instead of stippled glyphs (░). +func WithSolidFill(on bool) Option { + return func(m *Model) { m.SetSolidFill(on) } +} + +// WithTintColor sets a custom background tint color for tinted fill mode. +// If nil, DefaultTintColor (#1f2335) is used. +func WithTintColor(c color.Color) Option { + return func(m *Model) { m.SetTintColor(c) } +} + +// WithEvent registers an initial timeline event marker at a historical offset (0 = newest point). +func WithEvent(offset int, ev Event) Option { + return func(m *Model) { m.AddEventAt(offset, ev) } +} + +// WithSmooth enables or disables line smoothing (rounded corners or spline). +func WithSmooth(on bool) Option { + return func(m *Model) { m.SetSmooth(on) } +} + +// WithLineWidth sets line width: 1 thin, 2 bold. +func WithLineWidth(w int) Option { + return func(m *Model) { m.SetLineWidth(w) } +} + +// WithMode sets rendering mode (ModeLines or ModeBraille). +func WithMode(mode RenderMode) Option { + return func(m *Model) { m.SetMode(mode) } +} + +// WithTicks sets explicit Y-axis tick values (e.g. 0, 25, 50, 75, 100 or 0, 50, 100). +func WithTicks(ticks ...float64) Option { + return func(m *Model) { m.SetTicks(ticks...) } +} + +// WithLabelFormatter sets a custom formatter for Y-axis tick values. +func WithLabelFormatter(fn func(float64) string) Option { + return func(m *Model) { m.SetLabelFormatter(fn) } +} + +// WithLabelWidth sets an explicit width for Y-axis labels. +func WithLabelWidth(w int) Option { + return func(m *Model) { m.SetLabelWidth(w) } +} + +// WithRenderer sets a custom chart renderer. +func WithRenderer(r Renderer) Option { + return func(m *Model) { m.SetRenderer(r) } +} diff --git a/pulse.go b/pulse.go index 8d3a18d..f8b584d 100644 --- a/pulse.go +++ b/pulse.go @@ -1,100 +1,13 @@ package pulse import ( - "fmt" "image/color" - "math" "slices" - "strings" "charm.land/lipgloss/v2" - - "github.com/ingvarch/pulse/scale" ) -// RenderMode defines how line curves are rendered on the chart. -type RenderMode int - -const ( - // ModeLines renders smooth continuous lines (box-drawing ╭, ╮, ╯, ╰, ─, │) with Grafana-style area fill ░. - ModeLines RenderMode = iota - // ModeBraille renders using a 2x4 dot braille matrix (Unicode Braille Patterns). - ModeBraille - // ModeCustom represents a custom user-supplied Renderer. - ModeCustom -) - -// Event represents a discrete timeline annotation or event marker. -type Event struct { - ID string // Optional identifier for programmatic lookup or removal - Label string // Human-readable description (e.g. "Deploy v1.4.2") - Glyph string // Marker glyph (e.g. "â–Œ", "🚀", "▲", "◆", "!", "⚠"). Defaults to "â–Œ". - Style lipgloss.Style // Style for the marker glyph and vertical guideline - NoLine bool // If true, omits the vertical guideline across the chart -} - -// VisibleEvent pairs an Event with its current visible column in the chart window. -type VisibleEvent struct { - Col int // Column index in current plot area (0 to Width()-1) - Age int // How many time steps ago this event occurred - Event Event // The event metadata -} - -// Chart provides the read-only contract for inspecting chart dimensions, data, and styles. -type Chart interface { - Width() int - Height() int - Min() float64 - Max() float64 - Fill() bool - TintedFill() bool - SolidFill() bool - TintColor() color.Color - Grid() bool - Smooth() bool - LineWidth() int - AxisStyle() lipgloss.Style - LineStyle() lipgloss.Style - SeriesStyle(name string) lipgloss.Style - SeriesNames() []string - SeriesData(name string) []float64 - VisibleEvents() []VisibleEvent -} - -// RenderContext contains prepared axis, tick, label, and style metadata passed to a Renderer. -type RenderContext struct { - LabelWidth int - LabelStyle lipgloss.Style - RowLabel map[int]string - HGrid []bool - VGrid []bool - Names []string - Styles []lipgloss.Style - GridStyle lipgloss.Style -} - -// Renderer defines how chart series data and axes are rendered into a string. -// Custom renderers only need the Chart contract: build one with New and Push -// in tests, no mocks required. -type Renderer interface { - Render(c Chart, ctx RenderContext) string -} - -// LinesRenderer renders smooth box-drawing lines with optional area fill. -type LinesRenderer struct{} - -// BrailleRenderer renders 2x4 dot Braille patterns. -type BrailleRenderer struct{} - -var ( - _ Renderer = LinesRenderer{} - _ Renderer = BrailleRenderer{} - _ Chart = (*Model)(nil) -) - -// DefaultTintColor is the default background tint used when TintedFill is active -// and no custom tint color or style background is specified. Defaults to Tokyo Night slate (#1f2335). -var DefaultTintColor color.Color = lipgloss.Color("#1f2335") +var _ Chart = (*Model)(nil) // Model is a streaming terminal line chart. type Model struct { @@ -125,108 +38,12 @@ type Model struct { events []eventRecord } -type eventRecord struct { - event Event - step int -} - type series struct { data []float64 style lipgloss.Style styled bool } -// Option configures a Model. -type Option func(*Model) - -// WithRange sets the fixed Y range. For example, WithRange(0, 100) for CPU/RAM. -func WithRange(min, max float64) Option { - return func(m *Model) { m.SetRange(min, max) } -} - -// WithLineStyle sets the default line style. -func WithLineStyle(s lipgloss.Style) Option { - return func(m *Model) { m.SetLineStyle(s) } -} - -// WithAxisStyle sets the axes and labels style. -func WithAxisStyle(s lipgloss.Style) Option { - return func(m *Model) { m.SetAxisStyle(s) } -} - -// WithSeriesStyle sets the style for a named series. -func WithSeriesStyle(name string, s lipgloss.Style) Option { - return func(m *Model) { m.SetSeriesStyle(name, s) } -} - -// WithGrid enables or disables gridlines at tick positions. -func WithGrid(on bool) Option { - return func(m *Model) { m.SetGrid(on) } -} - -// WithFill enables or disables area fill under the line. -func WithFill(on bool) Option { - return func(m *Model) { m.SetFill(on) } -} - -// WithTintedFill enables or disables tinted background fill under the line, -// seamlessly bridging the gap between box-drawing characters and area fill. -func WithTintedFill(on bool) Option { - return func(m *Model) { m.SetTintedFill(on) } -} - -// WithSolidFill enables or disables solid background fill (spaces with background color) -// instead of stippled glyphs (░). -func WithSolidFill(on bool) Option { - return func(m *Model) { m.SetSolidFill(on) } -} - -// WithTintColor sets a custom background tint color for tinted fill mode. -// If nil, DefaultTintColor (#1f2335) is used. -func WithTintColor(c color.Color) Option { - return func(m *Model) { m.SetTintColor(c) } -} - -// WithEvent registers an initial timeline event marker at a historical offset (0 = newest point). -func WithEvent(offset int, ev Event) Option { - return func(m *Model) { m.AddEventAt(offset, ev) } -} - -// WithSmooth enables or disables line smoothing (rounded corners or spline). -func WithSmooth(on bool) Option { - return func(m *Model) { m.SetSmooth(on) } -} - -// WithLineWidth sets line width: 1 thin, 2 bold. -func WithLineWidth(w int) Option { - return func(m *Model) { m.SetLineWidth(w) } -} - -// WithMode sets rendering mode (ModeLines or ModeBraille). -func WithMode(mode RenderMode) Option { - return func(m *Model) { m.SetMode(mode) } -} - -// WithTicks sets explicit Y-axis tick values (e.g. 0, 25, 50, 75, 100 or 0, 50, 100). -func WithTicks(ticks ...float64) Option { - return func(m *Model) { m.SetTicks(ticks...) } -} - -// WithLabelFormatter sets a custom formatter for Y-axis tick values. -func WithLabelFormatter(fn func(float64) string) Option { - return func(m *Model) { m.SetLabelFormatter(fn) } -} - -// WithLabelWidth sets an explicit width for Y-axis labels. -func WithLabelWidth(w int) Option { - return func(m *Model) { m.SetLabelWidth(w) } -} - -// WithRenderer sets a custom chart renderer. -func WithRenderer(r Renderer) Option { - return func(m *Model) { m.SetRenderer(r) } -} - // New creates a chart for a w x h cells plot area. func New(w, h int, opts ...Option) *Model { if w < 1 { @@ -253,6 +70,9 @@ func New(w, h int, opts ...Option) *Model { for _, o := range opts { o(m) } + if m.min > m.max { + m.min, m.max = m.max, m.min + } if m.max == m.min { m.max = m.min + 1 } @@ -302,6 +122,9 @@ func (m *Model) LenSeries(name string) int { // SetRange dynamically sets the Y-axis range. func (m *Model) SetRange(min, max float64) { + if min > max { + min, max = max, min + } if min == max { max = min + 1 } @@ -485,16 +308,6 @@ func (m *Model) SeriesStyle(name string) lipgloss.Style { return m.lineStyle } -// Legend returns a formatted legend for named series, or empty string if none exist. -func (m *Model) Legend() string { - parts := make([]string, 0, len(m.order)) - for _, name := range m.order { - swatch := m.SeriesStyle(name).Render("■") - parts = append(parts, swatch+" "+name) - } - return strings.Join(parts, " ") -} - // Last returns the most recent value of a named series. func (m *Model) Last(name string) (float64, bool) { s, ok := m.series[name] @@ -503,803 +316,3 @@ func (m *Model) Last(name string) (float64, bool) { } return s.data[len(s.data)-1], true } - -func (m *Model) formatValue(v float64) string { - if m.labelFormatter != nil { - return m.labelFormatter(v) - } - return scale.FormatTick(v) -} - -// LegendBox returns a boxed legend with borders, swatches, series names, and recent values. -func (m *Model) LegendBox() string { - if len(m.order) == 0 { - return "" - } - lines := make([]string, 0, len(m.order)) - for _, name := range m.order { - swatch := m.SeriesStyle(name).Render("■") - value := "" - if v, ok := m.Last(name); ok { - value = m.formatValue(v) - } - lines = append(lines, swatch+" "+m.axisStyle.Render(name+" "+value)) - } - return lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - Padding(0, 1). - Render(strings.Join(lines, "\n")) -} - -// AddEvent records an event at the current newest point on the timeline. -// As new points are pushed, the event moves left across the window. -func (m *Model) AddEvent(ev Event) { - m.AddEventAt(0, ev) -} - -// AddEventAt records an event at a historical offset (0 = newest point, 10 = 10 points ago). -func (m *Model) AddEventAt(offset int, ev Event) { - if offset < 0 { - offset = 0 - } - m.events = append(m.events, eventRecord{ - event: ev, - step: m.step - offset, - }) -} - -// ClearEvents removes all timeline events from the chart. -func (m *Model) ClearEvents() { - m.events = nil -} - -// VisibleEvents returns all timeline events currently visible within the chart window. -func (m *Model) VisibleEvents() []VisibleEvent { - if len(m.events) == 0 { - return nil - } - w := m.w - out := make([]VisibleEvent, 0, len(m.events)) - pruned := make([]eventRecord, 0, len(m.events)) - for _, rec := range m.events { - age := m.step - rec.step - col := (w - 1) - age - if col >= 0 && col < w { - out = append(out, VisibleEvent{ - Col: col, - Age: age, - Event: rec.event, - }) - } - if col >= 0 { - pruned = append(pruned, rec) - } - } - m.events = pruned - return out -} - -// EventsBox returns a boxed card listing all currently visible timeline events. -// Returns an empty string if there are no visible events. -func (m *Model) EventsBox() string { - events := m.VisibleEvents() - if len(events) == 0 { - return "" - } - lines := make([]string, 0, len(events)) - for _, ve := range events { - glyph := ve.Event.Glyph - if glyph == "" { - glyph = "â–Œ" - } - st := ve.Event.Style - if st.GetForeground() == nil || st.GetForeground() == (lipgloss.NoColor{}) { - st = st.Foreground(lipgloss.Color("#7dcfff")).Bold(true) - } - badge := st.Render(glyph) - label := ve.Event.Label - if label == "" { - label = "Event" - } - timeAgo := fmt.Sprintf("(-%d)", ve.Age) - if ve.Age == 0 { - timeAgo = "(now)" - } - line := badge + " " + m.axisStyle.Render(label) + " " + m.axisStyle.Faint(true).Render(timeAgo) - lines = append(lines, line) - } - return lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - Padding(0, 1). - Render(strings.Join(lines, "\n")) -} - -var brailleBits = [4][2]uint8{ - {0x01, 0x08}, - {0x02, 0x10}, - {0x04, 0x20}, - {0x40, 0x80}, -} - -// dotCellOf maps a 2w x 4h coordinate into a Braille cell and its internal bit. -func dotCellOf(w, h, xd, yd int) (row, col int, bit uint8, ok bool) { - dw, dh := w*2, h*4 - if xd < 0 || yd < 0 || xd >= dw || yd >= dh { - return 0, 0, 0, false - } - rowFromBottom := yd / 4 - dy := yd % 4 - cx, dx := xd/2, xd%2 - return h - 1 - rowFromBottom, cx, brailleBits[dy][dx], true -} - -func plotDots(grid [][]uint8, owner [][]int, idx, lineWidth, w, h, xd, yd int) { - pts := [][2]int{{xd, yd}} - if lineWidth > 1 { - pts = append(pts, [2]int{xd + 1, yd}, [2]int{xd, yd + 1}, [2]int{xd + 1, yd + 1}) - } - for _, p := range pts { - row, col, bit, ok := dotCellOf(w, h, p[0], p[1]) - if !ok { - continue - } - grid[row][col] |= bit - owner[row][col] = idx - } -} - -func spanOf(min, max float64) float64 { - if max == min { - return 1 - } - return max - min -} - -func normOf(min, max, v float64) float64 { - norm := (v - min) / spanOf(min, max) - if norm < 0 { - return 0 - } - if norm > 1 { - return 1 - } - return norm -} - -func dotHOf(h int) int { - if dh := h*4 - 1; dh > 0 { - return dh - } - return 0 -} - -// dotYOf maps a value to dot row offset from the bottom (for Braille). -func dotYOf(h int, min, max, v float64) int { - return int(math.Round(normOf(min, max, v) * float64(dotHOf(h)))) -} - -// tickRowOf returns the terminal row index (0 at top to h-1 at bottom) for value v. -func tickRowOf(h int, min, max, v float64) int { - return int(math.Round(float64(h-1) * (1.0 - normOf(min, max, v)))) -} - -// tickRow returns the terminal row index (0 at top to h-1 at bottom) for value v. -func (m *Model) tickRow(v float64) int { - return tickRowOf(m.h, m.min, m.max, v) -} - -// maxGridTicks calculates an appropriate grid tick count based on chart height. -func (m *Model) maxGridTicks() int { - ticks := (m.h - 1) / 3 - if ticks < 2 { - return 2 - } - if ticks > 5 { - return 5 - } - return ticks -} - -// gridTicks returns adaptive grid ticks or user-specified custom ticks. -func (m *Model) gridTicks() []float64 { - if len(m.customTicks) > 0 { - return m.customTicks - } - return scale.NiceTicks(m.min, m.max, m.maxGridTicks()) -} - -type dot struct{ x, y int } - -// catmullRom computes a Catmull-Rom spline value for segment p1-p2 with neighbors p0, p3. -func catmullRom(p0, p1, p2, p3, t float64) float64 { - t2 := t * t - t3 := t2 * t - return 0.5 * ((2 * p1) + (-p0+p2)*t + (2*p0-5*p1+4*p2-p3)*t2 + (-p0+3*p1-3*p2+p3)*t3) -} - -func clampDotOf(w, h, xd, yd int) (int, int) { - maxX := w*2 - 1 - if maxX < 0 { - maxX = 0 - } - if xd < 0 { - xd = 0 - } - if xd > maxX { - xd = maxX - } - dh := dotHOf(h) - if yd < 0 { - yd = 0 - } - if yd > dh { - yd = dh - } - return xd, yd -} - -// sampleLineOf interpolates the polyline into dots: Catmull-Rom spline if smooth, otherwise line segments. -func sampleLineOf(w, h int, smooth bool, xs, ys []int) []dot { - out := []dot{{xs[0], ys[0]}} - n := len(xs) - for i := 0; i < n-1; i++ { - x0, y0, x1, y1 := float64(xs[i]), float64(ys[i]), float64(xs[i+1]), float64(ys[i+1]) - var px0, py0, px3, py3 float64 - if i == 0 { - px0, py0 = x0, y0 - } else { - px0, py0 = float64(xs[i-1]), float64(ys[i-1]) - } - if i+2 >= n { - px3, py3 = x1, y1 - } else { - px3, py3 = float64(xs[i+2]), float64(ys[i+2]) - } - dx, dy := x1-x0, y1-y0 - steps := 1 - if smooth { - steps = int((math.Abs(dx) + math.Abs(dy)) * 2) - if steps < 8 { - steps = 8 - } - if steps > 128 { - steps = 128 - } - } else { - steps = int(math.Abs(dx)) - if a := int(math.Abs(dy)); a > steps { - steps = a - } - if steps < 1 { - steps = 1 - } - } - for s := 1; s <= steps; s++ { - t := float64(s) / float64(steps) - var fx, fy float64 - if smooth { - fx = catmullRom(px0, x0, x1, px3, t) - fy = catmullRom(py0, y0, y1, py3, t) - } else { - fx = x0 + dx*t - fy = y0 + dy*t - } - xd, yd := clampDotOf(w, h, int(math.Round(fx)), int(math.Round(fy))) - out = append(out, dot{xd, yd}) - } - } - return out -} - -func plotSeriesBraille(c Chart, grid [][]uint8, owner [][]int, idx int, data []float64) []int { - w, h := c.Width(), c.Height() - min, max := c.Min(), c.Max() - colTop := make([]int, w*2) - for i := range colTop { - colTop[i] = -1 - } - n := len(data) - if n == 0 { - return colTop - } - window := w - if window < 1 { - window = 1 - } - dw := w*2 - 1 - if dw < 0 { - dw = 0 - } - den := window - 1 - if den < 1 { - den = 1 - } - xs := make([]int, n) - ys := make([]int, n) - for j, v := range data { - xd := (window - n + j) * dw / den - xs[j], ys[j] = xd, dotYOf(h, min, max, v) - } - top := func(xd, yd int) { - if xd >= 0 && xd < len(colTop) && yd > colTop[xd] { - colTop[xd] = yd - } - } - for _, d := range sampleLineOf(w, h, c.Smooth(), xs, ys) { - plotDots(grid, owner, idx, c.LineWidth(), w, h, d.x, d.y) - top(d.x, d.y) - } - return colTop -} - -const ( - armUp = 1 - armRight = 2 - armDown = 4 - armLeft = 8 -) - -type lineGlyphs struct { - vert, horiz rune - dr, dl, ur, ul rune - tr, tl, td, tu rune - cross rune -} - -var ( - glyphsThinRounded = lineGlyphs{ - vert: '│', horiz: '─', - dr: '╭', dl: '╮', ur: '╰', ul: '╯', - tr: '├', tl: '─', td: '┬', tu: '┮', - cross: '┌', - } - glyphsThinSharp = lineGlyphs{ - vert: '│', horiz: '─', - dr: '┌', dl: '┐', ur: '└', ul: '┘', - tr: '├', tl: '─', td: '┬', tu: '┮', - cross: '┌', - } - glyphsBoldRounded = lineGlyphs{ - vert: '┃', horiz: '━', - dr: '┏', dl: '┓', ur: '┗', ul: '┛', - tr: '┣', tl: '┫', td: '┳', tu: '┻', - cross: '╋', - } - glyphsBoldSharp = lineGlyphs{ - vert: '┃', horiz: '━', - dr: '┌', dl: '┐', ur: '└', ul: '┘', - tr: '├', tl: '─', td: '┬', tu: '┮', - cross: '┌', - } -) - -func glyphsFor(lineWidth int, smooth bool) lineGlyphs { - if lineWidth > 1 { - if !smooth { - return glyphsBoldSharp - } - return glyphsBoldRounded - } - if smooth { - return glyphsThinRounded - } - return glyphsThinSharp -} - -func runeForMask(lineWidth int, smooth bool, mask uint8) rune { - g := glyphsFor(lineWidth, smooth) - switch mask { - case armUp, armDown, armUp | armDown: - return g.vert - case armLeft, armRight, armLeft | armRight: - return g.horiz - case armDown | armRight: - return g.dr - case armDown | armLeft: - return g.dl - case armUp | armRight: - return g.ur - case armUp | armLeft: - return g.ul - case armUp | armDown | armRight: - return g.tr - case armUp | armDown | armLeft: - return g.tl - case armDown | armLeft | armRight: - return g.td - case armUp | armLeft | armRight: - return g.tu - case armUp | armDown | armLeft | armRight: - return g.cross - default: - return g.horiz - } -} - -// plotSeriesLines renders a smooth continuous box-drawing line and shaded area fill ░. -func plotSeriesLines(c Chart, lineMask [][]uint8, lineOwner [][]int, fillMask [][]bool, fillOwner [][]int, idx int, data []float64) { - w, h := c.Width(), c.Height() - min, max := c.Min(), c.Max() - n := len(data) - if n == 0 { - return - } - window := w - startCol := window - n - if startCol < 0 { - startCol = 0 - data = data[n-window:] - n = window - } - - ys := make([]int, window) - for j, v := range data { - c := startCol + j - ys[c] = tickRowOf(h, min, max, v) - } - - for c := startCol + 1; c < window; c++ { - y0 := ys[c-1] - y1 := ys[c] - - // Horizontal segment at row y0 between col c-1 and col c - lineMask[y0][c-1] |= armRight - lineOwner[y0][c-1] = idx - - lineMask[y0][c] |= armLeft - lineOwner[y0][c] = idx - - // Vertical transition in col c between y0 and y1 - if y0 > y1 { // line goes upwards (row index decreases) - for y := y1; y < y0; y++ { - lineMask[y][c] |= armDown - lineOwner[y][c] = idx - - lineMask[y+1][c] |= armUp - lineOwner[y+1][c] = idx - } - } else if y0 < y1 { // line goes downwards (row index increases) - for y := y0; y < y1; y++ { - lineMask[y][c] |= armDown - lineOwner[y][c] = idx - - lineMask[y+1][c] |= armUp - lineOwner[y+1][c] = idx - } - } - } - - // Terminate endpoints - if n == 1 { - lineMask[ys[startCol]][startCol] |= (armLeft | armRight) - lineOwner[ys[startCol]][startCol] = idx - } else if ys[startCol] >= 0 && ys[startCol] < h { - lineMask[ys[startCol]][startCol] |= armLeft - lineOwner[ys[startCol]][startCol] = idx - } - - // Baseline for area fill (0 by default) - baseRow := tickRowOf(h, min, max, 0) - - // Shaded area fill ░ below the line (strictly beneath the line's lower boundary in each column) - if c.Fill() { - for c := startCol; c < window; c++ { - bottomY := -1 - for r := h - 1; r >= 0; r-- { - if lineOwner[r][c] == idx { - bottomY = r - break - } - } - if bottomY < 0 { - bottomY = ys[c] - } - lo, hi := bottomY+1, baseRow - if lo > hi { - lo, hi = baseRow, bottomY-1 - } - for y := lo; y <= hi && y < h; y++ { - if y >= 0 { - fillMask[y][c] = true - fillOwner[y][c] = idx - } - } - } - } -} - -func styleFor(owner int, styles []lipgloss.Style, fallback lipgloss.Style) lipgloss.Style { - if owner >= 0 && owner < len(styles) { - return styles[owner] - } - return fallback -} - -func faintStyleFor(owner int, styles []lipgloss.Style, fallback lipgloss.Style) lipgloss.Style { - return styleFor(owner, styles, fallback).Faint(true) -} - -func gridCell(h, v bool) string { - switch { - case h && v: - return "┌" - case h: - return "─" - default: - return "│" - } -} - -func makeCells[T any](h, w int, fill T) [][]T { - cells := make([][]T, h) - for i := range cells { - cells[i] = make([]T, w) - for j := range cells[i] { - cells[i][j] = fill - } - } - return cells -} - -// View renders the chart: Y-axis labels + axis border + plot area. -func (m *Model) View() string { - ticks := m.gridTicks() - rowLabel := map[int]string{} - for i := len(ticks) - 1; i >= 0; i-- { - t := ticks[i] - if t < m.min || t > m.max { - continue - } - row := m.tickRow(t) - if _, taken := rowLabel[row]; !taken { - rowLabel[row] = m.formatValue(t) - } - } - - labelWidth := m.labelWidth - if labelWidth <= 0 { - labelWidth = 4 - for _, lbl := range rowLabel { - if w := lipgloss.Width(lbl); w > labelWidth { - labelWidth = w - } - } - } - labelStyle := m.axisStyle.Width(labelWidth).Align(lipgloss.Right) - - hGrid := make([]bool, m.h) - vGrid := make([]bool, m.w) - if m.grid { - for r := range hGrid { - hGrid[r] = true - } - for k := 1; k < 4; k++ { - if col := k * m.w / 4; col < m.w { - vGrid[col] = true - } - } - } - - names := make([]string, 0, len(m.order)+1) - names = append(names, "") - names = append(names, m.order...) - styles := make([]lipgloss.Style, len(names)) - for idx, name := range names { - styles[idx] = m.SeriesStyle(name) - } - - gridStyle := m.axisStyle.Faint(true) - ctx := RenderContext{ - LabelWidth: labelWidth, - LabelStyle: labelStyle, - RowLabel: rowLabel, - HGrid: hGrid, - VGrid: vGrid, - Names: names, - Styles: styles, - GridStyle: gridStyle, - } - - r := m.renderer - if r == nil { - r = LinesRenderer{} - } - return r.Render(m, ctx) -} - -// String implements fmt.Stringer, returning the rendered chart View(). -func (m *Model) String() string { - return m.View() -} - -func renderGrid(c Chart, ctx RenderContext, cellAt func(r, c int) (string, bool)) string { - w, h := c.Width(), c.Height() - axis := c.AxisStyle().Render("│") - var sb strings.Builder - for r := 0; r < h; r++ { - sb.WriteString(ctx.LabelStyle.Render(ctx.RowLabel[r])) - sb.WriteString(axis) - for col := 0; col < w; col++ { - if s, ok := cellAt(r, col); ok { - sb.WriteString(s) - if sw := lipgloss.Width(s); sw > 1 { - col += sw - 1 - } - continue - } - if ctx.HGrid[r] || ctx.VGrid[col] { - sb.WriteString(ctx.GridStyle.Render(gridCell(ctx.HGrid[r], ctx.VGrid[col]))) - continue - } - sb.WriteByte(' ') - } - if r < h-1 { - sb.WriteByte('\n') - } - } - return sb.String() -} - -func tintColorFor(c Chart, st lipgloss.Style) color.Color { - if tc := c.TintColor(); tc != nil && tc != (lipgloss.NoColor{}) { - return tc - } - if bg := st.GetBackground(); bg != nil && bg != (lipgloss.NoColor{}) { - return bg - } - return DefaultTintColor -} - -// Render implements Renderer for LinesRenderer. -func (LinesRenderer) Render(c Chart, ctx RenderContext) string { - w, h := c.Width(), c.Height() - lineMask := makeCells(h, w, uint8(0)) - lineOwner := makeCells(h, w, -1) - fillMask := makeCells(h, w, false) - fillOwner := makeCells(h, w, -1) - - for idx, name := range ctx.Names { - plotSeriesLines(c, lineMask, lineOwner, fillMask, fillOwner, idx, c.SeriesData(name)) - } - - visibleEvents := c.VisibleEvents() - eventMap := make(map[int]VisibleEvent, len(visibleEvents)) - for _, ve := range visibleEvents { - eventMap[ve.Col] = ve - } - - fallback, glyphWidth, glyphSmooth := c.LineStyle(), c.LineWidth(), c.Smooth() - tinted := c.TintedFill() - solid := c.SolidFill() - return renderGrid(c, ctx, func(r, col int) (string, bool) { - ve, hasEvent := eventMap[col] - if hasEvent && r == 0 { - glyph := ve.Event.Glyph - if glyph == "" { - glyph = "â–Œ" - } - st := ve.Event.Style - if st.GetForeground() == nil || st.GetForeground() == (lipgloss.NoColor{}) { - st = st.Foreground(lipgloss.Color("#7dcfff")).Bold(true) - } - return st.Render(glyph), true - } - if lineMask[r][col] != 0 { - st := styleFor(lineOwner[r][col], ctx.Styles, fallback) - if tinted { - st = st.Background(tintColorFor(c, st)) - } - ru := runeForMask(glyphWidth, glyphSmooth, lineMask[r][col]) - return st.Render(string(ru)), true - } - if hasEvent && !ve.Event.NoLine { - guideStyle := ve.Event.Style - if guideStyle.GetForeground() == nil || guideStyle.GetForeground() == (lipgloss.NoColor{}) { - guideStyle = guideStyle.Foreground(lipgloss.Color("#7dcfff")).Faint(true) - } - if fillMask[r][col] && tinted { - baseStyle := styleFor(fillOwner[r][col], ctx.Styles, fallback) - bg := tintColorFor(c, baseStyle) - return guideStyle.Background(bg).Render("┆"), true - } - return guideStyle.Render("┆"), true - } - if fillMask[r][col] { - owner := fillOwner[r][col] - st := faintStyleFor(owner, ctx.Styles, fallback) - if tinted { - baseStyle := styleFor(owner, ctx.Styles, fallback) - bg := tintColorFor(c, baseStyle) - if solid { - return lipgloss.NewStyle().Background(bg).Render(" "), true - } - return st.Background(bg).Render("░"), true - } - if solid { - return " ", true - } - return st.Render("░"), true - } - return "", false - }) -} - -// Render implements Renderer for BrailleRenderer. -func (BrailleRenderer) Render(c Chart, ctx RenderContext) string { - w, h := c.Width(), c.Height() - grid := makeCells(h, w, uint8(0)) - owner := makeCells(h, w, -1) - fillMask := makeCells(h, w, uint8(0)) - fillOwner := makeCells(h, w, -1) - - base := dotYOf(h, c.Min(), c.Max(), 0) - for idx, name := range ctx.Names { - colTop := plotSeriesBraille(c, grid, owner, idx, c.SeriesData(name)) - if !c.Fill() { - continue - } - for xd, top := range colTop { - for yd := base; yd < top; yd++ { - if xd%2 != 0 || yd%2 != 0 { - continue - } - row, col, bit, ok := dotCellOf(w, h, xd, yd) - if !ok { - continue - } - fillMask[row][col] |= bit - fillOwner[row][col] = idx - } - } - } - - visibleEvents := c.VisibleEvents() - eventMap := make(map[int]VisibleEvent, len(visibleEvents)) - for _, ve := range visibleEvents { - eventMap[ve.Col] = ve - } - - fallback := c.LineStyle() - tinted := c.TintedFill() - return renderGrid(c, ctx, func(r, col int) (string, bool) { - ve, hasEvent := eventMap[col] - if hasEvent && r == 0 { - glyph := ve.Event.Glyph - if glyph == "" { - glyph = "â–Œ" - } - st := ve.Event.Style - if st.GetForeground() == nil || st.GetForeground() == (lipgloss.NoColor{}) { - st = st.Foreground(lipgloss.Color("#7dcfff")).Bold(true) - } - return st.Render(glyph), true - } - lineDots := grid[r][col] - fillDots := fillMask[r][col] - dots := lineDots | fillDots - if dots != 0 { - if lineDots != 0 { - st := styleFor(owner[r][col], ctx.Styles, fallback) - if tinted { - st = st.Background(tintColorFor(c, st)) - } - return st.Render(string(rune(0x2800 + int(dots)))), true - } - ownerIdx := fillOwner[r][col] - st := faintStyleFor(ownerIdx, ctx.Styles, fallback) - if tinted { - baseStyle := styleFor(ownerIdx, ctx.Styles, fallback) - st = st.Background(tintColorFor(c, baseStyle)) - } - return st.Render(string(rune(0x2800 + int(dots)))), true - } - if hasEvent && !ve.Event.NoLine { - guideStyle := ve.Event.Style - if guideStyle.GetForeground() == nil || guideStyle.GetForeground() == (lipgloss.NoColor{}) { - guideStyle = guideStyle.Foreground(lipgloss.Color("#7dcfff")).Faint(true) - } - return guideStyle.Render("┆"), true - } - return "", false - }) -} diff --git a/pulse_bench_test.go b/pulse_bench_test.go new file mode 100644 index 0000000..ae5fb10 --- /dev/null +++ b/pulse_bench_test.go @@ -0,0 +1,97 @@ +package pulse_test + +import ( + "math" + "testing" + + "charm.land/lipgloss/v2" + "github.com/ingvarch/pulse" +) + +func seedBenchmarkData(m *pulse.Model, count int) { + for i := 0; i < count; i++ { + v := 50.0 + 35.0*math.Sin(float64(i)*0.15) + m.Push(v) + } +} + +func BenchmarkView_ModeLines(b *testing.B) { + m := pulse.New(80, 20, + pulse.WithRange(0, 100), + pulse.WithMode(pulse.ModeLines), + pulse.WithFill(false), + ) + seedBenchmarkData(m, 80) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.View() + } +} + +func BenchmarkView_ModeLines_TintedFill(b *testing.B) { + m := pulse.New(80, 20, + pulse.WithRange(0, 100), + pulse.WithMode(pulse.ModeLines), + pulse.WithFill(true), + pulse.WithTintedFill(true), + ) + seedBenchmarkData(m, 80) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.View() + } +} + +func BenchmarkView_ModeBraille(b *testing.B) { + m := pulse.New(80, 20, + pulse.WithRange(0, 100), + pulse.WithMode(pulse.ModeBraille), + ) + seedBenchmarkData(m, 80) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.View() + } +} + +func BenchmarkModel_Push(b *testing.B) { + m := pulse.New(80, 20, pulse.WithRange(0, 100)) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.Push(float64(i % 100)) + } +} + +func BenchmarkModel_LegendBox(b *testing.B) { + m := pulse.New(80, 20, pulse.WithRange(0, 100)) + m.PushSeries("cpu", 75.5) + m.PushSeries("mem", 42.1) + m.PushSeries("net", 12.8) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.LegendBox() + } +} + +func BenchmarkModel_EventsBox(b *testing.B) { + m := pulse.New(80, 20, pulse.WithRange(0, 100)) + seedBenchmarkData(m, 80) + m.AddEventAt(10, pulse.Event{Label: "Deploy v1.4.2", Glyph: "🚀"}) + m.AddEventAt(30, pulse.Event{Label: "Traffic Spike", Glyph: "⚡", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#e0af68"))}) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.EventsBox() + } +} diff --git a/pulse_test.go b/pulse_test.go index e43bffc..b911c9b 100644 --- a/pulse_test.go +++ b/pulse_test.go @@ -6,100 +6,93 @@ import ( "testing" "charm.land/lipgloss/v2" - "github.com/ingvarch/pulse" ) -// CPU/RAM case: 0-100% values, width W window, rendered as terminal lines. -func TestLineChart_RendersCPUValues(t *testing.T) { - lc := pulse.New(20, 8, pulse.WithRange(0, 100)) +func seedCPU(lc *pulse.Model) { for _, v := range []float64{10, 30, 50, 70, 90, 70, 50, 30} { lc.Push(v) } +} + +func TestLineChart_RendersCPUValues(t *testing.T) { + lc := pulse.New(20, 8, pulse.WithRange(0, 100)) + seedCPU(lc) view := lc.View() if strings.TrimSpace(view) == "" { - t.Fatal("View() is empty") + t.Fatal("View() returned empty string") } - // At least one non-space plot rune (braille, lines, or blocks). - hasMark := false - for _, r := range view { - if r != ' ' && r != '\n' && r != '\r' && r != '│' && r != '─' && r != '┌' && r != '0' && r != '1' && r != '2' && r != '3' && r != '4' && r != '5' && r != '6' && r != '7' && r != '8' && r != '9' { - hasMark = true - break - } + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + if len(lines) != 8 { + t.Fatalf("expected 8 lines, got %d", len(lines)) } - if !hasMark { - t.Fatalf("View() has no chart marks:\n%s", view) + // Check Y-axis border exists on every line + for i, ln := range lines { + if !strings.Contains(ln, "│") { + t.Fatalf("line %d missing Y-axis border: %q", i, ln) + } } } func TestLineChart_EmptyRendersAxes(t *testing.T) { - lc := pulse.New(10, 5, pulse.WithRange(0, 100)) + lc := pulse.New(20, 8, pulse.WithRange(0, 100)) view := lc.View() lines := strings.Split(strings.TrimRight(view, "\n"), "\n") - if len(lines) != 5 { - t.Fatalf("expected 5 lines, got %d:\n%s", len(lines), view) + if len(lines) != 8 { + t.Fatalf("expected 8 lines, got %d", len(lines)) } } func TestLineChart_NoDuplicateAdjacentLabels(t *testing.T) { - lc := pulse.New(20, 10, pulse.WithRange(0, 100)) - lc.Push(50) - view := lc.View() - lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + lc := pulse.New(20, 6, pulse.WithRange(0, 100)) + lines := strings.Split(strings.TrimRight(lc.View(), "\n"), "\n") prev := "" for i, ln := range lines { - runes := []rune(ln) - if len(runes) < 4 { - t.Fatalf("line %d too short: %q", i, ln) + sep := strings.Index(ln, "│") + if sep < 0 { + continue } - label := strings.TrimSpace(string(runes[:4])) - if label != "" && label == prev { - t.Fatalf("duplicate adjacent label %q on lines %d and %d:\n%s", label, i-1, i, view) + lbl := strings.TrimSpace(ln[:sep]) + if lbl == "" { + prev = "" + continue } - if label != "" { - prev = label + if lbl == prev { + t.Fatalf("duplicate adjacent label %q at line %d", lbl, i) } + prev = lbl } } func TestLineChart_SlidingWindow(t *testing.T) { - lc := pulse.New(4, 4, pulse.WithRange(0, 100)) + lc := pulse.New(5, 5, pulse.WithRange(0, 100)) for i := 0; i < 10; i++ { lc.Push(float64(i * 10)) } - if got := lc.Len(); got != 4 { - t.Fatalf("Len() = %d, want 4 (window = width)", got) + if lc.Len() != 5 { + t.Fatalf("expected sliding window len 5, got %d", lc.Len()) } } func TestLineChart_SetLineStyle(t *testing.T) { - lc := pulse.New(10, 5, pulse.WithRange(0, 100)) + red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) + lc := pulse.New(10, 5, pulse.WithRange(0, 100), pulse.WithLineStyle(red)) lc.Push(50) - lc.SetLineStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000"))) - if view := lc.View(); strings.TrimSpace(view) == "" { - t.Fatal("View() is empty after SetLineStyle") + if lc.LineStyle().GetForeground() != red.GetForeground() { + t.Fatal("LineStyle() did not retain style set via option") } } func TestLineChart_MultiSeries(t *testing.T) { lc := pulse.New(20, 8, pulse.WithRange(0, 100)) - for i := 0; i < 10; i++ { - lc.PushSeries("cpu", float64(i*10)) - lc.PushSeries("mem", float64(100-i*5)) + lc.PushSeries("cpu", 50) + lc.PushSeries("mem", 75) + if lc.LenSeries("cpu") != 1 || lc.LenSeries("mem") != 1 { + t.Fatal("named series not tracked correctly") } - if got := lc.LenSeries("cpu"); got != 10 { - t.Fatalf("LenSeries(cpu) = %d, want 10", got) - } - if got := lc.LenSeries("mem"); got != 10 { - t.Fatalf("LenSeries(mem) = %d, want 10", got) - } - if got := lc.Len(); got != 0 { - t.Fatalf("Len() = %d, want 0 (nothing pushed to default series)", got) - } - view := lc.View() - if strings.TrimSpace(view) == "" { - t.Fatal("View() is empty") + names := lc.SeriesNames() + if len(names) != 2 || names[0] != "cpu" || names[1] != "mem" { + t.Fatalf("SeriesNames() = %v, want [cpu mem]", names) } legend := lc.Legend() if !strings.Contains(legend, "cpu") || !strings.Contains(legend, "mem") { @@ -107,14 +100,6 @@ func TestLineChart_MultiSeries(t *testing.T) { } } -func TestLineChart_LegendEmptyWithoutNamedSeries(t *testing.T) { - lc := pulse.New(10, 5, pulse.WithRange(0, 100)) - lc.Push(50) - if got := lc.Legend(); strings.TrimSpace(got) != "" { - t.Fatalf("Legend() must be empty without named series, got %q", got) - } -} - func TestLineChart_Last(t *testing.T) { lc := pulse.New(10, 5, pulse.WithRange(0, 100)) if _, ok := lc.Last("cpu"); ok { @@ -131,371 +116,75 @@ func TestLineChart_Last(t *testing.T) { } } -func TestLineChart_LegendBox(t *testing.T) { - lc := pulse.New(20, 8, pulse.WithRange(0, 100)) - lc.PushSeries("cpu", 12.5) - lc.PushSeries("mem", 45) - box := lc.LegendBox() - if strings.TrimSpace(box) == "" { - t.Fatal("LegendBox() is empty") - } - for _, want := range []string{"cpu", "12.5", "mem", "45", "╭", "╰"} { - if !strings.Contains(box, want) { - t.Fatalf("LegendBox() must contain %q, got:\n%s", want, box) - } - } -} - -func TestLineChart_LegendBoxEmptyWithoutNamedSeries(t *testing.T) { - lc := pulse.New(10, 5, pulse.WithRange(0, 100)) - lc.Push(50) - if got := lc.LegendBox(); strings.TrimSpace(got) != "" { - t.Fatalf("LegendBox() must be empty without named series, got %q", got) - } -} - -func seedCPU(lc *pulse.Model) { - for _, v := range []float64{10, 30, 50, 70, 90, 70, 50, 30} { - lc.Push(v) - } -} - -func TestLineChart_GridToggleChangesRender(t *testing.T) { - a := pulse.New(20, 8, pulse.WithRange(0, 100)) - seedCPU(a) - b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithGrid(false)) - seedCPU(b) - if a.View() == b.View() { - t.Fatal("View() with grid on and off must differ") - } -} - -func TestLineChart_FillToggleChangesRender(t *testing.T) { - red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) - a := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(red)) - seedCPU(a) - b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(red), pulse.WithFill(false)) - seedCPU(b) - if a.View() == b.View() { - t.Fatal("View() with fill on and off must differ") - } -} - -func TestLineChart_TintedFillToggleChangesRender(t *testing.T) { - cyan := lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")) - a := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true)) - seedCPU(a) - b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true), pulse.WithTintedFill(true)) - seedCPU(b) - - if !b.TintedFill() { - t.Fatal("expected TintedFill() to be true") - } - if a.View() == b.View() { - t.Fatal("View() with tinted fill on and off must differ") - } - - b.SetTintedFill(false) - if b.TintedFill() { - t.Fatal("expected TintedFill() to be false after SetTintedFill(false)") - } - if a.View() != b.View() { - t.Fatal("expected View() to match after disabling tinted fill") - } -} - -func TestLineChart_SolidFillToggleChangesRender(t *testing.T) { - cyan := lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")) - a := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true), pulse.WithTintedFill(true)) - seedCPU(a) - b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true), pulse.WithTintedFill(true), pulse.WithSolidFill(true)) - seedCPU(b) - - if !b.SolidFill() { - t.Fatal("expected SolidFill() to be true") - } - if a.View() == b.View() { - t.Fatal("View() with solid fill and textured fill must differ") - } - - b.SetSolidFill(false) - if b.SolidFill() { - t.Fatal("expected SolidFill() to be false after SetSolidFill(false)") - } - if a.View() != b.View() { - t.Fatal("expected View() to match after disabling solid fill") - } -} - -func TestLineChart_CustomTintColorChangesRender(t *testing.T) { - cyan := lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")) - a := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true), pulse.WithTintedFill(true)) - seedCPU(a) - - customTint := lipgloss.Color("#251c3d") - b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true), pulse.WithTintedFill(true), pulse.WithTintColor(customTint)) - seedCPU(b) - - if b.TintColor() != customTint { - t.Fatalf("expected TintColor() to be %v, got %v", customTint, b.TintColor()) - } - if a.View() == b.View() { - t.Fatal("View() with custom tint color and default tint color must differ") - } - - b.SetTintColor(nil) - if b.TintColor() != nil { - t.Fatal("expected TintColor() to be nil after SetTintColor(nil)") - } - if a.View() != b.View() { - t.Fatal("expected View() to match after resetting tint color to nil (default)") - } -} - -func TestLineChart_PlainRenderKeepsDimensions(t *testing.T) { - lc := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithGrid(false), pulse.WithFill(false)) - seedCPU(lc) - lines := strings.Split(strings.TrimRight(lc.View(), "\n"), "\n") - if len(lines) != 8 { - t.Fatalf("expected 8 lines, got %d", len(lines)) - } -} - -func TestLineChart_GridLinesExactAndEven(t *testing.T) { - lc := pulse.New(20, 8, pulse.WithRange(0, 100)) - rows := strings.Split(lc.View(), "\n") - plot := func(r int) string { - runes := []rune(rows[r]) - i := -1 - for j, ru := range runes { - if ru == '│' { - i = j - break - } - } - if i < 0 { - t.Fatalf("row %d has no axis", r) - } - return string(runes[i+1:]) - } - // Solid ruling: every row in the plot has no spaces. - for r := range rows { - for _, ru := range plot(r) { - if ru == ' ' { - t.Fatalf("grid row %d must be solid, got %q", r, plot(r)) - } - } - } - // Solid grid made of box characters. - for _, want := range []string{"─", "│", "┌"} { - if !strings.Contains(lc.View(), want) { - t.Fatalf("View() with grid must contain %q", want) - } - } - plain := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithGrid(false), pulse.WithFill(false)) - seedCPU(plain) - for _, ln := range strings.Split(plain.View(), "\n") { - if i := strings.Index(ln, "│"); i >= 0 { - ln = ln[i+len("│"):] - } - for _, bad := range []string{"─", "│", "┌"} { - if strings.Contains(ln, bad) { - t.Fatalf("plot without grid must not contain %q", bad) - } - } - } -} - -func TestLineChart_DenseGridIsEven(t *testing.T) { - lc := pulse.New(60, 12, pulse.WithRange(0, 100)) - rows := strings.Split(lc.View(), "\n") - if len(rows) != 12 { - t.Fatalf("expected 12 rows, got %d", len(rows)) - } - labelRows := []int{} - for r, ln := range rows { - runes := []rune(ln) - if strings.TrimSpace(string(runes[:4])) != "" { - labelRows = append(labelRows, r) - } - } - // Gaps between adjacent labels differ by at most 1 row. - for i := 1; i < len(labelRows); i++ { - gap := labelRows[i] - labelRows[i-1] - if gap < 2 || gap > 3 { - t.Fatalf("label rows %v have uneven gap %d", labelRows, gap) - } - } -} - -func TestLineChart_LineWidthToggleChangesRender(t *testing.T) { - a := pulse.New(20, 8, pulse.WithRange(0, 100)) - seedCPU(a) - b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineWidth(1)) - seedCPU(b) - if a.View() == b.View() { - t.Fatal("View() with default and thin line must differ") - } -} - -func TestLineChart_NamedSeriesLeftEndpointOwner(t *testing.T) { - blue := lipgloss.NewStyle().Foreground(lipgloss.Color("#0000ff")) - red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) - - // Case 1: Multi-point line. Start point at startCol must be red, not blue. - lc := pulse.New(20, 8, - pulse.WithLineWidth(1), - pulse.WithRange(0, 100), - pulse.WithLineStyle(blue), - pulse.WithSeriesStyle("alpha", red), - pulse.WithGrid(false), - pulse.WithFill(false), - ) - for _, v := range []float64{50, 60, 70} { - lc.PushSeries("alpha", v) - } - view := lc.View() - - // Default line style (blue) must NEVER appear anywhere in a chart with only named series "alpha" - if strings.Contains(view, blue.Render("─")) || strings.Contains(view, blue.Render("╭")) || strings.Contains(view, blue.Render("╰")) { - t.Fatalf("default blue style leaked into named series alpha:\n%s", view) - } - - lines := strings.Split(strings.TrimRight(view, "\n"), "\n") - // For 50% on height 8: row index = round(7 * 0.5) = 4 (counting from top). - // With 3 points in window of 20: startCol = 20 - 3 = 17. - sepIdx := strings.Index(lines[4], "│") - if sepIdx < 0 { - t.Fatalf("missing axis border in line 4: %q", lines[4]) - } - plotArea := lines[4][sepIdx+len("│"):] - if len(plotArea) < 17 { - t.Fatalf("plotArea too short: %q", plotArea) - } - // Columns 0..16 must be blank spaces - if plotArea[:17] != strings.Repeat(" ", 17) { - t.Fatalf("expected 17 leading spaces before startCol, got %q", plotArea[:17]) - } - // Column 17 (the starting cell) must strictly begin with red.Render("─") - if !strings.HasPrefix(plotArea[17:], red.Render("─")) { - t.Fatalf("starting cell at col 17 must be red.Render(\"─\"), got:\n%q", plotArea[17:]) - } - - // Case 2: Single-point line (n == 1). Must also be red, not blue. - single := pulse.New(10, 5, - pulse.WithLineWidth(1), - pulse.WithRange(0, 100), - pulse.WithLineStyle(blue), - pulse.WithSeriesStyle("alpha", red), - pulse.WithGrid(false), - pulse.WithFill(false), - ) - single.PushSeries("alpha", 50) - singleView := single.View() - if strings.Contains(singleView, blue.Render("─")) { - t.Fatalf("single point left endpoint must not use default blue style") - } - if !strings.Contains(singleView, red.Render("─")) { - t.Fatalf("single point left endpoint must use series red style") - } -} - -func TestLineChart_LabelFormatterAndWidth(t *testing.T) { - fmtFn := func(v float64) string { - return strings.Repeat(" ", 0) + string([]byte{byte('0' + int(v)/1000)}) + " GHz" - } - lc := pulse.New(15, 5, - pulse.WithRange(0, 4000), - pulse.WithTicks(0, 2000, 4000), - pulse.WithLabelFormatter(fmtFn), - pulse.WithLabelWidth(8), - ) - view := lc.View() - lines := strings.Split(strings.TrimRight(view, "\n"), "\n") - for i, ln := range lines { - runes := []rune(ln) - if len(runes) < 9 { - t.Fatalf("line %d too short: %q", i, ln) - } - if runes[8] != '│' { - t.Fatalf("expected '│' at index 8 for line %d, got %q (line: %q)", i, string(runes[8]), ln) - } - } - if !strings.Contains(view, "4 GHz") || !strings.Contains(view, "0 GHz") { - t.Fatalf("expected custom formatted labels in View(), got:\n%s", view) - } -} - func TestLineChart_Stringer(t *testing.T) { lc := pulse.New(20, 8, pulse.WithRange(0, 100)) - lc.Push(50) - var stringer fmt.Stringer = lc - if stringer.String() != lc.View() { - t.Fatalf("expected String() to match View()") + seedCPU(lc) + str := fmt.Sprint(lc) + if str != lc.View() { + t.Fatalf("fmt.Sprint(lc) must match lc.View()") } } -type testCustomRenderer struct{} +type staticRenderer struct{} -func (testCustomRenderer) Render(m pulse.Chart, ctx pulse.RenderContext) string { - return fmt.Sprintf("custom[%dx%d]:%d", m.Width(), m.Height(), len(ctx.Names)) +func (staticRenderer) Render(c pulse.Chart, ctx pulse.RenderContext) string { + return "STATIC_CHART" } func TestLineChart_CustomRenderer(t *testing.T) { - lc := pulse.New(30, 10, pulse.WithRenderer(testCustomRenderer{})) - lc.PushSeries("s1", 42) - got := lc.View() - want := "custom[30x10]:2" // "" default series + "s1" - if got != want { - t.Fatalf("expected custom renderer output %q, got %q", want, got) + lc := pulse.New(20, 8, pulse.WithRenderer(staticRenderer{})) + if got := lc.View(); got != "STATIC_CHART" { + t.Fatalf("expected custom renderer output, got %q", got) } } func TestLineChart_UnicodeLabelAlignment(t *testing.T) { tests := []struct { - name string - formatter func(float64) string + name string + formatter func(float64) string + labelWidth int + maxVal float64 }{ { - name: "Celsius", - formatter: func(v float64) string { - return fmt.Sprintf("%d°C", int(v)) - }, + name: "Celsius", + formatter: func(v float64) string { return fmt.Sprintf("%.0f°C", v) }, + labelWidth: 5, + maxVal: 100, }, { - name: "CJK", - formatter: func(v float64) string { - return fmt.Sprintf("%d床", int(v)) - }, + name: "CJK", + formatter: func(v float64) string { return fmt.Sprintf("%.0f床", v) }, + labelWidth: 5, + maxVal: 100, }, { - name: "Emoji", - formatter: func(v float64) string { - return fmt.Sprintf("%d🔥", int(v)) - }, + name: "Emoji", + formatter: func(v float64) string { return fmt.Sprintf("%.0f🌡", v) }, + labelWidth: 6, + maxVal: 100, }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - lc := pulse.New(20, 6, - pulse.WithRange(0, 100), - pulse.WithTicks(0, 50, 100), - pulse.WithLabelFormatter(tc.formatter), + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lc := pulse.New(20, 8, + pulse.WithRange(0, tt.maxVal), + pulse.WithLabelFormatter(tt.formatter), + pulse.WithLabelWidth(tt.labelWidth), ) view := lc.View() - lines := strings.Split(strings.TrimRight(view, "\n"), "\n") - var axisCol int - for r, ln := range lines { - idx := strings.Index(ln, "│") - if idx < 0 { - t.Fatalf("line %d missing axis '│': %q", r, ln) + for _, line := range strings.Split(view, "\n") { + if len(line) == 0 { + continue + } + sepIdx := strings.Index(line, "│") + if sepIdx < 0 { + t.Fatalf("missing axis border in line: %q", line) } - // Visual width of the label column before '│' must be constant across all lines - visualW := lipgloss.Width(ln[:idx]) - if r == 0 { - axisCol = visualW - } else if visualW != axisCol { - t.Fatalf("line %d axis visual column mismatch: got %d, want %d (line: %q)", r, visualW, axisCol, ln) + prefix := line[:sepIdx] + w := lipgloss.Width(prefix) + if w != tt.labelWidth { + t.Errorf("visual width mismatch for prefix %q: got %d, want %d", prefix, w, tt.labelWidth) } } }) @@ -503,159 +192,46 @@ func TestLineChart_UnicodeLabelAlignment(t *testing.T) { } func TestLineChart_CustomTicks_SortAndDeduplicate(t *testing.T) { - // User provides unsorted ticks with duplicates: 100, 0, 50, 50 - lc := pulse.New(20, 6, + lc := pulse.New(20, 8, pulse.WithRange(0, 100), - pulse.WithTicks(100, 0, 50, 50), + pulse.WithTicks(100, 50, 0, 50, 25, 0, 75, 100), ) view := lc.View() - lines := strings.Split(strings.TrimRight(view, "\n"), "\n") - // Must have labels at top (100) and bottom (0) - if !strings.Contains(lines[0], "100") { - t.Fatalf("expected top line to contain 100, got: %q", lines[0]) + if strings.TrimSpace(view) == "" { + t.Fatal("View() returned empty string with custom ticks") } - if !strings.Contains(lines[len(lines)-1], "0") { - t.Fatalf("expected bottom line to contain 0, got: %q", lines[len(lines)-1]) + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + seen := make(map[string]int) + for _, ln := range lines { + if sep := strings.Index(ln, "│"); sep >= 0 { + lbl := strings.TrimSpace(ln[:sep]) + if lbl != "" { + seen[lbl]++ + if seen[lbl] > 1 { + t.Errorf("tick label %q appeared %d times", lbl, seen[lbl]) + } + } + } } } -type stubRenderer struct{} - -func (stubRenderer) Render(_ pulse.Chart, _ pulse.RenderContext) string { - return "CUSTOM" -} - func TestRenderer_CustomOverridesViewAndMode(t *testing.T) { - lc := pulse.New(20, 8, pulse.WithRange(0, 100)) - lc.Push(50) - lc.SetRenderer(stubRenderer{}) - if got := lc.Mode(); got != pulse.ModeCustom { - t.Fatalf("Mode() = %v, want ModeCustom", got) - } - if got := lc.View(); got != "CUSTOM" { - t.Fatalf("View() = %q, want custom renderer output", got) - } -} - -func TestLineChart_TimelineEventsSlidingWindow(t *testing.T) { - m := pulse.New(40, 8) - for i := 0; i < 40; i++ { - m.Push(50) - } - // Add event at newest point - m.AddEvent(pulse.Event{Label: "Deploy", Glyph: "🚀"}) - events := m.VisibleEvents() - if len(events) != 1 { - t.Fatalf("expected 1 visible event, got %d", len(events)) - } - if events[0].Col != 39 { - t.Fatalf("expected event at col 39, got %d", events[0].Col) - } - if events[0].Age != 0 { - t.Fatalf("expected age 0, got %d", events[0].Age) - } - - // Push 10 points - for i := 0; i < 10; i++ { - m.Push(55) - } - events = m.VisibleEvents() - if len(events) != 1 { - t.Fatalf("expected 1 visible event, got %d", len(events)) - } - if events[0].Col != 29 { - t.Fatalf("expected event at col 29, got %d", events[0].Col) - } - if events[0].Age != 10 { - t.Fatalf("expected age 10, got %d", events[0].Age) - } - - // Push 30 more points -> event scrolls off screen - for i := 0; i < 30; i++ { - m.Push(60) + m := pulse.New(20, 8, pulse.WithRenderer(staticRenderer{})) + if m.Mode() != pulse.ModeCustom { + t.Fatalf("expected ModeCustom for staticRenderer, got %v", m.Mode()) } - events = m.VisibleEvents() - if len(events) != 0 { - t.Fatalf("expected 0 visible events after scrolling off screen, got %d", len(events)) + if got := m.View(); got != "STATIC_CHART" { + t.Fatalf("expected STATIC_CHART, got %q", got) } } -func TestLineChart_EventsBoxFormatting(t *testing.T) { - m := pulse.New(40, 8) - if box := m.EventsBox(); box != "" { - t.Fatalf("expected empty EventsBox() when no events exist, got %q", box) - } - for i := 0; i < 40; i++ { - m.Push(50) - } - m.AddEvent(pulse.Event{Label: "Deploy v1.2", Glyph: "🚀"}) - m.AddEventAt(10, pulse.Event{Label: "Alert Triggered", Glyph: "⚠"}) - - box := m.EventsBox() - if !strings.Contains(box, "Deploy v1.2") || !strings.Contains(box, "🚀") { - t.Fatalf("EventsBox missing Deploy event: %s", box) - } - if !strings.Contains(box, "Alert Triggered") || !strings.Contains(box, "⚠") { - t.Fatalf("EventsBox missing Alert event: %s", box) - } - if !strings.Contains(box, "(now)") || !strings.Contains(box, "(-10)") { - t.Fatalf("EventsBox missing age offsets: %s", box) - } - - m.ClearEvents() - if box := m.EventsBox(); box != "" { - t.Fatalf("expected empty EventsBox() after ClearEvents(), got %q", box) - } -} - -func TestLineChart_EventsRenderInLinesAndBraille(t *testing.T) { - cyan := lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")) - m := pulse.New(40, 8, - pulse.WithRange(0, 100), - pulse.WithLineStyle(cyan), - pulse.WithFill(true), - pulse.WithTintedFill(true), - ) - for i := 0; i < 40; i++ { - m.Push(50) - } - m.AddEventAt(15, pulse.Event{Label: "Deploy", Glyph: "🚀"}) - - // Lines renderer check - viewLines := m.View() - if !strings.Contains(viewLines, "🚀") { - t.Fatalf("ModeLines View() must render event glyph 🚀") - } - if !strings.Contains(viewLines, "┆") { - t.Fatalf("ModeLines View() must render event guideline ┆") - } - - // Braille renderer check - m.SetMode(pulse.ModeBraille) - viewBraille := m.View() - if !strings.Contains(viewBraille, "🚀") { - t.Fatalf("ModeBraille View() must render event glyph 🚀") - } - if !strings.Contains(viewBraille, "┆") { - t.Fatalf("ModeBraille View() must render event guideline ┆") - } -} - -func TestLineChart_WithEventOption(t *testing.T) { - m := pulse.New(30, 8, - pulse.WithEvent(5, pulse.Event{Label: "InitEvent", Glyph: "★"}), - ) - for i := 0; i < 10; i++ { - m.Push(42) - } - events := m.VisibleEvents() - if len(events) != 1 { - t.Fatalf("expected 1 event from WithEvent option, got %d", len(events)) - } - if events[0].Event.Label != "InitEvent" || events[0].Event.Glyph != "★" { - t.Fatalf("unexpected event metadata: %+v", events[0]) +func TestLineChart_InvertedRangeNormalization(t *testing.T) { + m := pulse.New(20, 8, pulse.WithRange(100, 0)) + if m.Min() != 0 || m.Max() != 100 { + t.Fatalf("expected WithRange(100, 0) to normalize to (0, 100), got (%v, %v)", m.Min(), m.Max()) } - if events[0].Col != 14 { - t.Fatalf("expected col 14 (29 - 15), got %d", events[0].Col) + m.SetRange(80, 20) + if m.Min() != 20 || m.Max() != 80 { + t.Fatalf("expected SetRange(80, 20) to normalize to (20, 80), got (%v, %v)", m.Min(), m.Max()) } } diff --git a/renderer.go b/renderer.go new file mode 100644 index 0000000..a1e7a06 --- /dev/null +++ b/renderer.go @@ -0,0 +1,161 @@ +package pulse + +import ( + "image/color" + "math" + "strings" + + "charm.land/lipgloss/v2" +) + +// RenderMode defines how line curves are rendered on the chart. +type RenderMode int + +const ( + // ModeLines renders smooth continuous lines (box-drawing ╭, ╮, ╯, ╰, ─, │) with Grafana-style area fill ░. + ModeLines RenderMode = iota + // ModeBraille renders using a 2x4 dot braille matrix (Unicode Braille Patterns). + ModeBraille + // ModeCustom represents a custom user-supplied Renderer. + ModeCustom +) + +// DefaultTintColor is the default background tint used when TintedFill is active +// and no custom tint color or style background is specified. Defaults to Tokyo Night slate (#1f2335). +var DefaultTintColor color.Color = lipgloss.Color("#1f2335") + +// Chart provides the read-only contract for inspecting chart dimensions, data, and styles. +type Chart interface { + Width() int + Height() int + Min() float64 + Max() float64 + Fill() bool + TintedFill() bool + SolidFill() bool + TintColor() color.Color + Grid() bool + Smooth() bool + LineWidth() int + AxisStyle() lipgloss.Style + LineStyle() lipgloss.Style + SeriesStyle(name string) lipgloss.Style + SeriesNames() []string + SeriesData(name string) []float64 + VisibleEvents() []VisibleEvent +} + +// RenderContext contains prepared axis, tick, label, and style metadata passed to a Renderer. +type RenderContext struct { + LabelWidth int + LabelStyle lipgloss.Style + RowLabel map[int]string + HGrid []bool + VGrid []bool + Names []string + Styles []lipgloss.Style + SeriesData [][]float64 + GridStyle lipgloss.Style +} + +// Renderer defines how chart series data and axes are rendered into a string. +// Custom renderers only need the Chart contract: build one with New and Push +// in tests, no mocks required. +type Renderer interface { + Render(c Chart, ctx RenderContext) string +} + +func spanOf(min, max float64) float64 { + if max == min { + return 1 + } + return max - min +} + +func normOf(min, max, v float64) float64 { + norm := (v - min) / spanOf(min, max) + if norm < 0 { + return 0 + } + if norm > 1 { + return 1 + } + return norm +} + +// tickRowOf returns the terminal row index (0 at top to h-1 at bottom) for value v. +func tickRowOf(h int, min, max, v float64) int { + return int(math.Round(float64(h-1) * (1.0 - normOf(min, max, v)))) +} + +func renderGrid(c Chart, ctx RenderContext, cellAt func(r, c int) (string, bool)) string { + w, h := c.Width(), c.Height() + axis := c.AxisStyle().Render("│") + var sb strings.Builder + for r := 0; r < h; r++ { + sb.WriteString(ctx.LabelStyle.Render(ctx.RowLabel[r])) + sb.WriteString(axis) + for col := 0; col < w; col++ { + if s, ok := cellAt(r, col); ok { + sb.WriteString(s) + if sw := lipgloss.Width(s); sw > 1 { + col += sw - 1 + } + continue + } + if ctx.HGrid[r] || ctx.VGrid[col] { + sb.WriteString(ctx.GridStyle.Render(gridCell(ctx.HGrid[r], ctx.VGrid[col]))) + continue + } + sb.WriteByte(' ') + } + if r < h-1 { + sb.WriteByte('\n') + } + } + return sb.String() +} + +func styleFor(owner int, styles []lipgloss.Style, fallback lipgloss.Style) lipgloss.Style { + if owner >= 0 && owner < len(styles) { + return styles[owner] + } + return fallback +} + +func faintStyleFor(owner int, styles []lipgloss.Style, fallback lipgloss.Style) lipgloss.Style { + return styleFor(owner, styles, fallback).Faint(true) +} + +func gridCell(h, v bool) string { + switch { + case h && v: + return "┌" + case h: + return "─" + default: + return "│" + } +} + +func makeCells[T any](h, w int, fill T) [][]T { + buf := make([]T, h*w) + for i := range buf { + buf[i] = fill + } + cells := make([][]T, h) + for i := range cells { + cells[i] = buf[i*w : (i+1)*w] + } + return cells +} + +func tintColorFor(c Chart, st lipgloss.Style) color.Color { + if tc := c.TintColor(); tc != nil && tc != (lipgloss.NoColor{}) { + return tc + } + if bg := st.GetBackground(); bg != nil && bg != (lipgloss.NoColor{}) { + return bg + } + return DefaultTintColor +} diff --git a/renderer_braille.go b/renderer_braille.go new file mode 100644 index 0000000..1fb3988 --- /dev/null +++ b/renderer_braille.go @@ -0,0 +1,252 @@ +package pulse + +import ( + "math" +) + +// BrailleRenderer renders 2x4 dot Braille patterns. +type BrailleRenderer struct{} + +var _ Renderer = BrailleRenderer{} + +var brailleBits = [4][2]uint8{ + {0x01, 0x08}, + {0x02, 0x10}, + {0x04, 0x20}, + {0x40, 0x80}, +} + +// dotCellOf maps a 2w x 4h coordinate into a Braille cell and its internal bit. +func dotCellOf(w, h, xd, yd int) (row, col int, bit uint8, ok bool) { + dw, dh := w*2, h*4 + if xd < 0 || yd < 0 || xd >= dw || yd >= dh { + return 0, 0, 0, false + } + rowFromBottom := yd / 4 + dy := yd % 4 + cx, dx := xd/2, xd%2 + return h - 1 - rowFromBottom, cx, brailleBits[dy][dx], true +} + +func plotDots(grid [][]uint8, owner [][]int, idx, lineWidth, w, h, xd, yd int) { + pts := [][2]int{{xd, yd}} + if lineWidth > 1 { + pts = append(pts, [2]int{xd + 1, yd}, [2]int{xd, yd + 1}, [2]int{xd + 1, yd + 1}) + } + for _, p := range pts { + row, col, bit, ok := dotCellOf(w, h, p[0], p[1]) + if !ok { + continue + } + grid[row][col] |= bit + owner[row][col] = idx + } +} + +func dotHOf(h int) int { + if dh := h*4 - 1; dh > 0 { + return dh + } + return 0 +} + +// dotYOf maps a value to dot row offset from the bottom (for Braille). +func dotYOf(h int, min, max, v float64) int { + return int(math.Round(normOf(min, max, v) * float64(dotHOf(h)))) +} + +type dot struct{ x, y int } + +// catmullRom computes a Catmull-Rom spline value for segment p1-p2 with neighbors p0, p3. +func catmullRom(p0, p1, p2, p3, t float64) float64 { + t2 := t * t + t3 := t2 * t + return 0.5 * ((2 * p1) + (-p0+p2)*t + (2*p0-5*p1+4*p2-p3)*t2 + (-p0+3*p1-3*p2+p3)*t3) +} + +func clampDotOf(w, h, xd, yd int) (int, int) { + maxX := w*2 - 1 + if maxX < 0 { + maxX = 0 + } + if xd < 0 { + xd = 0 + } + if xd > maxX { + xd = maxX + } + dh := dotHOf(h) + if yd < 0 { + yd = 0 + } + if yd > dh { + yd = dh + } + return xd, yd +} + +// sampleLineOf interpolates the polyline into dots: Catmull-Rom spline if smooth, otherwise line segments. +func sampleLineOf(w, h int, smooth bool, xs, ys []int) []dot { + out := []dot{{xs[0], ys[0]}} + n := len(xs) + for i := 0; i < n-1; i++ { + x0, y0, x1, y1 := float64(xs[i]), float64(ys[i]), float64(xs[i+1]), float64(ys[i+1]) + var px0, py0, px3, py3 float64 + if i == 0 { + px0, py0 = x0, y0 + } else { + px0, py0 = float64(xs[i-1]), float64(ys[i-1]) + } + if i+2 >= n { + px3, py3 = x1, y1 + } else { + px3, py3 = float64(xs[i+2]), float64(ys[i+2]) + } + dx, dy := x1-x0, y1-y0 + steps := 1 + if smooth { + steps = int((math.Abs(dx) + math.Abs(dy)) * 2) + if steps < 8 { + steps = 8 + } + if steps > 128 { + steps = 128 + } + } else { + steps = int(math.Abs(dx)) + if a := int(math.Abs(dy)); a > steps { + steps = a + } + if steps < 1 { + steps = 1 + } + } + for s := 1; s <= steps; s++ { + t := float64(s) / float64(steps) + var fx, fy float64 + if smooth { + fx = catmullRom(px0, x0, x1, px3, t) + fy = catmullRom(py0, y0, y1, py3, t) + } else { + fx = x0 + dx*t + fy = y0 + dy*t + } + xd, yd := clampDotOf(w, h, int(math.Round(fx)), int(math.Round(fy))) + out = append(out, dot{xd, yd}) + } + } + return out +} + +func plotSeriesBraille(c Chart, grid [][]uint8, owner [][]int, idx int, data []float64) []int { + w, h := c.Width(), c.Height() + min, max := c.Min(), c.Max() + colTop := make([]int, w*2) + for i := range colTop { + colTop[i] = -1 + } + n := len(data) + if n == 0 { + return colTop + } + window := w + if window < 1 { + window = 1 + } + dw := w*2 - 1 + if dw < 0 { + dw = 0 + } + den := window - 1 + if den < 1 { + den = 1 + } + xs := make([]int, n) + ys := make([]int, n) + for j, v := range data { + xd := (window - n + j) * dw / den + xs[j], ys[j] = xd, dotYOf(h, min, max, v) + } + top := func(xd, yd int) { + if xd >= 0 && xd < len(colTop) && yd > colTop[xd] { + colTop[xd] = yd + } + } + for _, d := range sampleLineOf(w, h, c.Smooth(), xs, ys) { + plotDots(grid, owner, idx, c.LineWidth(), w, h, d.x, d.y) + top(d.x, d.y) + } + return colTop +} + +// Render implements Renderer for BrailleRenderer. +func (BrailleRenderer) Render(c Chart, ctx RenderContext) string { + w, h := c.Width(), c.Height() + grid := makeCells(h, w, uint8(0)) + owner := makeCells(h, w, -1) + fillMask := makeCells(h, w, uint8(0)) + fillOwner := makeCells(h, w, -1) + + base := dotYOf(h, c.Min(), c.Max(), 0) + for idx, name := range ctx.Names { + var data []float64 + if idx < len(ctx.SeriesData) && ctx.SeriesData[idx] != nil { + data = ctx.SeriesData[idx] + } else { + data = c.SeriesData(name) + } + colTop := plotSeriesBraille(c, grid, owner, idx, data) + if !c.Fill() { + continue + } + for xd, top := range colTop { + for yd := base; yd < top; yd++ { + if xd%2 != 0 || yd%2 != 0 { + continue + } + row, col, bit, ok := dotCellOf(w, h, xd, yd) + if !ok { + continue + } + fillMask[row][col] |= bit + fillOwner[row][col] = idx + } + } + } + + eventMap := eventsByColumn(c.VisibleEvents()) + + fallback := c.LineStyle() + tinted := c.TintedFill() + return renderGrid(c, ctx, func(r, col int) (string, bool) { + ve, hasEvent := eventMap[col] + if hasEvent && r == 0 { + return renderEventPin(ve), true + } + lineDots := grid[r][col] + fillDots := fillMask[r][col] + dots := lineDots | fillDots + if dots != 0 { + if lineDots != 0 { + st := styleFor(owner[r][col], ctx.Styles, fallback) + if tinted { + st = st.Background(tintColorFor(c, st)) + } + return st.Render(string(rune(0x2800 + int(dots)))), true + } + ownerIdx := fillOwner[r][col] + st := faintStyleFor(ownerIdx, ctx.Styles, fallback) + if tinted { + baseStyle := styleFor(ownerIdx, ctx.Styles, fallback) + st = st.Background(tintColorFor(c, baseStyle)) + } + return st.Render(string(rune(0x2800 + int(dots)))), true + } + if hasEvent { + if s, ok := renderEventGuideline(ve, nil); ok { + return s, true + } + } + return "", false + }) +} diff --git a/renderer_lines.go b/renderer_lines.go new file mode 100644 index 0000000..b58bb3c --- /dev/null +++ b/renderer_lines.go @@ -0,0 +1,254 @@ +package pulse + +import ( + "image/color" + + "charm.land/lipgloss/v2" +) + +// LinesRenderer renders smooth box-drawing lines with optional area fill. +type LinesRenderer struct{} + +var _ Renderer = LinesRenderer{} + +const ( + armUp = 1 + armRight = 2 + armDown = 4 + armLeft = 8 +) + +type lineGlyphs struct { + vert, horiz rune + dr, dl, ur, ul rune + tr, tl, td, tu rune + cross rune +} + +var ( + glyphsThinRounded = lineGlyphs{ + vert: '│', horiz: '─', + dr: '╭', dl: '╮', ur: '╰', ul: '╯', + tr: '├', tl: '─', td: '┬', tu: '┮', + cross: '┌', + } + glyphsThinSharp = lineGlyphs{ + vert: '│', horiz: '─', + dr: '┌', dl: '┐', ur: '└', ul: '┘', + tr: '├', tl: '─', td: '┬', tu: '┮', + cross: '┌', + } + glyphsBoldRounded = lineGlyphs{ + vert: '┃', horiz: '━', + dr: '┏', dl: '┓', ur: '┗', ul: '┛', + tr: '┣', tl: '┫', td: '┳', tu: '┻', + cross: '╋', + } + glyphsBoldSharp = lineGlyphs{ + vert: '┃', horiz: '━', + dr: '┌', dl: '┐', ur: '└', ul: '┘', + tr: '├', tl: '─', td: '┬', tu: '┮', + cross: '┌', + } +) + +func glyphsFor(lineWidth int, smooth bool) lineGlyphs { + if lineWidth > 1 { + if !smooth { + return glyphsBoldSharp + } + return glyphsBoldRounded + } + if smooth { + return glyphsThinRounded + } + return glyphsThinSharp +} + +func runeForMask(lineWidth int, smooth bool, mask uint8) rune { + g := glyphsFor(lineWidth, smooth) + switch mask { + case armUp, armDown, armUp | armDown: + return g.vert + case armLeft, armRight, armLeft | armRight: + return g.horiz + case armDown | armRight: + return g.dr + case armDown | armLeft: + return g.dl + case armUp | armRight: + return g.ur + case armUp | armLeft: + return g.ul + case armUp | armDown | armRight: + return g.tr + case armUp | armDown | armLeft: + return g.tl + case armDown | armLeft | armRight: + return g.td + case armUp | armLeft | armRight: + return g.tu + case armUp | armDown | armLeft | armRight: + return g.cross + default: + return g.horiz + } +} + +// plotSeriesLines renders a smooth continuous box-drawing line and shaded area fill ░. +func plotSeriesLines(c Chart, lineMask [][]uint8, lineOwner [][]int, fillMask [][]bool, fillOwner [][]int, idx int, data []float64) { + w, h := c.Width(), c.Height() + min, max := c.Min(), c.Max() + n := len(data) + if n == 0 { + return + } + window := w + startCol := window - n + if startCol < 0 { + startCol = 0 + data = data[n-window:] + n = window + } + + ys := make([]int, window) + for j, v := range data { + col := startCol + j + ys[col] = tickRowOf(h, min, max, v) + } + + for col := startCol + 1; col < window; col++ { + y0 := ys[col-1] + y1 := ys[col] + + // Horizontal segment at row y0 between col-1 and col + lineMask[y0][col-1] |= armRight + lineOwner[y0][col-1] = idx + + lineMask[y0][col] |= armLeft + lineOwner[y0][col] = idx + + // Vertical transition in col between y0 and y1 + if y0 > y1 { // line goes upwards (row index decreases) + for y := y1; y < y0; y++ { + lineMask[y][col] |= armDown + lineOwner[y][col] = idx + + lineMask[y+1][col] |= armUp + lineOwner[y+1][col] = idx + } + } else if y0 < y1 { // line goes downwards (row index increases) + for y := y0; y < y1; y++ { + lineMask[y][col] |= armDown + lineOwner[y][col] = idx + + lineMask[y+1][col] |= armUp + lineOwner[y+1][col] = idx + } + } + } + + // Terminate endpoints + if n == 1 { + lineMask[ys[startCol]][startCol] |= (armLeft | armRight) + lineOwner[ys[startCol]][startCol] = idx + } else if ys[startCol] >= 0 && ys[startCol] < h { + lineMask[ys[startCol]][startCol] |= armLeft + lineOwner[ys[startCol]][startCol] = idx + } + + // Baseline for area fill (0 by default) + baseRow := tickRowOf(h, min, max, 0) + + // Shaded area fill ░ below the line (strictly beneath the line's lower boundary in each column) + if c.Fill() { + for col := startCol; col < window; col++ { + bottomY := -1 + for r := h - 1; r >= 0; r-- { + if lineOwner[r][col] == idx { + bottomY = r + break + } + } + if bottomY < 0 { + bottomY = ys[col] + } + lo, hi := bottomY+1, baseRow + if lo > hi { + lo, hi = baseRow, bottomY-1 + } + for y := lo; y <= hi && y < h; y++ { + if y >= 0 { + fillMask[y][col] = true + fillOwner[y][col] = idx + } + } + } + } +} + +// Render implements Renderer for LinesRenderer. +func (LinesRenderer) Render(c Chart, ctx RenderContext) string { + w, h := c.Width(), c.Height() + lineMask := makeCells(h, w, uint8(0)) + lineOwner := makeCells(h, w, -1) + fillMask := makeCells(h, w, false) + fillOwner := makeCells(h, w, -1) + + for idx, name := range ctx.Names { + var data []float64 + if idx < len(ctx.SeriesData) && ctx.SeriesData[idx] != nil { + data = ctx.SeriesData[idx] + } else { + data = c.SeriesData(name) + } + plotSeriesLines(c, lineMask, lineOwner, fillMask, fillOwner, idx, data) + } + + eventMap := eventsByColumn(c.VisibleEvents()) + + fallback, glyphWidth, glyphSmooth := c.LineStyle(), c.LineWidth(), c.Smooth() + tinted := c.TintedFill() + solid := c.SolidFill() + return renderGrid(c, ctx, func(r, col int) (string, bool) { + ve, hasEvent := eventMap[col] + if hasEvent && r == 0 { + return renderEventPin(ve), true + } + if lineMask[r][col] != 0 { + st := styleFor(lineOwner[r][col], ctx.Styles, fallback) + if tinted { + st = st.Background(tintColorFor(c, st)) + } + ru := runeForMask(glyphWidth, glyphSmooth, lineMask[r][col]) + return st.Render(string(ru)), true + } + if hasEvent { + var bg color.Color + if fillMask[r][col] && tinted { + baseStyle := styleFor(fillOwner[r][col], ctx.Styles, fallback) + bg = tintColorFor(c, baseStyle) + } + if s, ok := renderEventGuideline(ve, bg); ok { + return s, true + } + } + if fillMask[r][col] { + owner := fillOwner[r][col] + st := faintStyleFor(owner, ctx.Styles, fallback) + if tinted { + baseStyle := styleFor(owner, ctx.Styles, fallback) + bg := tintColorFor(c, baseStyle) + if solid { + return lipgloss.NewStyle().Background(bg).Render(" "), true + } + return st.Background(bg).Render("░"), true + } + if solid { + return " ", true + } + return st.Render("░"), true + } + return "", false + }) +} diff --git a/renderer_test.go b/renderer_test.go new file mode 100644 index 0000000..8ea4ca0 --- /dev/null +++ b/renderer_test.go @@ -0,0 +1,274 @@ +package pulse_test + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/ingvarch/pulse" +) + +func TestLineChart_GridToggleChangesRender(t *testing.T) { + a := pulse.New(20, 8, pulse.WithRange(0, 100)) + seedCPU(a) + b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithGrid(false)) + seedCPU(b) + if a.View() == b.View() { + t.Fatal("View() with grid on and off must differ") + } +} + +func TestLineChart_FillToggleChangesRender(t *testing.T) { + red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) + a := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(red)) + seedCPU(a) + b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(red), pulse.WithFill(false)) + seedCPU(b) + if a.View() == b.View() { + t.Fatal("View() with fill on and off must differ") + } +} + +func TestLineChart_TintedFillToggleChangesRender(t *testing.T) { + cyan := lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")) + a := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true)) + seedCPU(a) + b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true), pulse.WithTintedFill(true)) + seedCPU(b) + + if !b.TintedFill() { + t.Fatal("expected TintedFill() to be true") + } + if a.View() == b.View() { + t.Fatal("View() with tinted fill on and off must differ") + } + + b.SetTintedFill(false) + if b.TintedFill() { + t.Fatal("expected TintedFill() to be false after SetTintedFill(false)") + } + if a.View() != b.View() { + t.Fatal("expected View() to match after disabling tinted fill") + } +} + +func TestLineChart_SolidFillToggleChangesRender(t *testing.T) { + cyan := lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")) + a := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true), pulse.WithTintedFill(true)) + seedCPU(a) + b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true), pulse.WithTintedFill(true), pulse.WithSolidFill(true)) + seedCPU(b) + + if !b.SolidFill() { + t.Fatal("expected SolidFill() to be true") + } + if a.View() == b.View() { + t.Fatal("View() with solid fill and textured fill must differ") + } + + b.SetSolidFill(false) + if b.SolidFill() { + t.Fatal("expected SolidFill() to be false after SetSolidFill(false)") + } + if a.View() != b.View() { + t.Fatal("expected View() to match after disabling solid fill") + } +} + +func TestLineChart_CustomTintColorChangesRender(t *testing.T) { + cyan := lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")) + a := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true), pulse.WithTintedFill(true)) + seedCPU(a) + + customTint := lipgloss.Color("#251c3d") + b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineStyle(cyan), pulse.WithFill(true), pulse.WithTintedFill(true), pulse.WithTintColor(customTint)) + seedCPU(b) + + if b.TintColor() != customTint { + t.Fatalf("expected TintColor() to be %v, got %v", customTint, b.TintColor()) + } + if a.View() == b.View() { + t.Fatal("View() with custom tint color and default tint color must differ") + } + + b.SetTintColor(nil) + if b.TintColor() != nil { + t.Fatal("expected TintColor() to be nil after SetTintColor(nil)") + } + if a.View() != b.View() { + t.Fatal("expected View() to match after resetting tint color to nil (default)") + } +} + +func TestLineChart_PlainRenderKeepsDimensions(t *testing.T) { + lc := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithGrid(false), pulse.WithFill(false)) + seedCPU(lc) + lines := strings.Split(strings.TrimRight(lc.View(), "\n"), "\n") + if len(lines) != 8 { + t.Fatalf("expected 8 lines, got %d", len(lines)) + } +} + +func TestLineChart_GridLinesExactAndEven(t *testing.T) { + lc := pulse.New(20, 8, pulse.WithRange(0, 100)) + rows := strings.Split(lc.View(), "\n") + plot := func(r int) string { + runes := []rune(rows[r]) + i := -1 + for j, ru := range runes { + if ru == '│' { + i = j + break + } + } + if i < 0 { + t.Fatalf("row %d has no axis", r) + } + return string(runes[i+1:]) + } + // Solid ruling: every row in the plot has no spaces. + for r := range rows { + for _, ru := range plot(r) { + if ru == ' ' { + t.Fatalf("grid row %d must be solid, got %q", r, plot(r)) + } + } + } + // Solid grid made of box characters. + for _, want := range []string{"─", "│", "┌"} { + if !strings.Contains(lc.View(), want) { + t.Fatalf("View() with grid must contain %q", want) + } + } + plain := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithGrid(false), pulse.WithFill(false)) + seedCPU(plain) + for _, ln := range strings.Split(plain.View(), "\n") { + if i := strings.Index(ln, "│"); i >= 0 { + ln = ln[i+len("│"):] + } + for _, bad := range []string{"─", "│", "┌"} { + if strings.Contains(ln, bad) { + t.Fatalf("plot without grid must not contain %q", bad) + } + } + } +} + +func TestLineChart_DenseGridIsEven(t *testing.T) { + lc := pulse.New(60, 12, pulse.WithRange(0, 100)) + rows := strings.Split(lc.View(), "\n") + if len(rows) != 12 { + t.Fatalf("expected 12 rows, got %d", len(rows)) + } + labelRows := []int{} + for r, ln := range rows { + runes := []rune(ln) + if strings.TrimSpace(string(runes[:4])) != "" { + labelRows = append(labelRows, r) + } + } + // Gaps between adjacent labels differ by at most 1 row. + for i := 1; i < len(labelRows); i++ { + gap := labelRows[i] - labelRows[i-1] + if gap < 2 || gap > 3 { + t.Fatalf("label rows %v have uneven gap %d", labelRows, gap) + } + } +} + +func TestLineChart_LineWidthToggleChangesRender(t *testing.T) { + a := pulse.New(20, 8, pulse.WithRange(0, 100)) + seedCPU(a) + b := pulse.New(20, 8, pulse.WithRange(0, 100), pulse.WithLineWidth(1)) + seedCPU(b) + if a.View() == b.View() { + t.Fatal("View() with default and thin line must differ") + } +} + +func TestLineChart_NamedSeriesLeftEndpointOwner(t *testing.T) { + blue := lipgloss.NewStyle().Foreground(lipgloss.Color("#0000ff")) + red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) + + // Case 1: Multi-point line. Start point at startCol must be red, not blue. + lc := pulse.New(20, 8, + pulse.WithLineWidth(1), + pulse.WithRange(0, 100), + pulse.WithLineStyle(blue), + pulse.WithSeriesStyle("alpha", red), + pulse.WithGrid(false), + pulse.WithFill(false), + ) + for _, v := range []float64{50, 60, 70} { + lc.PushSeries("alpha", v) + } + view := lc.View() + + // Default line style (blue) must NEVER appear anywhere in a chart with only named series "alpha" + if strings.Contains(view, blue.Render("─")) || strings.Contains(view, blue.Render("╭")) || strings.Contains(view, blue.Render("╰")) { + t.Fatalf("default blue style leaked into named series alpha:\n%s", view) + } + + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + sepIdx := strings.Index(lines[4], "│") + if sepIdx < 0 { + t.Fatalf("missing axis border in line 4: %q", lines[4]) + } + plotArea := lines[4][sepIdx+len("│"):] + if len(plotArea) < 17 { + t.Fatalf("plotArea too short: %q", plotArea) + } + // Columns 0..16 must be blank spaces + if plotArea[:17] != strings.Repeat(" ", 17) { + t.Fatalf("expected 17 leading spaces before startCol, got %q", plotArea[:17]) + } + // Column 17 (the starting cell) must strictly begin with red.Render("─") + if !strings.HasPrefix(plotArea[17:], red.Render("─")) { + t.Fatalf("starting cell at col 17 must be red.Render(\"─\"), got:\n%q", plotArea[17:]) + } + + // Case 2: Single-point line (n == 1). Must also be red, not blue. + single := pulse.New(10, 5, + pulse.WithLineWidth(1), + pulse.WithRange(0, 100), + pulse.WithLineStyle(blue), + pulse.WithSeriesStyle("alpha", red), + pulse.WithGrid(false), + pulse.WithFill(false), + ) + single.PushSeries("alpha", 50) + singleView := single.View() + if strings.Contains(singleView, blue.Render("─")) { + t.Fatalf("single point left endpoint must not use default blue style") + } + if !strings.Contains(singleView, red.Render("─")) { + t.Fatalf("single point left endpoint must use series red style") + } +} + +func TestLineChart_LabelFormatterAndWidth(t *testing.T) { + fmtFn := func(v float64) string { + return string([]byte{byte('0' + int(v)/1000)}) + " GHz" + } + lc := pulse.New(15, 5, + pulse.WithRange(0, 4000), + pulse.WithTicks(0, 2000, 4000), + pulse.WithLabelFormatter(fmtFn), + pulse.WithLabelWidth(8), + ) + view := lc.View() + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + for i, ln := range lines { + sep := strings.Index(ln, "│") + if sep < 0 { + continue + } + lbl := ln[:sep] + if len([]rune(lbl)) != 8 { + t.Fatalf("line %d label width = %d, want 8: %q", i, len([]rune(lbl)), lbl) + } + } + if !strings.Contains(view, "4 GHz") || !strings.Contains(view, "2 GHz") || !strings.Contains(view, "0 GHz") { + t.Fatalf("view must contain formatted labels: 4 GHz, 2 GHz, 0 GHz:\n%s", view) + } +} diff --git a/view.go b/view.go new file mode 100644 index 0000000..bb9a44f --- /dev/null +++ b/view.go @@ -0,0 +1,115 @@ +package pulse + +import ( + "charm.land/lipgloss/v2" + + "github.com/ingvarch/pulse/scale" +) + +// tickRow returns the terminal row index (0 at top to h-1 at bottom) for value v. +func (m *Model) tickRow(v float64) int { + return tickRowOf(m.h, m.min, m.max, v) +} + +// maxGridTicks calculates an appropriate grid tick count based on chart height. +func (m *Model) maxGridTicks() int { + ticks := (m.h - 1) / 3 + if ticks < 2 { + return 2 + } + if ticks > 5 { + return 5 + } + return ticks +} + +// gridTicks returns adaptive grid ticks or user-specified custom ticks. +func (m *Model) gridTicks() []float64 { + if len(m.customTicks) > 0 { + return m.customTicks + } + return scale.NiceTicks(m.min, m.max, m.maxGridTicks()) +} + +func (m *Model) formatValue(v float64) string { + if m.labelFormatter != nil { + return m.labelFormatter(v) + } + return scale.FormatTick(v) +} + +// View renders the chart: Y-axis labels + axis border + plot area. +func (m *Model) View() string { + ticks := m.gridTicks() + rowLabel := map[int]string{} + for i := len(ticks) - 1; i >= 0; i-- { + t := ticks[i] + if t < m.min || t > m.max { + continue + } + row := m.tickRow(t) + if _, taken := rowLabel[row]; !taken { + rowLabel[row] = m.formatValue(t) + } + } + + labelWidth := m.labelWidth + if labelWidth <= 0 { + labelWidth = 4 + for _, lbl := range rowLabel { + if w := lipgloss.Width(lbl); w > labelWidth { + labelWidth = w + } + } + } + labelStyle := m.axisStyle.Width(labelWidth).Align(lipgloss.Right) + + hGrid := make([]bool, m.h) + vGrid := make([]bool, m.w) + if m.grid { + for r := range hGrid { + hGrid[r] = true + } + for k := 1; k < 4; k++ { + if col := k * m.w / 4; col < m.w { + vGrid[col] = true + } + } + } + + names := make([]string, 0, len(m.order)+1) + names = append(names, "") + names = append(names, m.order...) + styles := make([]lipgloss.Style, len(names)) + seriesData := make([][]float64, len(names)) + for idx, name := range names { + styles[idx] = m.SeriesStyle(name) + if s, ok := m.series[name]; ok { + seriesData[idx] = s.data + } + } + + gridStyle := m.axisStyle.Faint(true) + ctx := RenderContext{ + LabelWidth: labelWidth, + LabelStyle: labelStyle, + RowLabel: rowLabel, + HGrid: hGrid, + VGrid: vGrid, + Names: names, + Styles: styles, + SeriesData: seriesData, + GridStyle: gridStyle, + } + + r := m.renderer + if r == nil { + r = LinesRenderer{} + } + return r.Render(m, ctx) +} + +// String implements fmt.Stringer, returning the rendered chart View(). +func (m *Model) String() string { + return m.View() +}