diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb8611..c3f08af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). the command line tools. - If a parameter is controlled by a `send`, the slider is now colored differently and there's a tooltip over the value to see where it comes from and its amount + ([#176][p176]) +- If a parameter has an invalid value (for now only `port` of a `send`), + value is printed grey ([#176][p176]) +- "Multi-Unit View" to see all units as column next to each other ([#173][i173]) ### Fixed - We try to honor the MIDI event time stamps, so that the timing between MIDI @@ -75,6 +79,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). matches the compiled output better, as usually compiled intros output sound in floating point. This might be important if OS sound drivers apply some audio enhancemenets e.g. compressors to the audio. +- Performance improvement: derived model that is useful for the UI is cached + on each score/patch change instead of evaluated on each draw ([#176][p176]) ## [0.4.1] ### Added 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] +}