package gioui import ( "fmt" "gioui.org/x/component" "image" "image/color" "strconv" "strings" "gioui.org/io/event" "gioui.org/io/key" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" "gioui.org/widget" "github.com/vsariola/sointu/tracker" "golang.org/x/exp/shiny/materialdesign/icons" ) const trackRowHeight = unit.Dp(16) const trackColWidth = unit.Dp(54) const trackColTitleHeight = unit.Dp(16) const trackPatMarkWidth = unit.Dp(25) const trackRowMarkWidth = unit.Dp(25) var noteStr [256]string var hexStr [256]string func init() { // initialize these strings once, so we don't have to do it every time we draw the note editor hexStr[0] = "--" hexStr[1] = ".." noteStr[0] = "---" noteStr[1] = "..." for i := 2; i < 256; i++ { hexStr[i] = fmt.Sprintf("%02x", i) oNote := mod(i-baseNote, 12) octave := (i - oNote - baseNote) / 12 switch { case octave < 0: noteStr[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('Z'+1+octave))) case octave >= 10: noteStr[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('A'+octave-10))) default: noteStr[i] = fmt.Sprintf("%s%d", notes[oNote], octave) } } } type NoteEditor struct { TrackVoices *NumberInput NewTrackBtn *ActionClickable DeleteTrackBtn *ActionClickable SplitTrackBtn *ActionClickable AddSemitoneBtn *ActionClickable SubtractSemitoneBtn *ActionClickable AddOctaveBtn *ActionClickable SubtractOctaveBtn *ActionClickable NoteOffBtn *ActionClickable EffectBtn *BoolClickable UniqueBtn *BoolClickable TrackMidiInBtn *BoolClickable TrackForMidiVelIn *MenuClickable scrollTable *ScrollTable eventFilters []event.Filter deleteTrackHint string addTrackHint string uniqueOffTip, uniqueOnTip string splitTrackHint string } func NewNoteEditor(model *tracker.Model) *NoteEditor { ret := &NoteEditor{ TrackVoices: NewNumberInput(model.TrackVoices().Int()), NewTrackBtn: NewActionClickable(model.AddTrack()), DeleteTrackBtn: NewActionClickable(model.DeleteTrack()), SplitTrackBtn: NewActionClickable(model.SplitTrack()), AddSemitoneBtn: NewActionClickable(model.AddSemitone()), SubtractSemitoneBtn: NewActionClickable(model.SubtractSemitone()), AddOctaveBtn: NewActionClickable(model.AddOctave()), SubtractOctaveBtn: NewActionClickable(model.SubtractOctave()), NoteOffBtn: NewActionClickable(model.EditNoteOff()), EffectBtn: NewBoolClickable(model.Effect().Bool()), UniqueBtn: NewBoolClickable(model.UniquePatterns().Bool()), TrackMidiInBtn: NewBoolClickable(model.TrackMidiIn().Bool()), TrackForMidiVelIn: &MenuClickable{Selected: model.TrackForMidiVelIn().OptionalInt()}, scrollTable: NewScrollTable( model.Notes().Table(), model.Tracks().List(), model.NoteRows().List(), ), } for k, a := range keyBindingMap { if len(a) < 4 || a[:4] != "Note" { continue } ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret.scrollTable, Required: k.Modifiers, Name: k.Name}) } for c := 'A'; c <= 'F'; c++ { ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret.scrollTable, Name: key.Name(c)}) } for c := '0'; c <= '9'; c++ { ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret.scrollTable, Name: key.Name(c)}) } ret.deleteTrackHint = makeHint("Delete\ntrack", "\n(%s)", "DeleteTrack") ret.addTrackHint = makeHint("Add\ntrack", "\n(%s)", "AddTrack") ret.uniqueOnTip = makeHint("Duplicate non-unique patterns", " (%s)", "UniquePatternsToggle") ret.uniqueOffTip = makeHint("Allow editing non-unique patterns", " (%s)", "UniquePatternsToggle") ret.splitTrackHint = makeHint("Split track", " (%s)", "SplitTrack") return ret } func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions { for { e, ok := gtx.Event(te.eventFilters...) if !ok { break } switch e := e.(type) { case key.Event: if e.State == key.Release { if noteID, ok := t.KeyPlaying[e.Name]; ok { noteID.NoteOff() delete(t.KeyPlaying, e.Name) } continue } te.command(t, e) } } 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() return Surface{Gray: 24, Focus: te.scrollTable.Focused()}.Layout(gtx, func(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return te.layoutButtons(gtx, t) }), layout.Flexed(1, func(gtx C) D { return te.layoutTracks(gtx, t) }), ) }) } func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { return Surface{Gray: 37, Focus: te.scrollTable.Focused() || te.scrollTable.ChildFocused(), FitSize: true}.Layout(gtx, func(gtx C) D { addSemitoneBtnStyle := ActionButton(gtx, t.Theme, te.AddSemitoneBtn, "+1") subtractSemitoneBtnStyle := ActionButton(gtx, t.Theme, te.SubtractSemitoneBtn, "-1") addOctaveBtnStyle := ActionButton(gtx, t.Theme, te.AddOctaveBtn, "+12") subtractOctaveBtnStyle := ActionButton(gtx, t.Theme, te.SubtractOctaveBtn, "-12") noteOffBtnStyle := ActionButton(gtx, t.Theme, te.NoteOffBtn, "Note Off") deleteTrackBtnStyle := ActionIcon(gtx, t.Theme, te.DeleteTrackBtn, icons.ActionDelete, te.deleteTrackHint) splitTrackBtnStyle := ActionIcon(gtx, t.Theme, te.SplitTrackBtn, icons.CommunicationCallSplit, te.splitTrackHint) newTrackBtnStyle := ActionIcon(gtx, t.Theme, te.NewTrackBtn, icons.ContentAdd, te.addTrackHint) voiceUpDown := NumericUpDownPadded(t.Theme, te.TrackVoices, "Number of voices for this track", 1) effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex") uniqueBtnStyle := ToggleIcon(gtx, t.Theme, te.UniqueBtn, icons.ToggleStarBorder, icons.ToggleStar, te.uniqueOffTip, te.uniqueOnTip) midiInBtnStyle := ToggleButton(gtx, t.Theme, te.TrackMidiInBtn, "MIDI") midiInBtnStyle.Hidden = !t.HasAnyMidiInput() trackForMidiVelInSelector := te.layoutMidiVelInTrackSelector(t, " vel:") return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Dp(unit.Dp(12)), 0)} }), layout.Rigid(addSemitoneBtnStyle.Layout), layout.Rigid(subtractSemitoneBtnStyle.Layout), layout.Rigid(addOctaveBtnStyle.Layout), layout.Rigid(subtractOctaveBtnStyle.Layout), layout.Rigid(noteOffBtnStyle.Layout), layout.Rigid(effectBtnStyle.Layout), layout.Rigid(uniqueBtnStyle.Layout), layout.Rigid(Label(" Voices:", white, t.Theme.Shaper)), layout.Rigid(voiceUpDown.Layout), layout.Rigid(splitTrackBtnStyle.Layout), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Rigid(midiInBtnStyle.Layout), layout.Rigid(trackForMidiVelInSelector), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Rigid(deleteTrackBtnStyle.Layout), layout.Rigid(newTrackBtnStyle.Layout)) }) } func (te *NoteEditor) layoutMidiVelInTrackSelector(t *Tracker, label string) func(gtx C) D { if !t.HasAnyMidiInput() { return layout.Spacer{}.Layout } tracks := t.Model.Tracks().List() trackItems := make([]MenuItem, tracks.Count()+1) trackForMidiVelIn := t.Model.TrackForMidiVelIn() offText := "\u2014off\u2014" currentText := offText for i := range trackItems { trackItems[i] = MenuItem{ Text: offText, Doer: tracker.Check( func() { trackForMidiVelIn.OptionalInt().Set(i-1, i > 0) }, func() bool { return t.Model.CanUseTrackForMidiVelInput(i - 1) }, ), } if i > 0 { trackItems[i].Text = fmt.Sprintf("%d %s", i-1, t.Model.TrackTitle(i-1)) } if trackForMidiVelIn.OptionalInt().Equals(i-1, i > 0) { trackItems[i].IconBytes = icons.NavigationChevronRight if trackForMidiVelIn.IsValid() { currentText = trackItems[i].Text } } } return func(gtx C) D { tooltip := component.PlatformTooltip(t.Theme, "Record MIDI VEL into chosen track. This can not be one of the selected tracks (where MIDI Notes go).") return te.TrackForMidiVelIn.TipArea.Layout(gtx, tooltip, func(gtx C) D { return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Rigid(SizedLabel(label, white, t.Theme.Shaper, unit.Sp(12))), layout.Rigid(t.layoutMenu(gtx, currentText, &te.TrackForMidiVelIn.Clickable, &te.TrackForMidiVelIn.menu, unit.Dp(200), trackItems..., )), ) }) } } const baseNote = 24 var notes = []string{ "C-", "C#", "D-", "D#", "E-", "F-", "F#", "G-", "G#", "A-", "A#", "B-", } func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop() beatMarkerDensity := t.RowsPerBeat().Value() switch beatMarkerDensity { case 0, 1, 2: beatMarkerDensity = 4 } playSongRow := t.PlaySongRow() pxWidth := gtx.Dp(trackColWidth) pxHeight := gtx.Dp(trackRowHeight) pxPatMarkWidth := gtx.Dp(trackPatMarkWidth) pxRowMarkWidth := gtx.Dp(trackRowMarkWidth) colTitle := func(gtx C, i int) D { h := gtx.Dp(trackColTitleHeight) gtx.Constraints = layout.Exact(image.Pt(pxWidth, h)) LabelStyle{ Alignment: layout.N, Text: t.Model.TrackTitle(i), FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.Theme.Shaper, }.Layout(gtx) return D{Size: image.Pt(pxWidth, h)} } rowTitleBg := func(gtx C, j int) D { if mod(j, beatMarkerDensity*2) == 0 { paint.FillShape(gtx.Ops, twoBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op()) } else if mod(j, beatMarkerDensity) == 0 { paint.FillShape(gtx.Ops, oneBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op()) } if t.SongPanel.PlayingBtn.Bool.Value() && j == playSongRow { paint.FillShape(gtx.Ops, trackerPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op()) } return D{} } rowTitle := func(gtx C, j int) D { rpp := max(t.RowsPerPattern().Value(), 1) pat := j / rpp row := j % rpp w := pxPatMarkWidth + pxRowMarkWidth defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop() if row == 0 { color := rowMarkerPatternTextColor if l := t.Loop(); pat >= l.Start && pat < l.Start+l.Length { color = loopMarkerColor } paint.ColorOp{Color: color}.Add(gtx.Ops) widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", pat)), op.CallOp{}) } defer op.Offset(image.Pt(pxPatMarkWidth, 0)).Push(gtx.Ops).Pop() paint.ColorOp{Color: rowMarkerRowTextColor}.Add(gtx.Ops) widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", row)), op.CallOp{}) return D{Size: image.Pt(w, pxHeight)} } cursor := te.scrollTable.Table.Cursor() drawSelection := cursor != te.scrollTable.Table.Cursor2() selection := te.scrollTable.Table.Range() hasTrackMidiIn := te.TrackMidiInBtn.Bool.Value() cell := func(gtx C, x, y int) D { // draw the background, to indicate selection color := transparent point := tracker.Point{X: x, Y: y} if drawSelection && selection.Contains(point) { color = inactiveSelectionColor if te.scrollTable.Focused() { color = selectionColor } } paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op()) // draw the cursor if point == cursor { c := inactiveSelectionColor if te.scrollTable.Focused() { c = cursorColor } if hasTrackMidiIn { c = trackMidiInCurrentColor } te.paintColumnCell(gtx, x, t, c, hasTrackMidiIn) } // draw the corresponding "fake cursors" for instrument-track-groups (for polyphony) if hasTrackMidiIn && y == cursor.Y { for _, trackIndex := range t.Model.TracksWithSameInstrumentAsCurrent() { if x == trackIndex { te.paintColumnCell(gtx, x, t, trackMidiInAdditionalColor, hasTrackMidiIn) } } if t.Model.TrackForMidiVelIn().Equals(x) { te.paintColumnCell(gtx, x, t, trackMidiVelInColor, hasTrackMidiIn) } } // draw the pattern marker rpp := max(t.RowsPerPattern().Value(), 1) pat := y / rpp row := y % rpp defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop() s := t.Model.Order().Value(tracker.Point{X: x, Y: pat}) if row == 0 { // draw the pattern marker paint.ColorOp{Color: trackerPatMarker}.Add(gtx.Ops) widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, patternIndexToString(s), op.CallOp{}) } if row == 1 && t.Model.PatternUnique(x, s) { // draw a * if the pattern is unique paint.ColorOp{Color: mediumEmphasisTextColor}.Add(gtx.Ops) widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, "*", op.CallOp{}) } if te.scrollTable.Table.Cursor() == point && te.scrollTable.Focused() { paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops) } else { paint.ColorOp{Color: trackerInactiveTextColor}.Add(gtx.Ops) } val := noteStr[byte(t.Model.Notes().Value(tracker.Point{X: x, Y: y}))] if t.Model.Notes().Effect(x) { val = hexStr[byte(t.Model.Notes().Value(tracker.Point{X: x, Y: y}))] } widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, val, op.CallOp{}) return D{Size: image.Pt(pxWidth, pxHeight)} } table := FilledScrollTable(t.Theme, te.scrollTable, cell, colTitle, rowTitle, nil, rowTitleBg) table.RowTitleWidth = trackPatMarkWidth + trackRowMarkWidth table.ColumnTitleHeight = trackColTitleHeight table.CellWidth = trackColWidth table.CellHeight = trackRowHeight return table.Layout(gtx) } func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA, ignoreEffect bool) { cw := gtx.Constraints.Min.X cx := 0 if t.Model.Notes().Effect(x) && !ignoreEffect { cw /= 2 if t.Model.Notes().LowNibble() { cx += cw } } paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op()) } func mod(x, d int) int { x = x % d if x >= 0 { return x } if d < 0 { return x - d } return x + d } func noteAsValue(octave, note int) byte { return byte(baseNote + (octave * 12) + note) } func (te *NoteEditor) command(t *Tracker, e key.Event) { var n byte if t.Model.Notes().Effect(te.scrollTable.Table.Cursor().X) { if nibbleValue, err := strconv.ParseInt(string(e.Name), 16, 8); err == nil { t.Model.Notes().FillNibble(byte(nibbleValue), t.Model.Notes().LowNibble()) n = t.Model.Notes().Value(te.scrollTable.Table.Cursor()) te.finishNoteInsert(t, n, e.Name) } } else { action, ok := keyBindingMap[e] if !ok { return } if action == "NoteOff" { t.Model.Notes().Table().Fill(0) te.finishNoteInsert(t, 0, "") return } if action[:4] == "Note" { val, err := strconv.Atoi(string(action[4:])) if err != nil { return } n = noteAsValue(t.OctaveNumberInput.Int.Value(), val-12) t.Model.Notes().Table().Fill(int(n)) te.finishNoteInsert(t, n, e.Name) } } } func (te *NoteEditor) finishNoteInsert(t *Tracker, note byte, keyName key.Name) { if step := t.Model.Step().Value(); step > 0 { te.scrollTable.Table.MoveCursor(0, step) te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor()) } te.scrollTable.EnsureCursorVisible() if keyName == "" { return } if _, ok := t.KeyPlaying[keyName]; !ok { trk := te.scrollTable.Table.Cursor().X t.KeyPlaying[keyName] = t.TrackNoteOn(trk, note) } }