From 6e03c12dea847af64ea27870e76cc26ff200a45e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Fri, 6 Mar 2026 12:33:08 +0100 Subject: [PATCH 1/5] feat: add Chart, BarChart, and Canvas components with enhanced Sparkline --- README.md | 59 +++- build.zig | 2 + examples/charts.zig | 183 +++++++++++ src/components/bar_chart.zig | 289 +++++++++++++++++ src/components/canvas.zig | 429 +++++++++++++++++++++++++ src/components/chart.zig | 586 +++++++++++++++++++++++++++++++++++ src/components/charting.zig | 243 +++++++++++++++ src/components/sparkline.zig | 218 ++++++++++--- src/root.zig | 20 ++ tests/chart_tests.zig | 126 ++++++++ 10 files changed, 2109 insertions(+), 46 deletions(-) create mode 100644 examples/charts.zig create mode 100644 src/components/bar_chart.zig create mode 100644 src/components/canvas.zig create mode 100644 src/components/chart.zig create mode 100644 src/components/charting.zig create mode 100644 tests/chart_tests.zig diff --git a/README.md b/README.md index c7d0951..4a85ec3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A delightful TUI framework for Zig, inspired by [Bubble Tea](https://github.com/ - **Elm Architecture** - Model-Update-View pattern for predictable state management - **Rich Styling** - Comprehensive styling system with colors, borders, padding, margin backgrounds, per-side border colors, tab width control, style ranges, full style inheritance, text transforms, whitespace formatting controls, and unset methods -- **19 Pre-built Components** - TextInput (with autocomplete/word movement), TextArea, List (fuzzy filtering), Table (interactive with row selection), Viewport, Progress (color gradients), Spinner, Tree, StyledList, Sparkline, Notification/Toast, Confirm dialog, Modal/Popup, Tooltip, Help, Paginator, Timer, FilePicker, TabGroup (multi-view routing) +- **22 Pre-built Components** - TextInput (with autocomplete/word movement), TextArea, List (fuzzy filtering), Table (interactive with row selection), Viewport, Progress (color gradients), Spinner, Tree, StyledList, Sparkline, Chart, BarChart, Canvas, Notification/Toast, Confirm dialog, Modal/Popup, Tooltip, Help, Paginator, Timer, FilePicker, TabGroup (multi-view routing) - **Focus Management** - `FocusGroup` with Tab/Shift+Tab cycling, comptime focusable protocol, `FocusStyle` for visual focus ring indicators - **Keybinding Management** - Structured `KeyBinding`/`KeyMap` with matching, display formatting, and Help component integration - **Color System** - ANSI 16, 256, and TrueColor with adaptive colors, color profile detection, and dark background detection @@ -384,17 +384,72 @@ try list.addItemNested("Sub-item", 1); ### Sparkline -Mini chart using Unicode block elements: +Mini chart using Unicode block elements with configurable bucketing, ranges, and gradients: ```zig var spark = zz.Sparkline.init(allocator); spark.setWidth(20); +spark.setSummary(.average); +spark.setGradient(zz.Color.hex("#F97316"), zz.Color.hex("#22C55E")); try spark.push(10.0); try spark.push(25.0); try spark.push(15.0); const chart = try spark.view(allocator); ``` +### Chart + +Cartesian chart with multiple datasets, axes, grid lines, legends, and selectable markers: + +```zig +var chart = zz.Chart.init(allocator); +chart.setSize(48, 16); +chart.setMarker(.braille); +chart.x_axis = .{ .title = "Time", .tick_count = 5, .show_grid = true }; +chart.y_axis = .{ .title = "CPU", .tick_count = 5, .show_grid = true }; + +var dataset = try zz.ChartDataset.init(allocator, "load"); +dataset.setStyle((zz.Style{}).fg(zz.Color.cyan()).bold(true)); +dataset.setShowPoints(true); +try dataset.setPoints(&.{ + .{ .x = 0, .y = 20 }, + .{ .x = 1, .y = 45 }, + .{ .x = 2, .y = 30 }, +}); +try chart.addDataset(dataset); + +const view = try chart.view(allocator); +``` + +### BarChart + +Vertical or horizontal bar chart with labels, values, and positive/negative baselines: + +```zig +var bars = zz.BarChart.init(allocator); +bars.setOrientation(.horizontal); +bars.show_values = true; +try bars.addBar(try zz.Bar.init(allocator, "api", 31)); +try bars.addBar(try zz.Bar.init(allocator, "db", -12)); +const view = try bars.view(allocator); +``` + +### Canvas + +Low-level plotting canvas for custom graphs, scatter plots, and braille-dot drawing: + +```zig +var canvas = zz.Canvas.init(allocator); +defer canvas.deinit(); + +canvas.setSize(24, 10); +canvas.setMarker(.braille); +canvas.setRanges(.{ .min = -1, .max = 1 }, .{ .min = -1, .max = 1 }); +try canvas.drawLineStyled(-1, -1, 1, 1, (zz.Style{}).fg(zz.Color.yellow()), null); +try canvas.drawPointStyled(0.25, 0.7, (zz.Style{}).fg(zz.Color.cyan()), null); +const view = try canvas.view(allocator); +``` + ### Notification/Toast Auto-dismissing timed messages with severity levels: diff --git a/build.zig b/build.zig index a1a830b..5b8d48a 100644 --- a/build.zig +++ b/build.zig @@ -19,6 +19,7 @@ pub fn build(b: *std.Build) void { "text_editor", "file_browser", "dashboard", + "charts", "showcase", "focus_form", "modal", @@ -65,6 +66,7 @@ pub fn build(b: *std.Build) void { "tests/modal_tests.zig", "tests/tooltip_tests.zig", "tests/tab_group_tests.zig", + "tests/chart_tests.zig", }; const test_step = b.step("test", "Run unit tests"); diff --git a/examples/charts.zig b/examples/charts.zig new file mode 100644 index 0000000..05f4ba1 --- /dev/null +++ b/examples/charts.zig @@ -0,0 +1,183 @@ +//! Charts example showcasing line charts, bar charts, sparklines, and the plotting canvas. + +const std = @import("std"); +const zz = @import("zigzag"); + +const Model = struct { + chart: zz.Chart, + bars: zz.BarChart, + spark: zz.Sparkline, + phase: f64, + + pub const Msg = union(enum) { + key: zz.KeyEvent, + tick: zz.msg.Tick, + }; + + pub fn init(self: *Model, ctx: *zz.Context) zz.Cmd(Msg) { + self.chart = zz.Chart.init(ctx.persistent_allocator); + self.chart.setSize(44, 16); + self.chart.setMarker(.braille); + self.chart.setLegendPosition(.top); + self.chart.x_axis = .{ + .title = "Time", + .tick_count = 5, + .show_grid = true, + }; + self.chart.y_axis = .{ + .title = "Load", + .tick_count = 5, + .show_grid = true, + }; + + var cpu = zz.ChartDataset.init(ctx.persistent_allocator, "CPU") catch unreachable; + cpu.setStyle((zz.Style{}).fg(zz.Color.cyan()).bold(true)); + cpu.setShowPoints(true); + var mem = zz.ChartDataset.init(ctx.persistent_allocator, "Memory") catch unreachable; + mem.setStyle((zz.Style{}).fg(zz.Color.magenta())); + + for (0..24) |i| { + const x = @as(f64, @floatFromInt(i)); + cpu.appendPoint(.{ .x = x, .y = 55.0 + @sin(x / 3.0) * 18.0 }) catch unreachable; + mem.appendPoint(.{ .x = x, .y = 40.0 + @cos(x / 4.0) * 14.0 }) catch unreachable; + } + + self.chart.addDataset(cpu) catch unreachable; + self.chart.addDataset(mem) catch unreachable; + + self.bars = zz.BarChart.init(ctx.persistent_allocator); + self.bars.setSize(30, 12); + self.bars.setOrientation(.horizontal); + self.bars.show_values = true; + self.bars.label_style = (zz.Style{}).fg(zz.Color.gray(18)).inline_style(true); + self.bars.positive_style = (zz.Style{}).fg(zz.Color.green()).inline_style(true); + self.bars.negative_style = (zz.Style{}).fg(zz.Color.red()).inline_style(true); + self.bars.axis_style = (zz.Style{}).fg(zz.Color.gray(10)).inline_style(true); + self.bars.addBar(zz.Bar.init(ctx.persistent_allocator, "api", 31) catch unreachable) catch unreachable; + self.bars.addBar(zz.Bar.init(ctx.persistent_allocator, "db", -12) catch unreachable) catch unreachable; + self.bars.addBar(zz.Bar.init(ctx.persistent_allocator, "queue", 22) catch unreachable) catch unreachable; + self.bars.addBar(zz.Bar.init(ctx.persistent_allocator, "cache", 14) catch unreachable) catch unreachable; + + self.spark = zz.Sparkline.init(ctx.persistent_allocator); + self.spark.setWidth(28); + self.spark.setSummary(.average); + self.spark.setRetentionLimit(120); + self.spark.setGradient(zz.Color.hex("#F97316"), zz.Color.hex("#22C55E")); + + for (0..60) |i| { + const x = @as(f64, @floatFromInt(i)); + self.spark.push(30.0 + 10.0 * @sin(x / 5.0)) catch unreachable; + } + + self.phase = 0; + return zz.Cmd(Msg).tickMs(80); + } + + pub fn deinit(self: *Model) void { + self.chart.deinit(); + self.bars.deinit(); + self.spark.deinit(); + } + + pub fn update(self: *Model, msg: Msg, _: *zz.Context) zz.Cmd(Msg) { + switch (msg) { + .key => |key| switch (key.key) { + .char => |c| if (c == 'q') return .quit, + .escape => return .quit, + else => {}, + }, + .tick => { + self.phase += 1.0; + + var cpu = &self.chart.datasets.items[0]; + var mem = &self.chart.datasets.items[1]; + if (cpu.points.items.len >= 32) _ = cpu.points.orderedRemove(0); + if (mem.points.items.len >= 32) _ = mem.points.orderedRemove(0); + + const next_x = if (cpu.points.items.len == 0) 0.0 else cpu.points.items[cpu.points.items.len - 1].x + 1.0; + cpu.appendPoint(.{ .x = next_x, .y = 55.0 + @sin((self.phase + next_x) / 3.0) * 18.0 }) catch {}; + mem.appendPoint(.{ .x = next_x, .y = 40.0 + @cos((self.phase + next_x) / 4.0) * 14.0 }) catch {}; + self.chart.x_axis.bounds = .{ .min = @max(0.0, next_x - 31.0), .max = next_x }; + + self.spark.push(30.0 + 10.0 * @sin((self.phase + next_x) / 5.0)) catch {}; + + self.bars.bars.items[0].value = 20.0 + @sin(self.phase / 3.0) * 18.0; + self.bars.bars.items[1].value = -5.0 - @cos(self.phase / 4.0) * 15.0; + self.bars.bars.items[2].value = 12.0 + @sin(self.phase / 5.0) * 12.0; + self.bars.bars.items[3].value = 8.0 + @cos(self.phase / 6.0) * 10.0; + + return zz.Cmd(Msg).tickMs(80); + }, + } + + return .none; + } + + pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { + const line_chart = self.chart.view(ctx.allocator) catch ""; + const bars = self.bars.view(ctx.allocator) catch ""; + const spark = self.spark.view(ctx.allocator) catch ""; + const canvas = self.renderCanvas(ctx) catch ""; + + const top = zz.joinHorizontal(ctx.allocator, &.{ + box(ctx, "Trend", line_chart) catch line_chart, + " ", + box(ctx, "Bars", bars) catch bars, + }) catch line_chart; + const bottom = zz.joinHorizontal(ctx.allocator, &.{ + box(ctx, "Sparkline", spark) catch spark, + " ", + box(ctx, "Canvas", canvas) catch canvas, + }) catch spark; + + const content = zz.joinVertical(ctx.allocator, &.{ top, "", bottom, "", "Press q to quit" }) catch top; + return zz.place.place(ctx.allocator, ctx.width, ctx.height, .center, .middle, content) catch content; + } + + fn renderCanvas(self: *const Model, ctx: *const zz.Context) ![]const u8 { + var canvas = zz.Canvas.init(ctx.allocator); + defer canvas.deinit(); + + canvas.setSize(28, 10); + canvas.setMarker(.braille); + canvas.setRanges(.{ .min = -1.2, .max = 1.2 }, .{ .min = -1.2, .max = 1.2 }); + + var point_style = zz.Style{}; + point_style = point_style.fg(zz.Color.yellow()); + point_style = point_style.inline_style(true); + + for (0..80) |i| { + const t = self.phase / 10.0 + @as(f64, @floatFromInt(i)) / 18.0; + const x = @sin(t * 1.7); + const y = @cos(t * 2.3); + try canvas.drawPointStyled(x, y, point_style, null); + } + + return try canvas.view(ctx.allocator); + } +}; + +fn box(ctx: *const zz.Context, title: []const u8, body: []const u8) ![]const u8 { + var style = zz.Style{}; + style = style.borderAll(zz.Border.rounded); + style = style.borderForeground(zz.Color.gray(12)); + style = style.paddingAll(1); + + var header_style = zz.Style{}; + header_style = header_style.bold(true); + header_style = header_style.fg(zz.Color.cyan()); + header_style = header_style.inline_style(true); + const header = try header_style.render(ctx.allocator, title); + + const content = try std.fmt.allocPrint(ctx.allocator, "{s}\n\n{s}", .{ header, body }); + return try style.render(ctx.allocator, content); +} + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + + var program = try zz.Program(Model).init(gpa.allocator()); + defer program.deinit(); + try program.run(); +} diff --git a/src/components/bar_chart.zig b/src/components/bar_chart.zig new file mode 100644 index 0000000..cd47f4d --- /dev/null +++ b/src/components/bar_chart.zig @@ -0,0 +1,289 @@ +//! Bar chart widget with vertical and horizontal layouts. + +const std = @import("std"); +const charting = @import("charting.zig"); +const measure = @import("../layout/measure.zig"); +const join = @import("../layout/join.zig"); +const style_mod = @import("../style/style.zig"); + +pub const Style = style_mod.Style; +pub const DataRange = charting.DataRange; +pub const Orientation = charting.Orientation; +pub const ValueFormatter = charting.ValueFormatter; + +pub const Bar = struct { + allocator: std.mem.Allocator, + label: []const u8, + value: f64, + style: ?Style = null, + glyph: []const u8 = "█", + + pub fn init(allocator: std.mem.Allocator, label: []const u8, value: f64) !Bar { + return .{ + .allocator = allocator, + .label = try allocator.dupe(u8, label), + .value = value, + }; + } + + pub fn deinit(self: *Bar) void { + self.allocator.free(self.label); + } + + pub fn setStyle(self: *Bar, style: Style) void { + self.style = charting.inlineStyle(style); + } + + pub fn setGlyph(self: *Bar, glyph: []const u8) void { + self.glyph = glyph; + } +}; + +pub const BarChart = struct { + allocator: std.mem.Allocator, + width: u16, + height: u16, + orientation: Orientation, + bars: std.array_list.Managed(Bar), + bar_width: u16, + gap: u16, + domain: ?DataRange, + baseline: f64, + show_labels: bool, + show_values: bool, + axis_style: Style, + label_style: Style, + value_style: Style, + positive_style: Style, + negative_style: Style, + formatter: ?ValueFormatter, + + pub fn init(allocator: std.mem.Allocator) BarChart { + return .{ + .allocator = allocator, + .width = 40, + .height = 12, + .orientation = .vertical, + .bars = std.array_list.Managed(Bar).init(allocator), + .bar_width = 2, + .gap = 1, + .domain = null, + .baseline = 0, + .show_labels = true, + .show_values = false, + .axis_style = charting.inlineStyle(Style{}), + .label_style = charting.inlineStyle(Style{}), + .value_style = charting.inlineStyle(Style{}), + .positive_style = charting.inlineStyle(Style{}), + .negative_style = charting.inlineStyle(Style{}), + .formatter = null, + }; + } + + pub fn deinit(self: *BarChart) void { + for (self.bars.items) |*bar| bar.deinit(); + self.bars.deinit(); + } + + pub fn clear(self: *BarChart) void { + for (self.bars.items) |*bar| bar.deinit(); + self.bars.clearRetainingCapacity(); + } + + pub fn addBar(self: *BarChart, bar: Bar) !void { + try self.bars.append(bar); + } + + pub fn setSize(self: *BarChart, width: u16, height: u16) void { + self.width = @max(8, width); + self.height = @max(4, height); + } + + pub fn setOrientation(self: *BarChart, orientation: Orientation) void { + self.orientation = orientation; + } + + pub fn setBarWidth(self: *BarChart, bar_width: u16) void { + self.bar_width = @max(1, bar_width); + } + + pub fn setGap(self: *BarChart, gap: u16) void { + self.gap = gap; + } + + pub fn setDomain(self: *BarChart, domain: ?DataRange) void { + self.domain = if (domain) |d| d.normalized() else null; + } + + pub fn setBaseline(self: *BarChart, baseline: f64) void { + self.baseline = baseline; + } + + pub fn setFormatter(self: *BarChart, formatter: ?ValueFormatter) void { + self.formatter = formatter; + } + + pub fn view(self: *const BarChart, allocator: std.mem.Allocator) ![]const u8 { + if (self.bars.items.len == 0) return try allocator.dupe(u8, ""); + + return switch (self.orientation) { + .vertical => self.renderVertical(allocator), + .horizontal => self.renderHorizontal(allocator), + }; + } + + fn resolvedDomain(self: *const BarChart) DataRange { + if (self.domain) |domain| return domain.normalized(); + + var min_value = self.baseline; + var max_value = self.baseline; + for (self.bars.items) |bar| { + min_value = @min(min_value, bar.value); + max_value = @max(max_value, bar.value); + } + + const range = DataRange{ .min = min_value, .max = max_value }; + return range.normalized(); + } + + fn renderVertical(self: *const BarChart, allocator: std.mem.Allocator) ![]const u8 { + const domain = self.resolvedDomain(); + const label_rows: usize = if (self.show_labels) 1 else 0; + const plot_height = @max(@as(usize, 1), @as(usize, self.height) -| label_rows); + const total_slot_width = @as(usize, self.bar_width) + @as(usize, self.gap); + const plot_width = @max(@as(usize, self.width), self.bars.items.len * total_slot_width); + + var buffer = try charting.CellBuffer.init(allocator, plot_width, plot_height); + defer buffer.deinit(); + var owned_values = std.array_list.Managed([]const u8).init(allocator); + defer { + for (owned_values.items) |value| allocator.free(value); + owned_values.deinit(); + } + for (0..plot_height) |y| for (0..plot_width) |x| buffer.setSlice(x, y, " ", null); + + const baseline_row = charting.mapY(self.baseline, domain, plot_height); + for (0..plot_width) |x| buffer.setSlice(x, baseline_row, "─", self.axis_style); + + for (self.bars.items, 0..) |bar, index| { + const start_x = index * total_slot_width; + const end_x = @min(plot_width, start_x + @as(usize, self.bar_width)); + const target_row = charting.mapY(bar.value, domain, plot_height); + const top = @min(target_row, baseline_row); + const bottom = @max(target_row, baseline_row); + const bar_style = bar.style orelse if (bar.value >= self.baseline) self.positive_style else self.negative_style; + + for (top..bottom + 1) |y| { + for (start_x..end_x) |x| { + buffer.setSlice(x, y, bar.glyph, bar_style); + } + } + + if (self.show_values) { + const label = try self.formatValue(allocator, bar.value); + try owned_values.append(label); + const row = if (bar.value >= self.baseline) top -| 1 else @min(plot_height - 1, bottom + 1); + const start = start_x + ((end_x - start_x) -| measure.width(label)) / 2; + buffer.writeText(start, row, label, self.value_style); + } + } + + const plot = try buffer.render(allocator); + defer allocator.free(plot); + if (!self.show_labels) return try allocator.dupe(u8, plot); + + var label_row = try charting.CellBuffer.init(allocator, plot_width, 1); + defer label_row.deinit(); + var owned_labels = std.array_list.Managed([]const u8).init(allocator); + defer { + for (owned_labels.items) |label| allocator.free(label); + owned_labels.deinit(); + } + for (0..plot_width) |x| label_row.setSlice(x, 0, " ", null); + + for (self.bars.items, 0..) |bar, index| { + const start_x = index * total_slot_width; + const label_width = measure.width(bar.label); + const label_start = start_x + (@as(usize, self.bar_width) -| @min(label_width, @as(usize, self.bar_width))) / 2; + const clipped = try truncateLabel(allocator, bar.label, @as(usize, self.bar_width)); + try owned_labels.append(clipped); + label_row.writeText(label_start, 0, clipped, self.label_style); + } + + const labels = try label_row.render(allocator); + defer allocator.free(labels); + return try join.vertical(allocator, .left, &.{ plot, labels }); + } + + fn renderHorizontal(self: *const BarChart, allocator: std.mem.Allocator) ![]const u8 { + const domain = self.resolvedDomain(); + const label_width = if (self.show_labels) self.maxLabelWidth() else 0; + const label_offset: usize = label_width + (if (label_width > 0) @as(usize, 1) else 0); + const plot_width = @max(@as(usize, 1), @as(usize, self.width) -| label_offset); + const total_slot_height = @as(usize, self.bar_width) + @as(usize, self.gap); + const plot_height = @max(@as(usize, self.height), self.bars.items.len * total_slot_height); + + var buffer = try charting.CellBuffer.init(allocator, label_offset + plot_width, plot_height); + defer buffer.deinit(); + var owned_values = std.array_list.Managed([]const u8).init(allocator); + defer { + for (owned_values.items) |value| allocator.free(value); + owned_values.deinit(); + } + for (0..buffer.height) |y| for (0..buffer.width) |x| buffer.setSlice(x, y, " ", null); + + const offset = label_offset; + const baseline_col = offset + charting.mapX(self.baseline, domain, plot_width); + for (0..plot_height) |y| buffer.setSlice(baseline_col, y, "│", self.axis_style); + + for (self.bars.items, 0..) |bar, index| { + const start_y = index * total_slot_height; + const end_y = @min(plot_height, start_y + @as(usize, self.bar_width)); + const target_col = offset + charting.mapX(bar.value, domain, plot_width); + const left = @min(target_col, baseline_col); + const right = @max(target_col, baseline_col); + const bar_style = bar.style orelse if (bar.value >= self.baseline) self.positive_style else self.negative_style; + + for (start_y..end_y) |y| { + if (self.show_labels and y == start_y) { + buffer.writeText(0, y, bar.label, self.label_style); + } + + for (left..right + 1) |x| { + buffer.setSlice(x, y, bar.glyph, bar_style); + } + + if (self.show_values and y == start_y) { + const value_text = try self.formatValue(allocator, bar.value); + try owned_values.append(value_text); + const text_x = if (bar.value >= self.baseline) + @min(buffer.width - measure.width(value_text), right + 1) + else + @max(offset, left -| (measure.width(value_text) + 1)); + buffer.writeText(text_x, y, value_text, self.value_style); + } + } + } + + return try buffer.render(allocator); + } + + fn maxLabelWidth(self: *const BarChart) usize { + var max_width: usize = 0; + for (self.bars.items) |bar| { + max_width = @max(max_width, measure.width(bar.label)); + } + return max_width; + } + + fn formatValue(self: *const BarChart, allocator: std.mem.Allocator, value: f64) ![]const u8 { + const formatter = self.formatter orelse charting.defaultFormatter; + return try formatter(allocator, value); + } +}; + +fn truncateLabel(allocator: std.mem.Allocator, label: []const u8, width: usize) ![]const u8 { + if (width == 0) return try allocator.dupe(u8, ""); + if (measure.width(label) <= width) return try allocator.dupe(u8, label); + return try measure.truncate(allocator, label, width); +} diff --git a/src/components/canvas.zig b/src/components/canvas.zig new file mode 100644 index 0000000..7abc9b6 --- /dev/null +++ b/src/components/canvas.zig @@ -0,0 +1,429 @@ +//! Plotting canvas with braille and cell-based markers. + +const std = @import("std"); +const charting = @import("charting.zig"); +const style_mod = @import("../style/style.zig"); + +pub const Marker = charting.Marker; +pub const Point = charting.Point; +pub const DataRange = charting.DataRange; +pub const Style = style_mod.Style; + +pub const Canvas = struct { + allocator: std.mem.Allocator, + width: u16, + height: u16, + x_range: DataRange, + y_range: DataRange, + marker: Marker, + background_glyph: []const u8, + point_glyph: []const u8, + line_glyph: []const u8, + default_style: Style, + operations: std.array_list.Managed(Operation), + + const Operation = union(enum) { + point: PointOp, + line: LineOp, + rect: RectOp, + text: TextOp, + }; + + const PointOp = struct { + point: Point, + style: Style, + glyph: ?[]const u8 = null, + }; + + const LineOp = struct { + from: Point, + to: Point, + style: Style, + glyph: ?[]const u8 = null, + }; + + const RectOp = struct { + min: Point, + max: Point, + filled: bool, + style: Style, + glyph: ?[]const u8 = null, + }; + + const TextOp = struct { + origin: Point, + text: []const u8, + style: Style, + }; + + const BrailleCell = struct { + bits: u8 = 0, + style: ?Style = null, + }; + + pub fn init(allocator: std.mem.Allocator) Canvas { + return .{ + .allocator = allocator, + .width = 40, + .height = 12, + .x_range = .{ .min = 0, .max = 1 }, + .y_range = .{ .min = 0, .max = 1 }, + .marker = .braille, + .background_glyph = " ", + .point_glyph = "•", + .line_glyph = "█", + .default_style = charting.inlineStyle(Style{}), + .operations = std.array_list.Managed(Operation).init(allocator), + }; + } + + pub fn deinit(self: *Canvas) void { + self.operations.deinit(); + } + + pub fn clear(self: *Canvas) void { + self.operations.clearRetainingCapacity(); + } + + pub fn setSize(self: *Canvas, width: u16, height: u16) void { + self.width = @max(1, width); + self.height = @max(1, height); + } + + pub fn setRanges(self: *Canvas, x_range: DataRange, y_range: DataRange) void { + self.x_range = x_range.normalized(); + self.y_range = y_range.normalized(); + } + + pub fn setMarker(self: *Canvas, marker: Marker) void { + self.marker = marker; + } + + pub fn setStyle(self: *Canvas, style: Style) void { + self.default_style = charting.inlineStyle(style); + } + + pub fn setGlyphs(self: *Canvas, point_glyph: []const u8, line_glyph: []const u8) void { + self.point_glyph = point_glyph; + self.line_glyph = line_glyph; + } + + pub fn setBackground(self: *Canvas, glyph: []const u8) void { + self.background_glyph = glyph; + } + + pub fn drawPoint(self: *Canvas, x: f64, y: f64) !void { + try self.drawPointStyled(x, y, self.default_style, null); + } + + pub fn drawPointStyled(self: *Canvas, x: f64, y: f64, style: Style, glyph: ?[]const u8) !void { + try self.operations.append(.{ + .point = .{ + .point = .{ .x = x, .y = y }, + .style = charting.inlineStyle(style), + .glyph = glyph, + }, + }); + } + + pub fn drawLine(self: *Canvas, x0: f64, y0: f64, x1: f64, y1: f64) !void { + try self.drawLineStyled(x0, y0, x1, y1, self.default_style, null); + } + + pub fn drawLineStyled(self: *Canvas, x0: f64, y0: f64, x1: f64, y1: f64, style: Style, glyph: ?[]const u8) !void { + try self.operations.append(.{ + .line = .{ + .from = .{ .x = x0, .y = y0 }, + .to = .{ .x = x1, .y = y1 }, + .style = charting.inlineStyle(style), + .glyph = glyph, + }, + }); + } + + pub fn drawRect(self: *Canvas, x0: f64, y0: f64, x1: f64, y1: f64, filled: bool, style: Style, glyph: ?[]const u8) !void { + try self.operations.append(.{ + .rect = .{ + .min = .{ .x = @min(x0, x1), .y = @min(y0, y1) }, + .max = .{ .x = @max(x0, x1), .y = @max(y0, y1) }, + .filled = filled, + .style = charting.inlineStyle(style), + .glyph = glyph, + }, + }); + } + + pub fn drawText(self: *Canvas, x: f64, y: f64, text: []const u8, style: Style) !void { + try self.operations.append(.{ + .text = .{ + .origin = .{ .x = x, .y = y }, + .text = text, + .style = charting.inlineStyle(style), + }, + }); + } + + pub fn view(self: *const Canvas, allocator: std.mem.Allocator) ![]const u8 { + return switch (self.marker) { + .braille => self.renderBraille(allocator), + .block, .dot, .ascii => self.renderCells(allocator), + }; + } + + fn renderCells(self: *const Canvas, allocator: std.mem.Allocator) ![]const u8 { + var buffer = try charting.CellBuffer.init(allocator, self.width, self.height); + defer buffer.deinit(); + + for (0..self.height) |y| { + for (0..self.width) |x| { + buffer.setSlice(x, y, self.background_glyph, null); + } + } + + for (self.operations.items) |op| { + switch (op) { + .point => |point_op| { + const x = charting.mapX(point_op.point.x, self.x_range, self.width); + const y = charting.mapY(point_op.point.y, self.y_range, self.height); + buffer.setSlice(x, y, point_op.glyph orelse self.defaultCellGlyph(), point_op.style); + }, + .line => |line_op| { + const x0 = charting.mapX(line_op.from.x, self.x_range, self.width); + const y0 = charting.mapY(line_op.from.y, self.y_range, self.height); + const x1 = charting.mapX(line_op.to.x, self.x_range, self.width); + const y1 = charting.mapY(line_op.to.y, self.y_range, self.height); + drawLineCells(&buffer, x0, y0, x1, y1, line_op.glyph orelse self.lineCellGlyph(), line_op.style); + }, + .rect => |rect_op| { + const min_x = charting.mapX(rect_op.min.x, self.x_range, self.width); + const min_y = charting.mapY(rect_op.max.y, self.y_range, self.height); + const max_x = charting.mapX(rect_op.max.x, self.x_range, self.width); + const max_y = charting.mapY(rect_op.min.y, self.y_range, self.height); + drawRectCells(&buffer, min_x, min_y, max_x, max_y, rect_op.filled, rect_op.glyph orelse self.lineCellGlyph(), rect_op.style); + }, + .text => |text_op| { + const x = charting.mapX(text_op.origin.x, self.x_range, self.width); + const y = charting.mapY(text_op.origin.y, self.y_range, self.height); + buffer.writeText(x, y, text_op.text, text_op.style); + }, + } + } + + return try buffer.render(allocator); + } + + fn renderBraille(self: *const Canvas, allocator: std.mem.Allocator) ![]const u8 { + const cell_count = @as(usize, self.width) * @as(usize, self.height); + const braille_cells = try allocator.alloc(BrailleCell, cell_count); + defer allocator.free(braille_cells); + for (braille_cells) |*cell| cell.* = .{}; + + var overlay = try charting.CellBuffer.init(allocator, self.width, self.height); + defer overlay.deinit(); + + for (self.operations.items) |op| { + switch (op) { + .point => |point_op| { + const x = charting.mapX(point_op.point.x, self.x_range, @as(usize, self.width) * 2); + const y = charting.mapY(point_op.point.y, self.y_range, @as(usize, self.height) * 4); + setBraillePixel(braille_cells, self.width, x, y, point_op.style); + }, + .line => |line_op| { + const x0 = charting.mapX(line_op.from.x, self.x_range, @as(usize, self.width) * 2); + const y0 = charting.mapY(line_op.from.y, self.y_range, @as(usize, self.height) * 4); + const x1 = charting.mapX(line_op.to.x, self.x_range, @as(usize, self.width) * 2); + const y1 = charting.mapY(line_op.to.y, self.y_range, @as(usize, self.height) * 4); + drawBrailleLine(braille_cells, self.width, x0, y0, x1, y1, line_op.style); + }, + .rect => |rect_op| { + const min_x = charting.mapX(rect_op.min.x, self.x_range, @as(usize, self.width) * 2); + const min_y = charting.mapY(rect_op.max.y, self.y_range, @as(usize, self.height) * 4); + const max_x = charting.mapX(rect_op.max.x, self.x_range, @as(usize, self.width) * 2); + const max_y = charting.mapY(rect_op.min.y, self.y_range, @as(usize, self.height) * 4); + drawBrailleRect(braille_cells, self.width, min_x, min_y, max_x, max_y, rect_op.filled, rect_op.style); + }, + .text => |text_op| { + const x = charting.mapX(text_op.origin.x, self.x_range, self.width); + const y = charting.mapY(text_op.origin.y, self.y_range, self.height); + overlay.writeText(x, y, text_op.text, text_op.style); + }, + } + } + + var final = try charting.CellBuffer.init(allocator, self.width, self.height); + defer final.deinit(); + + for (0..self.height) |y| { + for (0..self.width) |x| { + const cell = braille_cells[y * self.width + x]; + if (cell.bits == 0) { + final.setSlice(x, y, self.background_glyph, null); + } else { + final.setCodepoint(x, y, @as(u21, 0x2800) + cell.bits, cell.style); + } + } + } + + for (0..self.height) |y| { + for (0..self.width) |x| { + const cell = overlay.cells[y * self.width + x]; + if (glyphIsBlank(cell.glyph)) continue; + final.cells[y * self.width + x] = cell; + } + } + + return try final.render(allocator); + } + + fn defaultCellGlyph(self: *const Canvas) []const u8 { + return switch (self.marker) { + .braille => "•", + .block => if (self.point_glyph.len > 0) self.point_glyph else "█", + .dot => if (self.point_glyph.len > 0) self.point_glyph else "•", + .ascii => if (self.point_glyph.len > 0) self.point_glyph else "*", + }; + } + + fn lineCellGlyph(self: *const Canvas) []const u8 { + return switch (self.marker) { + .braille => "•", + .block => if (self.line_glyph.len > 0) self.line_glyph else "█", + .dot => if (self.line_glyph.len > 0) self.line_glyph else "•", + .ascii => if (self.line_glyph.len > 0) self.line_glyph else "*", + }; + } +}; + +fn glyphIsBlank(glyph: charting.Glyph) bool { + return switch (glyph) { + .slice => |slice| slice.len == 0 or std.mem.eql(u8, slice, " "), + .codepoint => |cp| cp == ' ' or cp == 0x2800, + }; +} + +fn drawLineCells(buffer: *charting.CellBuffer, x0: usize, y0: usize, x1: usize, y1: usize, glyph: []const u8, style: ?Style) void { + const dx = @as(isize, @intCast(x1)) - @as(isize, @intCast(x0)); + const dy = @as(isize, @intCast(y1)) - @as(isize, @intCast(y0)); + const steps = @max(@abs(dx), @abs(dy)); + if (steps == 0) { + buffer.setSlice(x0, y0, glyph, style); + return; + } + + const step_x = @as(f64, @floatFromInt(dx)) / @as(f64, @floatFromInt(steps)); + const step_y = @as(f64, @floatFromInt(dy)) / @as(f64, @floatFromInt(steps)); + + var x = @as(f64, @floatFromInt(x0)); + var y = @as(f64, @floatFromInt(y0)); + var i: usize = 0; + while (i <= @as(usize, @intCast(steps))) : (i += 1) { + buffer.setSlice( + @as(usize, @intFromFloat(@round(x))), + @as(usize, @intFromFloat(@round(y))), + glyph, + style, + ); + x += step_x; + y += step_y; + } +} + +fn drawRectCells(buffer: *charting.CellBuffer, min_x: usize, min_y: usize, max_x: usize, max_y: usize, filled: bool, glyph: []const u8, style: ?Style) void { + const left = @min(min_x, max_x); + const right = @max(min_x, max_x); + const top = @min(min_y, max_y); + const bottom = @max(min_y, max_y); + + if (filled) { + for (top..bottom + 1) |y| { + for (left..right + 1) |x| { + buffer.setSlice(x, y, glyph, style); + } + } + return; + } + + drawLineCells(buffer, left, top, right, top, glyph, style); + drawLineCells(buffer, left, bottom, right, bottom, glyph, style); + drawLineCells(buffer, left, top, left, bottom, glyph, style); + drawLineCells(buffer, right, top, right, bottom, glyph, style); +} + +fn setBraillePixel(cells: []Canvas.BrailleCell, width: u16, x: usize, y: usize, style: ?Style) void { + const cell_x = x / 2; + const cell_y = y / 4; + if (cell_x >= width) return; + const height = if (width == 0) 0 else cells.len / width; + if (cell_y >= height) return; + + const local_x = x % 2; + const local_y = y % 4; + + const bit = switch (local_x) { + 0 => switch (local_y) { + 0 => @as(u8, 0x01), + 1 => @as(u8, 0x02), + 2 => @as(u8, 0x04), + else => @as(u8, 0x40), + }, + else => switch (local_y) { + 0 => @as(u8, 0x08), + 1 => @as(u8, 0x10), + 2 => @as(u8, 0x20), + else => @as(u8, 0x80), + }, + }; + + const index = cell_y * width + cell_x; + cells[index].bits |= bit; + cells[index].style = style; +} + +fn drawBrailleLine(cells: []Canvas.BrailleCell, width: u16, x0: usize, y0: usize, x1: usize, y1: usize, style: ?Style) void { + const dx = @as(isize, @intCast(x1)) - @as(isize, @intCast(x0)); + const dy = @as(isize, @intCast(y1)) - @as(isize, @intCast(y0)); + const steps = @max(@abs(dx), @abs(dy)); + if (steps == 0) { + setBraillePixel(cells, width, x0, y0, style); + return; + } + + const step_x = @as(f64, @floatFromInt(dx)) / @as(f64, @floatFromInt(steps)); + const step_y = @as(f64, @floatFromInt(dy)) / @as(f64, @floatFromInt(steps)); + + var x = @as(f64, @floatFromInt(x0)); + var y = @as(f64, @floatFromInt(y0)); + var i: usize = 0; + while (i <= @as(usize, @intCast(steps))) : (i += 1) { + setBraillePixel( + cells, + width, + @as(usize, @intFromFloat(@round(x))), + @as(usize, @intFromFloat(@round(y))), + style, + ); + x += step_x; + y += step_y; + } +} + +fn drawBrailleRect(cells: []Canvas.BrailleCell, width: u16, min_x: usize, min_y: usize, max_x: usize, max_y: usize, filled: bool, style: ?Style) void { + const left = @min(min_x, max_x); + const right = @max(min_x, max_x); + const top = @min(min_y, max_y); + const bottom = @max(min_y, max_y); + + if (filled) { + for (top..bottom + 1) |y| { + for (left..right + 1) |x| { + setBraillePixel(cells, width, x, y, style); + } + } + return; + } + + drawBrailleLine(cells, width, left, top, right, top, style); + drawBrailleLine(cells, width, left, bottom, right, bottom, style); + drawBrailleLine(cells, width, left, top, left, bottom, style); + drawBrailleLine(cells, width, right, top, right, bottom, style); +} diff --git a/src/components/chart.zig b/src/components/chart.zig new file mode 100644 index 0000000..913c70e --- /dev/null +++ b/src/components/chart.zig @@ -0,0 +1,586 @@ +//! Cartesian chart widget with axes, legend, and multiple datasets. + +const std = @import("std"); +const charting = @import("charting.zig"); +const canvas_mod = @import("canvas.zig"); +const join = @import("../layout/join.zig"); +const measure = @import("../layout/measure.zig"); +const place = @import("../layout/place.zig"); +const style_mod = @import("../style/style.zig"); + +pub const Style = style_mod.Style; +pub const AxisLabel = charting.AxisLabel; +pub const DataRange = charting.DataRange; +pub const GraphType = charting.GraphType; +pub const LegendPosition = charting.LegendPosition; +pub const Marker = charting.Marker; +pub const Point = charting.Point; +pub const ValueFormatter = charting.ValueFormatter; + +pub const Axis = struct { + title: []const u8 = "", + bounds: ?DataRange = null, + labels: []const AxisLabel = &.{}, + tick_count: u8 = 5, + show_line: bool = true, + show_labels: bool = true, + show_grid: bool = false, + style: Style = charting.inlineStyle(Style{}), + label_style: Style = charting.inlineStyle(Style{}), + title_style: Style = charting.inlineStyle((Style{}).bold(true)), + grid_style: Style = charting.inlineStyle(Style{}), + formatter: ?ValueFormatter = null, +}; + +pub const Dataset = struct { + allocator: std.mem.Allocator, + label: []const u8, + points: std.array_list.Managed(Point), + style: Style, + graph_type: GraphType, + show_points: bool, + point_glyph: ?[]const u8, + fill_to: ?f64, + + pub fn init(allocator: std.mem.Allocator, label: []const u8) !Dataset { + return .{ + .allocator = allocator, + .label = try allocator.dupe(u8, label), + .points = std.array_list.Managed(Point).init(allocator), + .style = charting.inlineStyle(Style{}), + .graph_type = .line, + .show_points = false, + .point_glyph = null, + .fill_to = null, + }; + } + + pub fn deinit(self: *Dataset) void { + self.allocator.free(self.label); + self.points.deinit(); + } + + pub fn setStyle(self: *Dataset, style: Style) void { + self.style = charting.inlineStyle(style); + } + + pub fn setGraphType(self: *Dataset, graph_type: GraphType) void { + self.graph_type = graph_type; + } + + pub fn setShowPoints(self: *Dataset, show_points: bool) void { + self.show_points = show_points; + } + + pub fn setPointGlyph(self: *Dataset, glyph: ?[]const u8) void { + self.point_glyph = glyph; + } + + pub fn setFillBaseline(self: *Dataset, baseline: ?f64) void { + self.fill_to = baseline; + } + + pub fn appendPoint(self: *Dataset, point: Point) !void { + try self.points.append(point); + } + + pub fn setPoints(self: *Dataset, points: []const Point) !void { + self.points.clearRetainingCapacity(); + try self.points.appendSlice(points); + } +}; + +pub const Chart = struct { + allocator: std.mem.Allocator, + width: u16, + height: u16, + marker: Marker, + x_axis: Axis, + y_axis: Axis, + datasets: std.array_list.Managed(Dataset), + legend_position: LegendPosition, + legend_style: Style, + plot_background: []const u8, + + pub fn init(allocator: std.mem.Allocator) Chart { + return .{ + .allocator = allocator, + .width = 60, + .height = 18, + .marker = .braille, + .x_axis = .{}, + .y_axis = .{}, + .datasets = std.array_list.Managed(Dataset).init(allocator), + .legend_position = .bottom, + .legend_style = charting.inlineStyle((Style{}).bold(true)), + .plot_background = " ", + }; + } + + pub fn deinit(self: *Chart) void { + for (self.datasets.items) |*dataset| dataset.deinit(); + self.datasets.deinit(); + } + + pub fn clearDatasets(self: *Chart) void { + for (self.datasets.items) |*dataset| dataset.deinit(); + self.datasets.clearRetainingCapacity(); + } + + pub fn setSize(self: *Chart, width: u16, height: u16) void { + self.width = @max(10, width); + self.height = @max(6, height); + } + + pub fn setMarker(self: *Chart, marker: Marker) void { + self.marker = marker; + } + + pub fn setLegendPosition(self: *Chart, position: LegendPosition) void { + self.legend_position = position; + } + + pub fn setPlotBackground(self: *Chart, glyph: []const u8) void { + self.plot_background = glyph; + } + + pub fn addDataset(self: *Chart, dataset: Dataset) !void { + try self.datasets.append(dataset); + } + + pub fn view(self: *const Chart, allocator: std.mem.Allocator) ![]const u8 { + const resolved_x = self.resolveRange(.x); + const resolved_y = self.resolveRange(.y); + + var x_ticks = try TickSet.init(allocator, self.x_axis, resolved_x, self.width); + defer x_ticks.deinit(); + var y_ticks = try TickSet.init(allocator, self.y_axis, resolved_y, self.height); + defer y_ticks.deinit(); + + const y_label_width = if (self.y_axis.show_labels) y_ticks.maxLabelWidth() else 0; + const y_axis_offset: usize = if (self.y_axis.show_line) + 2 + else if (y_label_width > 0) + 1 + else + 0; + const left_gutter = y_label_width + y_axis_offset; + const x_axis_line_rows: usize = if (self.x_axis.show_line) 1 else 0; + const x_label_rows: usize = if (self.x_axis.show_labels) 1 else 0; + const x_title_rows: usize = if (self.x_axis.title.len > 0) 1 else 0; + const y_title_rows: usize = if (self.y_axis.title.len > 0) 1 else 0; + + const plot_width = @max(@as(usize, 1), @as(usize, self.width) -| left_gutter); + const plot_height = @max(@as(usize, 1), @as(usize, self.height) -| (x_axis_line_rows + x_label_rows + x_title_rows + y_title_rows)); + + const grid = try self.renderGrid(allocator, plot_width, plot_height, &x_ticks, &y_ticks); + defer allocator.free(grid); + + const datasets_view = try self.renderDatasets(allocator, plot_width, plot_height, resolved_x, resolved_y); + defer allocator.free(datasets_view); + + const plot = try place.overlay(allocator, grid, datasets_view, 0, 0); + defer allocator.free(plot); + + var rows = std.array_list.Managed([]const u8).init(allocator); + defer { + for (rows.items) |row| allocator.free(row); + rows.deinit(); + } + + if (self.y_axis.title.len > 0) { + const row = try self.renderYAxisTitleRow(allocator, y_label_width, plot_width); + try rows.append(row); + } + + var plot_lines = std.mem.splitScalar(u8, plot, '\n'); + var row_index: usize = 0; + while (plot_lines.next()) |plot_line| : (row_index += 1) { + const row = try self.renderPlotRow(allocator, row_index, plot_line, plot_height, y_label_width, &y_ticks); + try rows.append(row); + } + + if (self.x_axis.show_line) { + const axis_row = try self.renderXAxisLineRow(allocator, plot_width, y_label_width); + try rows.append(axis_row); + } + + if (self.x_axis.show_labels) { + const label_row = try self.renderXAxisLabelsRow(allocator, plot_width, y_label_width, &x_ticks); + try rows.append(label_row); + } + + if (self.x_axis.title.len > 0) { + const title_row = try self.renderXAxisTitleRow(allocator, plot_width, y_label_width); + try rows.append(title_row); + } + + const chart_body = try join.vertical(allocator, .left, rows.items); + defer allocator.free(chart_body); + + const legend = try self.renderLegend(allocator); + defer allocator.free(legend); + + return switch (self.legend_position) { + .hidden => try allocator.dupe(u8, chart_body), + .top => if (legend.len == 0) try allocator.dupe(u8, chart_body) else try join.vertical(allocator, .left, &.{ legend, chart_body }), + .bottom => if (legend.len == 0) try allocator.dupe(u8, chart_body) else try join.vertical(allocator, .left, &.{ chart_body, legend }), + .left => if (legend.len == 0) try allocator.dupe(u8, chart_body) else try join.horizontal(allocator, .top, &.{ legend, " ", chart_body }), + .right => if (legend.len == 0) try allocator.dupe(u8, chart_body) else try join.horizontal(allocator, .top, &.{ chart_body, " ", legend }), + }; + } + + const AxisKind = enum { x, y }; + + fn resolveRange(self: *const Chart, axis_kind: AxisKind) DataRange { + const axis = switch (axis_kind) { + .x => self.x_axis, + .y => self.y_axis, + }; + if (axis.bounds) |bounds| return bounds.normalized(); + + var found = false; + var min_value: f64 = 0; + var max_value: f64 = 0; + + for (self.datasets.items) |dataset| { + for (dataset.points.items) |point| { + const value = switch (axis_kind) { + .x => point.x, + .y => point.y, + }; + if (!std.math.isFinite(value)) continue; + if (!found) { + min_value = value; + max_value = value; + found = true; + } else { + min_value = @min(min_value, value); + max_value = @max(max_value, value); + } + } + + if (axis_kind == .y and dataset.graph_type == .area) { + if (dataset.fill_to) |baseline| { + if (!found) { + min_value = baseline; + max_value = baseline; + found = true; + } else { + min_value = @min(min_value, baseline); + max_value = @max(max_value, baseline); + } + } + } + } + + if (!found) return .{ .min = 0, .max = 1 }; + const range = DataRange{ .min = min_value, .max = max_value }; + return range.normalized(); + } + + fn renderGrid(self: *const Chart, allocator: std.mem.Allocator, plot_width: usize, plot_height: usize, x_ticks: *const TickSet, y_ticks: *const TickSet) ![]const u8 { + var buffer = try charting.CellBuffer.init(allocator, plot_width, plot_height); + defer buffer.deinit(); + + for (0..plot_height) |y| { + for (0..plot_width) |x| { + buffer.setSlice(x, y, self.plot_background, null); + } + } + + if (self.y_axis.show_grid) { + for (y_ticks.positions.items) |y| { + if (y >= plot_height) continue; + for (0..plot_width) |x| { + setGridGlyph(&buffer, x, y, .horizontal, self.y_axis.grid_style); + } + } + } + + if (self.x_axis.show_grid) { + for (x_ticks.positions.items) |x| { + if (x >= plot_width) continue; + for (0..plot_height) |y| { + setGridGlyph(&buffer, x, y, .vertical, self.x_axis.grid_style); + } + } + } + + return try buffer.render(allocator); + } + + fn renderDatasets(self: *const Chart, allocator: std.mem.Allocator, plot_width: usize, plot_height: usize, x_range: DataRange, y_range: DataRange) ![]const u8 { + var plot = canvas_mod.Canvas.init(allocator); + defer plot.deinit(); + + plot.setSize(@intCast(plot_width), @intCast(plot_height)); + plot.setRanges(x_range, y_range); + plot.setMarker(self.marker); + plot.setBackground(" "); + + for (self.datasets.items) |dataset| { + switch (dataset.graph_type) { + .line => { + if (dataset.points.items.len == 1) { + const point = dataset.points.items[0]; + try plot.drawPointStyled(point.x, point.y, dataset.style, dataset.point_glyph); + } else if (dataset.points.items.len > 1) { + var i: usize = 1; + while (i < dataset.points.items.len) : (i += 1) { + const prev = dataset.points.items[i - 1]; + const point = dataset.points.items[i]; + try plot.drawLineStyled(prev.x, prev.y, point.x, point.y, dataset.style, null); + } + } + + if (dataset.show_points) { + for (dataset.points.items) |point| { + try plot.drawPointStyled(point.x, point.y, dataset.style, dataset.point_glyph); + } + } + }, + .scatter => { + for (dataset.points.items) |point| { + try plot.drawPointStyled(point.x, point.y, dataset.style, dataset.point_glyph); + } + }, + .area => { + if (dataset.points.items.len > 1) { + var i: usize = 1; + while (i < dataset.points.items.len) : (i += 1) { + const prev = dataset.points.items[i - 1]; + const point = dataset.points.items[i]; + try plot.drawLineStyled(prev.x, prev.y, point.x, point.y, dataset.style, null); + } + } + + const baseline = dataset.fill_to orelse y_range.min; + for (dataset.points.items) |point| { + try plot.drawLineStyled(point.x, baseline, point.x, point.y, dataset.style, null); + } + }, + } + } + + return try plot.view(allocator); + } + + fn renderYAxisTitleRow(self: *const Chart, allocator: std.mem.Allocator, y_label_width: usize, plot_width: usize) ![]const u8 { + const prefix_width = leftPrefixWidth(self, y_label_width); + var row = try charting.CellBuffer.init(allocator, prefix_width + plot_width, 1); + defer row.deinit(); + + for (0..row.width) |x| row.setSlice(x, 0, " ", null); + row.writeText(prefix_width, 0, self.y_axis.title, self.y_axis.title_style); + return try row.render(allocator); + } + + fn renderPlotRow(self: *const Chart, allocator: std.mem.Allocator, row_index: usize, plot_line: []const u8, plot_height: usize, y_label_width: usize, y_ticks: *const TickSet) ![]const u8 { + _ = plot_height; + var result = std.array_list.Managed(u8).init(allocator); + const writer = result.writer(); + + if (self.y_axis.show_labels) { + const label = y_ticks.labelForRow(row_index) orelse ""; + const padded = try renderPaddedLabel(allocator, label, y_label_width, self.y_axis.label_style); + defer allocator.free(padded); + try writer.writeAll(padded); + } + + if (self.y_axis.show_line) { + const axis_glyph = try self.y_axis.style.render(allocator, " │"); + defer allocator.free(axis_glyph); + try writer.writeAll(axis_glyph); + } else if (y_label_width > 0) { + try writer.writeByte(' '); + } + + try writer.writeAll(plot_line); + return try result.toOwnedSlice(); + } + + fn renderXAxisLineRow(self: *const Chart, allocator: std.mem.Allocator, plot_width: usize, y_label_width: usize) ![]const u8 { + var result = std.array_list.Managed(u8).init(allocator); + const writer = result.writer(); + + if (self.y_axis.show_labels) { + for (0..y_label_width) |_| try writer.writeByte(' '); + } + + if (self.y_axis.show_line) { + const corner = try self.x_axis.style.render(allocator, " └"); + defer allocator.free(corner); + try writer.writeAll(corner); + } else if (y_label_width > 0) { + try writer.writeByte(' '); + } + + var axis_style = self.x_axis.style; + axis_style = charting.inlineStyle(axis_style); + const segment = try axis_style.render(allocator, "─"); + defer allocator.free(segment); + for (0..plot_width) |_| try writer.writeAll(segment); + + return try result.toOwnedSlice(); + } + + fn renderXAxisLabelsRow(self: *const Chart, allocator: std.mem.Allocator, plot_width: usize, y_label_width: usize, x_ticks: *const TickSet) ![]const u8 { + const offset = leftPrefixWidth(self, y_label_width); + var row = try charting.CellBuffer.init(allocator, offset + plot_width, 1); + defer row.deinit(); + + for (0..row.width) |x| row.setSlice(x, 0, " ", null); + + for (x_ticks.positions.items, x_ticks.labels.items) |x, label| { + const label_width = measure.width(label); + const start = offset + x -| (label_width / 2); + row.writeText(start, 0, label, self.x_axis.label_style); + } + + return try row.render(allocator); + } + + fn renderXAxisTitleRow(self: *const Chart, allocator: std.mem.Allocator, plot_width: usize, y_label_width: usize) ![]const u8 { + const offset = leftPrefixWidth(self, y_label_width); + var row = try charting.CellBuffer.init(allocator, offset + plot_width, 1); + defer row.deinit(); + + for (0..row.width) |x| row.setSlice(x, 0, " ", null); + + const title_width = measure.width(self.x_axis.title); + const start = offset + (plot_width -| title_width) / 2; + row.writeText(start, 0, self.x_axis.title, self.x_axis.title_style); + return try row.render(allocator); + } + + fn renderLegend(self: *const Chart, allocator: std.mem.Allocator) ![]const u8 { + if (self.legend_position == .hidden or self.datasets.items.len == 0) { + return try allocator.dupe(u8, ""); + } + + var pieces = std.array_list.Managed([]const u8).init(allocator); + defer { + for (pieces.items) |piece| allocator.free(piece); + pieces.deinit(); + } + + for (self.datasets.items) |dataset| { + const symbol_raw = switch (dataset.graph_type) { + .line => "──", + .scatter => dataset.point_glyph orelse "•", + .area => "██", + }; + const symbol = try dataset.style.render(allocator, symbol_raw); + defer allocator.free(symbol); + + const label = try self.legend_style.render(allocator, dataset.label); + defer allocator.free(label); + + const piece = try std.fmt.allocPrint(allocator, "{s} {s}", .{ symbol, label }); + try pieces.append(piece); + } + + return try join.horizontal(allocator, .top, pieces.items); + } +}; + +const TickSet = struct { + allocator: std.mem.Allocator, + positions: std.array_list.Managed(usize), + labels: std.array_list.Managed([]const u8), + + fn init(allocator: std.mem.Allocator, axis: Axis, range: DataRange, span_hint: usize) !TickSet { + var self = TickSet{ + .allocator = allocator, + .positions = std.array_list.Managed(usize).init(allocator), + .labels = std.array_list.Managed([]const u8).init(allocator), + }; + + const span = @max(@as(usize, 1), span_hint); + if (axis.labels.len > 0) { + for (axis.labels) |label| { + try self.positions.append(charting.mapToResolution(label.value, range, span)); + try self.labels.append(try allocator.dupe(u8, label.text)); + } + return self; + } + + const tick_count = @max(@as(usize, 2), axis.tick_count); + var i: usize = 0; + while (i < tick_count) : (i += 1) { + const t = if (tick_count == 1) 0.0 else @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(tick_count - 1)); + const value = range.min + (range.max - range.min) * t; + try self.positions.append(charting.mapToResolution(value, range, span)); + + const formatter = axis.formatter orelse charting.defaultFormatter; + const label = try formatter(allocator, value); + try self.labels.append(label); + } + + return self; + } + + fn deinit(self: *TickSet) void { + for (self.labels.items) |label| self.allocator.free(label); + self.positions.deinit(); + self.labels.deinit(); + } + + fn maxLabelWidth(self: *const TickSet) usize { + var max_width: usize = 0; + for (self.labels.items) |label| { + max_width = @max(max_width, measure.width(label)); + } + return max_width; + } + + fn labelForRow(self: *const TickSet, row: usize) ?[]const u8 { + for (self.positions.items, self.labels.items) |tick_row, label| { + if (tick_row == row) return label; + } + return null; + } +}; + +const GridOrientation = enum { vertical, horizontal }; + +fn renderPaddedLabel(allocator: std.mem.Allocator, label: []const u8, width: usize, style: Style) ![]const u8 { + const label_width = measure.width(label); + var result = std.array_list.Managed(u8).init(allocator); + const writer = result.writer(); + + const padding = width -| label_width; + for (0..padding) |_| try writer.writeByte(' '); + + if (label.len > 0) { + const rendered = try style.render(allocator, label); + defer allocator.free(rendered); + try writer.writeAll(rendered); + } + + return try result.toOwnedSlice(); +} + +fn setGridGlyph(buffer: *charting.CellBuffer, x: usize, y: usize, orientation: GridOrientation, style: Style) void { + const current = buffer.cells[y * buffer.width + x]; + const next = switch (current.glyph) { + .slice => |slice| blk: { + if (orientation == .vertical and std.mem.eql(u8, slice, "─")) break :blk "┼"; + if (orientation == .horizontal and std.mem.eql(u8, slice, "│")) break :blk "┼"; + if (std.mem.eql(u8, slice, "┼")) break :blk "┼"; + break :blk if (orientation == .vertical) "│" else "─"; + }, + else => if (orientation == .vertical) "│" else "─", + }; + buffer.setSlice(x, y, next, style); +} + +fn leftPrefixWidth(self: *const Chart, y_label_width: usize) usize { + return y_label_width + (if (self.y_axis.show_line) + @as(usize, 2) + else if (y_label_width > 0) + @as(usize, 1) + else + @as(usize, 0)); +} diff --git a/src/components/charting.zig b/src/components/charting.zig new file mode 100644 index 0000000..ef6da30 --- /dev/null +++ b/src/components/charting.zig @@ -0,0 +1,243 @@ +//! Shared plotting primitives for chart-like components. + +const std = @import("std"); +const measure = @import("../layout/measure.zig"); +const style_mod = @import("../style/style.zig"); + +pub const Style = style_mod.Style; + +pub const Point = struct { + x: f64, + y: f64, +}; + +pub const DataRange = struct { + min: f64, + max: f64, + + pub fn normalized(self: DataRange) DataRange { + if (!std.math.isFinite(self.min) or !std.math.isFinite(self.max)) { + return .{ .min = 0, .max = 1 }; + } + + if (self.min == self.max) { + return .{ + .min = self.min - 0.5, + .max = self.max + 0.5, + }; + } + + if (self.min < self.max) return self; + + return .{ + .min = self.max, + .max = self.min, + }; + } + + pub fn span(self: DataRange) f64 { + const normalized_range = self.normalized(); + return normalized_range.max - normalized_range.min; + } +}; + +pub const Marker = enum { + braille, + block, + dot, + ascii, +}; + +pub const GraphType = enum { + line, + scatter, + area, +}; + +pub const Orientation = enum { + vertical, + horizontal, +}; + +pub const Summary = enum { + last, + average, + minimum, + maximum, + sum, +}; + +pub const LegendPosition = enum { + hidden, + top, + bottom, + left, + right, +}; + +pub const AxisLabel = struct { + value: f64, + text: []const u8, +}; + +pub const ValueFormatter = *const fn (std.mem.Allocator, f64) anyerror![]const u8; + +pub const Glyph = union(enum) { + slice: []const u8, + codepoint: u21, +}; + +pub const Cell = struct { + glyph: Glyph = .{ .slice = " " }, + style: ?Style = null, +}; + +pub const CellBuffer = struct { + allocator: std.mem.Allocator, + width: usize, + height: usize, + cells: []Cell, + + pub fn init(allocator: std.mem.Allocator, width: usize, height: usize) !CellBuffer { + const cells = try allocator.alloc(Cell, width * height); + for (cells) |*cell| cell.* = .{}; + + return .{ + .allocator = allocator, + .width = width, + .height = height, + .cells = cells, + }; + } + + pub fn deinit(self: *CellBuffer) void { + self.allocator.free(self.cells); + } + + pub fn clear(self: *CellBuffer) void { + for (self.cells) |*cell| cell.* = .{}; + } + + fn index(self: *const CellBuffer, x: usize, y: usize) usize { + return y * self.width + x; + } + + pub fn setGlyph(self: *CellBuffer, x: usize, y: usize, glyph: Glyph, style: ?Style) void { + if (x >= self.width or y >= self.height) return; + self.cells[self.index(x, y)] = .{ + .glyph = glyph, + .style = style, + }; + } + + pub fn setSlice(self: *CellBuffer, x: usize, y: usize, glyph: []const u8, style: ?Style) void { + self.setGlyph(x, y, .{ .slice = glyph }, style); + } + + pub fn setCodepoint(self: *CellBuffer, x: usize, y: usize, codepoint: u21, style: ?Style) void { + self.setGlyph(x, y, .{ .codepoint = codepoint }, style); + } + + pub fn writeText(self: *CellBuffer, x: usize, y: usize, text: []const u8, style: ?Style) void { + if (y >= self.height) return; + + var col = x; + var i: usize = 0; + while (i < text.len and col < self.width) { + const byte_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + const end = @min(text.len, i + byte_len); + const glyph = text[i..end]; + + var codepoint_width: usize = 1; + if (end <= text.len) { + const cp = std.unicode.utf8Decode(glyph) catch 0; + if (cp != 0) { + codepoint_width = @max(1, measure.charWidth(cp)); + } + } + + self.setSlice(col, y, glyph, style); + if (codepoint_width > 1) { + var extra: usize = 1; + while (extra < codepoint_width and col + extra < self.width) : (extra += 1) { + self.setSlice(col + extra, y, " ", style); + } + } + + col += codepoint_width; + i = end; + } + } + + pub fn render(self: *const CellBuffer, allocator: std.mem.Allocator) ![]const u8 { + var result = std.array_list.Managed(u8).init(allocator); + const writer = result.writer(); + + var glyph_buf: [4]u8 = undefined; + + for (0..self.height) |y| { + if (y > 0) try writer.writeByte('\n'); + + for (0..self.width) |x| { + const cell = self.cells[self.index(x, y)]; + const glyph = switch (cell.glyph) { + .slice => |slice| slice, + .codepoint => |cp| blk: { + const len = try std.unicode.utf8Encode(cp, &glyph_buf); + break :blk glyph_buf[0..len]; + }, + }; + + if (cell.style) |base_style| { + var inline_style = base_style.inline_style(true); + const rendered = try inline_style.render(allocator, glyph); + defer allocator.free(rendered); + try writer.writeAll(rendered); + } else { + try writer.writeAll(glyph); + } + } + } + + return try result.toOwnedSlice(); + } +}; + +pub fn inlineStyle(style: Style) Style { + return style.inline_style(true); +} + +pub fn clampUnit(value: f64) f64 { + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +pub fn mapToResolution(value: f64, range: DataRange, resolution: usize) usize { + if (resolution <= 1) return 0; + + const normalized = range.normalized(); + const span = normalized.max - normalized.min; + if (span <= 0) return 0; + + const unit = clampUnit((value - normalized.min) / span); + return @intFromFloat(@round(unit * @as(f64, @floatFromInt(resolution - 1)))); +} + +pub fn mapX(value: f64, range: DataRange, width: usize) usize { + return mapToResolution(value, range, width); +} + +pub fn mapY(value: f64, range: DataRange, height: usize) usize { + if (height <= 1) return 0; + const mapped = mapToResolution(value, range, height); + return (height - 1) - mapped; +} + +pub fn defaultFormatter(allocator: std.mem.Allocator, value: f64) ![]const u8 { + if (value == @trunc(value)) { + return try std.fmt.allocPrint(allocator, "{d}", .{@as(i64, @intFromFloat(value))}); + } + + return try std.fmt.allocPrint(allocator, "{d:.2}", .{value}); +} diff --git a/src/components/sparkline.zig b/src/components/sparkline.zig index 85dd949..63d5cb1 100644 --- a/src/components/sparkline.zig +++ b/src/components/sparkline.zig @@ -1,29 +1,47 @@ -//! Sparkline component for mini charts using Unicode block elements. -//! Displays data as a compact bar chart. +//! Sparkline component with configurable aggregation and styling. const std = @import("std"); +const charting = @import("charting.zig"); +const progress = @import("progress.zig"); const style_mod = @import("../style/style.zig"); const Color = @import("../style/color.zig").Color; +pub const Style = style_mod.Style; +pub const Summary = charting.Summary; +pub const DataRange = charting.DataRange; + pub const Sparkline = struct { allocator: std.mem.Allocator, data: std.array_list.Managed(f64), display_width: u16, - spark_style: style_mod.Style, + summary: Summary, + retention_limit: ?usize, + spark_style: Style, + empty_char: []const u8, + glyphs: []const []const u8, + fixed_range: ?DataRange, + gradient_start: ?Color, + gradient_end: ?Color, - const block_chars = [_][]const u8{ " ", "▁", "▂", "▃", "▄", "▅", "▆", "▇" }; + const default_glyphs = [_][]const u8{ " ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" }; pub fn init(allocator: std.mem.Allocator) Sparkline { return .{ .allocator = allocator, .data = std.array_list.Managed(f64).init(allocator), .display_width = 40, + .summary = .last, + .retention_limit = 40, .spark_style = blk: { - var s = style_mod.Style{}; + var s = Style{}; s = s.fg(Color.green()); - s = s.inline_style(true); - break :blk s; + break :blk charting.inlineStyle(s); }, + .empty_char = " ", + .glyphs = default_glyphs[0..], + .fixed_range = null, + .gradient_start = null, + .gradient_end = null, }; } @@ -31,68 +49,180 @@ pub const Sparkline = struct { self.data.deinit(); } - /// Push a new value (ring buffer behavior: oldest removed when exceeding width) pub fn push(self: *Sparkline, value: f64) !void { try self.data.append(value); - // Keep only display_width values - while (self.data.items.len > self.display_width) { - _ = self.data.orderedRemove(0); - } + self.enforceRetention(); } - /// Set all data at once pub fn setData(self: *Sparkline, data: []const f64) !void { self.data.clearRetainingCapacity(); try self.data.appendSlice(data); - while (self.data.items.len > self.display_width) { - _ = self.data.orderedRemove(0); + self.enforceRetention(); + } + + pub fn clear(self: *Sparkline) void { + self.data.clearRetainingCapacity(); + } + + pub fn setWidth(self: *Sparkline, width: u16) void { + self.display_width = @max(1, width); + if (self.retention_limit) |limit| { + if (limit < self.display_width) { + self.retention_limit = self.display_width; + } } + self.enforceRetention(); + } + + pub fn setSummary(self: *Sparkline, summary: Summary) void { + self.summary = summary; } - /// Set display width - pub fn setWidth(self: *Sparkline, w: u16) void { - self.display_width = w; + pub fn setRetentionLimit(self: *Sparkline, limit: ?usize) void { + self.retention_limit = if (limit) |value| @max(value, self.display_width) else null; + self.enforceRetention(); } - /// Set style - pub fn setStyle(self: *Sparkline, s: style_mod.Style) void { - self.spark_style = s; + pub fn setStyle(self: *Sparkline, style: Style) void { + self.spark_style = charting.inlineStyle(style); + } + + pub fn setEmptyChar(self: *Sparkline, empty_char: []const u8) void { + self.empty_char = empty_char; + } + + pub fn setGlyphs(self: *Sparkline, glyphs: []const []const u8) void { + if (glyphs.len > 0) self.glyphs = glyphs; + } + + pub fn setRange(self: *Sparkline, range: ?DataRange) void { + self.fixed_range = if (range) |value| value.normalized() else null; + } + + pub fn setGradient(self: *Sparkline, start: ?Color, end: ?Color) void { + self.gradient_start = start; + self.gradient_end = end; } - /// Render the sparkline pub fn view(self: *const Sparkline, allocator: std.mem.Allocator) ![]const u8 { - if (self.data.items.len == 0) { - return try allocator.dupe(u8, ""); - } + if (self.display_width == 0) return try allocator.dupe(u8, ""); - // Find min/max of visible window - var min_val: f64 = self.data.items[0]; - var max_val: f64 = self.data.items[0]; - for (self.data.items) |v| { - if (v < min_val) min_val = v; - if (v > max_val) max_val = v; - } + var visible = try self.bucketValues(allocator); + defer visible.deinit(); var result = std.array_list.Managed(u8).init(allocator); const writer = result.writer(); - const range = max_val - min_val; + if (visible.items.len == 0) { + for (0..self.display_width) |_| try writer.writeAll(self.empty_char); + return try result.toOwnedSlice(); + } - for (self.data.items) |v| { - const normalized: f64 = if (range > 0) (v - min_val) / range else 0.5; - const idx: usize = @intFromFloat(@min(7.0, normalized * 7.0)); - const styled = try self.spark_style.render(allocator, block_chars[idx]); - try writer.writeAll(styled); + const range = self.fixed_range orelse self.computeRange(visible.items); + const glyph_count = self.glyphs.len; + const max_index = glyph_count - 1; + + for (visible.items, 0..) |value, index| { + const normalized = if (range.span() > 0) + charting.clampUnit((value - range.min) / range.span()) + else + 0.5; + + const glyph_index = @min(max_index, @as(usize, @intFromFloat(@round(normalized * @as(f64, @floatFromInt(max_index)))))); + const glyph = self.glyphs[glyph_index]; + + if (self.gradient_start != null and self.gradient_end != null) { + const t = if (visible.items.len <= 1) 0.0 else @as(f64, @floatFromInt(index)) / @as(f64, @floatFromInt(visible.items.len - 1)); + const color = progress.interpolateColor(self.gradient_start.?, self.gradient_end.?, t); + var style = self.spark_style.fg(color); + style = charting.inlineStyle(style); + const rendered = try style.render(allocator, glyph); + defer allocator.free(rendered); + try writer.writeAll(rendered); + } else { + const rendered = try self.spark_style.render(allocator, glyph); + defer allocator.free(rendered); + try writer.writeAll(rendered); + } } - // Pad remaining width - if (self.data.items.len < self.display_width) { - const remaining = self.display_width - @as(u16, @intCast(self.data.items.len)); - for (0..remaining) |_| { - try writer.writeAll(" "); + if (visible.items.len < self.display_width) { + for (0..(@as(usize, self.display_width) - visible.items.len)) |_| { + try writer.writeAll(self.empty_char); } } - return result.toOwnedSlice(); + return try result.toOwnedSlice(); + } + + fn enforceRetention(self: *Sparkline) void { + if (self.retention_limit) |limit| { + while (self.data.items.len > limit) { + _ = self.data.orderedRemove(0); + } + } + } + + fn bucketValues(self: *const Sparkline, allocator: std.mem.Allocator) !std.array_list.Managed(f64) { + var buckets = std.array_list.Managed(f64).init(allocator); + const width = @as(usize, self.display_width); + if (self.data.items.len == 0 or width == 0) return buckets; + + if (self.data.items.len <= width) { + try buckets.appendSlice(self.data.items); + return buckets; + } + + const data_len_f = @as(f64, @floatFromInt(self.data.items.len)); + const width_f = @as(f64, @floatFromInt(width)); + + for (0..width) |bucket_index| { + const start = @min(self.data.items.len, @as(usize, @intFromFloat(@floor(@as(f64, @floatFromInt(bucket_index)) * data_len_f / width_f)))); + const end = @min(self.data.items.len, @as(usize, @intFromFloat(@floor(@as(f64, @floatFromInt(bucket_index + 1)) * data_len_f / width_f)))); + const slice = if (end > start) self.data.items[start..end] else self.data.items[start .. @min(self.data.items.len, start + 1)]; + try buckets.append(summarize(slice, self.summary)); + } + + return buckets; + } + + fn computeRange(self: *const Sparkline, values: []const f64) DataRange { + _ = self; + var min_value = values[0]; + var max_value = values[0]; + for (values[1..]) |value| { + min_value = @min(min_value, value); + max_value = @max(max_value, value); + } + const range = DataRange{ .min = min_value, .max = max_value }; + return range.normalized(); } }; + +fn summarize(values: []const f64, summary: Summary) f64 { + if (values.len == 0) return 0; + + return switch (summary) { + .last => values[values.len - 1], + .average => blk: { + var total: f64 = 0; + for (values) |value| total += value; + break :blk total / @as(f64, @floatFromInt(values.len)); + }, + .minimum => blk: { + var value = values[0]; + for (values[1..]) |item| value = @min(value, item); + break :blk value; + }, + .maximum => blk: { + var value = values[0]; + for (values[1..]) |item| value = @max(value, item); + break :blk value; + }, + .sum => blk: { + var total: f64 = 0; + for (values) |value| total += value; + break :blk total; + }, + }; +} diff --git a/src/root.zig b/src/root.zig index e400941..e1bd34f 100644 --- a/src/root.zig +++ b/src/root.zig @@ -111,6 +111,12 @@ pub const components = struct { pub const Tree = @import("components/tree.zig").Tree; pub const StyledList = @import("components/styled_list.zig").StyledList; pub const Sparkline = @import("components/sparkline.zig").Sparkline; + pub const charting = @import("components/charting.zig"); + pub const canvas = @import("components/canvas.zig"); + pub const Canvas = canvas.Canvas; + pub const chart = @import("components/chart.zig"); + pub const Chart = chart.Chart; + pub const BarChart = @import("components/bar_chart.zig").BarChart; pub const notification = @import("components/notification.zig"); pub const Notification = notification.Notification; pub const Confirm = @import("components/confirm.zig").Confirm; @@ -134,6 +140,9 @@ pub const Table = components.Table; pub const Tree = components.Tree; pub const StyledList = components.StyledList; pub const Sparkline = components.Sparkline; +pub const Canvas = components.Canvas; +pub const Chart = components.Chart; +pub const BarChart = components.BarChart; pub const Notification = components.Notification; pub const Confirm = components.Confirm; pub const Modal = components.Modal; @@ -219,6 +228,17 @@ pub const compressAnsi = compress.compressAnsi; // Progress helpers pub const interpolateColor = @import("components/progress.zig").interpolateColor; +pub const PlotPoint = components.charting.Point; +pub const PlotRange = components.charting.DataRange; +pub const PlotMarker = components.charting.Marker; +pub const GraphType = components.chart.GraphType; +pub const Axis = components.chart.Axis; +pub const AxisLabel = components.chart.AxisLabel; +pub const ChartDataset = components.chart.Dataset; +pub const LegendPosition = components.chart.LegendPosition; +pub const Bar = @import("components/bar_chart.zig").Bar; +pub const ChartOrientation = components.charting.Orientation; +pub const SparkSummary = @import("components/sparkline.zig").Summary; test { std.testing.refAllDecls(@This()); diff --git a/tests/chart_tests.zig b/tests/chart_tests.zig new file mode 100644 index 0000000..18f4013 --- /dev/null +++ b/tests/chart_tests.zig @@ -0,0 +1,126 @@ +const std = @import("std"); +const testing = std.testing; +const zz = @import("zigzag"); + +test "sparkline buckets data with summary mode" { + const allocator = testing.allocator; + + var spark = zz.Sparkline.init(allocator); + defer spark.deinit(); + + spark.setWidth(4); + spark.setSummary(.average); + spark.setRetentionLimit(null); + spark.setStyle((zz.Style{}).inline_style(true)); + spark.setGlyphs(&.{ " ", ".", ":", "*", "#" }); + + try spark.setData(&.{ 1, 2, 3, 4, 5, 6, 7, 8 }); + const view = try spark.view(allocator); + defer allocator.free(view); + + try testing.expectEqual(@as(usize, 4), zz.width(view)); + try testing.expect(std.mem.indexOfAny(u8, view, ".:*#") != null); +} + +test "canvas renders braille dots" { + const allocator = testing.allocator; + + var canvas = zz.Canvas.init(allocator); + defer canvas.deinit(); + + canvas.setSize(2, 1); + canvas.setMarker(.braille); + canvas.setRanges(.{ .min = 0, .max = 1 }, .{ .min = 0, .max = 1 }); + try canvas.drawPoint(0, 0); + try canvas.drawPoint(1, 1); + + const view = try canvas.view(allocator); + defer allocator.free(view); + + try testing.expect(zz.width(view) == 2); + try testing.expect(!std.mem.eql(u8, view, " ")); +} + +test "chart renders titles and legend" { + const allocator = testing.allocator; + + var chart = zz.Chart.init(allocator); + defer chart.deinit(); + + chart.setSize(30, 10); + chart.setMarker(.ascii); + chart.setLegendPosition(.top); + chart.x_axis = .{ .title = "Time", .tick_count = 3, .show_grid = true }; + chart.y_axis = .{ .title = "Load", .tick_count = 3, .show_grid = true }; + + var dataset = try zz.ChartDataset.init(allocator, "CPU"); + dataset.setGraphType(.line); + dataset.setShowPoints(true); + dataset.setStyle((zz.Style{}).inline_style(true)); + try dataset.setPoints(&.{ + .{ .x = 0, .y = 10 }, + .{ .x = 1, .y = 40 }, + .{ .x = 2, .y = 25 }, + }); + try chart.addDataset(dataset); + + const view = try chart.view(allocator); + defer allocator.free(view); + const plain = try stripAnsi(allocator, view); + defer allocator.free(plain); + try testing.expect(std.mem.indexOf(u8, plain, "CPU") != null); + try testing.expect(std.mem.indexOf(u8, plain, "Time") != null); + try testing.expect(std.mem.indexOf(u8, plain, "Load") != null); +} + +test "horizontal bar chart supports negative values" { + const allocator = testing.allocator; + + var chart = zz.BarChart.init(allocator); + defer chart.deinit(); + + chart.setSize(24, 6); + chart.setOrientation(.horizontal); + chart.show_values = true; + chart.label_style = (zz.Style{}).inline_style(true); + chart.axis_style = (zz.Style{}).inline_style(true); + try chart.addBar(try zz.Bar.init(allocator, "api", 12)); + try chart.addBar(try zz.Bar.init(allocator, "db", -8)); + + const view = try chart.view(allocator); + defer allocator.free(view); + const plain = try stripAnsi(allocator, view); + defer allocator.free(plain); + + try testing.expect(std.mem.indexOf(u8, plain, "api") != null); + try testing.expect(std.mem.indexOf(u8, plain, "db") != null); + try testing.expect(std.mem.indexOf(u8, plain, "│") != null); +} + +fn stripAnsi(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { + var out = std.array_list.Managed(u8).init(allocator); + defer out.deinit(); + + var i: usize = 0; + while (i < input.len) { + if (input[i] == 0x1b) { + i += 1; + if (i < input.len and input[i] == '[') { + i += 1; + while (i < input.len) : (i += 1) { + const c = input[i]; + if ((c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')) { + i += 1; + break; + } + } + continue; + } + } + + try out.append(input[i]); + i += 1; + } + + return try out.toOwnedSlice(); +} From 10e1bcf73b98a4b3577b86a7ca82ca9a69f633fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Fri, 6 Mar 2026 12:43:35 +0100 Subject: [PATCH 2/5] feat: add interpolation modes for chart datasets --- README.md | 4 +- examples/charts.zig | 4 + src/components/canvas.zig | 111 ++++++++++++----- src/components/chart.zig | 229 +++++++++++++++++++++++++++++++----- src/components/charting.zig | 9 ++ src/root.zig | 1 + tests/chart_tests.zig | 43 +++++++ 7 files changed, 343 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 4a85ec3..bfd5f2c 100644 --- a/README.md +++ b/README.md @@ -399,7 +399,7 @@ const chart = try spark.view(allocator); ### Chart -Cartesian chart with multiple datasets, axes, grid lines, legends, and selectable markers: +Cartesian chart with multiple datasets, axes, grid lines, legends, selectable markers, and interpolation modes (`linear`, stepped, `catmull_rom`, `monotone_cubic`): ```zig var chart = zz.Chart.init(allocator); @@ -411,6 +411,8 @@ chart.y_axis = .{ .title = "CPU", .tick_count = 5, .show_grid = true }; var dataset = try zz.ChartDataset.init(allocator, "load"); dataset.setStyle((zz.Style{}).fg(zz.Color.cyan()).bold(true)); dataset.setShowPoints(true); +dataset.setInterpolation(.monotone_cubic); +dataset.setInterpolationSteps(10); try dataset.setPoints(&.{ .{ .x = 0, .y = 20 }, .{ .x = 1, .y = 45 }, diff --git a/examples/charts.zig b/examples/charts.zig index 05f4ba1..7f7b522 100644 --- a/examples/charts.zig +++ b/examples/charts.zig @@ -33,8 +33,12 @@ const Model = struct { var cpu = zz.ChartDataset.init(ctx.persistent_allocator, "CPU") catch unreachable; cpu.setStyle((zz.Style{}).fg(zz.Color.cyan()).bold(true)); cpu.setShowPoints(true); + cpu.setInterpolation(.monotone_cubic); + cpu.setInterpolationSteps(10); var mem = zz.ChartDataset.init(ctx.persistent_allocator, "Memory") catch unreachable; mem.setStyle((zz.Style{}).fg(zz.Color.magenta())); + mem.setInterpolation(.catmull_rom); + mem.setInterpolationSteps(10); for (0..24) |i| { const x = @as(f64, @floatFromInt(i)); diff --git a/src/components/canvas.zig b/src/components/canvas.zig index 7abc9b6..60cfa37 100644 --- a/src/components/canvas.zig +++ b/src/components/canvas.zig @@ -170,6 +170,13 @@ pub const Canvas = struct { }; } + pub fn drawIntoBuffer(self: *const Canvas, buffer: *charting.CellBuffer) void { + switch (self.marker) { + .braille => self.drawBrailleIntoBuffer(buffer), + .block, .dot, .ascii => self.drawCellIntoBuffer(buffer), + } + } + fn renderCells(self: *const Canvas, allocator: std.mem.Allocator) ![]const u8 { var buffer = try charting.CellBuffer.init(allocator, self.width, self.height); defer buffer.deinit(); @@ -180,34 +187,7 @@ pub const Canvas = struct { } } - for (self.operations.items) |op| { - switch (op) { - .point => |point_op| { - const x = charting.mapX(point_op.point.x, self.x_range, self.width); - const y = charting.mapY(point_op.point.y, self.y_range, self.height); - buffer.setSlice(x, y, point_op.glyph orelse self.defaultCellGlyph(), point_op.style); - }, - .line => |line_op| { - const x0 = charting.mapX(line_op.from.x, self.x_range, self.width); - const y0 = charting.mapY(line_op.from.y, self.y_range, self.height); - const x1 = charting.mapX(line_op.to.x, self.x_range, self.width); - const y1 = charting.mapY(line_op.to.y, self.y_range, self.height); - drawLineCells(&buffer, x0, y0, x1, y1, line_op.glyph orelse self.lineCellGlyph(), line_op.style); - }, - .rect => |rect_op| { - const min_x = charting.mapX(rect_op.min.x, self.x_range, self.width); - const min_y = charting.mapY(rect_op.max.y, self.y_range, self.height); - const max_x = charting.mapX(rect_op.max.x, self.x_range, self.width); - const max_y = charting.mapY(rect_op.min.y, self.y_range, self.height); - drawRectCells(&buffer, min_x, min_y, max_x, max_y, rect_op.filled, rect_op.glyph orelse self.lineCellGlyph(), rect_op.style); - }, - .text => |text_op| { - const x = charting.mapX(text_op.origin.x, self.x_range, self.width); - const y = charting.mapY(text_op.origin.y, self.y_range, self.height); - buffer.writeText(x, y, text_op.text, text_op.style); - }, - } - } + self.drawCellIntoBuffer(&buffer); return try buffer.render(allocator); } @@ -275,6 +255,81 @@ pub const Canvas = struct { return try final.render(allocator); } + fn drawCellIntoBuffer(self: *const Canvas, buffer: *charting.CellBuffer) void { + for (self.operations.items) |op| { + switch (op) { + .point => |point_op| { + const x = charting.mapX(point_op.point.x, self.x_range, self.width); + const y = charting.mapY(point_op.point.y, self.y_range, self.height); + buffer.setSlice(x, y, point_op.glyph orelse self.defaultCellGlyph(), point_op.style); + }, + .line => |line_op| { + const x0 = charting.mapX(line_op.from.x, self.x_range, self.width); + const y0 = charting.mapY(line_op.from.y, self.y_range, self.height); + const x1 = charting.mapX(line_op.to.x, self.x_range, self.width); + const y1 = charting.mapY(line_op.to.y, self.y_range, self.height); + drawLineCells(buffer, x0, y0, x1, y1, line_op.glyph orelse self.lineCellGlyph(), line_op.style); + }, + .rect => |rect_op| { + const min_x = charting.mapX(rect_op.min.x, self.x_range, self.width); + const min_y = charting.mapY(rect_op.max.y, self.y_range, self.height); + const max_x = charting.mapX(rect_op.max.x, self.x_range, self.width); + const max_y = charting.mapY(rect_op.min.y, self.y_range, self.height); + drawRectCells(buffer, min_x, min_y, max_x, max_y, rect_op.filled, rect_op.glyph orelse self.lineCellGlyph(), rect_op.style); + }, + .text => |text_op| { + const x = charting.mapX(text_op.origin.x, self.x_range, self.width); + const y = charting.mapY(text_op.origin.y, self.y_range, self.height); + buffer.writeText(x, y, text_op.text, text_op.style); + }, + } + } + } + + fn drawBrailleIntoBuffer(self: *const Canvas, buffer: *charting.CellBuffer) void { + const cell_count = @as(usize, self.width) * @as(usize, self.height); + const braille_cells = buffer.allocator.alloc(BrailleCell, cell_count) catch return; + defer buffer.allocator.free(braille_cells); + for (braille_cells) |*cell| cell.* = .{}; + + for (self.operations.items) |op| { + switch (op) { + .point => |point_op| { + const x = charting.mapX(point_op.point.x, self.x_range, @as(usize, self.width) * 2); + const y = charting.mapY(point_op.point.y, self.y_range, @as(usize, self.height) * 4); + setBraillePixel(braille_cells, self.width, x, y, point_op.style); + }, + .line => |line_op| { + const x0 = charting.mapX(line_op.from.x, self.x_range, @as(usize, self.width) * 2); + const y0 = charting.mapY(line_op.from.y, self.y_range, @as(usize, self.height) * 4); + const x1 = charting.mapX(line_op.to.x, self.x_range, @as(usize, self.width) * 2); + const y1 = charting.mapY(line_op.to.y, self.y_range, @as(usize, self.height) * 4); + drawBrailleLine(braille_cells, self.width, x0, y0, x1, y1, line_op.style); + }, + .rect => |rect_op| { + const min_x = charting.mapX(rect_op.min.x, self.x_range, @as(usize, self.width) * 2); + const min_y = charting.mapY(rect_op.max.y, self.y_range, @as(usize, self.height) * 4); + const max_x = charting.mapX(rect_op.max.x, self.x_range, @as(usize, self.width) * 2); + const max_y = charting.mapY(rect_op.min.y, self.y_range, @as(usize, self.height) * 4); + drawBrailleRect(braille_cells, self.width, min_x, min_y, max_x, max_y, rect_op.filled, rect_op.style); + }, + .text => |text_op| { + const x = charting.mapX(text_op.origin.x, self.x_range, self.width); + const y = charting.mapY(text_op.origin.y, self.y_range, self.height); + buffer.writeText(x, y, text_op.text, text_op.style); + }, + } + } + + for (0..self.height) |y| { + for (0..self.width) |x| { + const cell = braille_cells[y * self.width + x]; + if (cell.bits == 0) continue; + buffer.setCodepoint(x, y, @as(u21, 0x2800) + cell.bits, cell.style); + } + } + } + fn defaultCellGlyph(self: *const Canvas) []const u8 { return switch (self.marker) { .braille => "•", diff --git a/src/components/chart.zig b/src/components/chart.zig index 913c70e..66cb3b9 100644 --- a/src/components/chart.zig +++ b/src/components/chart.zig @@ -5,13 +5,13 @@ const charting = @import("charting.zig"); const canvas_mod = @import("canvas.zig"); const join = @import("../layout/join.zig"); const measure = @import("../layout/measure.zig"); -const place = @import("../layout/place.zig"); const style_mod = @import("../style/style.zig"); pub const Style = style_mod.Style; pub const AxisLabel = charting.AxisLabel; pub const DataRange = charting.DataRange; pub const GraphType = charting.GraphType; +pub const Interpolation = charting.Interpolation; pub const LegendPosition = charting.LegendPosition; pub const Marker = charting.Marker; pub const Point = charting.Point; @@ -38,6 +38,9 @@ pub const Dataset = struct { points: std.array_list.Managed(Point), style: Style, graph_type: GraphType, + interpolation: Interpolation, + interpolation_steps: u8, + curve_tension: f64, show_points: bool, point_glyph: ?[]const u8, fill_to: ?f64, @@ -49,6 +52,9 @@ pub const Dataset = struct { .points = std.array_list.Managed(Point).init(allocator), .style = charting.inlineStyle(Style{}), .graph_type = .line, + .interpolation = .linear, + .interpolation_steps = 8, + .curve_tension = 0.5, .show_points = false, .point_glyph = null, .fill_to = null, @@ -68,6 +74,18 @@ pub const Dataset = struct { self.graph_type = graph_type; } + pub fn setInterpolation(self: *Dataset, interpolation: Interpolation) void { + self.interpolation = interpolation; + } + + pub fn setInterpolationSteps(self: *Dataset, steps: u8) void { + self.interpolation_steps = @max(@as(u8, 1), steps); + } + + pub fn setCurveTension(self: *Dataset, tension: f64) void { + self.curve_tension = std.math.clamp(tension, 0.0, 1.0); + } + pub fn setShowPoints(self: *Dataset, show_points: bool) void { self.show_points = show_points; } @@ -152,9 +170,9 @@ pub const Chart = struct { const resolved_x = self.resolveRange(.x); const resolved_y = self.resolveRange(.y); - var x_ticks = try TickSet.init(allocator, self.x_axis, resolved_x, self.width); + var x_ticks = try TickSet.init(allocator, self.x_axis, resolved_x); defer x_ticks.deinit(); - var y_ticks = try TickSet.init(allocator, self.y_axis, resolved_y, self.height); + var y_ticks = try TickSet.init(allocator, self.y_axis, resolved_y); defer y_ticks.deinit(); const y_label_width = if (self.y_axis.show_labels) y_ticks.maxLabelWidth() else 0; @@ -173,13 +191,10 @@ pub const Chart = struct { const plot_width = @max(@as(usize, 1), @as(usize, self.width) -| left_gutter); const plot_height = @max(@as(usize, 1), @as(usize, self.height) -| (x_axis_line_rows + x_label_rows + x_title_rows + y_title_rows)); - const grid = try self.renderGrid(allocator, plot_width, plot_height, &x_ticks, &y_ticks); - defer allocator.free(grid); - - const datasets_view = try self.renderDatasets(allocator, plot_width, plot_height, resolved_x, resolved_y); - defer allocator.free(datasets_view); + x_ticks.updatePositions(plot_width); + y_ticks.updatePositions(plot_height); - const plot = try place.overlay(allocator, grid, datasets_view, 0, 0); + const plot = try self.renderPlot(allocator, plot_width, plot_height, resolved_x, resolved_y, &x_ticks, &y_ticks); defer allocator.free(plot); var rows = std.array_list.Managed([]const u8).init(allocator); @@ -279,7 +294,7 @@ pub const Chart = struct { return range.normalized(); } - fn renderGrid(self: *const Chart, allocator: std.mem.Allocator, plot_width: usize, plot_height: usize, x_ticks: *const TickSet, y_ticks: *const TickSet) ![]const u8 { + fn renderPlot(self: *const Chart, allocator: std.mem.Allocator, plot_width: usize, plot_height: usize, x_range: DataRange, y_range: DataRange, x_ticks: *const TickSet, y_ticks: *const TickSet) ![]const u8 { var buffer = try charting.CellBuffer.init(allocator, plot_width, plot_height); defer buffer.deinit(); @@ -307,29 +322,27 @@ pub const Chart = struct { } } - return try buffer.render(allocator); - } - - fn renderDatasets(self: *const Chart, allocator: std.mem.Allocator, plot_width: usize, plot_height: usize, x_range: DataRange, y_range: DataRange) ![]const u8 { var plot = canvas_mod.Canvas.init(allocator); defer plot.deinit(); plot.setSize(@intCast(plot_width), @intCast(plot_height)); plot.setRanges(x_range, y_range); plot.setMarker(self.marker); - plot.setBackground(" "); for (self.datasets.items) |dataset| { + var path = try sampledPath(allocator, &dataset); + defer path.deinit(); + switch (dataset.graph_type) { .line => { if (dataset.points.items.len == 1) { const point = dataset.points.items[0]; try plot.drawPointStyled(point.x, point.y, dataset.style, dataset.point_glyph); - } else if (dataset.points.items.len > 1) { + } else if (path.items.len > 1) { var i: usize = 1; - while (i < dataset.points.items.len) : (i += 1) { - const prev = dataset.points.items[i - 1]; - const point = dataset.points.items[i]; + while (i < path.items.len) : (i += 1) { + const prev = path.items[i - 1]; + const point = path.items[i]; try plot.drawLineStyled(prev.x, prev.y, point.x, point.y, dataset.style, null); } } @@ -346,24 +359,25 @@ pub const Chart = struct { } }, .area => { - if (dataset.points.items.len > 1) { + if (path.items.len > 1) { var i: usize = 1; - while (i < dataset.points.items.len) : (i += 1) { - const prev = dataset.points.items[i - 1]; - const point = dataset.points.items[i]; + while (i < path.items.len) : (i += 1) { + const prev = path.items[i - 1]; + const point = path.items[i]; try plot.drawLineStyled(prev.x, prev.y, point.x, point.y, dataset.style, null); } } const baseline = dataset.fill_to orelse y_range.min; - for (dataset.points.items) |point| { + for (path.items) |point| { try plot.drawLineStyled(point.x, baseline, point.x, point.y, dataset.style, null); } }, } } - return try plot.view(allocator); + plot.drawIntoBuffer(&buffer); + return try buffer.render(allocator); } fn renderYAxisTitleRow(self: *const Chart, allocator: std.mem.Allocator, y_label_width: usize, plot_width: usize) ![]const u8 { @@ -487,20 +501,24 @@ pub const Chart = struct { const TickSet = struct { allocator: std.mem.Allocator, + range: DataRange, + values: std.array_list.Managed(f64), positions: std.array_list.Managed(usize), labels: std.array_list.Managed([]const u8), - fn init(allocator: std.mem.Allocator, axis: Axis, range: DataRange, span_hint: usize) !TickSet { + fn init(allocator: std.mem.Allocator, axis: Axis, range: DataRange) !TickSet { var self = TickSet{ .allocator = allocator, + .range = range, + .values = std.array_list.Managed(f64).init(allocator), .positions = std.array_list.Managed(usize).init(allocator), .labels = std.array_list.Managed([]const u8).init(allocator), }; - const span = @max(@as(usize, 1), span_hint); if (axis.labels.len > 0) { for (axis.labels) |label| { - try self.positions.append(charting.mapToResolution(label.value, range, span)); + try self.values.append(label.value); + try self.positions.append(0); try self.labels.append(try allocator.dupe(u8, label.text)); } return self; @@ -511,7 +529,8 @@ const TickSet = struct { while (i < tick_count) : (i += 1) { const t = if (tick_count == 1) 0.0 else @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(tick_count - 1)); const value = range.min + (range.max - range.min) * t; - try self.positions.append(charting.mapToResolution(value, range, span)); + try self.values.append(value); + try self.positions.append(0); const formatter = axis.formatter orelse charting.defaultFormatter; const label = try formatter(allocator, value); @@ -521,8 +540,16 @@ const TickSet = struct { return self; } + fn updatePositions(self: *TickSet, span: usize) void { + const usable_span = @max(@as(usize, 1), span); + for (self.values.items, 0..) |value, index| { + self.positions.items[index] = charting.mapToResolution(value, self.range, usable_span); + } + } + fn deinit(self: *TickSet) void { for (self.labels.items) |label| self.allocator.free(label); + self.values.deinit(); self.positions.deinit(); self.labels.deinit(); } @@ -584,3 +611,147 @@ fn leftPrefixWidth(self: *const Chart, y_label_width: usize) usize { else @as(usize, 0)); } + +fn sampledPath(allocator: std.mem.Allocator, dataset: *const Dataset) !std.array_list.Managed(Point) { + var path = std.array_list.Managed(Point).init(allocator); + if (dataset.points.items.len == 0) return path; + + switch (dataset.interpolation) { + .linear => try path.appendSlice(dataset.points.items), + .step_start => try appendSteppedPoints(&path, dataset.points.items, .step_start), + .step_center => try appendSteppedPoints(&path, dataset.points.items, .step_center), + .step_end => try appendSteppedPoints(&path, dataset.points.items, .step_end), + .catmull_rom => try appendCatmullRomPoints(&path, dataset.points.items, dataset.interpolation_steps, dataset.curve_tension), + .monotone_cubic => try appendMonotonePoints(allocator, &path, dataset.points.items, dataset.interpolation_steps), + } + + return path; +} + +fn appendSteppedPoints(path: *std.array_list.Managed(Point), points: []const Point, mode: Interpolation) !void { + if (points.len == 0) return; + try path.append(points[0]); + + for (points[1..], 1..) |point, index| { + const prev = points[index - 1]; + switch (mode) { + .step_start => { + try path.append(.{ .x = prev.x, .y = point.y }); + }, + .step_center => { + const mid_x = prev.x + (point.x - prev.x) / 2.0; + try path.append(.{ .x = mid_x, .y = prev.y }); + try path.append(.{ .x = mid_x, .y = point.y }); + }, + .step_end => { + try path.append(.{ .x = point.x, .y = prev.y }); + }, + else => unreachable, + } + try path.append(point); + } +} + +fn appendCatmullRomPoints(path: *std.array_list.Managed(Point), points: []const Point, steps: u8, tension: f64) !void { + if (points.len <= 2) { + try path.appendSlice(points); + return; + } + + const alpha = 1.0 - tension; + try path.append(points[0]); + + var i: usize = 0; + while (i + 1 < points.len) : (i += 1) { + const p0 = if (i == 0) points[i] else points[i - 1]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = if (i + 2 < points.len) points[i + 2] else points[i + 1]; + + var step: usize = 1; + while (step <= steps) : (step += 1) { + const t = @as(f64, @floatFromInt(step)) / @as(f64, @floatFromInt(steps)); + try path.append(catmullRomPoint(p0, p1, p2, p3, t, alpha)); + } + } +} + +fn catmullRomPoint(p0: Point, p1: Point, p2: Point, p3: Point, t: f64, alpha: f64) Point { + const t2 = t * t; + const t3 = t2 * t; + + const a0 = -alpha * t3 + 2.0 * alpha * t2 - alpha * t; + const a1 = (2.0 - alpha) * t3 + (alpha - 3.0) * t2 + 1.0; + const a2 = (alpha - 2.0) * t3 + (3.0 - 2.0 * alpha) * t2 + alpha * t; + const a3 = alpha * t3 - alpha * t2; + + return .{ + .x = a0 * p0.x + a1 * p1.x + a2 * p2.x + a3 * p3.x, + .y = a0 * p0.y + a1 * p1.y + a2 * p2.y + a3 * p3.y, + }; +} + +fn appendMonotonePoints(allocator: std.mem.Allocator, path: *std.array_list.Managed(Point), points: []const Point, steps: u8) !void { + if (points.len <= 2 or !strictlyIncreasingX(points)) { + try path.appendSlice(points); + return; + } + + const delta = try allocator.alloc(f64, points.len - 1); + defer allocator.free(delta); + const slopes = try allocator.alloc(f64, points.len); + defer allocator.free(slopes); + + for (0..points.len - 1) |i| { + const dx = points[i + 1].x - points[i].x; + if (dx <= 0) { + try path.appendSlice(points); + return; + } + delta[i] = (points[i + 1].y - points[i].y) / dx; + } + + slopes[0] = delta[0]; + slopes[points.len - 1] = delta[delta.len - 1]; + + for (1..points.len - 1) |i| { + if (delta[i - 1] == 0 or delta[i] == 0 or std.math.signbit(delta[i - 1]) != std.math.signbit(delta[i])) { + slopes[i] = 0; + } else { + const h0 = points[i].x - points[i - 1].x; + const h1 = points[i + 1].x - points[i].x; + const w1 = 2.0 * h1 + h0; + const w2 = h1 + 2.0 * h0; + slopes[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]); + } + } + + try path.append(points[0]); + for (0..points.len - 1) |i| { + const p0 = points[i]; + const p1 = points[i + 1]; + const dx = p1.x - p0.x; + + var step: usize = 1; + while (step <= steps) : (step += 1) { + const t = @as(f64, @floatFromInt(step)) / @as(f64, @floatFromInt(steps)); + const t2 = t * t; + const t3 = t2 * t; + const h00 = 2.0 * t3 - 3.0 * t2 + 1.0; + const h10 = t3 - 2.0 * t2 + t; + const h01 = -2.0 * t3 + 3.0 * t2; + const h11 = t3 - t2; + try path.append(.{ + .x = p0.x + dx * t, + .y = h00 * p0.y + h10 * dx * slopes[i] + h01 * p1.y + h11 * dx * slopes[i + 1], + }); + } + } +} + +fn strictlyIncreasingX(points: []const Point) bool { + for (points[1..], 1..) |point, index| { + if (!(point.x > points[index - 1].x)) return false; + } + return true; +} diff --git a/src/components/charting.zig b/src/components/charting.zig index ef6da30..4094387 100644 --- a/src/components/charting.zig +++ b/src/components/charting.zig @@ -54,6 +54,15 @@ pub const GraphType = enum { area, }; +pub const Interpolation = enum { + linear, + step_start, + step_center, + step_end, + catmull_rom, + monotone_cubic, +}; + pub const Orientation = enum { vertical, horizontal, diff --git a/src/root.zig b/src/root.zig index e1bd34f..3e5a3e7 100644 --- a/src/root.zig +++ b/src/root.zig @@ -232,6 +232,7 @@ pub const PlotPoint = components.charting.Point; pub const PlotRange = components.charting.DataRange; pub const PlotMarker = components.charting.Marker; pub const GraphType = components.chart.GraphType; +pub const ChartInterpolation = components.chart.Interpolation; pub const Axis = components.chart.Axis; pub const AxisLabel = components.chart.AxisLabel; pub const ChartDataset = components.chart.Dataset; diff --git a/tests/chart_tests.zig b/tests/chart_tests.zig index 18f4013..d4bc1da 100644 --- a/tests/chart_tests.zig +++ b/tests/chart_tests.zig @@ -73,6 +73,49 @@ test "chart renders titles and legend" { try testing.expect(std.mem.indexOf(u8, plain, "Load") != null); } +test "chart dataset supports curved interpolation modes" { + const allocator = testing.allocator; + + var chart = zz.Chart.init(allocator); + defer chart.deinit(); + + chart.setSize(32, 12); + chart.setMarker(.ascii); + chart.x_axis = .{ .tick_count = 4 }; + chart.y_axis = .{ .tick_count = 4 }; + + var smooth = try zz.ChartDataset.init(allocator, "smooth"); + smooth.setGraphType(.line); + smooth.setInterpolation(.monotone_cubic); + smooth.setInterpolationSteps(12); + try smooth.setPoints(&.{ + .{ .x = 0, .y = 10 }, + .{ .x = 1, .y = 35 }, + .{ .x = 2, .y = 18 }, + .{ .x = 3, .y = 42 }, + }); + try chart.addDataset(smooth); + + var stepped = try zz.ChartDataset.init(allocator, "step"); + stepped.setGraphType(.line); + stepped.setInterpolation(.step_center); + try stepped.setPoints(&.{ + .{ .x = 0, .y = 8 }, + .{ .x = 1, .y = 14 }, + .{ .x = 2, .y = 9 }, + .{ .x = 3, .y = 20 }, + }); + try chart.addDataset(stepped); + + const view = try chart.view(allocator); + defer allocator.free(view); + const plain = try stripAnsi(allocator, view); + defer allocator.free(plain); + + try testing.expect(std.mem.indexOf(u8, plain, "smooth") != null); + try testing.expect(std.mem.indexOf(u8, plain, "step") != null); +} + test "horizontal bar chart supports negative values" { const allocator = testing.allocator; From a548d5a22dce940f6cd9cf44aeac2e19a91211e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Fri, 6 Mar 2026 12:50:53 +0100 Subject: [PATCH 3/5] feat: add Charts tab to showcase and expand charts example --- README.md | 7 +- examples/charts.zig | 33 ++++++++ examples/showcase.zig | 193 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 222 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bfd5f2c..2d20e3f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A delightful TUI framework for Zig, inspired by [Bubble Tea](https://github.com/ - **Elm Architecture** - Model-Update-View pattern for predictable state management - **Rich Styling** - Comprehensive styling system with colors, borders, padding, margin backgrounds, per-side border colors, tab width control, style ranges, full style inheritance, text transforms, whitespace formatting controls, and unset methods -- **22 Pre-built Components** - TextInput (with autocomplete/word movement), TextArea, List (fuzzy filtering), Table (interactive with row selection), Viewport, Progress (color gradients), Spinner, Tree, StyledList, Sparkline, Chart, BarChart, Canvas, Notification/Toast, Confirm dialog, Modal/Popup, Tooltip, Help, Paginator, Timer, FilePicker, TabGroup (multi-view routing) +- **22 Pre-built Components** - TextInput (with autocomplete/word movement), TextArea, List (fuzzy filtering), Table (interactive with row selection), Viewport, Progress (color gradients), Spinner, Tree, StyledList, Sparkline, Chart (linear, stepped, smoothed, area, scatter), BarChart, Canvas, Notification/Toast, Confirm dialog, Modal/Popup, Tooltip, Help, Paginator, Timer, FilePicker, TabGroup (multi-view routing) - **Focus Management** - `FocusGroup` with Tab/Shift+Tab cycling, comptime focusable protocol, `FocusStyle` for visual focus ring indicators - **Keybinding Management** - Structured `KeyBinding`/`KeyMap` with matching, display formatting, and Help component integration - **Color System** - ANSI 16, 256, and TrueColor with adaptive colors, color profile detection, and dark background detection @@ -401,6 +401,8 @@ const chart = try spark.view(allocator); Cartesian chart with multiple datasets, axes, grid lines, legends, selectable markers, and interpolation modes (`linear`, stepped, `catmull_rom`, `monotone_cubic`): +See `zig build run-charts` or the `Charts` tab in `zig build run-showcase` for a combined demo of smoothed lines, stepped areas, horizontal bars, vertical bars, sparklines, and canvas plots. + ```zig var chart = zz.Chart.init(allocator); chart.setSize(48, 16); @@ -1047,7 +1049,8 @@ zig build run-todo_list zig build run-text_editor zig build run-file_browser zig build run-dashboard -zig build run-showcase # Multi-tab demo of all features +zig build run-charts # Charts, bars, sparkline, canvas, interpolation modes +zig build run-showcase # Multi-tab demo of all features, including a dedicated Charts tab zig build run-focus_form # Focus management with Tab cycling zig build run-tabs # TabGroup multi-screen routing zig build run-clipboard_osc52 # OSC 52 clipboard output demo diff --git a/examples/charts.zig b/examples/charts.zig index 7f7b522..5dbcd6c 100644 --- a/examples/charts.zig +++ b/examples/charts.zig @@ -39,15 +39,22 @@ const Model = struct { mem.setStyle((zz.Style{}).fg(zz.Color.magenta())); mem.setInterpolation(.catmull_rom); mem.setInterpolationSteps(10); + var backlog = zz.ChartDataset.init(ctx.persistent_allocator, "Backlog") catch unreachable; + backlog.setStyle((zz.Style{}).fg(zz.Color.yellow())); + backlog.setGraphType(.area); + backlog.setInterpolation(.step_center); + backlog.setFillBaseline(18.0); for (0..24) |i| { const x = @as(f64, @floatFromInt(i)); cpu.appendPoint(.{ .x = x, .y = 55.0 + @sin(x / 3.0) * 18.0 }) catch unreachable; mem.appendPoint(.{ .x = x, .y = 40.0 + @cos(x / 4.0) * 14.0 }) catch unreachable; + backlog.appendPoint(.{ .x = x, .y = 18.0 + @sin(x / 2.4) * 7.0 + 3.0 }) catch unreachable; } self.chart.addDataset(cpu) catch unreachable; self.chart.addDataset(mem) catch unreachable; + self.chart.addDataset(backlog) catch unreachable; self.bars = zz.BarChart.init(ctx.persistent_allocator); self.bars.setSize(30, 12); @@ -95,12 +102,15 @@ const Model = struct { var cpu = &self.chart.datasets.items[0]; var mem = &self.chart.datasets.items[1]; + var backlog = &self.chart.datasets.items[2]; if (cpu.points.items.len >= 32) _ = cpu.points.orderedRemove(0); if (mem.points.items.len >= 32) _ = mem.points.orderedRemove(0); + if (backlog.points.items.len >= 32) _ = backlog.points.orderedRemove(0); const next_x = if (cpu.points.items.len == 0) 0.0 else cpu.points.items[cpu.points.items.len - 1].x + 1.0; cpu.appendPoint(.{ .x = next_x, .y = 55.0 + @sin((self.phase + next_x) / 3.0) * 18.0 }) catch {}; mem.appendPoint(.{ .x = next_x, .y = 40.0 + @cos((self.phase + next_x) / 4.0) * 14.0 }) catch {}; + backlog.appendPoint(.{ .x = next_x, .y = 18.0 + @sin((self.phase + next_x) / 2.4) * 7.0 + 3.0 }) catch {}; self.chart.x_axis.bounds = .{ .min = @max(0.0, next_x - 31.0), .max = next_x }; self.spark.push(30.0 + 10.0 * @sin((self.phase + next_x) / 5.0)) catch {}; @@ -120,6 +130,7 @@ const Model = struct { pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { const line_chart = self.chart.view(ctx.allocator) catch ""; const bars = self.bars.view(ctx.allocator) catch ""; + const vertical = self.renderVerticalBars(ctx) catch ""; const spark = self.spark.view(ctx.allocator) catch ""; const canvas = self.renderCanvas(ctx) catch ""; @@ -132,6 +143,8 @@ const Model = struct { box(ctx, "Sparkline", spark) catch spark, " ", box(ctx, "Canvas", canvas) catch canvas, + " ", + box(ctx, "Vertical Bars", vertical) catch vertical, }) catch spark; const content = zz.joinVertical(ctx.allocator, &.{ top, "", bottom, "", "Press q to quit" }) catch top; @@ -159,6 +172,26 @@ const Model = struct { return try canvas.view(ctx.allocator); } + + fn renderVerticalBars(self: *const Model, ctx: *const zz.Context) ![]const u8 { + _ = self; + var chart = zz.BarChart.init(ctx.allocator); + defer chart.deinit(); + + chart.setSize(22, 10); + chart.setOrientation(.vertical); + chart.show_values = true; + chart.label_style = (zz.Style{}).fg(zz.Color.gray(18)).inline_style(true); + chart.axis_style = (zz.Style{}).fg(zz.Color.gray(10)).inline_style(true); + chart.positive_style = (zz.Style{}).fg(zz.Color.hex("#F97316")).inline_style(true); + chart.negative_style = (zz.Style{}).fg(zz.Color.hex("#EF4444")).inline_style(true); + try chart.addBar(try zz.Bar.init(ctx.allocator, "Mon", 9)); + try chart.addBar(try zz.Bar.init(ctx.allocator, "Tue", 13)); + try chart.addBar(try zz.Bar.init(ctx.allocator, "Wed", 6)); + try chart.addBar(try zz.Bar.init(ctx.allocator, "Thu", -4)); + try chart.addBar(try zz.Bar.init(ctx.allocator, "Fri", 11)); + return try chart.view(ctx.allocator); + } }; fn box(ctx: *const zz.Context, title: []const u8, body: []const u8) ![]const u8 { diff --git a/examples/showcase.zig b/examples/showcase.zig index 98619b1..c0c4689 100644 --- a/examples/showcase.zig +++ b/examples/showcase.zig @@ -6,6 +6,7 @@ const zz = @import("zigzag"); const Tab = enum { dashboard, + charts, data, files, editor, @@ -14,6 +15,7 @@ const Tab = enum { pub fn name(self: Tab) []const u8 { return switch (self) { .dashboard => "Dashboard", + .charts => "Charts", .data => "Data", .files => "Files", .editor => "Editor", @@ -35,6 +37,8 @@ const Model = struct { progress: zz.Progress, timer: zz.components.Timer, sparkline: zz.Sparkline, + chart: zz.Chart, + bars: zz.BarChart, notifications: zz.Notification, frame_count: u64, paused: bool, @@ -80,6 +84,63 @@ const Model = struct { self.sparkline = zz.Sparkline.init(ctx.persistent_allocator); self.sparkline.setWidth(30); + self.sparkline.setGradient(zz.Color.hex("#F97316"), zz.Color.hex("#22C55E")); + + self.chart = zz.Chart.init(ctx.persistent_allocator); + self.chart.setSize(52, 18); + self.chart.setMarker(.braille); + self.chart.setLegendPosition(.top); + self.chart.x_axis = .{ + .title = "Time", + .tick_count = 5, + .show_grid = true, + }; + self.chart.y_axis = .{ + .title = "Utilization", + .tick_count = 5, + .show_grid = true, + }; + + var cpu = zz.ChartDataset.init(ctx.persistent_allocator, "CPU") catch unreachable; + cpu.setStyle((zz.Style{}).fg(zz.Color.cyan()).bold(true)); + cpu.setShowPoints(true); + cpu.setInterpolation(.monotone_cubic); + cpu.setInterpolationSteps(10); + + var mem = zz.ChartDataset.init(ctx.persistent_allocator, "Memory") catch unreachable; + mem.setStyle((zz.Style{}).fg(zz.Color.magenta())); + mem.setInterpolation(.catmull_rom); + mem.setInterpolationSteps(10); + + var backlog = zz.ChartDataset.init(ctx.persistent_allocator, "Backlog") catch unreachable; + backlog.setStyle((zz.Style{}).fg(zz.Color.yellow())); + backlog.setGraphType(.area); + backlog.setInterpolation(.step_center); + backlog.setFillBaseline(18.0); + + for (0..24) |i| { + const x = @as(f64, @floatFromInt(i)); + cpu.appendPoint(.{ .x = x, .y = 52.0 + @sin(x / 3.0) * 15.0 }) catch unreachable; + mem.appendPoint(.{ .x = x, .y = 44.0 + @cos(x / 4.0) * 12.0 }) catch unreachable; + backlog.appendPoint(.{ .x = x, .y = 18.0 + @sin(x / 2.6) * 7.0 + 3.0 }) catch unreachable; + } + + self.chart.addDataset(cpu) catch unreachable; + self.chart.addDataset(mem) catch unreachable; + self.chart.addDataset(backlog) catch unreachable; + + self.bars = zz.BarChart.init(ctx.persistent_allocator); + self.bars.setSize(32, 12); + self.bars.setOrientation(.horizontal); + self.bars.show_values = true; + self.bars.label_style = (zz.Style{}).fg(zz.Color.gray(18)).inline_style(true); + self.bars.positive_style = (zz.Style{}).fg(zz.Color.green()).inline_style(true); + self.bars.negative_style = (zz.Style{}).fg(zz.Color.red()).inline_style(true); + self.bars.axis_style = (zz.Style{}).fg(zz.Color.gray(10)).inline_style(true); + self.bars.addBar(zz.Bar.init(ctx.persistent_allocator, "api", 22) catch unreachable) catch unreachable; + self.bars.addBar(zz.Bar.init(ctx.persistent_allocator, "db", -10) catch unreachable) catch unreachable; + self.bars.addBar(zz.Bar.init(ctx.persistent_allocator, "queue", 15) catch unreachable) catch unreachable; + self.bars.addBar(zz.Bar.init(ctx.persistent_allocator, "cache", 9) catch unreachable) catch unreachable; self.notifications = zz.Notification.init(ctx.persistent_allocator); @@ -182,7 +243,7 @@ const Model = struct { self.show_quit_confirm = false; self.help = zz.components.Help.init(ctx.persistent_allocator); - self.help.addBinding("1-5", "tabs") catch {}; + self.help.addBinding("1-6", "tabs") catch {}; self.help.addBinding("Tab", "next tab") catch {}; self.help.addBinding("Ctrl+Q", "quit") catch {}; @@ -206,6 +267,25 @@ const Model = struct { // Update sparkline with FPS self.sparkline.push(ctx.fps()) catch {}; + var cpu = &self.chart.datasets.items[0]; + var mem = &self.chart.datasets.items[1]; + var backlog = &self.chart.datasets.items[2]; + if (cpu.points.items.len >= 32) _ = cpu.points.orderedRemove(0); + if (mem.points.items.len >= 32) _ = mem.points.orderedRemove(0); + if (backlog.points.items.len >= 32) _ = backlog.points.orderedRemove(0); + + const next_x = if (cpu.points.items.len == 0) 0.0 else cpu.points.items[cpu.points.items.len - 1].x + 1.0; + const phase = @as(f64, @floatFromInt(ctx.elapsed)); + cpu.appendPoint(.{ .x = next_x, .y = 52.0 + @sin((phase / 2_000_000.0 + next_x) / 3.0) * 15.0 }) catch {}; + mem.appendPoint(.{ .x = next_x, .y = 44.0 + @cos((phase / 2_000_000.0 + next_x) / 4.0) * 12.0 }) catch {}; + backlog.appendPoint(.{ .x = next_x, .y = 18.0 + @sin((phase / 2_000_000.0 + next_x) / 2.6) * 7.0 + 3.0 }) catch {}; + self.chart.x_axis.bounds = .{ .min = @max(0.0, next_x - 31.0), .max = next_x }; + + self.bars.bars.items[0].value = 20.0 + @sin(phase / 800_000_000.0) * 11.0; + self.bars.bars.items[1].value = -7.0 - @cos(phase / 900_000_000.0) * 8.0; + self.bars.bars.items[2].value = 12.0 + @sin(phase / 700_000_000.0) * 9.0; + self.bars.bars.items[3].value = 8.0 + @cos(phase / 600_000_000.0) * 6.0; + // Update notifications self.notifications.update(ctx.elapsed); } @@ -250,24 +330,27 @@ const Model = struct { switch (k.key) { .char => |c| switch (c) { '1' => self.active_tab = .dashboard, - '2' => self.active_tab = .data, - '3' => self.active_tab = .files, - '4' => self.active_tab = .editor, - '5' => self.active_tab = .unicode, + '2' => self.active_tab = .charts, + '3' => self.active_tab = .data, + '4' => self.active_tab = .files, + '5' => self.active_tab = .editor, + '6' => self.active_tab = .unicode, else => self.handleTabKey(k), }, .tab => { if (k.modifiers.shift) { self.active_tab = switch (self.active_tab) { .dashboard => .unicode, - .data => .dashboard, + .charts => .dashboard, + .data => .charts, .files => .data, .editor => .files, .unicode => .editor, }; } else { self.active_tab = switch (self.active_tab) { - .dashboard => .data, + .dashboard => .charts, + .charts => .data, .data => .files, .files => .editor, .editor => .unicode, @@ -327,6 +410,7 @@ const Model = struct { else => {}, } }, + .charts => {}, .data => { switch (k.key) { .char => |c| switch (c) { @@ -395,7 +479,7 @@ const Model = struct { var result = std.array_list.Managed(u8).init(ctx.allocator); const writer = result.writer(); - const tabs = [_]Tab{ .dashboard, .data, .files, .editor, .unicode }; + const tabs = [_]Tab{ .dashboard, .charts, .data, .files, .editor, .unicode }; for (tabs, 0..) |tab, i| { if (i > 0) try writer.writeAll(" "); @@ -432,6 +516,7 @@ const Model = struct { fn renderActiveTab(self: *const Model, ctx: *const zz.Context) ![]const u8 { return switch (self.active_tab) { .dashboard => self.renderDashboard(ctx), + .charts => self.renderChartsTab(ctx), .data => self.renderDataTab(ctx), .files => self.renderFilesTab(ctx), .editor => self.renderEditorTab(ctx), @@ -628,6 +713,44 @@ const Model = struct { return zz.joinVertical(ctx.allocator, &.{ main_row, "", focus_hint }); } + fn renderChartsTab(self: *const Model, ctx: *const zz.Context) ![]const u8 { + const trend_view = try self.chart.view(ctx.allocator); + const bars_view = try self.bars.view(ctx.allocator); + const vertical_view = try self.renderVerticalBars(ctx); + const canvas_view = try self.renderChartCanvas(ctx); + + var trend_style = zz.Style{}; + trend_style = trend_style.borderAll(zz.Border.rounded); + trend_style = trend_style.borderForeground(zz.Color.cyan()); + trend_style = trend_style.paddingAll(1); + + var bars_style = zz.Style{}; + bars_style = bars_style.borderAll(zz.Border.rounded); + bars_style = bars_style.borderForeground(zz.Color.green()); + bars_style = bars_style.paddingAll(1); + + var aux_style = zz.Style{}; + aux_style = aux_style.borderAll(zz.Border.rounded); + aux_style = aux_style.borderForeground(zz.Color.yellow()); + aux_style = aux_style.paddingAll(1); + + const trend_box = try trend_style.render(ctx.allocator, try self.section(ctx, "Interpolated Lines + Area", trend_view)); + const bars_box = try bars_style.render(ctx.allocator, try self.section(ctx, "Horizontal Bars", bars_view)); + const vertical_box = try aux_style.render(ctx.allocator, try self.section(ctx, "Vertical Bars", vertical_view)); + const canvas_box = try aux_style.render(ctx.allocator, try self.section(ctx, "Canvas Plot", canvas_view)); + + const top = try zz.joinHorizontal(ctx.allocator, &.{ trend_box, " ", bars_box }); + const bottom = try zz.joinHorizontal(ctx.allocator, &.{ vertical_box, " ", canvas_box }); + + var hint_style = zz.Style{}; + hint_style = hint_style.fg(zz.Color.gray(10)); + hint_style = hint_style.italic(true); + hint_style = hint_style.inline_style(true); + const hint = try hint_style.render(ctx.allocator, "Includes monotone cubic, Catmull-Rom, stepped area, horizontal bars, vertical bars, and braille canvas plotting."); + + return zz.joinVertical(ctx.allocator, &.{ top, "", bottom, "", hint }); + } + fn renderFilesTab(self: *const Model, ctx: *const zz.Context) ![]const u8 { const viewport_view = try self.file_viewport.view(ctx.allocator); @@ -647,6 +770,56 @@ const Model = struct { return box_style.render(ctx.allocator, content); } + fn renderVerticalBars(_: *const Model, ctx: *const zz.Context) ![]const u8 { + var chart = zz.BarChart.init(ctx.allocator); + defer chart.deinit(); + + chart.setSize(24, 10); + chart.setOrientation(.vertical); + chart.show_values = true; + chart.label_style = (zz.Style{}).fg(zz.Color.gray(18)).inline_style(true); + chart.axis_style = (zz.Style{}).fg(zz.Color.gray(10)).inline_style(true); + chart.positive_style = (zz.Style{}).fg(zz.Color.hex("#F97316")).inline_style(true); + chart.negative_style = (zz.Style{}).fg(zz.Color.hex("#EF4444")).inline_style(true); + try chart.addBar(try zz.Bar.init(ctx.allocator, "Mon", 9)); + try chart.addBar(try zz.Bar.init(ctx.allocator, "Tue", 13)); + try chart.addBar(try zz.Bar.init(ctx.allocator, "Wed", 6)); + try chart.addBar(try zz.Bar.init(ctx.allocator, "Thu", -4)); + try chart.addBar(try zz.Bar.init(ctx.allocator, "Fri", 11)); + return try chart.view(ctx.allocator); + } + + fn renderChartCanvas(self: *const Model, ctx: *const zz.Context) ![]const u8 { + _ = self; + var canvas = zz.Canvas.init(ctx.allocator); + defer canvas.deinit(); + + canvas.setSize(24, 10); + canvas.setMarker(.braille); + canvas.setRanges(.{ .min = -1.2, .max = 1.2 }, .{ .min = -1.2, .max = 1.2 }); + + var style = zz.Style{}; + style = style.fg(zz.Color.yellow()); + style = style.inline_style(true); + + for (0..64) |i| { + const t = @as(f64, @floatFromInt(i)) / 10.0; + try canvas.drawPointStyled(@sin(t * 1.5), @cos(t * 2.1), style, null); + } + + return try canvas.view(ctx.allocator); + } + + fn section(self: *const Model, ctx: *const zz.Context, title: []const u8, body: []const u8) ![]const u8 { + _ = self; + var header_style = zz.Style{}; + header_style = header_style.bold(true); + header_style = header_style.fg(zz.Color.white()); + header_style = header_style.inline_style(true); + const header = try header_style.render(ctx.allocator, title); + return try std.fmt.allocPrint(ctx.allocator, "{s}\n\n{s}", .{ header, body }); + } + fn renderEditorTab(self: *const Model, ctx: *const zz.Context) ![]const u8 { const editor_view = try self.text_area.view(ctx.allocator); @@ -776,7 +949,7 @@ const Model = struct { fn renderStatusBar(self: *const Model, ctx: *const zz.Context) ![]const u8 { _ = self; var help_comp = zz.components.Help.init(ctx.allocator); - try help_comp.addBinding("1-5", "tabs"); + try help_comp.addBinding("1-6", "tabs"); try help_comp.addBinding("Tab", "next"); try help_comp.addBinding("Ctrl+Q", "quit"); help_comp.setMaxWidth(ctx.width); @@ -794,6 +967,8 @@ const Model = struct { pub fn deinit(self: *Model) void { self.sparkline.deinit(); + self.chart.deinit(); + self.bars.deinit(); self.notifications.deinit(); self.table.deinit(); self.tree.deinit(); From e771a27a31b22347acc2e3e52bf54ce4b5061817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Fri, 6 Mar 2026 12:55:14 +0100 Subject: [PATCH 4/5] feat: add static snapshot charts and slower sample gating --- README.md | 6 +-- examples/charts.zig | 101 +++++++++++++++++++++++++++++++----------- examples/showcase.zig | 85 ++++++++++++++++++++++++++--------- 3 files changed, 143 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 2d20e3f..0631f58 100644 --- a/README.md +++ b/README.md @@ -401,7 +401,7 @@ const chart = try spark.view(allocator); Cartesian chart with multiple datasets, axes, grid lines, legends, selectable markers, and interpolation modes (`linear`, stepped, `catmull_rom`, `monotone_cubic`): -See `zig build run-charts` or the `Charts` tab in `zig build run-showcase` for a combined demo of smoothed lines, stepped areas, horizontal bars, vertical bars, sparklines, and canvas plots. +Charts are passive views over your data. They do not animate on their own; they only change when your model updates the dataset. `zig build run-charts` and the `Charts` tab in `zig build run-showcase` now demonstrate both static snapshot charts and slower sampled/live updates. ```zig var chart = zz.Chart.init(allocator); @@ -1049,8 +1049,8 @@ zig build run-todo_list zig build run-text_editor zig build run-file_browser zig build run-dashboard -zig build run-charts # Charts, bars, sparkline, canvas, interpolation modes -zig build run-showcase # Multi-tab demo of all features, including a dedicated Charts tab +zig build run-charts # Static snapshots plus slower sampled chart updates +zig build run-showcase # Multi-tab demo of all features, including a Charts tab with static and live examples zig build run-focus_form # Focus management with Tab cycling zig build run-tabs # TabGroup multi-screen routing zig build run-clipboard_osc52 # OSC 52 clipboard output demo diff --git a/examples/charts.zig b/examples/charts.zig index 5dbcd6c..8de23b4 100644 --- a/examples/charts.zig +++ b/examples/charts.zig @@ -8,6 +8,7 @@ const Model = struct { bars: zz.BarChart, spark: zz.Sparkline, phase: f64, + sample_gate: u8, pub const Msg = union(enum) { key: zz.KeyEvent, @@ -81,6 +82,7 @@ const Model = struct { } self.phase = 0; + self.sample_gate = 0; return zz.Cmd(Msg).tickMs(80); } @@ -98,27 +100,31 @@ const Model = struct { else => {}, }, .tick => { - self.phase += 1.0; - - var cpu = &self.chart.datasets.items[0]; - var mem = &self.chart.datasets.items[1]; - var backlog = &self.chart.datasets.items[2]; - if (cpu.points.items.len >= 32) _ = cpu.points.orderedRemove(0); - if (mem.points.items.len >= 32) _ = mem.points.orderedRemove(0); - if (backlog.points.items.len >= 32) _ = backlog.points.orderedRemove(0); - - const next_x = if (cpu.points.items.len == 0) 0.0 else cpu.points.items[cpu.points.items.len - 1].x + 1.0; - cpu.appendPoint(.{ .x = next_x, .y = 55.0 + @sin((self.phase + next_x) / 3.0) * 18.0 }) catch {}; - mem.appendPoint(.{ .x = next_x, .y = 40.0 + @cos((self.phase + next_x) / 4.0) * 14.0 }) catch {}; - backlog.appendPoint(.{ .x = next_x, .y = 18.0 + @sin((self.phase + next_x) / 2.4) * 7.0 + 3.0 }) catch {}; - self.chart.x_axis.bounds = .{ .min = @max(0.0, next_x - 31.0), .max = next_x }; - - self.spark.push(30.0 + 10.0 * @sin((self.phase + next_x) / 5.0)) catch {}; - - self.bars.bars.items[0].value = 20.0 + @sin(self.phase / 3.0) * 18.0; - self.bars.bars.items[1].value = -5.0 - @cos(self.phase / 4.0) * 15.0; - self.bars.bars.items[2].value = 12.0 + @sin(self.phase / 5.0) * 12.0; - self.bars.bars.items[3].value = 8.0 + @cos(self.phase / 6.0) * 10.0; + self.sample_gate +%= 1; + if (self.sample_gate >= 6) { + self.sample_gate = 0; + self.phase += 1.0; + + var cpu = &self.chart.datasets.items[0]; + var mem = &self.chart.datasets.items[1]; + var backlog = &self.chart.datasets.items[2]; + if (cpu.points.items.len >= 32) _ = cpu.points.orderedRemove(0); + if (mem.points.items.len >= 32) _ = mem.points.orderedRemove(0); + if (backlog.points.items.len >= 32) _ = backlog.points.orderedRemove(0); + + const next_x = if (cpu.points.items.len == 0) 0.0 else cpu.points.items[cpu.points.items.len - 1].x + 1.0; + cpu.appendPoint(.{ .x = next_x, .y = 55.0 + @sin((self.phase + next_x) / 3.0) * 18.0 }) catch {}; + mem.appendPoint(.{ .x = next_x, .y = 40.0 + @cos((self.phase + next_x) / 4.0) * 14.0 }) catch {}; + backlog.appendPoint(.{ .x = next_x, .y = 18.0 + @sin((self.phase + next_x) / 2.4) * 7.0 + 3.0 }) catch {}; + self.chart.x_axis.bounds = .{ .min = @max(0.0, next_x - 31.0), .max = next_x }; + + self.spark.push(30.0 + 10.0 * @sin((self.phase + next_x) / 5.0)) catch {}; + + self.bars.bars.items[0].value = 20.0 + @sin(self.phase / 3.0) * 18.0; + self.bars.bars.items[1].value = -5.0 - @cos(self.phase / 4.0) * 15.0; + self.bars.bars.items[2].value = 12.0 + @sin(self.phase / 5.0) * 12.0; + self.bars.bars.items[3].value = 8.0 + @cos(self.phase / 6.0) * 10.0; + } return zz.Cmd(Msg).tickMs(80); }, @@ -129,25 +135,30 @@ const Model = struct { pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { const line_chart = self.chart.view(ctx.allocator) catch ""; + const snapshot = self.renderStaticSnapshot(ctx) catch ""; const bars = self.bars.view(ctx.allocator) catch ""; const vertical = self.renderVerticalBars(ctx) catch ""; const spark = self.spark.view(ctx.allocator) catch ""; const canvas = self.renderCanvas(ctx) catch ""; const top = zz.joinHorizontal(ctx.allocator, &.{ - box(ctx, "Trend", line_chart) catch line_chart, + box(ctx, "Sampled Stream", line_chart) catch line_chart, " ", - box(ctx, "Bars", bars) catch bars, + box(ctx, "Static Snapshot", snapshot) catch snapshot, }) catch line_chart; + const middle = zz.joinHorizontal(ctx.allocator, &.{ + box(ctx, "Bars", bars) catch bars, + " ", + box(ctx, "Vertical Bars", vertical) catch vertical, + }) catch bars; const bottom = zz.joinHorizontal(ctx.allocator, &.{ box(ctx, "Sparkline", spark) catch spark, " ", box(ctx, "Canvas", canvas) catch canvas, - " ", - box(ctx, "Vertical Bars", vertical) catch vertical, }) catch spark; - const content = zz.joinVertical(ctx.allocator, &.{ top, "", bottom, "", "Press q to quit" }) catch top; + const note = "Live panels only update when a new sample arrives; snapshot panels stay fixed."; + const content = zz.joinVertical(ctx.allocator, &.{ top, "", middle, "", bottom, "", note, "", "Press q to quit" }) catch top; return zz.place.place(ctx.allocator, ctx.width, ctx.height, .center, .middle, content) catch content; } @@ -192,6 +203,44 @@ const Model = struct { try chart.addBar(try zz.Bar.init(ctx.allocator, "Fri", 11)); return try chart.view(ctx.allocator); } + + fn renderStaticSnapshot(self: *const Model, ctx: *const zz.Context) ![]const u8 { + _ = self; + var chart = zz.Chart.init(ctx.allocator); + defer chart.deinit(); + + chart.setSize(34, 12); + chart.setMarker(.braille); + chart.setLegendPosition(.top); + chart.x_axis = .{ .title = "Quarter", .tick_count = 4, .show_grid = true }; + chart.y_axis = .{ .title = "Revenue", .tick_count = 4, .show_grid = true }; + + var actual = try zz.ChartDataset.init(ctx.allocator, "Actual"); + actual.setStyle((zz.Style{}).fg(zz.Color.hex("#22C55E")).bold(true)); + actual.setInterpolation(.monotone_cubic); + actual.setInterpolationSteps(10); + actual.setShowPoints(true); + try actual.setPoints(&.{ + .{ .x = 1, .y = 18 }, + .{ .x = 2, .y = 24 }, + .{ .x = 3, .y = 21 }, + .{ .x = 4, .y = 29 }, + }); + + var forecast = try zz.ChartDataset.init(ctx.allocator, "Forecast"); + forecast.setStyle((zz.Style{}).fg(zz.Color.hex("#38BDF8"))); + forecast.setInterpolation(.step_end); + try forecast.setPoints(&.{ + .{ .x = 1, .y = 16 }, + .{ .x = 2, .y = 22 }, + .{ .x = 3, .y = 23 }, + .{ .x = 4, .y = 27 }, + }); + + try chart.addDataset(actual); + try chart.addDataset(forecast); + return try chart.view(ctx.allocator); + } }; fn box(ctx: *const zz.Context, title: []const u8, body: []const u8) ![]const u8 { diff --git a/examples/showcase.zig b/examples/showcase.zig index c0c4689..b8bb861 100644 --- a/examples/showcase.zig +++ b/examples/showcase.zig @@ -39,6 +39,7 @@ const Model = struct { sparkline: zz.Sparkline, chart: zz.Chart, bars: zz.BarChart, + chart_sample_gate: u8, notifications: zz.Notification, frame_count: u64, paused: bool, @@ -147,6 +148,7 @@ const Model = struct { self.frame_count = 0; self.paused = false; self.last_elapsed = 0; + self.chart_sample_gate = 0; // Data tab self.table = zz.Table(4).init(ctx.persistent_allocator); @@ -267,24 +269,29 @@ const Model = struct { // Update sparkline with FPS self.sparkline.push(ctx.fps()) catch {}; - var cpu = &self.chart.datasets.items[0]; - var mem = &self.chart.datasets.items[1]; - var backlog = &self.chart.datasets.items[2]; - if (cpu.points.items.len >= 32) _ = cpu.points.orderedRemove(0); - if (mem.points.items.len >= 32) _ = mem.points.orderedRemove(0); - if (backlog.points.items.len >= 32) _ = backlog.points.orderedRemove(0); - - const next_x = if (cpu.points.items.len == 0) 0.0 else cpu.points.items[cpu.points.items.len - 1].x + 1.0; - const phase = @as(f64, @floatFromInt(ctx.elapsed)); - cpu.appendPoint(.{ .x = next_x, .y = 52.0 + @sin((phase / 2_000_000.0 + next_x) / 3.0) * 15.0 }) catch {}; - mem.appendPoint(.{ .x = next_x, .y = 44.0 + @cos((phase / 2_000_000.0 + next_x) / 4.0) * 12.0 }) catch {}; - backlog.appendPoint(.{ .x = next_x, .y = 18.0 + @sin((phase / 2_000_000.0 + next_x) / 2.6) * 7.0 + 3.0 }) catch {}; - self.chart.x_axis.bounds = .{ .min = @max(0.0, next_x - 31.0), .max = next_x }; - - self.bars.bars.items[0].value = 20.0 + @sin(phase / 800_000_000.0) * 11.0; - self.bars.bars.items[1].value = -7.0 - @cos(phase / 900_000_000.0) * 8.0; - self.bars.bars.items[2].value = 12.0 + @sin(phase / 700_000_000.0) * 9.0; - self.bars.bars.items[3].value = 8.0 + @cos(phase / 600_000_000.0) * 6.0; + self.chart_sample_gate +%= 1; + if (self.chart_sample_gate >= 20) { + self.chart_sample_gate = 0; + + var cpu = &self.chart.datasets.items[0]; + var mem = &self.chart.datasets.items[1]; + var backlog = &self.chart.datasets.items[2]; + if (cpu.points.items.len >= 32) _ = cpu.points.orderedRemove(0); + if (mem.points.items.len >= 32) _ = mem.points.orderedRemove(0); + if (backlog.points.items.len >= 32) _ = backlog.points.orderedRemove(0); + + const next_x = if (cpu.points.items.len == 0) 0.0 else cpu.points.items[cpu.points.items.len - 1].x + 1.0; + const phase = @as(f64, @floatFromInt(ctx.elapsed)); + cpu.appendPoint(.{ .x = next_x, .y = 52.0 + @sin((phase / 2_000_000.0 + next_x) / 3.0) * 15.0 }) catch {}; + mem.appendPoint(.{ .x = next_x, .y = 44.0 + @cos((phase / 2_000_000.0 + next_x) / 4.0) * 12.0 }) catch {}; + backlog.appendPoint(.{ .x = next_x, .y = 18.0 + @sin((phase / 2_000_000.0 + next_x) / 2.6) * 7.0 + 3.0 }) catch {}; + self.chart.x_axis.bounds = .{ .min = @max(0.0, next_x - 31.0), .max = next_x }; + + self.bars.bars.items[0].value = 20.0 + @sin(phase / 800_000_000.0) * 11.0; + self.bars.bars.items[1].value = -7.0 - @cos(phase / 900_000_000.0) * 8.0; + self.bars.bars.items[2].value = 12.0 + @sin(phase / 700_000_000.0) * 9.0; + self.bars.bars.items[3].value = 8.0 + @cos(phase / 600_000_000.0) * 6.0; + } // Update notifications self.notifications.update(ctx.elapsed); @@ -717,6 +724,7 @@ const Model = struct { const trend_view = try self.chart.view(ctx.allocator); const bars_view = try self.bars.view(ctx.allocator); const vertical_view = try self.renderVerticalBars(ctx); + const snapshot_view = try self.renderStaticSnapshot(ctx); const canvas_view = try self.renderChartCanvas(ctx); var trend_style = zz.Style{}; @@ -737,16 +745,17 @@ const Model = struct { const trend_box = try trend_style.render(ctx.allocator, try self.section(ctx, "Interpolated Lines + Area", trend_view)); const bars_box = try bars_style.render(ctx.allocator, try self.section(ctx, "Horizontal Bars", bars_view)); const vertical_box = try aux_style.render(ctx.allocator, try self.section(ctx, "Vertical Bars", vertical_view)); + const snapshot_box = try aux_style.render(ctx.allocator, try self.section(ctx, "Static Snapshot", snapshot_view)); const canvas_box = try aux_style.render(ctx.allocator, try self.section(ctx, "Canvas Plot", canvas_view)); const top = try zz.joinHorizontal(ctx.allocator, &.{ trend_box, " ", bars_box }); - const bottom = try zz.joinHorizontal(ctx.allocator, &.{ vertical_box, " ", canvas_box }); + const bottom = try zz.joinHorizontal(ctx.allocator, &.{ vertical_box, " ", snapshot_box, " ", canvas_box }); var hint_style = zz.Style{}; hint_style = hint_style.fg(zz.Color.gray(10)); hint_style = hint_style.italic(true); hint_style = hint_style.inline_style(true); - const hint = try hint_style.render(ctx.allocator, "Includes monotone cubic, Catmull-Rom, stepped area, horizontal bars, vertical bars, and braille canvas plotting."); + const hint = try hint_style.render(ctx.allocator, "Live charts sample at a slower cadence; static snapshot panels show that charts stay fixed until your model changes the data."); return zz.joinVertical(ctx.allocator, &.{ top, "", bottom, "", hint }); } @@ -810,6 +819,42 @@ const Model = struct { return try canvas.view(ctx.allocator); } + fn renderStaticSnapshot(_: *const Model, ctx: *const zz.Context) ![]const u8 { + var chart = zz.Chart.init(ctx.allocator); + defer chart.deinit(); + + chart.setSize(24, 10); + chart.setMarker(.braille); + chart.setLegendPosition(.top); + chart.x_axis = .{ .title = "Quarter", .tick_count = 4, .show_grid = true }; + chart.y_axis = .{ .title = "Score", .tick_count = 4, .show_grid = true }; + + var actual = try zz.ChartDataset.init(ctx.allocator, "A"); + actual.setStyle((zz.Style{}).fg(zz.Color.hex("#22C55E")).bold(true)); + actual.setInterpolation(.monotone_cubic); + actual.setInterpolationSteps(10); + try actual.setPoints(&.{ + .{ .x = 1, .y = 18 }, + .{ .x = 2, .y = 24 }, + .{ .x = 3, .y = 21 }, + .{ .x = 4, .y = 29 }, + }); + + var target = try zz.ChartDataset.init(ctx.allocator, "T"); + target.setStyle((zz.Style{}).fg(zz.Color.hex("#38BDF8"))); + target.setInterpolation(.step_end); + try target.setPoints(&.{ + .{ .x = 1, .y = 16 }, + .{ .x = 2, .y = 22 }, + .{ .x = 3, .y = 23 }, + .{ .x = 4, .y = 27 }, + }); + + try chart.addDataset(actual); + try chart.addDataset(target); + return try chart.view(ctx.allocator); + } + fn section(self: *const Model, ctx: *const zz.Context, title: []const u8, body: []const u8) ![]const u8 { _ = self; var header_style = zz.Style{}; From e766a17c7136ac8aeca9ffc6e4fba0bc15b6d095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Fri, 6 Mar 2026 13:48:53 +0100 Subject: [PATCH 5/5] feat: enhance viewport with wrap, ANSI-aware scrolling, and custom scrollbar --- README.md | 10 +- build.zig | 1 + examples/charts.zig | 76 +++++--- src/components/viewport.zig | 354 ++++++++++++++++++++++++++---------- tests/viewport_tests.zig | 94 ++++++++++ 5 files changed, 408 insertions(+), 127 deletions(-) create mode 100644 tests/viewport_tests.zig diff --git a/README.md b/README.md index 0631f58..f99764c 100644 --- a/README.md +++ b/README.md @@ -304,11 +304,17 @@ list.handleKey(key_event); ### Viewport -Scrollable content area: +Scrollable content area with wrapping, horizontal scrolling, customizable scrollbar chars/styles, and built-in navigation keys (`j/k/h/l`, arrows, `PgUp/PgDn`, `g/G`, `d/u`): ```zig var viewport = zz.Viewport.init(allocator, 80, 24); try viewport.setContent(long_text); +viewport.setWrap(true); +viewport.setScrollbarChars("·", "█"); +viewport.setScrollbarStyle( + (zz.Style{}).fg(zz.Color.gray(8)).inline_style(true), + (zz.Style{}).fg(zz.Color.cyan()).inline_style(true), +); viewport.handleKey(key_event); // Supports j/k, Page Up/Down, etc. ``` @@ -401,7 +407,7 @@ const chart = try spark.view(allocator); Cartesian chart with multiple datasets, axes, grid lines, legends, selectable markers, and interpolation modes (`linear`, stepped, `catmull_rom`, `monotone_cubic`): -Charts are passive views over your data. They do not animate on their own; they only change when your model updates the dataset. `zig build run-charts` and the `Charts` tab in `zig build run-showcase` now demonstrate both static snapshot charts and slower sampled/live updates. +Charts are passive views over your data. They do not animate on their own; they only change when your model updates the dataset. `zig build run-charts` and the `Charts` tab in `zig build run-showcase` now demonstrate both static snapshot charts and slower sampled/live updates. The standalone `run-charts` demo also auto-sizes to the current terminal and uses a scrollable viewport when the content is larger than the screen. ```zig var chart = zz.Chart.init(allocator); diff --git a/build.zig b/build.zig index 5b8d48a..ceddb1f 100644 --- a/build.zig +++ b/build.zig @@ -67,6 +67,7 @@ pub fn build(b: *std.Build) void { "tests/tooltip_tests.zig", "tests/tab_group_tests.zig", "tests/chart_tests.zig", + "tests/viewport_tests.zig", }; const test_step = b.step("test", "Run unit tests"); diff --git a/examples/charts.zig b/examples/charts.zig index 8de23b4..ac3d4cb 100644 --- a/examples/charts.zig +++ b/examples/charts.zig @@ -7,12 +7,14 @@ const Model = struct { chart: zz.Chart, bars: zz.BarChart, spark: zz.Sparkline, + viewport: zz.Viewport, phase: f64, sample_gate: u8, pub const Msg = union(enum) { key: zz.KeyEvent, tick: zz.msg.Tick, + window_size: zz.msg.WindowSize, }; pub fn init(self: *Model, ctx: *zz.Context) zz.Cmd(Msg) { @@ -81,8 +83,17 @@ const Model = struct { self.spark.push(30.0 + 10.0 * @sin(x / 5.0)) catch unreachable; } + self.viewport = zz.Viewport.init(ctx.persistent_allocator, ctx.width, ctx.height); + self.viewport.setWrap(false); + self.viewport.setScrollbarChars("·", "█"); + self.viewport.setScrollbarStyle( + (zz.Style{}).fg(zz.Color.gray(8)).inline_style(true), + (zz.Style{}).fg(zz.Color.cyan()).inline_style(true), + ); + self.phase = 0; self.sample_gate = 0; + self.refreshViewport(ctx) catch {}; return zz.Cmd(Msg).tickMs(80); } @@ -90,14 +101,19 @@ const Model = struct { self.chart.deinit(); self.bars.deinit(); self.spark.deinit(); + self.viewport.deinit(); } - pub fn update(self: *Model, msg: Msg, _: *zz.Context) zz.Cmd(Msg) { + pub fn update(self: *Model, msg: Msg, ctx: *zz.Context) zz.Cmd(Msg) { switch (msg) { .key => |key| switch (key.key) { .char => |c| if (c == 'q') return .quit, .escape => return .quit, - else => {}, + else => self.viewport.handleKey(key), + }, + .window_size => { + self.refreshViewport(ctx) catch {}; + return .none; }, .tick => { self.sample_gate +%= 1; @@ -126,6 +142,8 @@ const Model = struct { self.bars.bars.items[3].value = 8.0 + @cos(self.phase / 6.0) * 10.0; } + self.refreshViewport(ctx) catch {}; + return zz.Cmd(Msg).tickMs(80); }, } @@ -134,32 +152,42 @@ const Model = struct { } pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { - const line_chart = self.chart.view(ctx.allocator) catch ""; - const snapshot = self.renderStaticSnapshot(ctx) catch ""; - const bars = self.bars.view(ctx.allocator) catch ""; - const vertical = self.renderVerticalBars(ctx) catch ""; - const spark = self.spark.view(ctx.allocator) catch ""; - const canvas = self.renderCanvas(ctx) catch ""; - - const top = zz.joinHorizontal(ctx.allocator, &.{ - box(ctx, "Sampled Stream", line_chart) catch line_chart, + const viewport_view = self.viewport.view(ctx.allocator) catch ""; + return viewport_view; + } + + fn refreshViewport(self: *Model, ctx: *zz.Context) !void { + self.viewport.setSize(ctx.width, ctx.height); + const content = try self.composeContent(ctx); + try self.viewport.setContent(content); + } + + fn composeContent(self: *const Model, ctx: *zz.Context) ![]const u8 { + const line_chart = try self.chart.view(ctx.allocator); + const snapshot = try self.renderStaticSnapshot(ctx); + const bars = try self.bars.view(ctx.allocator); + const vertical = try self.renderVerticalBars(ctx); + const spark = try self.spark.view(ctx.allocator); + const canvas = try self.renderCanvas(ctx); + + const top = try zz.joinHorizontal(ctx.allocator, &.{ + try box(ctx, "Sampled Stream", line_chart), " ", - box(ctx, "Static Snapshot", snapshot) catch snapshot, - }) catch line_chart; - const middle = zz.joinHorizontal(ctx.allocator, &.{ - box(ctx, "Bars", bars) catch bars, + try box(ctx, "Static Snapshot", snapshot), + }); + const middle = try zz.joinHorizontal(ctx.allocator, &.{ + try box(ctx, "Bars", bars), " ", - box(ctx, "Vertical Bars", vertical) catch vertical, - }) catch bars; - const bottom = zz.joinHorizontal(ctx.allocator, &.{ - box(ctx, "Sparkline", spark) catch spark, + try box(ctx, "Vertical Bars", vertical), + }); + const bottom = try zz.joinHorizontal(ctx.allocator, &.{ + try box(ctx, "Sparkline", spark), " ", - box(ctx, "Canvas", canvas) catch canvas, - }) catch spark; + try box(ctx, "Canvas", canvas), + }); - const note = "Live panels only update when a new sample arrives; snapshot panels stay fixed."; - const content = zz.joinVertical(ctx.allocator, &.{ top, "", middle, "", bottom, "", note, "", "Press q to quit" }) catch top; - return zz.place.place(ctx.allocator, ctx.width, ctx.height, .center, .middle, content) catch content; + const note = "Scroll: j/k/h/l, arrows, PgUp/PgDn, g/G. Live panels update only when a new sample arrives."; + return try zz.joinVertical(ctx.allocator, &.{ top, "", middle, "", bottom, "", note, "", "Press q to quit" }); } fn renderCanvas(self: *const Model, ctx: *const zz.Context) ![]const u8 { diff --git a/src/components/viewport.zig b/src/components/viewport.zig index 9fb4d24..4d56175 100644 --- a/src/components/viewport.zig +++ b/src/components/viewport.zig @@ -1,10 +1,10 @@ //! Scrollable content viewport component. -//! Allows scrolling through content larger than the visible area. const std = @import("std"); const keys = @import("../input/keys.zig"); -const style = @import("../style/style.zig"); const measure = @import("../layout/measure.zig"); +const style = @import("../style/style.zig"); +const ansi = @import("../terminal/ansi.zig"); const unicode = @import("../unicode.zig"); pub const Viewport = struct { @@ -12,27 +12,34 @@ pub const Viewport = struct { // Content content: []const u8, + owned_content: ?[]u8, lines: std.array_list.Managed([]const u8), // Dimensions width: u16, height: u16, - // Scroll position + // Scroll position (visual rows, display columns) y_offset: usize, x_offset: usize, // Styling viewport_style: style.Style, + scrollbar_track_style: style.Style, + scrollbar_thumb_style: style.Style, // Options wrap: bool, show_scrollbar: bool, + empty_char: []const u8, + scrollbar_track_char: []const u8, + scrollbar_thumb_char: []const u8, pub fn init(allocator: std.mem.Allocator, width: u16, height: u16) Viewport { return .{ .allocator = allocator, .content = "", + .owned_content = null, .lines = std.array_list.Managed([]const u8).init(allocator), .width = width, .height = height, @@ -40,116 +47,180 @@ pub const Viewport = struct { .x_offset = 0, .viewport_style = blk: { var s = style.Style{}; - s = s.inline_style(true); - break :blk s; + break :blk s.inline_style(true); + }, + .scrollbar_track_style = blk: { + var s = style.Style{}; + break :blk s.inline_style(true); + }, + .scrollbar_thumb_style = blk: { + var s = style.Style{}; + break :blk s.inline_style(true); }, .wrap = false, .show_scrollbar = true, + .empty_char = " ", + .scrollbar_track_char = "░", + .scrollbar_thumb_char = "█", }; } pub fn deinit(self: *Viewport) void { + if (self.owned_content) |content| self.allocator.free(content); self.lines.deinit(); } - /// Set content to display pub fn setContent(self: *Viewport, content: []const u8) !void { - self.content = content; + if (self.owned_content) |existing| self.allocator.free(existing); + const owned = try self.allocator.dupe(u8, content); + self.owned_content = owned; + self.content = owned; self.lines.clearRetainingCapacity(); - var iter = std.mem.splitScalar(u8, content, '\n'); + var iter = std.mem.splitScalar(u8, self.content, '\n'); while (iter.next()) |line| { try self.lines.append(line); } - // Ensure scroll position is valid self.clampScroll(); } - /// Set viewport dimensions pub fn setSize(self: *Viewport, width: u16, height: u16) void { self.width = width; self.height = height; self.clampScroll(); } - /// Scroll down by n lines + pub fn setWrap(self: *Viewport, wrap: bool) void { + self.wrap = wrap; + if (wrap) self.x_offset = 0; + self.clampScroll(); + } + + pub fn setShowScrollbar(self: *Viewport, show_scrollbar: bool) void { + self.show_scrollbar = show_scrollbar; + self.clampScroll(); + } + + pub fn setStyle(self: *Viewport, viewport_style: style.Style) void { + self.viewport_style = viewport_style.inline_style(true); + } + + pub fn setScrollbarStyle(self: *Viewport, track_style: style.Style, thumb_style: style.Style) void { + self.scrollbar_track_style = track_style.inline_style(true); + self.scrollbar_thumb_style = thumb_style.inline_style(true); + } + + pub fn setScrollbarChars(self: *Viewport, track_char: []const u8, thumb_char: []const u8) void { + self.scrollbar_track_char = track_char; + self.scrollbar_thumb_char = thumb_char; + } + + pub fn setEmptyChar(self: *Viewport, empty_char: []const u8) void { + self.empty_char = empty_char; + } + pub fn scrollDown(self: *Viewport, n: usize) void { self.y_offset += n; self.clampScroll(); } - /// Scroll up by n lines pub fn scrollUp(self: *Viewport, n: usize) void { self.y_offset -|= n; } - /// Scroll right by n columns pub fn scrollRight(self: *Viewport, n: usize) void { + if (self.wrap) return; self.x_offset += n; + self.clampScroll(); } - /// Scroll left by n columns pub fn scrollLeft(self: *Viewport, n: usize) void { self.x_offset -|= n; } - /// Go to top + pub fn scrollTo(self: *Viewport, y_offset: usize, x_offset: usize) void { + self.y_offset = y_offset; + self.x_offset = if (self.wrap) 0 else x_offset; + self.clampScroll(); + } + pub fn gotoTop(self: *Viewport) void { self.y_offset = 0; } - /// Go to bottom pub fn gotoBottom(self: *Viewport) void { - if (self.lines.items.len > self.height) { - self.y_offset = self.lines.items.len - self.height; + const total = self.totalVisualLines(); + if (total > self.height) { + self.y_offset = total - self.height; + } else { + self.y_offset = 0; } } - /// Page down pub fn pageDown(self: *Viewport) void { self.scrollDown(self.height); } - /// Page up pub fn pageUp(self: *Viewport) void { self.scrollUp(self.height); } - /// Half page down pub fn halfPageDown(self: *Viewport) void { self.scrollDown(self.height / 2); } - /// Half page up pub fn halfPageUp(self: *Viewport) void { self.scrollUp(self.height / 2); } - /// Get current scroll percentage (0-100) pub fn scrollPercent(self: *const Viewport) u8 { - if (self.lines.items.len <= self.height) return 100; - const max_offset = self.lines.items.len - self.height; + const total = self.totalVisualLines(); + if (total <= self.height) return 100; + const max_offset = total - self.height; return @intCast(@min(100, (self.y_offset * 100) / max_offset)); } - /// Check if at top pub fn atTop(self: *const Viewport) bool { return self.y_offset == 0; } - /// Check if at bottom pub fn atBottom(self: *const Viewport) bool { - if (self.lines.items.len <= self.height) return true; - return self.y_offset >= self.lines.items.len - self.height; + const total = self.totalVisualLines(); + if (total <= self.height) return true; + return self.y_offset >= total - self.height; } - /// Total lines in content pub fn totalLines(self: *const Viewport) usize { return self.lines.items.len; } - /// Handle key event + pub fn totalVisualLines(self: *const Viewport) usize { + return self.totalVisualLinesForWidth(self.visibleWidth()); + } + + fn totalVisualLinesForWidth(self: *const Viewport, visible_width: usize) usize { + if (self.lines.items.len == 0) return 0; + if (visible_width == 0) return 0; + + if (!self.wrap) return self.lines.items.len; + + var total: usize = 0; + for (self.lines.items) |line| { + const line_width = measure.width(line); + total += @max(@as(usize, 1), std.math.divCeil(usize, line_width, visible_width) catch 1); + } + return total; + } + + pub fn contentWidth(self: *const Viewport) usize { + var max_width: usize = 0; + for (self.lines.items) |line| { + max_width = @max(max_width, measure.width(line)); + } + return max_width; + } + pub fn handleKey(self: *Viewport, key: keys.KeyEvent) void { switch (key.key) { .up => self.scrollUp(1), @@ -160,91 +231,138 @@ pub const Viewport = struct { .page_down => self.pageDown(), .home => self.gotoTop(), .end => self.gotoBottom(), - .char => |c| { - switch (c) { - 'j' => self.scrollDown(1), - 'k' => self.scrollUp(1), - 'h' => self.scrollLeft(1), - 'l' => self.scrollRight(1), - 'g' => self.gotoTop(), - 'G' => self.gotoBottom(), - 'd' => self.halfPageDown(), - 'u' => self.halfPageUp(), - else => {}, - } + .char => |c| switch (c) { + 'j' => self.scrollDown(1), + 'k' => self.scrollUp(1), + 'h' => self.scrollLeft(1), + 'l' => self.scrollRight(1), + 'g' => self.gotoTop(), + 'G' => self.gotoBottom(), + 'd' => self.halfPageDown(), + 'u' => self.halfPageUp(), + else => {}, }, else => {}, } } fn clampScroll(self: *Viewport) void { - if (self.lines.items.len > self.height) { - const max_y = self.lines.items.len - self.height; - self.y_offset = @min(self.y_offset, max_y); + const total_visual_lines = self.totalVisualLines(); + if (total_visual_lines > self.height) { + self.y_offset = @min(self.y_offset, total_visual_lines - self.height); } else { self.y_offset = 0; } + + if (self.wrap) { + self.x_offset = 0; + return; + } + + const visible_width = self.visibleWidth(); + const total_width = self.contentWidth(); + if (total_width > visible_width) { + self.x_offset = @min(self.x_offset, total_width - visible_width); + } else { + self.x_offset = 0; + } } - /// Render the viewport pub fn view(self: *const Viewport, allocator: std.mem.Allocator) ![]const u8 { var result = std.array_list.Managed(u8).init(allocator); const writer = result.writer(); - const visible_width: usize = if (self.show_scrollbar and self.lines.items.len > self.height) - self.width -| 1 - else - self.width; - - // Render visible lines - var rendered_lines: usize = 0; - while (rendered_lines < self.height) : (rendered_lines += 1) { - if (rendered_lines > 0) try writer.writeByte('\n'); - - const line_idx = self.y_offset + rendered_lines; - if (line_idx < self.lines.items.len) { - const line = self.lines.items[line_idx]; - - // Apply horizontal scroll - const display_line = if (self.x_offset < measure.width(line)) - try self.getSubstring(allocator, line, self.x_offset, visible_width) - else - ""; - - try writer.writeAll(display_line); - - // Pad to width - const line_width = measure.width(display_line); - if (line_width < visible_width) { - for (0..(visible_width - line_width)) |_| { - try writer.writeByte(' '); - } - } - } else { - // Empty line - for (0..visible_width) |_| { - try writer.writeByte(' '); - } - } + const visible_width = self.visibleWidth(); + + var row: usize = 0; + while (row < self.height) : (row += 1) { + if (row > 0) try writer.writeByte('\n'); + + const line_text = try self.lineForVisualRow(allocator, self.y_offset + row, visible_width); + defer allocator.free(line_text); - // Scrollbar - if (self.show_scrollbar and self.lines.items.len > self.height) { - try self.renderScrollbar(writer, rendered_lines); + const padded = try self.padLine(allocator, line_text, visible_width); + defer allocator.free(padded); + + const rendered = try self.viewport_style.render(allocator, padded); + defer allocator.free(rendered); + try writer.writeAll(rendered); + + if (self.show_scrollbar and self.totalVisualLines() > self.height) { + const scrollbar = try self.renderScrollbar(allocator, row); + defer allocator.free(scrollbar); + try writer.writeAll(scrollbar); } } return result.toOwnedSlice(); } + fn visibleWidth(self: *const Viewport) usize { + if (!self.show_scrollbar) return self.width; + const with_scrollbar = self.width -| 1; + if (self.totalVisualLinesForWidth(@max(@as(usize, 1), with_scrollbar)) > self.height) { + return with_scrollbar; + } + return self.width; + } + + fn lineForVisualRow(self: *const Viewport, allocator: std.mem.Allocator, visual_row: usize, visible_width: usize) ![]const u8 { + if (visible_width == 0 or self.lines.items.len == 0) { + return try allocator.dupe(u8, ""); + } + + if (!self.wrap) { + if (visual_row >= self.lines.items.len) return try allocator.dupe(u8, ""); + const line = self.lines.items[visual_row]; + if (self.x_offset >= measure.width(line)) return try allocator.dupe(u8, ""); + return try self.getSubstring(allocator, line, self.x_offset, visible_width); + } + + var remaining = visual_row; + for (self.lines.items) |line| { + const line_width = measure.width(line); + const segments = @max(@as(usize, 1), std.math.divCeil(usize, line_width, visible_width) catch 1); + if (remaining < segments) { + return try self.getSubstring(allocator, line, remaining * visible_width, visible_width); + } + remaining -= segments; + } + + return try allocator.dupe(u8, ""); + } + + fn padLine(self: *const Viewport, allocator: std.mem.Allocator, line: []const u8, visible_width: usize) ![]const u8 { + const current_width = measure.width(line); + if (current_width >= visible_width) return try allocator.dupe(u8, line); + + var out = std.array_list.Managed(u8).init(allocator); + try out.appendSlice(line); + for (0..(visible_width - current_width)) |_| { + try out.appendSlice(self.empty_char); + } + return out.toOwnedSlice(); + } + fn getSubstring(self: *const Viewport, allocator: std.mem.Allocator, line: []const u8, start_col: usize, max_width: usize) ![]const u8 { _ = self; var result = std.array_list.Managed(u8).init(allocator); var col: usize = 0; var i: usize = 0; + var wrote_escape = false; - // Skip to start column (using display widths) while (i < line.len and col < start_col) { + if (line[i] == 0x1b) { + const seq_end = ansiSequenceEnd(line, i); + if (seq_end > i) { + try result.appendSlice(line[i..seq_end]); + wrote_escape = true; + i = seq_end; + continue; + } + } + const byte_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; if (i + byte_len <= line.len) { const cp = std.unicode.utf8Decode(line[i..][0..byte_len]) catch { @@ -257,9 +375,18 @@ pub const Viewport = struct { i += byte_len; } - // Copy characters up to max_width (using display widths) var output_width: usize = 0; while (i < line.len and output_width < max_width) { + if (line[i] == 0x1b) { + const seq_end = ansiSequenceEnd(line, i); + if (seq_end > i) { + try result.appendSlice(line[i..seq_end]); + wrote_escape = true; + i = seq_end; + continue; + } + } + const byte_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; if (i + byte_len <= line.len) { const cp = std.unicode.utf8Decode(line[i..][0..byte_len]) catch { @@ -269,7 +396,6 @@ pub const Viewport = struct { continue; }; const cw = unicode.charWidth(cp); - // Wide char would exceed max_width if (output_width + cw > max_width) break; try result.appendSlice(line[i..][0..byte_len]); output_width += cw; @@ -277,27 +403,53 @@ pub const Viewport = struct { i += byte_len; } + if (wrote_escape and !std.mem.endsWith(u8, result.items, ansi.reset)) { + try result.appendSlice(ansi.reset); + } + return result.toOwnedSlice(); } - fn renderScrollbar(self: *const Viewport, writer: anytype, row: usize) !void { - const total = self.lines.items.len; + fn renderScrollbar(self: *const Viewport, allocator: std.mem.Allocator, row: usize) ![]const u8 { + const total = self.totalVisualLines(); const visible = self.height; - if (total <= visible) { - try writer.writeByte(' '); - return; - } + if (total <= visible) return try allocator.dupe(u8, ""); - // Calculate scrollbar position const scrollbar_height = @max(1, (visible * visible) / total); const max_offset = total - visible; - const scrollbar_pos = (self.y_offset * (visible - scrollbar_height)) / max_offset; + const scrollbar_pos = if (max_offset == 0) 0 else (self.y_offset * (visible - scrollbar_height)) / max_offset; - if (row >= scrollbar_pos and row < scrollbar_pos + scrollbar_height) { - try writer.writeAll("█"); - } else { - try writer.writeAll("░"); - } + const glyph = if (row >= scrollbar_pos and row < scrollbar_pos + scrollbar_height) + try self.scrollbar_thumb_style.render(allocator, self.scrollbar_thumb_char) + else + try self.scrollbar_track_style.render(allocator, self.scrollbar_track_char); + + return glyph; } }; + +fn ansiSequenceEnd(line: []const u8, start: usize) usize { + if (start >= line.len or line[start] != 0x1b or start + 1 >= line.len) return start; + + const second = line[start + 1]; + if (second == '[') { + var i = start + 2; + while (i < line.len) : (i += 1) { + const c = line[i]; + if ((c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')) return i + 1; + } + return line.len; + } + + if (second == ']') { + var i = start + 2; + while (i < line.len) : (i += 1) { + if (line[i] == 0x07) return i + 1; + if (line[i] == 0x1b and i + 1 < line.len and line[i + 1] == '\\') return i + 2; + } + return line.len; + } + + return @min(line.len, start + 2); +} diff --git a/tests/viewport_tests.zig b/tests/viewport_tests.zig new file mode 100644 index 0000000..842e5c4 --- /dev/null +++ b/tests/viewport_tests.zig @@ -0,0 +1,94 @@ +const std = @import("std"); +const testing = std.testing; +const zz = @import("zigzag"); + +test "viewport supports wrapped scrolling" { + const allocator = testing.allocator; + + var viewport = zz.Viewport.init(allocator, 5, 2); + defer viewport.deinit(); + + viewport.setWrap(true); + viewport.setShowScrollbar(false); + try viewport.setContent("abcdefghij"); + + const first = try viewport.view(allocator); + defer allocator.free(first); + try testing.expect(std.mem.indexOf(u8, first, "abcde") != null); + try testing.expect(std.mem.indexOf(u8, first, "fghij") != null); + + viewport.scrollDown(1); + const second = try viewport.view(allocator); + defer allocator.free(second); + try testing.expect(std.mem.indexOf(u8, second, "fghij") != null); +} + +test "viewport clamps horizontal scrolling and supports custom scrollbar chars" { + const allocator = testing.allocator; + + var viewport = zz.Viewport.init(allocator, 6, 2); + defer viewport.deinit(); + + viewport.setWrap(false); + viewport.setScrollbarChars(".", "#"); + try viewport.setContent("0123456789\nabcdefghij\nklmnopqrst"); + viewport.scrollRight(4); + viewport.scrollDown(1); + + const rendered = try viewport.view(allocator); + defer allocator.free(rendered); + + try testing.expect(std.mem.indexOf(u8, rendered, "efghi") != null); + try testing.expect(std.mem.indexOf(u8, rendered, "#") != null or std.mem.indexOf(u8, rendered, ".") != null); +} + +test "viewport slices ANSI styled content without corrupting output" { + const allocator = testing.allocator; + + var viewport = zz.Viewport.init(allocator, 4, 1); + defer viewport.deinit(); + + var s = zz.Style{}; + s = s.fg(zz.Color.cyan()); + s = s.inline_style(true); + const styled = try s.render(allocator, "abcdef"); + defer allocator.free(styled); + + try viewport.setContent(styled); + viewport.scrollRight(2); + + const rendered = try viewport.view(allocator); + defer allocator.free(rendered); + const plain = try stripAnsi(allocator, rendered); + defer allocator.free(plain); + + try testing.expect(std.mem.indexOf(u8, plain, "cdef") != null); +} + +fn stripAnsi(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { + var out = std.array_list.Managed(u8).init(allocator); + defer out.deinit(); + + var i: usize = 0; + while (i < input.len) { + if (input[i] == 0x1b) { + i += 1; + if (i < input.len and input[i] == '[') { + i += 1; + while (i < input.len) : (i += 1) { + const c = input[i]; + if ((c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')) { + i += 1; + break; + } + } + continue; + } + } + + try out.append(input[i]); + i += 1; + } + + return try out.toOwnedSlice(); +}