From 94205b9ab2c7150f4c8afdb2fb77e73174be8b36 Mon Sep 17 00:00:00 2001 From: vsariola <5684185+vsariola@users.noreply.github.com> Date: Mon, 15 Feb 2021 23:05:06 +0200 Subject: [PATCH] feat(tracker): implement more proper menus, with Undo&Redo Closes #24 --- tracker/keyevent.go | 10 +++ tracker/menu.go | 151 +++++++++++++++++++++++++++++++++++++++++++ tracker/songpanel.go | 148 ++++++++++++++++++------------------------ tracker/theme.go | 2 + tracker/tracker.go | 6 ++ 5 files changed, 231 insertions(+), 86 deletions(-) create mode 100644 tracker/menu.go diff --git a/tracker/keyevent.go b/tracker/keyevent.go index 1a4eccf..25f2c59 100644 --- a/tracker/keyevent.go +++ b/tracker/keyevent.go @@ -111,6 +111,16 @@ func (t *Tracker) KeyEvent(w *app.Window, e key.Event) bool { t.LoadSong(defaultSong.Copy()) return true } + case "S": + if e.Modifiers.Contain(key.ModShortcut) { + t.SaveSongFile() + return false + } + case "O": + if e.Modifiers.Contain(key.ModShortcut) { + t.LoadSongFile() + return true + } case key.NameDeleteForward: switch t.EditMode { case EditTracks: diff --git a/tracker/menu.go b/tracker/menu.go new file mode 100644 index 0000000..b50332c --- /dev/null +++ b/tracker/menu.go @@ -0,0 +1,151 @@ +package tracker + +import ( + "image" + "image/color" + + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" +) + +type Menu struct { + Visible bool + clickable widget.Clickable + tags []bool + clicks []int + hover int +} + +type MenuStyle struct { + Menu *Menu + Title string + IconColor color.NRGBA + TextColor color.NRGBA + ShortCutColor color.NRGBA + FontSize unit.Value + IconSize unit.Value + HoverColor color.NRGBA +} + +type MenuItem struct { + IconBytes []byte + Text string + ShortcutText string + Disabled bool +} + +func (m *Menu) Clicked() (int, bool) { + if len(m.clicks) == 0 { + return 0, false + } + first := m.clicks[0] + for i := 1; i < len(m.clicks); i++ { + m.clicks[i-1] = m.clicks[i] + } + m.clicks = m.clicks[:len(m.clicks)-1] + return first, true +} + +func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { + contents := func(gtx C) D { + flexChildren := make([]layout.FlexChild, len(items)) + for i, item := range items { + // make sure we have a tag for every item + for len(m.Menu.tags) <= i { + m.Menu.tags = append(m.Menu.tags, false) + } + // handle pointer events for this item + for _, ev := range gtx.Events(&m.Menu.tags[i]) { + e, ok := ev.(pointer.Event) + if !ok { + continue + } + switch e.Type { + case pointer.Press: + m.Menu.clicks = append(m.Menu.clicks, i) + m.Menu.Visible = false + case pointer.Enter: + m.Menu.hover = i + 1 + case pointer.Leave: + if m.Menu.hover == i+1 { + m.Menu.hover = 0 + } + } + } + // layout contents for this item + i2 := i // avoid loop variable getting updated in closure + item2 := item + flexChildren[i] = layout.Rigid(func(gtx C) D { + defer op.Save(gtx.Ops).Load() + var macro op.MacroOp + if i2 == m.Menu.hover-1 && !item2.Disabled { + macro = op.Record(gtx.Ops) + } + icon := widgetForIcon(item2.IconBytes) + if !item2.Disabled { + icon.Color = m.IconColor + } else { + icon.Color = mediumEmphasisTextColor + } + iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)} + textLabel := LabelStyle{Text: item2.Text, FontSize: m.FontSize, Color: m.TextColor} + if item2.Disabled { + textLabel.Color = mediumEmphasisTextColor + } + shortcutLabel := LabelStyle{Text: item2.ShortcutText, FontSize: m.FontSize, Color: m.ShortCutColor} + shortcutInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(12), Bottom: unit.Dp(2), Top: unit.Dp(2)} + dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return iconInset.Layout(gtx, func(gtx C) D { + return icon.Layout(gtx, m.IconSize) + }) + }), + layout.Rigid(textLabel.Layout), + layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Max.X, 1)} }), + layout.Rigid(func(gtx C) D { + return shortcutInset.Layout(gtx, shortcutLabel.Layout) + }), + ) + if i2 == m.Menu.hover-1 && !item2.Disabled { + recording := macro.Stop() + paint.FillShape(gtx.Ops, m.HoverColor, clip.Rect{ + Max: image.Pt(dims.Size.X, dims.Size.Y), + }.Op()) + recording.Add(gtx.Ops) + } + if !item2.Disabled { + rect := image.Rect(0, 0, dims.Size.X, dims.Size.Y) + pointer.Rect(rect).Add(gtx.Ops) + pointer.InputOp{Tag: &m.Menu.tags[i2], + Types: pointer.Press | pointer.Enter | pointer.Leave, + }.Add(gtx.Ops) + } + return dims + }) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, flexChildren...) + } + popup := Popup(&m.Menu.Visible) + popup.NE = unit.Dp(0) + popup.ShadowN = unit.Dp(0) + popup.NW = unit.Dp(0) + return popup.Layout(gtx, contents) +} + +func PopupMenu(th *material.Theme, menu *Menu) MenuStyle { + return MenuStyle{ + Menu: menu, + IconColor: white, + TextColor: white, + ShortCutColor: mediumEmphasisTextColor, + FontSize: unit.Dp(16), + IconSize: unit.Dp(16), + HoverColor: menuHoverColor, + } +} diff --git a/tracker/songpanel.go b/tracker/songpanel.go index a52afcb..4a78a0e 100644 --- a/tracker/songpanel.go +++ b/tracker/songpanel.go @@ -3,6 +3,7 @@ package tracker import ( "image" "math" + "runtime" "gioui.org/f32" "gioui.org/io/clipboard" @@ -11,6 +12,7 @@ import ( "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" + "gioui.org/widget" "gioui.org/widget/material" "golang.org/x/exp/shiny/materialdesign/icons" "gopkg.in/yaml.v3" @@ -18,105 +20,80 @@ import ( func (t *Tracker) layoutSongPanel(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(t.layoutSongButtons), + layout.Rigid(t.layoutMenuBar), layout.Rigid(t.layoutSongOptions), ) } -func (t *Tracker) layoutSongButtons(gtx C) D { +func (t *Tracker) layoutMenu(title string, clickable *widget.Clickable, menu *Menu, width unit.Value, items ...MenuItem) layout.Widget { + for clickable.Clicked() { + menu.Visible = true + } + m := PopupMenu(t.Theme, menu) + return func(gtx C) D { + defer op.Save(gtx.Ops).Load() + titleBtn := material.Button(t.Theme, clickable, title) + titleBtn.Color = white + titleBtn.Background = transparent + titleBtn.CornerRadius = unit.Dp(0) + dims := titleBtn.Layout(gtx) + op.Offset(f32.Pt(0, float32(dims.Size.Y))).Add(gtx.Ops) + gtx.Constraints.Max.X = gtx.Px(width) + gtx.Constraints.Max.Y = gtx.Px(unit.Dp(1000)) + m.Layout(gtx, items...) + return dims + } +} + +func (t *Tracker) layoutMenuBar(gtx C) D { gtx.Constraints.Max.Y = gtx.Px(unit.Dp(36)) gtx.Constraints.Min.Y = gtx.Px(unit.Dp(36)) - //paint.FillShape(gtx.Ops, primaryColorDark, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op()) - - for t.NewSongFileBtn.Clicked() { - t.LoadSong(defaultSong.Copy()) - t.FileMenuVisible = false - } - - for t.LoadSongFileBtn.Clicked() { - t.LoadSongFile() - t.FileMenuVisible = false - } - - for t.SaveSongFileBtn.Clicked() { - t.SaveSongFile() - } - - for t.CopySongBtn.Clicked() { - if contents, err := yaml.Marshal(t.song); err == nil { - clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops) + for clickedItem, hasClicked := t.FileMenu.Clicked(); hasClicked; { + switch clickedItem { + case 0: + t.LoadSong(defaultSong.Copy()) + case 1: + t.LoadSongFile() + case 2: + t.SaveSongFile() } + clickedItem, hasClicked = t.FileMenu.Clicked() } - for t.PasteBtn.Clicked() { - clipboard.ReadOp{Tag: t.PasteBtn}.Add(gtx.Ops) + for clickedItem, hasClicked := t.EditMenu.Clicked(); hasClicked; { + switch clickedItem { + case 0: + t.Undo() + case 1: + t.Redo() + case 2: + if contents, err := yaml.Marshal(t.song); err == nil { + clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops) + } + case 3: + clipboard.ReadOp{Tag: t.EditMenu}.Add(gtx.Ops) + } + clickedItem, hasClicked = t.FileMenu.Clicked() } - newBtnStyle := material.IconButton(t.Theme, t.NewSongFileBtn, widgetForIcon(icons.ContentClear)) - newBtnStyle.Background = transparent - newBtnStyle.Inset = layout.UniformInset(unit.Dp(6)) - newBtnStyle.Color = primaryColor - - loadBtnStyle := material.IconButton(t.Theme, t.LoadSongFileBtn, widgetForIcon(icons.FileFolder)) - loadBtnStyle.Background = transparent - loadBtnStyle.Inset = layout.UniformInset(unit.Dp(6)) - loadBtnStyle.Color = primaryColor - - copySongBtnStyle := material.IconButton(t.Theme, t.CopySongBtn, widgetForIcon(icons.ContentContentCopy)) - copySongBtnStyle.Background = transparent - copySongBtnStyle.Inset = layout.UniformInset(unit.Dp(6)) - copySongBtnStyle.Color = primaryColor - - pasteBtnStyle := material.IconButton(t.Theme, t.PasteBtn, widgetForIcon(icons.ContentContentPaste)) - pasteBtnStyle.Background = transparent - pasteBtnStyle.Inset = layout.UniformInset(unit.Dp(6)) - pasteBtnStyle.Color = primaryColor - - menuContents := func(gtx C) D { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(newBtnStyle.Layout), - layout.Rigid(loadBtnStyle.Layout), - layout.Rigid(copySongBtnStyle.Layout), - layout.Rigid(pasteBtnStyle.Layout), - ) + shortcutKey := "Ctrl+" + if runtime.GOOS == "darwin" { + shortcutKey = "Cmd+" } - - fileMenu := Popup(&t.FileMenuVisible) - fileMenu.NE = unit.Dp(0) - fileMenu.ShadowN = unit.Dp(0) - fileMenu.NW = unit.Dp(0) - - saveBtnStyle := material.IconButton(t.Theme, t.SaveSongFileBtn, widgetForIcon(icons.ContentSave)) - saveBtnStyle.Background = transparent - saveBtnStyle.Inset = layout.UniformInset(unit.Dp(6)) - saveBtnStyle.Color = primaryColor - - fileMenuBtnStyle := material.IconButton(t.Theme, t.FileMenuBtn, widgetForIcon(icons.NavigationMoreVert)) - fileMenuBtnStyle.Background = transparent - fileMenuBtnStyle.Inset = layout.UniformInset(unit.Dp(6)) - fileMenuBtnStyle.Color = primaryColor - - for t.FileMenuBtn.Clicked() { - t.FileMenuVisible = !t.FileMenuVisible - } - - popupWidget := func(gtx C) D { - defer op.Save(gtx.Ops).Load() - dims := fileMenuBtnStyle.Layout(gtx) - op.Offset(f32.Pt(0, float32(dims.Size.Y))).Add(gtx.Ops) - gtx.Constraints.Max.X = 160 - gtx.Constraints.Max.Y = 300 - fileMenu.Layout(gtx, menuContents) - return dims - } - - layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Rigid(saveBtnStyle.Layout), - layout.Rigid(popupWidget), + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(t.layoutMenu("File", &t.MenuBar[0], t.FileMenu, unit.Dp(200), + MenuItem{IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N"}, + MenuItem{IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"}, + MenuItem{IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S"}, + )), + layout.Rigid(t.layoutMenu("Edit", &t.MenuBar[1], t.EditMenu, unit.Dp(160), + MenuItem{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Disabled: len(t.undoStack) == 0}, + MenuItem{IconBytes: icons.ContentRedo, Text: "Redo", ShortcutText: shortcutKey + "Y", Disabled: len(t.redoStack) == 0}, + MenuItem{IconBytes: icons.ContentContentCopy, Text: "Copy", ShortcutText: shortcutKey + "C"}, + MenuItem{IconBytes: icons.ContentContentPaste, Text: "Paste", ShortcutText: shortcutKey + "V"}, + )), ) - - return layout.Dimensions{Size: gtx.Constraints.Max} } func (t *Tracker) layoutSongOptions(gtx C) D { @@ -167,7 +144,6 @@ func (t *Tracker) layoutSongOptions(gtx C) D { dims := in.Layout(gtx, numStyle.Layout) t.SetBPM(t.BPM.Value) return dims - //return in.Layout(gtx, enableButton(smallButton(material.IconButton(t.Theme, t.BPMUpBtn, upIcon)), t.song.BPM < 999).Layout) }), ) }), diff --git a/tracker/theme.go b/tracker/theme.go index e939db6..dd699af 100644 --- a/tracker/theme.go +++ b/tracker/theme.go @@ -65,3 +65,5 @@ var selectionColor = color.NRGBA{R: 100, G: 140, B: 255, A: 8} var inactiveSelectionColor = color.NRGBA{R: 140, G: 140, B: 140, A: 16} var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255} + +var menuHoverColor = color.NRGBA{R: 30, G: 31, B: 38, A: 255} diff --git a/tracker/tracker.go b/tracker/tracker.go index 83ba733..427f02b 100644 --- a/tracker/tracker.go +++ b/tracker/tracker.go @@ -36,6 +36,8 @@ type Tracker struct { EditMode EditMode SelectionCorner SongPoint Cursor SongPoint + FileMenu *Menu + EditMenu *Menu CursorColumn int CurrentInstrument int CurrentUnit int @@ -67,6 +69,7 @@ type Tracker struct { SaveSongFileBtn *widget.Clickable FileMenuBtn *widget.Clickable PanicBtn *widget.Clickable + MenuBar []widget.Clickable FileMenuVisible bool ParameterSliders []*widget.Float ParameterList *layout.List @@ -683,6 +686,9 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService) *Tr DeleteUnitBtn: new(widget.Clickable), ClearUnitBtn: new(widget.Clickable), PanicBtn: new(widget.Clickable), + FileMenu: new(Menu), + EditMenu: new(Menu), + MenuBar: make([]widget.Clickable, 2), UnitDragList: &DragList{List: &layout.List{Axis: layout.Vertical}}, setPlaying: make(chan bool), rowJump: make(chan int),