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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/termtype/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/gdamore/tcell/v2"
"github.com/mattn/go-runewidth"
"github.com/namest504/termtype/internal/app"
"github.com/namest504/termtype/internal/chart"
"github.com/namest504/termtype/internal/domain"
"github.com/namest504/termtype/internal/store"
"github.com/namest504/termtype/internal/themes"
Expand Down Expand Up @@ -99,6 +100,21 @@ func drawText(s tcell.Screen, x, y int, style tcell.Style, text string) {
}
}

// chartOptionsFor maps a config style code onto chart options. Unknown
// codes fall back to the default so an edited config never breaks startup.
func chartOptionsFor(code string) chart.Options {
o := chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 2}
switch code {
case "braille1":
o.Thickness = 1
case "braille3":
o.Thickness = 3
case "box":
o.Style, o.Thickness = chart.StyleBox, 1
}
return o
}

// selection is everything the menu picks: the theme (and its registry name,
// recorded in history), the mode limit, the text source, and the language.
type selection struct {
Expand Down Expand Up @@ -289,6 +305,7 @@ func main() {
// Ctrl-C anywhere) leaves the program.
st := store.Default()
cfg := st.LoadConfig()
ui.SetChartOptions(chartOptionsFor(cfg.ChartStyle()))
for {
sel, err := selectTheme(s, events, cfg, st)
if err != nil {
Expand Down
27 changes: 27 additions & 0 deletions cmd/termtype/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package main

import (
"testing"

"github.com/namest504/termtype/internal/chart"
)

func TestChartOptionsFor(t *testing.T) {
cases := []struct {
code string
style chart.Style
thick int
}{
{"braille1", chart.StyleBraille, 1},
{"braille2", chart.StyleBraille, 2},
{"braille3", chart.StyleBraille, 3},
{"box", chart.StyleBox, 1},
{"unknown", chart.StyleBraille, 2}, // 알 수 없는 값은 기본값
}
for _, c := range cases {
o := chartOptionsFor(c.code)
if o.Style != c.style || o.Thickness != c.thick || o.Interp != chart.InterpSmooth {
t.Fatalf("%s → %+v", c.code, o)
}
}
}
59 changes: 55 additions & 4 deletions internal/chart/chart.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ var brailleBits = [4][2]int{{0x01, 0x08}, {0x02, 0x10}, {0x04, 0x20}, {0x40, 0x8
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.
// between samples; InterpSmooth uses monotone cubic interpolation.
func sample(series []float64, n int, ip Interp) []float64 {
if ip == InterpSmooth && len(series) >= 3 {
return monotoneCubic(series, n)
}
out := make([]float64, n)
for c := 0; c < n; c++ {
pos := float64(c) * float64(len(series)-1) / float64(n-1)
Expand Down Expand Up @@ -138,6 +140,8 @@ func Render(series []float64, o Options) [][]Cell {
switch o.Style {
case StyleASCII:
renderASCII(grid, series, cols, o.Height)
case StyleBox:
renderBox(grid, series, cols, o, lo, hi)
default:
renderBraille(grid, series, cols, o, lo, hi)
}
Expand All @@ -151,11 +155,24 @@ func Render(series []float64, o Options) [][]Cell {
// 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)
thick := o.Thickness
if thick < 1 {
thick = 1
}
if thick > 3 {
thick = 3
}
pxTotal := o.Height * 4
pxRows := pixelRows(sample(series, cols*2, o.Interp), pxTotal, lo, hi)
masks := make([][]int, o.Height)
for i := range masks {
masks[i] = make([]int, cols)
}
set := func(py, c int) {
if py >= 0 && py < pxTotal {
masks[py/4][c/2] |= brailleBits[py%4][c%2]
}
}
prev := pxRows[0]
for c, row := range pxRows {
lo, hi := row, row
Expand All @@ -167,7 +184,9 @@ func renderBraille(grid [][]Cell, series []float64, cols int, o Options, lo, hi
}
}
for py := lo; py <= hi; py++ {
masks[py/4][c/2] |= brailleBits[py%4][c%2]
for t := 0; t < thick; t++ {
set(py+t, c)
}
}
prev = row
}
Expand All @@ -180,6 +199,38 @@ func renderBraille(grid [][]Cell, series []float64, cols int, o Options, lo, hi
}
}

// renderBox draws a cell-resolution solid line with rounded corners:
// ─ for level runs, ╮╰ going down, ╯╭ going up, │ filling steep drops.
// lo, hi are the TRUE series bounds (matching the axis labels), NOT the
// bounds of the downsampled array — see renderBraille for why.
func renderBox(grid [][]Cell, series []float64, cols int, o Options, lo, hi float64) {
vals := sample(series, cols, o.Interp)
rows := pixelRows(vals, o.Height, lo, hi)
put := func(y, c int, r rune) {
grid[y][labelW+1+c] = Cell{Rune: r, Kind: KindLine}
}
prev := rows[0]
for c, row := range rows {
switch {
case c == 0 || row == prev:
put(row, c, '─')
case row < prev: // going up
put(prev, c, '╯')
put(row, c, '╭')
for r := row + 1; r < prev; r++ {
put(r, c, '│')
}
default: // going down
put(prev, c, '╮')
put(row, c, '╰')
for r := prev + 1; r < row; r++ {
put(r, c, '│')
}
}
prev = row
}
}

// 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.
Expand Down
74 changes: 74 additions & 0 deletions internal/chart/chart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,77 @@ func TestRenderBrailleScalesAgainstTrueSeriesBounds(t *testing.T) {
topRow, expectedCellRow)
}
}

func brailleDotCount(g [][]Cell) int {
n := 0
for _, row := range g {
for _, c := range row {
if c.Rune >= 0x2800 && c.Rune <= 0x28FF {
for mask := int(c.Rune - 0x2800); mask != 0; mask &= mask - 1 {
n++
}
}
}
}
return n
}

func TestThicknessAddsPixels(t *testing.T) {
series := []float64{0, 30, 60, 100}
thin := Render(series, Options{Width: 30, Height: 6, Thickness: 1})
thick := Render(series, Options{Width: 30, Height: 6, Thickness: 2})
if brailleDotCount(thick) <= brailleDotCount(thin) {
t.Fatalf("thickness 2 (%d dots) should light more pixels than 1 (%d)",
brailleDotCount(thick), brailleDotCount(thin))
}
}

func TestThicknessClampsAtBottom(t *testing.T) {
// a series hugging the minimum: thick pixels must not run past the grid
g := Render([]float64{0, 0, 100}, Options{Width: 20, Height: 3, Thickness: 3})
if g == nil {
t.Fatal("render returned nil")
}
// reaching here without an index-out-of-range panic is the real assertion
}

func TestThicknessZeroMeansOne(t *testing.T) {
series := []float64{0, 50, 100}
zero := Render(series, Options{Width: 30, Height: 6, Thickness: 0})
one := Render(series, Options{Width: 30, Height: 6, Thickness: 1})
if brailleDotCount(zero) != brailleDotCount(one) {
t.Fatal("thickness 0 should behave as 1")
}
}

func TestBoxStyleCorners(t *testing.T) {
// 3-sample V shape: down then up → expect ╮ ╰ on the way down, ╯ ╭ up
g := Render([]float64{100, 0, 100}, Options{Width: 20, Height: 5, Style: StyleBox})
counts := map[rune]int{}
for _, row := range g {
for _, c := range row {
if c.Kind == KindLine {
counts[c.Rune]++
}
}
}
for _, r := range []rune{'╮', '╰', '╯', '╭'} {
if counts[r] == 0 {
t.Fatalf("box line missing corner %q; got %v", r, counts)
}
}
if counts['─'] == 0 {
t.Fatalf("box line missing horizontal runs; got %v", counts)
}
}

func TestBoxStyleFlat(t *testing.T) {
g := Render([]float64{50, 50, 50}, Options{Width: 20, Height: 5, Style: StyleBox})
for y, row := range g {
for _, c := range row {
if c.Kind == KindLine && (c.Rune != '─' || y != 2) {
t.Fatalf("flat box line should be ─ on middle row, got %q on row %d", c.Rune, y)
}
}
}
}
52 changes: 52 additions & 0 deletions internal/chart/interp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package chart

import "math"

// monotoneCubic interpolates series onto n points with the Fritsch–Carlson
// monotone cubic scheme: the curve is smooth but never overshoots its
// samples, so a WPM graph never shows a peak the player didn't hit.
func monotoneCubic(series []float64, n int) []float64 {
m := len(series)
// secant slopes and tangents
d := make([]float64, m-1)
for i := range d {
d[i] = series[i+1] - series[i]
}
t := make([]float64, m)
t[0], t[m-1] = d[0], d[m-2]
for i := 1; i < m-1; i++ {
if d[i-1]*d[i] <= 0 {
t[i] = 0
} else {
t[i] = (d[i-1] + d[i]) / 2
}
}
// Fritsch–Carlson limiter keeps each segment monotone.
for i := 0; i < m-1; i++ {
if d[i] == 0 {
t[i], t[i+1] = 0, 0
continue
}
a, b := t[i]/d[i], t[i+1]/d[i]
if s := a*a + b*b; s > 9 {
tau := 3 / math.Sqrt(s)
t[i] = tau * a * d[i]
t[i+1] = tau * b * d[i]
}
}
out := make([]float64, n)
for c := 0; c < n; c++ {
pos := float64(c) * float64(m-1) / float64(n-1)
i := int(pos)
if i >= m-1 {
i = m - 2
}
x := pos - float64(i)
h00 := (1 + 2*x) * (1 - x) * (1 - x)
h10 := x * (1 - x) * (1 - x)
h01 := x * x * (3 - 2*x)
h11 := x * x * (x - 1)
out[c] = h00*series[i] + h10*t[i] + h01*series[i+1] + h11*t[i+1]
}
return out
}
48 changes: 48 additions & 0 deletions internal/chart/interp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package chart

import "testing"

// 단조 구간에서 보간값도 단조 — 오버슈트 없음이 스펙 요구사항이다.
func TestMonotoneCubicNoOvershoot(t *testing.T) {
series := []float64{0, 10, 12, 60, 62, 100}
got := monotoneCubic(series, 101)
lo, hi := bounds(series)
prev := got[0]
for i, v := range got {
if v < lo-1e-9 || v > hi+1e-9 {
t.Fatalf("overshoot at %d: %v outside [%v,%v]", i, v, lo, hi)
}
if v < prev-1e-9 {
t.Fatalf("monotone increasing series produced a dip at %d: %v < %v", i, v, prev)
}
prev = v
}
}

func TestMonotoneCubicHitsSamples(t *testing.T) {
series := []float64{20, 50, 30, 80}
got := monotoneCubic(series, 7) // n = 2*(len-1)+1 → 짝수 인덱스가 원본 샘플
for i, want := range series {
if diff := got[i*2] - want; diff > 1e-9 || diff < -1e-9 {
t.Fatalf("sample %d: got %v, want %v", i, got[i*2], want)
}
}
}

func TestMonotoneCubicFlat(t *testing.T) {
for _, v := range monotoneCubic([]float64{5, 5, 5}, 10) {
if v != 5 {
t.Fatalf("flat series interpolated to %v", v)
}
}
}

func TestSampleSmoothFallsBackBelowThree(t *testing.T) {
got := sample([]float64{0, 10}, 3, InterpSmooth)
want := []float64{0, 5, 10}
for i := range want {
if got[i] != want[i] {
t.Fatalf("2-sample smooth should be linear, got %v", got)
}
}
}
10 changes: 10 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,23 @@ type Config struct {
Lang string `json:"lang"`
Source string `json:"source"` // "builtin" | "words"
Graph string `json:"graph,omitempty"` // "" or "on" = auto result graph; "off" = manual (g key)
Style string `json:"style,omitempty"` // result-graph style; see Config.ChartStyle
Ghost bool `json:"ghost"`
}

// GraphAuto reports whether the result graph should show automatically
// after a round. It defaults to on.
func (c Config) GraphAuto() bool { return c.Graph != "off" }

// ChartStyle returns the configured result-graph style, defaulting to the
// 2px braille spline. Values: "braille1" | "braille2" | "braille3" | "box".
func (c Config) ChartStyle() string {
if c.Style == "" {
return "braille2"
}
return c.Style
}

// Round is one finished typing round — one line in history.jsonl. The
// RawWPM/CPM/WPMSeries fields were added later and are absent from older
// lines, which decode with zero values.
Expand Down
9 changes: 9 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,12 @@ func TestNoopStore(t *testing.T) {
}
}
}

func TestChartStyleDefault(t *testing.T) {
if got := (Config{}).ChartStyle(); got != "braille2" {
t.Fatalf("empty style should default to braille2, got %q", got)
}
if got := (Config{Style: "box"}).ChartStyle(); got != "box" {
t.Fatalf("explicit style should pass through, got %q", got)
}
}
Loading