diff --git a/.gitignore b/.gitignore index 35b5637..394a03a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ actual_output/ **/__debug_bin *.exe *.dll + +**/testdata/fuzz/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6347ccc..8adc0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased ### Added +- Massive rewrite of the GUI, in particular allowing better copying, pasting and + scrolling of table-based data (order list and note data). - Dbgain unit, which allows defining the gain in decibels (-40 dB to +40dB) ### Fixed - 32-bit su_load_gmdls clobbered ebx, even though __stdcall demands it to be not touched +- Spaces are allowed in instrument names (#120) ## v0.3.0 ### Added diff --git a/audio.go b/audio.go index 54ec70f..699f861 100644 --- a/audio.go +++ b/audio.go @@ -66,7 +66,7 @@ type ( // Play plays the Song by first compiling the patch with the given Synther, // returning the stereo audio buffer as a result (and possible errors). -func Play(synther Synther, song Song) (AudioBuffer, error) { +func Play(synther Synther, song Song, progress func(float32)) (AudioBuffer, error) { err := song.Validate() if err != nil { return nil, err @@ -125,6 +125,9 @@ func Play(synther Synther, song Song) (AudioBuffer, error) { return nil, fmt.Errorf("Song speed modulation likely so slow that row never advances; error at pattern %v, row %v", pattern, patternRow) } } + if progress != nil { + progress(float32(row+1) / float32(song.Score.LengthInRows())) + } } return buffer, nil } diff --git a/cmd/sointu-play/main.go b/cmd/sointu-play/main.go index a075d3b..bac4fb3 100644 --- a/cmd/sointu-play/main.go +++ b/cmd/sointu-play/main.go @@ -87,7 +87,7 @@ func main() { return fmt.Errorf("the song could not be parsed as .json (%v) or .yml (%v)", errJSON, errYaml) } } - buffer, err := sointu.Play(bridge.NativeSynther{}, song) // render the song to calculate its length + buffer, err := sointu.Play(bridge.NativeSynther{}, song, nil) // render the song to calculate its length if err != nil { return fmt.Errorf("sointu.Play failed: %v", err) } diff --git a/cmd/sointu-track/main.go b/cmd/sointu-track/main.go index 36fd7a0..24f9539 100644 --- a/cmd/sointu-track/main.go +++ b/cmd/sointu-track/main.go @@ -33,16 +33,16 @@ var memprofile = flag.String("memprofile", "", "write memory profile to `file`") func main() { flag.Parse() + var f *os.File if *cpuprofile != "" { - f, err := os.Create(*cpuprofile) + var err error + f, err = os.Create(*cpuprofile) if err != nil { log.Fatal("could not create CPU profile: ", err) } - defer f.Close() // error handling omitted for example if err := pprof.StartCPUProfile(f); err != nil { log.Fatal("could not start CPU profile: ", err) } - defer pprof.StopCPUProfile() } audioContext, err := oto.NewContext() if err != nil { @@ -50,15 +50,12 @@ func main() { os.Exit(1) } defer audioContext.Close() - modelMessages := make(chan interface{}, 1024) - playerMessages := make(chan tracker.PlayerMessage, 1024) recoveryFile := "" if configDir, err := os.UserConfigDir(); err == nil { recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery") } - model := tracker.NewModel(modelMessages, playerMessages, recoveryFile) - player := tracker.NewPlayer(cmd.MainSynther, playerMessages, modelMessages) - tracker := gioui.NewTracker(model, cmd.MainSynther) + model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile) + tracker := gioui.NewTracker(model) output := audioContext.Output() defer output.Close() go func() { @@ -71,6 +68,10 @@ func main() { }() go func() { tracker.Main() + if *cpuprofile != "" { + pprof.StopCPUProfile() + f.Close() + } if *memprofile != "" { f, err := os.Create(*memprofile) if err != nil { diff --git a/cmd/sointu-vsti/main.go b/cmd/sointu-vsti/main.go index ded6e7b..cb5328e 100644 --- a/cmd/sointu-vsti/main.go +++ b/cmd/sointu-vsti/main.go @@ -54,19 +54,16 @@ func init() { version = int32(100) ) vst2.PluginAllocator = func(h vst2.Host) (vst2.Plugin, vst2.Dispatcher) { - modelMessages := make(chan interface{}, 1024) - playerMessages := make(chan tracker.PlayerMessage, 1024) recoveryFile := "" if configDir, err := os.UserConfigDir(); err == nil { randBytes := make([]byte, 16) rand.Read(randBytes) recoveryFile = filepath.Join(configDir, "Sointu", "sointu-vsti-recovery-"+hex.EncodeToString(randBytes)) } - model := tracker.NewModel(modelMessages, playerMessages, recoveryFile) - player := tracker.NewPlayer(cmd.MainSynther, playerMessages, modelMessages) - tracker := gioui.NewTracker(model, cmd.MainSynther) - tracker.SetInstrEnlarged(true) // start the vsti with the instrument editor enlarged - go tracker.Main() + model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile) + t := gioui.NewTracker(model) + tracker.Bool{BoolData: (*tracker.InstrEnlarged)(model)}.Set(true) + go t.Main() context := VSTIProcessContext{host: h} buf := make(sointu.AudioBuffer, 1024) return vst2.Plugin{ @@ -110,14 +107,16 @@ func init() { } }, CloseFunc: func() { - tracker.Quit(true) - tracker.WaitQuitted() + t.Exec() <- func() { t.ForceQuit().Do() } + t.WaitQuitted() }, GetChunkFunc: func(isPreset bool) []byte { - return tracker.SafeMarshalRecovery() + retChn := make(chan []byte) + t.Exec() <- func() { retChn <- t.MarshalRecovery() } + return <-retChn }, SetChunkFunc: func(data []byte, isPreset bool) { - tracker.SafeUnmarshalRecovery(data) + t.Exec() <- func() { t.UnmarshalRecovery(data) } }, } diff --git a/song.go b/song.go index 2479d3b..a348080 100644 --- a/song.go +++ b/song.go @@ -67,8 +67,46 @@ type ( // the slice only by necessary amount when a new item is added, filling the // unused slots with -1s. Order []int + + // SongPos represents a position in a song, in terms of order row and + // pattern row. The order row is the index of the pattern in the order list, + // and the pattern row is the index of the row in the pattern. + SongPos struct { + OrderRow int + PatternRow int + } ) +func (s *Score) SongPos(songRow int) SongPos { + if s.RowsPerPattern == 0 { + return SongPos{OrderRow: 0, PatternRow: 0} + } + orderRow := songRow / s.RowsPerPattern + patternRow := songRow % s.RowsPerPattern + return SongPos{OrderRow: orderRow, PatternRow: patternRow} +} + +func (s *Score) SongRow(songPos SongPos) int { + return songPos.OrderRow*s.RowsPerPattern + songPos.PatternRow +} + +func (s *Score) Wrap(songPos SongPos) SongPos { + ret := s.SongPos(s.SongRow(songPos)) + ret.OrderRow %= s.Length + return ret +} + +func (s *Score) Clamp(songPos SongPos) SongPos { + r := s.SongRow(songPos) + if l := s.LengthInRows(); r >= l { + r = l - 1 + } + if r < 0 { + r = 0 + } + return s.SongPos(r) +} + // Get returns the value at index; or -1 is the index is out of range func (s Order) Get(index int) int { if index < 0 || index >= len(s) { @@ -85,6 +123,55 @@ func (s *Order) Set(index, value int) { (*s)[index] = value } +func (s Track) Note(pos SongPos) byte { + if pos.OrderRow < 0 || pos.OrderRow >= len(s.Order) { + return 1 + } + pat := s.Order[pos.OrderRow] + if pat < 0 || pat >= len(s.Patterns) { + return 1 + } + if pos.PatternRow < 0 || pos.PatternRow >= len(s.Patterns[pat]) { + return 1 + } + return s.Patterns[pat][pos.PatternRow] +} + +func (s *Track) SetNote(pos SongPos, note byte) { + if pos.OrderRow < 0 || pos.PatternRow < 0 { + return + } + pat := s.Order.Get(pos.OrderRow) + if pat < 0 { + if note == 1 { + return + } + for _, o := range s.Order { + if pat <= o { + pat = o + } + } + pat += 1 + if pat >= 36 { + return + } + s.Order.Set(pos.OrderRow, pat) + } + if pat >= len(s.Patterns) && note == 1 { + return + } + for pat >= len(s.Patterns) { + s.Patterns = append(s.Patterns, Pattern{}) + } + if pos.PatternRow >= len(s.Patterns[pat]) && note == 1 { + return + } + for pos.PatternRow >= len(s.Patterns[pat]) { + s.Patterns[pat] = append(s.Patterns[pat], 1) + } + s.Patterns[pat][pos.PatternRow] = note +} + // Get returns the value at index; or 1 is the index is out of range func (s Pattern) Get(index int) byte { if index < 0 || index >= len(s) { @@ -165,7 +252,10 @@ func (s *Song) Copy() Song { // Assuming 44100 Hz playback speed, return the number of samples of each row of // the song. func (s *Song) SamplesPerRow() int { - return 44100 * 60 / (s.BPM * s.RowsPerBeat) + if divisor := s.BPM * s.RowsPerBeat; divisor > 0 { + return 44100 * 60 / divisor + } + return 0 } // Validate checks if the Song looks like a valid song: BPM > 0, one or more diff --git a/tracker/action.go b/tracker/action.go new file mode 100644 index 0000000..2dd5d26 --- /dev/null +++ b/tracker/action.go @@ -0,0 +1,412 @@ +package tracker + +import ( + "os" + + "github.com/vsariola/sointu" + "github.com/vsariola/sointu/vm" +) + +type ( + // Action describes a user action that can be performed on the model. It is + // usually a button press or a menu item. Action advertises whether it is + // allowed to be performed or not. + Action struct { + do func() + allowed func() bool + } +) + +// Action methods + +func (e Action) Do() { + if e.allowed != nil && e.allowed() { + e.do() + } +} + +func (e Action) Allowed() bool { + return e.allowed != nil && e.allowed() +} + +func Allow(do func()) Action { + return Action{do: do, allowed: func() bool { return true }} +} + +func Check(do func(), allowed func() bool) Action { + return Action{do: do, allowed: allowed} +} + +// Model methods + +func (m *Model) AddTrack() Action { + return Action{ + allowed: func() bool { return m.d.Song.Score.NumVoices() < vm.MAX_VOICES }, + do: func() { + defer (*Model)(m).change("AddTrackAction", ScoreChange, MajorChange)() + if len(m.d.Song.Score.Tracks) == 0 { // no instruments, add one + m.d.Cursor.Track = 0 + } else { + m.d.Cursor.Track++ + } + m.d.Cursor.Track = intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)), 0) + newTracks := make([]sointu.Track, len(m.d.Song.Score.Tracks)+1) + copy(newTracks, m.d.Song.Score.Tracks[:m.d.Cursor.Track]) + copy(newTracks[m.d.Cursor.Track+1:], m.d.Song.Score.Tracks[m.d.Cursor.Track:]) + newTracks[m.d.Cursor.Track] = sointu.Track{ + NumVoices: 1, + Patterns: []sointu.Pattern{}, + } + m.d.Song.Score.Tracks = newTracks + }, + } +} + +func (m *Model) DeleteTrack() Action { + return Action{ + allowed: func() bool { return len(m.d.Song.Score.Tracks) > 0 }, + do: func() { + defer (*Model)(m).change("DeleteTrackAction", ScoreChange, MajorChange)() + m.d.Cursor.Track = intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)-1), 0) + newTracks := make([]sointu.Track, len(m.d.Song.Score.Tracks)-1) + copy(newTracks, m.d.Song.Score.Tracks[:m.d.Cursor.Track]) + copy(newTracks[m.d.Cursor.Track:], m.d.Song.Score.Tracks[m.d.Cursor.Track+1:]) + m.d.Cursor.Track = intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)-1), 0) + m.d.Song.Score.Tracks = newTracks + m.d.Cursor2 = m.d.Cursor + }, + } +} + +func (m *Model) AddInstrument() Action { + return Action{ + allowed: func() bool { return (*Model)(m).d.Song.Patch.NumVoices() < vm.MAX_VOICES }, + do: func() { + defer (*Model)(m).change("AddInstrumentAction", PatchChange, MajorChange)() + if len(m.d.Song.Patch) == 0 { // no instruments, add one + m.d.InstrIndex = 0 + } else { + m.d.InstrIndex++ + } + m.d.Song.Patch = append(m.d.Song.Patch, sointu.Instrument{}) + copy(m.d.Song.Patch[m.d.InstrIndex+1:], m.d.Song.Patch[m.d.InstrIndex:]) + newInstr := defaultInstrument.Copy() + (*Model)(m).assignUnitIDs(newInstr.Units) + m.d.Song.Patch[m.d.InstrIndex] = newInstr + m.d.InstrIndex2 = m.d.InstrIndex + m.d.UnitIndex = 0 + m.d.ParamIndex = 0 + }, + } +} + +func (m *Model) DeleteInstrument() Action { + return Action{ + allowed: func() bool { return len((*Model)(m).d.Song.Patch) > 0 }, + do: func() { + defer (*Model)(m).change("DeleteInstrumentAction", PatchChange, MajorChange)() + m.d.Song.Patch = append(m.d.Song.Patch[:m.d.InstrIndex], m.d.Song.Patch[m.d.InstrIndex+1:]...) + }, + } +} + +func (m *Model) AddUnit(before bool) Action { + return Allow(func() { + defer (*Model)(m).change("AddUnitAction", PatchChange, MajorChange)() + if len(m.d.Song.Patch) == 0 { // no instruments, add one + instr := sointu.Instrument{NumVoices: 1} + instr.Units = make([]sointu.Unit, 0, 1) + m.d.Song.Patch = append(m.d.Song.Patch, instr) + m.d.UnitIndex = 0 + } else { + if !before { + m.d.UnitIndex++ + } + } + m.d.InstrIndex = intMax(intMin(m.d.InstrIndex, len(m.d.Song.Patch)-1), 0) + instr := m.d.Song.Patch[m.d.InstrIndex] + newUnits := make([]sointu.Unit, len(instr.Units)+1) + m.d.UnitIndex = clamp(m.d.UnitIndex, 0, len(newUnits)-1) + m.d.UnitIndex2 = m.d.UnitIndex + copy(newUnits, instr.Units[:m.d.UnitIndex]) + copy(newUnits[m.d.UnitIndex+1:], instr.Units[m.d.UnitIndex:]) + (*Model)(m).assignUnitIDs(newUnits[m.d.UnitIndex : m.d.UnitIndex+1]) + m.d.Song.Patch[m.d.InstrIndex].Units = newUnits + m.d.ParamIndex = 0 + m.d.UnitSearchString = "" + }) +} + +func (m *Model) DeleteUnit() Action { + return Action{ + allowed: func() bool { + return len((*Model)(m).d.Song.Patch) > 0 && len((*Model)(m).d.Song.Patch[(*Model)(m).d.InstrIndex].Units) > 1 + }, + do: func() { + defer (*Model)(m).change("DeleteUnitAction", PatchChange, MajorChange)() + m.Units().List().DeleteElements(true) + m.d.UnitSearchString = m.Units().SelectedType() + }, + } +} + +func (m *Model) ClearUnit() Action { + return Action{ + do: func() { + defer (*Model)(m).change("DeleteUnitAction", PatchChange, MajorChange)() + m.d.UnitIndex = intMax(intMin(m.d.UnitIndex, len(m.d.Song.Patch[m.d.InstrIndex].Units)-1), 0) + m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex] = sointu.Unit{} + m.d.UnitSearchString = "" + }, + allowed: func() bool { + return m.d.InstrIndex >= 0 && + m.d.InstrIndex < len(m.d.Song.Patch) && + len(m.d.Song.Patch[m.d.InstrIndex].Units) > 0 + }, + } +} +func (m *Model) Undo() Action { + return Action{ + allowed: func() bool { return len((*Model)(m).undoStack) > 0 }, + do: func() { + m.redoStack = append(m.redoStack, m.d.Copy()) + if len(m.redoStack) >= maxUndo { + copy(m.redoStack, m.redoStack[len(m.redoStack)-maxUndo:]) + m.redoStack = m.redoStack[:maxUndo] + } + m.d = m.undoStack[len(m.undoStack)-1] + m.undoStack = m.undoStack[:len(m.undoStack)-1] + m.prevUndoKind = "" + (*Model)(m).send(m.d.Song.Copy()) + }, + } +} + +func (m *Model) Redo() Action { + return Action{ + allowed: func() bool { return len((*Model)(m).redoStack) > 0 }, + do: func() { + m.undoStack = append(m.undoStack, m.d.Copy()) + if len(m.undoStack) >= maxUndo { + copy(m.undoStack, m.undoStack[len(m.undoStack)-maxUndo:]) + m.undoStack = m.undoStack[:maxUndo] + } + m.d = m.redoStack[len(m.redoStack)-1] + m.redoStack = m.redoStack[:len(m.redoStack)-1] + m.prevUndoKind = "" + (*Model)(m).send(m.d.Song.Copy()) + }, + } +} + +func (m *Model) AddSemitone() Action { + return Allow(func() { Table{(*Notes)(m)}.Add(1) }) +} + +func (m *Model) SubtractSemitone() Action { + return Allow(func() { Table{(*Notes)(m)}.Add(-1) }) +} + +func (m *Model) AddOctave() Action { + return Allow(func() { Table{(*Notes)(m)}.Add(12) }) +} + +func (m *Model) SubtractOctave() Action { + return Allow(func() { Table{(*Notes)(m)}.Add(-12) }) +} + +func (m *Model) EditNoteOff() Action { + return Allow(func() { Table{(*Notes)(m)}.Fill(0) }) +} + +func (m *Model) RemoveUnused() Action { + return Allow(func() { + defer m.change("RemoveUnusedAction", ScoreChange, MajorChange)() + for trkIndex, trk := range m.d.Song.Score.Tracks { + // assign new indices to patterns + newIndex := map[int]int{} + runningIndex := 0 + length := 0 + if len(trk.Order) > m.d.Song.Score.Length { + trk.Order = trk.Order[:m.d.Song.Score.Length] + } + for i, p := range trk.Order { + // if the pattern hasn't been considered and is within limits + if _, ok := newIndex[p]; !ok && p >= 0 && p < len(trk.Patterns) { + pat := trk.Patterns[p] + useful := false + for _, n := range pat { // patterns that have anything else than all holds are useful and to be kept + if n != 1 { + useful = true + break + } + } + if useful { + newIndex[p] = runningIndex + runningIndex++ + } else { + newIndex[p] = -1 + } + } + if ind, ok := newIndex[p]; ok && ind > -1 { + length = i + 1 + trk.Order[i] = ind + } else { + trk.Order[i] = -1 + } + } + trk.Order = trk.Order[:length] + newPatterns := make([]sointu.Pattern, runningIndex) + for i, pat := range trk.Patterns { + if ind, ok := newIndex[i]; ok && ind > -1 { + patLength := 0 + for j, note := range pat { // find last note that is something else that hold + if note != 1 { + patLength = j + 1 + } + } + if patLength > m.d.Song.Score.RowsPerPattern { + patLength = m.d.Song.Score.RowsPerPattern + } + newPatterns[ind] = pat[:patLength] // crop to either RowsPerPattern or last row having something else than hold + } + } + trk.Patterns = newPatterns + m.d.Song.Score.Tracks[trkIndex] = trk + } + }) +} + +func (m *Model) Rewind() Action { + return Action{ + allowed: func() bool { + return m.playing || !m.instrEnlarged + }, + do: func() { + m.playing = true + m.send(StartPlayMsg{}) + }, + } +} + +func (m *Model) AddOrderRow(before bool) Action { + return Allow(func() { + defer m.change("AddOrderRowAction", ScoreChange, MinorChange)() + if before { + m.d.Cursor.OrderRow++ + } + m.d.Cursor2.OrderRow = m.d.Cursor.OrderRow + from := m.d.Cursor.OrderRow + m.d.Song.Score.Length++ + for i := range m.d.Song.Score.Tracks { + order := &m.d.Song.Score.Tracks[i].Order + if len(*order) > from { + *order = append(*order, -1) + copy((*order)[from+1:], (*order)[from:]) + (*order)[from] = -1 + } + } + }) +} + +func (m *Model) DeleteOrderRow(backwards bool) Action { + return Allow(func() { + defer m.change("AddOrderRowAction", ScoreChange, MinorChange)() + from := m.d.Cursor.OrderRow + m.d.Song.Score.Length-- + for i := range m.d.Song.Score.Tracks { + order := &m.d.Song.Score.Tracks[i].Order + if len(*order) > from { + copy((*order)[from:], (*order)[from+1:]) + *order = (*order)[:len(*order)-1] + } + } + if backwards { + if m.d.Cursor.OrderRow > 0 { + m.d.Cursor.OrderRow-- + } + } + m.d.Cursor2.OrderRow = m.d.Cursor.OrderRow + return + }) +} + +func (m *Model) NewSong() Action { + return Allow(func() { + m.dialog = NewSongChanges + m.completeAction(true) + }) +} + +func (m *Model) OpenSong() Action { + return Allow(func() { + m.dialog = OpenSongChanges + m.completeAction(true) + }) +} + +func (m *Model) Quit() Action { + return Allow(func() { + m.dialog = QuitChanges + m.completeAction(true) + }) +} + +func (m *Model) ForceQuit() Action { + return Allow(func() { + m.quitted = true + }) +} + +func (m *Model) SaveSong() Action { + return Allow(func() { + if m.d.FilePath == "" { + switch m.dialog { + case NoDialog: + m.dialog = SaveAsExplorer + case NewSongChanges: + m.dialog = NewSongSaveExplorer + case OpenSongChanges: + m.dialog = OpenSongSaveExplorer + case QuitChanges: + m.dialog = QuitSaveExplorer + } + return + } + f, err := os.Create(m.d.FilePath) + if err != nil { + m.Alerts().Add("Error creating file: "+err.Error(), Error) + return + } + m.WriteSong(f) + m.d.ChangedSinceSave = false + }) +} + +func (m *Model) DiscardSong() Action { return Allow(func() { m.completeAction(false) }) } +func (m *Model) SaveSongAs() Action { return Allow(func() { m.dialog = SaveAsExplorer }) } +func (m *Model) Cancel() Action { return Allow(func() { m.dialog = NoDialog }) } +func (m *Model) Export() Action { return Allow(func() { m.dialog = Export }) } +func (m *Model) ExportFloat() Action { return Allow(func() { m.dialog = ExportFloatExplorer }) } +func (m *Model) ExportInt16() Action { return Allow(func() { m.dialog = ExportInt16Explorer }) } + +func (m *Model) completeAction(checkSave bool) { + if checkSave && m.d.ChangedSinceSave { + return + } + switch m.dialog { + case NewSongChanges, NewSongSaveExplorer: + c := m.change("NewSong", SongChange, MajorChange) + m.resetSong() + c() + m.d.ChangedSinceSave = false + m.dialog = NoDialog + case OpenSongChanges, OpenSongSaveExplorer: + m.dialog = OpenSongOpenExplorer + case QuitChanges, QuitSaveExplorer: + m.quitted = true + m.dialog = NoDialog + default: + m.dialog = NoDialog + } +} diff --git a/tracker/alert.go b/tracker/alert.go new file mode 100644 index 0000000..5986967 --- /dev/null +++ b/tracker/alert.go @@ -0,0 +1,110 @@ +package tracker + +import ( + "container/heap" + "time" +) + +const alertSpeed = 10 // units: fadeLevels per second +const defaultAlertDuration = time.Second * 3 + +type ( + Alert struct { + Name string + Priority AlertPriority + Message string + Duration time.Duration + FadeLevel float64 + } + + AlertPriority int + AlertYieldFunc func(alert Alert) + Alerts Model +) + +const ( + None AlertPriority = iota + Info + Warning + Error +) + +// Model methods + +func (m *Model) Alerts() *Alerts { return (*Alerts)(m) } + +// Alerts methods + +func (m *Alerts) Iterate(yield AlertYieldFunc) { + for _, a := range m.alerts { + yield(a) + } +} + +func (m *Alerts) Update(d time.Duration) (animating bool) { + for i := len(m.alerts) - 1; i >= 0; i-- { + if m.alerts[i].Duration >= d { + m.alerts[i].Duration -= d + if m.alerts[i].FadeLevel < 1 { + animating = true + m.alerts[i].FadeLevel += float64(alertSpeed*d) / float64(time.Second) + if m.alerts[i].FadeLevel > 1 { + m.alerts[i].FadeLevel = 1 + } + } + } else { + m.alerts[i].Duration = 0 + m.alerts[i].FadeLevel -= float64(alertSpeed*d) / float64(time.Second) + animating = true + if m.alerts[i].FadeLevel < 0 { + heap.Remove(m, i) + } + } + } + return +} + +func (m *Alerts) Add(message string, priority AlertPriority) { + m.AddAlert(Alert{ + Priority: priority, + Message: message, + Duration: defaultAlertDuration, + }) +} + +func (m *Alerts) AddNamed(name, message string, priority AlertPriority) { + m.AddAlert(Alert{ + Name: name, + Priority: priority, + Message: message, + Duration: defaultAlertDuration, + }) +} + +func (m *Alerts) AddAlert(a Alert) { + for i := range m.alerts { + if n := m.alerts[i].Name; n != "" && n == a.Name { + a.FadeLevel = m.alerts[i].FadeLevel + m.alerts[i] = a + heap.Fix(m, i) + return + } + } + heap.Push(m, a) +} + +func (m *Alerts) Push(x any) { + m.alerts = append(m.alerts, x.(Alert)) +} + +func (m *Alerts) Pop() any { + old := m.alerts + n := len(old) + x := old[n-1] + m.alerts = old[0 : n-1] + return x +} + +func (m Alerts) Len() int { return len(m.alerts) } +func (m Alerts) Less(i, j int) bool { return m.alerts[i].Priority < m.alerts[j].Priority } +func (m Alerts) Swap(i, j int) { m.alerts[i], m.alerts[j] = m.alerts[j], m.alerts[i] } diff --git a/tracker/bool.go b/tracker/bool.go new file mode 100644 index 0000000..1ff352d --- /dev/null +++ b/tracker/bool.go @@ -0,0 +1,114 @@ +package tracker + +type ( + Bool struct { + BoolData + } + + BoolData interface { + Value() bool + Enabled() bool + setValue(bool) + } + + Panic Model + IsRecording Model + Playing Model + InstrEnlarged Model + Effect Model + CommentExpanded Model + NoteTracking Model +) + +func (v Bool) Toggle() { + v.Set(!v.Value()) +} + +func (v Bool) Set(value bool) { + if v.Enabled() && v.Value() != value { + v.setValue(value) + } +} + +// Model methods + +func (m *Model) Panic() *Panic { return (*Panic)(m) } +func (m *Model) IsRecording() *IsRecording { return (*IsRecording)(m) } +func (m *Model) Playing() *Playing { return (*Playing)(m) } +func (m *Model) InstrEnlarged() *InstrEnlarged { return (*InstrEnlarged)(m) } +func (m *Model) Effect() *Effect { return (*Effect)(m) } +func (m *Model) CommentExpanded() *CommentExpanded { return (*CommentExpanded)(m) } +func (m *Model) NoteTracking() *NoteTracking { return (*NoteTracking)(m) } + +// Panic methods + +func (m *Panic) Bool() Bool { return Bool{m} } +func (m *Panic) Value() bool { return m.panic } +func (m *Panic) setValue(val bool) { + m.panic = val + (*Model)(m).send(PanicMsg{val}) +} +func (m *Panic) Enabled() bool { return true } + +// IsRecording methods + +func (m *IsRecording) Bool() Bool { return Bool{m} } +func (m *IsRecording) Value() bool { return (*Model)(m).recording } +func (m *IsRecording) setValue(val bool) { + m.recording = val + m.instrEnlarged = val + (*Model)(m).send(RecordingMsg{val}) +} +func (m *IsRecording) Enabled() bool { return true } + +// Playing methods + +func (m *Playing) Bool() Bool { return Bool{m} } +func (m *Playing) Value() bool { return m.playing } +func (m *Playing) setValue(val bool) { + m.playing = val + if m.playing { + (*Model)(m).send(StartPlayMsg{m.d.Cursor.SongPos}) + } else { + (*Model)(m).send(IsPlayingMsg{val}) + } +} +func (m *Playing) Enabled() bool { return m.playing || !m.instrEnlarged } + +// InstrEnlarged methods + +func (m *InstrEnlarged) Bool() Bool { return Bool{m} } +func (m *InstrEnlarged) Value() bool { return m.instrEnlarged } +func (m *InstrEnlarged) setValue(val bool) { m.instrEnlarged = val } +func (m *InstrEnlarged) Enabled() bool { return true } + +// CommentExpanded methods + +func (m *CommentExpanded) Bool() Bool { return Bool{m} } +func (m *CommentExpanded) Value() bool { return m.commentExpanded } +func (m *CommentExpanded) setValue(val bool) { m.commentExpanded = val } +func (m *CommentExpanded) Enabled() bool { return true } + +// NoteTracking methods + +func (m *NoteTracking) Bool() Bool { return Bool{m} } +func (m *NoteTracking) Value() bool { return m.playing && m.noteTracking } +func (m *NoteTracking) setValue(val bool) { m.noteTracking = val } +func (m *NoteTracking) Enabled() bool { return m.playing } + +// Effect methods + +func (m *Effect) Bool() Bool { return Bool{m} } +func (m *Effect) Value() bool { + if m.d.Cursor.Track < 0 || m.d.Cursor.Track >= len(m.d.Song.Score.Tracks) { + return false + } + return m.d.Song.Score.Tracks[m.d.Cursor.Track].Effect +} +func (m *Effect) setValue(val bool) { + if m.d.Cursor.Track < 0 || m.d.Cursor.Track >= len(m.d.Song.Score.Tracks) { + return + } + m.d.Song.Score.Tracks[m.d.Cursor.Track].Effect = val +} +func (m *Effect) Enabled() bool { return true } diff --git a/tracker/files.go b/tracker/files.go new file mode 100644 index 0000000..6fcda6c --- /dev/null +++ b/tracker/files.go @@ -0,0 +1,179 @@ +package tracker + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/vsariola/sointu" +) + +func (m *Model) ReadSong(r io.ReadCloser) { + b, err := io.ReadAll(r) + if err != nil { + return + } + err = r.Close() + if err != nil { + return + } + var song sointu.Song + if errJSON := json.Unmarshal(b, &song); errJSON != nil { + if errYaml := yaml.Unmarshal(b, &song); errYaml != nil { + m.Alerts().Add(fmt.Sprintf("Error unmarshaling a song file: %v / %v", errYaml, errJSON), Error) + return + } + } + f := m.change("LoadSong", SongChange, MajorChange) + m.d.Song = song + if f, ok := r.(*os.File); ok { + m.d.FilePath = f.Name() + // when the song is loaded from a file, we are quite confident that the file is persisted and thus + // we can close sointu without worrying about losing changes + m.d.ChangedSinceSave = false + } + f() + m.completeAction(false) +} + +func (m *Model) WriteSong(w io.WriteCloser) { + path := "" + var extension = filepath.Ext(path) + var contents []byte + var err error + if extension == ".json" { + contents, err = json.Marshal(m.d.Song) + } else { + contents, err = yaml.Marshal(m.d.Song) + } + if err != nil { + m.Alerts().Add(fmt.Sprintf("Error marshaling a song file: %v", err), Error) + return + } + if _, err := w.Write(contents); err != nil { + m.Alerts().Add(fmt.Sprintf("Error writing to file: %v", err), Error) + return + } + if f, ok := w.(*os.File); ok { + path = f.Name() + // when the song is saved to a file, we are quite confident that the file is persisted and thus + // we can close sointu without worrying about losing changes + m.d.ChangedSinceSave = false + } + if err := w.Close(); err != nil { + m.Alerts().Add(fmt.Sprintf("Error rendering the song during export: %v", err), Error) + return + } + m.d.FilePath = path + m.completeAction(false) +} + +func (m *Model) WriteWav(w io.WriteCloser, pcm16 bool, execChan chan<- func()) { + m.dialog = NoDialog + song := m.d.Song.Copy() + go func() { + b := make([]byte, 32+2) + rand.Read(b) + name := fmt.Sprintf("%x", b)[2 : 32+2] + data, err := sointu.Play(m.synther, song, func(p float32) { + execChan <- func() { + m.Alerts().AddNamed(name, fmt.Sprintf("Exporting song: %.0f%%", p*100), Info) + } + }) // render the song to calculate its length + if err != nil { + execChan <- func() { + m.Alerts().Add(fmt.Sprintf("Error rendering the song during export: %v", err), Error) + } + return + } + buffer, err := data.Wav(pcm16) + if err != nil { + execChan <- func() { + m.Alerts().Add(fmt.Sprintf("Error converting to .wav: %v", err), Error) + } + return + } + w.Write(buffer) + w.Close() + }() +} + +func (m *Model) SaveInstrument(w io.WriteCloser) bool { + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + m.Alerts().Add("No instrument selected", Error) + return false + } + path := "" + if f, ok := w.(*os.File); ok { + path = f.Name() + } + var extension = filepath.Ext(path) + var contents []byte + var err error + if extension == ".json" { + contents, err = json.Marshal(m.d.Song.Patch[m.d.InstrIndex]) + } else { + contents, err = yaml.Marshal(m.d.Song.Patch[m.d.InstrIndex]) + } + if err != nil { + m.Alerts().Add(fmt.Sprintf("Error marshaling a ínstrument file: %v", err), Error) + return false + } + w.Write(contents) + w.Close() + return true +} + +func (m *Model) LoadInstrument(r io.ReadCloser) bool { + if m.d.InstrIndex < 0 { + return false + } + b, err := io.ReadAll(r) + if err != nil { + return false + } + var instrument sointu.Instrument + var errJSON, errYaml, err4ki, err4kp error + var patch sointu.Patch + errJSON = json.Unmarshal(b, &instrument) + if errJSON == nil { + goto success + } + errYaml = yaml.Unmarshal(b, &instrument) + if errYaml == nil { + goto success + } + patch, err4kp = sointu.Read4klangPatch(bytes.NewReader(b)) + if err4kp == nil { + defer m.change("LoadInstrument", PatchChange, MajorChange)() + m.d.Song.Patch = patch + return true + } + instrument, err4ki = sointu.Read4klangInstrument(bytes.NewReader(b)) + if err4ki == nil { + goto success + } + m.Alerts().Add(fmt.Sprintf("Error unmarshaling an instrument file: %v / %v / %v / %v", errYaml, errJSON, err4ki, err4kp), Error) + return false +success: + if f, ok := r.(*os.File); ok { + filename := f.Name() + // the 4klang instrument names are junk, replace them with the filename without extension + instrument.Name = filepath.Base(filename[:len(filename)-len(filepath.Ext(filename))]) + } + defer m.change("LoadInstrument", PatchChange, MajorChange)() + for len(m.d.Song.Patch) <= m.d.InstrIndex { + m.d.Song.Patch = append(m.d.Song.Patch, defaultInstrument.Copy()) + } + m.d.Song.Patch[m.d.InstrIndex] = instrument + if m.d.Song.Patch[m.d.InstrIndex].Comment != "" { + m.commentExpanded = true + } + return true +} diff --git a/tracker/gioui/alert.go b/tracker/gioui/alert.go deleted file mode 100644 index d168983..0000000 --- a/tracker/gioui/alert.go +++ /dev/null @@ -1,118 +0,0 @@ -package gioui - -import ( - "image" - "image/color" - "time" - - "gioui.org/layout" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/op/paint" - "gioui.org/text" - "gioui.org/unit" -) - -type Alert struct { - message string - alertType AlertType - duration time.Duration - showMessage string - showAlertType AlertType - showDuration time.Duration - showTime time.Time - pos float64 - lastUpdate time.Time - shaper *text.Shaper -} - -type AlertType int - -const ( - None AlertType = iota - Notify - Warning - Error -) - -var alertSpeed = 150 * time.Millisecond -var alertMargin = layout.UniformInset(unit.Dp(6)) -var alertInset = layout.UniformInset(unit.Dp(6)) - -func (a *Alert) Update(message string, alertType AlertType, duration time.Duration) { - if a.alertType < alertType { - a.message = message - a.alertType = alertType - a.duration = duration - } -} - -func (a *Alert) Layout(gtx C) D { - now := time.Now() - if a.alertType != None { - a.showMessage = a.message - a.showAlertType = a.alertType - a.showTime = now - a.showDuration = a.duration - } - a.alertType = None - var targetPos float64 = 0.0 - if now.Sub(a.showTime) <= a.showDuration { - targetPos = 1.0 - } - delta := float64(now.Sub(a.lastUpdate)) / float64(alertSpeed) - if a.pos < targetPos { - a.pos += delta - if a.pos > targetPos { - a.pos = targetPos - } else { - op.InvalidateOp{At: now.Add(50 * time.Millisecond)}.Add(gtx.Ops) - } - } else if a.pos > targetPos { - a.pos -= delta - if a.pos < targetPos { - a.pos = targetPos - } else { - op.InvalidateOp{At: now.Add(50 * time.Millisecond)}.Add(gtx.Ops) - } - } - a.lastUpdate = now - var color, textColor, shadeColor color.NRGBA - switch a.showAlertType { - case Warning: - color = warningColor - textColor = black - case Error: - color = errorColor - textColor = black - default: - color = popupSurfaceColor - textColor = white - shadeColor = black - } - bgWidget := func(gtx C) D { - paint.FillShape(gtx.Ops, color, clip.Rect{ - Max: gtx.Constraints.Min, - }.Op()) - return D{Size: gtx.Constraints.Min} - } - labelStyle := LabelStyle{Text: a.showMessage, Color: textColor, ShadeColor: shadeColor, Font: labelDefaultFont, Alignment: layout.Center, FontSize: unit.Sp(16), Shaper: a.shaper} - return alertMargin.Layout(gtx, func(gtx C) D { - return layout.S.Layout(gtx, func(gtx C) D { - defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - gtx.Constraints.Min.X = gtx.Constraints.Max.X - recording := op.Record(gtx.Ops) - dims := layout.Stack{Alignment: layout.Center}.Layout(gtx, - layout.Expanded(bgWidget), - layout.Stacked(func(gtx C) D { - return alertInset.Layout(gtx, labelStyle.Layout) - }), - ) - macro := recording.Stop() - totalY := dims.Size.Y + gtx.Dp(alertMargin.Bottom) - op.Offset(image.Point{0, int((1 - a.pos) * float64(totalY))}).Add((gtx.Ops)) - macro.Add(gtx.Ops) - return dims - }) - }) -} diff --git a/tracker/gioui/buttons.go b/tracker/gioui/buttons.go index 434595a..2d8fe71 100644 --- a/tracker/gioui/buttons.go +++ b/tracker/gioui/buttons.go @@ -6,17 +6,43 @@ import ( "gioui.org/widget" "gioui.org/widget/material" "gioui.org/x/component" + "github.com/vsariola/sointu/tracker" ) -type TipClickable struct { - Clickable widget.Clickable - TipArea component.TipArea +type ( + TipClickable struct { + Clickable widget.Clickable + TipArea component.TipArea + } + + ActionClickable struct { + Action tracker.Action + TipClickable + } + + TipIconButtonStyle struct { + TipArea *component.TipArea + IconButtonStyle material.IconButtonStyle + Tooltip component.Tooltip + } + + BoolClickable struct { + Clickable widget.Clickable + TipArea component.TipArea + Bool tracker.Bool + } +) + +func NewActionClickable(a tracker.Action) *ActionClickable { + return &ActionClickable{ + Action: a, + } } -type TipIconButtonStyle struct { - IconButtonStyle material.IconButtonStyle - Tooltip component.Tooltip - tipArea *component.TipArea +func NewBoolClickable(b tracker.Bool) *BoolClickable { + return &BoolClickable{ + Bool: b, + } } func Tooltip(th *material.Theme, tip string) component.Tooltip { @@ -25,24 +51,86 @@ func Tooltip(th *material.Theme, tip string) component.Tooltip { return tooltip } -func IconButton(th *material.Theme, w *TipClickable, icon []byte, enabled bool, tip string) TipIconButtonStyle { - ret := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "") - ret.Background = transparent - ret.Inset = layout.UniformInset(unit.Dp(6)) - if enabled { - ret.Color = primaryColor - } else { - ret.Color = disabledTextColor +func ActionIcon(th *material.Theme, w *ActionClickable, icon []byte, tip string) TipIconButtonStyle { + ret := TipIcon(th, &w.TipClickable, icon, tip) + for w.Clickable.Clicked() { + w.Action.Do() + } + if !w.Action.Allowed() { + ret.IconButtonStyle.Color = disabledTextColor + } + return ret +} + +func TipIcon(th *material.Theme, w *TipClickable, icon []byte, tip string) TipIconButtonStyle { + iconButtonStyle := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "") + iconButtonStyle.Color = primaryColor + iconButtonStyle.Background = transparent + iconButtonStyle.Inset = layout.UniformInset(unit.Dp(6)) + return TipIconButtonStyle{ + TipArea: &w.TipArea, + IconButtonStyle: iconButtonStyle, + Tooltip: Tooltip(th, tip), + } +} + +func ToggleIcon(th *material.Theme, w *BoolClickable, offIcon, onIcon []byte, offTip, onTip string) TipIconButtonStyle { + icon := offIcon + tip := offTip + if w.Bool.Value() { + icon = onIcon + tip = onTip + } + for w.Clickable.Clicked() { + w.Bool.Toggle() + } + ibStyle := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "") + ibStyle.Background = transparent + ibStyle.Inset = layout.UniformInset(unit.Dp(6)) + ibStyle.Color = primaryColor + if !w.Bool.Enabled() { + ibStyle.Color = disabledTextColor } return TipIconButtonStyle{ - IconButtonStyle: ret, + TipArea: &w.TipArea, + IconButtonStyle: ibStyle, Tooltip: Tooltip(th, tip), - tipArea: &w.TipArea, } } func (t *TipIconButtonStyle) Layout(gtx C) D { - return t.tipArea.Layout(gtx, t.Tooltip, t.IconButtonStyle.Layout) + return t.TipArea.Layout(gtx, t.Tooltip, t.IconButtonStyle.Layout) +} + +func ActionButton(th *material.Theme, w *ActionClickable, text string) material.ButtonStyle { + for w.Clickable.Clicked() { + w.Action.Do() + } + ret := material.Button(th, &w.Clickable, text) + ret.Color = th.Palette.Fg + if !w.Action.Allowed() { + ret.Color = disabledTextColor + } + ret.Background = transparent + ret.Inset = layout.UniformInset(unit.Dp(6)) + return ret +} + +func ToggleButton(th *material.Theme, b *BoolClickable, text string) material.ButtonStyle { + for b.Clickable.Clicked() { + b.Bool.Toggle() + } + ret := material.Button(th, &b.Clickable, text) + ret.Background = transparent + ret.Inset = layout.UniformInset(unit.Dp(6)) + if b.Bool.Value() { + ret.Color = th.Palette.ContrastFg + ret.Background = th.Palette.Fg + } else { + ret.Color = th.Palette.Fg + ret.Background = transparent + } + return ret } func LowEmphasisButton(th *material.Theme, w *widget.Clickable, text string) material.ButtonStyle { diff --git a/tracker/gioui/dialog.go b/tracker/gioui/dialog.go index 8800f14..1393a0f 100644 --- a/tracker/gioui/dialog.go +++ b/tracker/gioui/dialog.go @@ -1,56 +1,82 @@ package gioui import ( + "gioui.org/io/key" "gioui.org/layout" "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" - "gioui.org/widget" "gioui.org/widget/material" + "github.com/vsariola/sointu/tracker" ) type Dialog struct { - Visible bool - BtnAlt widget.Clickable - BtnOk widget.Clickable - BtnCancel widget.Clickable + BtnAlt *ActionClickable + BtnOk *ActionClickable + BtnCancel *ActionClickable + tag bool } type DialogStyle struct { dialog *Dialog + Title string Text string Inset layout.Inset - ShowAlt bool + TextInset layout.Inset AltStyle material.ButtonStyle OkStyle material.ButtonStyle CancelStyle material.ButtonStyle Shaper *text.Shaper } -func ConfirmDialog(th *material.Theme, dialog *Dialog, text string, shaper *text.Shaper) DialogStyle { +func NewDialog(ok, alt, cancel tracker.Action) *Dialog { + return &Dialog{ + BtnOk: NewActionClickable(ok), + BtnAlt: NewActionClickable(alt), + BtnCancel: NewActionClickable(cancel), + } +} + +func ConfirmDialog(th *material.Theme, dialog *Dialog, title, text string) DialogStyle { ret := DialogStyle{ dialog: dialog, + Title: title, Text: text, Inset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12), Left: unit.Dp(20), Right: unit.Dp(20)}, - AltStyle: HighEmphasisButton(th, &dialog.BtnAlt, "Alt"), - OkStyle: HighEmphasisButton(th, &dialog.BtnOk, "Ok"), - CancelStyle: HighEmphasisButton(th, &dialog.BtnCancel, "Cancel"), - Shaper: shaper, + TextInset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12)}, + AltStyle: ActionButton(th, dialog.BtnAlt, "Alt"), + OkStyle: ActionButton(th, dialog.BtnOk, "Ok"), + CancelStyle: ActionButton(th, dialog.BtnCancel, "Cancel"), + Shaper: th.Shaper, } return ret } func (d *DialogStyle) Layout(gtx C) D { - if d.dialog.Visible { - paint.Fill(gtx.Ops, dialogBgColor) - return layout.Center.Layout(gtx, func(gtx C) D { - return Popup(&d.dialog.Visible).Layout(gtx, func(gtx C) D { - return d.Inset.Layout(gtx, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(Label(d.Text, highEmphasisTextColor, d.Shaper)), - layout.Rigid(func(gtx C) D { + if !d.dialog.BtnOk.Clickable.Focused() && !d.dialog.BtnCancel.Clickable.Focused() && !d.dialog.BtnAlt.Clickable.Focused() { + d.dialog.BtnCancel.Clickable.Focus() + } + paint.Fill(gtx.Ops, dialogBgColor) + text := func(gtx C) D { + return d.TextInset.Layout(gtx, LabelStyle{Text: d.Text, Color: highEmphasisTextColor, Font: labelDefaultFont, FontSize: unit.Sp(14), Shaper: d.Shaper}.Layout) + } + for _, e := range gtx.Events(&d.dialog.tag) { + if e, ok := e.(key.Event); ok && e.State == key.Press { + d.command(e) + } + } + visible := true + return layout.Center.Layout(gtx, func(gtx C) D { + return Popup(&visible).Layout(gtx, func(gtx C) D { + key.InputOp{Tag: &d.dialog.tag, Keys: "⎋|←|→|Tab"}.Add(gtx.Ops) + return d.Inset.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(Label(d.Title, highEmphasisTextColor, d.Shaper)), + layout.Rigid(text), + layout.Rigid(func(gtx C) D { + return layout.E.Layout(gtx, func(gtx C) D { gtx.Constraints.Min.X = gtx.Dp(unit.Dp(120)) - if d.ShowAlt { + if d.dialog.BtnAlt.Action.Allowed() { return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx, layout.Rigid(d.OkStyle.Layout), layout.Rigid(d.AltStyle.Layout), @@ -61,11 +87,43 @@ func (d *DialogStyle) Layout(gtx C) D { layout.Rigid(d.OkStyle.Layout), layout.Rigid(d.CancelStyle.Layout), ) - }), - ) - }) + }) + }), + ) }) }) - } - return D{} + }) +} + +func (d *DialogStyle) command(e key.Event) { + switch e.Name { + case key.NameEscape: + d.dialog.BtnCancel.Action.Do() + case key.NameLeftArrow: + switch { + case d.dialog.BtnOk.Clickable.Focused(): + d.dialog.BtnCancel.Clickable.Focus() + case d.dialog.BtnCancel.Clickable.Focused(): + if d.dialog.BtnAlt.Action.Allowed() { + d.dialog.BtnAlt.Clickable.Focus() + } else { + d.dialog.BtnOk.Clickable.Focus() + } + case d.dialog.BtnAlt.Clickable.Focused(): + d.dialog.BtnOk.Clickable.Focus() + } + case key.NameRightArrow, key.NameTab: + switch { + case d.dialog.BtnOk.Clickable.Focused(): + if d.dialog.BtnAlt.Action.Allowed() { + d.dialog.BtnAlt.Clickable.Focus() + } else { + d.dialog.BtnCancel.Clickable.Focus() + } + case d.dialog.BtnCancel.Clickable.Focused(): + d.dialog.BtnOk.Clickable.Focus() + case d.dialog.BtnAlt.Clickable.Focused(): + d.dialog.BtnCancel.Clickable.Focus() + } + } } diff --git a/tracker/gioui/draglist.go b/tracker/gioui/draglist.go index 260df7d..ea62875 100644 --- a/tracker/gioui/draglist.go +++ b/tracker/gioui/draglist.go @@ -4,48 +4,54 @@ import ( "image" "image/color" + "gioui.org/io/clipboard" "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" + "gioui.org/unit" "gioui.org/widget/material" + "github.com/vsariola/sointu/tracker" ) type DragList struct { - SelectedItem int - SelectedItem2 int - HoverItem int - List *layout.List - drag bool - dragID pointer.ID - tags []bool - swapped bool - focused bool - requestFocus bool - mainTag bool + TrackerList tracker.List + HoverItem int + List *layout.List + ScrollBar *ScrollBar + drag bool + dragID pointer.ID + tags []bool + swapped bool + focused bool + requestFocus bool + mainTag bool } type FilledDragListStyle struct { - dragList *DragList - HoverColor color.NRGBA - SelectedColor color.NRGBA - CursorColor color.NRGBA - Count int - element func(gtx C, i int) D - swap func(i, j int) + dragList *DragList + HoverColor color.NRGBA + SelectedColor color.NRGBA + CursorColor color.NRGBA + ScrollBarWidth unit.Dp + element, bg func(gtx C, i int) D } -func FilledDragList(th *material.Theme, dragList *DragList, count int, element func(gtx C, i int) D, swap func(i, j int)) FilledDragListStyle { +func NewDragList(model tracker.List, axis layout.Axis) *DragList { + return &DragList{TrackerList: model, List: &layout.List{Axis: axis}, HoverItem: -1, ScrollBar: &ScrollBar{Axis: axis}} +} + +func FilledDragList(th *material.Theme, dragList *DragList, element, bg func(gtx C, i int) D) FilledDragListStyle { return FilledDragListStyle{ - dragList: dragList, - element: element, - swap: swap, - Count: count, - HoverColor: dragListHoverColor, - SelectedColor: dragListSelectedColor, - CursorColor: cursorColor, + dragList: dragList, + element: element, + bg: bg, + HoverColor: dragListHoverColor, + SelectedColor: dragListSelectedColor, + CursorColor: cursorColor, + ScrollBarWidth: unit.Dp(10), } } @@ -57,14 +63,18 @@ func (d *DragList) Focused() bool { return d.focused } -func (s *FilledDragListStyle) Layout(gtx C) D { +func (s FilledDragListStyle) LayoutScrollBar(gtx C) D { + return s.dragList.ScrollBar.Layout(gtx, s.ScrollBarWidth, s.dragList.TrackerList.Count(), &s.dragList.List.Position) +} + +func (s FilledDragListStyle) Layout(gtx C) D { swap := 0 defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() - keys := key.Set("↑|↓|Ctrl-↑|Ctrl-↓|Shift-↑|Shift-↓") + keys := key.Set("↑|↓|Ctrl-↑|Ctrl-↓|Shift-↑|Shift-↓|⇞|⇟|Ctrl-⇞|Ctrl-⇟|Ctrl-A|Ctrl-C|Ctrl-X|Ctrl-V|⌦|Ctrl-⌫") if s.dragList.List.Axis == layout.Horizontal { - keys = key.Set("←|→|Ctrl-←|Ctrl-→|Shift-←|Shift-→") + keys = key.Set("←|→|Ctrl-←|Ctrl-→|Shift-←|Shift-→|Home|End|Ctrl-Home|Ctrl-End|Ctrl-A|Ctrl-C|Ctrl-X|Ctrl-V|⌦|Ctrl-⌫") } key.InputOp{Tag: &s.dragList.mainTag, Keys: keys}.Add(gtx.Ops) @@ -79,65 +89,45 @@ func (s *FilledDragListStyle) Layout(gtx C) D { key.FocusOp{Tag: &s.dragList.mainTag}.Add(gtx.Ops) } - if !s.dragList.focused { - s.dragList.SelectedItem2 = s.dragList.SelectedItem - } - for _, ke := range gtx.Events(&s.dragList.mainTag) { switch ke := ke.(type) { case key.FocusEvent: s.dragList.focused = ke.Focus + if !s.dragList.focused { + s.dragList.TrackerList.SetSelected2(s.dragList.TrackerList.Selected()) + } case key.Event: if !s.dragList.focused || ke.State != key.Press { break } - delta := 0 - switch { - case s.dragList.List.Axis == layout.Horizontal && ke.Name == key.NameLeftArrow && s.dragList.SelectedItem > 0: - delta = -1 - case s.dragList.List.Axis == layout.Horizontal && ke.Name == key.NameRightArrow && s.dragList.SelectedItem < s.Count-1: - delta = 1 - case s.dragList.List.Axis == layout.Vertical && ke.Name == key.NameUpArrow && s.dragList.SelectedItem > 0: - delta = -1 - case s.dragList.List.Axis == layout.Vertical && ke.Name == key.NameDownArrow && s.dragList.SelectedItem < s.Count-1: - delta = 1 - } - if delta != 0 { - if ke.Modifiers.Contain(key.ModShortcut) { - swap = delta - } else { - s.dragList.SelectedItem += delta - if !ke.Modifiers.Contain(key.ModShift) { - s.dragList.SelectedItem2 = s.dragList.SelectedItem - } - } - } + s.dragList.command(gtx, ke) + case clipboard.Event: + s.dragList.TrackerList.PasteElements([]byte(ke.Text)) } + op.InvalidateOp{}.Add(gtx.Ops) } + _, isMutable := s.dragList.TrackerList.ListData.(tracker.MutableListData) + listElem := func(gtx C, index int) D { for len(s.dragList.tags) <= index { s.dragList.tags = append(s.dragList.tags, false) } - bg := func(gtx C) D { + cursorBg := func(gtx C) D { var color color.NRGBA - if s.dragList.SelectedItem == index { + if s.dragList.TrackerList.Selected() == index { if s.dragList.focused { color = s.CursorColor } else { color = s.SelectedColor } - } else if between(s.dragList.SelectedItem, index, s.dragList.SelectedItem2) { + } else if between(s.dragList.TrackerList.Selected(), index, s.dragList.TrackerList.Selected2()) { color = s.SelectedColor } else if s.dragList.HoverItem == index { color = s.HoverColor } paint.FillShape(gtx.Ops, color, clip.Rect{Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op()) - return D{Size: gtx.Constraints.Min} - } - inputFg := func(gtx C) D { - //defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() for _, ev := range gtx.Events(&s.dragList.tags[index]) { e, ok := ev.(pointer.Event) if !ok { @@ -154,9 +144,9 @@ func (s *FilledDragListStyle) Layout(gtx C) D { if s.dragList.drag { break } - s.dragList.SelectedItem = index + s.dragList.TrackerList.SetSelected(index) if !e.Modifiers.Contain(key.ModShift) { - s.dragList.SelectedItem2 = index + s.dragList.TrackerList.SetSelected2(index) } key.FocusOp{Tag: &s.dragList.mainTag}.Add(gtx.Ops) } @@ -167,7 +157,7 @@ func (s *FilledDragListStyle) Layout(gtx C) D { Types: pointer.Press | pointer.Enter | pointer.Leave, }.Add(gtx.Ops) area.Pop() - if index == s.dragList.SelectedItem { + if index == s.dragList.TrackerList.Selected() && isMutable { for _, ev := range gtx.Events(&s.dragList.focused) { e, ok := ev.(pointer.Event) if !ok { @@ -212,29 +202,31 @@ func (s *FilledDragListStyle) Layout(gtx C) D { } return layout.Dimensions{Size: gtx.Constraints.Min} } - return layout.Stack{Alignment: layout.W}.Layout(gtx, - layout.Expanded(bg), - layout.Expanded(inputFg), - layout.Stacked(func(gtx C) D { - return s.element(gtx, index) - }), - ) - } - dims := s.dragList.List.Layout(gtx, s.Count, listElem) - a := intMin(s.dragList.SelectedItem, s.dragList.SelectedItem2) - b := intMax(s.dragList.SelectedItem, s.dragList.SelectedItem2) - if !s.dragList.swapped && swap != 0 && a+swap >= 0 && b+swap < s.Count { - if swap < 0 { - for i := a; i <= b; i++ { - s.swap(i, i+swap) - } - } else { - for i := b; i >= a; i-- { - s.swap(i, i+swap) - } + macro := op.Record(gtx.Ops) + dims := s.element(gtx, index) + call := macro.Stop() + gtx.Constraints.Min = dims.Size + if s.bg != nil { + s.bg(gtx, index) + } + cursorBg(gtx) + call.Add(gtx.Ops) + if s.dragList.List.Axis == layout.Horizontal { + dims.Size.Y = gtx.Constraints.Max.Y + } else { + dims.Size.X = gtx.Constraints.Max.X + } + return dims + } + count := s.dragList.TrackerList.Count() + if count < 1 { + count = 1 // draw at least one empty element to get the correct size + } + dims := s.dragList.List.Layout(gtx, count, listElem) + if !s.dragList.swapped && swap != 0 { + if s.dragList.TrackerList.MoveElements(swap) { + op.InvalidateOp{}.Add(gtx.Ops) } - s.dragList.SelectedItem += swap - s.dragList.SelectedItem2 += swap s.dragList.swapped = true } else { s.dragList.swapped = false @@ -242,6 +234,88 @@ func (s *FilledDragListStyle) Layout(gtx C) D { return dims } +func (e *DragList) command(gtx layout.Context, k key.Event) { + if k.Modifiers.Contain(key.ModShortcut) { + switch k.Name { + case "V": + clipboard.ReadOp{Tag: &e.mainTag}.Add(gtx.Ops) + return + case "C", "X": + data, ok := e.TrackerList.CopyElements() + if ok && (k.Name == "C" || e.TrackerList.DeleteElements(false)) { + clipboard.WriteOp{Text: string(data)}.Add(gtx.Ops) + } + return + case "A": + e.TrackerList.SetSelected(0) + e.TrackerList.SetSelected2(e.TrackerList.Count() - 1) + return + } + } + delta := 0 + switch k.Name { + case key.NameDeleteBackward: + if k.Modifiers.Contain(key.ModShortcut) { + e.TrackerList.DeleteElements(true) + } + return + case key.NameDeleteForward: + e.TrackerList.DeleteElements(false) + return + case key.NameLeftArrow: + delta = -1 + case key.NameRightArrow: + delta = 1 + case key.NameHome: + delta = -1e6 + case key.NameEnd: + delta = 1e6 + case key.NameUpArrow: + delta = -1 + case key.NameDownArrow: + delta = 1 + case key.NamePageUp: + delta = -1e6 + case key.NamePageDown: + delta = 1e6 + } + if k.Modifiers.Contain(key.ModShortcut) { + e.TrackerList.MoveElements(delta) + } else { + e.TrackerList.SetSelected(e.TrackerList.Selected() + delta) + if !k.Modifiers.Contain(key.ModShift) { + e.TrackerList.SetSelected2(e.TrackerList.Selected()) + } + } + e.EnsureVisible(e.TrackerList.Selected()) +} + +func (l *DragList) EnsureVisible(item int) { + first := l.List.Position.First + last := l.List.Position.First + l.List.Position.Count - 1 + if item < first || (item == first && l.List.Position.Offset > 0) { + l.List.ScrollTo(item) + } + if item > last || (item == last && l.List.Position.OffsetLast < 0) { + o := -l.List.Position.OffsetLast + l.List.Position.Offset + l.List.ScrollTo(item - l.List.Position.Count + 1) + l.List.Position.Offset = o + } +} + +func (l *DragList) CenterOn(item int) { + lenPerChildPx := l.List.Position.Length / l.TrackerList.Count() + if lenPerChildPx == 0 { + return + } + listLengthPx := l.List.Position.Count*l.List.Position.Length/l.TrackerList.Count() + l.List.Position.OffsetLast - l.List.Position.Offset + lenBeforeItem := (listLengthPx - lenPerChildPx) / 2 + quot := lenBeforeItem / lenPerChildPx + rem := lenBeforeItem % lenPerChildPx + l.List.ScrollTo(item - quot - 1) + l.List.Position.Offset = lenPerChildPx - rem +} + func between(a, b, c int) bool { return (a <= b && b <= c) || (c <= b && b <= a) } diff --git a/tracker/gioui/files.go b/tracker/gioui/files.go deleted file mode 100644 index 50c3442..0000000 --- a/tracker/gioui/files.go +++ /dev/null @@ -1,224 +0,0 @@ -//go:build !js -// +build !js - -package gioui - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "time" - - "gopkg.in/yaml.v3" - - "github.com/vsariola/sointu" -) - -func (t *Tracker) OpenSongFile(forced bool) { - if !forced && t.ChangedSinceSave() { - t.ConfirmSongActionType = ConfirmLoad - t.ConfirmSongDialog.Visible = true - return - } - reader, err := t.Explorer.ChooseFile(".yml", ".json") - if err != nil { - return - } - t.loadSong(reader) -} - -func (t *Tracker) SaveSongFile() bool { - if p := t.FilePath(); p != "" { - if f, err := os.Create(p); err == nil { - return t.saveSong(f) - } - } - t.SaveSongAsFile() - return false -} - -func (t *Tracker) SaveSongAsFile() { - p := t.FilePath() - if p == "" { - p = "song.yml" - } - writer, err := t.Explorer.CreateFile(p) - if err != nil { - return - } - t.saveSong(writer) -} - -func (t *Tracker) ExportWav(pcm16 bool) { - filename := "song.wav" - if p := t.FilePath(); p != "" { - filename = p[:len(p)-len(filepath.Ext(p))] + ".wav" - } - writer, err := t.Explorer.CreateFile(filename) - if err != nil { - return - } - t.exportWav(writer, pcm16) -} - -func (t *Tracker) LoadInstrument() { - reader, err := t.Explorer.ChooseFile(".yml", ".json", ".4ki", ".4kp") - if err != nil { - return - } - t.loadInstrument(reader) -} - -func (t *Tracker) SaveInstrument() { - writer, err := t.Explorer.CreateFile(t.Instrument().Name + ".yml") - if err != nil { - return - } - t.saveInstrument(writer) -} - -func (t *Tracker) loadSong(r io.ReadCloser) { - b, err := io.ReadAll(r) - if err != nil { - return - } - err = r.Close() - if err != nil { - return - } - var song sointu.Song - if errJSON := json.Unmarshal(b, &song); errJSON != nil { - if errYaml := yaml.Unmarshal(b, &song); errYaml != nil { - t.Alert.Update(fmt.Sprintf("Error unmarshaling a song file: %v / %v", errYaml, errJSON), Error, time.Second*3) - } - } - if song.Score.Length <= 0 || len(song.Score.Tracks) == 0 || len(song.Patch) == 0 { - t.Alert.Update("The song file is malformed", Error, time.Second*3) - return - } - t.SetSong(song) - path := "" - if f, ok := r.(*os.File); ok { - path = f.Name() - } - t.SetFilePath(path) - t.ClearUndoHistory() - t.SetChangedSinceSave(false) -} - -func (t *Tracker) saveSong(w io.WriteCloser) bool { - path := "" - if f, ok := w.(*os.File); ok { - path = f.Name() - } - var extension = filepath.Ext(path) - 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 { - t.Alert.Update(fmt.Sprintf("Error marshaling a song file: %v", err), Error, time.Second*3) - return false - } - if _, err := w.Write(contents); err != nil { - t.Alert.Update(fmt.Sprintf("Error writing to file: %v", err), Error, time.Second*3) - return false - } - if err := w.Close(); err != nil { - t.Alert.Update(fmt.Sprintf("Error closing file: %v", err), Error, time.Second*3) - return false - } - t.SetFilePath(path) - t.SetChangedSinceSave(false) - return true -} - -func (t *Tracker) exportWav(w io.WriteCloser, pcm16 bool) { - data, err := sointu.Play(t.synther, t.Song()) // render the song to calculate its length - if err != nil { - t.Alert.Update(fmt.Sprintf("Error rendering the song during export: %v", err), Error, time.Second*3) - return - } - buffer, err := data.Wav(pcm16) - if err != nil { - t.Alert.Update(fmt.Sprintf("Error converting to .wav: %v", err), Error, time.Second*3) - return - } - w.Write(buffer) - w.Close() -} - -func (t *Tracker) saveInstrument(w io.WriteCloser) bool { - path := "" - if f, ok := w.(*os.File); ok { - path = f.Name() - } - var extension = filepath.Ext(path) - var contents []byte - var err error - if extension == ".json" { - contents, err = json.Marshal(t.Instrument()) - } else { - contents, err = yaml.Marshal(t.Instrument()) - } - if err != nil { - t.Alert.Update(fmt.Sprintf("Error marshaling a ínstrument file: %v", err), Error, time.Second*3) - return false - } - w.Write(contents) - w.Close() - return true -} - -func (t *Tracker) loadInstrument(r io.ReadCloser) bool { - b, err := io.ReadAll(r) - if err != nil { - return false - } - var instrument sointu.Instrument - var errJSON, errYaml, err4ki, err4kp error - var patch sointu.Patch - errJSON = json.Unmarshal(b, &instrument) - if errJSON == nil { - goto success - } - errYaml = yaml.Unmarshal(b, &instrument) - if errYaml == nil { - goto success - } - patch, err4kp = sointu.Read4klangPatch(bytes.NewReader(b)) - if err4kp == nil { - song := t.Song() - song.Score = t.Song().Score.Copy() - song.Patch = patch - t.SetSong(song) - return true - } - instrument, err4ki = sointu.Read4klangInstrument(bytes.NewReader(b)) - if err4ki == nil { - goto success - } - t.Alert.Update(fmt.Sprintf("Error unmarshaling an instrument file: %v / %v / %v / %v", errYaml, errJSON, err4ki, err4kp), Error, time.Second*3) - return false -success: - if f, ok := r.(*os.File); ok { - filename := f.Name() - // the 4klang instrument names are junk, replace them with the filename without extension - instrument.Name = filepath.Base(filename[:len(filename)-len(filepath.Ext(filename))]) - } - if len(instrument.Units) == 0 { - t.Alert.Update("The instrument file is malformed", Error, time.Second*3) - return false - } - t.SetInstrument(instrument) - if t.Instrument().Comment != "" { - t.InstrumentEditor.ExpandComment() - } - return true -} diff --git a/tracker/gioui/instrument_editor.go b/tracker/gioui/instrument_editor.go new file mode 100644 index 0000000..1d674a9 --- /dev/null +++ b/tracker/gioui/instrument_editor.go @@ -0,0 +1,461 @@ +package gioui + +import ( + "fmt" + "image" + "image/color" + "strconv" + "strings" + + "gioui.org/io/clipboard" + "gioui.org/io/key" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/text" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/vsariola/sointu" + "github.com/vsariola/sointu/tracker" + "golang.org/x/exp/shiny/materialdesign/icons" +) + +type InstrumentEditor struct { + newInstrumentBtn *ActionClickable + enlargeBtn *BoolClickable + deleteInstrumentBtn *ActionClickable + copyInstrumentBtn *TipClickable + saveInstrumentBtn *TipClickable + loadInstrumentBtn *TipClickable + addUnitBtn *ActionClickable + presetMenuBtn *TipClickable + commentExpandBtn *BoolClickable + commentEditor *widget.Editor + commentString tracker.String + nameEditor *widget.Editor + nameString tracker.String + searchEditor *widget.Editor + instrumentDragList *DragList + unitDragList *DragList + presetDragList *DragList + unitEditor *UnitEditor + tag bool + wasFocused bool + presetMenuItems []MenuItem + presetMenu Menu +} + +func NewInstrumentEditor(model *tracker.Model) *InstrumentEditor { + ret := &InstrumentEditor{ + newInstrumentBtn: NewActionClickable(model.AddInstrument()), + enlargeBtn: NewBoolClickable(model.InstrEnlarged().Bool()), + deleteInstrumentBtn: NewActionClickable(model.DeleteInstrument()), + copyInstrumentBtn: new(TipClickable), + saveInstrumentBtn: new(TipClickable), + loadInstrumentBtn: new(TipClickable), + addUnitBtn: NewActionClickable(model.AddUnit(false)), + commentExpandBtn: NewBoolClickable(model.CommentExpanded().Bool()), + presetMenuBtn: new(TipClickable), + commentEditor: new(widget.Editor), + nameEditor: &widget.Editor{SingleLine: true, Submit: true, Alignment: text.Middle}, + searchEditor: &widget.Editor{SingleLine: true, Submit: true, Alignment: text.Start}, + commentString: model.InstrumentComment().String(), + nameString: model.InstrumentName().String(), + instrumentDragList: NewDragList(model.Instruments().List(), layout.Horizontal), + unitDragList: NewDragList(model.Units().List(), layout.Vertical), + unitEditor: NewUnitEditor(model), + presetMenuItems: []MenuItem{}, + } + model.IterateInstrumentPresets(func(index int, name string) bool { + ret.presetMenuItems = append(ret.presetMenuItems, MenuItem{Text: name, IconBytes: icons.ImageAudiotrack, Doer: model.LoadPreset(index)}) + return true + }) + return ret +} + +func (ie *InstrumentEditor) Focus() { + ie.unitDragList.Focus() +} + +func (ie *InstrumentEditor) Focused() bool { + return ie.unitDragList.focused +} + +func (ie *InstrumentEditor) ChildFocused() bool { + return ie.unitEditor.sliderList.Focused() || ie.instrumentDragList.Focused() || ie.commentEditor.Focused() || ie.nameEditor.Focused() || ie.searchEditor.Focused() || + ie.addUnitBtn.Clickable.Focused() || ie.commentExpandBtn.Clickable.Focused() || ie.presetMenuBtn.Clickable.Focused() || ie.deleteInstrumentBtn.Clickable.Focused() || ie.copyInstrumentBtn.Clickable.Focused() +} + +func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D { + ie.wasFocused = ie.Focused() || ie.ChildFocused() + fullscreenBtnStyle := ToggleIcon(t.Theme, ie.enlargeBtn, icons.NavigationFullscreen, icons.NavigationFullscreenExit, "Enlarge (Ctrl+E)", "Shrink (Ctrl+E)") + + octave := func(gtx C) D { + in := layout.UniformInset(unit.Dp(1)) + numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, "Octave down (<) or up (>)") + dims := in.Layout(gtx, numStyle.Layout) + return dims + } + + newBtnStyle := ActionIcon(t.Theme, ie.newInstrumentBtn, icons.ContentAdd, "Add\ninstrument\n(Ctrl+I)") + ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Flex{}.Layout( + gtx, + layout.Flexed(1, func(gtx C) D { + return ie.layoutInstrumentList(gtx, t) + }), + layout.Rigid(func(gtx C) D { + inset := layout.UniformInset(unit.Dp(6)) + return inset.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(Label("OCT:", white, t.Theme.Shaper)), + layout.Rigid(octave), + ) + }) + }), + layout.Rigid(func(gtx C) D { + return layout.E.Layout(gtx, fullscreenBtnStyle.Layout) + }), + layout.Rigid(func(gtx C) D { + return layout.E.Layout(gtx, newBtnStyle.Layout) + }), + ) + }), + layout.Rigid(func(gtx C) D { + return ie.layoutInstrumentHeader(gtx, t) + }), + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return ie.layoutUnitList(gtx, t) + }), + layout.Flexed(1, func(gtx C) D { + return ie.unitEditor.Layout(gtx, t) + }), + ) + })) + return ret +} + +func (ie *InstrumentEditor) layoutInstrumentHeader(gtx C, t *Tracker) D { + header := func(gtx C) D { + commentExpandBtnStyle := ToggleIcon(t.Theme, ie.commentExpandBtn, icons.NavigationExpandMore, icons.NavigationExpandLess, "Expand comment", "Collapse comment") + presetMenuBtnStyle := TipIcon(t.Theme, ie.presetMenuBtn, icons.NavigationMenu, "Load preset") + copyInstrumentBtnStyle := TipIcon(t.Theme, ie.copyInstrumentBtn, icons.ContentContentCopy, "Copy instrument") + saveInstrumentBtnStyle := TipIcon(t.Theme, ie.saveInstrumentBtn, icons.ContentSave, "Save instrument") + loadInstrumentBtnStyle := TipIcon(t.Theme, ie.loadInstrumentBtn, icons.FileFolderOpen, "Load instrument") + deleteInstrumentBtnStyle := ActionIcon(t.Theme, ie.deleteInstrumentBtn, icons.ActionDelete, "Delete\ninstrument") + + m := PopupMenu(&ie.presetMenu, t.Theme.Shaper) + + for ie.copyInstrumentBtn.Clickable.Clicked() { + if contents, ok := t.Instruments().List().CopyElements(); ok { + clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops) + t.Alerts().Add("Instrument copied to clipboard", tracker.Info) + } + } + + for ie.saveInstrumentBtn.Clickable.Clicked() { + writer, err := t.Explorer.CreateFile(t.InstrumentName().Value() + ".yml") + if err != nil { + continue + } + t.SaveInstrument(writer) + } + + for ie.loadInstrumentBtn.Clickable.Clicked() { + reader, err := t.Explorer.ChooseFile(".yml", ".json", ".4ki", ".4kp") + if err != nil { + continue + } + t.LoadInstrument(reader) + } + + header := func(gtx C) D { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(Label("Voices: ", white, t.Theme.Shaper)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + numStyle := NumericUpDown(t.Theme, t.InstrumentVoices, "Number of voices for this instrument") + dims := numStyle.Layout(gtx) + return dims + }), + layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), + layout.Rigid(commentExpandBtnStyle.Layout), + layout.Rigid(func(gtx C) D { + //defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() + dims := presetMenuBtnStyle.Layout(gtx) + op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops) + gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(500)) + gtx.Constraints.Max.X = gtx.Dp(unit.Dp(180)) + m.Layout(gtx, ie.presetMenuItems...) + return dims + }), + layout.Rigid(saveInstrumentBtnStyle.Layout), + layout.Rigid(loadInstrumentBtnStyle.Layout), + layout.Rigid(copyInstrumentBtnStyle.Layout), + layout.Rigid(deleteInstrumentBtnStyle.Layout)) + } + + for ie.presetMenuBtn.Clickable.Clicked() { + ie.presetMenu.Visible = true + } + + if ie.commentExpandBtn.Bool.Value() || ie.commentEditor.Focused() { // we draw once the widget after it manages to lose focus + if ie.commentEditor.Text() != ie.commentString.Value() { + ie.commentEditor.SetText(ie.commentString.Value()) + } + ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(header), + layout.Rigid(func(gtx C) D { + defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() + key.InputOp{Tag: &ie.unitDragList, Keys: globalKeys + "|⎋"}.Add(gtx.Ops) + for _, event := range gtx.Events(&ie.unitDragList) { + if e, ok := event.(key.Event); ok && e.State == key.Press && e.Name == key.NameEscape { + ie.instrumentDragList.Focus() + } + } + editorStyle := material.Editor(t.Theme, ie.commentEditor, "Comment") + editorStyle.Color = highEmphasisTextColor + return layout.UniformInset(unit.Dp(6)).Layout(gtx, editorStyle.Layout) + }), + ) + ie.commentString.Set(ie.commentEditor.Text()) + return ret + } + return header(gtx) + } + + return Surface{Gray: 37, Focus: ie.wasFocused}.Layout(gtx, header) +} + +func (ie *InstrumentEditor) layoutInstrumentList(gtx C, t *Tracker) D { + gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36)) + element := func(gtx C, i int) D { + gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36)) + gtx.Constraints.Min.X = gtx.Dp(unit.Dp(30)) + grabhandle := LabelStyle{Text: "", ShadeColor: black, Color: white, FontSize: unit.Sp(10), Alignment: layout.Center, Shaper: t.Theme.Shaper} + if i == ie.instrumentDragList.TrackerList.Selected() { + grabhandle.Text = ":::" + } + label := func(gtx C) D { + name, level, ok := (*tracker.Instruments)(t.Model).Item(i) + if !ok { + labelStyle := LabelStyle{Text: "", ShadeColor: black, Color: white, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper} + return layout.Center.Layout(gtx, labelStyle.Layout) + } + k := byte(255 - level*127) + color := color.NRGBA{R: 255, G: k, B: 255, A: 255} + if i == ie.instrumentDragList.TrackerList.Selected() { + for _, ev := range ie.nameEditor.Events() { + _, ok := ev.(widget.SubmitEvent) + if ok { + ie.instrumentDragList.Focus() + continue + } + } + if n := name; n != ie.nameEditor.Text() { + ie.nameEditor.SetText(n) + } + editor := material.Editor(t.Theme, ie.nameEditor, "Instr") + editor.Color = color + editor.HintColor = instrumentNameHintColor + editor.TextSize = unit.Sp(12) + editor.Font = labelDefaultFont + dims := layout.Center.Layout(gtx, func(gtx C) D { + defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() + key.InputOp{Tag: &ie.nameEditor, Keys: globalKeys}.Add(gtx.Ops) + return editor.Layout(gtx) + }) + ie.nameString.Set(ie.nameEditor.Text()) + return dims + } + if name == "" { + name = "Instr" + } + labelStyle := LabelStyle{Text: name, ShadeColor: black, Color: color, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper} + return layout.Center.Layout(gtx, labelStyle.Layout) + } + return layout.Inset{Left: unit.Dp(6), Right: unit.Dp(6)}.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(grabhandle.Layout), + layout.Rigid(label), + ) + }) + } + + color := inactiveLightSurfaceColor + if ie.wasFocused { + color = activeLightSurfaceColor + } + instrumentList := FilledDragList(t.Theme, ie.instrumentDragList, element, nil) + instrumentList.SelectedColor = color + instrumentList.HoverColor = instrumentHoverColor + instrumentList.ScrollBarWidth = unit.Dp(6) + + defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() + defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() + key.InputOp{Tag: ie.instrumentDragList, Keys: "↓|⏎|⌤"}.Add(gtx.Ops) + + for _, event := range gtx.Events(ie.instrumentDragList) { + switch e := event.(type) { + case key.Event: + switch e.State { + case key.Press: + switch e.Name { + case key.NameDownArrow: + ie.unitDragList.Focus() + case key.NameReturn, key.NameEnter: + ie.nameEditor.Focus() + l := len(ie.nameEditor.Text()) + ie.nameEditor.SetCaret(l, l) + } + } + } + } + + dims := instrumentList.Layout(gtx) + gtx.Constraints = layout.Exact(dims.Size) + instrumentList.LayoutScrollBar(gtx) + return dims +} +func (ie *InstrumentEditor) layoutUnitList(gtx C, t *Tracker) D { + // TODO: how to ie.unitDragList.Focus() + addUnitBtnStyle := ActionIcon(t.Theme, ie.addUnitBtn, icons.ContentAdd, "Add unit (Enter)") + addUnitBtnStyle.IconButtonStyle.Color = t.Theme.ContrastFg + addUnitBtnStyle.IconButtonStyle.Background = t.Theme.Fg + addUnitBtnStyle.IconButtonStyle.Inset = layout.UniformInset(unit.Dp(4)) + + index := 0 + var units [256]tracker.UnitListItem + (*tracker.Units)(t.Model).Iterate(func(item tracker.UnitListItem) (ok bool) { + units[index] = item + index++ + return index <= 256 + }) + count := intMin(ie.unitDragList.TrackerList.Count(), 256) + + element := func(gtx C, i int) D { + gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(unit.Dp(120)), gtx.Dp(unit.Dp(20)))) + if i < 0 || i >= count { + return layout.Dimensions{Size: gtx.Constraints.Min} + } + u := units[i] + var color color.NRGBA = white + + var stackText string + stackText = strconv.FormatInt(int64(u.StackAfter), 10) + if u.StackNeed > u.StackBefore { + color = errorColor + (*tracker.Alerts)(t.Model).AddNamed("UnitNeedsInputs", fmt.Sprintf("%v needs at least %v input signals, got %v", u.Type, u.StackNeed, u.StackBefore), tracker.Error) + } else if i == count-1 && u.StackAfter != 0 { + color = warningColor + (*tracker.Alerts)(t.Model).AddNamed("InstrumentLeavesSignals", fmt.Sprintf("Instrument leaves %v signal(s) on the stack", u.StackAfter), tracker.Warning) + } + + stackLabel := LabelStyle{Text: stackText, ShadeColor: black, Color: mediumEmphasisTextColor, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper} + rightMargin := layout.Inset{Right: unit.Dp(10)} + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + if i == ie.unitDragList.TrackerList.Selected() { + for _, ev := range ie.searchEditor.Events() { + _, ok := ev.(widget.SubmitEvent) + if ok { + txt := "" + ie.unitDragList.Focus() + if text := ie.searchEditor.Text(); text != "" { + for _, n := range sointu.UnitNames { + if strings.HasPrefix(n, ie.searchEditor.Text()) { + txt = n + break + } + } + } + t.Units().SetSelectedType(txt) + continue + } + } + editor := material.Editor(t.Theme, ie.searchEditor, "---") + editor.Color = color + editor.HintColor = instrumentNameHintColor + editor.TextSize = unit.Sp(12) + editor.Font = labelDefaultFont + + defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() + key.InputOp{Tag: &ie.searchEditor, Keys: globalKeys}.Add(gtx.Ops) + str := tracker.String{StringData: (*tracker.UnitSearch)(t.Model)} + if ie.searchEditor.Text() != str.Value() { + ie.searchEditor.SetText(str.Value()) + } + ret := editor.Layout(gtx) + str.Set(ie.searchEditor.Text()) + return ret + } else { + unitNameLabel := LabelStyle{Text: u.Type, ShadeColor: black, Color: color, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper} + if unitNameLabel.Text == "" { + unitNameLabel.Text = "---" + } + return unitNameLabel.Layout(gtx) + } + }), + layout.Rigid(func(gtx C) D { + return rightMargin.Layout(gtx, stackLabel.Layout) + }), + ) + } + + defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() + unitList := FilledDragList(t.Theme, ie.unitDragList, element, nil) + return Surface{Gray: 30, Focus: ie.wasFocused}.Layout(gtx, func(gtx C) D { + return layout.Stack{Alignment: layout.SE}.Layout(gtx, + layout.Expanded(func(gtx C) D { + defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() + key.InputOp{Tag: ie.unitDragList, Keys: "→|⏎|Ctrl-⏎|⌫|⎋"}.Add(gtx.Ops) + for _, event := range gtx.Events(ie.unitDragList) { + switch e := event.(type) { + case key.Event: + switch e.State { + case key.Press: + switch e.Name { + case key.NameEscape: + ie.instrumentDragList.Focus() + case key.NameRightArrow: + ie.unitEditor.sliderList.Focus() + case key.NameDeleteBackward: + t.Units().SetSelectedType("") + ie.searchEditor.Focus() + l := len(ie.searchEditor.Text()) + ie.searchEditor.SetCaret(l, l) + case key.NameReturn: + t.Model.AddUnit(e.Modifiers.Contain(key.ModCtrl)).Do() + ie.searchEditor.SetText("") + ie.searchEditor.Focus() + l := len(ie.searchEditor.Text()) + ie.searchEditor.SetCaret(l, l) + } + } + } + } + gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(unit.Dp(120)), gtx.Constraints.Max.Y)) + dims := unitList.Layout(gtx) + unitList.LayoutScrollBar(gtx) + return dims + }), + layout.Stacked(func(gtx C) D { + margin := layout.Inset{Right: unit.Dp(20), Bottom: unit.Dp(1)} + return margin.Layout(gtx, addUnitBtnStyle.Layout) + }), + ) + }) +} + +func clamp(i, min, max int) int { + if i < min { + return min + } + if i > max { + return max + } + return i +} diff --git a/tracker/gioui/instrumenteditor.go b/tracker/gioui/instrumenteditor.go deleted file mode 100644 index b3c1ea6..0000000 --- a/tracker/gioui/instrumenteditor.go +++ /dev/null @@ -1,578 +0,0 @@ -package gioui - -import ( - "fmt" - "image" - "image/color" - "strconv" - "strings" - "time" - - "gioui.org/io/clipboard" - "gioui.org/io/key" - "gioui.org/io/pointer" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/text" - "gioui.org/unit" - "gioui.org/widget" - "gioui.org/widget/material" - "gioui.org/x/eventx" - "github.com/vsariola/sointu" - "github.com/vsariola/sointu/tracker" - "github.com/vsariola/sointu/vm" - "golang.org/x/exp/shiny/materialdesign/icons" - "gopkg.in/yaml.v3" -) - -type InstrumentEditor struct { - newInstrumentBtn *TipClickable - enlargeBtn *TipClickable - deleteInstrumentBtn *TipClickable - copyInstrumentBtn *TipClickable - saveInstrumentBtn *TipClickable - loadInstrumentBtn *TipClickable - addUnitBtn *TipClickable - commentExpandBtn *TipClickable - presetMenuBtn *TipClickable - commentEditor *widget.Editor - nameEditor *widget.Editor - unitTypeEditor *widget.Editor - instrumentDragList *DragList - instrumentScrollBar *ScrollBar - unitDragList *DragList - unitScrollBar *ScrollBar - confirmInstrDelete *Dialog - paramEditor *ParamEditor - stackUse []int - tag bool - wasFocused bool - commentExpanded bool - voiceLevels [vm.MAX_VOICES]float32 - presetMenuItems []MenuItem - presetMenu Menu -} - -func NewInstrumentEditor() *InstrumentEditor { - ret := &InstrumentEditor{ - newInstrumentBtn: new(TipClickable), - enlargeBtn: new(TipClickable), - deleteInstrumentBtn: new(TipClickable), - copyInstrumentBtn: new(TipClickable), - saveInstrumentBtn: new(TipClickable), - loadInstrumentBtn: new(TipClickable), - addUnitBtn: new(TipClickable), - commentExpandBtn: new(TipClickable), - presetMenuBtn: new(TipClickable), - commentEditor: new(widget.Editor), - nameEditor: &widget.Editor{SingleLine: true, Submit: true, Alignment: text.Middle}, - unitTypeEditor: &widget.Editor{SingleLine: true, Submit: true, Alignment: text.Start}, - instrumentDragList: &DragList{List: &layout.List{Axis: layout.Horizontal}, HoverItem: -1}, - instrumentScrollBar: &ScrollBar{Axis: layout.Horizontal}, - unitDragList: &DragList{List: &layout.List{Axis: layout.Vertical}, HoverItem: -1}, - unitScrollBar: &ScrollBar{Axis: layout.Vertical}, - confirmInstrDelete: new(Dialog), - paramEditor: NewParamEditor(), - presetMenuItems: []MenuItem{}, - } - for _, instr := range tracker.InstrumentPresets { - ret.presetMenuItems = append(ret.presetMenuItems, MenuItem{Text: instr.Name, IconBytes: icons.ImageAudiotrack}) - } - return ret -} - -func (t *InstrumentEditor) ExpandComment() { - t.commentExpanded = true -} - -func (ie *InstrumentEditor) Focus() { - ie.unitDragList.Focus() -} - -func (ie *InstrumentEditor) Focused() bool { - return ie.unitDragList.focused -} - -func (ie *InstrumentEditor) ChildFocused() bool { - return ie.paramEditor.Focused() || ie.instrumentDragList.Focused() || ie.commentEditor.Focused() || ie.nameEditor.Focused() || ie.unitTypeEditor.Focused() || - ie.addUnitBtn.Clickable.Focused() || ie.commentExpandBtn.Clickable.Focused() || ie.presetMenuBtn.Clickable.Focused() || ie.deleteInstrumentBtn.Clickable.Focused() || ie.copyInstrumentBtn.Clickable.Focused() -} - -func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D { - ie.wasFocused = ie.Focused() || ie.ChildFocused() - for _, e := range gtx.Events(&ie.tag) { - switch e.(type) { - case pointer.Event: - ie.unitDragList.Focus() - } - } - rect := image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y) - area := clip.Rect(rect).Push(gtx.Ops) - pointer.InputOp{Tag: &ie.tag, - Types: pointer.Press, - }.Add(gtx.Ops) - area.Pop() - - enlargeTip := "Enlarge" - icon := icons.NavigationFullscreen - if t.InstrEnlarged() { - icon = icons.NavigationFullscreenExit - enlargeTip = "Shrink" - } - - fullscreenBtnStyle := IconButton(t.Theme, ie.enlargeBtn, icon, true, enlargeTip) - for ie.enlargeBtn.Clickable.Clicked() { - t.SetInstrEnlarged(!t.InstrEnlarged()) - } - for ie.newInstrumentBtn.Clickable.Clicked() { - t.AddInstrument(true) - } - octave := func(gtx C) D { - in := layout.UniformInset(unit.Dp(1)) - t.OctaveNumberInput.Value = t.Octave() - numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, 0, 9, "Octave down (<) or up (>)") - dims := in.Layout(gtx, numStyle.Layout) - t.SetOctave(t.OctaveNumberInput.Value) - return dims - } - newBtnStyle := IconButton(t.Theme, ie.newInstrumentBtn, icons.ContentAdd, t.CanAddInstrument(), "Add\ninstrument") - ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return layout.Flex{}.Layout( - gtx, - layout.Flexed(1, func(gtx C) D { - return layout.Stack{}.Layout(gtx, - layout.Stacked(func(gtx C) D { - return ie.layoutInstrumentNames(gtx, t) - }), - layout.Expanded(func(gtx C) D { - return ie.instrumentScrollBar.Layout(gtx, unit.Dp(6), len(t.Song().Patch), &ie.instrumentDragList.List.Position) - }), - ) - }), - layout.Rigid(func(gtx C) D { - inset := layout.UniformInset(unit.Dp(6)) - return inset.Layout(gtx, func(gtx C) D { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(Label("OCT:", white, t.TextShaper)), - layout.Rigid(octave), - ) - }) - }), - layout.Rigid(func(gtx C) D { - return layout.E.Layout(gtx, fullscreenBtnStyle.Layout) - }), - layout.Rigid(func(gtx C) D { - return layout.E.Layout(gtx, newBtnStyle.Layout) - }), - ) - }), - layout.Rigid(func(gtx C) D { - return ie.layoutInstrumentHeader(gtx, t) - }), - layout.Flexed(1, func(gtx C) D { - return ie.layoutInstrumentEditor(gtx, t) - })) - return ret -} - -func (ie *InstrumentEditor) layoutInstrumentHeader(gtx C, t *Tracker) D { - header := func(gtx C) D { - collapseIcon := icons.NavigationExpandLess - commentTip := "Collapse comment" - if !ie.commentExpanded { - collapseIcon = icons.NavigationExpandMore - commentTip = "Expand comment" - } - - commentExpandBtnStyle := IconButton(t.Theme, ie.commentExpandBtn, collapseIcon, true, commentTip) - presetMenuBtnStyle := IconButton(t.Theme, ie.presetMenuBtn, icons.NavigationMenu, true, "Load preset") - copyInstrumentBtnStyle := IconButton(t.Theme, ie.copyInstrumentBtn, icons.ContentContentCopy, true, "Copy instrument") - saveInstrumentBtnStyle := IconButton(t.Theme, ie.saveInstrumentBtn, icons.ContentSave, true, "Save instrument") - loadInstrumentBtnStyle := IconButton(t.Theme, ie.loadInstrumentBtn, icons.FileFolderOpen, true, "Load instrument") - deleteInstrumentBtnStyle := IconButton(t.Theme, ie.deleteInstrumentBtn, icons.ActionDelete, t.CanDeleteInstrument(), "Delete\ninstrument") - - m := t.PopupMenu(&ie.presetMenu) - - for item, clicked := ie.presetMenu.Clicked(); clicked; item, clicked = ie.presetMenu.Clicked() { - t.SetInstrument(tracker.InstrumentPresets[item]) - } - - header := func(gtx C) D { - return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(Label("Voices: ", white, t.TextShaper)), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - maxRemain := t.MaxInstrumentVoices() - t.InstrumentVoices.Value = t.Instrument().NumVoices - numStyle := NumericUpDown(t.Theme, t.InstrumentVoices, 0, maxRemain, "Number of voices for this instrument") - dims := numStyle.Layout(gtx) - t.SetInstrumentVoices(t.InstrumentVoices.Value) - return dims - }), - layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), - layout.Rigid(commentExpandBtnStyle.Layout), - layout.Rigid(func(gtx C) D { - //defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - dims := presetMenuBtnStyle.Layout(gtx) - op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops) - gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(500)) - gtx.Constraints.Max.X = gtx.Dp(unit.Dp(180)) - m.Layout(gtx, ie.presetMenuItems...) - return dims - }), - layout.Rigid(saveInstrumentBtnStyle.Layout), - layout.Rigid(loadInstrumentBtnStyle.Layout), - layout.Rigid(copyInstrumentBtnStyle.Layout), - layout.Rigid(deleteInstrumentBtnStyle.Layout)) - } - - for ie.presetMenuBtn.Clickable.Clicked() { - ie.presetMenu.Visible = true - } - - for ie.commentExpandBtn.Clickable.Clicked() { - ie.commentExpanded = !ie.commentExpanded - if !ie.commentExpanded { - key.FocusOp{Tag: &ie.tag}.Add(gtx.Ops) // clear focus - } - } - if ie.commentExpanded || ie.commentEditor.Focused() { // we draw once the widget after it manages to lose focus - if ie.commentEditor.Text() != t.Instrument().Comment { - ie.commentEditor.SetText(t.Instrument().Comment) - } - editorStyle := material.Editor(t.Theme, ie.commentEditor, "Comment") - editorStyle.Color = highEmphasisTextColor - - ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(header), - layout.Rigid(func(gtx C) D { - spy, spiedGtx := eventx.Enspy(gtx) - ret := layout.UniformInset(unit.Dp(6)).Layout(spiedGtx, editorStyle.Layout) - for _, group := range spy.AllEvents() { - for _, event := range group.Items { - switch e := event.(type) { - case key.Event: - if e.Name == key.NameEscape { - ie.instrumentDragList.Focus() - } - } - } - } - return ret - }), - ) - t.SetInstrumentComment(ie.commentEditor.Text()) - return ret - } - return header(gtx) - } - - for ie.copyInstrumentBtn.Clickable.Clicked() { - contents, err := yaml.Marshal(t.Instrument()) - if err == nil { - clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops) - t.Alert.Update("Instrument copied to clipboard", Notify, time.Second*3) - } - } - for ie.deleteInstrumentBtn.Clickable.Clicked() { - if t.CanDeleteInstrument() { - dialogStyle := ConfirmDialog(t.Theme, ie.confirmInstrDelete, "Are you sure you want to delete this instrument?", t.TextShaper) - ie.confirmInstrDelete.Visible = true - t.ModalDialog = dialogStyle.Layout - } - } - for ie.confirmInstrDelete.BtnOk.Clicked() { - t.DeleteInstrument(false) - t.ModalDialog = nil - } - for ie.confirmInstrDelete.BtnCancel.Clicked() { - t.ModalDialog = nil - } - for ie.saveInstrumentBtn.Clickable.Clicked() { - t.SaveInstrument() - } - - for ie.loadInstrumentBtn.Clickable.Clicked() { - t.LoadInstrument() - } - return Surface{Gray: 37, Focus: ie.wasFocused}.Layout(gtx, header) -} - -func (ie *InstrumentEditor) layoutInstrumentNames(gtx C, t *Tracker) D { - element := func(gtx C, i int) D { - gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36)) - gtx.Constraints.Min.X = gtx.Dp(unit.Dp(30)) - grabhandle := LabelStyle{Text: "", ShadeColor: black, Color: white, FontSize: unit.Sp(10), Alignment: layout.Center, Shaper: t.TextShaper} - if i == t.InstrIndex() { - grabhandle.Text = ":::" - } - label := func(gtx C) D { - c := float32(0.0) - voice := t.Song().Patch.FirstVoiceForInstrument(i) - loopMax := t.Song().Patch[i].NumVoices - if loopMax > vm.MAX_VOICES { - loopMax = vm.MAX_VOICES - } - for j := 0; j < loopMax; j++ { - vc := ie.voiceLevels[voice] - if c < vc { - c = vc - } - voice++ - } - k := byte(255 - c*127) - color := color.NRGBA{R: 255, G: k, B: 255, A: 255} - if i == t.InstrIndex() { - for _, ev := range ie.nameEditor.Events() { - _, ok := ev.(widget.SubmitEvent) - if ok { - ie.instrumentDragList.Focus() - continue - } - } - if n := t.Instrument().Name; n != ie.nameEditor.Text() { - ie.nameEditor.SetText(n) - } - editor := material.Editor(t.Theme, ie.nameEditor, "Instr") - editor.Color = color - editor.HintColor = instrumentNameHintColor - editor.TextSize = unit.Sp(12) - dims := layout.Center.Layout(gtx, editor.Layout) - t.SetInstrumentName(ie.nameEditor.Text()) - return dims - } - text := t.Song().Patch[i].Name - if text == "" { - text = "Instr" - } - labelStyle := LabelStyle{Text: text, ShadeColor: black, Color: color, FontSize: unit.Sp(12), Shaper: t.TextShaper} - return layout.Center.Layout(gtx, labelStyle.Layout) - } - return layout.Inset{Left: unit.Dp(6), Right: unit.Dp(6)}.Layout(gtx, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(grabhandle.Layout), - layout.Rigid(label), - ) - }) - } - - color := inactiveLightSurfaceColor - if ie.wasFocused { - color = activeLightSurfaceColor - } - instrumentList := FilledDragList(t.Theme, ie.instrumentDragList, len(t.Song().Patch), element, t.SwapInstruments) - instrumentList.SelectedColor = color - instrumentList.HoverColor = instrumentHoverColor - - ie.instrumentDragList.SelectedItem = t.InstrIndex() - defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() - key.InputOp{Tag: ie.instrumentDragList, Keys: "↓|⏎|⌤"}.Add(gtx.Ops) - - for _, event := range gtx.Events(ie.instrumentDragList) { - switch e := event.(type) { - case key.Event: - switch e.State { - case key.Press: - switch e.Name { - case key.NameDownArrow: - ie.unitDragList.Focus() - case key.NameReturn, key.NameEnter: - ie.nameEditor.Focus() - l := len(ie.nameEditor.Text()) - ie.nameEditor.SetCaret(l, l) - } - } - } - } - - dims := instrumentList.Layout(gtx) - - if t.InstrIndex() != ie.instrumentDragList.SelectedItem { - t.SetInstrIndex(ie.instrumentDragList.SelectedItem) - op.InvalidateOp{}.Add(gtx.Ops) - } - return dims -} -func (ie *InstrumentEditor) layoutInstrumentEditor(gtx C, t *Tracker) D { - for ie.addUnitBtn.Clickable.Clicked() { - t.AddUnit(true) - ie.unitDragList.Focus() - } - addUnitBtnStyle := IconButton(t.Theme, ie.addUnitBtn, icons.ContentAdd, true, "Add unit (Ctrl+Enter)") - addUnitBtnStyle.IconButtonStyle.Color = t.Theme.ContrastFg - addUnitBtnStyle.IconButtonStyle.Background = t.Theme.Fg - addUnitBtnStyle.IconButtonStyle.Inset = layout.UniformInset(unit.Dp(4)) - - units := t.Instrument().Units - for len(ie.stackUse) < len(units) { - ie.stackUse = append(ie.stackUse, 0) - } - - stackHeight := 0 - for i, u := range units { - stackHeight += u.StackChange() - ie.stackUse[i] = stackHeight - } - - element := func(gtx C, i int) D { - gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(unit.Dp(120)), gtx.Dp(unit.Dp(20)))) - u := units[i] - var color color.NRGBA = white - - var stackText string - if i < len(ie.stackUse) { - stackText = strconv.FormatInt(int64(ie.stackUse[i]), 10) - var prevStackUse int - if i > 0 { - prevStackUse = ie.stackUse[i-1] - } - if stackNeed := u.StackNeed(); stackNeed > prevStackUse { - color = errorColor - typeString := u.Type - if u.Parameters["stereo"] == 1 { - typeString += " (stereo)" - } - t.Alert.Update(fmt.Sprintf("%v needs at least %v input signals, got %v", typeString, stackNeed, prevStackUse), Error, 0) - } else if i == len(units)-1 && ie.stackUse[i] != 0 { - color = warningColor - t.Alert.Update(fmt.Sprintf("Instrument leaves %v signal(s) on the stack", ie.stackUse[i]), Warning, 0) - } - } - - var unitName layout.Widget - if i == t.UnitIndex() { - for _, ev := range ie.unitTypeEditor.Events() { - _, ok := ev.(widget.SubmitEvent) - if ok { - ie.unitDragList.Focus() - if text := ie.unitTypeEditor.Text(); text != "" { - for _, n := range sointu.UnitNames { - if strings.HasPrefix(n, ie.unitTypeEditor.Text()) { - t.SetUnitType(n) - break - } - } - } else { - t.SetUnitType("") - } - continue - } - } - if !ie.unitTypeEditor.Focused() && !ie.paramEditor.Focused() && ie.unitTypeEditor.Text() != t.Unit().Type { - ie.unitTypeEditor.SetText(t.Unit().Type) - } - editor := material.Editor(t.Theme, ie.unitTypeEditor, "---") - editor.Color = color - editor.HintColor = instrumentNameHintColor - editor.TextSize = unit.Sp(12) - editor.Font = labelDefaultFont - unitName = editor.Layout - } else { - unitNameLabel := LabelStyle{Text: u.Type, ShadeColor: black, Color: color, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.TextShaper} - if unitNameLabel.Text == "" { - unitNameLabel.Text = "---" - } - unitName = unitNameLabel.Layout - } - - stackLabel := LabelStyle{Text: stackText, ShadeColor: black, Color: mediumEmphasisTextColor, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.TextShaper} - rightMargin := layout.Inset{Right: unit.Dp(10)} - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Flexed(1, unitName), - layout.Rigid(func(gtx C) D { - return rightMargin.Layout(gtx, stackLabel.Layout) - }), - ) - } - - defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - unitList := FilledDragList(t.Theme, ie.unitDragList, len(units), element, t.SwapUnits) - return Surface{Gray: 30, Focus: ie.wasFocused}.Layout(gtx, func(gtx C) D { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return layout.Stack{Alignment: layout.SE}.Layout(gtx, - layout.Expanded(func(gtx C) D { - defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() - key.InputOp{Tag: ie.unitDragList, Keys: "→|⏎|⌫|⌦|⎋|Ctrl-⏎|Ctrl-C|Ctrl-X"}.Add(gtx.Ops) - for _, event := range gtx.Events(ie.unitDragList) { - switch e := event.(type) { - case key.Event: - switch e.State { - case key.Press: - switch e.Name { - case key.NameEscape: - ie.instrumentDragList.Focus() - case key.NameRightArrow: - ie.paramEditor.Focus() - case key.NameDeleteBackward: - t.SetUnitType("") - ie.unitTypeEditor.Focus() - l := len(ie.unitTypeEditor.Text()) - ie.unitTypeEditor.SetCaret(l, l) - case key.NameDeleteForward: - t.DeleteUnits(true, ie.unitDragList.SelectedItem, ie.unitDragList.SelectedItem2) - ie.unitDragList.SelectedItem2 = t.UnitIndex() - case "X": - units := t.DeleteUnits(true, ie.unitDragList.SelectedItem, ie.unitDragList.SelectedItem2) - ie.unitDragList.SelectedItem2 = t.UnitIndex() - contents, err := yaml.Marshal(units) - if err == nil { - clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops) - t.Alert.Update("Unit(s) cut to clipboard", Notify, time.Second*3) - } - case "C": - a := clamp(ie.unitDragList.SelectedItem, 0, len(t.Instrument().Units)-1) - b := clamp(ie.unitDragList.SelectedItem2, 0, len(t.Instrument().Units)-1) - if a > b { - a, b = b, a - } - units := t.Instrument().Units[a : b+1] - contents, err := yaml.Marshal(units) - if err == nil { - clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops) - t.Alert.Update("Unit(s) copied to clipboard", Notify, time.Second*3) - } - case key.NameReturn: - if e.Modifiers.Contain(key.ModShortcut) { - t.AddUnit(true) - ie.unitDragList.SelectedItem2 = ie.unitDragList.SelectedItem - ie.unitTypeEditor.SetText("") - } - ie.unitTypeEditor.Focus() - l := len(ie.unitTypeEditor.Text()) - ie.unitTypeEditor.SetCaret(l, l) - } - } - } - } - ie.unitDragList.SelectedItem = t.UnitIndex() - dims := unitList.Layout(gtx) - if t.UnitIndex() != ie.unitDragList.SelectedItem { - t.SetUnitIndex(ie.unitDragList.SelectedItem) - ie.unitTypeEditor.SetText(t.Unit().Type) - } - return dims - }), - layout.Stacked(func(gtx C) D { - margin := layout.Inset{Right: unit.Dp(20), Bottom: unit.Dp(1)} - return margin.Layout(gtx, addUnitBtnStyle.Layout) - }), - layout.Expanded(func(gtx C) D { - return ie.unitScrollBar.Layout(gtx, unit.Dp(10), len(t.Instrument().Units), &ie.unitDragList.List.Position) - })) - }), - layout.Rigid(ie.paramEditor.Bind(t))) - }) -} - -func clamp(i, min, max int) int { - if i < min { - return min - } - if i > max { - return max - } - return i -} diff --git a/tracker/gioui/keyevent.go b/tracker/gioui/keyevent.go index 255c911..d1f2806 100644 --- a/tracker/gioui/keyevent.go +++ b/tracker/gioui/keyevent.go @@ -1,15 +1,15 @@ package gioui import ( - "time" - "gioui.org/io/clipboard" "gioui.org/io/key" "gioui.org/op" - "github.com/vsariola/sointu/tracker" - "gopkg.in/yaml.v3" ) +// globalKeys is a list of keys that are handled globally by the app. +// All Editors should capture these keys to prevent them flowing to the global handler. +var globalKeys = key.Set("Space|\\|<|>|A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|R|S|T|U|V|W|X|Y|Z|1|2|3|4|5|6|7|8|9|0|,|.") + var noteMap = map[string]int{ "Z": -12, "S": -11, @@ -49,15 +49,6 @@ var noteMap = map[string]int{ func (t *Tracker) KeyEvent(e key.Event, o *op.Ops) { if e.State == key.Press { switch e.Name { - case "C": - if e.Modifiers.Contain(key.ModShortcut) { - contents, err := yaml.Marshal(t.Song()) - if err == nil { - clipboard.WriteOp{Text: string(contents)}.Add(o) - t.Alert.Update("Song copied to clipboard", Notify, time.Second*3) - } - return - } case "V": if e.Modifiers.Contain(key.ModShortcut) { clipboard.ReadOp{Tag: t}.Add(o) @@ -65,97 +56,118 @@ func (t *Tracker) KeyEvent(e key.Event, o *op.Ops) { } case "Z": if e.Modifiers.Contain(key.ModShortcut) { - t.Undo() + t.Model.Undo().Do() return } case "Y": if e.Modifiers.Contain(key.ModShortcut) { - t.Redo() + t.Model.Redo().Do() return } case "N": if e.Modifiers.Contain(key.ModShortcut) { - t.NewSong(false) + t.NewSong().Do() return } case "S": if e.Modifiers.Contain(key.ModShortcut) { - t.SaveSongFile() + t.SaveSong().Do() return } case "O": if e.Modifiers.Contain(key.ModShortcut) { - t.OpenSongFile(false) + t.OpenSong().Do() + return + } + case "I": + if e.Modifiers.Contain(key.ModShortcut) { + if e.Modifiers.Contain(key.ModShift) { + t.DeleteInstrument().Do() + } else { + t.AddInstrument().Do() + } + return + } + case "T": + if e.Modifiers.Contain(key.ModShortcut) { + if e.Modifiers.Contain(key.ModShift) { + t.DeleteTrack().Do() + } else { + t.AddTrack().Do() + } + return + } + case "E": + if e.Modifiers.Contain(key.ModShortcut) { + t.InstrEnlarged().Bool().Toggle() + return + } + case "W": + if e.Modifiers.Contain(key.ModShortcut) && canQuit { + t.Quit().Do() return } case "F1": - t.OrderEditor.Focus() + t.OrderEditor.scrollTable.Focus() return case "F2": - t.TrackEditor.Focus() + t.TrackEditor.scrollTable.Focus() return case "F3": t.InstrumentEditor.Focus() return - case "F4": - t.TrackEditor.Focus() - return case "F5": - t.SetNoteTracking(true) - startRow := t.Cursor().ScoreRow - t.PlayFromPosition(startRow) + t.SongPanel.RewindBtn.Action.Do() + t.SongPanel.NoteTracking.Bool.Set(!e.Modifiers.Contain(key.ModCtrl)) return - case "F6": - t.SetNoteTracking(false) - startRow := t.Cursor().ScoreRow - t.PlayFromPosition(startRow) + case "F6", "Space": + t.SongPanel.PlayingBtn.Bool.Toggle() + t.SongPanel.NoteTracking.Bool.Set(!e.Modifiers.Contain(key.ModCtrl)) + return + case "F7": + t.SongPanel.RecordBtn.Bool.Toggle() return case "F8": - t.SetPlaying(false) + t.SongPanel.NoteTracking.Bool.Toggle() + return + case "F12": + t.Panic().Bool().Toggle() return - case "Space": - if !t.Playing() && !t.InstrEnlarged() { - t.SetNoteTracking(!e.Modifiers.Contain(key.ModShortcut)) - startRow := t.Cursor().ScoreRow - t.PlayFromPosition(startRow) - } else { - t.SetPlaying(false) - } case `\`, `<`, `>`: if e.Modifiers.Contain(key.ModShift) { - t.SetOctave(t.Octave() + 1) + t.OctaveNumberInput.Int.Add(1) } else { - t.SetOctave(t.Octave() - 1) + t.OctaveNumberInput.Int.Add(-1) } case key.NameTab: if e.Modifiers.Contain(key.ModShift) { switch { - case t.OrderEditor.Focused(): - t.InstrumentEditor.paramEditor.Focus() - case t.TrackEditor.Focused(): - t.OrderEditor.Focus() + case t.OrderEditor.scrollTable.Focused(): + t.InstrumentEditor.unitEditor.sliderList.Focus() + case t.TrackEditor.scrollTable.Focused(): + t.OrderEditor.scrollTable.Focus() case t.InstrumentEditor.Focused(): - if t.InstrEnlarged() { - t.InstrumentEditor.paramEditor.Focus() + if t.InstrumentEditor.enlargeBtn.Bool.Value() { + t.InstrumentEditor.unitEditor.sliderList.Focus() } else { - t.TrackEditor.Focus() + t.TrackEditor.scrollTable.Focus() } default: t.InstrumentEditor.Focus() } } else { switch { - case t.OrderEditor.Focused(): - t.TrackEditor.Focus() - case t.TrackEditor.Focused(): + case t.OrderEditor.scrollTable.Focused(): + t.TrackEditor.scrollTable.Focus() + case t.TrackEditor.scrollTable.Focused(): t.InstrumentEditor.Focus() case t.InstrumentEditor.Focused(): - t.InstrumentEditor.paramEditor.Focus() + t.InstrumentEditor.unitEditor.sliderList.Focus() default: - if t.InstrEnlarged() { + if t.InstrumentEditor.enlargeBtn.Bool.Value() { t.InstrumentEditor.Focus() } else { - t.OrderEditor.Focus() + t.OrderEditor.scrollTable.Focus() } } } @@ -166,28 +178,12 @@ func (t *Tracker) KeyEvent(e key.Event, o *op.Ops) { } } -// NumberPressed handles incoming presses while in either of the hex number columns -func (t *Tracker) NumberPressed(iv byte) { - val := t.Note() - if val == 1 { - val = 0 - } - if t.LowNibble() { - val = (val & 0xF0) | (iv & 0xF) - } else { - val = ((iv & 0xF) << 4) | (val & 0xF) - } - t.SetNote(val) -} - func (t *Tracker) JammingPressed(e key.Event) byte { if val, ok := noteMap[e.Name]; ok { if _, ok := t.KeyPlaying[e.Name]; !ok { - n := noteAsValue(t.OctaveNumberInput.Value, val) - instr := t.InstrIndex() - noteID := tracker.NoteIDInstr(instr, n) - t.NoteOn(noteID) - t.KeyPlaying[e.Name] = noteID + n := noteAsValue(t.OctaveNumberInput.Int.Value(), val) + instr := t.InstrumentEditor.instrumentDragList.TrackerList.Selected() + t.KeyPlaying[e.Name] = t.InstrNoteOn(instr, n) return n } } @@ -196,7 +192,7 @@ func (t *Tracker) JammingPressed(e key.Event) byte { func (t *Tracker) JammingReleased(e key.Event) bool { if noteID, ok := t.KeyPlaying[e.Name]; ok { - t.NoteOff(noteID) + noteID.NoteOff() delete(t.KeyPlaying, e.Name) return true } diff --git a/tracker/gioui/label.go b/tracker/gioui/label.go index b8958d9..1c2081a 100644 --- a/tracker/gioui/label.go +++ b/tracker/gioui/label.go @@ -24,28 +24,25 @@ type LabelStyle struct { } func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions { - return layout.Stack{Alignment: l.Alignment}.Layout(gtx, - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - paint.ColorOp{Color: l.ShadeColor}.Add(gtx.Ops) - op.Offset(image.Pt(2, 2)).Add(gtx.Ops) - dims := widget.Label{ - Alignment: text.Start, - MaxLines: 1, - }.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{}) - return layout.Dimensions{ - Size: dims.Size.Add(image.Pt(2, 2)), - Baseline: dims.Baseline, - } - }), - layout.Stacked(func(gtx layout.Context) layout.Dimensions { - paint.ColorOp{Color: l.Color}.Add(gtx.Ops) - return widget.Label{ - Alignment: text.Start, - MaxLines: 1, - }.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{}) - }), - ) + return l.Alignment.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min = image.Point{} + paint.ColorOp{Color: l.ShadeColor}.Add(gtx.Ops) + offs := op.Offset(image.Pt(2, 2)).Push(gtx.Ops) + widget.Label{ + Alignment: text.Start, + MaxLines: 1, + }.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{}) + offs.Pop() + paint.ColorOp{Color: l.Color}.Add(gtx.Ops) + dims := widget.Label{ + Alignment: text.Start, + MaxLines: 1, + }.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{}) + return layout.Dimensions{ + Size: dims.Size, + Baseline: dims.Baseline, + } + }) } func Label(str string, color color.NRGBA, shaper *text.Shaper) layout.Widget { diff --git a/tracker/gioui/layout.go b/tracker/gioui/layout.go deleted file mode 100644 index 3c88a25..0000000 --- a/tracker/gioui/layout.go +++ /dev/null @@ -1,121 +0,0 @@ -package gioui - -import ( - "image" - - "gioui.org/app" - "gioui.org/io/clipboard" - "gioui.org/io/key" - "gioui.org/layout" - "gioui.org/op/clip" - "gioui.org/op/paint" -) - -type C = layout.Context -type D = layout.Dimensions - -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: - t.UnmarshalContent([]byte(e.Text)) - } - } - - paint.FillShape(gtx.Ops, backgroundColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op()) - if t.InstrEnlarged() { - t.layoutTop(gtx) - } else { - t.VerticalSplit.Layout(gtx, - t.layoutTop, - t.layoutBottom) - } - t.Alert.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.", t.TextShaper) - 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 - } - dstyle = ConfirmDialog(t.Theme, t.WaveTypeDialog, "Export .wav in int16 or float32 sample format?", t.TextShaper) - dstyle.ShowAlt = true - dstyle.OkStyle.Text = "Int16" - dstyle.AltStyle.Text = "Float32" - dstyle.Layout(gtx) - for t.WaveTypeDialog.BtnOk.Clicked() { - t.ExportWav(true) - t.WaveTypeDialog.Visible = false - } - for t.WaveTypeDialog.BtnAlt.Clicked() { - t.ExportWav(false) - t.WaveTypeDialog.Visible = false - } - for t.WaveTypeDialog.BtnCancel.Clicked() { - t.WaveTypeDialog.Visible = false - } - if t.ModalDialog != nil { - t.ModalDialog(gtx) - } -} - -func (t *Tracker) confirmedSongAction() { - switch t.ConfirmSongActionType { - case ConfirmLoad: - t.OpenSongFile(true) - case ConfirmNew: - t.NewSong(true) - case ConfirmQuit: - t.Quit(true) - } -} - -func (t *Tracker) NewSong(forced bool) { - if !forced && t.ChangedSinceSave() { - t.ConfirmSongActionType = ConfirmNew - t.ConfirmSongDialog.Visible = true - return - } - t.ResetSong() - t.SetFilePath("") - t.ClearUndoHistory() - t.SetChangedSinceSave(false) -} - -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, - t.layoutSongPanel, - func(gtx C) D { - return t.InstrumentEditor.Layout(gtx, t) - }, - ) -} diff --git a/tracker/gioui/menu.go b/tracker/gioui/menu.go index 5d6e201..e373eba 100644 --- a/tracker/gioui/menu.go +++ b/tracker/gioui/menu.go @@ -12,6 +12,8 @@ import ( "gioui.org/text" "gioui.org/unit" "gioui.org/widget" + "gioui.org/widget/material" + "github.com/vsariola/sointu/tracker" ) type Menu struct { @@ -40,7 +42,7 @@ type MenuItem struct { IconBytes []byte Text string ShortcutText string - Disabled bool + Doer tracker.Action } func (m *Menu) Clicked() (int, bool) { @@ -57,7 +59,7 @@ func (m *Menu) Clicked() (int, bool) { func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { contents := func(gtx C) D { - for i := range items { + for i, item := range items { // make sure we have a tag for every item for len(m.Menu.tags) <= i { m.Menu.tags = append(m.Menu.tags, false) @@ -70,7 +72,7 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { } switch e.Type { case pointer.Press: - m.Menu.clicks = append(m.Menu.clicks, i) + item.Doer.Do() m.Menu.Visible = false case pointer.Enter: m.Menu.hover = i + 1 @@ -89,17 +91,17 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() var macro op.MacroOp item := &items[i] - if i == m.Menu.hover-1 && !item.Disabled { + if i == m.Menu.hover-1 && item.Doer.Allowed() { macro = op.Record(gtx.Ops) } icon := widgetForIcon(item.IconBytes) iconColor := m.IconColor - if item.Disabled { + if !item.Doer.Allowed() { iconColor = mediumEmphasisTextColor } iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)} textLabel := LabelStyle{Text: item.Text, FontSize: m.FontSize, Color: m.TextColor, Shaper: m.Shaper} - if item.Disabled { + if !item.Doer.Allowed() { textLabel.Color = mediumEmphasisTextColor } shortcutLabel := LabelStyle{Text: item.ShortcutText, FontSize: m.FontSize, Color: m.ShortCutColor, Shaper: m.Shaper} @@ -118,14 +120,14 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { return shortcutInset.Layout(gtx, shortcutLabel.Layout) }), ) - if i == m.Menu.hover-1 && !item.Disabled { + if i == m.Menu.hover-1 && item.Doer.Allowed() { recording := macro.Stop() paint.FillShape(gtx.Ops, m.HoverColor, clip.Rect{ Max: image.Pt(dims.Size.X, dims.Size.Y), }.Op()) recording.Add(gtx.Ops) } - if !item.Disabled { + if item.Doer.Allowed() { rect := image.Rect(0, 0, dims.Size.X, dims.Size.Y) area := clip.Rect(rect).Push(gtx.Ops) pointer.InputOp{Tag: &m.Menu.tags[i], @@ -148,7 +150,7 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { return popup.Layout(gtx, contents) } -func (t *Tracker) PopupMenu(menu *Menu) MenuStyle { +func PopupMenu(menu *Menu, shaper *text.Shaper) MenuStyle { return MenuStyle{ Menu: menu, IconColor: white, @@ -157,6 +159,26 @@ func (t *Tracker) PopupMenu(menu *Menu) MenuStyle { FontSize: unit.Sp(16), IconSize: unit.Dp(16), HoverColor: menuHoverColor, - Shaper: t.TextShaper, + Shaper: shaper, + } +} + +func (tr *Tracker) layoutMenu(title string, clickable *widget.Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget { + for clickable.Clicked() { + menu.Visible = true + } + m := PopupMenu(menu, tr.Theme.Shaper) + return func(gtx C) D { + defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() + titleBtn := material.Button(tr.Theme, clickable, title) + titleBtn.Color = white + titleBtn.Background = transparent + titleBtn.CornerRadius = unit.Dp(0) + dims := titleBtn.Layout(gtx) + op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops) + gtx.Constraints.Max.X = gtx.Dp(width) + gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(1000)) + m.Layout(gtx, items...) + return dims } } diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go new file mode 100644 index 0000000..1c528f6 --- /dev/null +++ b/tracker/gioui/note_editor.go @@ -0,0 +1,338 @@ +package gioui + +import ( + "fmt" + "image" + "strconv" + "strings" + + "gioui.org/io/key" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "gioui.org/widget" + "github.com/vsariola/sointu/tracker" + "golang.org/x/exp/shiny/materialdesign/icons" +) + +const trackRowHeight = unit.Dp(16) +const trackColWidth = unit.Dp(54) +const trackColTitleHeight = unit.Dp(16) +const trackPatMarkWidth = unit.Dp(25) +const trackRowMarkWidth = unit.Dp(25) + +var noteStr [256]string +var hexStr [256]string + +func init() { + // initialize these strings once, so we don't have to do it every time we draw the note editor + hexStr[0] = "--" + hexStr[1] = ".." + noteStr[0] = "---" + noteStr[1] = "..." + for i := 2; i < 256; i++ { + hexStr[i] = fmt.Sprintf("%02x", i) + oNote := mod(i-baseNote, 12) + octave := (i - oNote - baseNote) / 12 + switch { + case octave < 0: + noteStr[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('Z'+1+octave))) + case octave >= 10: + noteStr[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('A'+octave-10))) + default: + noteStr[i] = fmt.Sprintf("%s%d", notes[oNote], octave) + } + } +} + +type NoteEditor struct { + TrackVoices *NumberInput + NewTrackBtn *ActionClickable + DeleteTrackBtn *ActionClickable + AddSemitoneBtn *ActionClickable + SubtractSemitoneBtn *ActionClickable + AddOctaveBtn *ActionClickable + SubtractOctaveBtn *ActionClickable + NoteOffBtn *ActionClickable + EffectBtn *BoolClickable + + scrollTable *ScrollTable + tag struct{} +} + +func NewNoteEditor(model *tracker.Model) *NoteEditor { + return &NoteEditor{ + TrackVoices: NewNumberInput(model.TrackVoices().Int()), + NewTrackBtn: NewActionClickable(model.AddTrack()), + DeleteTrackBtn: NewActionClickable(model.DeleteTrack()), + AddSemitoneBtn: NewActionClickable(model.AddSemitone()), + SubtractSemitoneBtn: NewActionClickable(model.SubtractSemitone()), + AddOctaveBtn: NewActionClickable(model.AddOctave()), + SubtractOctaveBtn: NewActionClickable(model.SubtractOctave()), + NoteOffBtn: NewActionClickable(model.EditNoteOff()), + EffectBtn: NewBoolClickable(model.Effect().Bool()), + scrollTable: NewScrollTable( + model.Notes().Table(), + model.Tracks().List(), + model.NoteRows().List(), + ), + } +} + +func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions { + for _, e := range gtx.Events(&te.tag) { + switch e := e.(type) { + case key.Event: + if e.State == key.Release { + if noteID, ok := t.KeyPlaying[e.Name]; ok { + noteID.NoteOff() + delete(t.KeyPlaying, e.Name) + } + continue + } + te.command(gtx, t, e) + } + } + + defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() + defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() + key.InputOp{Tag: &te.tag, Keys: "Ctrl-⌫|Ctrl-⌦|⏎|Ctrl-⏎|A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|R|S|T|U|V|W|X|Y|Z|0|1|2|3|4|5|6|7|8|9|,|."}.Add(gtx.Ops) + + return Surface{Gray: 24, Focus: te.scrollTable.Focused()}.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return te.layoutButtons(gtx, t) + }), + layout.Flexed(1, func(gtx C) D { + return te.layoutTracks(gtx, t) + }), + ) + }) +} + +func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { + return Surface{Gray: 37, Focus: te.scrollTable.Focused() || te.scrollTable.ChildFocused(), FitSize: true}.Layout(gtx, func(gtx C) D { + addSemitoneBtnStyle := ActionButton(t.Theme, te.AddSemitoneBtn, "+1") + subtractSemitoneBtnStyle := ActionButton(t.Theme, te.SubtractSemitoneBtn, "-1") + addOctaveBtnStyle := ActionButton(t.Theme, te.AddOctaveBtn, "+12") + subtractOctaveBtnStyle := ActionButton(t.Theme, te.SubtractOctaveBtn, "-12") + noteOffBtnStyle := ActionButton(t.Theme, te.NoteOffBtn, "Note Off") + deleteTrackBtnStyle := ActionIcon(t.Theme, te.DeleteTrackBtn, icons.ActionDelete, "Delete track\n(Ctrl+Shift+T)") + newTrackBtnStyle := ActionIcon(t.Theme, te.NewTrackBtn, icons.ContentAdd, "Add track\n(Ctrl+T)") + in := layout.UniformInset(unit.Dp(1)) + voiceUpDown := func(gtx C) D { + numStyle := NumericUpDown(t.Theme, te.TrackVoices, "Number of voices for this track") + return in.Layout(gtx, numStyle.Layout) + } + effectBtnStyle := ToggleButton(t.Theme, te.EffectBtn, "Hex") + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Dp(unit.Dp(12)), 0)} }), + layout.Rigid(addSemitoneBtnStyle.Layout), + layout.Rigid(subtractSemitoneBtnStyle.Layout), + layout.Rigid(addOctaveBtnStyle.Layout), + layout.Rigid(subtractOctaveBtnStyle.Layout), + layout.Rigid(noteOffBtnStyle.Layout), + layout.Rigid(effectBtnStyle.Layout), + layout.Rigid(Label(" Voices:", white, t.Theme.Shaper)), + layout.Rigid(voiceUpDown), + layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), + layout.Rigid(deleteTrackBtnStyle.Layout), + layout.Rigid(newTrackBtnStyle.Layout)) + }) +} + +const baseNote = 24 + +var notes = []string{ + "C-", + "C#", + "D-", + "D#", + "E-", + "F-", + "F#", + "G-", + "G#", + "A-", + "A#", + "B-", +} + +func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { + defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() + defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop() + + beatMarkerDensity := t.RowsPerBeat().Value() + switch beatMarkerDensity { + case 0, 1, 2: + beatMarkerDensity = 4 + } + + playSongRow := t.PlaySongRow() + pxWidth := gtx.Dp(trackColWidth) + pxHeight := gtx.Dp(trackRowHeight) + pxPatMarkWidth := gtx.Dp(trackPatMarkWidth) + pxRowMarkWidth := gtx.Dp(trackRowMarkWidth) + + colTitle := func(gtx C, i int) D { + h := gtx.Dp(unit.Dp(trackColTitleHeight)) + title := ((*tracker.Order)(t.Model)).Title(i) + gtx.Constraints = layout.Exact(image.Pt(pxWidth, h)) + LabelStyle{Alignment: layout.N, Text: title, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.Theme.Shaper}.Layout(gtx) + return D{Size: image.Pt(pxWidth, h)} + } + + rowTitleBg := func(gtx C, j int) D { + if mod(j, beatMarkerDensity*2) == 0 { + paint.FillShape(gtx.Ops, twoBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op()) + } else if mod(j, beatMarkerDensity) == 0 { + paint.FillShape(gtx.Ops, oneBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op()) + } + if t.SongPanel.PlayingBtn.Bool.Value() && j == playSongRow { + paint.FillShape(gtx.Ops, trackerPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op()) + } + return D{} + } + + rowTitle := func(gtx C, j int) D { + rpp := intMax(t.RowsPerPattern().Value(), 1) + pat := j / rpp + row := j % rpp + w := pxPatMarkWidth + pxRowMarkWidth + paint.ColorOp{Color: rowMarkerPatternTextColor}.Add(gtx.Ops) + defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop() + if row == 0 { + widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", pat)), op.CallOp{}) + } + defer op.Offset(image.Pt(pxPatMarkWidth, 0)).Push(gtx.Ops).Pop() + paint.ColorOp{Color: rowMarkerRowTextColor}.Add(gtx.Ops) + widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", row)), op.CallOp{}) + return D{Size: image.Pt(w, pxHeight)} + } + + drawSelection := te.scrollTable.Table.Cursor() != te.scrollTable.Table.Cursor2() + selection := te.scrollTable.Table.Range() + + cell := func(gtx C, x, y int) D { + // draw the background, to indicate selection + color := transparent + point := tracker.Point{X: x, Y: y} + if drawSelection && selection.Contains(point) { + color = inactiveSelectionColor + if te.scrollTable.Focused() { + color = selectionColor + } + } + paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op()) + // draw the cursor + if point == te.scrollTable.Table.Cursor() { + cw := gtx.Constraints.Min.X + cx := 0 + if t.Model.Notes().Effect(x) { + cw /= 2 + if t.Model.Notes().LowNibble() { + cx += cw + } + } + c := inactiveSelectionColor + if te.scrollTable.Focused() { + c = cursorColor + } + paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op()) + } + // draw the pattern marker + rpp := intMax(t.RowsPerPattern().Value(), 1) + pat := y / rpp + row := y % rpp + defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop() + s := t.Model.Order().Value(tracker.Point{X: x, Y: pat}) + if row == 0 { // draw the pattern marker + paint.ColorOp{Color: trackerPatMarker}.Add(gtx.Ops) + widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, patternIndexToString(s), op.CallOp{}) + } + if row == 1 && t.Model.Notes().Unique(x, s) { // draw a * if the pattern is unique + paint.ColorOp{Color: mediumEmphasisTextColor}.Add(gtx.Ops) + widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, "*", op.CallOp{}) + } + if te.scrollTable.Table.Cursor() == point && te.scrollTable.Focused() { + paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops) + } else { + paint.ColorOp{Color: trackerInactiveTextColor}.Add(gtx.Ops) + } + val := noteStr[byte(t.Model.Notes().Value(tracker.Point{X: x, Y: y}))] + if t.Model.Notes().Effect(x) { + val = hexStr[byte(t.Model.Notes().Value(tracker.Point{X: x, Y: y}))] + } + widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, val, op.CallOp{}) + return D{Size: image.Pt(pxWidth, pxHeight)} + } + table := FilledScrollTable(t.Theme, te.scrollTable, cell, colTitle, rowTitle, nil, rowTitleBg) + table.RowTitleWidth = trackPatMarkWidth + trackRowMarkWidth + table.ColumnTitleHeight = trackColTitleHeight + table.CellWidth = trackColWidth + table.CellHeight = trackRowHeight + return table.Layout(gtx) +} + +func mod(x, d int) int { + x = x % d + if x >= 0 { + return x + } + if d < 0 { + return x - d + } + return x + d +} + +func noteAsValue(octave, note int) byte { + return byte(baseNote + (octave * 12) + note) +} + +func (te *NoteEditor) command(gtx C, t *Tracker, e key.Event) { + if e.Name == "A" || e.Name == "1" { + t.Model.Notes().Table().Fill(0) + te.scrollTable.EnsureCursorVisible() + return + } + var n byte + if t.Model.Notes().Effect(te.scrollTable.Table.Cursor().X) { + if nibbleValue, err := strconv.ParseInt(e.Name, 16, 8); err == nil { + n = t.Model.Notes().Value(te.scrollTable.Table.Cursor()) + t.Model.Notes().FillNibble(byte(nibbleValue), t.Model.Notes().LowNibble()) + goto validNote + } + } else { + if val, ok := noteMap[e.Name]; ok { + n = noteAsValue(t.OctaveNumberInput.Int.Value(), val) + t.Model.Notes().Table().Fill(int(n)) + goto validNote + } + } + return +validNote: + te.scrollTable.EnsureCursorVisible() + if _, ok := t.KeyPlaying[e.Name]; !ok { + trk := te.scrollTable.Table.Cursor().X + t.KeyPlaying[e.Name] = t.TrackNoteOn(trk, n) + } +} + +/* + +case "+": + if e.Modifiers.Contain(key.ModShortcut) { + te.AddOctaveBtn.Action.Do() + } else { + te.AddSemitoneBtn.Action.Do() + } +case "-": + if e.Modifiers.Contain(key.ModShortcut) { + te.SubtractSemitoneBtn.Action.Do() + } else { + te.SubtractOctaveBtn.Action.Do() + } +}*/ diff --git a/tracker/gioui/numericupdown.go b/tracker/gioui/numericupdown.go index 640c614..33d9975 100644 --- a/tracker/gioui/numericupdown.go +++ b/tracker/gioui/numericupdown.go @@ -5,6 +5,7 @@ import ( "image" "image/color" + "github.com/vsariola/sointu/tracker" "golang.org/x/exp/shiny/materialdesign/icons" "gioui.org/font" @@ -23,7 +24,7 @@ import ( ) type NumberInput struct { - Value int + Int tracker.Int dragStartValue int dragStartXY float32 clickDecrease gesture.Click @@ -33,8 +34,6 @@ type NumberInput struct { type NumericUpDownStyle struct { NumberInput *NumberInput - Min int - Max int Color color.NRGBA Font font.Font TextSize unit.Sp @@ -51,15 +50,17 @@ type NumericUpDownStyle struct { shaper text.Shaper } -func NumericUpDown(th *material.Theme, number *NumberInput, min, max int, tooltip string) NumericUpDownStyle { +func NewNumberInput(v tracker.Int) *NumberInput { + return &NumberInput{Int: v} +} + +func NumericUpDown(th *material.Theme, number *NumberInput, tooltip string) NumericUpDownStyle { bgColor := th.Palette.Fg bgColor.R /= 4 bgColor.G /= 4 bgColor.B /= 4 return NumericUpDownStyle{ NumberInput: number, - Min: min, - Max: max, Color: white, BorderColor: th.Palette.Fg, IconColor: th.Palette.ContrastFg, @@ -104,12 +105,6 @@ func (s *NumericUpDownStyle) actualLayout(gtx C) D { layout.Flexed(1, s.layoutText), layout.Rigid(s.button(gtx.Constraints.Max.Y, widgetForIcon(icons.NavigationArrowForward), 1, &s.NumberInput.clickIncrease)), ) - if s.NumberInput.Value < s.Min { - s.NumberInput.Value = s.Min - } - if s.NumberInput.Value > s.Max { - s.NumberInput.Value = s.Max - } off.Pop() c2.Pop() return layout.Dimensions{Size: size} @@ -156,7 +151,7 @@ func (s *NumericUpDownStyle) layoutText(gtx C) D { }), layout.Expanded(func(gtx layout.Context) layout.Dimensions { paint.ColorOp{Color: s.Color}.Add(gtx.Ops) - return widget.Label{Alignment: text.Middle}.Layout(gtx, &s.shaper, s.Font, s.TextSize, fmt.Sprintf("%v", s.NumberInput.Value), op.CallOp{}) + return widget.Label{Alignment: text.Middle}.Layout(gtx, &s.shaper, s.Font, s.TextSize, fmt.Sprintf("%v", s.NumberInput.Int.Value()), op.CallOp{}) }), layout.Expanded(s.layoutDrag), ) @@ -169,13 +164,13 @@ func (s *NumericUpDownStyle) layoutDrag(gtx layout.Context) layout.Dimensions { if e, ok := ev.(pointer.Event); ok { switch e.Type { case pointer.Press: - s.NumberInput.dragStartValue = s.NumberInput.Value + s.NumberInput.dragStartValue = s.NumberInput.Int.Value() s.NumberInput.dragStartXY = e.Position.X - e.Position.Y case pointer.Drag: var deltaCoord float32 deltaCoord = e.Position.X - e.Position.Y - s.NumberInput.dragStartXY - s.NumberInput.Value = s.NumberInput.dragStartValue + int(deltaCoord/pxPerStep+0.5) + s.NumberInput.Int.Set(s.NumberInput.dragStartValue + int(deltaCoord/pxPerStep+0.5)) } } } @@ -200,7 +195,7 @@ func (s *NumericUpDownStyle) layoutClick(gtx layout.Context, delta int, click *g for _, e := range click.Events(gtx) { switch e.Type { case gesture.TypeClick: - s.NumberInput.Value += delta + s.NumberInput.Int.Add(delta) } } // Avoid affecting the input tree with pointer events. diff --git a/tracker/gioui/order_editor.go b/tracker/gioui/order_editor.go new file mode 100644 index 0000000..51a1e09 --- /dev/null +++ b/tracker/gioui/order_editor.go @@ -0,0 +1,156 @@ +package gioui + +import ( + "fmt" + "image" + "math" + "strconv" + "strings" + + "gioui.org/f32" + "gioui.org/io/key" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "gioui.org/widget" + "github.com/vsariola/sointu/tracker" +) + +const patternCellHeight = 16 +const patternCellWidth = 16 +const patternRowMarkerWidth = 30 +const orderTitleHeight = unit.Dp(52) + +type OrderEditor struct { + scrollTable *ScrollTable + tag struct{} +} + +var patternIndexStrings [36]string + +func init() { + for i := 0; i < 10; i++ { + patternIndexStrings[i] = string('0' + byte(i)) + } + for i := 10; i < 36; i++ { + patternIndexStrings[i] = string('A' + byte(i-10)) + } +} + +func NewOrderEditor(m *tracker.Model) *OrderEditor { + return &OrderEditor{ + scrollTable: NewScrollTable( + m.Order().Table(), + m.Tracks().List(), + m.OrderRows().List(), + ), + } +} + +func (oe *OrderEditor) Layout(gtx C, t *Tracker) D { + if oe.scrollTable.CursorMoved() { + cursor := t.TrackEditor.scrollTable.Table.Cursor() + t.TrackEditor.scrollTable.ColTitleList.CenterOn(cursor.X) + t.TrackEditor.scrollTable.RowTitleList.CenterOn(cursor.Y) + } + + for _, e := range gtx.Events(&oe.tag) { + switch e := e.(type) { + case key.Event: + if e.State != key.Press { + continue + } + oe.command(gtx, t, e) + } + } + defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() + defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() + key.InputOp{Tag: &oe.tag, Keys: "Ctrl-⌫|Ctrl-⌦|⏎|Ctrl-⏎|0|1|2|3|4|5|6|7|8|9|A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|R|S|T|U|V|W|X|Y|Z"}.Add(gtx.Ops) + + colTitle := func(gtx C, i int) D { + h := gtx.Dp(orderTitleHeight) + defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop() + defer op.Affine(f32.Affine2D{}.Rotate(f32.Pt(0, 0), -90*math.Pi/180).Offset(f32.Point{X: 0, Y: float32(h)})).Push(gtx.Ops).Pop() + gtx.Constraints = layout.Exact(image.Pt(1e6, 1e6)) + title := t.Model.Order().Title(i) + LabelStyle{Alignment: layout.NW, Text: title, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.Theme.Shaper}.Layout(gtx) + return D{Size: image.Pt(patternCellWidth, h)} + } + + rowTitle := func(gtx C, j int) D { + if playPos := t.PlayPosition(); t.SongPanel.PlayingBtn.Bool.Value() && j == playPos.OrderRow { + paint.FillShape(gtx.Ops, patternPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, patternCellHeight)}.Op()) + } + w := gtx.Dp(unit.Dp(30)) + paint.ColorOp{Color: rowMarkerPatternTextColor}.Add(gtx.Ops) + defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop() + widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", j)), op.CallOp{}) + return D{Size: image.Pt(w, patternCellHeight)} + } + + selection := oe.scrollTable.Table.Range() + + cell := func(gtx C, x, y int) D { + val := patternIndexToString(t.Model.Order().Value(tracker.Point{X: x, Y: y})) + color := patternCellColor + point := tracker.Point{X: x, Y: y} + if selection.Contains(point) { + color = inactiveSelectionColor + if oe.scrollTable.Focused() { + color = selectionColor + if point == oe.scrollTable.Table.Cursor() { + color = cursorColor + } + } + } + paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(1, 1), Max: image.Pt(gtx.Constraints.Min.X-1, gtx.Constraints.Min.X-1)}.Op()) + paint.ColorOp{Color: patternTextColor}.Add(gtx.Ops) + defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop() + widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, val, op.CallOp{}) + return D{Size: image.Pt(patternCellWidth, patternCellHeight)} + } + + table := FilledScrollTable(t.Theme, oe.scrollTable, cell, colTitle, rowTitle, nil, nil) + table.ColumnTitleHeight = orderTitleHeight + + return table.Layout(gtx) +} + +func (oe *OrderEditor) command(gtx C, t *Tracker, e key.Event) { + switch e.Name { + case key.NameDeleteBackward: + if e.Modifiers.Contain(key.ModShortcut) { + t.Model.DeleteOrderRow(true).Do() + } + case key.NameDeleteForward: + if e.Modifiers.Contain(key.ModShortcut) { + t.Model.DeleteOrderRow(false).Do() + } + case key.NameReturn: + if e.Modifiers.Contain(key.ModShortcut) { + oe.scrollTable.Table.MoveCursor(0, -1) + oe.scrollTable.Table.SetCursor2(oe.scrollTable.Table.Cursor()) + } + t.Model.AddOrderRow(!e.Modifiers.Contain(key.ModShortcut)).Do() + } + if iv, err := strconv.Atoi(e.Name); err == nil { + t.Model.Order().SetValue(oe.scrollTable.Table.Cursor(), iv) + oe.scrollTable.EnsureCursorVisible() + } + if b := int(e.Name[0]) - 'A'; len(e.Name) == 1 && b >= 0 && b < 26 { + t.Model.Order().SetValue(oe.scrollTable.Table.Cursor(), b+10) + oe.scrollTable.EnsureCursorVisible() + } +} + +func patternIndexToString(index int) string { + if index < 0 { + return "" + } else if index < len(patternIndexStrings) { + return patternIndexStrings[index] + } + return "?" +} diff --git a/tracker/gioui/ordereditor.go b/tracker/gioui/ordereditor.go deleted file mode 100644 index 23a80dd..0000000 --- a/tracker/gioui/ordereditor.go +++ /dev/null @@ -1,262 +0,0 @@ -package gioui - -import ( - "fmt" - "image" - "strconv" - "strings" - - "gioui.org/io/key" - "gioui.org/io/pointer" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/op/paint" - "gioui.org/text" - "gioui.org/unit" - "gioui.org/widget" - "github.com/vsariola/sointu/tracker" -) - -const patternCellHeight = 16 -const patternCellWidth = 16 -const patternRowMarkerWidth = 30 - -type OrderEditor struct { - list *layout.List - titleList *DragList - scrollBar *ScrollBar - tag bool - focused bool - requestFocus bool -} - -func NewOrderEditor() *OrderEditor { - return &OrderEditor{ - list: &layout.List{Axis: layout.Vertical}, - titleList: &DragList{List: &layout.List{Axis: layout.Horizontal}}, - scrollBar: &ScrollBar{Axis: layout.Vertical}, - } -} - -func (oe *OrderEditor) Focus() { - oe.requestFocus = true -} - -func (oe *OrderEditor) Focused() bool { - return oe.focused -} - -func (oe *OrderEditor) Layout(gtx C, t *Tracker) D { - return Surface{Gray: 24, Focus: oe.focused}.Layout(gtx, func(gtx C) D { - return oe.doLayout(gtx, t) - }) -} - -func (oe *OrderEditor) doLayout(gtx C, t *Tracker) D { - for _, e := range gtx.Events(&oe.tag) { - switch e := e.(type) { - case key.FocusEvent: - oe.focused = e.Focus - case pointer.Event: - if e.Type == pointer.Press { - key.FocusOp{Tag: &oe.tag}.Add(gtx.Ops) - } - case key.Event: - if e.State != key.Press { - continue - } - switch e.Name { - case key.NameDeleteForward, key.NameDeleteBackward: - if e.Modifiers.Contain(key.ModShortcut) { - t.DeleteOrderRow(e.Name == key.NameDeleteForward) - } else { - t.DeletePatternSelection() - if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { - t.SetCursor(t.Cursor().AddPatterns(1)) - t.SetSelectionCorner(t.Cursor()) - } - } - case "Space": - if !t.Playing() { - t.SetNoteTracking(!e.Modifiers.Contain(key.ModShortcut)) - startRow := t.Cursor().ScoreRow - startRow.Row = 0 - t.PlayFromPosition(startRow) - } else { - t.SetPlaying(false) - } - case key.NameReturn: - t.AddOrderRow(!e.Modifiers.Contain(key.ModShortcut)) - case key.NameUpArrow: - cursor := t.Cursor() - if e.Modifiers.Contain(key.ModShortcut) { - cursor.ScoreRow = tracker.ScoreRow{} - } else { - cursor.Row -= t.Song().Score.RowsPerPattern - } - t.SetNoteTracking(false) - t.SetCursor(cursor) - case key.NameDownArrow: - cursor := t.Cursor() - if e.Modifiers.Contain(key.ModShortcut) { - cursor.Row = t.Song().Score.LengthInRows() - 1 - } else { - cursor.Row += t.Song().Score.RowsPerPattern - } - t.SetNoteTracking(false) - t.SetCursor(cursor) - case key.NameLeftArrow: - cursor := t.Cursor() - if e.Modifiers.Contain(key.ModShortcut) { - cursor.Track = 0 - } else { - cursor.Track-- - } - t.SetCursor(cursor) - case key.NameRightArrow: - cursor := t.Cursor() - if e.Modifiers.Contain(key.ModShortcut) { - cursor.Track = len(t.Song().Score.Tracks) - 1 - } else { - cursor.Track++ - } - t.SetCursor(cursor) - case "+": - t.AdjustPatternNumber(1, e.Modifiers.Contain(key.ModShortcut)) - continue - case "-": - t.AdjustPatternNumber(-1, e.Modifiers.Contain(key.ModShortcut)) - continue - case key.NameHome: - cursor := t.Cursor() - cursor.Track = 0 - t.SetCursor(cursor) - case key.NameEnd: - cursor := t.Cursor() - cursor.Track = len(t.Song().Score.Tracks) - 1 - t.SetCursor(cursor) - } - if (e.Name != key.NameLeftArrow && - e.Name != key.NameRightArrow && - e.Name != key.NameUpArrow && - e.Name != key.NameDownArrow) || - !e.Modifiers.Contain(key.ModShift) { - t.SetSelectionCorner(t.Cursor()) - } - if e.Modifiers.Contain(key.ModShortcut) { - continue - } - if iv, err := strconv.Atoi(e.Name); err == nil { - t.SetCurrentPattern(iv) - if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { - t.SetCursor(t.Cursor().AddPatterns(1)) - t.SetSelectionCorner(t.Cursor()) - } - } - if b := int(e.Name[0]) - 'A'; len(e.Name) == 1 && b >= 0 && b < 26 { - t.SetCurrentPattern(b + 10) - if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { - t.SetCursor(t.Cursor().AddPatterns(1)) - t.SetSelectionCorner(t.Cursor()) - } - } - } - } - defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - if oe.requestFocus { - oe.requestFocus = false - key.FocusOp{Tag: &oe.tag}.Add(gtx.Ops) - } - defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() - pointer.InputOp{Tag: &oe.tag, - Types: pointer.Press, - }.Add(gtx.Ops) - - key.InputOp{Tag: &oe.tag, Keys: "←|→|↑|↓|Shift-←|Shift-→|Shift-↑|Shift-↓|⏎|⇱|⇲|⌫|⌦|Ctrl-⌫|Ctrl-⌦|+|-|Space|0|1|2|3|4|5|6|7|8|9|A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|R|S|T|U|V|W|X|Y|Z"}.Add(gtx.Ops) - - patternRect := tracker.ScoreRect{ - Corner1: tracker.ScorePoint{ScoreRow: tracker.ScoreRow{Pattern: t.Cursor().Pattern}, Track: t.Cursor().Track}, - Corner2: tracker.ScorePoint{ScoreRow: tracker.ScoreRow{Pattern: t.SelectionCorner().Pattern}, Track: t.SelectionCorner().Track}, - } - - // draw the single letter titles for tracks - { - gtx := gtx - stack := op.Offset(image.Pt(patternRowMarkerWidth, 0)).Push(gtx.Ops) - gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X-patternRowMarkerWidth, patternCellHeight)) - elem := func(gtx C, i int) D { - gtx.Constraints = layout.Exact(image.Pt(patternCellWidth, patternCellHeight)) - instr, err := t.Song().Patch.InstrumentForVoice(t.Song().Score.FirstVoiceForTrack(i)) - var title string - if err == nil && len(t.Song().Patch[instr].Name) > 0 { - title = string(t.Song().Patch[instr].Name[0]) - } else { - title = "?" - } - LabelStyle{Alignment: layout.N, Text: title, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.TextShaper}.Layout(gtx) - return D{Size: gtx.Constraints.Min} - } - style := FilledDragList(t.Theme, oe.titleList, len(t.Song().Score.Tracks), elem, t.SwapTracks) - style.HoverColor = transparent - style.SelectedColor = transparent - style.Layout(gtx) - stack.Pop() - } - op.Offset(image.Pt(0, patternCellHeight)).Add(gtx.Ops) - gtx.Constraints.Max.Y -= patternCellHeight - gtx.Constraints.Min.Y -= patternCellHeight - element := func(gtx C, j int) D { - if playPos := t.PlayPosition(); t.Playing() && j == playPos.Pattern { - paint.FillShape(gtx.Ops, patternPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, patternCellHeight)}.Op()) - } - paint.ColorOp{Color: rowMarkerPatternTextColor}.Add(gtx.Ops) - widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", j)), op.CallOp{}) - stack := op.Offset(image.Pt(patternRowMarkerWidth, 0)).Push(gtx.Ops) - for i, track := range t.Song().Score.Tracks { - paint.FillShape(gtx.Ops, patternCellColor, clip.Rect{Min: image.Pt(1, 1), Max: image.Pt(patternCellWidth-1, patternCellHeight-1)}.Op()) - paint.ColorOp{Color: patternTextColor}.Add(gtx.Ops) - if j >= 0 && j < len(track.Order) && track.Order[j] >= 0 { - gtx := gtx - gtx.Constraints.Max.X = patternCellWidth - op.Offset(image.Pt(0, -2)).Add(gtx.Ops) - widget.Label{Alignment: text.Middle}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, patternIndexToString(track.Order[j]), op.CallOp{}) - op.Offset(image.Pt(0, 2)).Add(gtx.Ops) - } - point := tracker.ScorePoint{Track: i, ScoreRow: tracker.ScoreRow{Pattern: j}} - if oe.focused || t.TrackEditor.Focused() { - if patternRect.Contains(point) { - color := inactiveSelectionColor - if oe.focused { - color = selectionColor - if point.Pattern == t.Cursor().Pattern && point.Track == t.Cursor().Track { - color = cursorColor - } - } - paint.FillShape(gtx.Ops, color, clip.Rect{Max: image.Pt(patternCellWidth, patternCellHeight)}.Op()) - } - } - op.Offset(image.Pt(patternCellWidth, 0)).Add(gtx.Ops) - } - stack.Pop() - return D{Size: image.Pt(gtx.Constraints.Max.X, patternCellHeight)} - } - - return layout.Stack{Alignment: layout.NE}.Layout(gtx, - layout.Expanded(func(gtx C) D { - return oe.list.Layout(gtx, t.Song().Score.Length, element) - }), - layout.Expanded(func(gtx C) D { - return oe.scrollBar.Layout(gtx, unit.Dp(10), t.Song().Score.Length, &oe.list.Position) - }), - ) -} - -func patternIndexToString(index int) string { - if index < 0 { - return "" - } else if index < 10 { - return string('0' + byte(index)) - } - return string('A' + byte(index-10)) -} diff --git a/tracker/gioui/parameditor.go b/tracker/gioui/parameditor.go deleted file mode 100644 index 69d0d16..0000000 --- a/tracker/gioui/parameditor.go +++ /dev/null @@ -1,256 +0,0 @@ -package gioui - -import ( - "image" - "image/color" - "strings" - "time" - - "gioui.org/io/clipboard" - "gioui.org/io/key" - "gioui.org/io/pointer" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/op/paint" - "gioui.org/unit" - "gioui.org/widget" - "github.com/vsariola/sointu" - "golang.org/x/exp/shiny/materialdesign/icons" - "gopkg.in/yaml.v3" -) - -type ParamEditor struct { - list *layout.List - scrollBar *ScrollBar - Parameters []*ParameterWidget - DeleteUnitBtn *TipClickable - CopyUnitBtn *TipClickable - ClearUnitBtn *TipClickable - ChooseUnitTypeBtns []*widget.Clickable - tag bool - focused bool - requestFocus bool -} - -func (pe *ParamEditor) Focus() { - pe.requestFocus = true -} - -func (pe *ParamEditor) Focused() bool { - return pe.focused -} - -func NewParamEditor() *ParamEditor { - ret := &ParamEditor{ - DeleteUnitBtn: new(TipClickable), - ClearUnitBtn: new(TipClickable), - CopyUnitBtn: new(TipClickable), - list: &layout.List{Axis: layout.Vertical}, - scrollBar: &ScrollBar{Axis: layout.Vertical}, - } - for range sointu.UnitNames { - ret.ChooseUnitTypeBtns = append(ret.ChooseUnitTypeBtns, new(widget.Clickable)) - } - return ret -} - -func (pe *ParamEditor) Bind(t *Tracker) layout.Widget { - return func(gtx C) D { - for _, e := range gtx.Events(&pe.tag) { - switch e := e.(type) { - case key.FocusEvent: - pe.focused = e.Focus - case pointer.Event: - if e.Type == pointer.Press { - key.FocusOp{Tag: &pe.tag}.Add(gtx.Ops) - } - case key.Event: - if e.Modifiers.Contain(key.ModShortcut) { - continue - } - switch e.State { - case key.Press: - switch e.Name { - case key.NameUpArrow: - t.SetParamIndex(t.ParamIndex() - 1) - case key.NameDownArrow: - t.SetParamIndex(t.ParamIndex() + 1) - case key.NameLeftArrow: - p, err := t.Param(t.ParamIndex()) - if err != nil { - break - } - if e.Modifiers.Contain(key.ModShift) { - t.SetParam(p.Value - p.LargeStep) - } else { - t.SetParam(p.Value - 1) - } - case key.NameRightArrow: - p, err := t.Param(t.ParamIndex()) - if err != nil { - break - } - if e.Modifiers.Contain(key.ModShift) { - t.SetParam(p.Value + p.LargeStep) - } else { - t.SetParam(p.Value + 1) - } - case key.NameEscape: - t.InstrumentEditor.unitDragList.Focus() - } - } - } - } - if pe.requestFocus { - pe.requestFocus = false - key.FocusOp{Tag: &pe.tag}.Add(gtx.Ops) - } - editorFunc := pe.layoutUnitSliders - if y := t.Unit().Type; y == "" || y != t.InstrumentEditor.unitTypeEditor.Text() { - editorFunc = pe.layoutUnitTypeChooser - } - return Surface{Gray: 24, Focus: t.InstrumentEditor.wasFocused}.Layout(gtx, func(gtx C) D { - ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Flexed(1, func(gtx C) D { - return editorFunc(gtx, t) - }), - layout.Rigid(pe.layoutUnitFooter(t))) - rect := image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y) - area := clip.Rect(rect).Push(gtx.Ops) - defer pointer.PassOp{}.Push(gtx.Ops).Pop() - pointer.InputOp{Tag: &pe.tag, - Types: pointer.Press, - }.Add(gtx.Ops) - key.InputOp{Tag: &pe.tag, Keys: "←|Shift-←|→|Shift-→|↑|↓|⎋"}.Add(gtx.Ops) - area.Pop() - return ret - }) - } -} - -func (pe *ParamEditor) layoutUnitSliders(gtx C, t *Tracker) D { - numItems := t.NumParams() - - for len(pe.Parameters) <= numItems { - pe.Parameters = append(pe.Parameters, new(ParameterWidget)) - } - - listItem := func(gtx C, index int) D { - for pe.Parameters[index].Clicked() { - if t.ParamIndex() != index { - t.SetParamIndex(index) - } else { - t.ResetParam() - } - pe.Focus() - } - param, err := t.Param(index) - if err != nil { - return D{} - } - oldVal := param.Value - paramStyle := t.ParamStyle(t.Theme, ¶m, pe.Parameters[index]) - paramStyle.Focus = pe.focused && t.ParamIndex() == index - dims := paramStyle.Layout(gtx) - if oldVal != param.Value { - pe.Focus() - t.SetParamIndex(index) - t.SetParam(param.Value) - } - return dims - } - - return layout.Stack{}.Layout(gtx, - layout.Stacked(func(gtx C) D { - return pe.list.Layout(gtx, numItems, listItem) - }), - layout.Stacked(func(gtx C) D { - gtx.Constraints.Min = gtx.Constraints.Max - return pe.scrollBar.Layout(gtx, unit.Dp(10), numItems, &pe.list.Position) - })) -} - -func (pe *ParamEditor) layoutUnitFooter(t *Tracker) layout.Widget { - return func(gtx C) D { - for pe.ClearUnitBtn.Clickable.Clicked() { - t.SetUnitType("") - op.InvalidateOp{}.Add(gtx.Ops) - t.InstrumentEditor.unitDragList.Focus() - } - for pe.DeleteUnitBtn.Clickable.Clicked() { - t.DeleteUnits(false, t.UnitIndex(), t.UnitIndex()) - op.InvalidateOp{}.Add(gtx.Ops) - t.InstrumentEditor.unitDragList.Focus() - } - for pe.CopyUnitBtn.Clickable.Clicked() { - op.InvalidateOp{}.Add(gtx.Ops) - contents, err := yaml.Marshal([]sointu.Unit{t.Unit()}) - if err == nil { - clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops) - t.Alert.Update("Unit copied to clipboard", Notify, time.Second*3) - } - } - copyUnitBtnStyle := IconButton(t.Theme, pe.CopyUnitBtn, icons.ContentContentCopy, true, "Copy unit (Ctrl+C)") - deleteUnitBtnStyle := IconButton(t.Theme, pe.DeleteUnitBtn, icons.ActionDelete, t.CanDeleteUnit(), "Delete unit (Del)") - text := t.Unit().Type - if text == "" { - text = "Choose unit type" - } else { - text = strings.Title(text) - } - hintText := Label(text, white, t.TextShaper) - return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(deleteUnitBtnStyle.Layout), - layout.Rigid(copyUnitBtnStyle.Layout), - layout.Rigid(func(gtx C) D { - var dims D - if t.Unit().Type != "" { - clearUnitBtnStyle := IconButton(t.Theme, pe.ClearUnitBtn, icons.ContentClear, true, "Clear unit") - dims = clearUnitBtnStyle.Layout(gtx) - } - return D{Size: image.Pt(gtx.Dp(unit.Dp(48)), dims.Size.Y)} - }), - layout.Flexed(1, hintText), - ) - } -} - -func (pe *ParamEditor) layoutUnitTypeChooser(gtx C, t *Tracker) D { - listElem := func(gtx C, i int) D { - for pe.ChooseUnitTypeBtns[i].Clicked() { - t.SetUnitType(sointu.UnitNames[i]) - t.InstrumentEditor.unitTypeEditor.SetText(sointu.UnitNames[i]) - } - text := sointu.UnitNames[i] - if t.InstrumentEditor.unitTypeEditor.Focused() && !strings.HasPrefix(text, t.InstrumentEditor.unitTypeEditor.Text()) { - return D{} - } - labelStyle := LabelStyle{Text: text, ShadeColor: black, Color: white, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.TextShaper} - bg := func(gtx C) D { - gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, 20)) - var color color.NRGBA - if pe.ChooseUnitTypeBtns[i].Hovered() { - color = unitTypeListHighlightColor - } - paint.FillShape(gtx.Ops, color, clip.Rect{Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op()) - return D{Size: gtx.Constraints.Min} - } - leftMargin := layout.Inset{Left: unit.Dp(10)} - return layout.Stack{Alignment: layout.W}.Layout(gtx, - layout.Stacked(bg), - layout.Expanded(func(gtx C) D { - return pe.ChooseUnitTypeBtns[i].Layout(gtx, func(gtx C) D { - return leftMargin.Layout(gtx, labelStyle.Layout) - }) - })) - } - return layout.Stack{}.Layout(gtx, - layout.Stacked(func(gtx C) D { - return pe.list.Layout(gtx, len(sointu.UnitNames), listElem) - }), - layout.Expanded(func(gtx C) D { - return pe.scrollBar.Layout(gtx, unit.Dp(10), len(sointu.UnitNames), &pe.list.Position) - }), - ) -} diff --git a/tracker/gioui/parameter.go b/tracker/gioui/parameter.go deleted file mode 100644 index f68beec..0000000 --- a/tracker/gioui/parameter.go +++ /dev/null @@ -1,218 +0,0 @@ -package gioui - -import ( - "fmt" - "image" - "math" - - "gioui.org/io/pointer" - "gioui.org/layout" - "gioui.org/op/clip" - "gioui.org/op/paint" - "gioui.org/unit" - "gioui.org/widget" - "gioui.org/widget/material" - "github.com/vsariola/sointu/tracker" - "golang.org/x/exp/shiny/materialdesign/icons" -) - -type ParameterWidget struct { - floatWidget widget.Float - boolWidget widget.Bool - labelBtn widget.Clickable - instrBtn widget.Clickable - instrMenu Menu - unitBtn widget.Clickable - unitMenu Menu -} - -type ParameterStyle struct { - tracker *Tracker - Parameter *tracker.Parameter - ParameterWidget *ParameterWidget - Theme *material.Theme - Focus bool -} - -func (t *Tracker) ParamStyle(th *material.Theme, param *tracker.Parameter, paramWidget *ParameterWidget) ParameterStyle { - return ParameterStyle{ - tracker: t, // TODO: we need this to pull the instrument names for ID style parameters, find out another way - Parameter: param, - Theme: th, - ParameterWidget: paramWidget, - } -} - -func (p *ParameterWidget) Clicked() bool { - return p.labelBtn.Clicked() -} - -func (p ParameterStyle) Layout(gtx C) D { - return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return p.ParameterWidget.labelBtn.Layout(gtx, func(gtx C) D { - gtx.Constraints.Min.X = gtx.Dp(unit.Dp(110)) - return layout.E.Layout(gtx, Label(p.Parameter.Name, white, p.tracker.TextShaper)) - }) - }), - layout.Rigid(func(gtx C) D { - switch p.Parameter.Type { - case tracker.IntegerParameter: - for _, e := range gtx.Events(&p.ParameterWidget.floatWidget) { - switch ev := e.(type) { - case pointer.Event: - if ev.Type == pointer.Scroll { - delta := math.Min(math.Max(float64(ev.Scroll.Y), -1), 1) - p.Parameter.Value += int(math.Round(delta)) - } - } - } - gtx.Constraints.Min.X = gtx.Dp(unit.Dp(200)) - gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40)) - if p.Focus { - paint.FillShape(gtx.Ops, cursorColor, clip.Rect{ - Max: gtx.Constraints.Min, - }.Op()) - } - if !p.ParameterWidget.floatWidget.Dragging() { - p.ParameterWidget.floatWidget.Value = float32(p.Parameter.Value) - } - sliderStyle := material.Slider(p.Theme, &p.ParameterWidget.floatWidget, float32(p.Parameter.Min), float32(p.Parameter.Max)) - sliderStyle.Color = p.Theme.Fg - r := image.Rectangle{Max: gtx.Constraints.Min} - area := clip.Rect(r).Push(gtx.Ops) - pointer.InputOp{Tag: &p.ParameterWidget.floatWidget, Types: pointer.Scroll, ScrollBounds: image.Rectangle{Min: image.Pt(0, -1e6), Max: image.Pt(0, 1e6)}}.Add(gtx.Ops) - dims := sliderStyle.Layout(gtx) - area.Pop() - p.Parameter.Value = int(p.ParameterWidget.floatWidget.Value + 0.5) - return dims - case tracker.BoolParameter: - gtx.Constraints.Min.X = gtx.Dp(unit.Dp(60)) - gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40)) - if p.Focus { - paint.FillShape(gtx.Ops, cursorColor, clip.Rect{ - Max: gtx.Constraints.Min, - }.Op()) - } - p.ParameterWidget.boolWidget.Value = p.Parameter.Value > p.Parameter.Min - boolStyle := material.Switch(p.Theme, &p.ParameterWidget.boolWidget, "Toggle boolean parameter") - boolStyle.Color.Disabled = p.Theme.Fg - boolStyle.Color.Enabled = white - dims := layout.Center.Layout(gtx, boolStyle.Layout) - if p.ParameterWidget.boolWidget.Value { - p.Parameter.Value = p.Parameter.Max - } else { - p.Parameter.Value = p.Parameter.Min - } - return dims - case tracker.IDParameter: - gtx.Constraints.Min.X = gtx.Dp(unit.Dp(200)) - gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40)) - if p.Focus { - paint.FillShape(gtx.Ops, cursorColor, clip.Rect{ - Max: gtx.Constraints.Min, - }.Op()) - } - for clickedItem, hasClicked := p.ParameterWidget.instrMenu.Clicked(); hasClicked; { - p.Parameter.Value = p.tracker.Song().Patch[clickedItem].Units[0].ID - clickedItem, hasClicked = p.ParameterWidget.instrMenu.Clicked() - } - instrItems := make([]MenuItem, len(p.tracker.Song().Patch)) - for i, instr := range p.tracker.Song().Patch { - instrItems[i].Text = instr.Name - instrItems[i].IconBytes = icons.NavigationChevronRight - } - var unitItems []MenuItem - instrName := "" - unitName := "" - targetI, targetU, err := p.tracker.Song().Patch.FindUnit(p.Parameter.Value) - if err == nil { - targetInstrument := p.tracker.Song().Patch[targetI] - instrName = targetInstrument.Name - units := targetInstrument.Units - unitName = fmt.Sprintf("%v: %v", targetU, units[targetU].Type) - unitItems = make([]MenuItem, len(units)) - for clickedItem, hasClicked := p.ParameterWidget.unitMenu.Clicked(); hasClicked; { - p.Parameter.Value = units[clickedItem].ID - clickedItem, hasClicked = p.ParameterWidget.unitMenu.Clicked() - } - for j, unit := range units { - unitItems[j].Text = fmt.Sprintf("%v: %v", j, unit.Type) - unitItems[j].IconBytes = icons.NavigationChevronRight - } - } - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(p.tracker.layoutMenu(instrName, &p.ParameterWidget.instrBtn, &p.ParameterWidget.instrMenu, unit.Dp(200), - instrItems..., - )), - layout.Rigid(p.tracker.layoutMenu(unitName, &p.ParameterWidget.unitBtn, &p.ParameterWidget.unitMenu, unit.Dp(200), - unitItems..., - )), - ) - } - return D{} - }), - layout.Rigid(func(gtx C) D { - if p.Parameter.Type != tracker.IDParameter { - return Label(p.Parameter.Hint, white, p.tracker.TextShaper)(gtx) - } - return D{} - }), - ) -} - -/* - -func (t *Tracker) layoutParameter(gtx C, index int) D { - u := t.Unit() - ut, _ := sointu.UnitTypes[u.Type] - - params := u.Parameters - var name string - var value, min, max int - var valueText string - if u.Type == "oscillator" && index == len(ut) { - name = "sample" - key := compiler.SampleOffset{Start: uint32(params["samplestart"]), LoopStart: uint16(params["loopstart"]), LoopLength: uint16(params["looplength"])} - if v, ok := tracker.GmDlsEntryMap[key]; ok { - value = v + 1 - valueText = fmt.Sprintf("%v / %v", value, tracker.GmDlsEntries[v].Name) - } else { - value = 0 - valueText = "0 / custom" - } - min, max = 0, len(tracker.GmDlsEntries) - } else { - if ut[index].MaxValue < ut[index].MinValue { - return layout.Dimensions{} - } - name = ut[index].Name - if u.Type == "oscillator" && (name == "samplestart" || name == "loopstart" || name == "looplength") { - if params["type"] != sointu.Sample { - return layout.Dimensions{} - } - } - value = params[name] - min, max = ut[index].MinValue, ut[index].MaxValue - if u.Type == "send" && name == "voice" { - max = t.Song().Patch.NumVoices() - } else if u.Type == "send" && name == "unit" { // set the maximum values depending on the send target - instrIndex, _, _ := t.Song().Patch.FindSendTarget(t.Unit().Parameters["target"]) - if instrIndex != -1 { - max = len(t.Song().Patch[instrIndex].Units) - 1 - } - } else if u.Type == "send" && name == "port" { // set the maximum values depending on the send target - instrIndex, unitIndex, _ := t.Song().Patch.FindSendTarget(t.Unit().Parameters["target"]) - if instrIndex != -1 && unitIndex != -1 { - max = len(sointu.Ports[t.Song().Patch[instrIndex].Units[unitIndex].Type]) - 1 - } - } - hint := t.Song().Patch.ParamHintString(t.InstrIndex(), t.UnitIndex(), name) - if hint != "" { - valueText = fmt.Sprintf("%v / %v", value, hint) - } else { - valueText = fmt.Sprintf("%v", value) - } - } - -}*/ diff --git a/tracker/gioui/popup_alert.go b/tracker/gioui/popup_alert.go new file mode 100644 index 0000000..0020c26 --- /dev/null +++ b/tracker/gioui/popup_alert.go @@ -0,0 +1,81 @@ +package gioui + +import ( + "image" + "image/color" + "time" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "github.com/vsariola/sointu/tracker" +) + +type PopupAlert struct { + alerts *tracker.Alerts + prevUpdate time.Time + shaper *text.Shaper +} + +var alertSpeed = 150 * time.Millisecond +var alertMargin = layout.UniformInset(unit.Dp(6)) +var alertInset = layout.UniformInset(unit.Dp(6)) + +func NewPopupAlert(alerts *tracker.Alerts, shaper *text.Shaper) *PopupAlert { + return &PopupAlert{alerts: alerts, shaper: shaper, prevUpdate: time.Now()} +} + +func (a *PopupAlert) Layout(gtx C) D { + now := time.Now() + if a.alerts.Update(now.Sub(a.prevUpdate)) { + op.InvalidateOp{At: now.Add(50 * time.Millisecond)}.Add(gtx.Ops) + } + a.prevUpdate = now + + var totalY float64 + a.alerts.Iterate(func(alert tracker.Alert) { + var color, textColor, shadeColor color.NRGBA + switch alert.Priority { + case tracker.Warning: + color = warningColor + textColor = black + case tracker.Error: + color = errorColor + textColor = black + default: + color = popupSurfaceColor + textColor = white + shadeColor = black + } + bgWidget := func(gtx C) D { + paint.FillShape(gtx.Ops, color, clip.Rect{ + Max: gtx.Constraints.Min, + }.Op()) + return D{Size: gtx.Constraints.Min} + } + labelStyle := LabelStyle{Text: alert.Message, Color: textColor, ShadeColor: shadeColor, Font: labelDefaultFont, Alignment: layout.Center, FontSize: unit.Sp(16), Shaper: a.shaper} + alertMargin.Layout(gtx, func(gtx C) D { + return layout.S.Layout(gtx, func(gtx C) D { + defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() + gtx.Constraints.Min.X = gtx.Constraints.Max.X + recording := op.Record(gtx.Ops) + dims := layout.Stack{Alignment: layout.Center}.Layout(gtx, + layout.Expanded(bgWidget), + layout.Stacked(func(gtx C) D { + return alertInset.Layout(gtx, labelStyle.Layout) + }), + ) + macro := recording.Stop() + delta := float64(dims.Size.Y + gtx.Dp(alertMargin.Bottom)) + op.Offset(image.Point{0, int(-totalY*alert.FadeLevel + delta*(1-alert.FadeLevel))}).Add((gtx.Ops)) + totalY += delta + macro.Add(gtx.Ops) + return dims + }) + }) + }) + return D{} +} diff --git a/tracker/gioui/rowmarkers.go b/tracker/gioui/rowmarkers.go deleted file mode 100644 index 1646c9a..0000000 --- a/tracker/gioui/rowmarkers.go +++ /dev/null @@ -1,70 +0,0 @@ -package gioui - -import ( - "fmt" - "image" - "strings" - - "gioui.org/layout" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/op/paint" - "gioui.org/widget" -) - -const rowMarkerWidth = 50 - -func (t *Tracker) layoutRowMarkers(gtx C) D { - gtx.Constraints.Min.X = rowMarkerWidth - paint.FillShape(gtx.Ops, rowMarkerSurfaceColor, clip.Rect{ - Max: gtx.Constraints.Max, - }.Op()) - //defer op.Save(gtx.Ops).Load() - defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop() - op.Offset(image.Pt(0, (gtx.Constraints.Max.Y-trackRowHeight)/2)).Add(gtx.Ops) - cursorSongRow := t.Cursor().Pattern*t.Song().Score.RowsPerPattern + t.Cursor().Row - playPos := t.PlayPosition() - playSongRow := playPos.Pattern*t.Song().Score.RowsPerPattern + playPos.Row - op.Offset(image.Pt(0, (-1*trackRowHeight)*(cursorSongRow))).Add(gtx.Ops) - beatMarkerDensity := t.Song().RowsPerBeat - for beatMarkerDensity <= 2 { - beatMarkerDensity *= 2 - } - for i := 0; i < t.Song().Score.Length; i++ { - for j := 0; j < t.Song().Score.RowsPerPattern; j++ { - songRow := i*t.Song().Score.RowsPerPattern + j - if mod(songRow, beatMarkerDensity*2) == 0 { - paint.FillShape(gtx.Ops, twoBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op()) - } else if mod(songRow, beatMarkerDensity) == 0 { - paint.FillShape(gtx.Ops, oneBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op()) - } - if t.Playing() && songRow == playSongRow { - paint.FillShape(gtx.Ops, trackerPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op()) - } - if j == 0 { - paint.ColorOp{Color: rowMarkerPatternTextColor}.Add(gtx.Ops) - widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", i)), op.CallOp{}) - } - if t.TrackEditor.Focused() && songRow == cursorSongRow { - paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops) - } else { - paint.ColorOp{Color: rowMarkerRowTextColor}.Add(gtx.Ops) - } - op.Offset(image.Pt(rowMarkerWidth/2, 0)).Add(gtx.Ops) - widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", j)), op.CallOp{}) - op.Offset(image.Pt(-rowMarkerWidth/2, trackRowHeight)).Add(gtx.Ops) - } - } - return layout.Dimensions{Size: image.Pt(rowMarkerWidth, gtx.Constraints.Max.Y)} -} - -func mod(a, b int) int { - m := a % b - if a < 0 && b < 0 { - m -= b - } - if a < 0 && b > 0 { - m += b - } - return m -} diff --git a/tracker/gioui/scroll_table.go b/tracker/gioui/scroll_table.go new file mode 100644 index 0000000..f1be643 --- /dev/null +++ b/tracker/gioui/scroll_table.go @@ -0,0 +1,258 @@ +package gioui + +import ( + "image" + + "gioui.org/io/clipboard" + "gioui.org/io/key" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/unit" + "gioui.org/widget/material" + "github.com/vsariola/sointu/tracker" +) + +type ScrollTable struct { + ColTitleList *DragList + RowTitleList *DragList + Table tracker.Table + focused bool + requestFocus bool + tag bool + colTag bool + rowTag bool + cursorMoved bool +} + +type ScrollTableStyle struct { + RowTitleStyle FilledDragListStyle + ColTitleStyle FilledDragListStyle + ScrollTable *ScrollTable + ScrollBarWidth unit.Dp + RowTitleWidth unit.Dp + ColumnTitleHeight unit.Dp + CellWidth unit.Dp + CellHeight unit.Dp + element func(gtx C, x, y int) D +} + +func NewScrollTable(table tracker.Table, vertList, horizList tracker.List) *ScrollTable { + return &ScrollTable{ + Table: table, + ColTitleList: NewDragList(vertList, layout.Horizontal), + RowTitleList: NewDragList(horizList, layout.Vertical), + } +} + +func FilledScrollTable(th *material.Theme, scrollTable *ScrollTable, element func(gtx C, x, y int) D, colTitle, rowTitle, colTitleBg, rowTitleBg func(gtx C, i int) D) ScrollTableStyle { + return ScrollTableStyle{ + RowTitleStyle: FilledDragList(th, scrollTable.RowTitleList, rowTitle, rowTitleBg), + ColTitleStyle: FilledDragList(th, scrollTable.ColTitleList, colTitle, colTitleBg), + ScrollTable: scrollTable, + element: element, + ScrollBarWidth: unit.Dp(14), + RowTitleWidth: unit.Dp(30), + ColumnTitleHeight: unit.Dp(16), + CellWidth: unit.Dp(16), + CellHeight: unit.Dp(16), + } +} + +func (st *ScrollTable) CursorMoved() bool { + ret := st.cursorMoved + st.cursorMoved = false + return ret +} + +func (st *ScrollTable) Focus() { + st.requestFocus = true +} + +func (st *ScrollTable) Focused() bool { + return st.focused +} + +func (st *ScrollTable) EnsureCursorVisible() { + st.ColTitleList.EnsureVisible(st.Table.Cursor().X) + st.RowTitleList.EnsureVisible(st.Table.Cursor().Y) +} + +func (st *ScrollTable) ChildFocused() bool { + return st.ColTitleList.Focused() || st.RowTitleList.Focused() +} + +func (s ScrollTableStyle) Layout(gtx C) D { + p := image.Pt(gtx.Dp(s.RowTitleWidth), gtx.Dp(s.ColumnTitleHeight)) + + for _, e := range gtx.Events(&s.ScrollTable.tag) { + switch e := e.(type) { + case key.FocusEvent: + s.ScrollTable.focused = e.Focus + case pointer.Event: + if e.Position.X >= float32(p.X) && e.Position.Y >= float32(p.Y) { + if e.Type == pointer.Press { + key.FocusOp{Tag: &s.ScrollTable.tag}.Add(gtx.Ops) + } + dx := (int(e.Position.X) + s.ScrollTable.ColTitleList.List.Position.Offset - p.X) / gtx.Dp(s.CellWidth) + dy := (int(e.Position.Y) + s.ScrollTable.RowTitleList.List.Position.Offset - p.Y) / gtx.Dp(s.CellHeight) + x := dx + s.ScrollTable.ColTitleList.List.Position.First + y := dy + s.ScrollTable.RowTitleList.List.Position.First + s.ScrollTable.Table.SetCursor( + tracker.Point{X: x, Y: y}, + ) + if !e.Modifiers.Contain(key.ModShift) { + s.ScrollTable.Table.SetCursor2(s.ScrollTable.Table.Cursor()) + } + s.ScrollTable.cursorMoved = true + } + case key.Event: + if e.State == key.Press { + s.ScrollTable.command(gtx, e) + } + case clipboard.Event: + s.ScrollTable.Table.Paste([]byte(e.Text)) + } + } + + for _, e := range gtx.Events(&s.ScrollTable.rowTag) { + if e, ok := e.(key.Event); ok && e.State == key.Press { + s.ScrollTable.Focus() + } + } + + for _, e := range gtx.Events(&s.ScrollTable.colTag) { + if e, ok := e.(key.Event); ok && e.State == key.Press { + s.ScrollTable.Focus() + } + } + + return Surface{Gray: 24, Focus: s.ScrollTable.Focused() || s.ScrollTable.ChildFocused()}.Layout(gtx, func(gtx C) D { + defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() + pointer.InputOp{ + Tag: &s.ScrollTable.tag, + Types: pointer.Press, + }.Add(gtx.Ops) + dims := gtx.Constraints.Max + s.layoutColTitles(gtx, p) + s.layoutRowTitles(gtx, p) + defer op.Offset(p).Push(gtx.Ops).Pop() + gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X-p.X, gtx.Constraints.Max.Y-p.Y)) + s.layoutTable(gtx, p) + s.RowTitleStyle.LayoutScrollBar(gtx) + s.ColTitleStyle.LayoutScrollBar(gtx) + return D{Size: dims} + }) +} + +func (s ScrollTableStyle) layoutTable(gtx C, p image.Point) { + defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop() + + if s.ScrollTable.requestFocus { + s.ScrollTable.requestFocus = false + key.FocusOp{Tag: &s.ScrollTable.tag}.Add(gtx.Ops) + } + key.InputOp{Tag: &s.ScrollTable.tag, Keys: "←|→|↑|↓|Shift-←|Shift-→|Shift-↑|Shift-↓|Ctrl-←|Ctrl-→|Ctrl-↑|Ctrl-↓|Ctrl-Shift-←|Ctrl-Shift-→|Ctrl-Shift-↑|Ctrl-Shift-↓|Alt-←|Alt-→|Alt-↑|Alt-↓|Alt-Shift-←|Alt-Shift-→|Alt-Shift-↑|Alt-Shift-↓|⇱|⇲|Shift-⇱|Shift-⇲|⌫|⌦|⇞|⇟|Shift-⇞|Shift-⇟|Ctrl-C|Ctrl-V|Ctrl-X|Shift-,|Shift-."}.Add(gtx.Ops) + cellWidth := gtx.Dp(s.CellWidth) + cellHeight := gtx.Dp(s.CellHeight) + + gtx.Constraints = layout.Exact(image.Pt(cellWidth, cellHeight)) + + colP := s.ColTitleStyle.dragList.List.Position + rowP := s.RowTitleStyle.dragList.List.Position + defer op.Offset(image.Pt(-colP.Offset, -rowP.Offset)).Push(gtx.Ops).Pop() + for x := colP.First; x < colP.First+colP.Count; x++ { + offs := op.Offset(image.Point{}).Push(gtx.Ops) + for y := rowP.First; y < rowP.First+rowP.Count; y++ { + s.element(gtx, x, y) + op.Offset(image.Pt(0, cellHeight)).Add(gtx.Ops) + } + offs.Pop() + op.Offset(image.Pt(cellWidth, 0)).Add(gtx.Ops) + } +} + +func (s *ScrollTableStyle) layoutRowTitles(gtx C, p image.Point) { + defer op.Offset(image.Pt(0, p.Y)).Push(gtx.Ops).Pop() + gtx.Constraints.Min.X = p.X + gtx.Constraints.Max.Y -= p.Y + gtx.Constraints.Min.Y = gtx.Constraints.Max.Y + defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop() + key.InputOp{Tag: &s.ScrollTable.rowTag, Keys: "→"}.Add(gtx.Ops) + s.RowTitleStyle.Layout(gtx) +} + +func (s *ScrollTableStyle) layoutColTitles(gtx C, p image.Point) { + defer op.Offset(image.Pt(p.X, 0)).Push(gtx.Ops).Pop() + gtx.Constraints.Min.Y = p.Y + gtx.Constraints.Max.X -= p.X + gtx.Constraints.Min.X = gtx.Constraints.Max.X + defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop() + key.InputOp{Tag: &s.ScrollTable.colTag, Keys: "↓"}.Add(gtx.Ops) + s.ColTitleStyle.Layout(gtx) +} + +func (s *ScrollTable) command(gtx C, e key.Event) { + stepX := 1 + stepY := 1 + if e.Modifiers.Contain(key.ModAlt) { + stepX = intMax(s.ColTitleList.List.Position.Count-3, 8) + stepY = intMax(s.RowTitleList.List.Position.Count-3, 8) + } else if e.Modifiers.Contain(key.ModCtrl) { + stepX = 1e6 + stepY = 1e6 + } + switch e.Name { + case "X", "C": + if e.Modifiers.Contain(key.ModShortcut) { + contents, ok := s.Table.Copy() + if !ok { + return + } + clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops) + if e.Name == "X" { + s.Table.Clear() + } + return + } + case "V": + if e.Modifiers.Contain(key.ModShortcut) { + clipboard.ReadOp{Tag: &s.tag}.Add(gtx.Ops) + } + return + case key.NameDeleteBackward, key.NameDeleteForward: + s.Table.Clear() + return + case key.NameUpArrow: + if !s.Table.MoveCursor(0, -stepY) && stepY == 1 { + s.ColTitleList.Focus() + } + case key.NameDownArrow: + s.Table.MoveCursor(0, stepY) + case key.NameLeftArrow: + if !s.Table.MoveCursor(-stepX, 0) && stepX == 1 { + s.RowTitleList.Focus() + } + case key.NameRightArrow: + s.Table.MoveCursor(stepX, 0) + case key.NamePageUp: + s.Table.MoveCursor(0, -intMax(s.RowTitleList.List.Position.Count-3, 8)) + case key.NamePageDown: + s.Table.MoveCursor(0, intMax(s.RowTitleList.List.Position.Count-3, 8)) + case key.NameHome: + s.Table.SetCursorX(0) + case key.NameEnd: + s.Table.SetCursorX(s.Table.Width() - 1) + case ".": + s.Table.Add(1) + case ",": + s.Table.Add(-1) + } + if !e.Modifiers.Contain(key.ModShift) { + s.Table.SetCursor2(s.Table.Cursor()) + } + s.ColTitleList.EnsureVisible(s.Table.Cursor().X) + s.RowTitleList.EnsureVisible(s.Table.Cursor().Y) + s.cursorMoved = true +} diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index d0bda4e..4d30361 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -2,222 +2,180 @@ package gioui import ( "image" - "math" - "time" - "gioui.org/io/clipboard" "gioui.org/layout" - "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" - "gioui.org/widget/material" + "github.com/vsariola/sointu/tracker" "golang.org/x/exp/shiny/materialdesign/icons" - "gopkg.in/yaml.v3" ) +type SongPanel struct { + MenuBar []widget.Clickable + Menus []Menu + BPM *NumberInput + RowsPerPattern *NumberInput + RowsPerBeat *NumberInput + Step *NumberInput + SongLength *NumberInput + + RewindBtn *ActionClickable + PlayingBtn *BoolClickable + RecordBtn *BoolClickable + NoteTracking *BoolClickable + PanicBtn *BoolClickable + + // File menu items + fileMenuItems []MenuItem + NewSong tracker.Action + OpenSongFile tracker.Action + SaveSongFile tracker.Action + SaveSongAsFile tracker.Action + ExportWav tracker.Action + Quit tracker.Action + + // Edit menu items + editMenuItems []MenuItem +} + +func NewSongPanel(model *tracker.Model) *SongPanel { + ret := &SongPanel{ + MenuBar: make([]widget.Clickable, 2), + Menus: make([]Menu, 2), + BPM: NewNumberInput(model.BPM().Int()), + RowsPerPattern: NewNumberInput(model.RowsPerPattern().Int()), + RowsPerBeat: NewNumberInput(model.RowsPerBeat().Int()), + Step: NewNumberInput(model.Step().Int()), + SongLength: NewNumberInput(model.SongLength().Int()), + PanicBtn: NewBoolClickable(model.Panic().Bool()), + RecordBtn: NewBoolClickable(model.IsRecording().Bool()), + NoteTracking: NewBoolClickable(model.NoteTracking().Bool()), + PlayingBtn: NewBoolClickable(model.Playing().Bool()), + RewindBtn: NewActionClickable(model.Rewind()), + } + ret.fileMenuItems = []MenuItem{ + {IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N", Doer: model.NewSong()}, + {IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O", Doer: model.OpenSong()}, + {IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S", Doer: model.SaveSong()}, + {IconBytes: icons.ContentSave, Text: "Save Song As...", Doer: model.SaveSongAs()}, + {IconBytes: icons.ImageAudiotrack, Text: "Export Wav...", Doer: model.Export()}, + } + if canQuit { + ret.fileMenuItems = append(ret.fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit", Doer: model.Quit()}) + } + ret.editMenuItems = []MenuItem{ + {IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Doer: model.Undo()}, + {IconBytes: icons.ContentRedo, Text: "Redo", ShortcutText: shortcutKey + "Y", Doer: model.Redo()}, + {IconBytes: icons.ImageCrop, Text: "Remove unused data", Doer: model.RemoveUnused()}, + } + return ret +} + const shortcutKey = "Ctrl+" -var fileMenuItems []MenuItem = []MenuItem{ - {IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N"}, - {IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"}, - {IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S"}, - {IconBytes: icons.ContentSave, Text: "Save Song As..."}, - {IconBytes: icons.ImageAudiotrack, Text: "Export Wav..."}, -} - -func init() { - if CAN_QUIT { - fileMenuItems = append(fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit"}) - } -} - -func (t *Tracker) layoutSongPanel(gtx C) D { +func (s *SongPanel) Layout(gtx C, t *Tracker) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(t.layoutMenuBar), - layout.Rigid(t.layoutSongOptions), + layout.Rigid(func(gtx C) D { + return s.layoutMenuBar(gtx, t) + }), + layout.Rigid(func(gtx C) D { + return s.layoutSongOptions(gtx, t) + }), ) } -func (t *Tracker) layoutMenu(title string, clickable *widget.Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget { - for clickable.Clicked() { - menu.Visible = true - } - m := t.PopupMenu(menu) - return func(gtx C) D { - defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - titleBtn := material.Button(t.Theme, clickable, title) - titleBtn.Color = white - titleBtn.Background = transparent - titleBtn.CornerRadius = unit.Dp(0) - dims := titleBtn.Layout(gtx) - op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops) - gtx.Constraints.Max.X = gtx.Dp(width) - gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(1000)) - m.Layout(gtx, items...) - return dims - } -} - -func (t *Tracker) layoutMenuBar(gtx C) D { +func (t *SongPanel) layoutMenuBar(gtx C, tr *Tracker) D { gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36)) gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36)) - for clickedItem, hasClicked := t.Menus[0].Clicked(); hasClicked; { - switch clickedItem { - case 0: - t.NewSong(false) - case 1: - t.OpenSongFile(false) - case 2: - t.SaveSongFile() - case 3: - t.SaveSongAsFile() - case 4: - t.WaveTypeDialog.Visible = true - case 5: - t.Quit(false) - } - clickedItem, hasClicked = t.Menus[0].Clicked() - } - - for clickedItem, hasClicked := t.Menus[1].Clicked(); hasClicked; { - switch clickedItem { - case 0: - t.Undo() - case 1: - t.Redo() - case 2: - if contents, err := yaml.Marshal(t.Song()); err == nil { - clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops) - t.Alert.Update("Song copied to clipboard", Notify, time.Second*3) - } - case 3: - clipboard.ReadOp{Tag: t}.Add(gtx.Ops) - case 4: - t.RemoveUnusedData() - } - clickedItem, hasClicked = t.Menus[1].Clicked() - } - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(t.layoutMenu("File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), - fileMenuItems..., - )), - layout.Rigid(t.layoutMenu("Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200), - MenuItem{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Disabled: !t.CanUndo()}, - MenuItem{IconBytes: icons.ContentRedo, Text: "Redo", ShortcutText: shortcutKey + "Y", Disabled: !t.CanRedo()}, - MenuItem{IconBytes: icons.ContentContentCopy, Text: "Copy", ShortcutText: shortcutKey + "C"}, - MenuItem{IconBytes: icons.ContentContentPaste, Text: "Paste", ShortcutText: shortcutKey + "V"}, - MenuItem{IconBytes: icons.ImageCrop, Text: "Remove unused data"}, - )), + layout.Rigid(tr.layoutMenu("File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), t.fileMenuItems...)), + layout.Rigid(tr.layoutMenu("Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200), t.editMenuItems...)), ) } -func (t *Tracker) layoutSongOptions(gtx C) D { +func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D { paint.FillShape(gtx.Ops, songSurfaceColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op()) in := layout.UniformInset(unit.Dp(1)) - var panicBtnStyle material.ButtonStyle - if !t.Panic() { - panicBtnStyle = LowEmphasisButton(t.Theme, t.PanicBtn, "Panic") - } else { - panicBtnStyle = HighEmphasisButton(t.Theme, t.PanicBtn, "Panic") - } - - for t.PanicBtn.Clicked() { - t.SetPanic(!t.Panic()) - } - - var recordBtnStyle material.ButtonStyle - if !t.Recording() { - recordBtnStyle = LowEmphasisButton(t.Theme, t.RecordBtn, "Record") - } else { - recordBtnStyle = HighEmphasisButton(t.Theme, t.RecordBtn, "Record") - } - - for t.RecordBtn.Clicked() { - t.SetRecording(!t.Recording()) - } + panicBtnStyle := ToggleButton(tr.Theme, t.PanicBtn, "Panic (F12)") + rewindBtnStyle := ActionIcon(tr.Theme, t.RewindBtn, icons.AVFastRewind, "Rewind\n(F5)") + playBtnStyle := ToggleIcon(tr.Theme, t.PlayingBtn, icons.AVPlayArrow, icons.AVStop, "Play (F6 / Space)", "Stop (F6 / Space)") + recordBtnStyle := ToggleIcon(tr.Theme, t.RecordBtn, icons.AVFiberManualRecord, icons.AVFiberSmartRecord, "Record (F7)", "Stop (F7)") + noteTrackBtnStyle := ToggleIcon(tr.Theme, t.NoteTracking, icons.ActionSpeakerNotesOff, icons.ActionSpeakerNotes, "Follow\nOff\n(F8)", "Follow\nOn\n(F8)") return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(Label("LEN:", white, t.TextShaper)), + layout.Rigid(Label("LEN:", white, tr.Theme.Shaper)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - t.SongLength.Value = t.Song().Score.Length - numStyle := NumericUpDown(t.Theme, t.SongLength, 1, math.MaxInt32, "Song length") + numStyle := NumericUpDown(tr.Theme, t.SongLength, "Song length") gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20)) gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70)) dims := in.Layout(gtx, numStyle.Layout) - t.SetSongLength(t.SongLength.Value) return dims }), ) }), layout.Rigid(func(gtx C) D { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(Label("BPM:", white, t.TextShaper)), + layout.Rigid(Label("BPM:", white, tr.Theme.Shaper)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - t.BPM.Value = t.Song().BPM - numStyle := NumericUpDown(t.Theme, t.BPM, 1, 999, "Beats per minute") + numStyle := NumericUpDown(tr.Theme, t.BPM, "Beats per minute") gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20)) gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70)) dims := in.Layout(gtx, numStyle.Layout) - t.SetBPM(t.BPM.Value) return dims }), ) }), layout.Rigid(func(gtx C) D { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(Label("RPP:", white, t.TextShaper)), + layout.Rigid(Label("RPP:", white, tr.Theme.Shaper)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - t.RowsPerPattern.Value = t.Song().Score.RowsPerPattern - numStyle := NumericUpDown(t.Theme, t.RowsPerPattern, 1, 255, "Rows per pattern") + numStyle := NumericUpDown(tr.Theme, t.RowsPerPattern, "Rows per pattern") gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20)) gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70)) dims := in.Layout(gtx, numStyle.Layout) - t.SetRowsPerPattern(t.RowsPerPattern.Value) return dims }), ) }), layout.Rigid(func(gtx C) D { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(Label("RPB:", white, t.TextShaper)), + layout.Rigid(Label("RPB:", white, tr.Theme.Shaper)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - t.RowsPerBeat.Value = t.Song().RowsPerBeat - numStyle := NumericUpDown(t.Theme, t.RowsPerBeat, 1, 32, "Rows per beat") + numStyle := NumericUpDown(tr.Theme, t.RowsPerBeat, "Rows per beat") gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20)) gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70)) dims := in.Layout(gtx, numStyle.Layout) - t.SetRowsPerBeat(t.RowsPerBeat.Value) return dims }), ) }), layout.Rigid(func(gtx C) D { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(Label("STP:", white, t.TextShaper)), + layout.Rigid(Label("STP:", white, tr.Theme.Shaper)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - numStyle := NumericUpDown(t.Theme, t.Step, 0, 8, "Cursor step") + numStyle := NumericUpDown(tr.Theme, t.Step, "Cursor step") numStyle.UnitsPerStep = unit.Dp(20) dims := in.Layout(gtx, numStyle.Layout) return dims }), ) }), + layout.Rigid(VuMeter{AverageVolume: tr.Model.AverageVolume(), PeakVolume: tr.Model.PeakVolume(), Range: 100}.Layout), layout.Rigid(func(gtx C) D { - gtx.Constraints.Min = image.Pt(0, 0) - return panicBtnStyle.Layout(gtx) + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(rewindBtnStyle.Layout), + layout.Rigid(playBtnStyle.Layout), + layout.Rigid(recordBtnStyle.Layout), + layout.Rigid(noteTrackBtnStyle.Layout), + ) }), - layout.Rigid(func(gtx C) D { - gtx.Constraints.Min = image.Pt(0, 0) - return recordBtnStyle.Layout(gtx) - }), - layout.Rigid(VuMeter{AverageVolume: t.lastAvgVolume, PeakVolume: t.lastPeakVolume, Range: 100}.Layout), + layout.Rigid(panicBtnStyle.Layout), ) } diff --git a/tracker/gioui/surface.go b/tracker/gioui/surface.go index 0dbaad6..ee0ba7a 100644 --- a/tracker/gioui/surface.go +++ b/tracker/gioui/surface.go @@ -4,6 +4,7 @@ import ( "image/color" "gioui.org/layout" + "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" ) @@ -39,10 +40,13 @@ func (s Surface) Layout(gtx C, widget layout.Widget) D { return s.Inset.Layout(gtx, widget) } if s.FitSize { - return layout.Stack{}.Layout(gtx, - layout.Expanded(bg), - layout.Stacked(fg), - ) + macro := op.Record(gtx.Ops) + dims := fg(gtx) + call := macro.Stop() + gtx.Constraints = layout.Exact(dims.Size) + bg(gtx) + call.Add(gtx.Ops) + return dims } gtxbg := gtx gtxbg.Constraints.Min = gtxbg.Constraints.Max diff --git a/tracker/gioui/theme.go b/tracker/gioui/theme.go index 993898a..414823c 100644 --- a/tracker/gioui/theme.go +++ b/tracker/gioui/theme.go @@ -63,7 +63,7 @@ var inactiveLightSurfaceColor = color.NRGBA{R: 37, G: 37, B: 38, A: 255} var activeLightSurfaceColor = color.NRGBA{R: 45, G: 45, B: 45, A: 255} var cursorColor = color.NRGBA{R: 100, G: 140, B: 255, A: 48} -var selectionColor = color.NRGBA{R: 100, G: 140, B: 255, A: 8} +var selectionColor = color.NRGBA{R: 100, G: 140, B: 255, A: 12} var inactiveSelectionColor = color.NRGBA{R: 140, G: 140, B: 140, A: 16} var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255} diff --git a/tracker/gioui/trackeditor.go b/tracker/gioui/trackeditor.go deleted file mode 100644 index 488d9cd..0000000 --- a/tracker/gioui/trackeditor.go +++ /dev/null @@ -1,500 +0,0 @@ -package gioui - -import ( - "fmt" - "image" - "strconv" - "strings" - - "gioui.org/io/key" - "gioui.org/io/pointer" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/op/paint" - "gioui.org/unit" - "gioui.org/widget" - "gioui.org/widget/material" - "github.com/vsariola/sointu/tracker" - "golang.org/x/exp/shiny/materialdesign/icons" -) - -const trackRowHeight = 16 -const trackColWidth = 54 -const patmarkWidth = 16 - -type TrackEditor struct { - TrackVoices *NumberInput - NewTrackBtn *TipClickable - DeleteTrackBtn *TipClickable - AddSemitoneBtn *widget.Clickable - SubtractSemitoneBtn *widget.Clickable - AddOctaveBtn *widget.Clickable - SubtractOctaveBtn *widget.Clickable - NoteOffBtn *widget.Clickable - trackPointerTag bool - trackJumpPointerTag bool - tag bool - focused bool - requestFocus bool -} - -func NewTrackEditor() *TrackEditor { - return &TrackEditor{ - TrackVoices: new(NumberInput), - NewTrackBtn: new(TipClickable), - DeleteTrackBtn: new(TipClickable), - AddSemitoneBtn: new(widget.Clickable), - SubtractSemitoneBtn: new(widget.Clickable), - AddOctaveBtn: new(widget.Clickable), - SubtractOctaveBtn: new(widget.Clickable), - NoteOffBtn: new(widget.Clickable), - } -} - -func (te *TrackEditor) Focus() { - te.requestFocus = true -} - -func (te *TrackEditor) Focused() bool { - return te.focused || te.ChildFocused() -} - -func (te *TrackEditor) ChildFocused() bool { - return te.AddOctaveBtn.Focused() || te.AddSemitoneBtn.Focused() || te.DeleteTrackBtn.Clickable.Focused() || te.NewTrackBtn.Clickable.Focused() || te.NoteOffBtn.Focused() || te.SubtractOctaveBtn.Focused() || te.SubtractSemitoneBtn.Focused() || te.SubtractSemitoneBtn.Focused() || te.SubtractSemitoneBtn.Focused() -} - -var trackerEditorKeys key.Set = "+|-|←|→|↑|↓|Ctrl-←|Ctrl-→|Ctrl-↑|Ctrl-↓|Shift-←|Shift-→|Shift-↑|Shift-↓|⏎|⇱|⇲|⌫|⌦|A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|R|S|T|U|V|W|X|Y|Z|0|1|2|3|4|5|6|7|8|9|,|." - -func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions { - for _, e := range gtx.Events(te) { - switch e := e.(type) { - case key.FocusEvent: - te.focused = e.Focus - case pointer.Event: - if e.Type == pointer.Press { - key.FocusOp{Tag: te}.Add(gtx.Ops) - } - case key.Event: - switch e.State { - case key.Press: - switch e.Name { - case key.NameDeleteForward, key.NameDeleteBackward: - t.DeleteSelection() - if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { - t.SetCursor(t.Cursor().AddRows(t.Step.Value)) - t.SetSelectionCorner(t.Cursor()) - } - case key.NameUpArrow, key.NameDownArrow: - sign := -1 - if e.Name == key.NameDownArrow { - sign = 1 - } - cursor := t.Cursor() - if e.Modifiers.Contain(key.ModShortcut) { - cursor.Row += t.Song().Score.RowsPerPattern * sign - } else { - if t.Step.Value > 0 { - cursor.Row += t.Step.Value * sign - } else { - cursor.Row += sign - } - } - t.SetNoteTracking(false) - t.SetCursor(cursor) - if !e.Modifiers.Contain(key.ModShift) { - t.SetSelectionCorner(t.Cursor()) - } - //scrollToView(t.PatternOrderList, t.Cursor().Pattern, t.Song().Score.Length) - case key.NameLeftArrow: - cursor := t.Cursor() - if !t.LowNibble() || !t.Song().Score.Tracks[t.Cursor().Track].Effect || e.Modifiers.Contain(key.ModShortcut) { - cursor.Track-- - t.SetLowNibble(true) - } else { - t.SetLowNibble(false) - } - t.SetCursor(cursor) - if !e.Modifiers.Contain(key.ModShift) { - t.SetSelectionCorner(t.Cursor()) - } - case key.NameRightArrow: - if t.LowNibble() || !t.Song().Score.Tracks[t.Cursor().Track].Effect || e.Modifiers.Contain(key.ModShortcut) { - cursor := t.Cursor() - cursor.Track++ - t.SetCursor(cursor) - t.SetLowNibble(false) - } else { - t.SetLowNibble(true) - } - - if !e.Modifiers.Contain(key.ModShift) { - t.SetSelectionCorner(t.Cursor()) - } - case "+": - if e.Modifiers.Contain(key.ModShortcut) { - t.AdjustSelectionPitch(12) - } else { - t.AdjustSelectionPitch(1) - } - case "-": - if e.Modifiers.Contain(key.ModShortcut) { - t.AdjustSelectionPitch(-12) - } else { - t.AdjustSelectionPitch(-1) - } - } - if e.Modifiers.Contain(key.ModShortcut) { - continue - } - step := false - if t.Song().Score.Tracks[t.Cursor().Track].Effect { - if iv, err := strconv.ParseInt(e.Name, 16, 8); err == nil { - t.NumberPressed(byte(iv)) - step = true - } - } else { - if e.Name == "A" || e.Name == "1" { - t.SetNote(0) - step = true - } else { - if val, ok := noteMap[e.Name]; ok { - if _, ok := t.KeyPlaying[e.Name]; !ok { - n := noteAsValue(t.OctaveNumberInput.Value, val) - t.SetNote(n) - step = true - trk := t.Cursor().Track - noteID := tracker.NoteIDTrack(trk, n) - t.NoteOn(noteID) - t.KeyPlaying[e.Name] = noteID - } - } - } - } - if step && !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { - t.SetCursor(t.Cursor().AddRows(t.Step.Value)) - t.SetSelectionCorner(t.Cursor()) - } - case key.Release: - if noteID, ok := t.KeyPlaying[e.Name]; ok { - t.NoteOff(noteID) - delete(t.KeyPlaying, e.Name) - } - } - } - } - - if te.requestFocus || te.ChildFocused() { - te.requestFocus = false - key.FocusOp{Tag: te}.Add(gtx.Ops) - } - - rowMarkers := layout.Rigid(t.layoutRowMarkers) - - for te.NewTrackBtn.Clickable.Clicked() { - t.AddTrack(true) - } - - for te.DeleteTrackBtn.Clickable.Clicked() { - t.DeleteTrack(false) - } - - //t.TrackHexCheckBoxes[i2].Value = t.TrackShowHex[i2] - //cbStyle := material.CheckBox(t.Theme, t.TrackHexCheckBoxes[i2], "hex") - //cbStyle.Color = white - //cbStyle.IconColor = t.Theme.Fg - - for te.AddSemitoneBtn.Clicked() { - t.AdjustSelectionPitch(1) - } - - for te.SubtractSemitoneBtn.Clicked() { - t.AdjustSelectionPitch(-1) - } - - for te.NoteOffBtn.Clicked() { - t.SetNote(0) - if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 { - t.SetCursor(t.Cursor().AddRows(t.Step.Value)) - t.SetSelectionCorner(t.Cursor()) - } - } - - for te.AddOctaveBtn.Clicked() { - t.AdjustSelectionPitch(12) - } - - for te.SubtractOctaveBtn.Clicked() { - t.AdjustSelectionPitch(-12) - } - - menu := func(gtx C) D { - addSemitoneBtnStyle := LowEmphasisButton(t.Theme, te.AddSemitoneBtn, "+1") - subtractSemitoneBtnStyle := LowEmphasisButton(t.Theme, te.SubtractSemitoneBtn, "-1") - addOctaveBtnStyle := LowEmphasisButton(t.Theme, te.AddOctaveBtn, "+12") - subtractOctaveBtnStyle := LowEmphasisButton(t.Theme, te.SubtractOctaveBtn, "-12") - noteOffBtnStyle := LowEmphasisButton(t.Theme, te.NoteOffBtn, "Note Off") - deleteTrackBtnStyle := IconButton(t.Theme, te.DeleteTrackBtn, icons.ActionDelete, t.CanDeleteTrack(), "Delete track") - newTrackBtnStyle := IconButton(t.Theme, te.NewTrackBtn, icons.ContentAdd, t.CanAddTrack(), "Add track") - n := t.Song().Score.Tracks[t.Cursor().Track].NumVoices - te.TrackVoices.Value = n - in := layout.UniformInset(unit.Dp(1)) - voiceUpDown := func(gtx C) D { - numStyle := NumericUpDown(t.Theme, te.TrackVoices, 1, t.MaxTrackVoices(), "Number of voices for this track") - return in.Layout(gtx, numStyle.Layout) - } - t.TrackHexCheckBox.Value = t.Song().Score.Tracks[t.Cursor().Track].Effect - hexCheckBoxStyle := material.CheckBox(t.Theme, t.TrackHexCheckBox, "Hex") - dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Dp(unit.Dp(12)), 0)} }), - layout.Rigid(addSemitoneBtnStyle.Layout), - layout.Rigid(subtractSemitoneBtnStyle.Layout), - layout.Rigid(addOctaveBtnStyle.Layout), - layout.Rigid(subtractOctaveBtnStyle.Layout), - layout.Rigid(noteOffBtnStyle.Layout), - layout.Rigid(hexCheckBoxStyle.Layout), - layout.Rigid(Label(" Voices:", white, t.TextShaper)), - layout.Rigid(voiceUpDown), - layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), - layout.Rigid(deleteTrackBtnStyle.Layout), - layout.Rigid(newTrackBtnStyle.Layout)) - t.Song().Score.Tracks[t.Cursor().Track].Effect = t.TrackHexCheckBox.Value // TODO: we should not modify the model, but how should this be done - t.SetTrackVoices(te.TrackVoices.Value) - return dims - } - - rect := image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y) - area := clip.Rect(rect).Push(gtx.Ops) - pointer.InputOp{Tag: te, - Types: pointer.Press, - }.Add(gtx.Ops) - key.InputOp{Tag: te, Keys: trackerEditorKeys}.Add(gtx.Ops) - - dims := Surface{Gray: 24, Focus: te.Focused()}.Layout(gtx, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return Surface{Gray: 37, Focus: te.Focused(), FitSize: true}.Layout(gtx, menu) - }), - layout.Flexed(1, func(gtx C) D { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - rowMarkers, - layout.Flexed(1, func(gtx C) D { - return te.layoutTracks(gtx, t) - })) - }), - ) - }) - - area.Pop() - return dims -} - -const baseNote = 24 - -var notes = []string{ - "C-", - "C#", - "D-", - "D#", - "E-", - "F-", - "F#", - "G-", - "G#", - "A-", - "A#", - "B-", -} - -func noteStr(val byte) string { - if val == 1 { - return "..." // hold - } - if val == 0 { - return "---" // release - } - oNote := mod(int(val-baseNote), 12) - octave := (int(val) - oNote - baseNote) / 12 - if octave < 0 { - return fmt.Sprintf("%s%s", notes[oNote], string(byte('Z'+1+octave))) - } - if octave >= 10 { - return fmt.Sprintf("%s%s", notes[oNote], string(byte('A'+octave-10))) - } - return fmt.Sprintf("%s%d", notes[oNote], octave) -} - -func noteAsValue(octave, note int) byte { - return byte(baseNote + (octave * 12) + note) -} - -func (te *TrackEditor) layoutTracks(gtx C, t *Tracker) D { - defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop() - cursorSongRow := t.Cursor().Pattern*t.Song().Score.RowsPerPattern + t.Cursor().Row - for _, ev := range gtx.Events(&te.trackJumpPointerTag) { - e, ok := ev.(pointer.Event) - if !ok { - continue - } - if e.Type == pointer.Press { - te.Focus() - track := int(e.Position.X) / trackColWidth - row := int((e.Position.Y-float32(gtx.Constraints.Max.Y-trackRowHeight)/2)/trackRowHeight + float32(cursorSongRow)) - cursor := tracker.ScorePoint{Track: track, ScoreRow: tracker.ScoreRow{Row: row}}.Clamp(t.Song().Score) - t.SetCursor(cursor) - t.SetSelectionCorner(cursor) - cursorSongRow = cursor.Pattern*t.Song().Score.RowsPerPattern + cursor.Row - } - } - rect := image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y) - area := clip.Rect(rect).Push(gtx.Ops) - pointer.InputOp{Tag: &te.trackJumpPointerTag, - Types: pointer.Press, - }.Add(gtx.Ops) - area.Pop() - stack := op.Offset(image.Point{}).Push(gtx.Ops) - curVoice := 0 - for _, trk := range t.Song().Score.Tracks { - gtx := gtx - instrName := "?" - firstIndex, err := t.Song().Patch.InstrumentForVoice(curVoice) - lastIndex, err2 := t.Song().Patch.InstrumentForVoice(curVoice + trk.NumVoices - 1) - if err == nil && err2 == nil { - switch diff := lastIndex - firstIndex; diff { - case 0: - instrName = t.Song().Patch[firstIndex].Name - default: - n1 := t.Song().Patch[firstIndex].Name - n2 := t.Song().Patch[firstIndex+1].Name - if len(n1) > 0 { - n1 = string(n1[0]) - } else { - n1 = "?" - } - if len(n2) > 0 { - n2 = string(n2[0]) - } else { - n2 = "?" - } - if diff > 1 { - instrName = n1 + "/" + n2 + "..." - } else { - instrName = n1 + "/" + n2 - } - } - if len(instrName) > 7 { - instrName = instrName[:7] - } - } - gtx.Constraints.Max.X = trackColWidth - LabelStyle{Alignment: layout.N, Text: instrName, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.TextShaper}.Layout(gtx) - op.Offset(image.Point{trackColWidth, 0}).Add(gtx.Ops) - curVoice += trk.NumVoices - } - stack.Pop() - op.Offset(image.Point{0, (gtx.Constraints.Max.Y - trackRowHeight) / 2}).Add(gtx.Ops) - op.Offset(image.Point{0, int((-1 * trackRowHeight) * (cursorSongRow))}).Add(gtx.Ops) - if te.Focused() || t.OrderEditor.Focused() { - x1, y1 := t.Cursor().Track, t.Cursor().Pattern - x2, y2 := t.SelectionCorner().Track, t.SelectionCorner().Pattern - if x1 > x2 { - x1, x2 = x2, x1 - } - if y1 > y2 { - y1, y2 = y2, y1 - } - x2++ - y2++ - x1 *= trackColWidth - y1 *= trackRowHeight * t.Song().Score.RowsPerPattern - x2 *= trackColWidth - y2 *= trackRowHeight * t.Song().Score.RowsPerPattern - paint.FillShape(gtx.Ops, inactiveSelectionColor, clip.Rect{Min: image.Pt(x1, y1), Max: image.Pt(x2, y2)}.Op()) - } - if te.Focused() { - x1, y1 := t.Cursor().Track, t.Cursor().Pattern*t.Song().Score.RowsPerPattern+t.Cursor().Row - x2, y2 := t.SelectionCorner().Track, t.SelectionCorner().Pattern*t.Song().Score.RowsPerPattern+t.SelectionCorner().Row - if x1 > x2 { - x1, x2 = x2, x1 - } - if y1 > y2 { - y1, y2 = y2, y1 - } - x2++ - y2++ - x1 *= trackColWidth - y1 *= trackRowHeight - x2 *= trackColWidth - y2 *= trackRowHeight - paint.FillShape(gtx.Ops, selectionColor, clip.Rect{Min: image.Pt(x1, y1), Max: image.Pt(x2, y2)}.Op()) - cx := t.Cursor().Track * trackColWidth - cy := (t.Cursor().Pattern*t.Song().Score.RowsPerPattern + t.Cursor().Row) * trackRowHeight - cw := trackColWidth - if t.Song().Score.Tracks[t.Cursor().Track].Effect { - cw /= 2 - if t.LowNibble() { - cx += cw - } - } - paint.FillShape(gtx.Ops, cursorColor, clip.Rect{Min: image.Pt(cx, cy), Max: image.Pt(cx+cw, cy+trackRowHeight)}.Op()) - } - delta := (gtx.Constraints.Max.Y/2 + trackRowHeight - 1) / trackRowHeight - firstRow := cursorSongRow - delta - lastRow := cursorSongRow + delta - if firstRow < 0 { - firstRow = 0 - } - if l := t.Song().Score.LengthInRows(); lastRow >= l { - lastRow = l - 1 - } - op.Offset(image.Point{0, trackRowHeight * firstRow}).Add(gtx.Ops) - for trkIndex, trk := range t.Song().Score.Tracks { - stack := op.Offset(image.Point{}).Push(gtx.Ops) - for row := firstRow; row <= lastRow; row++ { - pat := row / t.Song().Score.RowsPerPattern - patRow := row % t.Song().Score.RowsPerPattern - s := trk.Order.Get(pat) - if s < 0 { - op.Offset(image.Point{0, trackRowHeight}).Add(gtx.Ops) - continue - } - if s >= 0 && patRow == 0 { - paint.ColorOp{Color: trackerPatMarker}.Add(gtx.Ops) - widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, patternIndexToString(s), op.CallOp{}) - } - if s >= 0 && patRow == 1 && t.IsPatternUnique(trkIndex, s) { - paint.ColorOp{Color: mediumEmphasisTextColor}.Add(gtx.Ops) - widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, "*", op.CallOp{}) - } - op.Offset(image.Point{patmarkWidth, 0}).Add(gtx.Ops) - if te.Focused() && t.Cursor().Row == patRow && t.Cursor().Pattern == pat { - paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops) - } else { - paint.ColorOp{Color: trackerInactiveTextColor}.Add(gtx.Ops) - } - var c byte = 1 - if s >= 0 && s < len(trk.Patterns) { - c = trk.Patterns[s].Get(patRow) - } - if trk.Effect { - var text string - switch c { - case 0: - text = "--" - case 1: - text = ".." - default: - text = fmt.Sprintf("%02x", c) - } - widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, strings.ToUpper(text), op.CallOp{}) - } else { - widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, noteStr(c), op.CallOp{}) - } - op.Offset(image.Point{-patmarkWidth, trackRowHeight}).Add(gtx.Ops) - } - stack.Pop() - op.Offset(image.Point{trackColWidth, 0}).Add(gtx.Ops) - } - return layout.Dimensions{Size: gtx.Constraints.Max} -} diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index 1daaebc..a7de378 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -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) + }, + ) +} diff --git a/tracker/gioui/tracker_not_plugin.go b/tracker/gioui/tracker_not_plugin.go deleted file mode 100644 index 067f8e0..0000000 --- a/tracker/gioui/tracker_not_plugin.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build !plugin - -package gioui - -const CAN_QUIT = true - -func (t *Tracker) Quit(forced bool) bool { - if !forced && t.ChangedSinceSave() { - t.ConfirmSongActionType = ConfirmQuit - t.ConfirmSongDialog.Visible = true - return false - } - t.sendQuit() - return true -} diff --git a/tracker/gioui/tracker_plugin.go b/tracker/gioui/tracker_plugin.go index d8e888e..4995550 100644 --- a/tracker/gioui/tracker_plugin.go +++ b/tracker/gioui/tracker_plugin.go @@ -2,11 +2,6 @@ package gioui -const CAN_QUIT = false - -func (t *Tracker) Quit(forced bool) bool { - if forced { - t.sendQuit() - } - return forced +func init() { + canQuit = false } diff --git a/tracker/gioui/unit_editor.go b/tracker/gioui/unit_editor.go new file mode 100644 index 0000000..f23a9dc --- /dev/null +++ b/tracker/gioui/unit_editor.go @@ -0,0 +1,321 @@ +package gioui + +import ( + "fmt" + "image" + "math" + + "gioui.org/io/clipboard" + "gioui.org/io/key" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/vsariola/sointu/tracker" + "golang.org/x/exp/shiny/materialdesign/icons" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +type UnitEditor struct { + sliderList *DragList + searchList *DragList + Parameters []*ParameterWidget + DeleteUnitBtn *ActionClickable + CopyUnitBtn *TipClickable + ClearUnitBtn *ActionClickable + SelectTypeBtn *widget.Clickable + tag bool + caser cases.Caser +} + +func NewUnitEditor(m *tracker.Model) *UnitEditor { + ret := &UnitEditor{ + DeleteUnitBtn: NewActionClickable(m.DeleteUnit()), + ClearUnitBtn: NewActionClickable(m.ClearUnit()), + CopyUnitBtn: new(TipClickable), + SelectTypeBtn: new(widget.Clickable), + sliderList: NewDragList(m.Params().List(), layout.Vertical), + searchList: NewDragList(m.SearchResults().List(), layout.Vertical), + } + ret.caser = cases.Title(language.English) + return ret +} + +func (pe *UnitEditor) Layout(gtx C, t *Tracker) D { + for _, e := range gtx.Events(&pe.tag) { + switch e := e.(type) { + case key.Event: + if e.State == key.Press { + pe.command(e, t) + } + } + } + defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() + defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() + key.InputOp{Tag: &pe.tag, Keys: "←|Shift-←|→|Shift-→|⎋"}.Add(gtx.Ops) + + editorFunc := pe.layoutSliders + str := tracker.String{StringData: (*tracker.UnitSearch)(t.Model)} + if str.Value() != t.Model.Units().SelectedType() || pe.sliderList.TrackerList.Count() == 0 { + editorFunc = pe.layoutUnitTypeChooser + } + return Surface{Gray: 24, Focus: t.InstrumentEditor.wasFocused}.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return editorFunc(gtx, t) + }), + layout.Rigid(func(gtx C) D { + return pe.layoutFooter(gtx, t) + }), + ) + }) +} + +func (pe *UnitEditor) layoutSliders(gtx C, t *Tracker) D { + numItems := pe.sliderList.TrackerList.Count() + + for len(pe.Parameters) < numItems { + pe.Parameters = append(pe.Parameters, new(ParameterWidget)) + } + + index := 0 + t.Model.Params().Iterate(func(param tracker.Parameter) { + pe.Parameters[index].Parameter = param + index++ + }) + + element := func(gtx C, index int) D { + if index < 0 || index >= numItems { + return D{} + } + paramStyle := t.ParamStyle(t.Theme, pe.Parameters[index]) + paramStyle.Focus = pe.sliderList.TrackerList.Selected() == index + dims := paramStyle.Layout(gtx) + return D{Size: image.Pt(gtx.Constraints.Max.X, dims.Size.Y)} + } + + fdl := FilledDragList(t.Theme, pe.sliderList, element, nil) + dims := fdl.Layout(gtx) + gtx.Constraints = layout.Exact(dims.Size) + fdl.LayoutScrollBar(gtx) + return dims +} + +func (pe *UnitEditor) layoutFooter(gtx C, t *Tracker) D { + for pe.CopyUnitBtn.Clickable.Clicked() { + if contents, ok := t.Units().List().CopyElements(); ok { + clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops) + t.Alerts().Add("Unit copied to clipboard", tracker.Info) + } + } + copyUnitBtnStyle := TipIcon(t.Theme, pe.CopyUnitBtn, icons.ContentContentCopy, "Copy unit (Ctrl+C)") + deleteUnitBtnStyle := ActionIcon(t.Theme, pe.DeleteUnitBtn, icons.ActionDelete, "Delete unit (Ctrl+Backspace)") + text := t.Units().SelectedType() + if text == "" { + text = "Choose unit type" + } else { + text = pe.caser.String(text) + } + hintText := Label(text, white, t.Theme.Shaper) + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(deleteUnitBtnStyle.Layout), + layout.Rigid(copyUnitBtnStyle.Layout), + layout.Rigid(func(gtx C) D { + var dims D + if t.Units().SelectedType() != "" { + clearUnitBtnStyle := ActionIcon(t.Theme, pe.ClearUnitBtn, icons.ContentClear, "Clear unit") + dims = clearUnitBtnStyle.Layout(gtx) + } + return D{Size: image.Pt(gtx.Dp(unit.Dp(48)), dims.Size.Y)} + }), + layout.Flexed(1, hintText), + ) +} + +func (pe *UnitEditor) layoutUnitTypeChooser(gtx C, t *Tracker) D { + var names [256]string + index := 0 + t.Model.SearchResults().Iterate(func(item string) (ok bool) { + names[index] = item + index++ + return index <= 256 + }) + element := func(gtx C, i int) D { + w := LabelStyle{Text: names[i], ShadeColor: black, Color: white, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper} + if i == pe.searchList.TrackerList.Selected() { + for pe.SelectTypeBtn.Clicked() { + t.Units().SetSelectedType(names[i]) + } + return pe.SelectTypeBtn.Layout(gtx, w.Layout) + } + return w.Layout(gtx) + } + fdl := FilledDragList(t.Theme, pe.searchList, element, nil) + dims := fdl.Layout(gtx) + gtx.Constraints = layout.Exact(dims.Size) + fdl.LayoutScrollBar(gtx) + return dims +} + +func (pe *UnitEditor) command(e key.Event, t *Tracker) { + params := (*tracker.Params)(t.Model) + switch e.State { + case key.Press: + switch e.Name { + case key.NameLeftArrow: + sel := params.SelectedItem() + if sel == nil { + return + } + i := (&tracker.Int{IntData: sel}) + if e.Modifiers.Contain(key.ModShift) { + i.Set(i.Value() - sel.LargeStep()) + } else { + i.Set(i.Value() - 1) + } + case key.NameRightArrow: + sel := params.SelectedItem() + if sel == nil { + return + } + i := (&tracker.Int{IntData: sel}) + if e.Modifiers.Contain(key.ModShift) { + i.Set(i.Value() + sel.LargeStep()) + } else { + i.Set(i.Value() + 1) + } + case key.NameEscape: + t.InstrumentEditor.unitDragList.Focus() + } + } +} + +type ParameterWidget struct { + floatWidget widget.Float + boolWidget widget.Bool + instrBtn widget.Clickable + instrMenu Menu + unitBtn widget.Clickable + unitMenu Menu + Parameter tracker.Parameter +} + +type ParameterStyle struct { + tracker *Tracker + w *ParameterWidget + Theme *material.Theme + Focus bool +} + +func (t *Tracker) ParamStyle(th *material.Theme, paramWidget *ParameterWidget) ParameterStyle { + return ParameterStyle{ + tracker: t, // TODO: we need this to pull the instrument names for ID style parameters, find out another way + Theme: th, + w: paramWidget, + } +} + +func (p ParameterStyle) Layout(gtx C) D { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Dp(unit.Dp(110)) + return layout.E.Layout(gtx, Label(p.w.Parameter.Name(), white, p.tracker.Theme.Shaper)) + }), + layout.Rigid(func(gtx C) D { + switch p.w.Parameter.Type() { + case tracker.IntegerParameter: + for _, e := range gtx.Events(&p.w.floatWidget) { + if ev, ok := e.(pointer.Event); ok && ev.Type == pointer.Scroll { + delta := math.Min(math.Max(float64(ev.Scroll.Y), -1), 1) + tracker.Int{IntData: p.w.Parameter}.Add(int(delta)) + } + } + gtx.Constraints.Min.X = gtx.Dp(unit.Dp(200)) + gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40)) + if !p.w.floatWidget.Dragging() { + p.w.floatWidget.Value = float32(p.w.Parameter.Value()) + } + ra := p.w.Parameter.Range() + sliderStyle := material.Slider(p.Theme, &p.w.floatWidget, float32(ra.Min), float32(ra.Max)) + sliderStyle.Color = p.Theme.Fg + r := image.Rectangle{Max: gtx.Constraints.Min} + area := clip.Rect(r).Push(gtx.Ops) + if p.Focus { + pointer.InputOp{Tag: &p.w.floatWidget, Types: pointer.Scroll, ScrollBounds: image.Rectangle{Min: image.Pt(0, -1e6), Max: image.Pt(0, 1e6)}}.Add(gtx.Ops) + } + dims := sliderStyle.Layout(gtx) + area.Pop() + tracker.Int{IntData: p.w.Parameter}.Set(int(p.w.floatWidget.Value + 0.5)) + return dims + case tracker.BoolParameter: + gtx.Constraints.Min.X = gtx.Dp(unit.Dp(60)) + gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40)) + ra := p.w.Parameter.Range() + p.w.boolWidget.Value = p.w.Parameter.Value() > ra.Min + boolStyle := material.Switch(p.Theme, &p.w.boolWidget, "Toggle boolean parameter") + boolStyle.Color.Disabled = p.Theme.Fg + boolStyle.Color.Enabled = white + dims := layout.Center.Layout(gtx, boolStyle.Layout) + if p.w.boolWidget.Value { + tracker.Int{IntData: p.w.Parameter}.Set(ra.Max) + } else { + tracker.Int{IntData: p.w.Parameter}.Set(ra.Min) + } + return dims + case tracker.IDParameter: + gtx.Constraints.Min.X = gtx.Dp(unit.Dp(200)) + gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40)) + instrItems := make([]MenuItem, p.tracker.Instruments().Count()) + for i := range instrItems { + i := i + name, _, _ := p.tracker.Instruments().Item(i) + instrItems[i].Text = name + instrItems[i].IconBytes = icons.NavigationChevronRight + instrItems[i].Doer = tracker.Allow(func() { + if id, ok := p.tracker.Instruments().FirstID(i); ok { + tracker.Int{IntData: p.w.Parameter}.Set(id) + } + }) + } + var unitItems []MenuItem + instrName := "" + unitName := "" + targetI, targetU, err := p.tracker.FindUnit(p.w.Parameter.Value()) + if err == nil { + targetInstrument := p.tracker.Instrument(targetI) + instrName = targetInstrument.Name + units := targetInstrument.Units + unitName = fmt.Sprintf("%v: %v", targetU, units[targetU].Type) + unitItems = make([]MenuItem, len(units)) + for j, unit := range units { + id := unit.ID + unitItems[j].Text = fmt.Sprintf("%v: %v", j, unit.Type) + unitItems[j].IconBytes = icons.NavigationChevronRight + unitItems[j].Doer = tracker.Allow(func() { + tracker.Int{IntData: p.w.Parameter}.Set(id) + }) + } + } + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(p.tracker.layoutMenu(instrName, &p.w.instrBtn, &p.w.instrMenu, unit.Dp(200), + instrItems..., + )), + layout.Rigid(p.tracker.layoutMenu(unitName, &p.w.unitBtn, &p.w.unitMenu, unit.Dp(200), + unitItems..., + )), + ) + } + return D{} + }), + layout.Rigid(func(gtx C) D { + if p.w.Parameter.Type() != tracker.IDParameter { + return Label(p.w.Parameter.Hint(), white, p.tracker.Theme.Shaper)(gtx) + } + return D{} + }), + ) +} diff --git a/tracker/int.go b/tracker/int.go new file mode 100644 index 0000000..ecfe373 --- /dev/null +++ b/tracker/int.go @@ -0,0 +1,191 @@ +package tracker + +import ( + "math" + + "github.com/vsariola/sointu/vm" +) + +type ( + Int struct { + IntData + } + + IntData interface { + Value() int + Range() intRange + + setValue(int) + change(kind string) func() + } + + intRange struct { + Min, Max int + } + + InstrumentVoices Model + TrackVoices Model + SongLength Model + BPM Model + RowsPerPattern Model + RowsPerBeat Model + Step Model + Octave Model +) + +func (v Int) Add(delta int) (ok bool) { + r := v.Range() + value := r.Clamp(v.Value() + delta) + if value == v.Value() || value < r.Min || value > r.Max { + return false + } + defer v.change("Add")() + v.setValue(value) + return true +} + +func (v Int) Set(value int) (ok bool) { + r := v.Range() + value = v.Range().Clamp(value) + if value == v.Value() || value < r.Min || value > r.Max { + return false + } + defer v.change("Set")() + v.setValue(value) + return true +} + +func (r intRange) Clamp(value int) int { + return intMax(intMin(value, r.Max), r.Min) +} + +// Model methods + +func (m *Model) InstrumentVoices() *InstrumentVoices { return (*InstrumentVoices)(m) } +func (m *Model) TrackVoices() *TrackVoices { return (*TrackVoices)(m) } +func (m *Model) SongLength() *SongLength { return (*SongLength)(m) } +func (m *Model) BPM() *BPM { return (*BPM)(m) } +func (m *Model) RowsPerPattern() *RowsPerPattern { return (*RowsPerPattern)(m) } +func (m *Model) RowsPerBeat() *RowsPerBeat { return (*RowsPerBeat)(m) } +func (m *Model) Step() *Step { return (*Step)(m) } +func (m *Model) Octave() *Octave { return (*Octave)(m) } + +// BeatsPerMinuteInt + +func (v *BPM) Int() Int { return Int{v} } +func (v *BPM) Value() int { return v.d.Song.BPM } +func (v *BPM) setValue(value int) { v.d.Song.BPM = value } +func (v *BPM) Range() intRange { return intRange{1, 999} } +func (v *BPM) change(kind string) func() { + return (*Model)(v).change("BPMInt."+kind, SongChange, MinorChange) +} + +// RowsPerPatternInt + +func (v *RowsPerPattern) Int() Int { return Int{v} } +func (v *RowsPerPattern) Value() int { return v.d.Song.Score.RowsPerPattern } +func (v *RowsPerPattern) setValue(value int) { v.d.Song.Score.RowsPerPattern = value } +func (v *RowsPerPattern) Range() intRange { return intRange{1, 256} } +func (v *RowsPerPattern) change(kind string) func() { + return (*Model)(v).change("RowsPerPatternInt."+kind, SongChange, MinorChange) +} + +// SongLengthInt + +func (v *SongLength) Int() Int { return Int{v} } +func (v *SongLength) Value() int { return v.d.Song.Score.Length } +func (v *SongLength) setValue(value int) { v.d.Song.Score.Length = value } +func (v *SongLength) Range() intRange { return intRange{1, math.MaxInt32} } +func (v *SongLength) change(kind string) func() { + return (*Model)(v).change("SongLengthInt."+kind, SongChange, MinorChange) +} + +// StepInt + +func (v *Step) Int() Int { return Int{v} } +func (v *Step) Value() int { return v.d.Step } +func (v *Step) setValue(value int) { v.d.Step = value } +func (v *Step) Range() intRange { return intRange{0, 8} } +func (v *Step) change(kind string) func() { + return (*Model)(v).change("StepInt"+kind, NoChange, MinorChange) +} + +// OctaveInt + +func (v *Octave) Int() Int { return Int{v} } +func (v *Octave) Value() int { return v.d.Octave } +func (v *Octave) setValue(value int) { v.d.Octave = value } +func (v *Octave) Range() intRange { return intRange{0, 9} } +func (v *Octave) change(kind string) func() { return func() {} } + +// RowsPerBeatInt + +func (v *RowsPerBeat) Int() Int { return Int{v} } +func (v *RowsPerBeat) Value() int { return v.d.Song.RowsPerBeat } +func (v *RowsPerBeat) setValue(value int) { v.d.Song.RowsPerBeat = value } +func (v *RowsPerBeat) Range() intRange { return intRange{1, 32} } +func (v *RowsPerBeat) change(kind string) func() { + return (*Model)(v).change("RowsPerBeatInt."+kind, SongChange, MinorChange) +} + +// InstrumentVoicesInt + +func (v *InstrumentVoices) Int() Int { + return Int{v} +} + +func (v *InstrumentVoices) Value() int { + if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) { + return 1 + } + return intMax(v.d.Song.Patch[v.d.InstrIndex].NumVoices, 1) +} + +func (v *InstrumentVoices) setValue(value int) { + if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) { + return + } + v.d.Song.Patch[v.d.InstrIndex].NumVoices = value +} + +func (v *InstrumentVoices) Range() intRange { + return intRange{1, vm.MAX_VOICES - v.d.Song.Patch.NumVoices() + v.Value()} +} + +func (v *InstrumentVoices) change(kind string) func() { + return (*Model)(v).change("InstrumentVoicesInt."+kind, PatchChange, MinorChange) +} + +// TrackVoicesInt + +func (v *TrackVoices) Int() Int { + return Int{v} +} + +func (v *TrackVoices) Value() int { + t := v.d.Cursor.Track + if t < 0 || t >= len(v.d.Song.Score.Tracks) { + return 1 + } + return intMax(v.d.Song.Score.Tracks[t].NumVoices, 1) +} + +func (v *TrackVoices) setValue(value int) { + t := v.d.Cursor.Track + if t < 0 || t >= len(v.d.Song.Score.Tracks) { + return + } + v.d.Song.Score.Tracks[t].NumVoices = value +} + +func (v *TrackVoices) Range() intRange { + t := v.d.Cursor.Track + if t < 0 || t >= len(v.d.Song.Score.Tracks) { + return intRange{1, 1} + } + return intRange{1, vm.MAX_VOICES - v.d.Song.Score.NumVoices() + v.d.Song.Score.Tracks[t].NumVoices} +} + +func (v *TrackVoices) change(kind string) func() { + return (*Model)(v).change("TrackVoicesInt."+kind, ScoreChange, MinorChange) +} diff --git a/tracker/list.go b/tracker/list.go new file mode 100644 index 0000000..fdefea3 --- /dev/null +++ b/tracker/list.go @@ -0,0 +1,765 @@ +package tracker + +import ( + "errors" + "fmt" + "strings" + + "github.com/vsariola/sointu" + "github.com/vsariola/sointu/vm" + "gopkg.in/yaml.v2" +) + +type ( + List struct { + ListData + } + + ListData interface { + Selected() int + Selected2() int + SetSelected(int) + SetSelected2(int) + Count() int + } + + MutableListData interface { + change(kind string, severity ChangeSeverity) func() + cancel() + swap(i, j int) (ok bool) + delete(i int) (ok bool) + marshal(from, to int) ([]byte, error) + unmarshal([]byte) (from, to int, err error) + } + + UnitListItem struct { + Type string + StackNeed, StackBefore, StackAfter int + } + + UnitYieldFunc func(item UnitListItem) (ok bool) + UnitSearchYieldFunc func(item string) (ok bool) + + Instruments Model // Instruments is a list of instruments, implementing ListData & MutableListData interfaces + Units Model // Units is a list of all the units in the selected instrument, implementing ListData & MutableListData interfaces + Tracks Model // Tracks is a list of all the tracks, implementing ListData & MutableListData interfaces + OrderRows Model // OrderRows is a list of all the order rows, implementing ListData & MutableListData interfaces + NoteRows Model // NoteRows is a list of all the note rows, implementing ListData & MutableListData interfaces + SearchResults Model // SearchResults is a unmutable list of all the search results, implementing ListData interface + Presets Model // Presets is a unmutable list of all the presets, implementing ListData interface +) + +// Model methods + +func (m *Model) Instruments() *Instruments { return (*Instruments)(m) } +func (m *Model) Units() *Units { return (*Units)(m) } +func (m *Model) Tracks() *Tracks { return (*Tracks)(m) } +func (m *Model) OrderRows() *OrderRows { return (*OrderRows)(m) } +func (m *Model) NoteRows() *NoteRows { return (*NoteRows)(m) } +func (m *Model) SearchResults() *SearchResults { return (*SearchResults)(m) } + +// MoveElements moves the selected elements in a list by delta. If delta is +// negative, the elements move up, otherwise down. The list must implement the +// MutableListData interface. +func (v List) MoveElements(delta int) (ok bool) { + if delta == 0 { + return false + } + s, ok := v.ListData.(MutableListData) + if !ok { + return + } + defer s.change("MoveElements", MajorChange)() + a, b := v.listRange() + if a+delta < 0 { + delta = -a + } + if b+delta >= v.Count() { + delta = v.Count() - 1 - b + } + if delta < 0 { + for i := a; i <= b; i++ { + if !s.swap(i, i+delta) { + s.cancel() + return false + } + } + } else { + for i := b; i >= a; i-- { + if !s.swap(i, i+delta) { + s.cancel() + return false + } + } + } + v.SetSelected(v.Selected() + delta) + v.SetSelected2(v.Selected2() + delta) + return true +} + +// DeleteElements deletes the selected elements in a list. The list must +// implement the MutableListData interface. +func (v List) DeleteElements(backwards bool) (ok bool) { + d, ok := v.ListData.(MutableListData) + if !ok { + return + } + defer d.change("DeleteElements", MajorChange)() + a, b := v.listRange() + for i := b; i >= a; i-- { + if !d.delete(i) { + d.cancel() + return false + } + } + if backwards && a > 0 { + a-- + } + v.SetSelected(a) + v.SetSelected2(a) + return true +} + +// CopyElements copies the selected elements in a list. The list must implement +// the MutableListData interface. Returns the copied data, marshaled into byte +// slice, and true if successful. +func (v List) CopyElements() ([]byte, bool) { + a, b := v.listRange() + m, ok := v.ListData.(MutableListData) + if !ok { + return nil, false + } + ret, err := m.marshal(a, b) + if err != nil { + return nil, false + } + return ret, true +} + +// PasteElements pastes the data into the list. The data is unmarshaled from the +// byte slice. The list must implement the MutableListData interface. Returns +// true if successful. +func (v List) PasteElements(data []byte) (ok bool) { + m, ok := v.ListData.(MutableListData) + if !ok { + return false + } + defer m.change("PasteElements", MajorChange)() + from, to, err := m.unmarshal(data) + if err != nil { + m.cancel() + return false + } + v.SetSelected(from) + v.SetSelected2(to) + return true +} + +func (v *List) listRange() (lower, higher int) { + lower = intMin(v.Selected(), v.Selected2()) + higher = intMax(v.Selected(), v.Selected2()) + return +} + +// Instruments methods + +func (v *Instruments) List() List { + return List{v} +} + +func (v *Instruments) Item(i int) (name string, maxLevel float32, ok bool) { + if i < 0 || i >= len(v.d.Song.Patch) { + return "", 0, false + } + name = v.d.Song.Patch[i].Name + start := v.d.Song.Patch.FirstVoiceForInstrument(i) + end := start + v.d.Song.Patch[i].NumVoices + if end >= vm.MAX_VOICES { + end = vm.MAX_VOICES + } + if start < end { + for _, level := range v.voiceLevels[start:end] { + if maxLevel < level { + maxLevel = level + } + } + } + ok = true + return +} +func (v *Instruments) FirstID(i int) (id int, ok bool) { + if i < 0 || i >= len(v.d.Song.Patch) { + return 0, false + } + if len(v.d.Song.Patch[i].Units) == 0 { + return 0, false + } + return v.d.Song.Patch[i].Units[0].ID, true +} + +func (v *Instruments) Selected() int { + return intMax(intMin(v.d.InstrIndex, v.Count()-1), 0) +} + +func (v *Instruments) Selected2() int { + return intMax(intMin(v.d.InstrIndex2, v.Count()-1), 0) +} + +func (v *Instruments) SetSelected(value int) { + v.d.InstrIndex = intMax(intMin(value, v.Count()-1), 0) + v.d.UnitIndex = 0 + v.d.UnitIndex2 = 0 +} + +func (v *Instruments) SetSelected2(value int) { + v.d.InstrIndex2 = intMax(intMin(value, v.Count()-1), 0) +} + +func (v *Instruments) swap(i, j int) (ok bool) { + if i < 0 || j < 0 || i >= len(v.d.Song.Patch) || j >= len(v.d.Song.Patch) || i == j { + return false + } + instr := v.d.Song.Patch + instr[i], instr[j] = instr[j], instr[i] + return true +} + +func (v *Instruments) delete(i int) (ok bool) { + if i < 0 || i >= len(v.d.Song.Patch) { + return false + } + v.d.Song.Patch = append(v.d.Song.Patch[:i], v.d.Song.Patch[i+1:]...) + return true +} + +func (v *Instruments) change(n string, severity ChangeSeverity) func() { + return (*Model)(v).change("InstrumentListView."+n, PatchChange, severity) +} + +func (v *Instruments) cancel() { + v.changeCancel = true +} + +func (v *Instruments) Count() int { + return len(v.d.Song.Patch) +} + +func (v *Instruments) marshal(from, to int) ([]byte, error) { + if from < 0 || to >= len(v.d.Song.Patch) || from > to { + return nil, fmt.Errorf("InstrumentListView.marshal: index out of range: %d, %d", from, to) + } + ret, err := yaml.Marshal(struct{ Patch sointu.Patch }{v.d.Song.Patch[from : to+1]}) + if err != nil { + return nil, fmt.Errorf("InstrumentListView.marshal: %v", err) + } + return ret, nil +} + +func (v *Instruments) unmarshal(data []byte) (from, to int, err error) { + var newInstr struct{ Patch sointu.Patch } + if err := yaml.Unmarshal(data, &newInstr); err != nil { + return 0, 0, fmt.Errorf("InstrumentListView.unmarshal: %v", err) + } + if len(newInstr.Patch) == 0 { + return 0, 0, errors.New("InstrumentListView.unmarshal: no instruments") + } + if v.d.Song.Patch.NumVoices()+newInstr.Patch.NumVoices() > vm.MAX_VOICES { + return 0, 0, fmt.Errorf("InstrumentListView.unmarshal: too many voices: %d", v.d.Song.Patch.NumVoices()+newInstr.Patch.NumVoices()) + } + patch := append(v.d.Song.Patch, make([]sointu.Instrument, len(newInstr.Patch))...) + sel := v.Selected() + copy(patch[sel+len(newInstr.Patch):], patch[sel:]) + copy(patch[sel:sel+len(newInstr.Patch)], newInstr.Patch) + v.d.Song.Patch = patch + from = sel + to = sel + len(newInstr.Patch) - 1 + return +} + +// Units methods + +func (v *Units) List() List { + return List{v} +} + +func (m *Units) SelectedType() string { + if m.d.InstrIndex < 0 || + m.d.InstrIndex >= len(m.d.Song.Patch) || + m.d.UnitIndex < 0 || + m.d.UnitIndex >= len(m.d.Song.Patch[m.d.InstrIndex].Units) { + return "" + } + return m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex].Type +} + +func (m *Units) SetSelectedType(t string) { + if m.d.InstrIndex < 0 || + m.d.InstrIndex >= len(m.d.Song.Patch) || + m.d.UnitIndex < 0 || + m.d.UnitIndex >= len(m.d.Song.Patch[m.d.InstrIndex].Units) { + return + } + unit, ok := defaultUnits[t] + if !ok { // if the type is invalid, we just set it to empty unit + unit = sointu.Unit{Parameters: make(map[string]int)} + } else { + unit = unit.Copy() + } + oldUnit := m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex] + if oldUnit.Type == unit.Type { + return + } + defer m.change("SetSelectedType", MajorChange)() + m.d.UnitSearchString = unit.Type + m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex] = unit + m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex].ID = oldUnit.ID // keep the ID of the replaced unit +} + +func (v *Units) Iterate(yield UnitYieldFunc) { + if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) { + return + } + stackBefore := 0 + for _, unit := range v.d.Song.Patch[v.d.InstrIndex].Units { + stackAfter := stackBefore + unit.StackChange() + if !yield(UnitListItem{unit.Type, unit.StackNeed(), stackBefore, stackAfter}) { + break + } + stackBefore = stackAfter + } +} + +func (v *Units) Selected() int { + return intMax(intMin(v.d.UnitIndex, v.Count()-1), 0) +} + +func (v *Units) Selected2() int { + return intMax(intMin(v.d.UnitIndex2, v.Count()-1), 0) +} + +func (v *Units) SetSelected(value int) { + m := (*Model)(v) + m.d.UnitIndex = intMax(intMin(value, v.Count()-1), 0) + m.d.ParamIndex = 0 + if m.d.UnitIndex >= 0 && m.d.UnitIndex < len(m.d.Song.Patch[m.d.InstrIndex].Units) { + m.d.UnitSearchString = m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex].Type + } +} + +func (v *Units) SetSelected2(value int) { + (*Model)(v).d.UnitIndex2 = intMax(intMin(value, v.Count()-1), 0) +} + +func (v *Units) Count() int { + m := (*Model)(v) + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return 0 + } + return len(m.d.Song.Patch[(*Model)(v).d.InstrIndex].Units) +} + +func (v *Units) swap(i, j int) (ok bool) { + m := (*Model)(v) + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return false + } + units := m.d.Song.Patch[m.d.InstrIndex].Units + if i < 0 || j < 0 || i >= len(units) || j >= len(units) || i == j { + return false + } + units[i], units[j] = units[j], units[i] + return true +} + +func (v *Units) delete(i int) (ok bool) { + m := (*Model)(v) + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return false + } + units := m.d.Song.Patch[m.d.InstrIndex].Units + if i < 0 || i >= len(units) { + return false + } + units = append(units[:i], units[i+1:]...) + m.d.Song.Patch[m.d.InstrIndex].Units = units + return true +} + +func (v *Units) change(n string, severity ChangeSeverity) func() { + return (*Model)(v).change("UnitListView."+n, PatchChange, severity) +} + +func (v *Units) cancel() { + (*Model)(v).changeCancel = true +} + +func (v *Units) marshal(from, to int) ([]byte, error) { + m := (*Model)(v) + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return nil, errors.New("UnitListView.marshal: no instruments") + } + if from < 0 || to >= len(m.d.Song.Patch[m.d.InstrIndex].Units) || from > to { + return nil, fmt.Errorf("UnitListView.marshal: index out of range: %d, %d", from, to) + } + ret, err := yaml.Marshal(struct{ Units []sointu.Unit }{m.d.Song.Patch[m.d.InstrIndex].Units[from : to+1]}) + if err != nil { + return nil, fmt.Errorf("UnitListView.marshal: %v", err) + } + return ret, nil +} + +func (v *Units) unmarshal(data []byte) (from, to int, err error) { + m := (*Model)(v) + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return 0, 0, errors.New("UnitListView.unmarshal: no instruments") + } + var pastedUnits struct{ Units []sointu.Unit } + if err := yaml.Unmarshal(data, &pastedUnits); err != nil { + return 0, 0, fmt.Errorf("UnitListView.unmarshal: %v", err) + } + if len(pastedUnits.Units) == 0 { + return 0, 0, errors.New("UnitListView.unmarshal: no units") + } + m.assignUnitIDs(pastedUnits.Units) + sel := v.Selected() + units := append(m.d.Song.Patch[m.d.InstrIndex].Units, make([]sointu.Unit, len(pastedUnits.Units))...) + copy(units[sel+len(pastedUnits.Units):], units[sel:]) + copy(units[sel:], pastedUnits.Units) + m.d.Song.Patch[m.d.InstrIndex].Units = units + from = sel + to = sel + len(pastedUnits.Units) - 1 + return +} + +// Tracks methods + +func (v *Tracks) List() List { + return List{v} +} + +func (v *Tracks) Selected() int { + return intMax(intMin(v.d.Cursor.Track, v.Count()-1), 0) +} + +func (v *Tracks) Selected2() int { + return intMax(intMin(v.d.Cursor2.Track, v.Count()-1), 0) +} + +func (v *Tracks) SetSelected(value int) { + v.d.Cursor.Track = intMax(intMin(value, v.Count()-1), 0) +} + +func (v *Tracks) SetSelected2(value int) { + v.d.Cursor2.Track = intMax(intMin(value, v.Count()-1), 0) +} + +func (v *Tracks) swap(i, j int) (ok bool) { + m := (*Model)(v) + if i < 0 || j < 0 || i >= len(m.d.Song.Score.Tracks) || j >= len(m.d.Song.Score.Tracks) || i == j { + return false + } + tracks := m.d.Song.Score.Tracks + tracks[i], tracks[j] = tracks[j], tracks[i] + return true +} + +func (v *Tracks) delete(i int) (ok bool) { + m := (*Model)(v) + if i < 0 || i >= len(m.d.Song.Score.Tracks) { + return false + } + m.d.Song.Score.Tracks = append(m.d.Song.Score.Tracks[:i], m.d.Song.Score.Tracks[i+1:]...) + return true +} + +func (v *Tracks) change(n string, severity ChangeSeverity) func() { + return (*Model)(v).change("TrackList."+n, ScoreChange, severity) +} + +func (v *Tracks) cancel() { + v.changeCancel = true +} + +func (v *Tracks) Count() int { + return len((*Model)(v).d.Song.Score.Tracks) +} + +func (v *Tracks) marshal(from, to int) ([]byte, error) { + m := (*Model)(v) + if from < 0 || to >= len(m.d.Song.Score.Tracks) || from > to { + return nil, fmt.Errorf("TrackListView.marshal: index out of range: %d, %d", from, to) + } + ret, err := yaml.Marshal(struct{ Score sointu.Score }{sointu.Score{Tracks: m.d.Song.Score.Tracks[from : to+1]}}) + if err != nil { + return nil, fmt.Errorf("TrackListView.marshal: %v", err) + } + return ret, nil +} + +func (v *Tracks) unmarshal(data []byte) (from, to int, err error) { + m := (*Model)(v) + var newTracks struct{ Score sointu.Score } + if err := yaml.Unmarshal(data, &newTracks); err != nil { + return 0, 0, fmt.Errorf("TrackListView.unmarshal: %v", err) + } + if len(newTracks.Score.Tracks) == 0 { + return 0, 0, errors.New("TrackListView.unmarshal: no tracks") + } + if v.d.Song.Score.NumVoices()+newTracks.Score.NumVoices() > vm.MAX_VOICES { + return 0, 0, fmt.Errorf("InstrumentListView.unmarshal: too many voices: %d", v.d.Song.Patch.NumVoices()+newTracks.Score.NumVoices()) + } + from = m.d.Cursor.Track + to = m.d.Cursor.Track + len(newTracks.Score.Tracks) - 1 + tracks := m.d.Song.Score.Tracks + newTracks.Score.Tracks = append(newTracks.Score.Tracks, tracks[m.d.Cursor.Track:]...) + tracks = append(tracks[:m.d.Cursor.Track], newTracks.Score.Tracks...) + m.d.Song.Score.Tracks = tracks + return +} + +// OrderRows methods + +func (v *OrderRows) List() List { + return List{v} +} + +func (v *OrderRows) Selected() int { + p := v.d.Cursor.OrderRow + p = intMax(intMin(p, v.Count()-1), 0) + return p +} + +func (v *OrderRows) Selected2() int { + p := v.d.Cursor2.OrderRow + p = intMax(intMin(p, v.Count()-1), 0) + return p +} + +func (v *OrderRows) SetSelected(value int) { + y := intMax(intMin(value, v.Count()-1), 0) + if y != v.d.Cursor.OrderRow { + v.noteTracking = false + } + v.d.Cursor.OrderRow = y +} + +func (v *OrderRows) SetSelected2(value int) { + v.d.Cursor2.OrderRow = intMax(intMin(value, v.Count()-1), 0) +} + +func (v *OrderRows) swap(x, y int) (ok bool) { + for i := range v.d.Song.Score.Tracks { + track := &v.d.Song.Score.Tracks[i] + a, b := track.Order.Get(x), track.Order.Get(y) + track.Order.Set(x, b) + track.Order.Set(y, a) + } + return true +} + +func (v *OrderRows) delete(i int) (ok bool) { + for _, track := range v.d.Song.Score.Tracks { + if i < len(track.Order) { + track.Order = append(track.Order[:i], track.Order[i+1:]...) + } + } + return true +} + +func (v *OrderRows) change(n string, severity ChangeSeverity) func() { + return (*Model)(v).change("OrderRowList."+n, ScoreChange, severity) +} + +func (v *OrderRows) cancel() { + v.changeCancel = true +} + +func (v *OrderRows) Count() int { + return v.d.Song.Score.Length +} + +type marshalOrderRows struct { + Columns [][]int `yaml:",flow"` +} + +func (v *OrderRows) marshal(from, to int) ([]byte, error) { + var table marshalOrderRows + for i := range v.d.Song.Score.Tracks { + table.Columns = append(table.Columns, make([]int, to-from+1)) + for j := 0; j < to-from+1; j++ { + table.Columns[i][j] = v.d.Song.Score.Tracks[i].Order.Get(from + j) + } + } + return yaml.Marshal(table) +} + +func (v *OrderRows) unmarshal(data []byte) (from, to int, err error) { + var table marshalOrderRows + err = yaml.Unmarshal(data, &table) + if err != nil { + return + } + if len(table.Columns) == 0 { + err = errors.New("OrderRowList.unmarshal: no rows") + return + } + from = v.d.Cursor.OrderRow + to = v.d.Cursor.OrderRow + len(table.Columns[0]) - 1 + for i := range v.d.Song.Score.Tracks { + if i >= len(table.Columns) { + break + } + order := &v.d.Song.Score.Tracks[i].Order + for j := 0; j < from-len(*order); j++ { + *order = append(*order, -1) + } + if len(*order) > from { + table.Columns[i] = append(table.Columns[i], (*order)[from:]...) + *order = (*order)[:from] + } + *order = append(*order, table.Columns[i]...) + } + return +} + +// NoteRows methods + +func (v *NoteRows) List() List { + return List{v} +} + +func (v *NoteRows) Selected() int { + return v.d.Song.Score.SongRow(v.d.Song.Score.Clamp(v.d.Cursor.SongPos)) +} + +func (v *NoteRows) Selected2() int { + return v.d.Song.Score.SongRow(v.d.Song.Score.Clamp(v.d.Cursor2.SongPos)) +} + +func (v *NoteRows) SetSelected(value int) { + if value != v.d.Song.Score.SongRow(v.d.Cursor.SongPos) { + v.noteTracking = false + } + v.d.Cursor.SongPos = v.d.Song.Score.Clamp(v.d.Song.Score.SongPos(value)) +} + +func (v *NoteRows) SetSelected2(value int) { + v.d.Cursor2.SongPos = v.d.Song.Score.Clamp(v.d.Song.Score.SongPos(value)) + +} + +func (v *NoteRows) swap(i, j int) (ok bool) { + ipos := v.d.Song.Score.SongPos(i) + jpos := v.d.Song.Score.SongPos(j) + for _, track := range v.d.Song.Score.Tracks { + n1 := track.Note(ipos) + n2 := track.Note(jpos) + track.SetNote(ipos, n2) + track.SetNote(jpos, n1) + } + return true +} + +func (v *NoteRows) delete(i int) (ok bool) { + if i < 0 || i >= v.Count() { + return + } + pos := v.d.Song.Score.SongPos(i) + for _, track := range v.d.Song.Score.Tracks { + track.SetNote(pos, 1) + } + return true +} + +func (v *NoteRows) change(n string, severity ChangeSeverity) func() { + return (*Model)(v).change("NoteRowList."+n, ScoreChange, severity) +} + +func (v *NoteRows) cancel() { + (*Model)(v).changeCancel = true +} + +func (v *NoteRows) Count() int { + return (*Model)(v).d.Song.Score.Length * v.d.Song.Score.RowsPerPattern +} + +type marshalNoteRows struct { + NoteRows [][]byte `yaml:",flow"` +} + +func (v *NoteRows) marshal(from, to int) ([]byte, error) { + var table marshalNoteRows + for i, track := range v.d.Song.Score.Tracks { + table.NoteRows = append(table.NoteRows, make([]byte, to-from+1)) + for j := 0; j < to-from+1; j++ { + row := from + j + pos := v.d.Song.Score.SongPos(row) + table.NoteRows[i][j] = track.Note(pos) + } + } + return yaml.Marshal(table) +} + +func (v *NoteRows) unmarshal(data []byte) (from, to int, err error) { + var table marshalNoteRows + if err := yaml.Unmarshal(data, &table); err != nil { + return 0, 0, fmt.Errorf("NoteRowList.unmarshal: %v", err) + } + if len(table.NoteRows) < 1 { + return 0, 0, errors.New("NoteRowList.unmarshal: no tracks") + } + from = v.d.Song.Score.SongRow(v.d.Cursor.SongPos) + for i, arr := range table.NoteRows { + if i >= len(v.d.Song.Score.Tracks) { + continue + } + to = from + len(arr) - 1 + for j, note := range arr { + y := j + from + pos := v.d.Song.Score.SongPos(y) + v.d.Song.Score.Tracks[i].SetNote(pos, note) + } + } + return +} + +// SearchResults + +func (v *SearchResults) List() List { + return List{v} +} + +func (l *SearchResults) Iterate(yield UnitSearchYieldFunc) { + for _, name := range sointu.UnitNames { + if !strings.HasPrefix(name, l.d.UnitSearchString) { + continue + } + if !yield(name) { + break + } + } +} + +func (l *SearchResults) Selected() int { + return intMax(intMin(l.d.UnitSearchIndex, l.Count()-1), 0) +} + +func (l *SearchResults) Selected2() int { + return intMax(intMin(l.d.UnitSearchIndex, l.Count()-1), 0) +} + +func (l *SearchResults) SetSelected(value int) { + l.d.UnitSearchIndex = intMax(intMin(value, l.Count()-1), 0) +} + +func (l *SearchResults) SetSelected2(value int) { +} + +func (l *SearchResults) Count() (count int) { + for _, n := range sointu.UnitNames { + if strings.HasPrefix(n, l.d.UnitSearchString) { + count++ + } + } + return +} diff --git a/tracker/model.go b/tracker/model.go index ceefb55..9f79ca5 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -4,15 +4,11 @@ import ( "encoding/json" "errors" "fmt" - "math" "os" "path/filepath" - "strconv" - "strings" "github.com/vsariola/sointu" "github.com/vsariola/sointu/vm" - "golang.org/x/exp/slices" ) // Model implements the mutable state for the tracker program GUI. @@ -25,42 +21,71 @@ import ( type ( // modelData is the part of the model that gets save to recovery file modelData struct { - Song sointu.Song - SelectionCorner ScorePoint - Cursor ScorePoint - LowNibble bool - InstrIndex int - UnitIndex int - ParamIndex int - Octave int - UsedIDs map[int]bool - MaxID int - FilePath string - ChangedSinceSave bool - PatternUseCount [][]int - InstrEnlarged bool - RecoveryFilePath string - ChangedSinceRecovery bool + Song sointu.Song + Cursor, Cursor2 Cursor + LowNibble bool + InstrIndex, InstrIndex2 int + UnitIndex, UnitIndex2 int + ParamIndex int + UnitSearchIndex int + UnitSearchString string + Octave int + Step int + FilePath string + ChangedSinceSave bool + RecoveryFilePath string + ChangedSinceRecovery bool } Model struct { d modelData - prevUndoType string + instrEnlarged bool + commentExpanded bool + + prevUndoKind string undoSkipCounter int undoStack []modelData redoStack []modelData - panic bool - playing bool - recording bool - playPosition ScoreRow - noteTracking bool + changeLevel int + changeCancel bool + changeSeverity ChangeSeverity + changeType ChangeType - PlayerMessages <-chan PlayerMessage + panic bool + recording bool + playing bool + playPosition sointu.SongPos + noteTracking bool + quitted bool + + cachePatternUseCount [][]int + + voiceLevels [vm.MAX_VOICES]float32 + avgVolume Volume + peakVolume Volume + + alerts []Alert + dialog Dialog + synther sointu.Synther // the synther used to create new synths + + PlayerMessages chan PlayerMsg modelMessages chan<- interface{} } + // Cursor identifies a row and a track in a song score. + Cursor struct { + Track int + sointu.SongPos + } + + Explore struct { + IsSave bool // true if this is a save operation, false if open operation + IsSong bool // true if this is a song, false if instrument + Continuation func(string) // function to call with the selected file path + } + // Describes a note triggered either a track or an instrument // If Go had union or Either types, this would be it, but in absence // those, this uses a boolean to define if the instrument is defined or the track @@ -69,75 +94,164 @@ type ( Instr int Track int Note byte + + model *Model } - ModelPlayingChangedMessage struct { - bool - } + IsPlayingMsg struct{ bool } + StartPlayMsg struct{ sointu.SongPos } + BPMMsg struct{ int } + RowsPerBeatMsg struct{ int } + PanicMsg struct{ bool } + RecordingMsg struct{ bool } + NoteOnMsg struct{ NoteID } + NoteOffMsg struct{ NoteID } - ModelPlayFromPositionMessage struct { - ScoreRow - } + ChangeSeverity int + ChangeType int - ModelBPMChangedMessage struct { - int - } - - ModelRowsPerBeatChangedMessage struct { - int - } - - ModelPanicMessage struct { - bool - } - - ModelRecordingMessage struct { - bool - } - - ModelNoteOnMessage struct { - NoteID - } - - ModelNoteOffMessage struct { - NoteID - } + Dialog int ) -type Parameter struct { - Type ParameterType - Name string - Hint string - Value int - Min int - Max int - LargeStep int -} - -type ParameterType int +const ( + MajorChange ChangeSeverity = iota + MinorChange +) const ( - IntegerParameter ParameterType = iota - BoolParameter - IDParameter + NoChange ChangeType = 0 + PatchChange ChangeType = 1 << iota + ScoreChange + BPMChange + RowsPerBeatChange + SongChange ChangeType = PatchChange | ScoreChange | BPMChange | RowsPerBeatChange +) + +const ( + NoDialog = iota + SaveAsExplorer + NewSongChanges + NewSongSaveExplorer + OpenSongChanges + OpenSongSaveExplorer + OpenSongOpenExplorer + Export + ExportFloatExplorer + ExportInt16Explorer + QuitChanges + QuitSaveExplorer ) const maxUndo = 64 -func NewModel(modelMessages chan<- interface{}, playerMessages <-chan PlayerMessage, recoveryFilePath string) *Model { - ret := new(Model) - ret.modelMessages = modelMessages - ret.PlayerMessages = playerMessages - ret.setSongNoUndo(defaultSong.Copy()) - ret.d.Octave = 4 - ret.d.RecoveryFilePath = recoveryFilePath +func (m *Model) AverageVolume() Volume { return m.avgVolume } +func (m *Model) PeakVolume() Volume { return m.peakVolume } +func (m *Model) PlayPosition() sointu.SongPos { return m.playPosition } +func (m *Model) PlaySongRow() int { return m.d.Song.Score.SongRow(m.playPosition) } +func (m *Model) ChangedSinceSave() bool { return m.d.ChangedSinceSave } +func (m *Model) Dialog() Dialog { return m.dialog } +func (m *Model) Quitted() bool { return m.quitted } + +// NewModelPlayer creates a new model and a player that communicates with it +func NewModelPlayer(synther sointu.Synther, recoveryFilePath string) (*Model, *Player) { + m := new(Model) + m.synther = synther + modelMessages := make(chan interface{}, 1024) + playerMessages := make(chan PlayerMsg, 1024) + m.modelMessages = modelMessages + m.PlayerMessages = playerMessages + m.d.Octave = 4 + m.d.RecoveryFilePath = recoveryFilePath + m.resetSong() if recoveryFilePath != "" { - if bytes2, err := os.ReadFile(ret.d.RecoveryFilePath); err == nil { - json.Unmarshal(bytes2, &ret.d) - ret.send(ret.d.Song.Copy()) + if bytes2, err := os.ReadFile(m.d.RecoveryFilePath); err == nil { + json.Unmarshal(bytes2, &m.d) + } + } + p := &Player{ + playerMsgs: playerMessages, + modelMsgs: modelMessages, + synther: synther, + song: m.d.Song.Copy(), + avgVolumeMeter: VolumeAnalyzer{Attack: 0.3, Release: 0.3, Min: -100, Max: 20}, + peakVolumeMeter: VolumeAnalyzer{Attack: 1e-4, Release: 1, Min: -100, Max: 20}, + } + p.compileOrUpdateSynth() + return m, p +} + +func (m *Model) change(kind string, t ChangeType, severity ChangeSeverity) func() { + if m.changeLevel == 0 { + m.changeType = NoChange + m.undoStack = append(m.undoStack, m.d.Copy()) + m.changeCancel = false + m.changeSeverity = severity + } else { + if m.changeSeverity < severity { + m.changeSeverity = severity + } + } + m.changeType |= t + m.changeLevel++ + return func() { + m.changeLevel-- + if m.changeLevel < 0 { + panic("changeLevel < 0, mismatched change() calls") + } + if m.changeLevel == 0 { + if m.changeCancel || m.d.Song.BPM <= 0 || m.d.Song.RowsPerBeat <= 0 || m.d.Song.Score.Length <= 0 { + // the change was cancelled or put the song in invalid state, so we don't save it + m.d = m.undoStack[len(m.undoStack)-1] + m.undoStack = m.undoStack[:len(m.undoStack)-1] + return + } + m.d.ChangedSinceSave = true + m.d.ChangedSinceRecovery = true + if m.changeType&ScoreChange != 0 { + m.updatePatternUseCount() + m.d.Cursor.SongPos = m.d.Song.Score.Wrap(m.d.Cursor.SongPos) + m.d.Cursor2.SongPos = m.d.Song.Score.Wrap(m.d.Cursor2.SongPos) + m.send(m.d.Song.Score.Copy()) + } + if m.changeType&PatchChange != 0 { + m.d.InstrIndex = clamp(m.d.InstrIndex, 0, len(m.d.Song.Patch)-1) + m.d.InstrIndex2 = clamp(m.d.InstrIndex2, 0, len(m.d.Song.Patch)-1) + unitCount := 0 + if m.d.InstrIndex >= 0 && m.d.InstrIndex < len(m.d.Song.Patch) { + unitCount = len(m.d.Song.Patch[m.d.InstrIndex].Units) + } + m.d.UnitIndex = clamp(m.d.UnitIndex, 0, unitCount-1) + m.d.UnitIndex2 = clamp(m.d.UnitIndex2, 0, unitCount-1) + m.send(m.d.Song.Patch.Copy()) + } + if m.changeType&BPMChange != 0 { + m.send(BPMMsg{m.d.Song.BPM}) + } + if m.changeType&RowsPerBeatChange != 0 { + m.send(RowsPerBeatMsg{m.d.Song.RowsPerBeat}) + } + m.undoSkipCounter++ + var limit int + switch m.changeSeverity { + default: + case MajorChange: + limit = 1 + case MinorChange: + limit = 10 + } + if m.prevUndoKind == kind && m.undoSkipCounter < limit { + m.undoStack = m.undoStack[:len(m.undoStack)-1] + return + } + m.undoSkipCounter = 0 + m.prevUndoKind = kind + m.redoStack = m.redoStack[:0] + if len(m.undoStack) > maxUndo { + copy(m.undoStack, m.undoStack[len(m.undoStack)-maxUndo:]) + m.undoStack = m.undoStack[:maxUndo] + } } } - return ret } func (m *Model) MarshalRecovery() []byte { @@ -191,1225 +305,83 @@ func (m *Model) UnmarshalRecovery(bytes []byte) { } m.d.ChangedSinceRecovery = false m.send(m.d.Song.Copy()) + m.updatePatternUseCount() } -func (m *Model) FilePath() string { - return m.d.FilePath -} - -func (m *Model) SetFilePath(value string) { - m.d.FilePath = value -} - -func (m *Model) ChangedSinceSave() bool { - return m.d.ChangedSinceSave -} - -func (m *Model) SetChangedSinceSave(value bool) { - m.d.ChangedSinceSave = value -} - -func (m *Model) ResetSong() { - m.SetSong(defaultSong.Copy()) - m.d.FilePath = "" - m.d.ChangedSinceSave = false -} - -func (m *Model) SetSong(song sointu.Song) { - // guard for malformed songs - if len(song.Score.Tracks) == 0 || song.Score.Length <= 0 || len(song.Patch) == 0 { - return +func (m *Model) ProcessPlayerMessage(msg PlayerMsg) { + m.playPosition = msg.SongPosition + m.voiceLevels = msg.VoiceLevels + m.avgVolume = msg.AverageVolume + m.peakVolume = msg.PeakVolume + if m.playing && m.noteTracking { + m.d.Cursor.SongPos = msg.SongPosition + m.d.Cursor2.SongPos = msg.SongPosition } - m.saveUndo("SetSong", 0) - m.setSongNoUndo(song) -} - -// Returns the current octave for jamming and inputting nodes -func (m *Model) Octave() int { - return m.d.Octave -} - -// Sets the current octave for jamming and inputting nodes and returns true if -// it changed. The value is clamped to 0..9 -func (m *Model) SetOctave(value int) bool { - value = clamp(value, 0, 9) - if m.d.Octave == value { - return false - } - m.d.Octave = value - return true -} - -func (m *Model) ProcessPlayerMessage(msg PlayerMessage) { - m.playPosition = msg.SongRow m.panic = msg.Panic switch e := msg.Inner.(type) { + case func(): + e() case Recording: if e.BPM == 0 { e.BPM = float64(m.d.Song.BPM) } - song, err := e.Song(m.d.Song.Patch, m.d.Song.RowsPerBeat, m.d.Song.Score.RowsPerPattern) - if err != nil { + score, err := e.Score(m.d.Song.Patch, m.d.Song.RowsPerBeat, m.d.Song.Score.RowsPerPattern) + if err != nil || score.Length <= 0 { break } - m.SetSong(song) - m.d.InstrEnlarged = false + defer m.change("Recording", SongChange, MajorChange)() + m.d.Song.Score = score + m.d.Song.BPM = int(e.BPM + 0.5) + m.instrEnlarged = false + case Alert: + m.Alerts().AddAlert(e) default: } } -func (m *Model) SetInstrument(instrument sointu.Instrument) bool { - if len(instrument.Units) == 0 { - return false - } - m.saveUndo("SetInstrument", 0) - m.freeUnitIDs(m.d.Song.Patch[m.d.InstrIndex].Units) - m.assignUnitIDs(instrument.Units) - m.d.Song.Patch[m.d.InstrIndex] = instrument - m.clampPositions() - m.send(m.d.Song.Patch.Copy()) - return true -} - -func (m *Model) SetInstrIndex(value int) { - m.d.InstrIndex = value - m.clampPositions() -} - -func (m *Model) SetInstrumentVoices(value int) { - if value < 1 { - value = 1 - } - maxRemain := m.MaxInstrumentVoices() - if value > maxRemain { - value = maxRemain - } - if m.Instrument().NumVoices == value { - return - } - m.saveUndo("SetInstrumentVoices", 10) - m.d.Song.Patch[m.d.InstrIndex].NumVoices = value - m.send(m.d.Song.Patch.Copy()) -} - -func (m *Model) MaxInstrumentVoices() int { - maxRemain := 32 - m.d.Song.Patch.NumVoices() + m.Instrument().NumVoices - if maxRemain < 1 { - return 1 - } - return maxRemain -} - -func (m *Model) SetInstrumentName(name string) { - name = strings.TrimSpace(name) - if m.Instrument().Name == name { - return - } - m.saveUndo("SetInstrumentName", 10) - m.d.Song.Patch[m.d.InstrIndex].Name = name -} - -func (m *Model) SetInstrumentComment(comment string) { - if m.Instrument().Comment == comment { - return - } - m.saveUndo("SetInstrumentComment", 10) - m.d.Song.Patch[m.d.InstrIndex].Comment = comment -} - -func (m *Model) SetBPM(value int) { - if value < 1 { - value = 1 - } - if value > 999 { - value = 999 - } - if m.d.Song.BPM == value { - return - } - m.saveUndo("SetBPM", 100) - m.d.Song.BPM = value - m.send(ModelBPMChangedMessage{value}) -} - -func (m *Model) SetRowsPerBeat(value int) { - if value < 1 { - value = 1 - } - if value > 32 { - value = 32 - } - if m.d.Song.RowsPerBeat == value { - return - } - m.saveUndo("SetRowsPerBeat", 10) - m.d.Song.RowsPerBeat = value - m.send(ModelRowsPerBeatChangedMessage{value}) -} - -func (m *Model) AddTrack(after bool) { - if !m.CanAddTrack() { - return - } - m.saveUndo("AddTrack", 0) - newTracks := make([]sointu.Track, len(m.d.Song.Score.Tracks)+1) - if after { - m.d.Cursor.Track++ - } - copy(newTracks, m.d.Song.Score.Tracks[:m.d.Cursor.Track]) - copy(newTracks[m.d.Cursor.Track+1:], m.d.Song.Score.Tracks[m.d.Cursor.Track:]) - newTracks[m.d.Cursor.Track] = sointu.Track{ - NumVoices: 1, - Patterns: []sointu.Pattern{}, - } - m.d.Song.Score.Tracks = newTracks - m.clampPositions() - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) CanAddTrack() bool { - return m.d.Song.Score.NumVoices() < 32 -} - -func (m *Model) DeleteTrack(forward bool) { - if !m.CanDeleteTrack() { - return - } - m.saveUndo("DeleteTrack", 0) - newTracks := make([]sointu.Track, len(m.d.Song.Score.Tracks)-1) - copy(newTracks, m.d.Song.Score.Tracks[:m.d.Cursor.Track]) - copy(newTracks[m.d.Cursor.Track:], m.d.Song.Score.Tracks[m.d.Cursor.Track+1:]) - m.d.Song.Score.Tracks = newTracks - if !forward { - m.d.Cursor.Track-- - } - m.d.SelectionCorner = m.d.Cursor - m.clampPositions() - m.computePatternUseCounts() - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) CanDeleteTrack() bool { - return len(m.d.Song.Score.Tracks) > 1 -} - -func (m *Model) SwapTracks(i, j int) { - if i < 0 || j < 0 || i >= len(m.d.Song.Score.Tracks) || j >= len(m.d.Song.Score.Tracks) || i == j { - return - } - m.saveUndo("SwapTracks", 10) - tracks := m.d.Song.Score.Tracks - tracks[i], tracks[j] = tracks[j], tracks[i] - m.clampPositions() - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) SetTrackVoices(value int) { - if value < 1 { - value = 1 - } - maxRemain := m.MaxTrackVoices() - if value > maxRemain { - value = maxRemain - } - if m.d.Song.Score.Tracks[m.d.Cursor.Track].NumVoices == value { - return - } - m.saveUndo("SetTrackVoices", 10) - m.d.Song.Score.Tracks[m.d.Cursor.Track].NumVoices = value - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) MaxTrackVoices() int { - maxRemain := 32 - m.d.Song.Score.NumVoices() + m.d.Song.Score.Tracks[m.d.Cursor.Track].NumVoices - if maxRemain < 1 { - maxRemain = 1 - } - return maxRemain -} - -func (m *Model) AddInstrument(after bool) { - if !m.CanAddInstrument() { - return - } - m.saveUndo("AddInstrument", 0) - newInstruments := make([]sointu.Instrument, len(m.d.Song.Patch)+1) - if after { - m.d.InstrIndex++ - } - copy(newInstruments, m.d.Song.Patch[:m.d.InstrIndex]) - copy(newInstruments[m.d.InstrIndex+1:], m.d.Song.Patch[m.d.InstrIndex:]) - newInstr := defaultInstrument.Copy() - m.assignUnitIDs(newInstr.Units) - newInstruments[m.d.InstrIndex] = newInstr - m.d.UnitIndex = 0 - m.d.ParamIndex = 0 - m.d.Song.Patch = newInstruments - m.send(m.d.Song.Patch.Copy()) -} - -func (m *Model) NoteOn(id NoteID) { - m.send(ModelNoteOnMessage{id}) -} - -func (m *Model) NoteOff(id NoteID) { - m.send(ModelNoteOffMessage{id}) -} - -func (m *Model) Playing() bool { - return m.playing -} - -func (m *Model) SetPlaying(val bool) { - if m.playing != val { - m.playing = val - m.send(ModelPlayingChangedMessage{val}) - } -} - -func (m *Model) PlayPosition() ScoreRow { - return m.playPosition -} - -func (m *Model) CanAddInstrument() bool { - return m.d.Song.Patch.NumVoices() < 32 -} - -func (m *Model) SwapInstruments(i, j int) { - if i < 0 || j < 0 || i >= len(m.d.Song.Patch) || j >= len(m.d.Song.Patch) || i == j { - return - } - m.saveUndo("SwapInstruments", 10) - instruments := m.d.Song.Patch - instruments[i], instruments[j] = instruments[j], instruments[i] - m.clampPositions() - m.send(m.d.Song.Patch.Copy()) -} - -func (m *Model) DeleteInstrument(forward bool) { - if !m.CanDeleteInstrument() { - return - } - m.saveUndo("DeleteInstrument", 0) - m.freeUnitIDs(m.d.Song.Patch[m.d.InstrIndex].Units) - m.d.Song.Patch = append(m.d.Song.Patch[:m.d.InstrIndex], m.d.Song.Patch[m.d.InstrIndex+1:]...) - if (!forward && m.d.InstrIndex > 0) || m.d.InstrIndex >= len(m.d.Song.Patch) { - m.d.InstrIndex-- - } - m.clampPositions() - m.send(m.d.Song.Patch.Copy()) -} - -func (m *Model) CanDeleteInstrument() bool { - return len(m.d.Song.Patch) > 1 -} - -func (m *Model) Note() byte { - trk := m.d.Song.Score.Tracks[m.d.Cursor.Track] - pat := trk.Order.Get(m.d.Cursor.Pattern) - if pat < 0 || pat >= len(trk.Patterns) { - return 1 - } - return trk.Patterns[pat].Get(m.d.Cursor.Row) -} - -// SetCurrentNote sets the (note) value in current pattern under cursor to iv -func (m *Model) SetNote(iv byte) { - m.saveUndo("SetNote", 10) - tracks := m.d.Song.Score.Tracks - if m.d.Cursor.Pattern < 0 || m.d.Cursor.Row < 0 { - return - } - patIndex := tracks[m.d.Cursor.Track].Order.Get(m.d.Cursor.Pattern) - if patIndex < 0 { - patIndex = len(tracks[m.d.Cursor.Track].Patterns) - for _, pi := range tracks[m.d.Cursor.Track].Order { - if pi >= patIndex { - patIndex = pi + 1 // we find a pattern that is not in the pattern table nor in the order list i.e. completely new pattern - } - } - tracks[m.d.Cursor.Track].Order.Set(m.d.Cursor.Pattern, patIndex) - } - for len(tracks[m.d.Cursor.Track].Patterns) <= patIndex { - tracks[m.d.Cursor.Track].Patterns = append(tracks[m.d.Cursor.Track].Patterns, nil) - } - tracks[m.d.Cursor.Track].Patterns[patIndex].Set(m.d.Cursor.Row, iv) - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) AdjustPatternNumber(delta int, swap bool) { - r1, r2 := m.d.Cursor.Pattern, m.d.SelectionCorner.Pattern - if r1 > r2 { - r1, r2 = r2, r1 - } - t1, t2 := m.d.Cursor.Track, m.d.SelectionCorner.Track - if t1 > t2 { - t1, t2 = t2, t1 - } - type k = struct { - track int - pat int - } - newIds := map[k]int{} - usedIds := map[k]bool{} - for t := t1; t <= t2; t++ { - for r := r1; r <= r2; r++ { - p := m.d.Song.Score.Tracks[t].Order.Get(r) - if p < 0 { - continue - } - if p+delta < 0 || p+delta > 35 { - return // if any of the patterns would go out of range, abort - } - newIds[k{t, p}] = p + delta - usedIds[k{t, p + delta}] = true - } - } - m.saveUndo("AdjustPatternNumber", 10) - for t := t1; t <= t2; t++ { - if swap { - maxId := len(m.d.Song.Score.Tracks[t].Patterns) - 1 - // check if song uses patterns that are not in the table yet - for _, o := range m.d.Song.Score.Tracks[t].Order { - if maxId < o { - maxId = o - } - } - for p := 0; p <= maxId; p++ { - j := p - if delta > 0 { - j = maxId - p - } - if _, ok := newIds[k{t, j}]; ok { - continue - } - nextId := j - for used := usedIds[k{t, nextId}]; used; used = usedIds[k{t, nextId}] { - if delta < 0 { - nextId++ - } else { - nextId-- - } - } - newIds[k{t, j}] = nextId - usedIds[k{t, nextId}] = true - } - for i, o := range m.d.Song.Score.Tracks[t].Order { - if o < 0 { - continue - } - m.d.Song.Score.Tracks[t].Order[i] = newIds[k{t, o}] - } - newPatterns := make([]sointu.Pattern, len(m.d.Song.Score.Tracks[t].Patterns)) - for p, pat := range m.d.Song.Score.Tracks[t].Patterns { - id := newIds[k{t, p}] - for len(newPatterns) <= id { - newPatterns = append(newPatterns, nil) - } - newPatterns[id] = pat - } - m.d.Song.Score.Tracks[t].Patterns = newPatterns - } else { - for r := r1; r <= r2; r++ { - p := m.d.Song.Score.Tracks[t].Order.Get(r) - if p < 0 { - continue - } - m.d.Song.Score.Tracks[t].Order.Set(r, p+delta) - } - } - } - m.computePatternUseCounts() - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) SetRecording(val bool) { - if m.recording != val { - m.recording = val - m.d.InstrEnlarged = val - m.send(ModelRecordingMessage{val}) - } -} - -func (m *Model) Recording() bool { - return m.recording -} - -func (m *Model) SetPanic(val bool) { - if m.panic != val { - m.panic = val - m.send(ModelPanicMessage{val}) - } -} - -func (m *Model) Panic() bool { - return m.panic -} - -func (m *Model) SetInstrEnlarged(val bool) { - m.d.InstrEnlarged = val -} - -func (m *Model) InstrEnlarged() bool { - return m.d.InstrEnlarged -} - -func (m *Model) PlayFromPosition(sr ScoreRow) { - m.playing = true - m.send(ModelPlayFromPositionMessage{sr}) -} - -func (m *Model) SetCurrentPattern(pat int) { - m.saveUndo("SetCurrentPattern", 0) - m.d.Song.Score.Tracks[m.d.Cursor.Track].Order.Set(m.d.Cursor.Pattern, pat) - m.computePatternUseCounts() - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) IsPatternUnique(track, pattern int) bool { - if track < 0 || track >= len(m.d.PatternUseCount) { - return false - } - p := m.d.PatternUseCount[track] - if pattern < 0 || pattern >= len(p) { - return false - } - return p[pattern] <= 1 -} - -func (m *Model) SetSongLength(value int) { - if value < 1 { - value = 1 - } - if value == m.d.Song.Score.Length { - return - } - m.saveUndo("SetSongLength", 10) - m.d.Song.Score.Length = value - m.clampPositions() - m.computePatternUseCounts() - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) SetRowsPerPattern(value int) { - if value < 1 { - value = 1 - } - if value > 255 { - value = 255 - } - if value == m.d.Song.Score.RowsPerPattern { - return - } - m.saveUndo("SetRowsPerPattern", 10) - m.d.Song.Score.RowsPerPattern = value - m.clampPositions() - m.send(m.d.Song.Score.Copy()) +func (m *Model) TrackNoteOn(track int, note byte) (id NoteID) { + id = NoteID{IsInstr: false, Track: track, Note: note, model: m} + m.send(NoteOnMsg{id}) + return id } -func (m *Model) SetUnitType(t string) { - unit, ok := defaultUnits[t] - if !ok { // if the type is invalid, we just set it to empty unit - unit = sointu.Unit{Parameters: make(map[string]int)} - } else { - unit = unit.Copy() - } - if m.Unit().Type == unit.Type { - return - } - m.saveUndo("SetUnitType", 0) - oldID := m.Unit().ID - m.Instrument().Units[m.d.UnitIndex] = unit - m.Instrument().Units[m.d.UnitIndex].ID = oldID // keep the ID of the replaced unit - m.send(m.d.Song.Patch.Copy()) -} - -func (m *Model) PasteUnits(units []sointu.Unit) { - m.saveUndo("PasteUnits", 0) - newUnits := make([]sointu.Unit, len(m.Instrument().Units)+len(units)) - m.d.UnitIndex++ - copy(newUnits, m.Instrument().Units[:m.d.UnitIndex]) - copy(newUnits[m.d.UnitIndex+len(units):], m.Instrument().Units[m.d.UnitIndex:]) - for _, unit := range units { - if _, ok := m.d.UsedIDs[unit.ID]; ok { - m.d.MaxID++ - unit.ID = m.d.MaxID - } - m.d.UsedIDs[unit.ID] = true - } - copy(newUnits[m.d.UnitIndex:m.d.UnitIndex+len(units)], units) - m.d.Song.Patch[m.d.InstrIndex].Units = newUnits - m.d.ParamIndex = 0 - m.clampPositions() - m.send(m.d.Song.Patch.Copy()) -} - -func (m *Model) SetUnitIndex(value int) { - m.d.UnitIndex = value - m.d.ParamIndex = 0 - m.clampPositions() -} - -func (m *Model) AddUnit(after bool) { - m.saveUndo("AddUnit", 10) - newUnits := make([]sointu.Unit, len(m.Instrument().Units)+1) - if after { - m.d.UnitIndex++ - } - copy(newUnits, m.Instrument().Units[:m.d.UnitIndex]) - copy(newUnits[m.d.UnitIndex+1:], m.Instrument().Units[m.d.UnitIndex:]) - m.assignUnitIDs(newUnits[m.d.UnitIndex : m.d.UnitIndex+1]) - m.d.Song.Patch[m.d.InstrIndex].Units = newUnits - m.d.ParamIndex = 0 - m.clampPositions() - m.send(m.d.Song.Patch.Copy()) -} - -func (m *Model) AddOrderRow(after bool) { - m.saveUndo("AddOrderRow", 10) - if after { - m.d.Cursor.Pattern++ - } - for i, trk := range m.d.Song.Score.Tracks { - if l := len(trk.Order); l > m.d.Cursor.Pattern { - newOrder := make([]int, l+1) - copy(newOrder, trk.Order[:m.d.Cursor.Pattern]) - copy(newOrder[m.d.Cursor.Pattern+1:], trk.Order[m.d.Cursor.Pattern:]) - newOrder[m.d.Cursor.Pattern] = -1 - m.d.Song.Score.Tracks[i].Order = newOrder - } - } - m.d.Song.Score.Length++ - m.d.SelectionCorner = m.d.Cursor - m.clampPositions() - m.computePatternUseCounts() - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) DeleteOrderRow(forward bool) { - if m.d.Song.Score.Length <= 1 { - return - } - m.saveUndo("DeleteOrderRow", 0) - for i, trk := range m.d.Song.Score.Tracks { - if l := len(trk.Order); l > m.d.Cursor.Pattern { - newOrder := make([]int, l-1) - copy(newOrder, trk.Order[:m.d.Cursor.Pattern]) - copy(newOrder[m.d.Cursor.Pattern:], trk.Order[m.d.Cursor.Pattern+1:]) - m.d.Song.Score.Tracks[i].Order = newOrder - } - } - if !forward && m.d.Cursor.Pattern > 0 { - m.d.Cursor.Pattern-- - } - m.d.Song.Score.Length-- - m.d.SelectionCorner = m.d.Cursor - m.clampPositions() - m.computePatternUseCounts() - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) DeleteUnits(forward bool, a, b int) []sointu.Unit { - instr := m.Instrument() - m.saveUndo("DeleteUnits", 0) - a, b = intMin(a, b), intMax(a, b) - if a < 0 { - a = 0 - } - if b > len(instr.Units)-1 { - b = len(instr.Units) - 1 - } - for i := a; i <= b; i++ { - delete(m.d.UsedIDs, instr.Units[i].ID) - } - var newUnits []sointu.Unit - if a == 0 && b == len(instr.Units)-1 { - newUnits = make([]sointu.Unit, 1) - m.d.UnitIndex = 0 - } else { - newUnits = make([]sointu.Unit, len(instr.Units)-(b-a+1)) - copy(newUnits, instr.Units[:a]) - copy(newUnits[a:], instr.Units[b+1:]) - m.d.UnitIndex = a - if forward { - m.d.UnitIndex-- - } - } - deletedUnits := instr.Units[a : b+1] - m.d.Song.Patch[m.d.InstrIndex].Units = newUnits - m.d.ParamIndex = 0 - m.clampPositions() - m.send(m.d.Song.Patch.Copy()) - return deletedUnits -} - -func (m *Model) CanDeleteUnit() bool { - return len(m.Instrument().Units) > 1 -} - -func (m *Model) ResetParam() { - p, err := m.Param(m.d.ParamIndex) - if err != nil { - return - } - unit := m.Unit() - paramList, ok := sointu.UnitTypes[unit.Type] - if !ok || m.d.ParamIndex < 0 || m.d.ParamIndex >= len(paramList) { - return - } - paramType := paramList[m.d.ParamIndex] - defaultValue, ok := defaultUnits[unit.Type].Parameters[paramType.Name] - if unit.Parameters[p.Name] == defaultValue { - return - } - m.saveUndo("ResetParam", 0) - unit.Parameters[paramType.Name] = defaultValue - m.clampPositions() - m.send(m.d.Song.Patch.Copy()) -} - -func (m *Model) SetParamIndex(value int) { - m.d.ParamIndex = value - m.clampPositions() -} - -func (m *Model) setGmDlsEntry(index int) { - if index < 0 || index >= len(GmDlsEntries) { - return - } - entry := GmDlsEntries[index] - unit := m.Unit() - if unit.Type != "oscillator" || unit.Parameters["type"] != sointu.Sample { - return - } - if unit.Parameters["samplestart"] == entry.Start && unit.Parameters["loopstart"] == entry.LoopStart && unit.Parameters["looplength"] == entry.LoopLength { - return - } - m.saveUndo("SetGmDlsEntry", 20) - unit.Parameters["samplestart"] = entry.Start - unit.Parameters["loopstart"] = entry.LoopStart - unit.Parameters["looplength"] = entry.LoopLength - unit.Parameters["transpose"] = 64 + entry.SuggestedTranspose - m.send(m.d.Song.Patch.Copy()) +func (m *Model) InstrNoteOn(instr int, note byte) (id NoteID) { + id = NoteID{IsInstr: true, Instr: instr, Note: note, model: m} + m.send(NoteOnMsg{id}) + return id } -func (m *Model) setReverb(index int) { - if index < 0 || index >= len(reverbs) { - return - } - entry := reverbs[index] - unit := &m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex] - if unit.Type != "delay" { - return - } - m.saveUndo("setReverb", 20) - unit.Parameters["stereo"] = entry.stereo - unit.Parameters["notetracking"] = 0 - unit.VarArgs = make([]int, len(entry.varArgs)) - copy(unit.VarArgs, entry.varArgs) - m.send(m.d.Song.Patch.Copy()) -} - -func (m *Model) SwapUnits(i, j int) { - units := m.Instrument().Units - if i < 0 || j < 0 || i >= len(units) || j >= len(units) || i == j { - return - } - m.saveUndo("SwapUnits", 10) - units[i], units[j] = units[j], units[i] - m.clampPositions() - m.send(m.d.Song.Patch.Copy()) +func (n NoteID) NoteOff() { + n.model.send(NoteOffMsg{n}) } -func (m *Model) getSelectionRange() (int, int, int, int) { - r1 := m.d.Cursor.Pattern*m.d.Song.Score.RowsPerPattern + m.d.Cursor.Row - r2 := m.d.SelectionCorner.Pattern*m.d.Song.Score.RowsPerPattern + m.d.SelectionCorner.Row - if r2 < r1 { - r1, r2 = r2, r1 - } - t1 := m.d.Cursor.Track - t2 := m.d.SelectionCorner.Track - if t2 < t1 { - t1, t2 = t2, t1 - } - return r1, r2, t1, t2 +func (m *Model) FindUnit(id int) (instrIndex, unitIndex int, err error) { + // TODO: this only used for choosing send target; find a better way for this + return m.d.Song.Patch.FindUnit(id) } -func (m *Model) AdjustSelectionPitch(delta int) { - m.saveUndo("AdjustSelectionPitch", 10) - r1, r2, t1, t2 := m.getSelectionRange() - for c := t1; c <= t2; c++ { - adjustedNotes := map[struct { - Pat int - Row int - }]bool{} - for r := r1; r <= r2; r++ { - s := ScoreRow{Row: r}.Wrap(m.d.Song.Score) - if s.Pattern >= len(m.d.Song.Score.Tracks[c].Order) { - break - } - p := m.d.Song.Score.Tracks[c].Order[s.Pattern] - if p < 0 { - continue - } - noteIndex := struct { - Pat int - Row int - }{p, s.Row} - if !adjustedNotes[noteIndex] { - patterns := m.d.Song.Score.Tracks[c].Patterns - if p >= len(patterns) { - continue - } - pattern := patterns[p] - if s.Row >= len(pattern) { - continue - } - if val := pattern[s.Row]; val > 1 { - newVal := int(val) + delta - if newVal < 2 { - newVal = 2 - } else if newVal > 255 { - newVal = 255 - } - pattern[s.Row] = byte(newVal) - } - adjustedNotes[noteIndex] = true - } - } +func (m *Model) Instrument(index int) sointu.Instrument { + // TODO: this only used for choosing send target; find a better way for this + // we make a copy just so that the gui can't accidentally modify the song + if index < 0 || index >= len(m.d.Song.Patch) { + return sointu.Instrument{} } - m.send(m.d.Song.Score.Copy()) + return m.d.Song.Patch[index].Copy() } -func (m *Model) DeleteSelection() { - m.saveUndo("DeleteSelection", 0) - r1, r2, t1, t2 := m.getSelectionRange() - for r := r1; r <= r2; r++ { - s := ScoreRow{Row: r}.Wrap(m.d.Song.Score) - for c := t1; c <= t2; c++ { - if len(m.d.Song.Score.Tracks[c].Order) <= s.Pattern { - continue - } - p := m.d.Song.Score.Tracks[c].Order[s.Pattern] - if p < 0 { - continue - } - patterns := m.d.Song.Score.Tracks[c].Patterns - if p >= len(patterns) { - continue - } - pattern := patterns[p] - if s.Row >= len(pattern) { - continue - } - m.d.Song.Score.Tracks[c].Patterns[p][s.Row] = 1 - } - } - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) DeletePatternSelection() { - m.saveUndo("DeletePatternSelection", 0) - r1, r2, t1, t2 := m.getSelectionRange() - p1 := ScoreRow{Row: r1}.Wrap(m.d.Song.Score).Pattern - p2 := ScoreRow{Row: r2}.Wrap(m.d.Song.Score).Pattern - for p := p1; p <= p2; p++ { - for c := t1; c <= t2; c++ { - if p < len(m.d.Song.Score.Tracks[c].Order) { - m.d.Song.Score.Tracks[c].Order[p] = -1 - } - } - } - m.computePatternUseCounts() - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) Undo() { - if !m.CanUndo() { - return - } - m.redoStack = append(m.redoStack, m.d.Copy()) - m.d = m.undoStack[len(m.undoStack)-1] - m.undoStack = m.undoStack[:len(m.undoStack)-1] - m.limitUndoRedoLengths() - m.prevUndoType = "" - m.send(m.d.Song.Copy()) -} - -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] - } - m.prevUndoType = "" -} - -func (m *Model) Redo() { - if !m.CanRedo() { - return - } - m.undoStack = append(m.undoStack, m.d.Copy()) - m.d = m.redoStack[len(m.redoStack)-1] - m.redoStack = m.redoStack[:len(m.redoStack)-1] - m.limitUndoRedoLengths() - m.prevUndoType = "" - m.send(m.d.Song.Copy()) -} - -func (m *Model) CanRedo() bool { - return len(m.redoStack) > 0 -} - -func (m *Model) SetNoteTracking(value bool) { - m.noteTracking = value -} - -func (m *Model) NoteTracking() bool { - return m.noteTracking -} - -func (m *Model) Song() sointu.Song { - return m.d.Song -} - -func (m *Model) SelectionCorner() ScorePoint { - return m.d.SelectionCorner -} - -func (m *Model) SetSelectionCorner(value ScorePoint) { - m.d.SelectionCorner = value - m.clampPositions() -} - -func (m *Model) Cursor() ScorePoint { - return m.d.Cursor -} - -func (m *Model) SetCursor(value ScorePoint) { - m.d.Cursor = value - m.clampPositions() -} - -func (m *Model) LowNibble() bool { - return m.d.LowNibble -} - -func (m *Model) SetLowNibble(value bool) { - m.d.LowNibble = value -} - -func (m *Model) InstrIndex() int { - return m.d.InstrIndex -} - -func (m *Model) Track() sointu.Track { - return m.d.Song.Score.Tracks[m.d.Cursor.Track] -} - -func (m *Model) Instrument() sointu.Instrument { - return m.d.Song.Patch[m.d.InstrIndex] -} - -func (m *Model) Unit() sointu.Unit { - return m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex] -} - -func (m *Model) UnitIndex() int { - return m.d.UnitIndex -} - -func (m *Model) ParamIndex() int { - return m.d.ParamIndex -} - -func (m *Model) limitUndoRedoLengths() { - if len(m.undoStack) >= maxUndo { - m.undoStack = m.undoStack[len(m.undoStack)-maxUndo:] - } - if len(m.redoStack) >= maxUndo { - m.redoStack = m.redoStack[len(m.redoStack)-maxUndo:] - } -} - -func (m *Model) clampPositions() { - m.d.Cursor = m.d.Cursor.Wrap(m.d.Song.Score) - m.d.SelectionCorner = m.d.SelectionCorner.Wrap(m.d.Song.Score) - if !m.Track().Effect { - m.d.LowNibble = false - } - m.d.InstrIndex = clamp(m.d.InstrIndex, 0, len(m.d.Song.Patch)-1) - m.d.UnitIndex = clamp(m.d.UnitIndex, 0, len(m.Instrument().Units)-1) - m.d.ParamIndex = clamp(m.d.ParamIndex, 0, m.NumParams()-1) -} - -func (m *Model) NumParams() int { - unit := m.Unit() - if unit.Type == "oscillator" { - if unit.Parameters["type"] != sointu.Sample { - return 10 - } - return 14 - } - numSettableParams := 0 - for _, t := range sointu.UnitTypes[m.Unit().Type] { - if t.CanSet { - numSettableParams++ - } - } - if numSettableParams == 0 { - numSettableParams = 1 - } - if unit.Type == "delay" { - numSettableParams += 2 + len(unit.VarArgs) - if len(unit.VarArgs)%2 == 1 && unit.Parameters["stereo"] == 1 { - numSettableParams++ - } - } - return numSettableParams -} - -func (m *Model) Param(index int) (Parameter, error) { - unit := m.Unit() - for _, t := range sointu.UnitTypes[unit.Type] { - if !t.CanSet { - continue - } - if index != 0 { - index-- - continue - } - typ := IntegerParameter - if t.MaxValue == t.MinValue+1 { - typ = BoolParameter - } - val := m.Unit().Parameters[t.Name] - name := t.Name - hint := m.d.Song.Patch.ParamHintString(m.d.InstrIndex, m.d.UnitIndex, name) - var text string - if hint != "" { - text = fmt.Sprintf("%v / %v", val, hint) - } else { - text = strconv.Itoa(val) - } - min, max := t.MinValue, t.MaxValue - if unit.Type == "send" { - if t.Name == "voice" { - i, _, err := m.d.Song.Patch.FindUnit(unit.Parameters["target"]) - if err == nil { - max = m.d.Song.Patch[i].NumVoices - } - } else if t.Name == "target" { - typ = IDParameter - } - } - largeStep := 16 - if unit.Type == "oscillator" && t.Name == "transpose" { - largeStep = 12 - } - return Parameter{Type: typ, Min: min, Max: max, Name: name, Hint: text, Value: val, LargeStep: largeStep}, nil - } - if unit.Type == "oscillator" && index == 0 { - key := vm.SampleOffset{Start: uint32(unit.Parameters["samplestart"]), LoopStart: uint16(unit.Parameters["loopstart"]), LoopLength: uint16(unit.Parameters["looplength"])} - val := 0 - hint := "0 / custom" - if v, ok := GmDlsEntryMap[key]; ok { - val = v + 1 - hint = fmt.Sprintf("%v / %v", val, GmDlsEntries[v].Name) - } - return Parameter{Type: IntegerParameter, Min: 0, Max: len(GmDlsEntries), Name: "sample", Hint: hint, Value: val}, nil - } - if unit.Type == "delay" { - if index == 0 { - i := slices.IndexFunc(reverbs, func(p delayPreset) bool { - return p.stereo == unit.Parameters["stereo"] && unit.Parameters["notetracking"] == 0 && slices.Equal(p.varArgs, unit.VarArgs) - }) - hint := "0 / custom" - if i >= 0 { - hint = fmt.Sprintf("%v / %v", i+1, reverbs[i].name) - } - return Parameter{Type: IntegerParameter, Min: 0, Max: len(reverbs), Name: "reverb", Hint: hint, Value: i + 1}, nil - } - if index == 1 { - l := len(unit.VarArgs) - if unit.Parameters["stereo"] == 1 { - l = (l + 1) / 2 - } - return Parameter{Type: IntegerParameter, Min: 1, Max: 32, Name: "delaylines", Hint: strconv.Itoa(l), Value: l}, nil - } - index -= 2 - if index < len(unit.VarArgs) { - val := unit.VarArgs[index] - var text string - switch unit.Parameters["notetracking"] { - default: - case 0: - text = fmt.Sprintf("%v / %.3f rows", val, float32(val)/float32(m.d.Song.SamplesPerRow())) - return Parameter{Type: IntegerParameter, Min: 1, Max: 65535, Name: "delaytime", Hint: text, Value: val, LargeStep: 256}, nil - case 1: - relPitch := float64(val) / 10787 - semitones := -math.Log2(relPitch) * 12 - text = fmt.Sprintf("%v / %.3f st", val, semitones) - return Parameter{Type: IntegerParameter, Min: 1, Max: 65535, Name: "delaytime", Hint: text, Value: val, LargeStep: 256}, nil - case 2: - k := 0 - v := val - for v&1 == 0 { // divide val by 2 until it is odd - v >>= 1 - k++ - } - text := "" - switch v { - case 1: - if k <= 7 { - text = fmt.Sprintf(" (1/%d triplet)", 1<<(7-k)) - } - case 3: - if k <= 6 { - text = fmt.Sprintf(" (1/%d)", 1<<(6-k)) - } - break - case 9: - if k <= 5 { - text = fmt.Sprintf(" (1/%d dotted)", 1<<(5-k)) - } - } - text = fmt.Sprintf("%v / %.3f beats%s", val, float32(val)/48.0, text) - return Parameter{Type: IntegerParameter, Min: 1, Max: 576, Name: "delaytime", Hint: text, Value: val, LargeStep: 16}, nil - } - - } - } - return Parameter{}, errors.New("invalid parameter") -} - -func (m *Model) RemoveUnusedData() { - m.saveUndo("RemoveUnusedData", 0) - for trkIndex, trk := range m.d.Song.Score.Tracks { - // assign new indices to patterns - newIndex := map[int]int{} - runningIndex := 0 - length := 0 - if len(trk.Order) > m.d.Song.Score.Length { - trk.Order = trk.Order[:m.d.Song.Score.Length] - } - for i, p := range trk.Order { - // if the pattern hasn't been considered and is within limits - if _, ok := newIndex[p]; !ok && p >= 0 && p < len(trk.Patterns) { - pat := trk.Patterns[p] - useful := false - for _, n := range pat { // patterns that have anything else than all holds are useful and to be kept - if n != 1 { - useful = true - break - } - } - if useful { - newIndex[p] = runningIndex - runningIndex++ - } else { - newIndex[p] = -1 - } - } - if ind, ok := newIndex[p]; ok && ind > -1 { - length = i + 1 - trk.Order[i] = ind - } else { - trk.Order[i] = -1 - } - } - trk.Order = trk.Order[:length] - newPatterns := make([]sointu.Pattern, runningIndex) - for i, pat := range trk.Patterns { - if ind, ok := newIndex[i]; ok && ind > -1 { - patLength := 0 - for j, note := range pat { // find last note that is something else that hold - if note != 1 { - patLength = j + 1 - } - } - if patLength > m.d.Song.Score.RowsPerPattern { - patLength = m.d.Song.Score.RowsPerPattern - } - newPatterns[ind] = pat[:patLength] // crop to either RowsPerPattern or last row having something else than hold - } - } - trk.Patterns = newPatterns - m.d.Song.Score.Tracks[trkIndex] = trk - } - m.computePatternUseCounts() - m.send(m.d.Song.Score.Copy()) -} - -func (m *Model) SetParam(value int) { - p, err := m.Param(m.d.ParamIndex) - if err != nil { - return - } - if value < p.Min { - value = p.Min - } else if value > p.Max { - value = p.Max - } - if p.Name == "sample" { - m.setGmDlsEntry(value - 1) - return - } - if p.Name == "reverb" { - m.setReverb(value - 1) - return - } - unit := m.Unit() - if p.Name == "delaylines" { - m.saveUndo("SetParam", 20) - targetLines := value - if unit.Parameters["stereo"] == 1 { - targetLines *= 2 - } - for len(m.Instrument().Units[m.d.UnitIndex].VarArgs) < targetLines { - m.Instrument().Units[m.d.UnitIndex].VarArgs = append(m.Instrument().Units[m.d.UnitIndex].VarArgs, 1) - } - m.Instrument().Units[m.d.UnitIndex].VarArgs = m.Instrument().Units[m.d.UnitIndex].VarArgs[:targetLines] - } else if p.Name == "delaytime" { - m.saveUndo("SetParam", 20) - index := m.d.ParamIndex - 8 - for len(m.Instrument().Units[m.d.UnitIndex].VarArgs) <= index { - m.Instrument().Units[m.d.UnitIndex].VarArgs = append(m.Instrument().Units[m.d.UnitIndex].VarArgs, 1) - } - m.Instrument().Units[m.d.UnitIndex].VarArgs[index] = value - } else { - if unit.Parameters[p.Name] == value { - return - } - m.saveUndo("SetParam", 20) - unit.Parameters[p.Name] = value - } - m.clampPositions() - m.send(m.d.Song.Patch.Copy()) +func (d *modelData) Copy() modelData { + ret := *d + ret.Song = d.Song.Copy() + return ret } -func (m *Model) setSongNoUndo(song sointu.Song) { - m.d.Song = song - m.d.UsedIDs = make(map[int]bool) - m.d.MaxID = 0 +func (m *Model) resetSong() { + m.d.Song = defaultSong.Copy() for _, instr := range m.d.Song.Patch { - for _, unit := range instr.Units { - if m.d.MaxID < unit.ID { - m.d.MaxID = unit.ID - } - } + (*Model)(m).assignUnitIDs(instr.Units) } - for _, instr := range m.d.Song.Patch { - m.assignUnitIDs(instr.Units) - } - m.clampPositions() - m.computePatternUseCounts() - m.send(m.d.Song.Copy()) + m.d.FilePath = "" + m.d.ChangedSinceSave = false } // send sends a message to the player @@ -1417,39 +389,29 @@ func (m *Model) send(message interface{}) { m.modelMessages <- message } -func (m *Model) saveUndo(undoType string, undoSkipping int) { - m.d.ChangedSinceSave = true - m.d.ChangedSinceRecovery = true - if m.prevUndoType == undoType && m.undoSkipCounter < undoSkipping { - m.undoSkipCounter++ - return - } - m.prevUndoType = undoType - m.undoSkipCounter = 0 - m.undoStack = append(m.undoStack, m.d.Copy()) - m.redoStack = m.redoStack[:0] - m.limitUndoRedoLengths() -} - -func (m *Model) freeUnitIDs(units []sointu.Unit) { - for _, u := range units { - delete(m.d.UsedIDs, u.ID) - } -} - func (m *Model) assignUnitIDs(units []sointu.Unit) { + maxId := 0 + usedIds := make(map[int]bool) + for _, instr := range m.d.Song.Patch { + for _, unit := range instr.Units { + usedIds[unit.ID] = true + if maxId < unit.ID { + maxId = unit.ID + } + } + } rewrites := map[int]int{} for i := range units { - if id := units[i].ID; id == 0 || m.d.UsedIDs[id] { - m.d.MaxID++ + if id := units[i].ID; id == 0 || usedIds[id] { + maxId++ if id > 0 { - rewrites[id] = m.d.MaxID + rewrites[id] = maxId } - units[i].ID = m.d.MaxID + units[i].ID = maxId } - m.d.UsedIDs[units[i].ID] = true - if m.d.MaxID < units[i].ID { - m.d.MaxID = units[i].ID + usedIds[units[i].ID] = true + if maxId < units[i].ID { + maxId = units[i].ID } } for i, u := range units { @@ -1461,60 +423,37 @@ func (m *Model) assignUnitIDs(units []sointu.Unit) { } } -func (m *Model) computePatternUseCounts() { +func (m *Model) updatePatternUseCount() { for i, track := range m.d.Song.Score.Tracks { - for len(m.d.PatternUseCount) <= i { - m.d.PatternUseCount = append(m.d.PatternUseCount, nil) + for len(m.cachePatternUseCount) <= i { + m.cachePatternUseCount = append(m.cachePatternUseCount, nil) } - for j := range m.d.PatternUseCount[i] { - m.d.PatternUseCount[i][j] = 0 + for j := range m.cachePatternUseCount[i] { + m.cachePatternUseCount[i][j] = 0 } for j := 0; j < m.d.Song.Score.Length; j++ { if j >= len(track.Order) { break } p := track.Order[j] - for len(m.d.PatternUseCount[i]) <= p { - m.d.PatternUseCount[i] = append(m.d.PatternUseCount[i], 0) + for len(m.cachePatternUseCount[i]) <= p { + m.cachePatternUseCount[i] = append(m.cachePatternUseCount[i], 0) } if p < 0 { continue } - m.d.PatternUseCount[i][p]++ + m.cachePatternUseCount[i][p]++ } } } -func NoteIDInstr(instr int, note byte) NoteID { - return NoteID{IsInstr: true, Instr: instr, Note: note} -} - -func NoteIDTrack(track int, note byte) NoteID { - return NoteID{IsInstr: false, Track: track, Note: note} -} - -func (d *modelData) Copy() modelData { - ret := *d - ret.Song = d.Song.Copy() - ret.PatternUseCount = make([][]int, len(d.PatternUseCount)) - for i := range ret.PatternUseCount { - ret.PatternUseCount[i] = make([]int, len(d.PatternUseCount[i])) - copy(ret.PatternUseCount[i], d.PatternUseCount[i]) - } - ret.UsedIDs = make(map[int]bool) - for k, v := range d.UsedIDs { - ret.UsedIDs[k] = v - } - return ret -} - func clamp(a, min, max int) int { - if a < min { - return min - } if a > max { return max } + if a < min { + return min + } return a } diff --git a/tracker/model_test.go b/tracker/model_test.go new file mode 100644 index 0000000..0261bf0 --- /dev/null +++ b/tracker/model_test.go @@ -0,0 +1,252 @@ +package tracker_test + +import ( + "bytes" + "encoding/binary" + "fmt" + "testing" + + "github.com/vsariola/sointu/tracker" + "github.com/vsariola/sointu/vm" +) + +type NullContext struct{} + +func (NullContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) { + return tracker.MIDINoteEvent{}, false +} + +func (NullContext) BPM() (bpm float64, ok bool) { + return 0, false +} + +type modelFuzzState struct { + model *tracker.Model + clipboard []byte +} + +func (s *modelFuzzState) Iterate(yield func(string, func(p string, t *testing.T)) bool, seed int) { + // Ints + s.IterateInt("InstrumentVoices", s.model.InstrumentVoices().Int(), yield, seed) + s.IterateInt("TrackVoices", s.model.TrackVoices().Int(), yield, seed) + s.IterateInt("SongLength", s.model.SongLength().Int(), yield, seed) + s.IterateInt("BPM", s.model.BPM().Int(), yield, seed) + s.IterateInt("RowsPerPattern", s.model.RowsPerPattern().Int(), yield, seed) + s.IterateInt("RowsPerBeat", s.model.RowsPerBeat().Int(), yield, seed) + s.IterateInt("Step", s.model.Step().Int(), yield, seed) + s.IterateInt("Octave", s.model.Octave().Int(), yield, seed) + // Lists + s.IterateList("Instruments", s.model.Instruments().List(), yield, seed) + s.IterateList("Units", s.model.Units().List(), yield, seed) + s.IterateList("Tracks", s.model.Tracks().List(), yield, seed) + s.IterateList("OrderRows", s.model.OrderRows().List(), yield, seed) + s.IterateList("NoteRows", s.model.NoteRows().List(), yield, seed) + s.IterateList("UnitSearchResults", s.model.SearchResults().List(), yield, seed) + s.IterateBool("Panic", s.model.Panic().Bool(), yield, seed) + s.IterateBool("Recording", s.model.IsRecording().Bool(), yield, seed) + s.IterateBool("Playing", s.model.Playing().Bool(), yield, seed) + s.IterateBool("InstrEnlarged", s.model.InstrEnlarged().Bool(), yield, seed) + s.IterateBool("Effect", s.model.Effect().Bool(), yield, seed) + s.IterateBool("CommentExpanded", s.model.CommentExpanded().Bool(), yield, seed) + s.IterateBool("NoteTracking", s.model.NoteTracking().Bool(), yield, seed) + // Strings + s.IterateString("FilePath", s.model.FilePath().String(), yield, seed) + s.IterateString("InstrumentName", s.model.InstrumentName().String(), yield, seed) + s.IterateString("InstrumentComment", s.model.InstrumentComment().String(), yield, seed) + s.IterateString("UnitSearchText", s.model.UnitSearch().String(), yield, seed) + // Actions + s.IterateAction("AddTrack", s.model.AddTrack(), yield, seed) + s.IterateAction("DeleteTrack", s.model.DeleteTrack(), yield, seed) + s.IterateAction("AddInstrument", s.model.AddInstrument(), yield, seed) + s.IterateAction("DeleteInstrument", s.model.DeleteInstrument(), yield, seed) + s.IterateAction("AddUnitAfter", s.model.AddUnit(false), yield, seed) + s.IterateAction("AddUnitBefore", s.model.AddUnit(true), yield, seed) + s.IterateAction("DeleteUnit", s.model.DeleteUnit(), yield, seed) + s.IterateAction("ClearUnit", s.model.ClearUnit(), yield, seed) + s.IterateAction("Undo", s.model.Undo(), yield, seed) + s.IterateAction("Redo", s.model.Redo(), yield, seed) + s.IterateAction("RemoveUnused", s.model.RemoveUnused(), yield, seed) + s.IterateAction("AddSemitone", s.model.AddSemitone(), yield, seed) + s.IterateAction("SubtractSemitone", s.model.SubtractSemitone(), yield, seed) + s.IterateAction("AddOctave", s.model.AddOctave(), yield, seed) + s.IterateAction("SubtractOctave", s.model.SubtractOctave(), yield, seed) + s.IterateAction("EditNoteOff", s.model.EditNoteOff(), yield, seed) + s.IterateAction("Rewind", s.model.Rewind(), yield, seed) + s.IterateAction("AddOrderRowAfter", s.model.AddOrderRow(false), yield, seed) + s.IterateAction("AddOrderRowBefore", s.model.AddOrderRow(true), yield, seed) + s.IterateAction("DeleteOrderRowForward", s.model.DeleteOrderRow(false), yield, seed) + s.IterateAction("DeleteOrderRowBackward", s.model.DeleteOrderRow(true), yield, seed) + // Tables + s.IterateTable("Order", s.model.Order().Table(), yield, seed) + s.IterateTable("Notes", s.model.Notes().Table(), yield, seed) +} + +func (s *modelFuzzState) IterateInt(name string, i tracker.Int, yield func(string, func(p string, t *testing.T)) bool, seed int) { + r := i.Range() + yield(name+".Set", func(p string, t *testing.T) { + i.Set(seed%(r.Max-r.Min+10) - 5 + r.Min) + }) + yield(name+".Value", func(p string, t *testing.T) { + if v := i.Value(); v < r.Min || v > r.Max { + r := i.Range() + t.Errorf("Path: %s %s value out of range [%d,%d]: %d", p, name, r.Min, r.Max, v) + } + }) +} + +func (s *modelFuzzState) IterateAction(name string, a tracker.Action, yield func(string, func(p string, t *testing.T)) bool, seed int) { + yield(name+".Do", func(p string, t *testing.T) { + a.Do() + }) +} + +func (s *modelFuzzState) IterateBool(name string, b tracker.Bool, yield func(string, func(p string, t *testing.T)) bool, seed int) { + yield(name+".Set", func(p string, t *testing.T) { + b.Set(seed%2 == 0) + }) + yield(name+".Toggle", func(p string, t *testing.T) { + b.Toggle() + }) +} + +func (s *modelFuzzState) IterateString(name string, str tracker.String, yield func(string, func(p string, t *testing.T)) bool, seed int) { + yield(name+".Set", func(p string, t *testing.T) { + str.Set(fmt.Sprintf("%d", seed)) + }) +} + +func (s *modelFuzzState) IterateList(name string, l tracker.List, yield func(string, func(p string, t *testing.T)) bool, seed int) { + yield(name+".SetSelected", func(p string, t *testing.T) { + l.SetSelected(seed%50 - 16) + }) + yield(name+".Count", func(p string, t *testing.T) { + if c := l.Count(); c > 0 { + if l.Selected() < 0 || l.Selected() >= c { + t.Errorf("Path: %s %s selected out of range: %d", p, name, l.Selected()) + } + } else { + if l.Selected() != 0 { + t.Errorf("Path: %s %s selected out of range: %d", p, name, l.Selected()) + } + } + }) + yield(name+".SetSelected2", func(p string, t *testing.T) { + l.SetSelected2(seed%50 - 16) + }) + yield(name+".Count2", func(p string, t *testing.T) { + if c := l.Count(); c > 0 { + if l.Selected2() < 0 || l.Selected2() >= c { + t.Errorf("Path: %s List selected2 out of range: %d", p, l.Selected2()) + } + } else { + if l.Selected2() != 0 { + t.Errorf("Path: %s List selected2 out of range: %d", p, l.Selected2()) + } + } + }) + yield(name+".MoveElements", func(p string, t *testing.T) { + l.MoveElements(seed%2*2 - 1) + }) + yield(name+".DeleteElementsForward", func(p string, t *testing.T) { + l.DeleteElements(false) + }) + yield(name+".DeleteElementsBackward", func(p string, t *testing.T) { + l.DeleteElements(true) + }) + yield(name+".CopyElements", func(p string, t *testing.T) { + s.clipboard, _ = l.CopyElements() + }) + yield(name+".PasteElements", func(p string, t *testing.T) { + l.PasteElements(s.clipboard) + }) +} + +func (s *modelFuzzState) IterateTable(name string, table tracker.Table, yield func(string, func(p string, t *testing.T)) bool, seed int) { + yield(name+".SetCursor", func(p string, t *testing.T) { + table.SetCursor(tracker.Point{seed % 16, seed * 1337 % 16}) + }) + yield(name+".SetCursor2", func(p string, t *testing.T) { + table.SetCursor2(tracker.Point{seed % 16, seed * 1337 % 16}) + }) + yield(name+".Cursor", func(p string, t *testing.T) { + if c := table.Cursor(); c.X < 0 || (c.X >= table.Width() && table.Width() > 0) || c.Y < 0 || (c.Y >= table.Height() && table.Height() > 0) { + t.Errorf("Path: %s Table cursor out of range: %v", p, c) + } + }) + yield(name+".Cursor2", func(p string, t *testing.T) { + if c := table.Cursor2(); c.X < 0 || (c.X >= table.Width() && table.Width() > 0) || c.Y < 0 || (c.Y >= table.Height() && table.Height() > 0) { + t.Errorf("Path: %s Table cursor2 out of range: %v", p, c) + } + }) + yield(name+".SetCursorX", func(p string, t *testing.T) { + table.SetCursorX(seed % 16) + }) + yield(name+".SetCursorY", func(p string, t *testing.T) { + table.SetCursorY(seed % 16) + }) + yield(name+".MoveCursor", func(p string, t *testing.T) { + table.MoveCursor(seed%2*2-1, seed%2*2-1) + }) + yield(name+".Copy", func(p string, t *testing.T) { + s.clipboard, _ = table.Copy() + }) + yield(name+".Paste", func(p string, t *testing.T) { + table.Paste(s.clipboard) + }) + yield(name+".Clear", func(p string, t *testing.T) { + table.Clear() + }) + yield(name+".Fill", func(p string, t *testing.T) { + table.Fill(seed % 16) + }) + yield(name+".Add", func(p string, t *testing.T) { + table.Add(seed % 16) + }) +} + +func FuzzModel(f *testing.F) { + seed := make([]byte, 1) + for i := range seed { + seed[i] = byte(i) + } + f.Add(seed) + f.Fuzz(func(t *testing.T, slice []byte) { + reader := bytes.NewReader(slice) + synther := vm.GoSynther{} + model, player := tracker.NewModelPlayer(synther, "") + buf := make([][2]float32, 2048) + closeChan := make(chan struct{}) + go func() { + loop: + for { + select { + case <-closeChan: + break loop + default: + ctx := NullContext{} + player.Process(buf, ctx) + } + } + }() + state := modelFuzzState{model: model} + count := 0 + state.Iterate(func(n string, f func(p string, t *testing.T)) bool { + count++ + return true + }, 0) + totalPath := "" + for m, err := binary.ReadVarint(reader); err == nil; m, err = binary.ReadVarint(reader) { + seed := int(m) + index := seed % count + state.Iterate(func(n string, f func(p string, t *testing.T)) bool { + if index == 0 { + totalPath += n + ". " + f(totalPath, t) + } + index-- + return index > 0 + }, seed) + } + closeChan <- struct{}{} + }) +} diff --git a/tracker/params.go b/tracker/params.go new file mode 100644 index 0000000..f2a9d1f --- /dev/null +++ b/tracker/params.go @@ -0,0 +1,345 @@ +package tracker + +import ( + "fmt" + "math" + "slices" + "strconv" + + "github.com/vsariola/sointu" + "github.com/vsariola/sointu/vm" +) + +type ( + Parameter interface { + IntData + Type() ParameterType + Name() string + Hint() string + LargeStep() int + Reset() + } + + parameter struct { + m *Model + unit *sointu.Unit + } + + NamedParameter struct { + parameter + up *sointu.UnitParameter + } + + DelayTimeParameter struct { + parameter + index int + } + + DelayLinesParameter struct{ parameter } + GmDlsEntryParameter struct{ parameter } + ReverbParameter struct{ parameter } + + Params Model + + ParamYieldFunc func(Parameter) + + ParameterType int +) + +const ( + IntegerParameter ParameterType = iota + BoolParameter + IDParameter +) + +// Model methods + +func (m *Model) Params() *Params { return (*Params)(m) } + +// parameter methods + +func (p parameter) change(kind string) func() { + return p.m.change("Parameter."+kind, PatchChange, MinorChange) +} + +// ParamList + +func (pl *Params) List() List { return List{pl} } +func (pl *Params) Selected() int { return pl.d.ParamIndex } +func (pl *Params) Selected2() int { return pl.Selected() } +func (pl *Params) SetSelected(value int) { pl.d.ParamIndex = intMax(intMin(value, pl.Count()-1), 0) } +func (pl *Params) SetSelected2(value int) {} +func (pl *Params) cancel() { (*Model)(pl).changeCancel = true } + +func (pl *Params) change(n string, severity ChangeSeverity) func() { + return (*Model)(pl).change("ParamList."+n, PatchChange, severity) +} + +func (pl *Params) Count() int { + count := 0 + pl.Iterate(func(p Parameter) { + count++ + }) + return count +} + +func (pl *Params) SelectedItem() (ret Parameter) { + index := pl.Selected() + pl.Iterate(func(param Parameter) { + if index == 0 { + ret = param + } + index-- + }) + return +} + +func (pl *Params) Iterate(yield ParamYieldFunc) { + if pl.d.InstrIndex < 0 || pl.d.InstrIndex >= len(pl.d.Song.Patch) { + return + } + if pl.d.UnitIndex < 0 || pl.d.UnitIndex >= len(pl.d.Song.Patch[pl.d.InstrIndex].Units) { + return + } + unit := &pl.d.Song.Patch[pl.d.InstrIndex].Units[pl.d.UnitIndex] + unitType, ok := sointu.UnitTypes[unit.Type] + if !ok { + return + } + for i := range unitType { + if !unitType[i].CanSet { + continue + } + if unit.Type == "oscillator" && unit.Parameters["type"] != sointu.Sample && i >= 11 { + break // don't show the sample related params unless necessary + } + yield(NamedParameter{ + parameter: parameter{m: (*Model)(pl), unit: unit}, + up: &unitType[i], + }) + } + if unit.Type == "oscillator" && unit.Parameters["type"] == sointu.Sample { + yield(GmDlsEntryParameter{parameter: parameter{m: (*Model)(pl), unit: unit}}) + } + switch { + case unit.Type == "delay": + if unit.Parameters["stereo"] == 1 && len(unit.VarArgs)%2 == 1 { + unit.VarArgs = append(unit.VarArgs, 1) + } + yield(ReverbParameter{parameter: parameter{m: (*Model)(pl), unit: unit}}) + yield(DelayLinesParameter{parameter: parameter{m: (*Model)(pl), unit: unit}}) + for i := range unit.VarArgs { + yield(DelayTimeParameter{parameter: parameter{m: (*Model)(pl), unit: unit}, index: i}) + } + } +} + +// NamedParameter + +func (p NamedParameter) Name() string { return p.up.Name } +func (p NamedParameter) Range() intRange { return intRange{Min: p.up.MinValue, Max: p.up.MaxValue} } +func (p NamedParameter) Value() int { return p.unit.Parameters[p.up.Name] } +func (p NamedParameter) setValue(value int) { p.unit.Parameters[p.up.Name] = value } + +func (p NamedParameter) Reset() { + v, ok := defaultUnits[p.unit.Type].Parameters[p.up.Name] + if !ok || p.unit.Parameters[p.up.Name] == v { + return + } + defer p.parameter.change("Reset")() + p.unit.Parameters[p.up.Name] = v +} + +func (p NamedParameter) Type() ParameterType { + if p.unit.Type == "send" && p.up.Name == "target" { + return IDParameter + } + if p.up.MinValue == 0 && p.up.MaxValue == 1 { + return BoolParameter + } + return IntegerParameter +} + +func (p NamedParameter) Hint() string { + val := p.Value() + text := p.m.d.Song.Patch.ParamHintString(p.m.d.InstrIndex, p.m.d.UnitIndex, p.up.Name) + if text != "" { + text = fmt.Sprintf("%v / %v", val, text) + } else { + text = strconv.Itoa(val) + } + return text +} + +func (p NamedParameter) LargeStep() int { + if p.up.Name == "transpose" { + return 12 + } + return 16 +} + +// GmDlsEntryParameter + +func (p GmDlsEntryParameter) Name() string { return "sample" } +func (p GmDlsEntryParameter) Type() ParameterType { return IntegerParameter } +func (p GmDlsEntryParameter) Range() intRange { return intRange{Min: 0, Max: len(GmDlsEntries)} } +func (p GmDlsEntryParameter) LargeStep() int { return 16 } +func (p GmDlsEntryParameter) Reset() { return } + +func (p GmDlsEntryParameter) Value() int { + key := vm.SampleOffset{Start: uint32(p.unit.Parameters["samplestart"]), LoopStart: uint16(p.unit.Parameters["loopstart"]), LoopLength: uint16(p.unit.Parameters["looplength"])} + if v, ok := gmDlsEntryMap[key]; ok { + return v + 1 + } + return 0 +} + +func (p GmDlsEntryParameter) setValue(v int) { + if v < 1 || v > len(GmDlsEntries) { + return + } + e := GmDlsEntries[v-1] + p.unit.Parameters["samplestart"] = e.Start + p.unit.Parameters["loopstart"] = e.LoopStart + p.unit.Parameters["looplength"] = e.LoopLength + p.unit.Parameters["transpose"] = 64 + e.SuggestedTranspose +} + +func (p GmDlsEntryParameter) Hint() string { + if v := p.Value(); v > 0 { + return fmt.Sprintf("%v / %v", v, GmDlsEntries[v-1].Name) + } + return "0 / custom" +} + +// DelayTimeParameter + +func (p DelayTimeParameter) Name() string { return "delaytime" } +func (p DelayTimeParameter) Type() ParameterType { return IntegerParameter } +func (p DelayTimeParameter) LargeStep() int { return 16 } +func (p DelayTimeParameter) Reset() { return } + +func (p DelayTimeParameter) Value() int { + if p.index < 0 || p.index >= len(p.unit.VarArgs) { + return 1 + } + return p.unit.VarArgs[p.index] +} + +func (p DelayTimeParameter) setValue(v int) { + p.unit.VarArgs[p.index] = v +} + +func (p DelayTimeParameter) Range() intRange { + if p.unit.Parameters["notetracking"] == 2 { + return intRange{Min: 1, Max: 576} + } + return intRange{Min: 1, Max: 65535} +} + +func (p DelayTimeParameter) Hint() string { + val := p.Value() + var text string + switch p.unit.Parameters["notetracking"] { + default: + case 0: + text = fmt.Sprintf("%v / %.3f rows", val, float32(val)/float32(p.m.d.Song.SamplesPerRow())) + case 1: + relPitch := float64(val) / 10787 + semitones := -math.Log2(relPitch) * 12 + text = fmt.Sprintf("%v / %.3f st", val, semitones) + case 2: + k := 0 + v := val + for v&1 == 0 { // divide val by 2 until it is odd + v >>= 1 + k++ + } + switch v { + case 1: + if k <= 7 { + text = fmt.Sprintf(" (1/%d triplet)", 1<<(7-k)) + } + case 3: + if k <= 6 { + text = fmt.Sprintf(" (1/%d)", 1<<(6-k)) + } + break + case 9: + if k <= 5 { + text = fmt.Sprintf(" (1/%d dotted)", 1<<(5-k)) + } + } + text = fmt.Sprintf("%v / %.3f beats%s", val, float32(val)/48.0, text) + } + if p.unit.Parameters["stereo"] == 1 { + if p.index < len(p.unit.VarArgs)/2 { + text += " R" + } else { + text += " L" + } + } + return text +} + +// DelayLinesParameter + +func (p DelayLinesParameter) Name() string { return "delaylines" } +func (p DelayLinesParameter) Type() ParameterType { return IntegerParameter } +func (p DelayLinesParameter) Range() intRange { return intRange{Min: 1, Max: 32} } +func (p DelayLinesParameter) LargeStep() int { return 4 } +func (p DelayLinesParameter) Reset() { return } +func (p DelayLinesParameter) Hint() string { return strconv.Itoa(p.Value()) } + +func (p DelayLinesParameter) Value() int { + val := len(p.unit.VarArgs) + if p.unit.Parameters["stereo"] == 1 { + val /= 2 + } + return val +} + +func (p DelayLinesParameter) setValue(v int) { + targetLines := v + if p.unit.Parameters["stereo"] == 1 { + targetLines *= 2 + } + for len(p.unit.VarArgs) < targetLines { + p.unit.VarArgs = append(p.unit.VarArgs, 1) + } + p.unit.VarArgs = p.unit.VarArgs[:targetLines] +} + +// ReverbParameter + +func (p ReverbParameter) Name() string { return "reverb" } +func (p ReverbParameter) Type() ParameterType { return IntegerParameter } +func (p ReverbParameter) Range() intRange { return intRange{Min: 0, Max: len(reverbs)} } +func (p ReverbParameter) LargeStep() int { return 1 } +func (p ReverbParameter) Reset() { return } + +func (p ReverbParameter) Value() int { + i := slices.IndexFunc(reverbs, func(d delayPreset) bool { + return d.stereo == p.unit.Parameters["stereo"] && p.unit.Parameters["notetracking"] == 0 && slices.Equal(d.varArgs, p.unit.VarArgs) + }) + return i + 1 +} + +func (p ReverbParameter) setValue(v int) { + if v < 1 || v > len(reverbs) { + return + } + entry := reverbs[v-1] + p.unit.Parameters["stereo"] = entry.stereo + p.unit.Parameters["notetracking"] = 0 + p.unit.VarArgs = make([]int, len(entry.varArgs)) + copy(p.unit.VarArgs, entry.varArgs) +} + +func (p ReverbParameter) Hint() string { + i := p.Value() + if i > 0 { + return fmt.Sprintf("%v / %v", i, reverbs[i-1].name) + } + return "0 / custom" +} diff --git a/tracker/player.go b/tracker/player.go index 26b474b..931aabd 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -19,7 +19,7 @@ type ( song sointu.Song // the song being played playing bool // is the player playing the score or not rowtime int // how many samples have been played in the current row - position ScoreRow // the current position in the score + songPos sointu.SongPos // the current position in the score avgVolumeMeter VolumeAnalyzer // the volume analyzer used to calculate the average volume peakVolumeMeter VolumeAnalyzer // the volume analyzer used to calculate the peak volume voiceLevels [vm.MAX_VOICES]float32 // a level that can be used to visualize the volume of each voice @@ -28,9 +28,9 @@ type ( recState recState // is the recording off; are we waiting for a note; or are we recording recording Recording // the recorded MIDI events and BPM - synther sointu.Synther // the synther used to create new synths - playerMessages chan<- PlayerMessage - modelMessages <-chan interface{} + synther sointu.Synther // the synther used to create new synths + playerMsgs chan<- PlayerMsg + modelMsgs <-chan interface{} } // PlayerProcessContext is the context given to the player when processing @@ -50,29 +50,19 @@ type ( Note byte } - // PlayerMessage is a message sent from the player to the model. The Inner + // PlayerMsg is a message sent from the player to the model. The Inner // field can contain any message. Panic, AverageVolume, PeakVolume, SongRow // and VoiceStates transmitted frequently, with every message, so they are // treated specially, to avoid boxing. All the rest messages can be boxed to // Inner interface{} - PlayerMessage struct { + PlayerMsg struct { Panic bool AverageVolume Volume PeakVolume Volume - SongRow ScoreRow + SongPosition sointu.SongPos VoiceLevels [vm.MAX_VOICES]float32 Inner interface{} } - - // PlayerCrashMessage is sent to the model when the player crashes. - PlayerCrashMessage struct { - error - } - - // PlayerVolumeErrorMessage is sent to the model there is an error in the volume analyzer. The error is not fatal. - PlayerVolumeErrorMessage struct { - error - } ) type ( @@ -93,20 +83,6 @@ const ( const numRenderTries = 10000 -// NewPlayer creates a new player. The playerMessages channel is used to send -// messages to the model. The modelMessages channel is used to receive messages -// from the model. The synther is used to create new synths. -func NewPlayer(synther sointu.Synther, playerMessages chan<- PlayerMessage, modelMessages <-chan interface{}) *Player { - p := &Player{ - playerMessages: playerMessages, - modelMessages: modelMessages, - synther: synther, - avgVolumeMeter: VolumeAnalyzer{Attack: 0.3, Release: 0.3, Min: -100, Max: 20}, - peakVolumeMeter: VolumeAnalyzer{Attack: 1e-4, Release: 1, Min: -100, Max: 20}, - } - return p -} - // Process renders audio to the given buffer, trying to fill it completely. If // the buffer is not filled, the synth is destroyed and an error is sent to the // model. context tells the player which MIDI events happen during the current @@ -152,6 +128,9 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext if p.playing { timeUntilRowAdvance = p.song.SamplesPerRow() - p.rowtime } + if timeUntilRowAdvance < 0 { + timeUntilRowAdvance = 0 + } var rendered, timeAdvanced int var err error if p.synth != nil { @@ -169,7 +148,7 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext } if err != nil { p.synth = nil - p.send(PlayerCrashMessage{fmt.Errorf("synth.Render: %w", err)}) + p.send(Alert{Message: fmt.Sprintf("synth.Render: %s", err.Error()), Priority: Error, Name: "PlayerCrash"}) } buffer = buffer[rendered:] frame += rendered @@ -189,47 +168,37 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext if len(buffer) == 0 { err := p.avgVolumeMeter.Update(oldBuffer) err2 := p.peakVolumeMeter.Update(oldBuffer) - var msg interface{} if err != nil { - msg = PlayerCrashMessage{err} p.synth = nil + p.sendAlert("PlayerVolume", err.Error(), Warning) + return } if err2 != nil { - msg = PlayerCrashMessage{err} p.synth = nil + p.sendAlert("PlayerVolume", err2.Error(), Warning) + return } - p.send(msg) + p.send(nil) return } } // we were not able to fill the buffer with NUM_RENDER_TRIES attempts, destroy synth and throw an error p.synth = nil - p.send(PlayerCrashMessage{fmt.Errorf("synth did not fill the audio buffer even with %d render calls", numRenderTries)}) + p.sendAlert("PlayerCrash", fmt.Sprintf("synth did not fill the audio buffer even with %d render calls", numRenderTries), Error) } func (p *Player) advanceRow() { if p.song.Score.Length == 0 || p.song.Score.RowsPerPattern == 0 { return } - p.position.Row++ // advance row (this is why we subtracted one in Play()) - p.position = p.position.Wrap(p.song.Score) + p.songPos.PatternRow++ // advance row (this is why we subtracted one in Play()) + p.songPos = p.song.Score.Wrap(p.songPos) p.send(nil) // just send volume and song row information lastVoice := 0 for i, t := range p.song.Score.Tracks { start := lastVoice lastVoice = start + t.NumVoices - if p.position.Pattern < 0 || p.position.Pattern >= len(t.Order) { - continue - } - o := t.Order[p.position.Pattern] - if o < 0 || o >= len(t.Patterns) { - continue - } - pat := t.Patterns[o] - if p.position.Row < 0 || p.position.Row >= len(pat) { - continue - } - n := pat[p.position.Row] + n := t.Note(p.songPos) switch { case n == 0: p.releaseTrack(i) @@ -245,9 +214,9 @@ func (p *Player) processMessages(context PlayerProcessContext) { loop: for { // process new message select { - case msg := <-p.modelMessages: + case msg := <-p.modelMsgs: switch m := msg.(type) { - case ModelPanicMessage: + case PanicMsg: if m.bool { p.synth = nil } else { @@ -261,23 +230,23 @@ loop: p.compileOrUpdateSynth() case sointu.Score: p.song.Score = m - case ModelPlayingChangedMessage: + case IsPlayingMsg: p.playing = bool(m.bool) if !p.playing { for i := range p.song.Score.Tracks { p.releaseTrack(i) } } - case ModelBPMChangedMessage: + case BPMMsg: p.song.BPM = m.int p.compileOrUpdateSynth() - case ModelRowsPerBeatChangedMessage: + case RowsPerBeatMsg: p.song.RowsPerBeat = m.int p.compileOrUpdateSynth() - case ModelPlayFromPositionMessage: + case StartPlayMsg: p.playing = true - p.position = m.ScoreRow - p.position.Row-- + p.songPos = m.SongPos + p.songPos.PatternRow-- p.rowtime = math.MaxInt for i, t := range p.song.Score.Tracks { if !t.Effect { @@ -285,19 +254,19 @@ loop: p.releaseTrack(i) } } - case ModelNoteOnMessage: + case NoteOnMsg: if m.IsInstr { p.triggerInstrument(m.Instr, m.Note) } else { p.triggerTrack(m.Track, m.Note) } - case ModelNoteOffMessage: + case NoteOffMsg: if m.IsInstr { p.releaseInstrument(m.Instr, m.Note) } else { p.releaseTrack(m.Track) } - case ModelRecordingMessage: + case RecordingMsg: if m.bool { p.recState = recStateWaitingForNote p.recording = Recording{} @@ -317,6 +286,15 @@ loop: } } +func (p *Player) sendAlert(name, message string, priority AlertPriority) { + p.send(Alert{ + Name: name, + Priority: priority, + Message: message, + Duration: defaultAlertDuration, + }) +} + func (p *Player) compileOrUpdateSynth() { if p.song.BPM <= 0 { return // bpm not set yet @@ -325,7 +303,7 @@ func (p *Player) compileOrUpdateSynth() { err := p.synth.Update(p.song.Patch, p.song.BPM) if err != nil { p.synth = nil - p.send(PlayerCrashMessage{fmt.Errorf("synth.Update: %w", err)}) + p.sendAlert("PlayerCrash", fmt.Sprintf("synth.Update: %v", err), Error) return } } else { @@ -333,7 +311,7 @@ func (p *Player) compileOrUpdateSynth() { p.synth, err = p.synther.Synth(p.song.Patch, p.song.BPM) if err != nil { p.synth = nil - p.send(PlayerCrashMessage{fmt.Errorf("synther.Synth: %w", err)}) + p.sendAlert("PlayerCrash", fmt.Sprintf("synther.Synth: %v", err), Error) return } } @@ -342,7 +320,7 @@ func (p *Player) compileOrUpdateSynth() { // all sends from player are always non-blocking, to ensure that the player thread cannot end up in a dead-lock func (p *Player) send(message interface{}) { select { - case p.playerMessages <- PlayerMessage{Panic: p.synth == nil, AverageVolume: p.avgVolumeMeter.Level, PeakVolume: p.peakVolumeMeter.Level, SongRow: p.position, VoiceLevels: p.voiceLevels, Inner: message}: + case p.playerMsgs <- PlayerMsg{Panic: p.synth == nil, AverageVolume: p.avgVolumeMeter.Level, PeakVolume: p.peakVolumeMeter.Level, SongPosition: p.songPos, VoiceLevels: p.voiceLevels, Inner: message}: default: } } diff --git a/tracker/presets.go b/tracker/presets.go index 545b383..ce985cb 100644 --- a/tracker/presets.go +++ b/tracker/presets.go @@ -12,23 +12,31 @@ import ( //go:generate go run generate/main.go -// GmDlsEntry is a single sample entry from the gm.dls file -type GmDlsEntry struct { - Start int // sample start offset in words - LoopStart int // loop start offset in words - LoopLength int // loop length in words - SuggestedTranspose int // suggested transpose in semitones, so that all samples play at same pitch - Name string // sample name -} +type ( + // GmDlsEntry is a single sample entry from the gm.dls file + GmDlsEntry struct { + Start int // sample start offset in words + LoopStart int // loop start offset in words + LoopLength int // loop length in words + SuggestedTranspose int // suggested transpose in semitones, so that all samples play at same pitch + Name string // sample name + } -// GmDlsEntryMap is a reverse map, to find the index of the GmDlsEntry in the + InstrumentPresetYieldFunc func(index int, item string) (ok bool) + LoadPreset struct { + Index int + *Model + } +) + +// gmDlsEntryMap is a reverse map, to find the index of the GmDlsEntry in the // GmDlsEntries list based on the sample offset. Do not modify during runtime. -var GmDlsEntryMap = make(map[vm.SampleOffset]int) +var gmDlsEntryMap = make(map[vm.SampleOffset]int) func init() { for i, e := range GmDlsEntries { key := vm.SampleOffset{Start: uint32(e.Start), LoopStart: uint16(e.LoopStart), LoopLength: uint16(e.LoopLength)} - GmDlsEntryMap[key] = i + gmDlsEntryMap[key] = i } } @@ -103,12 +111,6 @@ var defaultSong = sointu.Song{ }}}, } -type delayPreset struct { - name string - stereo int - varArgs []int -} - var reverbs = []delayPreset{ {"stereo", 1, []int{1116, 1188, 1276, 1356, 1422, 1492, 1556, 1618, 1140, 1212, 1300, 1380, 1446, 1516, 1580, 1642, @@ -117,11 +119,41 @@ var reverbs = []delayPreset{ {"right", 0, []int{1140, 1212, 1300, 1380, 1446, 1516, 1580, 1642}}, } -type instrumentPresets []sointu.Instrument +type delayPreset struct { + name string + stereo int + varArgs []int +} + +func (m *Model) IterateInstrumentPresets(yield InstrumentPresetYieldFunc) { + for index, instr := range instrumentPresets { + if !yield(index, instr.Name) { + return + } + } +} + +func (m *Model) LoadPreset(index int) Action { + return Action{do: func() { + defer m.change("LoadPreset", PatchChange, MajorChange)() + if m.d.InstrIndex < 0 { + m.d.InstrIndex = 0 + } + m.d.InstrIndex2 = m.d.InstrIndex + for m.d.InstrIndex >= len(m.d.Song.Patch) { + m.d.Song.Patch = append(m.d.Song.Patch, defaultInstrument.Copy()) + } + m.d.Song.Patch[m.d.InstrIndex] = instrumentPresets[index].Copy() + }, allowed: func() bool { + return true + }} +} + +type instrumentPresetsSlice []sointu.Instrument //go:embed presets/* var instrumentPresetFS embed.FS -var InstrumentPresets instrumentPresets +var instrumentPresets instrumentPresetsSlice func init() { fs.WalkDir(instrumentPresetFS, ".", func(path string, d fs.DirEntry, err error) error { @@ -139,20 +171,12 @@ func init() { if yaml.Unmarshal(data, &instr) != nil { return nil } - InstrumentPresets = append(InstrumentPresets, instr) + instrumentPresets = append(instrumentPresets, instr) return nil }) - sort.Sort(InstrumentPresets) + sort.Sort(instrumentPresets) } -func (p instrumentPresets) Len() int { - return len(p) -} - -func (p instrumentPresets) Less(i, j int) bool { - return p[i].Name < p[j].Name -} - -func (p instrumentPresets) Swap(i, j int) { - p[i], p[j] = p[j], p[i] -} +func (p instrumentPresetsSlice) Len() int { return len(p) } +func (p instrumentPresetsSlice) Less(i, j int) bool { return p[i].Name < p[j].Name } +func (p instrumentPresetsSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } diff --git a/tracker/recording.go b/tracker/recording.go index 9bdca1f..2b088c1 100644 --- a/tracker/recording.go +++ b/tracker/recording.go @@ -22,9 +22,9 @@ type recordingNote struct { var ErrInvalidRows = errors.New("rows per beat and rows per pattern must be greater than 1") -func (recording *Recording) Song(patch sointu.Patch, rowsPerBeat, rowsPerPattern int) (sointu.Song, error) { +func (recording *Recording) Score(patch sointu.Patch, rowsPerBeat, rowsPerPattern int) (sointu.Score, error) { if rowsPerBeat <= 1 || rowsPerPattern <= 1 { - return sointu.Song{}, ErrInvalidRows + return sointu.Score{}, ErrInvalidRows } channelNotes := make([][]recordingNote, 0) // find the length of each note and assign it to its respective channel @@ -88,15 +88,18 @@ func (recording *Recording) Song(patch sointu.Patch, rowsPerBeat, rowsPerPattern flatPattern[k] = 1 // set all notes as holds at first } for _, n := range t { - flatPattern.Set(n.startRow, n.note) + if n.startRow >= songLengthRows { + continue + } + flatPattern[n.startRow] = n.note if n.endRow < songLengthRows { for l := n.startRow + 1; l < n.endRow; l++ { - flatPattern.Set(l, 1) + flatPattern[l] = 1 } - flatPattern.Set(n.endRow, 0) + flatPattern[n.endRow] = 0 } else { for l := n.startRow + 1; l < songLengthRows; l++ { - flatPattern.Set(l, 1) + flatPattern[l] = 1 } } } @@ -136,7 +139,7 @@ func (recording *Recording) Song(patch sointu.Patch, rowsPerBeat, rowsPerPattern } } score := sointu.Score{Length: songLengthPatterns, RowsPerPattern: rowsPerPattern, Tracks: songTracks} - return sointu.Song{BPM: int(recording.BPM + 0.5), RowsPerBeat: rowsPerBeat, Score: score, Patch: patch.Copy()}, nil + return score, nil } func frameToRow(BPM float64, rowsPerBeat, frame int) int { diff --git a/tracker/songpoint.go b/tracker/songpoint.go deleted file mode 100644 index 13c3961..0000000 --- a/tracker/songpoint.go +++ /dev/null @@ -1,106 +0,0 @@ -package tracker - -import "github.com/vsariola/sointu" - -type ( - // ScoreRow identifies a row of the song score. - ScoreRow struct { - Pattern int - Row int - } - - // ScorePoint identifies a row and a track in a song score. - ScorePoint struct { - Track int - ScoreRow - } - - // ScoreRect identifies a rectangular area in a song score. - ScoreRect struct { - Corner1 ScorePoint - Corner2 ScorePoint - } -) - -func (r ScoreRow) AddRows(rows int) ScoreRow { - return ScoreRow{Row: r.Row + rows, Pattern: r.Pattern} -} - -func (r ScoreRow) AddPatterns(patterns int) ScoreRow { - return ScoreRow{Row: r.Row, Pattern: r.Pattern + patterns} -} - -func (r ScoreRow) Wrap(score sointu.Score) ScoreRow { - totalRow := r.Pattern*score.RowsPerPattern + r.Row - r.Row = mod(totalRow, score.RowsPerPattern) - r.Pattern = mod((totalRow-r.Row)/score.RowsPerPattern, score.Length) - return r -} - -func (r ScoreRow) Clamp(score sointu.Score) ScoreRow { - totalRow := r.Pattern*score.RowsPerPattern + r.Row - if totalRow < 0 { - totalRow = 0 - } - if totalRow >= score.LengthInRows() { - totalRow = score.LengthInRows() - 1 - } - r.Row = totalRow % score.RowsPerPattern - r.Pattern = ((totalRow - r.Row) / score.RowsPerPattern) % score.Length - return r -} - -func (r ScorePoint) AddRows(rows int) ScorePoint { - return ScorePoint{Track: r.Track, ScoreRow: r.ScoreRow.AddRows(rows)} -} - -func (r ScorePoint) AddPatterns(patterns int) ScorePoint { - return ScorePoint{Track: r.Track, ScoreRow: r.ScoreRow.AddPatterns(patterns)} -} - -func (p ScorePoint) Wrap(score sointu.Score) ScorePoint { - p.Track = mod(p.Track, len(score.Tracks)) - p.ScoreRow = p.ScoreRow.Wrap(score) - return p -} - -func (p ScorePoint) Clamp(score sointu.Score) ScorePoint { - if p.Track < 0 { - p.Track = 0 - } else if l := len(score.Tracks); p.Track >= l { - p.Track = l - 1 - } - p.ScoreRow = p.ScoreRow.Clamp(score) - return p -} - -func (r *ScoreRect) Contains(p ScorePoint) bool { - track1, track2 := r.Corner1.Track, r.Corner2.Track - if track2 < track1 { - track1, track2 = track2, track1 - } - if p.Track < track1 || p.Track > track2 { - return false - } - pattern1, row1, pattern2, row2 := r.Corner1.Pattern, r.Corner1.Row, r.Corner2.Pattern, r.Corner2.Row - if pattern2 < pattern1 || (pattern1 == pattern2 && row2 < row1) { - pattern1, row1, pattern2, row2 = pattern2, row2, pattern1, row1 - } - if p.Pattern < pattern1 || p.Pattern > pattern2 { - return false - } - if p.Pattern == pattern1 && p.Row < row1 { - return false - } - if p.Pattern == pattern2 && p.Row > row2 { - return false - } - return true -} - -func mod(a, b int) int { - if a < 0 { - return b - 1 - mod(-a-1, b) - } - return a % b -} diff --git a/tracker/string.go b/tracker/string.go new file mode 100644 index 0000000..cc3725e --- /dev/null +++ b/tracker/string.go @@ -0,0 +1,94 @@ +package tracker + +type ( + String struct { + StringData + } + + StringData interface { + Value() string + setValue(string) + change(kind string) func() + } + + FilePath Model + InstrumentName Model + InstrumentComment Model + UnitSearch Model +) + +func (v String) Set(value string) { + if v.Value() != value { + defer v.change("Set")() + v.setValue(value) + } +} + +// Model methods + +func (m *Model) FilePath() *FilePath { return (*FilePath)(m) } +func (m *Model) InstrumentName() *InstrumentName { return (*InstrumentName)(m) } +func (m *Model) InstrumentComment() *InstrumentComment { return (*InstrumentComment)(m) } +func (m *Model) UnitSearch() *UnitSearch { return (*UnitSearch)(m) } + +// FilePathString + +func (v *FilePath) String() String { return String{v} } +func (v *FilePath) Value() string { return v.d.FilePath } +func (v *FilePath) setValue(value string) { v.d.FilePath = value } +func (v *FilePath) change(kind string) func() { return func() {} } + +// UnitSearchString + +func (v *UnitSearch) String() String { return String{v} } +func (v *UnitSearch) Value() string { return v.d.UnitSearchString } +func (v *UnitSearch) setValue(value string) { v.d.UnitSearchString = value } +func (v *UnitSearch) change(kind string) func() { return func() {} } + +// InstrumentNameString + +func (v *InstrumentName) String() String { + return String{v} +} + +func (v *InstrumentName) Value() string { + if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) { + return "" + } + return v.d.Song.Patch[v.d.InstrIndex].Name +} + +func (v *InstrumentName) setValue(value string) { + if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) { + return + } + v.d.Song.Patch[v.d.InstrIndex].Name = value +} + +func (v *InstrumentName) change(kind string) func() { + return (*Model)(v).change("InstrumentNameString."+kind, PatchChange, MinorChange) +} + +// InstrumentComment + +func (v *InstrumentComment) String() String { + return String{v} +} + +func (v *InstrumentComment) Value() string { + if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) { + return "" + } + return v.d.Song.Patch[v.d.InstrIndex].Comment +} + +func (v *InstrumentComment) setValue(value string) { + if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) { + return + } + v.d.Song.Patch[v.d.InstrIndex].Comment = value +} + +func (v *InstrumentComment) change(kind string) func() { + return (*Model)(v).change("InstrumentComment."+kind, PatchChange, MinorChange) +} diff --git a/tracker/table.go b/tracker/table.go new file mode 100644 index 0000000..e5bbd1b --- /dev/null +++ b/tracker/table.go @@ -0,0 +1,632 @@ +package tracker + +import ( + "github.com/vsariola/sointu" + "gopkg.in/yaml.v3" +) + +type ( + Table struct { + TableData + } + + TableData interface { + Cursor() Point + Cursor2() Point + SetCursor(Point) + SetCursor2(Point) + Width() int + Height() int + MoveCursor(dx, dy int) (ok bool) + + clear(p Point) + set(p Point, value int) + add(rect Rect, delta int) (ok bool) + marshal(rect Rect) (data []byte, ok bool) + unmarshalAtCursor(data []byte) (ok bool) + unmarshalRange(rect Rect, data []byte) (ok bool) + change(kind string, severity ChangeSeverity) func() + cancel() + } + + Point struct { + X, Y int + } + + Rect struct { + TopLeft, BottomRight Point + } + + Order Model + Notes Model +) + +// Model methods + +func (m *Model) Order() *Order { return (*Order)(m) } +func (m *Model) Notes() *Notes { return (*Notes)(m) } + +// Rect methods + +func (r *Rect) Contains(p Point) bool { + return r.TopLeft.X <= p.X && p.X <= r.BottomRight.X && + r.TopLeft.Y <= p.Y && p.Y <= r.BottomRight.Y +} + +func (r *Rect) Width() int { + return r.BottomRight.X - r.TopLeft.X + 1 +} + +func (r *Rect) Height() int { + return r.BottomRight.Y - r.TopLeft.Y + 1 +} + +func (r *Rect) Limit(width, height int) { + if r.TopLeft.X < 0 { + r.TopLeft.X = 0 + } + if r.TopLeft.Y < 0 { + r.TopLeft.Y = 0 + } + if r.BottomRight.X >= width { + r.BottomRight.X = width - 1 + } + if r.BottomRight.Y >= height { + r.BottomRight.Y = height - 1 + } +} + +// Table methods + +func (v Table) Range() (rect Rect) { + rect.TopLeft.X = intMin(v.Cursor().X, v.Cursor2().X) + rect.TopLeft.Y = intMin(v.Cursor().Y, v.Cursor2().Y) + rect.BottomRight.X = intMax(v.Cursor().X, v.Cursor2().X) + rect.BottomRight.Y = intMax(v.Cursor().Y, v.Cursor2().Y) + return +} + +func (v Table) Copy() ([]byte, bool) { + ret, ok := v.marshal(v.Range()) + if !ok { + return nil, false + } + return ret, true +} + +func (v Table) Paste(data []byte) bool { + defer v.change("Paste", MajorChange)() + if v.Cursor() == v.Cursor2() { + return v.unmarshalAtCursor(data) + } else { + return v.unmarshalRange(v.Range(), data) + } +} + +func (v Table) Clear() { + defer v.change("Clear", MajorChange)() + rect := v.Range() + rect.Limit(v.Width(), v.Height()) + for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ { + for x := rect.TopLeft.X; x <= rect.BottomRight.X; x++ { + v.clear(Point{x, y}) + } + } +} + +func (v Table) Fill(value int) { + defer v.change("Fill", MajorChange)() + rect := v.Range() + rect.Limit(v.Width(), v.Height()) + for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ { + for x := rect.TopLeft.X; x <= rect.BottomRight.X; x++ { + v.set(Point{x, y}, value) + } + } +} + +func (v Table) Add(delta int) { + defer v.change("Add", MinorChange)() + if !v.add(v.Range(), delta) { + v.cancel() + } +} + +func (v Table) SetCursorX(x int) { + p := v.Cursor() + p.X = x + v.SetCursor(p) +} + +func (v Table) SetCursorY(y int) { + p := v.Cursor() + p.Y = y + v.SetCursor(p) +} + +// Order methods + +func (v *Order) Table() Table { + return Table{v} +} + +func (m *Order) Cursor() Point { + t := intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)-1), 0) + p := intMax(intMin(m.d.Cursor.OrderRow, m.d.Song.Score.Length-1), 0) + return Point{t, p} +} + +func (m *Order) Cursor2() Point { + t := intMax(intMin(m.d.Cursor2.Track, len(m.d.Song.Score.Tracks)-1), 0) + p := intMax(intMin(m.d.Cursor2.OrderRow, m.d.Song.Score.Length-1), 0) + return Point{t, p} +} + +func (m *Order) SetCursor(p Point) { + m.d.Cursor.Track = intMax(intMin(p.X, len(m.d.Song.Score.Tracks)-1), 0) + y := intMax(intMin(p.Y, m.d.Song.Score.Length-1), 0) + if y != m.d.Cursor.OrderRow { + m.noteTracking = false + } + m.d.Cursor.OrderRow = y + m.updateCursorRows() +} + +func (m *Order) SetCursor2(p Point) { + m.d.Cursor2.Track = intMax(intMin(p.X, len(m.d.Song.Score.Tracks)-1), 0) + m.d.Cursor2.OrderRow = intMax(intMin(p.Y, m.d.Song.Score.Length-1), 0) + m.updateCursorRows() +} + +func (v *Order) updateCursorRows() { + if v.Cursor() == v.Cursor2() { + v.d.Cursor.PatternRow = 0 + v.d.Cursor2.PatternRow = 0 + return + } + if v.d.Cursor.OrderRow > v.d.Cursor2.OrderRow { + v.d.Cursor.PatternRow = v.d.Song.Score.RowsPerPattern - 1 + v.d.Cursor2.PatternRow = 0 + } else { + v.d.Cursor.PatternRow = 0 + v.d.Cursor2.PatternRow = v.d.Song.Score.RowsPerPattern - 1 + } +} + +func (v *Order) Width() int { + return len((*Model)(v).d.Song.Score.Tracks) +} + +func (v *Order) Height() int { + return (*Model)(v).d.Song.Score.Length +} + +func (v *Order) MoveCursor(dx, dy int) (ok bool) { + p := v.Cursor() + p.X += dx + p.Y += dy + v.SetCursor(p) + return p == v.Cursor() +} + +func (m *Order) clear(p Point) { + m.d.Song.Score.Tracks[p.X].Order.Set(p.Y, -1) +} + +func (m *Order) set(p Point, value int) { + m.d.Song.Score.Tracks[p.X].Order.Set(p.Y, value) +} + +func (v *Order) add(rect Rect, delta int) (ok bool) { + for x := rect.TopLeft.X; x <= rect.BottomRight.X; x++ { + for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ { + if !v.add1(Point{x, y}, delta) { + return false + } + } + } + return true +} + +func (v *Order) add1(p Point, delta int) (ok bool) { + if p.X < 0 || p.X >= len(v.d.Song.Score.Tracks) { + return true + } + val := v.d.Song.Score.Tracks[p.X].Order.Get(p.Y) + if val < 0 { + return true + } + val += delta + if val < 0 || val > 36 { + return false + } + v.d.Song.Score.Tracks[p.X].Order.Set(p.Y, val) + return true +} + +type marshalOrder struct { + Order []int `yaml:",flow"` +} + +type marshalTracks struct { + Tracks []marshalOrder +} + +func (m *Order) marshal(rect Rect) (data []byte, ok bool) { + width := rect.BottomRight.X - rect.TopLeft.X + 1 + height := rect.BottomRight.Y - rect.TopLeft.Y + 1 + var table = marshalTracks{Tracks: make([]marshalOrder, 0, width)} + for x := 0; x < width; x++ { + ax := x + rect.TopLeft.X + if ax < 0 || ax >= len(m.d.Song.Score.Tracks) { + continue + } + table.Tracks = append(table.Tracks, marshalOrder{Order: make([]int, 0, rect.BottomRight.Y-rect.TopLeft.Y+1)}) + for y := 0; y < height; y++ { + table.Tracks[x].Order = append(table.Tracks[x].Order, m.d.Song.Score.Tracks[ax].Order.Get(y+rect.TopLeft.Y)) + } + } + ret, err := yaml.Marshal(table) + if err != nil { + return nil, false + } + return ret, true +} + +func (m *Order) unmarshal(data []byte) (marshalTracks, bool) { + var table marshalTracks + yaml.Unmarshal(data, &table) + if len(table.Tracks) == 0 { + return marshalTracks{}, false + } + for i := 0; i < len(table.Tracks); i++ { + if len(table.Tracks[i].Order) > 0 { + return table, true + } + } + return marshalTracks{}, false +} + +func (v *Order) unmarshalAtCursor(data []byte) bool { + table, ok := v.unmarshal(data) + if !ok { + return false + } + for i := 0; i < len(table.Tracks); i++ { + for j, q := range table.Tracks[i].Order { + if table.Tracks[i].Order[j] < -1 || table.Tracks[i].Order[j] > 36 { + continue + } + x := i + v.Cursor().X + y := j + v.Cursor().Y + if x < 0 || x >= len(v.d.Song.Score.Tracks) || y < 0 || y >= v.d.Song.Score.Length { + continue + } + v.d.Song.Score.Tracks[x].Order.Set(y, q) + } + } + return true +} + +func (v *Order) unmarshalRange(rect Rect, data []byte) bool { + table, ok := v.unmarshal(data) + if !ok { + return false + } + for i := 0; i < rect.Width(); i++ { + for j := 0; j < rect.Height(); j++ { + k := i % len(table.Tracks) + l := j % len(table.Tracks[k].Order) + a := table.Tracks[k].Order[l] + if a < -1 || a > 36 { + continue + } + x := i + rect.TopLeft.X + y := j + rect.TopLeft.Y + if x < 0 || x >= len(v.d.Song.Score.Tracks) || y < 0 || y >= v.d.Song.Score.Length { + continue + } + v.d.Song.Score.Tracks[x].Order.Set(y, a) + } + } + return true +} + +func (v *Order) change(kind string, severity ChangeSeverity) func() { + return (*Model)(v).change("OrderTableView."+kind, ScoreChange, severity) +} + +func (v *Order) cancel() { + v.changeCancel = true +} + +func (m *Order) Value(p Point) int { + if p.X < 0 || p.X >= len(m.d.Song.Score.Tracks) { + return -1 + } + return m.d.Song.Score.Tracks[p.X].Order.Get(p.Y) +} + +func (m *Order) SetValue(p Point, val int) { + defer (*Model)(m).change("OrderElement.SetValue", ScoreChange, MinorChange)() + m.d.Song.Score.Tracks[p.X].Order.Set(p.Y, val) +} + +func (e *Order) Title(x int) (title string) { + title = "?" + if x < 0 || x >= len(e.d.Song.Score.Tracks) { + return + } + t := e.d.Song.Score.Tracks[x] + firstVoice := e.d.Song.Score.FirstVoiceForTrack(x) + lastVoice := firstVoice + t.NumVoices - 1 + firstIndex, err := e.d.Song.Patch.InstrumentForVoice(firstVoice) + lastIndex, err2 := e.d.Song.Patch.InstrumentForVoice(lastVoice) + if err != nil || err2 != nil { + return + } + switch diff := lastIndex - firstIndex; diff { + case 0: + title = e.d.Song.Patch[firstIndex].Name + default: + n1 := e.d.Song.Patch[firstIndex].Name + n2 := e.d.Song.Patch[firstIndex+1].Name + if len(n1) > 0 { + n1 = string(n1[0]) + } else { + n1 = "?" + } + if len(n2) > 0 { + n2 = string(n2[0]) + } else { + n2 = "?" + } + if diff > 1 { + title = n1 + "/" + n2 + "..." + } else { + title = n1 + "/" + n2 + } + } + return +} + +// NoteTable + +func (v *Notes) Table() Table { + return Table{v} +} + +func (m *Notes) Cursor() Point { + t := intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)-1), 0) + p := intMax(intMin(m.d.Song.Score.SongRow(m.d.Cursor.SongPos), m.d.Song.Score.LengthInRows()-1), 0) + return Point{t, p} +} + +func (m *Notes) Cursor2() Point { + t := intMax(intMin(m.d.Cursor2.Track, len(m.d.Song.Score.Tracks)-1), 0) + p := intMax(intMin(m.d.Song.Score.SongRow(m.d.Cursor2.SongPos), m.d.Song.Score.LengthInRows()-1), 0) + return Point{t, p} +} + +func (v *Notes) SetCursor(p Point) { + v.d.Cursor.Track = intMax(intMin(p.X, len(v.d.Song.Score.Tracks)-1), 0) + newPos := v.d.Song.Score.Wrap(sointu.SongPos{PatternRow: p.Y}) + if newPos != v.d.Cursor.SongPos { + v.noteTracking = false + } + v.d.Cursor.SongPos = newPos +} + +func (v *Notes) SetCursor2(p Point) { + v.d.Cursor2.Track = intMax(intMin(p.X, len(v.d.Song.Score.Tracks)-1), 0) + v.d.Cursor2.SongPos = v.d.Song.Score.Wrap(sointu.SongPos{PatternRow: p.Y}) +} + +func (v *Notes) Width() int { + return len((*Model)(v).d.Song.Score.Tracks) +} + +func (v *Notes) Height() int { + return (*Model)(v).d.Song.Score.Length * (*Model)(v).d.Song.Score.RowsPerPattern +} + +func (v *Notes) MoveCursor(dx, dy int) (ok bool) { + p := v.Cursor() + for dx < 0 { + if v.Effect(p.X) && v.d.LowNibble { + v.d.LowNibble = false + } else { + p.X-- + v.d.LowNibble = true + } + dx++ + } + for dx > 0 { + if v.Effect(p.X) && !v.d.LowNibble { + v.d.LowNibble = true + } else { + p.X++ + v.d.LowNibble = false + } + dx-- + } + p.Y += dy + v.SetCursor(p) + return p == v.Cursor() +} + +func (v *Notes) clear(p Point) { + v.SetValue(p, 1) +} + +func (v *Notes) set(p Point, value int) { + v.SetValue(p, byte(value)) +} + +func (v *Notes) add(rect Rect, delta int) (ok bool) { + for x := rect.BottomRight.X; x >= rect.TopLeft.X; x-- { + for y := rect.BottomRight.Y; y >= rect.TopLeft.Y; y-- { + if x < 0 || x >= len(v.d.Song.Score.Tracks) || y < 0 || y >= v.d.Song.Score.LengthInRows() { + continue + } + pos := v.d.Song.Score.SongPos(y) + note := v.d.Song.Score.Tracks[x].Note(pos) + if note <= 1 { + continue + } + newVal := int(note) + delta + if newVal < 2 { + newVal = 2 + } else if newVal > 255 { + newVal = 255 + } + // only do all sets after all gets, so we don't accidentally adjust single note multiple times + defer v.d.Song.Score.Tracks[x].SetNote(pos, byte(newVal)) + } + } + return true +} + +type noteTable struct { + Notes [][]byte `yaml:",flow"` +} + +func (m *Notes) marshal(rect Rect) (data []byte, ok bool) { + width := rect.BottomRight.X - rect.TopLeft.X + 1 + height := rect.BottomRight.Y - rect.TopLeft.Y + 1 + var table = noteTable{Notes: make([][]byte, 0, width)} + for x := 0; x < width; x++ { + table.Notes = append(table.Notes, make([]byte, 0, rect.BottomRight.Y-rect.TopLeft.Y+1)) + for y := 0; y < height; y++ { + pos := m.d.Song.Score.SongPos(y + rect.TopLeft.Y) + ax := x + rect.TopLeft.X + if ax < 0 || ax >= len(m.d.Song.Score.Tracks) { + continue + } + table.Notes[x] = append(table.Notes[x], m.d.Song.Score.Tracks[ax].Note(pos)) + } + } + ret, err := yaml.Marshal(table) + if err != nil { + return nil, false + } + return ret, true +} + +func (v *Notes) unmarshal(data []byte) (noteTable, bool) { + var table noteTable + yaml.Unmarshal(data, &table) + if len(table.Notes) == 0 { + return noteTable{}, false + } + for i := 0; i < len(table.Notes); i++ { + if len(table.Notes[i]) > 0 { + return table, true + } + } + return noteTable{}, false +} + +func (v *Notes) unmarshalAtCursor(data []byte) bool { + table, ok := v.unmarshal(data) + if !ok { + return false + } + for i := 0; i < len(table.Notes); i++ { + for j, q := range table.Notes[i] { + x := i + v.Cursor().X + y := j + v.Cursor().Y + if x < 0 || x >= len(v.d.Song.Score.Tracks) || y < 0 || y >= v.d.Song.Score.LengthInRows() { + continue + } + pos := v.d.Song.Score.SongPos(y) + v.d.Song.Score.Tracks[x].SetNote(pos, q) + } + } + return true +} + +func (v *Notes) unmarshalRange(rect Rect, data []byte) bool { + table, ok := v.unmarshal(data) + if !ok { + return false + } + for i := 0; i < rect.Width(); i++ { + for j := 0; j < rect.Height(); j++ { + k := i % len(table.Notes) + l := j % len(table.Notes[k]) + a := table.Notes[k][l] + x := i + rect.TopLeft.X + y := j + rect.TopLeft.Y + if x < 0 || x >= len(v.d.Song.Score.Tracks) || y < 0 || y >= v.d.Song.Score.LengthInRows() { + continue + } + pos := v.d.Song.Score.SongPos(y) + v.d.Song.Score.Tracks[x].SetNote(pos, a) + } + } + return true +} + +func (v *Notes) change(kind string, severity ChangeSeverity) func() { + return (*Model)(v).change("OrderTableView."+kind, ScoreChange, severity) +} + +func (v *Notes) cancel() { + v.changeCancel = true +} + +func (m *Notes) Value(p Point) byte { + if p.Y < 0 || p.X < 0 || p.X >= len(m.d.Song.Score.Tracks) { + return 1 + } + pos := m.d.Song.Score.SongPos(p.Y) + return m.d.Song.Score.Tracks[p.X].Note(pos) +} + +func (m *Notes) Effect(x int) bool { + if x < 0 || x >= len(m.d.Song.Score.Tracks) { + return false + } + return m.d.Song.Score.Tracks[x].Effect +} + +func (m *Notes) LowNibble() bool { + return m.d.LowNibble +} + +func (m *Notes) Unique(t, p int) bool { + if t < 0 || t >= len(m.cachePatternUseCount) || p < 0 || p >= len(m.cachePatternUseCount[t]) { + return false + } + return m.cachePatternUseCount[t][p] == 1 +} + +func (m *Notes) SetValue(p Point, val byte) { + defer m.change("SetValue", MinorChange)() + if p.Y < 0 || p.X < 0 || p.X >= len(m.d.Song.Score.Tracks) { + return + } + track := &(m.d.Song.Score.Tracks[p.X]) + pos := m.d.Song.Score.SongPos(p.Y) + (*track).SetNote(pos, val) +} + +func (v *Notes) FillNibble(value byte, lowNibble bool) { + defer v.change("FillNibble", MajorChange)() + rect := Table{v}.Range() + for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ { + for x := rect.TopLeft.X; x <= rect.BottomRight.X; x++ { + val := v.Value(Point{x, y}) + if val == 1 { + val = 0 // treat hold also as 0 + } + if lowNibble { + val = (val & 0xf0) | byte(value&15) + } else { + val = (val & 0x0f) | byte((value&15)<<4) + } + v.SetValue(Point{x, y}, val) + } + } +} diff --git a/vm/compiler/bridge/native_synth_test.go b/vm/compiler/bridge/native_synth_test.go index f8d2954..834b447 100644 --- a/vm/compiler/bridge/native_synth_test.go +++ b/vm/compiler/bridge/native_synth_test.go @@ -40,7 +40,7 @@ func TestOscillatSine(t *testing.T) { }}} tracks := []sointu.Track{{NumVoices: 1, Order: []int{0}, Patterns: []sointu.Pattern{{64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0}}}} song := sointu.Song{BPM: 100, RowsPerBeat: 4, Score: sointu.Score{RowsPerPattern: 16, Length: 1, Tracks: tracks}, Patch: patch} - buffer, err := sointu.Play(bridge.NativeSynther{}, song) + buffer, err := sointu.Play(bridge.NativeSynther{}, song, nil) if err != nil { t.Fatalf("Render failed: %v", err) } @@ -95,7 +95,7 @@ func TestAllRegressionTests(t *testing.T) { if err != nil { t.Fatalf("could not parse the .yml file: %v", err) } - buffer, err := sointu.Play(bridge.NativeSynther{}, song) + buffer, err := sointu.Play(bridge.NativeSynther{}, song, nil) buffer = buffer[:song.Score.LengthInRows()*song.SamplesPerRow()] // extend to the nominal length always. if err != nil { t.Fatalf("Play failed: %v", err) diff --git a/vm/compiler/patterns.go b/vm/compiler/patterns.go index ad1942e..59b1d0a 100644 --- a/vm/compiler/patterns.go +++ b/vm/compiler/patterns.go @@ -14,13 +14,8 @@ func flattenSequence(t sointu.Track, songLength int, rowsPerPattern int, release notes := make([]int, sumLen) k := 0 for i := 0; i < songLength; i++ { - patIndex := t.Order.Get(i) - var pattern sointu.Pattern = nil - if patIndex >= 0 && patIndex < len(t.Patterns) { - pattern = t.Patterns[patIndex] - } for j := 0; j < rowsPerPattern; j++ { - note := int(pattern.Get(j)) + note := int(t.Note(sointu.SongPos{OrderRow: i, PatternRow: j})) if releaseFirst && i == 0 && j == 0 && note == 1 { note = 0 } diff --git a/vm/go_synth_test.go b/vm/go_synth_test.go index 67bec88..4f2e006 100644 --- a/vm/go_synth_test.go +++ b/vm/go_synth_test.go @@ -43,7 +43,7 @@ func TestAllRegressionTests(t *testing.T) { if err != nil { t.Fatalf("could not parse the .yml file: %v", err) } - buffer, err := sointu.Play(vm.GoSynther{}, song) + buffer, err := sointu.Play(vm.GoSynther{}, song, nil) buffer = buffer[:song.Score.LengthInRows()*song.SamplesPerRow()] // extend to the nominal length always. if err != nil { t.Fatalf("Play failed: %v", err)