package gioui import ( "fmt" "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 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()), 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) in := layout.UniformInset(unit.Dp(1)) voiceUpDown := func(gtx C) D { numStyle := NumericUpDown(t.Theme, te.TrackVoices, "Number of voices for this track") return in.Layout(gtx, numStyle.Layout) } 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") 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.Rigid(splitTrackBtnStyle.Layout), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Rigid(midiInBtnStyle.Layout), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Rigid(deleteTrackBtnStyle.Layout), layout.Rigid(newTrackBtnStyle.Layout)) }) } 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{ Direction: 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 = cursorForTrackMidiInColor } te.paintColumnCell(gtx, x, t, c) } // draw the corresponding "fake cursors" for instrument-track-groups (for polyphony) if hasTrackMidiIn { for _, trackIndex := range t.Model.TracksWithSameInstrumentAsCurrent() { if x == trackIndex && y == cursor.Y { te.paintColumnCell(gtx, x, t, cursorNeighborForTrackMidiInColor) } } } // 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) { cw := gtx.Constraints.Min.X cx := 0 if t.Model.Notes().Effect(x) { 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) } } func (te *NoteEditor) HandleMidiInput(t *Tracker) { inputDeactivated := !t.Model.TrackMidiIn().Value() if inputDeactivated { return } te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor()) remaining := t.Model.CountNextTracksForCurrentInstrument() for i, note := range t.MidiNotePlaying { t.Model.Notes().Table().Set(note) te.scrollTable.Table.MoveCursor(1, 0) te.scrollTable.EnsureCursorVisible() if i >= remaining { break } } te.scrollTable.Table.SetCursor(te.scrollTable.Table.Cursor2()) }