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

@ -17,18 +17,20 @@ import (
// accidental mutations in the song. But at least the value members are // accidental mutations in the song. But at least the value members are
// protected. // protected.
type Model struct { type Model struct {
song sointu.Song song sointu.Song
editMode EditMode editMode EditMode
selectionCorner SongPoint selectionCorner SongPoint
cursor SongPoint cursor SongPoint
lowNibble bool lowNibble bool
instrIndex int instrIndex int
unitIndex int unitIndex int
paramIndex int paramIndex int
octave int octave int
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 {