diff --git a/tracker/gioui/buttons.go b/tracker/gioui/buttons.go index 024ab10..11d337d 100644 --- a/tracker/gioui/buttons.go +++ b/tracker/gioui/buttons.go @@ -44,6 +44,14 @@ type ( TipArea component.TipArea Bool tracker.Bool } + + MenuClickable struct { + Clickable Clickable + menu Menu + Selected tracker.OptionalInt + TipArea component.TipArea + Tooltip component.Tooltip + } ) func NewActionClickable(a tracker.Action) *ActionClickable { @@ -136,7 +144,10 @@ func ToggleButton(gtx C, th *material.Theme, b *BoolClickable, text string) Butt ret := Button(th, &b.Clickable, text) ret.Background = transparent ret.Inset = layout.UniformInset(unit.Dp(6)) - if b.Bool.Value() { + if !b.Bool.Enabled() { + ret.Color = disabledTextColor + ret.Background = transparent + } else if b.Bool.Value() { ret.Color = th.Palette.ContrastFg ret.Background = th.Palette.Fg } else { @@ -287,6 +298,7 @@ type ButtonStyle struct { Inset layout.Inset Button *Clickable shaper *text.Shaper + Hidden bool } type ButtonLayoutStyle struct { @@ -351,6 +363,9 @@ func (b ButtonStyle) Layout(gtx layout.Context) layout.Dimensions { CornerRadius: b.CornerRadius, Button: b.Button, }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + if b.Hidden { + return layout.Dimensions{} + } return b.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { colMacro := op.Record(gtx.Ops) paint.ColorOp{Color: b.Color}.Add(gtx.Ops) diff --git a/tracker/gioui/iconcache.go b/tracker/gioui/iconcache.go index b79eadb..f663a2a 100644 --- a/tracker/gioui/iconcache.go +++ b/tracker/gioui/iconcache.go @@ -10,6 +10,9 @@ var iconCache = map[*byte]*widget.Icon{} // widgetForIcon returns a widget for IconVG data, but caching the results func widgetForIcon(icon []byte) *widget.Icon { + if icon == nil { + return nil + } if widget, ok := iconCache[&icon[0]]; ok { return widget } diff --git a/tracker/gioui/label.go b/tracker/gioui/label.go index 1c2081a..5e78993 100644 --- a/tracker/gioui/label.go +++ b/tracker/gioui/label.go @@ -46,5 +46,17 @@ func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions { } func Label(str string, color color.NRGBA, shaper *text.Shaper) layout.Widget { - return LabelStyle{Text: str, Color: color, ShadeColor: black, Font: labelDefaultFont, FontSize: labelDefaultFontSize, Alignment: layout.W, Shaper: shaper}.Layout + return SizedLabel(str, color, shaper, labelDefaultFontSize) +} + +func SizedLabel(str string, color color.NRGBA, shaper *text.Shaper, fontSize unit.Sp) layout.Widget { + return LabelStyle{ + Text: str, + Color: color, + ShadeColor: black, + Font: labelDefaultFont, + FontSize: fontSize, + Alignment: layout.W, + Shaper: shaper, + }.Layout } diff --git a/tracker/gioui/menu.go b/tracker/gioui/menu.go index 3597d31..7c552b9 100644 --- a/tracker/gioui/menu.go +++ b/tracker/gioui/menu.go @@ -12,8 +12,6 @@ import ( "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" - "gioui.org/widget" - "gioui.org/widget/material" "github.com/vsariola/sointu/tracker" ) @@ -103,12 +101,12 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { } icon := widgetForIcon(item.IconBytes) iconColor := m.IconColor - if !item.Doer.Allowed() { - iconColor = mediumEmphasisTextColor - } iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)} textLabel := LabelStyle{Text: item.Text, FontSize: m.FontSize, Color: m.TextColor, Shaper: m.Shaper} if !item.Doer.Allowed() { + // note: might be a bug in gioui, but for iconColor = mediumEmphasisTextColor + // this does not render the icon at all. other colors seem to work fine. + iconColor = disabledTextColor textLabel.Color = mediumEmphasisTextColor } shortcutLabel := LabelStyle{Text: item.ShortcutText, FontSize: m.FontSize, Color: m.ShortCutColor, Shaper: m.Shaper} @@ -116,13 +114,18 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D { 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 { - p := gtx.Dp(unit.Dp(m.IconSize)) + p := gtx.Dp(m.IconSize) gtx.Constraints.Min = image.Pt(p, p) + if icon == nil { + return D{Size: gtx.Constraints.Min} + } return icon.Layout(gtx, iconColor) }) }), layout.Rigid(textLabel.Layout), - layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Max.X, 1)} }), + 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) }), @@ -168,14 +171,14 @@ func PopupMenu(menu *Menu, shaper *text.Shaper) MenuStyle { } } -func (tr *Tracker) layoutMenu(gtx C, title string, clickable *widget.Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget { +func (tr *Tracker) layoutMenu(gtx C, title string, clickable *Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget { for clickable.Clicked(gtx) { menu.Visible = true } m := PopupMenu(menu, tr.Theme.Shaper) return func(gtx C) D { defer op.Offset(image.Point{}).Push(gtx.Ops).Pop() - titleBtn := material.Button(tr.Theme, clickable, title) + titleBtn := Button(tr.Theme, clickable, title) titleBtn.Color = white titleBtn.Background = transparent titleBtn.CornerRadius = unit.Dp(0) diff --git a/tracker/gioui/note_editor.go b/tracker/gioui/note_editor.go index 0006878..764b544 100644 --- a/tracker/gioui/note_editor.go +++ b/tracker/gioui/note_editor.go @@ -158,11 +158,7 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { 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) - } + voiceUpDown := NumericUpDownPadded(t.Theme, te.TrackVoices, "Number of voices for this track", 1) 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") @@ -176,7 +172,7 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D { layout.Rigid(effectBtnStyle.Layout), layout.Rigid(uniqueBtnStyle.Layout), layout.Rigid(Label(" Voices:", white, t.Theme.Shaper)), - layout.Rigid(voiceUpDown), + layout.Rigid(voiceUpDown.Layout), layout.Rigid(splitTrackBtnStyle.Layout), layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Rigid(midiInBtnStyle.Layout), @@ -287,15 +283,15 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { c = cursorColor } if hasTrackMidiIn { - c = cursorForTrackMidiInColor + c = trackMidiInCurrentColor } - te.paintColumnCell(gtx, x, t, c) + te.paintColumnCell(gtx, x, t, c, hasTrackMidiIn) } // draw the corresponding "fake cursors" for instrument-track-groups (for polyphony) - if hasTrackMidiIn { + if hasTrackMidiIn && y == cursor.Y { for _, trackIndex := range t.Model.TracksWithSameInstrumentAsCurrent() { - if x == trackIndex && y == cursor.Y { - te.paintColumnCell(gtx, x, t, cursorNeighborForTrackMidiInColor) + if x == trackIndex { + te.paintColumnCell(gtx, x, t, trackMidiInAdditionalColor, hasTrackMidiIn) } } } @@ -334,10 +330,10 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D { return table.Layout(gtx) } -func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA) { +func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA, ignoreEffect bool) { cw := gtx.Constraints.Min.X cx := 0 - if t.Model.Notes().Effect(x) { + if t.Model.Notes().Effect(x) && !ignoreEffect { cw /= 2 if t.Model.Notes().LowNibble() { cx += cw diff --git a/tracker/gioui/numericupdown.go b/tracker/gioui/numericupdown.go index 3b788bc..b1a86cd 100644 --- a/tracker/gioui/numericupdown.go +++ b/tracker/gioui/numericupdown.go @@ -48,7 +48,9 @@ type NumericUpDownStyle struct { Tooltip component.Tooltip Width unit.Dp Height unit.Dp + Padding unit.Dp shaper text.Shaper + Hidden bool } func NewNumberInput(v tracker.Int) *NumberInput { @@ -56,6 +58,10 @@ func NewNumberInput(v tracker.Int) *NumberInput { } func NumericUpDown(th *material.Theme, number *NumberInput, tooltip string) NumericUpDownStyle { + return NumericUpDownPadded(th, number, tooltip, 0) +} + +func NumericUpDownPadded(th *material.Theme, number *NumberInput, tooltip string, padding int) NumericUpDownStyle { bgColor := th.Palette.Fg bgColor.R /= 4 bgColor.G /= 4 @@ -74,11 +80,22 @@ func NumericUpDown(th *material.Theme, number *NumberInput, tooltip string) Nume Tooltip: Tooltip(th, tooltip), Width: unit.Dp(70), Height: unit.Dp(20), + Padding: unit.Dp(padding), shaper: *th.Shaper, } } func (s *NumericUpDownStyle) Layout(gtx C) D { + if s.Hidden { + return D{} + } + if s.Padding <= 0 { + return s.layoutWithTooltip(gtx) + } + return layout.UniformInset(s.Padding).Layout(gtx, s.layoutWithTooltip) +} + +func (s *NumericUpDownStyle) layoutWithTooltip(gtx C) D { if s.Tooltip.Text.Text != "" { return s.NumberInput.tipArea.Layout(gtx, s.Tooltip, s.actualLayout) } diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index b3ddac8..e08fa7f 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -7,14 +7,13 @@ import ( "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" - "gioui.org/widget" "github.com/vsariola/sointu/tracker" "github.com/vsariola/sointu/version" "golang.org/x/exp/shiny/materialdesign/icons" ) type SongPanel struct { - MenuBar []widget.Clickable + MenuBar []Clickable Menus []Menu BPM *NumberInput RowsPerPattern *NumberInput @@ -57,7 +56,7 @@ type SongPanel struct { func NewSongPanel(model *tracker.Model) *SongPanel { ret := &SongPanel{ - MenuBar: make([]widget.Clickable, 3), + MenuBar: make([]Clickable, 3), Menus: make([]Menu, 3), BPM: NewNumberInput(model.BPM().Int()), RowsPerPattern: NewNumberInput(model.RowsPerPattern().Int()), diff --git a/tracker/gioui/theme.go b/tracker/gioui/theme.go index 1df08c7..9beffc0 100644 --- a/tracker/gioui/theme.go +++ b/tracker/gioui/theme.go @@ -60,8 +60,10 @@ var activeLightSurfaceColor = color.NRGBA{R: 45, G: 45, B: 45, A: 255} var cursorColor = color.NRGBA{R: 100, G: 140, B: 255, A: 48} var selectionColor = color.NRGBA{R: 100, G: 140, B: 255, A: 12} var inactiveSelectionColor = color.NRGBA{R: 140, G: 140, B: 140, A: 16} -var cursorForTrackMidiInColor = color.NRGBA{R: 255, G: 100, B: 140, A: 48} -var cursorNeighborForTrackMidiInColor = color.NRGBA{R: 255, G: 100, B: 140, A: 24} + +var trackMidiInCurrentColor = color.NRGBA{R: 255, G: 100, B: 140, A: 48} +var trackMidiInAdditionalColor = withScaledAlpha(trackMidiInCurrentColor, 0.7) +var trackMidiVelInColor = withScaledAlpha(trackMidiInCurrentColor, 0.3) var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255} @@ -75,3 +77,13 @@ var dialogBgColor = color.NRGBA{R: 0, G: 0, B: 0, A: 224} var paramIsSendTargetColor = color.NRGBA{R: 120, G: 120, B: 210, A: 255} var paramValueInvalidColor = color.NRGBA{R: 120, G: 120, B: 120, A: 190} + +func withScaledAlpha(c color.NRGBA, factor float32) color.NRGBA { + A := factor * float32(c.A) + return color.NRGBA{ + R: c.R, + G: c.G, + B: c.B, + A: uint8(A), + } +} diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index f104924..b6f540e 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -101,9 +101,7 @@ func NewTracker(model *tracker.Model) *Tracker { func (t *Tracker) Main() { titleFooter := "" - w := new(app.Window) - w.Option(app.Title("Sointu Tracker")) - w.Option(app.Size(unit.Dp(800), unit.Dp(600))) + w := NewWindow() t.InstrumentEditor.Focus() recoveryTicker := time.NewTicker(time.Second * 30) t.Explorer = explorer.NewExplorer(w) @@ -127,9 +125,7 @@ func (t *Tracker) Main() { } if !t.Quitted() { // TODO: uh oh, there's no way of canceling the destroyevent in gioui? so we create a new window just to show the dialog - w = new(app.Window) - w.Option(app.Title("Sointu Tracker")) - w.Option(app.Size(unit.Dp(800), unit.Dp(600))) + w = NewWindow() t.Explorer = explorer.NewExplorer(w) go eventLoop(w, events, acks) } @@ -165,6 +161,16 @@ func (t *Tracker) Main() { t.quitWG.Done() } +func NewWindow() *app.Window { + w := new(app.Window) + w.Option(app.Title("Sointu Tracker")) + w.Option( + app.Size(unit.Dp(800), unit.Dp(600)), + app.Fullscreen.Option(), + ) + return w +} + func eventLoop(w *app.Window, events chan<- event.Event, acks <-chan struct{}) { // Iterate window events, sending each to the old event loop and waiting for // a signal that processing is complete before iterating again. diff --git a/tracker/gioui/unit_editor.go b/tracker/gioui/unit_editor.go index bd65463..3ff8ed7 100644 --- a/tracker/gioui/unit_editor.go +++ b/tracker/gioui/unit_editor.go @@ -32,7 +32,7 @@ type UnitEditor struct { CopyUnitBtn *TipClickable ClearUnitBtn *ActionClickable DisableUnitBtn *BoolClickable - SelectTypeBtn *widget.Clickable + SelectTypeBtn *Clickable commentEditor *Editor caser cases.Caser @@ -47,7 +47,7 @@ func NewUnitEditor(m *tracker.Model) *UnitEditor { ClearUnitBtn: NewActionClickable(m.ClearUnit()), DisableUnitBtn: NewBoolClickable(m.UnitDisabled().Bool()), CopyUnitBtn: new(TipClickable), - SelectTypeBtn: new(widget.Clickable), + SelectTypeBtn: new(Clickable), commentEditor: NewEditor(widget.Editor{SingleLine: true, Submit: true}), sliderList: NewDragList(m.Params().List(), layout.Vertical), searchList: NewDragList(m.SearchResults().List(), layout.Vertical), @@ -236,9 +236,9 @@ func (pe *UnitEditor) command(e key.Event, t *Tracker) { type ParameterWidget struct { floatWidget widget.Float boolWidget widget.Bool - instrBtn widget.Clickable + instrBtn Clickable instrMenu Menu - unitBtn widget.Clickable + unitBtn Clickable unitMenu Menu Parameter tracker.Parameter tipArea component.TipArea @@ -332,7 +332,6 @@ func (p ParameterStyle) Layout(gtx C) D { gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40)) instrItems := make([]MenuItem, p.tracker.Instruments().Count()) for i := range instrItems { - i := i name, _, _, _ := p.tracker.Instruments().Item(i) instrItems[i].Text = name instrItems[i].IconBytes = icons.NavigationChevronRight diff --git a/tracker/optional_int.go b/tracker/optional_int.go new file mode 100644 index 0000000..dbdfaa3 --- /dev/null +++ b/tracker/optional_int.go @@ -0,0 +1,42 @@ +package tracker + +type ( + // OptionalInt tries to follow the same convention as e.g. Int{...} or Bool{...} + // Do not confuse with types.OptionalInteger, which you might use as a model, + // but don't necessarily have to. + OptionalInt struct { + optionalIntData + } + + optionalIntData interface { + Unpack() (int, bool) + Value() int + Range() intRange + + setValue(int) + unsetValue() + change(kind string) func() + } + + TrackMidiVelIn Model +) + +func (v OptionalInt) Set(value int, present bool) (ok bool) { + if !present { + v.unsetValue() + return true + } + // TODO: can we deduplicate this by referencing Int{...}.Set(value) ? + r := v.Range() + if v.Equals(value, present) || value < r.Min || value > r.Max { + return false + } + defer v.change("Set")() + v.setValue(value) + return true +} + +func (v OptionalInt) Equals(value int, present bool) bool { + oldValue, oldPresent := v.Unpack() + return value == oldValue && present == oldPresent +} diff --git a/tracker/types/optional.go b/tracker/types/optional.go new file mode 100644 index 0000000..7b36690 --- /dev/null +++ b/tracker/types/optional.go @@ -0,0 +1,47 @@ +package types + +type ( + // OptionalInteger is the simple struct, not to be confused with tracker.OptionalInt. + // It implements the tracker.optionalIntData interface, without needing to know so. + OptionalInteger struct { + value int + exists bool + } +) + +func NewOptionalInteger(value int, exists bool) OptionalInteger { + return OptionalInteger{value, exists} +} + +func NewOptionalIntegerOf(value int) OptionalInteger { + return OptionalInteger{ + value: value, + exists: true, + } +} + +func NewEmptyOptionalInteger() OptionalInteger { + // could also just use OptionalInteger{} + return OptionalInteger{ + exists: false, + } +} + +func (i OptionalInteger) Unpack() (int, bool) { + return i.value, i.exists +} + +func (i OptionalInteger) Value() int { + if !i.exists { + panic("Access value of empty OptionalInteger") + } + return i.value +} + +func (i OptionalInteger) Empty() bool { + return !i.exists +} + +func (i OptionalInteger) Equals(value int) bool { + return i.exists && i.value == value +}