From 5be17432043ceb1ac778cbf855456a5125ad5e6e Mon Sep 17 00:00:00 2001 From: qm210 Date: Sun, 10 Nov 2024 23:23:06 +0100 Subject: [PATCH] feat: first draft of multi-unit view --- tracker/bool.go | 69 ++++++----- tracker/gioui/buttons.go | 9 +- tracker/gioui/draglist.go | 4 + tracker/gioui/instrument_editor.go | 16 ++- tracker/gioui/keyevent.go | 6 +- tracker/gioui/label.go | 14 ++- tracker/gioui/note_editor.go | 2 +- tracker/gioui/order_editor.go | 2 +- tracker/gioui/popup_alert.go | 2 +- tracker/gioui/unit_editor.go | 179 ++++++++++++++++++++------- tracker/list.go | 167 ------------------------- tracker/model.go | 3 +- tracker/params.go | 129 +++++++++++++------ tracker/units.go | 191 +++++++++++++++++++++++++++++ 14 files changed, 497 insertions(+), 296 deletions(-) create mode 100644 tracker/units.go diff --git a/tracker/bool.go b/tracker/bool.go index 3c32b5a..bd04f44 100644 --- a/tracker/bool.go +++ b/tracker/bool.go @@ -11,21 +11,22 @@ 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 + 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 + EnableMultiUnits Model ) func (v Bool) Toggle() { @@ -40,21 +41,22 @@ func (v Bool) Set(value bool) { // Model methods -func (m *Model) Panic() *Panic { return (*Panic)(m) } -func (m *Model) IsRecording() *IsRecording { return (*IsRecording)(m) } -func (m *Model) Playing() *Playing { return (*Playing)(m) } -func (m *Model) InstrEnlarged() *InstrEnlarged { return (*InstrEnlarged)(m) } -func (m *Model) Effect() *Effect { return (*Effect)(m) } -func (m *Model) TrackMidiIn() *TrackMidiIn { return (*TrackMidiIn)(m) } -func (m *Model) CommentExpanded() *CommentExpanded { return (*CommentExpanded)(m) } -func (m *Model) Follow() *Follow { return (*Follow)(m) } -func (m *Model) UnitSearching() *UnitSearching { return (*UnitSearching)(m) } -func (m *Model) UnitDisabled() *UnitDisabled { return (*UnitDisabled)(m) } -func (m *Model) LoopToggle() *LoopToggle { return (*LoopToggle)(m) } -func (m *Model) UniquePatterns() *UniquePatterns { return (*UniquePatterns)(m) } -func (m *Model) Mute() *Mute { return (*Mute)(m) } -func (m *Model) Solo() *Solo { return (*Solo)(m) } -func (m *Model) LinkInstrTrack() *LinkInstrTrack { return (*LinkInstrTrack)(m) } +func (m *Model) Panic() *Panic { return (*Panic)(m) } +func (m *Model) IsRecording() *IsRecording { return (*IsRecording)(m) } +func (m *Model) Playing() *Playing { return (*Playing)(m) } +func (m *Model) InstrEnlarged() *InstrEnlarged { return (*InstrEnlarged)(m) } +func (m *Model) Effect() *Effect { return (*Effect)(m) } +func (m *Model) TrackMidiIn() *TrackMidiIn { return (*TrackMidiIn)(m) } +func (m *Model) CommentExpanded() *CommentExpanded { return (*CommentExpanded)(m) } +func (m *Model) Follow() *Follow { return (*Follow)(m) } +func (m *Model) UnitSearching() *UnitSearching { return (*UnitSearching)(m) } +func (m *Model) UnitDisabled() *UnitDisabled { return (*UnitDisabled)(m) } +func (m *Model) LoopToggle() *LoopToggle { return (*LoopToggle)(m) } +func (m *Model) UniquePatterns() *UniquePatterns { return (*UniquePatterns)(m) } +func (m *Model) Mute() *Mute { return (*Mute)(m) } +func (m *Model) Solo() *Solo { return (*Solo)(m) } +func (m *Model) LinkInstrTrack() *LinkInstrTrack { return (*LinkInstrTrack)(m) } +func (m *Model) EnableMultiUnits() *EnableMultiUnits { return (*EnableMultiUnits)(m) } // Panic methods @@ -267,3 +269,10 @@ func (m *LinkInstrTrack) Bool() Bool { return Bool{m} } func (m *LinkInstrTrack) Value() bool { return m.linkInstrTrack } func (m *LinkInstrTrack) setValue(val bool) { m.linkInstrTrack = val } func (m *LinkInstrTrack) Enabled() bool { return true } + +// EnableMultiUnits methods + +func (m *EnableMultiUnits) Bool() Bool { return Bool{m} } +func (m *EnableMultiUnits) Value() bool { return m.enableMultiUnits } +func (m *EnableMultiUnits) setValue(val bool) { m.enableMultiUnits = val } +func (m *EnableMultiUnits) Enabled() bool { return true } diff --git a/tracker/gioui/buttons.go b/tracker/gioui/buttons.go index 024ab10..9a87f9c 100644 --- a/tracker/gioui/buttons.go +++ b/tracker/gioui/buttons.go @@ -168,6 +168,9 @@ type Clickable struct { history []widget.Press requestClicks int + + // optional callback for custom interactions + OnClick func() } // Click executes a simple programmatic click. @@ -177,7 +180,11 @@ func (b *Clickable) Click() { // Clicked calls Update and reports whether a click was registered. func (b *Clickable) Clicked(gtx layout.Context) bool { - return b.clicked(b, gtx) + clicked := b.clicked(b, gtx) + if clicked && b.OnClick != nil { + b.OnClick() + } + return clicked } func (b *Clickable) clicked(t event.Tag, gtx layout.Context) bool { diff --git a/tracker/gioui/draglist.go b/tracker/gioui/draglist.go index 44f8b6d..7c11f0e 100644 --- a/tracker/gioui/draglist.go +++ b/tracker/gioui/draglist.go @@ -31,6 +31,7 @@ type DragList struct { swapped bool focused bool requestFocus bool + onSelect func(index int) } type FilledDragListStyle struct { @@ -185,6 +186,9 @@ func (s FilledDragListStyle) Layout(gtx C) D { if !e.Modifiers.Contain(key.ModShift) { s.dragList.TrackerList.SetSelected2(index) } + if s.dragList.onSelect != nil { + s.dragList.onSelect(index) + } gtx.Execute(key.FocusCmd{Tag: s.dragList}) } } diff --git a/tracker/gioui/instrument_editor.go b/tracker/gioui/instrument_editor.go index ec5faad..fdb1938 100644 --- a/tracker/gioui/instrument_editor.go +++ b/tracker/gioui/instrument_editor.go @@ -105,6 +105,11 @@ func NewInstrumentEditor(model *tracker.Model) *InstrumentEditor { ret.linkDisabledHint = makeHint("Instrument-Track\nlinking disabled", "\n(%s)", "LinkInstrTrackToggle") ret.linkEnabledHint = makeHint("Instrument-Track\nlinking enabled", "\n(%s)", "LinkInstrTrackToggle") ret.splitInstrumentHint = makeHint("Split instrument", " (%s)", "SplitInstrument") + ret.unitDragList.onSelect = func(index int) { + if model.EnableMultiUnits().Value() { + ret.unitEditor.ScrollToUnit(index) + } + } return ret } @@ -117,7 +122,7 @@ func (ie *InstrumentEditor) Focused() bool { } func (ie *InstrumentEditor) childFocused(gtx C) bool { - return ie.unitEditor.sliderList.Focused() || + return ie.unitEditor.sliderColumns.Focused() || ie.instrumentDragList.Focused() || gtx.Source.Focused(ie.commentEditor) || gtx.Source.Focused(ie.nameEditor) || gtx.Source.Focused(ie.searchEditor) || gtx.Source.Focused(ie.addUnitBtn.Clickable) || gtx.Source.Focused(ie.commentExpandBtn.Clickable) || gtx.Source.Focused(ie.presetMenuBtn.Clickable) || gtx.Source.Focused(ie.deleteInstrumentBtn.Clickable) || gtx.Source.Focused(ie.copyInstrumentBtn.Clickable) @@ -277,7 +282,7 @@ func (ie *InstrumentEditor) layoutInstrumentList(gtx C, t *Tracker) D { element := func(gtx C, i int) D { gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36)) gtx.Constraints.Min.X = gtx.Dp(unit.Dp(30)) - grabhandle := LabelStyle{Text: strconv.Itoa(i + 1), ShadeColor: black, Color: mediumEmphasisTextColor, FontSize: unit.Sp(10), Alignment: layout.Center, Shaper: t.Theme.Shaper} + grabhandle := LabelStyle{Text: strconv.Itoa(i + 1), ShadeColor: black, Color: mediumEmphasisTextColor, FontSize: unit.Sp(10), Direction: layout.Center, Shaper: t.Theme.Shaper} label := func(gtx C) D { name, level, mute, ok := (*tracker.Instruments)(t.Model).Item(i) if !ok { @@ -375,9 +380,10 @@ func (ie *InstrumentEditor) layoutUnitList(gtx C, t *Tracker) D { addUnitBtnStyle.IconButtonStyle.Inset = layout.UniformInset(unit.Dp(4)) var units [256]tracker.UnitListItem - for i, item := range (*tracker.Units)(t.Model).Iterate { + for i, item := range t.Model.Units().Iterate { if i >= 256 { break + } units[i] = item } @@ -386,7 +392,7 @@ func (ie *InstrumentEditor) layoutUnitList(gtx C, t *Tracker) D { if ie.searchEditor.requestFocus { // for now, only the searchEditor has its requestFocus flag ie.searchEditor.requestFocus = false - gtx.Execute(key.FocusCmd{Tag: &ie.searchEditor.Editor}) + gtx.Execute(key.FocusCmd{Tag: ie.searchEditor.Editor}) } element := func(gtx C, i int) D { @@ -486,7 +492,7 @@ func (ie *InstrumentEditor) layoutUnitList(gtx C, t *Tracker) D { case key.NameEscape: ie.instrumentDragList.Focus() case key.NameRightArrow: - ie.unitEditor.sliderList.Focus() + ie.unitEditor.sliderColumns.Focus() case key.NameDeleteBackward: t.Units().SetSelectedType("") t.UnitSearching().Bool().Set(true) diff --git a/tracker/gioui/keyevent.go b/tracker/gioui/keyevent.go index b7e2f96..da81dbd 100644 --- a/tracker/gioui/keyevent.go +++ b/tracker/gioui/keyevent.go @@ -285,12 +285,12 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) { case "FocusPrev": switch { case t.OrderEditor.scrollTable.Focused(): - t.InstrumentEditor.unitEditor.sliderList.Focus() + t.InstrumentEditor.unitEditor.sliderColumns.Focus() case t.TrackEditor.scrollTable.Focused(): t.OrderEditor.scrollTable.Focus() case t.InstrumentEditor.Focused(): if t.InstrumentEditor.enlargeBtn.Bool.Value() { - t.InstrumentEditor.unitEditor.sliderList.Focus() + t.InstrumentEditor.unitEditor.sliderColumns.Focus() } else { t.TrackEditor.scrollTable.Focus() } @@ -304,7 +304,7 @@ func (t *Tracker) KeyEvent(e key.Event, gtx C) { case t.TrackEditor.scrollTable.Focused(): t.InstrumentEditor.Focus() case t.InstrumentEditor.Focused(): - t.InstrumentEditor.unitEditor.sliderList.Focus() + t.InstrumentEditor.unitEditor.sliderColumns.Focus() default: if t.InstrumentEditor.enlargeBtn.Bool.Value() { t.InstrumentEditor.Focus() diff --git a/tracker/gioui/label.go b/tracker/gioui/label.go index 1c2081a..1c9a4c6 100644 --- a/tracker/gioui/label.go +++ b/tracker/gioui/label.go @@ -17,14 +17,14 @@ type LabelStyle struct { Text string Color color.NRGBA ShadeColor color.NRGBA - Alignment layout.Direction + Direction layout.Direction Font font.Font FontSize unit.Sp Shaper *text.Shaper } func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions { - return l.Alignment.Layout(gtx, func(gtx C) D { + return l.Direction.Layout(gtx, func(gtx C) D { gtx.Constraints.Min = image.Point{} paint.ColorOp{Color: l.ShadeColor}.Add(gtx.Ops) offs := op.Offset(image.Pt(2, 2)).Push(gtx.Ops) @@ -46,5 +46,13 @@ func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions { } func Label(str string, color color.NRGBA, shaper *text.Shaper) layout.Widget { - return LabelStyle{Text: str, Color: color, ShadeColor: black, Font: labelDefaultFont, FontSize: labelDefaultFontSize, Alignment: layout.W, Shaper: shaper}.Layout + return LabelStyle{ + Text: str, + Color: color, + ShadeColor: black, + Font: labelDefaultFont, + FontSize: labelDefaultFontSize, + Direction: layout.W, + Shaper: shaper, + }.Layout } diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index 0006878..0eb49b3 100644 --- a/tracker/gioui/note_editor.go +++ b/tracker/gioui/note_editor.go @@ -223,7 +223,7 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { h := gtx.Dp(trackColTitleHeight) gtx.Constraints = layout.Exact(image.Pt(pxWidth, h)) LabelStyle{ - Alignment: layout.N, + Direction: layout.N, Text: t.Model.TrackTitle(i), FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, diff --git a/tracker/gioui/order_editor.go b/tracker/gioui/order_editor.go index 06b853d..7a823de 100644 --- a/tracker/gioui/order_editor.go +++ b/tracker/gioui/order_editor.go @@ -69,7 +69,7 @@ func (oe *OrderEditor) Layout(gtx C, t *Tracker) D { defer op.Affine(f32.Affine2D{}.Rotate(f32.Pt(0, 0), -90*math.Pi/180).Offset(f32.Point{X: 0, Y: float32(h)})).Push(gtx.Ops).Pop() gtx.Constraints = layout.Exact(image.Pt(1e6, 1e6)) LabelStyle{ - Alignment: layout.NW, + Direction: layout.NW, Text: t.Model.TrackTitle(i), FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, diff --git a/tracker/gioui/popup_alert.go b/tracker/gioui/popup_alert.go index 0971f0b..29c215c 100644 --- a/tracker/gioui/popup_alert.go +++ b/tracker/gioui/popup_alert.go @@ -55,7 +55,7 @@ func (a *PopupAlert) Layout(gtx C) D { }.Op()) return D{Size: gtx.Constraints.Min} } - labelStyle := LabelStyle{Text: alert.Message, Color: textColor, ShadeColor: shadeColor, Font: labelDefaultFont, Alignment: layout.Center, FontSize: unit.Sp(16), Shaper: a.shaper} + labelStyle := LabelStyle{Text: alert.Message, Color: textColor, ShadeColor: shadeColor, Font: labelDefaultFont, Direction: layout.Center, FontSize: unit.Sp(16), Shaper: a.shaper} alertMargin.Layout(gtx, func(gtx C) D { return layout.S.Layout(gtx, func(gtx C) D { defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() diff --git a/tracker/gioui/unit_editor.go b/tracker/gioui/unit_editor.go index bd65463..f04274a 100644 --- a/tracker/gioui/unit_editor.go +++ b/tracker/gioui/unit_editor.go @@ -25,46 +25,61 @@ import ( ) type UnitEditor struct { - sliderList *DragList + sliderRows []*DragList + sliderColumns *DragList searchList *DragList - Parameters []*ParameterWidget + Parameters [][]*ParameterWidget DeleteUnitBtn *ActionClickable CopyUnitBtn *TipClickable ClearUnitBtn *ActionClickable DisableUnitBtn *BoolClickable SelectTypeBtn *widget.Clickable + MultiUnitsBtn *BoolClickable commentEditor *Editor caser cases.Caser copyHint string disableUnitHint string enableUnitHint string + multiUnitsHint string + + totalWidthForUnit map[int]int + paramWidthForUnit map[int]int } func NewUnitEditor(m *tracker.Model) *UnitEditor { ret := &UnitEditor{ - DeleteUnitBtn: NewActionClickable(m.DeleteUnit()), - ClearUnitBtn: NewActionClickable(m.ClearUnit()), - DisableUnitBtn: NewBoolClickable(m.UnitDisabled().Bool()), - CopyUnitBtn: new(TipClickable), - SelectTypeBtn: new(widget.Clickable), - commentEditor: NewEditor(widget.Editor{SingleLine: true, Submit: true}), - sliderList: NewDragList(m.Params().List(), layout.Vertical), - searchList: NewDragList(m.SearchResults().List(), layout.Vertical), + DeleteUnitBtn: NewActionClickable(m.DeleteUnit()), + ClearUnitBtn: NewActionClickable(m.ClearUnit()), + DisableUnitBtn: NewBoolClickable(m.UnitDisabled().Bool()), + MultiUnitsBtn: NewBoolClickable(m.EnableMultiUnits().Bool()), + CopyUnitBtn: new(TipClickable), + SelectTypeBtn: new(widget.Clickable), + commentEditor: NewEditor(widget.Editor{SingleLine: true, Submit: true}), + sliderColumns: NewDragList(m.Units().List(), layout.Horizontal), + searchList: NewDragList(m.SearchResults().List(), layout.Vertical), + totalWidthForUnit: make(map[int]int), + paramWidthForUnit: make(map[int]int), } ret.caser = cases.Title(language.English) ret.copyHint = makeHint("Copy unit", " (%s)", "Copy") ret.disableUnitHint = makeHint("Disable unit", " (%s)", "UnitDisabledToggle") ret.enableUnitHint = makeHint("Enable unit", " (%s)", "UnitDisabledToggle") + ret.multiUnitsHint = "Toggle Multi-Unit View" + + ret.MultiUnitsBtn.Clickable.OnClick = func() { + ret.ScrollToUnit(m.Units().Selected()) + } + return ret } func (pe *UnitEditor) Layout(gtx C, t *Tracker) D { for { e, ok := gtx.Event( - key.Filter{Focus: pe.sliderList, Name: key.NameLeftArrow, Optional: key.ModShift}, - key.Filter{Focus: pe.sliderList, Name: key.NameRightArrow, Optional: key.ModShift}, - key.Filter{Focus: pe.sliderList, Name: key.NameEscape}, + key.Filter{Focus: pe.sliderColumns, Name: key.NameLeftArrow, Optional: key.ModShift}, + key.Filter{Focus: pe.sliderColumns, Name: key.NameRightArrow, Optional: key.ModShift}, + key.Filter{Focus: pe.sliderColumns, Name: key.NameEscape}, ) if !ok { break @@ -78,9 +93,9 @@ func (pe *UnitEditor) Layout(gtx C, t *Tracker) D { } defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop() - editorFunc := pe.layoutSliders + editorFunc := pe.layoutColumns - if t.UnitSearching().Value() || pe.sliderList.TrackerList.Count() == 0 { + if t.UnitSearching().Value() || pe.sliderColumns.TrackerList.Count() == 0 { editorFunc = pe.layoutUnitTypeChooser } return Surface{Gray: 24, Focus: t.InstrumentEditor.wasFocused}.Layout(gtx, func(gtx C) D { @@ -95,35 +110,85 @@ func (pe *UnitEditor) Layout(gtx C, t *Tracker) D { }) } -func (pe *UnitEditor) layoutSliders(gtx C, t *Tracker) D { - numItems := pe.sliderList.TrackerList.Count() - - for len(pe.Parameters) < numItems { - pe.Parameters = append(pe.Parameters, new(ParameterWidget)) +func (pe *UnitEditor) layoutColumns(gtx C, t *Tracker) D { + numUnits := pe.sliderColumns.TrackerList.Count() + for len(pe.Parameters) < numUnits { + pe.Parameters = append(pe.Parameters, []*ParameterWidget{}) + } + for u := len(pe.sliderRows); u < numUnits; u++ { + paramList := NewDragList(t.ParamsForUnit(u).List(), layout.Vertical) + pe.sliderRows = append(pe.sliderRows, paramList) } - index := 0 - for param := range t.Model.Params().Iterate { - pe.Parameters[index].Parameter = param - index++ + if !t.Model.EnableMultiUnits().Value() { + return pe.layoutSliderColumn(gtx, t, t.Model.Units().Selected(), false) } - element := func(gtx C, index int) D { - if index < 0 || index >= numItems { + + column := func(gtx C, index int) D { + if index < 0 || index > numUnits { return D{} } - paramStyle := t.ParamStyle(t.Theme, pe.Parameters[index]) - paramStyle.Focus = pe.sliderList.TrackerList.Selected() == index - dims := paramStyle.Layout(gtx) - return D{Size: image.Pt(gtx.Constraints.Max.X, dims.Size.Y)} + dims := pe.layoutSliderColumn(gtx, t, index, true) + return D{Size: image.Pt(dims.Size.X, gtx.Constraints.Max.Y)} } - - fdl := FilledDragList(t.Theme, pe.sliderList, element, nil) + fdl := FilledDragList(t.Theme, pe.sliderColumns, column, nil) dims := fdl.Layout(gtx) gtx.Constraints = layout.Exact(dims.Size) fdl.LayoutScrollBar(gtx) return dims } +func (pe *UnitEditor) layoutSliderColumn(gtx C, t *Tracker, u int, multiUnits bool) D { + numParams := 0 + for param := range t.Model.ParamsForUnit(u).Iterate { + for len(pe.Parameters[u]) < numParams+1 { + pe.Parameters[u] = append(pe.Parameters[u], new(ParameterWidget)) + } + + pe.Parameters[u][numParams].Parameter = param + numParams++ + } + + unitId := t.Model.Units().CurrentInstrumentUnitAt(u).ID + columnWidth := gtx.Constraints.Max.X + if multiUnits { + columnWidth = pe.totalWidthForUnit[unitId] + } + + element := func(gtx C, index int) D { + if index < 0 || index >= numParams { + return D{} + } + paramStyle := t.ParamStyle(t.Theme, pe.Parameters[u][index]) + paramStyle.Focus = pe.sliderRows[u].TrackerList.Selected() == index + dims := paramStyle.Layout(gtx, pe.paramWidthForUnit, unitId) + if multiUnits && pe.totalWidthForUnit[unitId] < dims.Size.X { + pe.totalWidthForUnit[unitId] = dims.Size.X + } + return D{Size: image.Pt(columnWidth, dims.Size.Y)} + } + + fdl := FilledDragList(t.Theme, pe.sliderRows[u], element, nil) + var dims D + if multiUnits { + name := buildUnitName(t.Model.Units().CurrentInstrumentUnitAt(u)) + dims = layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Flexed(1, fdl.Layout), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = columnWidth + gtx.Constraints.Min.Y = gtx.Sp(t.Theme.TextSize * 3) + return layout.Center.Layout(gtx, Label(name, primaryColor, t.Theme.Shaper)) + }), + ) + dims.Size.Y -= gtx.Dp(fdl.ScrollBarWidth) + } else { + dims = fdl.Layout(gtx) + } + gtx.Constraints = layout.Exact(dims.Size) + fdl.LayoutScrollBar(gtx) + return D{Size: image.Pt(columnWidth, dims.Size.Y)} +} + func (pe *UnitEditor) layoutFooter(gtx C, t *Tracker) D { for pe.CopyUnitBtn.Clickable.Clicked(gtx) { if contents, ok := t.Units().List().CopyElements(); ok { @@ -134,6 +199,7 @@ func (pe *UnitEditor) layoutFooter(gtx C, t *Tracker) D { copyUnitBtnStyle := TipIcon(t.Theme, pe.CopyUnitBtn, icons.ContentContentCopy, pe.copyHint) deleteUnitBtnStyle := ActionIcon(gtx, t.Theme, pe.DeleteUnitBtn, icons.ActionDelete, "Delete unit (Ctrl+Backspace)") disableUnitBtnStyle := ToggleIcon(gtx, t.Theme, pe.DisableUnitBtn, icons.AVVolumeUp, icons.AVVolumeOff, pe.disableUnitHint, pe.enableUnitHint) + multiUnitsBtnStyle := ToggleIcon(gtx, t.Theme, pe.MultiUnitsBtn, icons.ActionViewWeek, icons.ActionViewWeek, pe.multiUnitsHint, pe.multiUnitsHint) text := t.Units().SelectedType() if text == "" { text = "Choose unit type" @@ -172,6 +238,7 @@ func (pe *UnitEditor) layoutFooter(gtx C, t *Tracker) D { s.Set(pe.commentEditor.Text()) return ret }), + layout.Rigid(multiUnitsBtnStyle.Layout), ) } @@ -210,7 +277,7 @@ func (pe *UnitEditor) command(e key.Event, t *Tracker) { if sel == nil { return } - i := (&tracker.Int{IntData: sel}) + i := &tracker.Int{IntData: sel} if e.Modifiers.Contain(key.ModShift) { i.Set(i.Value() - sel.LargeStep()) } else { @@ -221,7 +288,7 @@ func (pe *UnitEditor) command(e key.Event, t *Tracker) { if sel == nil { return } - i := (&tracker.Int{IntData: sel}) + i := &tracker.Int{IntData: sel} if e.Modifiers.Contain(key.ModShift) { i.Set(i.Value() + sel.LargeStep()) } else { @@ -267,13 +334,25 @@ func (t *Tracker) ParamStyle(th *material.Theme, paramWidget *ParameterWidget) P } } -func (p ParameterStyle) Layout(gtx C) D { - isSendTarget, info := p.tryDerivedParameterInfo() +func spacer(px int) layout.FlexChild { + return layout.Rigid(func(gtx C) D { + return D{Size: image.Pt(px, px)} + }) +} + +func (p ParameterStyle) Layout(gtx C, paramWidthMap map[int]int, unitId int) D { + isSendTarget, info := p.tryDerivedParameterInfo(unitId) return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + spacer(24), layout.Rigid(func(gtx C) D { - gtx.Constraints.Min.X = gtx.Dp(unit.Dp(110)) - return layout.E.Layout(gtx, Label(p.w.Parameter.Name(), white, p.tracker.Theme.Shaper)) + dims := layout.E.Layout(gtx, Label(p.w.Parameter.Name(), white, p.tracker.Theme.Shaper)) + if paramWidthMap[unitId] < dims.Size.X { + paramWidthMap[unitId] = dims.Size.X + } + dims.Size.X = paramWidthMap[unitId] + return dims }), + spacer(8), layout.Rigid(func(gtx C) D { switch p.w.Parameter.Type() { case tracker.IntegerParameter: @@ -371,6 +450,7 @@ func (p ParameterStyle) Layout(gtx C) D { } return D{} }), + spacer(8), layout.Rigid(func(gtx C) D { if p.w.Parameter.Type() != tracker.IDParameter { color := white @@ -387,22 +467,31 @@ func (p ParameterStyle) Layout(gtx C) D { } return D{} }), + spacer(24), ) } func buildUnitLabel(index int, u sointu.Unit) string { - text := u.Type - if u.Comment != "" { - text = fmt.Sprintf("%s \"%s\"", text, u.Comment) - } - return fmt.Sprintf("%d: %s", index, text) + return fmt.Sprintf("%d: %s", index, buildUnitName(u)) } -func (p ParameterStyle) tryDerivedParameterInfo() (isSendTarget bool, sendInfo string) { +func buildUnitName(u sointu.Unit) string { + if u.Comment != "" { + return fmt.Sprintf("%s \"%s\"", u.Type, u.Comment) + } + return u.Type +} + +func (p ParameterStyle) tryDerivedParameterInfo(unitId int) (isSendTarget bool, sendInfo string) { param, ok := (p.w.Parameter).(tracker.NamedParameter) if !ok { return false, "" } - isSendTarget, sendInfo, _ = p.tracker.ParameterInfo(param.Unit().ID, param.Name()) + isSendTarget, sendInfo, _ = p.tracker.ParameterInfo(unitId, param.Name()) return isSendTarget, sendInfo } + +func (pe *UnitEditor) ScrollToUnit(index int) { + pe.sliderColumns.List.Position.First = index + pe.sliderColumns.List.Position.Offset = 0 +} diff --git a/tracker/list.go b/tracker/list.go index ee13c4a..ca5a786 100644 --- a/tracker/list.go +++ b/tracker/list.go @@ -35,22 +35,12 @@ type ( unmarshal([]byte) (r Range, err error) } - UnitListItem struct { - Type, Comment string - Disabled bool - StackNeed, StackBefore, StackAfter int - } - // Range is used to represent a range [Start,End) of integers Range struct { Start, End int } - UnitYieldFunc func(index int, item UnitListItem) (ok bool) - UnitSearchYieldFunc func(index int, item string) (ok bool) - Instruments Model // Instruments is a list of instruments, implementing ListData & MutableListData interfaces - Units Model // Units is a list of all the units in the selected instrument, implementing ListData & MutableListData interfaces Tracks Model // Tracks is a list of all the tracks, implementing ListData & MutableListData interfaces OrderRows Model // OrderRows is a list of all the order rows, implementing ListData & MutableListData interfaces NoteRows Model // NoteRows is a list of all the note rows, implementing ListData & MutableListData interfaces @@ -61,7 +51,6 @@ type ( // Model methods func (m *Model) Instruments() *Instruments { return (*Instruments)(m) } -func (m *Model) Units() *Units { return (*Units)(m) } func (m *Model) Tracks() *Tracks { return (*Tracks)(m) } func (m *Model) OrderRows() *OrderRows { return (*OrderRows)(m) } func (m *Model) NoteRows() *NoteRows { return (*NoteRows)(m) } @@ -257,162 +246,6 @@ func (m *Instruments) unmarshal(data []byte) (r Range, err error) { return r, nil } -// Units methods - -func (v *Units) List() List { - return List{v} -} - -func (m *Units) SelectedType() string { - if m.d.InstrIndex < 0 || - m.d.InstrIndex >= len(m.d.Song.Patch) || - m.d.UnitIndex < 0 || - m.d.UnitIndex >= len(m.d.Song.Patch[m.d.InstrIndex].Units) { - return "" - } - return m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex].Type -} - -func (m *Units) SetSelectedType(t string) { - if m.d.InstrIndex < 0 || - m.d.InstrIndex >= len(m.d.Song.Patch) { - return - } - if m.d.UnitIndex < 0 { - m.d.UnitIndex = 0 - } - for len(m.d.Song.Patch[m.d.InstrIndex].Units) <= m.d.UnitIndex { - m.d.Song.Patch[m.d.InstrIndex].Units = append(m.d.Song.Patch[m.d.InstrIndex].Units, sointu.Unit{}) - } - unit, ok := defaultUnits[t] - if !ok { // if the type is invalid, we just set it to empty unit - unit = sointu.Unit{Parameters: make(map[string]int)} - } else { - unit = unit.Copy() - } - oldUnit := m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex] - if oldUnit.Type == unit.Type { - return - } - defer m.change("SetSelectedType", MajorChange)() - m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex] = unit - m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex].ID = oldUnit.ID // keep the ID of the replaced unit -} - -func (v *Units) Iterate(yield UnitYieldFunc) { - if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) { - return - } - stackBefore := 0 - for i, unit := range v.d.Song.Patch[v.d.InstrIndex].Units { - stackAfter := stackBefore + unit.StackChange() - if !yield(i, UnitListItem{ - Type: unit.Type, - Comment: unit.Comment, - Disabled: unit.Disabled, - StackNeed: unit.StackNeed(), - StackBefore: stackBefore, - StackAfter: stackAfter, - }) { - break - } - stackBefore = stackAfter - } -} - -func (v *Units) Selected() int { - return max(min(v.d.UnitIndex, v.Count()-1), 0) -} - -func (v *Units) Selected2() int { - return max(min(v.d.UnitIndex2, v.Count()-1), 0) -} - -func (v *Units) SetSelected(value int) { - m := (*Model)(v) - m.d.UnitIndex = max(min(value, v.Count()-1), 0) - m.d.ParamIndex = 0 - m.d.UnitSearching = false - m.d.UnitSearchString = "" -} - -func (v *Units) SetSelected2(value int) { - (*Model)(v).d.UnitIndex2 = max(min(value, v.Count()-1), 0) -} - -func (v *Units) Count() int { - m := (*Model)(v) - if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { - return 0 - } - return len(m.d.Song.Patch[(*Model)(v).d.InstrIndex].Units) -} - -func (v *Units) move(r Range, delta int) (ok bool) { - m := (*Model)(v) - if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { - return false - } - units := m.d.Song.Patch[m.d.InstrIndex].Units - for i, j := range r.Swaps(delta) { - units[i], units[j] = units[j], units[i] - } - return true -} - -func (v *Units) delete(r Range) (ok bool) { - m := (*Model)(v) - if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { - return false - } - u := m.d.Song.Patch[m.d.InstrIndex].Units - m.d.Song.Patch[m.d.InstrIndex].Units = append(u[:r.Start], u[r.End:]...) - return true -} - -func (v *Units) change(n string, severity ChangeSeverity) func() { - return (*Model)(v).change("UnitListView."+n, PatchChange, severity) -} - -func (v *Units) cancel() { - (*Model)(v).changeCancel = true -} - -func (v *Units) marshal(r Range) ([]byte, error) { - m := (*Model)(v) - if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { - return nil, errors.New("UnitListView.marshal: no instruments") - } - units := m.d.Song.Patch[m.d.InstrIndex].Units[r.Start:r.End] - ret, err := yaml.Marshal(struct{ Units []sointu.Unit }{units}) - if err != nil { - return nil, fmt.Errorf("UnitListView.marshal: %v", err) - } - return ret, nil -} - -func (v *Units) unmarshal(data []byte) (r Range, err error) { - m := (*Model)(v) - if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { - return Range{}, errors.New("UnitListView.unmarshal: no instruments") - } - var pastedUnits struct{ Units []sointu.Unit } - if err := yaml.Unmarshal(data, &pastedUnits); err != nil { - return Range{}, fmt.Errorf("UnitListView.unmarshal: %v", err) - } - if len(pastedUnits.Units) == 0 { - return Range{}, errors.New("UnitListView.unmarshal: no units") - } - m.assignUnitIDs(pastedUnits.Units) - sel := v.Selected() - var ok bool - m.d.Song.Patch[m.d.InstrIndex].Units, ok = Insert(m.d.Song.Patch[m.d.InstrIndex].Units, sel, pastedUnits.Units...) - if !ok { - return Range{}, errors.New("UnitListView.unmarshal: insert failed") - } - return Range{sel, sel + len(pastedUnits.Units)}, nil -} - // Tracks methods func (v *Tracks) List() List { diff --git a/tracker/model.go b/tracker/model.go index 89dbf19..28ecebc 100644 --- a/tracker/model.go +++ b/tracker/model.go @@ -66,7 +66,8 @@ type ( // when linkInstrTrack is false, editing an instrument does not change // the track. when true, editing an instrument changes the tracks (e.g. // reordering or deleting instrument can delete track) - linkInstrTrack bool + linkInstrTrack bool + enableMultiUnits bool voiceLevels [vm.MAX_VOICES]float32 diff --git a/tracker/params.go b/tracker/params.go index ee699d3..681b0b1 100644 --- a/tracker/params.go +++ b/tracker/params.go @@ -41,6 +41,11 @@ type ( Params Model + ParamsForUnit struct { + *Params + unitIndex int + } + ParamYieldFunc func(param Parameter) bool ParameterType int @@ -61,6 +66,13 @@ const ( func (m *Model) Params() *Params { return (*Params)(m) } +func (m *Model) ParamsForUnit(u int) *ParamsForUnit { + return &ParamsForUnit{ + Params: m.Params(), + unitIndex: u, + } +} + // parameter methods func (p parameter) change(kind string) func() { @@ -80,14 +92,22 @@ func (pl *Params) change(n string, severity ChangeSeverity) func() { return (*Model)(pl).change("ParamList."+n, PatchChange, severity) } -func (pl *Params) Count() int { +func count(iterator func(yield ParamYieldFunc)) int { count := 0 - for range pl.Iterate { + for _ = range iterator { count++ } return count } +func (pl *Params) Count() int { + return count(pl.Iterate) +} + +func (pl *Params) CountInUnit(unitIndex int) int { + return count(pl.IterateInUnit(unitIndex)) +} + func (pl *Params) SelectedItem() (ret Parameter) { index := pl.Selected() for param := range pl.Iterate { @@ -100,55 +120,88 @@ func (pl *Params) SelectedItem() (ret Parameter) { } func (pl *Params) Iterate(yield ParamYieldFunc) { - if pl.d.InstrIndex < 0 || pl.d.InstrIndex >= len(pl.d.Song.Patch) { - return - } if pl.d.UnitIndex < 0 || pl.d.UnitIndex >= len(pl.d.Song.Patch[pl.d.InstrIndex].Units) { return } - unit := &pl.d.Song.Patch[pl.d.InstrIndex].Units[pl.d.UnitIndex] - unitType, ok := sointu.UnitTypes[unit.Type] - if !ok { - return - } - for i := range unitType { - if !unitType[i].CanSet { - continue - } - if unit.Type == "oscillator" && unit.Parameters["type"] != sointu.Sample && i >= 11 { - break // don't show the sample related params unless necessary - } - if !yield(NamedParameter{ - parameter: parameter{m: (*Model)(pl), unit: unit}, - up: &unitType[i], - }) { + pl.IterateInUnit(pl.d.UnitIndex)(yield) +} + +func (pl *Params) IterateInUnit(unitIndex int) func(yield ParamYieldFunc) { + return func(yield ParamYieldFunc) { + if pl.d.InstrIndex < 0 || pl.d.InstrIndex >= len(pl.d.Song.Patch) { return } - } - if unit.Type == "oscillator" && unit.Parameters["type"] == sointu.Sample { - if !yield(GmDlsEntryParameter{parameter: parameter{m: (*Model)(pl), unit: unit}}) { + unit := &pl.d.Song.Patch[pl.d.InstrIndex].Units[unitIndex] + unitType, ok := sointu.UnitTypes[unit.Type] + if !ok { return } - } - switch { - case unit.Type == "delay": - if unit.Parameters["stereo"] == 1 && len(unit.VarArgs)%2 == 1 { - unit.VarArgs = append(unit.VarArgs, 1) - } - if !yield(ReverbParameter{parameter: parameter{m: (*Model)(pl), unit: unit}}) { - return - } - if !yield(DelayLinesParameter{parameter: parameter{m: (*Model)(pl), unit: unit}}) { - return - } - for i := range unit.VarArgs { - if !yield(DelayTimeParameter{parameter: parameter{m: (*Model)(pl), unit: unit}, index: i}) { + for i := range unitType { + if !unitType[i].CanSet { + continue + } + if unit.Type == "oscillator" && unit.Parameters["type"] != sointu.Sample && i >= 11 { + break // don't show the sample related params unless necessary + } + if !yield(NamedParameter{ + parameter: parameter{m: (*Model)(pl), unit: unit}, + up: &unitType[i], + }) { return } } + if unit.Type == "oscillator" && unit.Parameters["type"] == sointu.Sample { + if !yield(GmDlsEntryParameter{parameter: parameter{m: (*Model)(pl), unit: unit}}) { + return + } + } + switch { + case unit.Type == "delay": + if unit.Parameters["stereo"] == 1 && len(unit.VarArgs)%2 == 1 { + unit.VarArgs = append(unit.VarArgs, 1) + } + if !yield(ReverbParameter{parameter: parameter{m: (*Model)(pl), unit: unit}}) { + return + } + if !yield(DelayLinesParameter{parameter: parameter{m: (*Model)(pl), unit: unit}}) { + return + } + for i := range unit.VarArgs { + if !yield(DelayTimeParameter{parameter: parameter{m: (*Model)(pl), unit: unit}, index: i}) { + return + } + } + } } } +// ParamsForUnit + +func (pu *ParamsForUnit) List() List { return List{pu} } +func (pu *ParamsForUnit) Selected2() int { return pu.Selected() } +func (pu *ParamsForUnit) SetSelected2(int) {} + +func (pu *ParamsForUnit) Selected() int { + if pu.unitIndex != pu.d.UnitIndex { + return -1 + } + return pu.d.ParamIndex +} + +func (pu *ParamsForUnit) SetSelected(value int) { + pu.d.ParamIndex = max(min(value, pu.Count()-1), 0) + pu.d.UnitIndex = pu.unitIndex + pu.d.UnitIndex2 = pu.unitIndex +} + +func (pu *ParamsForUnit) Count() int { + return count(pu.Iterate) +} + +func (pu *ParamsForUnit) Iterate(yield ParamYieldFunc) { + pu.Params.IterateInUnit(pu.unitIndex)(yield) +} + // NamedParameter func (p NamedParameter) Name() string { return p.up.Name } diff --git a/tracker/units.go b/tracker/units.go new file mode 100644 index 0000000..6b342ab --- /dev/null +++ b/tracker/units.go @@ -0,0 +1,191 @@ +package tracker + +import ( + "errors" + "fmt" + + "github.com/vsariola/sointu" + "gopkg.in/yaml.v2" +) + +type ( + UnitListItem struct { + Type, Comment string + Disabled bool + StackNeed, StackBefore, StackAfter int + } + + UnitYieldFunc func(index int, item UnitListItem) (ok bool) + UnitSearchYieldFunc func(index int, item string) (ok bool) + + Units Model // Units is a list of all the units in the selected instrument, implementing ListData & MutableListData interfaces + +) + +// Model methods + +func (m *Model) Units() *Units { return (*Units)(m) } + +// Units methods + +func (ul *Units) List() List { + return List{ul} +} + +func (ul *Units) SelectedType() string { + if ul.d.InstrIndex < 0 || + ul.d.InstrIndex >= len(ul.d.Song.Patch) || + ul.d.UnitIndex < 0 || + ul.d.UnitIndex >= len(ul.d.Song.Patch[ul.d.InstrIndex].Units) { + return "" + } + return ul.d.Song.Patch[ul.d.InstrIndex].Units[ul.d.UnitIndex].Type +} + +func (ul *Units) SetSelectedType(t string) { + if ul.d.InstrIndex < 0 || + ul.d.InstrIndex >= len(ul.d.Song.Patch) { + return + } + if ul.d.UnitIndex < 0 { + ul.d.UnitIndex = 0 + } + for len(ul.d.Song.Patch[ul.d.InstrIndex].Units) <= ul.d.UnitIndex { + ul.d.Song.Patch[ul.d.InstrIndex].Units = append(ul.d.Song.Patch[ul.d.InstrIndex].Units, sointu.Unit{}) + } + unit, ok := defaultUnits[t] + if !ok { // if the type is invalid, we just set it to empty unit + unit = sointu.Unit{Parameters: make(map[string]int)} + } else { + unit = unit.Copy() + } + oldUnit := ul.d.Song.Patch[ul.d.InstrIndex].Units[ul.d.UnitIndex] + if oldUnit.Type == unit.Type { + return + } + defer ul.change("SetSelectedType", MajorChange)() + ul.d.Song.Patch[ul.d.InstrIndex].Units[ul.d.UnitIndex] = unit + ul.d.Song.Patch[ul.d.InstrIndex].Units[ul.d.UnitIndex].ID = oldUnit.ID // keep the ID of the replaced unit +} + +func (ul *Units) Iterate(yield UnitYieldFunc) { + if ul.d.InstrIndex < 0 || ul.d.InstrIndex >= len(ul.d.Song.Patch) { + return + } + stackBefore := 0 + for i, unit := range ul.d.Song.Patch[ul.d.InstrIndex].Units { + stackAfter := stackBefore + unit.StackChange() + if !yield(i, UnitListItem{ + Type: unit.Type, + Comment: unit.Comment, + Disabled: unit.Disabled, + StackNeed: unit.StackNeed(), + StackBefore: stackBefore, + StackAfter: stackAfter, + }) { + break + } + stackBefore = stackAfter + } +} + +func (ul *Units) Selected() int { + return max(min(ul.d.UnitIndex, ul.Count()-1), 0) +} + +func (ul *Units) Selected2() int { + return max(min(ul.d.UnitIndex2, ul.Count()-1), 0) +} + +func (ul *Units) SetSelected(value int) { + m := (*Model)(ul) + m.d.UnitIndex = max(min(value, ul.Count()-1), 0) + m.d.ParamIndex = 0 + m.d.UnitSearching = false + m.d.UnitSearchString = "" +} + +func (ul *Units) SetSelected2(value int) { + (*Model)(ul).d.UnitIndex2 = max(min(value, ul.Count()-1), 0) +} + +func (ul *Units) Count() int { + m := (*Model)(ul) + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return 0 + } + return len(m.d.Song.Patch[(*Model)(ul).d.InstrIndex].Units) +} + +func (ul *Units) move(r Range, delta int) (ok bool) { + m := (*Model)(ul) + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return false + } + units := m.d.Song.Patch[m.d.InstrIndex].Units + for i, j := range r.Swaps(delta) { + units[i], units[j] = units[j], units[i] + } + return true +} + +func (ul *Units) delete(r Range) (ok bool) { + m := (*Model)(ul) + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return false + } + u := m.d.Song.Patch[m.d.InstrIndex].Units + m.d.Song.Patch[m.d.InstrIndex].Units = append(u[:r.Start], u[r.End:]...) + return true +} + +func (ul *Units) change(n string, severity ChangeSeverity) func() { + return (*Model)(ul).change("UnitListView."+n, PatchChange, severity) +} + +func (ul *Units) cancel() { + (*Model)(ul).changeCancel = true +} + +func (ul *Units) marshal(r Range) ([]byte, error) { + m := (*Model)(ul) + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return nil, errors.New("UnitListView.marshal: no instruments") + } + units := m.d.Song.Patch[m.d.InstrIndex].Units[r.Start:r.End] + ret, err := yaml.Marshal(struct{ Units []sointu.Unit }{units}) + if err != nil { + return nil, fmt.Errorf("UnitListView.marshal: %v", err) + } + return ret, nil +} + +func (ul *Units) unmarshal(data []byte) (r Range, err error) { + m := (*Model)(ul) + if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) { + return Range{}, errors.New("UnitListView.unmarshal: no instruments") + } + var pastedUnits struct{ Units []sointu.Unit } + if err := yaml.Unmarshal(data, &pastedUnits); err != nil { + return Range{}, fmt.Errorf("UnitListView.unmarshal: %v", err) + } + if len(pastedUnits.Units) == 0 { + return Range{}, errors.New("UnitListView.unmarshal: no units") + } + m.assignUnitIDs(pastedUnits.Units) + sel := ul.Selected() + var ok bool + m.d.Song.Patch[m.d.InstrIndex].Units, ok = Insert(m.d.Song.Patch[m.d.InstrIndex].Units, sel, pastedUnits.Units...) + if !ok { + return Range{}, errors.New("UnitListView.unmarshal: insert failed") + } + return Range{sel, sel + len(pastedUnits.Units)}, nil +} + +func (ul *Units) CurrentInstrumentUnitAt(index int) sointu.Unit { + units := ul.d.Song.Patch[ul.d.InstrIndex].Units + if index < 0 || index >= len(units) { + return sointu.Unit{} + } + return units[index] +}