feat(tracker, gioui): add confirmation dialogs before quit/new/load song

This should avoid accidentally losing all work by destroying window.
This commit is contained in:
vsariola
2021-04-16 22:42:51 +03:00
parent f3cf4a52ce
commit 7893c1d1ed
8 changed files with 181 additions and 23 deletions

View File

@ -10,6 +10,7 @@ import (
type Dialog struct { type Dialog struct {
Visible bool Visible bool
BtnAlt widget.Clickable
BtnOk widget.Clickable BtnOk widget.Clickable
BtnCancel widget.Clickable BtnCancel widget.Clickable
} }
@ -18,6 +19,8 @@ type DialogStyle struct {
dialog *Dialog dialog *Dialog
Text string Text string
Inset layout.Inset Inset layout.Inset
ShowAlt bool
AltStyle material.ButtonStyle
OkStyle material.ButtonStyle OkStyle material.ButtonStyle
CancelStyle material.ButtonStyle CancelStyle material.ButtonStyle
} }
@ -27,9 +30,11 @@ func ConfirmDialog(th *material.Theme, dialog *Dialog, text string) DialogStyle
dialog: dialog, dialog: dialog,
Text: text, Text: text,
Inset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12), Left: unit.Dp(20), Right: unit.Dp(20)}, Inset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12), Left: unit.Dp(20), Right: unit.Dp(20)},
AltStyle: material.Button(th, &dialog.BtnAlt, "Alt"),
OkStyle: material.Button(th, &dialog.BtnOk, "Ok"), OkStyle: material.Button(th, &dialog.BtnOk, "Ok"),
CancelStyle: material.Button(th, &dialog.BtnCancel, "Cancel"), CancelStyle: material.Button(th, &dialog.BtnCancel, "Cancel"),
} }
ret.AltStyle.Background = primaryColor
ret.OkStyle.Background = primaryColor ret.OkStyle.Background = primaryColor
ret.CancelStyle.Background = primaryColor ret.CancelStyle.Background = primaryColor
return ret return ret
@ -45,6 +50,13 @@ func (d *DialogStyle) Layout(gtx C) D {
layout.Rigid(Label(d.Text, highEmphasisTextColor)), layout.Rigid(Label(d.Text, highEmphasisTextColor)),
layout.Rigid(func(gtx C) D { layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Px(unit.Dp(120)) gtx.Constraints.Min.X = gtx.Px(unit.Dp(120))
if d.ShowAlt {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
layout.Rigid(d.OkStyle.Layout),
layout.Rigid(d.AltStyle.Layout),
layout.Rigid(d.CancelStyle.Layout),
)
}
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx, return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
layout.Rigid(d.OkStyle.Layout), layout.Rigid(d.OkStyle.Layout),
layout.Rigid(d.CancelStyle.Layout), layout.Rigid(d.CancelStyle.Layout),

View File

@ -4,9 +4,11 @@ package gioui
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io/ioutil" "io/ioutil"
"path/filepath" "path/filepath"
"gioui.org/app"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"github.com/sqweek/dialog" "github.com/sqweek/dialog"
@ -14,6 +16,30 @@ import (
) )
func (t *Tracker) LoadSongFile() { func (t *Tracker) LoadSongFile() {
if t.ChangedSinceSave() {
t.ConfirmSongActionType = ConfirmLoad
t.ConfirmSongDialog.Visible = true
return
}
t.loadSong()
}
func (t *Tracker) SaveSongFile() bool {
if p := t.FilePath(); p != "" {
return t.saveSong(p)
}
return t.SaveSongAsFile()
}
func (t *Tracker) SaveSongAsFile() bool {
filename, err := dialog.File().Filter("Sointu YAML song", "yml").Filter("Sointu JSON song", "json").Title("Save song").Save()
if err != nil {
return false
}
return t.saveSong(filename)
}
func (t *Tracker) loadSong() {
filename, err := dialog.File().Filter("Sointu YAML song", "yml").Filter("Sointu JSON song", "json").Title("Load song").Load() filename, err := dialog.File().Filter("Sointu YAML song", "yml").Filter("Sointu JSON song", "json").Title("Load song").Load()
if err != nil { if err != nil {
return return
@ -29,25 +55,30 @@ func (t *Tracker) LoadSongFile() {
} }
} }
t.SetSong(song) t.SetSong(song)
t.SetFilePath(filename)
t.window.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", filename)))
t.ClearUndoHistory()
t.SetChangedSinceSave(false)
} }
func (t *Tracker) SaveSongFile() { func (t *Tracker) saveSong(filename string) bool {
filename, err := dialog.File().Filter("Sointu YAML song", "yml").Filter("Sointu JSON song", "json").Title("Save song").Save()
if err != nil {
return
}
var extension = filepath.Ext(filename) var extension = filepath.Ext(filename)
var contents []byte var contents []byte
var err error
if extension == "json" { if extension == "json" {
contents, err = json.Marshal(t.Song()) contents, err = json.Marshal(t.Song())
} else { } else {
contents, err = yaml.Marshal(t.Song()) contents, err = yaml.Marshal(t.Song())
} }
if err != nil { if err != nil {
return return false
} }
if extension == "" { if extension == "" {
filename = filename + ".yml" filename = filename + ".yml"
} }
ioutil.WriteFile(filename, contents, 0644) ioutil.WriteFile(filename, contents, 0644)
t.SetFilePath(filename)
t.window.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", filename)))
t.SetChangedSinceSave(false)
return true
} }

View File

@ -111,7 +111,7 @@ func (t *Tracker) KeyEvent(w *app.Window, e key.Event) bool {
} }
case "N": case "N":
if e.Modifiers.Contain(key.ModShortcut) { if e.Modifiers.Contain(key.ModShortcut) {
t.ResetSong() t.TryResetSong()
return true return true
} }
case "S": case "S":

View File

@ -3,6 +3,7 @@ package gioui
import ( import (
"image" "image"
"gioui.org/app"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint" "gioui.org/op/paint"
@ -20,6 +21,62 @@ func (t *Tracker) Layout(gtx layout.Context) {
t.Alert.Layout(gtx) t.Alert.Layout(gtx)
dstyle := ConfirmDialog(t.Theme, t.ConfirmInstrDelete, "Are you sure you want to delete this instrument?") dstyle := ConfirmDialog(t.Theme, t.ConfirmInstrDelete, "Are you sure you want to delete this instrument?")
dstyle.Layout(gtx) dstyle.Layout(gtx)
dstyle = ConfirmDialog(t.Theme, t.ConfirmSongDialog, "Do you want to save your changes to the song? Your changes will be lost if you don't save them.")
dstyle.ShowAlt = true
dstyle.OkStyle.Text = "Save"
dstyle.AltStyle.Text = "Don't save"
dstyle.Layout(gtx)
for t.ConfirmSongDialog.BtnOk.Clicked() {
if t.SaveSongFile() {
t.confirmedSongAction()
}
t.ConfirmSongDialog.Visible = false
}
for t.ConfirmSongDialog.BtnAlt.Clicked() {
t.confirmedSongAction()
t.ConfirmSongDialog.Visible = false
}
for t.ConfirmSongDialog.BtnCancel.Clicked() {
t.ConfirmSongDialog.Visible = false
}
}
func (t *Tracker) confirmedSongAction() {
switch t.ConfirmSongActionType {
case ConfirmLoad:
t.loadSong()
case ConfirmNew:
t.ResetSong()
t.SetFilePath("")
t.window.Option(app.Title("Sointu Tracker"))
t.ClearUndoHistory()
t.SetChangedSinceSave(false)
case ConfirmQuit:
t.quitted = true
}
}
func (t *Tracker) TryResetSong() {
if t.ChangedSinceSave() {
t.ConfirmSongActionType = ConfirmNew
t.ConfirmSongDialog.Visible = true
return
}
t.ResetSong()
t.SetFilePath("")
t.window.Option(app.Title("Sointu Tracker"))
t.ClearUndoHistory()
t.SetChangedSinceSave(false)
}
func (t *Tracker) TryQuit() bool {
if t.ChangedSinceSave() {
t.ConfirmSongActionType = ConfirmQuit
t.ConfirmSongDialog.Visible = true
return false
}
t.quitted = true
return true
} }
func (t *Tracker) layoutBottom(gtx layout.Context) layout.Dimensions { func (t *Tracker) layoutBottom(gtx layout.Context) layout.Dimensions {

View File

@ -36,7 +36,13 @@ func (t *Tracker) Run(w *app.Window) error {
case e := <-w.Events(): case e := <-w.Events():
switch e := e.(type) { switch e := e.(type) {
case system.DestroyEvent: case system.DestroyEvent:
return e.Err if !t.TryQuit() {
// TODO: uh oh, there's no way of canceling the destroyevent in gioui? so we create a new window just to show the dialog
w = app.NewWindow(
app.Size(unit.Dp(800), unit.Dp(600)),
app.Title("Sointu Tracker"),
)
}
case key.Event: case key.Event:
if t.KeyEvent(w, e) { if t.KeyEvent(w, e) {
w.Invalidate() w.Invalidate()
@ -52,6 +58,9 @@ func (t *Tracker) Run(w *app.Window) error {
e.Frame(gtx.Ops) e.Frame(gtx.Ops)
} }
} }
if t.quitted {
return nil
}
} }
} }
@ -61,7 +70,7 @@ func Main(audioContext sointu.AudioContext, synthService sointu.SynthService, sy
app.Size(unit.Dp(800), unit.Dp(600)), app.Size(unit.Dp(800), unit.Dp(600)),
app.Title("Sointu Tracker"), app.Title("Sointu Tracker"),
) )
t := New(audioContext, synthService, syncChannel) t := New(audioContext, synthService, syncChannel, w)
defer t.Close() defer t.Close()
if err := t.Run(w); err != nil { if err := t.Run(w); err != nil {
fmt.Println(err) fmt.Println(err)

View File

@ -53,11 +53,15 @@ func (t *Tracker) layoutMenuBar(gtx C) D {
for clickedItem, hasClicked := t.Menus[0].Clicked(); hasClicked; { for clickedItem, hasClicked := t.Menus[0].Clicked(); hasClicked; {
switch clickedItem { switch clickedItem {
case 0: case 0:
t.ResetSong() t.TryResetSong()
case 1: case 1:
t.LoadSongFile() t.LoadSongFile()
case 2: case 2:
t.SaveSongFile() t.SaveSongFile()
case 3:
t.SaveSongAsFile()
case 4:
t.TryQuit()
} }
clickedItem, hasClicked = t.Menus[0].Clicked() clickedItem, hasClicked = t.Menus[0].Clicked()
} }
@ -88,6 +92,8 @@ func (t *Tracker) layoutMenuBar(gtx C) D {
MenuItem{IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N"}, MenuItem{IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N"},
MenuItem{IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"}, MenuItem{IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"},
MenuItem{IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S"}, MenuItem{IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S"},
MenuItem{IconBytes: icons.ContentSave, Text: "Save Song As..."},
MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit"},
)), )),
layout.Rigid(t.layoutMenu("Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(160), layout.Rigid(t.layoutMenu("Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(160),
MenuItem{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Disabled: !t.CanUndo()}, MenuItem{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Disabled: !t.CanUndo()},

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"gioui.org/app"
"gioui.org/font/gofont" "gioui.org/font/gofont"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/text" "gioui.org/text"
@ -15,6 +16,12 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const (
ConfirmQuit = iota
ConfirmLoad
ConfirmNew
)
type Tracker struct { type Tracker struct {
Theme *material.Theme Theme *material.Theme
MenuBar []widget.Clickable MenuBar []widget.Clickable
@ -62,6 +69,9 @@ type Tracker struct {
PatternOrderList *layout.List PatternOrderList *layout.List
PatternOrderScrollBar *ScrollBar PatternOrderScrollBar *ScrollBar
ConfirmInstrDelete *Dialog ConfirmInstrDelete *Dialog
ConfirmSongDialog *Dialog
ConfirmSongActionType int
window *app.Window
lastVolume tracker.Volume lastVolume tracker.Volume
volumeChan chan tracker.Volume volumeChan chan tracker.Volume
@ -70,6 +80,7 @@ type Tracker struct {
refresh chan struct{} refresh chan struct{}
playerCloser chan struct{} playerCloser chan struct{}
errorChannel chan error errorChannel chan error
quitted bool
audioContext sointu.AudioContext audioContext sointu.AudioContext
*tracker.Model *tracker.Model
@ -105,7 +116,7 @@ func (t *Tracker) Close() {
t.audioContext.Close() t.audioContext.Close()
} }
func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syncChannel chan<- []float32) *Tracker { func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syncChannel chan<- []float32, window *app.Window) *Tracker {
t := &Tracker{ t := &Tracker{
Theme: material.NewTheme(gofont.Collection()), Theme: material.NewTheme(gofont.Collection()),
audioContext: audioContext, audioContext: audioContext,
@ -153,7 +164,9 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syn
PatternOrderList: &layout.List{Axis: layout.Vertical}, PatternOrderList: &layout.List{Axis: layout.Vertical},
PatternOrderScrollBar: &ScrollBar{Axis: layout.Vertical}, PatternOrderScrollBar: &ScrollBar{Axis: layout.Vertical},
ConfirmInstrDelete: new(Dialog), ConfirmInstrDelete: new(Dialog),
ConfirmSongDialog: new(Dialog),
errorChannel: make(chan error, 32), errorChannel: make(chan error, 32),
window: window,
} }
t.Model = tracker.NewModel() t.Model = tracker.NewModel()
vuBufferObserver := make(chan []float32) vuBufferObserver := make(chan []float32)

View File

@ -29,6 +29,8 @@ type Model struct {
noteTracking bool noteTracking bool
usedIDs map[int]bool usedIDs map[int]bool
maxID int maxID int
filePath string
changedSinceSave bool
prevUndoType string prevUndoType string
undoSkipCounter int undoSkipCounter int
@ -76,8 +78,26 @@ func NewModel() *Model {
return ret return ret
} }
func (m *Model) FilePath() string {
return m.filePath
}
func (m *Model) SetFilePath(value string) {
m.filePath = value
}
func (m *Model) ChangedSinceSave() bool {
return m.changedSinceSave
}
func (m *Model) SetChangedSinceSave(value bool) {
m.changedSinceSave = value
}
func (m *Model) ResetSong() { func (m *Model) ResetSong() {
m.SetSong(defaultSong.Copy()) m.SetSong(defaultSong.Copy())
m.filePath = ""
m.changedSinceSave = false
} }
func (m *Model) SetSong(song sointu.Song) { func (m *Model) SetSong(song sointu.Song) {
@ -671,6 +691,15 @@ func (m *Model) CanUndo() bool {
return len(m.undoStack) > 0 return len(m.undoStack) > 0
} }
func (m *Model) ClearUndoHistory() {
if len(m.undoStack) > 0 {
m.undoStack = m.undoStack[:0]
}
if len(m.redoStack) > 0 {
m.redoStack = m.redoStack[:0]
}
}
func (m *Model) Redo() { func (m *Model) Redo() {
if !m.CanRedo() { if !m.CanRedo() {
return return
@ -986,6 +1015,7 @@ func (m *Model) saveUndo(undoType string, undoSkipping int) {
m.undoSkipCounter++ m.undoSkipCounter++
return return
} }
m.changedSinceSave = true
m.prevUndoType = undoType m.prevUndoType = undoType
m.undoSkipCounter = 0 m.undoSkipCounter = 0
if len(m.undoStack) >= maxUndo { if len(m.undoStack) >= maxUndo {