From f969a6d807d8cf57d982ff73d1d3308ab1571f8b Mon Sep 17 00:00:00 2001 From: Cody Mullins <1738479+codymullins@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:01:39 -0400 Subject: [PATCH 1/2] refactor(window): replace AppWindow input/render events with a single handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window has exactly one owner, so its input, render, resize, and focus callbacks move from multicast `event Action` to a single `AppWindow.Handler` (an `AppWindowHandler`). Subclass it, or use the `CallbackAppWindowHandler` adapter for lambda-style hookup. FrameLoop is the window's handler while attached: it drives the frame and forwards input, focus, and post-reconfigure resize to an app handler set on `FrameLoop.Handler`, so apps set that instead of the window's. AppHost.WindowClosed stays an event — it is a true broadcast notification where independent listeners legitimately observe each window's close. The internal IWindowBackend seam stays events too: a single internal subscriber and an implementation detail. Samples and the windowed harness move to the handler. --- samples/Approvals/Program.cs | 5 ++- samples/HelloWindow/Program.cs | 11 +++-- samples/InteractiveCanvas/Program.cs | 9 ++-- samples/TexturedQuad/Program.cs | 4 +- samples/ThrowingFrame/Program.cs | 11 +++-- samples/TransparentWindow/Program.cs | 2 +- samples/TwoWindows/Program.cs | 35 ++++++++------- samples/UnionApp/App.cs | 11 +++-- src/Skyline.Render/FrameLoop.cs | 34 ++++++++++++--- src/Skyline/AppHost.cs | 8 ++-- src/Skyline/AppWindow.cs | 44 ++++++++----------- src/Skyline/AppWindowHandler.cs | 60 ++++++++++++++++++++++++++ tests/Skyline.WindowedTests/Program.cs | 35 ++++++++++----- 13 files changed, 187 insertions(+), 82 deletions(-) create mode 100644 src/Skyline/AppWindowHandler.cs diff --git a/samples/Approvals/Program.cs b/samples/Approvals/Program.cs index 7fb2b33..3bb63c8 100644 --- a/samples/Approvals/Program.cs +++ b/samples/Approvals/Program.cs @@ -50,7 +50,8 @@ // The desktop input bridge: it mints the local person and routes pointer-downs // to the overlay, and wraps the window clipboard behind the transfer seam. var source = new AppWindowInteractionSource(overlay, new AppWindowClipboard(win)); -win.PointerInput += source.OnPointerEvent; +var handler = new CallbackAppWindowHandler { PointerInput = (_, e) => source.OnPointerEvent(e) }; +loop.Handler = handler; var planner = new Actor("planner", "Planner", ActorKind.Ai, ActorLocality.Local); var formFiller = new Actor("form-filler", "Form Filler", ActorKind.Automation, ActorLocality.Local); @@ -63,7 +64,7 @@ async Task Authorize(Actor actor, InteractionCapability capability, string promp : $" GRANTED {actor.DisplayName} / {capability}"); } -win.KeyInput += async e => +handler.KeyInput = async (_, e) => { if (!e.IsDown) { diff --git a/samples/HelloWindow/Program.cs b/samples/HelloWindow/Program.cs index 7ab995f..69e8452 100644 --- a/samples/HelloWindow/Program.cs +++ b/samples/HelloWindow/Program.cs @@ -47,13 +47,16 @@ var dirtyFrames = 30; var presented = 0; -win.Resized += f => +var handler = new CallbackAppWindowHandler(); +win.Handler = handler; + +handler.Resized = (_, f) => { gpu.Configure(f.PixelWidth, f.PixelHeight); dirtyFrames = 30; // reconfigure discards surface contents, so redraw }; -win.PointerInput += e => +handler.PointerInput = (_, e) => { if (e.Kind != PointerEventKind.Move) { @@ -66,7 +69,7 @@ dirtyFrames = Math.Max(dirtyFrames, 3); }; -win.KeyInput += e => +handler.KeyInput = (_, e) => { if (!e.IsDown) { @@ -85,7 +88,7 @@ // finishes, otherwise idle frames sleep inside Skyline's loop. win.IsDirty = () => maxFrames > 0 || animate || dirtyFrames > 0; -win.RenderFrame += f => +handler.RenderFrame = (_, f) => { if (animate) { diff --git a/samples/InteractiveCanvas/Program.cs b/samples/InteractiveCanvas/Program.cs index e3a7309..b28609d 100644 --- a/samples/InteractiveCanvas/Program.cs +++ b/samples/InteractiveCanvas/Program.cs @@ -29,13 +29,16 @@ var drawing = false; var presented = 0; -win.Resized += _ => +var handler = new CallbackAppWindowHandler(); +loop.Handler = handler; + +handler.Resized = (_, _) => { renderer.MarkDirty(); // logical size changed — geometry must recompute loop.RequestRedraw(); }; -win.PointerInput += e => +handler.PointerInput = (_, e) => { if (e.Kind == PointerEventKind.Down) { @@ -54,7 +57,7 @@ } }; -win.KeyInput += e => +handler.KeyInput = (_, e) => { if (!e.IsDown) { diff --git a/samples/TexturedQuad/Program.cs b/samples/TexturedQuad/Program.cs index 8b53f9a..506a769 100644 --- a/samples/TexturedQuad/Program.cs +++ b/samples/TexturedQuad/Program.cs @@ -35,7 +35,9 @@ var animate = true; var presented = 0; -win.KeyInput += e => +var handler = new CallbackAppWindowHandler(); +loop.Handler = handler; +handler.KeyInput = (_, e) => { if (!e.IsDown) { diff --git a/samples/ThrowingFrame/Program.cs b/samples/ThrowingFrame/Program.cs index d621574..fa9bc77 100644 --- a/samples/ThrowingFrame/Program.cs +++ b/samples/ThrowingFrame/Program.cs @@ -30,12 +30,15 @@ var rendered = 0; -win.KeyInput += e => +loop.Handler = new CallbackAppWindowHandler { - if (e.IsDown && e.Key == Key.Escape) + KeyInput = (_, e) => { - win.RequestClose(); - } + if (e.IsDown && e.Key == Key.Escape) + { + win.RequestClose(); + } + }, }; loop.OnRender = (in Frame frame) => diff --git a/samples/TransparentWindow/Program.cs b/samples/TransparentWindow/Program.cs index 23d5685..94f0d07 100644 --- a/samples/TransparentWindow/Program.cs +++ b/samples/TransparentWindow/Program.cs @@ -38,7 +38,7 @@ using var quads = new QuadRenderer(loop.Gpu, loop.Surface.Format); var slider = new Slider(); slider.Layout(window.CurrentFrame.LogicalWidth, window.CurrentFrame.LogicalHeight); -window.PointerInput += slider.OnPointer; +loop.Handler = new CallbackAppWindowHandler { PointerInput = (_, e) => slider.OnPointer(e) }; Console.WriteLine("Drag the slider to change the window transparency."); diff --git a/samples/TwoWindows/Program.cs b/samples/TwoWindows/Program.cs index 99f29ee..431ba58 100644 --- a/samples/TwoWindows/Program.cs +++ b/samples/TwoWindows/Program.cs @@ -72,25 +72,28 @@ public WindowRenderer(GpuContext gpu, WindowSurface surface, AppWindow window, ( var frame = window.CurrentFrame; _surface.Configure(frame.PixelWidth, frame.PixelHeight); - window.Resized += f => _surface.Configure(f.PixelWidth, f.PixelHeight); - window.KeyInput += e => + window.Handler = new CallbackAppWindowHandler { - if (e.IsDown && e.Key == Skyline.Input.Key.Escape) + Resized = (_, f) => _surface.Configure(f.PixelWidth, f.PixelHeight), + KeyInput = (_, e) => { - window.RequestClose(); - } - }; - window.RenderFrame += f => - { - if (!RenderFrame(f)) - { - return; - } - - if (_maxFrames > 0 && _presented >= _maxFrames) + if (e.IsDown && e.Key == Skyline.Input.Key.Escape) + { + window.RequestClose(); + } + }, + RenderFrame = (_, f) => { - window.RequestClose(); - } + if (!RenderFrame(f)) + { + return; + } + + if (_maxFrames > 0 && _presented >= _maxFrames) + { + window.RequestClose(); + } + }, }; } diff --git a/samples/UnionApp/App.cs b/samples/UnionApp/App.cs index e608aa4..e988a3a 100644 --- a/samples/UnionApp/App.cs +++ b/samples/UnionApp/App.cs @@ -45,12 +45,15 @@ public FrameOutcome Run() }); var presented = 0; - win.KeyInput += e => + loop.Handler = new CallbackAppWindowHandler { - if (e.IsDown && e.Key == Key.Escape) + KeyInput = (_, e) => { - win.RequestClose(); - } + if (e.IsDown && e.Key == Key.Escape) + { + win.RequestClose(); + } + }, }; loop.OnRender = (in Frame frame) => { diff --git a/src/Skyline.Render/FrameLoop.cs b/src/Skyline.Render/FrameLoop.cs index c9f08f8..58a2f96 100644 --- a/src/Skyline.Render/FrameLoop.cs +++ b/src/Skyline.Render/FrameLoop.cs @@ -12,8 +12,8 @@ namespace Skyline.Render; /// /// Owns the per-frame ritual every windowed wgpu app repeats: pace, acquire /// (with stale-swapchain recovery), begin a clear pass, run your draw, end, -/// submit, count, present. It also wires the window's resize, idle, and -/// render events. What it owns, it owns completely — but every raw handle +/// submit, count, present. It also drives the window's resize, idle, and +/// render callbacks. What it owns, it owns completely — but every raw handle /// (, , and the live encoder/view/pass /// on each ) stays one property away, so an app drops to /// raw wgpu without leaving the loop. @@ -103,6 +103,15 @@ internal static FrameLoop CreateForTest(GpuContext gpu, FramePacer pacer, FrameL /// Your per-frame draw. Runs after the clear pass begins (when enabled) and before submit/present. Set it before the loop starts. public FrameCallback? OnRender { get; set; } + /// + /// Optional handler for the window's input, resize, and focus callbacks. The + /// loop owns the window's while attached and + /// forwards everything except the frame draw here, so set this instead of the + /// window's handler. Draw through , not the handler's + /// render callback. Resize forwards after the loop reconfigures the surface. + /// + public AppWindowHandler? Handler { get; set; } + /// The GPU context — raw Api, device, and queue handles for the eject. public GpuContext Gpu => _gpu; @@ -131,8 +140,22 @@ private void Wire(AppWindow window) _cancel = _surface.CancelFrame; var frame = window.CurrentFrame; _surface.Configure(frame.PixelWidth, frame.PixelHeight); - window.Resized += OnResized; - window.RenderFrame += OnRenderFrame; + // The loop is the window's single handler. It drives the frame itself + // and forwards input, focus, and post-reconfigure resize to the app's + // Handler. + window.Handler = new CallbackAppWindowHandler + { + RenderFrame = (_, info) => OnRenderFrame(info), + Resized = (w, f) => + { + OnResized(f); + Handler?.OnResized(w, f); + }, + PointerInput = (w, e) => Handler?.OnPointerInput(w, e), + KeyInput = (w, e) => Handler?.OnKeyInput(w, e), + TextInput = (w, e) => Handler?.OnTextInput(w, e), + FocusChanged = (w, e) => Handler?.OnFocusChanged(w, e), + }; // Continuous loops render every frame (IsDirty left null). Event-driven // loops idle until a redraw is pending. Consuming the flag here — at the // gate that decides whether to render — makes the check-and-consume one @@ -252,8 +275,7 @@ public void Dispose() _disposed = true; if (_window is not null) { - _window.Resized -= OnResized; - _window.RenderFrame -= OnRenderFrame; + _window.Handler = null; } // Attach built the context and pacer, so Attach disposes them (and the // context disposes its surface). Over and the test seam borrow them, so diff --git a/src/Skyline/AppHost.cs b/src/Skyline/AppHost.cs index 41fc666..29a0ee7 100644 --- a/src/Skyline/AppHost.cs +++ b/src/Skyline/AppHost.cs @@ -16,10 +16,10 @@ namespace Skyline; /// own display's rate. Two vsynced windows on different monitors never /// throttle each other, because no two blocking waits share a thread. /// -/// Threading contract for consumers: input events and -/// actions run on the main thread. and -/// run on that window's render thread, so -/// all of a window's GPU work stays on one thread. +/// Threading contract for consumers: input callbacks and +/// actions run on the main thread. +/// and run on that window's render +/// thread, so all of a window's GPU work stays on one thread. /// public sealed class AppHost : IDisposable { diff --git a/src/Skyline/AppWindow.cs b/src/Skyline/AppWindow.cs index 6368f25..7102188 100644 --- a/src/Skyline/AppWindow.cs +++ b/src/Skyline/AppWindow.cs @@ -53,7 +53,7 @@ public AppWindow(AppWindowOptions? options = null) if (Host is null) { _geom = g; - Resized?.Invoke(Frame(0)); + Handler?.OnResized(this, Frame(0)); } else { @@ -91,23 +91,23 @@ public AppWindow(AppWindowOptions? options = null) Thread.Sleep(8); return; } - RenderFrame?.Invoke(Frame(delta)); + Handler?.OnRenderFrame(this, Frame(delta)); }; - _backend.Pointer += e => PointerInput?.Invoke(e); - _backend.Key += e => KeyInput?.Invoke(e); - _backend.Text += e => TextInput?.Invoke(e); - _backend.FocusChanged += focused => FocusChanged?.Invoke(focused); + _backend.Pointer += e => Handler?.OnPointerInput(this, e); + _backend.Key += e => Handler?.OnKeyInput(this, e); + _backend.Text += e => Handler?.OnTextInput(this, e); + _backend.FocusChanged += focused => Handler?.OnFocusChanged(this, new WindowFocusEvent(focused)); } internal void RaisePointer(PointerEventKind kind, float x, float y, int button, float wheelDx, float wheelDy, ModifierKeys modifiers = ModifierKeys.None) => - PointerInput?.Invoke(new PointerEvent(kind, x, y, button, wheelDx, wheelDy, modifiers)); + Handler?.OnPointerInput(this, new PointerEvent(kind, x, y, button, wheelDx, wheelDy, modifiers)); internal void RaiseKey(bool isDown, Silk.NET.Input.Key key, ModifierKeys modifiers = ModifierKeys.None) => - KeyInput?.Invoke(new KeyEvent(isDown, MapKey(key), (int)key, modifiers)); + Handler?.OnKeyInput(this, new KeyEvent(isDown, MapKey(key), (int)key, modifiers)); internal void RaiseText(char character) => - TextInput?.Invoke(new TextEvent(character)); + Handler?.OnTextInput(this, new TextEvent(character)); /// /// The native window as a surface source. Hand this to your renderer @@ -130,21 +130,13 @@ internal void RaiseText(char character) => /// public nint? MetalLayer => _backend.SurfaceSource is WindowSurfaceSource.MetalLayer m ? m.Layer : null; - /// Draw and present a frame. Skyline never presents for you. - public event Action? RenderFrame; - - /// Framebuffer size changed. Reconfigure your swapchain. - public event Action? Resized; - - public event Action? PointerInput; - public event Action? KeyInput; - public event Action? TextInput; - /// - /// The window gained or lost focus (true = active). Pause animation, - /// timers, and media while blurred; resume on focus. + /// The window's single handler — input, render, resize, and focus callbacks. + /// A window has one owner, so it takes one handler, not multicast events. + /// Subclass , or use + /// for lambda-style hookup. /// - public event Action? FocusChanged; + public AppWindowHandler? Handler { get; set; } /// /// When set and returning false, the frame is skipped and the loop @@ -197,7 +189,7 @@ public int Run() /// public void RequestRedraw() => _redraw.Set(); - /// Resize to a logical size. Main thread only; follows. + /// Resize to a logical size. Main thread only; follows. public void Resize(int width, int height) => _backend.Resize(width, height); /// @@ -229,7 +221,7 @@ public void Restore() /// /// Process pending OS events once, without the built-in loop. For /// consumers that drive their own frame loop — engines, benchmarks — - /// instead of subscribing to . + /// instead of setting a for . /// public void PumpEvents() => _backend.PumpEventsOnce(); @@ -250,7 +242,7 @@ private FrameInfo Frame(double delta) internal bool IsClosing => _backend.IsClosing; internal bool IsMinimized => _minimized; internal bool ShouldRenderNow => IsDirty?.Invoke() != false; - internal void RaiseRenderFrame(double delta) => RenderFrame?.Invoke(Frame(delta)); + internal void RaiseRenderFrame(double delta) => Handler?.OnRenderFrame(this, Frame(delta)); internal bool WaitForRedraw(int milliseconds) => _redraw.WaitOne(milliseconds); internal bool TryConsumePendingResize(out FrameInfo frame) @@ -266,7 +258,7 @@ internal bool TryConsumePendingResize(out FrameInfo frame) return true; } - internal void RaiseResized(FrameInfo frame) => Resized?.Invoke(frame); + internal void RaiseResized(FrameInfo frame) => Handler?.OnResized(this, frame); internal static Input.Key MapKey(Silk.NET.Input.Key k) { diff --git a/src/Skyline/AppWindowHandler.cs b/src/Skyline/AppWindowHandler.cs new file mode 100644 index 0000000..69978b3 --- /dev/null +++ b/src/Skyline/AppWindowHandler.cs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +using Skyline.Input; + +namespace Skyline; + +/// The window gained or lost focus. +public readonly record struct WindowFocusEvent(bool IsFocused); + +/// +/// Receives a window's input, render, resize, and focus callbacks. A window has +/// exactly one owner, so it has one handler — set . +/// Subclass and override the callbacks you need; the rest are no-ops. +/// +/// All callbacks run synchronously: input and focus on the main thread, render +/// and resize on the window's render thread (see ). For +/// quick hookups that prefer lambdas over a subclass, use +/// . +/// +public abstract class AppWindowHandler +{ + /// Draw and present a frame. Skyline never presents for you. Runs on the render thread. + public virtual void OnRenderFrame(AppWindow window, FrameInfo frame) { } + + /// Framebuffer size changed. Reconfigure your swapchain. Runs on the render thread. + public virtual void OnResized(AppWindow window, FrameInfo frame) { } + + public virtual void OnPointerInput(AppWindow window, PointerEvent e) { } + + public virtual void OnKeyInput(AppWindow window, KeyEvent e) { } + + public virtual void OnTextInput(AppWindow window, TextEvent e) { } + + /// + /// The window gained or lost focus. Pause animation, timers, and media + /// while blurred; resume on focus. + /// + public virtual void OnFocusChanged(AppWindow window, WindowFocusEvent e) { } +} + +/// +/// An whose callbacks are plain delegates, set one +/// per callback. Each is a single delegate, not a multicast event: assigning one +/// replaces it. Convenient for samples and quick hookups. +/// +public sealed class CallbackAppWindowHandler : AppWindowHandler +{ + public Action? RenderFrame { get; set; } + public Action? Resized { get; set; } + public Action? PointerInput { get; set; } + public Action? KeyInput { get; set; } + public Action? TextInput { get; set; } + public Action? FocusChanged { get; set; } + + public override void OnRenderFrame(AppWindow window, FrameInfo frame) => RenderFrame?.Invoke(window, frame); + public override void OnResized(AppWindow window, FrameInfo frame) => Resized?.Invoke(window, frame); + public override void OnPointerInput(AppWindow window, PointerEvent e) => PointerInput?.Invoke(window, e); + public override void OnKeyInput(AppWindow window, KeyEvent e) => KeyInput?.Invoke(window, e); + public override void OnTextInput(AppWindow window, TextEvent e) => TextInput?.Invoke(window, e); + public override void OnFocusChanged(AppWindow window, WindowFocusEvent e) => FocusChanged?.Invoke(window, e); +} diff --git a/tests/Skyline.WindowedTests/Program.cs b/tests/Skyline.WindowedTests/Program.cs index b7fb43d..19abccc 100644 --- a/tests/Skyline.WindowedTests/Program.cs +++ b/tests/Skyline.WindowedTests/Program.cs @@ -63,7 +63,9 @@ void Check(bool condition, string what) } var soloResized = false; -win.Resized += _ => soloResized = true; +var winHandler = new CallbackAppWindowHandler(); +win.Handler = winHandler; +winHandler.Resized = (_, _) => soloResized = true; win.Resize(360, 270); win.PumpEvents(); Check(soloResized, "Resize fires Resized in single-window mode"); @@ -74,9 +76,9 @@ void Check(bool condition, string what) PointerEvent? pointer = null; KeyEvent? key = null; TextEvent? text = null; -win.PointerInput += e => pointer = e; -win.KeyInput += e => key = e; -win.TextInput += e => text = e; +winHandler.PointerInput = (_, e) => pointer = e; +winHandler.KeyInput = (_, e) => key = e; +winHandler.TextInput = (_, e) => text = e; win.RaisePointer(PointerEventKind.Down, 10f, 20f, 0, 0, 0); Check(pointer is { Kind: PointerEventKind.Down, X: 10f, Y: 20f, Button: 0, Modifiers: ModifierKeys.None }, "RaisePointer reaches PointerInput"); @@ -276,7 +278,9 @@ void Check(bool condition, string what) var frames = 0; var idleFrames = 0; solo.IsDirty = () => idleFrames++ >= 3; // exercise the idle-sleep path first - solo.RenderFrame += f => + var soloHandler = new CallbackAppWindowHandler(); + solo.Handler = soloHandler; + soloHandler.RenderFrame = (_, _) => { frames++; if (frames >= 3) @@ -298,6 +302,11 @@ void Check(bool condition, string what) host.AddWindow(winA); host.AddWindow(winB); + var winAHandler = new CallbackAppWindowHandler(); + var winBHandler = new CallbackAppWindowHandler(); + winA.Handler = winAHandler; + winB.Handler = winBHandler; + var doubleAdd = false; try { host.AddWindow(winA); } catch (InvalidOperationException) { doubleAdd = true; } Check(doubleAdd, "adding a window to a host twice throws"); @@ -315,7 +324,7 @@ void Check(bool condition, string what) var idledOnceB = false; var hostedResizeThread = 0; - winB.Resized += _ => hostedResizeThread = Environment.CurrentManagedThreadId; + winBHandler.Resized = (_, _) => hostedResizeThread = Environment.CurrentManagedThreadId; winB.IsDirty = () => { // Return false exactly once to walk the host's idle-wait path. @@ -328,7 +337,7 @@ void Check(bool condition, string what) return true; }; - winA.RenderFrame += _ => + winAHandler.RenderFrame = (_, _) => { renderThreadA = Environment.CurrentManagedThreadId; framesA++; @@ -348,7 +357,7 @@ void Check(bool condition, string what) winA.RequestClose(); } }; - winB.RenderFrame += _ => + winBHandler.RenderFrame = (_, _) => { renderThreadB = Environment.CurrentManagedThreadId; framesB++; @@ -413,7 +422,9 @@ void Check(bool condition, string what) var lateFrames = 0; var firstFrames = 0; - first.RenderFrame += _ => + var firstHandler = new CallbackAppWindowHandler(); + first.Handler = firstHandler; + firstHandler.RenderFrame = (_, _) => { firstFrames++; Thread.Sleep(2); // pace so the late window's thread has time to spin up @@ -425,7 +436,9 @@ void Check(bool condition, string what) host.Invoke(() => { late = new AppWindow(new AppWindowOptions { Title = "late", Width = 160, Height = 120 }); - late.RenderFrame += _ => + var lateHandler = new CallbackAppWindowHandler(); + late.Handler = lateHandler; + lateHandler.RenderFrame = (_, _) => { lateThread = Environment.CurrentManagedThreadId; lateRendered = true; @@ -498,12 +511,12 @@ void Check(bool condition, string what) var rframes = 0; var ejectSeen = false; var rloopResized = false; - rwin.Resized += _ => rloopResized = true; using var loop = FrameLoop.Attach(rwin, new FrameLoopOptions { ClearColor = new Silk.NET.WebGPU.Color { R = 0.0, G = 0.4, B = 0.8, A = 1.0 }, }); + loop.Handler = new CallbackAppWindowHandler { Resized = (_, _) => rloopResized = true }; Check(ReferenceEquals(loop.Surface, loop.Gpu.Surface), "FrameLoop.Surface is the context surface"); Check(loop.Pacer.MaxFramesInFlight == 2, "FrameLoop exposes its pacer"); From cfa3b9b5847c5fb2263880e9d5aefa577b909fb5 Mon Sep 17 00:00:00 2001 From: Cody Mullins <1738479+codymullins@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:08:38 -0400 Subject: [PATCH 2/2] refactor(render): pre-create FrameLoop.Handler so callers configure it directly FrameLoop.Handler is now a get-only CallbackAppWindowHandler the loop owns, so callers set callbacks on it (loop.Handler.KeyInput = ...) instead of newing their own handler. Drops the per-caller construction in the samples and the windowed harness. --- samples/Approvals/Program.cs | 5 ++--- samples/InteractiveCanvas/Program.cs | 3 +-- samples/TexturedQuad/Program.cs | 4 +--- samples/ThrowingFrame/Program.cs | 11 ++++------- samples/TransparentWindow/Program.cs | 2 +- samples/UnionApp/App.cs | 11 ++++------- src/Skyline.Render/FrameLoop.cs | 23 ++++++++++++----------- tests/Skyline.WindowedTests/Program.cs | 2 +- 8 files changed, 26 insertions(+), 35 deletions(-) diff --git a/samples/Approvals/Program.cs b/samples/Approvals/Program.cs index 3bb63c8..8d517e7 100644 --- a/samples/Approvals/Program.cs +++ b/samples/Approvals/Program.cs @@ -50,8 +50,7 @@ // The desktop input bridge: it mints the local person and routes pointer-downs // to the overlay, and wraps the window clipboard behind the transfer seam. var source = new AppWindowInteractionSource(overlay, new AppWindowClipboard(win)); -var handler = new CallbackAppWindowHandler { PointerInput = (_, e) => source.OnPointerEvent(e) }; -loop.Handler = handler; +loop.Handler.PointerInput = (_, e) => source.OnPointerEvent(e); var planner = new Actor("planner", "Planner", ActorKind.Ai, ActorLocality.Local); var formFiller = new Actor("form-filler", "Form Filler", ActorKind.Automation, ActorLocality.Local); @@ -64,7 +63,7 @@ async Task Authorize(Actor actor, InteractionCapability capability, string promp : $" GRANTED {actor.DisplayName} / {capability}"); } -handler.KeyInput = async (_, e) => +loop.Handler.KeyInput = async (_, e) => { if (!e.IsDown) { diff --git a/samples/InteractiveCanvas/Program.cs b/samples/InteractiveCanvas/Program.cs index b28609d..e29a65f 100644 --- a/samples/InteractiveCanvas/Program.cs +++ b/samples/InteractiveCanvas/Program.cs @@ -29,8 +29,7 @@ var drawing = false; var presented = 0; -var handler = new CallbackAppWindowHandler(); -loop.Handler = handler; +var handler = loop.Handler; handler.Resized = (_, _) => { diff --git a/samples/TexturedQuad/Program.cs b/samples/TexturedQuad/Program.cs index 506a769..a01f20f 100644 --- a/samples/TexturedQuad/Program.cs +++ b/samples/TexturedQuad/Program.cs @@ -35,9 +35,7 @@ var animate = true; var presented = 0; -var handler = new CallbackAppWindowHandler(); -loop.Handler = handler; -handler.KeyInput = (_, e) => +loop.Handler.KeyInput = (_, e) => { if (!e.IsDown) { diff --git a/samples/ThrowingFrame/Program.cs b/samples/ThrowingFrame/Program.cs index fa9bc77..18015f3 100644 --- a/samples/ThrowingFrame/Program.cs +++ b/samples/ThrowingFrame/Program.cs @@ -30,15 +30,12 @@ var rendered = 0; -loop.Handler = new CallbackAppWindowHandler +loop.Handler.KeyInput = (_, e) => { - KeyInput = (_, e) => + if (e.IsDown && e.Key == Key.Escape) { - if (e.IsDown && e.Key == Key.Escape) - { - win.RequestClose(); - } - }, + win.RequestClose(); + } }; loop.OnRender = (in Frame frame) => diff --git a/samples/TransparentWindow/Program.cs b/samples/TransparentWindow/Program.cs index 94f0d07..42c77b9 100644 --- a/samples/TransparentWindow/Program.cs +++ b/samples/TransparentWindow/Program.cs @@ -38,7 +38,7 @@ using var quads = new QuadRenderer(loop.Gpu, loop.Surface.Format); var slider = new Slider(); slider.Layout(window.CurrentFrame.LogicalWidth, window.CurrentFrame.LogicalHeight); -loop.Handler = new CallbackAppWindowHandler { PointerInput = (_, e) => slider.OnPointer(e) }; +loop.Handler.PointerInput = (_, e) => slider.OnPointer(e); Console.WriteLine("Drag the slider to change the window transparency."); diff --git a/samples/UnionApp/App.cs b/samples/UnionApp/App.cs index e988a3a..e09e7f7 100644 --- a/samples/UnionApp/App.cs +++ b/samples/UnionApp/App.cs @@ -45,15 +45,12 @@ public FrameOutcome Run() }); var presented = 0; - loop.Handler = new CallbackAppWindowHandler + loop.Handler.KeyInput = (_, e) => { - KeyInput = (_, e) => + if (e.IsDown && e.Key == Key.Escape) { - if (e.IsDown && e.Key == Key.Escape) - { - win.RequestClose(); - } - }, + win.RequestClose(); + } }; loop.OnRender = (in Frame frame) => { diff --git a/src/Skyline.Render/FrameLoop.cs b/src/Skyline.Render/FrameLoop.cs index 58a2f96..beae3ca 100644 --- a/src/Skyline.Render/FrameLoop.cs +++ b/src/Skyline.Render/FrameLoop.cs @@ -104,13 +104,14 @@ internal static FrameLoop CreateForTest(GpuContext gpu, FramePacer pacer, FrameL public FrameCallback? OnRender { get; set; } /// - /// Optional handler for the window's input, resize, and focus callbacks. The - /// loop owns the window's while attached and - /// forwards everything except the frame draw here, so set this instead of the - /// window's handler. Draw through , not the handler's - /// render callback. Resize forwards after the loop reconfigures the surface. + /// Callbacks for the window's input, resize, and focus — already created, so + /// set them directly (e.g. loop.Handler.KeyInput = ...). The loop owns + /// the window's while attached and forwards + /// everything except the frame draw here. Draw through , + /// not the render callback. Resize forwards after the loop reconfigures the + /// surface. /// - public AppWindowHandler? Handler { get; set; } + public CallbackAppWindowHandler Handler { get; } = new(); /// The GPU context — raw Api, device, and queue handles for the eject. public GpuContext Gpu => _gpu; @@ -149,12 +150,12 @@ private void Wire(AppWindow window) Resized = (w, f) => { OnResized(f); - Handler?.OnResized(w, f); + Handler.OnResized(w, f); }, - PointerInput = (w, e) => Handler?.OnPointerInput(w, e), - KeyInput = (w, e) => Handler?.OnKeyInput(w, e), - TextInput = (w, e) => Handler?.OnTextInput(w, e), - FocusChanged = (w, e) => Handler?.OnFocusChanged(w, e), + PointerInput = (w, e) => Handler.OnPointerInput(w, e), + KeyInput = (w, e) => Handler.OnKeyInput(w, e), + TextInput = (w, e) => Handler.OnTextInput(w, e), + FocusChanged = (w, e) => Handler.OnFocusChanged(w, e), }; // Continuous loops render every frame (IsDirty left null). Event-driven // loops idle until a redraw is pending. Consuming the flag here — at the diff --git a/tests/Skyline.WindowedTests/Program.cs b/tests/Skyline.WindowedTests/Program.cs index 19abccc..2d44a1b 100644 --- a/tests/Skyline.WindowedTests/Program.cs +++ b/tests/Skyline.WindowedTests/Program.cs @@ -516,7 +516,7 @@ void Check(bool condition, string what) { ClearColor = new Silk.NET.WebGPU.Color { R = 0.0, G = 0.4, B = 0.8, A = 1.0 }, }); - loop.Handler = new CallbackAppWindowHandler { Resized = (_, _) => rloopResized = true }; + loop.Handler.Resized = (_, _) => rloopResized = true; Check(ReferenceEquals(loop.Surface, loop.Gpu.Surface), "FrameLoop.Surface is the context surface"); Check(loop.Pacer.MaxFramesInFlight == 2, "FrameLoop exposes its pacer");