mirror of
https://github.com/vsariola/sointu.git
synced 2025-05-25 18:00:37 -04:00
444 lines
14 KiB
Go
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()
|
|
}
|
|
}*/
|