diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d20c2a..c7219d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.23.0] - 2026-07-30 + +### Added + +- **`InputEvent` sealed interface** (ADR-058) — unified event type for SDL-style event queue. Sealed via unexported `inputEventTag()` marker — only gpucontext types implement it. Enables exhaustive type switch handling. +- **`KeyEvent`** struct — keyboard key state change (Key, Modifiers, Pressed) +- **`CharEvent`** struct — committed text input (Char rune) +- **`FocusEvent`** struct — window focus state change (Focused bool) +- **`ResizeEvent`** struct — window content area size change (Width, Height in logical DIP) +- `PointerEvent` and `ScrollEvent` now implement `InputEvent` interface +- 8 test cases covering interface satisfaction, type switch, field access + ## [0.22.0] - 2026-07-29 ### Added diff --git a/input_event.go b/input_event.go new file mode 100644 index 0000000..97d3d6c --- /dev/null +++ b/input_event.go @@ -0,0 +1,88 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +// InputEvent represents a discrete input event from the platform. +// +// InputEvent is a sealed interface — only types defined in this package +// implement it. This enables exhaustive handling via linters and guarantees +// the set of event types is known at compile time. +// +// Use a Go type switch to handle specific event types: +// +// for ev, ok := app.PollInputEvent(); ok; ev, ok = app.PollInputEvent() { +// switch e := ev.(type) { +// case gpucontext.KeyEvent: +// if e.Pressed && e.Key == gpucontext.KeyEscape { +// app.Quit() +// } +// case gpucontext.PointerEvent: +// if e.Type == gpucontext.PointerDown { +// handleClick(e.X, e.Y) +// } +// case gpucontext.ScrollEvent: +// handleScroll(e.DeltaX, e.DeltaY) +// } +// } +// +// Three input models coexist in gogpu — use whichever fits your use case: +// - Callbacks (EventSource): best for GUI frameworks +// - State polling (app.Input()): best for simple games (Ebiten-style) +// - Event queue (app.PollInputEvent()): best for complex games (SDL-style) +// +// Design references: +// - Qt6: QEvent in QtCore (shared core layer) +// - Bevy: bevy_input crate (separate from bevy_app) +// - Gio: io/event.Event with ImplementsEvent() marker +// - SDL3: SDL_PollEvent() + SDL_AppEvent() dual model +type InputEvent interface { + inputEventTag() +} + +// KeyEvent represents a keyboard key state change. +// +// A KeyEvent is emitted for each physical key press and release. For text +// input (after keyboard layout and IME processing), use CharEvent instead. +// +// All coordinates and dimensions in the event system use logical DIP +// (device-independent pixels), consistent with WindowProvider.Size(). +type KeyEvent struct { + Key Key + Mods Modifiers + Pressed bool // true = key down, false = key up +} + +func (KeyEvent) inputEventTag() {} + +// CharEvent represents committed text input. +// +// CharEvent is emitted after keyboard layout and input method processing. +// For CJK input, the composition preview is delivered via IME callbacks +// on EventSource; CharEvent contains only the final committed character. +// +// Use CharEvent for text fields. Use KeyEvent for keyboard shortcuts. +type CharEvent struct { + Char rune +} + +func (CharEvent) inputEventTag() {} + +// FocusEvent represents a window focus state change. +type FocusEvent struct { + Focused bool // true = window gained focus, false = lost focus +} + +func (FocusEvent) inputEventTag() {} + +// ResizeEvent represents a window content area size change. +// +// Width and Height are in logical DIP (device-independent pixels), +// consistent with WindowProvider.Size(). For physical pixel dimensions, +// multiply by ScaleFactor. +type ResizeEvent struct { + Width int + Height int +} + +func (ResizeEvent) inputEventTag() {} diff --git a/input_event_test.go b/input_event_test.go new file mode 100644 index 0000000..0c2ad91 --- /dev/null +++ b/input_event_test.go @@ -0,0 +1,136 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import "testing" + +func TestInputEventInterface(t *testing.T) { + events := []InputEvent{ + KeyEvent{Key: KeyA, Mods: ModShift, Pressed: true}, + KeyEvent{Key: KeyEscape, Pressed: false}, + CharEvent{Char: 'A'}, + FocusEvent{Focused: true}, + FocusEvent{Focused: false}, + ResizeEvent{Width: 800, Height: 600}, + PointerEvent{Type: PointerDown, X: 100, Y: 200}, + PointerEvent{Type: PointerMove, X: 150, Y: 250}, + ScrollEvent{DeltaY: -3.0, DeltaMode: ScrollDeltaLine}, + } + + if len(events) != 9 { + t.Fatalf("expected 9 events, got %d", len(events)) + } + + for i, ev := range events { + if ev == nil { + t.Errorf("event %d is nil", i) + } + } +} + +func TestInputEventTypeSwitch(t *testing.T) { + events := []InputEvent{ + KeyEvent{Key: KeyA, Pressed: true}, + PointerEvent{Type: PointerDown, X: 10, Y: 20}, + ScrollEvent{DeltaY: 1.0}, + CharEvent{Char: 'x'}, + FocusEvent{Focused: true}, + ResizeEvent{Width: 1024, Height: 768}, + } + + counts := map[string]int{} + for _, ev := range events { + switch ev.(type) { + case KeyEvent: + counts["key"]++ + case PointerEvent: + counts["pointer"]++ + case ScrollEvent: + counts["scroll"]++ + case CharEvent: + counts["char"]++ + case FocusEvent: + counts["focus"]++ + case ResizeEvent: + counts["resize"]++ + default: + t.Errorf("unexpected event type: %T", ev) + } + } + + for _, name := range []string{"key", "pointer", "scroll", "char", "focus", "resize"} { + if counts[name] != 1 { + t.Errorf("expected 1 %s event, got %d", name, counts[name]) + } + } +} + +func TestKeyEventFields(t *testing.T) { + ev := KeyEvent{Key: KeyA, Mods: ModShift | ModControl, Pressed: true} + if ev.Key != KeyA { + t.Errorf("Key = %v, want KeyA", ev.Key) + } + if !ev.Mods.HasShift() { + t.Error("expected HasShift") + } + if !ev.Mods.HasControl() { + t.Error("expected HasControl") + } + if !ev.Pressed { + t.Error("expected Pressed = true") + } +} + +func TestCharEventFields(t *testing.T) { + ev := CharEvent{Char: '日'} + if ev.Char != '日' { + t.Errorf("Char = %q, want '日'", ev.Char) + } +} + +func TestFocusEventFields(t *testing.T) { + gained := FocusEvent{Focused: true} + lost := FocusEvent{Focused: false} + if !gained.Focused { + t.Error("expected Focused = true") + } + if lost.Focused { + t.Error("expected Focused = false") + } +} + +func TestResizeEventFields(t *testing.T) { + ev := ResizeEvent{Width: 1920, Height: 1080} + if ev.Width != 1920 || ev.Height != 1080 { + t.Errorf("got %dx%d, want 1920x1080", ev.Width, ev.Height) + } +} + +func TestPointerEventImplementsInputEvent(t *testing.T) { + var ev InputEvent = PointerEvent{Type: PointerDown, X: 50, Y: 100} + pe, ok := ev.(PointerEvent) + if !ok { + t.Fatal("PointerEvent does not implement InputEvent") + } + if pe.Type != PointerDown { + t.Errorf("Type = %v, want PointerDown", pe.Type) + } + if pe.X != 50 || pe.Y != 100 { + t.Errorf("coordinates = (%v, %v), want (50, 100)", pe.X, pe.Y) + } +} + +func TestScrollEventImplementsInputEvent(t *testing.T) { + var ev InputEvent = ScrollEvent{DeltaY: -1.0, Phase: ScrollPhaseBegan} + se, ok := ev.(ScrollEvent) + if !ok { + t.Fatal("ScrollEvent does not implement InputEvent") + } + if se.DeltaY != -1.0 { + t.Errorf("DeltaY = %v, want -1.0", se.DeltaY) + } + if se.Phase != ScrollPhaseBegan { + t.Errorf("Phase = %v, want ScrollPhaseBegan", se.Phase) + } +} diff --git a/pointer.go b/pointer.go index 4848754..1a4f502 100644 --- a/pointer.go +++ b/pointer.go @@ -124,6 +124,8 @@ type PointerEvent struct { Timestamp time.Duration } +func (PointerEvent) inputEventTag() {} + // PointerEventType indicates the type of pointer event. type PointerEventType uint8 diff --git a/scroll.go b/scroll.go index 3b1d96d..5afa57a 100644 --- a/scroll.go +++ b/scroll.go @@ -112,6 +112,8 @@ type ScrollEvent struct { IsMomentum bool } +func (ScrollEvent) inputEventTag() {} + // ScrollDeltaMode indicates the unit of scroll delta values. type ScrollDeltaMode uint8