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

@@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- Preset explorer, whichs allows 1) searching the presets by name; 2) filtering
them by category (directory); 3) filtering them by being builtin vs. user;
4) filtering them if they need gm.dls (for Linux/Mac users, who don't have
it); and 5) saving and deleting user presets.
- Panic the synth if it outputs NaN or Inf, and handle these more gracefully in - Panic the synth if it outputs NaN or Inf, and handle these more gracefully in
the loudness and peak detector. ([#210][i210]) the loudness and peak detector. ([#210][i210])
- More presets from Reaby, and all new and existing presets were normalized - More presets from Reaby, and all new and existing presets were normalized

View File

@@ -11,22 +11,24 @@ type (
SetValue(bool) SetValue(bool)
} }
Panic Model Panic Model
IsRecording Model IsRecording Model
Playing Model Playing Model
InstrEnlarged Model InstrEnlarged Model
Effect Model Effect Model
TrackMidiIn Model TrackMidiIn Model
CommentExpanded Model Follow Model
Follow Model UnitSearching Model
UnitSearching Model UnitDisabled Model
UnitDisabled Model LoopToggle Model
LoopToggle Model UniquePatterns Model
UniquePatterns Model Mute Model
Mute Model Solo Model
Solo Model LinkInstrTrack Model
LinkInstrTrack Model Oversampling Model
Oversampling Model InstrEditor Model
InstrPresets Model
InstrComment Model
) )
func MakeBool(valueEnabler interface { 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) Value() bool { return m.instrEnlarged }
func (m *InstrEnlarged) SetValue(val bool) { m.instrEnlarged = val } func (m *InstrEnlarged) SetValue(val bool) { m.instrEnlarged = val }
// CommentExpanded methods // InstrEditor methods
func (m *Model) CommentExpanded() Bool { return MakeEnabledBool((*CommentExpanded)(m)) } func (m *Model) InstrEditor() Bool { return MakeEnabledBool((*InstrEditor)(m)) }
func (m *CommentExpanded) Value() bool { return m.commentExpanded } func (m *InstrEditor) Value() bool { return m.d.InstrumentTab == InstrumentEditorTab }
func (m *CommentExpanded) SetValue(val bool) { m.commentExpanded = val } 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 // Follow methods

View File

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

View File

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

View File

@@ -73,6 +73,13 @@ type (
Button 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 is a button with an icon.
IconButton struct { IconButton struct {
Theme *Theme 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 { func IconBtn(th *Theme, st *IconButtonStyle, c *Clickable, icon []byte, tip string) IconButton {
return IconButton{ return IconButton{
Theme: th, Theme: th,
@@ -288,6 +308,26 @@ func (b *ToggleIconButton) Layout(gtx C) D {
return b.IconButton.Layout(gtx) 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. // Click executes a simple programmatic click.
func (b *Clickable) Click() { func (b *Clickable) Click() {
b.requestClicks++ 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() { if e.widgetEditor.Text() != str.Value() {
e.widgetEditor.SetText(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 := material.Editor(&th.Material, &e.widgetEditor, hint)
me.Font = style.Font me.Font = style.Font

View File

@@ -8,6 +8,9 @@ import (
) )
type TagYieldFunc func(level int, tag event.Tag) bool 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 // 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 // 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 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" "image/color"
"io" "io"
"math" "math"
"strconv"
"strings"
"time" "time"
"gioui.org/f32" "gioui.org/f32"
@@ -16,6 +18,8 @@ import (
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint" "gioui.org/op/paint"
"gioui.org/text" "gioui.org/text"
"gioui.org/unit"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons" "golang.org/x/exp/shiny/materialdesign/icons"
"golang.org/x/text/cases" "golang.org/x/text/cases"
@@ -23,6 +27,18 @@ import (
) )
type ( type (
InstrumentEditor struct {
unitList UnitList
unitEditor UnitEditor
}
UnitList struct {
dragList *DragList
searchEditor *Editor
addUnitBtn *Clickable
addUnitAction tracker.Action
}
UnitEditor struct { UnitEditor struct {
paramTable *ScrollTable paramTable *ScrollTable
searchList *DragList 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 { func NewUnitEditor(m *tracker.Model) *UnitEditor {
ret := &UnitEditor{ ret := &UnitEditor{
DeleteUnitBtn: new(Clickable), 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 { if e, ok := e.(key.Event); ok && e.State == key.Press {
switch e.Name { switch e.Name {
case key.NameLeftArrow: case key.NameLeftArrow:
t.PatchPanel.unitList.dragList.Focus() t.PatchPanel.instrEditor.unitList.dragList.Focus()
case key.NameDeleteBackward: case key.NameDeleteBackward:
t.ClearUnit().Do() t.ClearUnit().Do()
t.UnitSearch().SetValue("") 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" "strings"
"gioui.org/io/clipboard" "gioui.org/io/clipboard"
"gioui.org/io/event"
"gioui.org/io/key" "gioui.org/io/key"
"github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/tracker"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
@@ -208,8 +209,6 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) {
t.InstrEnlarged().Toggle() t.InstrEnlarged().Toggle()
case "LinkInstrTrackToggle": case "LinkInstrTrackToggle":
t.LinkInstrTrack().Toggle() t.LinkInstrTrack().Toggle()
case "CommentExpandedToggle":
t.CommentExpanded().Toggle()
case "FollowToggle": case "FollowToggle":
t.Follow().Toggle() t.Follow().Toggle()
case "UnitDisabledToggle": case "UnitDisabledToggle":
@@ -259,13 +258,20 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) {
case "Paste": case "Paste":
gtx.Execute(clipboard.ReadCmd{Tag: t}) gtx.Execute(clipboard.ReadCmd{Tag: t})
case "OrderEditorFocus": case "OrderEditorFocus":
t.InstrEnlarged().SetValue(false)
gtx.Execute(key.FocusCmd{Tag: t.OrderEditor.scrollTable}) gtx.Execute(key.FocusCmd{Tag: t.OrderEditor.scrollTable})
case "TrackEditorFocus": case "TrackEditorFocus":
t.InstrEnlarged().SetValue(false)
gtx.Execute(key.FocusCmd{Tag: t.TrackEditor.scrollTable}) gtx.Execute(key.FocusCmd{Tag: t.TrackEditor.scrollTable})
case "InstrumentListFocus": case "InstrumentListFocus":
gtx.Execute(key.FocusCmd{Tag: t.PatchPanel.instrList.instrumentDragList}) gtx.Execute(key.FocusCmd{Tag: t.PatchPanel.instrList.instrumentDragList})
case "UnitListFocus": 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": case "FocusPrev":
t.FocusPrev(gtx, false) t.FocusPrev(gtx, false)
case "FocusPrevInto": case "FocusPrevInto":

View File

@@ -6,32 +6,42 @@ import (
"image/color" "image/color"
"io" "io"
"strconv" "strconv"
"strings"
"gioui.org/io/clipboard" "gioui.org/io/clipboard"
"gioui.org/io/event" "gioui.org/io/event"
"gioui.org/io/key" "gioui.org/io/key"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/text" "gioui.org/text"
"gioui.org/unit" "gioui.org/unit"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons" "golang.org/x/exp/shiny/materialdesign/icons"
) )
type ( type (
PatchPanel struct { PatchPanel struct {
instrList InstrumentList instrList InstrumentList
tools InstrumentTools tools InstrumentTools
unitList UnitList instrProps InstrumentProperties
unitEditor UnitEditor instrPresets InstrumentPresets
instrEditor InstrumentEditor
*tracker.Model
} }
InstrumentList struct { InstrumentList struct {
instrumentDragList *DragList instrumentDragList *DragList
nameEditor *Editor nameEditor *Editor
}
InstrumentTools struct {
EditorTab *Clickable
PresetsTab *Clickable
CommentTab *Clickable
saveInstrumentBtn *Clickable
loadInstrumentBtn *Clickable
copyInstrumentBtn *Clickable
deleteInstrumentBtn *Clickable
octave *NumericUpDownState octave *NumericUpDownState
enlargeBtn *Clickable enlargeBtn *Clickable
@@ -43,69 +53,58 @@ type (
linkEnabledHint string linkEnabledHint string
enlargeHint, shrinkHint string enlargeHint, shrinkHint string
addInstrumentHint 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 deleteInstrumentHint string
} }
UnitList struct {
dragList *DragList
searchEditor *Editor
addUnitBtn *Clickable
addUnitAction tracker.Action
}
) )
// PatchPanel methods // PatchPanel methods
func NewPatchPanel(model *tracker.Model) *PatchPanel { func NewPatchPanel(model *tracker.Model) *PatchPanel {
return &PatchPanel{ return &PatchPanel{
instrList: MakeInstrList(model), instrEditor: MakeInstrumentEditor(model),
tools: MakeInstrumentTools(model), instrList: MakeInstrList(model),
unitList: MakeUnitList(model), tools: MakeInstrumentTools(model),
unitEditor: *NewUnitEditor(model), instrProps: *NewInstrumentProperties(),
instrPresets: *NewInstrumentPresets(model),
Model: model,
} }
} }
func (pp *PatchPanel) Layout(gtx C) D { 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, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(pp.instrList.Layout), layout.Rigid(pp.instrList.Layout),
layout.Rigid(pp.tools.Layout), layout.Rigid(pp.tools.Layout),
layout.Flexed(1, func(gtx C) D { layout.Flexed(1, bottom),
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, )
layout.Rigid(pp.unitList.Layout), }
layout.Flexed(1, pp.unitEditor.Layout),
) 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 { func (pp *PatchPanel) Tags(level int, yield TagYieldFunc) bool {
return pp.instrList.Tags(level, yield) && return pp.instrList.Tags(level, yield) &&
pp.tools.Tags(level, yield) && pp.tools.Tags(level, yield) &&
pp.unitList.Tags(level, yield) && pp.BottomTags(level, yield)
pp.unitEditor.Tags(level, yield)
} }
// TreeFocused returns true if any of the tags in the patch panel is focused // 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 { func MakeInstrumentTools(m *tracker.Model) InstrumentTools {
ret := InstrumentTools{ ret := InstrumentTools{
Voices: NewNumericUpDownState(), EditorTab: new(Clickable),
PresetsTab: new(Clickable),
CommentTab: new(Clickable),
deleteInstrumentBtn: new(Clickable), deleteInstrumentBtn: new(Clickable),
splitInstrumentBtn: new(Clickable),
copyInstrumentBtn: new(Clickable), copyInstrumentBtn: new(Clickable),
saveInstrumentBtn: new(Clickable), saveInstrumentBtn: new(Clickable),
loadInstrumentBtn: 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"), deleteInstrumentHint: makeHint("Delete\ninstrument", "\n(%s)", "DeleteInstrument"),
muteHint: makeHint("Mute", " (%s)", "MuteToggle"), octave: NewNumericUpDownState(),
unmuteHint: makeHint("Unmute", " (%s)", "MuteToggle"), enlargeBtn: new(Clickable),
soloHint: makeHint("Solo", " (%s)", "SoloToggle"), linkInstrTrackBtn: new(Clickable),
unsoloHint: makeHint("Unsolo", " (%s)", "SoloToggle"), newInstrumentBtn: new(Clickable),
splitInstrumentHint: makeHint("Split instrument", " (%s)", "SplitInstrument"), octaveHint: makeHint("Octave down", " (%s)", "OctaveNumberInputSubtract") + makeHint(" or up", " (%s)", "OctaveNumberInputAdd"),
} linkDisabledHint: makeHint("Instrument-Track\nlinking disabled", "\n(%s)", "LinkInstrTrackToggle"),
for index, name := range m.IterateInstrumentPresets { linkEnabledHint: makeHint("Instrument-Track\nlinking enabled", "\n(%s)", "LinkInstrTrackToggle"),
ret.presetMenuItems = append(ret.presetMenuItems, MenuItem(m.LoadPreset(index), name, "", icons.ImageAudiotrack)) enlargeHint: makeHint("Enlarge", " (%s)", "InstrEnlargedToggle"),
shrinkHint: makeHint("Shrink", " (%s)", "InstrEnlargedToggle"),
addInstrumentHint: makeHint("Add\ninstrument", "\n(%s)", "AddInstrument"),
} }
return ret return ret
} }
@@ -150,55 +143,38 @@ func MakeInstrumentTools(m *tracker.Model) InstrumentTools {
func (it *InstrumentTools) Layout(gtx C) D { func (it *InstrumentTools) Layout(gtx C) D {
t := TrackerFromContext(gtx) t := TrackerFromContext(gtx)
it.update(gtx, t) it.update(gtx, t)
voicesLabel := Label(t.Theme, &t.Theme.InstrumentEditor.Voices, "Voices") editorBtn := TabBtn(t.Model.InstrEditor(), t.Theme, it.EditorTab, "Editor", "")
splitInstrumentBtn := ActionIconBtn(t.SplitInstrument(), t.Theme, it.splitInstrumentBtn, icons.CommunicationCallSplit, it.splitInstrumentHint) presetsBtn := TabBtn(t.Model.InstrPresets(), t.Theme, it.PresetsTab, "Presets", "")
commentExpandedBtn := ToggleIconBtn(t.CommentExpanded(), t.Theme, it.commentExpandBtn, icons.NavigationExpandMore, icons.NavigationExpandLess, it.expandCommentHint, it.collapseCommentHint) commentBtn := TabBtn(t.Model.InstrComment(), t.Theme, it.CommentTab, "Properties", "")
soloBtn := ToggleIconBtn(t.Solo(), t.Theme, it.soloBtn, icons.SocialGroup, icons.SocialPerson, it.soloHint, it.unsoloHint) octave := NumUpDown(t.Model.Octave(), t.Theme, t.OctaveNumberInput, "Octave")
muteBtn := ToggleIconBtn(t.Mute(), t.Theme, it.muteBtn, icons.AVVolumeUp, icons.AVVolumeOff, it.muteHint, it.unmuteHint) 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") 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") 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") 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) 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 { btns := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: 6}.Layout), layout.Rigid(layout.Spacer{Width: 6}.Layout),
layout.Rigid(voicesLabel.Layout), layout.Rigid(editorBtn.Layout),
layout.Rigid(layout.Spacer{Width: 4}.Layout), layout.Rigid(presetsBtn.Layout),
layout.Rigid(instrumentVoices.Layout), layout.Rigid(commentBtn.Layout),
layout.Rigid(splitInstrumentBtn.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(commentExpandedBtn.Layout), layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Rigid(soloBtn.Layout), layout.Rigid(Label(t.Theme, &t.Theme.InstrumentEditor.Octave, "Octave").Layout),
layout.Rigid(muteBtn.Layout), layout.Rigid(octave.Layout),
layout.Rigid(func(gtx C) D { layout.Rigid(linkInstrTrackBtn.Layout),
presetBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.presetMenuBtn, icons.NavigationMenu, "Load preset") layout.Rigid(instrEnlargedBtn.Layout),
dims := presetBtn.Layout(gtx) layout.Rigid(copyInstrumentBtn.Layout),
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(saveInstrumentBtn.Layout), layout.Rigid(saveInstrumentBtn.Layout),
layout.Rigid(loadInstrumentBtn.Layout), layout.Rigid(loadInstrumentBtn.Layout),
layout.Rigid(copyInstrumentBtn.Layout),
layout.Rigid(deleteInstrumentBtn.Layout), layout.Rigid(deleteInstrumentBtn.Layout),
layout.Rigid(addInstrumentBtn.Layout),
) )
} }
comment := func(gtx C) D { return Surface{Gray: 37, Focus: t.PatchPanel.TreeFocused(gtx)}.Layout(gtx, btns)
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)
})
} }
func (it *InstrumentTools) update(gtx C, tr *Tracker) { func (it *InstrumentTools) update(gtx C, tr *Tracker) {
@@ -222,18 +198,9 @@ func (it *InstrumentTools) update(gtx C, tr *Tracker) {
} }
tr.LoadInstrument(reader) 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 { func (it *InstrumentTools) Tags(level int, yield TagYieldFunc) bool {
if it.commentExpanded.Value() {
return yield(level+1, &it.commentEditor.widgetEditor)
}
return true return true
} }
@@ -243,41 +210,12 @@ func MakeInstrList(model *tracker.Model) InstrumentList {
return InstrumentList{ return InstrumentList{
instrumentDragList: NewDragList(model.Instruments().List(), layout.Horizontal), instrumentDragList: NewDragList(model.Instruments().List(), layout.Horizontal),
nameEditor: NewEditor(true, true, text.Middle), 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 { func (il *InstrumentList) Layout(gtx C) D {
t := TrackerFromContext(gtx) t := TrackerFromContext(gtx)
il.update(gtx, t) 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.Max.Y = gtx.Dp(36)
gtx.Constraints.Min.Y = gtx.Dp(36) gtx.Constraints.Min.Y = gtx.Dp(36)
element := func(gtx C, i int) D { 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 { if e, ok := event.(key.Event); ok && e.State == key.Press {
switch e.Name { switch e.Name {
case key.NameDownArrow: 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: case key.NameReturn, key.NameEnter:
il.nameEditor.Focus() il.nameEditor.Focus()
} }
@@ -351,132 +300,3 @@ func (il *InstrumentList) update(gtx C, t *Tracker) {
func (il *InstrumentList) Tags(level int, yield TagYieldFunc) bool { func (il *InstrumentList) Tags(level int, yield TagYieldFunc) bool {
return yield(level, il.instrumentDragList) 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 Text ButtonStyle
Disabled ButtonStyle Disabled ButtonStyle
Menu ButtonStyle Menu ButtonStyle
Tab struct {
Active ButtonStyle
Inactive ButtonStyle
IndicatorHeight unit.Dp
IndicatorColor color.NRGBA
}
} }
IconButton struct { IconButton struct {
Enabled IconButtonStyle Enabled IconButtonStyle
@@ -64,8 +70,10 @@ type Theme struct {
Preset MenuStyle Preset MenuStyle
} }
InstrumentEditor struct { InstrumentEditor struct {
Octave LabelStyle Octave LabelStyle
Voices LabelStyle Properties struct {
Label LabelStyle
}
InstrumentComment EditorStyle InstrumentComment EditorStyle
UnitComment EditorStyle UnitComment EditorStyle
InstrumentList struct { InstrumentList struct {
@@ -83,6 +91,15 @@ type Theme struct {
Warning color.NRGBA Warning color.NRGBA
Error color.NRGBA Error color.NRGBA
} }
Presets struct {
SearchBg color.NRGBA
Directory LabelStyle
Results struct {
Builtin LabelStyle
User LabelStyle
UserDir LabelStyle
}
}
} }
UnitEditor struct { UnitEditor struct {
Name LabelStyle Name LabelStyle

View File

@@ -54,6 +54,17 @@ button:
cornerradius: 0 cornerradius: 0
height: *buttonheight height: *buttonheight
inset: *buttoninset inset: *buttoninset
tab:
active: *textbutton
inactive:
background: *transparentcolor
color: *highemphasis
textsize: *buttontextsize
cornerradius: *buttoncornerradius
height: *buttonheight
inset: *buttoninset
indicatorheight: 2
indicatorcolor: *primarycolor
iconbutton: iconbutton:
enabled: enabled:
color: *primarycolor color: *primarycolor
@@ -153,7 +164,8 @@ menu:
height: 300 height: 300
instrumenteditor: instrumenteditor:
octave: { textsize: 14, color: *disabled } octave: { textsize: 14, color: *disabled }
voices: { textsize: 14, color: *disabled } properties:
label: { textsize: 14, color: *highemphasis }
instrumentcomment: instrumentcomment:
{ textsize: 14, color: *highemphasis, hintcolor: *disabled } { textsize: 14, color: *highemphasis, hintcolor: *disabled }
unitcomment: { textsize: 14, color: *highemphasis, hintcolor: *disabled } unitcomment: { textsize: 14, color: *highemphasis, hintcolor: *disabled }
@@ -178,6 +190,13 @@ instrumenteditor:
disabled: { textsize: 12, color: *disabled } disabled: { textsize: 12, color: *disabled }
warning: *warningcolor warning: *warningcolor
error: *errorcolor 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: cursor:
active: { r: 100, g: 140, b: 255, a: 48 } active: { r: 100, g: 140, b: 255, a: 48 }
activealt: { r: 255, g: 100, b: 140, 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()), DialogBtn("Close", t.Cancel()),
) )
dialog.Layout(gtx) 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 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 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 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 // Model methods

View File

@@ -36,14 +36,15 @@ type (
RecoveryFilePath string RecoveryFilePath string
ChangedSinceRecovery bool ChangedSinceRecovery bool
SendSource int SendSource int
InstrumentTab InstrumentTab
PresetSearchString string
} }
Model struct { Model struct {
d modelData d modelData
derived derivedModelData derived derivedModelData
instrEnlarged bool instrEnlarged bool
commentExpanded bool
prevUndoKind string prevUndoKind string
undoSkipCounter int undoSkipCounter int
@@ -84,6 +85,9 @@ type (
broker *Broker broker *Broker
MIDI MIDIContext MIDI MIDIContext
presets Presets
presetIndex int
} }
// Cursor identifies a row and a track in a song score. // Cursor identifies a row and a track in a song score.
@@ -129,6 +133,8 @@ type (
String() string String() string
Open() error Open() error
} }
InstrumentTab int
) )
const ( const (
@@ -159,6 +165,14 @@ const (
QuitChanges QuitChanges
QuitSaveExplorer QuitSaveExplorer
License License
DeleteUserPresetDialog
OverwriteUserPresetDialog
)
const (
InstrumentEditorTab InstrumentTab = iota
InstrumentPresetsTab
InstrumentCommentTab
) )
const maxUndo = 64 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 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.signalAnalyzer = NewScopeModel(broker, m.d.Song.BPM)
m.updateDeriveData(SongChange) m.updateDeriveData(SongChange)
m.presets.load()
m.updateDerivedPresetSearch()
return m 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("OrderRows", s.model.OrderRows().List(), yield, seed)
s.IterateList("NoteRows", s.model.NoteRows().List(), yield, seed) s.IterateList("NoteRows", s.model.NoteRows().List(), yield, seed)
s.IterateList("UnitSearchResults", s.model.SearchResults().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("Panic", s.model.Panic(), yield, seed)
s.IterateBool("Recording", s.model.IsRecording(), yield, seed) s.IterateBool("Recording", s.model.IsRecording(), yield, seed)
s.IterateBool("Playing", s.model.Playing(), yield, seed) s.IterateBool("Playing", s.model.Playing(), yield, seed)
s.IterateBool("InstrEnlarged", s.model.InstrEnlarged(), yield, seed) s.IterateBool("InstrEnlarged", s.model.InstrEnlarged(), yield, seed)
s.IterateBool("Effect", s.model.Effect(), 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("Follow", s.model.Follow(), yield, seed)
s.IterateBool("UniquePatterns", s.model.UniquePatterns(), yield, seed) s.IterateBool("UniquePatterns", s.model.UniquePatterns(), yield, seed)
s.IterateBool("LinkInstrTrack", s.model.LinkInstrTrack(), 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("DeleteOrderRowBackward", s.model.DeleteOrderRow(true), yield, seed)
s.IterateAction("SplitInstrument", s.model.SplitInstrument(), yield, seed) s.IterateAction("SplitInstrument", s.model.SplitInstrument(), yield, seed)
s.IterateAction("SplitTrack", s.model.SplitTrack(), 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 // Tables
s.IterateTable("Order", s.model.Order().Table(), yield, seed) s.IterateTable("Order", s.model.Order().Table(), yield, seed)
s.IterateTable("Notes", s.model.Notes().Table(), yield, seed) s.IterateTable("Notes", s.model.Notes().Table(), yield, seed)

View File

@@ -5,6 +5,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"slices" "slices"
"sort" "sort"
"strings" "strings"
@@ -17,6 +18,9 @@ import (
//go:generate go run generate/gmdls_entries.go //go:generate go run generate/gmdls_entries.go
//go:generate go run generate/clean_presets.go //go:generate go run generate/clean_presets.go
//go:embed presets/*
var instrumentPresetFS embed.FS
type ( type (
// GmDlsEntry is a single sample entry from the gm.dls file // GmDlsEntry is a single sample entry from the gm.dls file
GmDlsEntry struct { GmDlsEntry struct {
@@ -27,13 +31,390 @@ type (
Name string // sample Name 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) InstrumentPresetYieldFunc func(index int, item string) (ok bool)
LoadPreset struct { LoadPreset struct {
Index int Index int
*Model *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 // 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)
@@ -130,97 +511,6 @@ type delayPreset struct {
varArgs []int 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 { func splitPath(path string) []string {
subPath := path subPath := path
var result []string var result []string
@@ -246,6 +536,11 @@ func splitPath(path string) []string {
return result return result
} }
func (p instrumentPresetsSlice) Len() int { return len(p) } func (p Presets) Len() int { return len(p.Presets) }
func (p instrumentPresetsSlice) Less(i, j int) bool { return p[i].Name < p[j].Name } func (p Presets) Less(i, j int) bool {
func (p instrumentPresetsSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } 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