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/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() preferences Preferences *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(), preferences: MakePreferences(), } t.Theme.Shaper = text.NewShaper(text.WithCollection(fontCollection)) t.PopupAlert = NewPopupAlert(model.Alerts(), t.Theme.Shaper) if t.preferences.YmlError != nil { model.Alerts().Add( fmt.Sprintf("Preferences YML Error: %s", t.preferences.YmlError), tracker.Warning, ) } 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 := t.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 = t.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 (t *Tracker) newWindow() *app.Window { w := new(app.Window) w.Option(app.Title("Sointu Tracker")) w.Option(app.Size(t.preferences.WindowSize())) if t.preferences.Window.Maximized { w.Option(app.Maximized.Option()) } 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:]..., ) } } }