Files
sointu/tracker/gioui/unit_editor.go
5684185+vsariola@users.noreply.github.com 666af9433e feat!: display the parameters as knobs in a grid
Also removed the negbandpass & neghighpass parameters
and replaced them with bandpass & highpass set to -1, to
fit the switches better to the GUI.

Closes #51, closes #173
2025-07-08 19:47:32 +03:00

446 lines
15 KiB
Go

package gioui
import (
"bytes"
"image"
"image/color"
"io"
"math"
"time"
"gioui.org/f32"
"gioui.org/io/clipboard"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type (
UnitEditor struct {
paramTable *ScrollTable
searchList *DragList
Parameters [][]*ParamState
DeleteUnitBtn *Clickable
CopyUnitBtn *Clickable
ClearUnitBtn *Clickable
DisableUnitBtn *Clickable
SelectTypeBtn *Clickable
commentEditor *Editor
caser cases.Caser
copyHint string
disableUnitHint string
enableUnitHint string
searching tracker.Bool
}
)
func NewUnitEditor(m *tracker.Model) *UnitEditor {
ret := &UnitEditor{
DeleteUnitBtn: new(Clickable),
ClearUnitBtn: new(Clickable),
DisableUnitBtn: new(Clickable),
CopyUnitBtn: new(Clickable),
SelectTypeBtn: new(Clickable),
commentEditor: NewEditor(true, true, text.Start),
paramTable: NewScrollTable(m.Params().Table(), m.ParamVertList().List(), m.Units().List()),
searchList: NewDragList(m.SearchResults().List(), layout.Vertical),
searching: m.UnitSearching(),
}
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
}
func (pe *UnitEditor) Layout(gtx C) D {
t := TrackerFromContext(gtx)
pe.update(gtx, t)
editorFunc := pe.layoutRack
if pe.showingChooser() {
editorFunc = pe.layoutUnitTypeChooser
}
return Surface{Gray: 24, Focus: t.PatchPanel.TreeFocused(gtx)}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, editorFunc),
layout.Rigid(pe.layoutFooter),
)
})
}
func (pe *UnitEditor) showingChooser() bool {
return pe.searching.Value()
}
func (pe *UnitEditor) update(gtx C, t *Tracker) {
for pe.CopyUnitBtn.Clicked(gtx) {
if contents, ok := t.Units().List().CopyElements(); ok {
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))})
t.Alerts().Add("Unit(s) copied to clipboard", tracker.Info)
}
}
for pe.SelectTypeBtn.Clicked(gtx) {
pe.ChooseUnitType(t)
}
for pe.commentEditor.Update(gtx, t.UnitComment()) != EditorEventNone {
t.FocusPrev(gtx, false)
}
for pe.ClearUnitBtn.Clicked(gtx) {
t.ClearUnit().Do()
t.UnitSearch().SetValue("")
t.UnitSearching().SetValue(true)
pe.searchList.Focus()
}
for {
e, ok := gtx.Event(
key.Filter{Focus: pe.searchList, Name: key.NameEnter},
key.Filter{Focus: pe.searchList, Name: key.NameReturn},
key.Filter{Focus: pe.searchList, Name: key.NameEscape},
)
if !ok {
break
}
if e, ok := e.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameEscape:
t.UnitSearching().SetValue(false)
pe.paramTable.RowTitleList.Focus()
case key.NameEnter, key.NameReturn:
pe.ChooseUnitType(t)
}
}
}
for {
e, ok := gtx.Event(
key.Filter{Focus: pe.paramTable, Name: key.NameLeftArrow, Required: key.ModShift, Optional: key.ModShortcut},
key.Filter{Focus: pe.paramTable, Name: key.NameRightArrow, Required: key.ModShift, Optional: key.ModShortcut},
key.Filter{Focus: pe.paramTable, Name: key.NameDeleteBackward},
key.Filter{Focus: pe.paramTable, Name: key.NameDeleteForward},
)
if !ok {
break
}
if e, ok := e.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameLeftArrow:
t.Model.Params().Table().Add(-1, e.Modifiers.Contain(key.ModShortcut))
case key.NameRightArrow:
t.Model.Params().Table().Add(1, e.Modifiers.Contain(key.ModShortcut))
case key.NameDeleteBackward, key.NameDeleteForward:
t.Model.Params().Table().Clear()
}
c := t.Model.Params().Cursor()
if c.X >= 0 && c.Y >= 0 && c.Y < len(pe.Parameters) && c.X < len(pe.Parameters[c.Y]) {
ta := &pe.Parameters[c.Y][c.X].tipArea
ta.Appear(gtx.Now)
ta.Exit.SetTarget(gtx.Now.Add(ta.ExitDuration))
}
}
}
for {
e, ok := gtx.Event(
key.Filter{Focus: pe.paramTable.RowTitleList, Name: key.NameEnter},
key.Filter{Focus: pe.paramTable.RowTitleList, Name: key.NameReturn},
key.Filter{Focus: pe.paramTable.RowTitleList, Name: key.NameLeftArrow},
key.Filter{Focus: pe.paramTable.RowTitleList, Name: key.NameDeleteBackward},
)
if !ok {
break
}
if e, ok := e.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameLeftArrow:
t.PatchPanel.unitList.dragList.Focus()
case key.NameDeleteBackward:
t.ClearUnit().Do()
t.UnitSearch().SetValue("")
t.UnitSearching().SetValue(true)
pe.searchList.Focus()
case key.NameEnter, key.NameReturn:
t.Model.AddUnit(e.Modifiers.Contain(key.ModCtrl)).Do()
t.UnitSearch().SetValue("")
t.UnitSearching().SetValue(true)
pe.searchList.Focus()
}
}
}
}
func (pe *UnitEditor) ChooseUnitType(t *Tracker) {
if ut, ok := t.SearchResults().Item(pe.searchList.TrackerList.Selected()); ok {
t.Units().SetSelectedType(ut)
pe.paramTable.RowTitleList.Focus()
}
}
func (pe *UnitEditor) layoutRack(gtx C) D {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
t := TrackerFromContext(gtx)
// create enough parameter widget to match the number of parameters
width := pe.paramTable.Table.Width()
for len(pe.Parameters) < pe.paramTable.Table.Height() {
pe.Parameters = append(pe.Parameters, make([]*ParamState, 0))
}
cellWidth := gtx.Dp(t.Theme.UnitEditor.Width)
cellHeight := gtx.Dp(t.Theme.UnitEditor.Height)
rowTitleLabelWidth := gtx.Dp(t.Theme.UnitEditor.UnitList.LabelWidth)
rowTitleSignalWidth := gtx.Dp(t.Theme.SignalRail.SignalWidth) * t.RailWidth()
rowTitleWidth := rowTitleLabelWidth + rowTitleSignalWidth
signalError := t.RailError()
columnTitleHeight := gtx.Dp(0)
for i := range pe.Parameters {
for len(pe.Parameters[i]) < width {
pe.Parameters[i] = append(pe.Parameters[i], &ParamState{tipArea: TipArea{ExitDuration: time.Second * 2}})
}
}
coltitle := func(gtx C, x int) D {
return D{Size: image.Pt(cellWidth, columnTitleHeight)}
}
rowtitle := func(gtx C, y int) D {
if y < 0 || y >= len(pe.Parameters) {
return D{}
}
item := t.Units().Item(y)
sr := Rail(t.Theme, item.Signals)
label := Label(t.Theme, &t.Theme.UnitEditor.UnitList.Name, item.Type)
switch {
case item.Disabled:
label.LabelStyle = t.Theme.UnitEditor.UnitList.Disabled
case signalError.Err != nil && signalError.UnitIndex == y:
label.Color = t.Theme.UnitEditor.UnitList.Error
}
gtx.Constraints = layout.Exact(image.Pt(rowTitleWidth, cellHeight))
sr.Layout(gtx)
defer op.Affine(f32.Affine2D{}.Rotate(f32.Pt(0, 0), -90*math.Pi/180).Offset(f32.Point{X: float32(rowTitleSignalWidth), Y: float32(cellHeight)})).Push(gtx.Ops).Pop()
gtx.Constraints = layout.Exact(image.Pt(cellHeight, rowTitleLabelWidth))
label.Layout(gtx)
return D{Size: image.Pt(rowTitleWidth, cellHeight)}
}
cursor := t.Model.Params().Cursor()
cell := func(gtx C, x, y int) D {
gtx.Constraints = layout.Exact(image.Pt(cellWidth, cellHeight))
point := tracker.Point{X: x, Y: y}
if y < 0 || y >= len(pe.Parameters) || x < 0 || x >= len(pe.Parameters[y]) {
return D{}
}
selection := pe.paramTable.Table.Range()
if selection.Contains(point) {
color := t.Theme.Selection.Inactive
if gtx.Focused(pe.paramTable) {
color = t.Theme.Selection.Active
}
if point == cursor {
color = t.Theme.Cursor.Inactive
if gtx.Focused(pe.paramTable) {
color = t.Theme.Cursor.Active
}
}
paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op())
}
param := t.Model.Params().Item(point)
paramStyle := Param(param, t.Theme, pe.Parameters[y][x], pe.paramTable.Table.Cursor() == point, t.Units().Item(y).Disabled)
paramStyle.Layout(gtx)
comment := t.Units().Item(y).Comment
if comment != "" && x == t.Model.Params().RowWidth(y) {
label := Label(t.Theme, &t.Theme.UnitEditor.RackComment, comment)
return layout.W.Layout(gtx, func(gtx C) D {
gtx.Constraints.Max.X = 1e6
gtx.Constraints.Min.Y = 0
return label.Layout(gtx)
})
}
return D{Size: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)}
}
table := FilledScrollTable(t.Theme, pe.paramTable)
table.RowTitleWidth = gtx.Metric.PxToDp(rowTitleWidth)
table.ColumnTitleHeight = 0
table.CellWidth = t.Theme.UnitEditor.Width
table.CellHeight = t.Theme.UnitEditor.Height
pe.drawBackGround(gtx)
pe.drawSignals(gtx, rowTitleWidth)
dims := table.Layout(gtx, cell, coltitle, rowtitle, nil, nil)
return dims
}
func (pe *UnitEditor) drawSignals(gtx C, rowTitleWidth int) {
t := TrackerFromContext(gtx)
colP := pe.paramTable.ColTitleList.List.Position
rowP := pe.paramTable.RowTitleList.List.Position
p := image.Pt(rowTitleWidth, 0)
defer op.Offset(p).Push(gtx.Ops).Pop()
gtx.Constraints.Max = gtx.Constraints.Max.Sub(p)
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop()
defer op.Offset(image.Pt(-colP.Offset, -rowP.Offset)).Push(gtx.Ops).Pop()
for wire := range t.Wires {
clr := t.Theme.UnitEditor.WireColor
if wire.Highlight {
clr = t.Theme.UnitEditor.WireHighlight
}
switch {
case wire.FromSet && !wire.ToSet:
pe.drawRemoteSendSignal(gtx, wire, colP.First, rowP.First)
case !wire.FromSet && wire.ToSet:
pe.drawRemoteReceiveSignal(gtx, wire, colP.First, rowP.First, clr)
case wire.FromSet && wire.ToSet:
pe.drawSignal(gtx, wire, colP.First, rowP.First, clr)
}
}
}
func (pe *UnitEditor) drawBackGround(gtx C) {
t := TrackerFromContext(gtx)
rowP := pe.paramTable.RowTitleList.List.Position
defer op.Offset(image.Pt(0, -rowP.Offset)).Push(gtx.Ops).Pop()
for range pe.paramTable.RowTitleList.List.Position.Count + 1 {
paint.FillShape(gtx.Ops, t.Theme.UnitEditor.Divider, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, 1)}.Op())
op.Offset(image.Pt(0, gtx.Dp(t.Theme.UnitEditor.Height))).Add(gtx.Ops)
}
}
func (pe *UnitEditor) drawRemoteSendSignal(gtx C, wire tracker.Wire, col, row int) {
sy := wire.From - row
t := TrackerFromContext(gtx)
defer op.Offset(image.Pt(gtx.Dp(5), (sy+1)*gtx.Dp(t.Theme.UnitEditor.Height)-gtx.Dp(16))).Push(gtx.Ops).Pop()
Label(t.Theme, &t.Theme.UnitEditor.WireHint, wire.Hint).Layout(gtx)
}
func (pe *UnitEditor) drawRemoteReceiveSignal(gtx C, wire tracker.Wire, col, row int, clr color.NRGBA) {
ex := wire.To.X - col
ey := wire.To.Y - row
t := TrackerFromContext(gtx)
width := float32(gtx.Dp(t.Theme.UnitEditor.Width))
height := float32(gtx.Dp(t.Theme.UnitEditor.Height))
topLeft := f32.Pt(float32(ex)*width, float32(ey)*height)
center := topLeft.Add(f32.Pt(width/2, height/2))
c := float32(gtx.Dp(t.Theme.Knob.Diameter)) / 2 / float32(math.Sqrt2)
from := f32.Pt(c, c).Add(center)
q := c
c1 := f32.Pt(c+q, c+q).Add(center)
o := float32(gtx.Dp(8))
c2 := f32.Pt(width-q, height-o).Add(topLeft)
to := f32.Pt(width, height-o).Add(topLeft)
var path clip.Path
path.Begin(gtx.Ops)
path.MoveTo(from)
path.CubeTo(c1, c2, to)
paint.FillShape(gtx.Ops, clr,
clip.Stroke{
Path: path.End(),
Width: float32(gtx.Dp(t.Theme.SignalRail.LineWidth)),
}.Op())
defer op.Offset(image.Pt((ex+1)*gtx.Dp(t.Theme.UnitEditor.Width)+gtx.Dp(5), (ey+1)*gtx.Dp(t.Theme.UnitEditor.Height)-gtx.Dp(16))).Push(gtx.Ops).Pop()
Label(t.Theme, &t.Theme.UnitEditor.WireHint, wire.Hint).Layout(gtx)
}
func (pe *UnitEditor) drawSignal(gtx C, wire tracker.Wire, col, row int, clr color.NRGBA) {
sy := wire.From - row
ex := wire.To.X - col
ey := wire.To.Y - row
t := TrackerFromContext(gtx)
diam := gtx.Dp(t.Theme.Knob.Diameter)
c := float32(diam) / 2 / float32(math.Sqrt2)
width := float32(gtx.Dp(t.Theme.UnitEditor.Width))
height := float32(gtx.Dp(t.Theme.UnitEditor.Height))
from := f32.Pt(0, float32((sy+1)*gtx.Dp(t.Theme.UnitEditor.Height))-float32(gtx.Dp(8)))
corner := f32.Pt(1, 1)
if ex > 0 {
corner.X = -corner.X
}
if sy < ey {
corner.Y = -corner.Y
}
topLeft := f32.Pt(float32(ex)*width, float32(ey)*height)
center := topLeft.Add(f32.Pt(width/2, height/2))
to := mulVec(corner, f32.Pt(c, c)).Add(center)
p2 := mulVec(corner, f32.Pt(width/2, height/2)).Add(center)
p1 := f32.Pt(p2.X, float32((sy+1)*gtx.Dp(t.Theme.UnitEditor.Height)))
if sy > ey {
p1 = f32.Pt(p2.X, (float32(sy)+0.5)*float32(gtx.Dp(t.Theme.UnitEditor.Height))+float32(diam)/2)
}
k := float32(width) / 4
p2Tan := mulVec(corner, f32.Pt(-k, -k))
p1Tan := f32.Pt(k, p2Tan.Y)
fromTan := f32.Pt(k, 0)
var path clip.Path
path.Begin(gtx.Ops)
path.MoveTo(from)
path.CubeTo(from.Add(fromTan), p1.Sub(p1Tan), p1)
path.CubeTo(p1.Add(p1Tan), p2, to)
paint.FillShape(gtx.Ops, clr,
clip.Stroke{
Path: path.End(),
Width: float32(gtx.Dp(t.Theme.SignalRail.LineWidth)),
}.Op())
}
func mulVec(a, b f32.Point) f32.Point {
return f32.Pt(a.X*b.X, a.Y*b.Y)
}
func (pe *UnitEditor) layoutFooter(gtx C) D {
t := TrackerFromContext(gtx)
text := "Choose unit type"
if !t.UnitSearching().Value() {
text = pe.caser.String(t.Units().SelectedType())
}
hintText := Label(t.Theme, &t.Theme.UnitEditor.Hint, text)
deleteUnitBtn := ActionIconBtn(t.DeleteUnit(), t.Theme, pe.DeleteUnitBtn, icons.ActionDelete, "Delete unit (Ctrl+Backspace)")
copyUnitBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, pe.CopyUnitBtn, icons.ContentContentCopy, pe.copyHint)
disableUnitBtn := ToggleIconBtn(t.UnitDisabled(), t.Theme, pe.DisableUnitBtn, icons.AVVolumeUp, icons.AVVolumeOff, pe.disableUnitHint, pe.enableUnitHint)
clearUnitBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, pe.ClearUnitBtn, icons.ContentClear, "Clear unit")
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(deleteUnitBtn.Layout),
layout.Rigid(copyUnitBtn.Layout),
layout.Rigid(disableUnitBtn.Layout),
layout.Rigid(clearUnitBtn.Layout),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(130)
return hintText.Layout(gtx)
}),
layout.Flexed(1, func(gtx C) D {
return pe.commentEditor.Layout(gtx, t.UnitComment(), t.Theme, &t.Theme.InstrumentEditor.UnitComment, "Comment")
}),
)
}
func (pe *UnitEditor) layoutUnitTypeChooser(gtx C) D {
t := TrackerFromContext(gtx)
var namesArray [256]string
names := namesArray[:0]
for _, item := range t.Model.SearchResults().Iterate {
names = append(names, item)
}
element := func(gtx C, i int) D {
if i < 0 || i >= len(names) {
return D{}
}
w := Label(t.Theme, &t.Theme.UnitEditor.Chooser, names[i])
if i == pe.searchList.TrackerList.Selected() {
return pe.SelectTypeBtn.Layout(gtx, w.Layout)
}
return w.Layout(gtx)
}
fdl := FilledDragList(t.Theme, pe.searchList)
dims := fdl.Layout(gtx, element, nil)
gtx.Constraints = layout.Exact(dims.Size)
fdl.LayoutScrollBar(gtx)
return dims
}
func (t *UnitEditor) Tags(level int, yield TagYieldFunc) bool {
if t.showingChooser() {
return yield(level, t.searchList) && yield(level+1, &t.commentEditor.widgetEditor)
}
return yield(level+1, t.paramTable.RowTitleList) && yield(level, t.paramTable) && yield(level+1, &t.commentEditor.widgetEditor)
}