From 33f7b5fb6ade37dcd993bef4afa24682a4f0d88e Mon Sep 17 00:00:00 2001 From: "5684185+vsariola@users.noreply.github.com" <5684185+vsariola@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:15:46 +0300 Subject: [PATCH] refactor(tracker/gioui): Dialog binds to Model during Layout --- tracker/gioui/dialog.go | 221 +++++++++++++++++++++------------------ tracker/gioui/theme.go | 8 +- tracker/gioui/theme.yml | 13 ++- tracker/gioui/tracker.go | 41 ++++---- 4 files changed, 149 insertions(+), 134 deletions(-) diff --git a/tracker/gioui/dialog.go b/tracker/gioui/dialog.go index 70ed777..5ecec49 100644 --- a/tracker/gioui/dialog.go +++ b/tracker/gioui/dialog.go @@ -1,70 +1,143 @@ package gioui import ( + "fmt" "image/color" "gioui.org/io/key" "gioui.org/layout" "gioui.org/op/paint" - "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "github.com/vsariola/sointu/tracker" ) -type Dialog struct { - BtnAlt widget.Clickable - BtnOk widget.Clickable - BtnCancel widget.Clickable +const DIALOG_MAX_BTNS = 3 - ok, alt, cancel tracker.Action -} +type ( + // DialogState is the state that needs to be retained between frames + DialogState struct { + Clickables [DIALOG_MAX_BTNS]widget.Clickable -type DialogStyle struct { - dialog *Dialog - Title string - Text string - Inset layout.Inset - TextInset layout.Inset - AltStyle material.ButtonStyle - OkStyle material.ButtonStyle - CancelStyle material.ButtonStyle - Theme *Theme -} + visible bool // this is used to control the visibility of the dialog + } -func NewDialog(ok, alt, cancel tracker.Action) *Dialog { - ret := &Dialog{ok: ok, alt: alt, cancel: cancel} + // DialogStyle is the style for a dialog that is store in the theme.yml + DialogStyle struct { + TitleInset layout.Inset + TextInset layout.Inset + ButtonStyle ButtonStyle + Title LabelStyle + Text LabelStyle + Bg color.NRGBA + Buttons ButtonStyle + } + // Dialog is the widget with a Layout method that can be used to display a dialog. + Dialog struct { + Theme *Theme + State *DialogState + Style *DialogStyle + Btns [DIALOG_MAX_BTNS]DialogButton + NumBtns int + Title string + Text string + } + + DialogButton struct { + Text string + Action tracker.Action + } +) + +func MakeDialog(th *Theme, d *DialogState, title, text string, btns ...DialogButton) Dialog { + ret := Dialog{ + Theme: th, + Style: &th.Dialog, + State: d, + Title: title, + Text: text, + } + if len(btns) > DIALOG_MAX_BTNS { + panic(fmt.Sprintf("too many buttons for dialog: %d, max is %d", len(btns), DIALOG_MAX_BTNS)) + } + copy(ret.Btns[:], btns) + ret.NumBtns = len(btns) + d.visible = true return ret } -func ConfirmDialog(gtx C, th *Theme, dialog *Dialog, title, text string) DialogStyle { - ret := DialogStyle{ - dialog: dialog, - Title: title, - Text: text, - Inset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12), Left: unit.Dp(20), Right: unit.Dp(20)}, - TextInset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12)}, - AltStyle: material.Button(&th.Material, &dialog.BtnAlt, "Alt"), - OkStyle: material.Button(&th.Material, &dialog.BtnOk, "Ok"), - CancelStyle: material.Button(&th.Material, &dialog.BtnCancel, "Cancel"), - Theme: th, - } - for _, b := range [...]*material.ButtonStyle{&ret.AltStyle, &ret.OkStyle, &ret.CancelStyle} { - b.Background = color.NRGBA{} - b.Inset = layout.UniformInset(unit.Dp(6)) - b.Color = th.Material.Palette.Fg - } - return ret +func DialogBtn(text string, action tracker.Action) DialogButton { + return DialogButton{Text: text, Action: action} } -func (d *Dialog) handleKeysForButton(gtx C, btn, next, prev *widget.Clickable) { +func (d *Dialog) Layout(gtx C) D { + anyFocused := false + for i := 0; i < d.NumBtns; i++ { + anyFocused = anyFocused || gtx.Source.Focused(&d.State.Clickables[i]) + } + if !anyFocused { + gtx.Execute(key.FocusCmd{Tag: &d.State.Clickables[d.NumBtns-1]}) + } + d.handleKeys(gtx) + paint.Fill(gtx.Ops, d.Style.Bg) + return layout.Center.Layout(gtx, func(gtx C) D { + return Popup(d.Theme, &d.State.visible).Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return d.Style.TitleInset.Layout(gtx, Label(d.Theme, &d.Style.Title, d.Title).Layout) + }), + layout.Rigid(func(gtx C) D { + return d.Style.TextInset.Layout(gtx, Label(d.Theme, &d.Style.Text, d.Text).Layout) + }), + layout.Rigid(func(gtx C) D { + return layout.E.Layout(gtx, func(gtx C) D { + var fcs [DIALOG_MAX_BTNS]layout.FlexChild + var actBtns [DIALOG_MAX_BTNS]material.ButtonStyle + for i := 0; i < d.NumBtns; i++ { + actBtns[i] = material.Button(&d.Theme.Material, &d.State.Clickables[i], d.Btns[i].Text) + actBtns[i].Background = d.Style.Buttons.Background + actBtns[i].Color = d.Style.Buttons.Color + actBtns[i].TextSize = d.Style.Buttons.TextSize + actBtns[i].Font = d.Style.Buttons.Font + actBtns[i].Inset = d.Style.Buttons.Inset + actBtns[i].CornerRadius = d.Style.Buttons.CornerRadius + } + // putting this inside these inside the for loop + // cause heap escapes, so that's why this ugliness; + // remember to update if you change the + // DIAOLG_MAX_BTNS constant + fcs[0] = layout.Rigid(actBtns[0].Layout) + fcs[1] = layout.Rigid(actBtns[1].Layout) + fcs[2] = layout.Rigid(actBtns[2].Layout) + gtx.Constraints.Min.Y = gtx.Dp(d.Style.Buttons.Height) + return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx, fcs[:d.NumBtns]...) + }) + }), + ) + }) + }) +} + +func (d *Dialog) handleKeys(gtx C) { + for i := 0; i < d.NumBtns; i++ { + for d.State.Clickables[i].Clicked(gtx) { + d.Btns[i].Action.Do() + } + d.handleKeysForButton(gtx, (i+d.NumBtns-1)%d.NumBtns, i, (i+1)%d.NumBtns) + } +} + +func (d *Dialog) handleKeysForButton(gtx C, prev, cur, next int) { + cPrev := &d.State.Clickables[prev] + cCur := &d.State.Clickables[cur] + cNext := &d.State.Clickables[next] for { e, ok := gtx.Event( - key.Filter{Focus: btn, Name: key.NameLeftArrow}, - key.Filter{Focus: btn, Name: key.NameRightArrow}, - key.Filter{Focus: btn, Name: key.NameEscape}, - key.Filter{Focus: btn, Name: key.NameTab, Optional: key.ModShift}, + key.Filter{Focus: cCur, Name: key.NameLeftArrow}, + key.Filter{Focus: cCur, Name: key.NameRightArrow}, + key.Filter{Focus: cCur, Name: key.NameEscape}, + key.Filter{Focus: cCur, Name: key.NameTab, Optional: key.ModShift}, ) if !ok { break @@ -72,68 +145,12 @@ func (d *Dialog) handleKeysForButton(gtx C, btn, next, prev *widget.Clickable) { if e, ok := e.(key.Event); ok && e.State == key.Press { switch { case e.Name == key.NameLeftArrow || (e.Name == key.NameTab && e.Modifiers.Contain(key.ModShift)): - gtx.Execute(key.FocusCmd{Tag: prev}) + gtx.Execute(key.FocusCmd{Tag: cPrev}) case e.Name == key.NameRightArrow || (e.Name == key.NameTab && !e.Modifiers.Contain(key.ModShift)): - gtx.Execute(key.FocusCmd{Tag: next}) + gtx.Execute(key.FocusCmd{Tag: cNext}) case e.Name == key.NameEscape: - d.cancel.Do() + d.Btns[d.NumBtns-1].Action.Do() // last button is always the cancel button } } } } - -func (d *Dialog) handleKeys(gtx C) { - for d.BtnOk.Clicked(gtx) { - d.ok.Do() - } - for d.BtnAlt.Clicked(gtx) { - d.alt.Do() - } - for d.BtnCancel.Clicked(gtx) { - d.cancel.Do() - } - if d.alt.Enabled() && d.cancel.Enabled() { - d.handleKeysForButton(gtx, &d.BtnAlt, &d.BtnCancel, &d.BtnOk) - d.handleKeysForButton(gtx, &d.BtnCancel, &d.BtnOk, &d.BtnAlt) - d.handleKeysForButton(gtx, &d.BtnOk, &d.BtnAlt, &d.BtnCancel) - } else if d.ok.Enabled() { - d.handleKeysForButton(gtx, &d.BtnOk, &d.BtnCancel, &d.BtnCancel) - d.handleKeysForButton(gtx, &d.BtnCancel, &d.BtnOk, &d.BtnOk) - } else { - d.handleKeysForButton(gtx, &d.BtnCancel, &d.BtnCancel, &d.BtnCancel) - } -} - -func (d *DialogStyle) Layout(gtx C) D { - if !gtx.Source.Focused(&d.dialog.BtnOk) && !gtx.Source.Focused(&d.dialog.BtnCancel) && !gtx.Source.Focused(&d.dialog.BtnAlt) { - gtx.Execute(key.FocusCmd{Tag: &d.dialog.BtnCancel}) - } - d.dialog.handleKeys(gtx) - paint.Fill(gtx.Ops, d.Theme.Dialog.Bg) - visible := true - return layout.Center.Layout(gtx, func(gtx C) D { - return Popup(d.Theme, &visible).Layout(gtx, func(gtx C) D { - return d.Inset.Layout(gtx, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(Label(d.Theme, &d.Theme.Dialog.Title, d.Title).Layout), - layout.Rigid(Label(d.Theme, &d.Theme.Dialog.Text, d.Text).Layout), - layout.Rigid(func(gtx C) D { - return layout.E.Layout(gtx, func(gtx C) D { - fl := layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween} - ok := layout.Rigid(d.OkStyle.Layout) - alt := layout.Rigid(d.AltStyle.Layout) - cancel := layout.Rigid(d.CancelStyle.Layout) - if d.dialog.alt.Enabled() && d.dialog.cancel.Enabled() { - return fl.Layout(gtx, ok, alt, cancel) - } - if d.dialog.ok.Enabled() { - return fl.Layout(gtx, ok, cancel) - } - return fl.Layout(gtx, cancel) - }) - }), - ) - }) - }) - }) -} diff --git a/tracker/gioui/theme.go b/tracker/gioui/theme.go index 2f22481..6c2a94e 100644 --- a/tracker/gioui/theme.go +++ b/tracker/gioui/theme.go @@ -27,8 +27,6 @@ type Theme struct { } Oscilloscope OscilloscopeStyle NumericUpDown NumericUpDownStyle - DialogTitle LabelStyle - DialogText LabelStyle SongPanel struct { RowHeader LabelStyle RowValue LabelStyle @@ -55,11 +53,7 @@ type Theme struct { OneBeat color.NRGBA TwoBeat color.NRGBA } - Dialog struct { - Bg color.NRGBA - Title LabelStyle - Text LabelStyle - } + Dialog DialogStyle OrderEditor struct { TrackTitle LabelStyle RowTitle LabelStyle diff --git a/tracker/gioui/theme.yml b/tracker/gioui/theme.yml index 5d6dbea..41b2b7d 100644 --- a/tracker/gioui/theme.yml +++ b/tracker/gioui/theme.yml @@ -33,7 +33,7 @@ button: cornerradius: &buttoncornerradius 18 height: &buttonheight 36 inset: &buttoninset { top: 0, bottom: 0, left: 6, right: 6 } - text: + text: &textbutton background: *transparentcolor color: *primarycolor textsize: *buttontextsize @@ -110,10 +110,6 @@ alert: info: bg: { r: 50, g: 50, b: 51, a: 255 } text: { textsize: 16, color: *highemphasis, shadowcolor: *black } -dialog: - bg: { r: 0, g: 0, b: 0, a: 224 } - title: { textsize: 16, color: *highemphasis, shadowcolor: *black } - text: { textsize: 16, color: *highemphasis, shadowcolor: *black } ordereditor: tracktitle: { textsize: 12, color: *mediumemphasis } rowtitle: @@ -189,3 +185,10 @@ tooltip: { color: *white, bg: *black } popup: bg: { r: 50, g: 50, b: 51, a: 255 } shadow: { r: 0, g: 0, b: 0, a: 192 } +dialog: + bg: { r: 0, g: 0, b: 0, a: 224 } + title: { textsize: 16, color: *highemphasis, shadowcolor: *black } + text: { textsize: 16, color: *highemphasis, shadowcolor: *black } + titleinset: { top: 12, left: 20, right: 20 } + textinset: { top: 12, bottom: 12, left: 20, right: 20 } + buttons: *textbutton diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index e32578b..e5f5109 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -40,9 +40,7 @@ type ( PopupAlert *PopupAlert Zoom int - SaveChangesDialog *Dialog - WaveTypeDialog *Dialog - LicenseDialog *Dialog + DialogState *DialogState ModalDialog layout.Widget InstrumentEditor *InstrumentEditor @@ -85,12 +83,10 @@ func NewTracker(model *tracker.Model) *Tracker { BottomHorizontalSplit: &Split{Ratio: -.6, MinSize1: 180, MinSize2: 180}, VerticalSplit: &Split{Axis: layout.Vertical, MinSize1: 180, MinSize2: 180}, - SaveChangesDialog: NewDialog(model.SaveSong(), model.DiscardSong(), model.Cancel()), - WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()), - LicenseDialog: NewDialog(tracker.MakeAction(nil), tracker.MakeAction(nil), model.Cancel()), - InstrumentEditor: NewInstrumentEditor(model), - OrderEditor: NewOrderEditor(model), - TrackEditor: NewNoteEditor(model), + DialogState: new(DialogState), + InstrumentEditor: NewInstrumentEditor(model), + OrderEditor: NewOrderEditor(model), + TrackEditor: NewNoteEditor(model), Zoom: 6, @@ -275,15 +271,19 @@ func (t *Tracker) showDialog(gtx C) { } switch t.Dialog() { case tracker.NewSongChanges, tracker.OpenSongChanges, tracker.QuitChanges: - dstyle := ConfirmDialog(gtx, t.Theme, t.SaveChangesDialog, "Save changes to song?", "Your changes will be lost if you don't save them.") - dstyle.OkStyle.Text = "Save" - dstyle.AltStyle.Text = "Don't save" - dstyle.Layout(gtx) + dialog := MakeDialog(t.Theme, t.DialogState, "Save changes to song?", "Your changes will be lost if you don't save them.", + DialogBtn("Save", t.SaveSong()), + DialogBtn("Don't save", t.DiscardSong()), + DialogBtn("Cancel", t.Cancel()), + ) + dialog.Layout(gtx) case tracker.Export: - dstyle := ConfirmDialog(gtx, t.Theme, t.WaveTypeDialog, "", "Export .wav in int16 or float32 sample format?") - dstyle.OkStyle.Text = "Int16" - dstyle.AltStyle.Text = "Float32" - dstyle.Layout(gtx) + dialog := MakeDialog(t.Theme, t.DialogState, "Export format", "Choose the sample format for the exported .wav file.", + DialogBtn("Int16", t.ExportInt16()), + DialogBtn("Float32", t.ExportFloat()), + DialogBtn("Cancel", t.Cancel()), + ) + dialog.Layout(gtx) case tracker.OpenSongOpenExplorer: t.explorerChooseFile(t.ReadSong, ".yml", ".json") case tracker.NewSongSaveExplorer, tracker.OpenSongSaveExplorer, tracker.QuitSaveExplorer, tracker.SaveAsExplorer: @@ -301,9 +301,10 @@ func (t *Tracker) showDialog(gtx C) { t.WriteWav(wc, t.Dialog() == tracker.ExportInt16Explorer) }, filename) case tracker.License: - dstyle := ConfirmDialog(gtx, t.Theme, t.LicenseDialog, "License", sointu.License) - dstyle.CancelStyle.Text = "Close" - dstyle.Layout(gtx) + dialog := MakeDialog(t.Theme, t.DialogState, "License", sointu.License, + DialogBtn("Close", t.Cancel()), + ) + dialog.Layout(gtx) } }