Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions event.go
Original file line number Diff line number Diff line change
@@ -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"))
}
132 changes: 132 additions & 0 deletions event_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
37 changes: 37 additions & 0 deletions legend.go
Original file line number Diff line number Diff line change
@@ -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"))
}
39 changes: 39 additions & 0 deletions legend_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading