From 2e66b0a4debf938011d3ba038a489ede2404e559 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 12:34:06 +0900 Subject: [PATCH 1/4] refactor: Start a pure chart package with sparkline --- internal/chart/chart.go | 58 ++++++++++++++++++++++++++++++++ internal/chart/sparkline.go | 31 +++++++++++++++++ internal/chart/sparkline_test.go | 30 +++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 internal/chart/chart.go create mode 100644 internal/chart/sparkline.go create mode 100644 internal/chart/sparkline_test.go diff --git a/internal/chart/chart.go b/internal/chart/chart.go new file mode 100644 index 0000000..1725db6 --- /dev/null +++ b/internal/chart/chart.go @@ -0,0 +1,58 @@ +// Package chart renders float series as terminal-cell charts. It is pure +// computation: no screen, no styles — callers color cells by Kind. +package chart + +// Style selects the drawing technique for line charts. +type Style int + +const ( + StyleBraille Style = iota // 2×4 pixels per cell (Unicode braille) + StyleBox // cell-resolution solid line (╭─╮ box drawing) + StyleASCII // plain-ASCII fallback (marker per column) +) + +// Interp selects how samples are interpolated onto pixel columns. +type Interp int + +const ( + InterpLinear Interp = iota + InterpSmooth // monotone cubic (Fritsch–Carlson); no overshoot +) + +// Kind tags what a cell is part of, so callers can style layers separately. +type Kind int + +const ( + KindNone Kind = iota + KindLine + KindAxis + KindLabel +) + +// Cell is one terminal cell of a rendered chart. +type Cell struct { + Rune rune + Kind Kind +} + +// Options configures Render. Thickness (1..3) only affects StyleBraille; +// zero means 1. +type Options struct { + Width, Height int + Style Style + Interp Interp + Thickness int +} + +func bounds(series []float64) (lo, hi float64) { + lo, hi = series[0], series[0] + for _, v := range series[1:] { + if v < lo { + lo = v + } + if v > hi { + hi = v + } + } + return lo, hi +} diff --git a/internal/chart/sparkline.go b/internal/chart/sparkline.go new file mode 100644 index 0000000..be6b66f --- /dev/null +++ b/internal/chart/sparkline.go @@ -0,0 +1,31 @@ +package chart + +// Sparkline level glyphs, lowest to highest. The ASCII set is for terminals +// that render block elements as tofu. +var ( + sparkUnicode = []rune("▁▂▃▄▅▆▇█") + sparkASCII = []rune(".:-=+*#%") +) + +// Sparkline renders values as one glyph per value, min–max scaled to the +// glyph range. Equal values render at a middle level so a flat series stays +// visible. An empty input yields an empty string. +func Sparkline(values []float64, ascii bool) string { + if len(values) == 0 { + return "" + } + glyphs := sparkUnicode + if ascii { + glyphs = sparkASCII + } + lo, hi := bounds(values) + out := make([]rune, len(values)) + for i, v := range values { + level := len(glyphs) / 2 + if hi > lo { + level = int((v - lo) / (hi - lo) * float64(len(glyphs)-1)) + } + out[i] = glyphs[level] + } + return string(out) +} diff --git a/internal/chart/sparkline_test.go b/internal/chart/sparkline_test.go new file mode 100644 index 0000000..f75c574 --- /dev/null +++ b/internal/chart/sparkline_test.go @@ -0,0 +1,30 @@ +package chart + +import "testing" + +func TestSparklineScaling(t *testing.T) { + got := Sparkline([]float64{0, 100}, false) + if got != "▁█" { + t.Fatalf("got %q, want ▁█", got) + } +} + +func TestSparklineFlat(t *testing.T) { + got := Sparkline([]float64{50, 50, 50}, false) + if got != "▅▅▅" { + t.Fatalf("flat series should sit mid-level, got %q", got) + } +} + +func TestSparklineASCII(t *testing.T) { + got := Sparkline([]float64{0, 100}, true) + if got != ".%" { + t.Fatalf("got %q, want .%%", got) + } +} + +func TestSparklineEmpty(t *testing.T) { + if got := Sparkline(nil, false); got != "" { + t.Fatalf("empty input should yield empty string, got %q", got) + } +} From 9c70f3b644713d65ecec36e1faa54d96f17382f9 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 12:37:05 +0900 Subject: [PATCH 2/4] refactor: Compute line charts as a pure cell grid --- internal/chart/chart.go | 145 +++++++++++++++++++++++++++++++++++ internal/chart/chart_test.go | 125 ++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 internal/chart/chart_test.go diff --git a/internal/chart/chart.go b/internal/chart/chart.go index 1725db6..2631298 100644 --- a/internal/chart/chart.go +++ b/internal/chart/chart.go @@ -2,6 +2,8 @@ // computation: no screen, no styles — callers color cells by Kind. package chart +import "fmt" + // Style selects the drawing technique for line charts. type Style int @@ -56,3 +58,146 @@ func bounds(series []float64) (lo, hi float64) { } return lo, hi } + +// brailleBits holds the braille dot bit for the pixel at (px, py) inside one +// cell: 2 pixel columns × 4 pixel rows per character cell. +var brailleBits = [4][2]int{{0x01, 0x08}, {0x02, 0x10}, {0x04, 0x20}, {0x40, 0x80}} + +// labelW is the y-axis label gutter: "999 " right-aligned, then the tick. +const labelW = 4 + +// sample maps series onto n points. InterpLinear draws straight segments +// between samples; InterpSmooth is added with the style work (PR-2) and +// falls back to linear until then. +func sample(series []float64, n int, ip Interp) []float64 { + out := make([]float64, n) + for c := 0; c < n; c++ { + pos := float64(c) * float64(len(series)-1) / float64(n-1) + i := int(pos) + v := series[i] + if frac := pos - float64(i); i+1 < len(series) { + v = series[i]*(1-frac) + series[i+1]*frac + } + out[c] = v + } + return out +} + +// pixelRows maps sampled values onto pixel rows; row 0 is the top (max). +// A flat series sits on the middle row. +func pixelRows(values []float64, pxRows int) []int { + lo, hi := bounds(values) + out := make([]int, len(values)) + for i, v := range values { + if hi == lo { + out[i] = pxRows / 2 + continue + } + out[i] = int((hi - v) / (hi - lo) * float64(pxRows-1)) + } + return out +} + +// Render draws series as a line chart with a labeled y-axis into a +// Height×Width cell grid. Fewer than two samples, or a rect too small to +// hold the axis, returns nil. +func Render(series []float64, o Options) [][]Cell { + cols := o.Width - labelW - 1 + if len(series) < 2 || cols < 2 || o.Height < 2 { + return nil + } + grid := make([][]Cell, o.Height) + for i := range grid { + grid[i] = make([]Cell, o.Width) + for j := range grid[i] { + grid[i][j] = Cell{Rune: ' '} + } + } + + lo, hi := bounds(series) + tick := '┤' + if o.Style == StyleASCII { + tick = '|' + } + for row := 0; row < o.Height; row++ { + v := hi + if hi != lo { + v = hi - (hi-lo)*float64(row)/float64(o.Height-1) + } + if row == 0 || row == o.Height-1 || row == (o.Height-1)/2 { + label := fmt.Sprintf("%*.0f", labelW-1, v) + for i, r := range []rune(label) { + grid[row][i] = Cell{Rune: r, Kind: KindLabel} + } + } + grid[row][labelW] = Cell{Rune: tick, Kind: KindAxis} + } + + switch o.Style { + case StyleASCII: + renderASCII(grid, series, cols, o.Height) + default: + renderBraille(grid, series, cols, o) + } + return grid +} + +// renderBraille plots into a 2×4-per-cell pixel grid, joining neighbor +// pixel columns vertically so steep segments stay connected. +func renderBraille(grid [][]Cell, series []float64, cols int, o Options) { + pxRows := pixelRows(sample(series, cols*2, o.Interp), o.Height*4) + masks := make([][]int, o.Height) + for i := range masks { + masks[i] = make([]int, cols) + } + prev := pxRows[0] + for c, row := range pxRows { + lo, hi := row, row + if c > 0 { + if prev < row { + lo = prev + 1 + } else if prev > row { + hi = prev - 1 + } + } + for py := lo; py <= hi; py++ { + masks[py/4][c/2] |= brailleBits[py%4][c%2] + } + prev = row + } + for cy := range masks { + for cx, mask := range masks[cy] { + if mask != 0 { + grid[cy][labelW+1+cx] = Cell{Rune: rune(0x2800 + mask), Kind: KindLine} + } + } + } +} + +// renderASCII is the --ascii fallback: a marker per cell column with +// vertical fill between steep neighbors. Samples are picked per column +// without interpolation, matching the pre-refactor behavior. +func renderASCII(grid [][]Cell, series []float64, cols, height int) { + rows := make([]int, cols) + lo, hi := bounds(series) + for c := 0; c < cols; c++ { + i := c * (len(series) - 1) / (cols - 1) + if hi == lo { + rows[c] = height / 2 + continue + } + rows[c] = int((hi - series[i]) / (hi - lo) * float64(height-1)) + } + for c, row := range rows { + grid[row][labelW+1+c] = Cell{Rune: '*', Kind: KindLine} + if c > 0 { + a, b := rows[c-1], row + if a > b { + a, b = b, a + } + for between := a + 1; between < b; between++ { + grid[between][labelW+1+c] = Cell{Rune: '|', Kind: KindLine} + } + } + } +} diff --git a/internal/chart/chart_test.go b/internal/chart/chart_test.go new file mode 100644 index 0000000..d7054f7 --- /dev/null +++ b/internal/chart/chart_test.go @@ -0,0 +1,125 @@ +package chart + +import "testing" + +func braille2(o Options, series []float64) [][]Cell { + return Render(series, o) +} + +func TestRenderNilOnDegenerate(t *testing.T) { + o := Options{Width: 20, Height: 5} + if Render([]float64{42}, o) != nil { + t.Fatal("single sample should render nil") + } + if Render([]float64{1, 2}, Options{Width: 6, Height: 5}) != nil { + t.Fatal("too-narrow rect should render nil") + } + if Render([]float64{1, 2}, Options{Width: 20, Height: 1}) != nil { + t.Fatal("too-short rect should render nil") + } +} + +func TestRenderDimensions(t *testing.T) { + o := Options{Width: 20, Height: 5} + g := Render([]float64{10, 20, 30}, o) + if len(g) != 5 || len(g[0]) != 20 { + t.Fatalf("grid is %dx%d, want 5x20", len(g), len(g[0])) + } +} + +func TestRenderAxisAndLabels(t *testing.T) { + o := Options{Width: 20, Height: 5} + g := Render([]float64{0, 100}, o) + const labelW = 4 + for row := 0; row < 5; row++ { + if g[row][labelW].Kind != KindAxis || g[row][labelW].Rune != '┤' { + t.Fatalf("row %d col %d should be axis tick, got %q/%v", + row, labelW, g[row][labelW].Rune, g[row][labelW].Kind) + } + } + // top row label reads "100", bottom " 0" (right-aligned width 3) + top := string([]rune{g[0][0].Rune, g[0][1].Rune, g[0][2].Rune}) + if top != "100" { + t.Fatalf("top label %q, want 100", top) + } + if g[0][0].Kind != KindLabel { + t.Fatalf("label cell kind = %v, want KindLabel", g[0][0].Kind) + } +} + +func TestRenderLineCellsTagged(t *testing.T) { + o := Options{Width: 20, Height: 5} + g := Render([]float64{0, 50, 100}, o) + found := false + for _, row := range g { + for cx, c := range row { + if c.Kind == KindLine { + if cx <= 4 { + t.Fatalf("line cell inside axis area at col %d", cx) + } + found = true + } + } + } + if !found { + t.Fatal("no line cells rendered") + } +} + +func TestRenderFlatSeriesMidRow(t *testing.T) { + o := Options{Width: 20, Height: 5} + g := Render([]float64{50, 50, 50, 50}, o) + for row := 0; row < 5; row++ { + hasLine := false + for _, c := range g[row] { + if c.Kind == KindLine { + hasLine = true + } + } + // flat series → all line pixels land in the middle pixel row band + if hasLine && row != 2 { + t.Fatalf("flat series drew on cell row %d, want only row 2", row) + } + } +} + +func TestRenderASCIIStyle(t *testing.T) { + o := Options{Width: 20, Height: 5, Style: StyleASCII} + g := Render([]float64{0, 100, 0}, o) + stars := 0 + for _, row := range g { + for _, c := range row { + if c.Kind == KindLine && c.Rune == '*' { + stars++ + } + if c.Rune >= 0x2800 && c.Rune <= 0x28FF { + t.Fatal("ASCII style must not emit braille runes") + } + } + } + if stars == 0 { + t.Fatal("ASCII style should draw * markers") + } + if g[0][4].Rune != '|' { + t.Fatalf("ASCII tick should be '|', got %q", g[0][4].Rune) + } +} + +func TestSampleLinearInterpolates(t *testing.T) { + got := sample([]float64{0, 10}, 3, InterpLinear) + want := []float64{0, 5, 10} + for i := range want { + if got[i] != want[i] { + t.Fatalf("sample[%d] = %v, want %v", i, got[i], want[i]) + } + } +} + +func TestPixelRowsFlat(t *testing.T) { + rows := pixelRows([]float64{5, 5, 5, 5}, 8) + for _, r := range rows { + if r != 4 { + t.Fatalf("flat series pixel row = %d, want 4", r) + } + } +} From c2c17c8001071ae62082f57838cf8f96b0a0b573 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 12:42:57 +0900 Subject: [PATCH 3/4] fix: Scale the braille line against the raw series bounds pixelRows and renderBraille recomputed lo/hi from the downsampled array instead of using the true series bounds the axis labels are drawn from. When cols*2 < len(series), a narrow peak can be smoothed below the true max by interpolation before sampling, so the line would report as touching the axis top even though the labeled max is much higher. Both now take lo, hi as parameters, and Render passes through the raw-series bounds it already computed for the axis. Also removes the unused braille2 test helper. --- internal/chart/chart.go | 22 +++++++----- internal/chart/chart_test.go | 65 +++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/internal/chart/chart.go b/internal/chart/chart.go index 2631298..edcf87c 100644 --- a/internal/chart/chart.go +++ b/internal/chart/chart.go @@ -83,10 +83,12 @@ func sample(series []float64, n int, ip Interp) []float64 { return out } -// pixelRows maps sampled values onto pixel rows; row 0 is the top (max). -// A flat series sits on the middle row. -func pixelRows(values []float64, pxRows int) []int { - lo, hi := bounds(values) +// pixelRows maps sampled values onto pixel rows using the given lo/hi +// bounds (the TRUE series bounds, not the sampled array's own bounds — the +// caller must scale consistently with whatever bounds the axis labels +// use). Row 0 is the top (max). A flat series (hi == lo) sits on the +// middle row. +func pixelRows(values []float64, pxRows int, lo, hi float64) []int { out := make([]int, len(values)) for i, v := range values { if hi == lo { @@ -137,15 +139,19 @@ func Render(series []float64, o Options) [][]Cell { case StyleASCII: renderASCII(grid, series, cols, o.Height) default: - renderBraille(grid, series, cols, o) + renderBraille(grid, series, cols, o, lo, hi) } return grid } // renderBraille plots into a 2×4-per-cell pixel grid, joining neighbor -// pixel columns vertically so steep segments stay connected. -func renderBraille(grid [][]Cell, series []float64, cols int, o Options) { - pxRows := pixelRows(sample(series, cols*2, o.Interp), o.Height*4) +// pixel columns vertically so steep segments stay connected. lo, hi are +// the TRUE series bounds (matching the axis labels), NOT the bounds of the +// downsampled array — a narrow peak can fall between sample points and be +// smoothed away, so scaling against the sample's own bounds would diverge +// from what the axis reports. +func renderBraille(grid [][]Cell, series []float64, cols int, o Options, lo, hi float64) { + pxRows := pixelRows(sample(series, cols*2, o.Interp), o.Height*4, lo, hi) masks := make([][]int, o.Height) for i := range masks { masks[i] = make([]int, cols) diff --git a/internal/chart/chart_test.go b/internal/chart/chart_test.go index d7054f7..9bbc27a 100644 --- a/internal/chart/chart_test.go +++ b/internal/chart/chart_test.go @@ -2,10 +2,6 @@ package chart import "testing" -func braille2(o Options, series []float64) [][]Cell { - return Render(series, o) -} - func TestRenderNilOnDegenerate(t *testing.T) { o := Options{Width: 20, Height: 5} if Render([]float64{42}, o) != nil { @@ -116,10 +112,69 @@ func TestSampleLinearInterpolates(t *testing.T) { } func TestPixelRowsFlat(t *testing.T) { - rows := pixelRows([]float64{5, 5, 5, 5}, 8) + rows := pixelRows([]float64{5, 5, 5, 5}, 8, 5, 5) for _, r := range rows { if r != 4 { t.Fatalf("flat series pixel row = %d, want 4", r) } } } + +// TestRenderBrailleScalesAgainstTrueSeriesBounds is a regression test for a +// bug where the braille line was scaled against the bounds of the +// downsampled array (sample(series, cols*2, ...)) instead of the true +// series bounds used by the axis labels. When cols*2 < len(series), a +// narrow peak can be smoothed away by interpolation before scaling, so the +// sampled max is well below the true max — the line then reports as +// touching the axis top even though it is nowhere near the labeled max. +// +// series has a single spike (100) at index 3 out of 17 points, everything +// else 0. With Width=9 the plot area is 4 cols, so cols*2 = 8 sample +// points — well under 17, so the spike lands between two sample points and +// is interpolated down to ~28.6 instead of surviving as 100. +func TestRenderBrailleScalesAgainstTrueSeriesBounds(t *testing.T) { + series := make([]float64, 17) + series[3] = 100 + + o := Options{Width: 9, Height: 10} + cols := o.Width - labelW - 1 + if cols*2 >= len(series) { + t.Fatalf("test setup invalid: cols*2=%d must be < len(series)=%d", cols*2, len(series)) + } + + // Expected top pixel row, computed against the TRUE series bounds + // (what the axis labels use), not the sampled array's own bounds. + lo, hi := bounds(series) + values := sample(series, cols*2, InterpLinear) + peak := values[0] + for _, v := range values { + if v > peak { + peak = v + } + } + pxRows := o.Height * 4 + expectedPixelRow := int((hi - peak) / (hi - lo) * float64(pxRows-1)) + expectedCellRow := expectedPixelRow / 4 + + g := Render(series, o) + topRow := -1 + for row := range g { + for _, c := range g[row] { + if c.Kind == KindLine { + topRow = row + break + } + } + if topRow != -1 { + break + } + } + if topRow == -1 { + t.Fatal("no line cells rendered") + } + if topRow != expectedCellRow { + t.Fatalf("topmost line cell row = %d, want %d (scaled against true series bounds; "+ + "got row 0 would indicate the bug — scaling against the sampled array's own bounds)", + topRow, expectedCellRow) + } +} From af6a5384d410d14b1665a61a54a6027cba64f15b Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 12:46:13 +0900 Subject: [PATCH 4/4] refactor: Route chart drawing through the pure chart package --- internal/ui/chart.go | 171 ++++------------------------ internal/ui/chart_adapter_test.go | 52 +++++++++ internal/ui/chart_test.go | 95 ---------------- internal/ui/sparkline.go | 38 +------ internal/ui/sparkline_test.go | 33 ------ internal/ui/typing_renderer_test.go | 23 ++++ 6 files changed, 99 insertions(+), 313 deletions(-) create mode 100644 internal/ui/chart_adapter_test.go delete mode 100644 internal/ui/chart_test.go delete mode 100644 internal/ui/sparkline_test.go diff --git a/internal/ui/chart.go b/internal/ui/chart.go index 675f117..491afa4 100644 --- a/internal/ui/chart.go +++ b/internal/ui/chart.go @@ -1,169 +1,38 @@ package ui import ( - "fmt" - "github.com/gdamore/tcell/v2" - "github.com/mattn/go-runewidth" + "github.com/namest504/termtype/internal/chart" "github.com/namest504/termtype/internal/domain" ) -// chartRows maps a series onto a cols×rows grid: one row index per column, -// row 0 at the top holding the maximum. The series is stretched or -// compressed to fill the columns. A flat series sits on the middle row. -func chartRows(series []float64, cols, rows int) []int { - if len(series) < 2 || cols < 1 || rows < 1 { - return nil - } - min, max := series[0], series[0] - for _, v := range series { - if v < min { - min = v - } - if v > max { - max = v - } - } - - out := make([]int, cols) - for c := 0; c < cols; c++ { - i := 0 - if cols > 1 { - i = c * (len(series) - 1) / (cols - 1) - } - if max == min { - out[c] = rows / 2 - continue - } - out[c] = int((max - series[i]) / (max - min) * float64(rows-1)) - } - return out -} - -// chartBounds returns the series minimum and maximum. -func chartBounds(series []float64) (min, max float64) { - min, max = series[0], series[0] - for _, v := range series { - if v < min { - min = v - } - if v > max { - max = v - } - } - return min, max -} +// chartOpts is the app-wide chart rendering default, set once from config +// at startup (mirroring SetASCII). Width/Height are per-call. +var chartOpts = chart.Options{Style: chart.StyleBraille, Interp: chart.InterpLinear, Thickness: 1} -// chartPixelRows maps the series onto pxCols pixel columns of pxRows pixel -// rows, linearly interpolating between samples so the curve is smooth at -// sub-cell resolution. Row 0 is the top (the maximum). -func chartPixelRows(series []float64, pxCols, pxRows int) []int { - if len(series) < 2 || pxCols < 2 || pxRows < 1 { - return nil - } - min, max := chartBounds(series) - out := make([]int, pxCols) - for c := 0; c < pxCols; c++ { - if max == min { - out[c] = pxRows / 2 - continue - } - pos := float64(c) * float64(len(series)-1) / float64(pxCols-1) - i := int(pos) - v := series[i] - if frac := pos - float64(i); i+1 < len(series) { - v = series[i]*(1-frac) + series[i+1]*frac - } - out[c] = int((max - v) / (max - min) * float64(pxRows-1)) - } - return out -} - -// brailleBits holds the braille dot bit for the pixel at (px, py) inside one -// cell: 2 pixel columns × 4 pixel rows per character cell. -var brailleBits = [4][2]int{{0x01, 0x08}, {0x02, 0x10}, {0x04, 0x20}, {0x40, 0x80}} +// SetChartOptions selects the chart style/interpolation/thickness used by +// DrawLineChart. Width and Height on o are ignored. +func SetChartOptions(o chart.Options) { chartOpts = o } // DrawLineChart draws series as a line chart with a labeled y-axis inside -// the rect at (x, y) spanning width×height cells. In Unicode mode the line -// is drawn with braille dots at 2×4 pixels per cell, which reads as a -// smooth curve; ASCII mode falls back to one marker per column. Fewer than -// two samples, or a rect too small to hold the axis, draws nothing. +// the rect at (x, y) spanning width×height cells, using the app-wide chart +// options. --ascii mode overrides the style with the ASCII fallback. func DrawLineChart(r domain.Renderer, x, y, width, height int, series []float64, axisStyle, lineStyle tcell.Style) { - const labelW = 4 // "999 " — right-aligned wpm labels - cols := width - labelW - 1 - if len(series) < 2 || cols < 2 || height < 2 { - return - } - - gl := Glyphs() - min, max := chartBounds(series) - - // Y-axis: a tick every row, labels on the top, middle, and bottom rows. - for row := 0; row < height; row++ { - v := max - if max != min { - v = max - (max-min)*float64(row)/float64(height-1) - } - if row == 0 || row == height-1 || row == (height-1)/2 { - label := fmt.Sprintf("%*.0f", labelW-1, v) - r.DrawText(x+labelW-1-runewidth.StringWidth(label), y+row, axisStyle, label) - } - r.DrawText(x+labelW, y+row, axisStyle, gl.ChartTick) - } - + o := chartOpts + o.Width, o.Height = width, height if IsASCII() { - drawASCIILine(r, x+labelW+1, y, cols, height, series, lineStyle) - return - } - - // Braille line: plot into a 2×4-per-cell pixel grid, joining neighbor - // columns vertically so steep segments stay connected. - pxRows := chartPixelRows(series, cols*2, height*4) - grid := make([][]int, height) - for i := range grid { - grid[i] = make([]int, cols) + o.Style = chart.StyleASCII } - prev := pxRows[0] - for c, row := range pxRows { - lo, hi := row, row - if c > 0 { - if prev < row { - lo = prev + 1 - } else if prev > row { - hi = prev - 1 - } - } - for py := lo; py <= hi; py++ { - grid[py/4][c/2] |= brailleBits[py%4][c%2] - } - prev = row - } - for cy := range grid { - for cx, mask := range grid[cy] { - if mask != 0 { - r.SetContent(x+labelW+1+cx, y+cy, rune(0x2800+mask), lineStyle) - } - } - } -} - -// drawASCIILine is the --ascii fallback: a marker per cell column with -// vertical fill between steep neighbors. -func drawASCIILine(r domain.Renderer, x, y, cols, height int, series []float64, lineStyle tcell.Style) { - gl := Glyphs() - rows := chartRows(series, cols, height) - dot := []rune(gl.ChartDot)[0] - bar := []rune(gl.ChartTick)[0] - for c, row := range rows { - r.SetContent(x+c, y+row, dot, lineStyle) - if c > 0 { - lo, hi := rows[c-1], row - if lo > hi { - lo, hi = hi, lo + for cy, row := range chart.Render(series, o) { + for cx, c := range row { + if c.Kind == chart.KindNone { + continue } - for between := lo + 1; between < hi; between++ { - r.SetContent(x+c, y+between, bar, lineStyle) + st := axisStyle + if c.Kind == chart.KindLine { + st = lineStyle } + r.SetContent(x+cx, y+cy, c.Rune, st) } } } diff --git a/internal/ui/chart_adapter_test.go b/internal/ui/chart_adapter_test.go new file mode 100644 index 0000000..2353c67 --- /dev/null +++ b/internal/ui/chart_adapter_test.go @@ -0,0 +1,52 @@ +package ui + +import ( + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/chart" +) + +// TestDrawLineChartAdapts verifies the adapter places chart cells at the +// given origin and styles line vs axis cells differently. +func TestDrawLineChartAdapts(t *testing.T) { + SetChartOptions(chart.Options{Style: chart.StyleBraille, Interp: chart.InterpLinear, Thickness: 1}) + s := NewMockScreen(30, 10) + r := NewRenderer(s) + axis := tcell.StyleDefault.Foreground(tcell.ColorGray) + line := tcell.StyleDefault.Foreground(tcell.ColorYellow) + DrawLineChart(r, 2, 1, 20, 5, []float64{0, 50, 100}, axis, line) + + // the tick column sits at x = 2 + 4 + rn, st := s.Cell(6, 1) + if rn != '┤' || st != axis { + t.Fatalf("tick cell = %q with wrong style", rn) + } + // at least one braille line cell exists right of the axis, line-styled + found := false + for y := 1; y < 6; y++ { + for x := 7; x < 22; x++ { + if rn, st := s.Cell(x, y); rn >= 0x2800 && rn <= 0x28FF { + if st != line { + t.Fatalf("line cell (%d,%d) has axis style", x, y) + } + found = true + } + } + } + if !found { + t.Fatal("no line cells drawn") + } +} + +func TestSparklineDelegates(t *testing.T) { + SetASCII(false) + if got := Sparkline([]float64{0, 100}); got != "▁█" { + t.Fatalf("got %q", got) + } + SetASCII(true) + defer SetASCII(false) + if got := Sparkline([]float64{0, 100}); got != ".%" { + t.Fatalf("ascii got %q", got) + } +} diff --git a/internal/ui/chart_test.go b/internal/ui/chart_test.go deleted file mode 100644 index 368ce21..0000000 --- a/internal/ui/chart_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package ui - -import "testing" - -func TestChartRowsMapping(t *testing.T) { - rows := chartRows([]float64{60, 94}, 10, 6) - if len(rows) != 10 { - t.Fatalf("got %d columns, want 10", len(rows)) - } - if rows[0] != 5 { - t.Errorf("minimum should sit on the bottom row, got %d", rows[0]) - } - if rows[9] != 0 { - t.Errorf("maximum should sit on the top row, got %d", rows[9]) - } - for _, r := range rows { - if r < 0 || r > 5 { - t.Errorf("row %d out of range", r) - } - } -} - -func TestChartRowsFlatSeries(t *testing.T) { - rows := chartRows([]float64{76, 76, 76}, 8, 6) - for _, r := range rows { - if r != 3 { - t.Errorf("flat series should sit on the middle row, got %d", r) - } - } -} - -func TestChartRowsDegenerate(t *testing.T) { - if chartRows([]float64{76}, 10, 6) != nil { - t.Error("a single sample should draw nothing") - } - if chartRows(nil, 10, 6) != nil { - t.Error("an empty series should draw nothing") - } - if chartRows([]float64{1, 2}, 0, 6) != nil { - t.Error("zero columns should draw nothing") - } -} - -func TestChartPixelRowsInterpolates(t *testing.T) { - // Two samples over four pixel columns: the middle columns interpolate. - rows := chartPixelRows([]float64{0, 30}, 4, 31) - want := []int{30, 20, 10, 0} - for i, w := range want { - if rows[i] != w { - t.Errorf("pixel col %d = %d, want %d (rows %v)", i, rows[i], w, rows) - } - } -} - -func TestChartPixelRowsFlatAndDegenerate(t *testing.T) { - for _, r := range chartPixelRows([]float64{76, 76}, 8, 8) { - if r != 4 { - t.Errorf("flat series pixel row = %d, want middle 4", r) - } - } - if chartPixelRows([]float64{1}, 8, 8) != nil { - t.Error("single sample should draw nothing") - } -} - -func TestBrailleBitsDistinct(t *testing.T) { - seen := map[int]bool{} - sum := 0 - for _, row := range brailleBits { - for _, b := range row { - if seen[b] { - t.Errorf("bit %#x repeated", b) - } - seen[b] = true - sum |= b - } - } - if sum != 0xFF { - t.Errorf("bits cover %#x, want 0xFF", sum) - } -} - -func TestChartRowsCompress(t *testing.T) { - series := make([]float64, 120) // longer than the chart is wide - for i := range series { - series[i] = float64(i) - } - rows := chartRows(series, 30, 8) - if len(rows) != 30 { - t.Fatalf("got %d columns, want 30", len(rows)) - } - if rows[0] != 7 || rows[29] != 0 { - t.Errorf("compressed series should still span bottom to top, got %d..%d", rows[0], rows[29]) - } -} diff --git a/internal/ui/sparkline.go b/internal/ui/sparkline.go index b4ae407..b3f77f8 100644 --- a/internal/ui/sparkline.go +++ b/internal/ui/sparkline.go @@ -1,39 +1,9 @@ package ui -// Sparkline level glyphs, lowest to highest. The ASCII set mirrors the -// GlyphSet fallback idea for terminals that render block elements as tofu. -var ( - sparkUnicode = []rune("▁▂▃▄▅▆▇█") - sparkASCII = []rune(".:-=+*#%") -) +import "github.com/namest504/termtype/internal/chart" -// Sparkline renders values as one glyph per value, min–max scaled to the -// glyph range. Equal values render at a middle level so a flat series stays -// visible. An empty input yields an empty string. +// Sparkline renders values as one block glyph per value. It delegates to +// the chart package using the active glyph mode. func Sparkline(values []float64) string { - if len(values) == 0 { - return "" - } - glyphs := sparkUnicode - if IsASCII() { - glyphs = sparkASCII - } - lo, hi := values[0], values[0] - for _, v := range values[1:] { - if v < lo { - lo = v - } - if v > hi { - hi = v - } - } - out := make([]rune, len(values)) - for i, v := range values { - level := len(glyphs) / 2 - if hi > lo { - level = int((v - lo) / (hi - lo) * float64(len(glyphs)-1)) - } - out[i] = glyphs[level] - } - return string(out) + return chart.Sparkline(values, IsASCII()) } diff --git a/internal/ui/sparkline_test.go b/internal/ui/sparkline_test.go deleted file mode 100644 index d351aae..0000000 --- a/internal/ui/sparkline_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package ui - -import "testing" - -func TestSparklineScaling(t *testing.T) { - SetASCII(false) - // min=1 -> lowest glyph, max=8 -> highest, 4.5 -> level 3 of 0..7. - if got := Sparkline([]float64{1, 8, 4.5}); got != "▁█▄" { - t.Errorf("Sparkline() = %q, want ▁█▄", got) - } -} - -func TestSparklineFlat(t *testing.T) { - SetASCII(false) - // Equal values sit at the middle level so a flat series stays visible. - if got := Sparkline([]float64{5, 5}); got != "▅▅" { - t.Errorf("Sparkline() = %q, want ▅▅", got) - } -} - -func TestSparklineASCII(t *testing.T) { - SetASCII(true) - defer SetASCII(false) - if got := Sparkline([]float64{1, 8}); got != ".%" { - t.Errorf("Sparkline() = %q, want .%%", got) - } -} - -func TestSparklineEmpty(t *testing.T) { - if got := Sparkline(nil); got != "" { - t.Errorf("Sparkline(nil) = %q, want empty string", got) - } -} diff --git a/internal/ui/typing_renderer_test.go b/internal/ui/typing_renderer_test.go index b234c9a..0bb1eb4 100644 --- a/internal/ui/typing_renderer_test.go +++ b/internal/ui/typing_renderer_test.go @@ -7,16 +7,24 @@ import ( "github.com/gdamore/tcell/v2" ) +// mockCell holds the rune and style last written to one screen position. +type mockCell struct { + r rune + style tcell.Style +} + // MockScreen is a mock implementation of tcell.Screen for testing type MockScreen struct { tcell.Screen cells map[int]map[int]rune + sty map[int]map[int]mockCell w, h int } func NewMockScreen(w, h int) *MockScreen { return &MockScreen{ cells: make(map[int]map[int]rune), + sty: make(map[int]map[int]mockCell), w: w, h: h, } @@ -27,6 +35,21 @@ func (m *MockScreen) SetContent(x, y int, mainc rune, combc []rune, style tcell. m.cells[y] = make(map[int]rune) } m.cells[y][x] = mainc + if m.sty[y] == nil { + m.sty[y] = make(map[int]mockCell) + } + m.sty[y][x] = mockCell{r: mainc, style: style} +} + +// Cell returns the rune and style last written at (x, y). A position never +// written returns the zero rune and tcell.StyleDefault. +func (m *MockScreen) Cell(x, y int) (rune, tcell.Style) { + if row, ok := m.sty[y]; ok { + if c, ok := row[x]; ok { + return c.r, c.style + } + } + return 0, tcell.StyleDefault } func (m *MockScreen) Size() (int, int) {