sointu/tracker/gioui/instrumenteditor.go
5684185+vsariola@users.noreply.github.com beb06727b0 refactor: move UnitNames to top level package
2023-10-20 01:59:30 +03:00

579 lines
19 KiB
Go

package gioui
import (
"fmt"
"image"
"image/color"
"strconv"
"strings"
"time"
"gioui.org/io/clipboard"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"gioui.org/x/eventx"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker"
"github.com/vsariola/sointu/vm"
"golang.org/x/exp/shiny/materialdesign/icons"
"gopkg.in/yaml.v3"
)
type InstrumentEditor struct {
newInstrumentBtn *TipClickable
enlargeBtn *TipClickable
deleteInstrumentBtn *TipClickable
copyInstrumentBtn *TipClickable
saveInstrumentBtn *TipClickable
loadInstrumentBtn *TipClickable
addUnitBtn *TipClickable
commentExpandBtn *TipClickable
presetMenuBtn *TipClickable
commentEditor *widget.Editor
nameEditor *widget.Editor
unitTypeEditor *widget.Editor
instrumentDragList *DragList
instrumentScrollBar *ScrollBar
unitDragList *DragList
unitScrollBar *ScrollBar
confirmInstrDelete *Dialog
paramEditor *ParamEditor
stackUse []int
tag bool
wasFocused bool
commentExpanded bool
voiceLevels [vm.MAX_VOICES]float32
presetMenuItems []MenuItem
presetMenu Menu
}
func NewInstrumentEditor() *InstrumentEditor {
ret := &InstrumentEditor{
newInstrumentBtn: new(TipClickable),
enlargeBtn: new(TipClickable),
deleteInstrumentBtn: new(TipClickable),
copyInstrumentBtn: new(TipClickable),
saveInstrumentBtn: new(TipClickable),
loadInstrumentBtn: new(TipClickable),
addUnitBtn: new(TipClickable),
commentExpandBtn: new(TipClickable),
presetMenuBtn: new(TipClickable),
commentEditor: new(widget.Editor),
nameEditor: &widget.Editor{SingleLine: true, Submit: true, Alignment: text.Middle},
unitTypeEditor: &widget.Editor{SingleLine: true, Submit: true, Alignment: text.Start},
instrumentDragList: &DragList{List: &layout.List{Axis: layout.Horizontal}, HoverItem: -1},
instrumentScrollBar: &ScrollBar{Axis: layout.Horizontal},
unitDragList: &DragList{List: &layout.List{Axis: layout.Vertical}, HoverItem: -1},
unitScrollBar: &ScrollBar{Axis: layout.Vertical},
confirmInstrDelete: new(Dialog),
paramEditor: NewParamEditor(),
presetMenuItems: []MenuItem{},
}
for _, instr := range tracker.InstrumentPresets {
ret.presetMenuItems = append(ret.presetMenuItems, MenuItem{Text: instr.Name, IconBytes: icons.ImageAudiotrack})
}
return ret
}
func (t *InstrumentEditor) ExpandComment() {
t.commentExpanded = true
}
func (ie *InstrumentEditor) Focus() {
ie.unitDragList.Focus()
}
func (ie *InstrumentEditor) Focused() bool {
return ie.unitDragList.focused
}
func (ie *InstrumentEditor) ChildFocused() bool {
return ie.paramEditor.Focused() || ie.instrumentDragList.Focused() || ie.commentEditor.Focused() || ie.nameEditor.Focused() || ie.unitTypeEditor.Focused() ||
ie.addUnitBtn.Clickable.Focused() || ie.commentExpandBtn.Clickable.Focused() || ie.presetMenuBtn.Clickable.Focused() || ie.deleteInstrumentBtn.Clickable.Focused() || ie.copyInstrumentBtn.Clickable.Focused()
}
func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D {
ie.wasFocused = ie.Focused() || ie.ChildFocused()
for _, e := range gtx.Events(&ie.tag) {
switch e.(type) {
case pointer.Event:
ie.unitDragList.Focus()
}
}
rect := image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)
area := clip.Rect(rect).Push(gtx.Ops)
pointer.InputOp{Tag: &ie.tag,
Types: pointer.Press,
}.Add(gtx.Ops)
area.Pop()
enlargeTip := "Enlarge"
icon := icons.NavigationFullscreen
if t.InstrEnlarged() {
icon = icons.NavigationFullscreenExit
enlargeTip = "Shrink"
}
fullscreenBtnStyle := IconButton(t.Theme, ie.enlargeBtn, icon, true, enlargeTip)
for ie.enlargeBtn.Clickable.Clicked() {
t.SetInstrEnlarged(!t.InstrEnlarged())
}
for ie.newInstrumentBtn.Clickable.Clicked() {
t.AddInstrument(true)
}
octave := func(gtx C) D {
in := layout.UniformInset(unit.Dp(1))
t.OctaveNumberInput.Value = t.Octave()
numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, 0, 9, "Octave down (<) or up (>)")
dims := in.Layout(gtx, numStyle.Layout)
t.SetOctave(t.OctaveNumberInput.Value)
return dims
}
newBtnStyle := IconButton(t.Theme, ie.newInstrumentBtn, icons.ContentAdd, t.CanAddInstrument(), "Add\ninstrument")
ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Flex{}.Layout(
gtx,
layout.Flexed(1, func(gtx C) D {
return layout.Stack{}.Layout(gtx,
layout.Stacked(func(gtx C) D {
return ie.layoutInstrumentNames(gtx, t)
}),
layout.Expanded(func(gtx C) D {
return ie.instrumentScrollBar.Layout(gtx, unit.Dp(6), len(t.Song().Patch), &ie.instrumentDragList.List.Position)
}),
)
}),
layout.Rigid(func(gtx C) D {
inset := layout.UniformInset(unit.Dp(6))
return inset.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("OCT:", white)),
layout.Rigid(octave),
)
})
}),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, fullscreenBtnStyle.Layout)
}),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, newBtnStyle.Layout)
}),
)
}),
layout.Rigid(func(gtx C) D {
return ie.layoutInstrumentHeader(gtx, t)
}),
layout.Flexed(1, func(gtx C) D {
return ie.layoutInstrumentEditor(gtx, t)
}))
return ret
}
func (ie *InstrumentEditor) layoutInstrumentHeader(gtx C, t *Tracker) D {
header := func(gtx C) D {
collapseIcon := icons.NavigationExpandLess
commentTip := "Collapse comment"
if !ie.commentExpanded {
collapseIcon = icons.NavigationExpandMore
commentTip = "Expand comment"
}
commentExpandBtnStyle := IconButton(t.Theme, ie.commentExpandBtn, collapseIcon, true, commentTip)
presetMenuBtnStyle := IconButton(t.Theme, ie.presetMenuBtn, icons.NavigationMenu, true, "Load preset")
copyInstrumentBtnStyle := IconButton(t.Theme, ie.copyInstrumentBtn, icons.ContentContentCopy, true, "Copy instrument")
saveInstrumentBtnStyle := IconButton(t.Theme, ie.saveInstrumentBtn, icons.ContentSave, true, "Save instrument")
loadInstrumentBtnStyle := IconButton(t.Theme, ie.loadInstrumentBtn, icons.FileFolderOpen, true, "Load instrument")
deleteInstrumentBtnStyle := IconButton(t.Theme, ie.deleteInstrumentBtn, icons.ActionDelete, t.CanDeleteInstrument(), "Delete\ninstrument")
m := PopupMenu(t.Theme, &ie.presetMenu)
for item, clicked := ie.presetMenu.Clicked(); clicked; item, clicked = ie.presetMenu.Clicked() {
t.SetInstrument(tracker.InstrumentPresets[item])
}
header := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(Label("Voices: ", white)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
maxRemain := t.MaxInstrumentVoices()
t.InstrumentVoices.Value = t.Instrument().NumVoices
numStyle := NumericUpDown(t.Theme, t.InstrumentVoices, 0, maxRemain, "Number of voices for this instrument")
dims := numStyle.Layout(gtx)
t.SetInstrumentVoices(t.InstrumentVoices.Value)
return dims
}),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(commentExpandBtnStyle.Layout),
layout.Rigid(func(gtx C) D {
//defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
dims := presetMenuBtnStyle.Layout(gtx)
op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops)
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(500))
gtx.Constraints.Max.X = gtx.Dp(unit.Dp(180))
m.Layout(gtx, ie.presetMenuItems...)
return dims
}),
layout.Rigid(saveInstrumentBtnStyle.Layout),
layout.Rigid(loadInstrumentBtnStyle.Layout),
layout.Rigid(copyInstrumentBtnStyle.Layout),
layout.Rigid(deleteInstrumentBtnStyle.Layout))
}
for ie.presetMenuBtn.Clickable.Clicked() {
ie.presetMenu.Visible = true
}
for ie.commentExpandBtn.Clickable.Clicked() {
ie.commentExpanded = !ie.commentExpanded
if !ie.commentExpanded {
key.FocusOp{Tag: &ie.tag}.Add(gtx.Ops) // clear focus
}
}
if ie.commentExpanded || ie.commentEditor.Focused() { // we draw once the widget after it manages to lose focus
if ie.commentEditor.Text() != t.Instrument().Comment {
ie.commentEditor.SetText(t.Instrument().Comment)
}
editorStyle := material.Editor(t.Theme, ie.commentEditor, "Comment")
editorStyle.Color = highEmphasisTextColor
ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(header),
layout.Rigid(func(gtx C) D {
spy, spiedGtx := eventx.Enspy(gtx)
ret := layout.UniformInset(unit.Dp(6)).Layout(spiedGtx, editorStyle.Layout)
for _, group := range spy.AllEvents() {
for _, event := range group.Items {
switch e := event.(type) {
case key.Event:
if e.Name == key.NameEscape {
ie.instrumentDragList.Focus()
}
}
}
}
return ret
}),
)
t.SetInstrumentComment(ie.commentEditor.Text())
return ret
}
return header(gtx)
}
for ie.copyInstrumentBtn.Clickable.Clicked() {
contents, err := yaml.Marshal(t.Instrument())
if err == nil {
clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops)
t.Alert.Update("Instrument copied to clipboard", Notify, time.Second*3)
}
}
for ie.deleteInstrumentBtn.Clickable.Clicked() {
if t.CanDeleteInstrument() {
dialogStyle := ConfirmDialog(t.Theme, ie.confirmInstrDelete, "Are you sure you want to delete this instrument?")
ie.confirmInstrDelete.Visible = true
t.ModalDialog = dialogStyle.Layout
}
}
for ie.confirmInstrDelete.BtnOk.Clicked() {
t.DeleteInstrument(false)
t.ModalDialog = nil
}
for ie.confirmInstrDelete.BtnCancel.Clicked() {
t.ModalDialog = nil
}
for ie.saveInstrumentBtn.Clickable.Clicked() {
t.SaveInstrument()
}
for ie.loadInstrumentBtn.Clickable.Clicked() {
t.LoadInstrument()
}
return Surface{Gray: 37, Focus: ie.wasFocused}.Layout(gtx, header)
}
func (ie *InstrumentEditor) layoutInstrumentNames(gtx C, t *Tracker) D {
element := func(gtx C, i int) D {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(30))
grabhandle := LabelStyle{Text: "", ShadeColor: black, Color: white, FontSize: unit.Sp(10), Alignment: layout.Center}
if i == t.InstrIndex() {
grabhandle.Text = ":::"
}
label := func(gtx C) D {
c := float32(0.0)
voice := t.Song().Patch.FirstVoiceForInstrument(i)
loopMax := t.Song().Patch[i].NumVoices
if loopMax > vm.MAX_VOICES {
loopMax = vm.MAX_VOICES
}
for j := 0; j < loopMax; j++ {
vc := ie.voiceLevels[voice]
if c < vc {
c = vc
}
voice++
}
k := byte(255 - c*127)
color := color.NRGBA{R: 255, G: k, B: 255, A: 255}
if i == t.InstrIndex() {
for _, ev := range ie.nameEditor.Events() {
_, ok := ev.(widget.SubmitEvent)
if ok {
ie.instrumentDragList.Focus()
continue
}
}
if n := t.Instrument().Name; n != ie.nameEditor.Text() {
ie.nameEditor.SetText(n)
}
editor := material.Editor(t.Theme, ie.nameEditor, "Instr")
editor.Color = color
editor.HintColor = instrumentNameHintColor
editor.TextSize = unit.Sp(12)
dims := layout.Center.Layout(gtx, editor.Layout)
t.SetInstrumentName(ie.nameEditor.Text())
return dims
}
text := t.Song().Patch[i].Name
if text == "" {
text = "Instr"
}
labelStyle := LabelStyle{Text: text, ShadeColor: black, Color: color, FontSize: unit.Sp(12)}
return layout.Center.Layout(gtx, labelStyle.Layout)
}
return layout.Inset{Left: unit.Dp(6), Right: unit.Dp(6)}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(grabhandle.Layout),
layout.Rigid(label),
)
})
}
color := inactiveLightSurfaceColor
if ie.wasFocused {
color = activeLightSurfaceColor
}
instrumentList := FilledDragList(t.Theme, ie.instrumentDragList, len(t.Song().Patch), element, t.SwapInstruments)
instrumentList.SelectedColor = color
instrumentList.HoverColor = instrumentHoverColor
ie.instrumentDragList.SelectedItem = t.InstrIndex()
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
key.InputOp{Tag: ie.instrumentDragList, Keys: "↓|⏎|⌤"}.Add(gtx.Ops)
for _, event := range gtx.Events(ie.instrumentDragList) {
switch e := event.(type) {
case key.Event:
switch e.State {
case key.Press:
switch e.Name {
case key.NameDownArrow:
ie.unitDragList.Focus()
case key.NameReturn, key.NameEnter:
ie.nameEditor.Focus()
l := len(ie.nameEditor.Text())
ie.nameEditor.SetCaret(l, l)
}
}
}
}
dims := instrumentList.Layout(gtx)
if t.InstrIndex() != ie.instrumentDragList.SelectedItem {
t.SetInstrIndex(ie.instrumentDragList.SelectedItem)
op.InvalidateOp{}.Add(gtx.Ops)
}
return dims
}
func (ie *InstrumentEditor) layoutInstrumentEditor(gtx C, t *Tracker) D {
for ie.addUnitBtn.Clickable.Clicked() {
t.AddUnit(true)
ie.unitDragList.Focus()
}
addUnitBtnStyle := IconButton(t.Theme, ie.addUnitBtn, icons.ContentAdd, true, "Add unit (Ctrl+Enter)")
addUnitBtnStyle.IconButtonStyle.Color = t.Theme.ContrastFg
addUnitBtnStyle.IconButtonStyle.Background = t.Theme.Fg
addUnitBtnStyle.IconButtonStyle.Inset = layout.UniformInset(unit.Dp(4))
units := t.Instrument().Units
for len(ie.stackUse) < len(units) {
ie.stackUse = append(ie.stackUse, 0)
}
stackHeight := 0
for i, u := range units {
stackHeight += u.StackChange()
ie.stackUse[i] = stackHeight
}
element := func(gtx C, i int) D {
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(unit.Dp(120)), gtx.Dp(unit.Dp(20))))
u := units[i]
var color color.NRGBA = white
var stackText string
if i < len(ie.stackUse) {
stackText = strconv.FormatInt(int64(ie.stackUse[i]), 10)
var prevStackUse int
if i > 0 {
prevStackUse = ie.stackUse[i-1]
}
if stackNeed := u.StackNeed(); stackNeed > prevStackUse {
color = errorColor
typeString := u.Type
if u.Parameters["stereo"] == 1 {
typeString += " (stereo)"
}
t.Alert.Update(fmt.Sprintf("%v needs at least %v input signals, got %v", typeString, stackNeed, prevStackUse), Error, 0)
} else if i == len(units)-1 && ie.stackUse[i] != 0 {
color = warningColor
t.Alert.Update(fmt.Sprintf("Instrument leaves %v signal(s) on the stack", ie.stackUse[i]), Warning, 0)
}
}
var unitName layout.Widget
if i == t.UnitIndex() {
for _, ev := range ie.unitTypeEditor.Events() {
_, ok := ev.(widget.SubmitEvent)
if ok {
ie.unitDragList.Focus()
if text := ie.unitTypeEditor.Text(); text != "" {
for _, n := range sointu.UnitNames {
if strings.HasPrefix(n, ie.unitTypeEditor.Text()) {
t.SetUnitType(n)
break
}
}
} else {
t.SetUnitType("")
}
continue
}
}
if !ie.unitTypeEditor.Focused() && !ie.paramEditor.Focused() && ie.unitTypeEditor.Text() != t.Unit().Type {
ie.unitTypeEditor.SetText(t.Unit().Type)
}
editor := material.Editor(t.Theme, ie.unitTypeEditor, "---")
editor.Color = color
editor.HintColor = instrumentNameHintColor
editor.TextSize = unit.Sp(12)
editor.Font = labelDefaultFont
unitName = editor.Layout
} else {
unitNameLabel := LabelStyle{Text: u.Type, ShadeColor: black, Color: color, Font: labelDefaultFont, FontSize: unit.Sp(12)}
if unitNameLabel.Text == "" {
unitNameLabel.Text = "---"
}
unitName = unitNameLabel.Layout
}
stackLabel := LabelStyle{Text: stackText, ShadeColor: black, Color: mediumEmphasisTextColor, Font: labelDefaultFont, FontSize: unit.Sp(12)}
rightMargin := layout.Inset{Right: unit.Dp(10)}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Flexed(1, unitName),
layout.Rigid(func(gtx C) D {
return rightMargin.Layout(gtx, stackLabel.Layout)
}),
)
}
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
unitList := FilledDragList(t.Theme, ie.unitDragList, len(units), element, t.SwapUnits)
return Surface{Gray: 30, Focus: ie.wasFocused}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Stack{Alignment: layout.SE}.Layout(gtx,
layout.Expanded(func(gtx C) D {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
key.InputOp{Tag: ie.unitDragList, Keys: "→|⏎|⌫|⌦|⎋|Ctrl-⏎|Ctrl-C|Ctrl-X"}.Add(gtx.Ops)
for _, event := range gtx.Events(ie.unitDragList) {
switch e := event.(type) {
case key.Event:
switch e.State {
case key.Press:
switch e.Name {
case key.NameEscape:
ie.instrumentDragList.Focus()
case key.NameRightArrow:
ie.paramEditor.Focus()
case key.NameDeleteBackward:
t.SetUnitType("")
ie.unitTypeEditor.Focus()
l := len(ie.unitTypeEditor.Text())
ie.unitTypeEditor.SetCaret(l, l)
case key.NameDeleteForward:
t.DeleteUnits(true, ie.unitDragList.SelectedItem, ie.unitDragList.SelectedItem2)
ie.unitDragList.SelectedItem2 = t.UnitIndex()
case "X":
units := t.DeleteUnits(true, ie.unitDragList.SelectedItem, ie.unitDragList.SelectedItem2)
ie.unitDragList.SelectedItem2 = t.UnitIndex()
contents, err := yaml.Marshal(units)
if err == nil {
clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops)
t.Alert.Update("Unit(s) cut to clipboard", Notify, time.Second*3)
}
case "C":
a := clamp(ie.unitDragList.SelectedItem, 0, len(t.Instrument().Units)-1)
b := clamp(ie.unitDragList.SelectedItem2, 0, len(t.Instrument().Units)-1)
if a > b {
a, b = b, a
}
units := t.Instrument().Units[a : b+1]
contents, err := yaml.Marshal(units)
if err == nil {
clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops)
t.Alert.Update("Unit(s) copied to clipboard", Notify, time.Second*3)
}
case key.NameReturn:
if e.Modifiers.Contain(key.ModShortcut) {
t.AddUnit(true)
ie.unitDragList.SelectedItem2 = ie.unitDragList.SelectedItem
ie.unitTypeEditor.SetText("")
}
ie.unitTypeEditor.Focus()
l := len(ie.unitTypeEditor.Text())
ie.unitTypeEditor.SetCaret(l, l)
}
}
}
}
ie.unitDragList.SelectedItem = t.UnitIndex()
dims := unitList.Layout(gtx)
if t.UnitIndex() != ie.unitDragList.SelectedItem {
t.SetUnitIndex(ie.unitDragList.SelectedItem)
ie.unitTypeEditor.SetText(t.Unit().Type)
}
return dims
}),
layout.Stacked(func(gtx C) D {
margin := layout.Inset{Right: unit.Dp(20), Bottom: unit.Dp(1)}
return margin.Layout(gtx, addUnitBtnStyle.Layout)
}),
layout.Expanded(func(gtx C) D {
return ie.unitScrollBar.Layout(gtx, unit.Dp(10), len(t.Instrument().Units), &ie.unitDragList.List.Position)
}))
}),
layout.Rigid(ie.paramEditor.Bind(t)))
})
}
func clamp(i, min, max int) int {
if i < min {
return min
}
if i > max {
return max
}
return i
}