├── .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 | [![Go](https://img.shields.io/badge/Go-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev/) [![Cobra](https://img.shields.io/badge/Cobra-00ADD8?style=flat-square&logo=go&logoColor=white)](https://github.com/spf13/cobra) [![Bubble Tea](https://img.shields.io/badge/Bubble%20Tea-FF75B7?style=flat-square&logo=go&logoColor=white)](https://github.com/charmbracelet/bubbletea) [![Lip Gloss](https://img.shields.io/badge/Lip%20Gloss-FFABE7?style=flat-square&logo=go&logoColor=white)](https://github.com/charmbracelet/lipgloss) 11 | 12 |

📷 Screenshots

13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
Go Typer Main Screen
Go Typer Theme SelectionGo Typer Typing SessionGo Typer Settings
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 | [![📹 DEMO video](https://github.com/user-attachments/assets/644a3feb-5758-4d3e-bd0d-878abde63787)](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 | ![image](https://github.com/user-attachments/assets/fec6e04c-57d7-4d63-ae24-fc9dff73d923) 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 | --------------------------------------------------------------------------------