diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc7e94..e5bbef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- User can define own keybindings in + `os.UserConfigDir()/sointu/keybindings.yaml` ([#94][i94], [#151][i151]) - A small number above the instrument name identifies the MIDI channel / instrument number, with numbering starting from 1 ([#154][i154]) - The filter unit frequency parameter is displayed in Hz, corresponding roughly @@ -224,6 +226,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). [0.1.0]: https://github.com/vsariola/sointu/compare/4klang-3.11...v0.1.0 [i65]: https://github.com/vsariola/sointu/issues/65 [i68]: https://github.com/vsariola/sointu/issues/68 +[i94]: https://github.com/vsariola/sointu/issues/94 [i112]: https://github.com/vsariola/sointu/issues/112 [i116]: https://github.com/vsariola/sointu/issues/116 [i120]: https://github.com/vsariola/sointu/issues/120 diff --git a/tracker/gioui/instrument_editor.go b/tracker/gioui/instrument_editor.go index 2172c8d..7241927 100644 --- a/tracker/gioui/instrument_editor.go +++ b/tracker/gioui/instrument_editor.go @@ -51,6 +51,13 @@ type InstrumentEditor struct { commentKeyFilters []event.Filter searchkeyFilters []event.Filter nameKeyFilters []event.Filter + + enlargeHint, shrinkHint string + addInstrumentHint string + octaveHint string + expandCommentHint string + collapseCommentHint string + deleteInstrumentHint string } func NewInstrumentEditor(model *tracker.Model) *InstrumentEditor { @@ -78,10 +85,13 @@ func NewInstrumentEditor(model *tracker.Model) *InstrumentEditor { ret.presetMenuItems = append(ret.presetMenuItems, MenuItem{Text: name, IconBytes: icons.ImageAudiotrack, Doer: model.LoadPreset(index)}) return true }) - for k := range noteMap { - ret.commentKeyFilters = append(ret.commentKeyFilters, key.Filter{Name: k, Focus: ret.commentEditor}) - ret.searchkeyFilters = append(ret.searchkeyFilters, key.Filter{Name: k, Focus: ret.searchEditor}) - ret.nameKeyFilters = append(ret.nameKeyFilters, key.Filter{Name: k, Focus: ret.nameEditor}) + for k, a := range keyBindingMap { + if len(a) < 4 || a[:4] != "Note" { + continue + } + ret.commentKeyFilters = append(ret.commentKeyFilters, key.Filter{Name: k.Name, Required: k.Modifiers, Focus: ret.commentEditor}) + ret.searchkeyFilters = append(ret.searchkeyFilters, key.Filter{Name: k.Name, Required: k.Modifiers, Focus: ret.searchEditor}) + ret.nameKeyFilters = append(ret.nameKeyFilters, key.Filter{Name: k.Name, Required: k.Modifiers, Focus: ret.nameEditor}) } ret.commentKeyFilters = append(ret.commentKeyFilters, key.Filter{Name: key.NameEscape, Focus: ret.commentEditor}) ret.searchkeyFilters = append(ret.searchkeyFilters, key.Filter{Name: key.NameEscape, Focus: ret.searchEditor}) @@ -89,6 +99,13 @@ func NewInstrumentEditor(model *tracker.Model) *InstrumentEditor { ret.commentKeyFilters = append(ret.commentKeyFilters, key.Filter{Name: key.NameSpace, Focus: ret.commentEditor}) ret.searchkeyFilters = append(ret.searchkeyFilters, key.Filter{Name: key.NameSpace, Focus: ret.searchEditor}) ret.nameKeyFilters = append(ret.nameKeyFilters, key.Filter{Name: key.NameSpace, Focus: ret.nameEditor}) + ret.enlargeHint = makeHint("Enlarge", " (%s)", "InstrEnlargedToggle") + ret.shrinkHint = makeHint("Shrink", " (%s)", "InstrEnlargedToggle") + ret.addInstrumentHint = makeHint("Add\ninstrument", "\n(%s)", "AddInstrument") + ret.octaveHint = makeHint("Octave down", " (%s)", "OctaveNumberInputSubtract") + makeHint(" or up", " (%s)", "OctaveNumberInputAdd") + ret.expandCommentHint = makeHint("Expand comment", " (%s)", "CommentExpandedToggle") + ret.collapseCommentHint = makeHint("Collapse comment", " (%s)", "CommentExpandedToggle") + ret.deleteInstrumentHint = makeHint("Delete\ninstrument", "\n(%s)", "DeleteInstrument") return ret } @@ -109,16 +126,16 @@ func (ie *InstrumentEditor) childFocused(gtx C) bool { func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D { ie.wasFocused = ie.Focused() || ie.childFocused(gtx) - fullscreenBtnStyle := ToggleIcon(gtx, t.Theme, ie.enlargeBtn, icons.NavigationFullscreen, icons.NavigationFullscreenExit, "Enlarge (Ctrl+E)", "Shrink (Ctrl+E)") + fullscreenBtnStyle := ToggleIcon(gtx, t.Theme, ie.enlargeBtn, icons.NavigationFullscreen, icons.NavigationFullscreenExit, ie.enlargeHint, ie.shrinkHint) octave := func(gtx C) D { in := layout.UniformInset(unit.Dp(1)) - numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, "Octave down (<) or up (>)") + numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, ie.octaveHint) dims := in.Layout(gtx, numStyle.Layout) return dims } - newBtnStyle := ActionIcon(gtx, t.Theme, ie.newInstrumentBtn, icons.ContentAdd, "Add\ninstrument\n(Ctrl+I)") + newBtnStyle := ActionIcon(gtx, t.Theme, ie.newInstrumentBtn, icons.ContentAdd, ie.addInstrumentHint) ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Flex{}.Layout( @@ -161,12 +178,12 @@ func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D { func (ie *InstrumentEditor) layoutInstrumentHeader(gtx C, t *Tracker) D { header := func(gtx C) D { - commentExpandBtnStyle := ToggleIcon(gtx, t.Theme, ie.commentExpandBtn, icons.NavigationExpandMore, icons.NavigationExpandLess, "Expand comment", "Collapse comment") + commentExpandBtnStyle := ToggleIcon(gtx, t.Theme, ie.commentExpandBtn, icons.NavigationExpandMore, icons.NavigationExpandLess, ie.expandCommentHint, ie.collapseCommentHint) presetMenuBtnStyle := TipIcon(t.Theme, ie.presetMenuBtn, icons.NavigationMenu, "Load preset") copyInstrumentBtnStyle := TipIcon(t.Theme, ie.copyInstrumentBtn, icons.ContentContentCopy, "Copy instrument") saveInstrumentBtnStyle := TipIcon(t.Theme, ie.saveInstrumentBtn, icons.ContentSave, "Save instrument") loadInstrumentBtnStyle := TipIcon(t.Theme, ie.loadInstrumentBtn, icons.FileFolderOpen, "Load instrument") - deleteInstrumentBtnStyle := ActionIcon(gtx, t.Theme, ie.deleteInstrumentBtn, icons.ActionDelete, "Delete\ninstrument") + deleteInstrumentBtnStyle := ActionIcon(gtx, t.Theme, ie.deleteInstrumentBtn, icons.ActionDelete, ie.deleteInstrumentHint) m := PopupMenu(&ie.presetMenu, t.Theme.Shaper) diff --git a/tracker/gioui/keybindings.yaml b/tracker/gioui/keybindings.yaml new file mode 100644 index 0000000..f8732f8 --- /dev/null +++ b/tracker/gioui/keybindings.yaml @@ -0,0 +1,78 @@ +- {key: "C", shortcut: true, action: "Copy"} +- {key: "V", shortcut: true, action: "Paste"} +- {key: "A", shortcut: true, action: "SelectAll"} +- {key: "X", shortcut: true, action: "Cut"} +- {key: "Z", shortcut: true, action: "Undo"} +- {key: "Y", shortcut: true, action: "Redo"} +- {key: "D", shortcut: true, action: "UnitDisabledToggle"} +- {key: "L", shortcut: true, action: "LoopToggle"} +- {key: "N", shortcut: true, action: "NewSong"} +- {key: "S", shortcut: true, action: "SaveSong"} +- {key: "O", shortcut: true, action: "OpenSong"} +- {key: "I", shortcut: true, shift: true, action: "DeleteInstrument"} +- {key: "I", shortcut: true, action: "AddInstrument"} +- {key: "T", shortcut: true, shift: true, action: "DeleteTrack"} +- {key: "T", shortcut: true, action: "AddTrack"} +- {key: "E", shortcut: true, action: "InstrEnlargedToggle"} +- {key: "W", shortcut: true, action: "Quit"} +- {key: "Space", action: "PlayingToggleUnfollow"} +- {key: "Space", shift: true, action: "PlayingToggleFollow"} +- {key: "F1", action: "OrderEditorFocus"} +- {key: "F2", action: "TrackEditorFocus"} +- {key: "F3", action: "InstrumentEditorFocus"} +- {key: "F5", action: "PlayCurrentPosUnfollow"} +- {key: "F5", shift: true, action: "PlayCurrentPosFollow"} +- {key: "F5", shortcut: true, action: "PlaySongStartUnfollow"} +- {key: "F5", shortcut: true, shift: true, action: "PlaySongStartFollow"} +- {key: "F6", action: "PlaySelectedUnfollow"} +- {key: "F6", shift: true, action: "PlaySelectedFollow"} +- {key: "F6", shortcut: true, action: "PlayLoopUnfollow"} +- {key: "F6", shortcut: true, shift: true, action: "PlayLoopFollow"} +- {key: "F7", action: "RecordingToggle"} +- {key: "F8", action: "StopPlaying"} +- {key: "F9", action: "FollowToggle"} +- {key: "F12", action: "PanicToggle"} +- {key: "\\", shift: true, action: "OctaveAdd"} +- {key: "\\", action: "OctaveSubtract"} +- {key: ">", shift: true, action: "OctaveAdd"} +- {key: ">", action: "OctaveSubtract"} +- {key: "<", shift: true, action: "OctaveAdd"} +- {key: "<", action: "OctaveSubtract"} +- {key: "Tab", shift: true, action: "FocusPrev"} +- {key: "Tab", action: "FocusNext"} +- {key: "A", action: "NoteOff"} +- {key: "1", action: "NoteOff"} +- {key: "Z", action: "Note0"} +- {key: "S", action: "Note1"} +- {key: "X", action: "Note2"} +- {key: "D", action: "Note3"} +- {key: "C", action: "Note4"} +- {key: "V", action: "Note5"} +- {key: "G", action: "Note6"} +- {key: "B", action: "Note7"} +- {key: "H", action: "Note8"} +- {key: "N", action: "Note9"} +- {key: "J", action: "Note10"} +- {key: "M", action: "Note11"} +- {key: ",", action: "Note12"} +- {key: "L", action: "Note13"} +- {key: ".", action: "Note14"} +- {key: "Q", action: "Note12"} +- {key: "2", action: "Note13"} +- {key: "W", action: "Note14"} +- {key: "3", action: "Note15"} +- {key: "E", action: "Note16"} +- {key: "R", action: "Note17"} +- {key: "5", action: "Note18"} +- {key: "T", action: "Note19"} +- {key: "6", action: "Note20"} +- {key: "Y", action: "Note21"} +- {key: "7", action: "Note22"} +- {key: "U", action: "Note23"} +- {key: "I", action: "Note24"} +- {key: "9", action: "Note25"} +- {key: "O", action: "Note26"} +- {key: "0", action: "Note27"} +- {key: "P", action: "Note28"} +- {key: "+", action: "Increase"} +- {key: "-", action: "Decrease"} diff --git a/tracker/gioui/keyevent.go b/tracker/gioui/keyevent.go index 35b2e59..82b8fda 100644 --- a/tracker/gioui/keyevent.go +++ b/tracker/gioui/keyevent.go @@ -1,211 +1,314 @@ package gioui import ( + _ "embed" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "gioui.org/io/clipboard" "gioui.org/io/key" + "gopkg.in/yaml.v2" ) -var noteMap = map[key.Name]int{ - "Z": -12, - "S": -11, - "X": -10, - "D": -9, - "C": -8, - "V": -7, - "G": -6, - "B": -5, - "H": -4, - "N": -3, - "J": -2, - "M": -1, - ",": 0, - "L": 1, - ".": 2, - "Q": 0, - "2": 1, - "W": 2, - "3": 3, - "E": 4, - "R": 5, - "5": 6, - "T": 7, - "6": 8, - "Y": 9, - "7": 10, - "U": 11, - "I": 12, - "9": 13, - "O": 14, - "0": 15, - "P": 16, +type ( + KeyAction string + + KeyBinding struct { + Key string + Shortcut, Ctrl, Command, Shift, Alt, Super bool + Action string + } +) + +var keyBindingMap = map[key.Event]string{} +var keyActionMap = map[KeyAction]string{} // holds an informative string of the first key bound to an action + +func loadCustomKeyBindings() []KeyBinding { + var keyBindings []KeyBinding + configDir, err := os.UserConfigDir() + if err != nil { + return nil + } + path := filepath.Join(configDir, "sointu", "keybindings.yaml") + bytes, err := os.ReadFile(path) + if err != nil { + return nil + } + err = yaml.Unmarshal(bytes, &keyBindings) + if err != nil { + return nil + } + if len(keyBindings) == 0 { + return nil + } + return keyBindings +} + +//go:embed keybindings.yaml +var defaultKeyBindingsYaml []byte + +func loadDefaultKeyBindings() []KeyBinding { + var keyBindings []KeyBinding + err := yaml.Unmarshal(defaultKeyBindingsYaml, &keyBindings) + if err != nil { + panic(fmt.Errorf("failed to unmarshal keybindings: %w", err)) + } + return keyBindings +} + +func init() { + keyBindings := loadCustomKeyBindings() + if keyBindings == nil { + keyBindings = loadDefaultKeyBindings() + } + for _, kb := range keyBindings { + var mods key.Modifiers + if kb.Shortcut { + mods |= key.ModShortcut + } + if kb.Ctrl { + mods |= key.ModCtrl + } + if kb.Command { + mods |= key.ModCommand + } + if kb.Shift { + mods |= key.ModShift + } + if kb.Alt { + mods |= key.ModAlt + } + if kb.Super { + mods |= key.ModSuper + } + keyBindingMap[key.Event{Name: key.Name(kb.Key), Modifiers: mods, State: key.Press}] = kb.Action + if _, ok := keyActionMap[KeyAction(kb.Action)]; !ok { + modString := strings.Replace(mods.String(), "-", "+", -1) + text := kb.Key + if modString != "" { + text = modString + "+" + text + } + keyActionMap[KeyAction(kb.Action)] = text + } + } +} + +func makeHint(hint, format, action string) string { + if keyActionMap[KeyAction(action)] != "" { + return hint + fmt.Sprintf(format, keyActionMap[KeyAction(action)]) + } + return hint } // KeyEvent handles incoming key events and returns true if repaint is needed. func (t *Tracker) KeyEvent(e key.Event, gtx C) { - if e.State == key.Press { - switch e.Name { - case "V": - if e.Modifiers.Contain(key.ModShortcut) { - gtx.Execute(clipboard.ReadCmd{Tag: t}) - return - } - case "Z": - if e.Modifiers.Contain(key.ModShortcut) { - t.Model.Undo().Do() - return - } - case "Y": - if e.Modifiers.Contain(key.ModShortcut) { - t.Model.Redo().Do() - return - } - case "D": - if e.Modifiers.Contain(key.ModShortcut) { - t.Model.UnitDisabled().Bool().Toggle() - return - } - case "L": - if e.Modifiers.Contain(key.ModShortcut) { - t.Model.LoopToggle().Bool().Toggle() - return - } - case "N": - if e.Modifiers.Contain(key.ModShortcut) { - t.NewSong().Do() - return - } - case "S": - if e.Modifiers.Contain(key.ModShortcut) { - t.SaveSong().Do() - return - } - case "O": - if e.Modifiers.Contain(key.ModShortcut) { - t.OpenSong().Do() - return - } - case "I": - if e.Modifiers.Contain(key.ModShortcut) { - if e.Modifiers.Contain(key.ModShift) { - t.DeleteInstrument().Do() - } else { - t.AddInstrument().Do() - } - return - } - case "T": - if e.Modifiers.Contain(key.ModShortcut) { - if e.Modifiers.Contain(key.ModShift) { - t.DeleteTrack().Do() - } else { - t.AddTrack().Do() - } - return - } - case "E": - if e.Modifiers.Contain(key.ModShortcut) { - t.InstrEnlarged().Bool().Toggle() - return - } - case "W": - if e.Modifiers.Contain(key.ModShortcut) && canQuit { - t.Quit().Do() - return - } - case "F1": + if e.State == key.Release { + t.JammingReleased(e) + return + } + action, ok := keyBindingMap[e] + if !ok { + return + } + switch action { + // Actions + case "AddTrack": + t.AddTrack().Do() + case "DeleteTrack": + t.DeleteTrack().Do() + case "AddInstrument": + t.AddInstrument().Do() + case "DeleteInstrument": + t.DeleteInstrument().Do() + case "AddUnitAfter": + t.AddUnit(false).Do() + case "AddUnitBefore": + t.AddUnit(true).Do() + case "DeleteUnit": + t.DeleteUnit().Do() + case "ClearUnit": + t.ClearUnit().Do() + case "Undo": + t.Undo().Do() + case "Redo": + t.Redo().Do() + case "AddSemitone": + t.AddSemitone().Do() + case "SubtractSemitone": + t.SubtractSemitone().Do() + case "AddOctave": + t.AddOctave().Do() + case "SubtractOctave": + t.SubtractOctave().Do() + case "EditNoteOff": + t.EditNoteOff().Do() + case "RemoveUnused": + t.RemoveUnused().Do() + case "PlayCurrentPosFollow": + t.NoteTracking().Bool().Set(true) + t.PlayFromCurrentPosition().Do() + case "PlayCurrentPosUnfollow": + t.NoteTracking().Bool().Set(false) + t.PlayFromCurrentPosition().Do() + case "PlaySongStartFollow": + t.NoteTracking().Bool().Set(true) + t.PlayFromSongStart().Do() + case "PlaySongStartUnfollow": + t.NoteTracking().Bool().Set(false) + t.PlayFromSongStart().Do() + case "PlaySelectedFollow": + t.NoteTracking().Bool().Set(true) + t.PlaySelected().Do() + case "PlaySelectedUnfollow": + t.NoteTracking().Bool().Set(false) + t.PlaySelected().Do() + case "PlayLoopFollow": + t.NoteTracking().Bool().Set(true) + t.PlayFromLoopStart().Do() + case "PlayLoopUnfollow": + t.NoteTracking().Bool().Set(false) + t.PlayFromLoopStart().Do() + case "StopPlaying": + t.StopPlaying().Do() + case "AddOrderRowBefore": + t.AddOrderRow(true).Do() + case "AddOrderRowAfter": + t.AddOrderRow(false).Do() + case "DeleteOrderRowBackwards": + t.DeleteOrderRow(true).Do() + case "DeleteOrderRowForwards": + t.DeleteOrderRow(false).Do() + case "NewSong": + t.NewSong().Do() + case "OpenSong": + t.OpenSong().Do() + case "Quit": + if canQuit { + t.Quit().Do() + } + case "SaveSong": + t.SaveSong().Do() + case "SaveSongAs": + t.SaveSongAs().Do() + case "ExportWav": + t.Export().Do() + case "ExportFloat": + t.ExportFloat().Do() + case "ExportInt16": + t.ExportInt16().Do() + // Booleans + case "PanicToggle": + t.Panic().Bool().Toggle() + case "RecordingToggle": + t.IsRecording().Bool().Toggle() + case "PlayingToggleFollow": + t.NoteTracking().Bool().Set(true) + t.Playing().Bool().Toggle() + case "PlayingToggleUnfollow": + t.NoteTracking().Bool().Set(false) + t.Playing().Bool().Toggle() + case "InstrEnlargedToggle": + t.InstrEnlarged().Bool().Toggle() + case "CommentExpandedToggle": + t.CommentExpanded().Bool().Toggle() + case "FollowToggle": + t.NoteTracking().Bool().Toggle() + case "UnitDisabledToggle": + t.UnitDisabled().Bool().Toggle() + case "LoopToggle": + t.LoopToggle().Bool().Toggle() + // Integers + case "InstrumentVoicesAdd": + t.Model.InstrumentVoices().Int().Add(1) + case "InstrumentVoicesSubtract": + t.Model.InstrumentVoices().Int().Add(-1) + case "TrackVoicesAdd": + t.TrackVoices().Int().Add(1) + case "TrackVoicesSubtract": + t.TrackVoices().Int().Add(-1) + case "SongLengthAdd": + t.SongLength().Int().Add(1) + case "SongLengthSubtract": + t.SongLength().Int().Add(-1) + case "BPMAdd": + t.BPM().Int().Add(1) + case "BPMSubtract": + t.BPM().Int().Add(-1) + case "RowsPerPatternAdd": + t.RowsPerPattern().Int().Add(1) + case "RowsPerPatternSubtract": + t.RowsPerPattern().Int().Add(-1) + case "RowsPerBeatAdd": + t.RowsPerBeat().Int().Add(1) + case "RowsPerBeatSubtract": + t.RowsPerBeat().Int().Add(-1) + case "StepAdd": + t.Step().Int().Add(1) + case "StepSubtract": + t.Step().Int().Add(-1) + case "OctaveAdd": + t.Octave().Int().Add(1) + case "OctaveSubtract": + t.Octave().Int().Add(-1) + // Other miscellaneous + case "Paste": + gtx.Execute(clipboard.ReadCmd{Tag: t}) + case "OrderEditorFocus": + t.OrderEditor.scrollTable.Focus() + case "TrackEditorFocus": + t.TrackEditor.scrollTable.Focus() + case "InstrumentEditorFocus": + t.InstrumentEditor.Focus() + case "FocusPrev": + switch { + case t.OrderEditor.scrollTable.Focused(): + t.InstrumentEditor.unitEditor.sliderList.Focus() + case t.TrackEditor.scrollTable.Focused(): t.OrderEditor.scrollTable.Focus() - return - case "F2": - t.TrackEditor.scrollTable.Focus() - return - case "F3": + case t.InstrumentEditor.Focused(): + if t.InstrumentEditor.enlargeBtn.Bool.Value() { + t.InstrumentEditor.unitEditor.sliderList.Focus() + } else { + t.TrackEditor.scrollTable.Focus() + } + default: t.InstrumentEditor.Focus() - return - case "Space": - t.NoteTracking().Bool().Set(e.Modifiers.Contain(key.ModShift)) - t.Playing().Bool().Toggle() - return - case "F5": - t.NoteTracking().Bool().Set(e.Modifiers.Contain(key.ModShift)) - if e.Modifiers.Contain(key.ModCtrl) { - t.Model.PlayFromSongStart().Do() + } + case "FocusNext": + switch { + case t.OrderEditor.scrollTable.Focused(): + t.TrackEditor.scrollTable.Focus() + case t.TrackEditor.scrollTable.Focused(): + t.InstrumentEditor.Focus() + case t.InstrumentEditor.Focused(): + t.InstrumentEditor.unitEditor.sliderList.Focus() + default: + if t.InstrumentEditor.enlargeBtn.Bool.Value() { + t.InstrumentEditor.Focus() } else { - t.Model.PlayFromCurrentPosition().Do() - } - return - case "F6": - t.NoteTracking().Bool().Set(e.Modifiers.Contain(key.ModShift)) - if e.Modifiers.Contain(key.ModCtrl) { - t.Model.PlayFromLoopStart().Do() - } else { - t.Model.PlaySelected().Do() - } - return - case "F7": - t.IsRecording().Bool().Toggle() - return - case "F8": - t.StopPlaying().Do() - return - case "F9": - t.NoteTracking().Bool().Toggle() - return - case "F12": - t.Panic().Bool().Toggle() - return - case `\`, `<`, `>`: - if e.Modifiers.Contain(key.ModShift) { - t.OctaveNumberInput.Int.Add(1) - } else { - t.OctaveNumberInput.Int.Add(-1) - } - case key.NameTab: - if e.Modifiers.Contain(key.ModShift) { - switch { - case t.OrderEditor.scrollTable.Focused(): - t.InstrumentEditor.unitEditor.sliderList.Focus() - case t.TrackEditor.scrollTable.Focused(): - t.OrderEditor.scrollTable.Focus() - case t.InstrumentEditor.Focused(): - if t.InstrumentEditor.enlargeBtn.Bool.Value() { - t.InstrumentEditor.unitEditor.sliderList.Focus() - } else { - t.TrackEditor.scrollTable.Focus() - } - default: - t.InstrumentEditor.Focus() - } - } else { - switch { - case t.OrderEditor.scrollTable.Focused(): - t.TrackEditor.scrollTable.Focus() - case t.TrackEditor.scrollTable.Focused(): - t.InstrumentEditor.Focus() - case t.InstrumentEditor.Focused(): - t.InstrumentEditor.unitEditor.sliderList.Focus() - default: - if t.InstrumentEditor.enlargeBtn.Bool.Value() { - t.InstrumentEditor.Focus() - } else { - t.OrderEditor.scrollTable.Focus() - } - } + t.OrderEditor.scrollTable.Focus() } } - t.JammingPressed(e) - } else { // e.State == key.Release - t.JammingReleased(e) + default: + if action[:4] == "Note" { + val, err := strconv.Atoi(string(action[4:])) + if err != nil { + break + } + t.JammingPressed(e, val-12) + } } } -func (t *Tracker) JammingPressed(e key.Event) byte { - if val, ok := noteMap[e.Name]; ok { - if _, ok := t.KeyPlaying[e.Name]; !ok { - n := noteAsValue(t.OctaveNumberInput.Int.Value(), val) - instr := t.InstrumentEditor.instrumentDragList.TrackerList.Selected() - t.KeyPlaying[e.Name] = t.InstrNoteOn(instr, n) - return n - } +func (t *Tracker) JammingPressed(e key.Event, val int) byte { + if _, ok := t.KeyPlaying[e.Name]; !ok { + n := noteAsValue(t.OctaveNumberInput.Int.Value(), val) + instr := t.InstrumentEditor.instrumentDragList.TrackerList.Selected() + t.KeyPlaying[e.Name] = t.InstrNoteOn(instr, n) + return n } return 0 } diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index c21a0c4..48d982d 100644 --- a/tracker/gioui/note_editor.go +++ b/tracker/gioui/note_editor.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "gioui.org/io/event" "gioui.org/io/key" "gioui.org/layout" "gioui.org/op" @@ -59,12 +60,16 @@ type NoteEditor struct { NoteOffBtn *ActionClickable EffectBtn *BoolClickable - scrollTable *ScrollTable - tag struct{} + scrollTable *ScrollTable + tag struct{} + eventFilters []event.Filter + + deleteTrackHint string + addTrackHint string } func NewNoteEditor(model *tracker.Model) *NoteEditor { - return &NoteEditor{ + ret := &NoteEditor{ TrackVoices: NewNumberInput(model.TrackVoices().Int()), NewTrackBtn: NewActionClickable(model.AddTrack()), DeleteTrackBtn: NewActionClickable(model.DeleteTrack()), @@ -80,50 +85,20 @@ func NewNoteEditor(model *tracker.Model) *NoteEditor { 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, Name: k.Name}) + } + ret.deleteTrackHint = makeHint("Delete\ntrack", "\n(%s)", "DeleteTrack") + ret.addTrackHint = makeHint("Add\ntrack", "\n(%s)", "AddTrack") + return ret } func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions { for { - e, ok := gtx.Event( - key.Filter{Focus: te.scrollTable, Name: "A"}, - key.Filter{Focus: te.scrollTable, Name: "B"}, - key.Filter{Focus: te.scrollTable, Name: "C"}, - key.Filter{Focus: te.scrollTable, Name: "D"}, - key.Filter{Focus: te.scrollTable, Name: "E"}, - key.Filter{Focus: te.scrollTable, Name: "F"}, - key.Filter{Focus: te.scrollTable, Name: "G"}, - key.Filter{Focus: te.scrollTable, Name: "H"}, - key.Filter{Focus: te.scrollTable, Name: "I"}, - key.Filter{Focus: te.scrollTable, Name: "J"}, - key.Filter{Focus: te.scrollTable, Name: "K"}, - key.Filter{Focus: te.scrollTable, Name: "L"}, - key.Filter{Focus: te.scrollTable, Name: "M"}, - key.Filter{Focus: te.scrollTable, Name: "N"}, - key.Filter{Focus: te.scrollTable, Name: "O"}, - key.Filter{Focus: te.scrollTable, Name: "P"}, - key.Filter{Focus: te.scrollTable, Name: "Q"}, - key.Filter{Focus: te.scrollTable, Name: "R"}, - key.Filter{Focus: te.scrollTable, Name: "S"}, - key.Filter{Focus: te.scrollTable, Name: "T"}, - key.Filter{Focus: te.scrollTable, Name: "U"}, - key.Filter{Focus: te.scrollTable, Name: "V"}, - key.Filter{Focus: te.scrollTable, Name: "W"}, - key.Filter{Focus: te.scrollTable, Name: "X"}, - key.Filter{Focus: te.scrollTable, Name: "Y"}, - key.Filter{Focus: te.scrollTable, Name: "Z"}, - key.Filter{Focus: te.scrollTable, Name: "0"}, - key.Filter{Focus: te.scrollTable, Name: "1"}, - key.Filter{Focus: te.scrollTable, Name: "2"}, - key.Filter{Focus: te.scrollTable, Name: "3"}, - key.Filter{Focus: te.scrollTable, Name: "4"}, - key.Filter{Focus: te.scrollTable, Name: "5"}, - key.Filter{Focus: te.scrollTable, Name: "6"}, - key.Filter{Focus: te.scrollTable, Name: "7"}, - key.Filter{Focus: te.scrollTable, Name: "8"}, - key.Filter{Focus: te.scrollTable, Name: "9"}, - key.Filter{Focus: te.scrollTable, Name: ","}, - key.Filter{Focus: te.scrollTable, Name: "."}, - ) + e, ok := gtx.Event(te.eventFilters...) if !ok { break } @@ -162,8 +137,8 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { 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, "Delete track\n(Ctrl+Shift+T)") - newTrackBtnStyle := ActionIcon(gtx, t.Theme, te.NewTrackBtn, icons.ContentAdd, "Add track\n(Ctrl+T)") + deleteTrackBtnStyle := ActionIcon(gtx, t.Theme, te.DeleteTrackBtn, icons.ActionDelete, te.deleteTrackHint) + 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") @@ -347,7 +322,11 @@ func (te *NoteEditor) command(gtx C, t *Tracker, e key.Event) { goto validNote } } else { - if e.Name == "A" || e.Name == "1" { + action, ok := keyBindingMap[e] + if !ok { + return + } + if action == "NoteOff" { t.Model.Notes().Table().Fill(0) if step := t.Model.Step().Value(); step > 0 { te.scrollTable.Table.MoveCursor(0, step) @@ -356,8 +335,12 @@ func (te *NoteEditor) command(gtx C, t *Tracker, e key.Event) { te.scrollTable.EnsureCursorVisible() return } - if val, ok := noteMap[e.Name]; ok { - n = noteAsValue(t.OctaveNumberInput.Int.Value(), val) + 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)) goto validNote } diff --git a/tracker/gioui/scroll_table.go b/tracker/gioui/scroll_table.go index 2345c15..7ca35a2 100644 --- a/tracker/gioui/scroll_table.go +++ b/tracker/gioui/scroll_table.go @@ -25,6 +25,7 @@ type ScrollTable struct { focused bool requestFocus bool cursorMoved bool + eventFilters []event.Filter } type ScrollTableStyle struct { @@ -40,11 +41,33 @@ type ScrollTableStyle struct { } func NewScrollTable(table tracker.Table, vertList, horizList tracker.List) *ScrollTable { - return &ScrollTable{ + ret := &ScrollTable{ Table: table, ColTitleList: NewDragList(vertList, layout.Horizontal), RowTitleList: NewDragList(horizList, layout.Vertical), } + ret.eventFilters = []event.Filter{ + key.FocusFilter{Target: ret}, + transfer.TargetFilter{Target: ret, Type: "application/text"}, + pointer.Filter{Target: ret, Kinds: pointer.Press}, + key.Filter{Focus: ret, Name: key.NameLeftArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt}, + key.Filter{Focus: ret, Name: key.NameUpArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt}, + key.Filter{Focus: ret, Name: key.NameRightArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt}, + key.Filter{Focus: ret, Name: key.NameDownArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt}, + key.Filter{Focus: ret, Name: key.NamePageUp, Optional: key.ModShift}, + key.Filter{Focus: ret, Name: key.NamePageDown, Optional: key.ModShift}, + key.Filter{Focus: ret, Name: key.NameHome, Optional: key.ModShift}, + key.Filter{Focus: ret, Name: key.NameEnd, Optional: key.ModShift}, + key.Filter{Focus: ret, Name: key.NameDeleteBackward}, + key.Filter{Focus: ret, Name: key.NameDeleteForward}, + } + for k, a := range keyBindingMap { + switch a { + case "Copy", "Paste", "Cut", "Increase", "Decrease": + ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret, Name: k.Name, Required: k.Modifiers}) + } + } + return ret } func FilledScrollTable(th *material.Theme, scrollTable *ScrollTable, element func(gtx C, x, y int) D, colTitle, rowTitle, colTitleBg, rowTitleBg func(gtx C, i int) D) ScrollTableStyle { @@ -107,26 +130,7 @@ func (s ScrollTableStyle) Layout(gtx C) D { func (s *ScrollTableStyle) handleEvents(gtx layout.Context, p image.Point) { for { - e, ok := gtx.Event( - key.FocusFilter{Target: s.ScrollTable}, - transfer.TargetFilter{Target: s.ScrollTable, Type: "application/text"}, - pointer.Filter{Target: s.ScrollTable, Kinds: pointer.Press}, - key.Filter{Focus: s.ScrollTable, Name: key.NameLeftArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt}, - key.Filter{Focus: s.ScrollTable, Name: key.NameUpArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt}, - key.Filter{Focus: s.ScrollTable, Name: key.NameRightArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt}, - key.Filter{Focus: s.ScrollTable, Name: key.NameDownArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt}, - key.Filter{Focus: s.ScrollTable, Name: key.NamePageUp, Optional: key.ModShift}, - key.Filter{Focus: s.ScrollTable, Name: key.NamePageDown, Optional: key.ModShift}, - key.Filter{Focus: s.ScrollTable, Name: key.NameHome, Optional: key.ModShift}, - key.Filter{Focus: s.ScrollTable, Name: key.NameEnd, Optional: key.ModShift}, - key.Filter{Focus: s.ScrollTable, Name: key.NameDeleteBackward}, - key.Filter{Focus: s.ScrollTable, Name: key.NameDeleteForward}, - key.Filter{Focus: s.ScrollTable, Name: "C", Required: key.ModShortcut}, - key.Filter{Focus: s.ScrollTable, Name: "V", Required: key.ModShortcut}, - key.Filter{Focus: s.ScrollTable, Name: "X", Required: key.ModShortcut}, - key.Filter{Focus: s.ScrollTable, Name: "+"}, - key.Filter{Focus: s.ScrollTable, Name: "-"}, - ) + e, ok := gtx.Event(s.ScrollTable.eventFilters...) if !ok { break } @@ -240,23 +244,6 @@ func (s *ScrollTable) command(gtx C, e key.Event) { stepY = 1e6 } switch e.Name { - case "X", "C": - if e.Modifiers.Contain(key.ModShortcut) { - contents, ok := s.Table.Copy() - if !ok { - return - } - gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))}) - if e.Name == "X" { - s.Table.Clear() - } - return - } - case "V": - if e.Modifiers.Contain(key.ModShortcut) { - gtx.Execute(clipboard.ReadCmd{Tag: s}) - } - return case key.NameDeleteBackward, key.NameDeleteForward: s.Table.Clear() return @@ -280,12 +267,29 @@ func (s *ScrollTable) command(gtx C, e key.Event) { s.Table.SetCursorX(0) case key.NameEnd: s.Table.SetCursorX(s.Table.Width() - 1) - case "+": - s.Table.Add(1) - return - case "-": - s.Table.Add(-1) - return + default: + a := keyBindingMap[e] + switch a { + case "Copy", "Cut": + contents, ok := s.Table.Copy() + if !ok { + return + } + gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))}) + if a == "Cut" { + s.Table.Clear() + } + return + case "Paste": + gtx.Execute(clipboard.ReadCmd{Tag: s}) + return + case "Increase": + s.Table.Add(1) + return + case "Decrease": + s.Table.Add(-1) + return + } } if !e.Modifiers.Contain(key.ModShift) { s.Table.SetCursor2(s.Table.Cursor()) diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index 15501fc..eb21369 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -40,6 +40,14 @@ type SongPanel struct { // Edit menu items editMenuItems []MenuItem + + // Hints + rewindHint string + playHint, stopHint string + recordHint, stopRecordHint string + followOnHint, followOffHint string + panicHint string + loopOffHint, loopOnHint string } func NewSongPanel(model *tracker.Model) *SongPanel { @@ -59,25 +67,33 @@ func NewSongPanel(model *tracker.Model) *SongPanel { RewindBtn: NewActionClickable(model.PlayFromSongStart()), } ret.fileMenuItems = []MenuItem{ - {IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N", Doer: model.NewSong()}, - {IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O", Doer: model.OpenSong()}, - {IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S", Doer: model.SaveSong()}, - {IconBytes: icons.ContentSave, Text: "Save Song As...", Doer: model.SaveSongAs()}, - {IconBytes: icons.ImageAudiotrack, Text: "Export Wav...", Doer: model.Export()}, + {IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: keyActionMap["NewSong"], Doer: model.NewSong()}, + {IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: keyActionMap["OpenSong"], Doer: model.OpenSong()}, + {IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: keyActionMap["SaveSong"], Doer: model.SaveSong()}, + {IconBytes: icons.ContentSave, Text: "Save Song As...", ShortcutText: keyActionMap["SaveSongAs"], Doer: model.SaveSongAs()}, + {IconBytes: icons.ImageAudiotrack, Text: "Export Wav...", ShortcutText: keyActionMap["ExportWav"], Doer: model.Export()}, } if canQuit { - ret.fileMenuItems = append(ret.fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit", Doer: model.Quit()}) + ret.fileMenuItems = append(ret.fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit", ShortcutText: keyActionMap["Quit"], Doer: model.Quit()}) } ret.editMenuItems = []MenuItem{ - {IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Doer: model.Undo()}, - {IconBytes: icons.ContentRedo, Text: "Redo", ShortcutText: shortcutKey + "Y", Doer: model.Redo()}, - {IconBytes: icons.ImageCrop, Text: "Remove unused data", Doer: model.RemoveUnused()}, + {IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: keyActionMap["Undo"], Doer: model.Undo()}, + {IconBytes: icons.ContentRedo, Text: "Redo", ShortcutText: keyActionMap["Redo"], Doer: model.Redo()}, + {IconBytes: icons.ImageCrop, Text: "Remove unused data", ShortcutText: keyActionMap["RemoveUnused"], Doer: model.RemoveUnused()}, } + ret.rewindHint = makeHint("Rewind", "\n(%s)", "PlaySongStartUnfollow") + ret.playHint = makeHint("Play", " (%s)", "PlayCurrentPosUnfollow") + ret.stopHint = makeHint("Stop", " (%s)", "StopPlaying") + ret.panicHint = makeHint("Panic", " (%s)", "PanicToggle") + ret.recordHint = makeHint("Record", " (%s)", "RecordingToggle") + ret.stopRecordHint = makeHint("Stop", " (%s)", "RecordingToggle") + ret.followOnHint = makeHint("Follow on", " (%s)", "FollowToggle") + ret.followOffHint = makeHint("Follow off", " (%s)", "FollowToggle") + ret.loopOffHint = makeHint("Loop off", " (%s)", "LoopToggle") + ret.loopOnHint = makeHint("Loop on", " (%s)", "LoopToggle") return ret } -const shortcutKey = "Ctrl+" - func (s *SongPanel) Layout(gtx C, t *Tracker) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { @@ -104,12 +120,12 @@ func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D { in := layout.UniformInset(unit.Dp(1)) - panicBtnStyle := ToggleButton(gtx, tr.Theme, t.PanicBtn, "Panic (F12)") - rewindBtnStyle := ActionIcon(gtx, tr.Theme, t.RewindBtn, icons.AVFastRewind, "Rewind\n(Ctrl+F5)") - playBtnStyle := ToggleIcon(gtx, tr.Theme, t.PlayingBtn, icons.AVPlayArrow, icons.AVStop, "Play (F5 / Space)", "Stop (F8)") - recordBtnStyle := ToggleIcon(gtx, tr.Theme, t.RecordBtn, icons.AVFiberManualRecord, icons.AVFiberSmartRecord, "Record (F7)", "Stop (F7)") - noteTrackBtnStyle := ToggleIcon(gtx, tr.Theme, t.NoteTracking, icons.ActionSpeakerNotesOff, icons.ActionSpeakerNotes, "Follow\nOff", "Follow\nOn") - loopBtnStyle := ToggleIcon(gtx, tr.Theme, t.LoopBtn, icons.NavigationArrowForward, icons.AVLoop, "Loop\nOff\n(Ctrl+L)", "Loop\nOn\n(Ctrl+L)") + panicBtnStyle := ToggleButton(gtx, tr.Theme, t.PanicBtn, t.panicHint) + rewindBtnStyle := ActionIcon(gtx, tr.Theme, t.RewindBtn, icons.AVFastRewind, t.rewindHint) + playBtnStyle := ToggleIcon(gtx, tr.Theme, t.PlayingBtn, icons.AVPlayArrow, icons.AVStop, t.playHint, t.stopHint) + recordBtnStyle := ToggleIcon(gtx, tr.Theme, t.RecordBtn, icons.AVFiberManualRecord, icons.AVFiberSmartRecord, t.recordHint, t.stopRecordHint) + noteTrackBtnStyle := ToggleIcon(gtx, tr.Theme, t.NoteTracking, icons.ActionSpeakerNotesOff, icons.ActionSpeakerNotes, t.followOffHint, t.followOnHint) + loopBtnStyle := ToggleIcon(gtx, tr.Theme, t.LoopBtn, icons.NavigationArrowForward, icons.AVLoop, t.loopOffHint, t.loopOnHint) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { diff --git a/tracker/gioui/unit_editor.go b/tracker/gioui/unit_editor.go index df34b70..b39bfc1 100644 --- a/tracker/gioui/unit_editor.go +++ b/tracker/gioui/unit_editor.go @@ -33,6 +33,10 @@ type UnitEditor struct { DisableUnitBtn *BoolClickable SelectTypeBtn *widget.Clickable caser cases.Caser + + copyHint string + disableUnitHint string + enableUnitHint string } func NewUnitEditor(m *tracker.Model) *UnitEditor { @@ -46,6 +50,9 @@ func NewUnitEditor(m *tracker.Model) *UnitEditor { searchList: NewDragList(m.SearchResults().List(), layout.Vertical), } ret.caser = cases.Title(language.English) + ret.copyHint = makeHint("Copy unit", " (%s)", "Copy") + ret.disableUnitHint = makeHint("Disable unit", " (%s)", "UnitDisabledToggle") + ret.enableUnitHint = makeHint("Enable unit", " (%s)", "UnitDisabledToggle") return ret } @@ -122,9 +129,9 @@ func (pe *UnitEditor) layoutFooter(gtx C, t *Tracker) D { t.Alerts().Add("Unit copied to clipboard", tracker.Info) } } - copyUnitBtnStyle := TipIcon(t.Theme, pe.CopyUnitBtn, icons.ContentContentCopy, "Copy unit (Ctrl+C)") + copyUnitBtnStyle := TipIcon(t.Theme, pe.CopyUnitBtn, icons.ContentContentCopy, pe.copyHint) deleteUnitBtnStyle := ActionIcon(gtx, t.Theme, pe.DeleteUnitBtn, icons.ActionDelete, "Delete unit (Ctrl+Backspace)") - disableUnitBtnStyle := ToggleIcon(gtx, t.Theme, pe.DisableUnitBtn, icons.AVVolumeUp, icons.AVVolumeOff, "Disable unit (Ctrl-D)", "Enable unit (Ctrl-D)") + disableUnitBtnStyle := ToggleIcon(gtx, t.Theme, pe.DisableUnitBtn, icons.AVVolumeUp, icons.AVVolumeOff, pe.disableUnitHint, pe.enableUnitHint) text := t.Units().SelectedType() if text == "" { text = "Choose unit type"