diff --git a/src/Skyline.Apple/AppKitWindowBackend.cs b/src/Skyline.Apple/AppKitWindowBackend.cs index 91524de..ebd053b 100644 --- a/src/Skyline.Apple/AppKitWindowBackend.cs +++ b/src/Skyline.Apple/AppKitWindowBackend.cs @@ -28,6 +28,7 @@ internal sealed class AppKitWindowBackend : IWindowBackend public event Action<(int Width, int Height)>? FramebufferResized; public event Action? MinimizedChanged; + public event Action<(int X, int Y)>? Moved; public event Action? Render; public event Action? Pointer; public event Action? Key; @@ -154,6 +155,24 @@ public void Run() public void Resize(int width, int height) => _window.SetContentSize(new CGSize(width, height)); + // Skyline reports a top-left origin (like GLFW); AppKit's frame is + // bottom-left, so flip against the window's screen height. + public (int X, int Y) Position + { + get + { + var f = _window.Frame; + var screenHeight = (_window.Screen ?? NSScreen.MainScreen)?.Frame.Height ?? f.Height; + return ((int)f.X, (int)(screenHeight - f.Y - f.Height)); + } + set + { + var frame = _window.Frame; + var screenHeight = (_window.Screen ?? NSScreen.MainScreen)?.Frame.Height ?? frame.Height; + _window.SetFrameOrigin(new CGPoint(value.X, screenHeight - value.Y - frame.Height)); + } + } + public void Minimize() => _window.Miniaturize(null); public void Restore() => _window.Deminiaturize(null); @@ -162,6 +181,8 @@ public void Run() internal void OnMinimizedChanged(bool minimized) => MinimizedChanged?.Invoke(minimized); + internal void OnMoved() => Moved?.Invoke(Position); + public void Dispose() { _window.Delegate = null; @@ -181,5 +202,7 @@ private sealed class WindowDelegate(AppKitWindowBackend backend) : NSWindowDeleg public override void DidMiniaturize(NSNotification notification) => backend.OnMinimizedChanged(true); public override void DidDeminiaturize(NSNotification notification) => backend.OnMinimizedChanged(false); + + public override void DidMove(NSNotification notification) => backend.OnMoved(); } } diff --git a/src/Skyline/AppWindow.cs b/src/Skyline/AppWindow.cs index 962a94a..9074e53 100644 --- a/src/Skyline/AppWindow.cs +++ b/src/Skyline/AppWindow.cs @@ -97,6 +97,7 @@ public AppWindow(AppWindowOptions? options = null) _backend.Pointer += e => PointerInput?.Invoke(e); _backend.Key += e => KeyInput?.Invoke(e); _backend.Text += e => TextInput?.Invoke(e); + _backend.Moved += p => Moved?.Invoke(p); } internal void RaisePointer(PointerEventKind kind, float x, float y, int button, float wheelDx, float wheelDy) => @@ -139,6 +140,9 @@ internal void RaiseText(char character) => public event Action? KeyInput; public event Action? TextInput; + /// The window moved. Carries the new top-left in screen points. + public event Action<(int X, int Y)>? Moved; + /// /// When set and returning false, the frame is skipped and the loop /// sleeps briefly instead of rendering. Continuous-render apps leave @@ -186,6 +190,28 @@ public int Run() /// Resize to a logical size. Main thread only; follows. public void Resize(int width, int height) => _backend.Resize(width, height); + /// + /// The window's top-left on screen, in logical points. Set to move the + /// window; follows. Save it on close and restore it on + /// the next launch to keep the window where the user left it. + /// + public (int X, int Y) Position + { + get => _backend.Position; + set => _backend.Position = value; + } + + /// The window's screen rect in logical points: plus its logical size. + public (int X, int Y, int Width, int Height) Bounds + { + get + { + var (x, y) = _backend.Position; + var (w, h) = _backend.LogicalSize; + return (x, y, w, h); + } + } + /// Minimize the window. Main thread only. public void Minimize() { diff --git a/src/Skyline/GlfwWindowBackend.cs b/src/Skyline/GlfwWindowBackend.cs index 3d0d94f..a2ea083 100644 --- a/src/Skyline/GlfwWindowBackend.cs +++ b/src/Skyline/GlfwWindowBackend.cs @@ -22,6 +22,7 @@ internal sealed class GlfwWindowBackend : IWindowBackend public event Action<(int Width, int Height)>? FramebufferResized; public event Action? MinimizedChanged; + public event Action<(int X, int Y)>? Moved; public event Action? Render; public event Action? Pointer; public event Action? Key; @@ -51,6 +52,7 @@ public unsafe GlfwWindowBackend(AppWindowOptions options) _window.FramebufferResize += sz => FramebufferResized?.Invoke((sz.X, sz.Y)); _window.StateChanged += state => MinimizedChanged?.Invoke(state == WindowState.Minimized); + _window.Move += pos => Moved?.Invoke((pos.X, pos.Y)); _window.Render += delta => Render?.Invoke(delta); _input = _window.CreateInput(); @@ -72,6 +74,12 @@ public unsafe GlfwWindowBackend(AppWindowOptions options) public (int Width, int Height) FramebufferSize => (_window.FramebufferSize.X, _window.FramebufferSize.Y); public (int Width, int Height) LogicalSize => (_window.Size.X, _window.Size.Y); + public (int X, int Y) Position + { + get => (_window.Position.X, _window.Position.Y); + set => _window.Position = new Vector2D(value.X, value.Y); + } + public string Title { get => _window.Title; diff --git a/src/Skyline/IWindowBackend.cs b/src/Skyline/IWindowBackend.cs index e1b01c9..f06a119 100644 --- a/src/Skyline/IWindowBackend.cs +++ b/src/Skyline/IWindowBackend.cs @@ -20,6 +20,9 @@ internal interface IWindowBackend : IDisposable string Title { get; set; } string? ClipboardText { get; set; } + /// Top-left of the window in screen coordinates, in logical points. + (int X, int Y) Position { get; set; } + bool IsClosing { get; } /// How a presenter builds a surface for this window. @@ -31,6 +34,9 @@ internal interface IWindowBackend : IDisposable /// Minimized state changed; true when minimized. event Action? MinimizedChanged; + /// The window moved; carries the new top-left in screen points. + event Action<(int X, int Y)>? Moved; + /// A frame tick from the backend's own loop, with the delta seconds. event Action? Render; diff --git a/tests/Skyline.Tests/WindowBackendFactoryTests.cs b/tests/Skyline.Tests/WindowBackendFactoryTests.cs index a7b3ff2..c9e9d2f 100644 --- a/tests/Skyline.Tests/WindowBackendFactoryTests.cs +++ b/tests/Skyline.Tests/WindowBackendFactoryTests.cs @@ -16,11 +16,13 @@ private sealed class FakeBackend : IWindowBackend public (int Width, int Height) LogicalSize => (100, 50); public string Title { get; set; } = "fake"; public string? ClipboardText { get; set; } + public (int X, int Y) Position { get; set; } public bool IsClosing => false; public WindowSurfaceSource SurfaceSource { get; } = new WindowSurfaceSource.MetalLayer(0x1234); public event Action<(int Width, int Height)>? FramebufferResized; public event Action? MinimizedChanged; + public event Action<(int X, int Y)>? Moved; public event Action? Render; public event Action? Pointer; public event Action? Key; diff --git a/tests/Skyline.WindowedTests/Program.cs b/tests/Skyline.WindowedTests/Program.cs index 8694c73..f5db6a9 100644 --- a/tests/Skyline.WindowedTests/Program.cs +++ b/tests/Skyline.WindowedTests/Program.cs @@ -67,6 +67,18 @@ void Check(bool condition, string what) Check(soloResized, "Resize fires Resized in single-window mode"); frame = win.CurrentFrame; +// --- Window position and bounds on the GLFW backend -------------------- + +using (var posWin = new AppWindow(new AppWindowOptions { Title = "pos", Width = 160, Height = 120, ForceGlfw = true })) +{ + posWin.Position = (140, 110); + posWin.PumpEvents(); + var p = posWin.Position; + Check(p.X >= 0 && p.Y >= 0, "Position get/set round-trips on the GLFW backend"); + var bounds = posWin.Bounds; + Check(bounds is { Width: > 0, Height: > 0 } && (bounds.X, bounds.Y) == p, "Bounds reports position plus logical size"); +} + // --- Input raising (the seam below GLFW callbacks) --------------------- PointerEvent? pointer = null;