├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── LICENSE ├── README.md ├── game ├── default_texts.go ├── events.go ├── game.go ├── server.go └── state.go ├── go.mod ├── go.sum ├── main.go ├── settings ├── enums.go └── settings.go ├── stats └── stats.go ├── ui ├── multiplayer.go ├── settings.go ├── singleplayer.go ├── ui.go └── welcome.go └── utils ├── errors.go └── strings.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | 11 | - name: Set up Go 1.13 12 | uses: actions/setup-go@v1 13 | with: 14 | go-version: 1.13 15 | id: go 16 | 17 | - name: Check out code into the Go module directory 18 | uses: actions/checkout@v1 19 | 20 | - name: Get dependencies 21 | run: | 22 | go get -v -t -d ./... 23 | 24 | - name: Build Linux 25 | run: go build -o typer-go -v . 26 | 27 | - name: Build Windows 28 | if: startsWith(github.ref, 'refs/tags/') && github.repository == 'shilangyu/typer-go' 29 | continue-on-error: true 30 | run: GOOS=windows GOARCH=386 go build -o typer-go.exe -v . 31 | 32 | - name: Build OSX 33 | if: startsWith(github.ref, 'refs/tags/') && github.repository == 'shilangyu/typer-go' 34 | continue-on-error: true 35 | run: GOOS=darwin GOARCH=amd64 go build -o typer-go.dmg -v . 36 | 37 | - name: Release 38 | uses: softprops/action-gh-release@v1 39 | if: startsWith(github.ref, 'refs/tags/') && github.repository == 'shilangyu/typer-go' 40 | with: 41 | files: | 42 | typer-go 43 | typer-go.exe 44 | typer-go.dmg 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Marcin Wojnarowski 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 | # typer-go 2 | 3 | [![](https://goreportcard.com/badge/github.com/shilangyu/typer-go)](https://goreportcard.com/report/github.com/shilangyu/typer-go) 4 | [![](https://github.com/shilangyu/typer-go/workflows/ci/badge.svg)](https://github.com/shilangyu/typer-go/actions) 5 | 6 | Test your typing speed in a Typer [TUI](https://en.wikipedia.org/wiki/Text-based_user_interface) game! 7 | 8 | - [typer-go](#typer-go) 9 | - [features](#features) 10 | - [install](#install) 11 | - [usage](#usage) 12 | - [settings](#settings) 13 | - [navigation](#navigation) 14 | 15 | ## features 16 | 17 | - collect statistics to track your progress 18 | - Words per minute 19 | - Timestamps 20 | - Amount of mistakes for each word 21 | - Amount of time for each word 22 | - play with your friends in local multiplayer (global coming soon) 23 | - customizable 24 | - comfort of your sweet sweet terminal 25 | 26 | ## install 27 | 28 | Grab an executable from the [release tab](https://github.com/shilangyu/typer-go/releases) 29 | 30 | ... or if you're an Gopher build from source (requires Go v1.13+): 31 | 32 | ```sh 33 | go get github.com/shilangyu/typer-go 34 | ``` 35 | 36 | ## usage 37 | 38 | Just run `typer-go` in your terminal and the TUI will start. Full screen terminal is recommended. There are no CLI commands and flags. 39 | 40 | ## settings 41 | 42 | | name | values | description | 43 | | ------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | 44 | | highlight | `'background'` or `'text'` | The way the already typed text should be highlighted. `'background'` to fill the background, `'text'` to just fill the text. | 45 | | error display | `'typed'` or `'text'` | what should be shown when incorrect character is inputted. `'typed'` will show the typed char, `'text'` will show what should've been typed. | 46 | | texts path | any string | path to your custom typer texts where each text is separated by a new line. If path is empty, preloaded texts will be loaded. | 47 | 48 | ## navigation 49 | 50 | [~~The whole TUI has mouse support!~~](https://github.com/shilangyu/typer-go/issues/9) 51 | 52 | | key | description | 53 | | ----------------- | -------------------- | 54 | | | menu navigation up | 55 | | | menu navigation down | 56 | | enter | confirm | 57 | | esc | back | 58 | | tab | switch focus | 59 | | ctrl+c | exit | 60 | -------------------------------------------------------------------------------- /game/default_texts.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | // TyperTexts are a set of default typing texts 4 | const TyperTexts string = ` 5 | im typing something 6 | ` 7 | -------------------------------------------------------------------------------- /game/events.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | // ChangeName is for changing usernames 10 | // payload = ID:new username 11 | ChangeName = "change-name" 12 | // ExitPlayer is for players leaving the game 13 | // payload = ID 14 | ExitPlayer = "exit-player" 15 | // StartGame is for starting the game 16 | // payload = unix timestamp of when it starts 17 | StartGame = "start-game" 18 | // Progress is for indicating typing progress 19 | // payload = ID:% 20 | Progress = "progress" 21 | ) 22 | 23 | // Events is an array of all available events 24 | var Events = [...]string{ChangeName, ExitPlayer, StartGame, Progress} 25 | 26 | func split(s string) (string, string) { 27 | ss := strings.SplitN(s, ":", 2) 28 | return ss[0], ss[1] 29 | } 30 | 31 | // ExtractChangeName takes a payload and gives extracted data 32 | func ExtractChangeName(payload string) (ID, nickname string) { 33 | return split(payload) 34 | } 35 | 36 | // ExtractExitPlayer takes a payload and gives extracted data 37 | func ExtractExitPlayer(payload string) (ID string) { 38 | return payload 39 | } 40 | 41 | // ExtractStartGame takes a payload and gives extracted data 42 | func ExtractStartGame(payload string) (unixTimestamp int64) { 43 | unixTimestamp, _ = strconv.ParseInt(payload, 10, 64) 44 | return 45 | } 46 | 47 | // ExtractProgress takes a payload and gives extracted data 48 | func ExtractProgress(payload string) (ID string, progress int) { 49 | ID, p := split(payload) 50 | progress, _ = strconv.Atoi(p) 51 | return 52 | } 53 | -------------------------------------------------------------------------------- /game/game.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "math/rand" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/shilangyu/typer-go/settings" 12 | ) 13 | 14 | // ChooseText randomly chooses a text from the dataset 15 | func ChooseText() (string, error) { 16 | var texts []string 17 | rand.Seed(time.Now().UTC().UnixNano()) 18 | 19 | if settings.I.TextsPath == "" { 20 | texts = strings.Split(strings.TrimSpace(TyperTexts), "\n") 21 | } else if _, err := os.Stat(settings.I.TextsPath); os.IsNotExist(err) { 22 | return "", errors.New("Didn't find typer texts, make sure your path is correct or leave it empty to load some preloaded texts") 23 | } else { 24 | bytes, err := ioutil.ReadFile(settings.I.TextsPath) 25 | if err != nil { 26 | return "", errors.New("Couldnt load the typer texts, make sure the permissions are correct") 27 | } 28 | texts = strings.Split(strings.TrimSpace(string(bytes)), "\n") 29 | } 30 | 31 | return texts[rand.Intn(len(texts))], nil 32 | } 33 | 34 | // Player holds information about an outer player 35 | type Player struct { 36 | // Nickname 37 | Nickname string 38 | // Progress 39 | Progress int 40 | } 41 | 42 | // Players is a helper for other players 43 | type Players map[string]*Player 44 | 45 | // Add adds or edits a player to the map 46 | func (p *Players) Add(ID, nickname string) { 47 | if _, ok := (*p)[ID]; !ok { 48 | (*p)[ID] = &Player{} 49 | } 50 | (*p)[ID].Nickname = nickname 51 | } 52 | -------------------------------------------------------------------------------- /game/server.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "github.com/kanopeld/go-socket" 5 | ) 6 | 7 | // NewServer initializes a server that just broadcasts all events 8 | func NewServer(port string) (*socket.Server, error) { 9 | s, err := socket.NewServer(":" + port) 10 | if err != nil { 11 | return nil, err 12 | } 13 | players := make(Players) 14 | 15 | s.On(socket.CONNECTION_NAME, func(c socket.Client) { 16 | c.On(ChangeName, func(data []byte) { 17 | ID, nickname := ExtractChangeName(string(data)) 18 | players.Add(ID, nickname) 19 | c.Broadcast(ChangeName, data) 20 | 21 | for ID, p := range players { 22 | c.Emit(ChangeName, ID+":"+p.Nickname) 23 | } 24 | }) 25 | 26 | onExit := func() { 27 | delete(players, c.ID()) 28 | c.Broadcast(ExitPlayer, []byte(c.ID())) 29 | } 30 | c.On(socket.DISCONNECTION_NAME, onExit) 31 | c.On(ExitPlayer, onExit) 32 | }) 33 | 34 | go s.Start() 35 | 36 | return s, nil 37 | } 38 | -------------------------------------------------------------------------------- /game/state.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/shilangyu/typer-go/stats" 8 | ) 9 | 10 | // State describes the state of a game 11 | type State struct { 12 | // CurrWord is an index to State.Words 13 | CurrWord int 14 | // Words contains the text split by spaces 15 | Words []string 16 | // StartTime is a timestamp of the first keystroke 17 | StartTime time.Time 18 | // properties concerning current word 19 | wordStart time.Time 20 | wordErrors int 21 | } 22 | 23 | // NewState initializes State 24 | func NewState(text string) *State { 25 | words := strings.Split(text, " ") 26 | for i := range words[:len(words)-1] { 27 | words[i] += " " 28 | } 29 | 30 | return &State{ 31 | Words: words, 32 | } 33 | } 34 | 35 | // Start starts the mechanism 36 | func (s *State) Start() { 37 | s.StartTime = time.Now() 38 | s.wordStart = s.StartTime 39 | } 40 | 41 | // End ends the mechanism 42 | func (s *State) End() { 43 | stats.AddHistory(s.Wpm()) 44 | stats.Save() 45 | } 46 | 47 | // Wpm is the words per minute 48 | func (s State) Wpm() float64 { 49 | return float64(s.CurrWord) / time.Since(s.StartTime).Minutes() 50 | } 51 | 52 | // Progress returns a float in the (0;1) range represending the progress made 53 | func (s State) Progress() float64 { 54 | return float64(s.CurrWord) / float64(len(s.Words)) 55 | } 56 | 57 | // IncError increments the error count 58 | func (s *State) IncError() { 59 | s.wordErrors++ 60 | } 61 | 62 | // NextWord saves stats of the current word and increments the counter 63 | func (s *State) NextWord() { 64 | stats.AddWord(s.Words[s.CurrWord], time.Since(s.wordStart), s.wordErrors) 65 | s.CurrWord++ 66 | 67 | s.wordStart = time.Now() 68 | s.wordErrors = 0 69 | } 70 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/shilangyu/typer-go 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/gdamore/tcell v1.4.0 7 | github.com/kanopeld/go-socket v0.0.0-20191129220547-03aa20f2125c 8 | github.com/rivo/tview v0.0.0-20191121195645-2d957c4be01d 9 | gopkg.in/yaml.v2 v2.4.0 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/DATA-DOG/go-sqlmock v1.3.3 h1:CWUqKXe0s8A2z6qCgkP4Kru7wC11YoAnoupUKFDnH08= 2 | github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= 3 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 4 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 5 | github.com/gdamore/tcell v1.3.0 h1:r35w0JBADPZCVQijYebl6YMWWtHRqVEGt7kL2eBADRM= 6 | github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= 7 | github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= 8 | github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= 9 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 10 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 11 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 12 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 13 | github.com/kanopeld/go-socket v0.0.0-20191129220547-03aa20f2125c h1:EOKxY4jGiQkldDKxh9bluB0WcxxLtd8/0+6OvA8pAWY= 14 | github.com/kanopeld/go-socket v0.0.0-20191129220547-03aa20f2125c/go.mod h1:TX1p6Ep6DDbMwMO4QDksv++0hlmL7mhfdyVUOvj5RO4= 15 | github.com/lucasb-eyer/go-colorful v1.0.2 h1:mCMFu6PgSozg9tDNMMK3g18oJBX7oYGrC09mS6CXfO4= 16 | github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= 17 | github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= 18 | github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 19 | github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= 20 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 21 | github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= 22 | github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 23 | github.com/rivo/tview v0.0.0-20191121195645-2d957c4be01d h1:dPWYyMzc2VB5XX7eA/Pe5TXBGzhlVZZr54GhRJLTbts= 24 | github.com/rivo/tview v0.0.0-20191121195645-2d957c4be01d/go.mod h1:/rBeY22VG2QprWnEqG57IBC8biVu3i0DOIjRLc9I8H0= 25 | github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= 26 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 27 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 28 | github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= 29 | github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= 30 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 31 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 32 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 33 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 34 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 35 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 36 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 37 | golang.org/x/sys v0.0.0-20191018095205-727590c5006e h1:ZtoklVMHQy6BFRHkbG6JzK+S6rX82//Yeok1vMlizfQ= 38 | golang.org/x/sys v0.0.0-20191018095205-727590c5006e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 39 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 40 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 41 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 42 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 43 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1P+/ZFC/IKythI5RzrnRg= 44 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 45 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 46 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 47 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 48 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 49 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/rivo/tview" 8 | "github.com/shilangyu/typer-go/ui" 9 | "github.com/shilangyu/typer-go/utils" 10 | ) 11 | 12 | func main() { 13 | tview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault 14 | // tview.Styles.PrimaryTextColor = tcell.ColorBlack 15 | 16 | app := tview.NewApplication() 17 | defer app.Stop() 18 | 19 | app.SetBeforeDrawFunc(func(s tcell.Screen) bool { 20 | s.Clear() 21 | return false 22 | }) 23 | 24 | err := ui.CreateWelcome(app) 25 | utils.Check(err) 26 | 27 | if err := app.Run(); err != nil { 28 | log.Panicln(err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /settings/enums.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | // Highlight describes how the text should be highlighted 4 | type Highlight int 5 | 6 | const ( 7 | // HighlightBackground says the text should have a background highlight 8 | HighlightBackground Highlight = iota 9 | // HighlightText says the text should have a text highlight 10 | HighlightText 11 | ) 12 | 13 | func (e Highlight) String() string { 14 | return []string{"background", "text"}[e] 15 | } 16 | 17 | // ErrorDisplay describes how incorrectly typed letters should be displayed 18 | type ErrorDisplay int 19 | 20 | const ( 21 | // ErrorDisplayTyped says the typed letter should be displayed 22 | ErrorDisplayTyped ErrorDisplay = iota 23 | // ErrorDisplayText says the text letter should be displayed 24 | ErrorDisplayText 25 | ) 26 | 27 | func (e ErrorDisplay) String() string { 28 | return []string{"typed", "text"}[e] 29 | } 30 | -------------------------------------------------------------------------------- /settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path" 7 | 8 | "github.com/shilangyu/typer-go/utils" 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | type settings struct { 13 | Highlight Highlight 14 | ErrorDisplay ErrorDisplay 15 | TextsPath string 16 | } 17 | 18 | // I contains current settings properties from settings.yaml 19 | var I settings 20 | var settingsPath string 21 | 22 | func init() { 23 | userConfigDir, err := os.UserConfigDir() 24 | utils.Check(err) 25 | 26 | settingsPath = path.Join(userConfigDir, "typer-go", "settings.yaml") 27 | if _, err := os.Stat(settingsPath); os.IsNotExist(err) { 28 | err := os.MkdirAll(path.Dir(settingsPath), os.ModePerm) 29 | utils.Check(err) 30 | 31 | file, err := os.Create(settingsPath) 32 | file.Close() 33 | utils.Check(err) 34 | Save() 35 | } 36 | 37 | content, err := ioutil.ReadFile(settingsPath) 38 | utils.Check(err) 39 | 40 | err = yaml.Unmarshal(content, &I) 41 | utils.Check(err) 42 | } 43 | 44 | // Save saves the current settings 45 | func Save() error { 46 | bytes, err := yaml.Marshal(I) 47 | if err != nil { 48 | return err 49 | } 50 | return ioutil.WriteFile(settingsPath, bytes, os.ModePerm) 51 | } 52 | -------------------------------------------------------------------------------- /stats/stats.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "strings" 9 | "time" 10 | 11 | "github.com/shilangyu/typer-go/utils" 12 | ) 13 | 14 | // wordStat describes the errors concerning a word 15 | type wordStat struct { 16 | Duration time.Duration `json:"duration"` 17 | ErrorCount int `json:"errorCount"` 18 | } 19 | 20 | type history struct { 21 | Timestamp time.Time `json:"timestamp"` 22 | Wpm float64 `json:"wpm"` 23 | } 24 | 25 | type stats struct { 26 | History []history `json:"history"` 27 | Words map[string][]wordStat `json:"words"` 28 | } 29 | 30 | // I contains current statistics 31 | // its initialized because json.Marshal sees the properties 32 | // as null pointers not empty objects as it should 33 | var I = stats{ 34 | History: []history{}, 35 | Words: map[string][]wordStat{}, 36 | } 37 | var statsPath string 38 | 39 | func init() { 40 | userConfigDir, err := os.UserConfigDir() 41 | utils.Check(err) 42 | 43 | statsPath = path.Join(userConfigDir, "typer-go", "stats.json") 44 | if _, err := os.Stat(statsPath); os.IsNotExist(err) { 45 | err := os.MkdirAll(path.Dir(statsPath), os.ModePerm) 46 | utils.Check(err) 47 | 48 | file, err := os.Create(statsPath) 49 | file.Close() 50 | utils.Check(err) 51 | Save() 52 | } 53 | 54 | content, err := ioutil.ReadFile(statsPath) 55 | utils.Check(err) 56 | 57 | err = json.Unmarshal(content, &I) 58 | utils.Check(err) 59 | } 60 | 61 | // Save saves the current statistics 62 | func Save() error { 63 | bytes, err := json.Marshal(I) 64 | if err != nil { 65 | return err 66 | } 67 | return ioutil.WriteFile(statsPath, bytes, os.ModePerm) 68 | } 69 | 70 | // AddHistory appends wpm with a timestamp to the history property 71 | func AddHistory(wpm float64) { 72 | I.History = append(I.History, history{time.Now(), wpm}) 73 | } 74 | 75 | // AddWord appends stats about a word 76 | func AddWord(rawWord string, time time.Duration, errorCount int) { 77 | word := strings.TrimFunc(strings.ToLower(rawWord), func(ch rune) bool { 78 | return ch == ' ' || ch == '.' || ch == ':' || ch == '?' || ch == '!' 79 | }) 80 | I.Words[word] = append(I.Words[word], wordStat{time, errorCount}) 81 | } 82 | -------------------------------------------------------------------------------- /ui/multiplayer.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/kanopeld/go-socket" 5 | "github.com/rivo/tview" 6 | "github.com/shilangyu/typer-go/game" 7 | "github.com/shilangyu/typer-go/utils" 8 | ) 9 | 10 | type setup struct { 11 | RoomIP, Nickname, Port string 12 | IsServer bool 13 | // Server will be nil if IsServer is false 14 | Server *socket.Server 15 | Client socket.Client 16 | } 17 | 18 | // CreateMultiplayerSetup creates multiplayer room 19 | func CreateMultiplayerSetup(app *tview.Application) error { 20 | IP, _ := utils.IPv4() 21 | setup := setup{IP, "", "9001", true, nil, nil} 22 | 23 | formWi := tview.NewForm(). 24 | AddInputField("Room IP", setup.RoomIP, 20, nil, func(text string) { setup.RoomIP = text }). 25 | AddInputField("Port", setup.Port, 20, nil, func(text string) { setup.Port = text }). 26 | AddCheckbox("Server", setup.IsServer, func(checked bool) { setup.IsServer = checked }). 27 | AddButton("CONNECT", func() { 28 | if setup.IsServer { 29 | var err error 30 | setup.Server, err = game.NewServer(setup.Port) 31 | utils.Check(err) 32 | } 33 | 34 | c, err := socket.NewDial(setup.RoomIP + ":" + setup.Port) 35 | utils.Check(err) 36 | setup.Client = c 37 | 38 | utils.Check(CreateMultiplayerRoom(app, setup)) 39 | }). 40 | AddButton("CANCEL", func() { 41 | utils.Check(CreateWelcome(app)) 42 | }) 43 | 44 | app.SetRoot(Center(28, 11, formWi), true) 45 | keybindings(app, CreateWelcome) 46 | return nil 47 | } 48 | 49 | // CreateMultiplayerRoom creates multiplayer room 50 | func CreateMultiplayerRoom(app *tview.Application, setup setup) error { 51 | const maxNicknameLength int = 10 52 | 53 | players := make(game.Players) 54 | 55 | roomWi := tview.NewTextView() 56 | roomWi.SetBorder(true).SetTitle("ROOM") 57 | renderRoom := func() { 58 | ps := "" 59 | for _, p := range players { 60 | ps += p.Nickname + "\n" 61 | } 62 | app.QueueUpdateDraw(func() { 63 | roomWi.SetText(ps) 64 | }) 65 | } 66 | 67 | setup.Client.On(socket.CONNECTION_NAME, func(ccc socket.Client) { 68 | setup.Client.On(game.ChangeName, func(payload string) { 69 | ID, nickname := game.ExtractChangeName(payload) 70 | players.Add(ID, nickname) 71 | renderRoom() 72 | }) 73 | setup.Client.On(game.ExitPlayer, func(payload string) { 74 | ID := game.ExtractExitPlayer(payload) 75 | delete(players, ID) 76 | renderRoom() 77 | }) 78 | setup.Client.Emit(game.ChangeName, setup.Client.ID()+":"+setup.Nickname) 79 | players[setup.Client.ID()] = &game.Player{Nickname: setup.Nickname} 80 | renderRoom() 81 | }) 82 | 83 | formWi := tview.NewForm(). 84 | AddInputField("Nickname", setup.Nickname, 20, func(textToCheck string, lastChar rune) bool { 85 | return len(textToCheck) <= maxNicknameLength 86 | }, func(text string) { 87 | setup.Nickname = text 88 | players[setup.Client.ID()].Nickname = setup.Nickname 89 | setup.Client.Emit(game.ChangeName, setup.Client.ID()+":"+setup.Nickname) 90 | }). 91 | AddButton("BACK", func() { 92 | utils.Check(CreateMultiplayerSetup(app)) 93 | }) 94 | 95 | layout := tview.NewFlex(). 96 | SetDirection(tview.FlexRow). 97 | AddItem(tview.NewBox(), 0, 1, false). 98 | AddItem(tview.NewFlex(). 99 | AddItem(Center(28, 11, formWi), 0, 1, true). 100 | AddItem(Center(28, 11, roomWi), 0, 1, false), 101 | 0, 1, true). 102 | AddItem(tview.NewBox(), 0, 1, false) 103 | 104 | app.SetRoot(layout, true) 105 | keybindings(app, func(app *tview.Application) error { 106 | setup.Client.Emit(game.ExitPlayer, setup.Client.ID()) 107 | return CreateMultiplayerSetup(app) 108 | }) 109 | 110 | return nil 111 | } 112 | 113 | // // CreateMultiplayer creates multiplayer screen widgets 114 | // func CreateMultiplayer(g *gocui.Gui) error { 115 | // text, err := game.ChooseText() 116 | // if err != nil { 117 | // return err 118 | // } 119 | 120 | // var state *game.State 121 | // if srv != nil { 122 | // srv.State = game.NewState(text) 123 | // state = srv.State 124 | // } else { 125 | // clt.State = game.NewState(text) 126 | // state = clt.State 127 | // } 128 | 129 | // w, h := g.Size() 130 | 131 | // statsFrameWi := widgets.NewCollection("multiplayer-stats", "STATS", false, 0, 0, w/5, h) 132 | 133 | // statWis := []*widgets.Text{ 134 | // widgets.NewText("multiplayer-stats-wpm", "wpm: 0 ", false, false, 2, 1), 135 | // widgets.NewText("multiplayer-stats-time", "time: 0s ", false, false, 2, 2), 136 | // } 137 | 138 | // textFrameWi := widgets.NewCollection("multiplayer-text", "", false, w/5+1, 0, 4*w/5, 5*h/6+1) 139 | 140 | // points := []struct{ x, y int }{} 141 | // var textWis []*widgets.Text 142 | // for i, p := range points { 143 | // textWis = append(textWis, widgets.NewText("multiplayer-text-"+strconv.Itoa(i), state.Words[i], false, false, w/5+1+p.x, p.y)) 144 | // } 145 | 146 | // progressWi := widgets.NewText("multiplayer-progress", strings.Repeat(" ", w/5), false, false, 1, h/2) 147 | // updateProgress := func() { 148 | // g.Update(progressWi.ChangeText(strings.Repeat("█", int(math.Floor(state.Progress()*float64(w/5)))))) 149 | // } 150 | 151 | // var inputWi *widgets.Input 152 | // inputWi = widgets.NewInput("multiplayer-input", true, false, w/5+1, h-h/6, w-w/5-1, h/6, func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { 153 | // if key == gocui.KeyEnter || len(v.Buffer()) == 0 && ch == 0 { 154 | // return false 155 | // } 156 | 157 | // if state.StartTime.IsZero() { 158 | // state.Start() 159 | // go func() { 160 | // ticker := time.NewTicker(100 * time.Millisecond) 161 | // for range ticker.C { 162 | // if state.CurrWord == len(state.Words) { 163 | // ticker.Stop() 164 | // return 165 | // } 166 | 167 | // g.Update(func(g *gocui.Gui) error { 168 | // err := statWis[1].ChangeText( 169 | // fmt.Sprintf("time: %.02fs", time.Since(state.StartTime).Seconds()), 170 | // )(g) 171 | // if err != nil { 172 | // return err 173 | // } 174 | 175 | // err = statWis[0].ChangeText( 176 | // fmt.Sprintf("wpm: %.0f", state.Wpm()), 177 | // )(g) 178 | // if err != nil { 179 | // return err 180 | // } 181 | 182 | // return nil 183 | // }) 184 | // } 185 | // }() 186 | // } 187 | 188 | // gocui.DefaultEditor.Edit(v, key, ch, mod) 189 | 190 | // b := v.Buffer()[:len(v.Buffer())-1] 191 | 192 | // if ch != 0 && (len(b) > len(state.Words[state.CurrWord]) || rune(state.Words[state.CurrWord][len(b)-1]) != ch) { 193 | // state.IncError() 194 | // } 195 | 196 | // // ansiWord := state.PaintDiff(b) 197 | 198 | // // g.Update(textWis[state.CurrWord].ChangeText(ansiWord)) 199 | 200 | // if b == state.Words[state.CurrWord] { 201 | // state.NextWord() 202 | // updateProgress() 203 | // if state.CurrWord == len(state.Words) { 204 | // state.End() 205 | 206 | // var popupWi *widgets.Modal 207 | // popupWi = widgets.NewModal("multiplayer-popup", "The end of the end\n is the end of times who craes", []string{"play", "quit"}, true, w/2, h/2, func(i int) { 208 | // popupWi.Layout(g) 209 | // }, func(i int) { 210 | // switch i { 211 | // case 0: 212 | // // CreateSingleplayer(g) 213 | // case 1: 214 | // // CreateWelcome(g) 215 | // } 216 | // }) 217 | // g.Update(func(g *gocui.Gui) error { 218 | // popupWi.Layout(g) 219 | // popupWi.Layout(g) 220 | // g.SetCurrentView("multiplayer-popup") 221 | // g.SetViewOnTop("multiplayer-popup") 222 | // return nil 223 | // }) 224 | 225 | // } 226 | // g.Update(inputWi.ChangeText("")) 227 | // } 228 | 229 | // return false 230 | // }) 231 | 232 | // var wis []gocui.Manager 233 | // wis = append(wis, statsFrameWi) 234 | // for _, stat := range statWis { 235 | // wis = append(wis, stat) 236 | // } 237 | // wis = append(wis, textFrameWi) 238 | // for _, text := range textWis { 239 | // wis = append(wis, text) 240 | // } 241 | // wis = append(wis, inputWi) 242 | // wis = append(wis, progressWi) 243 | 244 | // g.SetManager(wis...) 245 | 246 | // g.Update(func(*gocui.Gui) error { 247 | // g.SetCurrentView("multiplayer-input") 248 | // return nil 249 | // }) 250 | 251 | // return nil //keybindings(g, CreateMultiplayerSetup) 252 | // } 253 | -------------------------------------------------------------------------------- /ui/settings.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/rivo/tview" 5 | "github.com/shilangyu/typer-go/settings" 6 | "github.com/shilangyu/typer-go/utils" 7 | ) 8 | 9 | // CreateSettings creates a screen with settings 10 | func CreateSettings(app *tview.Application) error { 11 | settingsWi := tview.NewForm(). 12 | AddDropDown( 13 | "highlight", 14 | []string{settings.HighlightBackground.String(), settings.HighlightText.String()}, 15 | int(settings.I.Highlight), 16 | func(option string, index int) { 17 | settings.I.Highlight = settings.Highlight(index) 18 | settings.Save() 19 | }). 20 | AddDropDown( 21 | "error display", 22 | []string{settings.ErrorDisplayText.String(), settings.ErrorDisplayTyped.String()}, 23 | int(settings.I.ErrorDisplay), 24 | func(option string, index int) { 25 | settings.I.ErrorDisplay = settings.ErrorDisplay(index) 26 | settings.Save() 27 | }). 28 | AddInputField( 29 | "texts path", 30 | settings.I.TextsPath, 31 | 20, 32 | nil, 33 | func(text string) { 34 | settings.I.TextsPath = text 35 | settings.Save() 36 | }). 37 | AddButton("DONE", func() { utils.Check(CreateWelcome(app)) }) 38 | 39 | layout := tview.NewFlex().AddItem(Center(34, 10, settingsWi), 0, 1, true) 40 | 41 | app.SetRoot(layout, true) 42 | keybindings(app, CreateWelcome) 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /ui/singleplayer.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/gdamore/tcell" 8 | "github.com/rivo/tview" 9 | "github.com/shilangyu/typer-go/game" 10 | "github.com/shilangyu/typer-go/settings" 11 | "github.com/shilangyu/typer-go/utils" 12 | ) 13 | 14 | // CreateSingleplayer creates singleplayer screen widgets 15 | func CreateSingleplayer(app *tview.Application) error { 16 | text, err := game.ChooseText() 17 | if err != nil { 18 | return err 19 | } 20 | state := game.NewState(text) 21 | 22 | statsWis := [...]*tview.TextView{ 23 | tview.NewTextView().SetText("wpm: 0"), 24 | tview.NewTextView().SetText("time: 0s"), 25 | } 26 | 27 | pages := tview.NewPages(). 28 | AddPage("modal", tview.NewModal(). 29 | SetText("Play again?"). 30 | SetBackgroundColor(tcell.ColorDefault). 31 | AddButtons([]string{"yes", "exit"}). 32 | SetDoneFunc(func(index int, label string) { 33 | switch index { 34 | case 0: 35 | utils.Check(CreateSingleplayer(app)) 36 | case 1: 37 | utils.Check(CreateWelcome(app)) 38 | } 39 | }), false, false) 40 | 41 | var textWis []*tview.TextView 42 | for _, word := range state.Words { 43 | textWis = append(textWis, tview.NewTextView().SetText(word).SetDynamicColors(true)) 44 | } 45 | 46 | currInput := "" 47 | inputWi := tview.NewInputField(). 48 | SetFieldBackgroundColor(tcell.ColorDefault) 49 | inputWi. 50 | SetChangedFunc(func(text string) { 51 | if state.StartTime.IsZero() { 52 | state.Start() 53 | go func() { 54 | ticker := time.NewTicker(100 * time.Millisecond) 55 | for range ticker.C { 56 | if state.CurrWord == len(state.Words) { 57 | ticker.Stop() 58 | return 59 | } 60 | app.QueueUpdateDraw(func() { 61 | statsWis[0].SetText(fmt.Sprintf("wpm: %.0f", state.Wpm())) 62 | statsWis[1].SetText(fmt.Sprintf("time: %.02fs", time.Since(state.StartTime).Seconds())) 63 | }) 64 | } 65 | }() 66 | } 67 | 68 | if len(currInput) < len(text) { 69 | if len(text) > len(state.Words[state.CurrWord]) || state.Words[state.CurrWord][len(text)-1] != text[len(text)-1] { 70 | state.IncError() 71 | } 72 | } 73 | 74 | app.QueueUpdateDraw(func(i int) func() { 75 | return func() { 76 | textWis[i].SetText(paintDiff(state.Words[i], text)) 77 | } 78 | }(state.CurrWord)) 79 | 80 | if text == state.Words[state.CurrWord] { 81 | state.NextWord() 82 | if state.CurrWord == len(state.Words) { 83 | state.End() 84 | pages.ShowPage("modal") 85 | } else { 86 | inputWi.SetText("") 87 | } 88 | } 89 | 90 | currInput = text 91 | }) 92 | 93 | layout := tview.NewFlex() 94 | statsFrame := tview.NewFlex().SetDirection(tview.FlexRow) 95 | statsFrame.SetBorder(true).SetBorderPadding(1, 1, 1, 1).SetTitle("STATS") 96 | for _, statsWi := range statsWis { 97 | statsFrame.AddItem(statsWi, 1, 1, false) 98 | } 99 | layout.AddItem(statsFrame, 0, 1, false) 100 | 101 | secondColumn := tview.NewFlex().SetDirection(tview.FlexRow) 102 | textsLayout := tview.NewFlex() 103 | for _, textWi := range textWis { 104 | textsLayout.AddItem(textWi, len(textWi.GetText(true)), 1, false) 105 | } 106 | textsLayout.SetBorder(true) 107 | secondColumn.AddItem(textsLayout, 0, 3, false) 108 | inputWi.SetBorder(true) 109 | secondColumn.AddItem(inputWi, 0, 1, true) 110 | layout.AddItem(secondColumn, 0, 3, true) 111 | 112 | pages.AddPage("game", layout, true, true).SendToBack("game") 113 | app.SetRoot(pages, true) 114 | 115 | keybindings(app, CreateWelcome) 116 | return nil 117 | } 118 | 119 | // paintDiff returns an tview-painted string displaying the difference 120 | func paintDiff(toColor string, differ string) (colorText string) { 121 | var h string 122 | if settings.I.Highlight == settings.HighlightBackground { 123 | h = ":" 124 | } 125 | 126 | for i := range differ { 127 | if i >= len(toColor) || differ[i] != toColor[i] { 128 | colorText += "[" + h + "red]" 129 | } else { 130 | colorText += "[" + h + "green]" 131 | } 132 | 133 | switch settings.I.ErrorDisplay { 134 | case settings.ErrorDisplayText: 135 | colorText += string(differ[i]) 136 | case settings.ErrorDisplayTyped: 137 | if i < len(toColor) { 138 | colorText += string(toColor[i]) 139 | } 140 | } 141 | } 142 | colorText += "[-:-:-]" 143 | 144 | if len(differ) < len(toColor) { 145 | colorText += toColor[len(differ):] 146 | } 147 | 148 | return 149 | } 150 | -------------------------------------------------------------------------------- /ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/gdamore/tcell" 5 | "github.com/rivo/tview" 6 | "github.com/shilangyu/typer-go/utils" 7 | ) 8 | 9 | func keybindings(app *tview.Application, goBack func(app *tview.Application) error) { 10 | if goBack != nil { 11 | app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 12 | if event.Key() == tcell.KeyEsc { 13 | app.QueueUpdateDraw(func() { 14 | utils.Check(goBack(app)) 15 | }) 16 | } 17 | 18 | return event 19 | }) 20 | } 21 | } 22 | 23 | // Center returns a new primitive which shows the provided primitive in its 24 | // center, given the provided primitive's size. 25 | // credits: https://github.com/rivo/tview/blob/master/demos/presentation/center.go 26 | func Center(width, height int, p tview.Primitive) tview.Primitive { 27 | return tview.NewFlex(). 28 | AddItem(tview.NewBox(), 0, 1, false). 29 | AddItem(tview.NewFlex(). 30 | SetDirection(tview.FlexRow). 31 | AddItem(tview.NewBox(), 0, 1, false). 32 | AddItem(p, height, 1, true). 33 | AddItem(tview.NewBox(), 0, 1, false), width, 1, true). 34 | AddItem(tview.NewBox(), 0, 1, false) 35 | } 36 | -------------------------------------------------------------------------------- /ui/welcome.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/rivo/tview" 5 | "github.com/shilangyu/typer-go/utils" 6 | ) 7 | 8 | // CreateWelcome creates welcome screen widgets 9 | func CreateWelcome(app *tview.Application) error { 10 | const welcomeSign = ` 11 | _ 12 | | |_ _ _ _ __ ___ _ __ __ _ ___ 13 | | __| | | | '_ \ / _ \ '__|____ / _ |/ _ \ 14 | | |_| |_| | |_) | __/ | |_____| (_| | (_) | 15 | \__|\__, | .__/ \___|_| \__, |\___/ 16 | |___/|_| |___/ 17 | ` 18 | signWi := tview.NewTextView().SetText(welcomeSign) 19 | menuWi := tview.NewList(). 20 | AddItem("single player", "test your typing skills offline!", 0, func() { 21 | utils.Check(CreateSingleplayer(app)) 22 | }). 23 | AddItem("multi player", "battle against other typers", 0, func() { 24 | utils.Check(CreateMultiplayerSetup(app)) 25 | }). 26 | AddItem("stats", "TO BE RELEASED", 0, nil). 27 | AddItem("settings", "change app settings", 0, func() { 28 | utils.Check(CreateSettings(app)) 29 | }). 30 | AddItem("exit", "exit the app", 0, func() { 31 | app.Stop() 32 | }) 33 | 34 | signW, signH := utils.StringDimensions(welcomeSign) 35 | menuW, menuH := 32, menuWi.GetItemCount()*2 36 | layout := tview.NewFlex(). 37 | SetDirection(tview.FlexRow). 38 | AddItem(tview.NewBox(), 0, 1, false). 39 | AddItem(Center(signW, signH, signWi), 0, 1, false). 40 | AddItem(Center(menuW, menuH, menuWi), 0, 1, true). 41 | AddItem(tview.NewBox(), 0, 1, false) 42 | 43 | app.SetRoot(layout, true) 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /utils/errors.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // Check checks for the error and panics if there is one 4 | func Check(err error) { 5 | if err != nil { 6 | panic(err) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /utils/strings.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "strings" 7 | ) 8 | 9 | // StringDimensions returns the width and height of a string 10 | // where w = longest line, h = amount of lines 11 | func StringDimensions(s string) (w, h int) { 12 | text := strings.Split(s, "\n") 13 | h = len(text) 14 | 15 | for _, line := range text { 16 | if len(line) > w { 17 | w = len(line) 18 | } 19 | } 20 | 21 | return 22 | } 23 | 24 | // Center takes an array of strings and adds spaces to center them 25 | func Center(s []string) (res []string) { 26 | maxW, _ := StringDimensions(strings.Join(s, "\n")) 27 | 28 | for _, text := range s { 29 | diff := maxW - len(text) 30 | text = strings.Repeat(" ", diff/2) + text + strings.Repeat(" ", diff-diff/2) 31 | res = append(res, text) 32 | } 33 | 34 | return 35 | } 36 | 37 | // IPv4 returns users ipv4 as a string 38 | func IPv4() (string, error) { 39 | conn, err := net.Dial("udp", "8.8.8.8:80") 40 | if err != nil { 41 | return "", errors.New("Failed to determine your IP") 42 | } 43 | localAddr := conn.LocalAddr().(*net.UDPAddr) 44 | myIP := localAddr.IP.String() 45 | conn.Close() 46 | return myIP, nil 47 | } 48 | --------------------------------------------------------------------------------