feat(tracker): add a preset explorer with search and filters

Closes #91
This commit is contained in:
5684185+vsariola@users.noreply.github.com
2025-10-15 12:19:25 +03:00
parent 3f365707c2
commit 2336a135c6
128 changed files with 1147 additions and 405 deletions

View File

@ -11,22 +11,24 @@ type (
SetValue(bool)
}
Panic Model
IsRecording Model
Playing Model
InstrEnlarged Model
Effect Model
TrackMidiIn Model
CommentExpanded Model
Follow Model
UnitSearching Model
UnitDisabled Model
LoopToggle Model
UniquePatterns Model
Mute Model
Solo Model
LinkInstrTrack Model
Oversampling Model
Panic Model
IsRecording Model
Playing Model
InstrEnlarged Model
Effect Model
TrackMidiIn Model
Follow Model
UnitSearching Model
UnitDisabled Model
LoopToggle Model
UniquePatterns Model
Mute Model
Solo Model
LinkInstrTrack Model
Oversampling Model
InstrEditor Model
InstrPresets Model
InstrComment Model
)
func MakeBool(valueEnabler interface {
@ -101,11 +103,31 @@ func (m *Model) InstrEnlarged() Bool { return MakeEnabledBool((*InstrEnlar
func (m *InstrEnlarged) Value() bool { return m.instrEnlarged }
func (m *InstrEnlarged) SetValue(val bool) { m.instrEnlarged = val }
// CommentExpanded methods
// InstrEditor methods
func (m *Model) CommentExpanded() Bool { return MakeEnabledBool((*CommentExpanded)(m)) }
func (m *CommentExpanded) Value() bool { return m.commentExpanded }
func (m *CommentExpanded) SetValue(val bool) { m.commentExpanded = val }
func (m *Model) InstrEditor() Bool { return MakeEnabledBool((*InstrEditor)(m)) }
func (m *InstrEditor) Value() bool { return m.d.InstrumentTab == InstrumentEditorTab }
func (m *InstrEditor) SetValue(val bool) {
if val {
m.d.InstrumentTab = InstrumentEditorTab
}
}
func (m *Model) InstrComment() Bool { return MakeEnabledBool((*InstrComment)(m)) }
func (m *InstrComment) Value() bool { return m.d.InstrumentTab == InstrumentCommentTab }
func (m *InstrComment) SetValue(val bool) {
if val {
m.d.InstrumentTab = InstrumentCommentTab
}
}
func (m *Model) InstrPresets() Bool { return MakeEnabledBool((*InstrPresets)(m)) }
func (m *InstrPresets) Value() bool { return m.d.InstrumentTab == InstrumentPresetsTab }
func (m *InstrPresets) SetValue(val bool) {
if val {
m.d.InstrumentTab = InstrumentPresetsTab
}
}
// Follow methods

View File

@ -33,9 +33,10 @@ type (
// corresponding part of the model changes.
derivedModelData struct {
// map Unit by ID, other entities by their respective index
patch []derivedInstrument
tracks []derivedTrack
railError RailError
patch []derivedInstrument
tracks []derivedTrack
railError RailError
presetSearch derivedPresetSearch
}
derivedInstrument struct {

View File

@ -186,8 +186,5 @@ success:
instrument.NumVoices = clamp(instrument.NumVoices, 1, 32-numVoices)
m.assignUnitIDs(instrument.Units)
m.d.Song.Patch[m.d.InstrIndex] = instrument
if m.d.Song.Patch[m.d.InstrIndex].Comment != "" {
m.commentExpanded = true
}
return true
}

View File

@ -73,6 +73,13 @@ type (
Button
}
// TabButton is a button used in a tab bar.
TabButton struct {
IndicatorHeight unit.Dp
IndicatorColor color.NRGBA
ToggleButton
}
// IconButton is a button with an icon.
IconButton struct {
Theme *Theme
@ -126,6 +133,19 @@ func ToggleBtn(b tracker.Bool, th *Theme, c *Clickable, text string, tip string)
}
}
func TabBtn(b tracker.Bool, th *Theme, c *Clickable, text string, tip string) TabButton {
return TabButton{
IndicatorHeight: th.Button.Tab.IndicatorHeight,
IndicatorColor: th.Button.Tab.IndicatorColor,
ToggleButton: ToggleButton{
Bool: b,
DisabledStyle: &th.Button.Disabled,
OffStyle: &th.Button.Tab.Inactive,
Button: Btn(th, &th.Button.Tab.Active, c, text, tip),
},
}
}
func IconBtn(th *Theme, st *IconButtonStyle, c *Clickable, icon []byte, tip string) IconButton {
return IconButton{
Theme: th,
@ -288,6 +308,26 @@ func (b *ToggleIconButton) Layout(gtx C) D {
return b.IconButton.Layout(gtx)
}
func (b *TabButton) Layout(gtx C) D {
return layout.Stack{Alignment: layout.S}.Layout(gtx,
layout.Stacked(b.ToggleButton.Layout),
layout.Expanded(func(gtx C) D {
if !b.ToggleButton.Bool.Value() {
return D{}
}
w := gtx.Constraints.Min.X
h := gtx.Dp(b.IndicatorHeight)
r := clip.RRect{
Rect: image.Rect(0, 0, w, h),
NE: h, NW: h, SE: 0, SW: 0,
}
defer r.Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, b.IndicatorColor)
return layout.Dimensions{Size: image.Pt(w, h)}
}),
)
}
// Click executes a simple programmatic click.
func (b *Clickable) Click() {
b.requestClicks++

View File

@ -67,6 +67,8 @@ func (e *Editor) Layout(gtx C, str tracker.String, th *Theme, style *EditorStyle
}
if e.widgetEditor.Text() != str.Value() {
e.widgetEditor.SetText(str.Value())
l := len(e.widgetEditor.Text())
e.widgetEditor.SetCaret(l, l)
}
me := material.Editor(&th.Material, &e.widgetEditor, hint)
me.Font = style.Font

View File

@ -8,6 +8,9 @@ import (
)
type TagYieldFunc func(level int, tag event.Tag) bool
type Tagged interface {
Tags(level int, yield TagYieldFunc) bool
}
// FocusNext navigates to the next focusable tag in the tracker. If stepInto is
// true, it will focus the next tag regardless of its depth; otherwise it will
@ -77,3 +80,12 @@ func (t *Tracker) findFocusedLevel(gtx C) (level int, ok bool) {
})
return level, ok
}
func firstTag(t Tagged) (tag event.Tag, ok bool) {
t.Tags(0, func(level int, t event.Tag) bool {
tag = t
ok = true
return false
})
return tag, ok
}

View File

@ -6,6 +6,8 @@ import (
"image/color"
"io"
"math"
"strconv"
"strings"
"time"
"gioui.org/f32"
@ -16,6 +18,8 @@ import (
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
"golang.org/x/text/cases"
@ -23,6 +27,18 @@ import (
)
type (
InstrumentEditor struct {
unitList UnitList
unitEditor UnitEditor
}
UnitList struct {
dragList *DragList
searchEditor *Editor
addUnitBtn *Clickable
addUnitAction tracker.Action
}
UnitEditor struct {
paramTable *ScrollTable
searchList *DragList
@ -43,6 +59,154 @@ type (
}
)
func MakeInstrumentEditor(model *tracker.Model) InstrumentEditor {
return InstrumentEditor{
unitList: MakeUnitList(model),
unitEditor: *NewUnitEditor(model),
}
}
func (ie *InstrumentEditor) layout(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(ie.unitList.Layout),
layout.Flexed(1, ie.unitEditor.Layout),
)
}
func (ie *InstrumentEditor) Tags(level int, yield TagYieldFunc) bool {
return ie.unitList.Tags(level, yield) &&
ie.unitEditor.Tags(level, yield)
}
// UnitList methods
func MakeUnitList(m *tracker.Model) UnitList {
ret := UnitList{
dragList: NewDragList(m.Units().List(), layout.Vertical),
addUnitBtn: new(Clickable),
searchEditor: NewEditor(true, true, text.Start),
}
ret.addUnitAction = tracker.MakeEnabledAction(tracker.DoFunc(func() {
m.AddUnit(false).Do()
ret.searchEditor.Focus()
}))
return ret
}
func (ul *UnitList) Layout(gtx C) D {
t := TrackerFromContext(gtx)
ul.update(gtx, t)
element := func(gtx C, i int) D {
gtx.Constraints.Max.Y = gtx.Dp(20)
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
u := t.Units().Item(i)
editorStyle := t.Theme.InstrumentEditor.UnitList.Name
signalError := t.RailError()
switch {
case u.Disabled:
editorStyle = t.Theme.InstrumentEditor.UnitList.NameDisabled
case signalError.Err != nil && signalError.UnitIndex == i:
editorStyle.Color = t.Theme.InstrumentEditor.UnitList.Error
}
unitName := func(gtx C) D {
if i == ul.dragList.TrackerList.Selected() {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
return ul.searchEditor.Layout(gtx, t.Model.UnitSearch(), t.Theme, &editorStyle, "---")
} else {
text := u.Type
if text == "" {
text = "---"
}
l := editorStyle.AsLabelStyle()
return Label(t.Theme, &l, text).Layout(gtx)
}
}
stackText := strconv.FormatInt(int64(u.Signals.StackAfter()), 10)
commentLabel := Label(t.Theme, &t.Theme.InstrumentEditor.UnitList.Comment, u.Comment)
stackLabel := Label(t.Theme, &t.Theme.InstrumentEditor.UnitList.Stack, stackText)
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(unitName),
layout.Rigid(layout.Spacer{Width: 5}.Layout),
layout.Flexed(1, commentLabel.Layout),
layout.Rigid(stackLabel.Layout),
layout.Rigid(layout.Spacer{Width: 10}.Layout),
)
}
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
unitList := FilledDragList(t.Theme, ul.dragList)
surface := 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()
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(140), gtx.Constraints.Max.Y))
dims := unitList.Layout(gtx, element, nil)
unitList.LayoutScrollBar(gtx)
return dims
}),
layout.Stacked(func(gtx C) D {
margin := layout.Inset{Right: unit.Dp(20), Bottom: unit.Dp(1)}
addUnitBtn := IconBtn(t.Theme, &t.Theme.IconButton.Emphasis, ul.addUnitBtn, icons.ContentAdd, "Add unit (Enter)")
return margin.Layout(gtx, addUnitBtn.Layout)
}),
)
}
return Surface{Gray: 30, Focus: t.PatchPanel.TreeFocused(gtx)}.Layout(gtx, surface)
}
func (ul *UnitList) update(gtx C, t *Tracker) {
for ul.addUnitBtn.Clicked(gtx) {
ul.addUnitAction.Do()
t.UnitSearching().SetValue(true)
ul.searchEditor.Focus()
}
for {
event, ok := gtx.Event(
key.Filter{Focus: ul.dragList, Name: key.NameRightArrow},
key.Filter{Focus: ul.dragList, Name: key.NameEnter, Optional: key.ModCtrl},
key.Filter{Focus: ul.dragList, Name: key.NameReturn, Optional: key.ModCtrl},
key.Filter{Focus: ul.dragList, Name: key.NameDeleteBackward},
)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameRightArrow:
t.PatchPanel.instrEditor.unitEditor.paramTable.RowTitleList.Focus()
case key.NameDeleteBackward:
t.Units().SetSelectedType("")
t.UnitSearching().SetValue(true)
ul.searchEditor.Focus()
case key.NameEnter, key.NameReturn:
t.Model.AddUnit(e.Modifiers.Contain(key.ModCtrl)).Do()
t.UnitSearching().SetValue(true)
ul.searchEditor.Focus()
}
}
}
str := t.Model.UnitSearch()
for ev := ul.searchEditor.Update(gtx, str); ev != EditorEventNone; ev = ul.searchEditor.Update(gtx, str) {
if ev == EditorEventSubmit {
if str.Value() != "" {
for _, n := range sointu.UnitNames {
if strings.HasPrefix(n, str.Value()) {
t.Units().SetSelectedType(n)
break
}
}
} else {
t.Units().SetSelectedType("")
}
}
ul.dragList.Focus()
t.UnitSearching().SetValue(false)
}
}
func (ul *UnitList) Tags(curLevel int, yield TagYieldFunc) bool {
return yield(curLevel, ul.dragList) && yield(curLevel+1, &ul.searchEditor.widgetEditor)
}
func NewUnitEditor(m *tracker.Model) *UnitEditor {
ret := &UnitEditor{
DeleteUnitBtn: new(Clickable),
@ -159,7 +323,7 @@ func (pe *UnitEditor) update(gtx C, t *Tracker) {
if e, ok := e.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameLeftArrow:
t.PatchPanel.unitList.dragList.Focus()
t.PatchPanel.instrEditor.unitList.dragList.Focus()
case key.NameDeleteBackward:
t.ClearUnit().Do()
t.UnitSearch().SetValue("")

View File

@ -0,0 +1,211 @@
package gioui
import (
"image"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type (
InstrumentPresets struct {
searchEditor *Editor
gmDlsBtn *Clickable
userPresetsBtn *Clickable
builtinPresetsBtn *Clickable
clearSearchBtn *Clickable
saveUserPreset *Clickable
deleteUserPreset *Clickable
dirList *DragList
resultList *DragList
}
)
func NewInstrumentPresets(m *tracker.Model) *InstrumentPresets {
return &InstrumentPresets{
searchEditor: NewEditor(true, true, text.Start),
gmDlsBtn: new(Clickable),
clearSearchBtn: new(Clickable),
userPresetsBtn: new(Clickable),
builtinPresetsBtn: new(Clickable),
saveUserPreset: new(Clickable),
deleteUserPreset: new(Clickable),
dirList: NewDragList(m.PresetDirList().List(), layout.Vertical),
resultList: NewDragList(m.PresetResultList().List(), layout.Vertical),
}
}
func (ip *InstrumentPresets) Tags(level int, yield TagYieldFunc) bool {
return yield(level, &ip.searchEditor.widgetEditor) &&
yield(level+1, ip.clearSearchBtn) &&
yield(level+1, ip.builtinPresetsBtn) &&
yield(level+1, ip.userPresetsBtn) &&
yield(level+1, ip.gmDlsBtn) &&
yield(level, ip.dirList) &&
yield(level, ip.resultList) &&
yield(level+1, ip.saveUserPreset) &&
yield(level+1, ip.deleteUserPreset)
}
func (ip *InstrumentPresets) update(gtx C) {
for {
event, ok := gtx.Event(
key.Filter{Focus: ip.resultList, Name: key.NameLeftArrow},
)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameLeftArrow:
ip.dirList.Focus()
}
}
}
for {
event, ok := gtx.Event(
key.Filter{Focus: ip.dirList, Name: key.NameRightArrow},
)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameRightArrow:
ip.resultList.Focus()
}
}
}
}
func (ip *InstrumentPresets) layout(gtx C) D {
ip.update(gtx)
// get tracker from values
tr := TrackerFromContext(gtx)
gmDlsBtn := ToggleBtn(tr.NoGmDls(), tr.Theme, ip.gmDlsBtn, "No gm.dls", "Exclude presets using gm.dls")
userPresetsFilterBtn := ToggleBtn(tr.UserPresetFilter(), tr.Theme, ip.userPresetsBtn, "User", "Show only user presets")
builtinPresetsFilterBtn := ToggleBtn(tr.BuiltinPresetsFilter(), tr.Theme, ip.builtinPresetsBtn, "Builtin", "Show only builtin presets")
saveUserPresetBtn := ActionIconBtn(tr.SaveAsUserPreset(), tr.Theme, ip.saveUserPreset, icons.ContentSave, "Save instrument as user preset")
deleteUserPresetBtn := ActionIconBtn(tr.TryDeleteUserPreset(), tr.Theme, ip.deleteUserPreset, icons.ActionDelete, "Delete user preset")
dirElem := func(gtx C, i int) D {
return Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Directory, tr.Model.PresetDirList().Value(i)).Layout(gtx)
}
dirs := func(gtx C) D {
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(140), gtx.Constraints.Max.Y))
fdl := FilledDragList(tr.Theme, ip.dirList)
dims := fdl.Layout(gtx, dirElem, nil)
fdl.LayoutScrollBar(gtx)
return dims
}
dirSurface := func(gtx C) D {
return Surface{Gray: 36, Focus: tr.PatchPanel.TreeFocused(gtx)}.Layout(gtx, dirs)
}
resultElem := func(gtx C, i int) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
n, d, u := tr.Model.PresetResultList().Value(i)
if u {
ln := Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Results.User, n)
ld := Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Results.UserDir, d)
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(ln.Layout),
layout.Rigid(layout.Spacer{Width: 6}.Layout),
layout.Rigid(ld.Layout),
)
}
return Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Results.Builtin, n).Layout(gtx)
}
floatButtons := func(gtx C) D {
if tr.Model.DeleteUserPreset().Enabled() {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(deleteUserPresetBtn.Layout),
layout.Rigid(saveUserPresetBtn.Layout),
layout.Rigid(layout.Spacer{Width: 10}.Layout),
)
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(saveUserPresetBtn.Layout),
layout.Rigid(layout.Spacer{Width: 10}.Layout),
)
}
results := func(gtx C) D {
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
fdl := FilledDragList(tr.Theme, ip.resultList)
dims := fdl.Layout(gtx, resultElem, nil)
layout.SE.Layout(gtx, floatButtons)
fdl.LayoutScrollBar(gtx)
return dims
}
resultSurface := func(gtx C) D {
return Surface{Gray: 30, Focus: tr.PatchPanel.TreeFocused(gtx)}.Layout(gtx, results)
}
bottom := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(dirSurface),
layout.Flexed(1, resultSurface),
)
}
// layout
f := func(gtx C) D {
m := gtx.Constraints.Max
gtx.Constraints.Max.X = min(gtx.Dp(360), gtx.Constraints.Max.X)
layout.Flex{Axis: layout.Vertical, Alignment: layout.Start}.Layout(gtx,
layout.Rigid(ip.layoutSearch),
layout.Rigid(func(gtx C) D {
return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(userPresetsFilterBtn.Layout),
layout.Rigid(builtinPresetsFilterBtn.Layout),
layout.Rigid(gmDlsBtn.Layout),
)
})
}),
layout.Rigid(bottom),
)
return D{Size: m}
}
return Surface{Gray: 24, Focus: tr.PatchPanel.TreeFocused(gtx)}.Layout(gtx, f)
}
func (ip *InstrumentPresets) layoutSearch(gtx C) D {
// draw search icon on left and clear button on right
// return ip.searchEditor.Layout(gtx, tr.Model.PresetSearchString(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Search presets")
tr := TrackerFromContext(gtx)
bg := func(gtx C) D {
rr := gtx.Dp(18)
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, tr.Theme.InstrumentEditor.Presets.SearchBg)
return D{Size: gtx.Constraints.Min}
}
// icon, search editor, clear button
icon := func(gtx C) D {
return tr.Theme.IconButton.Enabled.Inset.Layout(gtx, func(gtx C) D {
return tr.Theme.Icon(icons.ActionSearch).Layout(gtx, tr.Theme.Material.Fg)
})
}
ed := func(gtx C) D {
return ip.searchEditor.Layout(gtx, tr.Model.PresetSearchString(), tr.Theme, &tr.Theme.InstrumentEditor.UnitComment, "Search presets")
}
clr := func(gtx C) D {
btn := ActionIconBtn(tr.ClearPresetSearch(), tr.Theme, ip.clearSearchBtn, icons.ContentClear, "Clear search")
return btn.Layout(gtx)
}
w := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(icon),
layout.Flexed(1, ed),
layout.Rigid(clr),
)
}
return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
return layout.Stack{}.Layout(gtx,
layout.Expanded(bg),
layout.Stacked(w),
)
})
}

View File

@ -0,0 +1,105 @@
package gioui
import (
"image"
"image/color"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type (
InstrumentProperties struct {
nameEditor *Editor
commentEditor *Editor
list *layout.List
soloBtn *Clickable
muteBtn *Clickable
soloHint string
unsoloHint string
muteHint string
unmuteHint string
voices *NumericUpDownState
splitInstrumentBtn *Clickable
splitInstrumentHint string
}
)
func NewInstrumentProperties() *InstrumentProperties {
ret := &InstrumentProperties{
list: &layout.List{Axis: layout.Vertical},
nameEditor: NewEditor(true, true, text.Start),
commentEditor: NewEditor(false, false, text.Start),
soloBtn: new(Clickable),
muteBtn: new(Clickable),
voices: NewNumericUpDownState(),
splitInstrumentBtn: new(Clickable),
}
ret.soloHint = makeHint("Solo", " (%s)", "SoloToggle")
ret.unsoloHint = makeHint("Unsolo", " (%s)", "SoloToggle")
ret.muteHint = makeHint("Mute", " (%s)", "MuteToggle")
ret.unmuteHint = makeHint("Unmute", " (%s)", "MuteToggle")
ret.splitInstrumentHint = makeHint("Split instrument", " (%s)", "SplitInstrument")
return ret
}
func (ip *InstrumentProperties) Tags(level int, yield TagYieldFunc) bool {
return yield(level, &ip.commentEditor.widgetEditor)
}
// layout
func (ip *InstrumentProperties) layout(gtx C) D {
// get tracker from values
tr := TrackerFromContext(gtx)
voiceLine := func(gtx C) D {
splitInstrumentBtn := ActionIconBtn(tr.SplitInstrument(), tr.Theme, ip.splitInstrumentBtn, icons.CommunicationCallSplit, ip.splitInstrumentHint)
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
instrumentVoices := NumUpDown(tr.Model.InstrumentVoices(), tr.Theme, ip.voices, "Number of voices for this instrument")
return instrumentVoices.Layout(gtx)
}),
layout.Rigid(splitInstrumentBtn.Layout),
)
}
return ip.list.Layout(gtx, 9, func(gtx C, index int) D {
switch index {
case 0:
return layoutInstrumentPropertyLine(gtx, "Name", func(gtx C) D {
return ip.nameEditor.Layout(gtx, tr.InstrumentName(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Instr")
})
case 2:
return layoutInstrumentPropertyLine(gtx, "Voices", voiceLine)
case 4:
muteBtn := ToggleIconBtn(tr.Mute(), tr.Theme, ip.muteBtn, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, ip.muteHint, ip.unmuteHint)
return layoutInstrumentPropertyLine(gtx, "Mute", muteBtn.Layout)
case 6:
soloBtn := ToggleIconBtn(tr.Solo(), tr.Theme, ip.soloBtn, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, ip.soloHint, ip.unsoloHint)
return layoutInstrumentPropertyLine(gtx, "Solo", soloBtn.Layout)
case 8:
return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D {
return ip.commentEditor.Layout(gtx, tr.InstrumentComment(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Comment")
})
default: // odd valued list items are dividers
px := max(gtx.Dp(unit.Dp(1)), 1)
paint.FillShape(gtx.Ops, color.NRGBA{255, 255, 255, 3}, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, px)).Op())
return D{Size: image.Pt(gtx.Constraints.Max.X, px)}
}
})
}
func layoutInstrumentPropertyLine(gtx C, text string, content layout.Widget) D {
tr := TrackerFromContext(gtx)
gtx.Constraints.Max.X = min(gtx.Dp(unit.Dp(200)), gtx.Constraints.Max.X)
label := Label(tr.Theme, &tr.Theme.InstrumentEditor.Properties.Label, text)
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: 6, Height: 36}.Layout),
layout.Rigid(label.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(content),
)
}

View File

@ -7,6 +7,7 @@ import (
"strings"
"gioui.org/io/clipboard"
"gioui.org/io/event"
"gioui.org/io/key"
"github.com/vsariola/sointu/tracker"
"gopkg.in/yaml.v2"
@ -208,8 +209,6 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) {
t.InstrEnlarged().Toggle()
case "LinkInstrTrackToggle":
t.LinkInstrTrack().Toggle()
case "CommentExpandedToggle":
t.CommentExpanded().Toggle()
case "FollowToggle":
t.Follow().Toggle()
case "UnitDisabledToggle":
@ -259,13 +258,20 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) {
case "Paste":
gtx.Execute(clipboard.ReadCmd{Tag: t})
case "OrderEditorFocus":
t.InstrEnlarged().SetValue(false)
gtx.Execute(key.FocusCmd{Tag: t.OrderEditor.scrollTable})
case "TrackEditorFocus":
t.InstrEnlarged().SetValue(false)
gtx.Execute(key.FocusCmd{Tag: t.TrackEditor.scrollTable})
case "InstrumentListFocus":
gtx.Execute(key.FocusCmd{Tag: t.PatchPanel.instrList.instrumentDragList})
case "UnitListFocus":
gtx.Execute(key.FocusCmd{Tag: t.PatchPanel.unitList.dragList})
var tag event.Tag
t.PatchPanel.BottomTags(0, func(level int, t event.Tag) bool {
tag = t
return false
})
gtx.Execute(key.FocusCmd{Tag: tag})
case "FocusPrev":
t.FocusPrev(gtx, false)
case "FocusPrevInto":

View File

@ -6,32 +6,42 @@ import (
"image/color"
"io"
"strconv"
"strings"
"gioui.org/io/clipboard"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/text"
"gioui.org/unit"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type (
PatchPanel struct {
instrList InstrumentList
tools InstrumentTools
unitList UnitList
unitEditor UnitEditor
instrList InstrumentList
tools InstrumentTools
instrProps InstrumentProperties
instrPresets InstrumentPresets
instrEditor InstrumentEditor
*tracker.Model
}
InstrumentList struct {
instrumentDragList *DragList
nameEditor *Editor
}
InstrumentTools struct {
EditorTab *Clickable
PresetsTab *Clickable
CommentTab *Clickable
saveInstrumentBtn *Clickable
loadInstrumentBtn *Clickable
copyInstrumentBtn *Clickable
deleteInstrumentBtn *Clickable
octave *NumericUpDownState
enlargeBtn *Clickable
@ -43,69 +53,58 @@ type (
linkEnabledHint string
enlargeHint, shrinkHint string
addInstrumentHint string
}
InstrumentTools struct {
Voices *NumericUpDownState
splitInstrumentBtn *Clickable
commentExpandBtn *Clickable
commentEditor *Editor
soloBtn *Clickable
muteBtn *Clickable
presetMenuBtn *Clickable
presetMenu MenuState
presetMenuItems []ActionMenuItem
saveInstrumentBtn *Clickable
loadInstrumentBtn *Clickable
copyInstrumentBtn *Clickable
deleteInstrumentBtn *Clickable
commentExpanded tracker.Bool
muteHint, unmuteHint string
soloHint, unsoloHint string
expandCommentHint string
collapseCommentHint string
splitInstrumentHint string
deleteInstrumentHint string
}
UnitList struct {
dragList *DragList
searchEditor *Editor
addUnitBtn *Clickable
addUnitAction tracker.Action
}
)
// PatchPanel methods
func NewPatchPanel(model *tracker.Model) *PatchPanel {
return &PatchPanel{
instrList: MakeInstrList(model),
tools: MakeInstrumentTools(model),
unitList: MakeUnitList(model),
unitEditor: *NewUnitEditor(model),
instrEditor: MakeInstrumentEditor(model),
instrList: MakeInstrList(model),
tools: MakeInstrumentTools(model),
instrProps: *NewInstrumentProperties(),
instrPresets: *NewInstrumentPresets(model),
Model: model,
}
}
func (pp *PatchPanel) Layout(gtx C) D {
tr := TrackerFromContext(gtx)
bottom := func(gtx C) D {
switch {
case tr.InstrComment().Value():
return pp.instrProps.layout(gtx)
case tr.InstrPresets().Value():
return pp.instrPresets.layout(gtx)
default: // editor
return pp.instrEditor.layout(gtx)
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(pp.instrList.Layout),
layout.Rigid(pp.tools.Layout),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(pp.unitList.Layout),
layout.Flexed(1, pp.unitEditor.Layout),
)
}))
layout.Flexed(1, bottom),
)
}
func (pp *PatchPanel) BottomTags(level int, yield TagYieldFunc) bool {
switch {
case pp.InstrComment().Value():
return pp.instrProps.Tags(level, yield)
case pp.InstrPresets().Value():
return pp.instrPresets.Tags(level, yield)
default: // editor
return pp.instrEditor.Tags(level, yield)
}
}
func (pp *PatchPanel) Tags(level int, yield TagYieldFunc) bool {
return pp.instrList.Tags(level, yield) &&
pp.tools.Tags(level, yield) &&
pp.unitList.Tags(level, yield) &&
pp.unitEditor.Tags(level, yield)
pp.BottomTags(level, yield)
}
// TreeFocused returns true if any of the tags in the patch panel is focused
@ -119,30 +118,24 @@ func (pp *PatchPanel) TreeFocused(gtx C) bool {
func MakeInstrumentTools(m *tracker.Model) InstrumentTools {
ret := InstrumentTools{
Voices: NewNumericUpDownState(),
EditorTab: new(Clickable),
PresetsTab: new(Clickable),
CommentTab: new(Clickable),
deleteInstrumentBtn: new(Clickable),
splitInstrumentBtn: new(Clickable),
copyInstrumentBtn: new(Clickable),
saveInstrumentBtn: new(Clickable),
loadInstrumentBtn: new(Clickable),
commentExpandBtn: new(Clickable),
presetMenuBtn: new(Clickable),
soloBtn: new(Clickable),
muteBtn: new(Clickable),
presetMenuItems: []ActionMenuItem{},
commentEditor: NewEditor(false, false, text.Start),
commentExpanded: m.CommentExpanded(),
expandCommentHint: makeHint("Expand comment", " (%s)", "CommentExpandedToggle"),
collapseCommentHint: makeHint("Collapse comment", " (%s)", "CommentExpandedToggle"),
deleteInstrumentHint: makeHint("Delete\ninstrument", "\n(%s)", "DeleteInstrument"),
muteHint: makeHint("Mute", " (%s)", "MuteToggle"),
unmuteHint: makeHint("Unmute", " (%s)", "MuteToggle"),
soloHint: makeHint("Solo", " (%s)", "SoloToggle"),
unsoloHint: makeHint("Unsolo", " (%s)", "SoloToggle"),
splitInstrumentHint: makeHint("Split instrument", " (%s)", "SplitInstrument"),
}
for index, name := range m.IterateInstrumentPresets {
ret.presetMenuItems = append(ret.presetMenuItems, MenuItem(m.LoadPreset(index), name, "", icons.ImageAudiotrack))
octave: NewNumericUpDownState(),
enlargeBtn: new(Clickable),
linkInstrTrackBtn: new(Clickable),
newInstrumentBtn: new(Clickable),
octaveHint: makeHint("Octave down", " (%s)", "OctaveNumberInputSubtract") + makeHint(" or up", " (%s)", "OctaveNumberInputAdd"),
linkDisabledHint: makeHint("Instrument-Track\nlinking disabled", "\n(%s)", "LinkInstrTrackToggle"),
linkEnabledHint: makeHint("Instrument-Track\nlinking enabled", "\n(%s)", "LinkInstrTrackToggle"),
enlargeHint: makeHint("Enlarge", " (%s)", "InstrEnlargedToggle"),
shrinkHint: makeHint("Shrink", " (%s)", "InstrEnlargedToggle"),
addInstrumentHint: makeHint("Add\ninstrument", "\n(%s)", "AddInstrument"),
}
return ret
}
@ -150,55 +143,38 @@ func MakeInstrumentTools(m *tracker.Model) InstrumentTools {
func (it *InstrumentTools) Layout(gtx C) D {
t := TrackerFromContext(gtx)
it.update(gtx, t)
voicesLabel := Label(t.Theme, &t.Theme.InstrumentEditor.Voices, "Voices")
splitInstrumentBtn := ActionIconBtn(t.SplitInstrument(), t.Theme, it.splitInstrumentBtn, icons.CommunicationCallSplit, it.splitInstrumentHint)
commentExpandedBtn := ToggleIconBtn(t.CommentExpanded(), t.Theme, it.commentExpandBtn, icons.NavigationExpandMore, icons.NavigationExpandLess, it.expandCommentHint, it.collapseCommentHint)
soloBtn := ToggleIconBtn(t.Solo(), t.Theme, it.soloBtn, icons.SocialGroup, icons.SocialPerson, it.soloHint, it.unsoloHint)
muteBtn := ToggleIconBtn(t.Mute(), t.Theme, it.muteBtn, icons.AVVolumeUp, icons.AVVolumeOff, it.muteHint, it.unmuteHint)
editorBtn := TabBtn(t.Model.InstrEditor(), t.Theme, it.EditorTab, "Editor", "")
presetsBtn := TabBtn(t.Model.InstrPresets(), t.Theme, it.PresetsTab, "Presets", "")
commentBtn := TabBtn(t.Model.InstrComment(), t.Theme, it.CommentTab, "Properties", "")
octave := NumUpDown(t.Model.Octave(), t.Theme, t.OctaveNumberInput, "Octave")
linkInstrTrackBtn := ToggleIconBtn(t.Model.LinkInstrTrack(), t.Theme, it.linkInstrTrackBtn, icons.NotificationSyncDisabled, icons.NotificationSync, it.linkDisabledHint, it.linkEnabledHint)
instrEnlargedBtn := ToggleIconBtn(t.Model.InstrEnlarged(), t.Theme, it.enlargeBtn, icons.NavigationFullscreen, icons.NavigationFullscreenExit, it.enlargeHint, it.shrinkHint)
addInstrumentBtn := ActionIconBtn(t.Model.AddInstrument(), t.Theme, it.newInstrumentBtn, icons.ContentAdd, it.addInstrumentHint)
saveInstrumentBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.saveInstrumentBtn, icons.ContentSave, "Save instrument")
loadInstrumentBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.loadInstrumentBtn, icons.FileFolderOpen, "Load instrument")
copyInstrumentBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.copyInstrumentBtn, icons.ContentContentCopy, "Copy instrument")
deleteInstrumentBtn := ActionIconBtn(t.DeleteInstrument(), t.Theme, it.deleteInstrumentBtn, icons.ActionDelete, it.deleteInstrumentHint)
instrumentVoices := NumUpDown(t.Model.InstrumentVoices(), t.Theme, it.Voices, "Number of voices for this instrument")
btns := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: 6}.Layout),
layout.Rigid(voicesLabel.Layout),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Rigid(instrumentVoices.Layout),
layout.Rigid(splitInstrumentBtn.Layout),
layout.Rigid(editorBtn.Layout),
layout.Rigid(presetsBtn.Layout),
layout.Rigid(commentBtn.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(commentExpandedBtn.Layout),
layout.Rigid(soloBtn.Layout),
layout.Rigid(muteBtn.Layout),
layout.Rigid(func(gtx C) D {
presetBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.presetMenuBtn, icons.NavigationMenu, "Load preset")
dims := presetBtn.Layout(gtx)
op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops)
m := Menu(t.Theme, &it.presetMenu)
m.Style = &t.Theme.Menu.Preset
m.Layout(gtx, it.presetMenuItems...)
return dims
}),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Rigid(Label(t.Theme, &t.Theme.InstrumentEditor.Octave, "Octave").Layout),
layout.Rigid(octave.Layout),
layout.Rigid(linkInstrTrackBtn.Layout),
layout.Rigid(instrEnlargedBtn.Layout),
layout.Rigid(copyInstrumentBtn.Layout),
layout.Rigid(saveInstrumentBtn.Layout),
layout.Rigid(loadInstrumentBtn.Layout),
layout.Rigid(copyInstrumentBtn.Layout),
layout.Rigid(deleteInstrumentBtn.Layout),
layout.Rigid(addInstrumentBtn.Layout),
)
}
comment := func(gtx C) D {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
ret := layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D {
return it.commentEditor.Layout(gtx, t.InstrumentComment(), t.Theme, &t.Theme.InstrumentEditor.InstrumentComment, "Comment")
})
return ret
}
return Surface{Gray: 37, Focus: t.PatchPanel.TreeFocused(gtx)}.Layout(gtx, func(gtx C) D {
if t.CommentExpanded().Value() {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(btns), layout.Rigid(comment))
}
return btns(gtx)
})
return Surface{Gray: 37, Focus: t.PatchPanel.TreeFocused(gtx)}.Layout(gtx, btns)
}
func (it *InstrumentTools) update(gtx C, tr *Tracker) {
@ -222,18 +198,9 @@ func (it *InstrumentTools) update(gtx C, tr *Tracker) {
}
tr.LoadInstrument(reader)
}
for it.presetMenuBtn.Clicked(gtx) {
it.presetMenu.visible = true
}
for it.commentEditor.Update(gtx, tr.InstrumentComment()) != EditorEventNone {
tr.PatchPanel.instrList.instrumentDragList.Focus()
}
}
func (it *InstrumentTools) Tags(level int, yield TagYieldFunc) bool {
if it.commentExpanded.Value() {
return yield(level+1, &it.commentEditor.widgetEditor)
}
return true
}
@ -243,41 +210,12 @@ func MakeInstrList(model *tracker.Model) InstrumentList {
return InstrumentList{
instrumentDragList: NewDragList(model.Instruments().List(), layout.Horizontal),
nameEditor: NewEditor(true, true, text.Middle),
octave: NewNumericUpDownState(),
enlargeBtn: new(Clickable),
linkInstrTrackBtn: new(Clickable),
newInstrumentBtn: new(Clickable),
octaveHint: makeHint("Octave down", " (%s)", "OctaveNumberInputSubtract") + makeHint(" or up", " (%s)", "OctaveNumberInputAdd"),
linkDisabledHint: makeHint("Instrument-Track\nlinking disabled", "\n(%s)", "LinkInstrTrackToggle"),
linkEnabledHint: makeHint("Instrument-Track\nlinking enabled", "\n(%s)", "LinkInstrTrackToggle"),
enlargeHint: makeHint("Enlarge", " (%s)", "InstrEnlargedToggle"),
shrinkHint: makeHint("Shrink", " (%s)", "InstrEnlargedToggle"),
addInstrumentHint: makeHint("Add\ninstrument", "\n(%s)", "AddInstrument"),
}
}
func (il *InstrumentList) Layout(gtx C) D {
t := TrackerFromContext(gtx)
il.update(gtx, t)
octave := NumUpDown(t.Model.Octave(), t.Theme, t.OctaveNumberInput, "Octave")
linkInstrTrackBtn := ToggleIconBtn(t.Model.LinkInstrTrack(), t.Theme, il.linkInstrTrackBtn, icons.NotificationSyncDisabled, icons.NotificationSync, il.linkDisabledHint, il.linkEnabledHint)
instrEnlargedBtn := ToggleIconBtn(t.Model.InstrEnlarged(), t.Theme, il.enlargeBtn, icons.NavigationFullscreen, icons.NavigationFullscreenExit, il.enlargeHint, il.shrinkHint)
addInstrumentBtn := ActionIconBtn(t.Model.AddInstrument(), t.Theme, il.newInstrumentBtn, icons.ContentAdd, il.addInstrumentHint)
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(
gtx,
layout.Flexed(1, il.actualLayout),
layout.Rigid(layout.Spacer{Width: 10}.Layout),
layout.Rigid(Label(t.Theme, &t.Theme.InstrumentEditor.Octave, "Octave").Layout),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Rigid(octave.Layout),
layout.Rigid(linkInstrTrackBtn.Layout),
layout.Rigid(instrEnlargedBtn.Layout),
layout.Rigid(addInstrumentBtn.Layout),
)
}
func (il *InstrumentList) actualLayout(gtx C) D {
t := TrackerFromContext(gtx)
gtx.Constraints.Max.Y = gtx.Dp(36)
gtx.Constraints.Min.Y = gtx.Dp(36)
element := func(gtx C, i int) D {
@ -340,7 +278,18 @@ func (il *InstrumentList) update(gtx C, t *Tracker) {
if e, ok := event.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameDownArrow:
t.PatchPanel.unitList.dragList.Focus()
var tagged Tagged
switch {
case t.InstrComment().Value():
tagged = &t.PatchPanel.instrProps
case t.InstrPresets().Value():
tagged = &t.PatchPanel.instrPresets
default: // editor
tagged = &t.PatchPanel.instrEditor
}
if tag, ok := firstTag(tagged); ok {
gtx.Execute(key.FocusCmd{Tag: tag})
}
case key.NameReturn, key.NameEnter:
il.nameEditor.Focus()
}
@ -351,132 +300,3 @@ func (il *InstrumentList) update(gtx C, t *Tracker) {
func (il *InstrumentList) Tags(level int, yield TagYieldFunc) bool {
return yield(level, il.instrumentDragList)
}
// UnitList methods
func MakeUnitList(m *tracker.Model) UnitList {
ret := UnitList{
dragList: NewDragList(m.Units().List(), layout.Vertical),
addUnitBtn: new(Clickable),
searchEditor: NewEditor(true, true, text.Start),
}
ret.addUnitAction = tracker.MakeEnabledAction(tracker.DoFunc(func() {
m.AddUnit(false).Do()
ret.searchEditor.Focus()
}))
return ret
}
func (ul *UnitList) Layout(gtx C) D {
t := TrackerFromContext(gtx)
ul.update(gtx, t)
element := func(gtx C, i int) D {
gtx.Constraints.Max.Y = gtx.Dp(20)
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
u := t.Units().Item(i)
editorStyle := t.Theme.InstrumentEditor.UnitList.Name
signalError := t.RailError()
switch {
case u.Disabled:
editorStyle = t.Theme.InstrumentEditor.UnitList.NameDisabled
case signalError.Err != nil && signalError.UnitIndex == i:
editorStyle.Color = t.Theme.InstrumentEditor.UnitList.Error
}
unitName := func(gtx C) D {
if i == ul.dragList.TrackerList.Selected() {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
return ul.searchEditor.Layout(gtx, t.Model.UnitSearch(), t.Theme, &editorStyle, "---")
} else {
text := u.Type
if text == "" {
text = "---"
}
l := editorStyle.AsLabelStyle()
return Label(t.Theme, &l, text).Layout(gtx)
}
}
stackText := strconv.FormatInt(int64(u.Signals.StackAfter()), 10)
commentLabel := Label(t.Theme, &t.Theme.InstrumentEditor.UnitList.Comment, u.Comment)
stackLabel := Label(t.Theme, &t.Theme.InstrumentEditor.UnitList.Stack, stackText)
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(unitName),
layout.Rigid(layout.Spacer{Width: 5}.Layout),
layout.Flexed(1, commentLabel.Layout),
layout.Rigid(stackLabel.Layout),
layout.Rigid(layout.Spacer{Width: 10}.Layout),
)
}
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
unitList := FilledDragList(t.Theme, ul.dragList)
surface := 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()
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(140), gtx.Constraints.Max.Y))
dims := unitList.Layout(gtx, element, nil)
unitList.LayoutScrollBar(gtx)
return dims
}),
layout.Stacked(func(gtx C) D {
margin := layout.Inset{Right: unit.Dp(20), Bottom: unit.Dp(1)}
addUnitBtn := IconBtn(t.Theme, &t.Theme.IconButton.Emphasis, ul.addUnitBtn, icons.ContentAdd, "Add unit (Enter)")
return margin.Layout(gtx, addUnitBtn.Layout)
}),
)
}
return Surface{Gray: 30, Focus: t.PatchPanel.TreeFocused(gtx)}.Layout(gtx, surface)
}
func (ul *UnitList) update(gtx C, t *Tracker) {
for ul.addUnitBtn.Clicked(gtx) {
ul.addUnitAction.Do()
t.UnitSearching().SetValue(true)
ul.searchEditor.Focus()
}
for {
event, ok := gtx.Event(
key.Filter{Focus: ul.dragList, Name: key.NameRightArrow},
key.Filter{Focus: ul.dragList, Name: key.NameEnter, Optional: key.ModCtrl},
key.Filter{Focus: ul.dragList, Name: key.NameReturn, Optional: key.ModCtrl},
key.Filter{Focus: ul.dragList, Name: key.NameDeleteBackward},
)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameRightArrow:
t.PatchPanel.unitEditor.paramTable.RowTitleList.Focus()
case key.NameDeleteBackward:
t.Units().SetSelectedType("")
t.UnitSearching().SetValue(true)
ul.searchEditor.Focus()
case key.NameEnter, key.NameReturn:
t.Model.AddUnit(e.Modifiers.Contain(key.ModCtrl)).Do()
t.UnitSearching().SetValue(true)
ul.searchEditor.Focus()
}
}
}
str := t.Model.UnitSearch()
for ev := ul.searchEditor.Update(gtx, str); ev != EditorEventNone; ev = ul.searchEditor.Update(gtx, str) {
if ev == EditorEventSubmit {
if str.Value() != "" {
for _, n := range sointu.UnitNames {
if strings.HasPrefix(n, str.Value()) {
t.Units().SetSelectedType(n)
break
}
}
} else {
t.Units().SetSelectedType("")
}
}
ul.dragList.Focus()
t.UnitSearching().SetValue(false)
}
}
func (ul *UnitList) Tags(curLevel int, yield TagYieldFunc) bool {
return yield(curLevel, ul.dragList) && yield(curLevel+1, &ul.searchEditor.widgetEditor)
}

View File

@ -19,6 +19,12 @@ type Theme struct {
Text ButtonStyle
Disabled ButtonStyle
Menu ButtonStyle
Tab struct {
Active ButtonStyle
Inactive ButtonStyle
IndicatorHeight unit.Dp
IndicatorColor color.NRGBA
}
}
IconButton struct {
Enabled IconButtonStyle
@ -64,8 +70,10 @@ type Theme struct {
Preset MenuStyle
}
InstrumentEditor struct {
Octave LabelStyle
Voices LabelStyle
Octave LabelStyle
Properties struct {
Label LabelStyle
}
InstrumentComment EditorStyle
UnitComment EditorStyle
InstrumentList struct {
@ -83,6 +91,15 @@ type Theme struct {
Warning color.NRGBA
Error color.NRGBA
}
Presets struct {
SearchBg color.NRGBA
Directory LabelStyle
Results struct {
Builtin LabelStyle
User LabelStyle
UserDir LabelStyle
}
}
}
UnitEditor struct {
Name LabelStyle

View File

@ -54,6 +54,17 @@ button:
cornerradius: 0
height: *buttonheight
inset: *buttoninset
tab:
active: *textbutton
inactive:
background: *transparentcolor
color: *highemphasis
textsize: *buttontextsize
cornerradius: *buttoncornerradius
height: *buttonheight
inset: *buttoninset
indicatorheight: 2
indicatorcolor: *primarycolor
iconbutton:
enabled:
color: *primarycolor
@ -153,7 +164,8 @@ menu:
height: 300
instrumenteditor:
octave: { textsize: 14, color: *disabled }
voices: { textsize: 14, color: *disabled }
properties:
label: { textsize: 14, color: *highemphasis }
instrumentcomment:
{ textsize: 14, color: *highemphasis, hintcolor: *disabled }
unitcomment: { textsize: 14, color: *highemphasis, hintcolor: *disabled }
@ -178,6 +190,13 @@ instrumenteditor:
disabled: { textsize: 12, color: *disabled }
warning: *warningcolor
error: *errorcolor
presets:
searchbg: { r: 255, g: 255, b: 255, a: 6 }
directory: { textsize: 12, color: *white, maxlines: 1 }
results:
builtin: { textsize: 12, color: *white, maxlines: 1 }
user: { textsize: 12, color: *secondarycolor, maxlines: 1 }
userdir: { textsize: 12, color: *mediumemphasis, maxlines: 1 }
cursor:
active: { r: 100, g: 140, b: 255, a: 48 }
activealt: { r: 255, g: 100, b: 140, a: 48 }

View File

@ -316,6 +316,18 @@ func (t *Tracker) showDialog(gtx C) {
DialogBtn("Close", t.Cancel()),
)
dialog.Layout(gtx)
case tracker.DeleteUserPresetDialog:
dialog := MakeDialog(t.Theme, t.DialogState, "Delete user preset?", "Are you sure you want to delete the selected user preset?\nThis action cannot be undone.",
DialogBtn("Delete", t.DeleteUserPreset()),
DialogBtn("Cancel", t.Cancel()),
)
dialog.Layout(gtx)
case tracker.OverwriteUserPresetDialog:
dialog := MakeDialog(t.Theme, t.DialogState, "Overwrite user preset?", "Are you sure you want to overwrite the existing user preset with the same name?",
DialogBtn("Save", t.OverwriteUserPreset()),
DialogBtn("Cancel", t.Cancel()),
)
dialog.Layout(gtx)
}
}

View File

@ -55,7 +55,6 @@ type (
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

View File

@ -36,14 +36,15 @@ type (
RecoveryFilePath string
ChangedSinceRecovery bool
SendSource int
InstrumentTab InstrumentTab
PresetSearchString string
}
Model struct {
d modelData
derived derivedModelData
instrEnlarged bool
commentExpanded bool
instrEnlarged bool
prevUndoKind string
undoSkipCounter int
@ -84,6 +85,9 @@ type (
broker *Broker
MIDI MIDIContext
presets Presets
presetIndex int
}
// Cursor identifies a row and a track in a song score.
@ -129,6 +133,8 @@ type (
String() string
Open() error
}
InstrumentTab int
)
const (
@ -159,6 +165,14 @@ const (
QuitChanges
QuitSaveExplorer
License
DeleteUserPresetDialog
OverwriteUserPresetDialog
)
const (
InstrumentEditorTab InstrumentTab = iota
InstrumentPresetsTab
InstrumentCommentTab
)
const maxUndo = 64
@ -193,6 +207,8 @@ func NewModel(broker *Broker, synthers []sointu.Synther, midiContext MIDIContext
TrySend(broker.ToPlayer, any(m.d.Song.Copy())) // we should be non-blocking in the constructor
m.signalAnalyzer = NewScopeModel(broker, m.d.Song.BPM)
m.updateDeriveData(SongChange)
m.presets.load()
m.updateDerivedPresetSearch()
return m
}

View File

@ -50,12 +50,14 @@ func (s *modelFuzzState) Iterate(yield func(string, func(p string, t *testing.T)
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.IterateList("PresetDirs", s.model.PresetDirList().List(), yield, seed)
s.IterateList("PresetResults", s.model.PresetResultList().List(), yield, seed)
// Bools
s.IterateBool("Panic", s.model.Panic(), yield, seed)
s.IterateBool("Recording", s.model.IsRecording(), yield, seed)
s.IterateBool("Playing", s.model.Playing(), yield, seed)
s.IterateBool("InstrEnlarged", s.model.InstrEnlarged(), yield, seed)
s.IterateBool("Effect", s.model.Effect(), yield, seed)
s.IterateBool("CommentExpanded", s.model.CommentExpanded(), yield, seed)
s.IterateBool("Follow", s.model.Follow(), yield, seed)
s.IterateBool("UniquePatterns", s.model.UniquePatterns(), yield, seed)
s.IterateBool("LinkInstrTrack", s.model.LinkInstrTrack(), yield, seed)
@ -88,8 +90,6 @@ func (s *modelFuzzState) Iterate(yield func(string, func(p string, t *testing.T)
s.IterateAction("DeleteOrderRowBackward", s.model.DeleteOrderRow(true), yield, seed)
s.IterateAction("SplitInstrument", s.model.SplitInstrument(), yield, seed)
s.IterateAction("SplitTrack", s.model.SplitTrack(), yield, seed)
// just test loading one of the presets
s.IterateAction("LoadPreset", s.model.LoadPreset(seed%tracker.NumPresets()), yield, seed)
// Tables
s.IterateTable("Order", s.model.Order().Table(), yield, seed)
s.IterateTable("Notes", s.model.Notes().Table(), yield, seed)

View File

@ -5,6 +5,7 @@ import (
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
@ -17,6 +18,9 @@ import (
//go:generate go run generate/gmdls_entries.go
//go:generate go run generate/clean_presets.go
//go:embed presets/*
var instrumentPresetFS embed.FS
type (
// GmDlsEntry is a single sample entry from the gm.dls file
GmDlsEntry struct {
@ -27,13 +31,390 @@ type (
Name string // sample Name
}
Preset struct {
Directory string
User bool
NeedsGmDls bool
Instr sointu.Instrument
}
Presets struct {
Presets []Preset
Dirs []string
}
InstrumentPresetYieldFunc func(index int, item string) (ok bool)
LoadPreset struct {
Index int
*Model
}
PresetSearchString Model
NoGmDlsFilter Model
BuiltinPresetsFilter Model
UserPresetsFilter Model
PresetDirectory Model
PresetKind Model
ClearPresetSearch Model
PresetDirList Model
PresetResultList Model
SaveUserPreset Model
TryDeleteUserPreset Model
DeleteUserPreset Model
ConfirmDeleteUserPresetAction Model
OverwriteUserPreset Model
derivedPresetSearch struct {
dir string
dirIndex int
noGmDls bool
kind PresetKindEnum
searchStrings []string
results []Preset
}
PresetKindEnum int
)
const (
BuiltinPresets PresetKindEnum = -1
AllPresets PresetKindEnum = 0
UserPresets PresetKindEnum = 1
)
func (m *Model) updateDerivedPresetSearch() {
// reset derived data, keeping the
str := m.derived.presetSearch.searchStrings[:0]
m.derived.presetSearch = derivedPresetSearch{searchStrings: str, dirIndex: -1}
// parse filters from the search string. in: dir, gmdls: yes/no, kind: builtin/user/all
search := strings.TrimSpace(m.d.PresetSearchString)
parts := strings.Fields(search)
// parse parts to see if they contain :
for _, part := range parts {
if strings.HasPrefix(part, "d:") && len(part) > 2 {
dir := strings.TrimSpace(part[2:])
m.derived.presetSearch.dir = dir
ind := slices.IndexFunc(m.presets.Dirs, func(c string) bool { return c == dir })
m.derived.presetSearch.dirIndex = ind
} else if strings.HasPrefix(part, "g:n") {
m.derived.presetSearch.noGmDls = true
} else if strings.HasPrefix(part, "t:") && len(part) > 2 {
val := strings.TrimSpace(part[2:3])
switch val {
case "b":
m.derived.presetSearch.kind = BuiltinPresets
case "u":
m.derived.presetSearch.kind = UserPresets
}
} else {
m.derived.presetSearch.searchStrings = append(m.derived.presetSearch.searchStrings, strings.ToLower(part))
}
}
// update results
m.derived.presetSearch.results = m.derived.presetSearch.results[:0]
for _, p := range m.presets.Presets {
if m.derived.presetSearch.kind == BuiltinPresets && p.User {
continue
}
if m.derived.presetSearch.kind == UserPresets && !p.User {
continue
}
if m.derived.presetSearch.dir != "" && p.Directory != m.derived.presetSearch.dir {
continue
}
if m.derived.presetSearch.noGmDls && p.NeedsGmDls {
continue
}
if len(m.derived.presetSearch.searchStrings) == 0 {
goto found
}
for _, s := range m.derived.presetSearch.searchStrings {
if strings.Contains(strings.ToLower(p.Instr.Name), s) {
goto found
}
}
continue
found:
m.derived.presetSearch.results = append(m.derived.presetSearch.results, p)
}
}
func (m *Presets) load() {
*m = Presets{}
seenDir := make(map[string]bool)
m.loadPresetsFromFs(instrumentPresetFS, false, seenDir)
if configDir, err := os.UserConfigDir(); err == nil {
userPresets := filepath.Join(configDir, "sointu")
m.loadPresetsFromFs(os.DirFS(userPresets), true, seenDir)
}
sort.Sort(m)
m.Dirs = make([]string, 0, len(seenDir))
for k := range seenDir {
m.Dirs = append(m.Dirs, k)
}
sort.Strings(m.Dirs)
}
func (m *Presets) loadPresetsFromFs(fsys fs.FS, userDefined bool, seenDir map[string]bool) {
fs.WalkDir(fsys, "presets", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(fsys, path)
if err != nil {
return nil
}
var instr sointu.Instrument
if yaml.UnmarshalStrict(data, &instr) == nil {
noExt := path[:len(path)-len(filepath.Ext(path))]
splitted := splitPath(noExt)
splitted = splitted[1:] // remove "presets" from the path
instr.Name = filenameToInstrumentName(splitted[len(splitted)-1])
dir := strings.Join(splitted[:len(splitted)-1], "/")
preset := Preset{
Directory: dir,
User: userDefined,
Instr: instr,
NeedsGmDls: checkNeedsGmDls(instr),
}
if dir != "" {
seenDir[dir] = true
}
m.Presets = append(m.Presets, preset)
}
return nil
})
}
func filenameToInstrumentName(filename string) string {
return strings.ReplaceAll(filename, "_", " ")
}
func instrumentNameToFilename(name string) string {
// remove all special characters
reg, _ := regexp.Compile("[^a-zA-Z0-9 _]+")
name = reg.ReplaceAllString(name, "")
name = strings.ReplaceAll(name, " ", "_")
return name
}
func checkNeedsGmDls(instr sointu.Instrument) bool {
for _, u := range instr.Units {
if u.Type == "oscillator" {
if u.Parameters["type"] == sointu.Sample {
return true
}
}
}
return false
}
func (m *Model) PresetSearchString() String { return MakeString((*PresetSearchString)(m)) }
func (m *PresetSearchString) Value() string { return m.d.PresetSearchString }
func (m *PresetSearchString) SetValue(value string) bool {
if m.d.PresetSearchString == value {
return false
}
m.d.PresetSearchString = value
(*Model)(m).updateDerivedPresetSearch()
return true
}
func (m *Model) NoGmDls() Bool { return MakeBool((*NoGmDlsFilter)(m)) }
func (m *NoGmDlsFilter) Value() bool { return m.derived.presetSearch.noGmDls }
func (m *NoGmDlsFilter) SetValue(val bool) {
if m.derived.presetSearch.noGmDls == val {
return
}
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "g:")
if val {
m.d.PresetSearchString = "g:n " + m.d.PresetSearchString
}
(*Model)(m).updateDerivedPresetSearch()
}
func (m *NoGmDlsFilter) Enabled() bool { return true }
func (m *Model) UserPresetFilter() Bool { return MakeBool((*UserPresetsFilter)(m)) }
func (m *UserPresetsFilter) Value() bool { return m.derived.presetSearch.kind == UserPresets }
func (m *UserPresetsFilter) SetValue(val bool) {
if (m.derived.presetSearch.kind == UserPresets) == val {
return
}
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "t:")
if val {
m.d.PresetSearchString = "t:u " + m.d.PresetSearchString
}
(*Model)(m).updateDerivedPresetSearch()
}
func (m *UserPresetsFilter) Enabled() bool { return true }
func (m *Model) BuiltinPresetsFilter() Bool { return MakeBool((*BuiltinPresetsFilter)(m)) }
func (m *BuiltinPresetsFilter) Value() bool { return m.derived.presetSearch.kind == BuiltinPresets }
func (m *BuiltinPresetsFilter) SetValue(val bool) {
if (m.derived.presetSearch.kind == BuiltinPresets) == val {
return
}
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "t:")
if val {
m.d.PresetSearchString = "t:b " + m.d.PresetSearchString
}
(*Model)(m).updateDerivedPresetSearch()
}
func (m *BuiltinPresetsFilter) Enabled() bool { return true }
func (m *Model) ClearPresetSearch() Action { return MakeAction((*ClearPresetSearch)(m)) }
func (m *ClearPresetSearch) Enabled() bool { return len(m.d.PresetSearchString) > 0 }
func (m *ClearPresetSearch) Do() {
m.d.PresetSearchString = ""
(*Model)(m).updateDerivedPresetSearch()
}
func (m *Model) PresetDirList() *PresetDirList { return (*PresetDirList)(m) }
func (v *PresetDirList) List() List { return List{v} }
func (m *PresetDirList) Count() int { return len(m.presets.Dirs) + 1 }
func (m *PresetDirList) Selected() int { return m.derived.presetSearch.dirIndex + 1 }
func (m *PresetDirList) Selected2() int { return m.derived.presetSearch.dirIndex + 1 }
func (m *PresetDirList) SetSelected2(i int) {}
func (m *PresetDirList) Value(i int) string {
if i < 1 || i > len(m.presets.Dirs) {
return "---"
}
return m.presets.Dirs[i-1]
}
func (m *PresetDirList) SetSelected(i int) {
i = min(max(i, 0), len(m.presets.Dirs))
if i < 0 || i > len(m.presets.Dirs) {
return
}
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "d:")
if i > 0 {
m.d.PresetSearchString = "d:" + m.presets.Dirs[i-1] + " " + m.d.PresetSearchString
}
(*Model)(m).updateDerivedPresetSearch()
}
func (m *Model) PresetResultList() *PresetResultList { return (*PresetResultList)(m) }
func (v *PresetResultList) List() List { return List{v} }
func (m *PresetResultList) Count() int { return len(m.derived.presetSearch.results) }
func (m *PresetResultList) Selected() int {
return min(max(m.presetIndex, 0), len(m.derived.presetSearch.results)-1)
}
func (m *PresetResultList) Selected2() int { return m.Selected() }
func (m *PresetResultList) SetSelected2(i int) {}
func (m *PresetResultList) Value(i int) (name string, dir string, user bool) {
if i < 0 || i >= len(m.derived.presetSearch.results) {
return "", "", false
}
p := m.derived.presetSearch.results[i]
return p.Instr.Name, p.Directory, p.User
}
func (m *PresetResultList) SetSelected(i int) {
i = min(max(i, 0), len(m.derived.presetSearch.results)-1)
if i < 0 || i >= len(m.derived.presetSearch.results) {
return
}
m.presetIndex = i
defer (*Model)(m).change("LoadPreset", PatchChange, MinorChange)()
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())
}
newInstr := m.derived.presetSearch.results[i].Instr.Copy()
newInstr.NumVoices = clamp(m.d.Song.Patch[m.d.InstrIndex].NumVoices, 1, vm.MAX_VOICES)
(*Model)(m).assignUnitIDs(newInstr.Units)
m.d.Song.Patch[m.d.InstrIndex] = newInstr
}
func removeFilters(str string, prefix string) string {
parts := strings.Split(str, " ")
newParts := make([]string, 0, len(parts))
for _, part := range parts {
if !strings.HasPrefix(strings.ToLower(part), prefix) {
newParts = append(newParts, part)
}
}
return strings.Join(newParts, " ")
}
func (m *Model) SaveAsUserPreset() Action { return MakeAction((*SaveUserPreset)(m)) }
func (m *SaveUserPreset) Enabled() bool {
return m.d.InstrIndex >= 0 && m.d.InstrIndex < len(m.d.Song.Patch)
}
func (m *SaveUserPreset) Do() {
configDir, err := os.UserConfigDir()
if err != nil {
return
}
userPresetsDir := filepath.Join(configDir, "sointu", "presets", m.derived.presetSearch.dir)
instr := m.d.Song.Patch[m.d.InstrIndex]
name := instrumentNameToFilename(instr.Name)
fileName := filepath.Join(userPresetsDir, name+".yml")
// if exists, do not overwrite
if _, err := os.Stat(fileName); err == nil {
m.dialog = OverwriteUserPresetDialog
return
}
(*Model)(m).OverwriteUserPreset().Do()
}
func (m *Model) OverwriteUserPreset() Action { return MakeAction((*OverwriteUserPreset)(m)) }
func (m *OverwriteUserPreset) Enabled() bool { return true }
func (m *OverwriteUserPreset) Do() {
configDir, err := os.UserConfigDir()
if err != nil {
return
}
userPresetsDir := filepath.Join(configDir, "sointu", "presets", m.derived.presetSearch.dir)
instr := m.d.Song.Patch[m.d.InstrIndex]
name := instrumentNameToFilename(instr.Name)
fileName := filepath.Join(userPresetsDir, name+".yml")
os.MkdirAll(userPresetsDir, 0755)
data, err := yaml.Marshal(&instr)
if err != nil {
return
}
os.WriteFile(fileName, data, 0644)
m.dialog = NoDialog
(*Model)(m).presets.load()
(*Model)(m).updateDerivedPresetSearch()
}
func (m *Model) TryDeleteUserPreset() Action { return MakeAction((*TryDeleteUserPreset)(m)) }
func (m *TryDeleteUserPreset) Do() { m.dialog = DeleteUserPresetDialog }
func (m *TryDeleteUserPreset) Enabled() bool {
if m.presetIndex < 0 || m.presetIndex >= len(m.derived.presetSearch.results) {
return false
}
return m.derived.presetSearch.results[m.presetIndex].User
}
func (m *Model) DeleteUserPreset() Action { return MakeAction((*DeleteUserPreset)(m)) }
func (m *DeleteUserPreset) Enabled() bool { return (*Model)(m).TryDeleteUserPreset().Enabled() }
func (m *DeleteUserPreset) Do() {
configDir, err := os.UserConfigDir()
if err != nil {
return
}
p := m.derived.presetSearch.results[m.presetIndex]
userPresetsDir := filepath.Join(configDir, "sointu", "presets")
if p.Directory != "" {
userPresetsDir = filepath.Join(userPresetsDir, p.Directory)
}
name := instrumentNameToFilename(p.Instr.Name)
fileName := filepath.Join(userPresetsDir, name+".yml")
os.Remove(fileName)
m.dialog = NoDialog
(*Model)(m).presets.load()
(*Model)(m).updateDerivedPresetSearch()
}
// gmDlsEntryMap is a reverse map, to find the index of the GmDlsEntry in the
// GmDlsEntries list based on the sample offset. Do not modify during runtime.
var gmDlsEntryMap = make(map[vm.SampleOffset]int)
@ -130,97 +511,6 @@ type delayPreset struct {
varArgs []int
}
func (m *Model) IterateInstrumentPresets(yield InstrumentPresetYieldFunc) {
for index, instr := range instrumentPresets {
if !yield(index, instr.Name) {
return
}
}
}
func NumPresets() int {
return len(instrumentPresets)
}
// LoadPreset loads a preset from the list of instrument presets. The index
// should be within the range of 0 to NumPresets()-1.
func (m *Model) LoadPreset(index int) Action {
return MakeEnabledAction(LoadPreset{Index: index, Model: m})
}
func (m LoadPreset) Do() {
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())
}
newInstr := instrumentPresets[m.Index].Copy()
newInstr.NumVoices = clamp(m.d.Song.Patch[m.d.InstrIndex].NumVoices, 1, vm.MAX_VOICES)
m.Model.assignUnitIDs(newInstr.Units)
m.d.Song.Patch[m.d.InstrIndex] = newInstr
}
type instrumentPresetsSlice []sointu.Instrument
//go:embed presets/*
var instrumentPresetFS embed.FS
var instrumentPresets instrumentPresetsSlice
func init() {
fs.WalkDir(instrumentPresetFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(instrumentPresetFS, path)
if err != nil {
return nil
}
var instr sointu.Instrument
if yaml.UnmarshalStrict(data, &instr) == nil {
noExt := path[:len(path)-len(filepath.Ext(path))]
splitted := splitPath(noExt)
splitted = splitted[1:] // remove "presets" from the path
instr.Name = strings.Join(splitted, " ")
instrumentPresets = append(instrumentPresets, instr)
}
return nil
})
if configDir, err := os.UserConfigDir(); err == nil {
userPresets := filepath.Join(configDir, "sointu", "presets")
filepath.WalkDir(userPresets, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var instr sointu.Instrument
if yaml.Unmarshal(data, &instr) == nil {
if len(userPresets)+1 > len(path) {
return nil
}
subPath := path[len(userPresets)+1:]
noExt := subPath[:len(subPath)-len(filepath.Ext(subPath))]
splitted := splitPath(noExt)
instr.Name = strings.Join(splitted, " ")
instrumentPresets = append(instrumentPresets, instr)
}
return nil
})
}
sort.Sort(instrumentPresets)
}
func splitPath(path string) []string {
subPath := path
var result []string
@ -246,6 +536,11 @@ func splitPath(path string) []string {
return result
}
func (p instrumentPresetsSlice) Len() int { return len(p) }
func (p instrumentPresetsSlice) Less(i, j int) bool { return p[i].Name < p[j].Name }
func (p instrumentPresetsSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
func (p Presets) Len() int { return len(p.Presets) }
func (p Presets) Less(i, j int) bool {
if p.Presets[i].Instr.Name == p.Presets[j].Instr.Name {
return p.Presets[i].User && !p.Presets[j].User
}
return p.Presets[i].Instr.Name < p.Presets[j].Instr.Name
}
func (p Presets) Swap(i, j int) { p.Presets[i], p.Presets[j] = p.Presets[j], p.Presets[i] }

Some files were not shown because too many files have changed in this diff Show More