Skip to content

Commit

Permalink
Merge pull request #809 from erri120/workspace-cleanup
Browse files Browse the repository at this point in the history
Workspace Testing rework
Al12rs authored Dec 7, 2023

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
2 parents c6c90cc + e098c00 commit 17bf70e
Showing 8 changed files with 986 additions and 363 deletions.
123 changes: 72 additions & 51 deletions src/NexusMods.App.UI/WorkspaceSystem/GridUtils.cs
Original file line number Diff line number Diff line change
@@ -6,6 +6,46 @@ namespace NexusMods.App.UI.WorkspaceSystem;

internal static class GridUtils
{
/// <summary>
/// Checks whether the given state is a perfect grid.
/// </summary>
/// <remarks>
/// A perfect has no gaps, and no panel is out-of-bounds.
/// </remarks>
/// <exception cref="Exception">Thrown when the grid is not perfect.</exception>
internal static bool IsPerfectGrid(WorkspaceGridState gridState)
{
var totalArea = 0.0;

foreach (var panelState in gridState)
{
var (id, rect) = panelState;
if (rect.Left < 0.0 || rect.Right > 1.0 || rect.Top < 0.0 || rect.Bottom > 1.0)
{
throw new Exception($"Panel {panelState} is out of bounds");
}

totalArea += rect.Height * rect.Width;

foreach (var other in gridState)
{
if (id == other.Id) continue;

if (rect.Intersects(other.Rect))
{
throw new Exception($"{panelState.ToString()} intersects with {other.ToString()}");
}
}
}

if (!totalArea.IsCloseTo(1.0))
{
throw new Exception($"Area of {totalArea} doesn't match 1.0");
}

return true;
}

/// <summary>
/// Returns all possible new states.
/// </summary>
@@ -146,60 +186,39 @@ private static bool CanAddRow(
return currentRows < maxRows;
}

internal static IReadOnlyDictionary<PanelId, Rect> GetStateWithoutPanel(
ImmutableDictionary<PanelId, Rect> currentState,
PanelId panelToRemove,
bool isHorizontal = true)
internal static WorkspaceGridState GetStateWithoutPanel(
WorkspaceGridState gridState,
PanelId panelToRemove)
{
if (currentState.Count == 1) return ImmutableDictionary<PanelId, Rect>.Empty;
if (gridState.Count == 1) return WorkspaceGridState.Empty(gridState.IsHorizontal);

var res = currentState.Remove(panelToRemove);
if (res.Count == 1)
{
return new Dictionary<PanelId, Rect>
{
{ res.First().Key, MathUtils.One }
};
}
var res = gridState.Remove(gridState[panelToRemove]);
if (res.Count == 1) return WorkspaceGridState.Empty(gridState.IsHorizontal).Add(new PanelGridState(res[0].Id, MathUtils.One));

var currentRect = currentState[panelToRemove];
var panelState = gridState[panelToRemove];
var currentRect = panelState.Rect;

Span<PanelId> sameColumn = stackalloc PanelId[currentState.Count];
Span<PanelId> sameColumn = stackalloc PanelId[gridState.Count];
var sameColumnCount = 0;

Span<PanelId> sameRow = stackalloc PanelId[currentState.Count];
Span<PanelId> sameRow = stackalloc PanelId[gridState.Count];
var sameRowCount = 0;

foreach (var kv in res)
using (var enumerator = res.EnumerateAdjacentPanels(panelState, includeAnchor: true))
{
var (id, rect) = kv;

// same column
// | a | x | | b | x |
// | b | x | | a | x |
if (rect.Left.IsGreaterThanOrCloseTo(currentRect.Left) && rect.Right.IsLessThanOrCloseTo(currentRect.Right))
{
if (rect.Top.IsCloseTo(currentRect.Bottom) || rect.Bottom.IsCloseTo(currentRect.Top))
{
sameColumn[sameColumnCount++] = id;
}
}

// same row
// | a | b | | b | a | | a | b |
// | x | x | | x | x | | a | c |
if (rect.Top.IsGreaterThanOrCloseTo(currentRect.Top) && rect.Bottom.IsLessThanOrCloseTo(currentRect.Bottom))
while (enumerator.MoveNext())
{
if (rect.Left.IsCloseTo(currentRect.Right) || rect.Right.IsCloseTo(currentRect.Left))
{
sameRow[sameRowCount++] = id;
}
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;
}
}

Debug.Assert(sameColumnCount > 0 || sameRowCount > 0);

if (isHorizontal)
if (gridState.IsHorizontal)
{
// prefer columns over rows when horizontal
if (sameColumnCount > 0)
@@ -225,54 +244,56 @@ internal static IReadOnlyDictionary<PanelId, Rect> GetStateWithoutPanel(
return res;
}

private static ImmutableDictionary<PanelId, Rect> JoinSameColumn(
ImmutableDictionary<PanelId, Rect> res,
private static WorkspaceGridState JoinSameColumn(
WorkspaceGridState res,
Rect currentRect,
Span<PanelId> sameColumn,
int sameColumnCount)
{
var updates = GC.AllocateUninitializedArray<KeyValuePair<PanelId, Rect>>(sameColumnCount);
var updates = GC.AllocateUninitializedArray<PanelGridState>(sameColumnCount);

for (var i = 0; i < sameColumnCount; i++)
{
var id = sameColumn[i];
var rect = res[id];
var panel = res[id];
var rect = panel.Rect;

var x = rect.X;
var width = rect.Width;

var y = Math.Min(rect.Y, currentRect.Y);
var height = rect.Height + currentRect.Height;

updates[i] = new KeyValuePair<PanelId, Rect>(id, new Rect(x, y, width, height));
updates[i] = new PanelGridState(id, new Rect(x, y, width, height));
}

return res.SetItems(updates);
return res.UnionById(updates);
}

private static ImmutableDictionary<PanelId, Rect> JoinSameRow(
ImmutableDictionary<PanelId, Rect> res,
private static WorkspaceGridState JoinSameRow(
WorkspaceGridState res,
Rect currentRect,
Span<PanelId> sameRow,
int sameRowCount)
{
var updates = GC.AllocateUninitializedArray<KeyValuePair<PanelId, Rect>>(sameRowCount);
var updates = GC.AllocateUninitializedArray<PanelGridState>(sameRowCount);

for (var i = 0; i < sameRowCount; i++)
{
var id = sameRow[i];
var rect = res[id];
var panel = res[id];
var rect = panel.Rect;

var y = rect.Y;
var height = rect.Height;

var x = Math.Min(rect.X, currentRect.X);
var width = rect.Width + currentRect.Width;

updates[i] = new KeyValuePair<PanelId, Rect>(id, new Rect(x, y, width, height));
updates[i] = new PanelGridState(id, new Rect(x, y, width, height));
}

return res.SetItems(updates);
return res.UnionById(updates);
}

internal static IReadOnlyList<ResizerInfo> GetResizers(
24 changes: 24 additions & 0 deletions src/NexusMods.App.UI/WorkspaceSystem/PanelGridState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Avalonia;

namespace NexusMods.App.UI.WorkspaceSystem;

public record struct PanelGridState(PanelId Id, Rect Rect);

public class PanelGridStateComparer : IComparer<PanelGridState>
{
public static readonly IComparer<PanelGridState> Instance = new PanelGridStateComparer();

public int Compare(PanelGridState x, PanelGridState y)
{
var a = x.Rect;
var b = y.Rect;

var xComparison = a.X.CompareTo(b.X);
if (xComparison != 0) return xComparison;

var yComparison = a.Y.CompareTo(b.Y);
if (yComparison != 0) return yComparison;

return x.Id.CompareTo(y.Id);
}
}
Original file line number Diff line number Diff line change
@@ -294,16 +294,16 @@ public IPanelViewModel AddPanel(IReadOnlyDictionary<PanelId, Rect> state)

public void ClosePanel(PanelId panelToClose)
{
var currentState = _panels.ToImmutableDictionary(panel => panel.Id, panel => panel.LogicalBounds);
var newState = GridUtils.GetStateWithoutPanel(currentState, panelToClose, isHorizontal: IsHorizontal);
var currentState = WorkspaceGridState.From(_panels, IsHorizontal);
var newState = GridUtils.GetStateWithoutPanel(currentState, panelToClose);

_panelSource.Edit(updater =>
{
updater.Remove(panelToClose);

foreach (var kv in newState)
foreach (var panelState in newState)
{
var (panelId, logicalBounds) = kv;
var (panelId, logicalBounds) = panelState;
{
var existingPanel = updater.Lookup(panelId);
Debug.Assert(existingPanel.HasValue);
217 changes: 217 additions & 0 deletions src/NexusMods.App.UI/WorkspaceSystem/WorkspaceGridState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
using System.Collections;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Avalonia;

namespace NexusMods.App.UI.WorkspaceSystem;

public readonly struct WorkspaceGridState :
IImmutableSet<PanelGridState>,
IReadOnlyList<PanelGridState>
{
public readonly ImmutableSortedSet<PanelGridState> Inner;
public readonly bool IsHorizontal;

public WorkspaceGridState(ImmutableSortedSet<PanelGridState> inner, bool isHorizontal)
{
Inner = inner.WithComparer(PanelGridStateComparer.Instance);
IsHorizontal = isHorizontal;
}

public static WorkspaceGridState From(IEnumerable<KeyValuePair<PanelId, Rect>> values, bool isHorizontal)
{
return new WorkspaceGridState(
inner: values.Select(kv => new PanelGridState(kv.Key, kv.Value)).ToImmutableSortedSet(PanelGridStateComparer.Instance),
isHorizontal
);
}

public static WorkspaceGridState From(IEnumerable<IPanelViewModel> panels, bool isHorizontal)
{
return new WorkspaceGridState(
inner: panels.Select(panel => new PanelGridState(panel.Id, panel.LogicalBounds)).ToImmutableSortedSet(PanelGridStateComparer.Instance),
isHorizontal
);
}

public static WorkspaceGridState From(IEnumerable<PanelGridState> panels, bool isHorizontal)
{
return new WorkspaceGridState(
inner: panels.ToImmutableSortedSet(PanelGridStateComparer.Instance),
isHorizontal
);
}

public static WorkspaceGridState Empty(bool isHorizontal) => new(ImmutableSortedSet<PanelGridState>.Empty, isHorizontal);

private WorkspaceGridState WithInner(ImmutableSortedSet<PanelGridState> inner)
{
return new WorkspaceGridState(inner, IsHorizontal);
}

[SuppressMessage("ReSharper", "ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator")]
public PanelGridState this[PanelId id]
{
get
{
foreach (var panel in Inner)
{
if (panel.Id == id) return panel;
}

throw new KeyNotFoundException();
}
}

[SuppressMessage("ReSharper", "ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator")]
public bool TryGetValue(PanelId id, out PanelGridState panel)
{
foreach (var item in Inner)
{
if (item.Id != id) continue;
panel = item;
return true;
}

panel = default;
return false;
}

[SuppressMessage("ReSharper", "ParameterTypeCanBeEnumerable.Global")]
public WorkspaceGridState UnionById(PanelGridState[] other)
{
var builder = Inner.ToBuilder();
foreach (var panelToAdd in other)
{
if (TryGetValue(panelToAdd.Id, out var existingPanel))
{
builder.Remove(existingPanel);
}

builder.Add(panelToAdd);
}

return WithInner(builder.ToImmutable());
}

public AdjacentPanelEnumerator EnumerateAdjacentPanels(PanelGridState anchor, bool includeAnchor) => new(this, anchor, includeAnchor);

[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<AdjacentPanel>
{
private ImmutableSortedSet<PanelGridState>.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 AdjacentPanel Current { get; private set; }
object IEnumerator.Current => Current;

public bool MoveNext()
{
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;
}
}

public void Reset() => _enumerator.Reset();
public void Dispose()
{
_enumerator.Dispose();
}
}

#region Interface Implementations

public ImmutableSortedSet<PanelGridState>.Enumerator GetEnumerator() => Inner.GetEnumerator();
IEnumerator<PanelGridState> IEnumerable<PanelGridState>.GetEnumerator() => Inner.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => Inner.GetEnumerator();

public int Count => Inner.Count;
public bool Contains(PanelGridState value) => Inner.Contains(value);

public bool IsProperSubsetOf(IEnumerable<PanelGridState> other) => Inner.IsProperSubsetOf(other);
public bool IsProperSupersetOf(IEnumerable<PanelGridState> other) => Inner.IsProperSubsetOf(other);
public bool IsSubsetOf(IEnumerable<PanelGridState> other) => Inner.IsSubsetOf(other);
public bool IsSupersetOf(IEnumerable<PanelGridState> other) => Inner.IsSupersetOf(other);
public bool Overlaps(IEnumerable<PanelGridState> other) => Inner.Overlaps(other);
public bool SetEquals(IEnumerable<PanelGridState> other) => Inner.SetEquals(other);

IImmutableSet<PanelGridState> IImmutableSet<PanelGridState>.Add(PanelGridState value) => Inner.Add(value);
public WorkspaceGridState Add(PanelGridState value) => WithInner(Inner.Add(value));

IImmutableSet<PanelGridState> IImmutableSet<PanelGridState>.Clear() => Inner.Clear();
public WorkspaceGridState Clear() => WithInner(Inner.Clear());

IImmutableSet<PanelGridState> IImmutableSet<PanelGridState>.Except(IEnumerable<PanelGridState> other) => Inner.Except(other);
public WorkspaceGridState Except(IEnumerable<PanelGridState> other) => WithInner(Inner.Except(other));

IImmutableSet<PanelGridState> IImmutableSet<PanelGridState>.Intersect(IEnumerable<PanelGridState> other) => Inner.Intersect(other);
public WorkspaceGridState Intersect(IEnumerable<PanelGridState> other) => WithInner(Inner.Intersect(other));

IImmutableSet<PanelGridState> IImmutableSet<PanelGridState>.Remove(PanelGridState value) => Inner.Remove(value);
public WorkspaceGridState Remove(PanelGridState value) => WithInner(Inner.Remove(value));

IImmutableSet<PanelGridState> IImmutableSet<PanelGridState>.SymmetricExcept(IEnumerable<PanelGridState> other) => Inner.SymmetricExcept(other);
public WorkspaceGridState SymmetricExcept(IEnumerable<PanelGridState> other) => WithInner(Inner.SymmetricExcept(other));

bool IImmutableSet<PanelGridState>.TryGetValue(PanelGridState equalValue, out PanelGridState actualValue) => Inner.TryGetValue(equalValue, out actualValue);

IImmutableSet<PanelGridState> IImmutableSet<PanelGridState>.Union(IEnumerable<PanelGridState> other) => Inner.Union(other);
public WorkspaceGridState Union(IEnumerable<PanelGridState> other) => WithInner(Inner.Union(other));

public int IndexOf(PanelGridState item) => Inner.IndexOf(item);

public PanelGridState this[int index] => Inner[index];

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=workspacesystem_005Cgridutilstests/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
314 changes: 6 additions & 308 deletions tests/NexusMods.UI.Tests/WorkspaceSystem/GridUtilsTests.cs
Original file line number Diff line number Diff line change
@@ -10,315 +10,8 @@ namespace NexusMods.UI.Tests.WorkspaceSystem;

[UsedImplicitly]
[SuppressMessage("ReSharper", "HeapView.BoxingAllocation")]
public class GridUtilsTests
public partial class GridUtilsTests
{
[Fact]
public static void Test_GetPossibleStates()
{
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 int columns = 2;
const int rows = 2;
var panels = new List<IPanelViewModel>(capacity: columns * rows);

var res = GridUtils.GetPossibleStates(ToInput(), columns, rows).ToArray();
res.Should().BeEmpty();

// NOTE(erri120): Start with a single "main" panel that takes up the entire space.
var firstPanel = CreatePanel(firstPanelId, new Rect(0, 0, 1, 1));
panels.Add(firstPanel);

// NOTE(erri120): Testing with a single panel in the grid. There are four possible
// new grid states in this scenario since the single panel can be split vertically
// or horizontally and the new panel can be placed below/right or above/left of the
// main panel (1 = existing, 2 = new):
// | 1 | 2 | | 2 | 1 | | 1 | 1 | | 2 | 2 |
// | 1 | 2 | | 2 | 1 | | 2 | 2 | | 1 | 1 |

res = GridUtils.GetPossibleStates(ToInput(), columns, rows).ToArray();
res.Should().HaveCount(4).And.SatisfyRespectively(dict =>
{
dict.Should().HaveCount(2).And.Equal(new KeyValuePair<PanelId, Rect>[]
{
new(firstPanel.Id, new Rect(0, 0, 0.5, 1)),
new(PanelId.DefaultValue, new Rect(0.5, 0, 0.5, 1)),
});
}, dict => {
dict.Should().HaveCount(2).And.Equal(new KeyValuePair<PanelId, Rect>[]
{
new(firstPanel.Id, new Rect(0.5, 0, 0.5, 1)),
new(PanelId.DefaultValue, new Rect(0, 0, 0.5, 1)),
});
}, dict =>
{
dict.Should().HaveCount(2).And.Equal(new KeyValuePair<PanelId, Rect>[]
{
new(firstPanel.Id, new Rect(0, 0, 1, 0.5)),
new(PanelId.DefaultValue, new Rect(0, 0.5, 1, 0.5)),
});
}, dict => {
dict.Should().HaveCount(2).And.Equal(new KeyValuePair<PanelId, Rect>[]
{
new(firstPanel.Id, new Rect(0, 0.5, 1, 0.5)),
new(PanelId.DefaultValue, new Rect(0, 0, 1, 0.5)),
});
});

// NOTE(erri120): Add a vertical panel that takes up half the space:
// | 1 | 2 |
// | 1 | 2 |
firstPanel.LogicalBounds = new Rect(0, 0, 0.5, 1);
var secondPanel = CreatePanel(secondPanelId, new Rect(0.5, 0, 0.5, 1));
panels.Add(secondPanel);

// NOTE(erri120): A third panel can now only appear in the corners, which is why
// we have 4 possible states (1 = first panel, 2 = second panel, 3 = new panel):
// | 1 | 2 | | 3 | 2 | | 1 | 2 | | 1 | 3 |
// | 3 | 2 | | 1 | 2 | | 1 | 3 | | 1 | 2 |
res = GridUtils.GetPossibleStates(ToInput(), columns, rows).ToArray();
res.Should().HaveCount(4).And.SatisfyRespectively(dict =>
{
dict.Should().HaveCount(3).And.Equal(new KeyValuePair<PanelId, Rect>[]
{
new(firstPanel.Id, new Rect(0, 0, 0.5, 0.5)),
new(PanelId.DefaultValue, new Rect(0, 0.5, 0.5, 0.5)),
new(secondPanel.Id, new Rect(0.5, 0, 0.5, 1)),
});
}, dict =>
{
dict.Should().HaveCount(3).And.Equal(new KeyValuePair<PanelId, Rect>[]
{
new(PanelId.DefaultValue, new Rect(0, 0, 0.5, 0.5)),
new(firstPanel.Id, new Rect(0, 0.5, 0.5, 0.5)),
new(secondPanel.Id, new Rect(0.5, 0, 0.5, 1)),
});
}, dict =>
{
dict.Should().HaveCount(3).And.Equal(new KeyValuePair<PanelId, Rect>[]
{
new(firstPanel.Id, new Rect(0, 0, 0.5, 1)),
new(secondPanel.Id, new Rect(0.5, 0, 0.5, 0.5)),
new(PanelId.DefaultValue, new Rect(0.5, 0.5, 0.5, 0.5)),
});
}, dict =>
{
dict.Should().HaveCount(3).And.Equal(new KeyValuePair<PanelId, Rect>[]
{
new(firstPanel.Id, new Rect(0, 0, 0.5, 1)),
new(secondPanel.Id, new Rect(0.5, 0.5, 0.5, 0.5)),
new(PanelId.DefaultValue, new Rect(0.5, 0, 0.5, 0.5)),
});
});

// NOTE(erri120): add the third panel in the bottom left corner
// | 1 | 2 |
// | 3 | 2 |
firstPanel.LogicalBounds = new Rect(0, 0, 0.5, 0.5);
var thirdPanel = CreatePanel(thirdPanelId, new Rect(0, 0.5, 0.5, 0.5));
panels.Add(thirdPanel);

// NOTE(erri120): The final fourth panel can now only appear in one of the corners
// in the right column (1 = first panel, 2 = second panel, 3 = third panel, 4 = new panel):
// | 1 | 2 | | 1 | 4 |
// | 3 | 4 | | 3 | 2 |
res = GridUtils.GetPossibleStates(ToInput(), columns, rows).ToArray();
res.Should().HaveCount(2).And.SatisfyRespectively(dict =>
{
dict.Should().HaveCount(4).And.Equal(new KeyValuePair<PanelId, Rect>[]
{
new(firstPanel.Id, new Rect(0, 0, 0.5, 0.5)),
new(secondPanel.Id, new Rect(0.5, 0, 0.5, 0.5)),
new(thirdPanel.Id, new Rect(0, 0.5, 0.5, 0.5)),
new(PanelId.DefaultValue, new Rect(0.5, 0.5, 0.5, 0.5)),
});
}, dict =>
{
dict.Should().HaveCount(4).And.Equal(new KeyValuePair<PanelId, Rect>[]
{
new(firstPanel.Id, new Rect(0, 0, 0.5, 0.5)),
new(secondPanel.Id, new Rect(0.5, 0.5, 0.5, 0.5)),
new(thirdPanel.Id, new Rect(0, 0.5, 0.5, 0.5)),
new(PanelId.DefaultValue, new Rect(0.5, 0, 0.5, 0.5)),
});
});

// NOTE(erri120): add the fourth panel in the bottom right corner
// | 1 | 2 |
// | 3 | 4 |
secondPanel.LogicalBounds = new Rect(0, 0, 0.5, 0.5);
var fourthPanel = CreatePanel(fourthPanelId, new Rect(0.5, 0.5, 0.5, 0.5));
panels.Add(fourthPanel);

// NOTE(erri120): final state reached, no more panels possible.
res = GridUtils.GetPossibleStates(ToInput(), columns, rows).ToArray();
res.Should().BeEmpty();
return;

ImmutableDictionary<PanelId, Rect> ToInput()
{
return panels.ToImmutableDictionary(x => x.Id, x => x.LogicalBounds);
}
}

[Fact]
public void Test_GetStateWithoutPanel()
{
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 int columns = 2;
const int rows = 2;

// NOTE(erri120): two columns
// | a | b |
// | a | b |
var state = new List<IPanelViewModel>(capacity: columns * rows)
{
CreatePanel(firstPanelId, new Rect(0, 0, 0.5, 1)),
CreatePanel(secondPanelId, new Rect(0.5, 0, 0.5, 1))
}.ToImmutableDictionary(panel => panel.Id, panel => panel.LogicalBounds);

// NOTE(erri120): remove the column on the right, the left column should take up the entire space
var res = GridUtils.GetStateWithoutPanel(state, secondPanelId);
res.Should().ContainSingle().Which.Should().Be(new KeyValuePair<PanelId, Rect>(firstPanelId, new Rect(0, 0, 1, 1)));

// NOTE(erri120): remove the column on the left, the right column should take up the entire space
res = GridUtils.GetStateWithoutPanel(state, firstPanelId);
res.Should().ContainSingle().Which.Should().Be(new KeyValuePair<PanelId, Rect>(secondPanelId, new Rect(0, 0, 1, 1)));



// NOTE(erri120): two rows
// | a | a |
// | b | b |
state = new List<IPanelViewModel>(capacity: columns * rows)
{
CreatePanel(firstPanelId, new Rect(0, 0, 1, 0.5)),
CreatePanel(secondPanelId, new Rect(0, 0.5, 1, 0.5))
}.ToImmutableDictionary(panel => panel.Id, panel => panel.LogicalBounds);

// NOTE(erri120): remove the row at the bottom, the top row should take up the entire space
res = GridUtils.GetStateWithoutPanel(state, secondPanelId);
res.Should().ContainSingle().Which.Should().Be(new KeyValuePair<PanelId, Rect>(firstPanelId, new Rect(0, 0, 1, 1)));

// NOTE(erri120): remove the row at the top, the bottom row should take up the entire space
res = GridUtils.GetStateWithoutPanel(state, firstPanelId);
res.Should().ContainSingle().Which.Should().Be(new KeyValuePair<PanelId, Rect>(secondPanelId, new Rect(0, 0, 1, 1)));



// NOTE(erri120):
// | a | b |
// | a | c |
state = new List<IPanelViewModel>(capacity: columns * rows)
{
CreatePanel(firstPanelId, new Rect(0, 0, 0.5, 1)),
CreatePanel(secondPanelId, new Rect(0.5, 0, 0.5, 0.5)),
CreatePanel(thirdPanelId, new Rect(0.5, 0.5, 0.5, 0.5))
}.ToImmutableDictionary(panel => panel.Id, panel => panel.LogicalBounds);

// NOTE(erri120): remove the panel in the bottom right corner, the second panel should take up the space:
// | a | b |
// | a | b |
res = GridUtils.GetStateWithoutPanel(state, thirdPanelId);
res.Should().HaveCount(2).And.SatisfyRespectively(kv =>
{
kv.Key.Should().Be(firstPanelId);
kv.Value.Should().Be(new Rect(0, 0, 0.5, 1));
}, kv =>
{
kv.Key.Should().Be(secondPanelId);
kv.Value.Should().Be(new Rect(0.5, 0, 0.5, 1));
});

// NOTE(erri120): remove the panel in the top right corner, the third panel should take up the space:
// | a | c |
// | a | c |
res = GridUtils.GetStateWithoutPanel(state, secondPanelId);
res.Should().HaveCount(2).And.SatisfyRespectively(kv =>
{
kv.Key.Should().Be(firstPanelId);
kv.Value.Should().Be(new Rect(0, 0, 0.5, 1));
}, kv =>
{
kv.Key.Should().Be(thirdPanelId);
kv.Value.Should().Be(new Rect(0.5, 0, 0.5, 1));
});

// NOTE(erri120): remove the column on the left, both panels on the right should expand:
// | b | b |
// | c | c |
res = GridUtils.GetStateWithoutPanel(state, firstPanelId);
res.Should().HaveCount(2).And.SatisfyRespectively(kv =>
{
kv.Key.Should().Be(secondPanelId);
kv.Value.Should().Be(new Rect(0, 0, 1, 0.5));
}, kv =>
{
kv.Key.Should().Be(thirdPanelId);
kv.Value.Should().Be(new Rect(0, 0.5, 1, 0.5));
});



// NOTE(erri120):
// | a | a |
// | b | c |
state = new List<IPanelViewModel>(capacity: columns * rows)
{
CreatePanel(firstPanelId, new Rect(0, 0, 1, 0.5)),
CreatePanel(secondPanelId, new Rect(0, 0.5, 0.5, 0.5)),
CreatePanel(thirdPanelId, new Rect(0.5, 0.5, 0.5, 0.5))
}.ToImmutableDictionary(panel => panel.Id, panel => panel.LogicalBounds);

// NOTE(erri120): remove the panel in the bottom right corner, the second panel should take up the space:
// | a | a |
// | b | b |
res = GridUtils.GetStateWithoutPanel(state, thirdPanelId);
res.Should().HaveCount(2).And.SatisfyRespectively(kv =>
{
kv.Key.Should().Be(firstPanelId);
kv.Value.Should().Be(new Rect(0, 0, 1, 0.5));
}, kv =>
{
kv.Key.Should().Be(secondPanelId);
kv.Value.Should().Be(new Rect(0, 0.5, 1, 0.5));
});

// NOTE(erri120): remove the panel in the bottom left corner, the third panel should take up the space:
// | a | a |
// | c | c |
res = GridUtils.GetStateWithoutPanel(state, secondPanelId);
res.Should().HaveCount(2).And.SatisfyRespectively(kv =>
{
kv.Key.Should().Be(firstPanelId);
kv.Value.Should().Be(new Rect(0, 0, 1, 0.5));
}, kv =>
{
kv.Key.Should().Be(thirdPanelId);
kv.Value.Should().Be(new Rect(0, 0.5, 1, 0.5));
});

// NOTE(erri120): remove the top row, the remaining panels should take up the space
// | b | c |
// | b | c |
res = GridUtils.GetStateWithoutPanel(state, firstPanelId);
res.Should().HaveCount(2).And.SatisfyRespectively(kv =>
{
kv.Key.Should().Be(secondPanelId);
kv.Value.Should().Be(new Rect(0, 0, 0.5, 1));
}, kv =>
{
kv.Key.Should().Be(thirdPanelId);
kv.Value.Should().Be(new Rect(0.5, 0, 0.5, 1));
});
}

[Fact]
public void Test_GetResizers()
{
@@ -521,4 +214,9 @@ private static IPanelViewModel CreatePanel(PanelId panelId, Rect logicalBounds)
panel.Id.Returns(panelId);
return panel;
}

private static WorkspaceGridState CreateState(bool isHorizontal, params PanelGridState[] panels)
{
return WorkspaceGridState.From(panels, isHorizontal);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using FluentAssertions;
using NexusMods.App.UI.WorkspaceSystem;

namespace NexusMods.UI.Tests.WorkspaceSystem;

[SuppressMessage("ReSharper", "HeapView.BoxingAllocation")]
[SuppressMessage("ReSharper", "HeapView.ObjectAllocation.Evident")]
[SuppressMessage("ReSharper", "HeapView.ObjectAllocation")]
public partial class GridUtilsTests
{
[Theory]
[MemberData(nameof(TestData_GetPossibleStates_Generated))]
public void Test_GetPossibleStates(
WorkspaceGridState currentState,
WorkspaceGridState[] expectedOutputs)
{
GridUtils.IsPerfectGrid(currentState).Should().BeTrue();
if (expectedOutputs.Length != 0)
{
expectedOutputs.Should().AllSatisfy(expectedOutput =>
{
expectedOutput.IsHorizontal.Should().Be(currentState.IsHorizontal);
GridUtils.IsPerfectGrid(expectedOutput).Should().BeTrue();
});
}

var actualOutputs = GridUtils.GetPossibleStates(
currentState.Inner.ToImmutableDictionary(x => x.Id, x => x.Rect),
columns: 2,
rows: 2
).ToArray();

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)
{
convertedOutputs.Should().AllSatisfy(output =>
{
GridUtils.IsPerfectGrid(output).Should().BeTrue();
});
}

convertedOutputs.Should().HaveCount(expectedOutputs.Length);
for (var i = 0; i < convertedOutputs.Length; i++)
{
convertedOutputs[i].Should().Equal(expectedOutputs[i]);
}
}

public static IEnumerable<object[]> TestData_GetPossibleStates_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"));

// TODO: test with variable sized panels

// Input: one panel
// Possible States:
// 1) split vertically, new panel is in the second column
// 2) split vertically, new panel is in the first column
// 3) split horizontally, new panel is in the second row
// 4) split horizontally, new panel is in the first row
yield return new object[]
{
CreateState(
isHorizontal: true,
new PanelGridState(firstPanelId, MathUtils.One)
),
new[]
{
CreateState(
isHorizontal: true,
new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 1)),
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))
),
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))
),
}
};

// 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
yield return new object[]
{
CreateState(
isHorizontal: true,
new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 1)),
new PanelGridState(secondPanelId, new Rect(0.5, 0, 0.5, 1))
),
new[]
{
CreateState(
isHorizontal: true,
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))
),
CreateState(
isHorizontal: true,
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))
),
CreateState(
isHorizontal: true,
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))
),
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))
),
}
};

// 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
yield return new object[]
{
CreateState(
isHorizontal: true,
new PanelGridState(firstPanelId, new Rect(0, 0, 1, 0.5)),
new PanelGridState(secondPanelId, new Rect(0, 0.5, 1, 0.5))
),
new[]
{
CreateState(
isHorizontal: true,
new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 0.5)),
new PanelGridState(secondPanelId, new Rect(0, 0.5, 1, 0.5)),
new PanelGridState(newPanelId, new Rect(0.5, 0, 0.5, 0.5))
),
CreateState(
isHorizontal: true,
new PanelGridState(newPanelId, new Rect(0, 0, 0.5, 0.5)),
new PanelGridState(secondPanelId, new Rect(0, 0.5, 1, 0.5)),
new PanelGridState(firstPanelId, new Rect(0.5, 0, 0.5, 0.5))
),
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(newPanelId, new Rect(0.5, 0.5, 0.5, 0.5))
),
CreateState(
isHorizontal: true,
new PanelGridState(firstPanelId, new Rect(0, 0, 1, 0.5)),
new PanelGridState(newPanelId, new Rect(0, 0.5, 0.5, 0.5)),
new PanelGridState(secondPanelId, new Rect(0.5, 0.5, 0.5, 0.5))
),
}
};

// 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[]
{
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[]
{
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))
),
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))
)
}
};

// 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
yield return new object[]
{
CreateState(
isHorizontal: true,
new PanelGridState(firstPanelId, new Rect(0, 0, 0.5, 1)),
new PanelGridState(secondPanelId, new Rect(0.5, 0, 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, 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, 0.5)),
new PanelGridState(thirdPanelId, new Rect(0.5, 0.5, 0.5, 0.5))
),
CreateState(
isHorizontal: true,
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, 0.5)),
new PanelGridState(thirdPanelId, new Rect(0.5, 0.5, 0.5, 0.5))
),
}
};

// Input: four panels
// Possible States: none
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, 0.5)),
new PanelGridState(fourthPanelId, new Rect(0.5, 0.5, 0.5, 0.5))
),
Array.Empty<WorkspaceGridState>()
};
}
}

Large diffs are not rendered by default.

0 comments on commit 17bf70e

Please sign in to comment.