diff --git a/CHANGELOG.md b/CHANGELOG.md index 568cc77..8e0eae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] - 2026-03-20 + +### Added + +- **WindowChrome interface** for custom window chrome (frameless windows) + - `SetFrameless(bool)` / `IsFrameless() bool` — enable/disable frameless mode + - `SetHitTestCallback(HitTestCallback)` — custom hit testing for drag, resize, buttons + - `Minimize()` / `Maximize()` / `IsMaximized() bool` / `Close()` — window controls + - Optional interface — use type assertion: + `if wc, ok := provider.(gpucontext.WindowChrome); ok { ... }` + +- **HitTestResult enum** (13 values) for custom window regions + - `HitTestClient` — normal content area + - `HitTestCaption` — title bar drag area + - `HitTestClose` / `HitTestMaximize` / `HitTestMinimize` — window buttons + - `HitTestResizeN/S/W/E/NW/NE/SW/SE` — 8 resize edges/corners + - `String()` method for debugging + +- **HitTestCallback type** — `func(x, y float64) HitTestResult` + +- **NullWindowChrome** — no-op implementation for testing + ## [0.10.0] - 2026-03-15 ### Removed @@ -128,6 +150,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **TouchCancelled → TouchCanceled** — US English spelling (misspell linter) - Removed unused `DeviceHandle` alias +[0.11.0]: https://github.com/gogpu/gpucontext/releases/tag/v0.11.0 +[0.10.0]: https://github.com/gogpu/gpucontext/releases/tag/v0.10.0 [0.9.0]: https://github.com/gogpu/gpucontext/releases/tag/v0.9.0 [0.8.0]: https://github.com/gogpu/gpucontext/releases/tag/v0.8.0 [0.7.0]: https://github.com/gogpu/gpucontext/releases/tag/v0.7.0 diff --git a/README.md b/README.md index 05df2bc..06cd576 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Shared GPU infrastructure for the [gogpu](https://github.com/gogpu) ecosystem. | Package | Purpose | Dependencies | |---------|---------|--------------| | [gputypes](https://github.com/gogpu/gputypes) | WebGPU types (enums, structs, constants) | **ZERO** | -| **gpucontext** | Interfaces (DeviceProvider, EventSource, Texture) | imports gputypes | +| **gpucontext** | Interfaces (DeviceProvider, EventSource, WindowChrome, Texture) | imports gputypes | gpucontext imports gputypes to use shared types in interface signatures, ensuring type compatibility across the ecosystem. @@ -34,6 +34,7 @@ go get github.com/gogpu/gpucontext - **ScrollEventSource** — Scroll/wheel events with pixel/line/page modes - **Texture** — Minimal interface for GPU textures with TextureUpdater/TextureDrawer/TextureCreator - **IME Support** — Input Method Editor for CJK languages (Chinese, Japanese, Korean) +- **WindowChrome** — Custom window chrome for frameless windows (hit testing, minimize/maximize/close) - **Registry[T]** — Generic registry with priority-based backend selection - **WebGPU Interfaces** — Device, Queue, Adapter, Surface interfaces - **WebGPU Types** — Re-exports from [gputypes](https://github.com/gogpu/gputypes) (TextureFormat, etc.) @@ -247,6 +248,31 @@ func (ctx *Context) DrawTexture(tex gpucontext.Texture, x, y float32) error { } ``` +### WindowChrome (frameless windows) + +`WindowChrome` enables custom window chrome for frameless windows with custom title bars: + +```go +// In gogpu/ui - custom title bar with hit testing +func (ui *UI) SetupFramelessWindow(provider gpucontext.WindowProvider) { + if wc, ok := provider.(gpucontext.WindowChrome); ok { + wc.SetFrameless(true) + wc.SetHitTestCallback(func(x, y float64) gpucontext.HitTestResult { + if y < 40 { // title bar height + return gpucontext.HitTestCaption // enables window dragging + } + return gpucontext.HitTestClient + }) + } +} + +// Window controls +wc.Minimize() +wc.Maximize() // toggles maximized/restored +wc.IsMaximized() // for button icon state +wc.Close() +``` + ### Backend Registry The `Registry[T]` provides thread-safe registration with priority-based selection: @@ -289,7 +315,7 @@ names := backends.Available() // ["vulkan", "software"] ▼ gpucontext (imports gputypes) - DeviceProvider, + DeviceProvider, WindowChrome, WindowProvider, PlatformProvider, EventSource, Texture, Registry │ diff --git a/doc.go b/doc.go index baad92e..5c3e32c 100644 --- a/doc.go +++ b/doc.go @@ -9,6 +9,7 @@ // - WindowProvider: Interface for window geometry, DPI, and redraw requests // - PlatformProvider: Interface for clipboard, cursor, dark mode, accessibility // - ScrollEventSource: Interface for detailed scroll events +// - WindowChrome: Interface for custom window chrome (frameless windows) // - Texture: Minimal interface for GPU textures // - TextureDrawer: Interface for drawing textures (2D rendering) // - TextureCreator: Interface for creating textures from pixel data diff --git a/window_chrome.go b/window_chrome.go new file mode 100644 index 0000000..634e7f9 --- /dev/null +++ b/window_chrome.go @@ -0,0 +1,184 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +// HitTestResult represents what region of the window the cursor is over. +// +// When a window is frameless (no OS title bar), the application must tell +// the OS what part of the window the cursor is in, so the OS can handle +// dragging, resizing, and window button interactions. +// +// These values map directly to platform-specific hit test constants: +// - Windows: WM_NCHITTEST return values (HTCLIENT, HTCAPTION, etc.) +// - macOS: NSWindow regions +// - Linux: xdg-toplevel resize edges +type HitTestResult int + +const ( + // HitTestClient indicates the cursor is over the client (content) area. + // The application handles all input normally. + HitTestClient HitTestResult = iota + + // HitTestCaption indicates the cursor is over the title bar / drag area. + // The OS handles window dragging on mouse down. + HitTestCaption + + // HitTestClose indicates the cursor is over the close button region. + HitTestClose + + // HitTestMaximize indicates the cursor is over the maximize button region. + HitTestMaximize + + // HitTestMinimize indicates the cursor is over the minimize button region. + HitTestMinimize + + // HitTestResizeN indicates the cursor is over the top resize edge. + HitTestResizeN + + // HitTestResizeS indicates the cursor is over the bottom resize edge. + HitTestResizeS + + // HitTestResizeW indicates the cursor is over the left resize edge. + HitTestResizeW + + // HitTestResizeE indicates the cursor is over the right resize edge. + HitTestResizeE + + // HitTestResizeNW indicates the cursor is over the top-left resize corner. + HitTestResizeNW + + // HitTestResizeNE indicates the cursor is over the top-right resize corner. + HitTestResizeNE + + // HitTestResizeSW indicates the cursor is over the bottom-left resize corner. + HitTestResizeSW + + // HitTestResizeSE indicates the cursor is over the bottom-right resize corner. + HitTestResizeSE +) + +// String returns the hit test result name for debugging. +func (h HitTestResult) String() string { + switch h { + case HitTestClient: + return "Client" + case HitTestCaption: + return "Caption" + case HitTestClose: + return "Close" + case HitTestMaximize: + return "Maximize" + case HitTestMinimize: + return "Minimize" + case HitTestResizeN: + return "ResizeN" + case HitTestResizeS: + return "ResizeS" + case HitTestResizeW: + return "ResizeW" + case HitTestResizeE: + return "ResizeE" + case HitTestResizeNW: + return "ResizeNW" + case HitTestResizeNE: + return "ResizeNE" + case HitTestResizeSW: + return "ResizeSW" + case HitTestResizeSE: + return "ResizeSE" + default: + return "Unknown" + } +} + +// HitTestCallback is called by the platform layer to determine what region +// of the window the cursor is in. The coordinates (x, y) are in logical +// points (DIP) relative to the window's top-left corner. +// +// The callback is invoked during mouse move events when the window is frameless. +// It must return quickly to avoid input lag. +type HitTestCallback func(x, y float64) HitTestResult + +// WindowChrome provides control over window chrome (title bar, borders). +// +// This interface enables replacing the OS window chrome with a custom +// GPU-rendered title bar. When frameless mode is enabled, the OS removes +// the title bar and borders, and the application takes responsibility for: +// - Rendering its own title bar +// - Providing hit-test regions (drag area, buttons, resize edges) +// - Handling minimize/maximize/close actions +// +// Implementations: +// - gogpu.App implements WindowChrome via platform-specific code +// - NullWindowChrome provides no-op defaults for testing +// +// WindowChrome is optional. Use type assertion to check availability: +// +// if wc, ok := provider.(gpucontext.WindowChrome); ok { +// wc.SetFrameless(true) +// wc.SetHitTestCallback(myHitTest) +// } +type WindowChrome interface { + // SetFrameless enables or disables frameless (borderless) window mode. + // When true, the OS title bar and borders are removed. + // The application must provide its own title bar via SetHitTestCallback. + SetFrameless(frameless bool) + + // IsFrameless returns true if the window is in frameless mode. + IsFrameless() bool + + // SetHitTestCallback sets the callback that determines what region + // of the window the cursor is over. This is used by the platform layer + // to route mouse events to the OS for dragging, resizing, etc. + // + // Pass nil to clear the callback (all areas become HitTestClient). + SetHitTestCallback(callback HitTestCallback) + + // Minimize minimizes the window to the taskbar/dock. + Minimize() + + // Maximize toggles between maximized and restored window state. + // If the window is currently maximized, it is restored to its previous size. + Maximize() + + // IsMaximized returns true if the window is currently maximized. + IsMaximized() bool + + // Close requests the window to close. + // This triggers the normal close flow (close events, cleanup). + Close() +} + +// NullWindowChrome implements WindowChrome with no-op behavior. +// Used for testing and platforms without window chrome support. +// +// Default return values: +// - IsFrameless: false +// - IsMaximized: false +// - All actions: no-op +type NullWindowChrome struct{} + +// SetFrameless does nothing. +func (NullWindowChrome) SetFrameless(bool) {} + +// IsFrameless returns false. +func (NullWindowChrome) IsFrameless() bool { return false } + +// SetHitTestCallback does nothing. +func (NullWindowChrome) SetHitTestCallback(HitTestCallback) {} + +// Minimize does nothing. +func (NullWindowChrome) Minimize() {} + +// Maximize does nothing. +func (NullWindowChrome) Maximize() {} + +// IsMaximized returns false. +func (NullWindowChrome) IsMaximized() bool { return false } + +// Close does nothing. +func (NullWindowChrome) Close() {} + +// Ensure NullWindowChrome implements WindowChrome. +var _ WindowChrome = NullWindowChrome{} diff --git a/window_chrome_test.go b/window_chrome_test.go new file mode 100644 index 0000000..d9b6470 --- /dev/null +++ b/window_chrome_test.go @@ -0,0 +1,184 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import "testing" + +func TestNullWindowChrome_Defaults(t *testing.T) { + var wc WindowChrome = NullWindowChrome{} + + if wc.IsFrameless() { + t.Error("IsFrameless() should return false") + } + if wc.IsMaximized() { + t.Error("IsMaximized() should return false") + } +} + +func TestNullWindowChrome_Actions(t *testing.T) { + var wc WindowChrome = NullWindowChrome{} + + // All actions should succeed without panic + wc.SetFrameless(true) + wc.SetFrameless(false) + wc.SetHitTestCallback(func(x, y float64) HitTestResult { return HitTestClient }) + wc.SetHitTestCallback(nil) + wc.Minimize() + wc.Maximize() + wc.Close() +} + +func TestHitTestResult_String(t *testing.T) { + tests := []struct { + result HitTestResult + want string + }{ + {HitTestClient, "Client"}, + {HitTestCaption, "Caption"}, + {HitTestClose, "Close"}, + {HitTestMaximize, "Maximize"}, + {HitTestMinimize, "Minimize"}, + {HitTestResizeN, "ResizeN"}, + {HitTestResizeS, "ResizeS"}, + {HitTestResizeW, "ResizeW"}, + {HitTestResizeE, "ResizeE"}, + {HitTestResizeNW, "ResizeNW"}, + {HitTestResizeNE, "ResizeNE"}, + {HitTestResizeSW, "ResizeSW"}, + {HitTestResizeSE, "ResizeSE"}, + {HitTestResult(99), "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.result.String(); got != tt.want { + t.Errorf("HitTestResult(%d).String() = %q, want %q", tt.result, got, tt.want) + } + }) + } +} + +func TestHitTestResult_Values(t *testing.T) { + // Verify hit test result constants are sequential starting from 0 + if HitTestClient != 0 { + t.Errorf("HitTestClient = %d, want 0", HitTestClient) + } + if HitTestCaption != 1 { + t.Errorf("HitTestCaption = %d, want 1", HitTestCaption) + } + if HitTestClose != 2 { + t.Errorf("HitTestClose = %d, want 2", HitTestClose) + } + if HitTestMaximize != 3 { + t.Errorf("HitTestMaximize = %d, want 3", HitTestMaximize) + } + if HitTestMinimize != 4 { + t.Errorf("HitTestMinimize = %d, want 4", HitTestMinimize) + } + if HitTestResizeN != 5 { + t.Errorf("HitTestResizeN = %d, want 5", HitTestResizeN) + } + if HitTestResizeS != 6 { + t.Errorf("HitTestResizeS = %d, want 6", HitTestResizeS) + } + if HitTestResizeW != 7 { + t.Errorf("HitTestResizeW = %d, want 7", HitTestResizeW) + } + if HitTestResizeE != 8 { + t.Errorf("HitTestResizeE = %d, want 8", HitTestResizeE) + } + if HitTestResizeNW != 9 { + t.Errorf("HitTestResizeNW = %d, want 9", HitTestResizeNW) + } + if HitTestResizeNE != 10 { + t.Errorf("HitTestResizeNE = %d, want 10", HitTestResizeNE) + } + if HitTestResizeSW != 11 { + t.Errorf("HitTestResizeSW = %d, want 11", HitTestResizeSW) + } + if HitTestResizeSE != 12 { + t.Errorf("HitTestResizeSE = %d, want 12", HitTestResizeSE) + } +} + +// mockWindowChrome verifies the interface can be implemented by custom types. +type mockWindowChrome struct { + frameless bool + maximized bool + closed bool + minimized bool + callback HitTestCallback +} + +func (m *mockWindowChrome) SetFrameless(frameless bool) { m.frameless = frameless } +func (m *mockWindowChrome) IsFrameless() bool { return m.frameless } +func (m *mockWindowChrome) SetHitTestCallback(cb HitTestCallback) { m.callback = cb } +func (m *mockWindowChrome) Minimize() { m.minimized = true } +func (m *mockWindowChrome) Maximize() { m.maximized = !m.maximized } +func (m *mockWindowChrome) IsMaximized() bool { return m.maximized } +func (m *mockWindowChrome) Close() { m.closed = true } + +// Ensure mockWindowChrome implements WindowChrome. +var _ WindowChrome = &mockWindowChrome{} + +func TestWindowChrome_CustomImplementation(t *testing.T) { + mock := &mockWindowChrome{} + var wc WindowChrome = mock + + // Test frameless mode + wc.SetFrameless(true) + if !wc.IsFrameless() { + t.Error("IsFrameless() should return true after SetFrameless(true)") + } + wc.SetFrameless(false) + if wc.IsFrameless() { + t.Error("IsFrameless() should return false after SetFrameless(false)") + } + + // Test hit test callback + called := false + wc.SetHitTestCallback(func(x, y float64) HitTestResult { + called = true + if x > 100 { + return HitTestClient + } + return HitTestCaption + }) + if mock.callback == nil { + t.Fatal("callback should be set") + } + result := mock.callback(50, 10) + if !called { + t.Error("callback should have been called") + } + if result != HitTestCaption { + t.Errorf("callback(50, 10) = %v, want Caption", result) + } + result = mock.callback(150, 10) + if result != HitTestClient { + t.Errorf("callback(150, 10) = %v, want Client", result) + } + + // Test minimize + wc.Minimize() + if !mock.minimized { + t.Error("Minimize() should set minimized to true") + } + + // Test maximize toggle + wc.Maximize() + if !wc.IsMaximized() { + t.Error("IsMaximized() should return true after first Maximize()") + } + wc.Maximize() + if wc.IsMaximized() { + t.Error("IsMaximized() should return false after second Maximize()") + } + + // Test close + wc.Close() + if !mock.closed { + t.Error("Close() should set closed to true") + } +}