From 7893c1d1ed5d21ae42721a3468b9532bf1cadfc5 Mon Sep 17 00:00:00 2001 From: vsariola <5684185+vsariola@users.noreply.github.com> Date: Fri, 16 Apr 2021 22:42:51 +0300 Subject: [PATCH] feat(tracker, gioui): add confirmation dialogs before quit/new/load song This should avoid accidentally losing all work by destroying window. --- tracker/gioui/dialog.go | 12 ++++++++ tracker/gioui/files.go | 43 ++++++++++++++++++++++++---- tracker/gioui/keyevent.go | 2 +- tracker/gioui/layout.go | 57 ++++++++++++++++++++++++++++++++++++++ tracker/gioui/run.go | 13 +++++++-- tracker/gioui/songpanel.go | 8 +++++- tracker/gioui/tracker.go | 15 +++++++++- tracker/model.go | 54 ++++++++++++++++++++++++++++-------- 8 files changed, 181 insertions(+), 23 deletions(-) diff --git a/tracker/gioui/dialog.go b/tracker/gioui/dialog.go index 0a0dcf3..a88366b 100644 --- a/tracker/gioui/dialog.go +++ b/tracker/gioui/dialog.go @@ -10,6 +10,7 @@ import ( type Dialog struct { Visible bool + BtnAlt widget.Clickable BtnOk widget.Clickable BtnCancel widget.Clickable } @@ -18,6 +19,8 @@ type DialogStyle struct { dialog *Dialog Text string Inset layout.Inset + ShowAlt bool + AltStyle material.ButtonStyle OkStyle material.ButtonStyle CancelStyle material.ButtonStyle } @@ -27,9 +30,11 @@ func ConfirmDialog(th *material.Theme, dialog *Dialog, text string) DialogStyle dialog: dialog, Text: text, 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"), CancelStyle: material.Button(th, &dialog.BtnCancel, "Cancel"), } + ret.AltStyle.Background = primaryColor ret.OkStyle.Background = primaryColor ret.CancelStyle.Background = primaryColor return ret @@ -45,6 +50,13 @@ func (d *DialogStyle) Layout(gtx C) D { layout.Rigid(Label(d.Text, highEmphasisTextColor)), layout.Rigid(func(gtx C) D { 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, layout.Rigid(d.OkStyle.Layout), layout.Rigid(d.CancelStyle.Layout), diff --git a/tracker/gioui/files.go b/tracker/gioui/files.go index a5f3bbf..52ac8f3 100644 --- a/tracker/gioui/files.go +++ b/tracker/gioui/files.go @@ -4,9 +4,11 @@ package gioui import ( "encoding/json" + "fmt" "io/ioutil" "path/filepath" + "gioui.org/app" "gopkg.in/yaml.v3" "github.com/sqweek/dialog" @@ -14,6 +16,30 @@ import ( ) 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() if err != nil { return @@ -29,25 +55,30 @@ func (t *Tracker) LoadSongFile() { } } 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() { - filename, err := dialog.File().Filter("Sointu YAML song", "yml").Filter("Sointu JSON song", "json").Title("Save song").Save() - if err != nil { - return - } +func (t *Tracker) saveSong(filename string) bool { var extension = filepath.Ext(filename) var contents []byte + var err error if extension == "json" { contents, err = json.Marshal(t.Song()) } else { contents, err = yaml.Marshal(t.Song()) } if err != nil { - return + return false } if extension == "" { filename = filename + ".yml" } ioutil.WriteFile(filename, contents, 0644) + t.SetFilePath(filename) + t.window.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", filename))) + t.SetChangedSinceSave(false) + return true } diff --git a/tracker/gioui/keyevent.go b/tracker/gioui/keyevent.go index 0fdc715..fa9e349 100644 --- a/tracker/gioui/keyevent.go +++ b/tracker/gioui/keyevent.go @@ -111,7 +111,7 @@ func (t *Tracker) KeyEvent(w *app.Window, e key.Event) bool { } case "N": if e.Modifiers.Contain(key.ModShortcut) { - t.ResetSong() + t.TryResetSong() return true } case "S": diff --git a/tracker/gioui/layout.go b/tracker/gioui/layout.go index 0dc8bee..a5dcd05 100644 --- a/tracker/gioui/layout.go +++ b/tracker/gioui/layout.go @@ -3,6 +3,7 @@ package gioui import ( "image" + "gioui.org/app" "gioui.org/layout" "gioui.org/op/clip" "gioui.org/op/paint" @@ -20,6 +21,62 @@ func (t *Tracker) Layout(gtx layout.Context) { t.Alert.Layout(gtx) dstyle := ConfirmDialog(t.Theme, t.ConfirmInstrDelete, "Are you sure you want to delete this instrument?") 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 { diff --git a/tracker/gioui/run.go b/tracker/gioui/run.go index aaf7f58..20f6adb 100644 --- a/tracker/gioui/run.go +++ b/tracker/gioui/run.go @@ -36,7 +36,13 @@ func (t *Tracker) Run(w *app.Window) error { case e := <-w.Events(): switch e := e.(type) { 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: if t.KeyEvent(w, e) { w.Invalidate() @@ -52,6 +58,9 @@ func (t *Tracker) Run(w *app.Window) error { 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.Title("Sointu Tracker"), ) - t := New(audioContext, synthService, syncChannel) + t := New(audioContext, synthService, syncChannel, w) defer t.Close() if err := t.Run(w); err != nil { fmt.Println(err) diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index 5a07f9c..7923ea0 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -53,11 +53,15 @@ func (t *Tracker) layoutMenuBar(gtx C) D { for clickedItem, hasClicked := t.Menus[0].Clicked(); hasClicked; { switch clickedItem { case 0: - t.ResetSong() + t.TryResetSong() case 1: t.LoadSongFile() case 2: t.SaveSongFile() + case 3: + t.SaveSongAsFile() + case 4: + t.TryQuit() } 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.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"}, 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), MenuItem{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Disabled: !t.CanUndo()}, diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index eb23524..5f10425 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "gioui.org/app" "gioui.org/font/gofont" "gioui.org/layout" "gioui.org/text" @@ -15,6 +16,12 @@ import ( "gopkg.in/yaml.v3" ) +const ( + ConfirmQuit = iota + ConfirmLoad + ConfirmNew +) + type Tracker struct { Theme *material.Theme MenuBar []widget.Clickable @@ -62,6 +69,9 @@ type Tracker struct { PatternOrderList *layout.List PatternOrderScrollBar *ScrollBar ConfirmInstrDelete *Dialog + ConfirmSongDialog *Dialog + ConfirmSongActionType int + window *app.Window lastVolume tracker.Volume volumeChan chan tracker.Volume @@ -70,6 +80,7 @@ type Tracker struct { refresh chan struct{} playerCloser chan struct{} errorChannel chan error + quitted bool audioContext sointu.AudioContext *tracker.Model @@ -105,7 +116,7 @@ func (t *Tracker) 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{ Theme: material.NewTheme(gofont.Collection()), audioContext: audioContext, @@ -153,7 +164,9 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syn PatternOrderList: &layout.List{Axis: layout.Vertical}, PatternOrderScrollBar: &ScrollBar{Axis: layout.Vertical}, ConfirmInstrDelete: new(Dialog), + ConfirmSongDialog: new(Dialog), errorChannel: make(chan error, 32), + window: window, } t.Model = tracker.NewModel() vuBufferObserver := make(chan []float32) diff --git a/tracker/model.go b/tracker/model.go index 0477221..b399644 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -17,18 +17,20 @@ import ( // accidental mutations in the song. But at least the value members are // protected. type Model struct { - song sointu.Song - editMode EditMode - selectionCorner SongPoint - cursor SongPoint - lowNibble bool - instrIndex int - unitIndex int - paramIndex int - octave int - noteTracking bool - usedIDs map[int]bool - maxID int + song sointu.Song + editMode EditMode + selectionCorner SongPoint + cursor SongPoint + lowNibble bool + instrIndex int + unitIndex int + paramIndex int + octave int + noteTracking bool + usedIDs map[int]bool + maxID int + filePath string + changedSinceSave bool prevUndoType string undoSkipCounter int @@ -76,8 +78,26 @@ func NewModel() *Model { 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() { m.SetSong(defaultSong.Copy()) + m.filePath = "" + m.changedSinceSave = false } func (m *Model) SetSong(song sointu.Song) { @@ -671,6 +691,15 @@ func (m *Model) CanUndo() bool { 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() { if !m.CanRedo() { return @@ -986,6 +1015,7 @@ func (m *Model) saveUndo(undoType string, undoSkipping int) { m.undoSkipCounter++ return } + m.changedSinceSave = true m.prevUndoType = undoType m.undoSkipCounter = 0 if len(m.undoStack) >= maxUndo {