From 41ef866e0e11bb9a157d4330b68a5c1ada552d1f Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:03:35 +0900 Subject: [PATCH 1/5] feat: Move round options into a dedicated settings screen --- cmd/termtype/settings.go | 174 ++++++++++++++++++++++++++++++++++ cmd/termtype/settings_test.go | 67 +++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 cmd/termtype/settings.go create mode 100644 cmd/termtype/settings_test.go diff --git a/cmd/termtype/settings.go b/cmd/termtype/settings.go new file mode 100644 index 0000000..a7c42ca --- /dev/null +++ b/cmd/termtype/settings.go @@ -0,0 +1,174 @@ +package main + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/store" + "github.com/namest504/termtype/internal/ui" +) + +// chartStyles are the result-graph styles the settings screen cycles +// through; codes are what config.json stores (see Config.ChartStyle). +var chartStyles = []struct{ code, label string }{ + {"braille1", "braille · 1px"}, + {"braille2", "braille · 2px"}, + {"braille3", "braille · 3px"}, + {"box", "box"}, +} + +const settingsRows = 5 // Mode, Text, Language, Graph, Style + +// settingsModel is the settings screen state, kept free of drawing so key +// transitions are unit-testable. +type settingsModel struct { + row int + modeIdx int + srcIdx int + langIdx int + styleIdx int + graphOn bool +} + +func newSettingsModel(cfg store.Config) settingsModel { + return settingsModel{ + modeIdx: indexOf(len(gameModes), func(i int) bool { return store.ModeString(gameModes[i].limit) == cfg.Mode }), + srcIdx: indexOf(len(textSources), func(i int) bool { return textSources[i].code == cfg.Source }), + langIdx: indexOf(len(languages), func(i int) bool { return languages[i].code == cfg.Lang }), + styleIdx: indexOf(len(chartStyles), func(i int) bool { return chartStyles[i].code == cfg.Style }), + graphOn: cfg.GraphAuto(), + } +} + +// handleKey advances the model. changed means a value moved (caller saves); +// done means Esc closed the screen. +func (m *settingsModel) handleKey(ev *tcell.EventKey) (changed, done bool) { + switch ev.Key() { + case tcell.KeyEscape: + return false, true + case tcell.KeyUp: + if m.row > 0 { + m.row-- + } + case tcell.KeyDown: + if m.row < settingsRows-1 { + m.row++ + } + case tcell.KeyLeft: + return m.cycle(-1), false + case tcell.KeyRight: + return m.cycle(1), false + } + return false, false +} + +func cycleIdx(i, d, n int) int { return (i + d + n) % n } + +func (m *settingsModel) cycle(d int) bool { + switch m.row { + case 0: + m.modeIdx = cycleIdx(m.modeIdx, d, len(gameModes)) + case 1: + m.srcIdx = cycleIdx(m.srcIdx, d, len(textSources)) + case 2: + // The words pool is English-only; the row is pinned while it is active. + if textSources[m.srcIdx].code == "words" { + return false + } + m.langIdx = cycleIdx(m.langIdx, d, len(languages)) + case 3: + m.graphOn = !m.graphOn + case 4: + m.styleIdx = cycleIdx(m.styleIdx, d, len(chartStyles)) + } + return true +} + +// apply writes the model's values onto cfg, leaving unrelated fields alone. +func (m settingsModel) apply(cfg store.Config) store.Config { + cfg.Mode = store.ModeString(gameModes[m.modeIdx].limit) + cfg.Source = textSources[m.srcIdx].code + cfg.Lang = languages[m.langIdx].code + cfg.Graph = "on" + if !m.graphOn { + cfg.Graph = "off" + } + cfg.Style = chartStyles[m.styleIdx].code + return cfg +} + +// runSettings shows the settings screen. Every value change is saved to +// config immediately; Esc returns to the menu. +func runSettings(s tcell.Screen, events <-chan tcell.Event, cfg *store.Config, st *store.Store) { + m := newSettingsModel(*cfg) + for { + drawSettings(s, m) + switch ev := (<-events).(type) { + case nil: + return + case *tcell.EventResize: + s.Sync() + case *tcell.EventKey: + if ev.Key() == tcell.KeyCtrlC { + // quit is the menu's job; treat as Esc here + return + } + changed, done := m.handleKey(ev) + if changed { + *cfg = m.apply(*cfg) + st.SaveConfig(*cfg) + ui.SetChartOptions(chartOptionsFor(cfg.ChartStyle())) + } + if done { + return + } + } + } +} + +func drawSettings(s tcell.Screen, m settingsModel) { + s.Clear() + gl := ui.Glyphs() + drawText(s, 2, 1, tcell.StyleDefault.Bold(true), "Settings") + + langName := languages[m.langIdx].name + langPinned := textSources[m.srcIdx].code == "words" + if langPinned { + langName = "English" + } + graph := "On" + if !m.graphOn { + graph = "Off" + } + style := chartStyles[m.styleIdx].label + if ui.IsASCII() { + style += " (ascii)" + } + rows := []struct { + name, value string + dim bool + }{ + {"Mode", gameModes[m.modeIdx].name, false}, + {"Text", textSources[m.srcIdx].name, false}, + {"Language", langName, langPinned}, + {"Graph", graph, false}, + {"Style", style, false}, + } + for i, row := range rows { + st := tcell.StyleDefault + if row.dim { + st = st.Foreground(tcell.ColorGray) + } + if i == m.row { + st = st.Reverse(true) + } + line := fmt.Sprintf("%-10s %s %s %s", row.name, "‹", row.value, "›") + if ui.IsASCII() { + line = fmt.Sprintf("%-10s < %s >", row.name, row.value) + } + drawText(s, 3, 3+i, st, line) + } + help := gl.ArrowUD + " select " + gl.Sep + " " + gl.ArrowLR + " change " + gl.Sep + " Esc back" + drawText(s, 2, 3+settingsRows+1, tcell.StyleDefault.Foreground(tcell.ColorGray), help) + s.Show() +} diff --git a/cmd/termtype/settings_test.go b/cmd/termtype/settings_test.go new file mode 100644 index 0000000..4fe9297 --- /dev/null +++ b/cmd/termtype/settings_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/store" +) + +func key(k tcell.Key) *tcell.EventKey { return tcell.NewEventKey(k, 0, tcell.ModNone) } + +func TestSettingsModelFromConfig(t *testing.T) { + m := newSettingsModel(store.Config{Mode: "ta30", Source: "words", Lang: "ko", Graph: "off", Style: "box"}) + if gameModes[m.modeIdx].name != "Time Attack (30s)" { + t.Fatalf("mode idx wrong: %s", gameModes[m.modeIdx].name) + } + if textSources[m.srcIdx].code != "words" || languages[m.langIdx].code != "ko" { + t.Fatal("source/lang not restored") + } + if m.graphOn || chartStyles[m.styleIdx].code != "box" { + t.Fatal("graph/style not restored") + } +} + +func TestSettingsCycleAndApply(t *testing.T) { + m := newSettingsModel(store.Config{}) + // row 0 = Mode: Right → Time Attack (15s) + if changed, _ := m.handleKey(key(tcell.KeyRight)); !changed { + t.Fatal("right on mode row should report a change") + } + // row 4 = Style: Left → wraps to box + m.row = 4 + m.handleKey(key(tcell.KeyLeft)) + cfg := m.apply(store.Config{Theme: "cozy"}) + if cfg.Mode != "ta15" || cfg.Style != "box" || cfg.Theme != "cozy" { + t.Fatalf("apply produced %+v", cfg) + } +} + +func TestSettingsLanguagePinnedForWords(t *testing.T) { + m := newSettingsModel(store.Config{Source: "words"}) + m.row = 2 // Language + if changed, _ := m.handleKey(key(tcell.KeyRight)); changed { + t.Fatal("language must not cycle while Words is selected") + } +} + +func TestSettingsEscDone(t *testing.T) { + m := newSettingsModel(store.Config{}) + if _, done := m.handleKey(key(tcell.KeyEscape)); !done { + t.Fatal("esc should finish the screen") + } +} + +func TestSettingsRowNavigationClamps(t *testing.T) { + m := newSettingsModel(store.Config{}) + m.handleKey(key(tcell.KeyUp)) // already at top + if m.row != 0 { + t.Fatal("up at top should clamp") + } + for i := 0; i < 10; i++ { + m.handleKey(key(tcell.KeyDown)) + } + if m.row != settingsRows-1 { + t.Fatalf("down should clamp at %d, got %d", settingsRows-1, m.row) + } +} From b25a7016c29e845d5b5ee393e30b4f7de2e5c823 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:07:19 +0900 Subject: [PATCH 2/5] fix: Restore the settings style row from the defaulted chart style --- cmd/termtype/settings.go | 2 +- cmd/termtype/settings_test.go | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cmd/termtype/settings.go b/cmd/termtype/settings.go index a7c42ca..caab337 100644 --- a/cmd/termtype/settings.go +++ b/cmd/termtype/settings.go @@ -35,7 +35,7 @@ func newSettingsModel(cfg store.Config) settingsModel { modeIdx: indexOf(len(gameModes), func(i int) bool { return store.ModeString(gameModes[i].limit) == cfg.Mode }), srcIdx: indexOf(len(textSources), func(i int) bool { return textSources[i].code == cfg.Source }), langIdx: indexOf(len(languages), func(i int) bool { return languages[i].code == cfg.Lang }), - styleIdx: indexOf(len(chartStyles), func(i int) bool { return chartStyles[i].code == cfg.Style }), + styleIdx: indexOf(len(chartStyles), func(i int) bool { return chartStyles[i].code == cfg.ChartStyle() }), graphOn: cfg.GraphAuto(), } } diff --git a/cmd/termtype/settings_test.go b/cmd/termtype/settings_test.go index 4fe9297..18cc235 100644 --- a/cmd/termtype/settings_test.go +++ b/cmd/termtype/settings_test.go @@ -28,15 +28,31 @@ func TestSettingsCycleAndApply(t *testing.T) { if changed, _ := m.handleKey(key(tcell.KeyRight)); !changed { t.Fatal("right on mode row should report a change") } - // row 4 = Style: Left → wraps to box + // row 4 = Style: Left twice (braille2 → braille1 → box) m.row = 4 m.handleKey(key(tcell.KeyLeft)) + m.handleKey(key(tcell.KeyLeft)) cfg := m.apply(store.Config{Theme: "cozy"}) if cfg.Mode != "ta15" || cfg.Style != "box" || cfg.Theme != "cozy" { t.Fatalf("apply produced %+v", cfg) } } +func TestSettingsFreshConfigDefaultsToBraille2(t *testing.T) { + m := newSettingsModel(store.Config{}) + // Fresh config should show braille2 (not braille1) + if chartStyles[m.styleIdx].code != "braille2" { + t.Fatalf("fresh config should default to braille2, got %s", chartStyles[m.styleIdx].code) + } + // Changing an unrelated row should not downgrade the style + m.row = 0 // Mode + m.handleKey(key(tcell.KeyRight)) + cfg := m.apply(store.Config{}) + if cfg.Style != "braille2" { + t.Fatalf("changing unrelated row should preserve braille2, got %s", cfg.Style) + } +} + func TestSettingsLanguagePinnedForWords(t *testing.T) { m := newSettingsModel(store.Config{Source: "words"}) m.row = 2 // Language From e9db9c39d252f0aa3230f95e25ea866b51ecf961 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:10:29 +0900 Subject: [PATCH 3/5] feat: Simplify the menu into a theme carousel --- cmd/termtype/menu.go | 161 ++++++++++++++++++++++++++++++++++++++ cmd/termtype/menu_test.go | 73 +++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 cmd/termtype/menu.go create mode 100644 cmd/termtype/menu_test.go diff --git a/cmd/termtype/menu.go b/cmd/termtype/menu.go new file mode 100644 index 0000000..4763f0c --- /dev/null +++ b/cmd/termtype/menu.go @@ -0,0 +1,161 @@ +package main + +import ( + "sort" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-runewidth" + "github.com/namest504/termtype/internal/themes" + "github.com/namest504/termtype/internal/ui" +) + +// sortedThemeNames returns the registry names in menu order: cozy leads +// (the default), log keeps second place, the rest follow alphabetically. +func sortedThemeNames() []string { + var names []string + for name := range themes.Themes { + names = append(names, name) + } + rank := func(name string) int { + switch name { + case "cozy": + return 0 + case "log": + return 1 + } + return 2 + } + sort.Slice(names, func(i, j int) bool { + if ri, rj := rank(names[i]), rank(names[j]); ri != rj { + return ri < rj + } + return names[i] < names[j] + }) + return names +} + +// menuAction is what a key press asks the menu loop to do. +type menuAction int + +const ( + actNone menuAction = iota + actStart + actSettings + actHistory + actQuit +) + +// menuModel is the main-menu state: a theme carousel that can expand into +// a full list. Drawing is separate so transitions are unit-testable. +type menuModel struct { + themes []string + idx int // carousel position (the picked theme) + expanded bool // theme list unfolded below the carousel + sel int // list selection while expanded +} + +func newMenuModel(cfgTheme string) menuModel { + names := sortedThemeNames() + return menuModel{ + themes: names, + idx: indexOf(len(names), func(i int) bool { return names[i] == cfgTheme }), + } +} + +func (m *menuModel) handleKey(ev *tcell.EventKey) menuAction { + if m.expanded { + switch ev.Key() { + case tcell.KeyUp: + if m.sel > 0 { + m.sel-- + } + case tcell.KeyDown: + if m.sel < len(m.themes)-1 { + m.sel++ + } + case tcell.KeyEnter: + m.idx = m.sel + m.expanded = false + case tcell.KeyEscape: + m.expanded = false + case tcell.KeyCtrlC: + return actQuit + } + return actNone + } + switch ev.Key() { + case tcell.KeyLeft: + m.idx = cycleIdx(m.idx, -1, len(m.themes)) + case tcell.KeyRight: + m.idx = cycleIdx(m.idx, 1, len(m.themes)) + case tcell.KeyDown: + m.expanded, m.sel = true, m.idx + case tcell.KeyEnter: + return actStart + case tcell.KeyEscape, tcell.KeyCtrlC: + return actQuit + case tcell.KeyRune: + switch ev.Rune() { + case 's', 'S': + return actSettings + case 'h', 'H': + return actHistory + } + } + return actNone +} + +// drawMenu renders the carousel main screen; summary is the read-only +// "Mode · Text · Language" line built by the caller from config. +func drawMenu(s tcell.Screen, m menuModel, summary string) { + s.Clear() + w, _ := s.Size() + gl := ui.Glyphs() + centered := func(y int, style tcell.Style, text string) { + x := (w - runewidth.StringWidth(text)) / 2 + if x < 0 { + x = 0 + } + drawText(s, x, y, style, ui.Truncate(text, w)) + } + + centered(1, tcell.StyleDefault.Bold(true), "termtype") + + l, r := "‹", "›" + if ui.IsASCII() { + l, r = "<", ">" + } + centered(3, tcell.StyleDefault.Reverse(true), " "+l+" "+m.themes[m.idx]+" "+r+" ") + centered(5, tcell.StyleDefault.Foreground(tcell.ColorGray), summary) + + helpY := 7 + if m.expanded { + for i, name := range m.themes { + style := tcell.StyleDefault + if i == m.sel { + style = style.Reverse(true) + } + centered(7+i, style, " "+name+" ") + } + helpY = 7 + len(m.themes) + 1 + centered(helpY, tcell.StyleDefault.Foreground(tcell.ColorGray), + gl.ArrowUD+" pick "+gl.Sep+" "+gl.Enter+" select "+gl.Sep+" Esc close") + s.Show() + return + } + + full := strings.Join([]string{ + gl.Enter + " start", "s settings", "h history", "Esc quit", + }, " "+gl.Sep+" ") + compact := gl.Enter + " start " + gl.Sep + " s settings" + help := full + if runewidth.StringWidth(help) > w-2 { + help = compact + } + if runewidth.StringWidth(help) > w-2 { + help = gl.Enter + " start" + } + centered(helpY, tcell.StyleDefault.Foreground(tcell.ColorGray), help) + s.Show() +} diff --git a/cmd/termtype/menu_test.go b/cmd/termtype/menu_test.go new file mode 100644 index 0000000..b34b17d --- /dev/null +++ b/cmd/termtype/menu_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "testing" + + "github.com/gdamore/tcell/v2" +) + +func rkey(r rune) *tcell.EventKey { return tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone) } + +func TestSortedThemesCozyFirst(t *testing.T) { + names := sortedThemeNames() + if len(names) < 3 || names[0] != "cozy" || names[1] != "log" { + t.Fatalf("theme order wrong: %v", names) + } +} + +func TestCarouselWraps(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyLeft)) + if m.idx != len(m.themes)-1 { + t.Fatalf("left from first should wrap to last, got %d", m.idx) + } + m.handleKey(key(tcell.KeyRight)) + if m.idx != 0 { + t.Fatalf("right should wrap back to first, got %d", m.idx) + } +} + +func TestExpandSelectCollapse(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyDown)) + if !m.expanded || m.sel != m.idx { + t.Fatal("down should expand with selection on current theme") + } + m.handleKey(key(tcell.KeyDown)) // move selection + m.handleKey(key(tcell.KeyEnter)) + if m.expanded || m.idx != 1 { + t.Fatalf("enter should pick sel and collapse, idx=%d expanded=%v", m.idx, m.expanded) + } +} + +func TestExpandedEscCollapsesWithoutQuit(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyDown)) + if act := m.handleKey(key(tcell.KeyEscape)); act != actNone || m.expanded { + t.Fatalf("esc while expanded should just collapse, got act=%v", act) + } + if act := m.handleKey(key(tcell.KeyEscape)); act != actQuit { + t.Fatalf("esc while collapsed should quit, got %v", act) + } +} + +func TestMenuActions(t *testing.T) { + m := newMenuModel("cozy") + if act := m.handleKey(key(tcell.KeyEnter)); act != actStart { + t.Fatalf("enter → start, got %v", act) + } + if act := m.handleKey(rkey('s')); act != actSettings { + t.Fatalf("s → settings, got %v", act) + } + if act := m.handleKey(rkey('h')); act != actHistory { + t.Fatalf("h → history, got %v", act) + } +} + +func TestRestoresSavedTheme(t *testing.T) { + names := sortedThemeNames() + m := newMenuModel(names[len(names)-1]) + if m.idx != len(names)-1 { + t.Fatalf("saved theme not restored, idx=%d", m.idx) + } +} From 848b2563be251d403ec06af98147e24cef3df85c Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:13:41 +0900 Subject: [PATCH 4/5] feat: Wire the carousel menu and settings screen into the app --- README.md | 15 ++-- cmd/termtype/main.go | 158 +++++++++++-------------------------------- 2 files changed, 45 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 9381c78..cf5d6c3 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,8 @@ termtype The menu opens on the `cozy` theme and remembers your selections for the next launch: -- `↑`/`↓` — theme -- `Tab` — mode: Normal, or Time Attack (15s / 30s / 60s) -- `Space` — text: built-in sentences, or a stream of common English words -- `←`/`→` — language: English or Korean (한국어; the words stream is - English-only for now) -- `g` — result graph on/off +- `←`/`→` — theme; `↓` unfolds the full theme list +- `s` — settings: mode, text source, language, result graph, graph style - `h` — history browser - `Enter` — start, `Esc` — quit @@ -65,8 +61,11 @@ Attack) sits in the top-right corner on every theme. `Ctrl-P` pauses, TermType samples your WPM once a second. When a round ends, a WPM-over-time graph pops up with an accuracy/raw/cpm summary; `g` toggles back to the theme's own result screen. The `cozy` theme draws the chart -right on its result screen instead. Turn the automatic graph off from the -menu (`g` — `Graph: Off`) and it stays on the `g` key only. +right on its result screen instead. Turn the automatic graph off from +settings (`s` — `Graph: Off`) and it stays on the `g` key only. + +Pick the curve's look in settings (`s` — `Style`): a braille wave at 1–3px +thickness, or a solid box-drawing line. ## History & personal bests diff --git a/cmd/termtype/main.go b/cmd/termtype/main.go index 96b4b23..5cdafd8 100644 --- a/cmd/termtype/main.go +++ b/cmd/termtype/main.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "sort" "strings" "time" @@ -136,128 +135,52 @@ func indexOf(n int, match func(int) bool) int { return 0 } -func selectTheme(s tcell.Screen, events <-chan tcell.Event, cfg store.Config, st *store.Store) (selection, error) { - var themeNames []string - for name := range themes.Themes { - themeNames = append(themeNames, name) - } - // cozy leads (the default), log keeps second place, the rest follow - // alphabetically. - rank := func(name string) int { - switch name { - case "cozy": - return 0 - case "log": - return 1 - } - return 2 - } - sort.Slice(themeNames, func(i, j int) bool { - if ri, rj := rank(themeNames[i]), rank(themeNames[j]); ri != rj { - return ri < rj - } - return themeNames[i] < themeNames[j] - }) - - // Start from the remembered selections; zero-value config lands on 0s. - selectedIndex := indexOf(len(themeNames), func(i int) bool { return themeNames[i] == cfg.Theme }) - modeIndex := indexOf(len(gameModes), func(i int) bool { return store.ModeString(gameModes[i].limit) == cfg.Mode }) - srcIndex := indexOf(len(textSources), func(i int) bool { return textSources[i].code == cfg.Source }) - langIndex := indexOf(len(languages), func(i int) bool { return languages[i].code == cfg.Lang }) - graphOn := cfg.GraphAuto() - +// runMenu is the menu ↔ settings/history hub. It returns the round +// selection on Enter, or an error when the player quits. +func runMenu(s tcell.Screen, events <-chan tcell.Event, cfg *store.Config, st *store.Store) (selection, error) { + m := newMenuModel(cfg.Theme) for { - s.Clear() - drawText(s, 2, 1, tcell.StyleDefault.Bold(true), "Select a theme:") - - for i, name := range themeNames { - style := tcell.StyleDefault - if i == selectedIndex { - style = style.Reverse(true) - } - drawText(s, 4, 3+i, style, name) - } - - gl := ui.Glyphs() - w, _ := s.Size() - modeRow := 3 + len(themeNames) + 1 - drawText(s, 2, modeRow, tcell.StyleDefault.Foreground(tcell.ColorYellow), - "Mode: "+gameModes[modeIndex].name) - drawText(s, 2, modeRow+1, tcell.StyleDefault.Foreground(tcell.ColorGreen), - "Text: "+textSources[srcIndex].name) - // The words pool is English-only, so the language row pins to English - // while Words is selected. - langLabel := "Language: " + languages[langIndex].name - if textSources[srcIndex].code == "words" { - langLabel = "Language: English" - } - drawText(s, 2, modeRow+2, tcell.StyleDefault.Foreground(tcell.ColorTeal), langLabel) - graphLabel := "Graph: On" - if !graphOn { - graphLabel = "Graph: Off" - } - drawText(s, 2, modeRow+3, tcell.StyleDefault.Foreground(tcell.ColorPurple), graphLabel) - - // Pick the widest help line that fits the terminal. - sep := " " + gl.Sep + " " - full := strings.Join([]string{ - gl.ArrowUD + " theme", "Tab mode", "Space text", gl.ArrowLR + " language", - "g graph", "h history", gl.Enter + " start", "Esc quit", - }, sep) - compact := strings.Join([]string{gl.ArrowUD + " theme", "Tab mode", "Space text"}, " ") - help := full - if runewidth.StringWidth(help) > w-2 { - help = compact - } - if runewidth.StringWidth(help) > w-2 { - help = gl.Enter + " start" - } - drawText(s, 2, modeRow+5, tcell.StyleDefault.Foreground(tcell.ColorGray), help) - s.Show() - - ev := <-events - switch ev := ev.(type) { + drawMenu(s, m, summaryLine(*cfg)) + switch ev := (<-events).(type) { case nil: return selection{}, fmt.Errorf("screen closed") case *tcell.EventResize: s.Sync() case *tcell.EventKey: - switch ev.Key() { - case tcell.KeyEscape, tcell.KeyCtrlC: - return selection{}, fmt.Errorf("theme selection cancelled") - case tcell.KeyUp: - if selectedIndex > 0 { - selectedIndex-- - } - case tcell.KeyDown: - if selectedIndex < len(themeNames)-1 { - selectedIndex++ - } - case tcell.KeyTab: - modeIndex = (modeIndex + 1) % len(gameModes) - case tcell.KeyLeft, tcell.KeyRight: - if textSources[srcIndex].code != "words" { - langIndex = (langIndex + 1) % len(languages) - } - case tcell.KeyRune: - switch ev.Rune() { - case ' ': - srcIndex = (srcIndex + 1) % len(textSources) - case 'g', 'G': - graphOn = !graphOn - case 'h', 'H': - showHistory(s, events, st.LoadHistory()) - } - case tcell.KeyEnter: - name := themeNames[selectedIndex] - return selection{theme: themes.Themes[name], themeName: name, - limit: gameModes[modeIndex].limit, src: textSources[srcIndex], - lang: languages[langIndex], graphOn: graphOn}, nil + switch m.handleKey(ev) { + case actQuit: + return selection{}, fmt.Errorf("menu cancelled") + case actSettings: + runSettings(s, events, cfg, st) + case actHistory: + showHistory(s, events, st.LoadHistory()) + case actStart: + name := m.themes[m.idx] + sm := newSettingsModel(*cfg) + return selection{ + theme: themes.Themes[name], + themeName: name, + limit: gameModes[sm.modeIdx].limit, + src: textSources[sm.srcIdx], + lang: languages[sm.langIdx], + graphOn: cfg.GraphAuto(), + }, nil } } } } +// summaryLine is the read-only settings recap under the carousel. +func summaryLine(cfg store.Config) string { + sm := newSettingsModel(cfg) + lang := languages[sm.langIdx].name + if textSources[sm.srcIdx].code == "words" { + lang = "English" + } + sep := " " + ui.Glyphs().Sep + " " + return gameModes[sm.modeIdx].name + sep + textSources[sm.srcIdx].name + sep + lang +} + func main() { versionFlag := flag.Bool("version", false, "Print version information") vFlag := flag.Bool("v", false, "Print version information (shorthand)") @@ -307,18 +230,13 @@ func main() { cfg := st.LoadConfig() ui.SetChartOptions(chartOptionsFor(cfg.ChartStyle())) for { - sel, err := selectTheme(s, events, cfg, st) + sel, err := runMenu(s, events, &cfg, st) if err != nil { return // menu cancelled; the deferred Fini restores the terminal } - // Remember the selections for the next launch. - cfg.Theme, cfg.Mode = sel.themeName, store.ModeString(sel.limit) - cfg.Source, cfg.Lang = sel.src.code, sel.lang.code - cfg.Graph = "on" - if !sel.graphOn { - cfg.Graph = "off" - } + // Settings save on change; only the theme needs saving here. + cfg.Theme = sel.themeName st.SaveConfig(cfg) // The words source replaces the sentence pool with a generated stream: From 644452bdff0fbce288d78774b89a0805a68171bb Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:23:29 +0900 Subject: [PATCH 5/5] fix: Share one style table and polish review findings Make chartStyles the single source of truth for style codes so chartOptionsFor and newSettingsModel can no longer disagree on the fallback for an unknown code: both now fall back to braille2 instead of chartOptionsFor's braille2 vs newSettingsModel's index-0 braille1, which used to render braille2, display braille1, and silently rewrite the config to braille1 on any unrelated settings change. Also: drawSettings truncates rows/help to terminal width like drawMenu/history do; renderBraille's loop locals no longer shadow the lo/hi bounds parameters; MockScreen in typing_renderer_test.go keeps one cell map instead of two. --- cmd/termtype/main.go | 11 +------ cmd/termtype/settings.go | 48 ++++++++++++++++++++++------- cmd/termtype/settings_test.go | 28 +++++++++++++++++ internal/chart/chart.go | 8 ++--- internal/ui/typing_renderer_test.go | 20 +++++------- internal/ui/window_test.go | 6 ++-- 6 files changed, 80 insertions(+), 41 deletions(-) diff --git a/cmd/termtype/main.go b/cmd/termtype/main.go index 5cdafd8..e4c62ff 100644 --- a/cmd/termtype/main.go +++ b/cmd/termtype/main.go @@ -102,16 +102,7 @@ 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 + return chartStyles[styleIdxFor(code)].opts } // selection is everything the menu picks: the theme (and its registry name, diff --git a/cmd/termtype/settings.go b/cmd/termtype/settings.go index caab337..464bf14 100644 --- a/cmd/termtype/settings.go +++ b/cmd/termtype/settings.go @@ -4,19 +4,32 @@ import ( "fmt" "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/chart" "github.com/namest504/termtype/internal/store" "github.com/namest504/termtype/internal/ui" ) -// chartStyles are the result-graph styles the settings screen cycles -// through; codes are what config.json stores (see Config.ChartStyle). -var chartStyles = []struct{ code, label string }{ - {"braille1", "braille · 1px"}, - {"braille2", "braille · 2px"}, - {"braille3", "braille · 3px"}, - {"box", "box"}, +// chartStyles is the single source of truth for the result-graph styles: +// codes are what config.json stores (see Config.ChartStyle), and opts is +// the chart.Options each code renders with. Both the settings screen and +// chartOptionsFor derive from this table so they can never disagree on +// what an unknown/legacy code falls back to. +var chartStyles = []struct { + code, label string + opts chart.Options +}{ + {"braille1", "braille · 1px", chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 1}}, + {"braille2", "braille · 2px", chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 2}}, + {"braille3", "braille · 3px", chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 3}}, + {"box", "box", chart.Options{Style: chart.StyleBox, Interp: chart.InterpSmooth, Thickness: 1}}, } +// defaultChartStyleIdx is the table index for store.Config{}.ChartStyle() +// (currently "braille2"), used as the fallback when a code isn't found. +var defaultChartStyleIdx = indexOf(len(chartStyles), func(i int) bool { + return chartStyles[i].code == store.Config{}.ChartStyle() +}) + const settingsRows = 5 // Mode, Text, Language, Graph, Style // settingsModel is the settings screen state, kept free of drawing so key @@ -30,12 +43,24 @@ type settingsModel struct { graphOn bool } +// styleIdxFor finds a style code's index in chartStyles, falling back to +// the braille2 entry when the code is unknown (e.g. a stale/hand-edited +// config) so the settings screen shows the same style chartOptionsFor +// renders. +func styleIdxFor(code string) int { + idx := indexOf(len(chartStyles), func(i int) bool { return chartStyles[i].code == code }) + if chartStyles[idx].code != code { + return defaultChartStyleIdx + } + return idx +} + func newSettingsModel(cfg store.Config) settingsModel { return settingsModel{ modeIdx: indexOf(len(gameModes), func(i int) bool { return store.ModeString(gameModes[i].limit) == cfg.Mode }), srcIdx: indexOf(len(textSources), func(i int) bool { return textSources[i].code == cfg.Source }), langIdx: indexOf(len(languages), func(i int) bool { return languages[i].code == cfg.Lang }), - styleIdx: indexOf(len(chartStyles), func(i int) bool { return chartStyles[i].code == cfg.ChartStyle() }), + styleIdx: styleIdxFor(cfg.ChartStyle()), graphOn: cfg.GraphAuto(), } } @@ -128,8 +153,9 @@ func runSettings(s tcell.Screen, events <-chan tcell.Event, cfg *store.Config, s func drawSettings(s tcell.Screen, m settingsModel) { s.Clear() + w, _ := s.Size() gl := ui.Glyphs() - drawText(s, 2, 1, tcell.StyleDefault.Bold(true), "Settings") + drawText(s, 2, 1, tcell.StyleDefault.Bold(true), ui.Truncate("Settings", w-2)) langName := languages[m.langIdx].name langPinned := textSources[m.srcIdx].code == "words" @@ -166,9 +192,9 @@ func drawSettings(s tcell.Screen, m settingsModel) { if ui.IsASCII() { line = fmt.Sprintf("%-10s < %s >", row.name, row.value) } - drawText(s, 3, 3+i, st, line) + drawText(s, 3, 3+i, st, ui.Truncate(line, w-3)) } help := gl.ArrowUD + " select " + gl.Sep + " " + gl.ArrowLR + " change " + gl.Sep + " Esc back" - drawText(s, 2, 3+settingsRows+1, tcell.StyleDefault.Foreground(tcell.ColorGray), help) + drawText(s, 2, 3+settingsRows+1, tcell.StyleDefault.Foreground(tcell.ColorGray), ui.Truncate(help, w-2)) s.Show() } diff --git a/cmd/termtype/settings_test.go b/cmd/termtype/settings_test.go index 18cc235..49bc9c3 100644 --- a/cmd/termtype/settings_test.go +++ b/cmd/termtype/settings_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/chart" "github.com/namest504/termtype/internal/store" ) @@ -53,6 +54,33 @@ func TestSettingsFreshConfigDefaultsToBraille2(t *testing.T) { } } +// TestUnknownStyleFallsBackToBraille2Consistently guards against +// chartOptionsFor and newSettingsModel disagreeing on the fallback for an +// unknown/legacy style code: both must treat it as braille2, and an +// unrelated settings change must persist "braille2" rather than +// silently rewriting it to "braille1" (index-0 fallback). +func TestUnknownStyleFallsBackToBraille2Consistently(t *testing.T) { + const unknown = "braille4" + + o := chartOptionsFor(unknown) + want := chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 2} + if o != want { + t.Fatalf("chartOptionsFor(%q) = %+v, want %+v", unknown, o, want) + } + + m := newSettingsModel(store.Config{Style: unknown}) + if chartStyles[m.styleIdx].code != "braille2" { + t.Fatalf("newSettingsModel(%q) showed %s, want braille2", unknown, chartStyles[m.styleIdx].code) + } + + m.row = 0 // Mode: unrelated to Style + m.handleKey(key(tcell.KeyRight)) + cfg := m.apply(store.Config{Style: unknown}) + if cfg.Style != "braille2" { + t.Fatalf("unrelated change rewrote style to %q, want braille2", cfg.Style) + } +} + func TestSettingsLanguagePinnedForWords(t *testing.T) { m := newSettingsModel(store.Config{Source: "words"}) m.row = 2 // Language diff --git a/internal/chart/chart.go b/internal/chart/chart.go index a502b70..4f0c266 100644 --- a/internal/chart/chart.go +++ b/internal/chart/chart.go @@ -175,15 +175,15 @@ func renderBraille(grid [][]Cell, series []float64, cols int, o Options, lo, hi } prev := pxRows[0] for c, row := range pxRows { - lo, hi := row, row + top, bot := row, row if c > 0 { if prev < row { - lo = prev + 1 + top = prev + 1 } else if prev > row { - hi = prev - 1 + bot = prev - 1 } } - for py := lo; py <= hi; py++ { + for py := top; py <= bot; py++ { for t := 0; t < thick; t++ { set(py+t, c) } diff --git a/internal/ui/typing_renderer_test.go b/internal/ui/typing_renderer_test.go index 0bb1eb4..10022b2 100644 --- a/internal/ui/typing_renderer_test.go +++ b/internal/ui/typing_renderer_test.go @@ -16,15 +16,13 @@ type mockCell struct { // 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 + cells 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), + cells: make(map[int]map[int]mockCell), w: w, h: h, } @@ -32,19 +30,15 @@ func NewMockScreen(w, h int) *MockScreen { func (m *MockScreen) SetContent(x, y int, mainc rune, combc []rune, style tcell.Style) { if m.cells[y] == nil { - m.cells[y] = make(map[int]rune) + m.cells[y] = make(map[int]mockCell) } - 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} + m.cells[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 row, ok := m.cells[y]; ok { if c, ok := row[x]; ok { return c.r, c.style } @@ -98,8 +92,8 @@ func TestTypingRenderer_Draw_Padding(t *testing.T) { for y := 0; y < height; y++ { if row, ok := mockScreen.cells[y]; ok { for x := width - 3; x < width; x++ { - if char, exists := row[x]; exists && char != ' ' && char != 0 { - t.Errorf("Found character '%c' at (%d, %d), expected padding", char, x, y) + if c, exists := row[x]; exists && c.r != ' ' && c.r != 0 { + t.Errorf("Found character '%c' at (%d, %d), expected padding", c.r, x, y) } } } diff --git a/internal/ui/window_test.go b/internal/ui/window_test.go index 4966a5c..888356d 100644 --- a/internal/ui/window_test.go +++ b/internal/ui/window_test.go @@ -59,9 +59,9 @@ func TestDrawWindowsLongTargets(t *testing.T) { t.Fatalf("Draw returned %d rows, want the 3-line window", rows) } for y := 3; y < 12; y++ { - for x, ch := range mock.cells[y] { - if ch != ' ' && ch != 0 { - t.Fatalf("content %q at (%d,%d) below the window", ch, x, y) + for x, c := range mock.cells[y] { + if c.r != ' ' && c.r != 0 { + t.Fatalf("content %q at (%d,%d) below the window", c.r, x, y) } } }