From 705bbd2fba8395cbf285234421b98c7d235ab5a9 Mon Sep 17 00:00:00 2001 From: stlim Date: Fri, 21 Aug 2026 11:02:48 +0900 Subject: [PATCH 1/2] fix: Encode the hex result stats as real dump rows --- internal/themes/hex_editor_test.go | 132 +++++++++++++++++++++++++++- internal/themes/hex_editor_theme.go | 39 +++++++- 2 files changed, 166 insertions(+), 5 deletions(-) diff --git a/internal/themes/hex_editor_test.go b/internal/themes/hex_editor_test.go index 52df923..2ddf2e8 100644 --- a/internal/themes/hex_editor_test.go +++ b/internal/themes/hex_editor_test.go @@ -1,6 +1,136 @@ package themes -import "testing" +import ( + "strconv" + "strings" + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/domain" + "github.com/namest504/termtype/internal/ui" +) + +// gridRenderer is a screen-capturing fake implementing domain.Renderer: it +// keeps the full cell grid so tests can read back what a theme drew. Unlike +// mockRenderer (responsive_test.go), which only reports size, this one +// actually records content. Every string this theme writes is single-byte +// ASCII (hex digits, ".", printable bytes), so a naive one-rune-per-cell +// write is faithful to what the real tcell renderer would do here. +type gridRenderer struct { + w, h int + grid [][]rune +} + +func newGridRenderer(w, h int) *gridRenderer { + g := &gridRenderer{w: w, h: h} + g.grid = make([][]rune, h) + for y := range g.grid { + g.grid[y] = make([]rune, w) + } + g.Clear() + return g +} + +func (g *gridRenderer) Clear() { + for y := 0; y < g.h; y++ { + for x := 0; x < g.w; x++ { + g.grid[y][x] = ' ' + } + } +} +func (g *gridRenderer) DrawText(x, y int, style tcell.Style, text string) { + for i, r := range []rune(text) { + g.SetContent(x+i, y, r, style) + } +} +func (g *gridRenderer) DrawRune(x, y int, r rune, style tcell.Style) int { + g.SetContent(x, y, r, style) + return 1 +} +func (g *gridRenderer) Show() {} +func (g *gridRenderer) Size() (int, int) { return g.w, g.h } +func (g *gridRenderer) SetContent(x, y int, r rune, style tcell.Style) { + if x >= 0 && x < g.w && y >= 0 && y < g.h { + g.grid[y][x] = r + } +} +func (g *gridRenderer) HideCursor() {} +func (g *gridRenderer) ShowCursor(x, y int) {} + +// TestHexResultEncodesStats verifies the result screen encodes the round's +// stats as a real dump row right below the target, instead of overlapping an +// existing background row: (1) the stat text appears in the ascii gutter, +// (2) the hex columns on that row parse back to the stat string's UTF-8 +// bytes, and (3) nothing bleeds through past the stat content on that row — +// i.e. the row was cleared, not drawn over. +func TestHexResultEncodesStats(t *testing.T) { + theme := &HexTheme{} + w, h := 100, 30 + + gs := &domain.GameState{Sentences: []string{"hi"}} + theme.ResetState(gs) + gs.TargetSentence = "hi" // short + deterministic: exactly one target dump row + gs.IsFinished = true + gs.WPM = 61.2 + gs.Accuracy = 97.5 + gs.FinalDurS = 12 + + r := newGridRenderer(w, h) + theme.UpdateScreen(r, gs) + + stats := []byte(ui.ResultText(gs)) + targetRows := (len([]byte(gs.TargetSentence)) + 15) / 16 + startRow := h/2 + targetRows + 1 // StartLine (h/2) + target rows + one blank row + if startRow >= h { + t.Fatalf("test setup: expected result row %d falls off screen height %d", startRow, h) + } + chunk := stats[:min(len(stats), 16)] + + // (1) stat text appears in the ascii gutter (x=62..77) + asciiLine := string(r.grid[startRow][62:78]) + if !strings.Contains(asciiLine, "wpm") { + t.Fatalf("ascii gutter row %d = %q, want it to contain \"wpm\"", startRow, asciiLine) + } + + // (2) hex columns (x=10..) parse back to the stat string's UTF-8 bytes + for i, want := range chunk { + hi, lo := r.grid[startRow][10+i*3], r.grid[startRow][10+i*3+1] + got, err := strconv.ParseUint(string(hi)+string(lo), 16, 8) + if err != nil { + t.Fatalf("row %d byte %d: hex cell %q%q does not parse: %v", startRow, i, hi, lo, err) + } + if byte(got) != want { + t.Errorf("row %d byte %d = %#02x, want %#02x (stats %q)", startRow, i, byte(got), want, string(chunk)) + } + } + + // ascii gutter should be the matching byte-for-byte translation too + // (printable ASCII as itself, everything else -- including multibyte + // UTF-8 continuation bytes -- as '.'). + wantAscii := make([]rune, len(chunk)) + for i, b := range chunk { + if b >= 32 && b <= 126 { + wantAscii[i] = rune(b) + } else { + wantAscii[i] = '.' + } + } + if got := string(r.grid[startRow][62 : 62+len(chunk)]); got != string(wantAscii) { + t.Errorf("row %d ascii gutter = %q, want %q", startRow, got, string(wantAscii)) + } + + // (3) no leftover background bytes after the stat content on this row + for x := 62 + len(chunk); x < 78; x++ { + if r.grid[startRow][x] != ' ' { + t.Errorf("row %d ascii cell x=%d = %q, want cleared space (background bleed-through)", startRow, x, r.grid[startRow][x]) + } + } + for x := 10 + len(chunk)*3; x < 10+16*3; x++ { + if r.grid[startRow][x] != ' ' { + t.Errorf("row %d hex cell x=%d = %q, want cleared space (background bleed-through)", startRow, x, r.grid[startRow][x]) + } + } +} func TestHexWindow(t *testing.T) { cases := []struct { diff --git a/internal/themes/hex_editor_theme.go b/internal/themes/hex_editor_theme.go index fa0fca1..d28fb11 100644 --- a/internal/themes/hex_editor_theme.go +++ b/internal/themes/hex_editor_theme.go @@ -49,7 +49,7 @@ func (t *HexTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameState) winStart := t.drawTarget(renderer, gs, state, h) if gs.IsFinished { - t.drawResult(renderer, gs, h) + t.drawResult(renderer, gs, state, h) } else { t.drawCursor(renderer, gs, state, winStart) } @@ -171,10 +171,41 @@ func (t *HexTheme) drawCursor(renderer domain.Renderer, gs *domain.GameState, st renderer.ShowCursor(62+byteIdx%16, state.StartLine+row) } -func (t *HexTheme) drawResult(renderer domain.Renderer, gs *domain.GameState, h int) { +// drawResult writes the round's stats INTO the dump as real encoded rows: +// the stat string's UTF-8 bytes appear in the hex columns and the string in +// the ascii gutter, highlighted, right below the target rows. +func (t *HexTheme) drawResult(renderer domain.Renderer, gs *domain.GameState, state *HexThemeState, h int) { renderer.HideCursor() - resultText := ui.ResultText(gs) - renderer.DrawText(0, h-1, tcell.StyleDefault, resultText) + statStyle := tcell.StyleDefault.Foreground(tcell.ColorGreen) + addrStyle := tcell.StyleDefault.Foreground(tcell.ColorBlue) + + stats := []byte(ui.ResultText(gs)) + targetRows := (len([]byte(gs.TargetSentence)) + 15) / 16 + startRow := state.StartLine + targetRows + 1 // one blank dump row of breathing room + + for r := 0; r*16 < len(stats); r++ { + y := startRow + r + if y >= h { + break + } + chunk := stats[r*16 : min(len(stats), r*16+16)] + // clear the whole dump row first so no background bytes bleed through + for x := 0; x < 62+16; x++ { + renderer.SetContent(x, y, ' ', tcell.StyleDefault) + } + renderer.DrawText(0, y, addrStyle, fmt.Sprintf("%08x", (startRow+r)*16)) + hexStr, asciiStr := "", "" + for _, b := range chunk { + hexStr += fmt.Sprintf("%02x ", b) + if b >= 32 && b <= 126 { + asciiStr += string(rune(b)) + } else { + asciiStr += "." + } + } + renderer.DrawText(10, y, statStyle, hexStr) + renderer.DrawText(62, y, statStyle, asciiStr) + } } // OnTick flips a few background bytes, like memory being written. From 3fd3c65905906cdbd584bcb17468a46fab4fa217 Mon Sep 17 00:00:00 2001 From: stlim Date: Fri, 21 Aug 2026 11:06:10 +0900 Subject: [PATCH 2/2] feat: Give the matrix theme a clearing and a living result panel --- internal/themes/matrix_theme.go | 41 ++++++++--- internal/themes/matrix_theme_test.go | 103 +++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 internal/themes/matrix_theme_test.go diff --git a/internal/themes/matrix_theme.go b/internal/themes/matrix_theme.go index 368d7f8..d5959f6 100644 --- a/internal/themes/matrix_theme.go +++ b/internal/themes/matrix_theme.go @@ -117,7 +117,26 @@ func matrixMaxLines(h, startY int) int { return maxLines } +// clearBand blanks rows [top, bottom] so the text sits in a readable +// clearing instead of on top of the rain. +func clearBand(renderer domain.Renderer, w, top, bottom, h int) { + for y := top; y <= bottom; y++ { + if y < 0 || y >= h { + continue + } + for x := 0; x < w; x++ { + renderer.SetContent(x, y, ' ', tcell.StyleDefault.Background(tcell.ColorBlack)) + } + } +} + func (t *MatrixTheme) drawTypingArea(renderer domain.Renderer, gs *domain.GameState, w, h, startY int) { + rows := len(ui.WrapText(gs.TargetSentence, w-4)) + if cap := matrixMaxLines(h, startY); rows > cap { + rows = cap + } + clearBand(renderer, w, startY-1, startY+rows, h) + tr := &ui.TypingRenderer{} tr.Draw(renderer, gs, ui.TypingRendererOptions{ StartY: startY, @@ -129,19 +148,19 @@ func (t *MatrixTheme) drawTypingArea(renderer domain.Renderer, gs *domain.GameSt } func (t *MatrixTheme) drawResultArea(renderer domain.Renderer, gs *domain.GameState, w, h, startY int) { - // Recompute how many rows the typing window occupied. - rows := len(ui.WrapText(gs.TargetSentence, w-4)) - if cap := matrixMaxLines(h, startY); rows > cap { - rows = cap - } - renderer.HideCursor() - resultText := ui.ResultText(gs) - x := (w - runewidth.StringWidth(resultText)) / 2 - if x < 0 { - x = 0 + title := "TRACE COMPLETE" + stats := ui.ResultText(gs) + clearBand(renderer, w, startY-1, startY+3, h) + center := func(y int, style tcell.Style, s string) { + x := (w - runewidth.StringWidth(s)) / 2 + if x < 0 { + x = 0 + } + renderer.DrawText(x, y, style, ui.Truncate(s, w)) } - renderer.DrawText(x, startY+rows+1, tcell.StyleDefault.Background(tcell.ColorBlack), resultText) + center(startY, tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true), title) + center(startY+2, tcell.StyleDefault.Foreground(tcell.ColorWhite), stats) } func (t *MatrixTheme) OnTick(gs *domain.GameState) { diff --git a/internal/themes/matrix_theme_test.go b/internal/themes/matrix_theme_test.go new file mode 100644 index 0000000..bdcdb10 --- /dev/null +++ b/internal/themes/matrix_theme_test.go @@ -0,0 +1,103 @@ +package themes + +import ( + "strings" + "testing" + + "github.com/namest504/termtype/internal/domain" + "github.com/namest504/termtype/internal/ui" +) + +// TestMatrixTypingClearing verifies the sentence band sits in a readable +// clearing: the row just above the wrapped sentence and the row just below +// it must be entirely blank, even though the rain is animating underneath. +func TestMatrixTypingClearing(t *testing.T) { + theme := &MatrixTheme{} + w, h := 80, 24 + + gs := &domain.GameState{Sentences: []string{"hello world"}} + theme.ResetState(gs) + gs.TargetSentence = "hello world" + + r := newGridRenderer(w, h) + // Rain drops initialize on the first UpdateScreen call, so the screen + // size must already be set before that call. Then mirror the real game + // loop's Update -> Tick -> Update order. + theme.UpdateScreen(r, gs) + theme.OnTick(gs) + theme.UpdateScreen(r, gs) + + startY := h/2 - 2 + rows := len(ui.WrapText(gs.TargetSentence, w-4)) + if cap := matrixMaxLines(h, startY); rows > cap { + rows = cap + } + + top := startY - 1 + bottom := startY + rows + for _, y := range []int{top, bottom} { + if y < 0 || y >= h { + continue + } + for x := 0; x < w; x++ { + if r.grid[y][x] != ' ' { + t.Errorf("row %d (clearing band edge) cell x=%d = %q, want blank", y, x, r.grid[y][x]) + } + } + } +} + +// TestMatrixResultPanel verifies the finished-state panel shows "TRACE +// COMPLETE" and the result stats, and that the rain is still alive outside +// the cleared panel band. +func TestMatrixResultPanel(t *testing.T) { + theme := &MatrixTheme{} + w, h := 80, 24 + + gs := &domain.GameState{Sentences: []string{"hello world"}} + theme.ResetState(gs) + gs.TargetSentence = "hello world" + gs.IsFinished = true + gs.WPM = 61.2 + gs.Accuracy = 97.5 + gs.FinalDurS = 12 + + r := newGridRenderer(w, h) + theme.UpdateScreen(r, gs) + theme.OnTick(gs) + theme.UpdateScreen(r, gs) + + startY := h/2 - 2 + + titleLine := string(r.grid[startY]) + if !strings.Contains(titleLine, "TRACE COMPLETE") { + t.Errorf("row %d = %q, want it to contain %q", startY, titleLine, "TRACE COMPLETE") + } + + statsLine := string(r.grid[startY+2]) + stats := ui.ResultText(gs) + if !strings.Contains(statsLine, stats) { + t.Errorf("row %d = %q, want it to contain %q", startY+2, statsLine, stats) + } + + // Outside the panel band ([startY-1, startY+3]) the rain should still be + // alive: at least one non-blank cell somewhere else on the screen. + found := false + for y := 0; y < h; y++ { + if y >= startY-1 && y <= startY+3 { + continue + } + for x := 0; x < w; x++ { + if r.grid[y][x] != ' ' { + found = true + break + } + } + if found { + break + } + } + if !found { + t.Errorf("expected non-blank rain cells outside the result panel band, found none") + } +}