From aa14f639cac0562ae37eefab12765d6516ab34db Mon Sep 17 00:00:00 2001 From: Britton Hayes <46035482+brittonhayes@users.noreply.github.com> Date: Sun, 15 May 2022 20:53:32 -0700 Subject: [PATCH] feat: terminal user interface added (#3) * feat: terminal user interface added * fix: gosimple recommendations Co-authored-by: Britton Hayes --- cmd/roll/main.go | 21 +++- dice.go | 2 +- dice_test.go | 8 +- go.mod | 16 ++- go.sum | 50 +++++++++- parse/parse.go | 9 +- ui/keymap.go | 66 +++++++++++++ ui/model.go | 246 +++++++++++++++++++++++++++++++++++++++++++++++ ui/style.go | 31 ++++++ ui/view.go | 1 + 10 files changed, 436 insertions(+), 14 deletions(-) create mode 100644 ui/keymap.go create mode 100644 ui/model.go create mode 100644 ui/style.go create mode 100644 ui/view.go diff --git a/cmd/roll/main.go b/cmd/roll/main.go index 9b88ede..474e4b7 100644 --- a/cmd/roll/main.go +++ b/cmd/roll/main.go @@ -8,6 +8,8 @@ import ( "github.com/alecthomas/kong" "github.com/briandowns/spinner" "github.com/brittonhayes/roll/parse" + "github.com/brittonhayes/roll/ui" + tea "github.com/charmbracelet/bubbletea" "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) @@ -16,7 +18,8 @@ var CLI struct { Verbose bool `help:"Display verbose log output" short:"v" env:"VERBOSE" default:"false"` SkipSpinner bool `help:"Skip loading spinner" short:"s" env:"SKIP_SPINNER" default:"false"` - Dice string `arg:"" help:"Dice to roll +/- modifiers e.g. 'roll 1d6', 'roll 2d12+20', or 'roll 1d20-5'" required:""` + Dice string `arg:"" help:"Dice to roll +/- modifiers e.g. 'roll 1d6', 'roll 2d12+20', or 'roll 1d20-5'" xor:"ui" optional:""` + Interactive bool `help:"Run in terminal UI mode" short:"i" xor:"ui" required:""` } func main() { @@ -34,6 +37,22 @@ func main() { zerolog.SetGlobalLevel(zerolog.InfoLevel) } + // Run in interactive mode + if CLI.Interactive { + ui := ui.New() + p := tea.NewProgram(ui, tea.WithAltScreen()) + if err := p.Start(); err != nil { + log.Fatal().Err(err).Send() + } + + return + } + + // Run in CLI mode + roll(ctx) +} + +func roll(ctx *kong.Context) { p, err := parse.NewParser(CLI.Dice) ctx.FatalIfErrorf(err) diff --git a/dice.go b/dice.go index 6a5c707..24cd365 100644 --- a/dice.go +++ b/dice.go @@ -43,7 +43,7 @@ func NewDie(min, max int) (*Die, error) { // String returns the string representation of // the die func (d Die) String() string { - return fmt.Sprintf("D%d", d.Max) + return fmt.Sprintf("d%d", d.Max) } // Validate ensures that the Dice are in a valid diff --git a/dice_test.go b/dice_test.go index f2ddbfa..db6cb8a 100644 --- a/dice_test.go +++ b/dice_test.go @@ -17,10 +17,10 @@ func TestDice_String(t *testing.T) { fields fields want string }{ - {name: "D6", fields: fields{Min: 1, Max: 6}, want: "D6"}, - {name: "D12", fields: fields{Min: 1, Max: 12}, want: "D12"}, - {name: "D20", fields: fields{Min: 1, Max: 20}, want: "D20"}, - {name: "D100", fields: fields{Min: 1, Max: 100}, want: "D100"}, + {name: "D6", fields: fields{Min: 1, Max: 6}, want: "d6"}, + {name: "D12", fields: fields{Min: 1, Max: 12}, want: "d12"}, + {name: "D20", fields: fields{Min: 1, Max: 20}, want: "d20"}, + {name: "D100", fields: fields{Min: 1, Max: 100}, want: "d100"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/go.mod b/go.mod index c3edaa4..8e3946e 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.18 require ( github.com/alecthomas/kong v0.5.0 github.com/briandowns/spinner v1.18.1 + github.com/charmbracelet/bubbles v0.10.3 + github.com/charmbracelet/bubbletea v0.20.0 github.com/go-playground/validator/v10 v10.11.0 github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.26.1 @@ -12,16 +14,26 @@ require ( ) require ( + github.com/charmbracelet/lipgloss v0.5.0 // indirect + github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.7.0 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/knipferrc/teacup v0.1.1 // indirect github.com/leodido/go-urn v1.2.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.2 // indirect - github.com/mattn/go-isatty v0.0.8 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e // indirect - golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect + golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect + golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect golang.org/x/text v0.3.7 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/go.sum b/go.sum index fc641d9..43cb0b8 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,21 @@ github.com/alecthomas/kong v0.5.0 h1:u8Kdw+eeml93qtMZ04iei0CFYve/WPcA5IFh+9wSskE github.com/alecthomas/kong v0.5.0/go.mod h1:uzxf/HUh0tj43x1AyJROl3JT7SgsZ5m+icOv1csRhc0= github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48= github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= +github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho= +github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= +github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= +github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= +github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= +github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= +github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -20,6 +33,8 @@ github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw= github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/knipferrc/teacup v0.1.1 h1:RPcz3UFvZXNm6laS/tWl9eQREPdFgptRGaPNqCJQzEM= +github.com/knipferrc/teacup v0.1.1/go.mod h1:2RwQFOuP3cUqP1NUMyU8BqLQcjPC87sTEHlkNvT2uBE= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -28,23 +43,46 @@ 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/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +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-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -67,13 +105,21 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8= +golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/parse/parse.go b/parse/parse.go index 6fd0d21..c6fc91f 100644 --- a/parse/parse.go +++ b/parse/parse.go @@ -7,13 +7,10 @@ import ( "strings" "github.com/brittonhayes/roll" - "github.com/go-playground/validator/v10" "github.com/pkg/errors" "github.com/rs/zerolog/log" ) -var validate = validator.New() - const ( indexQuantity int = iota indexDie @@ -91,7 +88,11 @@ func NewParser(s string) (*Parser, error) { } func (p *Parser) String() string { - return fmt.Sprintf("%d %s %s %d", p.Quantity, p.Dice[0], p.Operator, p.Modifier) + if p.Modifier == 0 { + return fmt.Sprintf("%dx %s", p.Quantity, p.Dice[0]) + } + + return fmt.Sprintf("%dx %s %s %d", p.Quantity, p.Dice[0], p.Operator, p.Modifier) } // Roll rolls the dice with modifiers diff --git a/ui/keymap.go b/ui/keymap.go new file mode 100644 index 0000000..c95f932 --- /dev/null +++ b/ui/keymap.go @@ -0,0 +1,66 @@ +package ui + +import ( + "github.com/charmbracelet/bubbles/key" +) + +type keymap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Roll key.Binding + Clear key.Binding + Help key.Binding + Quit key.Binding +} + +var DefaultKeyMap = keymap{ + Up: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("โ†‘/k", "add dice"), + ), + Down: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("โ†“/j", "remove dice"), + ), + Left: key.NewBinding( + key.WithKeys("h", "left"), + key.WithHelp("๐Ÿก /h", "prev die"), + ), + Right: key.NewBinding( + key.WithKeys("l", "right"), + key.WithHelp("๐Ÿกข/l", "next die"), + ), + Roll: key.NewBinding( + key.WithKeys("e", "enter"), + key.WithHelp("e/enter", "roll dice"), + ), + Clear: key.NewBinding( + key.WithKeys("c", "ctrl+l"), + key.WithHelp("c", "clear"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c", "esc"), + key.WithHelp("q", "quit"), + ), +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{k.Help, k.Roll} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k keymap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Roll}, + {k.Quit, k.Help}, + } +} diff --git a/ui/model.go b/ui/model.go new file mode 100644 index 0000000..d240aa0 --- /dev/null +++ b/ui/model.go @@ -0,0 +1,246 @@ +package ui + +import ( + "fmt" + + "github.com/brittonhayes/roll/parse" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/go-playground/validator/v10" + "github.com/knipferrc/teacup/statusbar" +) + +var validate = validator.New() + +type Model struct { + // Icon of the application + icon string + + // Title displayed in the UI + title string + + // Quantity of dice of selected dice in hand + quantity int `validate:"positive"` + + // Cursor is the currently selected dice index + cursor int `validate:"gte=0"` + + // Dice is the list of available die to choose from + dice []string + + // Hand of dice +/- modifers to be rolled + hand string + + // Roll result + history string + roll string + + // Keys for the terminal interface + keys keymap + + // Valid is the state of validity of the hand + // for rolling + valid bool + + // Err message from validation + err error + + // Status is the current application status + status statusbar.Bubble + height int + + // Help message content + help help.Model +} + +func New() Model { + return Model{ + icon: "๐ŸŽฒ", + title: "๐ŸŽฒ Roll CLI", + quantity: 1, + hand: "1d4", + cursor: 0, + history: "", + dice: []string{"d4", "d6", "d8", "d10", "d12", "d20", "d100"}, + keys: DefaultKeyMap, + help: help.New(), + status: statusbar.New( + statusbar.ColorConfig{ + Foreground: lipgloss.AdaptiveColor{Light: "#202020", Dark: "#4a4e69"}, + Background: lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#7b2cbf"}, + }, + statusbar.ColorConfig{ + Foreground: lipgloss.AdaptiveColor{Light: "#202020", Dark: "#4a4e69"}, + Background: lipgloss.AdaptiveColor{Light: "#3c3836", Dark: "#303030"}, + }, + statusbar.ColorConfig{ + Foreground: lipgloss.AdaptiveColor{Light: "#202020", Dark: "#ebebeb"}, + Background: lipgloss.AdaptiveColor{Light: "#A550DF", Dark: "#3c096c"}, + }, + statusbar.ColorConfig{ + Foreground: lipgloss.AdaptiveColor{Light: "#202020", Dark: "#ebebeb"}, + Background: lipgloss.AdaptiveColor{Light: "#6124DF", Dark: "#5a189a"}, + }, + ), + } +} + +func (m Model) IsValid() (bool, error) { + if err := validate.Struct(m); err != nil { + return false, err + } + + _, err := parse.Match(m.hand) + if err != nil || (m.hand == "") { + return false, err + } + + return true, nil +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Selection() string { + return fmt.Sprintf("%d%s", m.quantity, m.dice[m.cursor]) +} + +func (m Model) IncreaseQuantity() (tea.Model, tea.Cmd) { + if m.quantity > 0 { + m.quantity++ + } + + return m.SetStatus() +} + +func (m Model) DecreaseQuantity() (tea.Model, tea.Cmd) { + if m.quantity > 1 { + m.quantity-- + } + + return m.SetStatus() +} + +func (m Model) NextDie() (tea.Model, tea.Cmd) { + if m.cursor < len(m.dice) { + m.cursor++ + } + + if m.cursor >= len(m.dice) { + m.cursor = 0 + } + + return m.SetStatus() +} + +func (m Model) PrevDie() (tea.Model, tea.Cmd) { + if m.cursor >= 0 { + m.cursor-- + } + + if m.cursor < 0 { + m.cursor = len(m.dice) - 1 + } + + return m.SetStatus() +} + +func (m Model) SetStatus() (tea.Model, tea.Cmd) { + m.hand = m.Selection() + if m.help.ShowAll { + m.status.SetContent(m.icon, fmt.Sprintf("History: %s", m.history), fmt.Sprintf("Quantity: %dx", m.quantity), fmt.Sprintf("Die: %s", m.dice[m.cursor])) + return m, nil + } + + m.status.SetContent(m.icon, fmt.Sprintf("History: %s", m.history), fmt.Sprintf("%dx", m.quantity), m.dice[m.cursor]) + return m, nil +} + +func (m Model) Roll() (tea.Model, tea.Cmd) { + if !m.valid { + return m, nil + } + + p, err := parse.NewParser(m.hand) + if err != nil { + m.roll = err.Error() + return m, nil + } + + m.history = m.roll + m.roll = fmt.Sprintf("Rolled '%d' with %s", p.Roll(), p.String()) + return m.SetStatus() +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.height = msg.Height + m.help.Width = msg.Width + m.status.SetSize(msg.Width) + m.status.SetContent(m.icon, "", fmt.Sprint(m.quantity), m.dice[m.cursor]) + + case tea.KeyMsg: + switch { + case key.Matches(msg, DefaultKeyMap.Left): + m.valid, m.err = m.IsValid() + m, cmd := m.PrevDie() + return m, cmd + + case key.Matches(msg, DefaultKeyMap.Right): + m.valid, m.err = m.IsValid() + m, cmd := m.NextDie() + return m, cmd + + case key.Matches(msg, DefaultKeyMap.Up): + m.valid, m.err = m.IsValid() + m, cmd := m.IncreaseQuantity() + return m, cmd + + case key.Matches(msg, DefaultKeyMap.Down): + m.valid, m.err = m.IsValid() + m, cmd := m.DecreaseQuantity() + return m, cmd + + case key.Matches(msg, DefaultKeyMap.Clear): + m.roll = "" + return m, nil + + case key.Matches(msg, DefaultKeyMap.Roll): + m.valid, m.err = m.IsValid() + if !m.valid { + m.roll = fmt.Sprintf("%s - %q must be in the format 1d6 or 1d6+/-2", StyleErr(m.err.Error()), m.hand) + return m, nil + } + + return m.Roll() + + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + return m.SetStatus() + + case key.Matches(msg, DefaultKeyMap.Quit): + return m, tea.Quit + } + } + + return m, nil +} + +func (m Model) View() string { + + title := StyleHeader(m.title) + result := StyleResult(m.roll) + help := m.help.View(m.keys) + + content := lipgloss.JoinVertical(lipgloss.Left, title, result, help) + + return lipgloss.JoinVertical( + lipgloss.Top, + lipgloss.NewStyle().Height(m.height-statusbar.Height).Render(content), + m.status.View(), + ) +} diff --git a/ui/style.go b/ui/style.go new file mode 100644 index 0000000..bea512a --- /dev/null +++ b/ui/style.go @@ -0,0 +1,31 @@ +package ui + +import ( + "github.com/charmbracelet/lipgloss" +) + +func StyleHeader(s string) string { + return lipgloss.NewStyle().Faint(true).MarginBottom(2).Render(s) +} + +func StyleResult(s string) string { + return lipgloss.NewStyle().Bold(true).MarginBottom(2).Render(s) +} + +func StyleStatus(s string) string { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#f15bb5")).Render(s) +} + +func StyleErr(s string) string { + return lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ + Light: "#202020", + Dark: "#f72585", + }).Render(s) +} + +func StyleKey(s string) string { + return lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ + Light: "#202020", + Dark: "#4cc9f0", + }).Italic(true).Render(s) +} diff --git a/ui/view.go b/ui/view.go new file mode 100644 index 0000000..5b1faa2 --- /dev/null +++ b/ui/view.go @@ -0,0 +1 @@ +package ui