diff --git a/.gitignore b/.gitignore index 1b86803..7871689 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +__debug* /retrosheet +test.gm *.pdf /*.csv /.vscode diff --git a/20230916-1.gm b/20230916-1.gm deleted file mode 100644 index fbde4f7..0000000 --- a/20230916-1.gm +++ /dev/null @@ -1,81 +0,0 @@ -date: 9/16/23 -game: 1 -visitorid: pride-jf-16u -home: Ohana Tigers McKay -timelimit: 80m -tournament: Back to School Bash for Cash ---- -visitorplays -pitching 1 -1 17 BLBSFBFFX S7/L7 -2 6 B WP 1-2 -... MBFBB W -3 26 CBX 53/G5/B/SH 1-2 2-3 -4 11 BFX S8/G1 2-H 3-H -5 3 BB WP 1-2 -... BSB W+SB3 -6 7 X FC1/BG1/SH B-1 1-2 -7 12 BB WP 1-2 2-3 3-H -... SSC K -8 21 CBSBX 43/G4 -score 3 - -9 2 BFFBBB W -10 18 BBBCFB W 1-2 -11 00 BBFBFX 9/L9 -12 17 BCFFBFC K -13 6 BBX S3/G3/B 1-2 2-3 -14 26 FBBBB W 1-2 2-3 3-H -15 11 X S8/G4 B-2 1-3(E8) 2-H 3-H -16 3 BBCBSFS K -score 6 - -17 7 X S9/G4 -18 12 BB WP 1-2 -... SSFBS K -19 21 X 63/G6 2-H(E3/TH) -20 2 BX 43/G4 -score 7 - -21 18 X 6/P6 -22 00 BX S8/G8 -23 17 SBLBX S7/L7 1-2 -24 6 B PB 1-2 2-3 -... BFFBFB W+WP 2-3 3-H -25 26 CBX FC1/G1 B-1 1-3 3-H -26 11 BX FC4/G4 B-1 1-2(E6) 3-H -27 3 FBFFBS K -28 7 FBBFBB W 1-2 2-3 -29 12 CFBBS K -score 10 - -homeplays -pitching 11 -1 1 FSS K -2 17 BFBBCB W -3 11 FCX 9/F9 -4 18 BFBBFFB W 1-2 -5 5 SX S7/G6 1-3 2-H -6 32 BX E4/P4 B-1 1-3 3-H -alt 4/P4 : bad positioning -7 15 BCX 13/G1 -score 2 - -8 14 BBFBX 9/L9 -9 7 SBSBS K -10 16 BSFFBX 53/G5 -11 23 CFS K23 -12 20 CSS K23 -13 1 X 6/P6 -score 2 - -14 17 BFCBX 5/L5 -15 11 X H/F7 B-H -16 18 BSX 63/G6 -17 5 FX S7/G56 -18 32 X E5/G5/TH B-3(E9) 1-H -alt 53/G5 1-2 -19 15 X 3/P3 -final 4 - - diff --git a/cmd/newgame.go b/cmd/newgame.go index a020c2e..ef244eb 100644 --- a/cmd/newgame.go +++ b/cmd/newgame.go @@ -1,71 +1,10 @@ package cmd import ( - "fmt" - "os" - "path/filepath" - "strconv" - "github.com/slshen/sb/pkg/gamefile" "github.com/spf13/cobra" ) -func writeNewGame(gm *gamefile.File, nextDay bool) (*gamefile.File, error) { - ng := &gamefile.File{} - date, err := gm.GetGameDate() - if err != nil { - return nil, fmt.Errorf("%s does not have a game date - %w", gm.Path, err) - } - var numberString string - if nextDay { - date = date.AddDate(0, 0, 1) - numberString = "1" - } else { - number, _ := strconv.Atoi(gm.Properties["game"]) - numberString = fmt.Sprintf("%d", number+1) - } - dateString := date.Format(gamefile.GameDateFormat) - for _, prop := range gm.PropertyList { - switch { - case prop.Key == "date": - ng.PropertyList = append(ng.PropertyList, - &gamefile.Property{ - Key: "date", - Value: dateString, - }) - case prop.Key == "game": - ng.PropertyList = append(ng.PropertyList, - &gamefile.Property{ - Key: "game", - Value: numberString, - }) - case prop.Key == "visitorid" || prop.Key == "homeid" || - prop.Key == "tournament" || prop.Key == "league" || - prop.Key == "timelimit": - ng.PropertyList = append(ng.PropertyList, prop) - default: - ng.PropertyList = append(ng.PropertyList, - &gamefile.Property{ - Key: prop.Key, - }) - } - } - if err := ng.Validate(); err != nil { - return nil, err - } - file := filepath.Join(filepath.Dir(gm.Path), fmt.Sprintf("%s-%s.gm", date.Format("20060102"), numberString)) - fmt.Printf("Creating new game %s\n", file) - ng.Path = file - flags := os.O_CREATE | os.O_TRUNC | os.O_WRONLY | os.O_EXCL - f, err := os.OpenFile(file, flags, 0666) - if err != nil { - return nil, err - } - defer f.Close() - ng.Write(f) - return ng, nil -} - func newGameCommand() *cobra.Command { var ( nextDay bool @@ -80,7 +19,7 @@ func newGameCommand() *cobra.Command { return err } for i := 0; i < count; i++ { - ng, err := writeNewGame(gm, nextDay) + ng, err := gm.WriteNewGame(nextDay) if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index 14110dc..22cfed7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,6 +24,7 @@ func Root() *cobra.Command { fmtCommand(), altCommand(), webdataCommand(), newGameCommand(), battingCountCommand(), battingTimesSeenPitcherCommand(), pitchingTimesSeenLineupCommand(), simCommand(), + uiCommand(), ) return root } diff --git a/cmd/ui.go b/cmd/ui.go new file mode 100644 index 0000000..56dfe9f --- /dev/null +++ b/cmd/ui.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "log" + "os" + + "github.com/slshen/sb/pkg/ui" + "github.com/spf13/cobra" +) + +func uiCommand() *cobra.Command { + var ( + debugOut string + re reArgs + ) + c := &cobra.Command{ + Use: "ui", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var path string + if len(args) == 1 { + path = args[0] + } + ui := ui.New() + re, err := re.getRunExpectancy() + if err != nil { + return err + } + ui.RE = re + if debugOut != "" { + f, err := os.Create(debugOut) + if err != nil { + return err + } + defer f.Close() + ui.Logger = log.New(f, "", log.LstdFlags) + } + return ui.Run(path) + }, + } + c.Flags().StringVar(&debugOut, "debug-out", "", "Write debug logs to FILE") + re.registerFlags(c.Flags()) + return c +} diff --git a/go.mod b/go.mod index 6c59b30..56dc9c9 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.18 require ( github.com/alecthomas/participle/v2 v2.1.0 + github.com/gdamore/tcell/v2 v2.7.1 github.com/hashicorp/go-multierror v1.1.1 github.com/kr/text v0.2.0 github.com/mitchellh/go-wordwrap v1.0.1 github.com/mitchellh/mapstructure v1.5.0 + github.com/rivo/tview v0.0.0-20240921122403-a64fc48d7654 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.4 @@ -16,9 +18,16 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gdamore/encoding v1.0.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/term v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index e90516a..8c98b97 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.7.1 h1:TiCcmpWHiAU7F0rA2I3S2Y4mmLmO9KHxJ7E1QhYzQbc= +github.com/gdamore/tcell/v2 v2.7.1/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -21,6 +25,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -28,6 +36,12 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.0.0-20240921122403-a64fc48d7654 h1:oa+fljZiaJUVyiT7WgIM3OhirtwBm0LJA97LvWUlBu8= +github.com/rivo/tview v0.0.0-20240921122403-a64fc48d7654/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -37,6 +51,42 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/dataframe/data.go b/pkg/dataframe/data.go index df8ab86..86792cd 100644 --- a/pkg/dataframe/data.go +++ b/pkg/dataframe/data.go @@ -34,11 +34,7 @@ func FromStructs(name string, values interface{}) (*Data, error) { var idx *Index for i := 0; i < v.Len(); i++ { val := v.Index(i).Interface() - var err error idx = dat.AppendStruct(idx, val) - if err != nil { - return nil, err - } } return dat, nil } @@ -141,6 +137,15 @@ func (dat *Data) RApply(f func(row int)) { } } +func (dat *Data) GetColumn(name string) *Column { + for _, col := range dat.Columns { + if col.Name == name { + return col + } + } + return nil +} + func (dat *Data) GetRow(r int) []interface{} { row := make([]interface{}, len(dat.Columns)) for i, col := range dat.Columns { diff --git a/pkg/game/advance.go b/pkg/game/advance.go index a15b9f3..ef0ad34 100644 --- a/pkg/game/advance.go +++ b/pkg/game/advance.go @@ -1,7 +1,6 @@ package game import ( - "fmt" "regexp" "github.com/slshen/sb/pkg/gamefile" @@ -53,7 +52,7 @@ func (a *Advance) GoString() string { func parseAdvance(play gamefile.Play, s string) (*Advance, error) { m := advanceRegexp.FindStringSubmatch(s) if m == nil { - return nil, fmt.Errorf("%s: illegal advance code %s", play.GetPos(), s) + return nil, NewError("illegal advance code %s", play.GetPos(), s) } a := &Advance{ Code: s, @@ -70,12 +69,12 @@ func parseAdvance(play gamefile.Play, s string) (*Advance, error) { if f >= '1' && f <= '9' { a.Fielders = append(a.Fielders, int(f-'1')+1) } else { - return nil, fmt.Errorf("%s: illegal fielder %c for put out in advance code %s", + return nil, NewError("illegal fielder %c for put out in advance code %s", play.GetPos(), f, s) } } if len(a.Fielders) == 0 { - return nil, fmt.Errorf("%s: no fielders for put out in advancde code %s", + return nil, NewError("no fielders for put out in advancde code %s", play.GetPos(), s) } } @@ -110,20 +109,20 @@ func parseAdvances(play gamefile.Play, batter PlayerID, runners [3]PlayerID) (ad return } if advances.From(advance.From) != nil { - err = fmt.Errorf("%s: cannot advance %s twice in %s", play.GetPos(), advance.From, as) + err = NewError("cannot advance %s twice in %s", play.GetPos(), advance.From, as) return } if advance.From == "B" { advance.Runner = batter } else { /*if runners == nil { - err = fmt.Errorf("%s: no runner to advance from %s at the start of a half-inning", + err = NewError("no runner to advance from %s at the start of a half-inning", play.GetPos(), advance.From) return }*/ advance.Runner = runners[runnerNumber[advance.From]] if advance.Runner == "" { - err = fmt.Errorf("%s: no runner to advance from %s in %s", play.GetPos(), + err = NewError("no runner to advance from %s in %s", play.GetPos(), advance.From, as) return } diff --git a/pkg/game/error.go b/pkg/game/error.go new file mode 100644 index 0000000..70df737 --- /dev/null +++ b/pkg/game/error.go @@ -0,0 +1,23 @@ +package game + +import ( + "fmt" + + "github.com/slshen/sb/pkg/gamefile" +) + +type Error struct { + Pos gamefile.Position + Message string +} + +func NewError(template string, pos gamefile.Position, args ...any) Error { + return Error{ + Pos: pos, + Message: fmt.Sprintf("%s: %s", pos, fmt.Sprintf(template, args...)), + } +} + +func (e Error) Error() string { return e.Message } + +func (e Error) Position() gamefile.Position { return e.Pos } diff --git a/pkg/game/fieldingerror.go b/pkg/game/fieldingerror.go index 4552d57..efb5d7f 100644 --- a/pkg/game/fieldingerror.go +++ b/pkg/game/fieldingerror.go @@ -16,10 +16,10 @@ var NoError = FieldingError{} func parseFieldingError(play gamefile.Play, s string) (FieldingError, error) { if len(s) < 2 || s[0] != 'E' || (len(s) > 2 && s[2] != '/') { - return FieldingError{}, fmt.Errorf("%s: illegal error code %s", play.GetPos(), s) + return FieldingError{}, NewError("illegal error code %s", play.GetPos(), s) } if s[1] < '1' || s[1] > '9' { - return FieldingError{}, fmt.Errorf("%s: illegal fielder %c in error code %s", play.GetPos(), s[1], s) + return FieldingError{}, NewError("illegal fielder %c in error code %s", play.GetPos(), s[1], s) } fe := FieldingError{ Fielder: int(s[1] - '0'), diff --git a/pkg/game/game.go b/pkg/game/game.go index d0ca7da..72110b9 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -1,7 +1,6 @@ package game import ( - "fmt" "os" "path/filepath" "regexp" @@ -146,10 +145,10 @@ func ReadGameFile(path string) (*Game, error) { if err != nil { return nil, err } - return newGame(gf) + return NewGame(gf) } -func newGame(gf *gamefile.File) (*Game, error) { +func NewGame(gf *gamefile.File) (*Game, error) { g := &Game{ File: gf, Tournament: gf.Properties["tournament"], @@ -251,7 +250,7 @@ func (g *Game) runPlays(battingTeam, fieldingTeam *Team, half Half, events []*ga if events == nil { return } - m := newGameMachine(half, battingTeam, fieldingTeam) + m := newGameMachine(battingTeam, fieldingTeam) lastState := &State{ InningNumber: 1, Half: half, @@ -262,7 +261,7 @@ func (g *Game) runPlays(battingTeam, fieldingTeam *Team, half Half, events []*ga } if m.final { errs = multierror.Append(errs, - fmt.Errorf("%s: cannot have more plays after final score", event.Pos)) + NewError("cannot have more plays after final score", event.Pos)) break } switch { @@ -297,7 +296,7 @@ func (g *Game) runPlays(battingTeam, fieldingTeam *Team, half Half, events []*ga state.Comment = event.Alternative.Comment if g.altStates[lastState] != nil { errs = multierror.Append(errs, - fmt.Errorf("%s: only a single alternate state is allowed", event.Pos)) + NewError("only a single alternate state is allowed", event.Pos)) } else { g.altStates[lastState] = state } diff --git a/pkg/game/gamemachine.go b/pkg/game/gamemachine.go index 7bda126..d55cb40 100644 --- a/pkg/game/gamemachine.go +++ b/pkg/game/gamemachine.go @@ -19,7 +19,7 @@ type gameMachine struct { modifiers Modifiers } -func newGameMachine(half Half, battingTeam, fieldingTeam *Team) *gameMachine { +func newGameMachine(battingTeam, fieldingTeam *Team) *gameMachine { m := &gameMachine{ battingTeam: battingTeam, fieldingTeam: fieldingTeam, @@ -74,7 +74,7 @@ func (m *gameMachine) handleActualPlay(play *gamefile.ActualPlay, lastState *Sta state.PlateAppearance.Number = play.PlateAppearance.Int() if play.ContinuedPlateAppearance { if state.LastState == nil { - return nil, fmt.Errorf("%s: ... can only be used to continue a plate appearance", play.GetPos()) + return nil, NewError("... can only be used to continue a plate appearance", play.GetPos()) } state.Pitches = state.LastState.Pitches + Pitches(play.PitchSequence) state.Batter = state.LastState.Batter @@ -83,7 +83,7 @@ func (m *gameMachine) handleActualPlay(play *gamefile.ActualPlay, lastState *Sta state.Pitches = Pitches(play.PitchSequence) } if state.Batter == "" { - return nil, fmt.Errorf("%s: no batter for %s", play.GetPos(), play.GetCode()) + return nil, NewError("no batter for %s", play.GetPos(), play.GetCode()) } err := m.handlePlay(play, state) return state, err @@ -93,7 +93,7 @@ func (m *gameMachine) handlePlay(play gamefile.Play, state *State) error { state.PlayCode = play.GetCode() state.AdvancesCodes = play.GetAdvances() if state.PlayCode == "" { - return fmt.Errorf("%s: empty event code in %s", play.GetPos(), play.GetCode()) + return NewError("empty event code in %s", play.GetPos(), play.GetCode()) } m.basePutOuts = nil if err := m.parseAdvances(play, state); err != nil { @@ -115,32 +115,32 @@ func (m *gameMachine) handlePlay(play gamefile.Play, state *State) error { _, balls, strikes := state.Pitches.Count() if state.Play.IsBallInPlay() { if strikes > 2 { - return fmt.Errorf("%s: cannot put ball in play with %d strikes (%s)", state.Pos, strikes, play.GetCode()) + return NewError("cannot put ball in play with %d strikes (%s)", state.Pos, strikes, play.GetCode()) } if balls > 3 { - return fmt.Errorf("%s: cannot put ball in play with %d balls (%s)", state.Pos, balls, play.GetCode()) + return NewError("cannot put ball in play with %d balls (%s)", state.Pos, balls, play.GetCode()) } } if state.Play.IsStrikeOut() { if state.Pitches[len(state.Pitches)-1] == 'X' { - return fmt.Errorf("%s: strike out pitch sequence should not end in X", state.Pos) + return NewError("strike out pitch sequence should not end in X", state.Pos) } if strikes != 3 { - return fmt.Errorf("%s: must strike out with 3 strikes", state.Pos) + return NewError("must strike out with 3 strikes", state.Pos) } if balls > 3 { - return fmt.Errorf("%s: cannot strike out with more than 3 balls", state.Pos) + return NewError("cannot strike out with more than 3 balls", state.Pos) } } if state.Play.IsWalk() { if state.Pitches[len(state.Pitches)-1] == 'X' { - return fmt.Errorf("%s: walk pitch sequence should not end in X", state.Pos) + return NewError("walk pitch sequence should not end in X", state.Pos) } if strikes > 2 { - return fmt.Errorf("%s: cannot walk with more than 2 strikes", state.Pos) + return NewError("cannot walk with more than 2 strikes", state.Pos) } if balls != 4 { - return fmt.Errorf("%s: must walk with 4 balls", state.Pos) + return NewError("must walk with 4 balls", state.Pos) } } if !strings.HasSuffix(string(state.Pitches), "X") && @@ -169,19 +169,19 @@ func (m *gameMachine) handleSpecialEvent(event *gamefile.Event, state *State) (* } if event.Score != "" { if state.Outs != 3 { - return nil, fmt.Errorf("%s: the inning with %d outs has not ended after %s", + return nil, NewError("the inning with %d outs has not ended after %s", event.Pos, state.Outs, state.PlayCode) } score, err := strconv.Atoi(event.Score) if err != nil || state.Score != score { - return nil, fmt.Errorf("%s: in inning %d # runs is %d not %s", event.Pos, + return nil, NewError("in inning %d # runs is %d not %s", event.Pos, state.InningNumber, state.Score, event.Score) } } if event.Final != "" { score, err := strconv.Atoi(event.Final) if err != nil || state.Score != score { - return nil, fmt.Errorf("%s: in inning %d final score is %d not %s", event.Pos, + return nil, NewError("in inning %d final score is %d not %s", event.Pos, state.InningNumber, state.Score, event.Score) } m.final = true @@ -190,10 +190,10 @@ func (m *gameMachine) handleSpecialEvent(event *gamefile.Event, state *State) (* runner := m.battingTeam.parsePlayerID(event.RAdjRunner.String()) base := event.RAdjBase if runner == "" || !(base == "1" || base == "2" || base == "3") { - return nil, fmt.Errorf("%s: invalid base %s for radj", event.Pos, event.RAdjBase) + return nil, NewError("invalid base %s for radj", event.Pos, event.RAdjBase) } if state.Outs != 3 { - return nil, fmt.Errorf("%s: radj must be at the inning start", event.Pos) + return nil, NewError("radj must be at the inning start", event.Pos) } lastState := &State{ InningNumber: state.InningNumber + 1, @@ -245,7 +245,7 @@ func (m *gameMachine) handlePlayCode(play gamefile.Play, state *State) error { } } if !ok { - return fmt.Errorf("%s: cannot score SacrificeFly unless a runner scores", play.GetPos()) + return NewError("cannot score SacrificeFly unless a runner scores", play.GetPos()) } } state.recordOut() @@ -288,11 +288,11 @@ func (m *gameMachine) handlePlayCode(play gamefile.Play, state *State) error { case pp.playIs("K+PO%($$)") || pp.playIs("K+PO%(E$)"): from := pp.playMatches[0] if !(from == "1" || from == "2" || from == "3") { - return fmt.Errorf("%s: illegal picked off base in %s", play.GetPos(), pp.playCode) + return NewError("illegal picked off base in %s", play.GetPos(), pp.playCode) } _, err := state.GetBaseRunner(from) if err != nil { - return fmt.Errorf("%s: cannot pick off in %s - %w", play.GetPos(), pp.playCode, err) + return NewError("cannot pick off in %s - %w", play.GetPos(), pp.playCode, err) } state.Play = Play{ Type: StrikeOutPickedOff, @@ -311,7 +311,7 @@ func (m *gameMachine) handlePlayCode(play gamefile.Play, state *State) error { state.recordOut() m.putOut(from) } else { - return fmt.Errorf("%s: picked off runner on %s cannot advance", play.GetPos(), from) + return NewError("picked off runner on %s cannot advance", play.GetPos(), from) } } case pp.playIs("W+WP"): @@ -426,7 +426,7 @@ func (m *gameMachine) handlePlayCode(play gamefile.Play, state *State) error { case pp.playIs("E$"): fe, err := parseFieldingError(play, pp.playCode) if err != nil { - return fmt.Errorf("%s: cannot parse fielding error in %s - %w", play.GetPos(), + return NewError("cannot parse fielding error in %s - %w", play.GetPos(), pp.playCode, err) } state.Play = Play{ @@ -446,7 +446,7 @@ func (m *gameMachine) handlePlayCode(play gamefile.Play, state *State) error { } } if fielder == 0 { - return fmt.Errorf("%s: no fielder in catcher's interference", play.GetPos()) + return NewError("no fielder in catcher's interference", play.GetPos()) } state.Play = Play{ Type: CatcherInterference, @@ -482,7 +482,7 @@ func (m *gameMachine) handlePlayCode(play gamefile.Play, state *State) error { case pp.playIs("$(B)$(%)") || pp.playIs("$(B)$$(%)") || pp.playIs("$(B)$$$(%)"): if !m.modifiers.Contains("LDP", "FDP") { - return fmt.Errorf("%s: play should contain LDP or FDP modifier in %s (%v)", + return NewError("play should contain LDP or FDP modifier in %s (%v)", play.GetPos(), pp.playCode, state.Modifiers) } base := pp.playMatches[len(pp.playMatches)-1] @@ -519,25 +519,25 @@ func (m *gameMachine) handlePlayCode(play gamefile.Play, state *State) error { } // no play default: - return fmt.Errorf("%s: unknown play %s", play.GetPos(), play.GetCode()) + return NewError("unknown play %s", play.GetPos(), play.GetCode()) } return nil } func (m *gameMachine) handleGroundBallDoublePlay(play gamefile.Play, state *State, pp playCodeParser, runnerBase string) error { if !m.modifiers.Contains("GDP") { - return fmt.Errorf("%s: play should contain GDP modifier in %s", play.GetPos(), pp.playCode) + return NewError("play should contain GDP modifier in %s", play.GetPos(), pp.playCode) } _, err := state.GetBaseRunner(runnerBase) if err != nil { - return fmt.Errorf("%s: no runner in double play %s - %w", play.GetPos(), pp.playCode, err) + return NewError("no runner in double play %s - %w", play.GetPos(), pp.playCode, err) } state.Play = Play{ Type: DoublePlay, } nextBase := NextBase[runnerBase] if nextBase == "" { - return fmt.Errorf("%s: double play runner cannot be at %s", play.GetPos(), runnerBase) + return NewError("double play runner cannot be at %s", play.GetPos(), runnerBase) } paren := strings.IndexRune(pp.playCode, '(') fielders := pp.playCode[0:paren] @@ -558,7 +558,7 @@ func (m *gameMachine) handleCaughtStealing(play gamefile.Play, state *State, pp advance := state.Advances.From(from) runner, err := state.GetBaseRunner(from) if err != nil { - return fmt.Errorf("%s: cannot catch stealing runner in %s - %w", play.GetPos(), pp.playCode, err) + return NewError("cannot catch stealing runner in %s - %w", play.GetPos(), pp.playCode, err) } state.Play = Play{ Type: CaughtStealing, @@ -578,11 +578,11 @@ func (m *gameMachine) handleCaughtStealing(play gamefile.Play, state *State, pp func (m *gameMachine) handlePickedoff(play gamefile.Play, state *State, pp playCodeParser, playType PlayType, fieldingError FieldingError) error { from := pp.playMatches[0] if !(from == "1" || from == "2" || from == "3") { - return fmt.Errorf("%s: illegal picked off base %s", play.GetPos(), from) + return NewError("illegal picked off base %s", play.GetPos(), from) } runner, err := state.GetBaseRunner(from) if err != nil { - return fmt.Errorf("%s: cannot pick off runner - %w", play.GetPos(), err) + return NewError("cannot pick off runner - %w", play.GetPos(), err) } state.Play = Play{ Type: playType, @@ -601,7 +601,7 @@ func (m *gameMachine) handlePickedoff(play gamefile.Play, state *State, pp playC func (m *gameMachine) handleStolenBase(play gamefile.Play, state *State, eventMatches []string) error { if state.LastState == nil { - return fmt.Errorf("%s: cannot steal bases at the start of a half-inning", play.GetPos()) + return NewError("cannot steal bases at the start of a half-inning", play.GetPos()) } for i := range eventMatches { base := eventMatches[i] @@ -620,13 +620,13 @@ func (m *gameMachine) handleStolenBase(play gamefile.Play, state *State, eventMa adv = m.impliedAdvance(play, state, "3-H") runner = state.LastState.Runners[2] default: - return fmt.Errorf("%s: unknown stolen base code", play.GetPos()) + return NewError("unknown stolen base code", play.GetPos()) } adv.Runner = runner adv.Steal = true state.Play.StolenBases = append(state.Play.StolenBases, base) if runner == "" { - return fmt.Errorf("%s: no runner can steal %s", play.GetPos(), base) + return NewError("no runner can steal %s", play.GetPos(), base) } } return nil @@ -656,10 +656,10 @@ func (m *gameMachine) moveRunners(play gamefile.Play, state *State) error { to := BaseNumber[advance.To] switch { case state.LastState == nil && advance.From != "B": - return fmt.Errorf("%s: cannot advance a runner from %s to %s at start of half-inning", + return NewError("cannot advance a runner from %s to %s at start of half-inning", play.GetPos(), advance.From, advance.To) case advance.From != "B" && state.LastState != nil && state.LastState.Runners[from] == "": - return fmt.Errorf("%s: cannot advance non-existent runner from %s", + return NewError("cannot advance non-existent runner from %s", play.GetPos(), advance.From) case advance.Out: state.recordOut() @@ -674,13 +674,13 @@ func (m *gameMachine) moveRunners(play gamefile.Play, state *State) error { } case advance.From == "B": if state.Runners[to] != "" && !isFieldersChoice3rdOut(state) { - return fmt.Errorf("%s: cannot advance batter-runner %s to %d because it's already occupied by %s", + return NewError("cannot advance batter-runner %s to %d because it's already occupied by %s", play.GetPos(), state.Batter, to+1, state.Runners[to]) } state.Runners[to] = state.Batter default: if state.Runners[to] != "" && !isFieldersChoice3rdOut(state) { - return fmt.Errorf("%s: cannot advance runner %s to %d because it's already occupied by %s", + return NewError("cannot advance runner %s to %d because it's already occupied by %s", play.GetPos(), state.LastState.Runners[from], to+1, state.Runners[to]) } state.Runners[to] = state.LastState.Runners[from] diff --git a/pkg/game/state.go b/pkg/game/state.go index 272f6eb..adcd07f 100644 --- a/pkg/game/state.go +++ b/pkg/game/state.go @@ -4,14 +4,13 @@ import ( "fmt" "regexp" "strings" + + "github.com/slshen/sb/pkg/gamefile" ) type Pitches string type PlayerID string -type FileLocation struct { - Filename string - Line int -} +type FileLocation = gamefile.Position type Half string @@ -50,10 +49,6 @@ type PlateAppearance struct { Modifiers `yaml:",omitempty,flow"` } -func (pos FileLocation) String() string { - return fmt.Sprintf("%s:%d", pos.Filename, pos.Line) -} - func (state *State) Top() bool { return state.Half == Top } @@ -69,16 +64,16 @@ func (state *State) GetRunsScored() int { func (state *State) GetBaseRunner(base string) (runner PlayerID, err error) { if base == "H" { - err = fmt.Errorf("a runner cannot be at H") + err = NewError("a runner cannot be at H", state.Pos) return } if state.LastState == nil || (state.LastState.InningNumber != state.InningNumber) { - err = fmt.Errorf("no runners are on base at the start of a half-inning") + err = NewError("no runners are on base at the start of a half-inning", state.Pos) return } runner = state.LastState.Runners[runnerNumber[base]] if runner == "" { - err = fmt.Errorf("no runner on %s", base) + err = NewError("no runner on %s", state.Pos, base) } return } diff --git a/pkg/gamefile/file.go b/pkg/gamefile/file.go index c7912d1..d278d4b 100644 --- a/pkg/gamefile/file.go +++ b/pkg/gamefile/file.go @@ -34,7 +34,7 @@ type TeamEvents struct { type Property struct { Pos Position Key string `parser:"@Ident"` - Value string `parser:"@Text (NL|EOF)"` + Value string `parser:"@Text (NL+|EOF)"` } type Event struct { @@ -101,6 +101,10 @@ func (n Numbers) Int() int { return i } +func (f *File) Parse(r io.Reader) error { + return nil +} + func (f *File) Validate() error { f.Properties = make(map[string]string) f.PropertyPos = make(map[string]Position) diff --git a/pkg/gamefile/newgame.go b/pkg/gamefile/newgame.go new file mode 100644 index 0000000..a5afa23 --- /dev/null +++ b/pkg/gamefile/newgame.go @@ -0,0 +1,61 @@ +package gamefile + +import ( + "fmt" + "os" + "path/filepath" + "strconv" +) + +func (f *File) WriteNewGame(nextDay bool) (*File, error) { + ng := &File{} + date, err := f.GetGameDate() + if err != nil { + return nil, fmt.Errorf("%s does not have a game date - %w", f.Path, err) + } + var numberString string + if nextDay { + date = date.AddDate(0, 0, 1) + numberString = "1" + } else { + number, _ := strconv.Atoi(f.Properties["game"]) + numberString = fmt.Sprintf("%d", number+1) + } + dateString := date.Format(GameDateFormat) + for _, prop := range f.PropertyList { + switch { + case prop.Key == "date": + ng.PropertyList = append(ng.PropertyList, + &Property{ + Key: "date", + Value: dateString, + }) + case prop.Key == "game": + ng.PropertyList = append(ng.PropertyList, + &Property{ + Key: "game", + Value: numberString, + }) + case prop.Key == "visitorid" || prop.Key == "homeid" || + prop.Key == "tournament" || prop.Key == "league" || + prop.Key == "timelimit": + ng.PropertyList = append(ng.PropertyList, prop) + default: + ng.PropertyList = append(ng.PropertyList, + &Property{ + Key: prop.Key, + }) + } + } + if err := ng.Validate(); err != nil { + return nil, err + } + file := filepath.Join(filepath.Dir(f.Path), fmt.Sprintf("%s-%s.gm", date.Format("20060102"), numberString)) + ng.Path = file + fd, err := os.Create(file) + if err != nil { + return nil, err + } + ng.Write(fd) + return ng, fd.Close() +} diff --git a/pkg/gamefile/parser.go b/pkg/gamefile/parser.go index 98583fb..5183aee 100644 --- a/pkg/gamefile/parser.go +++ b/pkg/gamefile/parser.go @@ -6,7 +6,16 @@ import ( "github.com/alecthomas/participle/v2" ) -var parser = participle.MustBuild[File](participle.Lexer(gameFileDef)) +var Parser = participle.MustBuild[File](participle.Lexer(gameFileDef)) + +func ParseString(path string, text string) (*File, error) { + file, err := Parser.ParseString(path, text) + if err == nil { + file.Path = path + err = file.Validate() + } + return file, err +} func ParseFile(path string) (*File, error) { f, err := os.Open(path) @@ -14,7 +23,7 @@ func ParseFile(path string) (*File, error) { return nil, err } defer f.Close() - file, err := parser.Parse(path, f) + file, err := Parser.Parse(path, f) if err != nil { return nil, err } diff --git a/pkg/gamefile/parser_test.go b/pkg/gamefile/parser_test.go index c226208..3c128cb 100644 --- a/pkg/gamefile/parser_test.go +++ b/pkg/gamefile/parser_test.go @@ -8,7 +8,7 @@ import ( func TestParser(t *testing.T) { assert := assert.New(t) - assert.NotNil(parser) + assert.NotNil(Parser) f, err := ParseFile("testdata/test.gm") if !assert.NoError(err) { return diff --git a/pkg/ui/chooser.go b/pkg/ui/chooser.go new file mode 100644 index 0000000..1ab21d0 --- /dev/null +++ b/pkg/ui/chooser.go @@ -0,0 +1,79 @@ +package ui + +import ( + "fmt" + "os" + "path" + "sort" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type Chooser struct { + *tview.Grid + done func(string, bool) + dir string + files []string +} + +func NewChooser(dir string, name string, done func(string, bool)) *Chooser { + c := &Chooser{ + Grid: tview.NewGrid(), + done: done, + dir: dir, + } + files, index := listGameFiles(dir, name) + c.files = files + list := tview.NewList(). + ShowSecondaryText(false). + SetSelectedFunc(c.selectFile) + list.SetBorder(true).SetTitle("Choose a game (ESC closes)") + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + c.done("", false) + return nil + } + return event + }) + for _, file := range files { + list.AddItem(file, "", 0, nil) + } + list.AddItem("", "", 0, nil) + if index >= 0 { + list.SetCurrentItem(index) + } + c.SetColumns(2, 0, 2).SetRows(2, 0, 2).AddItem(list, 1, 1, 1, 1, 0, 0, true) + return c +} + +func (c *Chooser) selectFile(i int, _ string, _ string, _ rune) { + switch { + case i < len(c.files): + c.done(path.Join(c.dir, c.files[i]), false) + case len(c.files) == 0: + c.done(path.Join(c.dir, fmt.Sprintf("%s-1.gm", time.Now().Format("20060102"))), false) + default: + c.done(path.Join(c.dir, c.files[len(c.files)-1]), true) + } +} + +func listGameFiles(dir string, name string) (result []string, index int) { + entries, _ := os.ReadDir(dir) + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".gm") { + result = append(result, entry.Name()) + } + } + sort.Strings(result) + index = -1 + for i := range result { + if result[i] == name { + index = i + break + } + } + return +} diff --git a/pkg/ui/lines.go b/pkg/ui/lines.go new file mode 100644 index 0000000..1a79c3c --- /dev/null +++ b/pkg/ui/lines.go @@ -0,0 +1,51 @@ +package ui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// TextArea doesn't provide GetSelectedStyle() so just assume it's the default +var defaultSelectedStyle = tcell.StyleDefault.Background(tview.Styles.PrimaryTextColor). + Foreground(tview.Styles.PrimitiveBackgroundColor) + +type LinedTextArea struct { + *tview.TextArea + LineColors map[int]*tcell.Color +} + +func NewLinedtextArea() *LinedTextArea { + t := &LinedTextArea{ + TextArea: tview.NewTextArea(), + LineColors: map[int]*tcell.Color{}, + } + return t +} + +func (t *LinedTextArea) ClearColors() { + for k := range t.LineColors { + delete(t.LineColors, k) + } +} + +func (t *LinedTextArea) Draw(screen tcell.Screen) { + t.TextArea.Draw(screen) + firstLine, _ := t.GetOffset() + x, y, width, height := t.GetInnerRect() + for row := y; row < y+height; row++ { + line := firstLine + row - y + lineColor := t.LineColors[line] + if lineColor != nil { + for col := x; col < x+width; { + ch, chc, chStyle, chw := screen.GetContent(col, row) + if chStyle == defaultSelectedStyle { + chStyle = chStyle.Foreground(*lineColor) + } else { + chStyle = chStyle.Background(*lineColor) + } + screen.SetContent(col, row, ch, chc, chStyle) + col += chw + } + } + } +} diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go new file mode 100644 index 0000000..eb59785 --- /dev/null +++ b/pkg/ui/ui.go @@ -0,0 +1,466 @@ +package ui + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "log" + "os" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/hashicorp/go-multierror" + "github.com/rivo/tview" + "github.com/slshen/sb/pkg/boxscore" + "github.com/slshen/sb/pkg/game" + "github.com/slshen/sb/pkg/gamefile" + "github.com/slshen/sb/pkg/stats" +) + +type UI struct { + Logger *log.Logger + RE stats.RunExpectancy + path string + app *tview.Application + root *tview.Pages + box *tview.TextView + properties *LinedTextArea + visitorPlaysStart int + visitorPlays *LinedTextArea + homePlaysStart int + homePlays *LinedTextArea + messages *tview.TextView + lastKey time.Time + lastUpdate time.Time + focusOrder []tview.Primitive + status *tview.TextView + dialog tview.Primitive + modified bool +} + +var errorColor = tcell.ColorBlue + +func New() *UI { + ui := &UI{ + Logger: log.New(io.Discard, "", 0), + app: tview.NewApplication(), + root: tview.NewPages(), + properties: NewLinedtextArea(), + visitorPlays: NewLinedtextArea(), + homePlays: NewLinedtextArea(), + box: tview.NewTextView(), + messages: tview.NewTextView().SetDynamicColors(true), + status: tview.NewTextView().SetTextAlign(tview.AlignRight), + } + for _, box := range []any{ui.properties, ui.box, ui.visitorPlays, ui.homePlays, ui.messages} { + box.(interface{ SetBorder(bool) *tview.Box }).SetBorder(true) + } + ui.focusOrder = []tview.Primitive{ui.properties, ui.visitorPlays, ui.homePlays} + flex := tview.NewFlex().SetDirection(tview.FlexRow) + flex.AddItem(tview.NewFlex(). + AddItem(ui.properties, 0, 1, true). + AddItem(ui.box, 0, 1, false), + 7, 0, true) + flex.AddItem(tview.NewFlex(). + AddItem(ui.visitorPlays, 0, 1, false). + AddItem(ui.homePlays, 0, 1, false), + 0, 1, false). + AddItem(ui.messages, 6, 0, false). + AddItem(tview.NewFlex(). + AddItem(tview.NewTextView().SetText("Quit:^Q Save:^S Choose:^L"), 0, 4, false). + AddItem(ui.status, 0, 1, false), + 1, 0, false) + ui.root.AddAndSwitchToPage("main", flex, true) + ui.app. + EnableMouse(true). + SetRoot(ui.root, true). + SetInputCapture(ui.inputHandler) + return ui +} + +func (ui *UI) update() { + t0 := time.Now() + var text string + ui.app.QueueUpdate(func() { + text = ui.getGameText() + }) + file, err := gamefile.ParseString(ui.path, text) + var ( + msg string + boxScore string + gm *game.Game + ) + if err != nil { + msg = err.Error() + } else { + gm, err = game.NewGame(file) + if err != nil { + msg = err.Error() + } else { + box, err := boxscore.NewBoxScore(gm, ui.RE) + if err != nil { + msg = err.Error() + } else if box.HomeLineup.TeamStats != nil && box.VisitorLineup.TeamStats != nil { + scoreTable := box.InningScoreTable() + scoreTable.Columns[0].Name = "" + rc := box.AltPlays().GetColumn("RCost").GetSummary() + boxScore = fmt.Sprintf("%s\nMisplay runs = %.2f", scoreTable, rc) + } + } + } + ui.Logger.Println("updating message: ", msg) + ui.Logger.Println("update took ", time.Since(t0)) + ui.app.QueueUpdateDraw(func() { + ui.messages.SetText(msg) + if boxScore != "" { + ui.box.SetText(boxScore) + } + if gm != nil { + ui.homePlays.SetTitle(gm.Home.Name) + ui.visitorPlays.SetTitle(gm.Visitor.Name) + } + ui.properties.ClearColors() + ui.homePlays.ClearColors() + ui.visitorPlays.ClearColors() + for _, err := range allErrors(err) { + if gerr, ok := err.(interface{ Position() gamefile.Position }); ok { + line := gerr.Position().Line - 1 + switch { + case line < ui.visitorPlaysStart: + ui.Logger.Println("highlighting properties line ", line) + ui.properties.LineColors[line] = &errorColor + case line < ui.homePlaysStart: + ui.Logger.Println("highlighting visitor plays line ", line-ui.visitorPlaysStart) + ui.visitorPlays.LineColors[line-ui.visitorPlaysStart] = &errorColor + default: + ui.Logger.Println("highlighting home plays line ", line-ui.homePlaysStart) + ui.homePlays.LineColors[line-ui.homePlaysStart] = &errorColor + } + } + } + }) +} + +func allErrors(err error) []error { + if err == nil { + return nil + } + if m, ok := err.(*multierror.Error); ok && m.Len() > 0 { + return m.Errors + } + return []error{err} +} + +func (ui *UI) getGameText() string { + var buf bytes.Buffer + fmt.Fprint(&buf, ui.properties.GetText()) + if buf.Len() > 0 && buf.Bytes()[buf.Len()-1] != '\n' { + fmt.Fprintln(&buf) + } + fmt.Fprintln(&buf, "---") + fmt.Fprintln(&buf, "visitorplays") + ui.visitorPlaysStart = lineCount(buf.String()) + visitorPlaysText := ui.visitorPlays.GetText() + fmt.Fprint(&buf, visitorPlaysText) + ui.homePlaysStart = ui.visitorPlaysStart + 1 + lineCount(visitorPlaysText) + if buf.Bytes()[buf.Len()-1] != '\n' { + fmt.Fprintln(&buf) + ui.homePlaysStart++ + } + fmt.Fprintln(&buf, "homeplays") + fmt.Fprintln(&buf, ui.homePlays.GetText()) + ui.lastUpdate = time.Now() + ui.Logger.Println("got game text at ", ui.lastUpdate) + return buf.String() +} + +func lineCount(s string) (count int) { + for _, ch := range s { + if ch == '\n' { + count++ + } + } + return count +} + +func (ui *UI) parseGame(gamePath string) { + ui.path = gamePath + ui.status.SetText(path.Base(ui.path)) + ui.box.SetText("") + ui.messages.SetText("") + ui.properties.SetText("", false) + ui.homePlays.SetText("", false) + ui.visitorPlays.SetText("", false) + f, err := os.Open(ui.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return + } + ui.messages.SetText(fmt.Sprintf("cannot parse %s: %s", ui.path, err)) + return + } + defer f.Close() + scanner := bufio.NewScanner(f) + state := "props" + var ( + buf strings.Builder + targetPlays *LinedTextArea + ) + for scanner.Scan() { + line := scanner.Text() + switch { + case state == "props": + if line == "---" { + state = "plays" + ui.properties.SetText(buf.String(), false) + buf.Reset() + } else { + fmt.Fprintln(&buf, line) + } + case state == "plays" && line == "homeplays": + state = "homeplays" + targetPlays = ui.homePlays + case state == "plays" && line == "visitorplays": + state = "visitorplays" + targetPlays = ui.visitorPlays + case targetPlays != nil: + if (line == "homeplays" || line == "visitorplays") && line != state { + targetPlays.SetText(buf.String(), false) + buf.Reset() + if state == "homeplays" { + targetPlays = ui.visitorPlays + state = "visitorplays" + } else { + targetPlays = ui.homePlays + state = "homeplays" + } + } else { + fmt.Fprintln(&buf, line) + } + } + } + if targetPlays != nil { + targetPlays.SetText(buf.String(), false) + } + ui.lastKey = time.Now() +} + +func (ui *UI) inputHandler(event *tcell.EventKey) *tcell.EventKey { + var focusInc int + switch event.Key() { + case tcell.KeyCtrlQ: + ui.app.Stop() + case tcell.KeyCtrlC: + return nil + case tcell.KeyCtrlS: + if ui.dialog == nil { + ui.save() + } + return nil + case tcell.KeyCtrlL: + if ui.dialog == nil { + ui.showDialog(NewChooser(path.Dir(ui.path), ui.path, func(s string, newgame bool) { + ui.closeDialog() + if s != "" { + if newgame { + ui.newGame(s) + } else { + ui.parseGame(s) + } + } + })) + } + return nil + case tcell.KeyCtrlP: + return tcell.NewEventKey(tcell.KeyUp, 0, 0) + case tcell.KeyCtrlN: + return tcell.NewEventKey(tcell.KeyDown, 0, 0) + case tcell.KeyCtrlF: + return tcell.NewEventKey(tcell.KeyRight, 0, 0) + case tcell.KeyCtrlB: + return tcell.NewEventKey(tcell.KeyLeft, 0, 0) + case tcell.KeyUp: + case tcell.KeyDown: + case tcell.KeyLeft: + case tcell.KeyRight: + break + case tcell.KeyTAB: + focusInc = 1 + case tcell.KeyBacktab: + focusInc = -1 + default: + switch { + case ui.properties.HasFocus(): + fallthrough + case ui.homePlays.HasFocus(): + fallthrough + case ui.visitorPlays.HasFocus(): + if !ui.modified { + ui.modified = true + ui.status.SetText(ui.status.GetText(false) + "*") + } + ui.lastKey = event.When() + ui.Logger.Println("got key at ", ui.lastKey) + } + } + if focusInc != 0 { + f := ui.app.GetFocus() + j := 0 + for i := range ui.focusOrder { + if ui.focusOrder[i] == f { + j = i + focusInc + if j < 0 { + j = len(ui.focusOrder) - 1 + } else if j == len(ui.focusOrder) { + j = 0 + } + } + } + ui.app.SetFocus(ui.focusOrder[j]) + return nil + } + return event +} + +func (ui *UI) newGame(gamePath string) { + file, _ := gamefile.ParseFile(gamePath) + if file != nil { + newFile, _ := file.WriteNewGame(false) + if newFile != nil { + ui.parseGame(newFile.Path) + return + } + } + m := regexp.MustCompile(`(^.*-)([0-9]+)\.gm$`).FindStringSubmatch(gamePath) + var newGamePath string + if m != nil { + n, _ := strconv.Atoi(m[2]) + if n != 0 { + newGamePath = m[1] + fmt.Sprintf("%d", n+1) + ".gm" + } + } + if newGamePath == "" { + newGamePath = path.Join(path.Dir(gamePath), time.Now().Format(gamefile.GameDateFormat)+"-1.gm") + } + ui.parseGame(newGamePath) +} + +func (ui *UI) save() { + text := ui.getGameText() + var canonName string + gf, _ := gamefile.ParseString(ui.path, text) + if gf != nil { + gm, _ := game.NewGame(gf) + if gm != nil && gm.GetDate().Unix() != 0 && gm.Number != "" { + canonName = fmt.Sprintf("%s-%s.gm", gm.GetDate().Format("20060102"), gm.Number) + if canonName == path.Base(ui.path) { + canonName = "" + } + } + } + doSave := func() { + originalPath := ui.path + if canonName != "" { + ui.path = path.Join(path.Dir(ui.path), canonName) + } + f, err := os.Create(ui.path) + var msg string + if err != nil { + msg = fmt.Sprintf("[yellow:red]%s", err.Error()) + } else { + _, _ = f.WriteString(text) + f.Close() + msg = fmt.Sprintf("%s [green]saved", ui.path) + } + ui.messages.SetText(msg) + ui.modified = false + ui.status.SetText(path.Base(ui.path)) + if canonName != "" { + _ = os.Remove(originalPath) + } + } + if canonName != "" { + ui.showQuestionDialog(fmt.Sprintf("Rename %s to %s ?", path.Base(ui.path), canonName), "OK", doSave) + } else { + doSave() + } +} + +func (ui *UI) showDialog(dialog tview.Primitive) { + if ui.dialog == nil { + ui.dialog = dialog + ui.root.AddPage("dialog", dialog, true, true) + ui.app.SetFocus(dialog) + } +} + +func (ui *UI) closeDialog() { + ui.root.RemovePage("dialog") + ui.dialog = nil +} + +func (ui *UI) showQuestionDialog(question string, okLabel string, ok func()) { + modal := tview.NewModal().AddButtons([]string{okLabel, "Cancel"}). + SetText(question). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == okLabel { + ok() + } + ui.closeDialog() + }) + ui.showDialog(modal) +} + +func (ui *UI) backgroundUpdate(ticker *time.Ticker, done <-chan bool) { + for { + select { + case <-ticker.C: + ui.app.QueueUpdate(func() { + if ui.lastKey.After(ui.lastUpdate) { + go ui.update() + } + }) + case <-done: + return + } + } +} + +func (ui *UI) Run(path string) error { + ticker := time.NewTicker(250 * time.Millisecond) + done := make(chan bool) + go ui.backgroundUpdate(ticker, done) + if path == "" { + path = "." + } + fi, err := os.Stat(path) + if err != nil { + return err + } + if fi.IsDir() { + ui.showDialog(NewChooser(path, "", func(s string, newgame bool) { + if s == "" { + ui.app.Stop() + } else { + ui.closeDialog() + if newgame { + ui.newGame(s) + } else { + ui.parseGame(s) + } + } + })) + } else { + ui.parseGame(path) + } + err = ui.app.Run() + ticker.Stop() + done <- true + return err +}