diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs
index 7a4836982..340a47321 100644
--- a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs
+++ b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs
@@ -41,5 +41,10 @@ namespace Ryujinx.Graphics.GAL.Multithreading
public void SetScalingFilterLevel(float level) { }
public void SetColorSpacePassthrough(bool colorSpacePassthroughEnabled) { }
+
+ ///
+ /// Gets the underlying implementation window for direct access
+ ///
+ public IWindow BaseWindow => _impl.Window;
}
}
diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/ControllerOverlay.cs b/src/Ryujinx.Graphics.Gpu/Overlay/ControllerOverlay.cs
new file mode 100644
index 000000000..2d5904628
--- /dev/null
+++ b/src/Ryujinx.Graphics.Gpu/Overlay/ControllerOverlay.cs
@@ -0,0 +1,244 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OriginalInputConfig = Ryujinx.Common.Configuration.Hid.InputConfig;
+using OriginalPlayerIndex = Ryujinx.Common.Configuration.Hid.PlayerIndex;
+using OriginalInputBackendType = Ryujinx.Common.Configuration.Hid.InputBackendType;
+
+namespace Ryujinx.Graphics.Gpu.Overlay
+{
+ ///
+ /// Controller overlay that shows controller bindings matching the original AXAML design
+ ///
+ public class ControllerOverlay : Overlay
+ {
+ private const float OverlayWidth = 400;
+ private const float Padding = 24;
+ private const float Spacing = 16;
+ private const float PlayerSpacing = 12;
+ private const float PlayerRowHeight = 32;
+
+ private const float TitleTextSize = 25;
+ private const float PlayerTextSize = 22;
+
+ private float _lifespan = 0f;
+
+ public ControllerOverlay() : base("ControllerOverlay")
+ {
+ CreateBaseElements();
+ }
+
+ private void CreateBaseElements()
+ {
+ // Main background container
+ var background = new RectangleElement(0, 0, OverlayWidth, 200, // Dynamic height will be set later
+ new SKColor(0, 0, 0, 224)) // #E0000000
+ {
+ Name = "Background",
+ CornerRadius = 12,
+ BorderColor = new SKColor(255, 255, 255, 64), // #40FFFFFF
+ BorderWidth = 1
+ };
+ AddElement(background);
+
+ // Title text
+ var titleText = new TextElement(Padding + 30, Padding, "Controller Bindings", TitleTextSize, SKColors.White)
+ {
+ Name = "TitleText",
+ FontStyle = SKFontStyle.Bold
+ };
+ AddElement(titleText);
+ }
+
+ ///
+ /// Show controller bindings matching the original AXAML implementation
+ ///
+ public void ShowControllerBindings(List inputConfigs, int durationSeconds = 3)
+ {
+ // Reset lifespan and opacity
+ _lifespan = durationSeconds;
+
+ // Clear existing player bindings
+ ClearPlayerBindings();
+
+ // Debug: Log input data
+ // Group controllers by player index
+ var playerBindings = new Dictionary>();
+
+ foreach (var config in inputConfigs.Where(c => c.PlayerIndex <= OriginalPlayerIndex.Player4))
+ {
+ Console.WriteLine($"ControllerOverlay: Config for Player {config.PlayerIndex}: {config.Name} ({config.Backend})");
+ if (!playerBindings.ContainsKey(config.PlayerIndex))
+ {
+ playerBindings[config.PlayerIndex] = new List();
+ }
+ playerBindings[config.PlayerIndex].Add(config);
+ }
+
+ float currentY = Padding + 40; // After title
+
+ // Add player bindings to UI
+ for (int i = 0; i < 4; i++)
+ {
+ var playerIndex = (OriginalPlayerIndex)i;
+ float rowY = currentY + (i * (PlayerRowHeight + PlayerSpacing));
+
+ // Player number with colored background (circular badge)
+ var playerColor = GetPlayerColor(i);
+ var playerBadge = new RectangleElement(Padding, rowY, 24, 20, playerColor)
+ {
+ Name = $"PlayerBadge_{i}",
+ CornerRadius = 12
+ };
+ AddElement(playerBadge);
+
+ // Player number text
+ var playerLabel = new TextElement(Padding + 12, rowY + 2, $"P{i + 1}", PlayerTextSize, SKColors.White)
+ {
+ Name = $"PlayerLabel_{i}",
+ FontStyle = SKFontStyle.Bold,
+ TextAlign = SKTextAlign.Center
+ };
+ AddElement(playerLabel);
+
+ // Controller info
+ if (playerBindings.ContainsKey(playerIndex))
+ {
+ var controllers = playerBindings[playerIndex];
+ var controllerNames = controllers.Select(c => GetControllerDisplayName(c)).ToList();
+
+ var controllerText = new TextElement(Padding + 56, rowY + 2, string.Join(", ", controllerNames), PlayerTextSize, new SKColor(144, 238, 144)) // LightGreen
+ {
+ Name = $"ControllerText_{i}",
+ FontStyle = SKFontStyle.Bold
+ };
+ AddElement(controllerText);
+ }
+ else
+ {
+ var noControllerText = new TextElement(Padding + 56, rowY + 2, "No controller assigned", PlayerTextSize, new SKColor(128, 128, 128)) // Gray
+ {
+ Name = $"NoControllerText_{i}",
+ FontStyle = SKFontStyle.Italic
+ };
+ AddElement(noControllerText);
+ }
+ }
+
+ // Calculate total height and update background
+ float totalHeight = Padding + 40 + (4 * (PlayerRowHeight + PlayerSpacing)) + Padding + 40; // Extra space for duration text
+ var background = FindElement("Background");
+ if (background != null)
+ {
+ background.Height = totalHeight;
+ }
+
+ // Duration text at bottom
+ string durationText = durationSeconds == 1
+ ? "This overlay will disappear in 1 second"
+ : $"This overlay will disappear in {durationSeconds} seconds";
+
+ // Show the overlay (position will be set by Window class with actual dimensions)
+ IsVisible = true;
+ }
+
+ private static SKColor GetPlayerColor(int playerIndex)
+ {
+ return playerIndex switch
+ {
+ 0 => new SKColor(255, 92, 92), // Red for Player 1
+ 1 => new SKColor(54, 162, 235), // Blue for Player 2
+ 2 => new SKColor(255, 206, 84), // Yellow for Player 3
+ 3 => new SKColor(75, 192, 192), // Green for Player 4
+ _ => new SKColor(128, 128, 128) // Gray fallback
+ };
+ }
+
+ private static string GetControllerDisplayName(OriginalInputConfig config)
+ {
+ if (string.IsNullOrEmpty(config.Name))
+ {
+ return config.Backend switch
+ {
+ OriginalInputBackendType.WindowKeyboard => "Keyboard",
+ OriginalInputBackendType.GamepadSDL2 => "Controller",
+ _ => "Unknown"
+ };
+ }
+
+ // Truncate long controller names
+ string name = config.Name;
+ if (name.Length > 25)
+ {
+ name = name.Substring(0, 22) + "...";
+ }
+
+ return name;
+ }
+
+ ///
+ /// Clear all player bindings
+ ///
+ private void ClearPlayerBindings()
+ {
+ var elementsToRemove = new List();
+
+ foreach (var element in GetElements())
+ {
+ if (element.Name.StartsWith("PlayerBadge_") ||
+ element.Name.StartsWith("PlayerLabel_") ||
+ element.Name.StartsWith("ControllerText_") ||
+ element.Name.StartsWith("NoControllerText_"))
+ {
+ elementsToRemove.Add(element);
+ }
+ }
+
+ foreach (var element in elementsToRemove)
+ {
+ RemoveElement(element);
+ element.Dispose();
+ }
+ }
+
+ ///
+ /// Update overlay
+ ///
+ public override void Update(float deltaTime, SKSize screenSize)
+ {
+ _lifespan -= deltaTime;
+
+ if (_lifespan <= 0)
+ {
+ IsVisible = false;
+ return;
+ }
+
+ if (_lifespan <= 0.5f)
+ {
+ // Fade out during the last 0.5 seconds
+ Opacity = _lifespan / 0.5f;
+ }
+ else
+ {
+ Opacity = 1;
+ }
+
+ // Update position if screen size is provided
+ if (screenSize.Width > 0 && screenSize.Height > 0)
+ {
+ SetPositionToTopRight(screenSize.Width, screenSize.Height);
+ }
+ }
+
+ ///
+ /// Position overlay to top-right matching original AXAML positioning
+ ///
+ public void SetPositionToTopRight(float screenWidth, float screenHeight)
+ {
+ X = screenWidth - OverlayWidth - 20; // 20px margin from right
+ Y = 50; // 50px margin from top
+ }
+ }
+}
diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/ImageElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/ImageElement.cs
new file mode 100644
index 000000000..57122d73d
--- /dev/null
+++ b/src/Ryujinx.Graphics.Gpu/Overlay/ImageElement.cs
@@ -0,0 +1,145 @@
+using SkiaSharp;
+using System;
+
+namespace Ryujinx.Graphics.Gpu.Overlay
+{
+ ///
+ /// Image overlay element
+ ///
+ public class ImageElement : OverlayElement
+ {
+ private SKBitmap? _bitmap;
+ private byte[]? _imageData;
+ private string? _imagePath;
+
+ public SKFilterQuality FilterQuality { get; set; } = SKFilterQuality.Medium;
+ public bool MaintainAspectRatio { get; set; } = true;
+
+ public ImageElement()
+ {
+ }
+
+ public ImageElement(float x, float y, float width, float height, byte[] imageData)
+ {
+ X = x;
+ Y = y;
+ Width = width;
+ Height = height;
+ SetImageData(imageData);
+ }
+
+ public ImageElement(float x, float y, float width, float height, string imagePath)
+ {
+ X = x;
+ Y = y;
+ Width = width;
+ Height = height;
+ SetImagePath(imagePath);
+ }
+
+ ///
+ /// Set image from byte array
+ ///
+ public void SetImageData(byte[] imageData)
+ {
+ _imageData = imageData;
+ _imagePath = null;
+ LoadBitmap();
+ }
+
+ ///
+ /// Set image from file path
+ ///
+ public void SetImagePath(string imagePath)
+ {
+ _imagePath = imagePath;
+ _imageData = null;
+ LoadBitmap();
+ }
+
+ ///
+ /// Set image from existing SKBitmap
+ ///
+ public void SetBitmap(SKBitmap bitmap)
+ {
+ _bitmap?.Dispose();
+ _bitmap = bitmap;
+ _imageData = null;
+ _imagePath = null;
+ }
+
+ private void LoadBitmap()
+ {
+ try
+ {
+ _bitmap?.Dispose();
+ _bitmap = null;
+
+ if (_imageData != null)
+ {
+ _bitmap = SKBitmap.Decode(_imageData);
+ }
+ else if (!string.IsNullOrEmpty(_imagePath))
+ {
+ _bitmap = SKBitmap.Decode(_imagePath);
+ }
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Failed to load image: {ex.Message}");
+ _bitmap = null;
+ }
+ }
+
+ public override void Render(SKCanvas canvas, float globalOpacity = 1.0f)
+ {
+ if (!IsVisible || _bitmap == null || Width <= 0 || Height <= 0)
+ return;
+
+ float effectiveOpacity = Opacity * globalOpacity;
+
+ using var paint = new SKPaint
+ {
+ FilterQuality = FilterQuality,
+ Color = SKColors.White.WithAlpha((byte)(255 * effectiveOpacity))
+ };
+
+ var sourceRect = new SKRect(0, 0, _bitmap.Width, _bitmap.Height);
+ var destRect = new SKRect(X, Y, X + Width, Y + Height);
+
+ if (MaintainAspectRatio)
+ {
+ // Calculate aspect ratio preserving destination rectangle
+ float sourceAspect = (float)_bitmap.Width / _bitmap.Height;
+ float destAspect = Width / Height;
+
+ if (sourceAspect > destAspect)
+ {
+ // Source is wider, fit to width
+ float newHeight = Width / sourceAspect;
+ float yOffset = (Height - newHeight) / 2;
+ destRect = new SKRect(X, Y + yOffset, X + Width, Y + yOffset + newHeight);
+ }
+ else
+ {
+ // Source is taller, fit to height
+ float newWidth = Height * sourceAspect;
+ float xOffset = (Width - newWidth) / 2;
+ destRect = new SKRect(X + xOffset, Y, X + xOffset + newWidth, Y + Height);
+ }
+ }
+
+ canvas.DrawBitmap(_bitmap, sourceRect, destRect, paint);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _bitmap?.Dispose();
+ _bitmap = null;
+ }
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs b/src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs
new file mode 100644
index 000000000..a414888e1
--- /dev/null
+++ b/src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs
@@ -0,0 +1,115 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Ryujinx.Graphics.Gpu.Overlay
+{
+ ///
+ /// Base overlay class containing multiple elements
+ ///
+ public abstract class Overlay : IDisposable
+ {
+ private readonly List _elements = new();
+
+ public string Name { get; set; } = string.Empty;
+ public bool IsVisible { get; set; } = true;
+ public float Opacity { get; set; } = 1.0f;
+ public float X { get; set; }
+ public float Y { get; set; }
+ public int ZIndex { get; set; } = 0;
+
+ public Overlay()
+ {
+ }
+
+ public Overlay(string name)
+ {
+ Name = name;
+ }
+
+ ///
+ /// Add an element to this overlay
+ ///
+ public void AddElement(OverlayElement element)
+ {
+ _elements.Add(element);
+ }
+
+ ///
+ /// Remove an element from this overlay
+ ///
+ public void RemoveElement(OverlayElement element)
+ {
+ _elements.Remove(element);
+ }
+
+ ///
+ /// Get all elements
+ ///
+ public IReadOnlyList GetElements()
+ {
+ return _elements.AsReadOnly();
+ }
+
+ ///
+ /// Find element by name
+ ///
+ public T FindElement(string name) where T : OverlayElement
+ {
+ return _elements.OfType().FirstOrDefault(e => e.Name == name);
+ }
+
+ ///
+ /// Clear all elements
+ ///
+ public void Clear()
+ {
+ foreach (var element in _elements)
+ {
+ element.Dispose();
+ }
+ _elements.Clear();
+ }
+
+ ///
+ /// Update overlay
+ ///
+ public abstract void Update(float deltaTime, SKSize screenSize = default);
+
+ ///
+ /// Render this overlay
+ ///
+ public void Render(SKCanvas canvas)
+ {
+ if (!IsVisible || Opacity <= 0.0f)
+ return;
+
+ // Save canvas state
+ canvas.Save();
+
+ // Apply overlay position offset
+ if (X != 0 || Y != 0)
+ {
+ canvas.Translate(X, Y);
+ }
+
+ // Render all elements
+ foreach (var element in _elements)
+ {
+ if (element.IsVisible)
+ {
+ element.Render(canvas, Opacity);
+ }
+ }
+
+ // Restore canvas state
+ canvas.Restore();
+ }
+
+ public void Dispose()
+ {
+ Clear();
+ }
+ }
+}
diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/OverlayElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayElement.cs
new file mode 100644
index 000000000..c4c528d65
--- /dev/null
+++ b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayElement.cs
@@ -0,0 +1,66 @@
+using SkiaSharp;
+using System;
+
+namespace Ryujinx.Graphics.Gpu.Overlay
+{
+ ///
+ /// Base class for all overlay elements
+ ///
+ public abstract class OverlayElement : IDisposable
+ {
+ public float X { get; set; }
+ public float Y { get; set; }
+ public float Width { get; set; }
+ public float Height { get; set; }
+ public bool IsVisible { get; set; } = true;
+ public float Opacity { get; set; } = 1.0f;
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Render this element to the canvas
+ ///
+ /// The canvas to draw on
+ /// Global opacity multiplier
+ public abstract void Render(SKCanvas canvas, float globalOpacity = 1.0f);
+
+ ///
+ /// Check if a point is within this element's bounds
+ ///
+ public virtual bool Contains(float x, float y)
+ {
+ return x >= X && x <= X + Width && y >= Y && y <= Y + Height;
+ }
+
+ ///
+ /// Get the bounds of this element
+ ///
+ public SKRect GetBounds()
+ {
+ return new SKRect(X, Y, X + Width, Y + Height);
+ }
+
+ ///
+ /// Apply opacity to a color
+ ///
+ protected SKColor ApplyOpacity(SKColor color, float opacity)
+ {
+ return color.WithAlpha((byte)(color.Alpha * opacity));
+ }
+
+ ///
+ /// Dispose of resources
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Dispose of resources
+ ///
+ protected virtual void Dispose(bool disposing)
+ {
+ }
+ }
+}
diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/OverlayManager.cs b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayManager.cs
new file mode 100644
index 000000000..fb0a4906f
--- /dev/null
+++ b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayManager.cs
@@ -0,0 +1,160 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Ryujinx.Graphics.Gpu.Image;
+
+namespace Ryujinx.Graphics.Gpu.Overlay
+{
+ ///
+ /// Manages multiple overlays and handles rendering
+ ///
+ public class OverlayManager : IDisposable
+ {
+ private readonly List _overlays = new();
+ private readonly object _lock = new();
+
+ ///
+ /// Add an overlay to the manager
+ ///
+ public void AddOverlay(Overlay overlay)
+ {
+ lock (_lock)
+ {
+ _overlays.Add(overlay);
+ SortOverlays();
+ }
+ }
+
+ ///
+ /// Remove an overlay from the manager
+ ///
+ public void RemoveOverlay(Overlay overlay)
+ {
+ lock (_lock)
+ {
+ _overlays.Remove(overlay);
+ }
+ }
+
+ ///
+ /// Remove overlay by name
+ ///
+ public void RemoveOverlay(string name)
+ {
+ lock (_lock)
+ {
+ var overlay = _overlays.FirstOrDefault(o => o.Name == name);
+ if (overlay != null)
+ {
+ _overlays.Remove(overlay);
+ overlay.Dispose();
+ }
+ }
+ }
+
+ ///
+ /// Find overlay by name
+ ///
+ public Overlay FindOverlay(string name)
+ {
+ lock (_lock)
+ {
+ return _overlays.FirstOrDefault(o => o.Name == name);
+ }
+ }
+
+ ///
+ /// Get all overlays
+ ///
+ public IReadOnlyList GetOverlays()
+ {
+ lock (_lock)
+ {
+ return _overlays.AsReadOnly();
+ }
+ }
+
+ ///
+ /// Clear all overlays
+ ///
+ public void Clear()
+ {
+ lock (_lock)
+ {
+ foreach (var overlay in _overlays)
+ {
+ overlay.Dispose();
+ }
+ _overlays.Clear();
+ }
+ }
+
+ ///
+ /// Update all overlays (for animations)
+ ///
+ public void Update(float deltaTime, SKSize screenSize = default)
+ {
+ lock (_lock)
+ {
+ foreach (var overlay in _overlays.Where(o => o.IsVisible))
+ {
+ overlay.Update(deltaTime, screenSize);
+ }
+ }
+ }
+
+ ///
+ /// Render all visible overlays
+ ///
+ public void Render(SKCanvas canvas)
+ {
+ lock (_lock)
+ {
+ foreach (var overlay in _overlays.Where(o => o.IsVisible && o.Opacity > 0.0f))
+ {
+ overlay.Render(canvas);
+ }
+ }
+ }
+
+ ///
+ /// Sort overlays by Z-index
+ ///
+ private void SortOverlays()
+ {
+ _overlays.Sort((a, b) => a.ZIndex.CompareTo(b.ZIndex));
+ }
+
+ ///
+ /// Show overlay
+ ///
+ public void ShowOverlay(string name)
+ {
+ var overlay = FindOverlay(name);
+ if (overlay != null)
+ {
+ overlay.IsVisible = true;
+ overlay.Opacity = 1.0f;
+ }
+ }
+
+ ///
+ /// Hide overlay
+ ///
+ public void HideOverlay(string name)
+ {
+ var overlay = FindOverlay(name);
+ if (overlay != null)
+ {
+ overlay.IsVisible = false;
+ overlay.Opacity = 0.0f;
+ }
+ }
+
+ public void Dispose()
+ {
+ Clear();
+ }
+ }
+}
diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/RectangleElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/RectangleElement.cs
new file mode 100644
index 000000000..b310ef2fc
--- /dev/null
+++ b/src/Ryujinx.Graphics.Gpu/Overlay/RectangleElement.cs
@@ -0,0 +1,78 @@
+using SkiaSharp;
+
+namespace Ryujinx.Graphics.Gpu.Overlay
+{
+ ///
+ /// Rectangle overlay element
+ ///
+ public class RectangleElement : OverlayElement
+ {
+ public SKColor BackgroundColor { get; set; } = SKColors.Transparent;
+ public SKColor BorderColor { get; set; } = SKColors.Transparent;
+ public float BorderWidth { get; set; } = 0;
+ public float CornerRadius { get; set; } = 0;
+
+ public RectangleElement()
+ {
+ }
+
+ public RectangleElement(float x, float y, float width, float height, SKColor backgroundColor)
+ {
+ X = x;
+ Y = y;
+ Width = width;
+ Height = height;
+ BackgroundColor = backgroundColor;
+ }
+
+ public override void Render(SKCanvas canvas, float globalOpacity = 1.0f)
+ {
+ if (!IsVisible || Width <= 0 || Height <= 0)
+ return;
+
+ float effectiveOpacity = Opacity * globalOpacity;
+ var bounds = new SKRect(X, Y, X + Width, Y + Height);
+
+ // Draw background
+ if (BackgroundColor.Alpha > 0)
+ {
+ using var backgroundPaint = new SKPaint
+ {
+ Color = ApplyOpacity(BackgroundColor, effectiveOpacity),
+ Style = SKPaintStyle.Fill,
+ IsAntialias = true
+ };
+
+ if (CornerRadius > 0)
+ {
+ canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, backgroundPaint);
+ }
+ else
+ {
+ canvas.DrawRect(bounds, backgroundPaint);
+ }
+ }
+
+ // Draw border
+ if (BorderWidth > 0 && BorderColor.Alpha > 0)
+ {
+ using var borderPaint = new SKPaint
+ {
+ Color = ApplyOpacity(BorderColor, effectiveOpacity),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = BorderWidth,
+ IsAntialias = true
+ };
+
+ if (CornerRadius > 0)
+ {
+ canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, borderPaint);
+ }
+ else
+ {
+ canvas.DrawRect(bounds, borderPaint);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/TextElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/TextElement.cs
new file mode 100644
index 000000000..a1788fde6
--- /dev/null
+++ b/src/Ryujinx.Graphics.Gpu/Overlay/TextElement.cs
@@ -0,0 +1,121 @@
+using SkiaSharp;
+
+namespace Ryujinx.Graphics.Gpu.Overlay
+{
+ ///
+ /// Text overlay element
+ ///
+ public class TextElement : OverlayElement
+ {
+ public string Text { get; set; } = string.Empty;
+ public SKColor TextColor { get; set; } = SKColors.White;
+ public float FontSize { get; set; } = 16;
+ public string FontFamily { get; set; } = "Arial";
+ public SKFontStyle FontStyle { get; set; } = SKFontStyle.Normal;
+ public SKTextAlign TextAlign { get; set; } = SKTextAlign.Left;
+ public bool IsAntialias { get; set; } = true;
+
+ // Shadow properties
+ public bool HasShadow { get; set; } = false;
+ public SKColor ShadowColor { get; set; } = SKColors.Black;
+ public float ShadowOffsetX { get; set; } = 1;
+ public float ShadowOffsetY { get; set; } = 1;
+ public float ShadowBlur { get; set; } = 0;
+
+ public TextElement()
+ {
+ }
+
+ public TextElement(float x, float y, string text, float fontSize = 16, SKColor? color = null)
+ {
+ X = x;
+ Y = y;
+ Text = text;
+ FontSize = fontSize;
+ TextColor = color ?? SKColors.White;
+
+ // Auto-calculate width and height based on text
+ UpdateDimensions();
+ }
+
+ public override void Render(SKCanvas canvas, float globalOpacity = 1.0f)
+ {
+ if (!IsVisible || string.IsNullOrEmpty(Text))
+ return;
+
+ float effectiveOpacity = Opacity * globalOpacity;
+
+ using var typeface = SKTypeface.FromFamilyName(FontFamily, FontStyle);
+ using var paint = new SKPaint
+ {
+ Color = ApplyOpacity(TextColor, effectiveOpacity),
+ TextSize = FontSize,
+ Typeface = typeface,
+ TextAlign = TextAlign,
+ IsAntialias = IsAntialias
+ };
+
+ float textX = X;
+ float textY = Y + FontSize; // Baseline adjustment
+
+ // Adjust X position based on alignment
+ if (TextAlign == SKTextAlign.Center)
+ {
+ textX += Width / 2;
+ }
+ else if (TextAlign == SKTextAlign.Right)
+ {
+ textX += Width;
+ }
+
+ // Draw shadow if enabled
+ if (HasShadow)
+ {
+ using var shadowPaint = new SKPaint
+ {
+ Color = ApplyOpacity(ShadowColor, effectiveOpacity),
+ TextSize = FontSize,
+ Typeface = typeface,
+ TextAlign = TextAlign,
+ IsAntialias = IsAntialias
+ };
+
+ if (ShadowBlur > 0)
+ {
+ shadowPaint.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, ShadowBlur);
+ }
+
+ canvas.DrawText(Text, textX + ShadowOffsetX, textY + ShadowOffsetY, shadowPaint);
+ }
+
+ // Draw main text
+ canvas.DrawText(Text, textX, textY, paint);
+ }
+
+ ///
+ /// Update width and height based on current text and font settings
+ ///
+ public void UpdateDimensions()
+ {
+ if (string.IsNullOrEmpty(Text))
+ {
+ Width = 0;
+ Height = 0;
+ return;
+ }
+
+ using var typeface = SKTypeface.FromFamilyName(FontFamily, FontStyle);
+ using var paint = new SKPaint
+ {
+ TextSize = FontSize,
+ Typeface = typeface
+ };
+
+ var bounds = new SKRect();
+ paint.MeasureText(Text, ref bounds);
+
+ Width = bounds.Width;
+ Height = FontSize; // Use font size as height for consistency
+ }
+ }
+}
diff --git a/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj b/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj
index 4cd9c1d5c..9228fceba 100644
--- a/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj
+++ b/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj
@@ -14,4 +14,8 @@
+
+
+
+
diff --git a/src/Ryujinx.Graphics.Gpu/Window.cs b/src/Ryujinx.Graphics.Gpu/Window.cs
index 5c3463f2a..0c59967a0 100644
--- a/src/Ryujinx.Graphics.Gpu/Window.cs
+++ b/src/Ryujinx.Graphics.Gpu/Window.cs
@@ -1,10 +1,14 @@
+using Ryujinx.Common.Memory;
using Ryujinx.Graphics.GAL;
using Ryujinx.Graphics.Gpu.Image;
using Ryujinx.Graphics.Gpu.Memory;
+using Ryujinx.Graphics.Gpu.Overlay;
using Ryujinx.Graphics.Texture;
using Ryujinx.Memory.Range;
+using SkiaSharp;
using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.Threading;
namespace Ryujinx.Graphics.Gpu
@@ -15,6 +19,8 @@ namespace Ryujinx.Graphics.Gpu
public class Window
{
private readonly GpuContext _context;
+ private readonly OverlayManager _overlayManager;
+ private DateTime _lastUpdateTime = DateTime.UtcNow;
///
/// Texture presented on the window.
@@ -98,8 +104,12 @@ namespace Ryujinx.Graphics.Gpu
public Window(GpuContext context)
{
_context = context;
+ _overlayManager = new OverlayManager();
_frameQueue = new ConcurrentQueue();
+
+ // Initialize controller overlay
+ InitializeControllerOverlay();
}
///
@@ -207,6 +217,9 @@ namespace Ryujinx.Graphics.Gpu
texture.SynchronizeMemory();
+ // Add overlays by modifying texture data directly
+ AddOverlaysToTexture(texture);
+
ImageCrop crop = new(
(int)(pt.Crop.Left * texture.ScaleFactor),
(int)MathF.Ceiling(pt.Crop.Right * texture.ScaleFactor),
@@ -244,6 +257,88 @@ namespace Ryujinx.Graphics.Gpu
}
}
+ ///
+ /// Initialize controller overlay
+ ///
+ private void InitializeControllerOverlay()
+ {
+ var controllerOverlay = new ControllerOverlay();
+ _overlayManager.AddOverlay(controllerOverlay);
+ }
+
+ ///
+ /// Add overlays to the texture using SkiaSharp
+ ///
+ /// The texture to modify
+ private void AddOverlaysToTexture(Image.Texture texture)
+ {
+ try
+ {
+ // Calculate delta time for lifespan updates
+ DateTime currentTime = DateTime.UtcNow;
+ float deltaTime = (float)(currentTime - _lastUpdateTime).TotalSeconds;
+ _lastUpdateTime = currentTime;
+
+ // Update overlay animations
+ _overlayManager.Update(deltaTime, new SKSize(texture.Info.Width, texture.Info.Height));
+
+ // Get texture data from host texture
+ using var pinnedData = texture.HostTexture.GetData();
+ var data = pinnedData.Get().ToArray();
+ if (data == null || data.Length == 0)
+ return;
+
+ int width = texture.Info.Width;
+ int height = texture.Info.Height;
+ int bytesPerPixel = texture.Info.FormatInfo.BytesPerPixel;
+
+ // Determine the SKColorType based on bytes per pixel
+ SKColorType colorType = bytesPerPixel switch
+ {
+ 4 => SKColorType.Rgba8888,
+ 3 => SKColorType.Rgb888x,
+ 2 => SKColorType.Rgb565,
+ _ => SKColorType.Rgba8888
+ };
+
+ // Create SKBitmap from texture data
+ var imageInfo = new SKImageInfo(width, height, colorType, SKAlphaType.Premul);
+ using var bitmap = new SKBitmap(imageInfo);
+
+ // Copy texture data to bitmap
+ unsafe
+ {
+ fixed (byte* dataPtr = data)
+ {
+ bitmap.SetPixels((IntPtr)dataPtr);
+ }
+ }
+
+ // Create canvas for drawing overlays
+ using var canvas = new SKCanvas(bitmap);
+
+ // Render all overlays
+ _overlayManager.Render(canvas);
+
+ // Copy modified bitmap data back to texture data array
+ var pixels = bitmap.Bytes;
+ if (pixels.Length <= data.Length)
+ {
+ Array.Copy(pixels, data, pixels.Length);
+ }
+
+ // Upload modified data back to texture
+ var memoryOwner = MemoryOwner.Rent(data.Length);
+ data.CopyTo(memoryOwner.Span);
+ texture.HostTexture.SetData(memoryOwner); // SetData will dispose the MemoryOwner
+ }
+ catch (Exception ex)
+ {
+ // Silently fail if overlay rendering doesn't work
+ System.Diagnostics.Debug.WriteLine($"Overlay rendering failed: {ex.Message}");
+ }
+ }
+
///
/// Indicate that a frame on the queue is ready to be acquired.
///
@@ -267,5 +362,32 @@ namespace Ryujinx.Graphics.Gpu
return false;
}
+
+ ///
+ /// Show controller overlay with the provided input configurations
+ ///
+ /// List of input configurations to display
+ /// Duration to show the overlay in seconds
+ public void ShowControllerBindings(List inputConfigs, int durationSeconds = 3)
+ {
+ var controllerOverlay = _overlayManager.FindOverlay("ControllerOverlay") as ControllerOverlay;
+ controllerOverlay?.ShowControllerBindings(inputConfigs, durationSeconds);
+ }
+
+ ///
+ /// Get the overlay manager for external access
+ ///
+ public OverlayManager GetOverlayManager()
+ {
+ return _overlayManager;
+ }
+
+ ///
+ /// Dispose resources
+ ///
+ public void Dispose()
+ {
+ _overlayManager?.Dispose();
+ }
}
}
diff --git a/src/Ryujinx/Systems/AppHost.cs b/src/Ryujinx/Systems/AppHost.cs
index fef287f93..6c9cec18a 100644
--- a/src/Ryujinx/Systems/AppHost.cs
+++ b/src/Ryujinx/Systems/AppHost.cs
@@ -1480,22 +1480,23 @@ namespace Ryujinx.Ava.Systems
private void ShowControllerOverlay(List inputConfigs)
{
- Dispatcher.UIThread.InvokeAsync(() =>
+ try
{
- try
+ // Show overlay through the GPU context window directly
+ if (Device?.Gpu?.Window != null)
{
- // Access the overlay through the MainWindow via the ViewModel
- if (_viewModel?.Window?.ControllerOverlay != null)
- {
- int duration = ConfigurationState.Instance.ControllerOverlayInputCycleDuration.Value;
- _viewModel.Window.ControllerOverlay.ShowControllerBindings(inputConfigs, duration);
- }
+ int duration = ConfigurationState.Instance.ControllerOverlayInputCycleDuration.Value;
+ Device.Gpu.Window.ShowControllerBindings(inputConfigs, duration);
}
- catch (Exception ex)
+ else
{
- Logger.Error?.Print(LogClass.Application, $"Failed to show controller overlay: {ex.Message}");
+ Logger.Warning?.Print(LogClass.Application, "AppHost: Cannot show overlay - Device.Gpu.Window is null");
}
- });
+ }
+ catch (Exception ex)
+ {
+ Logger.Error?.Print(LogClass.Application, $"Failed to show controller overlay: {ex.Message}");
+ }
}
private KeyboardHotkeyState GetHotkeyState()
diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs
index 20b09c775..45a72b75d 100644
--- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs
+++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs
@@ -1707,11 +1707,22 @@ namespace Ryujinx.Ava.UI.ViewModels
? ConfigurationState.InstanceExtra.Hid.InputConfig.Value
: ConfigurationState.Instance.Hid.InputConfig.Value;
- // Only show overlay if there are actual controller configurations for players 1-4
- if (inputConfigs?.Any(c => c.PlayerIndex <= PlayerIndex.Player4) == true)
+ // Debug: Log what we're getting
+ Logger.Info?.Print(LogClass.UI, $"MainWindowViewModel: Found {inputConfigs?.Count ?? 0} input configs");
+ if (inputConfigs != null)
{
- int duration = ConfigurationState.Instance.ControllerOverlayGameStartDuration.Value;
- Window.ControllerOverlay.ShowControllerBindings(inputConfigs, duration);
+ foreach (var config in inputConfigs)
+ {
+ Logger.Info?.Print(LogClass.UI, $" Config: Player {config.PlayerIndex}, Backend {config.Backend}, Name '{config.Name}', Type {config.ControllerType}");
+ }
+ }
+
+ // Always show overlay - if no configs, it will show test data
+ int duration = ConfigurationState.Instance.ControllerOverlayGameStartDuration.Value;
+ // Show overlay through the GPU context window directly
+ if (AppHost?.Device?.Gpu?.Window != null)
+ {
+ AppHost.Device.Gpu.Window.ShowControllerBindings(inputConfigs, duration);
}
}
catch (Exception ex)
diff --git a/src/Ryujinx/UI/Windows/MainWindow.axaml b/src/Ryujinx/UI/Windows/MainWindow.axaml
index 8085ffe93..db0385696 100644
--- a/src/Ryujinx/UI/Windows/MainWindow.axaml
+++ b/src/Ryujinx/UI/Windows/MainWindow.axaml
@@ -180,9 +180,6 @@
Grid.Row="2" />
-
-
+