From f7ed6125cae8018c20b6faef679800b917eec934 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Thu, 1 Oct 2026 11:26:03 +0000 Subject: [PATCH] feat(pulse): add timeline annotations and event markers with sliding cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Event and VisibleEvent types with WithEvent, AddEvent, and AddEventAt APIs - Render marker glyphs (โ–ผ, ๐Ÿš€, โšก) and vertical guidelines (โ”†) across Lines and Braille modes - Implement dynamic age tracking and automatic garbage collection for off-screen events - Add EventsBox() helper for Lip Gloss cards with relative age tags (now, -12) - Handle multi-width runes and emojis in grid without column drift - Add interactive event injection hotkey ('e') to livedemo - Add comprehensive documentation in docs/timeline-events.md - Add unit tests covering sliding window aging, memory pruning, and rendering --- README.md | 2 + docs/timeline-events.md | 283 ++++++++++++++++++++++++++++++++++++++ examples/livedemo/main.go | 31 ++++- pulse.go | 199 +++++++++++++++++++++++++-- pulse_test.go | 123 +++++++++++++++++ 5 files changed, 624 insertions(+), 14 deletions(-) create mode 100644 docs/timeline-events.md diff --git a/README.md b/README.md index ff83e29..ef0aed1 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - **โ–‘ Shaded Area Fill**: Half-tone shading beneath curves that cleanly absorbs grid intersections. - **โ”ผ Aligned Coordinate Grid**: Subtle coordinate grid aligned exactly to Y-axis ticks and width quarter-steps. - **๐Ÿ“ˆ Multi-Series Support**: Plot multiple named metrics simultaneously with dedicated styles and swatches. +- **๐Ÿท๏ธ Timeline Annotations**: Pin events, deploys, and alerts onto the sliding timeline with custom glyphs and vertical guidelines. - **๐Ÿ“ฆ Live Legend Box**: Bordered legend overlay displaying swatches, names, and real-time values. - **๐Ÿซง Charm Native**: Fully customizable with Lip Gloss styles and built for Bubble Tea event loops. @@ -142,6 +143,7 @@ Detailed guides and API references are available in the [docs](docs/) directory: | [**Getting Started**](docs/getting-started.md) | Constructor options, sliding window buffers, multi-series, and runtime controls | | [**Rendering Modes**](docs/rendering-modes.md) | Box-drawing curves (rounded vs bold), Braille sub-pixel matrix, and area fill | | [**Axes & Grid**](docs/axes-and-grid.md) | Adaptive NiceTicks, custom tick quarter-steps, and Grafana grid alignment | +| [**Timeline Events**](docs/timeline-events.md) | Pin deployments, alerts, and markers (`โ–ผ`, `๐Ÿš€`, `โšก`) with guidelines and sliding cards | | [**Bubble Tea Integration**](docs/bubbletea.md) | Embedding in Bubble Tea models, telemetry ticks, and interactive hotkeys | ## ๐ŸŽฎ Interactive Demos diff --git a/docs/timeline-events.md b/docs/timeline-events.md new file mode 100644 index 0000000..6ce5e46 --- /dev/null +++ b/docs/timeline-events.md @@ -0,0 +1,283 @@ +# Timeline Annotations & Event Markers + +**Pulse** is the first Go terminal charting library to feature **first-class Timeline Annotations & Event Markers**, bringing a core capability from cloud observability platforms like **Grafana** and **Datadog** straight into your terminal user interface. + +--- + +## ๐ŸŽฏ Why Timeline Annotations? + +When monitoring metrics in real time (e.g. CPU spikes, memory leaks, error rate surges), raw numbers only tell you *that* something happenedโ€”not *why* it happened. + +In modern operations and incident response, correlating metrics with discrete engineering events is essential: +- **Deployments**: *"Did latency increase right after deploying `v2.4.1`?"* +- **Autoscaling & Restarts**: *"Was this CPU drop caused by a pod OOM-kill or HPA scale-up?"* +- **Configuration Changes**: *"Did DB connection pool saturation start when feature flag `X` was enabled?"* +- **Chaos / Load Tests**: *"How did response times behave during the simulated network partition?"* + +Pulse enables you to pin events directly onto the time-series grid with vertical guidelines (`โ”†`), custom badges or emojis (`โ–ผ`, `๐Ÿš€`, `โšก`, `โš ๏ธ`), and dynamic aging cards. + +```text + 100โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ [Deploy v1.4.2 (-24)] + โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”†โ”€โ”€โ”€โ”€โ”€โ•ญโ”€โ”€โ•ฏโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ•ฐโ”€โ”€ + 50โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”†โ”€โ”€โ”€โ•ญโ”€โ•ฏโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ + 0โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”†โ”€โ”€โ”€โ•ฏโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ +``` + +--- + +## ๐Ÿ“ฆ Core Types & API + +### `Event` + +```go +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 // Lip Gloss style for the glyph and guideline + NoLine bool // If true, omits the vertical guideline across the chart +} +``` + +### `VisibleEvent` + +```go +type VisibleEvent struct { + Col int // Column index within the visible chart window (0 to Width()-1) + Age int // How many time steps ago this event occurred + Event Event // The original event metadata +} +``` + +### Chart Methods + +| Method | Description | +| ------ | ----------- | +| `chart.AddEvent(ev Event)` | Pins a new event at the current newest point (`now`, right edge). | +| `chart.AddEventAt(offset int, ev Event)` | Pins an event at a historical offset (`0` = newest point, `15` = 15 points ago). | +| `chart.VisibleEvents() []VisibleEvent` | Returns all events currently visible within the sliding window. Automatically prunes off-screen events. | +| `chart.EventsBox() string` | Returns a formatted, bordered Lip Gloss card displaying all active visible events with age tags (`(now)`, `(-14)`). | +| `chart.ClearEvents()` | Clears all registered events from the chart. | + +### Functional Option + +```go +pulse.New(width, height, + // Pin an initial event 25 points in the past + pulse.WithEvent(25, pulse.Event{ + Label: "Traffic Spike", + Glyph: "โšก", + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#e0af68")).Bold(true), + }), +) +``` + +--- + +## ๐Ÿ› ๏ธ How It Works: The Sliding Window Lifecycle + +Pulse charts maintain an internal time step (`m.step`). Every time you push a data point (`chart.Push(v)` or `chart.PushSeries(name, v)`), the timeline advances by one tick: + +1. **Recording**: When you call `chart.AddEvent(ev)`, Pulse stamps the event with the current `step`. +2. **Aging**: At any moment, the event's age is calculated as: + $$\text{age} = \text{current\_step} - \text{event\_step}$$ +3. **Column Mapping**: In a chart of width $W$, the screen column is: + $$\text{col} = (W - 1) - \text{age}$$ + - When $\text{age} = 0$, the marker appears at the extreme right edge: `col = W - 1`. + - As new points are pushed, the marker smoothly travels leftward across the grid. +4. **Rendering**: + - The pin glyph (`โ–ผ`, `๐Ÿš€`, `โšก`) is anchored at row 0 (the top margin). + - If `NoLine` is `false`, a subtle vertical guideline (`โ”†`) cascades down through empty space and shaded/tinted area fill, preserving line curves (`โ•ญ`, `โ•ฏ`, `โ”€`) without disruption. +5. **Garbage Collection**: Once an event scrolls past the left edge ($\text{col} < 0$), Pulse automatically purges it from memory. + +--- + +## ๐Ÿ’ก Practical Examples + +### 1. Static Report / CLI Incident Summary + +Pin past events on a static time-series chart before rendering: + +```go +package main + +import ( + "fmt" + "math" + + "charm.land/lipgloss/v2" + "github.com/ingvarch/pulse" + "github.com/ingvarch/pulse/theme" +) + +func main() { + preset := theme.TokyoNight() + chart := pulse.New(60, 12, + pulse.WithRange(0, 100), + pulse.WithTicks(0, 25, 50, 75, 100), + pulse.WithFill(true), + pulse.WithTintedFill(true), + pulse.WithAxisStyle(preset.Axis), + pulse.WithLineStyle(preset.LineFor(60)), + ) + + // Stream synthetic metric values + for i := 0; i < 60; i++ { + val := 40.0 + 35.0*math.Sin(float64(i)*0.15) + chart.Push(val) + } + + // Annotate historical events + chart.AddEventAt(35, pulse.Event{ + Label: "Deploy v1.4.2", + Glyph: "๐Ÿš€", + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")).Bold(true), + }) + + chart.AddEventAt(15, pulse.Event{ + Label: "Spike Detected", + Glyph: "โšก", + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#e0af68")).Bold(true), + }) + + // Render chart and events summary card + fmt.Println(chart.View()) + fmt.Println(chart.EventsBox()) +} +``` + +--- + +### 2. Live Bubble Tea Dashboard with Dynamic Event Injection + +Trigger events dynamically via user hotkeys or incoming background messages: + +```go +package main + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "charm.land/lipgloss/v2" + "github.com/ingvarch/pulse" +) + +type tickMsg time.Time + +type model struct { + chart *pulse.Model + step int +} + +func (m model) Init() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "e": // Drop a deploy event at (now) + m.chart.AddEvent(pulse.Event{ + Label: fmt.Sprintf("Deploy #%d", m.step), + Glyph: "๐Ÿš€", + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")).Bold(true), + }) + case "c": // Clear all events + m.chart.ClearEvents() + } + + case tickMsg: + m.step++ + // Push latest metric + m.chart.Push(computeCurrentMetric(m.step)) + return m, tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { + return tickMsg(t) + }) + } + + return m, nil +} + +func (m model) View() string { + chartView := m.chart.View() + eventsCard := m.chart.EventsBox() + + if eventsCard != "" { + return lipgloss.JoinVertical(lipgloss.Left, chartView, eventsCard) + } + return chartView +} +``` + +--- + +## ๐ŸŽจ Best Practices & UI Tips + +### 1. Choosing Marker Glyphs + +Pulse supports single-character Unicode symbols, ASCII, and multi-width emojis: + +| Glyph | Recommended Purpose | Sample Style Color | +| :---: | ------------------- | ------------------ | +| `โ–ผ` | General event marker / pin | `#7dcfff` (Cyan) | +| `๐Ÿš€` | Application deployment or rollout | `#bb9af7` (Purple) | +| `โšก` | Traffic spike or load increase | `#e0af68` (Amber) | +| `โš ๏ธ` | Warning or threshold breach | `#f7768e` (Red) | +| `๐Ÿท๏ธ` | Release or milestone tag | `#73daca` (Teal) | +| `๐Ÿ”„` | Pod restart or container recycle | `#2ac3de` (Sky Blue) | +| `โ—†` | Discrete log event or audit action | `#9ece6a` (Green) | + +> [!TIP] +> Pulse automatically measures terminal display width via `lipgloss.Width(glyph)`. If you use double-width emojis (such as `๐Ÿš€` or `โšก`), Pulse offsets surrounding grid cells to ensure the vertical guideline `โ”†` stays strictly aligned with the marker. + +--- + +### 2. Guideline Visibility (`NoLine`) + +By default, every event drops a subtle dotted vertical line (`โ”†`) through the chart: +- **`NoLine: false` (Default)**: Best for single or spaced-out events where you need to trace the exact point in time down to the baseline or across filled areas. +- **`NoLine: true`**: Best when you have high-frequency events or dense timelines, avoiding vertical stripes across the plot area. + +```go +chart.AddEvent(pulse.Event{ + Label: "Minor Warning", + Glyph: "โš ๏ธ", + NoLine: true, // Marker only on row 0; no vertical line +}) +``` + +--- + +### 3. Pairing with `LegendBox` and `EventsBox` + +For a production-grade TUI layout, place the `LegendBox()` and `EventsBox()` side-by-side beneath the chart using `lipgloss.JoinHorizontal`: + +```go +footer := lipgloss.JoinHorizontal( + lipgloss.Top, + chart.LegendBox(), + " ", + chart.EventsBox(), +) + +fullDashboard := lipgloss.JoinVertical( + lipgloss.Left, + chart.View(), + footer, +) +``` + +--- + +### 4. Compatibility with Rendering Engines + +Timeline annotations work seamlessly across all Pulse rendering modes: +- **`pulse.ModeLines`**: Guidelines run through box-drawing turns without breaking corner connections. In tinted fill mode (`WithTintedFill(true)`), guidelines automatically inherit the underlying tint background. +- **`pulse.ModeBraille`**: Markers and guidelines cleanly overlay the 2ร—4 sub-pixel Braille matrix. diff --git a/examples/livedemo/main.go b/examples/livedemo/main.go index 08af168..0a5c000 100644 --- a/examples/livedemo/main.go +++ b/examples/livedemo/main.go @@ -8,6 +8,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/ingvarch/pulse" "github.com/ingvarch/pulse/theme" @@ -57,6 +58,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.chart.SetSolidFill(!m.chart.SolidFill()) return m, nil } + if s == "e" { + m.chart.AddEvent(pulse.Event{ + Label: fmt.Sprintf("Event @ %02.0f", m.last), + Glyph: "๐Ÿท๏ธ", + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#f7768e")).Bold(true), + }) + return m, nil + } if s == "m" { m.chart.ToggleRenderMode() return m, nil @@ -94,9 +103,15 @@ func (m model) View() tea.View { if m.chart.SolidFill() { fillStr = "solid fill" } - header := fmt.Sprintf("signal %5.1f [%s, %s, %s] (f: tint, s: solid/textured, t: width, m: braille, q: quit)\n", + header := fmt.Sprintf("signal %5.1f [%s, %s, %s] (f: tint, s: solid, t: width, e: drop event, m: braille, q: quit)\n", m.last, modeStr, tintStr, fillStr) - return tea.NewView(header + m.chart.View() + "\n" + m.chart.LegendBox() + "\n") + + boxes := m.chart.LegendBox() + if eb := m.chart.EventsBox(); eb != "" { + boxes = lipgloss.JoinHorizontal(lipgloss.Top, boxes, " ", eb) + } + + return tea.NewView(header + m.chart.View() + "\n" + boxes + "\n") } func main() { @@ -121,6 +136,18 @@ func main() { lastVal := signal(initStep) lc.SetSeriesStyle("signal", preset.LineFor(lastVal)) + // Pre-seed timeline events to showcase the feature on startup + lc.AddEventAt(52, pulse.Event{ + Label: "Deploy v1.4.2", + Glyph: "๐Ÿš€", + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#7dcfff")).Bold(true), + }) + lc.AddEventAt(24, pulse.Event{ + Label: "Traffic Spike", + Glyph: "โšก", + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#e0af68")).Bold(true), + }) + p := tea.NewProgram(model{ chart: lc, preset: preset, diff --git a/pulse.go b/pulse.go index 07072cd..8d3a18d 100644 --- a/pulse.go +++ b/pulse.go @@ -1,6 +1,7 @@ package pulse import ( + "fmt" "image/color" "math" "slices" @@ -23,6 +24,22 @@ const ( 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 @@ -41,6 +58,7 @@ type Chart interface { 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. @@ -102,6 +120,14 @@ type Model struct { labelWidth int labelFormatter func(float64) string + + step int + events []eventRecord +} + +type eventRecord struct { + event Event + step int } type series struct { @@ -161,6 +187,11 @@ 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) } @@ -252,6 +283,9 @@ func (m *Model) PushSeries(name string, v float64) { if len(s.data) > m.w { s.data = s.data[len(s.data)-m.w:] } + if len(m.order) == 0 || name == m.order[0] || name == "" { + m.step++ + } } // Len returns the number of points in the default series within the window. @@ -497,6 +531,89 @@ func (m *Model) LegendBox() string { 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}, @@ -1005,6 +1122,9 @@ func renderGrid(c Chart, ctx RenderContext, cellAt func(r, c int) (string, bool) 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] { @@ -1042,10 +1162,28 @@ func (LinesRenderer) Render(c Chart, ctx RenderContext) string { 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 { @@ -1054,6 +1192,18 @@ func (LinesRenderer) Render(c Chart, ctx RenderContext) string { 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) @@ -1103,28 +1253,53 @@ func (BrailleRenderer) Render(c Chart, ctx RenderContext) string { } } + 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 { - return "", false - } - if lineDots != 0 { - st := styleFor(owner[r][col], ctx.Styles, fallback) + 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 { - st = st.Background(tintColorFor(c, st)) + baseStyle := styleFor(ownerIdx, ctx.Styles, fallback) + st = st.Background(tintColorFor(c, baseStyle)) } 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)) + 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 st.Render(string(rune(0x2800 + int(dots)))), true + return "", false }) } diff --git a/pulse_test.go b/pulse_test.go index 5505a09..e43bffc 100644 --- a/pulse_test.go +++ b/pulse_test.go @@ -536,3 +536,126 @@ func TestRenderer_CustomOverridesViewAndMode(t *testing.T) { 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) + } + 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) + } +}