mirror of
https://github.com/vsariola/sointu.git
synced 2025-07-18 21:14:31 -04:00
feat!: rewrote the GUI and model for better testability
The Model was getting unmaintanable mess. This is an attempt to refactor/rewrite the Model so that data of certain type is exposed in standardized way, offering certain standard manipulations for that data type, and on the GUI side, certain standard widgets to tied to that data. This rewrite closes #72, #106 and #120.
This commit is contained in:
parent
6d3c65e11d
commit
d92426a100
@ -1,24 +1,63 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/io/clipboard"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/system"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"gioui.org/x/explorer"
|
||||
"github.com/vsariola/sointu"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var canQuit = true // set to false in init() if plugin tag is enabled
|
||||
|
||||
type (
|
||||
Tracker struct {
|
||||
Theme *material.Theme
|
||||
OctaveNumberInput *NumberInput
|
||||
InstrumentVoices *NumberInput
|
||||
TopHorizontalSplit *Split
|
||||
BottomHorizontalSplit *Split
|
||||
VerticalSplit *Split
|
||||
KeyPlaying map[string]tracker.NoteID
|
||||
PopupAlert *PopupAlert
|
||||
|
||||
SaveChangesDialog *Dialog
|
||||
WaveTypeDialog *Dialog
|
||||
|
||||
ModalDialog layout.Widget
|
||||
InstrumentEditor *InstrumentEditor
|
||||
OrderEditor *OrderEditor
|
||||
TrackEditor *NoteEditor
|
||||
Explorer *explorer.Explorer
|
||||
Exploring bool
|
||||
SongPanel *SongPanel
|
||||
|
||||
filePathString tracker.String
|
||||
|
||||
quitWG sync.WaitGroup
|
||||
execChan chan func()
|
||||
|
||||
*tracker.Model
|
||||
}
|
||||
|
||||
C = layout.Context
|
||||
D = layout.Dimensions
|
||||
)
|
||||
|
||||
const (
|
||||
@ -27,140 +66,34 @@ const (
|
||||
ConfirmNew
|
||||
)
|
||||
|
||||
type Tracker struct {
|
||||
Theme *material.Theme
|
||||
MenuBar []widget.Clickable
|
||||
Menus []Menu
|
||||
OctaveNumberInput *NumberInput
|
||||
BPM *NumberInput
|
||||
RowsPerPattern *NumberInput
|
||||
RowsPerBeat *NumberInput
|
||||
Step *NumberInput
|
||||
InstrumentVoices *NumberInput
|
||||
SongLength *NumberInput
|
||||
PanicBtn *widget.Clickable
|
||||
RecordBtn *widget.Clickable
|
||||
AddUnitBtn *widget.Clickable
|
||||
TrackHexCheckBox *widget.Bool
|
||||
TopHorizontalSplit *Split
|
||||
BottomHorizontalSplit *Split
|
||||
VerticalSplit *Split
|
||||
KeyPlaying map[string]tracker.NoteID
|
||||
Alert Alert
|
||||
ConfirmSongDialog *Dialog
|
||||
WaveTypeDialog *Dialog
|
||||
ConfirmSongActionType int
|
||||
ModalDialog layout.Widget
|
||||
InstrumentEditor *InstrumentEditor
|
||||
OrderEditor *OrderEditor
|
||||
TrackEditor *TrackEditor
|
||||
Explorer *explorer.Explorer
|
||||
|
||||
TextShaper *text.Shaper
|
||||
|
||||
lastAvgVolume tracker.Volume
|
||||
lastPeakVolume tracker.Volume
|
||||
|
||||
wavFilePath string
|
||||
quitChannel chan struct{}
|
||||
quitWG sync.WaitGroup
|
||||
errorChannel chan error
|
||||
quitted bool
|
||||
unmarshalRecoveryChannel chan []byte
|
||||
marshalRecoveryChannel chan (chan []byte)
|
||||
synther sointu.Synther
|
||||
|
||||
*trackerModel
|
||||
}
|
||||
|
||||
type trackerModel = tracker.Model
|
||||
|
||||
func (t *Tracker) UnmarshalContent(bytes []byte) error {
|
||||
var units []sointu.Unit
|
||||
if errJSON := json.Unmarshal(bytes, &units); errJSON == nil {
|
||||
if len(units) == 0 {
|
||||
return nil
|
||||
}
|
||||
t.PasteUnits(units)
|
||||
// TODO: this is a bit hacky, but works for now. How to change the selection to the pasted units more elegantly?
|
||||
t.InstrumentEditor.unitDragList.SelectedItem = t.UnitIndex()
|
||||
t.InstrumentEditor.unitDragList.SelectedItem2 = t.UnitIndex() + len(units) - 1
|
||||
return nil
|
||||
}
|
||||
if errYaml := yaml.Unmarshal(bytes, &units); errYaml == nil {
|
||||
if len(units) == 0 {
|
||||
return nil
|
||||
}
|
||||
t.PasteUnits(units)
|
||||
t.InstrumentEditor.unitDragList.SelectedItem = t.UnitIndex()
|
||||
t.InstrumentEditor.unitDragList.SelectedItem2 = t.UnitIndex() + len(units) - 1
|
||||
return nil
|
||||
}
|
||||
var instr sointu.Instrument
|
||||
if errJSON := json.Unmarshal(bytes, &instr); errJSON == nil {
|
||||
if t.SetInstrument(instr) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if errYaml := yaml.Unmarshal(bytes, &instr); errYaml == nil {
|
||||
if t.SetInstrument(instr) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var song sointu.Song
|
||||
if errJSON := json.Unmarshal(bytes, &song); errJSON != nil {
|
||||
if errYaml := yaml.Unmarshal(bytes, &song); errYaml != nil {
|
||||
return fmt.Errorf("the song could not be parsed as .json (%v) or .yml (%v)", errJSON, errYaml)
|
||||
}
|
||||
}
|
||||
if song.BPM > 0 {
|
||||
t.SetSong(song)
|
||||
return nil
|
||||
}
|
||||
return errors.New("was able to unmarshal a song, but the bpm was 0")
|
||||
}
|
||||
|
||||
func NewTracker(model *tracker.Model, synther sointu.Synther) *Tracker {
|
||||
func NewTracker(model *tracker.Model) *Tracker {
|
||||
t := &Tracker{
|
||||
Theme: material.NewTheme(),
|
||||
BPM: new(NumberInput),
|
||||
OctaveNumberInput: &NumberInput{Value: 4},
|
||||
SongLength: new(NumberInput),
|
||||
RowsPerPattern: new(NumberInput),
|
||||
RowsPerBeat: new(NumberInput),
|
||||
Step: &NumberInput{Value: 1},
|
||||
InstrumentVoices: new(NumberInput),
|
||||
|
||||
PanicBtn: new(widget.Clickable),
|
||||
RecordBtn: new(widget.Clickable),
|
||||
TrackHexCheckBox: new(widget.Bool),
|
||||
Menus: make([]Menu, 2),
|
||||
MenuBar: make([]widget.Clickable, 2),
|
||||
quitChannel: make(chan struct{}, 1), // use non-blocking sends; no need to queue extra ticks if one is queued already
|
||||
OctaveNumberInput: NewNumberInput(model.Octave().Int()),
|
||||
InstrumentVoices: NewNumberInput(model.InstrumentVoices().Int()),
|
||||
|
||||
TopHorizontalSplit: &Split{Ratio: -.6},
|
||||
BottomHorizontalSplit: &Split{Ratio: -.6},
|
||||
VerticalSplit: &Split{Axis: layout.Vertical},
|
||||
|
||||
KeyPlaying: make(map[string]tracker.NoteID),
|
||||
ConfirmSongDialog: new(Dialog),
|
||||
WaveTypeDialog: new(Dialog),
|
||||
InstrumentEditor: NewInstrumentEditor(),
|
||||
OrderEditor: NewOrderEditor(),
|
||||
TrackEditor: NewTrackEditor(),
|
||||
SaveChangesDialog: NewDialog(model.SaveSong(), model.DiscardSong(), model.Cancel()),
|
||||
WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()),
|
||||
InstrumentEditor: NewInstrumentEditor(model),
|
||||
OrderEditor: NewOrderEditor(model),
|
||||
TrackEditor: NewNoteEditor(model),
|
||||
SongPanel: NewSongPanel(model),
|
||||
|
||||
errorChannel: make(chan error, 32),
|
||||
synther: synther,
|
||||
trackerModel: model,
|
||||
Model: model,
|
||||
|
||||
marshalRecoveryChannel: make(chan (chan []byte)),
|
||||
unmarshalRecoveryChannel: make(chan []byte),
|
||||
filePathString: model.FilePath().String(),
|
||||
execChan: make(chan func(), 1024),
|
||||
}
|
||||
t.TextShaper = text.NewShaper(text.WithCollection(fontCollection))
|
||||
t.Alert.shaper = t.TextShaper
|
||||
t.Theme.Shaper = text.NewShaper(text.WithCollection(fontCollection))
|
||||
t.PopupAlert = NewPopupAlert(model.Alerts(), t.Theme.Shaper)
|
||||
t.Theme.Palette.Fg = primaryColor
|
||||
t.Theme.Palette.ContrastFg = black
|
||||
t.TrackEditor.Focus()
|
||||
t.TrackEditor.scrollTable.Focus()
|
||||
t.quitWG.Add(1)
|
||||
return t
|
||||
}
|
||||
@ -171,19 +104,13 @@ func (t *Tracker) Main() {
|
||||
app.Size(unit.Dp(800), unit.Dp(600)),
|
||||
app.Title("Sointu Tracker"),
|
||||
)
|
||||
t.InstrumentEditor.Focus()
|
||||
recoveryTicker := time.NewTicker(time.Second * 30)
|
||||
t.Explorer = explorer.NewExplorer(w)
|
||||
var ops op.Ops
|
||||
mainloop:
|
||||
for {
|
||||
if pos, playing := t.PlayPosition(), t.Playing(); t.NoteTracking() && playing {
|
||||
cursor := t.Cursor()
|
||||
cursor.ScoreRow = pos
|
||||
t.SetCursor(cursor)
|
||||
t.SetSelectionCorner(cursor)
|
||||
}
|
||||
if titleFooter != t.FilePath() {
|
||||
titleFooter = t.FilePath()
|
||||
if titleFooter != t.filePathString.Value() {
|
||||
titleFooter = t.filePathString.Value()
|
||||
if titleFooter != "" {
|
||||
w.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", titleFooter)))
|
||||
} else {
|
||||
@ -191,28 +118,16 @@ mainloop:
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-t.quitChannel:
|
||||
recoveryTicker.Stop()
|
||||
break mainloop
|
||||
case e := <-t.errorChannel:
|
||||
t.Alert.Update(e.Error(), Error, time.Second*5)
|
||||
w.Invalidate()
|
||||
case e := <-t.PlayerMessages:
|
||||
if err, ok := e.Inner.(tracker.PlayerCrashMessage); ok {
|
||||
t.Alert.Update(err.Error(), Error, time.Second*3)
|
||||
}
|
||||
if err, ok := e.Inner.(tracker.PlayerVolumeErrorMessage); ok {
|
||||
t.Alert.Update(err.Error(), Warning, time.Second*3)
|
||||
}
|
||||
t.lastAvgVolume = e.AverageVolume
|
||||
t.lastPeakVolume = e.PeakVolume
|
||||
t.InstrumentEditor.voiceLevels = e.VoiceLevels
|
||||
t.ProcessPlayerMessage(e)
|
||||
w.Invalidate()
|
||||
case e := <-w.Events():
|
||||
switch e := e.(type) {
|
||||
case system.DestroyEvent:
|
||||
if !t.Quit(false) {
|
||||
if canQuit {
|
||||
t.Quit().Do()
|
||||
}
|
||||
if !t.Quitted() {
|
||||
// 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)),
|
||||
@ -222,41 +137,146 @@ mainloop:
|
||||
}
|
||||
case system.FrameEvent:
|
||||
gtx := layout.NewContext(&ops, e)
|
||||
if t.SongPanel.PlayingBtn.Bool.Value() && t.SongPanel.NoteTracking.Bool.Value() {
|
||||
t.TrackEditor.scrollTable.RowTitleList.CenterOn(t.PlaySongRow())
|
||||
}
|
||||
t.Layout(gtx, w)
|
||||
e.Frame(gtx.Ops)
|
||||
}
|
||||
case <-recoveryTicker.C:
|
||||
t.SaveRecovery()
|
||||
case retChn := <-t.marshalRecoveryChannel:
|
||||
retChn <- t.MarshalRecovery()
|
||||
case bytes := <-t.unmarshalRecoveryChannel:
|
||||
t.UnmarshalRecovery(bytes)
|
||||
case f := <-t.execChan:
|
||||
f()
|
||||
}
|
||||
if t.Quitted() {
|
||||
break
|
||||
}
|
||||
}
|
||||
recoveryTicker.Stop()
|
||||
w.Perform(system.ActionClose)
|
||||
t.SaveRecovery()
|
||||
t.quitWG.Done()
|
||||
}
|
||||
|
||||
// thread safe, executed in the GUI thread
|
||||
func (t *Tracker) SafeMarshalRecovery() []byte {
|
||||
retChn := make(chan []byte)
|
||||
t.marshalRecoveryChannel <- retChn
|
||||
return <-retChn
|
||||
}
|
||||
|
||||
// thread safe, executed in the GUI thread
|
||||
func (t *Tracker) SafeUnmarshalRecovery(data []byte) {
|
||||
t.unmarshalRecoveryChannel <- data
|
||||
}
|
||||
|
||||
func (t *Tracker) sendQuit() {
|
||||
select {
|
||||
case t.quitChannel <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
func (t *Tracker) Exec() chan<- func() {
|
||||
return t.execChan
|
||||
}
|
||||
|
||||
func (t *Tracker) WaitQuitted() {
|
||||
t.quitWG.Wait()
|
||||
}
|
||||
|
||||
func (t *Tracker) Layout(gtx layout.Context, w *app.Window) {
|
||||
// this is the top level input handler for the whole app
|
||||
// it handles all the global key events and clipboard events
|
||||
// we need to tell gio that we handle tabs too; otherwise
|
||||
// it will steal them for focus switching
|
||||
key.InputOp{Tag: t, Keys: "Tab|Shift-Tab"}.Add(gtx.Ops)
|
||||
for _, ev := range gtx.Events(t) {
|
||||
switch e := ev.(type) {
|
||||
case key.Event:
|
||||
t.KeyEvent(e, gtx.Ops)
|
||||
case clipboard.Event:
|
||||
stringReader := strings.NewReader(e.Text)
|
||||
stringReadCloser := io.NopCloser(stringReader)
|
||||
t.ReadSong(stringReadCloser)
|
||||
}
|
||||
}
|
||||
|
||||
paint.FillShape(gtx.Ops, backgroundColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
|
||||
if t.InstrumentEditor.enlargeBtn.Bool.Value() {
|
||||
t.layoutTop(gtx)
|
||||
} else {
|
||||
t.VerticalSplit.Layout(gtx,
|
||||
t.layoutTop,
|
||||
t.layoutBottom)
|
||||
}
|
||||
t.PopupAlert.Layout(gtx)
|
||||
t.showDialog(gtx)
|
||||
}
|
||||
|
||||
func (t *Tracker) showDialog(gtx C) {
|
||||
if t.Exploring {
|
||||
return
|
||||
}
|
||||
switch t.Dialog() {
|
||||
case tracker.NewSongChanges, tracker.OpenSongChanges, tracker.QuitChanges:
|
||||
dstyle := ConfirmDialog(t.Theme, t.SaveChangesDialog, "Save changes to song?", "Your changes will be lost if you don't save them.")
|
||||
dstyle.OkStyle.Text = "Save"
|
||||
dstyle.AltStyle.Text = "Don't save"
|
||||
dstyle.Layout(gtx)
|
||||
case tracker.Export:
|
||||
dstyle := ConfirmDialog(t.Theme, t.WaveTypeDialog, "", "Export .wav in int16 or float32 sample format?")
|
||||
dstyle.OkStyle.Text = "Int16"
|
||||
dstyle.AltStyle.Text = "Float32"
|
||||
dstyle.Layout(gtx)
|
||||
case tracker.OpenSongOpenExplorer:
|
||||
t.explorerChooseFile(t.ReadSong, ".yml", ".json")
|
||||
case tracker.NewSongSaveExplorer, tracker.OpenSongSaveExplorer, tracker.QuitSaveExplorer, tracker.SaveAsExplorer:
|
||||
filename := t.filePathString.Value()
|
||||
if filename == "" {
|
||||
filename = "song.yml"
|
||||
}
|
||||
t.explorerCreateFile(t.WriteSong, filename)
|
||||
case tracker.ExportFloatExplorer, tracker.ExportInt16Explorer:
|
||||
filename := "song.wav"
|
||||
if p := t.filePathString.Value(); p != "" {
|
||||
filename = p[:len(p)-len(filepath.Ext(p))] + ".wav"
|
||||
}
|
||||
t.explorerCreateFile(func(wc io.WriteCloser) {
|
||||
t.WriteWav(wc, t.Dialog() == tracker.ExportInt16Explorer, t.execChan)
|
||||
}, filename)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) explorerChooseFile(success func(io.ReadCloser), extensions ...string) {
|
||||
t.Exploring = true
|
||||
go func() {
|
||||
file, err := t.Explorer.ChooseFile(extensions...)
|
||||
t.Exec() <- func() {
|
||||
t.Exploring = false
|
||||
if err == nil {
|
||||
success(file)
|
||||
} else {
|
||||
t.Cancel().Do()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *Tracker) explorerCreateFile(success func(io.WriteCloser), filename string) {
|
||||
t.Exploring = true
|
||||
go func() {
|
||||
file, err := t.Explorer.CreateFile(filename)
|
||||
t.Exec() <- func() {
|
||||
t.Exploring = false
|
||||
if err == nil {
|
||||
success(file)
|
||||
} else {
|
||||
t.Cancel().Do()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *Tracker) layoutBottom(gtx layout.Context) layout.Dimensions {
|
||||
return t.BottomHorizontalSplit.Layout(gtx,
|
||||
func(gtx C) D {
|
||||
return t.OrderEditor.Layout(gtx, t)
|
||||
},
|
||||
func(gtx C) D {
|
||||
return t.TrackEditor.Layout(gtx, t)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (t *Tracker) layoutTop(gtx layout.Context) layout.Dimensions {
|
||||
return t.TopHorizontalSplit.Layout(gtx,
|
||||
func(gtx C) D {
|
||||
return t.SongPanel.Layout(gtx, t)
|
||||
},
|
||||
func(gtx C) D {
|
||||
return t.InstrumentEditor.Layout(gtx, t)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user