From d1f7c02c7712a620fb12bd26f7919f5503bcab11 Mon Sep 17 00:00:00 2001 From: stlim Date: Fri, 21 Aug 2026 11:13:24 +0900 Subject: [PATCH 1/4] feat: Redraw the tutor hands and keep the keyboard on results --- internal/themes/tutor_theme.go | 108 ++++++++++++++++++++--- internal/themes/tutor_theme_test.go | 128 +++++++++++++++++++++++++--- 2 files changed, 212 insertions(+), 24 deletions(-) diff --git a/internal/themes/tutor_theme.go b/internal/themes/tutor_theme.go index d0b95eb..e121280 100644 --- a/internal/themes/tutor_theme.go +++ b/internal/themes/tutor_theme.go @@ -1,6 +1,7 @@ package themes import ( + "strings" "unicode" "github.com/gdamore/tcell/v2" @@ -156,16 +157,41 @@ func (t *TutorTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameState MaxLines: textLines, }) + kbTop := top + rows + 1 + if gs.IsFinished { renderer.HideCursor() - renderer.DrawText(colX, top+rows+1, tcell.StyleDefault.Foreground(tcell.ColorWhite), ui.Truncate(ui.ResultText(gs), colW)) + // Keep the keyboard and hands on screen (no key highlighted) so the + // result reads as a continuation of the same tutor layout rather than + // a jarring switch to plain text. The sentence area (just replaced by + // tr.Draw above) is overwritten with the centered result summary, the + // same slot the target text used. + blankW := colW + if blankW < 0 { + blankW = 0 + } + blank := strings.Repeat(" ", blankW) + for i := 0; i < rows; i++ { + renderer.DrawText(colX, top+i, tcell.StyleDefault, blank) + } + result := ui.Truncate(ui.ResultText(gs), colW) + rx := colX + (colW-len([]rune(result)))/2 + if rx < colX { + rx = colX + } + renderer.DrawText(rx, top, tcell.StyleDefault.Foreground(tcell.ColorWhite), result) + if showKB { + t.drawKeyboard(renderer, (w-kbWidth)/2, kbTop, 0, false, false) + } + if showHands { + t.drawHands(renderer, w, kbTop+kbRowsH+1, -1) + } renderer.Show() return } key, shift, ok := nextKeyInfo([]rune(gs.TargetSentence), []rune(gs.UserInput)) - kbTop := top + rows + 1 if showKB { t.drawKeyboard(renderer, (w-kbWidth)/2, kbTop, key, shift, ok) } @@ -218,9 +244,19 @@ func (t *TutorTheme) drawCap(renderer domain.Renderer, x, y int, k rune, color t renderer.SetContent(x, y, k, style) } -// handArt is one hand, thumb-side blank; the right hand mirrors it. Fingers -// occupy fixed columns so the active one can be recolored. -var handArt = []string{ +// handArtUnicode is one (left) hand with four distinct rounded-box fingers +// and the thumb-side box; the right hand mirrors it. Width must stay +// handWidth. +var handArtUnicode = []string{ + "╭╮╭╮╭╮╭╮ ", + "││││││││ ", + "││││││││╭─╮", + "│╰╯╰╯╰╯╰╯ │", + "╰─────────╯", +} + +// handArtASCII is the fallback for terminals without box glyphs. +var handArtASCII = []string{ " _ _ _ _ ", " | | | | | ", " | | | | |_", @@ -228,6 +264,16 @@ var handArt = []string{ " |________|", } +// handArt returns the active (left) hand art, ASCII or Unicode, for the +// current glyph mode; the right hand mirrors it. Fingers occupy fixed +// columns so the active one can be recolored. +func handArt() []string { + if ui.IsASCII() { + return handArtASCII + } + return handArtUnicode +} + // handCell is one cell of a finger's highlight: its position in the (left) // hand art and the glyph drawn there. type handCell struct { @@ -235,9 +281,10 @@ type handCell struct { r rune } -// fingerCells maps art fingers 0..3 (pinky..index on the left hand, index.. -// pinky mirrored on the right) plus 4 (thumb) to their highlight cells. -var fingerCells = [][]handCell{ +// fingerCellsASCII maps handArtASCII fingers 0..3 (pinky..index on the left +// hand, index..pinky mirrored on the right) plus 4 (thumb) to their +// highlight cells. +var fingerCellsASCII = [][]handCell{ {{2, 0, '_'}, {1, 1, '|'}, {3, 1, '|'}, {1, 2, '|'}, {3, 2, '|'}}, // pinky {{4, 0, '_'}, {3, 1, '|'}, {5, 1, '|'}, {3, 2, '|'}, {5, 2, '|'}}, // ring {{6, 0, '_'}, {5, 1, '|'}, {7, 1, '|'}, {5, 2, '|'}, {7, 2, '|'}}, // middle @@ -245,6 +292,38 @@ var fingerCells = [][]handCell{ {{10, 2, '_'}, {10, 3, '|'}}, // thumb hook } +// fingerCellsUnicode maps handArtUnicode fingers 0..3 to their highlight +// cells: finger i occupies columns 2i, 2i+1 across rows 0..3. Cell 4 is the +// thumb-side box. +var fingerCellsUnicode = buildUnicodeFingerCells() + +func buildUnicodeFingerCells() [][]handCell { + cells := make([][]handCell, 5) + for f := 0; f < 4; f++ { + for row := 0; row < 4; row++ { + line := []rune(handArtUnicode[row]) + for _, col := range [2]int{2 * f, 2*f + 1} { + if col < len(line) && line[col] != ' ' { + cells[f] = append(cells[f], handCell{col, row, line[col]}) + } + } + } + } + // Thumb-side box (the rounded corner opposite the fingers). + cells[4] = []handCell{ + {8, 2, '╭'}, {9, 2, '─'}, {10, 2, '╮'}, {10, 3, '│'}, + } + return cells +} + +// fingerCells returns the active finger-highlight table matching handArt(). +func fingerCells() [][]handCell { + if ui.IsASCII() { + return fingerCellsASCII + } + return fingerCellsUnicode +} + func (t *TutorTheme) drawHands(renderer domain.Renderer, w, y, finger int) { dim := tcell.StyleDefault.Foreground(tcell.ColorDimGray) accent := tcell.StyleDefault.Foreground(tcell.ColorOrange).Bold(true) @@ -253,8 +332,11 @@ func (t *TutorTheme) drawHands(renderer domain.Renderer, w, y, finger int) { lx := (w - (2*handWidth + gap)) / 2 rx := lx + handWidth + gap + art := handArt() + cells := fingerCells() + // Base art: the right hand is the left mirrored. - for row, line := range handArt { + for row, line := range art { runes := []rune(line) for col, r := range runes { if r != ' ' { @@ -274,15 +356,15 @@ func (t *TutorTheme) drawHands(renderer domain.Renderer, w, y, finger int) { // Recolor the active finger's cells ('_' and '|' mirror onto themselves). if finger <= lThumb { // left hand: finger index matches art order - for _, c := range fingerCells[finger] { + for _, c := range cells[finger] { renderer.SetContent(lx+c.dx, y+c.dy, c.r, accent) } } else { // right hand: mirror the column - art := rPinky - finger // 9→0 pinky .. 6→3 index + artIdx := rPinky - finger // 9→0 pinky .. 6→3 index if finger == rThumb { - art = 4 + artIdx = 4 } - for _, c := range fingerCells[art] { + for _, c := range cells[artIdx] { renderer.SetContent(rx+(handWidth-1-c.dx), y+c.dy, c.r, accent) } } diff --git a/internal/themes/tutor_theme_test.go b/internal/themes/tutor_theme_test.go index 0850e61..7e3864f 100644 --- a/internal/themes/tutor_theme_test.go +++ b/internal/themes/tutor_theme_test.go @@ -1,6 +1,12 @@ package themes -import "testing" +import ( + "strings" + "testing" + + "github.com/namest504/termtype/internal/domain" + "github.com/namest504/termtype/internal/ui" +) func TestTutorRegistered(t *testing.T) { if _, ok := Themes["tutor"]; !ok { @@ -49,19 +55,119 @@ func TestNextKeyInfo(t *testing.T) { } func TestFingerCellsInsideHandArt(t *testing.T) { - for f, cells := range fingerCells { - for _, c := range cells { - if c.dy < 0 || c.dy >= len(handArt) { - t.Fatalf("finger %d cell row %d outside the art", f, c.dy) + cases := []struct { + name string + art []string + cells [][]handCell + }{ + {"ascii", handArtASCII, fingerCellsASCII}, + {"unicode", handArtUnicode, fingerCellsUnicode}, + } + for _, tc := range cases { + for f, cells := range tc.cells { + for _, c := range cells { + if c.dy < 0 || c.dy >= len(tc.art) { + t.Fatalf("%s: finger %d cell row %d outside the art", tc.name, f, c.dy) + } + row := []rune(tc.art[c.dy]) + if c.dx < 0 || c.dx >= len(row) { + t.Fatalf("%s: finger %d cell col %d outside the art", tc.name, f, c.dx) + } + if row[c.dx] != c.r { + t.Errorf("%s: finger %d cell (%d,%d) glyph %q does not match the art %q", + tc.name, f, c.dx, c.dy, c.r, row[c.dx]) + } } - row := []rune(handArt[c.dy]) - if c.dx < 0 || c.dx >= len(row) { - t.Fatalf("finger %d cell col %d outside the art", f, c.dx) + } + } +} + +// 결과 화면에도 키보드가 남아 있어야 한다 (충분히 큰 화면에서). +func TestTutorResultKeepsKeyboard(t *testing.T) { + theme := &TutorTheme{} + w, h := 100, 40 + + gs := &domain.GameState{Sentences: []string{"hello world"}} + theme.ResetState(gs) + gs.TargetSentence = "hello world" + gs.UserInput = "hello world" + gs.IsFinished = true + gs.WPM = 61.2 + gs.Accuracy = 97.5 + gs.FinalDurS = 12 + + r := newGridRenderer(w, h) + theme.UpdateScreen(r, gs) + + // The second keyboard row is "qwertyuiop[]\\"; look for that run of + // keys somewhere on the grid. + found := false + for y := 0; y < h; y++ { + if strings.Contains(string(r.grid[y]), "q") && strings.Contains(string(r.grid[y]), "p") { + // Confirm it's really the qwerty row, not incidental letters + // elsewhere: q must appear before p on the same row. + line := string(r.grid[y]) + if strings.Index(line, "q") < strings.Index(line, "p") { + found = true + break } - if row[c.dx] != c.r { - t.Errorf("finger %d cell (%d,%d) glyph %q does not match the art %q", - f, c.dx, c.dy, c.r, row[c.dx]) + } + } + if !found { + t.Error("finished tutor screen should still show the keyboard's q..p row") + } + + // The result text should also be present somewhere on screen. + result := ui.ResultText(gs) + resultFound := false + for y := 0; y < h; y++ { + if strings.Contains(string(r.grid[y]), "wpm") { + resultFound = true + break + } + } + if !resultFound { + t.Errorf("finished tutor screen should show the result text %q", result) + } +} + +// 유니코드 손 아트는 라운드 박스 글리프를 쓴다. +func TestTutorHandsRounded(t *testing.T) { + theme := &TutorTheme{} + w, h := 100, 40 + + render := func() *gridRenderer { + gs := &domain.GameState{Sentences: []string{"hello world"}} + theme.ResetState(gs) + gs.TargetSentence = "hello world" + gs.UserInput = "" + r := newGridRenderer(w, h) + theme.UpdateScreen(r, gs) + return r + } + + ui.SetASCII(false) + t.Cleanup(func() { ui.SetASCII(false) }) + r := render() + if !gridContainsRune(r, '╭') { + t.Error("Unicode mode should draw rounded hand-art glyphs like '╭'") + } + + ui.SetASCII(true) + r = render() + ui.SetASCII(false) + if gridContainsRune(r, '╭') { + t.Error("ASCII mode should not draw rounded box glyphs") + } +} + +func gridContainsRune(r *gridRenderer, want rune) bool { + for y := 0; y < r.h; y++ { + for x := 0; x < r.w; x++ { + if r.grid[y][x] == want { + return true } } } + return false } From edba850816fd70027d35484f655096be4b2e9909 Mon Sep 17 00:00:00 2001 From: stlim Date: Fri, 21 Aug 2026 11:19:56 +0900 Subject: [PATCH 2/4] fix: Mirror the box-drawing glyphs on the right tutor hand --- internal/themes/tutor_theme.go | 23 ++++++++++-- internal/themes/tutor_theme_test.go | 55 ++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/internal/themes/tutor_theme.go b/internal/themes/tutor_theme.go index e121280..d82defd 100644 --- a/internal/themes/tutor_theme.go +++ b/internal/themes/tutor_theme.go @@ -365,17 +365,36 @@ func (t *TutorTheme) drawHands(renderer domain.Renderer, w, y, finger int) { artIdx = 4 } for _, c := range cells[artIdx] { - renderer.SetContent(rx+(handWidth-1-c.dx), y+c.dy, c.r, accent) + renderer.SetContent(rx+(handWidth-1-c.dx), y+c.dy, mirrorRune(c.r), accent) } } } +// mirrorGlyphs maps direction-sensitive box-drawing (and similar) glyphs to +// their horizontal mirror image. Glyphs not listed (e.g. '│', '─', '╷') are +// self-symmetric and pass through unchanged. +var mirrorGlyphs = map[rune]rune{ + '╭': '╮', '╮': '╭', + '╰': '╯', '╯': '╰', + '╱': '╲', '╲': '╱', + '<': '>', '>': '<', +} + +// mirrorRune returns the horizontal mirror of a single glyph, or the glyph +// itself if it has no direction-sensitive counterpart. +func mirrorRune(r rune) rune { + if m, ok := mirrorGlyphs[r]; ok { + return m + } + return r +} + // mirrorRunes flips a hand row horizontally, swapping the glyphs that have // a direction. func mirrorRunes(runes []rune) []rune { out := make([]rune, len(runes)) for i, r := range runes { - out[len(runes)-1-i] = r + out[len(runes)-1-i] = mirrorRune(r) } return out } diff --git a/internal/themes/tutor_theme_test.go b/internal/themes/tutor_theme_test.go index 7e3864f..a798e77 100644 --- a/internal/themes/tutor_theme_test.go +++ b/internal/themes/tutor_theme_test.go @@ -146,8 +146,10 @@ func TestTutorHandsRounded(t *testing.T) { return r } + orig := ui.IsASCII() + t.Cleanup(func() { ui.SetASCII(orig) }) + ui.SetASCII(false) - t.Cleanup(func() { ui.SetASCII(false) }) r := render() if !gridContainsRune(r, '╭') { t.Error("Unicode mode should draw rounded hand-art glyphs like '╭'") @@ -161,6 +163,57 @@ func TestTutorHandsRounded(t *testing.T) { } } +// 오른손 아트는 왼손을 단순 반전한 것이 아니라, 방향성 있는 박스 글리프도 +// 좌우가 뒤바뀐 모양이어야 한다 (예: 아랫변 모서리 ╰...╯, 손가락 윗줄 ╭╮). +func TestTutorRightHandMirrorsGlyphs(t *testing.T) { + orig := ui.IsASCII() + t.Cleanup(func() { ui.SetASCII(orig) }) + ui.SetASCII(false) + + theme := &TutorTheme{} + w, h := 100, 40 + gs := &domain.GameState{Sentences: []string{"hello world"}} + theme.ResetState(gs) + gs.TargetSentence = "hello world" + gs.UserInput = "" + r := newGridRenderer(w, h) + theme.UpdateScreen(r, gs) + + gap := 4 + lx := (w - (2*handWidth + gap)) / 2 + rx := lx + handWidth + gap + + // Locate the hand-art bottom row by finding the left hand's (unmirrored) + // bottom border, which starts at lx. + wantBottom := "╰─────────╯" + bottomY := -1 + for y := 0; y < h; y++ { + if lx+handWidth <= len(r.grid[y]) && string(r.grid[y][lx:lx+handWidth]) == wantBottom { + bottomY = y + break + } + } + if bottomY == -1 { + t.Fatal("could not locate hand-art bottom row") + } + + if rightBottom := string(r.grid[bottomY][rx : rx+handWidth]); rightBottom != wantBottom { + t.Errorf("right hand bottom row = %q, want %q (mirrored corners, not %q)", + rightBottom, wantBottom, "╯─────────╰") + } + + // The finger-top row is 4 rows above the bottom border in handArtUnicode. + topY := bottomY - 4 + if topY < 0 { + t.Fatalf("hand-art top row %d out of range", topY) + } + rightTop := string(r.grid[topY][rx : rx+handWidth]) + if !strings.Contains(rightTop, "╭╮╭╮") { + t.Errorf("right hand top row = %q, want it to contain %q (not the un-mirrored %q)", + rightTop, "╭╮╭╮", "╮╭╮╭") + } +} + func gridContainsRune(r *gridRenderer, want rune) bool { for y := 0; y < r.h; y++ { for x := 0; x < r.w; x++ { From 904f3ae52920117a8eb24e314545dc83abce6f82 Mon Sep 17 00:00:00 2001 From: stlim Date: Fri, 21 Aug 2026 11:24:14 +0900 Subject: [PATCH 3/4] feat: Liven the log stream and center the simple theme --- internal/themes/log_theme.go | 41 ++++++++---- internal/themes/log_theme_test.go | 96 ++++++++++++++++++++++++++++ internal/themes/simple_theme.go | 51 ++++++++------- internal/themes/simple_theme_test.go | 69 ++++++++++++++++++++ 4 files changed, 221 insertions(+), 36 deletions(-) create mode 100644 internal/themes/log_theme_test.go create mode 100644 internal/themes/simple_theme_test.go diff --git a/internal/themes/log_theme.go b/internal/themes/log_theme.go index b842ed5..2c7be24 100644 --- a/internal/themes/log_theme.go +++ b/internal/themes/log_theme.go @@ -15,11 +15,11 @@ import ( var logLevels = []string{"INFO", "WARN", "DEBUG", "ERROR"} var sources = []string{"auth-service", "api-gateway", "db-connector", "cache-worker", "metrics-agent"} -func formatAsLogLine(sentence string) (string, string, string) { - ts := time.Now().Format("2006-01-02T15:04:05Z") +func formatAsLogLine(ts time.Time, sentence string) (string, string, string) { + tsStr := ts.Format("2006-01-02T15:04:05Z") level := logLevels[rand.Intn(len(logLevels))] source := sources[rand.Intn(len(sources))] - prefix := fmt.Sprintf("[%s] [%s] [%s] ", ts, level, source) + prefix := fmt.Sprintf("[%s] [%s] [%s] ", tsStr, level, source) return prefix + sentence, prefix, sentence } @@ -50,7 +50,7 @@ func (t *LogTheme) ResetState(gs *domain.GameState) { gs.CustomState = logState selectedSentence := gs.RandomSentence() - fullLog, prefix, sentence := formatAsLogLine(selectedSentence) + fullLog, prefix, sentence := formatAsLogLine(time.Now(), selectedSentence) logState.targetLogLine = fullLog logState.logPrefix = prefix @@ -91,9 +91,21 @@ func (t *LogTheme) drawBackgroundLogs(renderer domain.Renderer, gs *domain.GameS if numLogs < 0 { numLogs = 0 } - for len(logState.backgroundLogs) < numLogs { - newLog, _, _ := formatAsLogLine(gs.RandomPoolSentence()) - logState.backgroundLogs = append([]string{newLog}, logState.backgroundLogs...) + if needed := numLogs - len(logState.backgroundLogs); needed > 0 { + now := time.Now() + newLines := make([]string, needed) + // Stagger each newly generated line into the past, oldest at the top + // and closest to now at the bottom, so the log reads as a stream of + // events rather than a wall of identical timestamps. Offsets + // accumulate from the bottom up so timestamps stay monotonically + // increasing top to bottom. + var offset time.Duration + for i := needed - 1; i >= 0; i-- { + offset += time.Duration(3+rand.Intn(20)) * time.Second + newLog, _, _ := formatAsLogLine(now.Add(-offset), gs.RandomPoolSentence()) + newLines[i] = newLog + } + logState.backgroundLogs = append(newLines, logState.backgroundLogs...) } if len(logState.backgroundLogs) > numLogs { logState.backgroundLogs = logState.backgroundLogs[len(logState.backgroundLogs)-numLogs:] @@ -143,11 +155,12 @@ func (t *LogTheme) drawResultLine(renderer domain.Renderer, gs *domain.GameState renderer.HideCursor() renderer.DrawText(1, targetY, tcell.StyleDefault.Foreground(tcell.ColorDimGray), logState.targetLogLine) - resultLog := fmt.Sprintf("[%s] [DEBUG] [metrics-agent] Round finished. WPM: %.2f, Accuracy: %.2f%%", time.Now().Format("2006-01-02T15:04:05Z"), gs.WPM, gs.Accuracy) - renderer.DrawText(1, targetY+1, getStyleForLogLevel("DEBUG"), resultLog) - - guideText := "Press Enter for the next round, Esc for the menu." - renderer.DrawText(1, targetY+3, tcell.StyleDefault, guideText) + now := time.Now().Format("2006-01-02T15:04:05Z") + line1 := fmt.Sprintf("[%s] [INFO] [typing-daemon] session complete — %.0f wpm, %.1f%% acc, %.0fs", + now, gs.WPM, gs.Accuracy, gs.FinalDurS) + line2 := fmt.Sprintf("[%s] [INFO] [typing-daemon] process exited (0)", now) + renderer.DrawText(1, targetY+1, getStyleForLogLevel("INFO"), line1) + renderer.DrawText(1, targetY+2, getStyleForLogLevel("INFO"), line2) } // OnTick gives the LogTheme a real-time scrolling effect. @@ -162,8 +175,8 @@ func (t *LogTheme) OnTick(gs *domain.GameState) { return } - // Append a new log and remove the oldest one. - newLog, _, _ := formatAsLogLine(gs.RandomPoolSentence()) + // Append a new log (stamped with the current time) and remove the oldest one. + newLog, _, _ := formatAsLogLine(time.Now(), gs.RandomPoolSentence()) logState.backgroundLogs = append(logState.backgroundLogs[1:], newLog) } diff --git a/internal/themes/log_theme_test.go b/internal/themes/log_theme_test.go new file mode 100644 index 0000000..60ca2f5 --- /dev/null +++ b/internal/themes/log_theme_test.go @@ -0,0 +1,96 @@ +package themes + +import ( + "regexp" + "strings" + "testing" + "time" + + "github.com/namest504/termtype/internal/domain" +) + +var logLineTsRE = regexp.MustCompile(`^\[([^\]]+)\]`) + +// TestLogTimestampsSpread verifies that the decorative background log lines +// are not all stamped with the same timestamp: they should read as a stream +// of past events leading up to now, monotonically increasing top to bottom. +func TestLogTimestampsSpread(t *testing.T) { + theme := &LogTheme{} + w, h := 100, 30 + + gs := &domain.GameState{Sentences: []string{"hello world", "the quick fox", "another pool sentence"}} + theme.ResetState(gs) + gs.TargetSentence = "hello world" + + r := newGridRenderer(w, h) + theme.UpdateScreen(r, gs) + + var timestamps []time.Time + for y := 0; y < h; y++ { + line := strings.TrimSpace(string(r.grid[y])) + m := logLineTsRE.FindStringSubmatch(line) + if m == nil { + continue + } + ts, err := time.Parse("2006-01-02T15:04:05Z", m[1]) + if err != nil { + continue + } + timestamps = append(timestamps, ts) + } + + if len(timestamps) < 2 { + t.Fatalf("expected multiple background log lines with timestamps, got %d", len(timestamps)) + } + + distinct := map[int64]bool{} + for _, ts := range timestamps { + distinct[ts.Unix()] = true + } + if len(distinct) <= 1 { + t.Errorf("expected background log timestamps to differ, got all equal: %v", timestamps) + } + + for i := 1; i < len(timestamps); i++ { + if timestamps[i].Before(timestamps[i-1]) { + t.Errorf("expected timestamps to be monotonically increasing top to bottom, row %d (%v) is before row %d (%v)", + i, timestamps[i], i-1, timestamps[i-1]) + } + } +} + +// TestLogResultLine verifies the finished screen renders typing-daemon style +// INFO log lines instead of the old "Round finished" DEBUG summary. +func TestLogResultLine(t *testing.T) { + theme := &LogTheme{} + w, h := 100, 30 + + gs := &domain.GameState{Sentences: []string{"hello world"}} + theme.ResetState(gs) + gs.TargetSentence = "hello world" + gs.UserInput = "hello world" + gs.IsFinished = true + gs.WPM = 61.2 + gs.Accuracy = 97.5 + gs.FinalDurS = 12 + + r := newGridRenderer(w, h) + theme.UpdateScreen(r, gs) + + var screen strings.Builder + for y := 0; y < h; y++ { + screen.WriteString(strings.TrimRight(string(r.grid[y]), " ")) + screen.WriteString("\n") + } + text := screen.String() + + if !strings.Contains(text, "session complete") { + t.Error("expected the result screen to contain 'session complete'") + } + if !strings.Contains(text, "wpm") { + t.Error("expected the result screen to contain 'wpm'") + } + if strings.Contains(text, "Round finished") { + t.Error("expected the old 'Round finished' DEBUG line to be gone") + } +} diff --git a/internal/themes/simple_theme.go b/internal/themes/simple_theme.go index 729f708..3b95938 100644 --- a/internal/themes/simple_theme.go +++ b/internal/themes/simple_theme.go @@ -2,6 +2,7 @@ package themes import ( "github.com/gdamore/tcell/v2" + "github.com/mattn/go-runewidth" "github.com/namest504/termtype/internal/domain" "github.com/namest504/termtype/internal/ui" ) @@ -21,43 +22,49 @@ func (t *SimpleTheme) ResetState(gs *domain.GameState) { func (t *SimpleTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameState) { renderer.Clear() - - // Starting Y coordinate for drawing text - startY := 1 - if !gs.IsFinished { - t.drawTypingScreen(renderer, gs, startY) + t.drawTypingScreen(renderer, gs) } else { - t.drawResultScreen(renderer, gs, startY) + t.drawResultScreen(renderer, gs) } - renderer.Show() } -func (t *SimpleTheme) drawTypingScreen(renderer domain.Renderer, gs *domain.GameState, startY int) { +func (t *SimpleTheme) drawTypingScreen(renderer domain.Renderer, gs *domain.GameState) { w, h := renderer.Size() - maxLines := h - startY - 3 // leave room for the hint line below + rows := len(ui.WrapText(gs.TargetSentence, w-2)) + maxLines := h - 4 if maxLines < 1 { maxLines = 1 } + if rows > maxLines { + rows = maxLines + } + startY := (h - rows) / 2 + if startY < 1 { + startY = 1 + } tr := &ui.TypingRenderer{} - rows := tr.Draw(renderer, gs, ui.TypingRendererOptions{ - StartY: startY, - Width: w - 2, // 1 padding on each side - PrefixWidth: 0, - CenterText: false, - MaxLines: maxLines, + tr.Draw(renderer, gs, ui.TypingRendererOptions{ + StartY: startY, Width: w - 2, PrefixWidth: 0, CenterText: true, MaxLines: maxLines, }) - renderer.DrawText(1, startY+rows+1, tcell.StyleDefault.Foreground(tcell.ColorWhite), "(Esc for menu)") + hint := "esc menu" + renderer.DrawText((w-runewidth.StringWidth(hint))/2, h-2, tcell.StyleDefault.Foreground(tcell.ColorGray), hint) } -func (t *SimpleTheme) drawResultScreen(renderer domain.Renderer, gs *domain.GameState, startY int) { +func (t *SimpleTheme) drawResultScreen(renderer domain.Renderer, gs *domain.GameState) { renderer.HideCursor() - defStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite) - resultText1 := ui.ResultText(gs) - resultText2 := "Press Enter for the next round, Esc for the menu." - renderer.DrawText(1, startY, defStyle, resultText1) - renderer.DrawText(1, startY+2, defStyle, resultText2) + w, h := renderer.Size() + gl := ui.Glyphs() + 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)) + } + center(h/2-1, tcell.StyleDefault.Foreground(tcell.ColorWhite), ui.ResultText(gs)) + center(h/2+1, tcell.StyleDefault.Foreground(tcell.ColorGray), gl.Send+" next "+gl.Sep+" esc menu") } func (t *SimpleTheme) OnTick(gs *domain.GameState) { /* Do nothing */ } diff --git a/internal/themes/simple_theme_test.go b/internal/themes/simple_theme_test.go new file mode 100644 index 0000000..fbdb1f2 --- /dev/null +++ b/internal/themes/simple_theme_test.go @@ -0,0 +1,69 @@ +package themes + +import ( + "strings" + "testing" + + "github.com/namest504/termtype/internal/domain" +) + +// TestSimpleCentered verifies the simple theme's typing screen centers the +// sentence vertically, and that the result screen is horizontally centered +// with no leftover "(Esc for menu)" hint. +func TestSimpleCentered(t *testing.T) { + theme := &SimpleTheme{} + w, h := 80, 24 + + gs := &domain.GameState{Sentences: []string{"hello world"}} + theme.ResetState(gs) + gs.TargetSentence = "hello world" + + r := newGridRenderer(w, h) + theme.UpdateScreen(r, gs) + + sentenceY := -1 + for y := 0; y < h; y++ { + line := string(r.grid[y]) + if strings.Contains(line, "hello world") { + sentenceY = y + break + } + } + if sentenceY == -1 { + t.Fatal("expected to find the target sentence somewhere on screen") + } + if d := sentenceY - h/2; d < -2 || d > 2 { + t.Errorf("expected sentence row %d to be within ±2 of vertical center %d", sentenceY, h/2) + } + + for y := 0; y < h; y++ { + if strings.Contains(string(r.grid[y]), "(Esc for menu)") { + t.Error("expected the old '(Esc for menu)' hint text to be gone") + } + } + + // Result screen: the result line should be centered, not left-aligned at x=1. + gs.IsFinished = true + gs.WPM = 61.2 + gs.Accuracy = 97.5 + gs.FinalDurS = 12 + + r2 := newGridRenderer(w, h) + theme.UpdateScreen(r2, gs) + + resultY := -1 + for y := 0; y < h; y++ { + if strings.Contains(string(r2.grid[y]), "wpm") { + resultY = y + break + } + } + if resultY == -1 { + t.Fatal("expected to find the result text on the finished screen") + } + line := string(r2.grid[resultY]) + firstNonSpace := strings.IndexFunc(line, func(r rune) bool { return r != ' ' }) + if firstNonSpace <= 1 { + t.Errorf("expected the result line to be centered (not left-aligned at x<=1), got leading content at x=%d", firstNonSpace) + } +} From df33d203eefbc35682027968bbf9bb24bd157b2b Mon Sep 17 00:00:00 2001 From: stlim Date: Fri, 21 Aug 2026 11:37:21 +0900 Subject: [PATCH 4/4] fix: Keep hex stats on screen and honor the ASCII contract --- internal/themes/ascii_theme_test.go | 60 +++++++++++++++++++++++++++++ internal/themes/claude_theme.go | 34 ++++++++++++---- internal/themes/hex_editor_test.go | 48 +++++++++++++++++++++++ internal/themes/hex_editor_theme.go | 15 +++++++- internal/themes/log_theme.go | 8 ++-- internal/themes/log_theme_test.go | 3 ++ internal/themes/tutor_theme.go | 3 +- 7 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 internal/themes/ascii_theme_test.go diff --git a/internal/themes/ascii_theme_test.go b/internal/themes/ascii_theme_test.go new file mode 100644 index 0000000..040dd8d --- /dev/null +++ b/internal/themes/ascii_theme_test.go @@ -0,0 +1,60 @@ +package themes + +import ( + "testing" + + "github.com/namest504/termtype/internal/domain" + "github.com/namest504/termtype/internal/ui" +) + +// TestThemesHonorASCIIContract renders every registered theme, in both its +// typing and finished states, with ui.SetASCII(true) active, and asserts no +// rune above the ASCII range (127) ever lands on the grid. This is the +// enforcement test for the ASCII contract: any theme that draws a decorative +// glyph or em dash without checking ui.IsASCII() should fail it. +func TestThemesHonorASCIIContract(t *testing.T) { + prevASCII := ui.IsASCII() + ui.SetASCII(true) + t.Cleanup(func() { ui.SetASCII(prevASCII) }) + + sentences := []string{"the quick brown fox jumps over the lazy dog"} + + for name, theme := range Themes { + for _, size := range []struct{ w, h int }{{100, 30}, {40, 16}} { + t.Run(name, func(t *testing.T) { + gs := &domain.GameState{Sentences: sentences} + theme.ResetState(gs) + gs.TargetSentence = sentences[0] + + r := newGridRenderer(size.w, size.h) + + // Typing state, partway through. + gs.UserInput = "the quick brown" + gs.IsFinished = false + theme.UpdateScreen(r, gs) + assertGridIsASCII(t, r, name, "typing") + + // Finished state. + gs.UserInput = sentences[0] + gs.IsFinished = true + gs.WPM = 61.2 + gs.Accuracy = 97.5 + gs.FinalDurS = 12 + r2 := newGridRenderer(size.w, size.h) + theme.UpdateScreen(r2, gs) + assertGridIsASCII(t, r2, name, "finished") + }) + } + } +} + +func assertGridIsASCII(t *testing.T, r *gridRenderer, theme, state string) { + t.Helper() + for y := 0; y < r.h; y++ { + for x := 0; x < r.w; x++ { + if ch := r.grid[y][x]; ch > 127 { + t.Errorf("theme %q (%s state): non-ASCII rune %q at (%d,%d) with ui.SetASCII(true)", theme, state, ch, x, y) + } + } + } +} diff --git a/internal/themes/claude_theme.go b/internal/themes/claude_theme.go index 08581b5..3e6f080 100644 --- a/internal/themes/claude_theme.go +++ b/internal/themes/claude_theme.go @@ -75,7 +75,7 @@ var claudeScenarios = []claudeScenario{ {cToolCall, "Update(server.go)"}, {cToolRes, "Updated server.go with 38 additions and 6 removals"}, {cToolCall, "Bash(go test ./...)"}, - {cToolRes, "ok — 42 tests passed"}, + {cToolRes, "ok - 42 tests passed"}, {cAsst, "Done - the server now drains connections on SIGTERM."}, }, }, @@ -108,7 +108,7 @@ var claudeScenarios = []claudeScenario{ active: []cLine{ {cAsst, "I'll run the suite to confirm the flake is gone."}, {cToolCall, "Bash(go test ./internal/cache/ -count=20)"}, - {cToolRes, "ok — 20 runs, no failures"}, + {cToolRes, "ok - 20 runs, no failures"}, {cAsst, "All green - want me to check the other suites?"}, }, }, @@ -205,11 +205,14 @@ func (t *ClaudeTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameStat // Render the conversation bottom-anchored, ending just above the status row. firstRow := statusRow - len(convo) + prevWasTodo := false for i, ln := range convo { y := firstRow + i + isTodo := ln.kind == cToolRes && isTodoLine(ln.text) if y >= 0 && y < statusRow { - t.drawConvoLine(renderer, y, w, ln, white, orange, faint) + t.drawConvoLine(renderer, y, w, ln, white, orange, faint, prevWasTodo && isTodo) } + prevWasTodo = isTodo } t.drawStatus(renderer, gs, st, sc.active, reveal, statusRow, w, orange, faint) @@ -220,7 +223,13 @@ func (t *ClaudeTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameStat renderer.Show() } -func (t *ClaudeTheme) drawConvoLine(renderer domain.Renderer, y, w int, ln cLine, white, orange, faint tcell.Style) { +// isTodoLine reports whether a tool-result line is one of the TODO_* scene +// lines drawn under an "Update Todos" call. +func isTodoLine(text string) bool { + return strings.HasPrefix(text, "TODO_DONE ") || strings.HasPrefix(text, "TODO_OPEN ") +} + +func (t *ClaudeTheme) drawConvoLine(renderer domain.Renderer, y, w int, ln cLine, white, orange, faint tcell.Style, skipBranch bool) { gl := ui.Glyphs() green := tcell.StyleDefault.Foreground(tcell.ColorGreen) switch ln.kind { @@ -240,7 +249,13 @@ func (t *ClaudeTheme) drawConvoLine(renderer domain.Renderer, y, w int, ln cLine } else if rest, ok := strings.CutPrefix(text, "TODO_OPEN "); ok { text = gl.TodoOpen + " " + rest } - renderer.DrawText(2, y, faint, gl.ToolBranch) + // Consecutive TODO_* lines belong to the same "Update Todos" scene, so + // only the first prints the branch marker; the rest indent to match. + if skipBranch { + renderer.DrawText(2, y, faint, strings.Repeat(" ", runewidth.StringWidth(gl.ToolBranch))) + } else { + renderer.DrawText(2, y, faint, gl.ToolBranch) + } renderer.DrawText(4, y, faint, ui.Truncate(text, w-4)) } } @@ -257,7 +272,12 @@ func (t *ClaudeTheme) drawStatus(renderer domain.Renderer, gs *domain.GameState, if reveal >= len(active) { doneAt := len(active) * claudeRevealEvery green := tcell.StyleDefault.Foreground(tcell.ColorGreen) - msg := fmt.Sprintf("%s responded in %ds %s %d edits %s esc to interrupt", gl.Check, doneAt, gl.Sep, claudeToolCount(active), gl.Sep) + edits := claudeToolCount(active) + editWord := "edits" + if edits == 1 { + editWord = "edit" + } + msg := fmt.Sprintf("%s responded in %ds %s %d %s", gl.Check, doneAt, gl.Sep, edits, editWord) renderer.DrawText(0, statusRow, green, ui.Truncate(msg, w)) return } @@ -331,7 +351,7 @@ func (t *ClaudeTheme) drawHint(renderer domain.Renderer, gs *domain.GameState, h renderer.DrawText(0, hintRow, dim, ui.Truncate(result, w)) return } - left := "? for shortcuts " + gl.Sep + " esc menu" + left := "esc menu " + gl.Sep + " ? for shortcuts" right := gl.FastFwd + " accept edits on (shift+tab to cycle)" renderer.DrawText(0, hintRow, faint, ui.Truncate(left, w)) if lw, rw := runewidth.StringWidth(left), runewidth.StringWidth(right); lw+rw+2 <= w { diff --git a/internal/themes/hex_editor_test.go b/internal/themes/hex_editor_test.go index 2ddf2e8..48d7ed4 100644 --- a/internal/themes/hex_editor_test.go +++ b/internal/themes/hex_editor_test.go @@ -132,6 +132,54 @@ func TestHexResultEncodesStats(t *testing.T) { } } +// TestHexResultStaysOnScreen is a regression test: with a long target (e.g. +// a 250-word Time Attack "words" target) or a short terminal, the old +// drawResult computed startRow from the FULL target length rather than the +// window drawTarget actually draws, so it could land past h and draw +// nothing -- the round ended with no visible stats. drawResult must clamp +// the stat rows onto the screen in both cases. +func TestHexResultStaysOnScreen(t *testing.T) { + longTarget := strings.Repeat("word ", 250) + longTarget = strings.TrimSpace(longTarget) + + cases := []struct { + name string + w, h int + target string + }{ + {"long target, normal terminal", 100, 24, longTarget}, + {"short terminal, normal target", 100, 16, "the quick brown fox jumps over the lazy dog"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + theme := &HexTheme{} + gs := &domain.GameState{Sentences: []string{c.target}} + theme.ResetState(gs) + gs.TargetSentence = c.target + gs.UserInput = c.target + gs.IsFinished = true + gs.WPM = 61.2 + gs.Accuracy = 97.5 + gs.FinalDurS = 12 + + r := newGridRenderer(c.w, c.h) + theme.UpdateScreen(r, gs) + + found := false + for y := 0; y < c.h && !found; y++ { + line := string(r.grid[y]) + if strings.Contains(line, "wpm") { + found = true + } + } + if !found { + t.Errorf("%s: expected the stat text (\"wpm\") to appear somewhere on a %dx%d screen, found nothing", c.name, c.w, c.h) + } + }) + } +} + func TestHexWindow(t *testing.T) { cases := []struct { name string diff --git a/internal/themes/hex_editor_theme.go b/internal/themes/hex_editor_theme.go index d28fb11..503316b 100644 --- a/internal/themes/hex_editor_theme.go +++ b/internal/themes/hex_editor_theme.go @@ -180,8 +180,19 @@ func (t *HexTheme) drawResult(renderer domain.Renderer, gs *domain.GameState, st 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 + _, visible := hexWindow(len([]byte(gs.TargetSentence)), len([]byte(gs.UserInput)), h-state.StartLine-1) + startRow := state.StartLine + visible + 1 // one blank dump row of breathing room + + statRows := (len(stats) + 15) / 16 + if statRows < 1 { + statRows = 1 + } + if startRow+statRows > h { + startRow = h - statRows + } + if startRow < 0 { + startRow = 0 + } for r := 0; r*16 < len(stats); r++ { y := startRow + r diff --git a/internal/themes/log_theme.go b/internal/themes/log_theme.go index 2c7be24..8168403 100644 --- a/internal/themes/log_theme.go +++ b/internal/themes/log_theme.go @@ -78,7 +78,7 @@ func (t *LogTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameState) } func (t *LogTheme) calculateTargetY(h int) int { - numLogs := h - 4 + numLogs := h - 5 if numLogs < 0 { numLogs = 0 } @@ -87,7 +87,7 @@ func (t *LogTheme) calculateTargetY(h int) int { func (t *LogTheme) drawBackgroundLogs(renderer domain.Renderer, gs *domain.GameState, logState *LogThemeState, h int) { // Dynamically adjust the number of log lines to the terminal height. - numLogs := h - 4 // reserve space for top margin, target line, result line, etc. + numLogs := h - 5 // reserve space for top margin, target line, result lines, etc. if numLogs < 0 { numLogs = 0 } @@ -156,11 +156,13 @@ func (t *LogTheme) drawResultLine(renderer domain.Renderer, gs *domain.GameState renderer.DrawText(1, targetY, tcell.StyleDefault.Foreground(tcell.ColorDimGray), logState.targetLogLine) now := time.Now().Format("2006-01-02T15:04:05Z") - line1 := fmt.Sprintf("[%s] [INFO] [typing-daemon] session complete — %.0f wpm, %.1f%% acc, %.0fs", + line1 := fmt.Sprintf("[%s] [INFO] [typing-daemon] session complete - %.0f wpm, %.1f%% acc, %.0fs", now, gs.WPM, gs.Accuracy, gs.FinalDurS) line2 := fmt.Sprintf("[%s] [INFO] [typing-daemon] process exited (0)", now) + line3 := fmt.Sprintf("[%s] [INFO] [typing-daemon] waiting for input - enter: restart, esc: menu", now) renderer.DrawText(1, targetY+1, getStyleForLogLevel("INFO"), line1) renderer.DrawText(1, targetY+2, getStyleForLogLevel("INFO"), line2) + renderer.DrawText(1, targetY+3, getStyleForLogLevel("INFO"), line3) } // OnTick gives the LogTheme a real-time scrolling effect. diff --git a/internal/themes/log_theme_test.go b/internal/themes/log_theme_test.go index 60ca2f5..2452d23 100644 --- a/internal/themes/log_theme_test.go +++ b/internal/themes/log_theme_test.go @@ -93,4 +93,7 @@ func TestLogResultLine(t *testing.T) { if strings.Contains(text, "Round finished") { t.Error("expected the old 'Round finished' DEBUG line to be gone") } + if !strings.Contains(text, "waiting for input") { + t.Error("expected the result screen to contain 'waiting for input' key guidance") + } } diff --git a/internal/themes/tutor_theme.go b/internal/themes/tutor_theme.go index d82defd..431f21b 100644 --- a/internal/themes/tutor_theme.go +++ b/internal/themes/tutor_theme.go @@ -5,6 +5,7 @@ import ( "unicode" "github.com/gdamore/tcell/v2" + "github.com/mattn/go-runewidth" "github.com/namest504/termtype/internal/domain" "github.com/namest504/termtype/internal/ui" ) @@ -175,7 +176,7 @@ func (t *TutorTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameState renderer.DrawText(colX, top+i, tcell.StyleDefault, blank) } result := ui.Truncate(ui.ResultText(gs), colW) - rx := colX + (colW-len([]rune(result)))/2 + rx := colX + (colW-runewidth.StringWidth(result))/2 if rx < colX { rx = colX }