sointu/tracker/gioui/tracker.go
2024-11-22 15:46:46 +01:00

357 lines
9.0 KiB
Go

package gioui
import (
"fmt"
"image"
"io"
"path/filepath"
"sync"
"time"
"gioui.org/app"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/system"
"gioui.org/io/transfer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget/material"
"gioui.org/x/explorer"
"github.com/vsariola/sointu/tracker"
)
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[key.Name]tracker.NoteID
MidiNotePlaying []byte
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 (
ConfirmQuit = iota
ConfirmLoad
ConfirmNew
)
func NewTracker(model *tracker.Model) *Tracker {
t := &Tracker{
Theme: material.NewTheme(),
OctaveNumberInput: NewNumberInput(model.Octave().Int()),
InstrumentVoices: NewNumberInput(model.InstrumentVoices().Int()),
TopHorizontalSplit: &Split{Ratio: -.5},
BottomHorizontalSplit: &Split{Ratio: -.6},
VerticalSplit: &Split{Axis: layout.Vertical},
KeyPlaying: make(map[key.Name]tracker.NoteID),
MidiNotePlaying: make([]byte, 0, 32),
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),
Model: model,
filePathString: model.FilePath().String(),
}
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.scrollTable.Focus()
t.quitWG.Add(1)
return t
}
func (t *Tracker) Main() {
titleFooter := ""
w := NewWindow()
t.InstrumentEditor.Focus()
recoveryTicker := time.NewTicker(time.Second * 30)
t.Explorer = explorer.NewExplorer(w)
// Make a channel to read window events from.
events := make(chan event.Event)
// Make a channel to signal the end of processing a window event.
acks := make(chan struct{})
go eventLoop(w, events, acks)
var ops op.Ops
for {
select {
case e := <-t.Broker().ToModel:
t.ProcessMsg(e)
w.Invalidate()
case e := <-events:
switch e := e.(type) {
case app.DestroyEvent:
acks <- struct{}{}
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 = NewWindow()
t.Explorer = explorer.NewExplorer(w)
go eventLoop(w, events, acks)
}
case app.FrameEvent:
if titleFooter != t.filePathString.Value() {
titleFooter = t.filePathString.Value()
if titleFooter != "" {
w.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", titleFooter)))
} else {
w.Option(app.Title("Sointu Tracker"))
}
}
gtx := app.NewContext(&ops, e)
if t.SongPanel.PlayingBtn.Bool.Value() && t.SongPanel.FollowBtn.Bool.Value() {
t.TrackEditor.scrollTable.RowTitleList.CenterOn(t.PlaySongRow())
}
t.Layout(gtx, w)
e.Frame(gtx.Ops)
acks <- struct{}{}
default:
acks <- struct{}{}
}
case <-recoveryTicker.C:
t.SaveRecovery()
}
if t.Quitted() {
break
}
}
recoveryTicker.Stop()
w.Perform(system.ActionClose)
t.SaveRecovery()
t.quitWG.Done()
}
func NewWindow() *app.Window {
w := new(app.Window)
w.Option(app.Title("Sointu Tracker"))
w.Option(app.Size(unit.Dp(800), unit.Dp(600)))
return w
}
func eventLoop(w *app.Window, events chan<- event.Event, acks <-chan struct{}) {
// Iterate window events, sending each to the old event loop and waiting for
// a signal that processing is complete before iterating again.
for {
ev := w.Event()
events <- ev
<-acks
if _, ok := ev.(app.DestroyEvent); ok {
return
}
}
}
func (t *Tracker) WaitQuitted() {
t.quitWG.Wait()
}
func (t *Tracker) Layout(gtx layout.Context, w *app.Window) {
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)
// 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
for {
ev, ok := gtx.Event(
key.Filter{Name: "", Optional: key.ModAlt | key.ModCommand | key.ModShift | key.ModShortcut | key.ModSuper},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
transfer.TargetFilter{Target: t, Type: "application/text"},
)
if !ok {
break
}
switch e := ev.(type) {
case key.Event:
t.KeyEvent(e, gtx)
case transfer.DataEvent:
t.ReadSong(e.Open())
}
}
}
func (t *Tracker) showDialog(gtx C) {
if t.Exploring {
return
}
switch t.Dialog() {
case tracker.NewSongChanges, tracker.OpenSongChanges, tracker.QuitChanges:
dstyle := ConfirmDialog(gtx, 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(gtx, 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)
}, filename)
}
}
func (t *Tracker) explorerChooseFile(success func(io.ReadCloser), extensions ...string) {
t.Exploring = true
go func() {
file, err := t.Explorer.ChooseFile(extensions...)
t.Broker().ToModel <- tracker.MsgToModel{Data: 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.Broker().ToModel <- tracker.MsgToModel{Data: 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)
},
)
}
/// Event Handling (for UI updates when playing etc.)
func (t *Tracker) ProcessMessage(msg interface{}) {
switch msg.(type) {
case tracker.StartPlayMsg:
fmt.Println("Tracker received StartPlayMsg")
case tracker.RecordingMsg:
fmt.Println("Tracker received RecordingMsg")
default:
break
}
}
func (t *Tracker) ProcessEvent(event tracker.MIDINoteEvent) {
// MIDINoteEvent can be only NoteOn / NoteOff, i.e. its On field
if event.On {
t.addToMidiNotePlaying(event.Note)
} else {
t.removeFromMidiNotePlaying(event.Note)
}
t.TrackEditor.HandleMidiInput(t)
}
func (t *Tracker) addToMidiNotePlaying(note byte) {
for _, n := range t.MidiNotePlaying {
if n == note {
return
}
}
t.MidiNotePlaying = append(t.MidiNotePlaying, note)
}
func (t *Tracker) removeFromMidiNotePlaying(note byte) {
for i, n := range t.MidiNotePlaying {
if n == note {
t.MidiNotePlaying = append(
t.MidiNotePlaying[:i],
t.MidiNotePlaying[i+1:]...,
)
}
}
}
func (t *Tracker) HasAnyMidiInput() bool {
for _ = range t.Model.MIDI.InputDevices {
return true
}
return false
}