feat!: rewrote the GUI and model for better testability

The Model was getting unmaintanable mess. This is an attempt to refactor/rewrite the Model so that data of certain type is exposed in standardized way, offering certain standard manipulations for that data type, and on the GUI side, certain standard widgets to tied to that data.

This rewrite closes #72, #106 and #120.
This commit is contained in:
5684185+vsariola@users.noreply.github.com
2023-10-24 13:35:43 +03:00
parent 6d3c65e11d
commit d92426a100
53 changed files with 5992 additions and 4507 deletions

2
.gitignore vendored
View File

@ -31,3 +31,5 @@ actual_output/
**/__debug_bin **/__debug_bin
*.exe *.exe
*.dll *.dll
**/testdata/fuzz/

View File

@ -5,11 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased ## Unreleased
### Added ### 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) - Dbgain unit, which allows defining the gain in decibels (-40 dB to +40dB)
### Fixed ### Fixed
- 32-bit su_load_gmdls clobbered ebx, even though __stdcall demands it to be not - 32-bit su_load_gmdls clobbered ebx, even though __stdcall demands it to be not
touched touched
- Spaces are allowed in instrument names (#120)
## v0.3.0 ## v0.3.0
### Added ### Added

View File

@ -66,7 +66,7 @@ type (
// Play plays the Song by first compiling the patch with the given Synther, // Play plays the Song by first compiling the patch with the given Synther,
// returning the stereo audio buffer as a result (and possible errors). // 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() err := song.Validate()
if err != nil { if err != nil {
return nil, err 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) 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 return buffer, nil
} }

View File

@ -87,7 +87,7 @@ func main() {
return fmt.Errorf("the song could not be parsed as .json (%v) or .yml (%v)", errJSON, errYaml) 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 { if err != nil {
return fmt.Errorf("sointu.Play failed: %v", err) return fmt.Errorf("sointu.Play failed: %v", err)
} }

View File

@ -33,16 +33,16 @@ var memprofile = flag.String("memprofile", "", "write memory profile to `file`")
func main() { func main() {
flag.Parse() flag.Parse()
var f *os.File
if *cpuprofile != "" { if *cpuprofile != "" {
f, err := os.Create(*cpuprofile) var err error
f, err = os.Create(*cpuprofile)
if err != nil { if err != nil {
log.Fatal("could not create CPU profile: ", err) log.Fatal("could not create CPU profile: ", err)
} }
defer f.Close() // error handling omitted for example
if err := pprof.StartCPUProfile(f); err != nil { if err := pprof.StartCPUProfile(f); err != nil {
log.Fatal("could not start CPU profile: ", err) log.Fatal("could not start CPU profile: ", err)
} }
defer pprof.StopCPUProfile()
} }
audioContext, err := oto.NewContext() audioContext, err := oto.NewContext()
if err != nil { if err != nil {
@ -50,15 +50,12 @@ func main() {
os.Exit(1) os.Exit(1)
} }
defer audioContext.Close() defer audioContext.Close()
modelMessages := make(chan interface{}, 1024)
playerMessages := make(chan tracker.PlayerMessage, 1024)
recoveryFile := "" recoveryFile := ""
if configDir, err := os.UserConfigDir(); err == nil { if configDir, err := os.UserConfigDir(); err == nil {
recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery") recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery")
} }
model := tracker.NewModel(modelMessages, playerMessages, recoveryFile) model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile)
player := tracker.NewPlayer(cmd.MainSynther, playerMessages, modelMessages) tracker := gioui.NewTracker(model)
tracker := gioui.NewTracker(model, cmd.MainSynther)
output := audioContext.Output() output := audioContext.Output()
defer output.Close() defer output.Close()
go func() { go func() {
@ -71,6 +68,10 @@ func main() {
}() }()
go func() { go func() {
tracker.Main() tracker.Main()
if *cpuprofile != "" {
pprof.StopCPUProfile()
f.Close()
}
if *memprofile != "" { if *memprofile != "" {
f, err := os.Create(*memprofile) f, err := os.Create(*memprofile)
if err != nil { if err != nil {

View File

@ -54,19 +54,16 @@ func init() {
version = int32(100) version = int32(100)
) )
vst2.PluginAllocator = func(h vst2.Host) (vst2.Plugin, vst2.Dispatcher) { vst2.PluginAllocator = func(h vst2.Host) (vst2.Plugin, vst2.Dispatcher) {
modelMessages := make(chan interface{}, 1024)
playerMessages := make(chan tracker.PlayerMessage, 1024)
recoveryFile := "" recoveryFile := ""
if configDir, err := os.UserConfigDir(); err == nil { if configDir, err := os.UserConfigDir(); err == nil {
randBytes := make([]byte, 16) randBytes := make([]byte, 16)
rand.Read(randBytes) rand.Read(randBytes)
recoveryFile = filepath.Join(configDir, "Sointu", "sointu-vsti-recovery-"+hex.EncodeToString(randBytes)) recoveryFile = filepath.Join(configDir, "Sointu", "sointu-vsti-recovery-"+hex.EncodeToString(randBytes))
} }
model := tracker.NewModel(modelMessages, playerMessages, recoveryFile) model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile)
player := tracker.NewPlayer(cmd.MainSynther, playerMessages, modelMessages) t := gioui.NewTracker(model)
tracker := gioui.NewTracker(model, cmd.MainSynther) tracker.Bool{BoolData: (*tracker.InstrEnlarged)(model)}.Set(true)
tracker.SetInstrEnlarged(true) // start the vsti with the instrument editor enlarged go t.Main()
go tracker.Main()
context := VSTIProcessContext{host: h} context := VSTIProcessContext{host: h}
buf := make(sointu.AudioBuffer, 1024) buf := make(sointu.AudioBuffer, 1024)
return vst2.Plugin{ return vst2.Plugin{
@ -110,14 +107,16 @@ func init() {
} }
}, },
CloseFunc: func() { CloseFunc: func() {
tracker.Quit(true) t.Exec() <- func() { t.ForceQuit().Do() }
tracker.WaitQuitted() t.WaitQuitted()
}, },
GetChunkFunc: func(isPreset bool) []byte { 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) { SetChunkFunc: func(data []byte, isPreset bool) {
tracker.SafeUnmarshalRecovery(data) t.Exec() <- func() { t.UnmarshalRecovery(data) }
}, },
} }

92
song.go
View File

@ -67,8 +67,46 @@ type (
// the slice only by necessary amount when a new item is added, filling the // the slice only by necessary amount when a new item is added, filling the
// unused slots with -1s. // unused slots with -1s.
Order []int 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 // Get returns the value at index; or -1 is the index is out of range
func (s Order) Get(index int) int { func (s Order) Get(index int) int {
if index < 0 || index >= len(s) { if index < 0 || index >= len(s) {
@ -85,6 +123,55 @@ func (s *Order) Set(index, value int) {
(*s)[index] = value (*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 // Get returns the value at index; or 1 is the index is out of range
func (s Pattern) Get(index int) byte { func (s Pattern) Get(index int) byte {
if index < 0 || index >= len(s) { 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 // Assuming 44100 Hz playback speed, return the number of samples of each row of
// the song. // the song.
func (s *Song) SamplesPerRow() int { 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 // Validate checks if the Song looks like a valid song: BPM > 0, one or more

412
tracker/action.go Normal file
View File

@ -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
}
}

110
tracker/alert.go Normal file
View File

@ -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] }

114
tracker/bool.go Normal file
View File

@ -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 }

179
tracker/files.go Normal file
View File

@ -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
}

View File

@ -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
})
})
}

View File

@ -6,17 +6,43 @@ import (
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
"gioui.org/x/component" "gioui.org/x/component"
"github.com/vsariola/sointu/tracker"
) )
type TipClickable struct { type (
Clickable widget.Clickable TipClickable struct {
TipArea component.TipArea 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 { func NewBoolClickable(b tracker.Bool) *BoolClickable {
IconButtonStyle material.IconButtonStyle return &BoolClickable{
Tooltip component.Tooltip Bool: b,
tipArea *component.TipArea }
} }
func Tooltip(th *material.Theme, tip string) component.Tooltip { func Tooltip(th *material.Theme, tip string) component.Tooltip {
@ -25,24 +51,86 @@ func Tooltip(th *material.Theme, tip string) component.Tooltip {
return tooltip return tooltip
} }
func IconButton(th *material.Theme, w *TipClickable, icon []byte, enabled bool, tip string) TipIconButtonStyle { func ActionIcon(th *material.Theme, w *ActionClickable, icon []byte, tip string) TipIconButtonStyle {
ret := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "") ret := TipIcon(th, &w.TipClickable, icon, tip)
ret.Background = transparent for w.Clickable.Clicked() {
ret.Inset = layout.UniformInset(unit.Dp(6)) w.Action.Do()
if enabled { }
ret.Color = primaryColor if !w.Action.Allowed() {
} else { ret.IconButtonStyle.Color = disabledTextColor
ret.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{ return TipIconButtonStyle{
IconButtonStyle: ret, TipArea: &w.TipArea,
IconButtonStyle: ibStyle,
Tooltip: Tooltip(th, tip), Tooltip: Tooltip(th, tip),
tipArea: &w.TipArea,
} }
} }
func (t *TipIconButtonStyle) Layout(gtx C) D { 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 { func LowEmphasisButton(th *material.Theme, w *widget.Clickable, text string) material.ButtonStyle {

View File

@ -1,56 +1,82 @@
package gioui package gioui
import ( import (
"gioui.org/io/key"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op/paint" "gioui.org/op/paint"
"gioui.org/text" "gioui.org/text"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
) )
type Dialog struct { type Dialog struct {
Visible bool BtnAlt *ActionClickable
BtnAlt widget.Clickable BtnOk *ActionClickable
BtnOk widget.Clickable BtnCancel *ActionClickable
BtnCancel widget.Clickable tag bool
} }
type DialogStyle struct { type DialogStyle struct {
dialog *Dialog dialog *Dialog
Title string
Text string Text string
Inset layout.Inset Inset layout.Inset
ShowAlt bool TextInset layout.Inset
AltStyle material.ButtonStyle AltStyle material.ButtonStyle
OkStyle material.ButtonStyle OkStyle material.ButtonStyle
CancelStyle material.ButtonStyle CancelStyle material.ButtonStyle
Shaper *text.Shaper 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{ ret := DialogStyle{
dialog: dialog, dialog: dialog,
Title: title,
Text: text, Text: text,
Inset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12), Left: unit.Dp(20), Right: unit.Dp(20)}, Inset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12), Left: unit.Dp(20), Right: unit.Dp(20)},
AltStyle: HighEmphasisButton(th, &dialog.BtnAlt, "Alt"), TextInset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12)},
OkStyle: HighEmphasisButton(th, &dialog.BtnOk, "Ok"), AltStyle: ActionButton(th, dialog.BtnAlt, "Alt"),
CancelStyle: HighEmphasisButton(th, &dialog.BtnCancel, "Cancel"), OkStyle: ActionButton(th, dialog.BtnOk, "Ok"),
Shaper: shaper, CancelStyle: ActionButton(th, dialog.BtnCancel, "Cancel"),
Shaper: th.Shaper,
} }
return ret return ret
} }
func (d *DialogStyle) Layout(gtx C) D { func (d *DialogStyle) Layout(gtx C) D {
if d.dialog.Visible { if !d.dialog.BtnOk.Clickable.Focused() && !d.dialog.BtnCancel.Clickable.Focused() && !d.dialog.BtnAlt.Clickable.Focused() {
paint.Fill(gtx.Ops, dialogBgColor) d.dialog.BtnCancel.Clickable.Focus()
return layout.Center.Layout(gtx, func(gtx C) D { }
return Popup(&d.dialog.Visible).Layout(gtx, func(gtx C) D { paint.Fill(gtx.Ops, dialogBgColor)
return d.Inset.Layout(gtx, func(gtx C) D { text := func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, return d.TextInset.Layout(gtx, LabelStyle{Text: d.Text, Color: highEmphasisTextColor, Font: labelDefaultFont, FontSize: unit.Sp(14), Shaper: d.Shaper}.Layout)
layout.Rigid(Label(d.Text, highEmphasisTextColor, d.Shaper)), }
layout.Rigid(func(gtx C) D { 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)) 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, return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
layout.Rigid(d.OkStyle.Layout), layout.Rigid(d.OkStyle.Layout),
layout.Rigid(d.AltStyle.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.OkStyle.Layout),
layout.Rigid(d.CancelStyle.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()
}
}
} }

View File

@ -4,48 +4,54 @@ import (
"image" "image"
"image/color" "image/color"
"gioui.org/io/clipboard"
"gioui.org/io/key" "gioui.org/io/key"
"gioui.org/io/pointer" "gioui.org/io/pointer"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint" "gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget/material" "gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
) )
type DragList struct { type DragList struct {
SelectedItem int TrackerList tracker.List
SelectedItem2 int HoverItem int
HoverItem int List *layout.List
List *layout.List ScrollBar *ScrollBar
drag bool drag bool
dragID pointer.ID dragID pointer.ID
tags []bool tags []bool
swapped bool swapped bool
focused bool focused bool
requestFocus bool requestFocus bool
mainTag bool mainTag bool
} }
type FilledDragListStyle struct { type FilledDragListStyle struct {
dragList *DragList dragList *DragList
HoverColor color.NRGBA HoverColor color.NRGBA
SelectedColor color.NRGBA SelectedColor color.NRGBA
CursorColor color.NRGBA CursorColor color.NRGBA
Count int ScrollBarWidth unit.Dp
element func(gtx C, i int) D element, bg func(gtx C, i int) D
swap func(i, j int)
} }
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{ return FilledDragListStyle{
dragList: dragList, dragList: dragList,
element: element, element: element,
swap: swap, bg: bg,
Count: count, HoverColor: dragListHoverColor,
HoverColor: dragListHoverColor, SelectedColor: dragListSelectedColor,
SelectedColor: dragListSelectedColor, CursorColor: cursorColor,
CursorColor: cursorColor, ScrollBarWidth: unit.Dp(10),
} }
} }
@ -57,14 +63,18 @@ func (d *DragList) Focused() bool {
return d.focused 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 swap := 0
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() 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() 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 { 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) 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) 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) { for _, ke := range gtx.Events(&s.dragList.mainTag) {
switch ke := ke.(type) { switch ke := ke.(type) {
case key.FocusEvent: case key.FocusEvent:
s.dragList.focused = ke.Focus s.dragList.focused = ke.Focus
if !s.dragList.focused {
s.dragList.TrackerList.SetSelected2(s.dragList.TrackerList.Selected())
}
case key.Event: case key.Event:
if !s.dragList.focused || ke.State != key.Press { if !s.dragList.focused || ke.State != key.Press {
break break
} }
delta := 0 s.dragList.command(gtx, ke)
switch { case clipboard.Event:
case s.dragList.List.Axis == layout.Horizontal && ke.Name == key.NameLeftArrow && s.dragList.SelectedItem > 0: s.dragList.TrackerList.PasteElements([]byte(ke.Text))
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
}
}
}
} }
op.InvalidateOp{}.Add(gtx.Ops)
} }
_, isMutable := s.dragList.TrackerList.ListData.(tracker.MutableListData)
listElem := func(gtx C, index int) D { listElem := func(gtx C, index int) D {
for len(s.dragList.tags) <= index { for len(s.dragList.tags) <= index {
s.dragList.tags = append(s.dragList.tags, false) s.dragList.tags = append(s.dragList.tags, false)
} }
bg := func(gtx C) D { cursorBg := func(gtx C) D {
var color color.NRGBA var color color.NRGBA
if s.dragList.SelectedItem == index { if s.dragList.TrackerList.Selected() == index {
if s.dragList.focused { if s.dragList.focused {
color = s.CursorColor color = s.CursorColor
} else { } else {
color = s.SelectedColor 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 color = s.SelectedColor
} else if s.dragList.HoverItem == index { } else if s.dragList.HoverItem == index {
color = s.HoverColor color = s.HoverColor
} }
paint.FillShape(gtx.Ops, color, clip.Rect{Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op()) 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]) { for _, ev := range gtx.Events(&s.dragList.tags[index]) {
e, ok := ev.(pointer.Event) e, ok := ev.(pointer.Event)
if !ok { if !ok {
@ -154,9 +144,9 @@ func (s *FilledDragListStyle) Layout(gtx C) D {
if s.dragList.drag { if s.dragList.drag {
break break
} }
s.dragList.SelectedItem = index s.dragList.TrackerList.SetSelected(index)
if !e.Modifiers.Contain(key.ModShift) { if !e.Modifiers.Contain(key.ModShift) {
s.dragList.SelectedItem2 = index s.dragList.TrackerList.SetSelected2(index)
} }
key.FocusOp{Tag: &s.dragList.mainTag}.Add(gtx.Ops) 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, Types: pointer.Press | pointer.Enter | pointer.Leave,
}.Add(gtx.Ops) }.Add(gtx.Ops)
area.Pop() area.Pop()
if index == s.dragList.SelectedItem { if index == s.dragList.TrackerList.Selected() && isMutable {
for _, ev := range gtx.Events(&s.dragList.focused) { for _, ev := range gtx.Events(&s.dragList.focused) {
e, ok := ev.(pointer.Event) e, ok := ev.(pointer.Event)
if !ok { if !ok {
@ -212,29 +202,31 @@ func (s *FilledDragListStyle) Layout(gtx C) D {
} }
return layout.Dimensions{Size: gtx.Constraints.Min} return layout.Dimensions{Size: gtx.Constraints.Min}
} }
return layout.Stack{Alignment: layout.W}.Layout(gtx, macro := op.Record(gtx.Ops)
layout.Expanded(bg), dims := s.element(gtx, index)
layout.Expanded(inputFg), call := macro.Stop()
layout.Stacked(func(gtx C) D { gtx.Constraints.Min = dims.Size
return s.element(gtx, index) if s.bg != nil {
}), s.bg(gtx, index)
) }
} cursorBg(gtx)
dims := s.dragList.List.Layout(gtx, s.Count, listElem) call.Add(gtx.Ops)
a := intMin(s.dragList.SelectedItem, s.dragList.SelectedItem2) if s.dragList.List.Axis == layout.Horizontal {
b := intMax(s.dragList.SelectedItem, s.dragList.SelectedItem2) dims.Size.Y = gtx.Constraints.Max.Y
if !s.dragList.swapped && swap != 0 && a+swap >= 0 && b+swap < s.Count { } else {
if swap < 0 { dims.Size.X = gtx.Constraints.Max.X
for i := a; i <= b; i++ { }
s.swap(i, i+swap) return dims
} }
} else { count := s.dragList.TrackerList.Count()
for i := b; i >= a; i-- { if count < 1 {
s.swap(i, i+swap) 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 s.dragList.swapped = true
} else { } else {
s.dragList.swapped = false s.dragList.swapped = false
@ -242,6 +234,88 @@ func (s *FilledDragListStyle) Layout(gtx C) D {
return dims 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 { func between(a, b, c int) bool {
return (a <= b && b <= c) || (c <= b && b <= a) return (a <= b && b <= c) || (c <= b && b <= a)
} }

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -1,15 +1,15 @@
package gioui package gioui
import ( import (
"time"
"gioui.org/io/clipboard" "gioui.org/io/clipboard"
"gioui.org/io/key" "gioui.org/io/key"
"gioui.org/op" "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{ var noteMap = map[string]int{
"Z": -12, "Z": -12,
"S": -11, "S": -11,
@ -49,15 +49,6 @@ var noteMap = map[string]int{
func (t *Tracker) KeyEvent(e key.Event, o *op.Ops) { func (t *Tracker) KeyEvent(e key.Event, o *op.Ops) {
if e.State == key.Press { if e.State == key.Press {
switch e.Name { 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": case "V":
if e.Modifiers.Contain(key.ModShortcut) { if e.Modifiers.Contain(key.ModShortcut) {
clipboard.ReadOp{Tag: t}.Add(o) clipboard.ReadOp{Tag: t}.Add(o)
@ -65,97 +56,118 @@ func (t *Tracker) KeyEvent(e key.Event, o *op.Ops) {
} }
case "Z": case "Z":
if e.Modifiers.Contain(key.ModShortcut) { if e.Modifiers.Contain(key.ModShortcut) {
t.Undo() t.Model.Undo().Do()
return return
} }
case "Y": case "Y":
if e.Modifiers.Contain(key.ModShortcut) { if e.Modifiers.Contain(key.ModShortcut) {
t.Redo() t.Model.Redo().Do()
return return
} }
case "N": case "N":
if e.Modifiers.Contain(key.ModShortcut) { if e.Modifiers.Contain(key.ModShortcut) {
t.NewSong(false) t.NewSong().Do()
return return
} }
case "S": case "S":
if e.Modifiers.Contain(key.ModShortcut) { if e.Modifiers.Contain(key.ModShortcut) {
t.SaveSongFile() t.SaveSong().Do()
return return
} }
case "O": case "O":
if e.Modifiers.Contain(key.ModShortcut) { 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 return
} }
case "F1": case "F1":
t.OrderEditor.Focus() t.OrderEditor.scrollTable.Focus()
return return
case "F2": case "F2":
t.TrackEditor.Focus() t.TrackEditor.scrollTable.Focus()
return return
case "F3": case "F3":
t.InstrumentEditor.Focus() t.InstrumentEditor.Focus()
return return
case "F4":
t.TrackEditor.Focus()
return
case "F5": case "F5":
t.SetNoteTracking(true) t.SongPanel.RewindBtn.Action.Do()
startRow := t.Cursor().ScoreRow t.SongPanel.NoteTracking.Bool.Set(!e.Modifiers.Contain(key.ModCtrl))
t.PlayFromPosition(startRow)
return return
case "F6": case "F6", "Space":
t.SetNoteTracking(false) t.SongPanel.PlayingBtn.Bool.Toggle()
startRow := t.Cursor().ScoreRow t.SongPanel.NoteTracking.Bool.Set(!e.Modifiers.Contain(key.ModCtrl))
t.PlayFromPosition(startRow) return
case "F7":
t.SongPanel.RecordBtn.Bool.Toggle()
return return
case "F8": case "F8":
t.SetPlaying(false) t.SongPanel.NoteTracking.Bool.Toggle()
return
case "F12":
t.Panic().Bool().Toggle()
return 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 `\`, `<`, `>`: case `\`, `<`, `>`:
if e.Modifiers.Contain(key.ModShift) { if e.Modifiers.Contain(key.ModShift) {
t.SetOctave(t.Octave() + 1) t.OctaveNumberInput.Int.Add(1)
} else { } else {
t.SetOctave(t.Octave() - 1) t.OctaveNumberInput.Int.Add(-1)
} }
case key.NameTab: case key.NameTab:
if e.Modifiers.Contain(key.ModShift) { if e.Modifiers.Contain(key.ModShift) {
switch { switch {
case t.OrderEditor.Focused(): case t.OrderEditor.scrollTable.Focused():
t.InstrumentEditor.paramEditor.Focus() t.InstrumentEditor.unitEditor.sliderList.Focus()
case t.TrackEditor.Focused(): case t.TrackEditor.scrollTable.Focused():
t.OrderEditor.Focus() t.OrderEditor.scrollTable.Focus()
case t.InstrumentEditor.Focused(): case t.InstrumentEditor.Focused():
if t.InstrEnlarged() { if t.InstrumentEditor.enlargeBtn.Bool.Value() {
t.InstrumentEditor.paramEditor.Focus() t.InstrumentEditor.unitEditor.sliderList.Focus()
} else { } else {
t.TrackEditor.Focus() t.TrackEditor.scrollTable.Focus()
} }
default: default:
t.InstrumentEditor.Focus() t.InstrumentEditor.Focus()
} }
} else { } else {
switch { switch {
case t.OrderEditor.Focused(): case t.OrderEditor.scrollTable.Focused():
t.TrackEditor.Focus() t.TrackEditor.scrollTable.Focus()
case t.TrackEditor.Focused(): case t.TrackEditor.scrollTable.Focused():
t.InstrumentEditor.Focus() t.InstrumentEditor.Focus()
case t.InstrumentEditor.Focused(): case t.InstrumentEditor.Focused():
t.InstrumentEditor.paramEditor.Focus() t.InstrumentEditor.unitEditor.sliderList.Focus()
default: default:
if t.InstrEnlarged() { if t.InstrumentEditor.enlargeBtn.Bool.Value() {
t.InstrumentEditor.Focus() t.InstrumentEditor.Focus()
} else { } 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 { func (t *Tracker) JammingPressed(e key.Event) byte {
if val, ok := noteMap[e.Name]; ok { if val, ok := noteMap[e.Name]; ok {
if _, ok := t.KeyPlaying[e.Name]; !ok { if _, ok := t.KeyPlaying[e.Name]; !ok {
n := noteAsValue(t.OctaveNumberInput.Value, val) n := noteAsValue(t.OctaveNumberInput.Int.Value(), val)
instr := t.InstrIndex() instr := t.InstrumentEditor.instrumentDragList.TrackerList.Selected()
noteID := tracker.NoteIDInstr(instr, n) t.KeyPlaying[e.Name] = t.InstrNoteOn(instr, n)
t.NoteOn(noteID)
t.KeyPlaying[e.Name] = noteID
return n return n
} }
} }
@ -196,7 +192,7 @@ func (t *Tracker) JammingPressed(e key.Event) byte {
func (t *Tracker) JammingReleased(e key.Event) bool { func (t *Tracker) JammingReleased(e key.Event) bool {
if noteID, ok := t.KeyPlaying[e.Name]; ok { if noteID, ok := t.KeyPlaying[e.Name]; ok {
t.NoteOff(noteID) noteID.NoteOff()
delete(t.KeyPlaying, e.Name) delete(t.KeyPlaying, e.Name)
return true return true
} }

View File

@ -24,28 +24,25 @@ type LabelStyle struct {
} }
func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions { func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions {
return layout.Stack{Alignment: l.Alignment}.Layout(gtx, return l.Alignment.Layout(gtx, func(gtx C) D {
layout.Stacked(func(gtx layout.Context) layout.Dimensions { gtx.Constraints.Min = image.Point{}
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() paint.ColorOp{Color: l.ShadeColor}.Add(gtx.Ops)
paint.ColorOp{Color: l.ShadeColor}.Add(gtx.Ops) offs := op.Offset(image.Pt(2, 2)).Push(gtx.Ops)
op.Offset(image.Pt(2, 2)).Add(gtx.Ops) widget.Label{
dims := widget.Label{ Alignment: text.Start,
Alignment: text.Start, MaxLines: 1,
MaxLines: 1, }.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{})
}.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{}) offs.Pop()
return layout.Dimensions{ paint.ColorOp{Color: l.Color}.Add(gtx.Ops)
Size: dims.Size.Add(image.Pt(2, 2)), dims := widget.Label{
Baseline: dims.Baseline, Alignment: text.Start,
} MaxLines: 1,
}), }.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{})
layout.Stacked(func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{
paint.ColorOp{Color: l.Color}.Add(gtx.Ops) Size: dims.Size,
return widget.Label{ Baseline: dims.Baseline,
Alignment: text.Start, }
MaxLines: 1, })
}.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{})
}),
)
} }
func Label(str string, color color.NRGBA, shaper *text.Shaper) layout.Widget { func Label(str string, color color.NRGBA, shaper *text.Shaper) layout.Widget {

View File

@ -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)
},
)
}

View File

@ -12,6 +12,8 @@ import (
"gioui.org/text" "gioui.org/text"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
) )
type Menu struct { type Menu struct {
@ -40,7 +42,7 @@ type MenuItem struct {
IconBytes []byte IconBytes []byte
Text string Text string
ShortcutText string ShortcutText string
Disabled bool Doer tracker.Action
} }
func (m *Menu) Clicked() (int, bool) { 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 { func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D {
contents := func(gtx C) D { contents := func(gtx C) D {
for i := range items { for i, item := range items {
// make sure we have a tag for every item // make sure we have a tag for every item
for len(m.Menu.tags) <= i { for len(m.Menu.tags) <= i {
m.Menu.tags = append(m.Menu.tags, false) m.Menu.tags = append(m.Menu.tags, false)
@ -70,7 +72,7 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D {
} }
switch e.Type { switch e.Type {
case pointer.Press: case pointer.Press:
m.Menu.clicks = append(m.Menu.clicks, i) item.Doer.Do()
m.Menu.Visible = false m.Menu.Visible = false
case pointer.Enter: case pointer.Enter:
m.Menu.hover = i + 1 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() defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
var macro op.MacroOp var macro op.MacroOp
item := &items[i] 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) macro = op.Record(gtx.Ops)
} }
icon := widgetForIcon(item.IconBytes) icon := widgetForIcon(item.IconBytes)
iconColor := m.IconColor iconColor := m.IconColor
if item.Disabled { if !item.Doer.Allowed() {
iconColor = mediumEmphasisTextColor iconColor = mediumEmphasisTextColor
} }
iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)} 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} textLabel := LabelStyle{Text: item.Text, FontSize: m.FontSize, Color: m.TextColor, Shaper: m.Shaper}
if item.Disabled { if !item.Doer.Allowed() {
textLabel.Color = mediumEmphasisTextColor textLabel.Color = mediumEmphasisTextColor
} }
shortcutLabel := LabelStyle{Text: item.ShortcutText, FontSize: m.FontSize, Color: m.ShortCutColor, Shaper: m.Shaper} 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) 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() recording := macro.Stop()
paint.FillShape(gtx.Ops, m.HoverColor, clip.Rect{ paint.FillShape(gtx.Ops, m.HoverColor, clip.Rect{
Max: image.Pt(dims.Size.X, dims.Size.Y), Max: image.Pt(dims.Size.X, dims.Size.Y),
}.Op()) }.Op())
recording.Add(gtx.Ops) recording.Add(gtx.Ops)
} }
if !item.Disabled { if item.Doer.Allowed() {
rect := image.Rect(0, 0, dims.Size.X, dims.Size.Y) rect := image.Rect(0, 0, dims.Size.X, dims.Size.Y)
area := clip.Rect(rect).Push(gtx.Ops) area := clip.Rect(rect).Push(gtx.Ops)
pointer.InputOp{Tag: &m.Menu.tags[i], 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) return popup.Layout(gtx, contents)
} }
func (t *Tracker) PopupMenu(menu *Menu) MenuStyle { func PopupMenu(menu *Menu, shaper *text.Shaper) MenuStyle {
return MenuStyle{ return MenuStyle{
Menu: menu, Menu: menu,
IconColor: white, IconColor: white,
@ -157,6 +159,26 @@ func (t *Tracker) PopupMenu(menu *Menu) MenuStyle {
FontSize: unit.Sp(16), FontSize: unit.Sp(16),
IconSize: unit.Dp(16), IconSize: unit.Dp(16),
HoverColor: menuHoverColor, 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
} }
} }

View File

@ -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()
}
}*/

View File

@ -5,6 +5,7 @@ import (
"image" "image"
"image/color" "image/color"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons" "golang.org/x/exp/shiny/materialdesign/icons"
"gioui.org/font" "gioui.org/font"
@ -23,7 +24,7 @@ import (
) )
type NumberInput struct { type NumberInput struct {
Value int Int tracker.Int
dragStartValue int dragStartValue int
dragStartXY float32 dragStartXY float32
clickDecrease gesture.Click clickDecrease gesture.Click
@ -33,8 +34,6 @@ type NumberInput struct {
type NumericUpDownStyle struct { type NumericUpDownStyle struct {
NumberInput *NumberInput NumberInput *NumberInput
Min int
Max int
Color color.NRGBA Color color.NRGBA
Font font.Font Font font.Font
TextSize unit.Sp TextSize unit.Sp
@ -51,15 +50,17 @@ type NumericUpDownStyle struct {
shaper text.Shaper 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 := th.Palette.Fg
bgColor.R /= 4 bgColor.R /= 4
bgColor.G /= 4 bgColor.G /= 4
bgColor.B /= 4 bgColor.B /= 4
return NumericUpDownStyle{ return NumericUpDownStyle{
NumberInput: number, NumberInput: number,
Min: min,
Max: max,
Color: white, Color: white,
BorderColor: th.Palette.Fg, BorderColor: th.Palette.Fg,
IconColor: th.Palette.ContrastFg, IconColor: th.Palette.ContrastFg,
@ -104,12 +105,6 @@ func (s *NumericUpDownStyle) actualLayout(gtx C) D {
layout.Flexed(1, s.layoutText), layout.Flexed(1, s.layoutText),
layout.Rigid(s.button(gtx.Constraints.Max.Y, widgetForIcon(icons.NavigationArrowForward), 1, &s.NumberInput.clickIncrease)), 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() off.Pop()
c2.Pop() c2.Pop()
return layout.Dimensions{Size: size} return layout.Dimensions{Size: size}
@ -156,7 +151,7 @@ func (s *NumericUpDownStyle) layoutText(gtx C) D {
}), }),
layout.Expanded(func(gtx layout.Context) layout.Dimensions { layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.ColorOp{Color: s.Color}.Add(gtx.Ops) 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), layout.Expanded(s.layoutDrag),
) )
@ -169,13 +164,13 @@ func (s *NumericUpDownStyle) layoutDrag(gtx layout.Context) layout.Dimensions {
if e, ok := ev.(pointer.Event); ok { if e, ok := ev.(pointer.Event); ok {
switch e.Type { switch e.Type {
case pointer.Press: 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 s.NumberInput.dragStartXY = e.Position.X - e.Position.Y
case pointer.Drag: case pointer.Drag:
var deltaCoord float32 var deltaCoord float32
deltaCoord = e.Position.X - e.Position.Y - s.NumberInput.dragStartXY 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) { for _, e := range click.Events(gtx) {
switch e.Type { switch e.Type {
case gesture.TypeClick: case gesture.TypeClick:
s.NumberInput.Value += delta s.NumberInput.Int.Add(delta)
} }
} }
// Avoid affecting the input tree with pointer events. // Avoid affecting the input tree with pointer events.

View File

@ -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 "?"
}

View File

@ -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))
}

View File

@ -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, &param, 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)
}),
)
}

View File

@ -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 := "<instr>"
unitName := "<unit>"
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)
}
}
}*/

View File

@ -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{}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -2,222 +2,180 @@ package gioui
import ( import (
"image" "image"
"math"
"time"
"gioui.org/io/clipboard"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint" "gioui.org/op/paint"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material" "github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons" "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+" const shortcutKey = "Ctrl+"
var fileMenuItems []MenuItem = []MenuItem{ func (s *SongPanel) Layout(gtx C, t *Tracker) D {
{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 {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(t.layoutMenuBar), layout.Rigid(func(gtx C) D {
layout.Rigid(t.layoutSongOptions), 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 { func (t *SongPanel) layoutMenuBar(gtx C, tr *Tracker) D {
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 {
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36)) gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36))
gtx.Constraints.Min.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, return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(t.layoutMenu("File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), layout.Rigid(tr.layoutMenu("File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), t.fileMenuItems...)),
fileMenuItems..., layout.Rigid(tr.layoutMenu("Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200), t.editMenuItems...)),
)),
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"},
)),
) )
} }
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()) 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)) in := layout.UniformInset(unit.Dp(1))
var panicBtnStyle material.ButtonStyle panicBtnStyle := ToggleButton(tr.Theme, t.PanicBtn, "Panic (F12)")
if !t.Panic() { rewindBtnStyle := ActionIcon(tr.Theme, t.RewindBtn, icons.AVFastRewind, "Rewind\n(F5)")
panicBtnStyle = LowEmphasisButton(t.Theme, t.PanicBtn, "Panic") playBtnStyle := ToggleIcon(tr.Theme, t.PlayingBtn, icons.AVPlayArrow, icons.AVStop, "Play (F6 / Space)", "Stop (F6 / Space)")
} else { recordBtnStyle := ToggleIcon(tr.Theme, t.RecordBtn, icons.AVFiberManualRecord, icons.AVFiberSmartRecord, "Record (F7)", "Stop (F7)")
panicBtnStyle = HighEmphasisButton(t.Theme, t.PanicBtn, "Panic") noteTrackBtnStyle := ToggleIcon(tr.Theme, t.NoteTracking, icons.ActionSpeakerNotesOff, icons.ActionSpeakerNotes, "Follow\nOff\n(F8)", "Follow\nOn\n(F8)")
}
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())
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D { layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, 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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
t.SongLength.Value = t.Song().Score.Length numStyle := NumericUpDown(tr.Theme, t.SongLength, "Song length")
numStyle := NumericUpDown(t.Theme, t.SongLength, 1, math.MaxInt32, "Song length")
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20)) gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70)) gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
dims := in.Layout(gtx, numStyle.Layout) dims := in.Layout(gtx, numStyle.Layout)
t.SetSongLength(t.SongLength.Value)
return dims return dims
}), }),
) )
}), }),
layout.Rigid(func(gtx C) D { layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, 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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
t.BPM.Value = t.Song().BPM numStyle := NumericUpDown(tr.Theme, t.BPM, "Beats per minute")
numStyle := NumericUpDown(t.Theme, t.BPM, 1, 999, "Beats per minute")
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20)) gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70)) gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
dims := in.Layout(gtx, numStyle.Layout) dims := in.Layout(gtx, numStyle.Layout)
t.SetBPM(t.BPM.Value)
return dims return dims
}), }),
) )
}), }),
layout.Rigid(func(gtx C) D { layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, 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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
t.RowsPerPattern.Value = t.Song().Score.RowsPerPattern numStyle := NumericUpDown(tr.Theme, t.RowsPerPattern, "Rows per pattern")
numStyle := NumericUpDown(t.Theme, t.RowsPerPattern, 1, 255, "Rows per pattern")
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20)) gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70)) gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
dims := in.Layout(gtx, numStyle.Layout) dims := in.Layout(gtx, numStyle.Layout)
t.SetRowsPerPattern(t.RowsPerPattern.Value)
return dims return dims
}), }),
) )
}), }),
layout.Rigid(func(gtx C) D { layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, 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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
t.RowsPerBeat.Value = t.Song().RowsPerBeat numStyle := NumericUpDown(tr.Theme, t.RowsPerBeat, "Rows per beat")
numStyle := NumericUpDown(t.Theme, t.RowsPerBeat, 1, 32, "Rows per beat")
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20)) gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70)) gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
dims := in.Layout(gtx, numStyle.Layout) dims := in.Layout(gtx, numStyle.Layout)
t.SetRowsPerBeat(t.RowsPerBeat.Value)
return dims return dims
}), }),
) )
}), }),
layout.Rigid(func(gtx C) D { layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, 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 { 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) numStyle.UnitsPerStep = unit.Dp(20)
dims := in.Layout(gtx, numStyle.Layout) dims := in.Layout(gtx, numStyle.Layout)
return dims return dims
}), }),
) )
}), }),
layout.Rigid(VuMeter{AverageVolume: tr.Model.AverageVolume(), PeakVolume: tr.Model.PeakVolume(), Range: 100}.Layout),
layout.Rigid(func(gtx C) D { layout.Rigid(func(gtx C) D {
gtx.Constraints.Min = image.Pt(0, 0) return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
return panicBtnStyle.Layout(gtx) layout.Rigid(rewindBtnStyle.Layout),
layout.Rigid(playBtnStyle.Layout),
layout.Rigid(recordBtnStyle.Layout),
layout.Rigid(noteTrackBtnStyle.Layout),
)
}), }),
layout.Rigid(func(gtx C) D { layout.Rigid(panicBtnStyle.Layout),
gtx.Constraints.Min = image.Pt(0, 0)
return recordBtnStyle.Layout(gtx)
}),
layout.Rigid(VuMeter{AverageVolume: t.lastAvgVolume, PeakVolume: t.lastPeakVolume, Range: 100}.Layout),
) )
} }

View File

@ -4,6 +4,7 @@ import (
"image/color" "image/color"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint" "gioui.org/op/paint"
) )
@ -39,10 +40,13 @@ func (s Surface) Layout(gtx C, widget layout.Widget) D {
return s.Inset.Layout(gtx, widget) return s.Inset.Layout(gtx, widget)
} }
if s.FitSize { if s.FitSize {
return layout.Stack{}.Layout(gtx, macro := op.Record(gtx.Ops)
layout.Expanded(bg), dims := fg(gtx)
layout.Stacked(fg), call := macro.Stop()
) gtx.Constraints = layout.Exact(dims.Size)
bg(gtx)
call.Add(gtx.Ops)
return dims
} }
gtxbg := gtx gtxbg := gtx
gtxbg.Constraints.Min = gtxbg.Constraints.Max gtxbg.Constraints.Min = gtxbg.Constraints.Max

View File

@ -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 activeLightSurfaceColor = color.NRGBA{R: 45, G: 45, B: 45, A: 255}
var cursorColor = color.NRGBA{R: 100, G: 140, B: 255, A: 48} 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 inactiveSelectionColor = color.NRGBA{R: 140, G: 140, B: 140, A: 16}
var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255} var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255}

View File

@ -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}
}

View File

@ -1,24 +1,63 @@
package gioui package gioui
import ( import (
"encoding/json"
"errors"
"fmt" "fmt"
"image"
"io"
"path/filepath"
"strings"
"sync" "sync"
"time" "time"
"gioui.org/app" "gioui.org/app"
"gioui.org/io/clipboard"
"gioui.org/io/key"
"gioui.org/io/system" "gioui.org/io/system"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text" "gioui.org/text"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
"gioui.org/x/explorer" "gioui.org/x/explorer"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker" "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 ( const (
@ -27,140 +66,34 @@ const (
ConfirmNew ConfirmNew
) )
type Tracker struct { func NewTracker(model *tracker.Model) *Tracker {
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 {
t := &Tracker{ t := &Tracker{
Theme: material.NewTheme(), Theme: material.NewTheme(),
BPM: new(NumberInput), OctaveNumberInput: NewNumberInput(model.Octave().Int()),
OctaveNumberInput: &NumberInput{Value: 4}, InstrumentVoices: NewNumberInput(model.InstrumentVoices().Int()),
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
TopHorizontalSplit: &Split{Ratio: -.6}, TopHorizontalSplit: &Split{Ratio: -.6},
BottomHorizontalSplit: &Split{Ratio: -.6}, BottomHorizontalSplit: &Split{Ratio: -.6},
VerticalSplit: &Split{Axis: layout.Vertical}, VerticalSplit: &Split{Axis: layout.Vertical},
KeyPlaying: make(map[string]tracker.NoteID), KeyPlaying: make(map[string]tracker.NoteID),
ConfirmSongDialog: new(Dialog), SaveChangesDialog: NewDialog(model.SaveSong(), model.DiscardSong(), model.Cancel()),
WaveTypeDialog: new(Dialog), WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()),
InstrumentEditor: NewInstrumentEditor(), InstrumentEditor: NewInstrumentEditor(model),
OrderEditor: NewOrderEditor(), OrderEditor: NewOrderEditor(model),
TrackEditor: NewTrackEditor(), TrackEditor: NewNoteEditor(model),
SongPanel: NewSongPanel(model),
errorChannel: make(chan error, 32), Model: model,
synther: synther,
trackerModel: model,
marshalRecoveryChannel: make(chan (chan []byte)), filePathString: model.FilePath().String(),
unmarshalRecoveryChannel: make(chan []byte), execChan: make(chan func(), 1024),
} }
t.TextShaper = text.NewShaper(text.WithCollection(fontCollection)) t.Theme.Shaper = text.NewShaper(text.WithCollection(fontCollection))
t.Alert.shaper = t.TextShaper t.PopupAlert = NewPopupAlert(model.Alerts(), t.Theme.Shaper)
t.Theme.Palette.Fg = primaryColor t.Theme.Palette.Fg = primaryColor
t.Theme.Palette.ContrastFg = black t.Theme.Palette.ContrastFg = black
t.TrackEditor.Focus() t.TrackEditor.scrollTable.Focus()
t.quitWG.Add(1) t.quitWG.Add(1)
return t return t
} }
@ -171,19 +104,13 @@ func (t *Tracker) Main() {
app.Size(unit.Dp(800), unit.Dp(600)), app.Size(unit.Dp(800), unit.Dp(600)),
app.Title("Sointu Tracker"), app.Title("Sointu Tracker"),
) )
t.InstrumentEditor.Focus()
recoveryTicker := time.NewTicker(time.Second * 30) recoveryTicker := time.NewTicker(time.Second * 30)
t.Explorer = explorer.NewExplorer(w) t.Explorer = explorer.NewExplorer(w)
var ops op.Ops var ops op.Ops
mainloop:
for { for {
if pos, playing := t.PlayPosition(), t.Playing(); t.NoteTracking() && playing { if titleFooter != t.filePathString.Value() {
cursor := t.Cursor() titleFooter = t.filePathString.Value()
cursor.ScoreRow = pos
t.SetCursor(cursor)
t.SetSelectionCorner(cursor)
}
if titleFooter != t.FilePath() {
titleFooter = t.FilePath()
if titleFooter != "" { if titleFooter != "" {
w.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", titleFooter))) w.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", titleFooter)))
} else { } else {
@ -191,28 +118,16 @@ mainloop:
} }
} }
select { 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: 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) t.ProcessPlayerMessage(e)
w.Invalidate() w.Invalidate()
case e := <-w.Events(): case e := <-w.Events():
switch e := e.(type) { switch e := e.(type) {
case system.DestroyEvent: 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 // 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( w = app.NewWindow(
app.Size(unit.Dp(800), unit.Dp(600)), app.Size(unit.Dp(800), unit.Dp(600)),
@ -222,41 +137,146 @@ mainloop:
} }
case system.FrameEvent: case system.FrameEvent:
gtx := layout.NewContext(&ops, e) 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) t.Layout(gtx, w)
e.Frame(gtx.Ops) e.Frame(gtx.Ops)
} }
case <-recoveryTicker.C: case <-recoveryTicker.C:
t.SaveRecovery() t.SaveRecovery()
case retChn := <-t.marshalRecoveryChannel: case f := <-t.execChan:
retChn <- t.MarshalRecovery() f()
case bytes := <-t.unmarshalRecoveryChannel: }
t.UnmarshalRecovery(bytes) if t.Quitted() {
break
} }
} }
recoveryTicker.Stop()
w.Perform(system.ActionClose) w.Perform(system.ActionClose)
t.SaveRecovery() t.SaveRecovery()
t.quitWG.Done() t.quitWG.Done()
} }
// thread safe, executed in the GUI thread func (t *Tracker) Exec() chan<- func() {
func (t *Tracker) SafeMarshalRecovery() []byte { return t.execChan
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) WaitQuitted() { func (t *Tracker) WaitQuitted() {
t.quitWG.Wait() 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)
},
)
}

View File

@ -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
}

View File

@ -2,11 +2,6 @@
package gioui package gioui
const CAN_QUIT = false func init() {
canQuit = false
func (t *Tracker) Quit(forced bool) bool {
if forced {
t.sendQuit()
}
return forced
} }

View File

@ -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 := "<instr>"
unitName := "<unit>"
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{}
}),
)
}

191
tracker/int.go Normal file
View File

@ -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)
}

765
tracker/list.go Normal file
View File

@ -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
}

File diff suppressed because it is too large Load Diff

252
tracker/model_test.go Normal file
View File

@ -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{}{}
})
}

345
tracker/params.go Normal file
View File

@ -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"
}

View File

@ -19,7 +19,7 @@ type (
song sointu.Song // the song being played song sointu.Song // the song being played
playing bool // is the player playing the score or not playing bool // is the player playing the score or not
rowtime int // how many samples have been played in the current row 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 avgVolumeMeter VolumeAnalyzer // the volume analyzer used to calculate the average volume
peakVolumeMeter VolumeAnalyzer // the volume analyzer used to calculate the peak 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 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 recState recState // is the recording off; are we waiting for a note; or are we recording
recording Recording // the recorded MIDI events and BPM recording Recording // the recorded MIDI events and BPM
synther sointu.Synther // the synther used to create new synths synther sointu.Synther // the synther used to create new synths
playerMessages chan<- PlayerMessage playerMsgs chan<- PlayerMsg
modelMessages <-chan interface{} modelMsgs <-chan interface{}
} }
// PlayerProcessContext is the context given to the player when processing // PlayerProcessContext is the context given to the player when processing
@ -50,29 +50,19 @@ type (
Note byte 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 // field can contain any message. Panic, AverageVolume, PeakVolume, SongRow
// and VoiceStates transmitted frequently, with every message, so they are // and VoiceStates transmitted frequently, with every message, so they are
// treated specially, to avoid boxing. All the rest messages can be boxed to // treated specially, to avoid boxing. All the rest messages can be boxed to
// Inner interface{} // Inner interface{}
PlayerMessage struct { PlayerMsg struct {
Panic bool Panic bool
AverageVolume Volume AverageVolume Volume
PeakVolume Volume PeakVolume Volume
SongRow ScoreRow SongPosition sointu.SongPos
VoiceLevels [vm.MAX_VOICES]float32 VoiceLevels [vm.MAX_VOICES]float32
Inner interface{} 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 ( type (
@ -93,20 +83,6 @@ const (
const numRenderTries = 10000 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 // 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 // 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 // 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 { if p.playing {
timeUntilRowAdvance = p.song.SamplesPerRow() - p.rowtime timeUntilRowAdvance = p.song.SamplesPerRow() - p.rowtime
} }
if timeUntilRowAdvance < 0 {
timeUntilRowAdvance = 0
}
var rendered, timeAdvanced int var rendered, timeAdvanced int
var err error var err error
if p.synth != nil { if p.synth != nil {
@ -169,7 +148,7 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext
} }
if err != nil { if err != nil {
p.synth = 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:] buffer = buffer[rendered:]
frame += rendered frame += rendered
@ -189,47 +168,37 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext
if len(buffer) == 0 { if len(buffer) == 0 {
err := p.avgVolumeMeter.Update(oldBuffer) err := p.avgVolumeMeter.Update(oldBuffer)
err2 := p.peakVolumeMeter.Update(oldBuffer) err2 := p.peakVolumeMeter.Update(oldBuffer)
var msg interface{}
if err != nil { if err != nil {
msg = PlayerCrashMessage{err}
p.synth = nil p.synth = nil
p.sendAlert("PlayerVolume", err.Error(), Warning)
return
} }
if err2 != nil { if err2 != nil {
msg = PlayerCrashMessage{err}
p.synth = nil p.synth = nil
p.sendAlert("PlayerVolume", err2.Error(), Warning)
return
} }
p.send(msg) p.send(nil)
return return
} }
} }
// we were not able to fill the buffer with NUM_RENDER_TRIES attempts, destroy synth and throw an error // we were not able to fill the buffer with NUM_RENDER_TRIES attempts, destroy synth and throw an error
p.synth = nil 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() { func (p *Player) advanceRow() {
if p.song.Score.Length == 0 || p.song.Score.RowsPerPattern == 0 { if p.song.Score.Length == 0 || p.song.Score.RowsPerPattern == 0 {
return return
} }
p.position.Row++ // advance row (this is why we subtracted one in Play()) p.songPos.PatternRow++ // advance row (this is why we subtracted one in Play())
p.position = p.position.Wrap(p.song.Score) p.songPos = p.song.Score.Wrap(p.songPos)
p.send(nil) // just send volume and song row information p.send(nil) // just send volume and song row information
lastVoice := 0 lastVoice := 0
for i, t := range p.song.Score.Tracks { for i, t := range p.song.Score.Tracks {
start := lastVoice start := lastVoice
lastVoice = start + t.NumVoices lastVoice = start + t.NumVoices
if p.position.Pattern < 0 || p.position.Pattern >= len(t.Order) { n := t.Note(p.songPos)
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]
switch { switch {
case n == 0: case n == 0:
p.releaseTrack(i) p.releaseTrack(i)
@ -245,9 +214,9 @@ func (p *Player) processMessages(context PlayerProcessContext) {
loop: loop:
for { // process new message for { // process new message
select { select {
case msg := <-p.modelMessages: case msg := <-p.modelMsgs:
switch m := msg.(type) { switch m := msg.(type) {
case ModelPanicMessage: case PanicMsg:
if m.bool { if m.bool {
p.synth = nil p.synth = nil
} else { } else {
@ -261,23 +230,23 @@ loop:
p.compileOrUpdateSynth() p.compileOrUpdateSynth()
case sointu.Score: case sointu.Score:
p.song.Score = m p.song.Score = m
case ModelPlayingChangedMessage: case IsPlayingMsg:
p.playing = bool(m.bool) p.playing = bool(m.bool)
if !p.playing { if !p.playing {
for i := range p.song.Score.Tracks { for i := range p.song.Score.Tracks {
p.releaseTrack(i) p.releaseTrack(i)
} }
} }
case ModelBPMChangedMessage: case BPMMsg:
p.song.BPM = m.int p.song.BPM = m.int
p.compileOrUpdateSynth() p.compileOrUpdateSynth()
case ModelRowsPerBeatChangedMessage: case RowsPerBeatMsg:
p.song.RowsPerBeat = m.int p.song.RowsPerBeat = m.int
p.compileOrUpdateSynth() p.compileOrUpdateSynth()
case ModelPlayFromPositionMessage: case StartPlayMsg:
p.playing = true p.playing = true
p.position = m.ScoreRow p.songPos = m.SongPos
p.position.Row-- p.songPos.PatternRow--
p.rowtime = math.MaxInt p.rowtime = math.MaxInt
for i, t := range p.song.Score.Tracks { for i, t := range p.song.Score.Tracks {
if !t.Effect { if !t.Effect {
@ -285,19 +254,19 @@ loop:
p.releaseTrack(i) p.releaseTrack(i)
} }
} }
case ModelNoteOnMessage: case NoteOnMsg:
if m.IsInstr { if m.IsInstr {
p.triggerInstrument(m.Instr, m.Note) p.triggerInstrument(m.Instr, m.Note)
} else { } else {
p.triggerTrack(m.Track, m.Note) p.triggerTrack(m.Track, m.Note)
} }
case ModelNoteOffMessage: case NoteOffMsg:
if m.IsInstr { if m.IsInstr {
p.releaseInstrument(m.Instr, m.Note) p.releaseInstrument(m.Instr, m.Note)
} else { } else {
p.releaseTrack(m.Track) p.releaseTrack(m.Track)
} }
case ModelRecordingMessage: case RecordingMsg:
if m.bool { if m.bool {
p.recState = recStateWaitingForNote p.recState = recStateWaitingForNote
p.recording = Recording{} 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() { func (p *Player) compileOrUpdateSynth() {
if p.song.BPM <= 0 { if p.song.BPM <= 0 {
return // bpm not set yet return // bpm not set yet
@ -325,7 +303,7 @@ func (p *Player) compileOrUpdateSynth() {
err := p.synth.Update(p.song.Patch, p.song.BPM) err := p.synth.Update(p.song.Patch, p.song.BPM)
if err != nil { if err != nil {
p.synth = nil p.synth = nil
p.send(PlayerCrashMessage{fmt.Errorf("synth.Update: %w", err)}) p.sendAlert("PlayerCrash", fmt.Sprintf("synth.Update: %v", err), Error)
return return
} }
} else { } else {
@ -333,7 +311,7 @@ func (p *Player) compileOrUpdateSynth() {
p.synth, err = p.synther.Synth(p.song.Patch, p.song.BPM) p.synth, err = p.synther.Synth(p.song.Patch, p.song.BPM)
if err != nil { if err != nil {
p.synth = nil p.synth = nil
p.send(PlayerCrashMessage{fmt.Errorf("synther.Synth: %w", err)}) p.sendAlert("PlayerCrash", fmt.Sprintf("synther.Synth: %v", err), Error)
return 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 // 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{}) { func (p *Player) send(message interface{}) {
select { 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: default:
} }
} }

View File

@ -12,23 +12,31 @@ import (
//go:generate go run generate/main.go //go:generate go run generate/main.go
// GmDlsEntry is a single sample entry from the gm.dls file type (
type GmDlsEntry struct { // GmDlsEntry is a single sample entry from the gm.dls file
Start int // sample start offset in words GmDlsEntry struct {
LoopStart int // loop start offset in words Start int // sample start offset in words
LoopLength int // loop length in words LoopStart int // loop start offset in words
SuggestedTranspose int // suggested transpose in semitones, so that all samples play at same pitch LoopLength int // loop length in words
Name string // sample name 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. // 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() { func init() {
for i, e := range GmDlsEntries { for i, e := range GmDlsEntries {
key := vm.SampleOffset{Start: uint32(e.Start), LoopStart: uint16(e.LoopStart), LoopLength: uint16(e.LoopLength)} 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{ var reverbs = []delayPreset{
{"stereo", 1, []int{1116, 1188, 1276, 1356, 1422, 1492, 1556, 1618, {"stereo", 1, []int{1116, 1188, 1276, 1356, 1422, 1492, 1556, 1618,
1140, 1212, 1300, 1380, 1446, 1516, 1580, 1642, 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}}, {"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/* //go:embed presets/*
var instrumentPresetFS embed.FS var instrumentPresetFS embed.FS
var InstrumentPresets instrumentPresets var instrumentPresets instrumentPresetsSlice
func init() { func init() {
fs.WalkDir(instrumentPresetFS, ".", func(path string, d fs.DirEntry, err error) error { fs.WalkDir(instrumentPresetFS, ".", func(path string, d fs.DirEntry, err error) error {
@ -139,20 +171,12 @@ func init() {
if yaml.Unmarshal(data, &instr) != nil { if yaml.Unmarshal(data, &instr) != nil {
return nil return nil
} }
InstrumentPresets = append(InstrumentPresets, instr) instrumentPresets = append(instrumentPresets, instr)
return nil return nil
}) })
sort.Sort(InstrumentPresets) sort.Sort(instrumentPresets)
} }
func (p instrumentPresets) Len() int { func (p instrumentPresetsSlice) Len() int { return len(p) }
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] }
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]
}

View File

@ -22,9 +22,9 @@ type recordingNote struct {
var ErrInvalidRows = errors.New("rows per beat and rows per pattern must be greater than 1") 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 { if rowsPerBeat <= 1 || rowsPerPattern <= 1 {
return sointu.Song{}, ErrInvalidRows return sointu.Score{}, ErrInvalidRows
} }
channelNotes := make([][]recordingNote, 0) channelNotes := make([][]recordingNote, 0)
// find the length of each note and assign it to its respective channel // 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 flatPattern[k] = 1 // set all notes as holds at first
} }
for _, n := range t { for _, n := range t {
flatPattern.Set(n.startRow, n.note) if n.startRow >= songLengthRows {
continue
}
flatPattern[n.startRow] = n.note
if n.endRow < songLengthRows { if n.endRow < songLengthRows {
for l := n.startRow + 1; l < n.endRow; l++ { 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 { } else {
for l := n.startRow + 1; l < songLengthRows; l++ { 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} 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 { func frameToRow(BPM float64, rowsPerBeat, frame int) int {

View File

@ -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
}

94
tracker/string.go Normal file
View File

@ -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)
}

632
tracker/table.go Normal file
View File

@ -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)
}
}
}

View File

@ -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}}}} 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} 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 { if err != nil {
t.Fatalf("Render failed: %v", err) t.Fatalf("Render failed: %v", err)
} }
@ -95,7 +95,7 @@ func TestAllRegressionTests(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("could not parse the .yml file: %v", err) 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. buffer = buffer[:song.Score.LengthInRows()*song.SamplesPerRow()] // extend to the nominal length always.
if err != nil { if err != nil {
t.Fatalf("Play failed: %v", err) t.Fatalf("Play failed: %v", err)

View File

@ -14,13 +14,8 @@ func flattenSequence(t sointu.Track, songLength int, rowsPerPattern int, release
notes := make([]int, sumLen) notes := make([]int, sumLen)
k := 0 k := 0
for i := 0; i < songLength; i++ { 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++ { 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 { if releaseFirst && i == 0 && j == 0 && note == 1 {
note = 0 note = 0
} }

View File

@ -43,7 +43,7 @@ func TestAllRegressionTests(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("could not parse the .yml file: %v", err) 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. buffer = buffer[:song.Score.LengthInRows()*song.SamplesPerRow()] // extend to the nominal length always.
if err != nil { if err != nil {
t.Fatalf("Play failed: %v", err) t.Fatalf("Play failed: %v", err)