sointu/tracker/gioui/note_editor.go
2024-10-22 07:56:36 +03:00

444 lines
14 KiB
Go

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(unit.Dp(trackColTitleHeight))
title := ((*tracker.Order)(t.Model)).Title(i)
gtx.Constraints = layout.Exact(image.Pt(pxWidth, h))
LabelStyle{
Alignment: layout.N,
Text: title,
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 ((*tracker.Order)(t.Model)).TrackIndicesForCurrentInstrument() {
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.Notes().Unique(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 := (*tracker.Order)(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())
}
/*
case "+":
if e.Modifiers.Contain(key.ModShortcut) {
te.AddOctaveBtn.Action.Do()
} else {
te.AddSemitoneBtn.Action.Do()
}
case "-":
if e.Modifiers.Contain(key.ModShortcut) {
te.SubtractSemitoneBtn.Action.Do()
} else {
te.SubtractOctaveBtn.Action.Do()
}
}*/