From 336a949ed017f25ebc148f593b88c2f3783ef0d4 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 15 Nov 2020 18:02:35 -0800 Subject: [PATCH] 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. --- go.mod | 4 ++ go.sum | 23 ++++++++ pkg/commands.go | 4 ++ pkg/doodle.go | 37 +++++++++--- pkg/editor_scene.go | 20 +++++++ pkg/editor_ui.go | 38 +++++++------ pkg/modal/alert.go | 74 ++++++++++++++++++++++++ pkg/modal/confirm.go | 91 ++++++++++++++++++++++++++++++ pkg/modal/modal.go | 112 +++++++++++++++++++++++++++++++++++++ pkg/modal/modal_test.go | 15 +++++ pkg/uix/canvas.go | 6 +- pkg/uix/canvas_editable.go | 15 +++++ 12 files changed, 413 insertions(+), 26 deletions(-) create mode 100644 pkg/modal/alert.go create mode 100644 pkg/modal/confirm.go create mode 100644 pkg/modal/modal.go create mode 100644 pkg/modal/modal_test.go diff --git a/go.mod b/go.mod index a149ae9..8cb4800 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,9 @@ module git.kirsle.net/apps/doodle go 1.15 +replace git.kirsle.net/go/render => /home/kirsle/SketchyMaze/render +replace git.kirsle.net/go/ui => /home/kirsle/SketchyMaze/ui + require ( git.kirsle.net/go/audio v0.0.0-20200429055451-ae3b0695ba6f git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b @@ -19,4 +22,5 @@ require ( golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 google.golang.org/appengine v1.6.7 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect + mvdan.cc/unparam v0.0.0-20200501210554-b37ab49443f7 // indirect ) diff --git a/go.sum b/go.sum index a201794..27f7c6f 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/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/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/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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/vmihailenco/msgpack v3.3.3+incompatible h1:wapg9xDUZDzGCNFlwc5SqI1rvcciqcxEHac4CYj89xI= 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-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/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-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM= 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-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-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-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 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.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-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/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 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/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= 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= +mvdan.cc/unparam v0.0.0-20200501210554-b37ab49443f7 h1:kAREL6MPwpsk1/PQPFD3Eg7WAQR5mPTWZJaBiG5LDbY= +mvdan.cc/unparam v0.0.0-20200501210554-b37ab49443f7/go.mod h1:HGC5lll35J70Y5v7vCGb9oLhHoScFwkHDJm/05RdSTc= diff --git a/pkg/commands.go b/pkg/commands.go index c2841ff..4430378 100644 --- a/pkg/commands.go +++ b/pkg/commands.go @@ -7,6 +7,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/enum" + "git.kirsle.net/apps/doodle/pkg/modal" "github.com/robertkrimen/otto" ) @@ -33,6 +34,9 @@ func (c Command) Run(d *Doodle) error { case "echo": d.Flash(c.ArgsLiteral) return nil + case "alert": + modal.Alert(c.ArgsLiteral) + return nil case "new": return c.New(d) case "save": diff --git a/pkg/doodle.go b/pkg/doodle.go index 25b7f69..7efd65d 100644 --- a/pkg/doodle.go +++ b/pkg/doodle.go @@ -10,6 +10,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/branding" "git.kirsle.net/apps/doodle/pkg/enum" "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/shmem" golog "git.kirsle.net/go/log" @@ -84,10 +85,15 @@ func (d *Doodle) Title() string { // SetupEngine sets up the rendering engine. func (d *Doodle) SetupEngine() error { + // Set up the rendering engine (SDL2, etc.) if err := d.Engine.Setup(); err != nil { return err } d.engineReady = true + + // Initialize the UI modal manager. + modal.Initialize(d.Engine) + return nil } @@ -113,7 +119,6 @@ func (d *Doodle) Run() error { // Poll for events. ev, err := d.Engine.Poll() - // log.Error("Button1 is: %+v", ev.Button1) shmem.Cursor = render.NewPoint(ev.CursorX, ev.CursorY) if err != nil { log.Error("event poll error: %s", err) @@ -132,8 +137,8 @@ func (d *Doodle) Run() error { // Global event handlers. if ev.Escape { log.Error("Escape key pressed, shutting down") - d.running = false - break + d.ConfirmExit() + continue } if ev.KeyDown("F1") { @@ -148,17 +153,24 @@ func (d *Doodle) Run() error { ev.SetKeyDown("F4", false) } - // Run the scene's logic. - err = d.Scene.Loop(d, ev) - if err != nil { - return err + // Is a UI modal active? + if modal.Handled(ev) == false { + // Run the scene's logic. + err = d.Scene.Loop(d, ev) + if err != nil { + return err + } } + } // Draw the scene. 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) if err != nil { log.Error("shell error: %s", err) @@ -199,6 +211,15 @@ func (d *Doodle) Run() error { 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. func (d *Doodle) NewMap() { log.Info("Starting a new map") diff --git a/pkg/editor_scene.go b/pkg/editor_scene.go index a91ed57..bda6e1d 100644 --- a/pkg/editor_scene.go +++ b/pkg/editor_scene.go @@ -12,6 +12,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/enum" "git.kirsle.net/apps/doodle/pkg/level" "git.kirsle.net/apps/doodle/pkg/log" + "git.kirsle.net/apps/doodle/pkg/modal" "git.kirsle.net/apps/doodle/pkg/userdir" "git.kirsle.net/go/render" "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. func (s *EditorScene) Loop(d *Doodle, ev *event.State) error { // Update debug overlay values. @@ -265,6 +279,9 @@ func (s *EditorScene) SaveLevel(filename string) error { m.Palette = s.UI.Canvas.Palette m.Chunker = s.UI.Canvas.Chunker() + // Clear the modified flag on the level. + s.UI.Canvas.SetModified(false) + return m.WriteFile(filename) } @@ -307,6 +324,9 @@ func (s *EditorScene) SaveDoodad(filename string) error { d.Palette = s.UI.Canvas.Palette 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. filename = userdir.DoodadPath(filename) log.Info("Write Doodad: %s", filename) diff --git a/pkg/editor_ui.go b/pkg/editor_ui.go index 692353f..e572fbc 100644 --- a/pkg/editor_ui.go +++ b/pkg/editor_ui.go @@ -2,7 +2,6 @@ package doodle import ( "fmt" - "os" "path/filepath" "strconv" @@ -483,21 +482,25 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar { // File menu fileMenu := menu.AddMenu("File") fileMenu.AddItemAccel("New level", "Ctrl-N", func() { - d.GotoNewMenu() + u.Scene.ConfirmUnload(func() { + d.GotoNewMenu() + }) }) if !balance.FreeVersion { fileMenu.AddItem("New doodad", func() { - d.Prompt("Doodad size [100]>", func(answer string) { - size := balance.DoodadSize - if answer != "" { - i, err := strconv.Atoi(answer) - if err != nil { - d.Flash("Error: Doodad size must be a number.") - return + u.Scene.ConfirmUnload(func() { + d.Prompt("Doodad size [100]>", func(answer string) { + size := balance.DoodadSize + if answer != "" { + i, err := strconv.Atoi(answer) + if err != nil { + 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() { - d.GotoLoadMenu() + u.Scene.ConfirmUnload(func() { + d.GotoLoadMenu() + }) }) fileMenu.AddSeparator() fileMenu.AddItem("Close "+drawingType, func() { - d.Goto(&MainScene{}) + u.Scene.ConfirmUnload(func() { + d.Goto(&MainScene{}) + }) }) fileMenu.AddItemAccel("Quit", "Ctrl-Q", func() { - // TODO graceful shutdown - os.Exit(0) + d.ConfirmExit() }) //////// diff --git a/pkg/modal/alert.go b/pkg/modal/alert.go new file mode 100644 index 0000000..a653cda --- /dev/null +++ b/pkg/modal/alert.go @@ -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 +} diff --git a/pkg/modal/confirm.go b/pkg/modal/confirm.go new file mode 100644 index 0000000..31497e5 --- /dev/null +++ b/pkg/modal/confirm.go @@ -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 +} diff --git a/pkg/modal/modal.go b/pkg/modal/modal.go new file mode 100644 index 0000000..704df3f --- /dev/null +++ b/pkg/modal/modal.go @@ -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() +} diff --git a/pkg/modal/modal_test.go b/pkg/modal/modal_test.go new file mode 100644 index 0000000..ffdb316 --- /dev/null +++ b/pkg/modal/modal_test.go @@ -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 +} diff --git a/pkg/uix/canvas.go b/pkg/uix/canvas.go index 745c99f..e17e024 100644 --- a/pkg/uix/canvas.go +++ b/pkg/uix/canvas.go @@ -48,8 +48,9 @@ type Canvas struct { NoLimitScroll bool // Underlying chunk data for the drawing. - level *level.Level - chunks *level.Chunker + level *level.Level + 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. 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) { w.Palette = p w.chunks = g + w.modified = false if len(w.Palette.Swatches) > 0 { w.SetSwatch(w.Palette.Swatches[0]) diff --git a/pkg/uix/canvas_editable.go b/pkg/uix/canvas_editable.go index 2d14c15..e9245a7 100644 --- a/pkg/uix/canvas_editable.go +++ b/pkg/uix/canvas_editable.go @@ -8,6 +8,18 @@ import ( "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 // actively drawing onto the canvas. This is for Edit Mode. func (w *Canvas) commitStroke(tool drawtool.Tool, addHistory bool) { @@ -16,6 +28,9 @@ func (w *Canvas) commitStroke(tool drawtool.Tool, addHistory bool) { return } + // Mark the canvas as modified. + w.modified = true + var ( deleting = w.currentStroke.Shape == drawtool.Eraser dedupe = map[render.Point]interface{}{} // don't revisit the same point twice