From 5de5e59fef61d60f40ac73c9e9dc5ecb67c2008b Mon Sep 17 00:00:00 2001 From: erri120 Date: Mon, 11 Dec 2023 19:14:01 +0100 Subject: [PATCH] Fix panel splitting (#813) * Use WorkspaceGridState * Update enumerators * Add comments * Add RowEnumerator * Add CountRows and CountColumns * Update GetPossbileStates for horizontal layouts * Make IsHorizontal reactive * Add DebugPrint * Fix tests * Update test * Implement GetPossibleStatesForHorizontal * Update tests --- .../WorkspaceSystem/GridUtils.cs | 284 ++++++++------ .../Workspace/WorkspaceViewModel.cs | 33 +- ...kspaceGridState.AdjacentPanelEnumerator.cs | 86 +++++ .../WorkspaceGridState.ColumnEnumerator.cs | 128 +++++++ .../WorkspaceGridState.RowEnumerator.cs | 128 +++++++ .../WorkspaceSystem/WorkspaceGridState.cs | 109 ++---- .../NexusMods.UI.Tests.csproj.DotSettings | 3 +- .../GridUtilsTests/GetPossibleStatesTests.cs | 354 +++++++++++++++--- .../WorkspaceGridStateTests.cs | 11 + .../ColumnEnumeratorTests.cs | 82 ++++ .../RowEnumeratorTests.cs | 82 ++++ 11 files changed, 1060 insertions(+), 240 deletions(-) create mode 100644 src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.AdjacentPanelEnumerator.cs create mode 100644 src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.ColumnEnumerator.cs create mode 100644 src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.RowEnumerator.cs create mode 100644 tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests.cs create mode 100644 tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests/ColumnEnumeratorTests.cs create mode 100644 tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests/RowEnumeratorTests.cs diff --git a/src/NexusMods.App.UI/WorkspaceSystem/GridUtils.cs b/src/NexusMods.App.UI/WorkspaceSystem/GridUtils.cs index 470e0348f0..497df6d364 100644 --- a/src/NexusMods.App.UI/WorkspaceSystem/GridUtils.cs +++ b/src/NexusMods.App.UI/WorkspaceSystem/GridUtils.cs @@ -49,141 +49,215 @@ internal static bool IsPerfectGrid(WorkspaceGridState gridState) /// /// Returns all possible new states. /// - internal static IEnumerable> GetPossibleStates( - ImmutableDictionary panels, - int columns, - int rows) + internal static List GetPossibleStates( + WorkspaceGridState currentState, + int maxColumns, + int maxRows) + { + if (currentState.Count == maxColumns * maxRows) return []; + + return currentState.IsHorizontal + ? GetPossibleStatesForHorizontal(currentState, maxColumns, maxRows) + : GetPossibleStatesForVertical(currentState, maxColumns, maxRows); + } + + private static List GetPossibleStatesForHorizontal( + WorkspaceGridState currentState, + int maxColumns, + int maxRows) { - if (panels.Count == columns * rows) yield break; + var res = new List(); + + Span seenColumns = stackalloc WorkspaceGridState.ColumnInfo[maxColumns]; + using var columnEnumerator = new WorkspaceGridState.ColumnEnumerator(currentState, seenColumns); + + var columnCount = 0; + var rowCount = 0; - foreach (var kv in panels) + // Step 1: Iterate over all columns. + // NOTE(erri120): this will fill up seenColumns + Span rowBuffer = stackalloc PanelGridState[maxRows]; + while (columnEnumerator.MoveNext(rowBuffer)) { - if (CanAddColumn(kv, panels, columns)) - { - var res = CreateResult(panels, kv, vertical: true, inverse: false); - yield return res; + columnCount += 1; - if (res.First().Value != res.Last().Value) - yield return CreateResult(panels, kv, vertical: true, inverse: true); - } + var column = columnEnumerator.Current; + var rows = column.Rows; + rowCount = Math.Max(rowCount, rows.Length); - if (CanAddRow(kv, panels, rows)) + // NOTE(erri120): In a horizontal layout, the rows can move independent of rows in other columns. + if (rows.Length == maxRows) continue; + foreach (var panelToSplit in rows) { - var res = CreateResult(panels, kv, vertical: false, inverse: false); - yield return res; - - if (res.First().Value != res.Last().Value) - yield return CreateResult(panels, kv, vertical: false, inverse: true); + res.Add(CreateResult(currentState, panelToSplit, splitVertically: false, inverse: false)); + res.Add(CreateResult(currentState, panelToSplit, splitVertically: false, inverse: true)); } } - } - private static ImmutableDictionary CreateResult( - ImmutableDictionary currentPanels, - KeyValuePair kv, - bool vertical, - bool inverse) - { - var (updatedLogicalBounds, newPanelLogicalBounds) = MathUtils.Split(kv.Value, vertical); + var seenColumnSlice = seenColumns[..columnCount]; - if (inverse) - { - var res = currentPanels.SetItems(new [] - { - new KeyValuePair(kv.Key, newPanelLogicalBounds), - new KeyValuePair(PanelId.DefaultValue, updatedLogicalBounds) - }); + Span seenRows = stackalloc WorkspaceGridState.RowInfo[rowCount]; + using var rowEnumerator = new WorkspaceGridState.RowEnumerator(currentState, seenRows); - return res; - } - else + // Step 2: Iterate over all rows. + Span columnBuffer = stackalloc PanelGridState[columnCount]; + while (rowEnumerator.MoveNext(columnBuffer)) { - var res = currentPanels.SetItems(new [] + var row = rowEnumerator.Current; + var columns = row.Columns; + + // NOTE(erri120): In a horizontal layout, the columns are linked together. + if (columns.Length == maxColumns) continue; + foreach (var panelToSplit in columns) { - new KeyValuePair(kv.Key, updatedLogicalBounds), - new KeyValuePair(PanelId.DefaultValue, newPanelLogicalBounds) - }); + var rect = panelToSplit.Rect; + + if (columnCount == 1) + { + res.Add(CreateResult(currentState, panelToSplit, splitVertically: true, inverse: false)); + res.Add(CreateResult(currentState, panelToSplit, splitVertically: true, inverse: true)); + continue; + } + + foreach (var seenColumn in seenColumnSlice) + { + if (seenColumn.X.IsCloseTo(rect.X) && seenColumn.Width.IsCloseTo(rect.Width)) continue; + + if (seenColumn.X > rect.X && seenColumn.Right().IsLessThanOrCloseTo(rect.Right)) + { + var updatedLogicalBounds = new Rect(rect.X, rect.Y, seenColumn.X, rect.Height); + var newPanelLogicalBounds = new Rect(seenColumn.X, rect.Y, seenColumn.Width, rect.Height); - return res; + res.Add(CreateResult(currentState, panelToSplit,updatedLogicalBounds,newPanelLogicalBounds, inverse: false)); + res.Add(CreateResult(currentState, panelToSplit, updatedLogicalBounds, newPanelLogicalBounds, inverse: true)); + } + } + } } + + return res; } - private static bool CanAddColumn( - KeyValuePair kv, - ImmutableDictionary panels, - int maxColumns) + private static List GetPossibleStatesForVertical( + WorkspaceGridState currentState, + int maxColumns, + int maxRows) { - var currentColumns = 0; - var current = kv.Value; + var res = new List(); + + Span seenRows = stackalloc WorkspaceGridState.RowInfo[maxRows]; + using var rowEnumerator = new WorkspaceGridState.RowEnumerator(currentState, seenRows); - // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator - foreach (var otherPair in panels) + var rowCount = 0; + var columnCount = 0; + + // Step 1: Iterate over all rows. + Span columnBuffer = stackalloc PanelGridState[maxColumns]; + while (rowEnumerator.MoveNext(columnBuffer)) { - var other = otherPair.Value; + rowCount += 1; + + var row = rowEnumerator.Current; + var columns = row.Columns; + columnCount = Math.Max(columnCount, columns.Length); - // NOTE(erri120): +1 column if another panel (self included) has the same Y position. - // Since self is included, the number of columns is guaranteed to be at least 1. - if (other.Y.IsCloseTo(current.Y)) + // NOTE(erri120): In a vertical layout, the columns can move independent of columns in other columns. + if (columns.Length == maxColumns) continue; + foreach (var panelToSplit in columns) { - currentColumns++; - continue; + res.Add(CreateResult(currentState, panelToSplit, splitVertically: true, inverse: false)); + res.Add(CreateResult(currentState, panelToSplit, splitVertically: true, inverse: true)); } + } - // NOTE(erri120): See the example tables below. If the current panel is "3" - // we need to count 2 columns. With the Y check above, this is not possible, - // since the panel "2" in the first table and the panel "1" in the second table - // start above but go down and end next to panel "3". - // | 1 | 2 | | 1 | 2 | - // | 3 | 2 | | 1 | 3 | + var seenRowSlice = seenRows[..rowCount]; - // 1) check if the panel is next to us - if (!other.Left.IsCloseTo(current.Right) && !other.Right.IsCloseTo(current.Left)) continue; + Span seenColumns = stackalloc WorkspaceGridState.ColumnInfo[columnCount]; + using var columnEnumerator = new WorkspaceGridState.ColumnEnumerator(currentState, seenColumns); - // 2) check if the panel is in the current row - if (other.Bottom.IsGreaterThanOrCloseTo(current.Y) || other.Top.IsLessThanOrCloseTo(current.Y)) - currentColumns++; + // Step 2: Iterate over all columns. + Span rowBuffer = stackalloc PanelGridState[rowCount]; + while (columnEnumerator.MoveNext(rowBuffer)) + { + var column = columnEnumerator.Current; + var rows = column.Rows; + + // NOTE(erri120): In a vertical layout, the rows are linked together. + if (rows.Length == maxRows) continue; + foreach (var panelToSplit in rows) + { + var rect = panelToSplit.Rect; + + if (rowCount == 1) + { + res.Add(CreateResult(currentState, panelToSplit, splitVertically: false, inverse: false)); + res.Add(CreateResult(currentState, panelToSplit, splitVertically: false, inverse: true)); + } + + foreach (var seenRow in seenRowSlice) + { + if (seenRow.Y.IsCloseTo(rect.Y) && seenRow.Height.IsCloseTo(rect.Height)) continue; + + if (seenRow.Y > rect.Y && seenRow.Bottom().IsLessThanOrCloseTo(rect.Bottom)) + { + var updatedLogicalBounds = new Rect(rect.X, rect.Y, rect.Width, seenRow.Y); + var newPanelLogicalBounds = new Rect(rect.X, seenRow.Y, rect.Width, seenRow.Height); + + res.Add(CreateResult(currentState, panelToSplit,updatedLogicalBounds,newPanelLogicalBounds, inverse: false)); + res.Add(CreateResult(currentState, panelToSplit, updatedLogicalBounds, newPanelLogicalBounds, inverse: true)); + } + } + } } - return currentColumns < maxColumns; + return res; } - private static bool CanAddRow( - KeyValuePair kv, - ImmutableDictionary panels, - int maxRows) + private static WorkspaceGridState CreateResult( + WorkspaceGridState currentState, + PanelGridState panelToSplit, + Rect updatedLogicalBounds, + Rect newPanelLogicalBounds, + bool inverse) { - var currentRows = 0; - var current = kv.Value; - - // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator - foreach (var otherPair in panels) + Span updatedValues = stackalloc PanelGridState[2]; + if (inverse) { - var other = otherPair.Value; - - // NOTE(erri120): +1 column if another panel (self included) has the same X position. - // Since self is included, the number of columns is guaranteed to be at least 1. - if (other.X.IsCloseTo(current.X)) - { - currentRows++; - continue; - } + updatedValues[0] = new PanelGridState(PanelId.DefaultValue, updatedLogicalBounds); + updatedValues[1] = panelToSplit with { Rect = newPanelLogicalBounds }; + } + else + { + updatedValues[0] = panelToSplit with { Rect = updatedLogicalBounds }; + updatedValues[1] = new PanelGridState(PanelId.DefaultValue, newPanelLogicalBounds); + } - // NOTE(erri120): See the example tables below. If the current panel is "3" - // we need to count 2 rows. With the X check above, this is not possible, - // since the panel "2" in the first table and the panel "1" in the second table - // extend above and below to panel "3". - // | 1 | 3 | | 1 | 1 | - // | 2 | 2 | | 2 | 3 | + var res = currentState.UnionById(updatedValues); + return res; + } - // 1) check if the panel is above or below us - if (!other.Top.IsCloseTo(current.Bottom) && !other.Bottom.IsCloseTo(current.Top)) continue; + private static WorkspaceGridState CreateResult( + WorkspaceGridState workspaceState, + PanelGridState panelToSplit, + bool splitVertically, + bool inverse) + { + var (updatedLogicalBounds, newPanelLogicalBounds) = MathUtils.Split(panelToSplit.Rect, splitVertically); - // 2) check if the panel is in the current column - if (other.Right.IsGreaterThanOrCloseTo(current.X) || other.Left.IsLessThanOrCloseTo(current.X)) - currentRows++; + Span updatedValues = stackalloc PanelGridState[2]; + if (inverse) + { + updatedValues[0] = new PanelGridState(PanelId.DefaultValue, updatedLogicalBounds); + updatedValues[1] = panelToSplit with { Rect = newPanelLogicalBounds }; + } + else + { + updatedValues[0] = panelToSplit with { Rect = updatedLogicalBounds }; + updatedValues[1] = new PanelGridState(PanelId.DefaultValue, newPanelLogicalBounds); } - return currentRows < maxRows; + var res = workspaceState.UnionById(updatedValues); + return res; } internal static WorkspaceGridState GetStateWithoutPanel( @@ -204,16 +278,12 @@ internal static WorkspaceGridState GetStateWithoutPanel( Span sameRow = stackalloc PanelId[gridState.Count]; var sameRowCount = 0; - using (var enumerator = res.EnumerateAdjacentPanels(panelState, includeAnchor: true)) + foreach (var adjacent in res.EnumerateAdjacentPanels(panelState, includeAnchor: true)) { - while (enumerator.MoveNext()) - { - var adjacent = enumerator.Current; - if ((adjacent.Kind & WorkspaceGridState.AdjacencyKind.SameColumn) == WorkspaceGridState.AdjacencyKind.SameColumn) - sameColumn[sameColumnCount++] = adjacent.Panel.Id; - if ((adjacent.Kind & WorkspaceGridState.AdjacencyKind.SameRow) == WorkspaceGridState.AdjacencyKind.SameRow) - sameRow[sameRowCount++] = adjacent.Panel.Id; - } + if ((adjacent.Kind & WorkspaceGridState.AdjacencyKind.SameColumn) == WorkspaceGridState.AdjacencyKind.SameColumn) + sameColumn[sameColumnCount++] = adjacent.Panel.Id; + if ((adjacent.Kind & WorkspaceGridState.AdjacencyKind.SameRow) == WorkspaceGridState.AdjacencyKind.SameRow) + sameRow[sameRowCount++] = adjacent.Panel.Id; } Debug.Assert(sameColumnCount > 0 || sameRowCount > 0); diff --git a/src/NexusMods.App.UI/WorkspaceSystem/Workspace/WorkspaceViewModel.cs b/src/NexusMods.App.UI/WorkspaceSystem/Workspace/WorkspaceViewModel.cs index 673b762f3d..cb71465e35 100644 --- a/src/NexusMods.App.UI/WorkspaceSystem/Workspace/WorkspaceViewModel.cs +++ b/src/NexusMods.App.UI/WorkspaceSystem/Workspace/WorkspaceViewModel.cs @@ -7,14 +7,15 @@ using DynamicData; using DynamicData.Aggregation; using ReactiveUI; +using ReactiveUI.Fody.Helpers; namespace NexusMods.App.UI.WorkspaceSystem; public class WorkspaceViewModel : AViewModel, IWorkspaceViewModel { - private const int Columns = 2; - private const int Rows = 2; - private const int MaxPanelCount = Columns * Rows; + private const int MaxColumns = 2; + private const int MaxRows = 2; + private const int MaxPanelCount = MaxColumns * MaxRows; private readonly SourceCache _panelSource = new(x => x.Id); private readonly ReadOnlyObservableCollection _panels; @@ -53,6 +54,13 @@ public WorkspaceViewModel(PageFactoryController factoryController) this.WhenActivated(disposables => { + // Workspace resizing + this.WhenAnyValue(vm => vm.IsHorizontal) + .Distinct() + .Do(_ => UpdateStates()) + .Do(_ => UpdateResizers()) + .Subscribe(); + // Adding a panel _addPanelButtonViewModelSource .Connect() @@ -196,14 +204,16 @@ public WorkspaceViewModel(PageFactoryController factoryController) }); } - // TODO: make this reactive private Size _lastWorkspaceSize; - private bool IsHorizontal => _lastWorkspaceSize.Width > _lastWorkspaceSize.Height; + + [Reactive] private bool IsHorizontal { get; set; } /// public void Arrange(Size workspaceSize) { _lastWorkspaceSize = workspaceSize; + IsHorizontal = _lastWorkspaceSize.Width > _lastWorkspaceSize.Height; + foreach (var panelViewModel in Panels) { panelViewModel.Arrange(workspaceSize); @@ -229,12 +239,15 @@ private void UpdateStates() updater.Clear(); if (_panels.Count == MaxPanelCount) return; - var panels = _panels.ToImmutableDictionary(panel => panel.Id, panel => panel.LogicalBounds); - var states = GridUtils.GetPossibleStates(panels, Columns, Rows); - foreach (var state in states) + var currentState = WorkspaceGridState.From(_panels, IsHorizontal); + var newStates = GridUtils.GetPossibleStates(currentState, MaxColumns, MaxRows); + + foreach (var state in newStates) { - var image = IconUtils.StateToBitmap(state); - updater.Add(new AddPanelButtonViewModel(state, image)); + var dict = state.ToDictionary(); + + var image = IconUtils.StateToBitmap(dict); + updater.Add(new AddPanelButtonViewModel(dict, image)); } }); } diff --git a/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.AdjacentPanelEnumerator.cs b/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.AdjacentPanelEnumerator.cs new file mode 100644 index 0000000000..c0788c76f8 --- /dev/null +++ b/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.AdjacentPanelEnumerator.cs @@ -0,0 +1,86 @@ +using System.Collections; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +namespace NexusMods.App.UI.WorkspaceSystem; + +public readonly partial struct WorkspaceGridState +{ + [Flags] + public enum AdjacencyKind : byte + { + None = 0, + SameRow = 1 << 0, + SameColumn = 1 << 1 + } + + public record struct AdjacentPanel(PanelGridState Panel, AdjacencyKind Kind); + + public struct AdjacentPanelEnumerator : IEnumerator + { + private ImmutableSortedSet.Enumerator _enumerator; + private readonly PanelGridState _anchor; + private readonly bool _includeAnchor; + + internal AdjacentPanelEnumerator(WorkspaceGridState parent, PanelGridState anchor, bool includeAnchor) + { + _enumerator = parent.GetEnumerator(); + _anchor = anchor; + _includeAnchor = includeAnchor; + } + + public AdjacentPanelEnumerator GetEnumerator() => this; + + public AdjacentPanel Current { get; private set; } + [SuppressMessage("ReSharper", "HeapView.BoxingAllocation")] + object IEnumerator.Current => Current; + + public bool MoveNext() + { + while (_enumerator.MoveNext()) + { + var other = _enumerator.Current; + if (!_includeAnchor && other.Id == _anchor.Id) continue; + + var (anchorRect, otherRect) = (_anchor.Rect, other.Rect); + var flags = AdjacencyKind.None; + + // same column + // | a | x | | b | x | + // | b | x | | a | x | + if (otherRect.Left.IsGreaterThanOrCloseTo(anchorRect.Left) && otherRect.Right.IsLessThanOrCloseTo(anchorRect.Right)) + { + if (otherRect.Top.IsCloseTo(anchorRect.Bottom) || otherRect.Bottom.IsCloseTo(anchorRect.Top)) + { + flags |= AdjacencyKind.SameColumn; + } + } + + // same row + // | a | b | | b | a | | a | b | + // | x | x | | x | x | | a | c | + if (otherRect.Top.IsGreaterThanOrCloseTo(anchorRect.Top) && otherRect.Bottom.IsLessThanOrCloseTo(anchorRect.Bottom)) + { + if (otherRect.Left.IsCloseTo(anchorRect.Right) || otherRect.Right.IsCloseTo(anchorRect.Left)) + { + flags |= AdjacencyKind.SameRow; + } + } + + if (flags == AdjacencyKind.None) continue; + + Current = new AdjacentPanel(other, flags); + return true; + } + + return false; + } + + public void Reset() => _enumerator.Reset(); + + public void Dispose() + { + _enumerator.Dispose(); + } + } +} diff --git a/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.ColumnEnumerator.cs b/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.ColumnEnumerator.cs new file mode 100644 index 0000000000..7cee2914a8 --- /dev/null +++ b/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.ColumnEnumerator.cs @@ -0,0 +1,128 @@ +using System.Collections.Immutable; + +namespace NexusMods.App.UI.WorkspaceSystem; + +public readonly partial struct WorkspaceGridState +{ + public readonly record struct ColumnInfo(double X, double Width) + { + public double Right() => X + Width; + } + + public ref struct Column(ColumnInfo info, ReadOnlySpan rows) + { + public readonly ColumnInfo Info = info; + public readonly ReadOnlySpan Rows = rows; + } + + /// + /// Efficient column enumerator. + /// + public ref struct ColumnEnumerator + { + private ImmutableSortedSet.Enumerator _enumerator; + + private readonly Span _seenColumns; + private int _numColumns; + private int _currentColumnIndex; + + public ColumnEnumerator(WorkspaceGridState parent, Span seenColumns) + { + _enumerator = parent.GetEnumerator(); + _seenColumns = seenColumns; + + Setup(); + } + + public Column Current { get; private set; } + + public bool MoveNext(Span rowBuffer) + { + if (_currentColumnIndex == _numColumns) return false; + var columnInfo = _seenColumns[_currentColumnIndex++]; + + var rowCount = 0; + while (_enumerator.MoveNext()) + { + var current = _enumerator.Current; + var rect = current.Rect; + + if (rect.X.IsCloseTo(columnInfo.X) && rect.Right.IsGreaterThanOrCloseTo(columnInfo.Right())) + { + rowBuffer[rowCount++] = current; + } else if (rect.Right.IsCloseTo(columnInfo.Right()) && rect.X.IsLessThanOrCloseTo(columnInfo.X)) + { + rowBuffer[rowCount++] = current; + } + } + + if (_currentColumnIndex != _numColumns) _enumerator.Reset(); + + // sort the rows by the Y component + var slice = rowBuffer[..rowCount]; + slice.Sort(YComparer.Instance); + + Current = new Column(columnInfo, rowBuffer[..rowCount]); + return true; + } + + private void Setup() + { + // Fill the Span with positive infinity values + // This eliminates the need for a running variable and allows us to use BinarySearch + _seenColumns.Fill(new ColumnInfo(double.PositiveInfinity, double.PositiveInfinity)); + + while (_enumerator.MoveNext()) + { + var (_, rect) = _enumerator.Current; + var info = new ColumnInfo(rect.X, rect.Width); + + if (_numColumns == 0) + { + _seenColumns[_numColumns++] = info; + } + else + { + var index = _seenColumns.BinarySearch(info, XComparer.Instance); + if (index < 0) + { + _seenColumns[~index] = info; + _numColumns += 1; + } + else + { + // the binary search only uses the X component, we want the smallest columns only + var other = _seenColumns[index]; + if (other.Width > rect.Width) + { + _seenColumns[index] = info; + } + } + } + } + + _enumerator.Reset(); + } + + public void Dispose() => _enumerator.Dispose(); + + private class XComparer : IComparer + { + public static readonly IComparer Instance = new XComparer(); + public int Compare(ColumnInfo a, ColumnInfo b) + { + return a.X.CompareTo(b.X); + } + } + + private class YComparer : IComparer + { + public static readonly IComparer Instance = new YComparer(); + + public int Compare(PanelGridState a, PanelGridState b) + { + return a.Rect.Y.CompareTo(b.Rect.Y); + } + } + } +} diff --git a/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.RowEnumerator.cs b/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.RowEnumerator.cs new file mode 100644 index 0000000000..ed7c1894d7 --- /dev/null +++ b/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.RowEnumerator.cs @@ -0,0 +1,128 @@ +using System.Collections.Immutable; + +namespace NexusMods.App.UI.WorkspaceSystem; + +public readonly partial struct WorkspaceGridState +{ + public readonly record struct RowInfo(double Y, double Height) + { + public double Bottom() => Y + Height; + } + + public ref struct Row(RowInfo info, ReadOnlySpan columns) + { + public readonly RowInfo Info = info; + public readonly ReadOnlySpan Columns = columns; + } + + /// + /// Efficient row enumerator. + /// + public ref struct RowEnumerator + { + private ImmutableSortedSet.Enumerator _enumerator; + + private readonly Span _seenRows; + private int _numRows; + private int _currentRowIndex; + + public RowEnumerator(WorkspaceGridState parent, Span seenRows) + { + _enumerator = parent.GetEnumerator(); + _seenRows = seenRows; + + Setup(); + } + + public Row Current { get; private set; } + + public bool MoveNext(Span columnBuffer) + { + if (_currentRowIndex == _numRows) return false; + var rowInfo = _seenRows[_currentRowIndex++]; + + var columnCount = 0; + while (_enumerator.MoveNext()) + { + var current = _enumerator.Current; + var rect = current.Rect; + + if (rect.Y.IsCloseTo(rowInfo.Y) && rect.Bottom.IsGreaterThanOrCloseTo(rowInfo.Bottom())) + { + columnBuffer[columnCount++] = current; + } else if (rect.Bottom.IsCloseTo(rowInfo.Bottom()) && rect.Y.IsLessThanOrCloseTo(rowInfo.Y)) + { + columnBuffer[columnCount++] = current; + } + } + + if (_currentRowIndex != _numRows) _enumerator.Reset(); + + // sort the columns by the X component + var slice = columnBuffer[..columnCount]; + slice.Sort(XComparer.Instance); + + Current = new Row(rowInfo, columnBuffer[..columnCount]); + return true; + } + + private void Setup() + { + // Fill the Span with positive infinity values + // This eliminates the need for a running variable and allows us to use BinarySearch + _seenRows.Fill(new RowInfo(double.PositiveInfinity, double.PositiveInfinity)); + + while (_enumerator.MoveNext()) + { + var (_, rect) = _enumerator.Current; + var info = new RowInfo(rect.Y, rect.Height); + + if (_numRows == 0) + { + _seenRows[_numRows++] = info; + } + else + { + var index = _seenRows.BinarySearch(info, YComparer.Instance); + if (index < 0) + { + _seenRows[~index] = info; + _numRows += 1; + } + else + { + // the binary search only uses the Y component, we want the smallest rows only + var other = _seenRows[index]; + if (other.Height > rect.Height) + { + _seenRows[index] = info; + } + } + } + } + + _enumerator.Reset(); + } + + public void Dispose() => _enumerator.Dispose(); + + private class YComparer : IComparer + { + public static readonly IComparer Instance = new YComparer(); + public int Compare(RowInfo a, RowInfo b) + { + return a.Y.CompareTo(b.Y); + } + } + + private class XComparer : IComparer + { + public static readonly IComparer Instance = new XComparer(); + + public int Compare(PanelGridState a, PanelGridState b) + { + return a.Rect.X.CompareTo(b.Rect.X); + } + } + } +} diff --git a/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.cs b/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.cs index 9a16372f49..d1ada1f6ff 100644 --- a/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.cs +++ b/src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.cs @@ -1,14 +1,18 @@ using System.Collections; using System.Collections.Immutable; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Avalonia; namespace NexusMods.App.UI.WorkspaceSystem; -public readonly struct WorkspaceGridState : +public readonly partial struct WorkspaceGridState : IImmutableSet, IReadOnlyList { + internal const int MaxColumns = 8; + internal const int MaxRows = 8; + public readonly ImmutableSortedSet Inner; public readonly bool IsHorizontal; @@ -18,12 +22,10 @@ public WorkspaceGridState(ImmutableSortedSet inner, bool isHoriz IsHorizontal = isHorizontal; } - public static WorkspaceGridState From(IEnumerable> values, bool isHorizontal) + [Obsolete("Usages of the Workspace State as an ImmutableDictionary<> will be phased out.")] + public ImmutableDictionary ToDictionary() { - return new WorkspaceGridState( - inner: values.Select(kv => new PanelGridState(kv.Key, kv.Value)).ToImmutableSortedSet(PanelGridStateComparer.Instance), - isHorizontal - ); + return Inner.ToImmutableDictionary(x => x.Id, x => x.Rect); } public static WorkspaceGridState From(IEnumerable panels, bool isHorizontal) @@ -77,8 +79,9 @@ public bool TryGetValue(PanelId id, out PanelGridState panel) return false; } - [SuppressMessage("ReSharper", "ParameterTypeCanBeEnumerable.Global")] - public WorkspaceGridState UnionById(PanelGridState[] other) + public WorkspaceGridState UnionById(PanelGridState[] other) => UnionById(other.AsSpan()); + + public WorkspaceGridState UnionById(ReadOnlySpan other) { var builder = Inner.ToBuilder(); foreach (var panelToAdd in other) @@ -96,78 +99,48 @@ public WorkspaceGridState UnionById(PanelGridState[] other) public AdjacentPanelEnumerator EnumerateAdjacentPanels(PanelGridState anchor, bool includeAnchor) => new(this, anchor, includeAnchor); - [Flags] - public enum AdjacencyKind : byte + public int CountColumns() { - None = 0, - SameRow = 1 << 0, - SameColumn = 1 << 1 - } - - public record struct AdjacentPanel(PanelGridState Panel, AdjacencyKind Kind); + var res = 0; - public struct AdjacentPanelEnumerator : IEnumerator - { - private ImmutableSortedSet.Enumerator _enumerator; - private readonly PanelGridState _anchor; - private readonly bool _includeAnchor; + Span seenColumns = stackalloc ColumnInfo[MaxColumns]; + using var enumerator = new ColumnEnumerator(this, seenColumns); - internal AdjacentPanelEnumerator(WorkspaceGridState parent, PanelGridState anchor, bool includeAnchor) + Span rowBuffer = stackalloc PanelGridState[MaxRows]; + while (enumerator.MoveNext(rowBuffer)) { - _enumerator = parent.GetEnumerator(); - _anchor = anchor; - _includeAnchor = includeAnchor; + res += 1; } - public AdjacentPanel Current { get; private set; } - object IEnumerator.Current => Current; + return res; + } + + public int CountRows() + { + var res = 0; + + Span seenRows = stackalloc RowInfo[MaxRows]; + using var enumerator = new RowEnumerator(this, seenRows); - public bool MoveNext() + Span columnBuffer = stackalloc PanelGridState[MaxColumns]; + while (enumerator.MoveNext(columnBuffer)) { - while (true) - { - if (!_enumerator.MoveNext()) return false; - - var other = _enumerator.Current; - if (!_includeAnchor && other.Id == _anchor.Id) continue; - - var (anchorRect, otherRect) = (_anchor.Rect, other.Rect); - var flags = AdjacencyKind.None; - - // same column - // | a | x | | b | x | - // | b | x | | a | x | - if (otherRect.Left.IsGreaterThanOrCloseTo(anchorRect.Left) && otherRect.Right.IsLessThanOrCloseTo(anchorRect.Right)) - { - if (otherRect.Top.IsCloseTo(anchorRect.Bottom) || otherRect.Bottom.IsCloseTo(anchorRect.Top)) - { - flags |= AdjacencyKind.SameColumn; - } - } - - // same row - // | a | b | | b | a | | a | b | - // | x | x | | x | x | | a | c | - if (otherRect.Top.IsGreaterThanOrCloseTo(anchorRect.Top) && otherRect.Bottom.IsLessThanOrCloseTo(anchorRect.Bottom)) - { - if (otherRect.Left.IsCloseTo(anchorRect.Right) || otherRect.Right.IsCloseTo(anchorRect.Left)) - { - flags |= AdjacencyKind.SameRow; - } - } - - if (flags == AdjacencyKind.None) continue; - - Current = new AdjacentPanel(other, flags); - return true; - } + res += 1; } - public void Reset() => _enumerator.Reset(); - public void Dispose() + return res; + } + + [Conditional("DEBUG")] + [SuppressMessage("ReSharper", "UnusedMember.Global")] + public void DebugPrint() + { + foreach (var panel in Inner) { - _enumerator.Dispose(); + Console.WriteLine(panel.ToString()); } + + Console.WriteLine(); } #region Interface Implementations diff --git a/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj.DotSettings b/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj.DotSettings index e79d3e2b12..342c802909 100644 --- a/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj.DotSettings +++ b/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj.DotSettings @@ -1,2 +1,3 @@  - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/tests/NexusMods.UI.Tests/WorkspaceSystem/GridUtilsTests/GetPossibleStatesTests.cs b/tests/NexusMods.UI.Tests/WorkspaceSystem/GridUtilsTests/GetPossibleStatesTests.cs index 057354dde0..1f59951853 100644 --- a/tests/NexusMods.UI.Tests/WorkspaceSystem/GridUtilsTests/GetPossibleStatesTests.cs +++ b/tests/NexusMods.UI.Tests/WorkspaceSystem/GridUtilsTests/GetPossibleStatesTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using Avalonia; using FluentAssertions; @@ -12,8 +11,11 @@ namespace NexusMods.UI.Tests.WorkspaceSystem; public partial class GridUtilsTests { [Theory] - [MemberData(nameof(TestData_GetPossibleStates_Generated))] + [MemberData(nameof(TestData_GetPossibleStates_Horizontal_Generated))] + [MemberData(nameof(TestData_GetPossibleStates_Vertical_Generated))] + [SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] public void Test_GetPossibleStates( + string name, WorkspaceGridState currentState, WorkspaceGridState[] expectedOutputs) { @@ -28,31 +30,27 @@ public void Test_GetPossibleStates( } var actualOutputs = GridUtils.GetPossibleStates( - currentState.Inner.ToImmutableDictionary(x => x.Id, x => x.Rect), - columns: 2, - rows: 2 - ).ToArray(); + currentState, + maxColumns: 2, + maxRows: 2 + ); - var convertedOutputs = actualOutputs - .Select(actualOutput => new WorkspaceGridState(actualOutput.Select(kv => new PanelGridState(kv.Key, kv.Value)).ToImmutableSortedSet(PanelGridStateComparer.Instance), isHorizontal: currentState.IsHorizontal)) - .ToArray(); - - if (convertedOutputs.Length != 0) + if (actualOutputs.Count != 0) { - convertedOutputs.Should().AllSatisfy(output => + actualOutputs.Should().AllSatisfy(output => { GridUtils.IsPerfectGrid(output).Should().BeTrue(); }); } - convertedOutputs.Should().HaveCount(expectedOutputs.Length); - for (var i = 0; i < convertedOutputs.Length; i++) + actualOutputs.Should().HaveCount(expectedOutputs.Length); + for (var i = 0; i < actualOutputs.Count; i++) { - convertedOutputs[i].Should().Equal(expectedOutputs[i]); + actualOutputs[i].Should().Equal(expectedOutputs[i]); } } - public static IEnumerable TestData_GetPossibleStates_Generated() + public static IEnumerable TestData_GetPossibleStates_Vertical_Generated() { var newPanelId = PanelId.DefaultValue; var firstPanelId = PanelId.From(Guid.Parse("11111111-1111-1111-1111-111111111111")); @@ -60,7 +58,9 @@ public static IEnumerable TestData_GetPossibleStates_Generated() var thirdPanelId = PanelId.From(Guid.Parse("33333333-3333-3333-3333-333333333333")); var fourthPanelId = PanelId.From(Guid.Parse("44444444-4444-4444-4444-444444444444")); - // TODO: test with variable sized panels + const double step = 0.1; + const double min = 0.2; + const double max = 1.0 - min; // Input: one panel // Possible States: @@ -70,29 +70,30 @@ public static IEnumerable TestData_GetPossibleStates_Generated() // 4) split horizontally, new panel is in the first row yield return new object[] { + "vertical | one panel", CreateState( - isHorizontal: true, + isHorizontal: false, new PanelGridState(firstPanelId, MathUtils.One) ), new[] { CreateState( - isHorizontal: true, + isHorizontal: false, new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 1)), new PanelGridState(newPanelId, new Rect(0.5, 0, 0.5, 1)) ), CreateState( - isHorizontal: true, + isHorizontal: false, new PanelGridState(newPanelId, new Rect(0, 0, 0.5, 1)), new PanelGridState(firstPanelId, new Rect(0.5, 0, 0.5, 1)) ), CreateState( - isHorizontal: true, + isHorizontal: false, new PanelGridState(firstPanelId, new Rect(0, 0, 1, 0.5)), new PanelGridState(newPanelId, new Rect(0, 0.5, 1, 0.5)) ), CreateState( - isHorizontal: true, + isHorizontal: false, new PanelGridState(newPanelId, new Rect(0, 0, 1, 0.5)), new PanelGridState(firstPanelId, new Rect(0, 0.5, 1, 0.5)) ), @@ -111,40 +112,278 @@ public static IEnumerable TestData_GetPossibleStates_Generated() // TODO: 6) split both the first and second panel horizontally, the new panel will take up the entirety of the first row yield return new object[] { + "vertical | two column", CreateState( - isHorizontal: true, + isHorizontal: false, new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 1)), - new PanelGridState(secondPanelId, new Rect(0.5, 0, 0.5, 1)) + new PanelGridState(secondPanelId, new Rect(0.5, 0, 1 - 0.5, 1)) ), new[] { CreateState( - isHorizontal: true, + isHorizontal: false, new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 0.5)), new PanelGridState(newPanelId, new Rect(0, 0.5, 0.5, 0.5)), - new PanelGridState(secondPanelId, new Rect(0.5, 0, 0.5, 1)) + new PanelGridState(secondPanelId, new Rect(0.5, 0, 1 - 0.5, 1)) ), CreateState( - isHorizontal: true, + isHorizontal: false, new PanelGridState(newPanelId, new Rect(0, 0, 0.5, 0.5)), new PanelGridState(firstPanelId, new Rect(0, 0.5, 0.5, 0.5)), - new PanelGridState(secondPanelId, new Rect(0.5, 0, 0.5, 1)) + new PanelGridState(secondPanelId, new Rect(0.5, 0, 1 - 0.5, 1)) ), CreateState( - isHorizontal: true, + isHorizontal: false, new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 1)), - new PanelGridState(secondPanelId, new Rect(0.5, 0, 0.5, 0.5)), - new PanelGridState(newPanelId, new Rect(0.5, 0.5, 0.5, 0.5)) + new PanelGridState(secondPanelId, new Rect(0.5, 0, 1 - 0.5, 0.5)), + new PanelGridState(newPanelId, new Rect(0.5, 0.5, 1 - 0.5, 0.5)) + ), + CreateState( + isHorizontal: false, + new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 1)), + new PanelGridState(newPanelId, new Rect(0.5, 0, 1 - 0.5, 0.5)), + new PanelGridState(secondPanelId, new Rect(0.5, 0.5, 1 - 0.5, 0.5)) + ), + } + }; + + // Input: two rows + // | 1 | 1 | + // | 2 | 2 | + // Possible States: + // 1) split the first panel vertically, the new panel is in the second column + // 2) split the first panel vertically, the new panel is in the first column + // 3) split the second panel vertically, the new panel is in the second column + // 4) split the second panel vertically, the new panel is in the first column + // TODO: 5) split both the first and second panel vertically, the new panel will take up the entirety of the second column + // TODO: 6) split both the first and second panel vertically, the new panel will take up the entirety of the first column + for (var height = min; height < max; height += step) + { + yield return new object[] + { + $"vertical | two rows | height: {height}", + CreateState( + isHorizontal: false, + new PanelGridState(firstPanelId, new Rect(0, 0, 1, height)), + new PanelGridState(secondPanelId, new Rect(0, height, 1, 1 - height)) + ), + new[] + { + CreateState( + isHorizontal: false, + new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, height)), + new PanelGridState(secondPanelId, new Rect(0, height, 1, 1 - height)), + new PanelGridState(newPanelId, new Rect(0.5, 0, 0.5, height)) + ), + CreateState( + isHorizontal: false, + new PanelGridState(newPanelId, new Rect(0, 0, 0.5, height)), + new PanelGridState(secondPanelId, new Rect(0, height, 1, 1 - height)), + new PanelGridState(firstPanelId, new Rect(0.5, 0, 0.5, height)) + ), + CreateState( + isHorizontal: false, + new PanelGridState(firstPanelId, new Rect(0, 0, 1, height)), + new PanelGridState(secondPanelId, new Rect(0,height, 0.5, 1 - height)), + new PanelGridState(newPanelId, new Rect(0.5,height, 0.5, 1 - height)) + ), + CreateState( + isHorizontal: false, + new PanelGridState(firstPanelId, new Rect(0, 0, 1, height)), + new PanelGridState(newPanelId, new Rect(0,height, 0.5, 1 - height)), + new PanelGridState(secondPanelId, new Rect(0.5,height, 0.5, 1 - height)) + ), + } + }; + } + + // Input: three panels with one large row + // | 1 | 1 | + // | 2 | 3 | + // Possible States: + // 1) split the first panel vertically, the new panel is in the second column + // 2) split the first panel vertically, the new panel is in the first column + yield return new object[] + { + "vertical | three panels with one large row", + CreateState( + isHorizontal: false, + new PanelGridState(firstPanelId, new Rect(0, 0, 1, 0.5)), + new PanelGridState(secondPanelId, new Rect(0, 0.5, 0.5, 0.5)), + new PanelGridState(thirdPanelId, new Rect(0.5, 0.5, 1 - 0.5, 0.5)) + ), + new[] + { + CreateState( + isHorizontal: false, + new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 0.5)), + new PanelGridState(newPanelId, new Rect(0.5, 0, 1 - 0.5, 0.5)), + new PanelGridState(secondPanelId, new Rect(0, 0.5, 0.5, 0.5)), + new PanelGridState(thirdPanelId, new Rect(0.5, 0.5, 1 - 0.5, 0.5)) + ), + CreateState( + isHorizontal: false, + new PanelGridState(newPanelId, new Rect(0, 0, 0.5, 0.5)), + new PanelGridState(firstPanelId, new Rect(0.5, 0, 1 - 0.5, 0.5)), + new PanelGridState(secondPanelId, new Rect(0, 0.5, 0.5, 0.5)), + new PanelGridState(thirdPanelId, new Rect(0.5, 0.5, 1 - 0.5, 0.5)) + ) + } + }; + + // Input: three panels with one large column + // | 1 | 2 | + // | 1 | 3 | + // Possible States: + // 1) split the first panel horizontally, the new panel is in the second row + // 2) split the first panel horizontally, the new panel is in the first row + for (var height = min; height < max; height += step) + { + yield return new object[] + { + $"vertical | three panels with one large column | height: {height}", + CreateState( + isHorizontal: false, + new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 1)), + new PanelGridState(secondPanelId, new Rect(0.5, 0, 0.5, height)), + new PanelGridState(thirdPanelId, new Rect(0.5, height, 0.5, 1 - height)) + ), + new[] + { + CreateState( + isHorizontal: false, + new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, height)), + new PanelGridState(newPanelId, new Rect(0, height, 0.5, 1 - height)), + new PanelGridState(secondPanelId, new Rect(0.5, 0, 0.5, height)), + new PanelGridState(thirdPanelId, new Rect(0.5, height, 0.5, 1 - height)) + ), + CreateState( + isHorizontal: false, + new PanelGridState(newPanelId, new Rect(0, 0, 0.5, height)), + new PanelGridState(firstPanelId, new Rect(0, height, 0.5, 1 - height)), + new PanelGridState(secondPanelId, new Rect(0.5, 0, 0.5, height)), + new PanelGridState(thirdPanelId, new Rect(0.5, height, 0.5, 1 - height)) + ), + } + }; + } + + // Input: four panels + // Possible States: none + yield return new object[] + { + "vertical | four panels", + CreateState( + isHorizontal: false, + new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 0.5)), + new PanelGridState(secondPanelId, new Rect(0, 0.5, 0.5, 0.5)), + new PanelGridState(thirdPanelId, new Rect(0.5, 0, 0.5, 0.5)), + new PanelGridState(fourthPanelId, new Rect(0.5, 0.5, 0.5, 0.5)) + ), + Array.Empty() + }; + } + + public static IEnumerable TestData_GetPossibleStates_Horizontal_Generated() + { + var newPanelId = PanelId.DefaultValue; + var firstPanelId = PanelId.From(Guid.Parse("11111111-1111-1111-1111-111111111111")); + var secondPanelId = PanelId.From(Guid.Parse("22222222-2222-2222-2222-222222222222")); + var thirdPanelId = PanelId.From(Guid.Parse("33333333-3333-3333-3333-333333333333")); + var fourthPanelId = PanelId.From(Guid.Parse("44444444-4444-4444-4444-444444444444")); + + const double step = 0.1; + const double min = 0.2; + const double max = 1.0 - min; + + // Input: one panel + // Possible States: + // 1) split horizontally, new panel is in the second row + // 2) split horizontally, new panel is in the first row + // 3) split vertically, new panel is in the second column + // 4) split vertically, new panel is in the first column + yield return new object[] + { + "horizontal | one panel", + CreateState( + isHorizontal: true, + new PanelGridState(firstPanelId, MathUtils.One) + ), + new[] + { + CreateState( + isHorizontal: true, + new PanelGridState(firstPanelId, new Rect(0, 0, 1, 0.5)), + new PanelGridState(newPanelId, new Rect(0, 0.5, 1, 0.5)) + ), + CreateState( + isHorizontal: true, + new PanelGridState(newPanelId, new Rect(0, 0, 1, 0.5)), + new PanelGridState(firstPanelId, new Rect(0, 0.5, 1, 0.5)) ), CreateState( isHorizontal: true, new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 1)), - new PanelGridState(newPanelId, new Rect(0.5, 0, 0.5, 0.5)), - new PanelGridState(secondPanelId, new Rect(0.5, 0.5, 0.5, 0.5)) + new PanelGridState(newPanelId, new Rect(0.5, 0, 0.5, 1)) + ), + CreateState( + isHorizontal: true, + new PanelGridState(newPanelId, new Rect(0, 0, 0.5, 1)), + new PanelGridState(firstPanelId, new Rect(0.5, 0, 0.5, 1)) ), } }; + // Input: two columns + // | 1 | 2 | + // | 1 | 2 | + // Possible States: + // 1) split the first panel horizontally, the new panel is in the second row + // 2) split the first panel horizontally, the new panel is in the first row + // 3) split the second panel horizontally, the new panel is in the second row + // 4) split the second panel horizontally, the new panel is in the first row + // TODO: 5) split both the first and second panel horizontally, the new panel will take up the entirety of the second row + // TODO: 6) split both the first and second panel horizontally, the new panel will take up the entirety of the first row + for (var width = min; width < max; width += step) + { + yield return new object[] + { + $"horizontal | two columns | width: {width}", + CreateState( + isHorizontal: true, + new PanelGridState(firstPanelId, new Rect(0, 0, width, 1)), + new PanelGridState(secondPanelId, new Rect(width, 0, 1 - width, 1)) + ), + new[] + { + CreateState( + isHorizontal: true, + new PanelGridState(firstPanelId, new Rect(0, 0, width, 0.5)), + new PanelGridState(newPanelId, new Rect(0, 0.5, width, 0.5)), + new PanelGridState(secondPanelId, new Rect(width, 0, 1 - width, 1)) + ), + CreateState( + isHorizontal: true, + new PanelGridState(newPanelId, new Rect(0, 0, width, 0.5)), + new PanelGridState(firstPanelId, new Rect(0, 0.5, width, 0.5)), + new PanelGridState(secondPanelId, new Rect(width, 0, 1 - width, 1)) + ), + CreateState( + isHorizontal: true, + new PanelGridState(firstPanelId, new Rect(0, 0, width, 1)), + new PanelGridState(secondPanelId, new Rect(width, 0, 1 - width, 0.5)), + new PanelGridState(newPanelId, new Rect(width, 0.5, 1 - width, 0.5)) + ), + CreateState( + isHorizontal: true, + new PanelGridState(firstPanelId, new Rect(0, 0, width, 1)), + new PanelGridState(newPanelId, new Rect(width, 0, 1 - width, 0.5)), + new PanelGridState(secondPanelId, new Rect(width, 0.5, 1 - width, 0.5)) + ), + } + }; + } + // Input: two rows // | 1 | 1 | // | 2 | 2 | @@ -157,6 +396,7 @@ public static IEnumerable TestData_GetPossibleStates_Generated() // TODO: 6) split both the first and second panel vertically, the new panel will take up the entirety of the first column yield return new object[] { + "horizontal | two rows", CreateState( isHorizontal: true, new PanelGridState(firstPanelId, new Rect(0, 0, 1, 0.5)), @@ -197,32 +437,36 @@ public static IEnumerable TestData_GetPossibleStates_Generated() // Possible States: // 1) split the first panel vertically, the new panel is in the second column // 2) split the first panel vertically, the new panel is in the first column - yield return new object[] + for (var width = min; width < max; width += step) { - CreateState( - isHorizontal: true, - new PanelGridState(firstPanelId, new Rect(0, 0, 1, 0.5)), - new PanelGridState(secondPanelId, new Rect(0, 0.5, 0.5, 0.5)), - new PanelGridState(thirdPanelId, new Rect(0.5, 0.5, 0.5, 0.5)) - ), - new[] + yield return new object[] { + $"horizontal | three panels with one large row | width: {width}", CreateState( isHorizontal: true, - new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 0.5)), - new PanelGridState(newPanelId, new Rect(0.5, 0, 0.5, 0.5)), - new PanelGridState(secondPanelId, new Rect(0, 0.5, 0.5, 0.5)), - new PanelGridState(thirdPanelId, new Rect(0.5, 0.5, 0.5, 0.5)) + new PanelGridState(firstPanelId, new Rect(0, 0, 1, 0.5)), + new PanelGridState(secondPanelId, new Rect(0, 0.5, width, 0.5)), + new PanelGridState(thirdPanelId, new Rect(width, 0.5, 1 - width, 0.5)) ), - CreateState( - isHorizontal: true, - new PanelGridState(newPanelId, new Rect(0, 0, 0.5, 0.5)), - new PanelGridState(firstPanelId, new Rect(0.5, 0, 0.5, 0.5)), - new PanelGridState(secondPanelId, new Rect(0, 0.5, 0.5, 0.5)), - new PanelGridState(thirdPanelId, new Rect(0.5, 0.5, 0.5, 0.5)) - ) - } - }; + new[] + { + CreateState( + isHorizontal: true, + new PanelGridState(firstPanelId, new Rect(0, 0, width, 0.5)), + new PanelGridState(newPanelId, new Rect(width, 0, 1 - width, 0.5)), + new PanelGridState(secondPanelId, new Rect(0, 0.5, width, 0.5)), + new PanelGridState(thirdPanelId, new Rect(width, 0.5, 1 - width, 0.5)) + ), + CreateState( + isHorizontal: true, + new PanelGridState(newPanelId, new Rect(0, 0, width, 0.5)), + new PanelGridState(firstPanelId, new Rect(width, 0, 1 - width, 0.5)), + new PanelGridState(secondPanelId, new Rect(0, 0.5, width, 0.5)), + new PanelGridState(thirdPanelId, new Rect(width, 0.5, 1 - width, 0.5)) + ) + } + }; + } // Input: three panels with one large column // | 1 | 2 | @@ -232,6 +476,7 @@ public static IEnumerable TestData_GetPossibleStates_Generated() // 2) split the first panel horizontally, the new panel is in the first row yield return new object[] { + "horizontal | three panels with one large column", CreateState( isHorizontal: true, new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 1)), @@ -261,6 +506,7 @@ public static IEnumerable TestData_GetPossibleStates_Generated() // Possible States: none yield return new object[] { + "horizontal | four panels", CreateState( isHorizontal: true, new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 0.5)), diff --git a/tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests.cs b/tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests.cs new file mode 100644 index 0000000000..c198525a0f --- /dev/null +++ b/tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests.cs @@ -0,0 +1,11 @@ +using NexusMods.App.UI.WorkspaceSystem; + +namespace NexusMods.UI.Tests.WorkspaceSystem; + +public partial class WorkspaceGridStateTests +{ + private static WorkspaceGridState CreateState(bool isHorizontal, params PanelGridState[] panels) + { + return WorkspaceGridState.From(panels, isHorizontal); + } +} diff --git a/tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests/ColumnEnumeratorTests.cs b/tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests/ColumnEnumeratorTests.cs new file mode 100644 index 0000000000..17b318a4a0 --- /dev/null +++ b/tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests/ColumnEnumeratorTests.cs @@ -0,0 +1,82 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia; +using FluentAssertions; +using NexusMods.App.UI.WorkspaceSystem; + +namespace NexusMods.UI.Tests.WorkspaceSystem; + +public partial class WorkspaceGridStateTests +{ + [Theory] + [MemberData(nameof(TestData_ColumnEnumerator))] + public void Test_ColumnEnumerator( + WorkspaceGridState currentState, + (WorkspaceGridState.ColumnInfo Info, PanelGridState[] Rows)[] expectedOutputs) + { + GridUtils.IsPerfectGrid(currentState).Should().BeTrue(); + + Span seenColumns = stackalloc WorkspaceGridState.ColumnInfo[8]; + using var columnEnumerator = new WorkspaceGridState.ColumnEnumerator(currentState, seenColumns); + + var actualOutputs = new List<(WorkspaceGridState.ColumnInfo Info, PanelGridState[] Rows)>(); + + Span rowBuffer = stackalloc PanelGridState[2]; + while (columnEnumerator.MoveNext(rowBuffer)) + { + var current = columnEnumerator.Current; + actualOutputs.Add((current.Info, current.Rows.ToArray())); + } + + actualOutputs.Should().HaveCount(expectedOutputs.Length); + for (var i = 0; i < actualOutputs.Count; i++) + { + var actualOutput = actualOutputs[i]; + var expectedOutput = expectedOutputs[i]; + + actualOutput.Info.Should().Be(expectedOutput.Info); + actualOutput.Rows.Should().Equal(expectedOutput.Rows); + } + + currentState.CountColumns().Should().Be(expectedOutputs.Length); + } + + [SuppressMessage("ReSharper", "HeapView.ObjectAllocation.Evident")] + [SuppressMessage("ReSharper", "HeapView.ObjectAllocation")] + [SuppressMessage("ReSharper", "HeapView.BoxingAllocation")] + public static IEnumerable TestData_ColumnEnumerator() + { + var firstPanelId = PanelId.From(Guid.Parse("11111111-1111-1111-1111-111111111111")); + var secondPanelId = PanelId.From(Guid.Parse("22222222-2222-2222-2222-222222222222")); + var thirdPanelId = PanelId.From(Guid.Parse("33333333-3333-3333-3333-333333333333")); + var fourthPanelId = PanelId.From(Guid.Parse("44444444-4444-4444-4444-444444444444")); + + yield return new object[] + { + CreateState( + isHorizontal: true, + new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 0.5)), + new PanelGridState(secondPanelId, new Rect(0.5, 0, 0.5, 0.5)), + new PanelGridState(thirdPanelId, new Rect(0, 0.5, 1, 0.5)) + ), + new[] + { + ( + Info: new WorkspaceGridState.ColumnInfo(0, 0.5), + Rows: new[] + { + new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 0.5)), + new PanelGridState(thirdPanelId, new Rect(0, 0.5, 1, 0.5)) + } + ), + ( + Info: new WorkspaceGridState.ColumnInfo(0.5, 0.5), + Rows: new[] + { + new PanelGridState(secondPanelId, new Rect(0.5, 0, 0.5, 0.5)), + new PanelGridState(thirdPanelId, new Rect(0, 0.5, 1, 0.5)) + } + ), + } + }; + } +} diff --git a/tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests/RowEnumeratorTests.cs b/tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests/RowEnumeratorTests.cs new file mode 100644 index 0000000000..b937ee4c41 --- /dev/null +++ b/tests/NexusMods.UI.Tests/WorkspaceSystem/WorkspaceGridStateTests/RowEnumeratorTests.cs @@ -0,0 +1,82 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia; +using FluentAssertions; +using NexusMods.App.UI.WorkspaceSystem; + +namespace NexusMods.UI.Tests.WorkspaceSystem; + +public partial class WorkspaceGridStateTests +{ + [Theory] + [MemberData(nameof(TestData_RowEnumerator))] + public void Test_RowEnumerator( + WorkspaceGridState currentState, + (WorkspaceGridState.RowInfo Info, PanelGridState[] Columns)[] expectedOutputs) + { + GridUtils.IsPerfectGrid(currentState).Should().BeTrue(); + + Span seenRows = stackalloc WorkspaceGridState.RowInfo[8]; + using var rowEnumerator = new WorkspaceGridState.RowEnumerator(currentState, seenRows); + + var actualOutputs = new List<(WorkspaceGridState.RowInfo Info, PanelGridState[] Columns)>(); + + Span columnBuffer = stackalloc PanelGridState[2]; + while (rowEnumerator.MoveNext(columnBuffer)) + { + var current = rowEnumerator.Current; + actualOutputs.Add((current.Info, current.Columns.ToArray())); + } + + actualOutputs.Should().HaveCount(expectedOutputs.Length); + for (var i = 0; i < actualOutputs.Count; i++) + { + var actualOutput = actualOutputs[i]; + var expectedOutput = expectedOutputs[i]; + + actualOutput.Info.Should().Be(expectedOutput.Info); + actualOutput.Columns.Should().Equal(expectedOutput.Columns); + } + + currentState.CountRows().Should().Be(expectedOutputs.Length); + } + + [SuppressMessage("ReSharper", "HeapView.ObjectAllocation.Evident")] + [SuppressMessage("ReSharper", "HeapView.ObjectAllocation")] + [SuppressMessage("ReSharper", "HeapView.BoxingAllocation")] + public static IEnumerable TestData_RowEnumerator() + { + var firstPanelId = PanelId.From(Guid.Parse("11111111-1111-1111-1111-111111111111")); + var secondPanelId = PanelId.From(Guid.Parse("22222222-2222-2222-2222-222222222222")); + var thirdPanelId = PanelId.From(Guid.Parse("33333333-3333-3333-3333-333333333333")); + var fourthPanelId = PanelId.From(Guid.Parse("44444444-4444-4444-4444-444444444444")); + + yield return new object[] + { + CreateState( + isHorizontal: true, + new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 0.5)), + new PanelGridState(secondPanelId, new Rect(0, 0.5, 0.5, 0.5)), + new PanelGridState(thirdPanelId, new Rect(0.5, 0, 0.5, 1)) + ), + new[] + { + ( + Info: new WorkspaceGridState.RowInfo(0, 0.5), + Columns: new[] + { + new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 0.5)), + new PanelGridState(thirdPanelId, new Rect(0.5, 0, 0.5, 1)) + } + ), + ( + Info: new WorkspaceGridState.RowInfo(0.5, 0.5), + Columns: new[] + { + new PanelGridState(secondPanelId, new Rect(0, 0.5, 0.5, 0.5)), + new PanelGridState(thirdPanelId, new Rect(0.5, 0, 0.5, 1)) + } + ), + } + }; + } +}