diff --git a/src/Myra/Graphics2D/UI/Desktop.cs b/src/Myra/Graphics2D/UI/Desktop.cs index 7618ec83..229f09a3 100644 --- a/src/Myra/Graphics2D/UI/Desktop.cs +++ b/src/Myra/Graphics2D/UI/Desktop.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Linq; +using FontStashSharp.RichText; using Myra.Graphics2D.UI.Styles; using Myra.Utility; using Myra.Events; @@ -541,7 +543,11 @@ public void RenderVisual() widget.Render(_renderContext); } } - + +#if DEBUG + RenderDebugInfo(_renderContext); +#endif + _renderContext.End(); _renderContext.DeviceScissor = oldDeviceScissor; @@ -585,6 +591,83 @@ public void Render() // Render run RenderVisual(); } + + /// + /// Draws debug information on the screen. + /// This function can be called after the normal render procedures conclude + /// and before the context is closed. + /// + /// This allows us to render on top of everything else. + /// + /// The current render context + private void RenderDebugInfo(RenderContext context) + { + if (!MyraEnvironment.DrawMouseHoveredWidgetInfo || + MyraEnvironment.DefaultDebugFont == null || + MousePosition == null + ) + return; + + // Look for the deepest child being hit. That'd be the actual widget we're hovering over as opposed to one of its parents + Widget widget = null; + var children = ChildrenCopy; + for (var i = children.Count - 1; i >= 0; --i) + { + var w = children[i].HitTest(MousePosition); + if (w != null) + { + widget = w; + break; + } + } + + // Nothing under the cursor, nothing to render + if (widget == null) + return; + + // Get the widget's current transformed rectangle; That should effectively be the actual on-screen position and size + // in the context of the monitor being used (i.e., ignores other monitors) + Rectangle transformedPos = widget.Transform.Apply(widget.Bounds); + var text = new RichTextLayout + { + Text = $""" + {GetDebugTypeName(widget.GetType())} + eH: {widget.Height ?? transformedPos.Height} + eW: {widget.Width ?? transformedPos.Width} + eX: {transformedPos.X} + eY: {transformedPos.Y} + """, + Font = MyraEnvironment.DefaultDebugFont + }; + + context.DrawRichText( + text, + new Vector2(MousePosition.X - 60, MousePosition.Y - 30), + Color.Yellow + ); + } + + /// + /// Gets a type's 'clean' name, replacing generic type arguments with their actual types + /// + /// The type to get the name of + /// The type's clean display name + private static string GetDebugTypeName(Type type) + { + if (!type.IsGenericType) + { + return type.Name; + } + + var name = type.Name; + var tickIndex = name.IndexOf('`'); + if (tickIndex >= 0) + { + name = name.Substring(0, tickIndex); + } + + return $"{name}<{string.Join(", ", type.GetGenericArguments().Select(GetDebugTypeName))}>"; + } private void InvalidateTransform() { diff --git a/src/Myra/Graphics2D/UI/Misc/Window.cs b/src/Myra/Graphics2D/UI/Misc/Window.cs index 92230a2c..b19af9e7 100644 --- a/src/Myra/Graphics2D/UI/Misc/Window.cs +++ b/src/Myra/Graphics2D/UI/Misc/Window.cs @@ -24,7 +24,7 @@ namespace Myra.Graphics2D.UI public class Window : ContentControl { private readonly StackPanelLayout _layout = new StackPanelLayout(Orientation.Vertical); - private readonly Label _titleLabel; + protected readonly Label _titleLabel; private Widget _content; private Widget _previousKeyboardFocus; diff --git a/src/Myra/Graphics2D/UI/WrapPanel/WrapPanel.cs b/src/Myra/Graphics2D/UI/WrapPanel/WrapPanel.cs new file mode 100644 index 00000000..bbd358e6 --- /dev/null +++ b/src/Myra/Graphics2D/UI/WrapPanel/WrapPanel.cs @@ -0,0 +1,166 @@ +using System.ComponentModel; +using Microsoft.Xna.Framework; +using Myra.Graphics2D.UI; +using Container = Myra.Graphics2D.UI.Container; + +namespace Myra.Graphics2D.UI.WrapPanel; + +/// +/// A container that arranges its child widgets in a sequence that wraps at the edge of the container. +/// +public class WrapPanel : Container +{ + private readonly WrapPanelLayout _layout = new(); + + /// + /// Gets or sets the orientation of the layout (Horizontal or Vertical). + /// + [Category("Layout")] + [DefaultValue(Orientation.Horizontal)] + public Orientation Orientation + { + get => _layout.Orientation; + set + { + if (value == _layout.Orientation) + return; + + _layout.Orientation = value; + InvalidateMeasure(); + } + } + + /// + /// Gets or sets the horizontal spacing between child widgets. + /// + [Category("Layout")] + [DefaultValue(0)] + public int HorizontalSpacing + { + get => _layout.HorizontalSpacing; + set + { + if (value == _layout.HorizontalSpacing) + return; + + _layout.HorizontalSpacing = value; + InvalidateMeasure(); + } + } + + /// + /// Gets or sets the vertical spacing between child widgets. + /// + [Category("Layout")] + [DefaultValue(0)] + public int VerticalSpacing + { + get => _layout.VerticalSpacing; + set + { + if (value == _layout.VerticalSpacing) + return; + + _layout.VerticalSpacing = value; + InvalidateMeasure(); + } + } + + /// + /// Gets or sets a value indicating whether child widgets in a row/column should be aligned to the row height/column width. + /// + [Category("Layout")] + [DefaultValue(true)] + public bool Aligned + { + get => _layout.Aligned; + set + { + if (value == _layout.Aligned) + return; + + _layout.Aligned = value; + InvalidateArrange(); + } + } + + /// + /// Gets or sets a value indicating whether all child widgets should have the same size, based on the largest child. + /// + [Category("Layout")] + [DefaultValue(true)] + public bool UniformSizing + { + get => _layout.UniformSizing; + set + { + if (value == _layout.UniformSizing) + return; + + _layout.UniformSizing = value; + InvalidateMeasure(); + } + } + + /// + /// Gets or sets the preferred width of the wrap panel. + /// + [Category("Layout")] + [DefaultValue(null)] + public int? PreferredWidth + { + get => _layout.PreferredWidth; + set + { + if (value == _layout.PreferredWidth) + return; + + _layout.PreferredWidth = value; + InvalidateMeasure(); + } + } + + /// + /// Gets or sets the preferred height of the wrap panel. + /// + [Category("Layout")] + [DefaultValue(null)] + public int? PreferredHeight + { + get => _layout.PreferredHeight; + set + { + if (value == _layout.PreferredHeight) + return; + + _layout.PreferredHeight = value; + InvalidateMeasure(); + } + } + + /// + /// Initializes a new instance of the class. + /// + public WrapPanel() + { + ChildrenLayout = _layout; + } + + /// + /// Copies the properties from another widget into this one. + /// + /// The widget to copy from. + protected internal override void CopyFrom(Widget w) + { + base.CopyFrom(w); + + var wrapPanel = (WrapPanel)w; + Orientation = wrapPanel.Orientation; + HorizontalSpacing = wrapPanel.HorizontalSpacing; + VerticalSpacing = wrapPanel.VerticalSpacing; + Aligned = wrapPanel.Aligned; + UniformSizing = wrapPanel.UniformSizing; + PreferredWidth = wrapPanel.PreferredWidth; + PreferredHeight = wrapPanel.PreferredHeight; + } +} diff --git a/src/Myra/Graphics2D/UI/WrapPanel/WrapPanelLayout.cs b/src/Myra/Graphics2D/UI/WrapPanel/WrapPanelLayout.cs new file mode 100644 index 00000000..3e26dcbf --- /dev/null +++ b/src/Myra/Graphics2D/UI/WrapPanel/WrapPanelLayout.cs @@ -0,0 +1,413 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Myra.Graphics2D.UI; +using Myra.Utility; + +namespace Myra.Graphics2D.UI.WrapPanel; + +/// +/// Implements a layout that arranges widgets in rows or columns, wrapping when the edge is reached. +/// +public class WrapPanelLayout : ILayout +{ + /// + /// Gets or sets the orientation of the layout. + /// + /// + /// The orientation determines what axis of space will be used first; + /// For , horizontal space will be used first, wrapping + /// to a new row when necessary. + /// For , vertical space will be used first, wrapping + /// to a new column when necessary. + /// + public Orientation Orientation { get; set; } = Orientation.Horizontal; + + /// + /// Gets or sets the horizontal spacing between widgets. + /// + public int HorizontalSpacing { get; set; } + + /// + /// Gets or sets the vertical spacing between widgets. + /// + public int VerticalSpacing { get; set; } + + /// + /// Gets or sets a value indicating whether widgets in a row/column should be aligned to the row height/column width. + /// + public bool Aligned { get; set; } = true; + + /// + /// Gets or sets a value indicating whether all widgets should have the same size, based on the largest widget. + /// + public bool UniformSizing { get; set; } = true; + + /// + /// Gets or sets the preferred width for measurement and arrangement. + /// + public int? PreferredWidth { get; set; } + + /// + /// Gets or sets the preferred height for measurement and arrangement. + /// + public int? PreferredHeight { get; set; } + + /// + /// Calculates the uniform size for all widgets based on the largest widget's measurements. + /// + /// The collection of widgets from which to determine the 'max' widget size + /// The available size for measurement. + /// The uniform size for widgets. + private static Point GetUniformSize(IEnumerable widgets, Point availableSize) + { + int maxChildWidth = 0; + int maxChildHeight = 0; + + foreach (Widget widget in widgets) + { + if (!widget.Visible) + continue; + + Point size = widget.Measure(availableSize); + maxChildWidth = Math.Max(maxChildWidth, size.X); + maxChildHeight = Math.Max(maxChildHeight, size.Y); + } + + return new Point(maxChildWidth, maxChildHeight); + } + + /// + /// Measures the total size required for the widgets given the available size. + /// + /// The collection of widgets to measure. + /// The available size for measurement. + /// The required size for the layout. + public Point Measure(IEnumerable widgets, Point availableSize) + { + // The measurement methods are pretty difficult to keep DRY; + // We either it somewhat duplicated or we start having to pass around + // many parameters. + return Orientation == Orientation.Horizontal + ? MeasureWithHorizontalBias(widgets, availableSize) + : MeasureWithVerticalBias(widgets, availableSize); + } + + /// + /// Measures the total size required for the widgets given the available size. + /// This has a horizontal bias, meaning it'll attempt to use whatever horizontal space is + /// available prior to wrapping into a new row + /// + /// The collection of widgets to measure. + /// The available size for measurement. + /// The required size for the layout. + private Point MeasureWithHorizontalBias(IEnumerable widgets, Point availableSize) + { + Point result = Point.Zero; + int rowWidth = 0; + int rowHeight = 0; + + Point effectiveAvailableSize = GetEffectiveAvailableSize(availableSize); + Widget[] widgetsArr = widgets.ToArray(); + + // Determine the uniform size if uniform sizing is enabled + Point uniformSize = UniformSizing ? GetUniformSize(widgetsArr, effectiveAvailableSize) : Point.Zero; + + bool firstInRow = true; + foreach (Widget widget in widgetsArr) + { + if (!widget.Visible) + continue; + + Point widgetSize = UniformSizing ? uniformSize : widget.Measure(effectiveAvailableSize); + + // Check if the current widget exceeds the available row width + if (!firstInRow && effectiveAvailableSize.X > 0 && + rowWidth + HorizontalSpacing + widgetSize.X > effectiveAvailableSize.X) + { + // Move to the next row + result.X = Math.Max(result.X, rowWidth); + result.Y += rowHeight + VerticalSpacing; + rowWidth = widgetSize.X; + rowHeight = widgetSize.Y; + } + else + { + // Add widget to the current row + if (!firstInRow) + rowWidth += HorizontalSpacing; + rowWidth += widgetSize.X; + rowHeight = Math.Max(rowHeight, widgetSize.Y); + } + + firstInRow = false; + } + + // Finalize measurement for the last row + result.X = Math.Max(result.X, rowWidth); + result.Y += rowHeight; + + if (PreferredWidth.HasValue && availableSize.X is <= 0 or >= 1000000) + result.X = Math.Max(result.X, PreferredWidth.Value); + + return result; + } + + /// + /// Measures the total size required for the widgets given the available size. + /// This has a vertical bias, meaning it'll attempt to use whatever vertical space is + /// available prior to wrapping into a new column + /// + /// The collection of widgets to measure. + /// The available size for measurement. + /// The required size for the layout. + private Point MeasureWithVerticalBias(IEnumerable widgets, Point availableSize) + { + Point result = Point.Zero; + int rowWidth = 0; + int rowHeight = 0; + + Point effectiveAvailableSize = GetEffectiveAvailableSize(availableSize); + Widget[] widgetsArr = widgets.ToArray(); + + // Determine the uniform size if uniform sizing is enabled + Point uniformSize = UniformSizing ? GetUniformSize(widgetsArr, effectiveAvailableSize) : Point.Zero; + + bool firstInCol = true; + foreach (Widget widget in widgetsArr) + { + if (!widget.Visible) + continue; + + Point widgetSize = UniformSizing ? uniformSize : widget.Measure(effectiveAvailableSize); + + // Check if the current widget exceeds the available column height + if (!firstInCol && effectiveAvailableSize.Y > 0 && + rowHeight + VerticalSpacing + widgetSize.Y > effectiveAvailableSize.Y) + { + // Move to the next column + result.Y = Math.Max(result.Y, rowHeight); + result.X += rowWidth + HorizontalSpacing; + rowHeight = widgetSize.Y; + rowWidth = widgetSize.X; + } + else + { + // Add widget to the current column + if (!firstInCol) + rowHeight += VerticalSpacing; + rowHeight += widgetSize.Y; + rowWidth = Math.Max(rowWidth, widgetSize.X); + } + + firstInCol = false; + } + + // Finalize measurement for the last column + result.Y = Math.Max(result.Y, rowHeight); + result.X += rowWidth; + + if (PreferredHeight.HasValue && availableSize.Y is <= 0 or >= 1000000) + result.Y = Math.Max(result.Y, PreferredHeight.Value); + + return result; + } + + /// + /// Gets the effective available size, considering the preferred width/height and orientation. + /// + /// The original available size. + /// The effective available size for layout calculations. + private Point GetEffectiveAvailableSize(Point availableSize) + { + Point effectiveAvailableSize = availableSize; + switch (Orientation) + { + case Orientation.Horizontal: + default: + { + if (PreferredWidth.HasValue) + effectiveAvailableSize.X = availableSize.X is <= 0 or >= 1000000 + ? PreferredWidth.Value + : Math.Min(availableSize.X, PreferredWidth.Value); + break; + } + case Orientation.Vertical: + { + if (PreferredHeight.HasValue) + effectiveAvailableSize.Y = availableSize.Y is <= 0 or >= 1000000 + ? PreferredHeight.Value + : Math.Min(availableSize.Y, PreferredHeight.Value); + break; + } + } + + return effectiveAvailableSize; + } + + /// + /// Arranges the widgets within the specified bounds. + /// + /// The collection of widgets to arrange. + /// The bounds to arrange the widgets within. + public void Arrange(IEnumerable widgets, Rectangle bounds) + { + if (Orientation == Orientation.Horizontal) + ArrangeHorizontal(widgets, bounds); + else + ArrangeVertical(widgets, bounds); + } + + /// + /// Arranges widgets horizontally in rows. + /// + /// The collection of widgets to arrange. + /// The bounds to arrange the widgets within. + private void ArrangeHorizontal(IEnumerable widgets, Rectangle bounds) + { + Point actualAvailableSize = bounds.Size(); + Point effectiveAvailableSize = actualAvailableSize; + + if (PreferredWidth.HasValue) + effectiveAvailableSize.X = actualAvailableSize.X is <= 0 or >= 1000000 + ? PreferredWidth.Value + : Math.Min(actualAvailableSize.X, PreferredWidth.Value); + + int x = bounds.X; + int y = bounds.Y; + int rowHeight = 0; + var rowWidgets = new List(); + + Widget[] widgetsArr = widgets.ToArray(); + Point uniformSize = UniformSizing ? GetUniformSize(widgetsArr, effectiveAvailableSize) : Point.Zero; + + foreach (Widget widget in widgetsArr) + { + if (!widget.Visible) continue; + + Point size = UniformSizing ? uniformSize : widget.Measure(effectiveAvailableSize); + + // Wrap to next row if current widget exceeds horizontal bounds + if (rowWidgets.Count > 0 && effectiveAvailableSize.X > 0 && + x + HorizontalSpacing + size.X > bounds.X + effectiveAvailableSize.X) + { + // Arrange previous row + ArrangeRow(rowWidgets, bounds.X, y, rowHeight, effectiveAvailableSize, uniformSize); + + y += rowHeight + VerticalSpacing; + x = bounds.X; + rowHeight = 0; + rowWidgets.Clear(); + } + + if (rowWidgets.Count > 0) x += HorizontalSpacing; + + rowWidgets.Add(widget); + x += size.X; + rowHeight = Math.Max(rowHeight, size.Y); + } + + // Arrange the last remaining row + if (rowWidgets.Count > 0) + ArrangeRow(rowWidgets, bounds.X, y, rowHeight, effectiveAvailableSize, uniformSize); + } + + /// + /// Arranges widgets vertically in columns. + /// + /// The collection of widgets to arrange. + /// The bounds to arrange the widgets within. + private void ArrangeVertical(IEnumerable widgets, Rectangle bounds) + { + Point actualAvailableSize = bounds.Size(); + Point effectiveAvailableSize = actualAvailableSize; + + if (PreferredHeight.HasValue) + effectiveAvailableSize.Y = actualAvailableSize.Y is <= 0 or >= 1000000 + ? PreferredHeight.Value + : Math.Min(actualAvailableSize.Y, PreferredHeight.Value); + + int x = bounds.X; + int y = bounds.Y; + int rowWidth = 0; + var colWidgets = new List(); + + Widget[] widgetsArr = widgets.ToArray(); + Point uniformSize = UniformSizing ? GetUniformSize(widgetsArr, effectiveAvailableSize) : Point.Zero; + + foreach (Widget widget in widgetsArr) + { + if (!widget.Visible) continue; + + Point size = UniformSizing ? uniformSize : widget.Measure(effectiveAvailableSize); + + // Wrap to next column if current widget exceeds vertical bounds + if (colWidgets.Count > 0 && effectiveAvailableSize.Y > 0 && + y + VerticalSpacing + size.Y > bounds.Y + effectiveAvailableSize.Y) + { + // Arrange previous column + ArrangeCol(colWidgets, x, bounds.Y, rowWidth, effectiveAvailableSize, uniformSize); + + x += rowWidth + HorizontalSpacing; + y = bounds.Y; + rowWidth = 0; + colWidgets.Clear(); + } + + if (colWidgets.Count > 0) y += VerticalSpacing; + + colWidgets.Add(widget); + y += size.Y; + rowWidth = Math.Max(rowWidth, size.X); + } + + // Arrange the last remaining column + if (colWidgets.Count > 0) + ArrangeCol(colWidgets, x, bounds.Y, rowWidth, effectiveAvailableSize, uniformSize); + } + + /// + /// Finalizes the arrangement of a single row. + /// + /// The widgets in the row. + /// The starting X coordinate. + /// The starting Y coordinate. + /// The height of the row. + /// The total available size for measurement. + /// The uniform size for widgets, if enabled. + private void ArrangeRow(List widgets, int x, int y, int rowHeight, Point availableSize, Point uniformSize) + { + foreach (Widget widget in widgets) + { + Point size = UniformSizing ? uniformSize : widget.Measure(availableSize); + int height = Aligned ? rowHeight : size.Y; + var widgetBounds = new Rectangle(x, y, size.X, height); + widget.Arrange(widgetBounds); + + x += size.X + HorizontalSpacing; + } + } + + /// + /// Finalizes the arrangement of a single column. + /// + /// The widgets in the column. + /// The starting X coordinate. + /// The starting Y coordinate. + /// The width of the column. + /// The total available size for measurement. + /// The uniform size for widgets, if enabled. + private void ArrangeCol(List widgets, int x, int y, int colWidth, Point availableSize, Point uniformSize) + { + foreach (Widget widget in widgets) + { + Point size = UniformSizing ? uniformSize : widget.Measure(availableSize); + int width = Aligned ? colWidth : size.X; + var widgetBounds = new Rectangle(x, y, width, size.Y); + widget.Arrange(widgetBounds); + + y += size.Y + VerticalSpacing; + } + } +} \ No newline at end of file diff --git a/src/Myra/MyraEnvironment.cs b/src/Myra/MyraEnvironment.cs index da4ac925..393529ad 100644 --- a/src/Myra/MyraEnvironment.cs +++ b/src/Myra/MyraEnvironment.cs @@ -5,6 +5,7 @@ using AssetManagementBase; using Myra.Graphics2D.UI; using System.Collections.Generic; +using FontStashSharp; #if MONOGAME || FNA using Microsoft.Xna.Framework; @@ -224,11 +225,26 @@ public static AssetManager DefaultAssetManager _defaultAssetManager = value; } } - + + /// + /// Highlights the border of all widgets + /// public static bool DrawWidgetsFrames { get; set; } public static bool DrawKeyboardFocusedWidgetFrame { get; set; } + /// + /// Highlights the borders of the widget hierarchy currently being hovered over + /// public static bool DrawMouseHoveredWidgetFrame { get; set; } public static bool DrawTextGlyphsFrames { get; set; } + /// + /// Draws information about the widget under the mouse cursor + /// + public static bool DrawMouseHoveredWidgetInfo { get; set; } + /// + /// The default font to use when rendering text in overlays. + /// If unset, some debug overlays may not work. + /// + public static SpriteFontBase DefaultDebugFont { get; set; } public static bool DisableClipping { get; set; } public static Func MouseInfoGetter { get; set; } = DefaultMouseInfoGetter;