Global UI Popup Modals

* Adds global modal support in the pkg/modal/ package. It has easy
  Alert() and Confirm() methods to prompt the user before calling a
  callback function on affirmative response.
* Modals have global app state: they're processed in the main loop in
  pkg/doodle.go similar to the global command shell.
* When a modal is active, a semitransparent black frame covers the
  screen (gameplay loop paused, last game frame rendered below) and the
  modal window appears on top.
* The developer console retains higher priority than the modal system
  and always renders on top.
* Editor Mode: track when the level pixels have been modified, and
  confirm the user about unsaved changes when they attempt to close the
  level (New, Open, Close, etc.)
* Global: the Escape key no longer immediately shuts down the game, but
  will confirm the user's intent via a modal.
* File->Quit in the Editor Mode also invokes the confirm shutdown modal.
This commit is contained in:
Noah 2020-11-15 18:02:35 -08:00
parent bc02f2c685
commit 336a949ed0
12 changed files with 413 additions and 26 deletions

4
go.mod
View File

@ -2,6 +2,9 @@ module git.kirsle.net/apps/doodle
go 1.15 go 1.15
replace git.kirsle.net/go/render => /home/kirsle/SketchyMaze/render
replace git.kirsle.net/go/ui => /home/kirsle/SketchyMaze/ui
require ( require (
git.kirsle.net/go/audio v0.0.0-20200429055451-ae3b0695ba6f git.kirsle.net/go/audio v0.0.0-20200429055451-ae3b0695ba6f
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
@ -19,4 +22,5 @@ require (
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 golang.org/x/image v0.0.0-20200927104501-e162460cd6b5
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect
mvdan.cc/unparam v0.0.0-20200501210554-b37ab49443f7 // indirect
) )

23
go.sum
View File

@ -16,9 +16,13 @@ github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU=
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac h1:kYPjbEN6YPYWWHI6ky1J813KzIq/8+Wg4TO4xU7A/KU= github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac h1:kYPjbEN6YPYWWHI6ky1J813KzIq/8+Wg4TO4xU7A/KU=
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY= github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY=
github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
@ -34,25 +38,44 @@ github.com/veandco/go-sdl2 v0.4.4 h1:coOJGftOdvNvGoUIZmm4XD+ZRQF4mg9ZVHmH3/42zFQ
github.com/veandco/go-sdl2 v0.4.4/go.mod h1:FB+kTpX9YTE+urhYiClnRzpOXbiWgaU3+5F2AB78DPg= github.com/veandco/go-sdl2 v0.4.4/go.mod h1:FB+kTpX9YTE+urhYiClnRzpOXbiWgaU3+5F2AB78DPg=
github.com/vmihailenco/msgpack v3.3.3+incompatible h1:wapg9xDUZDzGCNFlwc5SqI1rvcciqcxEHac4CYj89xI= github.com/vmihailenco/msgpack v3.3.3+incompatible h1:wapg9xDUZDzGCNFlwc5SqI1rvcciqcxEHac4CYj89xI=
github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 h1:umElSU9WZirRdgu2yFHY0ayQkEnKiOC1TtM3fWXFnoU= golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 h1:umElSU9WZirRdgu2yFHY0ayQkEnKiOC1TtM3fWXFnoU=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM= golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM=
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ= golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.0.0-20200426102838-f3a5411a4c3b h1:zSzQJAznWxAh9fZxiPy2FZo+ZZEYoYFYYDYdOrU7AaM=
golang.org/x/tools v0.0.0-20200426102838-f3a5411a4c3b/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
mvdan.cc/unparam v0.0.0-20200501210554-b37ab49443f7 h1:kAREL6MPwpsk1/PQPFD3Eg7WAQR5mPTWZJaBiG5LDbY=
mvdan.cc/unparam v0.0.0-20200501210554-b37ab49443f7/go.mod h1:HGC5lll35J70Y5v7vCGb9oLhHoScFwkHDJm/05RdSTc=

View File

@ -7,6 +7,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/enum" "git.kirsle.net/apps/doodle/pkg/enum"
"git.kirsle.net/apps/doodle/pkg/modal"
"github.com/robertkrimen/otto" "github.com/robertkrimen/otto"
) )
@ -33,6 +34,9 @@ func (c Command) Run(d *Doodle) error {
case "echo": case "echo":
d.Flash(c.ArgsLiteral) d.Flash(c.ArgsLiteral)
return nil return nil
case "alert":
modal.Alert(c.ArgsLiteral)
return nil
case "new": case "new":
return c.New(d) return c.New(d)
case "save": case "save":

View File

@ -10,6 +10,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/branding" "git.kirsle.net/apps/doodle/pkg/branding"
"git.kirsle.net/apps/doodle/pkg/enum" "git.kirsle.net/apps/doodle/pkg/enum"
"git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/modal"
"git.kirsle.net/apps/doodle/pkg/native" "git.kirsle.net/apps/doodle/pkg/native"
"git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/shmem"
golog "git.kirsle.net/go/log" golog "git.kirsle.net/go/log"
@ -84,10 +85,15 @@ func (d *Doodle) Title() string {
// SetupEngine sets up the rendering engine. // SetupEngine sets up the rendering engine.
func (d *Doodle) SetupEngine() error { func (d *Doodle) SetupEngine() error {
// Set up the rendering engine (SDL2, etc.)
if err := d.Engine.Setup(); err != nil { if err := d.Engine.Setup(); err != nil {
return err return err
} }
d.engineReady = true d.engineReady = true
// Initialize the UI modal manager.
modal.Initialize(d.Engine)
return nil return nil
} }
@ -113,7 +119,6 @@ func (d *Doodle) Run() error {
// Poll for events. // Poll for events.
ev, err := d.Engine.Poll() ev, err := d.Engine.Poll()
// log.Error("Button1 is: %+v", ev.Button1)
shmem.Cursor = render.NewPoint(ev.CursorX, ev.CursorY) shmem.Cursor = render.NewPoint(ev.CursorX, ev.CursorY)
if err != nil { if err != nil {
log.Error("event poll error: %s", err) log.Error("event poll error: %s", err)
@ -132,8 +137,8 @@ func (d *Doodle) Run() error {
// Global event handlers. // Global event handlers.
if ev.Escape { if ev.Escape {
log.Error("Escape key pressed, shutting down") log.Error("Escape key pressed, shutting down")
d.running = false d.ConfirmExit()
break continue
} }
if ev.KeyDown("F1") { if ev.KeyDown("F1") {
@ -148,17 +153,24 @@ func (d *Doodle) Run() error {
ev.SetKeyDown("F4", false) ev.SetKeyDown("F4", false)
} }
// Run the scene's logic. // Is a UI modal active?
err = d.Scene.Loop(d, ev) if modal.Handled(ev) == false {
if err != nil { // Run the scene's logic.
return err err = d.Scene.Loop(d, ev)
if err != nil {
return err
}
} }
} }
// Draw the scene. // Draw the scene.
d.Scene.Draw(d) d.Scene.Draw(d)
// Draw the shell. // Draw modals on top of the game UI.
modal.Draw()
// Draw the shell, always on top of UI and modals.
err = d.shell.Draw(d, ev) err = d.shell.Draw(d, ev)
if err != nil { if err != nil {
log.Error("shell error: %s", err) log.Error("shell error: %s", err)
@ -199,6 +211,15 @@ func (d *Doodle) Run() error {
return nil return nil
} }
// ConfirmExit may shut down Doodle gracefully after showing the user a
// confirmation modal.
func (d *Doodle) ConfirmExit() {
modal.Confirm("Are you sure you want to quit %s?", branding.AppName).
WithTitle("Confirm Quit").Then(func() {
d.running = false
})
}
// NewMap loads a new map in Edit Mode. // NewMap loads a new map in Edit Mode.
func (d *Doodle) NewMap() { func (d *Doodle) NewMap() {
log.Info("Starting a new map") log.Info("Starting a new map")

View File

@ -12,6 +12,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/enum" "git.kirsle.net/apps/doodle/pkg/enum"
"git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/modal"
"git.kirsle.net/apps/doodle/pkg/userdir" "git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
"git.kirsle.net/go/render/event" "git.kirsle.net/go/render/event"
@ -160,6 +161,19 @@ func (s *EditorScene) Playtest() {
}) })
} }
// ConfirmUnload may pop up a confirmation modal to save the level before the
// user performs an action that may close the level, such as click File->New.
func (s *EditorScene) ConfirmUnload(fn func()) {
if !s.UI.Canvas.Modified() {
fn()
return
}
modal.Confirm(
"This level has unsaved changes. Are you sure you\nwant to continue and lose your changes?",
).WithTitle("Confirm Closing Level").Then(fn)
}
// Loop the editor scene. // Loop the editor scene.
func (s *EditorScene) Loop(d *Doodle, ev *event.State) error { func (s *EditorScene) Loop(d *Doodle, ev *event.State) error {
// Update debug overlay values. // Update debug overlay values.
@ -265,6 +279,9 @@ func (s *EditorScene) SaveLevel(filename string) error {
m.Palette = s.UI.Canvas.Palette m.Palette = s.UI.Canvas.Palette
m.Chunker = s.UI.Canvas.Chunker() m.Chunker = s.UI.Canvas.Chunker()
// Clear the modified flag on the level.
s.UI.Canvas.SetModified(false)
return m.WriteFile(filename) return m.WriteFile(filename)
} }
@ -307,6 +324,9 @@ func (s *EditorScene) SaveDoodad(filename string) error {
d.Palette = s.UI.Canvas.Palette d.Palette = s.UI.Canvas.Palette
d.Layers[0].Chunker = s.UI.Canvas.Chunker() d.Layers[0].Chunker = s.UI.Canvas.Chunker()
// Clear the modified flag on the level.
s.UI.Canvas.SetModified(false)
// Save it to their profile directory. // Save it to their profile directory.
filename = userdir.DoodadPath(filename) filename = userdir.DoodadPath(filename)
log.Info("Write Doodad: %s", filename) log.Info("Write Doodad: %s", filename)

View File

@ -2,7 +2,6 @@ package doodle
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@ -483,21 +482,25 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar {
// File menu // File menu
fileMenu := menu.AddMenu("File") fileMenu := menu.AddMenu("File")
fileMenu.AddItemAccel("New level", "Ctrl-N", func() { fileMenu.AddItemAccel("New level", "Ctrl-N", func() {
d.GotoNewMenu() u.Scene.ConfirmUnload(func() {
d.GotoNewMenu()
})
}) })
if !balance.FreeVersion { if !balance.FreeVersion {
fileMenu.AddItem("New doodad", func() { fileMenu.AddItem("New doodad", func() {
d.Prompt("Doodad size [100]>", func(answer string) { u.Scene.ConfirmUnload(func() {
size := balance.DoodadSize d.Prompt("Doodad size [100]>", func(answer string) {
if answer != "" { size := balance.DoodadSize
i, err := strconv.Atoi(answer) if answer != "" {
if err != nil { i, err := strconv.Atoi(answer)
d.Flash("Error: Doodad size must be a number.") if err != nil {
return d.Flash("Error: Doodad size must be a number.")
return
}
size = i
} }
size = i d.NewDoodad(size)
} })
d.NewDoodad(size)
}) })
}) })
} }
@ -520,15 +523,18 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar {
}) })
}) })
fileMenu.AddItemAccel("Open...", "Ctrl-O", func() { fileMenu.AddItemAccel("Open...", "Ctrl-O", func() {
d.GotoLoadMenu() u.Scene.ConfirmUnload(func() {
d.GotoLoadMenu()
})
}) })
fileMenu.AddSeparator() fileMenu.AddSeparator()
fileMenu.AddItem("Close "+drawingType, func() { fileMenu.AddItem("Close "+drawingType, func() {
d.Goto(&MainScene{}) u.Scene.ConfirmUnload(func() {
d.Goto(&MainScene{})
})
}) })
fileMenu.AddItemAccel("Quit", "Ctrl-Q", func() { fileMenu.AddItemAccel("Quit", "Ctrl-Q", func() {
// TODO graceful shutdown d.ConfirmExit()
os.Exit(0)
}) })
//////// ////////

74
pkg/modal/alert.go Normal file
View File

@ -0,0 +1,74 @@
package modal
import (
"fmt"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/go/ui"
)
// Alert pops up an alert box modal.
func Alert(message string, args ...interface{}) *Modal {
if !ready {
panic("modal.Alert(): not ready")
} else if current != nil {
return current
}
// Reset the supervisor.
supervisor = ui.NewSupervisor()
m := &Modal{
title: "Alert",
message: fmt.Sprintf(message, args...),
}
m.window = makeAlert(m)
center(m.window)
current = m
return m
}
// alertWindow creates the ui.Window for the Alert modal.
func makeAlert(m *Modal) *ui.Window {
win := ui.NewWindow("Alert")
_, title := win.TitleBar()
title.TextVariable = &m.title
msgFrame := ui.NewFrame("Alert Message")
win.Pack(msgFrame, ui.Pack{
Side: ui.N,
})
msg := ui.NewLabel(ui.Label{
TextVariable: &m.message,
Font: balance.UIFont,
})
msgFrame.Pack(msg, ui.Pack{
Side: ui.N,
})
button := ui.NewButton("Ok Button", ui.NewLabel(ui.Label{
Text: "Ok",
Font: balance.MenuFont,
}))
button.Handle(ui.Click, func(ev ui.EventData) error {
log.Info("clicked!")
m.Dismiss(true)
return nil
})
win.Pack(button, ui.Pack{
Side: ui.N,
PadY: 4,
})
button.Compute(engine)
supervisor.Add(button)
win.Compute(engine)
win.Supervise(supervisor)
return win
}

91
pkg/modal/confirm.go Normal file
View File

@ -0,0 +1,91 @@
package modal
import (
"fmt"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/go/ui"
)
// Confirm pops up an Ok/Cancel modal.
func Confirm(message string, args ...interface{}) *Modal {
if !ready {
panic("modal.Confirm(): not ready")
} else if current != nil {
return current
}
// Reset the supervisor.
supervisor = ui.NewSupervisor()
m := &Modal{
title: "Confirm",
message: fmt.Sprintf(message, args...),
}
m.window = makeConfirm(m)
center(m.window)
current = m
return m
}
// makeConfirm creates the ui.Window for the Confirm modal.
func makeConfirm(m *Modal) *ui.Window {
win := ui.NewWindow("Confirm")
_, title := win.TitleBar()
title.TextVariable = &m.title
msgFrame := ui.NewFrame("Confirm Message")
win.Pack(msgFrame, ui.Pack{
Side: ui.N,
})
msg := ui.NewLabel(ui.Label{
TextVariable: &m.message,
Font: balance.UIFont,
})
msgFrame.Pack(msg, ui.Pack{
Side: ui.N,
})
// Ok/Cancel button bar.
btnBar := ui.NewFrame("Button Bar")
msgFrame.Pack(btnBar, ui.Pack{
Side: ui.N,
PadY: 4,
})
for _, btn := range []struct {
Label string
F func(ui.EventData) error
}{
{"Ok", func(ev ui.EventData) error {
m.Dismiss(true)
return nil
}},
{"Cancel", func(ev ui.EventData) error {
m.Dismiss(false)
return nil
}},
} {
btn := btn
button := ui.NewButton(btn.Label+"Button", ui.NewLabel(ui.Label{
Text: btn.Label,
Font: balance.MenuFont,
}))
button.Handle(ui.Click, btn.F)
button.Compute(engine)
supervisor.Add(button)
btnBar.Pack(button, ui.Pack{
Side: ui.W,
PadX: 2,
})
}
win.Compute(engine)
win.Supervise(supervisor)
return win
}

112
pkg/modal/modal.go Normal file
View File

@ -0,0 +1,112 @@
// Package modal provides UI pop-up modals for Doodle.
package modal
import (
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/ui"
)
// Package global variables.
var (
ready bool // Has been initialized with a render.Engine
current *Modal // Current modal object, nil if no modal active.
engine render.Engine
window render.Rect // cached window dimensions
supervisor *ui.Supervisor
screen *ui.Frame
)
// Initialize the modal package.
func Initialize(e render.Engine) {
engine = e
supervisor = ui.NewSupervisor()
width, height := engine.WindowSize()
window = render.NewRect(width, height)
screen = ui.NewFrame("Modal Screen")
screen.SetBackground(render.RGBA(1, 1, 1, 128))
screen.Resize(window)
screen.Compute(e)
ready = true
}
// Reset the modal state (closing all modals).
func Reset() {
supervisor = nil
current = nil
}
// Handled runs the modal manager's logic. Returns true if a modal
// is presently active, to signal to Doodle not to run game logic.
func Handled(ev *event.State) bool {
if !ready || current == nil {
return false
}
supervisor.Loop(ev)
// Has the window changed size?
size := render.NewRect(engine.WindowSize())
if size != window {
window = size
screen.Resize(window)
}
return true
}
// Draw the modal UI to the screen.
func Draw() {
if ready && current != nil {
screen.Present(engine, render.Origin)
supervisor.Present(engine)
}
}
// Center the window on screen.
func center(win *ui.Window) {
var modSize = win.Size()
var moveTo = render.Point{
X: (window.W / 2) - (modSize.W / 2),
Y: (window.H / 4) - (modSize.H / 2),
}
win.MoveTo(moveTo)
// HACK: ideally the modal should auto-size itself, but currently
// the body of the window juts out the right and bottom side by
// a few pixels. Fix the underlying problem later, for now we
// set the modal size to big enough to hide the problem.
win.Children()[0].Resize(render.NewRect(modSize.W+12, modSize.H+12))
}
// Modal is an instance of a modal, i.e. Alert or Confirm.
type Modal struct {
title string
message string
window *ui.Window
callback func()
}
// WithTitle sets the title of the modal.
func (m *Modal) WithTitle(title string) *Modal {
m.title = title
return m
}
// Then calls a function after the modal is answered.
func (m *Modal) Then(f func()) *Modal {
m.callback = f
return m
}
// Dismiss the modal and optionally call the callback function.
func (m *Modal) Dismiss(call bool) {
if call && m.callback != nil {
m.callback()
}
Reset()
}

15
pkg/modal/modal_test.go Normal file
View File

@ -0,0 +1,15 @@
package modal_test
import (
"fmt"
modal "git.kirsle.net/apps/doodle/pkg/modal"
)
func ExampleAlert() {
alert := modal.Alert("Permission Denied").WithTitle("Error").Then(func() {
fmt.Println("Alert button answered!")
})
_ = alert
}

View File

@ -48,8 +48,9 @@ type Canvas struct {
NoLimitScroll bool NoLimitScroll bool
// Underlying chunk data for the drawing. // Underlying chunk data for the drawing.
level *level.Level level *level.Level
chunks *level.Chunker chunks *level.Chunker
modified bool // set to True when the drawing has been modified, like in Editor Mode.
// Actors to superimpose on top of the drawing. // Actors to superimpose on top of the drawing.
actor *Actor // if this canvas IS an actor actor *Actor // if this canvas IS an actor
@ -131,6 +132,7 @@ func NewCanvas(size int, editable bool) *Canvas {
func (w *Canvas) Load(p *level.Palette, g *level.Chunker) { func (w *Canvas) Load(p *level.Palette, g *level.Chunker) {
w.Palette = p w.Palette = p
w.chunks = g w.chunks = g
w.modified = false
if len(w.Palette.Swatches) > 0 { if len(w.Palette.Swatches) > 0 {
w.SetSwatch(w.Palette.Swatches[0]) w.SetSwatch(w.Palette.Swatches[0])

View File

@ -8,6 +8,18 @@ import (
"git.kirsle.net/go/ui" "git.kirsle.net/go/ui"
) )
// Modified returns whether the canvas has been modified since it was last
// loaded. Methods like Load and LoadFile will set modified to false, and
// commitStroke sets it to true.
func (w *Canvas) Modified() bool {
return w.modified
}
// SetModified sets the modified bit on the canvas.
func (w *Canvas) SetModified(v bool) {
w.modified = v
}
// commitStroke is the common function that applies a stroke the user is // commitStroke is the common function that applies a stroke the user is
// actively drawing onto the canvas. This is for Edit Mode. // actively drawing onto the canvas. This is for Edit Mode.
func (w *Canvas) commitStroke(tool drawtool.Tool, addHistory bool) { func (w *Canvas) commitStroke(tool drawtool.Tool, addHistory bool) {
@ -16,6 +28,9 @@ func (w *Canvas) commitStroke(tool drawtool.Tool, addHistory bool) {
return return
} }
// Mark the canvas as modified.
w.modified = true
var ( var (
deleting = w.currentStroke.Shape == drawtool.Eraser deleting = w.currentStroke.Shape == drawtool.Eraser
dedupe = map[render.Point]interface{}{} // don't revisit the same point twice dedupe = map[render.Point]interface{}{} // don't revisit the same point twice