diff --git a/core/button/event.go b/core/button/event.go index 4903ff9..f942f02 100644 --- a/core/button/event.go +++ b/core/button/event.go @@ -105,8 +105,9 @@ func handleActivationKey(w *Widget, e *event.KeyEvent) bool { } } -// fireOnClick calls the configured onClick handler if present. +// fireOnClick plays the click sound and calls the configured onClick handler. func fireOnClick(w *Widget) { + widget.PlaySound(widget.SoundClick) if w.cfg.onClick != nil { w.cfg.onClick() } diff --git a/core/checkbox/event.go b/core/checkbox/event.go index 29724cc..121d5dd 100644 --- a/core/checkbox/event.go +++ b/core/checkbox/event.go @@ -104,8 +104,10 @@ func handleActivationKey(w *Widget, e *event.KeyEvent) bool { } } -// fireToggle toggles the checked state and calls the configured onToggle handler. +// fireToggle plays the click sound, toggles the checked state, and calls the +// configured onToggle handler. func fireToggle(w *Widget) { + widget.PlaySound(widget.SoundClick) newChecked := !w.cfg.ResolvedChecked() // TWO-WAY: if a CheckedSignal is bound, write back the new state. if w.cfg.checkedSignal != nil { diff --git a/core/collapsible/collapsible.go b/core/collapsible/collapsible.go index 175eb06..81fa161 100644 --- a/core/collapsible/collapsible.go +++ b/core/collapsible/collapsible.go @@ -315,6 +315,8 @@ func (w *Widget) Unmount() { // setExpandedState updates the expanded state and starts animation if needed. func (w *Widget) setExpandedState(expanded bool) { + widget.PlaySound(widget.SoundClick) + // Update state source. if w.cfg.expandedSignal != nil { w.cfg.expandedSignal.Set(expanded) diff --git a/core/dialog/widget.go b/core/dialog/widget.go index aca8796..131267d 100644 --- a/core/dialog/widget.go +++ b/core/dialog/widget.go @@ -79,6 +79,8 @@ func (w *Widget) Show(ctx widget.Context) { return } + widget.PlaySound(widget.SoundAlert) + w.visible = true w.SetVisible(true) diff --git a/core/dropdown/widget.go b/core/dropdown/widget.go index 92c832b..88b1412 100644 --- a/core/dropdown/widget.go +++ b/core/dropdown/widget.go @@ -232,6 +232,8 @@ func (w *Widget) selectItem(ctx widget.Context, index int) { return } + widget.PlaySound(widget.SoundClick) + w.selectedIndex = index // Update signal if bound. diff --git a/core/menu/menu.go b/core/menu/menu.go index 0ffab24..a664385 100644 --- a/core/menu/menu.go +++ b/core/menu/menu.go @@ -239,6 +239,7 @@ func (m *menuPanel) activateHighlighted(ctx widget.Context) bool { // selectItem fires the item's action and closes the entire menu tree. func (m *menuPanel) selectItem(item *MenuItem) { + widget.PlaySound(widget.SoundClick) if m.onSelect != nil { m.onSelect(item) } diff --git a/core/radio/group.go b/core/radio/group.go index 1cb65b0..95bd14e 100644 --- a/core/radio/group.go +++ b/core/radio/group.go @@ -133,6 +133,8 @@ func (g *Group) selectValue(value string) *Item { return nil } + widget.PlaySound(widget.SoundClick) + // TWO-WAY: if a SelectedSignal is bound, write back the new value. if selectedSignal != nil { selectedSignal.Set(value) diff --git a/core/tabview/event.go b/core/tabview/event.go index cd8f3a6..03d6d4f 100644 --- a/core/tabview/event.go +++ b/core/tabview/event.go @@ -190,6 +190,7 @@ func (w *Widget) selectTab(idx int) { if idx == w.cfg.ResolvedSelected() { return } + widget.PlaySound(widget.SoundClick) w.cfg.setSelected(idx) if w.cfg.onSelect != nil { w.cfg.onSelect(idx) diff --git a/desktop/sound.go b/desktop/sound.go new file mode 100644 index 0000000..37d9578 --- /dev/null +++ b/desktop/sound.go @@ -0,0 +1,22 @@ +package desktop + +import ( + "github.com/gogpu/gogpu/sound" + "github.com/gogpu/ui/widget" +) + +func init() { + // Register the SoundPlayer so that widget.PlaySound() delegates to + // gogpu/sound.Play() without widget code importing gogpu directly. + // Sound is disabled by default; the app enables it via + // gogpu.DefaultConfig().WithSoundFeedback(true) which calls + // sound.SetEnabled(true). + widget.RegisterSoundPlayer(func(e widget.SoundEvent) { + switch e { + case widget.SoundClick: + sound.Play(sound.Click) + case widget.SoundAlert: + sound.Play(sound.Alert) + } + }) +} diff --git a/examples/sounds/main.go b/examples/sounds/main.go index 2391587..907c775 100644 --- a/examples/sounds/main.go +++ b/examples/sounds/main.go @@ -20,6 +20,9 @@ import ( ) func main() { + // Enable platform sounds globally. All interactive widgets (button, + // checkbox, radio, dropdown, collapsible, tabview, menu) now auto-play + // a click sound on activation — no manual sound.Play needed. sound.SetEnabled(true) m3 := material3.New(widget.Hex(0x1565C0)) @@ -30,14 +33,13 @@ func main() { root := primitives.VBox( primitives.Text("System Sounds Demo").FontSize(22).Bold(), - primitives.Text("Every interaction plays a platform system sound"). + primitives.Text("Widgets auto-play sounds — no manual sound.Play needed"). FontSize(13).Color(widget.RGBA(0.5, 0.5, 0.5, 1)), - primitives.Text("Checkboxes").FontSize(16).Bold(), + primitives.Text("Checkboxes (auto click sound)").FontSize(16).Bold(), checkbox.New( checkbox.LabelOpt("Enable notifications"), checkbox.OnToggle(func(checked bool) { - sound.Play(sound.Click) fmt.Println("notifications:", checked) }), checkbox.PainterOpt(cp), @@ -45,13 +47,12 @@ func main() { checkbox.New( checkbox.LabelOpt("Dark mode"), checkbox.OnToggle(func(checked bool) { - sound.Play(sound.Click) fmt.Println("dark mode:", checked) }), checkbox.PainterOpt(cp), ), - primitives.Text("Theme").FontSize(16).Bold(), + primitives.Text("Radio (auto click sound)").FontSize(16).Bold(), radio.NewGroup( radio.Items( radio.ItemDef{Value: "light", Label: "Light"}, @@ -60,13 +61,12 @@ func main() { ), radio.Selected("light"), radio.OnChange(func(v string) { - sound.Play(sound.Click) fmt.Println("theme:", v) }), radio.GroupPainter(rp), ), - primitives.Text("Volume").FontSize(16).Bold(), + primitives.Text("Slider (no sound — continuous drag)").FontSize(16).Bold(), slider.New( slider.Min(0), slider.Max(100), @@ -77,6 +77,29 @@ func main() { slider.PainterOpt(sp), ), + primitives.Text("Buttons (auto click sound)").FontSize(16).Bold(), + primitives.HBox( + button.New( + button.Text("Action 1"), + button.OnClick(func() { fmt.Println("action 1") }), + button.PainterOpt(bp), + button.VariantOpt(button.Filled), + ), + button.New( + button.Text("Action 2"), + button.OnClick(func() { fmt.Println("action 2") }), + button.PainterOpt(bp), + button.VariantOpt(button.Tonal), + ), + button.New( + button.Text("Action 3"), + button.OnClick(func() { fmt.Println("action 3") }), + button.PainterOpt(bp), + button.VariantOpt(button.Outlined), + ), + ).Gap(8), + + primitives.Text("Special sounds (manual)").FontSize(16).Bold(), primitives.HBox( button.New( button.Text("Success"), @@ -101,7 +124,7 @@ func main() { gogpuApp := gogpu.NewApp(gogpu.DefaultConfig(). WithTitle("gogpu/ui — System Sounds"). - WithSize(400, 520)) + WithSize(400, 600)) uiApp := app.New( app.WithWindowProvider(gogpuApp), diff --git a/go.mod b/go.mod index 66ff0ae..a31d460 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,11 @@ go 1.25.0 require ( github.com/coregx/signals v0.1.1 - github.com/gogpu/gg v0.50.13 - github.com/gogpu/gogpu v0.50.0 + github.com/gogpu/gg v0.50.14 + github.com/gogpu/gogpu v0.50.2 github.com/gogpu/gpucontext v0.24.0 github.com/gogpu/gputypes v0.5.1 - github.com/gogpu/wgpu v0.30.36 + github.com/gogpu/wgpu v0.30.37 golang.org/x/image v0.44.0 ) diff --git a/go.sum b/go.sum index ed586d3..f6389de 100644 --- a/go.sum +++ b/go.sum @@ -4,18 +4,18 @@ github.com/go-webgpu/goffi v0.6.3 h1:p4gKGikHBAQ/8iUiew9MV4C5M1ZGIsk8QGFGuJjMj+A github.com/go-webgpu/goffi v0.6.3/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= github.com/go-webgpu/webgpu v0.5.5 h1:pIrXzRg0LRlNjNmR+ZNo/ERN88YaObzbCY5oYhir5SA= github.com/go-webgpu/webgpu v0.5.5/go.mod h1:vgIuNTa1UlZ4njCGY6Pmp/0c5T5o2Ml2fQ0znLc7B98= -github.com/gogpu/gg v0.50.13 h1:GIDqgR6CozprmFD3MgeMhFkXbZ76FH5ofZuwblCPOy4= -github.com/gogpu/gg v0.50.13/go.mod h1:cV+vVHM75pf7qPDIKPLSFUQuazolClb8xSW9tdQTJRU= -github.com/gogpu/gogpu v0.50.0 h1:qyUi5vBte/m7NDd49aN+YJffn36V4jGJydXCg37NR9E= -github.com/gogpu/gogpu v0.50.0/go.mod h1:hIEFjkuVV2+eOkayR7gS38h7UrnUOZfcQXXQbxt36c0= +github.com/gogpu/gg v0.50.14 h1:JhahLKhiij7XaFWkjYBnw7/HOV25FAXqD1KS9/ceHIA= +github.com/gogpu/gg v0.50.14/go.mod h1:CCA/RZOr+RrpWPzOYPnAWaYCjWfI543RPr+JeaJV38I= +github.com/gogpu/gogpu v0.50.2 h1:7FzoGH2aBr5MDOLpu7RONwKQYvqQoZle4v44o8jXkvU= +github.com/gogpu/gogpu v0.50.2/go.mod h1:hIEFjkuVV2+eOkayR7gS38h7UrnUOZfcQXXQbxt36c0= github.com/gogpu/gpucontext v0.24.0 h1:YQ0FxGqXEO8LQB+6/TOqZOV1fn0mO9UDYR0obSU3R6o= github.com/gogpu/gpucontext v0.24.0/go.mod h1:OrT137boh5yPhqBEhF4UQKIOu1Jq74SLQzYa/m6JbNo= github.com/gogpu/gputypes v0.5.1 h1:X38OPcP6umQqqubzzJYL6Nm1tXHSNQj6TRSAoxdAJmg= github.com/gogpu/gputypes v0.5.1/go.mod h1:cnXrDMwTpWTvJLW1Vreop3PcT6a2YP/i3s91rPaOavw= github.com/gogpu/naga v0.18.0 h1:2y83HUcAnlwEZMuHOiYTMdNYM9J8rev40gkI4zJ96Uk= github.com/gogpu/naga v0.18.0/go.mod h1:15sQaHKkbqXcwTN+hHYGLsA0WBBnkmYzne/eF5p5WEg= -github.com/gogpu/wgpu v0.30.36 h1:8r4tYLCo8qG0ZaOQxsCh4BpZJ5u8CL3Ab/XDOwMH3co= -github.com/gogpu/wgpu v0.30.36/go.mod h1:fi3zxQmJnjPRMqqA5wl2kta2Z6kxdf8HQxEORNT1Lt4= +github.com/gogpu/wgpu v0.30.37 h1:OhgiFY/RU+v+JuD8pq0c0SV3iCi1UFy4j8/JqEYEmCw= +github.com/gogpu/wgpu v0.30.37/go.mod h1:fi3zxQmJnjPRMqqA5wl2kta2Z6kxdf8HQxEORNT1Lt4= golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= diff --git a/widget/sound.go b/widget/sound.go new file mode 100644 index 0000000..cbeeb78 --- /dev/null +++ b/widget/sound.go @@ -0,0 +1,44 @@ +package widget + +// SoundEvent represents a UI interaction sound type. +// Widget code uses these constants to request sound feedback without +// importing platform-specific sound packages. The actual playback +// is handled by a [SoundPlayer] registered via [RegisterSoundPlayer]. +type SoundEvent int + +const ( + // SoundClick is the default UI interaction sound (button click, + // checkbox toggle, radio select, menu item activation). + SoundClick SoundEvent = iota + + // SoundAlert is a notification/dialog sound played when a modal + // dialog is shown. + SoundAlert +) + +// SoundPlayer is a callback that plays a system sound for the given event. +// Registered by the app/desktop layer to bridge widget sound requests +// to the platform sound API (gogpu/sound) without a direct import. +type SoundPlayer func(SoundEvent) + +// soundPlayer holds the registered sound callback. +// Set by the app layer during initialization via RegisterSoundPlayer. +var soundPlayer SoundPlayer + +// RegisterSoundPlayer registers the callback that plays system sounds. +// Called by the desktop layer during initialization to inject the +// platform sound implementation. Only one player may be registered; +// subsequent calls replace the previous one. +func RegisterSoundPlayer(p SoundPlayer) { + soundPlayer = p +} + +// PlaySound plays a UI sound if a [SoundPlayer] is registered. +// This is a no-op if no player has been registered (sound disabled +// or app layer not wired). Widgets call this before firing their +// user-facing callbacks so the sound plays immediately on interaction. +func PlaySound(e SoundEvent) { + if soundPlayer != nil { + soundPlayer(e) + } +} diff --git a/widget/sound_test.go b/widget/sound_test.go new file mode 100644 index 0000000..0060eb2 --- /dev/null +++ b/widget/sound_test.go @@ -0,0 +1,60 @@ +package widget + +import "testing" + +func TestPlaySound_NoPlayer(t *testing.T) { + // Save and restore the global player. + old := soundPlayer + defer func() { soundPlayer = old }() + + soundPlayer = nil + + // Should not panic when no player is registered. + PlaySound(SoundClick) + PlaySound(SoundAlert) +} + +func TestPlaySound_WithPlayer(t *testing.T) { + old := soundPlayer + defer func() { soundPlayer = old }() + + var received []SoundEvent + RegisterSoundPlayer(func(e SoundEvent) { + received = append(received, e) + }) + + PlaySound(SoundClick) + PlaySound(SoundAlert) + PlaySound(SoundClick) + + if len(received) != 3 { + t.Fatalf("expected 3 events, got %d", len(received)) + } + if received[0] != SoundClick { + t.Errorf("event[0] = %d, want SoundClick(%d)", received[0], SoundClick) + } + if received[1] != SoundAlert { + t.Errorf("event[1] = %d, want SoundAlert(%d)", received[1], SoundAlert) + } + if received[2] != SoundClick { + t.Errorf("event[2] = %d, want SoundClick(%d)", received[2], SoundClick) + } +} + +func TestRegisterSoundPlayer_Replaces(t *testing.T) { + old := soundPlayer + defer func() { soundPlayer = old }() + + var first, second bool + RegisterSoundPlayer(func(SoundEvent) { first = true }) + RegisterSoundPlayer(func(SoundEvent) { second = true }) + + PlaySound(SoundClick) + + if first { + t.Error("first player should NOT have been called after replacement") + } + if !second { + t.Error("second player should have been called") + } +}