diff --git a/CHANGELOG.md b/CHANGELOG.md index 226db16..5f75298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). wrong scaling ([#150][i150]) - Empty patch should not crash the native synth ([#148][i148]) - sointu-play does not default to the native synth yet, choose via `-tags=native` +- Space Bar does not re-trigger the Button with current focus (Enter still does) ### Changed - The keyboard shortcuts are now again closer to what they were old trackers diff --git a/tracker/gioui/buttons.go b/tracker/gioui/buttons.go index 4e8de3c..3b31304 100644 --- a/tracker/gioui/buttons.go +++ b/tracker/gioui/buttons.go @@ -3,15 +3,16 @@ package gioui import ( "gioui.org/layout" "gioui.org/unit" - "gioui.org/widget" "gioui.org/widget/material" "gioui.org/x/component" "github.com/vsariola/sointu/tracker" + "github.com/vsariola/sointu/tracker/gioui/patch" + patched "github.com/vsariola/sointu/tracker/gioui/patch/material" ) type ( TipClickable struct { - Clickable widget.Clickable + Clickable patch.Clickable TipArea component.TipArea } @@ -22,12 +23,12 @@ type ( TipIconButtonStyle struct { TipArea *component.TipArea - IconButtonStyle material.IconButtonStyle + IconButtonStyle patched.IconButtonStyle Tooltip component.Tooltip } BoolClickable struct { - Clickable widget.Clickable + Clickable patch.Clickable TipArea component.TipArea Bool tracker.Bool } @@ -63,7 +64,7 @@ func ActionIcon(gtx C, th *material.Theme, w *ActionClickable, icon []byte, tip } func TipIcon(th *material.Theme, w *TipClickable, icon []byte, tip string) TipIconButtonStyle { - iconButtonStyle := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "") + iconButtonStyle := patched.IconButton(th, &w.Clickable, widgetForIcon(icon), "") iconButtonStyle.Color = primaryColor iconButtonStyle.Background = transparent iconButtonStyle.Inset = layout.UniformInset(unit.Dp(6)) @@ -84,7 +85,7 @@ func ToggleIcon(gtx C, th *material.Theme, w *BoolClickable, offIcon, onIcon []b for w.Clickable.Clicked(gtx) { w.Bool.Toggle() } - ibStyle := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "") + ibStyle := patched.IconButton(th, &w.Clickable, widgetForIcon(icon), "") ibStyle.Background = transparent ibStyle.Inset = layout.UniformInset(unit.Dp(6)) ibStyle.Color = primaryColor @@ -102,11 +103,11 @@ func (t *TipIconButtonStyle) Layout(gtx C) D { return t.TipArea.Layout(gtx, t.Tooltip, t.IconButtonStyle.Layout) } -func ActionButton(gtx C, th *material.Theme, w *ActionClickable, text string) material.ButtonStyle { +func ActionButton(gtx C, th *material.Theme, w *ActionClickable, text string) patched.ButtonStyle { for w.Clickable.Clicked(gtx) { w.Action.Do() } - ret := material.Button(th, &w.Clickable, text) + ret := patched.Button(th, &w.Clickable, text) ret.Color = th.Palette.Fg if !w.Action.Allowed() { ret.Color = disabledTextColor @@ -116,11 +117,11 @@ func ActionButton(gtx C, th *material.Theme, w *ActionClickable, text string) ma return ret } -func ToggleButton(gtx C, th *material.Theme, b *BoolClickable, text string) material.ButtonStyle { +func ToggleButton(gtx C, th *material.Theme, b *BoolClickable, text string) patched.ButtonStyle { for b.Clickable.Clicked(gtx) { b.Bool.Toggle() } - ret := material.Button(th, &b.Clickable, text) + ret := patched.Button(th, &b.Clickable, text) ret.Background = transparent ret.Inset = layout.UniformInset(unit.Dp(6)) if b.Bool.Value() { @@ -133,16 +134,16 @@ func ToggleButton(gtx C, th *material.Theme, b *BoolClickable, text string) mate return ret } -func LowEmphasisButton(th *material.Theme, w *widget.Clickable, text string) material.ButtonStyle { - ret := material.Button(th, w, text) +func LowEmphasisButton(th *material.Theme, w *patch.Clickable, text string) patched.ButtonStyle { + ret := patched.Button(th, w, text) ret.Color = th.Palette.Fg ret.Background = transparent ret.Inset = layout.UniformInset(unit.Dp(6)) return ret } -func HighEmphasisButton(th *material.Theme, w *widget.Clickable, text string) material.ButtonStyle { - ret := material.Button(th, w, text) +func HighEmphasisButton(th *material.Theme, w *patch.Clickable, text string) patched.ButtonStyle { + ret := patched.Button(th, w, text) ret.Color = th.Palette.ContrastFg ret.Background = th.Palette.Fg ret.Inset = layout.UniformInset(unit.Dp(6)) diff --git a/tracker/gioui/dialog.go b/tracker/gioui/dialog.go index cea3b9f..a84acc1 100644 --- a/tracker/gioui/dialog.go +++ b/tracker/gioui/dialog.go @@ -8,6 +8,7 @@ import ( "gioui.org/unit" "gioui.org/widget/material" "github.com/vsariola/sointu/tracker" + patched "github.com/vsariola/sointu/tracker/gioui/patch/material" ) type Dialog struct { @@ -22,9 +23,9 @@ type DialogStyle struct { Text string Inset layout.Inset TextInset layout.Inset - AltStyle material.ButtonStyle - OkStyle material.ButtonStyle - CancelStyle material.ButtonStyle + AltStyle patched.ButtonStyle + OkStyle patched.ButtonStyle + CancelStyle patched.ButtonStyle Shaper *text.Shaper } diff --git a/tracker/gioui/patch/WHAT_IS_THIS.md b/tracker/gioui/patch/WHAT_IS_THIS.md new file mode 100644 index 0000000..83d2415 --- /dev/null +++ b/tracker/gioui/patch/WHAT_IS_THIS.md @@ -0,0 +1,22 @@ +2024/10/27 qm210 + +As suggested by +https://github.com/vsariola/sointu/issues/156 +and further by +https://github.com/vsariola/sointu/issues/151 + +the current Gio (0.7.1) Buttons (Clickables) have intrinsic behaviour +to re-trigger when SPACE is pressed. + +This is so not what anyone using a Tracker expects, which is why for now, +this original Button component is copied here with that behaviour changed +https://github.com/gioui/gio/blob/v0.7.1/widget/button.go +https://github.com/gioui/gio/blob/v0.7.1/widget/material/button.go +https://github.com/gioui/gio/blob/v0.7.1/internal/f32color/rgba.go + +This is obviously dangerous, because it decouples this Button from future +Gio releases, and our solution is a shady hack for now, but, +that spacebar shenanigans needs to end. + +That is allowed, cf. comments in these files: +// SPDX-License-Identifier: Unlicense OR MIT diff --git a/tracker/gioui/patch/button.go b/tracker/gioui/patch/button.go new file mode 100644 index 0000000..9fedfe1 --- /dev/null +++ b/tracker/gioui/patch/button.go @@ -0,0 +1,196 @@ +//////////// +// qm210: Had to copy this button because Gioui v0.7.1 makes the Space Bar trigger the last Clicked button again. +// this interferes too much with the mindset of a music tracker. +//////////// + +// SPDX-License-Identifier: Unlicense OR MIT + +package patch + +import ( + "image" + "time" + + "gioui.org/gesture" + "gioui.org/io/event" + "gioui.org/io/key" + "gioui.org/io/pointer" + "gioui.org/io/semantic" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" +) + +// Clickable represents a clickable area. +type Clickable struct { + click gesture.Click + history []Press + + requestClicks int + pressedKey key.Name +} + +// Click represents a click. +type Click struct { + Modifiers key.Modifiers + NumClicks int +} + +// Press represents a past pointer press. +type Press struct { + // Position of the press. + Position image.Point + // Start is when the press began. + Start time.Time + // End is when the press was ended by a release or cancel. + // A zero End means it hasn't ended yet. + End time.Time + // Cancelled is true for cancelled presses. + Cancelled bool +} + +// Click executes a simple programmatic click. +func (b *Clickable) Click() { + b.requestClicks++ +} + +// Clicked calls Update and reports whether a click was registered. +func (b *Clickable) Clicked(gtx layout.Context) bool { + return b.clicked(b, gtx) +} + +func (b *Clickable) clicked(t event.Tag, gtx layout.Context) bool { + _, clicked := b.update(t, gtx) + return clicked +} + +// Hovered reports whether a pointer is over the element. +func (b *Clickable) Hovered() bool { + return b.click.Hovered() +} + +// Pressed reports whether a pointer is pressing the element. +func (b *Clickable) Pressed() bool { + return b.click.Pressed() +} + +// History is the past pointer presses useful for drawing markers. +// History is retained for a short duration (about a second). +func (b *Clickable) History() []Press { + return b.history +} + +// Layout and update the button state. +func (b *Clickable) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions { + return b.layout(b, gtx, w) +} + +func (b *Clickable) layout(t event.Tag, gtx layout.Context, w layout.Widget) layout.Dimensions { + for { + _, ok := b.update(t, gtx) + if !ok { + break + } + } + m := op.Record(gtx.Ops) + dims := w(gtx) + c := m.Stop() + defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop() + semantic.EnabledOp(gtx.Enabled()).Add(gtx.Ops) + b.click.Add(gtx.Ops) + event.Op(gtx.Ops, t) + c.Add(gtx.Ops) + return dims +} + +// Update the button state by processing events, and return the next +// click, if any. +func (b *Clickable) Update(gtx layout.Context) (Click, bool) { + return b.update(b, gtx) +} + +func (b *Clickable) update(t event.Tag, gtx layout.Context) (Click, bool) { + for len(b.history) > 0 { + c := b.history[0] + if c.End.IsZero() || gtx.Now.Sub(c.End) < 1*time.Second { + break + } + n := copy(b.history, b.history[1:]) + b.history = b.history[:n] + } + if c := b.requestClicks; c > 0 { + b.requestClicks = 0 + return Click{ + NumClicks: c, + }, true + } + for { + e, ok := b.click.Update(gtx.Source) + if !ok { + break + } + switch e.Kind { + case gesture.KindClick: + if l := len(b.history); l > 0 { + b.history[l-1].End = gtx.Now + } + return Click{ + Modifiers: e.Modifiers, + NumClicks: e.NumClicks, + }, true + case gesture.KindCancel: + for i := range b.history { + b.history[i].Cancelled = true + if b.history[i].End.IsZero() { + b.history[i].End = gtx.Now + } + } + case gesture.KindPress: + if e.Source == pointer.Mouse { + gtx.Execute(key.FocusCmd{Tag: t}) + } + b.history = append(b.history, Press{ + Position: e.Position, + Start: gtx.Now, + }) + } + } + // qm210: removed the additional Filter for key.NameSpace + for { + e, ok := gtx.Event( + key.FocusFilter{Target: t}, + key.Filter{Focus: t, Name: key.NameReturn}, + ) + if !ok { + break + } + switch e := e.(type) { + case key.FocusEvent: + if e.Focus { + b.pressedKey = "" + } + case key.Event: + if !gtx.Focused(t) { + break + } + if e.Name != key.NameReturn { + break + } + switch e.State { + case key.Press: + b.pressedKey = e.Name + case key.Release: + if b.pressedKey != e.Name { + break + } + // only register a key as a click if the key was pressed and released while this button was focused + b.pressedKey = "" + return Click{ + Modifiers: e.Modifiers, + NumClicks: 1, + }, true + } + } + } + return Click{}, false +} diff --git a/tracker/gioui/patch/f32color/rgba.go b/tracker/gioui/patch/f32color/rgba.go new file mode 100644 index 0000000..5488a0c --- /dev/null +++ b/tracker/gioui/patch/f32color/rgba.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package f32color + +import ( + "image/color" + "math" +) + +//go:generate go run ./f32colorgen -out tables.go + +// RGBA is a 32 bit floating point linear premultiplied color space. +type RGBA struct { + R, G, B, A float32 +} + +// Array returns rgba values in a [4]float32 array. +func (rgba RGBA) Array() [4]float32 { + return [4]float32{rgba.R, rgba.G, rgba.B, rgba.A} +} + +// Float32 returns r, g, b, a values. +func (col RGBA) Float32() (r, g, b, a float32) { + return col.R, col.G, col.B, col.A +} + +// SRGBA converts from linear to sRGB color space. +func (col RGBA) SRGB() color.NRGBA { + if col.A == 0 { + return color.NRGBA{} + } + return color.NRGBA{ + R: uint8(linearTosRGB(col.R/col.A)*255 + .5), + G: uint8(linearTosRGB(col.G/col.A)*255 + .5), + B: uint8(linearTosRGB(col.B/col.A)*255 + .5), + A: uint8(col.A*255 + .5), + } +} + +// Luminance calculates the relative luminance of a linear RGBA color. +// Normalized to 0 for black and 1 for white. +// +// See https://www.w3.org/TR/WCAG20/#relativeluminancedef for more details +func (col RGBA) Luminance() float32 { + return 0.2126*col.R + 0.7152*col.G + 0.0722*col.B +} + +// Opaque returns the color without alpha component. +func (col RGBA) Opaque() RGBA { + col.A = 1.0 + return col +} + +// LinearFromSRGB converts from col in the sRGB colorspace to RGBA. +func LinearFromSRGB(col color.NRGBA) RGBA { + af := float32(col.A) / 0xFF + return RGBA{ + R: srgb8ToLinear[col.R] * af, // sRGBToLinear(float32(col.R)/0xff) * af, + G: srgb8ToLinear[col.G] * af, // sRGBToLinear(float32(col.G)/0xff) * af, + B: srgb8ToLinear[col.B] * af, // sRGBToLinear(float32(col.B)/0xff) * af, + A: af, + } +} + +// NRGBAToRGBA converts from non-premultiplied sRGB color to premultiplied sRGB color. +// +// Each component in the result is `sRGBToLinear(c * alpha)`, where `c` +// is the linear color. +func NRGBAToRGBA(col color.NRGBA) color.RGBA { + if col.A == 0xFF { + return color.RGBA(col) + } + c := LinearFromSRGB(col) + return color.RGBA{ + R: uint8(linearTosRGB(c.R)*255 + .5), + G: uint8(linearTosRGB(c.G)*255 + .5), + B: uint8(linearTosRGB(c.B)*255 + .5), + A: col.A, + } +} + +// NRGBAToLinearRGBA converts from non-premultiplied sRGB color to premultiplied linear RGBA color. +// +// Each component in the result is `c * alpha`, where `c` is the linear color. +func NRGBAToLinearRGBA(col color.NRGBA) color.RGBA { + if col.A == 0xFF { + return color.RGBA(col) + } + c := LinearFromSRGB(col) + return color.RGBA{ + R: uint8(c.R*255 + .5), + G: uint8(c.G*255 + .5), + B: uint8(c.B*255 + .5), + A: col.A, + } +} + +// RGBAToNRGBA converts from premultiplied sRGB color to non-premultiplied sRGB color. +func RGBAToNRGBA(col color.RGBA) color.NRGBA { + if col.A == 0xFF { + return color.NRGBA(col) + } + + linear := RGBA{ + R: sRGBToLinear(float32(col.R) / 0xff), + G: sRGBToLinear(float32(col.G) / 0xff), + B: sRGBToLinear(float32(col.B) / 0xff), + A: float32(col.A) / 0xff, + } + + return linear.SRGB() +} + +// linearTosRGB transforms color value from linear to sRGB. +func linearTosRGB(c float32) float32 { + // Formula from EXT_sRGB. + switch { + case c <= 0: + return 0 + case 0 < c && c < 0.0031308: + return 12.92 * c + case 0.0031308 <= c && c < 1: + return 1.055*float32(math.Pow(float64(c), 0.41666)) - 0.055 + } + + return 1 +} + +// sRGBToLinear transforms color value from sRGB to linear. +func sRGBToLinear(c float32) float32 { + // Formula from EXT_sRGB. + if c <= 0.04045 { + return c / 12.92 + } else { + return float32(math.Pow(float64((c+0.055)/1.055), 2.4)) + } +} + +// MulAlpha applies the alpha to the color. +func MulAlpha(c color.NRGBA, alpha uint8) color.NRGBA { + c.A = uint8(uint32(c.A) * uint32(alpha) / 0xFF) + return c +} + +// Disabled blends color towards the luminance and multiplies alpha. +// Blending towards luminance will desaturate the color. +// Multiplying alpha blends the color together more with the background. +func Disabled(c color.NRGBA) (d color.NRGBA) { + const r = 80 // blend ratio + lum := approxLuminance(c) + d = mix(c, color.NRGBA{A: c.A, R: lum, G: lum, B: lum}, r) + d = MulAlpha(d, 128+32) + return +} + +// Hovered blends dark colors towards white, and light colors towards +// black. It is approximate because it operates in non-linear sRGB space. +func Hovered(c color.NRGBA) (h color.NRGBA) { + if c.A == 0 { + // Provide a reasonable default for transparent widgets. + return color.NRGBA{A: 0x44, R: 0x88, G: 0x88, B: 0x88} + } + const ratio = 0x20 + m := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: c.A} + if approxLuminance(c) > 128 { + m = color.NRGBA{A: c.A} + } + return mix(m, c, ratio) +} + +// mix mixes c1 and c2 weighted by (1 - a/256) and a/256 respectively. +func mix(c1, c2 color.NRGBA, a uint8) color.NRGBA { + ai := int(a) + return color.NRGBA{ + R: byte((int(c1.R)*ai + int(c2.R)*(256-ai)) / 256), + G: byte((int(c1.G)*ai + int(c2.G)*(256-ai)) / 256), + B: byte((int(c1.B)*ai + int(c2.B)*(256-ai)) / 256), + A: byte((int(c1.A)*ai + int(c2.A)*(256-ai)) / 256), + } +} + +// approxLuminance is a fast approximate version of RGBA.Luminance. +func approxLuminance(c color.NRGBA) byte { + const ( + r = 13933 // 0.2126 * 256 * 256 + g = 46871 // 0.7152 * 256 * 256 + b = 4732 // 0.0722 * 256 * 256 + t = r + g + b + ) + return byte((r*int(c.R) + g*int(c.G) + b*int(c.B)) / t) +} diff --git a/tracker/gioui/patch/f32color/tables.go b/tracker/gioui/patch/f32color/tables.go new file mode 100644 index 0000000..137d05b --- /dev/null +++ b/tracker/gioui/patch/f32color/tables.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +// Code generated by f32colorgen. DO NOT EDIT. + +package f32color + +// table corresponds to sRGBToLinear(float32(index)/0xff) +var srgb8ToLinear = [...]float32{ + 0, 0.000303527, 0.000607054, 0.000910581, 0.001214108, 0.001517635, 0.001821162, 0.0021246888, 0.002428216, 0.002731743, 0.00303527, 0.0033465363, 0.0036765079, 0.004024718, 0.004391443, 0.004776954, + 0.005181518, 0.0056053926, 0.006048834, 0.0065120924, 0.0069954116, 0.007499033, 0.008023194, 0.008568126, 0.009134059, 0.00972122, 0.010329825, 0.010960096, 0.011612247, 0.012286489, 0.0129830325, 0.013702083, + 0.014443846, 0.015208517, 0.015996296, 0.016807377, 0.017641956, 0.01850022, 0.019382365, 0.020288566, 0.021219013, 0.022173887, 0.023153368, 0.024157634, 0.025186861, 0.026241226, 0.027320895, 0.028426042, + 0.029556837, 0.030713446, 0.031896036, 0.033104766, 0.03433981, 0.035601318, 0.03688945, 0.03820437, 0.039546244, 0.040915202, 0.042311415, 0.043735035, 0.04518621, 0.04666509, 0.048171826, 0.049706567, + 0.05126947, 0.05286066, 0.05448029, 0.056128502, 0.05780544, 0.059511248, 0.06124608, 0.06301004, 0.06480329, 0.06662596, 0.06847819, 0.07036012, 0.07227187, 0.07421359, 0.0761854, 0.078187436, + 0.080219835, 0.08228272, 0.08437622, 0.08650047, 0.08865561, 0.09084174, 0.09305899, 0.09530749, 0.09758737, 0.09989875, 0.102241755, 0.10461651, 0.10702312, 0.10946173, 0.11193245, 0.11443539, + 0.11697068, 0.11953844, 0.122138806, 0.12477185, 0.12743771, 0.1301365, 0.13286835, 0.13563335, 0.13843164, 0.14126332, 0.14412849, 0.14702728, 0.1499598, 0.15292618, 0.15592648, 0.15896088, + 0.16202942, 0.16513222, 0.16826941, 0.17144111, 0.1746474, 0.17788842, 0.18116425, 0.18447499, 0.18782078, 0.19120169, 0.19461782, 0.19806932, 0.20155625, 0.20507872, 0.20863685, 0.21223074, + 0.21586055, 0.21952623, 0.223228, 0.2269659, 0.23074009, 0.23455067, 0.23839766, 0.24228121, 0.24620141, 0.25015837, 0.25415218, 0.25818294, 0.26225075, 0.2663557, 0.27049786, 0.2746774, + 0.27889434, 0.28314883, 0.2874409, 0.29177073, 0.29613835, 0.30054384, 0.30498737, 0.30946898, 0.31398878, 0.31854683, 0.32314327, 0.32777816, 0.33245158, 0.33716366, 0.34191447, 0.3467041, + 0.35153273, 0.35640025, 0.3613069, 0.36625272, 0.37123778, 0.37626222, 0.3813261, 0.38642955, 0.39157256, 0.39675534, 0.40197787, 0.4072403, 0.4125427, 0.41788515, 0.42326775, 0.42869058, + 0.4341537, 0.43965724, 0.44520128, 0.45078585, 0.4564111, 0.46207705, 0.46778387, 0.47353154, 0.47932023, 0.48515, 0.4910209, 0.49693304, 0.5028866, 0.50888145, 0.5149178, 0.5209957, + 0.5271153, 0.53327656, 0.5394796, 0.5457246, 0.55201155, 0.5583405, 0.56471163, 0.5711249, 0.5775806, 0.58407855, 0.59061897, 0.5972019, 0.6038274, 0.6104957, 0.61720663, 0.6239605, + 0.6307572, 0.63759696, 0.64447975, 0.6514057, 0.6583749, 0.66538733, 0.6724432, 0.67954254, 0.6866855, 0.6938719, 0.7011021, 0.70837593, 0.71569365, 0.7230553, 0.7304609, 0.73791057, + 0.74540436, 0.7529423, 0.76052463, 0.7681513, 0.77582234, 0.7835379, 0.79129803, 0.79910284, 0.80695236, 0.8148467, 0.82278585, 0.83076996, 0.8387991, 0.84687334, 0.8549927, 0.8631573, + 0.8713672, 0.87962234, 0.8879232, 0.89626944, 0.90466136, 0.9130987, 0.92158204, 0.9301109, 0.9386859, 0.9473066, 0.9559735, 0.9646863, 0.9734455, 0.9822506, 0.9911022, 1, +} \ No newline at end of file diff --git a/tracker/gioui/patch/material/button.go b/tracker/gioui/patch/material/button.go new file mode 100644 index 0000000..db28813 --- /dev/null +++ b/tracker/gioui/patch/material/button.go @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package material + +import ( + "github.com/vsariola/sointu/tracker/gioui/patch" + "github.com/vsariola/sointu/tracker/gioui/patch/f32color" + "image" + "image/color" + "math" + + "gioui.org/font" + "gioui.org/io/semantic" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" +) + +type ButtonStyle struct { + Text string + // Color is the text color. + Color color.NRGBA + Font font.Font + TextSize unit.Sp + Background color.NRGBA + CornerRadius unit.Dp + Inset layout.Inset + Button *patch.Clickable + shaper *text.Shaper +} + +type ButtonLayoutStyle struct { + Background color.NRGBA + CornerRadius unit.Dp + Button *patch.Clickable +} + +type IconButtonStyle struct { + Background color.NRGBA + // Color is the icon color. + Color color.NRGBA + Icon *widget.Icon + // Size is the icon size. + Size unit.Dp + Inset layout.Inset + Button *patch.Clickable + Description string +} + +func Button(th *material.Theme, button *patch.Clickable, txt string) ButtonStyle { + b := ButtonStyle{ + Text: txt, + Color: th.Palette.ContrastFg, + CornerRadius: 4, + Background: th.Palette.ContrastBg, + TextSize: th.TextSize * 14.0 / 16.0, + Inset: layout.Inset{ + Top: 10, Bottom: 10, + Left: 12, Right: 12, + }, + Button: button, + shaper: th.Shaper, + } + b.Font.Typeface = th.Face + return b +} + +func ButtonLayout(th *material.Theme, button *patch.Clickable) ButtonLayoutStyle { + return ButtonLayoutStyle{ + Button: button, + Background: th.Palette.ContrastBg, + CornerRadius: 4, + } +} + +func IconButton(th *material.Theme, button *patch.Clickable, icon *widget.Icon, description string) IconButtonStyle { + return IconButtonStyle{ + Background: th.Palette.ContrastBg, + Color: th.Palette.ContrastFg, + Icon: icon, + Size: 24, + Inset: layout.UniformInset(12), + Button: button, + Description: description, + } +} + +// Clickable lays out a rectangular clickable widget without further +// decoration. +func Clickable(gtx layout.Context, button *widget.Clickable, w layout.Widget) layout.Dimensions { + return button.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + semantic.Button.Add(gtx.Ops) + return layout.Background{}.Layout(gtx, + func(gtx layout.Context) layout.Dimensions { + defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() + if button.Hovered() || gtx.Focused(button) { + paint.Fill(gtx.Ops, f32color.Hovered(color.NRGBA{})) + } + for _, c := range button.History() { + drawInk(gtx, c) + } + return layout.Dimensions{Size: gtx.Constraints.Min} + }, + w, + ) + }) +} + +func (b ButtonStyle) Layout(gtx layout.Context) layout.Dimensions { + return ButtonLayoutStyle{ + Background: b.Background, + CornerRadius: b.CornerRadius, + Button: b.Button, + }.Layout(gtx, func(gtx layout.Context) 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) + return widget.Label{Alignment: text.Middle}.Layout(gtx, b.shaper, b.Font, b.TextSize, b.Text, colMacro.Stop()) + }) + }) +} + +func (b ButtonLayoutStyle) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions { + min := gtx.Constraints.Min + return b.Button.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + semantic.Button.Add(gtx.Ops) + return layout.Background{}.Layout(gtx, + func(gtx layout.Context) layout.Dimensions { + rr := gtx.Dp(b.CornerRadius) + defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop() + background := b.Background + switch { + case !gtx.Enabled(): + background = f32color.Disabled(b.Background) + case b.Button.Hovered() || gtx.Focused(b.Button): + background = f32color.Hovered(b.Background) + } + paint.Fill(gtx.Ops, background) + for _, c := range b.Button.History() { + drawInk(gtx, (widget.Press)(c)) + } + return layout.Dimensions{Size: gtx.Constraints.Min} + }, + func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min = min + return layout.Center.Layout(gtx, w) + }, + ) + }) +} + +func (b IconButtonStyle) Layout(gtx layout.Context) layout.Dimensions { + m := op.Record(gtx.Ops) + dims := b.Button.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + semantic.Button.Add(gtx.Ops) + if d := b.Description; d != "" { + semantic.DescriptionOp(b.Description).Add(gtx.Ops) + } + return layout.Background{}.Layout(gtx, + func(gtx layout.Context) layout.Dimensions { + rr := (gtx.Constraints.Min.X + gtx.Constraints.Min.Y) / 4 + defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop() + background := b.Background + switch { + case !gtx.Enabled(): + background = f32color.Disabled(b.Background) + case b.Button.Hovered() || gtx.Focused(b.Button): + background = f32color.Hovered(b.Background) + } + paint.Fill(gtx.Ops, background) + for _, c := range b.Button.History() { + drawInk(gtx, (widget.Press)(c)) + } + return layout.Dimensions{Size: gtx.Constraints.Min} + }, + func(gtx layout.Context) layout.Dimensions { + return b.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + size := gtx.Dp(b.Size) + if b.Icon != nil { + gtx.Constraints.Min = image.Point{X: size} + b.Icon.Layout(gtx, b.Color) + } + return layout.Dimensions{ + Size: image.Point{X: size, Y: size}, + } + }) + }, + ) + }) + c := m.Stop() + bounds := image.Rectangle{Max: dims.Size} + defer clip.Ellipse(bounds).Push(gtx.Ops).Pop() + c.Add(gtx.Ops) + return dims +} + +func drawInk(gtx layout.Context, c widget.Press) { + // duration is the number of seconds for the + // completed animation: expand while fading in, then + // out. + const ( + expandDuration = float32(0.5) + fadeDuration = float32(0.9) + ) + + now := gtx.Now + + t := float32(now.Sub(c.Start).Seconds()) + + end := c.End + if end.IsZero() { + // If the press hasn't ended, don't fade-out. + end = now + } + + endt := float32(end.Sub(c.Start).Seconds()) + + // Compute the fade-in/out position in [0;1]. + var alphat float32 + { + var haste float32 + if c.Cancelled { + // If the press was cancelled before the inkwell + // was fully faded in, fast forward the animation + // to match the fade-out. + if h := 0.5 - endt/fadeDuration; h > 0 { + haste = h + } + } + // Fade in. + half1 := t/fadeDuration + haste + if half1 > 0.5 { + half1 = 0.5 + } + + // Fade out. + half2 := float32(now.Sub(end).Seconds()) + half2 /= fadeDuration + half2 += haste + if half2 > 0.5 { + // Too old. + return + } + + alphat = half1 + half2 + } + + // Compute the expand position in [0;1]. + sizet := t + if c.Cancelled { + // Freeze expansion of cancelled presses. + sizet = endt + } + sizet /= expandDuration + + // Animate only ended presses, and presses that are fading in. + if !c.End.IsZero() || sizet <= 1.0 { + gtx.Execute(op.InvalidateCmd{}) + } + + if sizet > 1.0 { + sizet = 1.0 + } + + if alphat > .5 { + // Start fadeout after half the animation. + alphat = 1.0 - alphat + } + // Twice the speed to attain fully faded in at 0.5. + t2 := alphat * 2 + // BeziƩr ease-in curve. + alphaBezier := t2 * t2 * (3.0 - 2.0*t2) + sizeBezier := sizet * sizet * (3.0 - 2.0*sizet) + size := gtx.Constraints.Min.X + if h := gtx.Constraints.Min.Y; h > size { + size = h + } + // Cover the entire constraints min rectangle and + // apply curve values to size and color. + size = int(float32(size) * 2 * float32(math.Sqrt(2)) * sizeBezier) + alpha := 0.7 * alphaBezier + const col = 0.8 + ba, bc := byte(alpha*0xff), byte(col*0xff) + rgba := f32color.MulAlpha(color.NRGBA{A: 0xff, R: bc, G: bc, B: bc}, ba) + ink := paint.ColorOp{Color: rgba} + ink.Add(gtx.Ops) + rr := size / 2 + defer op.Offset(c.Position.Add(image.Point{ + X: -rr, + Y: -rr, + })).Push(gtx.Ops).Pop() + defer clip.UniformRRect(image.Rectangle{Max: image.Pt(size, size)}, rr).Push(gtx.Ops).Pop() + paint.PaintOp{}.Add(gtx.Ops) +}