diff --git a/samples/Approvals/Program.cs b/samples/Approvals/Program.cs
index 7fb2b33..8d517e7 100644
--- a/samples/Approvals/Program.cs
+++ b/samples/Approvals/Program.cs
@@ -50,7 +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));
-win.PointerInput += source.OnPointerEvent;
+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);
@@ -63,7 +63,7 @@ async Task Authorize(Actor actor, InteractionCapability capability, string promp
: $" GRANTED {actor.DisplayName} / {capability}");
}
-win.KeyInput += async e =>
+loop.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..e29a65f 100644
--- a/samples/InteractiveCanvas/Program.cs
+++ b/samples/InteractiveCanvas/Program.cs
@@ -29,13 +29,15 @@
var drawing = false;
var presented = 0;
-win.Resized += _ =>
+var handler = loop.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 +56,7 @@
}
};
-win.KeyInput += e =>
+handler.KeyInput = (_, e) =>
{
if (!e.IsDown)
{
diff --git a/samples/TexturedQuad/Program.cs b/samples/TexturedQuad/Program.cs
index 8b53f9a..a01f20f 100644
--- a/samples/TexturedQuad/Program.cs
+++ b/samples/TexturedQuad/Program.cs
@@ -35,7 +35,7 @@
var animate = true;
var presented = 0;
-win.KeyInput += e =>
+loop.Handler.KeyInput = (_, e) =>
{
if (!e.IsDown)
{
diff --git a/samples/ThrowingFrame/Program.cs b/samples/ThrowingFrame/Program.cs
index d621574..18015f3 100644
--- a/samples/ThrowingFrame/Program.cs
+++ b/samples/ThrowingFrame/Program.cs
@@ -30,7 +30,7 @@
var rendered = 0;
-win.KeyInput += e =>
+loop.Handler.KeyInput = (_, e) =>
{
if (e.IsDown && e.Key == Key.Escape)
{
diff --git a/samples/TransparentWindow/Program.cs b/samples/TransparentWindow/Program.cs
index 23d5685..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);
-window.PointerInput += slider.OnPointer;
+loop.Handler.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..e09e7f7 100644
--- a/samples/UnionApp/App.cs
+++ b/samples/UnionApp/App.cs
@@ -45,7 +45,7 @@ public FrameOutcome Run()
});
var presented = 0;
- win.KeyInput += e =>
+ loop.Handler.KeyInput = (_, e) =>
{
if (e.IsDown && e.Key == Key.Escape)
{
diff --git a/src/Skyline.Render/FrameLoop.cs b/src/Skyline.Render/FrameLoop.cs
index c9f08f8..beae3ca 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,16 @@ 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; }
+ ///
+ /// 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 CallbackAppWindowHandler Handler { get; } = new();
+
/// The GPU context — raw Api, device, and queue handles for the eject.
public GpuContext Gpu => _gpu;
@@ -131,8 +141,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 +276,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..2d44a1b 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.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");