// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.InteropServices; using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.ImageExtensions; using osu.Framework.Input; using osu.Framework.Platform.SDL2; using osu.Framework.Platform.Windows.Native; using osu.Framework.Threading; using osuTK; using osuTK.Input; using SDL2; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using Image = SixLabors.ImageSharp.Image; using Point = System.Drawing.Point; using Rectangle = System.Drawing.Rectangle; using Size = System.Drawing.Size; // ReSharper disable UnusedParameter.Local (Class regularly handles native events where we don't consume all parameters) namespace osu.Framework.Platform { /// /// Default implementation of a desktop window, using SDL for windowing and graphics support. /// public class SDL2DesktopWindow : IWindow { internal IntPtr SDLWindowHandle { get; private set; } = IntPtr.Zero; private readonly IGraphicsBackend graphicsBackend; private bool focused; /// /// Whether the window currently has focus. /// public bool Focused { get => focused; private set { if (value == focused) return; isActive.Value = focused = value; } } /// /// Enables or disables vertical sync. /// public bool VerticalSync { get => graphicsBackend.VerticalSync; set => graphicsBackend.VerticalSync = value; } /// /// Returns true if window has been created. /// Returns false if the window has not yet been created, or has been closed. /// public bool Exists { get; private set; } public WindowMode DefaultWindowMode => Configuration.WindowMode.Windowed; /// /// Returns the window modes that the platform should support by default. /// protected virtual IEnumerable DefaultSupportedWindowModes => Enum.GetValues(typeof(WindowMode)).OfType(); private Point position; /// /// Returns or sets the window's position in screen space. Only valid when in /// public Point Position { get => position; set { position = value; ScheduleCommand(() => SDL.SDL_SetWindowPosition(SDLWindowHandle, value.X, value.Y)); } } private bool resizable = true; /// /// Returns or sets whether the window is resizable or not. Only valid when in . /// public bool Resizable { get => resizable; set { if (resizable == value) return; resizable = value; ScheduleCommand(() => SDL.SDL_SetWindowResizable(SDLWindowHandle, value ? SDL.SDL_bool.SDL_TRUE : SDL.SDL_bool.SDL_FALSE)); } } private bool relativeMouseMode; /// /// Set the state of SDL2's RelativeMouseMode (https://wiki.libsdl.org/SDL_SetRelativeMouseMode). /// On all platforms, this will lock the mouse to the window (although escaping by setting is still possible via a local implementation). /// On windows, this will use raw input if available. /// public bool RelativeMouseMode { get => relativeMouseMode; set { if (relativeMouseMode == value) return; if (value && !CursorState.HasFlagFast(CursorState.Hidden)) throw new InvalidOperationException($"Cannot set {nameof(RelativeMouseMode)} to true when the cursor is not hidden via {nameof(CursorState)}."); relativeMouseMode = value; ScheduleCommand(() => SDL.SDL_SetRelativeMouseMode(value ? SDL.SDL_bool.SDL_TRUE : SDL.SDL_bool.SDL_FALSE)); } } private Size size = new Size(default_width, default_height); /// /// Returns or sets the window's internal size, before scaling. /// public Size Size { get => size; private set { if (value.Equals(size)) return; size = value; ScheduleEvent(() => Resized?.Invoke()); } } /// /// Provides a bindable that controls the window's . /// public Bindable CursorStateBindable { get; } = new Bindable(); public CursorState CursorState { get => CursorStateBindable.Value; set => CursorStateBindable.Value = value; } public Bindable CurrentDisplayBindable { get; } = new Bindable(); public Bindable WindowMode { get; } = new Bindable(); private readonly BindableBool isActive = new BindableBool(); public IBindable IsActive => isActive; private readonly BindableBool cursorInWindow = new BindableBool(); public IBindable CursorInWindow => cursorInWindow; public IBindableList SupportedWindowModes { get; } public BindableSafeArea SafeAreaPadding { get; } = new BindableSafeArea(); public virtual Point PointToClient(Point point) => point; public virtual Point PointToScreen(Point point) => point; private const int default_width = 1366; private const int default_height = 768; private const int default_icon_size = 256; private readonly Scheduler commandScheduler = new Scheduler(); private readonly Scheduler eventScheduler = new Scheduler(); private readonly Dictionary controllers = new Dictionary(); private string title = string.Empty; /// /// Gets and sets the window title. /// public string Title { get => title; set { title = value; ScheduleCommand(() => SDL.SDL_SetWindowTitle(SDLWindowHandle, title)); } } private bool visible; /// /// Enables or disables the window visibility. /// public bool Visible { get => visible; set { visible = value; ScheduleCommand(() => { if (value) SDL.SDL_ShowWindow(SDLWindowHandle); else SDL.SDL_HideWindow(SDLWindowHandle); }); } } private void updateCursorVisibility(bool visible) => ScheduleCommand(() => SDL.SDL_ShowCursor(visible ? SDL.SDL_ENABLE : SDL.SDL_DISABLE)); private void updateCursorConfined(bool confined) => ScheduleCommand(() => SDL.SDL_SetWindowGrab(SDLWindowHandle, confined ? SDL.SDL_bool.SDL_TRUE : SDL.SDL_bool.SDL_FALSE)); private WindowState windowState = WindowState.Normal; private WindowState? pendingWindowState; /// /// Returns or sets the window's current . /// public WindowState WindowState { get => windowState; set { if (pendingWindowState == null && windowState == value) return; pendingWindowState = value; } } /// /// Stores whether the window used to be in maximised state or not. /// Used to properly decide what window state to pick when switching to windowed mode (see change event) /// private bool windowMaximised; /// /// Returns the drawable area, after scaling. /// public Size ClientSize => new Size(Size.Width, Size.Height); public float Scale = 1; /// /// Queries the physical displays and their supported resolutions. /// public IEnumerable Displays => Enumerable.Range(0, SDL.SDL_GetNumVideoDisplays()).Select(displayFromSDL); /// /// Gets the that has been set as "primary" or "default" in the operating system. /// public virtual Display PrimaryDisplay => Displays.First(); private Display currentDisplay; private int displayIndex = -1; /// /// Gets or sets the that this window is currently on. /// public Display CurrentDisplay { get; private set; } public readonly Bindable ConfineMouseMode = new Bindable(); private readonly Bindable currentDisplayMode = new Bindable(); /// /// The for the display that this window is currently on. /// public IBindable CurrentDisplayMode => currentDisplayMode; /// /// Gets the native window handle as provided by the operating system. /// public IntPtr WindowHandle { get { if (SDLWindowHandle == IntPtr.Zero) return IntPtr.Zero; var wmInfo = getWindowWMInfo(); // Window handle is selected per subsystem as defined at: // https://wiki.libsdl.org/SDL_SysWMinfo switch (wmInfo.subsystem) { case SDL.SDL_SYSWM_TYPE.SDL_SYSWM_WINDOWS: return wmInfo.info.win.window; case SDL.SDL_SYSWM_TYPE.SDL_SYSWM_X11: return wmInfo.info.x11.window; case SDL.SDL_SYSWM_TYPE.SDL_SYSWM_DIRECTFB: return wmInfo.info.dfb.window; case SDL.SDL_SYSWM_TYPE.SDL_SYSWM_COCOA: return wmInfo.info.cocoa.window; case SDL.SDL_SYSWM_TYPE.SDL_SYSWM_UIKIT: return wmInfo.info.uikit.window; case SDL.SDL_SYSWM_TYPE.SDL_SYSWM_WAYLAND: return wmInfo.info.wl.shell_surface; case SDL.SDL_SYSWM_TYPE.SDL_SYSWM_ANDROID: return wmInfo.info.android.window; default: return IntPtr.Zero; } } } private SDL.SDL_SysWMinfo getWindowWMInfo() { if (SDLWindowHandle == IntPtr.Zero) return default; var wmInfo = new SDL.SDL_SysWMinfo(); SDL.SDL_GetWindowWMInfo(SDLWindowHandle, ref wmInfo); return wmInfo; } private Rectangle windowDisplayBounds { get { SDL.SDL_GetDisplayBounds(displayIndex, out var rect); return new Rectangle(rect.x, rect.y, rect.w, rect.h); } } public bool CapsLockPressed => SDL.SDL_GetModState().HasFlagFast(SDL.SDL_Keymod.KMOD_CAPS); private bool firstDraw = true; private readonly BindableSize sizeFullscreen = new BindableSize(); private readonly BindableSize sizeWindowed = new BindableSize(); private readonly BindableDouble windowPositionX = new BindableDouble(); private readonly BindableDouble windowPositionY = new BindableDouble(); private readonly Bindable windowDisplayIndexBindable = new Bindable(); public SDL2DesktopWindow() { SDL.SDL_Init(SDL.SDL_INIT_VIDEO | SDL.SDL_INIT_GAMECONTROLLER); graphicsBackend = CreateGraphicsBackend(); SupportedWindowModes = new BindableList(DefaultSupportedWindowModes); CursorStateBindable.ValueChanged += evt => { updateCursorVisibility(!evt.NewValue.HasFlagFast(CursorState.Hidden)); updateCursorConfined(evt.NewValue.HasFlagFast(CursorState.Confined)); }; populateJoysticks(); } /// /// Creates the window and initialises the graphics backend. /// public virtual void Create() { SDL.SDL_WindowFlags flags = SDL.SDL_WindowFlags.SDL_WINDOW_OPENGL | SDL.SDL_WindowFlags.SDL_WINDOW_RESIZABLE | SDL.SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI | SDL.SDL_WindowFlags.SDL_WINDOW_HIDDEN | // shown after first swap to avoid white flash on startup (windows) WindowState.ToFlags(); SDL.SDL_SetHint(SDL.SDL_HINT_WINDOWS_NO_CLOSE_ON_ALT_F4, "1"); SDL.SDL_SetHint(SDL.SDL_HINT_VIDEO_MINIMIZE_ON_FOCUS_LOSS, "1"); SDLWindowHandle = SDL.SDL_CreateWindow(title, Position.X, Position.Y, Size.Width, Size.Height, flags); Exists = true; MouseEntered += () => cursorInWindow.Value = true; MouseLeft += () => cursorInWindow.Value = false; graphicsBackend.Initialise(this); updateWindowSpecifics(); updateWindowSize(); WindowMode.TriggerChange(); } // reference must be kept to avoid GC, see https://stackoverflow.com/a/6193914 [UsedImplicitly] private SDL.SDL_EventFilter eventFilterDelegate; /// /// Starts the window's run loop. /// public void Run() { // polling via SDL_PollEvent blocks on resizes (https://stackoverflow.com/a/50858339) SDL.SDL_SetEventFilter(eventFilterDelegate = (_, eventPtr) => { // ReSharper disable once PossibleNullReferenceException var e = (SDL.SDL_Event)Marshal.PtrToStructure(eventPtr, typeof(SDL.SDL_Event)); if (e.type == SDL.SDL_EventType.SDL_WINDOWEVENT && e.window.windowEvent == SDL.SDL_WindowEventID.SDL_WINDOWEVENT_RESIZED) { updateWindowSize(); } return 1; }, IntPtr.Zero); while (Exists) { commandScheduler.Update(); if (!Exists) break; if (pendingWindowState != null) updateWindowSpecifics(); pollSDLEvents(); if (!cursorInWindow.Value) pollMouse(); eventScheduler.Update(); Update?.Invoke(); } Exited?.Invoke(); if (SDLWindowHandle != IntPtr.Zero) SDL.SDL_DestroyWindow(SDLWindowHandle); SDL.SDL_Quit(); } /// /// Updates the client size and the scale according to the window. /// /// Whether the window size has been changed after updating. private void updateWindowSize() { SDL.SDL_GL_GetDrawableSize(SDLWindowHandle, out var w, out var h); SDL.SDL_GetWindowSize(SDLWindowHandle, out var actualW, out var _); Scale = (float)w / actualW; Size = new Size(w, h); // This function may be invoked before the SDL internal states are all changed. (as documented here: https://wiki.libsdl.org/SDL_SetEventFilter) // Scheduling the store to config until after the event poll has run will ensure the window is in the correct state. eventScheduler.Add(storeWindowSizeToConfig, true); } /// /// Forcefully closes the window. /// public void Close() => ScheduleCommand(() => Exists = false); /// /// Attempts to close the window. /// public void RequestClose() => ScheduleEvent(() => { if (ExitRequested?.Invoke() != true) Close(); }); public void SwapBuffers() { graphicsBackend.SwapBuffers(); if (firstDraw) { Visible = true; firstDraw = false; } } /// /// Requests that the graphics backend become the current context. /// May not be required for some backends. /// public void MakeCurrent() => graphicsBackend.MakeCurrent(); /// /// Requests that the current context be cleared. /// public void ClearCurrent() => graphicsBackend.ClearCurrent(); private void enqueueJoystickAxisInput(JoystickAxisSource axisSource, short axisValue) { // SDL reports axis values in the range short.MinValue to short.MaxValue, so we scale and clamp it to the range of -1f to 1f var clamped = Math.Clamp((float)axisValue / short.MaxValue, -1f, 1f); ScheduleEvent(() => JoystickAxisChanged?.Invoke(new JoystickAxis(axisSource, clamped))); } private void enqueueJoystickButtonInput(JoystickButton button, bool isPressed) { if (isPressed) ScheduleEvent(() => JoystickButtonDown?.Invoke(button)); else ScheduleEvent(() => JoystickButtonUp?.Invoke(button)); } /// /// Attempts to set the window's icon to the specified image. /// /// An to set as the window icon. private unsafe void setSDLIcon(Image image) { var pixelMemory = image.CreateReadOnlyPixelMemory(); var imageSize = image.Size(); ScheduleCommand(() => { var pixelSpan = pixelMemory.Span; IntPtr surface; fixed (Rgba32* ptr = pixelSpan) surface = SDL.SDL_CreateRGBSurfaceFrom(new IntPtr(ptr), imageSize.Width, imageSize.Height, 32, imageSize.Width * 4, 0xff, 0xff00, 0xff0000, 0xff000000); SDL.SDL_SetWindowIcon(SDLWindowHandle, surface); SDL.SDL_FreeSurface(surface); }); } private Point previousPolledPoint = Point.Empty; private void pollMouse() { SDL.SDL_GetGlobalMouseState(out var x, out var y); if (previousPolledPoint.X == x && previousPolledPoint.Y == y) return; previousPolledPoint = new Point(x, y); var pos = WindowMode.Value == Configuration.WindowMode.Windowed ? Position : windowDisplayBounds.Location; var rx = x - pos.X; var ry = y - pos.Y; ScheduleEvent(() => MouseMove?.Invoke(new Vector2(rx * Scale, ry * Scale))); } #region SDL Event Handling /// /// Adds an to the expected to handle event callbacks. /// /// The to execute. protected void ScheduleEvent(Action action) => eventScheduler.Add(action, false); protected void ScheduleCommand(Action action) => commandScheduler.Add(action, false); /// /// Poll for all pending events. /// private void pollSDLEvents() { while (SDL.SDL_PollEvent(out var e) > 0) handleSDLEvent(e); } private void handleSDLEvent(SDL.SDL_Event e) { switch (e.type) { case SDL.SDL_EventType.SDL_QUIT: case SDL.SDL_EventType.SDL_APP_TERMINATING: handleQuitEvent(e.quit); break; case SDL.SDL_EventType.SDL_WINDOWEVENT: handleWindowEvent(e.window); break; case SDL.SDL_EventType.SDL_KEYDOWN: case SDL.SDL_EventType.SDL_KEYUP: handleKeyboardEvent(e.key); break; case SDL.SDL_EventType.SDL_TEXTEDITING: handleTextEditingEvent(e.edit); break; case SDL.SDL_EventType.SDL_TEXTINPUT: handleTextInputEvent(e.text); break; case SDL.SDL_EventType.SDL_MOUSEMOTION: handleMouseMotionEvent(e.motion); break; case SDL.SDL_EventType.SDL_MOUSEBUTTONDOWN: case SDL.SDL_EventType.SDL_MOUSEBUTTONUP: handleMouseButtonEvent(e.button); break; case SDL.SDL_EventType.SDL_MOUSEWHEEL: handleMouseWheelEvent(e.wheel); break; case SDL.SDL_EventType.SDL_JOYAXISMOTION: handleJoyAxisEvent(e.jaxis); break; case SDL.SDL_EventType.SDL_JOYBALLMOTION: handleJoyBallEvent(e.jball); break; case SDL.SDL_EventType.SDL_JOYHATMOTION: handleJoyHatEvent(e.jhat); break; case SDL.SDL_EventType.SDL_JOYBUTTONDOWN: case SDL.SDL_EventType.SDL_JOYBUTTONUP: handleJoyButtonEvent(e.jbutton); break; case SDL.SDL_EventType.SDL_JOYDEVICEADDED: case SDL.SDL_EventType.SDL_JOYDEVICEREMOVED: handleJoyDeviceEvent(e.jdevice); break; case SDL.SDL_EventType.SDL_CONTROLLERAXISMOTION: handleControllerAxisEvent(e.caxis); break; case SDL.SDL_EventType.SDL_CONTROLLERBUTTONDOWN: case SDL.SDL_EventType.SDL_CONTROLLERBUTTONUP: handleControllerButtonEvent(e.cbutton); break; case SDL.SDL_EventType.SDL_CONTROLLERDEVICEADDED: case SDL.SDL_EventType.SDL_CONTROLLERDEVICEREMOVED: case SDL.SDL_EventType.SDL_CONTROLLERDEVICEREMAPPED: handleControllerDeviceEvent(e.cdevice); break; case SDL.SDL_EventType.SDL_FINGERDOWN: case SDL.SDL_EventType.SDL_FINGERUP: case SDL.SDL_EventType.SDL_FINGERMOTION: handleTouchFingerEvent(e.tfinger); break; case SDL.SDL_EventType.SDL_DROPFILE: case SDL.SDL_EventType.SDL_DROPTEXT: case SDL.SDL_EventType.SDL_DROPBEGIN: case SDL.SDL_EventType.SDL_DROPCOMPLETE: handleDropEvent(e.drop); break; } } private void handleQuitEvent(SDL.SDL_QuitEvent evtQuit) => RequestClose(); private void handleDropEvent(SDL.SDL_DropEvent evtDrop) { switch (evtDrop.type) { case SDL.SDL_EventType.SDL_DROPFILE: var str = SDL.UTF8_ToManaged(evtDrop.file, true); if (str != null) ScheduleEvent(() => DragDrop?.Invoke(str)); break; } } private void handleTouchFingerEvent(SDL.SDL_TouchFingerEvent evtTfinger) { } private void handleControllerDeviceEvent(SDL.SDL_ControllerDeviceEvent evtCdevice) { switch (evtCdevice.type) { case SDL.SDL_EventType.SDL_CONTROLLERDEVICEADDED: addJoystick(evtCdevice.which); break; case SDL.SDL_EventType.SDL_CONTROLLERDEVICEREMOVED: SDL.SDL_GameControllerClose(controllers[evtCdevice.which].ControllerHandle); controllers.Remove(evtCdevice.which); break; case SDL.SDL_EventType.SDL_CONTROLLERDEVICEREMAPPED: if (controllers.TryGetValue(evtCdevice.which, out var state)) state.PopulateBindings(); break; } } private void handleControllerButtonEvent(SDL.SDL_ControllerButtonEvent evtCbutton) { var button = ((SDL.SDL_GameControllerButton)evtCbutton.button).ToJoystickButton(); switch (evtCbutton.type) { case SDL.SDL_EventType.SDL_CONTROLLERBUTTONDOWN: enqueueJoystickButtonInput(button, true); break; case SDL.SDL_EventType.SDL_CONTROLLERBUTTONUP: enqueueJoystickButtonInput(button, false); break; } } private void handleControllerAxisEvent(SDL.SDL_ControllerAxisEvent evtCaxis) => enqueueJoystickAxisInput(((SDL.SDL_GameControllerAxis)evtCaxis.axis).ToJoystickAxisSource(), evtCaxis.axisValue); private void addJoystick(int which) { var instanceID = SDL.SDL_JoystickGetDeviceInstanceID(which); // if the joystick is already opened, ignore it if (controllers.ContainsKey(instanceID)) return; var joystick = SDL.SDL_JoystickOpen(which); var controller = IntPtr.Zero; if (SDL.SDL_IsGameController(which) == SDL.SDL_bool.SDL_TRUE) controller = SDL.SDL_GameControllerOpen(which); controllers[instanceID] = new SDL2ControllerBindings(joystick, controller); } /// /// Populates with joysticks that are already connected. /// private void populateJoysticks() { for (int i = 0; i < SDL.SDL_NumJoysticks(); i++) { addJoystick(i); } } private void handleJoyDeviceEvent(SDL.SDL_JoyDeviceEvent evtJdevice) { switch (evtJdevice.type) { case SDL.SDL_EventType.SDL_JOYDEVICEADDED: addJoystick(evtJdevice.which); break; case SDL.SDL_EventType.SDL_JOYDEVICEREMOVED: // if the joystick is already closed, ignore it if (!controllers.ContainsKey(evtJdevice.which)) break; SDL.SDL_JoystickClose(controllers[evtJdevice.which].JoystickHandle); controllers.Remove(evtJdevice.which); break; } } private void handleJoyButtonEvent(SDL.SDL_JoyButtonEvent evtJbutton) { // if this button exists in the controller bindings, skip it if (controllers.TryGetValue(evtJbutton.which, out var state) && state.GetButtonForIndex(evtJbutton.button) != SDL.SDL_GameControllerButton.SDL_CONTROLLER_BUTTON_INVALID) return; var button = JoystickButton.FirstButton + evtJbutton.button; switch (evtJbutton.type) { case SDL.SDL_EventType.SDL_JOYBUTTONDOWN: enqueueJoystickButtonInput(button, true); break; case SDL.SDL_EventType.SDL_JOYBUTTONUP: enqueueJoystickButtonInput(button, false); break; } } private void handleJoyHatEvent(SDL.SDL_JoyHatEvent evtJhat) { } private void handleJoyBallEvent(SDL.SDL_JoyBallEvent evtJball) { } private void handleJoyAxisEvent(SDL.SDL_JoyAxisEvent evtJaxis) { // if this axis exists in the controller bindings, skip it if (controllers.TryGetValue(evtJaxis.which, out var state) && state.GetAxisForIndex(evtJaxis.axis) != SDL.SDL_GameControllerAxis.SDL_CONTROLLER_AXIS_INVALID) return; enqueueJoystickAxisInput(JoystickAxisSource.Axis1 + evtJaxis.axis, evtJaxis.axisValue); } private void handleMouseWheelEvent(SDL.SDL_MouseWheelEvent evtWheel) => ScheduleEvent(() => TriggerMouseWheel(new Vector2(evtWheel.x, evtWheel.y), false)); private void handleMouseButtonEvent(SDL.SDL_MouseButtonEvent evtButton) { MouseButton button = mouseButtonFromEvent(evtButton.button); switch (evtButton.type) { case SDL.SDL_EventType.SDL_MOUSEBUTTONDOWN: ScheduleEvent(() => MouseDown?.Invoke(button)); break; case SDL.SDL_EventType.SDL_MOUSEBUTTONUP: ScheduleEvent(() => MouseUp?.Invoke(button)); break; } } private void handleMouseMotionEvent(SDL.SDL_MouseMotionEvent evtMotion) { if (SDL.SDL_GetRelativeMouseMode() == SDL.SDL_bool.SDL_FALSE) ScheduleEvent(() => MouseMove?.Invoke(new Vector2(evtMotion.x * Scale, evtMotion.y * Scale))); else ScheduleEvent(() => MouseMoveRelative?.Invoke(new Vector2(evtMotion.xrel * Scale, evtMotion.yrel * Scale))); } private unsafe void handleTextInputEvent(SDL.SDL_TextInputEvent evtText) { var ptr = new IntPtr(evtText.text); if (ptr == IntPtr.Zero) return; string text = Marshal.PtrToStringUTF8(ptr) ?? ""; foreach (char c in text) ScheduleEvent(() => KeyTyped?.Invoke(c)); } private void handleTextEditingEvent(SDL.SDL_TextEditingEvent evtEdit) { } private void handleKeyboardEvent(SDL.SDL_KeyboardEvent evtKey) { Key key = evtKey.keysym.ToKey(); if (key == Key.Unknown) return; switch (evtKey.type) { case SDL.SDL_EventType.SDL_KEYDOWN: ScheduleEvent(() => KeyDown?.Invoke(key)); break; case SDL.SDL_EventType.SDL_KEYUP: ScheduleEvent(() => KeyUp?.Invoke(key)); break; } } private void handleWindowEvent(SDL.SDL_WindowEvent evtWindow) { updateWindowSpecifics(); switch (evtWindow.windowEvent) { case SDL.SDL_WindowEventID.SDL_WINDOWEVENT_MOVED: // explicitly requery as there are occasions where what SDL has provided us with is not up-to-date. SDL.SDL_GetWindowPosition(SDLWindowHandle, out int x, out int y); var newPosition = new Point(x, y); if (!newPosition.Equals(Position)) { position = newPosition; ScheduleEvent(() => Moved?.Invoke(newPosition)); if (WindowMode.Value == Configuration.WindowMode.Windowed) storeWindowPositionToConfig(); } break; case SDL.SDL_WindowEventID.SDL_WINDOWEVENT_SIZE_CHANGED: updateWindowSize(); break; case SDL.SDL_WindowEventID.SDL_WINDOWEVENT_ENTER: cursorInWindow.Value = true; ScheduleEvent(() => MouseEntered?.Invoke()); break; case SDL.SDL_WindowEventID.SDL_WINDOWEVENT_LEAVE: cursorInWindow.Value = false; ScheduleEvent(() => MouseLeft?.Invoke()); break; case SDL.SDL_WindowEventID.SDL_WINDOWEVENT_RESTORED: case SDL.SDL_WindowEventID.SDL_WINDOWEVENT_FOCUS_GAINED: ScheduleEvent(() => Focused = true); break; case SDL.SDL_WindowEventID.SDL_WINDOWEVENT_MINIMIZED: case SDL.SDL_WindowEventID.SDL_WINDOWEVENT_FOCUS_LOST: ScheduleEvent(() => Focused = false); break; case SDL.SDL_WindowEventID.SDL_WINDOWEVENT_CLOSE: break; } } /// /// Should be run on a regular basis to check for external window state changes. /// private void updateWindowSpecifics() { // don't attempt to run before the window is initialised, as Create() will do so anyway. if (SDLWindowHandle == IntPtr.Zero) return; var stateBefore = windowState; // check for a pending user state change and give precedence. if (pendingWindowState != null) { windowState = pendingWindowState.Value; pendingWindowState = null; updateWindowStateAndSize(); } else { windowState = ((SDL.SDL_WindowFlags)SDL.SDL_GetWindowFlags(SDLWindowHandle)).ToWindowState(); } if (windowState != stateBefore) { ScheduleEvent(() => WindowStateChanged?.Invoke(windowState)); updateMaximisedState(); } int newDisplayIndex = SDL.SDL_GetWindowDisplayIndex(SDLWindowHandle); if (displayIndex != newDisplayIndex) { displayIndex = newDisplayIndex; currentDisplay = Displays.ElementAtOrDefault(displayIndex) ?? PrimaryDisplay; ScheduleEvent(() => { CurrentDisplayBindable.Value = currentDisplay; }); } } /// /// Should be run after a local window state change, to propagate the correct SDL actions. /// private void updateWindowStateAndSize() { // this reset is required even on changing from one fullscreen resolution to another. // if it is not included, the GL context will not get the correct size. // this is mentioned by multiple sources as an SDL issue, which seems to resolve by similar means (see https://discourse.libsdl.org/t/sdl-setwindowsize-does-not-work-in-fullscreen/20711/4). SDL.SDL_SetWindowBordered(SDLWindowHandle, SDL.SDL_bool.SDL_TRUE); SDL.SDL_SetWindowFullscreen(SDLWindowHandle, (uint)SDL.SDL_bool.SDL_FALSE); switch (windowState) { case WindowState.Normal: Size = (sizeWindowed.Value * Scale).ToSize(); SDL.SDL_RestoreWindow(SDLWindowHandle); SDL.SDL_SetWindowSize(SDLWindowHandle, sizeWindowed.Value.Width, sizeWindowed.Value.Height); SDL.SDL_SetWindowResizable(SDLWindowHandle, Resizable ? SDL.SDL_bool.SDL_TRUE : SDL.SDL_bool.SDL_FALSE); readWindowPositionFromConfig(); break; case WindowState.Fullscreen: var closestMode = getClosestDisplayMode(sizeFullscreen.Value, currentDisplayMode.Value.RefreshRate, currentDisplay.Index); Size = new Size(closestMode.w, closestMode.h); SDL.SDL_SetWindowDisplayMode(SDLWindowHandle, ref closestMode); SDL.SDL_SetWindowFullscreen(SDLWindowHandle, (uint)SDL.SDL_WindowFlags.SDL_WINDOW_FULLSCREEN); break; case WindowState.FullscreenBorderless: Size = SetBorderless(); break; case WindowState.Maximised: SDL.SDL_RestoreWindow(SDLWindowHandle); SDL.SDL_MaximizeWindow(SDLWindowHandle); SDL.SDL_GL_GetDrawableSize(SDLWindowHandle, out int w, out int h); Size = new Size(w, h); break; case WindowState.Minimised: SDL.SDL_MinimizeWindow(SDLWindowHandle); break; } updateMaximisedState(); if (SDL.SDL_GetWindowDisplayMode(SDLWindowHandle, out var mode) >= 0) currentDisplayMode.Value = new DisplayMode(mode.format.ToString(), new Size(mode.w, mode.h), 32, mode.refresh_rate, displayIndex, displayIndex); } private void updateMaximisedState() { if (windowState == WindowState.Normal || windowState == WindowState.Maximised) windowMaximised = windowState == WindowState.Maximised; } private void readWindowPositionFromConfig() { if (WindowState != WindowState.Normal) return; var configPosition = new Vector2((float)windowPositionX.Value, (float)windowPositionY.Value); var displayBounds = CurrentDisplay.Bounds; var windowSize = sizeWindowed.Value; var windowX = (int)Math.Round((displayBounds.Width - windowSize.Width) * configPosition.X); var windowY = (int)Math.Round((displayBounds.Height - windowSize.Height) * configPosition.Y); Position = new Point(windowX + displayBounds.X, windowY + displayBounds.Y); } private void storeWindowPositionToConfig() { if (WindowState != WindowState.Normal) return; var displayBounds = CurrentDisplay.Bounds; var windowX = Position.X - displayBounds.X; var windowY = Position.Y - displayBounds.Y; var windowSize = sizeWindowed.Value; windowPositionX.Value = displayBounds.Width > windowSize.Width ? (float)windowX / (displayBounds.Width - windowSize.Width) : 0; windowPositionY.Value = displayBounds.Height > windowSize.Height ? (float)windowY / (displayBounds.Height - windowSize.Height) : 0; } /// /// Set to true while the window size is being stored to config to avoid bindable feedback. /// private bool storingSizeToConfig; private void storeWindowSizeToConfig() { if (WindowState != WindowState.Normal) return; storingSizeToConfig = true; sizeWindowed.Value = (Size / Scale).ToSize(); storingSizeToConfig = false; } /// /// Prepare display of a borderless window. /// /// /// The size of the borderless window's draw area. /// protected virtual Size SetBorderless() { // this is a generally sane method of handling borderless, and works well on macOS and linux. SDL.SDL_SetWindowFullscreen(SDLWindowHandle, (uint)SDL.SDL_WindowFlags.SDL_WINDOW_FULLSCREEN_DESKTOP); return currentDisplay.Bounds.Size; } private MouseButton mouseButtonFromEvent(byte button) { switch ((uint)button) { default: case SDL.SDL_BUTTON_LEFT: return MouseButton.Left; case SDL.SDL_BUTTON_RIGHT: return MouseButton.Right; case SDL.SDL_BUTTON_MIDDLE: return MouseButton.Middle; case SDL.SDL_BUTTON_X1: return MouseButton.Button1; case SDL.SDL_BUTTON_X2: return MouseButton.Button2; } } #endregion protected virtual IGraphicsBackend CreateGraphicsBackend() => new SDL2GraphicsBackend(); public void SetupWindow(FrameworkConfigManager config) { CurrentDisplayBindable.ValueChanged += evt => { windowDisplayIndexBindable.Value = (DisplayIndex)evt.NewValue.Index; windowPositionX.Value = 0.5; windowPositionY.Value = 0.5; }; config.BindWith(FrameworkSetting.LastDisplayDevice, windowDisplayIndexBindable); windowDisplayIndexBindable.BindValueChanged(evt => CurrentDisplay = Displays.ElementAtOrDefault((int)evt.NewValue) ?? PrimaryDisplay, true); sizeFullscreen.ValueChanged += evt => { if (storingSizeToConfig) return; if (windowState != WindowState.Fullscreen) return; pendingWindowState = windowState; }; sizeWindowed.ValueChanged += evt => { if (storingSizeToConfig) return; if (windowState != WindowState.Normal) return; pendingWindowState = windowState; }; config.BindWith(FrameworkSetting.SizeFullscreen, sizeFullscreen); config.BindWith(FrameworkSetting.WindowedSize, sizeWindowed); config.BindWith(FrameworkSetting.WindowedPositionX, windowPositionX); config.BindWith(FrameworkSetting.WindowedPositionY, windowPositionY); config.BindWith(FrameworkSetting.WindowMode, WindowMode); config.BindWith(FrameworkSetting.ConfineMouseMode, ConfineMouseMode); WindowMode.BindValueChanged(evt => { switch (evt.NewValue) { case Configuration.WindowMode.Fullscreen: WindowState = WindowState.Fullscreen; break; case Configuration.WindowMode.Borderless: WindowState = WindowState.FullscreenBorderless; break; case Configuration.WindowMode.Windowed: WindowState = windowMaximised ? WindowState.Maximised : WindowState.Normal; break; } updateConfineMode(); }); ConfineMouseMode.BindValueChanged(_ => updateConfineMode()); } public void CycleMode() { var currentValue = WindowMode.Value; do { switch (currentValue) { case Configuration.WindowMode.Windowed: currentValue = Configuration.WindowMode.Borderless; break; case Configuration.WindowMode.Borderless: currentValue = Configuration.WindowMode.Fullscreen; break; case Configuration.WindowMode.Fullscreen: currentValue = Configuration.WindowMode.Windowed; break; } } while (!SupportedWindowModes.Contains(currentValue) && currentValue != WindowMode.Value); WindowMode.Value = currentValue; } /// /// Update the host window manager's cursor position based on a location relative to window coordinates. /// /// A position inside the window. public void UpdateMousePosition(Vector2 position) => ScheduleCommand(() => SDL.SDL_WarpMouseInWindow(SDLWindowHandle, (int)(position.X / Scale), (int)(position.Y / Scale))); public void SetIconFromStream(Stream stream) { using (var ms = new MemoryStream()) { stream.CopyTo(ms); ms.Position = 0; var imageInfo = Image.Identify(ms); if (imageInfo != null) SetIconFromImage(Image.Load(ms.GetBuffer())); else if (IconGroup.TryParse(ms.GetBuffer(), out var iconGroup)) SetIconFromGroup(iconGroup); } } internal virtual void SetIconFromGroup(IconGroup iconGroup) { // LoadRawIcon returns raw PNG data if available, which avoids any Windows-specific pinvokes var bytes = iconGroup.LoadRawIcon(default_icon_size, default_icon_size); if (bytes == null) return; SetIconFromImage(Image.Load(bytes)); } internal virtual void SetIconFromImage(Image iconImage) => setSDLIcon(iconImage); private void updateConfineMode() { bool confine = false; switch (ConfineMouseMode.Value) { case Input.ConfineMouseMode.Fullscreen: confine = WindowMode.Value != Configuration.WindowMode.Windowed; break; case Input.ConfineMouseMode.Always: confine = true; break; } if (confine) CursorStateBindable.Value |= CursorState.Confined; else CursorStateBindable.Value &= ~CursorState.Confined; } #region Helper functions private SDL.SDL_DisplayMode getClosestDisplayMode(Size size, int refreshRate, int displayIndex) { var targetMode = new SDL.SDL_DisplayMode { w = size.Width, h = size.Height, refresh_rate = refreshRate }; if (SDL.SDL_GetClosestDisplayMode(displayIndex, ref targetMode, out var mode) != IntPtr.Zero) return mode; // fallback to current display's native bounds targetMode.w = currentDisplay.Bounds.Width; targetMode.h = currentDisplay.Bounds.Height; targetMode.refresh_rate = 0; if (SDL.SDL_GetClosestDisplayMode(displayIndex, ref targetMode, out mode) != IntPtr.Zero) return mode; // finally return the current mode if everything else fails. // not sure this is required. if (SDL.SDL_GetWindowDisplayMode(SDLWindowHandle, out mode) >= 0) return mode; throw new InvalidOperationException("couldn't retrieve valid display mode"); } private static Display displayFromSDL(int displayIndex) { var displayModes = Enumerable.Range(0, SDL.SDL_GetNumDisplayModes(displayIndex)) .Select(modeIndex => { SDL.SDL_GetDisplayMode(displayIndex, modeIndex, out var mode); return displayModeFromSDL(mode, displayIndex, modeIndex); }) .ToArray(); SDL.SDL_GetDisplayBounds(displayIndex, out var rect); return new Display(displayIndex, SDL.SDL_GetDisplayName(displayIndex), new Rectangle(rect.x, rect.y, rect.w, rect.h), displayModes); } private static DisplayMode displayModeFromSDL(SDL.SDL_DisplayMode mode, int displayIndex, int modeIndex) { SDL.SDL_PixelFormatEnumToMasks(mode.format, out var bpp, out _, out _, out _, out _); return new DisplayMode(SDL.SDL_GetPixelFormatName(mode.format), new Size(mode.w, mode.h), bpp, mode.refresh_rate, modeIndex, displayIndex); } #endregion #region Events /// /// Invoked once every window event loop. /// public event Action Update; /// /// Invoked after the window has resized. /// public event Action Resized; /// /// Invoked after the window's state has changed. /// public event Action WindowStateChanged; /// /// Invoked when the user attempts to close the window. Return value of true will cancel exit. /// public event Func ExitRequested; /// /// Invoked when the window is about to close. /// public event Action Exited; /// /// Invoked when the mouse cursor enters the window. /// public event Action MouseEntered; /// /// Invoked when the mouse cursor leaves the window. /// public event Action MouseLeft; /// /// Invoked when the window moves. /// public event Action Moved; /// /// Invoked when the user scrolls the mouse wheel over the window. /// public event Action MouseWheel; protected void TriggerMouseWheel(Vector2 delta, bool precise) => MouseWheel?.Invoke(delta, precise); /// /// Invoked when the user moves the mouse cursor within the window. /// public event Action MouseMove; /// /// Invoked when the user moves the mouse cursor within the window (via relative / raw input). /// public event Action MouseMoveRelative; /// /// Invoked when the user presses a mouse button. /// public event Action MouseDown; /// /// Invoked when the user releases a mouse button. /// public event Action MouseUp; /// /// Invoked when the user presses a key. /// public event Action KeyDown; /// /// Invoked when the user releases a key. /// public event Action KeyUp; /// /// Invoked when the user types a character. /// public event Action KeyTyped; /// /// Invoked when a joystick axis changes. /// public event Action JoystickAxisChanged; /// /// Invoked when the user presses a button on a joystick. /// public event Action JoystickButtonDown; /// /// Invoked when the user releases a button on a joystick. /// public event Action JoystickButtonUp; /// /// Invoked when the user drops a file into the window. /// public event Action DragDrop; #endregion public void Dispose() { } } }