diff --git a/internal/chart/chart.go b/internal/chart/chart.go new file mode 100644 index 0000000..edcf87c --- /dev/null +++ b/internal/chart/chart.go @@ -0,0 +1,209 @@ +// Package chart renders float series as terminal-cell charts. It is pure +// 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 + +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 +} + +// 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 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 { + 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, lo, hi) + } + return grid +} + +// renderBraille plots into a 2×4-per-cell pixel grid, joining neighbor +// 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) + } + 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..9bbc27a --- /dev/null +++ b/internal/chart/chart_test.go @@ -0,0 +1,180 @@ +package chart + +import "testing" + +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, 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) + } +} 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) + } +} 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) {