├── .dockerignore
├── .github
├── dependabot.yml
└── workflows
│ └── release.yml
├── .gitignore
├── .goreleaser.yaml
├── Dockerfile
├── LICENSE
├── README.md
├── cmd
├── fetch.go
├── main.go
└── root.go
├── colorschemes
├── dark.yml
├── default.yml
└── monochrome.yml
├── dev-scripts
└── clean-build-all.sh
├── go.mod
├── go.sum
├── main.go
├── makefile
├── todos.md
└── ui
├── cursor.go
├── debug.go
├── endgame.go
├── game.go
├── global_timer.go
├── gradient.go
├── loading.go
├── settings.go
├── spinner.go
├── startscreen.go
├── styles.go
├── text.go
├── text_source.go
├── theme.go
├── welcome.go
└── word.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | bin/
2 | go-typer
3 | run
4 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gomod"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Go Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*"
7 |
8 | permissions:
9 | contents: write
10 | packages: write
11 | issues: write
12 |
13 | jobs:
14 | goreleaser:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v4
19 | with:
20 | fetch-depth: 0
21 |
22 | - name: Set up Go
23 | uses: actions/setup-go@v5
24 | with:
25 | go-version: "1.24.1"
26 |
27 | - name: Run GoReleaser
28 | uses: goreleaser/goreleaser-action@v5
29 | with:
30 | distribution: goreleaser
31 | version: latest
32 | args: release --clean
33 | env:
34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | bin/
2 | go-typer
3 | run
4 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | project_name: go-typer
2 |
3 | before:
4 | hooks:
5 | - go mod tidy
6 |
7 | builds:
8 | - env:
9 | - CGO_ENABLED=0
10 | goos:
11 | - linux
12 | - darwin
13 | - windows
14 | goarch:
15 | - amd64 # Renamed to x86_64 in outputs
16 | - arm64
17 | - "386"
18 | main: ./main.go
19 | binary: "go-typer-{{ .Version }}-{{ replace .Os `darwin` `macOS` }}-{{ if eq .Os `darwin` }}{{ if eq .Arch `arm64` }}apple-silicon{{ else }}intel{{ end }}{{ else }}{{ replace (replace .Arch `amd64` `x86_64`) `386` `i386` }}{{ end }}"
20 | ldflags:
21 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
22 | ignore:
23 | - goos: darwin
24 | goarch: "386"
25 |
26 | archives:
27 | - format: tar.gz
28 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ replace .Os `darwin` `macOS` }}_{{ if eq .Os `darwin` }}{{ if eq .Arch `arm64` }}apple-silicon{{ else }}intel{{ end }}{{ else }}{{ replace (replace .Arch `amd64` `x86_64`) `386` `i386` }}{{ end }}"
29 | format_overrides:
30 | - goos: windows
31 | format: zip
32 | files:
33 | - LICENSE
34 | - README.md
35 |
36 | nfpms:
37 | - vendor: "go-typer"
38 | homepage: "https://github.com/prime-run/go-typer"
39 | maintainer: "prime-run"
40 | description: "go-typer: A typing game in terminal built in go"
41 | license: "MIT"
42 | formats:
43 | - deb
44 | - rpm
45 | bindir: /usr/bin
46 |
47 | checksum:
48 | name_template: "checksums.txt"
49 |
50 | snapshot:
51 | name_template: "{{ incpatch .Version }}-next"
52 |
53 | changelog:
54 | sort: asc
55 | filters:
56 | exclude:
57 | - "^docs:"
58 | - "^test:"
59 | - "^ci:"
60 | - "^chore:"
61 |
62 | release:
63 | github:
64 | owner: prime-run
65 | name: go-typer
66 | prerelease: auto
67 | draft: false
68 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.24.1-alpine
2 |
3 | ENV COLORTERM=truecolor
4 |
5 | RUN apk add --no-cache zsh
6 |
7 | # Set the working directory
8 | WORKDIR /app
9 |
10 | COPY . .
11 |
12 | RUN go build -o go-typer .
13 |
14 | ENTRYPOINT ["./go-typer"]
15 | CMD ["start"]
16 |
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 prime-run
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🚀 Go Typer
2 |
3 | **The sleek, fast terminal typing game inspired by [MonkeyType](https://monkeytype.com/)!**
4 |
5 | Go Typer brings the popular web-based typing experience of MonkeyType to your terminal with a beautiful, customizable interface. Master your typing skills right in your terminal (where it actually matters 😉) without a browser.
6 | (online multiplayer type racer, coming soon)
7 |
8 | ## 🛠️ Built With
9 |
10 | [](https://go.dev/) [](https://github.com/spf13/cobra) [](https://github.com/charmbracelet/bubbletea) [](https://github.com/charmbracelet/lipgloss)
11 |
12 |
📷 Screenshots
13 |
14 |
15 |  |
16 |
17 |
18 |  |
19 |  |
20 |  |
21 |
22 |
23 |
24 | ## ✨ Features
25 |
26 | - **⚡ Standard-Style Gameplay**: Space bar to advance between words, just like the web favorite!
27 | - **📊 WPM & Accuracy Tracking**: Watch your stats update when you done typing
28 | - **🎮 Multiple Game Modes**: Choose between normal mode (with punctuation) or simple mode for beginners
29 | - **🎨 Gorgeous Themes**: Customize your experience with beautiful color schemes
30 | - **📏 Flexible Text Lengths**: Practice with short, medium, long, or very long passages
31 | - **⚙️ Performance Tuning**: Adjust refresh rates from 1-60 FPS for any terminals (or modify it in code for any value)
32 | - **📝 Cursor Options**: Choose your preferred cursor style (block or underline)
33 | - **💻 100% Terminal-Based**: No browser needed - perfect for developers and terminal enthusiasts.
34 |
35 | ### Demo video
36 |
37 | [](https://github.com/user-attachments/assets/644a3feb-5758-4d3e-bd0d-878abde63787)
38 |
39 | ## 🖥️ Terminal Requirements
40 |
41 | Go Typer works best in terminals that support "TrueColor" (24-bit color). It's been tested extensively on Linux but runs great on macOS and Windows too!
42 | (if you see inconsistency with colors, animations or functionality with different terminal emulators please open an issue in this repo)
43 |
44 | Verify your terminal supports TrueColor by running:
45 |
46 | ```bash
47 | printf "\x1b[38;2;255;0;0mTRUECOLOR\x1b[0m\n"
48 | ```
49 |
50 | If you see "TRUECOLOR" in red, you're good to go\! If not, check out [this compatibility guide](https://gist.github.com/weimeng23/60b51b30eb758bd7a2a648436da1e562).
51 |
52 | > [\!CAUTION]
53 | > Please avoid launching the game through `tmux` as it might cause unexpected behavior.
54 |
55 | > [\!TIP]
56 | > I recommend using terminal emulators like [`alacritty`](https://github.com/alacritty/alacritty) or [`kitty`](https://github.com/kovidgoyal/kitty), as they are GPU accelerated and generally offer better performance.
57 |
58 | ## 🚀 Installation
59 |
60 | Choose the installation method that suits you best:
61 |
62 |
63 | ⬇️ Download Binaries (Quickest Start)
64 |
65 | Download the latest pre-built binaries for your operating system from the [Releases](https://github.com/prime-run/go-typer/releases) page. Here's a simplified way to download and install (rootless):
66 |
67 | **Linux (x86_64):**
68 |
69 | ```bash
70 | wget https://github.com/prime-run/go-typer/releases/download/v1.0.2/go-typer_1.0.2_linux_x86_64.tar.gz
71 | mkdir -p ~/.local/bin
72 | tar -xzf go-typer_*.tar.gz -C ~/.local/bin go-typer
73 | ```
74 |
75 | **macOS (Intel x86_64):**
76 |
77 | ```bash
78 | wget https://github.com/prime-run/go-typer/releases/download/v1.0.2/go-typer_1.0.2_macOS_intel.tar.gz
79 | mkdir -p ~/.local/bin
80 | tar -xzf go-typer_*.tar.gz -C ~/.local/bin go-typer
81 | ```
82 |
83 | **macOS (Apple Silicon arm64):**
84 |
85 | ```bash
86 | wget https://github.com/prime-run/go-typer/releases/download/v1.0.2/go-typer_1.0.2_macOS_apple-silicon.tar.gz
87 | mkdir -p ~/.local/bin
88 | tar -xzf go-typer_*.tar.gz -C ~/.local/bin go-typer
89 | ```
90 |
91 | After downloading and extracting, ensure that `~/.local/bin` is in your system's `PATH` environment variable. You can usually do this by adding the following line to your shell's configuration file (e.g., `.bashrc`, `.zshrc`):
92 |
93 | ```bash
94 | export PATH="$HOME/.local/bin:$PATH"
95 | ```
96 |
97 | Then, reload your shell configuration:
98 |
99 | ```bash
100 | source ~/.bashrc # For Bash
101 | # or
102 | source ~/.zshrc # For Zsh
103 | ```
104 |
105 | Now you should be able to run Go Typer by simply typing `go-typer` in your terminal.
106 |
107 |
108 |
109 |
110 | ⚙️ Go Install (For Go Users)
111 |
112 | > [!NOTE]
113 | > [go](https://go.dev/doc/install) version > v1.24 is required
114 |
115 | ```bash
116 | go install github.com/prime-run/go-typer@latest
117 | ```
118 |
119 | Make sure you have Go installed and your `GOPATH/bin` or `GOBIN` is in your system's `PATH`.
120 |
121 |
122 |
123 |
124 | 🛠️ Clone and Build (From Source)
125 |
126 | ```bash
127 | git clone https://github.com/prime-run/go-typer.git
128 | cd go-typer
129 | go build -o bin/go-typer
130 | ./bin/go-typer
131 | ```
132 |
133 |
134 |
135 |
136 | 🔨 Make (Unix/Linux)
137 |
138 | ```bash
139 | git clone https://github.com/prime-run/go-typer.git
140 | cd go-typer
141 | make
142 | ./bin/go-typer
143 | ```
144 |
145 |
146 |
147 |
148 | 🐳 Docker (Container)
149 |
150 | ```bash
151 | git clone https://github.com/prime-run/go-typer.git
152 | cd go-typer
153 | docker build -t go-typer .
154 |
155 | # Run in container
156 | docker run -it --rm go-typer
157 |
158 | ```
159 |
160 |
161 |
162 | ## 🎮 How to Play
163 |
164 | 1. Launch Go Typer:
165 |
166 | ```bash
167 | go-typer
168 | ```
169 |
170 | 2. **Navigate** through the menu using the arrow keys or `j`/`k`.
171 |
172 | 3. Press **Enter** to select a menu item and start typing.
173 |
174 | 4. **Type** the text as displayed. Correctly typed characters will be highlighted.
175 |
176 | 5. Press **spacebar** to advance to the next word, just like on MonkeyType\!
177 |
178 | 6. Your **WPM**, **accuracy**, and time are tracked in real-time at the bottom of the screen.
179 |
180 | 7. Complete the passage to see your final statistics.
181 |
182 | ### 🎯 Keyboard Controls
183 |
184 | - **↑/↓ or j/k**: Navigate through menu items
185 | - **Enter**: Select menu item
186 | - **Esc**: Go back to the previous screen
187 | - **Space**: Advance to the next word while typing
188 | - **Tab**: Restart the current typing exercise
189 | - **q or Ctrl+C**: Quit the application
190 |
191 | ## ⚙️ Configuration
192 |
193 | Go Typer automatically saves your preferences in your user config directory:
194 |
195 | - Linux/BSD: `~/.config/go-typer/settings.json`
196 | - macOS: `~/Library/Application Support/go-typer/settings.json`
197 | - Windows: `%AppData%\go-typer\settings.json`
198 |
199 | 
200 |
201 | You can directly edit the `settings.json` file to customize the following options:
202 |
203 | - **theme**: Pick from eye-catching color schemes (`default`, `dark`, `monochrome`) or create your own.
204 | - **cursor_style**: Choose between `block` or `underline`.
205 | - **game_mode**: Select `normal` (with punctuation) or `simple` for beginners.
206 | - **include_numbers**: Set to `true` to include numbers in typing tests.
207 | - **text_length**: Choose from `short`, `medium`, `long`, or `very_long`.
208 | - **refresh_rate**: Fine-tune animation smoothness from `5` (battery-saving) to `60` (ultra-smooth) FPS.
209 |
210 | ## 🎨 Themes
211 |
212 | Go Typer includes beautiful themes inspired by popular coding and typing interfaces.
213 |
214 | Built-in themes:
215 |
216 | - `default`: Clean light theme with green/blue highlights
217 | - `dark`: Sleek dark theme with purple/blue accents
218 | - `monochrome`: Minimalist black and white theme for distraction-free typing
219 |
220 | ### 🖌️ Create Your Own Theme
221 |
222 | Further customize your typing experience by creating a custom theme file with a `toml` file under `colorschemes` directory and select it within the game settings. Here's the structure of a theme file:
223 |
224 | ```yaml
225 | # UI Elements
226 | help_text: "#626262" # Help text at the bottom
227 | timer: "#FFDB58" # Timer display
228 | border: "#7F9ABE" # Border color for containers
229 |
230 | # Text Display
231 | text_dim: "#555555" # Untyped text
232 | text_preview: "#7F9ABE" # Preview text color
233 | text_correct: "#00FF00" # Correctly typed text
234 | text_error: "#FF0000" # Incorrectly typed characters
235 | text_partial_error: "#FF8C00" # Correct characters in error words
236 |
237 | # Cursor
238 | cursor_fg: "#FFFFFF" # Cursor foreground color
239 | cursor_bg: "#00AAFF" # Cursor background color
240 | cursor_underline: "#00AAFF" # Underline cursor color
241 |
242 | # Miscellaneous
243 | padding: "#888888" # Padding elements color
244 | ```
245 |
246 | ## 🔄 Related Projects
247 |
248 | **togo**: A terminal-based todo manager built with the same technology stack\!
249 | [Check out togo on GitHub](https://github.com/prime-run/togo)
250 |
251 | ## 🤝 Contributing
252 |
253 | Love Go Typer? Contributions are always welcome\!
254 |
255 | - Check out our [todos](https://www.google.com/search?q=todos.md) for upcoming features and areas where you can help.
256 | - Feel free to submit pull requests for bug fixes or new features.
257 | - If you have any suggestions or find any issues, please open an issue on GitHub.
258 |
259 | ## 📜 License
260 |
261 | [MIT](https://www.google.com/search?q=LICENSE)
262 |
--------------------------------------------------------------------------------
/cmd/fetch.go:
--------------------------------------------------------------------------------
1 | // NOTE:QUTE FETCHER
2 | // .. maybe cache using a free edge run-time and store in an S3 bucket?
3 | package cmd
4 |
5 | import (
6 | "encoding/json"
7 | "fmt"
8 | "github.com/spf13/cobra"
9 | "io"
10 | "net/http"
11 | "strings"
12 | "time"
13 | )
14 |
15 | func formatText(text string) string {
16 | text = strings.TrimSpace(text)
17 |
18 | text = strings.Join(strings.Fields(text), " ")
19 |
20 | text = strings.Map(func(r rune) rune {
21 | if r < 32 || r > 126 {
22 | return -1
23 | }
24 | return r
25 | }, text)
26 |
27 | return text
28 | }
29 |
30 | func formatForGameMode(text string, mode string) string {
31 | text = formatText(text)
32 |
33 | switch mode {
34 | case "words":
35 | words := strings.Fields(text)
36 | return strings.Join(words, "\n")
37 | case "sentences":
38 | text = strings.ReplaceAll(text, ".", ".\n")
39 | text = strings.ReplaceAll(text, "!", "!\n")
40 | text = strings.ReplaceAll(text, "?", "?\n")
41 | lines := strings.Split(text, "\n")
42 | var cleanLines []string
43 | for _, line := range lines {
44 | if clean := strings.TrimSpace(line); clean != "" {
45 | cleanLines = append(cleanLines, clean)
46 | }
47 | }
48 | return strings.Join(cleanLines, "\n")
49 | default:
50 | return text
51 | }
52 | }
53 |
54 | var fetchCmd = &cobra.Command{
55 | Use: "fetch",
56 | Short: "Test text fetching from APIs",
57 | Run: func(cmd *cobra.Command, args []string) {
58 | modes := []string{"default", "words", "sentences"}
59 |
60 | fmt.Println("Trying ZenQuotes API...")
61 | zenQuotes := &TextSource{
62 | URL: "https://zenquotes.io/api/random",
63 | Parser: func(body []byte) (string, error) {
64 | var result []struct {
65 | Quote string `json:"q"`
66 | Author string `json:"a"`
67 | }
68 | if err := json.Unmarshal(body, &result); err != nil {
69 | return "", fmt.Errorf("failed to parse JSON: %w", err)
70 | }
71 | if len(result) == 0 {
72 | return "", fmt.Errorf("no quotes found in response")
73 | }
74 |
75 | quote := formatText(result[0].Quote)
76 | author := formatText(result[0].Author)
77 |
78 | formattedQuote := quote
79 | if !strings.HasSuffix(quote, ".") && !strings.HasSuffix(quote, "!") && !strings.HasSuffix(quote, "?") {
80 | formattedQuote += "."
81 | }
82 | return fmt.Sprintf("%s - %s", formattedQuote, author), nil
83 | },
84 | }
85 | if text, err := zenQuotes.FetchText(); err != nil {
86 | fmt.Printf("ZenQuotes API failed: %v\n", err)
87 | } else {
88 | fmt.Printf("\nZenQuotes API success:\n")
89 | for _, mode := range modes {
90 | formatted := formatForGameMode(text, mode)
91 | fmt.Printf("\nMode: %s\n", mode)
92 | fmt.Printf("Text:\n%s\n", formatted)
93 | fmt.Printf("Character count: %d\n", len(formatted))
94 | fmt.Printf("Line count: %d\n", len(strings.Split(formatted, "\n")))
95 | }
96 | }
97 |
98 | fmt.Println("\nTrying Bible API...")
99 | bible := &TextSource{
100 | URL: "https://bible-api.com/john+3:16",
101 | Parser: func(body []byte) (string, error) {
102 | var result struct {
103 | Text string `json:"text"`
104 | Reference string `json:"reference"`
105 | }
106 | if err := json.Unmarshal(body, &result); err != nil {
107 | return "", fmt.Errorf("failed to parse JSON: %w", err)
108 | }
109 |
110 | verse := formatText(result.Text)
111 | // WARN:don't include the reference for typing practice
112 | return verse, nil
113 | },
114 | }
115 | if text, err := bible.FetchText(); err != nil {
116 | fmt.Printf("Bible API failed: %v\n", err)
117 | } else {
118 | fmt.Printf("\nBible API success:\n")
119 | for _, mode := range modes {
120 | formatted := formatForGameMode(text, mode)
121 | fmt.Printf("\nMode: %s\n", mode)
122 | fmt.Printf("Text:\n%s\n", formatted)
123 | fmt.Printf("Character count: %d\n", len(formatted))
124 | fmt.Printf("Line count: %d\n", len(strings.Split(formatted, "\n")))
125 | }
126 | }
127 | },
128 | }
129 |
130 | type TextSource struct {
131 | URL string
132 | Parser func([]byte) (string, error)
133 | }
134 |
135 | func (s *TextSource) FetchText() (string, error) {
136 | client := &http.Client{
137 | Timeout: 10 * time.Second,
138 | }
139 |
140 | fmt.Printf("Fetching from URL: %s\n", s.URL)
141 | resp, err := client.Get(s.URL)
142 | if err != nil {
143 | return "", fmt.Errorf("failed to fetch: %w", err)
144 | }
145 | defer resp.Body.Close()
146 |
147 | if resp.StatusCode != http.StatusOK {
148 | return "", fmt.Errorf("API returned status %d", resp.StatusCode)
149 | }
150 |
151 | body, err := io.ReadAll(resp.Body)
152 | if err != nil {
153 | return "", fmt.Errorf("failed to read response: %w", err)
154 | }
155 |
156 | if s.Parser != nil {
157 | return s.Parser(body)
158 | }
159 | return string(body), nil
160 | }
161 |
162 | func init() {
163 | rootCmd.AddCommand(fetchCmd)
164 | }
165 |
--------------------------------------------------------------------------------
/cmd/main.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "github.com/prime-run/go-typer/ui"
6 | "github.com/spf13/cobra"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | )
11 |
12 | var cursorType string
13 | var themeName string
14 | var listThemes bool
15 | var debugMode bool
16 |
17 | var startCmd = &cobra.Command{
18 | Use: "start",
19 | Short: "Start a new game",
20 | Long: `Start a new game of Go Typer. This command will initialize a new game session.`,
21 | Run: func(cmd *cobra.Command, args []string) {
22 | if debugMode {
23 | ui.DebugEnabled = true
24 | ui.InitDebugLog()
25 | defer ui.CloseDebugLog()
26 |
27 | cmd.Printf("Debug mode enabled, logging to %s\n", filepath.Join(getConfigDirPath(), "debug.log"))
28 | }
29 |
30 | if listThemes {
31 | themes := ui.ListAvailableThemes()
32 | fmt.Println("Available themes:")
33 | for _, theme := range themes {
34 | fmt.Printf(" - %s\n", theme)
35 | }
36 | return
37 | }
38 |
39 | if themeName != "" {
40 | if strings.HasPrefix(themeName, "-") {
41 | cmd.Printf("Warning: Invalid theme name '%s'. Theme names cannot start with '-'.\n", themeName)
42 | cmd.Println("Using saved settings")
43 | } else if isValidThemeName(themeName) {
44 | if err := ui.LoadTheme(themeName); err != nil {
45 | cmd.Printf("Warning: Could not load theme '%s': %v\n", themeName, err)
46 | cmd.Println("Using saved settings")
47 | } else {
48 | ui.UpdateStyles()
49 | cmd.Printf("Using theme: %s\n", getDisplayThemeName(themeName))
50 |
51 | ui.CurrentSettings.ThemeName = themeName
52 | }
53 | } else {
54 | cmd.Printf("Warning: Invalid theme name '%s'. Using saved settings.\n", themeName)
55 | }
56 | }
57 |
58 | if cursorType != "" {
59 | ui.CurrentSettings.CursorType = cursorType
60 | }
61 |
62 | ui.ApplySettings()
63 |
64 | ui.StartLoadingWithOptions(ui.CurrentSettings.CursorType)
65 | },
66 | }
67 |
68 | func getConfigDirPath() string {
69 | configDir, err := ui.GetConfigDir()
70 | if err != nil {
71 | return os.TempDir()
72 | }
73 | return configDir
74 | }
75 |
76 | func isValidThemeName(name string) bool {
77 | if strings.Contains(name, ".") && !strings.HasSuffix(name, ".yml") {
78 | return false
79 | }
80 |
81 | if strings.Contains(name, "/") || strings.Contains(name, "\\") {
82 | _, err := os.Stat(name)
83 | return err == nil
84 | }
85 |
86 | for _, c := range name {
87 | if !isValidThemeNameChar(c) {
88 | return false
89 | }
90 | }
91 |
92 | return true
93 | }
94 |
95 | func isValidThemeNameChar(c rune) bool {
96 | return (c >= 'a' && c <= 'z') ||
97 | (c >= 'A' && c <= 'Z') ||
98 | (c >= '0' && c <= '9') ||
99 | c == '_' || c == '-'
100 | }
101 |
102 | func getDisplayThemeName(themeName string) string {
103 | if strings.Contains(themeName, "/") || strings.Contains(themeName, "\\") {
104 | themeName = filepath.Base(themeName)
105 | }
106 |
107 | themeName = strings.TrimSuffix(themeName, ".yml")
108 |
109 | words := strings.Split(themeName, "_")
110 | for i, word := range words {
111 | if len(word) > 0 {
112 | words[i] = strings.ToUpper(word[0:1]) + word[1:]
113 | }
114 | }
115 |
116 | return strings.Join(words, " ")
117 | }
118 |
119 | func init() {
120 | rootCmd.AddCommand(startCmd)
121 | startCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output")
122 | startCmd.Flags().StringVarP(&cursorType, "cursor", "c", "block", "Cursor type (block or underline)")
123 | startCmd.Flags().StringVarP(&themeName, "theme", "t", "", "Theme name or path to custom theme file (default: default)")
124 | startCmd.Flags().BoolVar(&listThemes, "list-themes", false, "List available themes and exit")
125 | startCmd.Flags().BoolVar(&debugMode, "debug", false, "Enable debug mode for performance analysis")
126 | }
127 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "github.com/prime-run/go-typer/ui"
5 | "github.com/spf13/cobra"
6 | "os"
7 | )
8 |
9 | var rootCmd = &cobra.Command{
10 | Use: "go-typer",
11 | Short: "A terminal-based typing game",
12 | Long: `Go Typer is a terminal-based typing game with features like:
13 | - Real-time WPM calculation
14 | - Customizable themes
15 | - Multiple cursor styles
16 | - And more!`,
17 | Run: func(cmd *cobra.Command, args []string) {
18 | ui.RunStartScreen()
19 | },
20 | }
21 |
22 | func Execute() {
23 | err := rootCmd.Execute()
24 | if err != nil {
25 | os.Exit(1)
26 | }
27 | }
28 |
29 | func init() {
30 | ui.InitSettings()
31 |
32 | rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
33 | }
34 |
--------------------------------------------------------------------------------
/colorschemes/dark.yml:
--------------------------------------------------------------------------------
1 | # GoTyper Dark Theme
2 | # Dark blue/purple theme for night coding
3 |
4 | # UI Elements
5 | help_text: "#888888" # Help text at the bottom
6 | timer: "#A177FF" # Timer display
7 | border: "#5661B3" # Border color for containers
8 |
9 | # Text Display
10 | text_dim: "#444444" # Untyped text
11 | text_preview: "#8892BF" # Preview text color
12 | text_correct: "#36D399" # Correctly typed text
13 | text_error: "#F87272" # Incorrectly typed characters
14 | text_partial_error: "#FBBD23" # Correct characters in error words
15 |
16 | # Cursor
17 | cursor_fg: "#222222" # Cursor foreground color
18 | cursor_bg: "#7B93DB" # Cursor background color
19 | cursor_underline: "#7B93DB" # Underline cursor color
20 |
21 | # Miscellaneous
22 | padding: "#666666" # Padding elements color
--------------------------------------------------------------------------------
/colorschemes/default.yml:
--------------------------------------------------------------------------------
1 | # GoTyper Default Theme
2 | # Light green/blue theme with good contrast
3 |
4 | # UI Elements
5 | help_text: "#626262" # Help text at the bottom
6 | timer: "#FFDB58" # Timer display
7 | border: "#7F9ABE" # Border color for containers
8 |
9 | # Text Display
10 | text_dim: "#555555" # Untyped text
11 | text_preview: "#7F9ABE" # Preview text color
12 | text_correct: "#00FF00" # Correctly typed text
13 | text_error: "#FF0000" # Incorrectly typed characters
14 | text_partial_error: "#FF8C00" # Correct characters in error words
15 |
16 | # Cursor
17 | cursor_fg: "#000000" # Cursor foreground color
18 | cursor_bg: "#00AAFF" # Cursor background color
19 | cursor_underline: "#00AAFF" # Underline cursor color
20 |
21 | # Miscellaneous
22 | padding: "#888888" # Padding elements color
23 |
24 |
--------------------------------------------------------------------------------
/colorschemes/monochrome.yml:
--------------------------------------------------------------------------------
1 | # GoTyper Monochrome Theme
2 | # Minimalist black and white theme with grayscale
3 |
4 | # UI Elements
5 | help_text: "#757575" # Help text at the bottom
6 | timer: "#BBBBBB" # Timer display
7 | border: "#999999" # Border color for containers
8 |
9 | # Text Display
10 | text_dim: "#555555" # Untyped text
11 | text_preview: "#AAAAAA" # Preview text color
12 | text_correct: "#FFFFFF" # Correctly typed text
13 | text_error: "#999999" # Incorrectly typed characters
14 | text_partial_error: "#DDDDDD" # Correct characters in error words
15 |
16 | # Cursor
17 | cursor_fg: "#000000" # Cursor foreground color
18 | cursor_bg: "#FFFFFF" # Cursor background color
19 | cursor_underline: "#FFFFFF" # Underline cursor color
20 |
21 | # Miscellaneous
22 | padding: "#777777" # Padding elements color
--------------------------------------------------------------------------------
/dev-scripts/clean-build-all.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | rm -rf ./bin
3 | rm -rf ./go-typer
4 | go build -o ./bin/gotyper
5 | ./bin/gotyper start
6 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/prime-run/go-typer
2 |
3 | go 1.24.1
4 |
5 | require (
6 | github.com/charmbracelet/bubbles v0.20.0
7 | github.com/charmbracelet/bubbletea v1.3.5
8 | github.com/charmbracelet/lipgloss v1.1.0
9 | github.com/spf13/cobra v1.9.1
10 | golang.org/x/text v0.24.0
11 | gopkg.in/yaml.v3 v3.0.1
12 | )
13 |
14 | require (
15 | github.com/atotto/clipboard v0.1.4 // indirect
16 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
17 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
18 | github.com/charmbracelet/harmonica v0.2.0 // indirect
19 | github.com/charmbracelet/x/ansi v0.8.0 // indirect
20 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
21 | github.com/charmbracelet/x/term v0.2.1 // indirect
22 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
23 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
24 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
25 | github.com/mattn/go-isatty v0.0.20 // indirect
26 | github.com/mattn/go-localereader v0.0.1 // indirect
27 | github.com/mattn/go-runewidth v0.0.16 // indirect
28 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
29 | github.com/muesli/cancelreader v0.2.2 // indirect
30 | github.com/muesli/termenv v0.16.0 // indirect
31 | github.com/rivo/uniseg v0.4.7 // indirect
32 | github.com/sahilm/fuzzy v0.1.1 // indirect
33 | github.com/spf13/pflag v1.0.6 // indirect
34 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
35 | golang.org/x/sync v0.13.0 // indirect
36 | golang.org/x/sys v0.32.0 // indirect
37 | )
38 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
6 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
7 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
8 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
9 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
10 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
11 | github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
12 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
13 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
14 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
15 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
16 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
17 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
18 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
19 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
20 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
21 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
22 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
23 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
24 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
25 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
26 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
27 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
28 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
29 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
30 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
31 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
32 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
33 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
34 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
35 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
36 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
37 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
38 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
39 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
40 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
41 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
42 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
43 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
44 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
45 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
46 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
47 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
48 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
49 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
50 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
51 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
52 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
53 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
54 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
55 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
56 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
57 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
58 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
59 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
60 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
61 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
62 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
63 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
64 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
65 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
66 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
67 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
68 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "github.com/prime-run/go-typer/cmd"
4 |
5 | func main() {
6 | cmd.Execute()
7 | }
8 |
--------------------------------------------------------------------------------
/makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build install clean
2 |
3 | BINARY_NAME := go-typer
4 | BUILD_DIR := ./bin
5 | BUILD_PATH := $(BUILD_DIR)/$(BINARY_NAME)
6 |
7 | build:
8 | @echo "Building $(BINARY_NAME)..."
9 | @mkdir -p $(BUILD_DIR)
10 | @go build -o $(BUILD_PATH)
11 |
12 | install: build
13 | @echo "Starting installation process..."
14 | @if [ ! -f "$(BUILD_PATH)" ]; then \
15 | echo "❌ Error: Binary not found at $(BUILD_PATH)"; \
16 | exit 1; \
17 | fi
18 | @echo "Please select your preferred installation method:"
19 | @echo "1) Local user install (~/.local/bin) - Recommended for single user"
20 | @echo "2) System-wide install (/usr/local/bin) - Requires sudo, for all users"
21 | @read -p "Enter your choice [1-2]: " install_choice; \
22 | if [[ ! "$$install_choice" =~ ^[12]$$ ]]; then \
23 | echo "❌ Invalid selection. Please choose 1 or 2."; \
24 | exit 1; \
25 | fi; \
26 | case "$$(uname -s)" in \
27 | Linux*) OS=Linux ;; \
28 | Darwin*) OS=macOS ;; \
29 | *) OS="UNKNOWN" ;; \
30 | esac; \
31 | if [[ "$$OS" == "UNKNOWN" ]]; then \
32 | echo "⚠️ Unsupported operating system detected. Proceeding with basic installation."; \
33 | fi; \
34 | case $$install_choice in \
35 | 1) \
36 | TARGET_DIR="$$HOME/.local/bin"; \
37 | mkdir -p "$$TARGET_DIR"; \
38 | if cp "$(BUILD_PATH)" "$$TARGET_DIR/"; then \
39 | echo "✅ Successfully installed to $$TARGET_DIR/$(BINARY_NAME)"; \
40 | else \
41 | echo "❌ Local installation failed. Please check permissions."; \
42 | exit 1; \
43 | fi; \
44 | ;; \
45 | 2) \
46 | TARGET_DIR="/usr/local/bin"; \
47 | echo "Installing system-wide (requires sudo privileges)..."; \
48 | if sudo cp "$(BUILD_PATH)" "$$TARGET_DIR/"; then \
49 | echo "✅ Successfully installed to $$TARGET_DIR/$(BINARY_NAME)"; \
50 | else \
51 | echo "❌ System-wide installation failed. Please check sudo permissions."; \
52 | exit 1; \
53 | fi; \
54 | ;; \
55 | esac; \
56 |
57 | # --- Clean Target ---
58 | clean:
59 | @echo "Cleaning up build artifacts..."
60 | @rm -rf $(BUILD_DIR)
61 | @echo "✅ Cleanup complete."
62 | # --------------------------
63 |
64 | .DEFAULT_GOAL := install
65 |
--------------------------------------------------------------------------------
/todos.md:
--------------------------------------------------------------------------------
1 | ## notes:
2 |
3 | - since wpm is the standard we evaluate word by word!
4 | - logical worde devider is ``
5 | - overtype (ot) is when type letters > current word letter
6 | - undertype is when word is not overtyped (assuing player is dont typing current word)
7 | - space devider edgecase : undertyped and space is pressed -> the standard is just jump to the next word and mark current one as incorrect (I personally don't like it space should count as a letter and it's easier to implement but we go for the standard!)
8 | - the placeholder should shift forward in overtype and if we assign `` as the begining of the next word while countong it as a logical devider we don't have to deal with problems caused by overtype shift in validation! another solution is to refactor validation by having and expected next word conect, and since we know where spaces are, we shift if we get letter in space place and we jump to next word if we get space in letter's place! the rest is even easier to validate!
9 | - keep the textarea hidden and focues and just deal the simple text styling !
10 |
11 | ## dev todos:
12 |
13 | - [ ] find a way live render the the paragraph as placeholder and impliment the eval function in it!
14 | - [ ] add a test for the new `--no-stdin` flag
15 | - [ ] probaly a good time to start testing for different shells and terminal emulators!
16 | - [ ] turns out it's really hard to deal with the placeholder shift in the lip textarea! best case i was getting ANSI chars in the nstdout! solution: rawdog a textarea with hanging placeholder, for now i just changed the gameplay by shifting typed words up and resetting the textarea! (can be useful for simon says gamemode)
17 |
18 | ## gameplay todos:
19 |
20 | - [ ] check out typing styles! now everyone uses vim mode, eg: cltr + backspace should delete the whole word, (and we should support visual mode, diw)
21 | - [ ] lowercase , no digit as default gamemode ?
22 |
23 | ## server todos
24 |
25 | - [ ] we can use the vercel runtime for free hosting if the server doesnt get complicated
26 | - [ ] sources for text : wikipedia, ...
27 |
--------------------------------------------------------------------------------
/ui/cursor.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | type CursorType int
4 |
5 | const (
6 | BlockCursor CursorType = iota
7 | UnderlineCursor
8 | )
9 |
10 | var DefaultCursorType CursorType = BlockCursor
11 |
12 | type Cursor struct {
13 | style CursorType
14 | }
15 |
16 | func NewCursor(style CursorType) *Cursor {
17 | return &Cursor{
18 | style: style,
19 | }
20 | }
21 |
22 | func (c *Cursor) Render(char rune) string {
23 | switch c.style {
24 | case BlockCursor:
25 | return BlockCursorStyle.Render(string(char))
26 | case UnderlineCursor:
27 | return UnderlineCursorStyle.Render(string(char))
28 | default:
29 | return BlockCursorStyle.Render(string(char))
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/ui/debug.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path/filepath"
7 | "runtime"
8 | "time"
9 | )
10 |
11 | var (
12 | DebugEnabled = false
13 | debugFile *os.File
14 | lastFlush time.Time
15 | )
16 |
17 | func InitDebugLog() {
18 | if !DebugEnabled {
19 | return
20 | }
21 |
22 | configDir, err := GetConfigDir()
23 | if err != nil {
24 | fmt.Printf("Error getting config directory: %v\n", err)
25 | return
26 | }
27 |
28 | logPath := filepath.Join(configDir, "debug.log")
29 | debugFile, err = os.Create(logPath)
30 | if err != nil {
31 | fmt.Printf("Error creating debug log file: %v\n", err)
32 | return
33 | }
34 |
35 | lastFlush = time.Now()
36 | DebugLog("Debug logging initialized")
37 |
38 | DebugLog("OS: %s, Arch: %s, NumCPU: %d, NumGoroutine: %d",
39 | runtime.GOOS, runtime.GOARCH, runtime.NumCPU(), runtime.NumGoroutine())
40 | }
41 |
42 | func DebugLog(format string, args ...interface{}) {
43 | if !DebugEnabled || debugFile == nil {
44 | return
45 | }
46 |
47 | now := time.Now()
48 | timestamp := now.Format("15:04:05.000")
49 | message := fmt.Sprintf(format, args...)
50 |
51 | numGoroutines := runtime.NumGoroutine()
52 |
53 | fmt.Fprintf(debugFile, "[%s] [G:%d] %s\n", timestamp, numGoroutines, message)
54 |
55 | if now.Sub(lastFlush) > time.Second {
56 | debugFile.Sync()
57 | lastFlush = now
58 | }
59 | }
60 |
61 | func CloseDebugLog() {
62 | if debugFile != nil {
63 | DebugLog("Closing debug log")
64 | debugFile.Close()
65 | debugFile = nil
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/ui/endgame.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | tea "github.com/charmbracelet/bubbletea"
6 | "github.com/charmbracelet/lipgloss"
7 | "strings"
8 | "time"
9 | )
10 |
11 | type EndGameModel struct {
12 | selectedItem int
13 | width int
14 | height int
15 | wpm float64
16 | accuracy float64
17 | words int
18 | correct int
19 | errors int
20 | text string
21 | startTime time.Time
22 | lastTick time.Time
23 | }
24 |
25 | func NewEndGameModel(wpm, accuracy float64, words, correct, errors int, text string) *EndGameModel {
26 | return &EndGameModel{
27 | selectedItem: 0,
28 | wpm: wpm,
29 | accuracy: accuracy,
30 | words: words,
31 | correct: correct,
32 | errors: errors,
33 | text: text,
34 | startTime: time.Now(),
35 | lastTick: time.Now(),
36 | }
37 | }
38 |
39 | func (m *EndGameModel) Init() tea.Cmd {
40 | return InitGlobalTick()
41 | }
42 |
43 | func (m *EndGameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
44 | switch msg := msg.(type) {
45 | case GlobalTickMsg:
46 | var cmd tea.Cmd
47 | m.lastTick, _, cmd = HandleGlobalTick(m.lastTick, msg)
48 | return m, cmd
49 |
50 | case tea.KeyMsg:
51 | switch msg.String() {
52 | case "ctrl+c", "q":
53 | return m, tea.Quit
54 |
55 | case "up", "k":
56 | m.selectedItem--
57 | if m.selectedItem < 0 {
58 | m.selectedItem = 1
59 | }
60 | return m, nil
61 |
62 | case "down", "j":
63 | m.selectedItem++
64 | if m.selectedItem > 1 {
65 | m.selectedItem = 0
66 | }
67 | return m, nil
68 |
69 | case "enter", " ":
70 | switch m.selectedItem {
71 | case 0:
72 | return NewTypingModel(m.width, m.height, m.text), InitGlobalTick()
73 | case 1:
74 | StartLoadingWithOptions(CurrentSettings.CursorType)
75 | return m, tea.Quit
76 | }
77 |
78 | case "esc":
79 | return m, tea.Quit
80 | }
81 |
82 | return m, nil
83 |
84 | case tea.WindowSizeMsg:
85 | m.width = msg.Width
86 | m.height = msg.Height
87 | return m, nil
88 | }
89 |
90 | return m, nil
91 | }
92 |
93 | func (m *EndGameModel) View() string {
94 |
95 | title := lipgloss.NewStyle().
96 | Bold(true).
97 | Foreground(GetColor("text_correct")).
98 | Render("Game Complete!")
99 |
100 | wpmStyle := lipgloss.NewStyle().Foreground(GetColor("timer"))
101 | accuracyStyle := lipgloss.NewStyle().Foreground(GetColor("text_correct"))
102 | wordsStyle := lipgloss.NewStyle().Foreground(GetColor("text_preview"))
103 | correctStyle := lipgloss.NewStyle().Foreground(GetColor("text_correct"))
104 | errorsStyle := lipgloss.NewStyle().Foreground(GetColor("text_error"))
105 |
106 | wpmText := RenderGradientOverlay(fmt.Sprintf("WPM: %.1f", m.wpm), wpmStyle, m.lastTick)
107 | accuracyText := RenderGradientOverlay(fmt.Sprintf("Accuracy: %.1f%%", m.accuracy), accuracyStyle, m.lastTick)
108 | wordsText := RenderGradientOverlay(fmt.Sprintf("Words: %d", m.words), wordsStyle, m.lastTick)
109 | correctText := RenderGradientOverlay(fmt.Sprintf("Correct: %d", m.correct), correctStyle, m.lastTick)
110 | errorsText := RenderGradientOverlay(fmt.Sprintf("Errors: %d", m.errors), errorsStyle, m.lastTick)
111 |
112 | stats := fmt.Sprintf("%s %s %s %s %s",
113 | wpmText, accuracyText, wordsText, correctText, errorsText)
114 |
115 | options := []string{
116 | "Play with Same Text",
117 | "Play with New Text",
118 | }
119 |
120 | var menuItems []string
121 | for i, option := range options {
122 | cursor := " "
123 | style := EndGameOptionStyle
124 | if m.selectedItem == i {
125 | cursor = ">"
126 | style = EndGameSelectedOptionStyle
127 | }
128 | menuItems = append(menuItems, style.Render(fmt.Sprintf("%s %s", cursor, option)))
129 | }
130 |
131 | menu := strings.Join(menuItems, "\n")
132 |
133 | content := lipgloss.NewStyle().
134 | Width(m.width * 3 / 4).
135 | Align(lipgloss.Center).
136 | Render(
137 | "\n" +
138 | title + "\n\n" +
139 | stats + "\n\n" +
140 | menu + "\n\n" +
141 | HelpStyle("Use arrow keys to navigate, enter to select, esc to quit"),
142 | )
143 |
144 | return lipgloss.Place(m.width, m.height,
145 | lipgloss.Center, lipgloss.Center,
146 | content)
147 | }
148 |
--------------------------------------------------------------------------------
/ui/game.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | tea "github.com/charmbracelet/bubbletea"
6 | "github.com/charmbracelet/lipgloss"
7 | "golang.org/x/text/cases"
8 | "golang.org/x/text/language"
9 | "time"
10 | )
11 |
12 | type TypingModel struct {
13 | text *Text
14 | width int
15 | height int
16 | startTime time.Time
17 | timerRunning bool
18 | cursorType CursorType
19 | lastKeyTime time.Time
20 | needsRefresh bool
21 | gameComplete bool
22 | lastTick time.Time
23 | }
24 |
25 | func NewTypingModel(width, height int, text string) *TypingModel {
26 | DebugLog("Game: Creating new typing model with text: %s", text)
27 | model := &TypingModel{
28 | width: width,
29 | height: height,
30 | timerRunning: false,
31 | cursorType: DefaultCursorType,
32 | needsRefresh: true,
33 | lastKeyTime: time.Now(),
34 | lastTick: time.Now(),
35 | }
36 | model.text = NewText(text)
37 | model.text.SetCursorType(DefaultCursorType)
38 | return model
39 | }
40 |
41 | func (m *TypingModel) Init() tea.Cmd {
42 | DebugLog("Game: Init called")
43 | return InitGlobalTick()
44 | }
45 |
46 | func (m *TypingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47 | switch msg := msg.(type) {
48 | case GlobalTickMsg:
49 |
50 | var cmd tea.Cmd
51 | m.lastTick, _, cmd = HandleGlobalTick(m.lastTick, msg)
52 |
53 | if !m.gameComplete && m.text.GetCursorPos() == len(m.text.words)-1 {
54 | lastWord := m.text.words[m.text.GetCursorPos()]
55 | if lastWord.IsComplete() {
56 | return m.handleGameCompletion()
57 | }
58 | }
59 |
60 | return m, cmd
61 |
62 | case tea.KeyMsg:
63 |
64 | if m.gameComplete {
65 | return m, nil
66 | }
67 |
68 | m.lastKeyTime = time.Now()
69 |
70 | keyStr := msg.String()
71 | DebugLog("Game: Key pressed: %s", keyStr)
72 |
73 | if !m.timerRunning && keyStr != "tab" && keyStr != "esc" && keyStr != "ctrl+c" {
74 | m.timerRunning = true
75 | m.startTime = time.Now()
76 | }
77 |
78 | switch msg.Type {
79 | case tea.KeyCtrlC, tea.KeyEsc:
80 | return m, tea.Quit
81 | case tea.KeyTab:
82 |
83 | newModel := NewTypingModel(m.width, m.height, m.text.GetText())
84 | return newModel, InitGlobalTick()
85 | case tea.KeyBackspace:
86 | m.text.Backspace()
87 | default:
88 | if len(keyStr) == 1 {
89 | m.text.Type([]rune(keyStr)[0])
90 |
91 | if m.text.GetCursorPos() == len(m.text.words)-1 {
92 | lastWord := m.text.words[m.text.GetCursorPos()]
93 | if lastWord.IsComplete() {
94 | return m.handleGameCompletion()
95 | }
96 | }
97 | }
98 | }
99 |
100 | return m, nil
101 |
102 | case tea.WindowSizeMsg:
103 | m.width = msg.Width
104 | m.height = msg.Height
105 | return m, nil
106 | }
107 |
108 | return m, nil
109 | }
110 |
111 | func (m *TypingModel) handleGameCompletion() (tea.Model, tea.Cmd) {
112 | total, correct, errors := m.text.Stats()
113 | accuracy := 0.0
114 | if total > 0 {
115 | accuracy = float64(correct) / float64(total) * 100
116 | }
117 |
118 | elapsedMinutes := m.lastTick.Sub(m.startTime).Minutes()
119 |
120 | wpm := 0.0
121 | if elapsedMinutes > 0 {
122 | wpm = float64(correct*5) / elapsedMinutes / 5
123 | }
124 |
125 | endModel := NewEndGameModel(wpm, accuracy, total, correct, errors, m.text.GetText())
126 | endModel.width = m.width
127 | endModel.height = m.height
128 | return endModel, InitGlobalTick()
129 | }
130 |
131 | func (m *TypingModel) formatElapsedTime() string {
132 | if !m.timerRunning {
133 | return "00:00"
134 | }
135 |
136 | elapsed := m.lastTick.Sub(m.startTime)
137 | minutes := int(elapsed.Minutes())
138 | seconds := int(elapsed.Seconds()) % 60
139 |
140 | return fmt.Sprintf("%02d:%02d", minutes, seconds)
141 | }
142 |
143 | func (m *TypingModel) View() string {
144 | startTime := time.Now()
145 | DebugLog("Game: View rendering started")
146 | DebugLog("Game: Current text: %s", m.text.GetText())
147 |
148 | textContent := m.text.Render()
149 | DebugLog("Game: Rendered text: %s", textContent)
150 |
151 | if m.gameComplete {
152 | textContent = lipgloss.NewStyle().
153 | Foreground(GetColor("text_correct")).
154 | Render(textContent)
155 | }
156 |
157 | cursorType := "Underline cursor"
158 | if m.cursorType == BlockCursor {
159 | cursorType = "Block cursor"
160 | }
161 |
162 | modeInfo := cases.Title(language.English).String(CurrentSettings.GameMode) + " mode"
163 | if CurrentSettings.UseNumbers {
164 | modeInfo += " with numbers"
165 | }
166 |
167 | lengthMap := map[string]string{
168 | TextLengthShort: "Short passage (1 quote)",
169 | TextLengthMedium: "Medium passage (2 quotes)",
170 | TextLengthLong: "Long passage (3 quotes)",
171 | TextLengthVeryLong: "Very Long passage (5 quotes)",
172 | }
173 |
174 | // FIX:? Render the complete view in one go
175 | //LOL Bug or feature! i really don't konow what to call it!
176 | content := lipgloss.NewStyle().
177 | Width(m.width * 3 / 4).
178 | Align(lipgloss.Center).
179 | Render(fmt.Sprintf(
180 | "\nGoTyper - Typing Practice %s\n\n%s\n\n%s\n\n%s\n%s",
181 | TimerStyle.Render(m.formatElapsedTime()),
182 | textContent,
183 | HintStyle("◾ Type the text above. results would pop when you are done typing.\n◾ Timer will start as soon as you press the first key.\n◾ Paragraph's lenght, gameplay and alot more can be adjusted in settings.\n◾ Press ESC to quit, TAB to reset current passage."),
184 | SettingsStyle("Current Settings:"),
185 | HelpStyle(fmt.Sprintf(" • %s • %s • %s", cursorType, modeInfo, lengthMap[CurrentSettings.TextLength])),
186 | ))
187 |
188 | result := lipgloss.Place(m.width, m.height,
189 | lipgloss.Center, lipgloss.Center,
190 | content)
191 |
192 | renderTime := time.Since(startTime)
193 | DebugLog("Game: View rendering completed in %s", renderTime)
194 |
195 | return result
196 | }
197 |
198 | func StartTypingGame(width, height int, text string) tea.Model {
199 | DebugLog("Game: Starting typing game with dimensions: %dx%d", width, height)
200 |
201 | startTime := time.Now()
202 | model := NewTypingModel(width, height, text)
203 | initTime := time.Since(startTime)
204 |
205 | DebugLog("Game: Model initialization completed in %s", initTime)
206 | DebugLog("Game: Using theme: %s, cursor: %s", CurrentSettings.ThemeName, CurrentSettings.CursorType)
207 |
208 | return model
209 | }
210 |
211 | func RunTypingGame() {
212 | DebugLog("Game: RunTypingGame started")
213 |
214 | DebugLog("Game: Running in terminal mode")
215 |
216 | DebugLog("Game: Running with settings - Theme: %s, Cursor: %s, Mode: %s, UseNumbers: %v",
217 | CurrentSettings.ThemeName, CurrentSettings.CursorType,
218 | CurrentSettings.GameMode, CurrentSettings.UseNumbers)
219 |
220 | StartLoadingWithOptions(CurrentSettings.CursorType)
221 | }
222 |
--------------------------------------------------------------------------------
/ui/global_timer.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | tea "github.com/charmbracelet/bubbletea"
5 | "time"
6 | )
7 |
8 | type GlobalTickMsg time.Time
9 |
10 | func GetRefreshInterval() time.Duration {
11 | fps := 10
12 | if CurrentSettings.RefreshRate > 0 {
13 | fps = CurrentSettings.RefreshRate
14 | }
15 |
16 | return time.Duration(1000/fps) * time.Millisecond
17 | }
18 |
19 | func GlobalTickCmd(interval time.Duration) tea.Cmd {
20 | return tea.Tick(interval, func(t time.Time) tea.Msg {
21 | return GlobalTickMsg(t)
22 | })
23 | }
24 |
25 | func InitGlobalTick() tea.Cmd {
26 | return GlobalTickCmd(GetRefreshInterval())
27 | }
28 |
29 | func HandleGlobalTick(lastTick time.Time, msg GlobalTickMsg) (time.Time, bool, tea.Cmd) {
30 | newTick := time.Time(msg)
31 |
32 | cmd := GlobalTickCmd(GetRefreshInterval())
33 |
34 | return newTick, true, cmd
35 | }
36 |
--------------------------------------------------------------------------------
/ui/gradient.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "github.com/charmbracelet/lipgloss"
5 | "strings"
6 | "time"
7 | )
8 |
9 | var GradientColors = []string{
10 | "#00ADD8", // go blue
11 | "#15B5DB",
12 | "#2ABEDE",
13 | "#3FC6E1",
14 | "#54CFE4",
15 | "#69D7E7",
16 | "#7EE0EA",
17 | "#93E8ED",
18 | "#A8F1F0",
19 | "#BDF9F3",
20 | "#D2FFF6",
21 | "#E7FFF9",
22 | "#FCFFFC",
23 | "#E7FFF9",
24 | "#D2FFF6",
25 | "#BDF9F3",
26 | "#A8F1F0",
27 | "#93E8ED",
28 | "#7EE0EA",
29 | "#69D7E7",
30 | "#54CFE4",
31 | "#3FC6E1",
32 | "#2ABEDE",
33 | "#15B5DB",
34 | }
35 |
36 | func GetGradientIndex(tickTime time.Time) int {
37 | return int(tickTime.UnixNano()/int64(30*time.Millisecond)) % len(GradientColors)
38 | }
39 |
40 | func RenderGradientText(text string, tickTime time.Time) string {
41 | var result strings.Builder
42 | colorIndex := GetGradientIndex(tickTime)
43 |
44 | result.Grow(len(text) * 3)
45 |
46 | for _, char := range text {
47 | style := lipgloss.NewStyle().Foreground(lipgloss.Color(GradientColors[colorIndex]))
48 | result.WriteString(style.Render(string(char)))
49 | colorIndex = (colorIndex + 1) % len(GradientColors)
50 | }
51 | return result.String()
52 | }
53 |
54 | func RenderGradientOverlay(text string, baseStyle lipgloss.Style, tickTime time.Time) string {
55 | var result strings.Builder
56 | colorIndex := GetGradientIndex(tickTime)
57 |
58 | result.Grow(len(text) * 3)
59 |
60 | for _, char := range text {
61 | gradientColor := lipgloss.Color(GradientColors[colorIndex])
62 |
63 | combinedStyle := baseStyle.Copy().
64 | Foreground(gradientColor).
65 | Bold(true)
66 |
67 | result.WriteString(combinedStyle.Render(string(char)))
68 | colorIndex = (colorIndex + 1) % len(GradientColors)
69 | }
70 |
71 | return result.String()
72 | }
73 |
--------------------------------------------------------------------------------
/ui/loading.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "github.com/charmbracelet/bubbles/progress"
6 | tea "github.com/charmbracelet/bubbletea"
7 | "github.com/charmbracelet/lipgloss"
8 | "github.com/spf13/cobra"
9 | "os"
10 | "strings"
11 | "time"
12 | )
13 |
14 | type textFetchedMsg string
15 |
16 | type LoadingModel struct {
17 | spinner *Spinner
18 | width int
19 | height int
20 | progress float64
21 | text string
22 | lastTick time.Time
23 | progressBar progress.Model
24 | }
25 |
26 | func NewLoadingModel() *LoadingModel {
27 | p := progress.New(
28 | progress.WithDefaultGradient(),
29 | progress.WithWidth(60),
30 | progress.WithoutPercentage(),
31 | )
32 |
33 | return &LoadingModel{
34 | spinner: NewSpinner(),
35 | progress: 0.0,
36 | lastTick: time.Now(),
37 | progressBar: p,
38 | }
39 | }
40 |
41 | func (m *LoadingModel) Init() tea.Cmd {
42 | return tea.Batch(
43 | InitGlobalTick(),
44 | fetchTextCmd(),
45 | )
46 | }
47 |
48 | func (m *LoadingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
49 | switch msg := msg.(type) {
50 | case GlobalTickMsg:
51 | m.spinner.Update()
52 | m.progress += 0.03
53 | if m.progress > 1.0 {
54 | m.progress = 0.1
55 | }
56 |
57 | var cmd tea.Cmd
58 | m.lastTick, _, cmd = HandleGlobalTick(m.lastTick, msg)
59 | return m, cmd
60 |
61 | case textFetchedMsg:
62 | m.text = string(msg)
63 | DebugLog("Loading: Fetched text: %s", m.text)
64 | return StartTypingGame(m.width, m.height, m.text), nil
65 |
66 | case tea.WindowSizeMsg:
67 | m.width = msg.Width
68 | m.height = msg.Height
69 | return m, nil
70 | }
71 |
72 | return m, nil
73 | }
74 |
75 | func (m *LoadingModel) View() string {
76 | spinnerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ADD8"))
77 |
78 | progressBar := m.progressBar.ViewAs(m.progress)
79 | spinnerDisplay := spinnerStyle.Render(m.spinner.View())
80 |
81 | centeredSpinner := lipgloss.NewStyle().Width(m.width * 3 / 4).Align(lipgloss.Center).Render(spinnerDisplay)
82 | centeredLoadingText := lipgloss.NewStyle().Width(m.width * 3 / 4).Align(lipgloss.Center).Render("Loading text...")
83 | centeredProgressBar := lipgloss.NewStyle().Width(m.width * 3 / 4).Align(lipgloss.Center).Render(progressBar)
84 | centeredHelp := lipgloss.NewStyle().Width(m.width * 3 / 4).Align(lipgloss.Center).Render(HelpStyle("Fetching random text from https://zenquotes.io/api/random ..."))
85 |
86 | content := "\n\n" +
87 | centeredSpinner + "\n\n" +
88 | centeredLoadingText + "\n\n" +
89 | centeredProgressBar + "\n\n" +
90 | centeredHelp
91 |
92 | return lipgloss.Place(m.width, m.height,
93 | lipgloss.Center, lipgloss.Center,
94 | content)
95 | }
96 |
97 | func fetchTextCmd() tea.Cmd {
98 | return func() tea.Msg {
99 | textCount := map[string]int{
100 | TextLengthShort: 1,
101 | TextLengthMedium: 2,
102 | TextLengthLong: 3,
103 | TextLengthVeryLong: 5,
104 | }
105 |
106 | count := textCount[CurrentSettings.TextLength]
107 |
108 | texts := make([]string, 0, count)
109 |
110 | estimatedTotalLen := count * 200
111 |
112 | for i := 0; i < count; i++ {
113 | text := GetRandomText()
114 | texts = append(texts, text)
115 | }
116 |
117 | var finalTextBuilder strings.Builder
118 | finalTextBuilder.Grow(estimatedTotalLen + count)
119 |
120 | for i, text := range texts {
121 | finalTextBuilder.WriteString(text)
122 | if i < len(texts)-1 {
123 | finalTextBuilder.WriteRune(' ')
124 | }
125 | }
126 |
127 | return textFetchedMsg(finalTextBuilder.String())
128 | }
129 | }
130 |
131 | func StartLoading(cmd *cobra.Command, args []string) {
132 | StartLoadingWithOptions("block")
133 | }
134 |
135 | func StartLoadingWithOptions(cursorTypeStr string) {
136 | selectedCursorType := BlockCursor
137 | if cursorTypeStr == "underline" {
138 | selectedCursorType = UnderlineCursor
139 | }
140 |
141 | DefaultCursorType = selectedCursorType
142 |
143 | model := NewLoadingModel()
144 | p := tea.NewProgram(model, tea.WithAltScreen())
145 | if _, err := p.Run(); err != nil {
146 | fmt.Println("Oh no!", err)
147 | os.Exit(1)
148 | }
149 | }
150 |
151 | func ReloadTheme(filePath string) error {
152 | err := LoadTheme(filePath)
153 | if err != nil {
154 | return err
155 | }
156 | UpdateStyles()
157 | return nil
158 | }
159 |
--------------------------------------------------------------------------------
/ui/settings.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "github.com/charmbracelet/bubbles/list"
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/charmbracelet/lipgloss"
9 | "os"
10 | "path/filepath"
11 | )
12 |
13 | type UserSettings struct {
14 | ThemeName string `json:"theme"`
15 | CursorType string `json:"cursor_type"`
16 | GameMode string `json:"game_mode"`
17 | UseNumbers bool `json:"use_numbers"`
18 | TextLength string `json:"text_length"`
19 | HasSeenWelcome bool `json:"has_seen_welcome"`
20 | RefreshRate int `json:"refresh_rate"` // NOTE:in frames per second not tick
21 | }
22 |
23 | const (
24 | GameModeNormal = "normal"
25 | GameModeSimple = "simple"
26 |
27 | TextLengthShort = "short" // 1
28 | TextLengthMedium = "medium" // 2
29 | TextLengthLong = "long" // 3
30 | TextLengthVeryLong = "very long" // 5
31 | )
32 |
33 | var DefaultSettings = UserSettings{
34 | ThemeName: "default",
35 | CursorType: "block",
36 | GameMode: GameModeNormal,
37 | UseNumbers: true,
38 | TextLength: TextLengthShort,
39 | HasSeenWelcome: false,
40 | RefreshRate: 10,
41 | }
42 |
43 | var CurrentSettings UserSettings
44 |
45 | func GetConfigDir() (string, error) {
46 | configDir, err := os.UserConfigDir()
47 | if err != nil {
48 | return "", fmt.Errorf("failed to get user config directory: %w", err)
49 | }
50 |
51 | appConfigDir := filepath.Join(configDir, "go-typer")
52 |
53 | if err := os.MkdirAll(appConfigDir, 0755); err != nil {
54 | return "", fmt.Errorf("failed to create config directory: %w", err)
55 | }
56 |
57 | return appConfigDir, nil
58 | }
59 |
60 | func GetSettingsFilePath() (string, error) {
61 | configDir, err := GetConfigDir()
62 | if err != nil {
63 | return "", err
64 | }
65 |
66 | return filepath.Join(configDir, "settings.json"), nil
67 | }
68 |
69 | func LoadSettings() error {
70 | CurrentSettings = DefaultSettings
71 |
72 | settingsPath, err := GetSettingsFilePath()
73 | if err != nil {
74 | return fmt.Errorf("failed to get settings file path: %w", err)
75 | }
76 |
77 | data, err := os.ReadFile(settingsPath)
78 | if err != nil {
79 | if os.IsNotExist(err) {
80 | return SaveSettings()
81 | }
82 | return fmt.Errorf("error reading settings file: %w", err)
83 | }
84 |
85 | if err := json.Unmarshal(data, &CurrentSettings); err != nil {
86 | return fmt.Errorf("error parsing settings file: %w", err)
87 | }
88 |
89 | ApplySettings()
90 |
91 | return nil
92 | }
93 |
94 | func SaveSettings() error {
95 | settingsPath, err := GetSettingsFilePath()
96 | if err != nil {
97 | return fmt.Errorf("failed to get settings file path: %w", err)
98 | }
99 |
100 | data, err := json.MarshalIndent(CurrentSettings, "", " ")
101 | if err != nil {
102 | return fmt.Errorf("error marshaling settings: %w", err)
103 | }
104 |
105 | if err := os.WriteFile(settingsPath, data, 0644); err != nil {
106 | return fmt.Errorf("error writing settings file: %w", err)
107 | }
108 |
109 | return nil
110 | }
111 |
112 | func UpdateSettings(settings UserSettings) error {
113 | if settings.ThemeName != "" {
114 | CurrentSettings.ThemeName = settings.ThemeName
115 | }
116 |
117 | if settings.CursorType != "" {
118 | CurrentSettings.CursorType = settings.CursorType
119 | }
120 |
121 | if settings.GameMode != "" {
122 | CurrentSettings.GameMode = settings.GameMode
123 | }
124 |
125 | if settings.UseNumbers != CurrentSettings.UseNumbers {
126 | CurrentSettings.UseNumbers = settings.UseNumbers
127 | }
128 |
129 | if settings.TextLength != "" {
130 | CurrentSettings.TextLength = settings.TextLength
131 | }
132 |
133 | if settings.RefreshRate > 0 {
134 | CurrentSettings.RefreshRate = settings.RefreshRate
135 | }
136 |
137 | ApplySettings()
138 |
139 | return SaveSettings()
140 | }
141 |
142 | func ApplySettings() {
143 | if CurrentSettings.ThemeName != "" {
144 | LoadTheme(CurrentSettings.ThemeName)
145 | UpdateStyles()
146 | }
147 |
148 | if CurrentSettings.CursorType == "underline" {
149 | DefaultCursorType = UnderlineCursor
150 | } else {
151 | DefaultCursorType = BlockCursor
152 | }
153 | }
154 |
155 | func InitSettings() {
156 | if err := LoadSettings(); err != nil {
157 | fmt.Printf("Warning: Could not load settings: %v\n", err)
158 | fmt.Println("Using default settings")
159 |
160 | CurrentSettings = DefaultSettings
161 | ApplySettings()
162 | }
163 | }
164 |
165 | type SettingsItem struct {
166 | title string
167 | options []string
168 | details string
169 | selected int
170 | expanded bool
171 | key string
172 | }
173 |
174 | func (i SettingsItem) Title() string {
175 | arrow := "→"
176 | if i.expanded {
177 | arrow = "↓"
178 | }
179 | return fmt.Sprintf("%s %s", arrow, i.title)
180 | }
181 |
182 | func (i SettingsItem) Description() string {
183 | return fmt.Sprintf("%s: %s", i.options[i.selected], i.details)
184 | }
185 |
186 | func (i SettingsItem) FilterValue() string { return i.title }
187 |
188 | type SettingsModel struct {
189 | list list.Model
190 | height, width int
191 | settings UserSettings
192 | }
193 |
194 | func createSettingsItems(settings UserSettings) []list.Item {
195 | themeOptions := []string{"default", "dark", "light"}
196 | themeSelected := 0
197 | for i, opt := range themeOptions {
198 | if opt == settings.ThemeName {
199 | themeSelected = i
200 | break
201 | }
202 | }
203 |
204 | cursorOptions := []string{"block", "underline"}
205 | cursorSelected := 0
206 | for i, opt := range cursorOptions {
207 | if opt == settings.CursorType {
208 | cursorSelected = i
209 | break
210 | }
211 | }
212 |
213 | gameModeOptions := []string{GameModeNormal, GameModeSimple}
214 | gameModeSelected := 0
215 | for i, opt := range gameModeOptions {
216 | if opt == settings.GameMode {
217 | gameModeSelected = i
218 | break
219 | }
220 | }
221 |
222 | textLengthOptions := []string{TextLengthShort, TextLengthMedium, TextLengthLong, TextLengthVeryLong}
223 | textLengthSelected := 0
224 | for i, opt := range textLengthOptions {
225 | if opt == settings.TextLength {
226 | textLengthSelected = i
227 | break
228 | }
229 | }
230 |
231 | refreshRateOptions := []string{"5", "10", "15", "20", "30"}
232 | refreshRateSelected := 0
233 | for i, opt := range refreshRateOptions {
234 | if opt == fmt.Sprintf("%d", settings.RefreshRate) {
235 | refreshRateSelected = i
236 | break
237 | }
238 | }
239 |
240 | return []list.Item{
241 | &SettingsItem{
242 | title: "Theme",
243 | options: themeOptions,
244 | details: "Select your preferred theme",
245 | selected: themeSelected,
246 | key: "theme",
247 | },
248 | &SettingsItem{
249 | title: "Cursor Type",
250 | options: cursorOptions,
251 | details: "Choose cursor appearance",
252 | selected: cursorSelected,
253 | key: "cursor",
254 | },
255 | &SettingsItem{
256 | title: "Game Mode",
257 | options: gameModeOptions,
258 | details: "Select game difficulty mode",
259 | selected: gameModeSelected,
260 | key: "game_mode",
261 | },
262 | &SettingsItem{
263 | title: "Text Length",
264 | options: textLengthOptions,
265 | details: "Choose text length for typing",
266 | selected: textLengthSelected,
267 | key: "text_length",
268 | },
269 | &SettingsItem{
270 | title: "Refresh Rate",
271 | options: refreshRateOptions,
272 | details: "Set UI refresh rate (FPS)",
273 | selected: refreshRateSelected,
274 | key: "refresh_rate",
275 | },
276 | }
277 | }
278 |
279 | func initialSettingsModel() SettingsModel {
280 | settings := CurrentSettings
281 | items := createSettingsItems(settings)
282 |
283 | l := list.New(items, list.NewDefaultDelegate(), 0, 0)
284 | l.SetShowHelp(false)
285 | l.SetFilteringEnabled(false)
286 | l.SetShowStatusBar(false)
287 | l.Title = "Settings"
288 | l.Styles.Title = SettingsTitleStyle
289 |
290 | return SettingsModel{
291 | list: l,
292 | settings: settings,
293 | }
294 | }
295 |
296 | func (m SettingsModel) Init() tea.Cmd { return nil }
297 |
298 | func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
299 | switch msg := msg.(type) {
300 | case tea.WindowSizeMsg:
301 | m.width, m.height = msg.Width, msg.Height
302 | m.list.SetSize(m.width/3, m.height/2)
303 |
304 | case tea.KeyMsg:
305 | switch msg.String() {
306 | case "q", "ctrl+c":
307 | return m, tea.Quit
308 | case "enter":
309 | if i, ok := m.list.SelectedItem().(*SettingsItem); ok {
310 | if !i.expanded {
311 | i.expanded = true
312 | } else {
313 | i.selected = (i.selected + 1) % len(i.options)
314 | switch i.key {
315 | case "theme":
316 | m.settings.ThemeName = i.options[i.selected]
317 | case "cursor":
318 | m.settings.CursorType = i.options[i.selected]
319 | case "game_mode":
320 | m.settings.GameMode = i.options[i.selected]
321 | case "text_length":
322 | m.settings.TextLength = i.options[i.selected]
323 | case "refresh_rate":
324 | fmt.Sscanf(i.options[i.selected], "%d", &m.settings.RefreshRate)
325 | }
326 | UpdateSettings(m.settings)
327 | }
328 | }
329 | case "esc":
330 | if i, ok := m.list.SelectedItem().(*SettingsItem); ok {
331 | i.expanded = false
332 | }
333 | }
334 | }
335 |
336 | var cmd tea.Cmd
337 | m.list, cmd = m.list.Update(msg)
338 | return m, cmd
339 | }
340 |
341 | func (m SettingsModel) View() string {
342 | if m.width == 0 {
343 | return "Loading..."
344 | }
345 |
346 | listView := SettingsListStyle.Render(m.list.View())
347 |
348 | details := "Select an item to view details"
349 | if i, ok := m.list.SelectedItem().(*SettingsItem); ok {
350 | if i.expanded {
351 | details = fmt.Sprintf("%s\n\nCurrent: %s\n\nOptions:\n",
352 | i.details,
353 | i.options[i.selected],
354 | )
355 | for idx, opt := range i.options {
356 | bullet := "•"
357 | if idx == i.selected {
358 | bullet = ">"
359 | }
360 | details += fmt.Sprintf("%s %s\n", bullet, opt)
361 | }
362 | } else {
363 | details = i.details
364 | }
365 | }
366 |
367 | detailsView := SettingsDetailsStyle.Render(details)
368 |
369 | content := lipgloss.Place(
370 | m.width,
371 | m.height-1,
372 | lipgloss.Center,
373 | lipgloss.Center,
374 | lipgloss.JoinHorizontal(lipgloss.Top, listView, detailsView),
375 | )
376 |
377 | help := lipgloss.PlaceHorizontal(
378 | m.width,
379 | lipgloss.Center,
380 | SettingsHelpStyle.Render("↑/↓: Navigate • Enter: Select • Esc: Back • q: Quit"),
381 | )
382 |
383 | return lipgloss.JoinVertical(lipgloss.Bottom, content, help)
384 | }
385 |
386 | func ShowSettings() error {
387 | p := tea.NewProgram(initialSettingsModel(), tea.WithAltScreen())
388 | if _, err := p.Run(); err != nil {
389 | return fmt.Errorf("error running settings program: %w", err)
390 | }
391 | return nil
392 | }
393 |
--------------------------------------------------------------------------------
/ui/spinner.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | type Spinner struct {
4 | frames []string
5 | index int
6 | }
7 |
8 | func NewSpinner() *Spinner {
9 | return &Spinner{
10 | frames: []string{
11 | "⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷",
12 | },
13 | index: 0,
14 | }
15 | }
16 |
17 | func (s *Spinner) Update() {
18 | s.index = (s.index + 1) % len(s.frames)
19 | }
20 |
21 | func (s *Spinner) View() string {
22 | return s.frames[s.index]
23 | }
24 |
--------------------------------------------------------------------------------
/ui/startscreen.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | tea "github.com/charmbracelet/bubbletea"
6 | "github.com/charmbracelet/lipgloss"
7 | "strings"
8 | "time"
9 | )
10 |
11 | const logoArt = `
12 | █████████ ███████ ███████████
13 | ███░░░░░███ ███░░░░░███ ░█░░░███░░░█
14 | ███ ░░░ ███ ░░███ ░ ░███ ░ █████ ████ ████████ ██████ ████████
15 | ░███ ░███ ░███ ░███ ░░███ ░███ ░░███░░███ ███░░███░░███░░███
16 | ░███ █████░███ ░███ ░███ ░███ ░███ ░███ ░███░███████ ░███ ░░░
17 | ░░███ ░░███ ░░███ ███ ░███ ░███ ░███ ░███ ░███░███░░░ ░███
18 | ░░█████████ ░░░███████░ █████ ░░███████ ░███████ ░░██████ █████
19 | ░░░░░░░░░ ░░░░░░░ ░░░░░ ░░░░░███ ░███░░░ ░░░░░░ ░░░░░
20 | ███ ░███ ░███
21 | ░░██████ █████
22 | ░░░░░░ ░░░░░
23 | `
24 |
25 | const (
26 | MenuMain int = iota
27 | MenuSettings
28 | )
29 |
30 | type menuItem struct {
31 | title string
32 | action func(*StartScreenModel) tea.Cmd
33 | disabled bool
34 | backColor string
35 | }
36 |
37 | type StartScreenModel struct {
38 | width int
39 | height int
40 | menuState int
41 | selectedItem int
42 | mainMenuItems []menuItem
43 | settingsItems []menuItem
44 | cursorType string
45 | selectedTheme string
46 | initialTheme string
47 | availableThemes []string
48 | themeChanged bool
49 | gameMode string
50 | useNumbers bool
51 | textLength string
52 | refreshRate int
53 | startTime time.Time
54 | lastTick time.Time
55 | }
56 |
57 | func NewStartScreenModel() *StartScreenModel {
58 | themes := ListAvailableThemes()
59 |
60 | model := &StartScreenModel{
61 | menuState: MenuMain,
62 | selectedItem: 0,
63 | cursorType: CurrentSettings.CursorType,
64 | selectedTheme: CurrentSettings.ThemeName,
65 | initialTheme: CurrentSettings.ThemeName,
66 | availableThemes: themes,
67 | themeChanged: false,
68 | gameMode: CurrentSettings.GameMode,
69 | useNumbers: CurrentSettings.UseNumbers,
70 | textLength: CurrentSettings.TextLength,
71 | refreshRate: CurrentSettings.RefreshRate,
72 | startTime: time.Now(),
73 | lastTick: time.Now(),
74 | }
75 |
76 | model.mainMenuItems = []menuItem{
77 | {title: "Start Typing", action: startGame},
78 | {title: "Multiplayer Typeracer", action: nil, disabled: true, backColor: "#555555"},
79 | {title: "Settings", action: openSettings},
80 | {title: "Statistics", action: openStats, disabled: true, backColor: "#555555"},
81 | {title: "Quit", action: quitGame},
82 | }
83 |
84 | model.settingsItems = []menuItem{
85 | {title: "Theme", action: cycleTheme},
86 | {title: "Cursor Style", action: cycleCursor},
87 | {title: "Game Mode", action: cycleGameMode},
88 | {title: "Use Numbers", action: toggleNumbers},
89 | {title: "Text Length", action: cycleTextLength},
90 | {title: "Refresh Rate", action: cycleRefreshRate},
91 | {title: "Back", action: saveAndGoBack},
92 | }
93 |
94 | if model.mainMenuItems[model.selectedItem].disabled {
95 | for i, item := range model.mainMenuItems {
96 | if !item.disabled {
97 | model.selectedItem = i
98 | break
99 | }
100 | }
101 | }
102 |
103 | return model
104 | }
105 |
106 | func (m *StartScreenModel) Init() tea.Cmd {
107 | return InitGlobalTick()
108 | }
109 |
110 | func (m *StartScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111 | switch msg := msg.(type) {
112 | case GlobalTickMsg:
113 | var cmd tea.Cmd
114 | m.lastTick, _, cmd = HandleGlobalTick(m.lastTick, msg)
115 | return m, cmd
116 |
117 | case tea.KeyMsg:
118 | switch msg.String() {
119 | case "ctrl+c", "q":
120 | return m, tea.Quit
121 |
122 | case "up", "k":
123 | previousItem := m.selectedItem
124 | for {
125 | m.selectedItem--
126 | if m.selectedItem < 0 {
127 | if m.menuState == MenuMain {
128 | m.selectedItem = len(m.mainMenuItems) - 1
129 | } else {
130 | m.selectedItem = len(m.settingsItems) - 1
131 | }
132 | }
133 |
134 | if m.menuState == MenuMain {
135 | if !m.mainMenuItems[m.selectedItem].disabled || m.selectedItem == previousItem {
136 | break
137 | }
138 | } else {
139 | if !m.settingsItems[m.selectedItem].disabled || m.selectedItem == previousItem {
140 | break
141 | }
142 | }
143 | }
144 |
145 | case "down", "j":
146 | previousItem := m.selectedItem
147 | for {
148 | m.selectedItem++
149 |
150 | if m.menuState == MenuMain {
151 | if m.selectedItem >= len(m.mainMenuItems) {
152 | m.selectedItem = 0
153 | }
154 |
155 | if !m.mainMenuItems[m.selectedItem].disabled || m.selectedItem == previousItem {
156 | break
157 | }
158 | } else {
159 | if m.selectedItem >= len(m.settingsItems) {
160 | m.selectedItem = 0
161 | }
162 |
163 | if !m.settingsItems[m.selectedItem].disabled || m.selectedItem == previousItem {
164 | break
165 | }
166 | }
167 | }
168 |
169 | case "enter", " ":
170 | var items []menuItem
171 | if m.menuState == MenuMain {
172 | items = m.mainMenuItems
173 | } else {
174 | items = m.settingsItems
175 | }
176 |
177 | if m.selectedItem < len(items) && !items[m.selectedItem].disabled {
178 | return m, items[m.selectedItem].action(m)
179 | }
180 |
181 | case "backspace", "-", "esc":
182 | if m.menuState != MenuMain {
183 | if m.themeChanged {
184 | LoadTheme(m.initialTheme)
185 | UpdateStyles()
186 | m.selectedTheme = m.initialTheme
187 | m.themeChanged = false
188 | }
189 |
190 | m.cursorType = CurrentSettings.CursorType
191 |
192 | m.menuState = MenuMain
193 | m.selectedItem = 0
194 |
195 | for m.mainMenuItems[m.selectedItem].disabled {
196 | m.selectedItem++
197 | if m.selectedItem >= len(m.mainMenuItems) {
198 | m.selectedItem = 0
199 | }
200 | }
201 | }
202 | }
203 |
204 | case tea.WindowSizeMsg:
205 | m.width = msg.Width
206 | m.height = msg.Height
207 | return m, nil
208 | }
209 |
210 | return m, nil
211 | }
212 |
213 | func (m *StartScreenModel) View() string {
214 | var menuContent string
215 |
216 | if m.menuState == MenuMain {
217 | menuContent = fmt.Sprintf("%s\n%s",
218 | renderAnimatedAscii(logoArt, m.lastTick),
219 | m.renderMainMenu())
220 | } else if m.menuState == MenuSettings {
221 | menuContent = m.renderSettingsMenu()
222 | }
223 |
224 | footer := "\n" + HelpStyle("↑/↓: Navigate • Enter: Select • Esc: Back • q: Quit")
225 |
226 | content := fmt.Sprintf("%s\n%s", menuContent, footer)
227 |
228 | if m.width > 0 && m.height > 0 {
229 | return lipgloss.Place(m.width, m.height,
230 | lipgloss.Center, lipgloss.Center,
231 | content)
232 | }
233 |
234 | return content
235 | }
236 |
237 | func (m *StartScreenModel) renderMainMenu() string {
238 | var sb strings.Builder
239 |
240 | titleStyle := lipgloss.NewStyle().
241 | Foreground(GetColor("timer")).
242 | Bold(true).
243 | Margin(1, 0, 2, 0)
244 |
245 | sb.WriteString(titleStyle.Render("Main Menu"))
246 | sb.WriteString("\n\n")
247 |
248 | for i, item := range m.mainMenuItems {
249 | var s lipgloss.Style
250 |
251 | if i == m.selectedItem {
252 | s = lipgloss.NewStyle().
253 | Foreground(GetColor("cursor_bg")).
254 | Bold(true).
255 | Padding(0, 4).
256 | Underline(true)
257 | } else if item.disabled {
258 | c := GetColor("text_dim")
259 | if item.backColor != "" {
260 | c = lipgloss.Color(item.backColor)
261 | }
262 | s = lipgloss.NewStyle().
263 | Foreground(c).
264 | Padding(0, 4)
265 | } else {
266 | s = lipgloss.NewStyle().
267 | Foreground(GetColor("text_preview")).
268 | Padding(0, 4)
269 | }
270 |
271 | sb.WriteString(s.Render(item.title))
272 | if item.disabled {
273 | sb.WriteString(" " + HelpStyle("(coming soon)"))
274 | }
275 | sb.WriteString("\n\n")
276 | }
277 |
278 | return sb.String()
279 | }
280 |
281 | func (m *StartScreenModel) renderSettingsMenu() string {
282 | var sb strings.Builder
283 |
284 | titleStyle := lipgloss.NewStyle().
285 | Foreground(GetColor("timer")).
286 | Bold(true).
287 | Margin(1, 0, 2, 0)
288 |
289 | sb.WriteString(titleStyle.Render("Settings"))
290 | sb.WriteString("\n\n")
291 |
292 | var exampleContent string
293 |
294 | if m.selectedItem == 0 {
295 | exampleContent = renderThemeExample(m.selectedTheme)
296 | } else if m.selectedItem == 1 {
297 | exampleContent = renderCursorExample(m.cursorType)
298 | } else if m.selectedItem == 2 {
299 | exampleContent = renderGameModeExample(m.gameMode)
300 | } else if m.selectedItem == 3 {
301 | exampleContent = renderUseNumbersExample(m.useNumbers)
302 | } else if m.selectedItem == 4 {
303 | exampleContent = renderTextLengthExample(m.textLength)
304 | } else if m.selectedItem == 5 {
305 | exampleContent = renderRefreshRateExample(m.refreshRate, m.lastTick)
306 | }
307 |
308 | var settingsList []string
309 | for i, item := range m.settingsItems {
310 | var s lipgloss.Style
311 |
312 | if i == m.selectedItem {
313 | s = lipgloss.NewStyle().
314 | Foreground(GetColor("cursor_bg")).
315 | Bold(true).
316 | Padding(0, 2).
317 | Underline(true)
318 | } else {
319 | s = lipgloss.NewStyle().
320 | Foreground(GetColor("text_preview")).
321 | Padding(0, 2)
322 | }
323 |
324 | menuText := item.title
325 |
326 | if i == 0 {
327 | menuText = fmt.Sprintf("%-15s: %s", item.title, m.selectedTheme)
328 | } else if i == 1 {
329 | menuText = fmt.Sprintf("%-15s: %s", item.title, m.cursorType)
330 | } else if i == 2 {
331 | menuText = fmt.Sprintf("%-15s: %s", item.title, m.gameMode)
332 | } else if i == 3 {
333 | menuText = fmt.Sprintf("%-15s: %v", item.title, m.useNumbers)
334 | } else if i == 4 {
335 | menuText = fmt.Sprintf("%-15s: %s", item.title, m.textLength)
336 | } else if i == 5 {
337 | menuText = fmt.Sprintf("%-15s: %d FPS", item.title, m.refreshRate)
338 | }
339 |
340 | settingsList = append(settingsList, s.Render(menuText))
341 | }
342 |
343 | var exampleBox string
344 | if m.selectedItem < len(m.settingsItems) {
345 | switch m.selectedItem {
346 | case 0:
347 | exampleBox = exampleContent
348 | case 1:
349 | exampleBox = renderCursorExample(m.cursorType)
350 | case 2:
351 | exampleBox = renderGameModeExample(m.gameMode)
352 | case 3:
353 | exampleBox = renderUseNumbersExample(m.useNumbers)
354 | case 4:
355 | exampleBox = renderTextLengthExample(m.textLength)
356 | case 5:
357 | exampleBox = renderRefreshRateExample(m.refreshRate, m.lastTick)
358 | }
359 | }
360 |
361 | leftWidth := 30
362 | rightWidth := m.width - leftWidth - 4
363 |
364 | exampleStyle := lipgloss.NewStyle().
365 | Padding(1, 2).
366 | Width(rightWidth)
367 |
368 | // create columns
369 | leftColumn := lipgloss.NewStyle().
370 | Width(leftWidth).
371 | Render(lipgloss.JoinVertical(lipgloss.Left, settingsList...))
372 |
373 | rightColumn := exampleStyle.Render(exampleBox)
374 |
375 | // join them
376 | content := lipgloss.JoinHorizontal(
377 | lipgloss.Top,
378 | leftColumn,
379 | " ",
380 | rightColumn,
381 | )
382 |
383 | sb.WriteString(content)
384 | sb.WriteString("\n\n")
385 |
386 | return sb.String()
387 | }
388 |
389 | func renderThemeExample(theme string) string {
390 | var sb strings.Builder
391 |
392 | colorOrder := []string{
393 | "Help Text",
394 | "Timer",
395 | "Border",
396 | "Text Dim",
397 | "Text Preview",
398 | "Text Correct",
399 | "Text Error",
400 | "Text Partial",
401 | "Cursor FG",
402 | "Cursor BG",
403 | "Cursor Under",
404 | "Padding",
405 | }
406 |
407 | for _, name := range colorOrder {
408 | color := GetColor(strings.ToLower(strings.ReplaceAll(name, " ", "_")))
409 | style := lipgloss.NewStyle().
410 | Foreground(color).
411 | Padding(0, 1)
412 |
413 | colorBox := lipgloss.NewStyle().
414 | Background(color).
415 | Padding(0, 2).
416 | Render(" ")
417 |
418 | sb.WriteString(fmt.Sprintf("%-15s %s %s\n", name, colorBox, style.Render(string(color))))
419 | }
420 |
421 | return sb.String()
422 | }
423 |
424 | func renderCursorExample(cursorType string) string {
425 | var example strings.Builder
426 |
427 | titleStyle := lipgloss.NewStyle().Foreground(GetColor("timer")).Bold(true)
428 | example.WriteString(titleStyle.Render("Cursor Style: "))
429 |
430 | cursorTypeStyle := lipgloss.NewStyle().Foreground(GetColor("text_preview"))
431 | example.WriteString(cursorTypeStyle.Render(cursorType))
432 | example.WriteString("\n\n")
433 |
434 | example.WriteString(titleStyle.Render("Example Text:\n"))
435 |
436 | dimStyle := lipgloss.NewStyle().Foreground(GetColor("text_dim"))
437 | example.WriteString(dimStyle.Render("quick "))
438 |
439 | example.WriteString(dimStyle.Render("b"))
440 | if cursorType == "block" {
441 | cursorStyle := lipgloss.NewStyle().
442 | Foreground(GetColor("cursor_fg")).
443 | Background(GetColor("cursor_bg"))
444 | example.WriteString(cursorStyle.Render("r"))
445 | } else {
446 | cursorStyle := lipgloss.NewStyle().
447 | Foreground(GetColor("cursor_underline")).
448 | Underline(true)
449 | example.WriteString(cursorStyle.Render("r"))
450 | }
451 | example.WriteString(dimStyle.Render("own"))
452 |
453 | return example.String()
454 | }
455 |
456 | func renderGameModeExample(gameMode string) string {
457 | var example strings.Builder
458 |
459 | titleStyle := lipgloss.NewStyle().Foreground(GetColor("timer")).Bold(true)
460 | example.WriteString(titleStyle.Render("Game Mode: "))
461 |
462 | modeStyle := lipgloss.NewStyle().Foreground(GetColor("text_preview"))
463 | if gameMode == "normal" {
464 | example.WriteString(modeStyle.Render("Normal (With Punctuation)"))
465 | } else {
466 | example.WriteString(modeStyle.Render("Simple (No Punctuation)"))
467 | }
468 | example.WriteString("\n\n")
469 |
470 | example.WriteString(titleStyle.Render("Example:\n"))
471 |
472 | if gameMode == "normal" {
473 | example.WriteString(TextToTypeStyle.Render("The quick brown fox jumps."))
474 | } else {
475 | example.WriteString(TextToTypeStyle.Render("the quick brown fox jumps"))
476 | }
477 |
478 | return example.String()
479 | }
480 |
481 | func renderUseNumbersExample(useNumbers bool) string {
482 | var example strings.Builder
483 |
484 | titleStyle := lipgloss.NewStyle().Foreground(GetColor("timer")).Bold(true)
485 | example.WriteString(titleStyle.Render("Use Numbers: "))
486 |
487 | valueStyle := lipgloss.NewStyle().Foreground(GetColor("text_preview"))
488 | if useNumbers {
489 | example.WriteString(valueStyle.Render("Yes"))
490 | } else {
491 | example.WriteString(valueStyle.Render("No"))
492 | }
493 | example.WriteString("\n\n")
494 |
495 | example.WriteString(titleStyle.Render("Example:\n"))
496 |
497 | if useNumbers {
498 | example.WriteString(TextToTypeStyle.Render("quick brown fox jumps over 5 lazy dogs"))
499 | } else {
500 | example.WriteString(TextToTypeStyle.Render("quick brown fox jumps over lazy dogs"))
501 | }
502 |
503 | return example.String()
504 | }
505 |
506 | func renderTextLengthExample(length string) string {
507 | var example strings.Builder
508 |
509 | titleStyle := lipgloss.NewStyle().Foreground(GetColor("timer")).Bold(true)
510 | example.WriteString(titleStyle.Render("Text Length: "))
511 |
512 | valueStyle := lipgloss.NewStyle().Foreground(GetColor("text_preview"))
513 | example.WriteString(valueStyle.Render(length))
514 | example.WriteString("\n\n")
515 |
516 | example.WriteString(titleStyle.Render("Quotes to fetch:\n"))
517 |
518 | textCount := map[string]int{
519 | TextLengthShort: 1,
520 | TextLengthMedium: 2,
521 | TextLengthLong: 3,
522 | TextLengthVeryLong: 5,
523 | }
524 |
525 | count := textCount[length]
526 | example.WriteString(fmt.Sprintf("\nWill fetch and combine %d quote(s)", count))
527 | example.WriteString("\nEstimated word count: ")
528 |
529 | wordCount := count * 30
530 | example.WriteString(valueStyle.Render(fmt.Sprintf("%d words", wordCount)))
531 |
532 | return example.String()
533 | }
534 |
535 | func renderRefreshRateExample(rate int, tickTime time.Time) string {
536 | var sb strings.Builder
537 |
538 | titleStyle := lipgloss.NewStyle().
539 | Foreground(GetColor("timer")).
540 | Bold(true)
541 |
542 | sb.WriteString(titleStyle.Render("Refresh Rate: "))
543 |
544 | valueStyle := lipgloss.NewStyle().
545 | Foreground(GetColor("text_preview"))
546 | sb.WriteString(valueStyle.Render(fmt.Sprintf("%d FPS", rate)))
547 | sb.WriteString("\n\n")
548 |
549 | descStyle := lipgloss.NewStyle().
550 | Foreground(GetColor("text_dim"))
551 |
552 | sb.WriteString(descStyle.Render(
553 | fmt.Sprintf("Updates %d times per second (%.1f ms per frame)",
554 | rate, 1000.0/float64(rate))))
555 | sb.WriteString("\n\n")
556 |
557 | helpStyle := lipgloss.NewStyle().
558 | Foreground(GetColor("help_text"))
559 |
560 | sb.WriteString(helpStyle.Render(
561 | "Higher values give smoother animations\n" +
562 | "Lower values use less CPU/battery"))
563 | sb.WriteString("\n\n")
564 |
565 | var spinner string
566 | frames := []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}
567 | index := int(tickTime.UnixNano()/int64(time.Second/time.Duration(rate))) % len(frames)
568 | spinner = frames[index]
569 |
570 | spinnerStyle := lipgloss.NewStyle().
571 | Foreground(GetColor("text_correct"))
572 |
573 | sb.WriteString(spinnerStyle.Render(spinner + " "))
574 | sb.WriteString(valueStyle.Render(fmt.Sprintf("Animation at %d FPS", rate)))
575 |
576 | return sb.String()
577 | }
578 |
579 | func renderAnimatedAscii(logoArt string, tickTime time.Time) string {
580 | var result strings.Builder
581 | colors := []string{
582 | "#87CEEB", // Sky blue
583 | "#4682B4", // Steel blue
584 | "#1E90FF", // Dodger blue
585 | "#0000CD", // Medium blue
586 | "#000080", // Navy blue
587 | }
588 |
589 | startIndex := int(tickTime.UnixNano()/int64(100*time.Millisecond)) % len(colors)
590 |
591 | lines := strings.Split(logoArt, "\n")
592 | for i, line := range lines {
593 | if line == "" {
594 | result.WriteString("\n")
595 | continue
596 | }
597 | colorIndex := (startIndex + i) % len(colors)
598 | style := lipgloss.NewStyle().Foreground(lipgloss.Color(colors[colorIndex]))
599 | result.WriteString(style.Render(line))
600 | result.WriteString("\n")
601 | }
602 |
603 | return result.String()
604 | }
605 |
606 | func startGame(m *StartScreenModel) tea.Cmd {
607 | return tea.Quit
608 | }
609 |
610 | func openSettings(m *StartScreenModel) tea.Cmd {
611 | m.initialTheme = m.selectedTheme
612 | m.themeChanged = false
613 |
614 | m.menuState = MenuSettings
615 | m.selectedItem = 0
616 | return nil
617 | }
618 |
619 | func openStats(m *StartScreenModel) tea.Cmd {
620 | return nil
621 | }
622 |
623 | func quitGame(m *StartScreenModel) tea.Cmd {
624 | return tea.Quit
625 | }
626 |
627 | func saveAndGoBack(m *StartScreenModel) tea.Cmd {
628 | settings := UserSettings{
629 | ThemeName: m.selectedTheme,
630 | CursorType: m.cursorType,
631 | GameMode: m.gameMode,
632 | UseNumbers: m.useNumbers,
633 | TextLength: m.textLength,
634 | RefreshRate: m.refreshRate,
635 | HasSeenWelcome: CurrentSettings.HasSeenWelcome,
636 | }
637 |
638 | if err := UpdateSettings(settings); err != nil {
639 | DebugLog("Settings: Error updating settings: %v", err)
640 | }
641 |
642 | m.menuState = MenuMain
643 | m.selectedItem = 0
644 |
645 | for m.mainMenuItems[m.selectedItem].disabled {
646 | m.selectedItem++
647 | if m.selectedItem >= len(m.mainMenuItems) {
648 | m.selectedItem = 0
649 | }
650 | }
651 |
652 | return nil
653 | }
654 |
655 | func cycleTheme(m *StartScreenModel) tea.Cmd {
656 | currentIndex := -1
657 | for i, theme := range m.availableThemes {
658 | if theme == m.selectedTheme {
659 | currentIndex = i
660 | break
661 | }
662 | }
663 |
664 | currentIndex = (currentIndex + 1) % len(m.availableThemes)
665 | m.selectedTheme = m.availableThemes[currentIndex]
666 | m.themeChanged = true
667 |
668 | LoadTheme(m.selectedTheme)
669 | UpdateStyles()
670 |
671 | return nil
672 | }
673 |
674 | func cycleCursor(m *StartScreenModel) tea.Cmd {
675 | if m.cursorType == "block" {
676 | m.cursorType = "underline"
677 | } else {
678 | m.cursorType = "block"
679 | }
680 |
681 | return nil
682 | }
683 |
684 | func cycleGameMode(m *StartScreenModel) tea.Cmd {
685 | if m.gameMode == "normal" {
686 | m.gameMode = "simple"
687 | } else {
688 | m.gameMode = "normal"
689 | }
690 |
691 | return nil
692 | }
693 |
694 | func toggleNumbers(m *StartScreenModel) tea.Cmd {
695 | m.useNumbers = !m.useNumbers
696 | return nil
697 | }
698 |
699 | func cycleTextLength(m *StartScreenModel) tea.Cmd {
700 | lengths := []string{TextLengthShort, TextLengthMedium, TextLengthLong, TextLengthVeryLong}
701 | var currentIndex int
702 |
703 | for i, length := range lengths {
704 | if length == m.textLength {
705 | currentIndex = i
706 | break
707 | }
708 | }
709 |
710 | currentIndex = (currentIndex + 1) % len(lengths)
711 | m.textLength = lengths[currentIndex]
712 |
713 | return nil
714 | }
715 |
716 | func cycleRefreshRate(m *StartScreenModel) tea.Cmd {
717 | rates := []int{1, 5, 10, 15, 30, 60}
718 |
719 | currentIndex := -1
720 | for i, r := range rates {
721 | if r == m.refreshRate {
722 | currentIndex = i
723 | break
724 | }
725 | }
726 |
727 | currentIndex = (currentIndex + 1) % len(rates)
728 | m.refreshRate = rates[currentIndex]
729 |
730 | return nil
731 | }
732 |
733 | type StartGameMsg struct {
734 | cursorType string
735 | theme string
736 | }
737 |
738 | func RunStartScreen() {
739 | ShowWelcomeScreen()
740 |
741 | p := tea.NewProgram(NewStartScreenModel(), tea.WithAltScreen())
742 |
743 | model, err := p.Run()
744 | if err != nil {
745 | fmt.Printf("Error running start screen: %v\n", err)
746 | return
747 | }
748 |
749 | if m, ok := model.(*StartScreenModel); ok {
750 | UpdateSettings(UserSettings{
751 | ThemeName: m.selectedTheme,
752 | CursorType: m.cursorType,
753 | GameMode: m.gameMode,
754 | UseNumbers: m.useNumbers,
755 | TextLength: m.textLength,
756 | RefreshRate: m.refreshRate,
757 | HasSeenWelcome: CurrentSettings.HasSeenWelcome,
758 | })
759 |
760 | if m.menuState == MenuMain && m.selectedItem < len(m.mainMenuItems) {
761 | item := m.mainMenuItems[m.selectedItem]
762 |
763 | if item.title == "Start Typing" {
764 | StartLoadingWithOptions(m.cursorType)
765 | }
766 | }
767 | }
768 | }
769 |
--------------------------------------------------------------------------------
/ui/styles.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "github.com/charmbracelet/lipgloss"
5 | )
6 |
7 | const (
8 | Padding = 2
9 | MaxWidth = 80
10 | )
11 |
12 | // WARN:switched to true color might comeback to bite later in testing for other termnal emulators!
13 |
14 | // TODO:a theming system would be nice [x]
15 |
16 | func UpdateStyles() {
17 |
18 | helpStyle := lipgloss.NewStyle().Foreground(GetColor("help_text"))
19 | HelpStyle = helpStyle.Render
20 |
21 | hintStyle := lipgloss.NewStyle().Foreground(GetColor("text_preview")).Italic(true)
22 | HintStyle = hintStyle.Render
23 |
24 | settingsStyle := lipgloss.NewStyle().Foreground(GetColor("timer")).Bold(true)
25 | SettingsStyle = settingsStyle.Render
26 |
27 | TextToTypeStyle = lipgloss.NewStyle().Foreground(GetColor("text_preview")).Padding(1).Width(MaxWidth)
28 | InputStyle = lipgloss.NewStyle().Foreground(GetColor("text_correct"))
29 | ErrorStyle = lipgloss.NewStyle().Foreground(GetColor("text_error"))
30 | PartialErrorStyle = lipgloss.NewStyle().Foreground(GetColor("text_partial_error"))
31 | DimStyle = lipgloss.NewStyle().Foreground(GetColor("text_dim"))
32 |
33 | CenterStyle = lipgloss.NewStyle().Align(lipgloss.Center)
34 | PadStyle = lipgloss.NewStyle().Foreground(GetColor("padding"))
35 |
36 | TimerStyle = lipgloss.NewStyle().
37 | Foreground(GetColor("timer")).
38 | Bold(true).
39 | Padding(0, 1)
40 |
41 | PreviewStyle = lipgloss.NewStyle().
42 | Padding(1).
43 | Margin(8, 0, 0, 0).
44 | Border(lipgloss.RoundedBorder()).
45 | BorderForeground(GetColor("border")).
46 | Width(MaxWidth)
47 |
48 | TextContainerStyle = lipgloss.NewStyle().
49 | Padding(1).
50 | Width(MaxWidth)
51 |
52 | BlockCursorStyle = lipgloss.NewStyle().
53 | Foreground(GetColor("cursor_fg")).
54 | Background(GetColor("cursor_bg"))
55 |
56 | UnderlineCursorStyle = lipgloss.NewStyle().
57 | Foreground(GetColor("cursor_underline")).
58 | Underline(true)
59 |
60 | SettingsListStyle = lipgloss.NewStyle().
61 | Width(MaxWidth/3 - 4).
62 | MarginLeft(2).
63 | MarginRight(2)
64 |
65 | SettingsDetailsStyle = lipgloss.NewStyle().
66 | Width(MaxWidth / 2).
67 | MarginLeft(2)
68 |
69 | SettingsTitleStyle = lipgloss.NewStyle().
70 | Background(lipgloss.Color("62")).
71 | Foreground(lipgloss.Color("0")).
72 | Padding(0, 1)
73 |
74 | SettingsHelpStyle = lipgloss.NewStyle().
75 | Foreground(lipgloss.Color("241"))
76 |
77 | EndGameTitleStyle = lipgloss.NewStyle().
78 | Foreground(GetColor("text_correct")).
79 | Bold(true).
80 | Border(lipgloss.RoundedBorder()).
81 | BorderForeground(GetColor("border")).
82 | Padding(0, 2)
83 |
84 | EndGameStatsBoxStyle = lipgloss.NewStyle().
85 | Border(lipgloss.RoundedBorder()).
86 | BorderForeground(GetColor("border")).
87 | Padding(1, 2)
88 |
89 | EndGameWpmStyle = lipgloss.NewStyle().
90 | Foreground(GetColor("timer")).
91 | Bold(true).
92 | Underline(true)
93 |
94 | EndGameAccuracyStyle = lipgloss.NewStyle().
95 | Foreground(GetColor("text_correct"))
96 |
97 | EndGameWordsStyle = lipgloss.NewStyle().
98 | Foreground(GetColor("text_preview"))
99 |
100 | EndGameCorrectStyle = lipgloss.NewStyle().
101 | Foreground(GetColor("text_correct"))
102 |
103 | EndGameErrorsStyle = lipgloss.NewStyle().
104 | Foreground(GetColor("text_error"))
105 |
106 | EndGameOptionStyle = lipgloss.NewStyle().
107 | Foreground(GetColor("text_preview"))
108 |
109 | EndGameSelectedOptionStyle = lipgloss.NewStyle().
110 | Foreground(GetColor("text_correct")).
111 | Bold(true)
112 | }
113 |
114 | var HelpStyle func(...string) string
115 | var HintStyle func(...string) string
116 | var SettingsStyle func(...string) string
117 | var TextToTypeStyle lipgloss.Style
118 | var InputStyle lipgloss.Style
119 | var ErrorStyle lipgloss.Style
120 | var PartialErrorStyle lipgloss.Style
121 | var CenterStyle lipgloss.Style
122 | var PadStyle lipgloss.Style
123 | var TimerStyle lipgloss.Style
124 | var PreviewStyle lipgloss.Style
125 | var DimStyle lipgloss.Style
126 | var TextContainerStyle lipgloss.Style
127 | var BlockCursorStyle lipgloss.Style
128 | var UnderlineCursorStyle lipgloss.Style
129 | var SettingsListStyle lipgloss.Style
130 | var SettingsDetailsStyle lipgloss.Style
131 | var SettingsTitleStyle lipgloss.Style
132 | var SettingsHelpStyle lipgloss.Style
133 | var EndGameTitleStyle lipgloss.Style
134 | var EndGameStatsBoxStyle lipgloss.Style
135 | var EndGameWpmStyle lipgloss.Style
136 | var EndGameAccuracyStyle lipgloss.Style
137 | var EndGameWordsStyle lipgloss.Style
138 | var EndGameCorrectStyle lipgloss.Style
139 | var EndGameErrorsStyle lipgloss.Style
140 | var EndGameOptionStyle lipgloss.Style
141 | var EndGameSelectedOptionStyle lipgloss.Style
142 |
143 | const (
144 | SampleTextNormal = "The quick brown fox jumps over the lazy dog. Programming is the process of creating a set of instructions that tell a computer how to perform a task. Programming can be done using a variety of computer programming languages, such as JavaScript, Python, and C++."
145 |
146 | SampleTextNormalWithNumbers = "The quick brown fox jumps over the 5 lazy dogs. In 2023, "
147 |
148 | SampleTextSimple = "the quick brown fox jumps over the lazy dog programming is the process of creating a set of instructions that tell a computer how to perform a task programming can be done using a variety of computer programming languages such as javascript python and c plus plus"
149 |
150 | SampleTextSimpleWithNumbers = "the quick brown fox jumps over 5 lazy dogs in 2023 programming is the process of creating a set of instructions that tell a computer how to perform a task programming can be done using a variety of computer programming languages such as javascript python and c plus plus with over 300 languages in existence"
151 | )
152 |
153 | func init() {
154 |
155 | InitTheme()
156 |
157 | UpdateStyles()
158 | }
159 | func GetSampleText() string {
160 | if CurrentSettings.GameMode == GameModeSimple {
161 | if CurrentSettings.UseNumbers {
162 | return SampleTextSimpleWithNumbers
163 | }
164 | return SampleTextSimple
165 | } else {
166 | if CurrentSettings.UseNumbers {
167 | return SampleTextNormalWithNumbers
168 | }
169 | return SampleTextNormal
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/ui/text.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "strings"
5 | "time"
6 | )
7 |
8 | type Text struct {
9 | words []*Word
10 | cursorPos int
11 | showCursor bool
12 | cursorType CursorType
13 | sourceText string
14 | }
15 |
16 | func NewText(text string) *Text {
17 |
18 | estimatedWordCount := len(text)/6 + 1
19 | words := make([]*Word, 0, estimatedWordCount)
20 | var currentWord []rune
21 |
22 | for _, r := range text {
23 | if r == ' ' {
24 | if len(currentWord) > 0 {
25 | words = append(words, NewWord(currentWord))
26 | currentWord = make([]rune, 0, 8)
27 | }
28 | words = append(words, NewWord([]rune{' '}))
29 | } else {
30 | currentWord = append(currentWord, r)
31 | }
32 | }
33 |
34 | if len(currentWord) > 0 {
35 | words = append(words, NewWord(currentWord))
36 | }
37 |
38 | t := &Text{
39 | words: words,
40 | cursorPos: 0,
41 | showCursor: true,
42 | cursorType: UnderlineCursor,
43 | sourceText: text,
44 | }
45 |
46 | if len(t.words) > 0 {
47 | t.words[0].SetActive(true)
48 | }
49 |
50 | return t
51 | }
52 |
53 | func (t *Text) CurrentWord() *Word {
54 | if t.cursorPos >= len(t.words) {
55 | return nil
56 | }
57 | return t.words[t.cursorPos]
58 | }
59 |
60 | func (t *Text) Type(r rune) {
61 | if t.cursorPos >= len(t.words) {
62 | return
63 | }
64 |
65 | currentWord := t.words[t.cursorPos]
66 |
67 | if currentWord.IsSpace() {
68 | if r == ' ' {
69 | currentWord.Type(r)
70 | if t.cursorPos < len(t.words)-1 {
71 | currentWord.SetActive(false)
72 | t.cursorPos++
73 | t.words[t.cursorPos].SetActive(true)
74 | }
75 | } else {
76 | currentWord.Type(r)
77 | }
78 | return
79 | }
80 |
81 | if r == ' ' {
82 | if !currentWord.HasStarted() {
83 | return
84 | }
85 |
86 | if !currentWord.IsComplete() {
87 | currentWord.Skip()
88 | }
89 |
90 | if t.cursorPos < len(t.words)-2 {
91 | currentWord.SetActive(false)
92 | t.cursorPos += 2
93 | t.words[t.cursorPos].SetActive(true)
94 | } else if t.cursorPos < len(t.words)-1 {
95 | currentWord.SetActive(false)
96 | t.cursorPos++
97 | t.words[t.cursorPos].SetActive(true)
98 | } else {
99 | currentWord.SetActive(false)
100 | if !currentWord.IsComplete() {
101 | currentWord.Skip()
102 | }
103 | }
104 | } else {
105 | currentWord.Type(r)
106 |
107 | if currentWord.IsComplete() {
108 | if t.cursorPos < len(t.words)-1 {
109 | nextWord := t.words[t.cursorPos+1]
110 | currentWord.SetActive(false)
111 | t.cursorPos++
112 | nextWord.SetActive(true)
113 | } else {
114 | currentWord.SetActive(false)
115 | if !currentWord.IsComplete() {
116 | currentWord.Skip()
117 | }
118 | }
119 | }
120 | }
121 | }
122 |
123 | func (t *Text) Backspace() {
124 | if t.cursorPos >= len(t.words) {
125 | return
126 | }
127 |
128 | currentWord := t.words[t.cursorPos]
129 | if !currentWord.Backspace() && t.cursorPos > 0 {
130 | currentWord.SetActive(false)
131 | t.cursorPos--
132 | currentWord = t.words[t.cursorPos]
133 | currentWord.SetActive(true)
134 |
135 | if currentWord.IsSpace() {
136 | currentWord.Backspace()
137 | if t.cursorPos > 0 {
138 | currentWord.SetActive(false)
139 | t.cursorPos--
140 | t.words[t.cursorPos].SetActive(true)
141 | }
142 | }
143 | }
144 | }
145 |
146 | func (t *Text) Render() string {
147 | startTime := time.Now()
148 | DebugLog("Text: Render started")
149 |
150 | estimatedSize := 0
151 | for _, word := range t.words {
152 | estimatedSize += len(word.target) * 3
153 | }
154 |
155 | var result strings.Builder
156 | result.Grow(estimatedSize)
157 |
158 | showCursor := t.showCursor
159 | if t.cursorType == UnderlineCursor {
160 | showCursor = true
161 | }
162 |
163 | for _, word := range t.words {
164 | result.WriteString(word.Render(showCursor))
165 | }
166 |
167 | rendered := TextContainerStyle.Render(result.String())
168 |
169 | renderTime := time.Since(startTime)
170 | DebugLog("Text: Render completed in %s, length: %d", renderTime, len(rendered))
171 |
172 | return rendered
173 | }
174 |
175 | func (t *Text) Update() {
176 | t.showCursor = true
177 | }
178 |
179 | func (t *Text) SetCursorType(cursorType CursorType) {
180 | t.cursorType = cursorType
181 | for _, word := range t.words {
182 | word.SetCursorType(cursorType)
183 | }
184 | }
185 |
186 | func (t *Text) IsComplete() bool {
187 | for _, word := range t.words {
188 | if !word.IsComplete() {
189 | return false
190 | }
191 | }
192 | return true
193 | }
194 |
195 | func (t *Text) GetCursorPos() int {
196 | return t.cursorPos
197 | }
198 |
199 | func (t *Text) Stats() (total, correct, errors int) {
200 | for _, word := range t.words {
201 | if word.IsSpace() {
202 | continue
203 | }
204 |
205 | if word.state == Perfect {
206 | correct++
207 | } else if word.state == Error {
208 | errors++
209 | }
210 | total++
211 | }
212 | return
213 | }
214 |
215 | func (t *Text) GetText() string {
216 | if t.sourceText != "" {
217 | return t.sourceText
218 | }
219 |
220 | var builder strings.Builder
221 | builder.Grow(len(t.words) * 8)
222 |
223 | for _, word := range t.words {
224 | if !word.IsSpace() {
225 | builder.WriteString(string(word.target))
226 | } else {
227 | builder.WriteRune(' ')
228 | }
229 | }
230 | return builder.String()
231 | }
232 |
--------------------------------------------------------------------------------
/ui/text_source.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "strings"
9 | "time"
10 | )
11 |
12 | type TextSource interface {
13 | FetchText() (string, error)
14 | FormatText(text string) string
15 | }
16 |
17 | type ZenQuotesSource struct {
18 | URL string
19 | }
20 |
21 | func NewZenQuotesSource() *ZenQuotesSource {
22 | return &ZenQuotesSource{
23 | URL: "https://zenquotes.io/api/random",
24 | }
25 | }
26 |
27 | func (s *ZenQuotesSource) FetchText() (string, error) {
28 | DebugLog("TextSource: Fetching quote from %s", s.URL)
29 | client := &http.Client{
30 | Timeout: 10 * time.Second,
31 | }
32 | resp, err := client.Get(s.URL)
33 | if err != nil {
34 | DebugLog("TextSource: Failed to fetch quote: %v", err)
35 | return "", fmt.Errorf("failed to fetch quote: %w", err)
36 | }
37 | defer resp.Body.Close()
38 |
39 | if resp.StatusCode != http.StatusOK {
40 | DebugLog("TextSource: API returned non-200 status: %d", resp.StatusCode)
41 | return "", fmt.Errorf("API returned status %d", resp.StatusCode)
42 | }
43 |
44 | body, err := io.ReadAll(resp.Body)
45 | if err != nil {
46 | DebugLog("TextSource: Failed to read response: %v", err)
47 | return "", fmt.Errorf("failed to read response: %w", err)
48 | }
49 |
50 | DebugLog("TextSource: Raw API response: %s", string(body))
51 |
52 | var result []struct {
53 | Quote string `json:"q"`
54 | Author string `json:"a"`
55 | }
56 | if err := json.Unmarshal(body, &result); err != nil {
57 | DebugLog("TextSource: Failed to parse quote: %v", err)
58 | return "", fmt.Errorf("failed to parse quote: %w", err)
59 | }
60 |
61 | if len(result) == 0 {
62 | DebugLog("TextSource: No quotes returned from API")
63 | return "", fmt.Errorf("no quotes returned from API")
64 | }
65 |
66 | quote := result[0].Quote
67 | author := result[0].Author
68 |
69 | if !strings.HasSuffix(quote, ".") && !strings.HasSuffix(quote, "!") && !strings.HasSuffix(quote, "?") {
70 | quote += "."
71 | }
72 |
73 | DebugLog("TextSource: Parsed quote - Content: %s, Author: %s", quote, author)
74 | return fmt.Sprintf("%s - %s", quote, author), nil
75 | }
76 |
77 | func (s *ZenQuotesSource) FormatText(text string) string {
78 | if CurrentSettings.GameMode == GameModeSimple {
79 | var builder strings.Builder
80 | builder.Grow(len(text))
81 |
82 | for _, r := range text {
83 | if r >= 'A' && r <= 'Z' {
84 | builder.WriteRune(r + 32) // Lowercase (faster than unicode functions)
85 | } else if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
86 | builder.WriteRune(r)
87 | } else if r == '.' || r == ',' || r == ';' || r == ':' || r == '!' || r == '?' {
88 | } else {
89 | builder.WriteRune(' ')
90 | }
91 | }
92 |
93 | processed := builder.String()
94 | words := strings.Fields(processed)
95 |
96 | if len(words) > 100 {
97 | words = words[:100]
98 | }
99 |
100 | var finalBuilder strings.Builder
101 | finalBuilder.Grow(len(processed))
102 |
103 | for i, word := range words {
104 | finalBuilder.WriteString(word)
105 | if i < len(words)-1 {
106 | finalBuilder.WriteRune(' ')
107 | }
108 | }
109 |
110 | return finalBuilder.String()
111 | }
112 |
113 | var builder strings.Builder
114 | builder.Grow(len(text))
115 |
116 | for _, r := range text {
117 | if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') ||
118 | r == ' ' || r == '.' || r == ',' || r == ';' || r == ':' || r == '!' || r == '?' {
119 | builder.WriteRune(r)
120 | } else {
121 | builder.WriteRune(' ')
122 | }
123 | }
124 |
125 | processed := builder.String()
126 | words := strings.Fields(processed)
127 |
128 | if len(words) > 100 {
129 | words = words[:100]
130 | }
131 |
132 | var finalBuilder strings.Builder
133 | finalBuilder.Grow(len(processed))
134 |
135 | for i, word := range words {
136 | finalBuilder.WriteString(word)
137 | if i < len(words)-1 {
138 | finalBuilder.WriteRune(' ')
139 | }
140 | }
141 |
142 | return finalBuilder.String()
143 | }
144 |
145 | type BibleSource struct {
146 | URL string
147 | }
148 |
149 | func NewBibleSource() *BibleSource {
150 | return &BibleSource{
151 | URL: "https://bible-api.com/john+3:16",
152 | }
153 | }
154 |
155 | func (s *BibleSource) FetchText() (string, error) {
156 | DebugLog("TextSource: Fetching bible verse from %s", s.URL)
157 | resp, err := http.Get(s.URL)
158 | if err != nil {
159 | DebugLog("TextSource: Failed to fetch bible verse: %v", err)
160 | return "", fmt.Errorf("failed to fetch bible verse: %w", err)
161 | }
162 | defer resp.Body.Close()
163 |
164 | body, err := io.ReadAll(resp.Body)
165 | if err != nil {
166 | DebugLog("TextSource: Failed to read response: %v", err)
167 | return "", fmt.Errorf("failed to read response: %w", err)
168 | }
169 |
170 | DebugLog("TextSource: Raw API response: %s", string(body))
171 |
172 | var result struct {
173 | Text string `json:"text"`
174 | Reference string `json:"reference"`
175 | }
176 | if err := json.Unmarshal(body, &result); err != nil {
177 | DebugLog("TextSource: Failed to parse bible verse: %v", err)
178 | return "", fmt.Errorf("failed to parse bible verse: %w", err)
179 | }
180 |
181 | verse := strings.TrimSpace(result.Text)
182 | verse = strings.Join(strings.Fields(verse), " ")
183 |
184 | DebugLog("TextSource: Parsed verse - Text: %s", verse)
185 | return verse, nil
186 | }
187 |
188 | func (s *BibleSource) FormatText(text string) string {
189 | if CurrentSettings.GameMode == GameModeSimple {
190 | var builder strings.Builder
191 | builder.Grow(len(text))
192 |
193 | for _, r := range text {
194 | if r >= 'A' && r <= 'Z' {
195 | builder.WriteRune(r + 32)
196 | } else if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
197 | builder.WriteRune(r)
198 | } else if r == '.' || r == ',' || r == ';' || r == ':' || r == '!' || r == '?' {
199 | } else {
200 | builder.WriteRune(' ')
201 | }
202 | }
203 |
204 | processed := builder.String()
205 | words := strings.Fields(processed)
206 |
207 | if len(words) > 100 {
208 | words = words[:100]
209 | }
210 |
211 | var finalBuilder strings.Builder
212 | finalBuilder.Grow(len(processed))
213 |
214 | for i, word := range words {
215 | finalBuilder.WriteString(word)
216 | if i < len(words)-1 {
217 | finalBuilder.WriteRune(' ')
218 | }
219 | }
220 |
221 | return finalBuilder.String()
222 | }
223 |
224 | var builder strings.Builder
225 | builder.Grow(len(text))
226 |
227 | for _, r := range text {
228 | if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') ||
229 | r == ' ' || r == '.' || r == ',' || r == ';' || r == ':' || r == '!' || r == '?' {
230 | builder.WriteRune(r)
231 | } else {
232 | builder.WriteRune(' ')
233 | }
234 | }
235 |
236 | processed := builder.String()
237 | words := strings.Fields(processed)
238 |
239 | if len(words) > 100 {
240 | words = words[:100]
241 | }
242 |
243 | var finalBuilder strings.Builder
244 | finalBuilder.Grow(len(processed))
245 |
246 | for i, word := range words {
247 | finalBuilder.WriteString(word)
248 | if i < len(words)-1 {
249 | finalBuilder.WriteRune(' ')
250 | }
251 | }
252 |
253 | return finalBuilder.String()
254 | }
255 |
256 | func GetRandomText() string {
257 | var source TextSource
258 | var err error
259 | var text string
260 |
261 | for i := 0; i < 2; i++ {
262 | switch i {
263 | case 0:
264 | source = NewZenQuotesSource()
265 | DebugLog("TextSource: Trying ZenQuotes API")
266 | case 1:
267 | source = NewBibleSource()
268 | DebugLog("TextSource: Trying Bible API")
269 | }
270 |
271 | text, err = source.FetchText()
272 | if err == nil {
273 | DebugLog("TextSource: Successfully fetched text: %s", text)
274 | return source.FormatText(text)
275 | }
276 | DebugLog("TextSource: Failed to fetch from source %d: %v", i, err)
277 | }
278 |
279 | DebugLog("TextSource: All sources failed, using default text")
280 | return "The quick brown fox jumps over the lazy dog."
281 | }
282 |
--------------------------------------------------------------------------------
/ui/theme.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "github.com/charmbracelet/lipgloss"
6 | "gopkg.in/yaml.v3"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | )
11 |
12 | const (
13 | ThemeDefault = "default"
14 | ThemeDark = "dark"
15 | ThemeMonochrome = "monochrome"
16 | )
17 |
18 | type ThemeColors struct {
19 | HelpText string `yaml:"help_text"`
20 | Timer string `yaml:"timer"`
21 | Border string `yaml:"border"`
22 |
23 | TextDim string `yaml:"text_dim"`
24 | TextPreview string `yaml:"text_preview"`
25 | TextCorrect string `yaml:"text_correct"`
26 | TextError string `yaml:"text_error"`
27 | TextPartialError string `yaml:"text_partial_error"`
28 |
29 | CursorFg string `yaml:"cursor_fg"`
30 | CursorBg string `yaml:"cursor_bg"`
31 | CursorUnderline string `yaml:"cursor_underline"`
32 |
33 | Padding string `yaml:"padding"`
34 | }
35 |
36 | var DefaultTheme = ThemeColors{
37 |
38 | HelpText: "#626262",
39 | Timer: "#FFDB58",
40 | Border: "#7F9ABE",
41 |
42 | TextDim: "#555555",
43 | TextPreview: "#7F9ABE",
44 | TextCorrect: "#00FF00",
45 | TextError: "#FF0000",
46 | TextPartialError: "#FF8C00",
47 |
48 | CursorFg: "#FFFFFF",
49 | CursorBg: "#00AAFF",
50 | CursorUnderline: "#00AAFF",
51 |
52 | Padding: "#888888",
53 | }
54 |
55 | var CurrentTheme ThemeColors
56 |
57 | func GetThemePath(themeName string) string {
58 | themeName = strings.TrimPrefix(themeName, "-")
59 |
60 | if strings.HasSuffix(themeName, ".yml") {
61 | return themeName
62 | }
63 |
64 | configDir, err := GetConfigDir()
65 | if err != nil {
66 | return filepath.Join("colorschemes", themeName+".yml")
67 | }
68 |
69 | colorschemesDir := filepath.Join(configDir, "colorschemes")
70 | if err := os.MkdirAll(colorschemesDir, 0755); err != nil {
71 | return filepath.Join("colorschemes", themeName+".yml")
72 | }
73 |
74 | return filepath.Join(colorschemesDir, themeName+".yml")
75 | }
76 |
77 | func LoadTheme(themeNameOrPath string) error {
78 | CurrentTheme = DefaultTheme
79 |
80 | if strings.TrimSpace(themeNameOrPath) == "" {
81 | return fmt.Errorf("empty theme name")
82 | }
83 |
84 | themePath := GetThemePath(themeNameOrPath)
85 |
86 | data, err := os.ReadFile(themePath)
87 | if err != nil {
88 | if os.IsNotExist(err) {
89 | themeName := filepath.Base(themePath)
90 | themeName = strings.TrimSuffix(themeName, ".yml")
91 | if !isValidThemeName(themeName) {
92 | return fmt.Errorf("invalid theme name: %s", themeName)
93 | }
94 |
95 | themeDir := filepath.Dir(themePath)
96 | if err := os.MkdirAll(themeDir, 0755); err != nil {
97 | return fmt.Errorf("error creating theme directory: %w", err)
98 | }
99 |
100 | yamlData, err := yaml.Marshal(DefaultTheme)
101 | if err != nil {
102 | return fmt.Errorf("error marshaling default theme: %w", err)
103 | }
104 |
105 | if err := os.WriteFile(themePath, yamlData, 0644); err != nil {
106 | return fmt.Errorf("error writing default theme file: %w", err)
107 | }
108 |
109 | fmt.Printf("Created default theme file at %s\n", themePath)
110 | return nil
111 | }
112 | return fmt.Errorf("error reading theme file: %w", err)
113 | }
114 |
115 | if err := yaml.Unmarshal(data, &CurrentTheme); err != nil {
116 | return fmt.Errorf("error parsing theme file: %w", err)
117 | }
118 |
119 | return nil
120 | }
121 |
122 | func isValidThemeName(name string) bool {
123 | if name == "" {
124 | return false
125 | }
126 |
127 | for _, c := range name {
128 | if !isValidThemeNameChar(c) {
129 | return false
130 | }
131 | }
132 |
133 | return true
134 | }
135 |
136 | func isValidThemeNameChar(c rune) bool {
137 | return (c >= 'a' && c <= 'z') ||
138 | (c >= 'A' && c <= 'Z') ||
139 | (c >= '0' && c <= '9') ||
140 | c == '_' || c == '-'
141 | }
142 |
143 | func GetColor(colorName string) lipgloss.Color {
144 | var hexColor string
145 |
146 | switch colorName {
147 | case "help_text":
148 | hexColor = CurrentTheme.HelpText
149 | case "timer":
150 | hexColor = CurrentTheme.Timer
151 | case "border":
152 | hexColor = CurrentTheme.Border
153 | case "text_dim":
154 | hexColor = CurrentTheme.TextDim
155 | case "text_preview":
156 | hexColor = CurrentTheme.TextPreview
157 | case "text_correct":
158 | hexColor = CurrentTheme.TextCorrect
159 | case "text_error":
160 | hexColor = CurrentTheme.TextError
161 | case "text_partial_error":
162 | hexColor = CurrentTheme.TextPartialError
163 | case "cursor_fg":
164 | hexColor = CurrentTheme.CursorFg
165 | case "cursor_bg":
166 | hexColor = CurrentTheme.CursorBg
167 | case "cursor_underline":
168 | hexColor = CurrentTheme.CursorUnderline
169 | case "padding":
170 | hexColor = CurrentTheme.Padding
171 | default:
172 | hexColor = "#FFFFFF"
173 | }
174 |
175 | return lipgloss.Color(hexColor)
176 | }
177 |
178 | func ListAvailableThemes() []string {
179 | themes := []string{ThemeDefault, ThemeDark, ThemeMonochrome}
180 |
181 | configDir, err := GetConfigDir()
182 | if err == nil {
183 | colorschemesDir := filepath.Join(configDir, "colorschemes")
184 | files, err := os.ReadDir(colorschemesDir)
185 | if err == nil {
186 | for _, file := range files {
187 | if !file.IsDir() && strings.HasSuffix(file.Name(), ".yml") {
188 | themeName := strings.TrimSuffix(file.Name(), ".yml")
189 | if themeName != ThemeDefault && themeName != ThemeDark && themeName != ThemeMonochrome {
190 | themes = append(themes, themeName)
191 | }
192 | }
193 | }
194 | }
195 | }
196 |
197 | files, err := os.ReadDir("colorschemes")
198 | if err == nil {
199 | for _, file := range files {
200 | if !file.IsDir() && strings.HasSuffix(file.Name(), ".yml") {
201 | themeName := strings.TrimSuffix(file.Name(), ".yml")
202 | if themeName != ThemeDefault && themeName != ThemeDark && themeName != ThemeMonochrome {
203 | themes = append(themes, themeName)
204 | }
205 | }
206 | }
207 | }
208 |
209 | return themes
210 | }
211 |
212 | func InitTheme() {
213 | ensureDefaultThemesExist()
214 |
215 | themeFile := GetThemePath(ThemeDefault)
216 | if err := LoadTheme(themeFile); err != nil {
217 | fmt.Printf("Warning: Could not load theme file: %v\n", err)
218 | fmt.Println("Using default theme")
219 | }
220 | }
221 |
222 | func ensureDefaultThemesExist() {
223 | defaultThemes := map[string]ThemeColors{
224 | ThemeDefault: DefaultTheme,
225 | ThemeDark: {
226 | HelpText: "#888888",
227 | Timer: "#A177FF",
228 | Border: "#5661B3",
229 |
230 | TextDim: "#444444",
231 | TextPreview: "#8892BF",
232 | TextCorrect: "#36D399",
233 | TextError: "#F87272",
234 | TextPartialError: "#FBBD23",
235 |
236 | CursorFg: "#222222",
237 | CursorBg: "#7B93DB",
238 | CursorUnderline: "#7B93DB",
239 |
240 | Padding: "#666666",
241 | },
242 | ThemeMonochrome: {
243 | HelpText: "#AAAAAA",
244 | Timer: "#FFFFFF",
245 | Border: "#DDDDDD",
246 |
247 | TextDim: "#777777",
248 | TextPreview: "#DDDDDD",
249 | TextCorrect: "#FFFFFF",
250 | TextError: "#444444",
251 | TextPartialError: "#BBBBBB",
252 |
253 | CursorFg: "#000000",
254 | CursorBg: "#FFFFFF",
255 | CursorUnderline: "#FFFFFF",
256 |
257 | Padding: "#999999",
258 | },
259 | }
260 |
261 | configDir, err := GetConfigDir()
262 | if err != nil {
263 | fmt.Printf("Warning: Could not get config directory: %v\n", err)
264 | return
265 | }
266 |
267 | colorschemesDir := filepath.Join(configDir, "colorschemes")
268 | if err := os.MkdirAll(colorschemesDir, 0755); err != nil {
269 | fmt.Printf("Warning: Could not create colorschemes directory: %v\n", err)
270 | return
271 | }
272 |
273 | for themeName, colors := range defaultThemes {
274 | themePath := filepath.Join(colorschemesDir, themeName+".yml")
275 |
276 | if _, err := os.Stat(themePath); err == nil {
277 | continue
278 | }
279 |
280 | yamlData, err := yaml.Marshal(colors)
281 | if err != nil {
282 | fmt.Printf("Warning: Could not marshal %s theme: %v\n", themeName, err)
283 | continue
284 | }
285 |
286 | if err := os.WriteFile(themePath, yamlData, 0644); err != nil {
287 | fmt.Printf("Warning: Could not create %s theme file: %v\n", themeName, err)
288 | continue
289 | }
290 |
291 | fmt.Printf("Created %s theme file at %s\n", themeName, themePath)
292 | }
293 | }
294 |
--------------------------------------------------------------------------------
/ui/welcome.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | tea "github.com/charmbracelet/bubbletea"
6 | "github.com/charmbracelet/lipgloss"
7 | "time"
8 | )
9 |
10 | type WelcomeModel struct {
11 | width int
12 | height int
13 | step int
14 | done bool
15 | startTime time.Time
16 | lastTick time.Time
17 | }
18 |
19 | func NewWelcomeModel() *WelcomeModel {
20 | return &WelcomeModel{
21 | step: 0,
22 | done: false,
23 | startTime: time.Now(),
24 | lastTick: time.Now(),
25 | }
26 | }
27 |
28 | func (m *WelcomeModel) Init() tea.Cmd {
29 | return InitGlobalTick()
30 | }
31 |
32 | func (m *WelcomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33 | switch msg := msg.(type) {
34 | case GlobalTickMsg:
35 | var cmd tea.Cmd
36 | m.lastTick, _, cmd = HandleGlobalTick(m.lastTick, msg)
37 | return m, cmd
38 |
39 | case tea.KeyMsg:
40 | if msg.Type == tea.KeyCtrlC || msg.Type == tea.KeyEsc {
41 | return m, tea.Quit
42 | }
43 | m.step++
44 | if m.step >= 2 {
45 | m.done = true
46 | CurrentSettings.HasSeenWelcome = true
47 | SaveSettings()
48 | return m, tea.Quit
49 | }
50 | return m, nil
51 |
52 | case tea.WindowSizeMsg:
53 | m.width = msg.Width
54 | m.height = msg.Height
55 | return m, nil
56 | }
57 |
58 | return m, nil
59 | }
60 |
61 | func (m *WelcomeModel) View() string {
62 | if m.done {
63 | return ""
64 | }
65 |
66 | titleStyle := lipgloss.NewStyle().
67 | Bold(true).
68 | Margin(1, 0)
69 |
70 | textStyle := lipgloss.NewStyle().
71 | Width(80).
72 | Align(lipgloss.Center)
73 |
74 | var content string
75 | switch m.step {
76 | case 0:
77 | title := RenderGradientText("Welcome to Go Typer!", m.lastTick)
78 | description := RenderGradientText("\nYou only see these messages once, so first, thank you for playing :)", m.lastTick)
79 | content = titleStyle.Render(title) + "\n\n" +
80 | textStyle.Render(description) + "\n\n" +
81 | HintStyle("\n\n\nPress any key to continue...")
82 |
83 | case 1:
84 | title := RenderGradientText("Please keep in mind:", m.lastTick)
85 | features := RenderGradientText("• This was a passion project, built in a few-days, so if you found it buggy, check for updates hopefully they are fixed :) \n"+
86 | "\n• I tried to mimic the monkey type styles such as jumps to the beginning of next word and ...\n"+
87 | "\n• I'd be grateful to hear your feedback on github.\n https://github.com/prime-run/go-typer\n"+
88 | "\n• to see enjoy the TUI you pprobably need a nerd-font and a modern shell and that can't be fixed via docker :)\n"+
89 | "\nImma head out out now, I hope you enjoy it, gl hf", m.lastTick)
90 | content = titleStyle.Render(title) + "\n\n" +
91 | textStyle.Render(features) + "\n\n" +
92 | HintStyle("\n\nPress any key to start typing...")
93 | }
94 |
95 | return lipgloss.Place(m.width, m.height,
96 | lipgloss.Center, lipgloss.Center,
97 | content)
98 | }
99 |
100 | func ShowWelcomeScreen() bool {
101 | InitSettings()
102 |
103 | // TODO:REMOVE BELOW COMMENT IN PROD
104 | if CurrentSettings.HasSeenWelcome {
105 | return false
106 | }
107 |
108 | model := NewWelcomeModel()
109 | p := tea.NewProgram(model, tea.WithAltScreen())
110 | if _, err := p.Run(); err != nil {
111 | fmt.Printf("Error running welcome screen: %v\n", err)
112 | }
113 |
114 | return true
115 | }
116 |
--------------------------------------------------------------------------------
/ui/word.go:
--------------------------------------------------------------------------------
1 | // WARN:NOTES in this files are important!
2 | // otehrwise it'll re-render exponentially on type!
3 | package ui
4 |
5 | import (
6 | "slices"
7 | "strings"
8 | "time"
9 | )
10 |
11 | type WordState int
12 |
13 | const (
14 | Untyped WordState = iota
15 | Perfect
16 | Imperfect
17 | Error
18 | )
19 |
20 | type Word struct {
21 | target []rune
22 | typed []rune
23 | state WordState
24 | active bool
25 | cursor *Cursor
26 | cached string
27 | dirty bool
28 | }
29 |
30 | func NewWord(target []rune) *Word {
31 | targetLen := len(target)
32 | targetCopy := make([]rune, targetLen)
33 | copy(targetCopy, target)
34 |
35 | return &Word{
36 | target: targetCopy,
37 | typed: make([]rune, 0, targetLen),
38 | state: Untyped,
39 | active: false,
40 | cursor: NewCursor(DefaultCursorType),
41 | dirty: true, // NOTE:start with dirty cache
42 | }
43 | }
44 |
45 | func (w *Word) Type(r rune) {
46 | if w.IsSpace() {
47 | if r == ' ' {
48 | w.typed = []rune{' '}
49 | w.state = Perfect
50 | } else {
51 | w.typed = []rune{r}
52 | w.state = Error
53 | }
54 | w.dirty = true
55 | return
56 | }
57 |
58 | if len(w.typed) < len(w.target) {
59 | w.typed = append(w.typed, r)
60 | } else if len(w.typed) == len(w.target) {
61 | w.typed[len(w.typed)-1] = r
62 | }
63 |
64 | w.updateState()
65 | w.dirty = true
66 | }
67 |
68 | func (w *Word) Skip() {
69 | targetLen := len(w.target)
70 | typedLen := len(w.typed)
71 |
72 | if typedLen == 0 {
73 | // NOTE:optimize by pre-allocating the full array
74 | w.typed = make([]rune, targetLen)
75 | for i := 0; i < targetLen; i++ {
76 | w.typed[i] = '\x00'
77 | }
78 | } else if typedLen < targetLen {
79 | // NOTE:optimize by growing the slice once
80 | needed := targetLen - typedLen
81 | w.typed = append(w.typed, make([]rune, needed)...)
82 | for i := typedLen; i < targetLen; i++ {
83 | w.typed[i] = '\x00'
84 | }
85 | }
86 |
87 | w.state = Error
88 | w.dirty = true
89 | }
90 |
91 | func (w *Word) Backspace() bool {
92 | if len(w.typed) == 0 {
93 | return false
94 | }
95 | w.typed = w.typed[:len(w.typed)-1]
96 | w.updateState()
97 | w.dirty = true
98 | return true
99 | }
100 |
101 | func (w *Word) updateState() {
102 | if len(w.typed) == 0 {
103 | w.state = Untyped
104 | return
105 | }
106 |
107 | if w.IsSpace() {
108 | if len(w.typed) == 1 && w.typed[0] == ' ' {
109 | w.state = Perfect
110 | } else {
111 | w.state = Error
112 | }
113 | return
114 | }
115 |
116 | if slices.Contains(w.typed, '\x00') {
117 | w.state = Error
118 | return
119 | }
120 |
121 | minLen := min(len(w.typed), len(w.target))
122 | perfect := true
123 | for i := 0; i < minLen; i++ {
124 | if w.typed[i] != w.target[i] {
125 | perfect = false
126 | break
127 | }
128 | }
129 |
130 | if perfect && len(w.typed) == len(w.target) {
131 | w.state = Perfect
132 | } else if perfect && len(w.typed) < len(w.target) {
133 | w.state = Imperfect
134 | } else {
135 | w.state = Error
136 | }
137 | }
138 |
139 | func (w *Word) IsComplete() bool {
140 | complete := len(w.typed) >= len(w.target)
141 | DebugLog("Word: IsComplete - Target: '%s' (%d), Typed: '%s' (%d), Complete: %v",
142 | string(w.target), len(w.target), string(w.typed), len(w.typed), complete)
143 | return complete
144 | }
145 |
146 | func (w *Word) HasStarted() bool {
147 | return len(w.typed) > 0
148 | }
149 |
150 | func (w *Word) IsSpace() bool {
151 | return len(w.target) == 1 && w.target[0] == ' '
152 | }
153 |
154 | func (w *Word) SetActive(active bool) {
155 | if w.active != active {
156 | w.active = active
157 | w.dirty = true
158 | }
159 | }
160 |
161 | func (w *Word) SetCursorType(cursorType CursorType) {
162 | w.cursor = NewCursor(cursorType)
163 | w.dirty = true
164 | }
165 |
166 | func (w *Word) Render(showCursor bool) string {
167 | //.
168 | //NOTE: If word is active, always render fresh
169 | // NOTE:If word is not active and not dirty, return cached result
170 | //.
171 |
172 | if !w.active && !w.dirty && w.cached != "" {
173 | return w.cached
174 | }
175 |
176 | startTime := time.Now()
177 |
178 | var result strings.Builder
179 |
180 | // NOTE:estimate buffer size to avoid reallocations
181 | // NOTE:allow extra space for style sequences
182 |
183 | result.Grow(max(len(w.target), len(w.typed)) * 3)
184 | if w.IsSpace() {
185 | if len(w.typed) == 0 {
186 | if showCursor && w.active {
187 | w.cached = w.cursor.Render(' ')
188 | return w.cached
189 | }
190 | w.cached = DimStyle.Render(" ")
191 | return w.cached
192 | } else if len(w.typed) == 1 && w.typed[0] == ' ' {
193 | w.cached = InputStyle.Render(" ")
194 | return w.cached
195 | } else {
196 | w.cached = ErrorStyle.Render(string(w.typed[0]))
197 | return w.cached
198 | }
199 | }
200 |
201 | targetLen := len(w.target)
202 | typedLen := len(w.typed)
203 |
204 | for i := 0; i < max(targetLen, typedLen); i++ {
205 | if showCursor && w.active && i == typedLen {
206 | if i < targetLen {
207 | result.WriteString(w.cursor.Render(w.target[i]))
208 | } else {
209 | result.WriteString(w.cursor.Render(' '))
210 | }
211 | continue
212 | }
213 |
214 | if i >= typedLen {
215 | result.WriteString(DimStyle.Render(string(w.target[i])))
216 | continue
217 | }
218 |
219 | if i >= targetLen {
220 | result.WriteString(ErrorStyle.Render(string(w.typed[i])))
221 | continue
222 | }
223 |
224 | if w.typed[i] == '\x00' {
225 | result.WriteString(DimStyle.Render(string(w.target[i])))
226 | continue
227 | }
228 |
229 | if w.typed[i] == w.target[i] {
230 | if w.state == Error {
231 | result.WriteString(PartialErrorStyle.Render(string(w.target[i])))
232 | } else {
233 | result.WriteString(InputStyle.Render(string(w.target[i])))
234 | }
235 | } else {
236 | result.WriteString(ErrorStyle.Render(string(w.typed[i])))
237 | }
238 | }
239 |
240 | rendered := result.String()
241 |
242 | if !w.active {
243 | w.cached = rendered
244 | w.dirty = false
245 | }
246 |
247 | if w.active {
248 | renderTime := time.Since(startTime)
249 | DebugLog("Word: Active word render completed in %s, length: %d", renderTime, len(rendered))
250 | }
251 |
252 | return rendered
253 | }
254 |
255 | func min(a, b int) int {
256 | if a < b {
257 | return a
258 | }
259 | return b
260 | }
261 |
262 | func max(a, b int) int {
263 | if a > b {
264 | return a
265 | }
266 | return b
267 | }
268 |
--------------------------------------------------------------------------------