diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98d63b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.vs/ +obj/ +bin/ \ No newline at end of file diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..cd4e9a5 --- /dev/null +++ b/License.txt @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2024 Melissa Geels +Copyright (c) 2016 Andrew Richards +Copyright (c) 2014 Leonard Ritter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/Nimble.Layout.Benchmark/Nimble.Layout.Benchmark.csproj b/Nimble.Layout.Benchmark/Nimble.Layout.Benchmark.csproj new file mode 100644 index 0000000..79675c3 --- /dev/null +++ b/Nimble.Layout.Benchmark/Nimble.Layout.Benchmark.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/Nimble.Layout.Benchmark/Program.cs b/Nimble.Layout.Benchmark/Program.cs new file mode 100644 index 0000000..e3f9434 --- /dev/null +++ b/Nimble.Layout.Benchmark/Program.cs @@ -0,0 +1,120 @@ +namespace Nimble.Layout.Benchmark +{ + internal class Program + { + static void Main(string[] args) + { + Console.WriteLine("Running..."); + + const int numRuns = 100_000; + + double totalTime = 0; + + for (int i = 0; i < numRuns; i++) { + var start = DateTime.Now; + var root = ConstructNestedBoxes(); + for (int j = 0; j < 5; j++) { + root.Run(); + } + totalTime += (DateTime.Now - start).TotalMilliseconds; + } + + Console.WriteLine($"Total time: {totalTime}"); + Console.WriteLine($" Avg time: {totalTime / numRuns}"); + Console.ReadKey(); + } + + static LayoutItem ConstructNestedBoxes() + { + const int numRows = 5; + // One of the rows is "fake" and will have 0 units tall height + const int numRowsWithHeight = numRows - 1; + + var root = new LayoutItem { + Size = new(70, numRowsWithHeight * 10 + 2 * 10), + }; + + var mainChild = new LayoutItem { + Margins = new(10), + Contain = ContainFlags.Column, + Behave = BehaveFlags.Fill, + }; + root.AddChild(mainChild); + + var rows = new LayoutItem[numRows]; + + // Auto-filling columns-in-row, each one should end up being 10 units wide + rows[0] = new LayoutItem { + Contain = ContainFlags.Row, + Behave = BehaveFlags.Fill, + }; + var cols1 = new LayoutItem[5]; + for (int i = 0; i < cols1.Length; i++) { + cols1[i] = new LayoutItem { + Behave = BehaveFlags.Fill, + }; + } + rows[0].AddChildren(cols1); + + rows[1] = new LayoutItem { + Contain = ContainFlags.Row, + Behave = BehaveFlags.VFill, + }; + var cols2 = new LayoutItem[5]; + for (int i = 0; i < cols2.Length; i++) { + // Fixed-size horizontally, fill vertically + cols2[i] = new LayoutItem { + Size = new(10, 0), + Behave = BehaveFlags.VFill, + }; + } + rows[1].AddChildren(cols2); + + // These columns have an inner item which sizes them + rows[2] = new LayoutItem { + Contain = ContainFlags.Row, + }; + var cols3 = new LayoutItem[2]; + for (int i = 0; i < cols3.Length; i++) { + var col = new LayoutItem { + Behave = BehaveFlags.Bottom, + }; + var innerSize = new LayoutItem { + Size = new(25, 10 * i), + }; + col.AddChild(innerSize); + cols3[i] = col; + } + rows[2].AddChildren(cols3); + + // Row 4 should end up being 0 units tall after layout + rows[3] = new LayoutItem { + Contain = ContainFlags.Row, + Behave = BehaveFlags.HFill, + }; + var cols4 = new LayoutItem[99]; + for (int i = 0; i < cols4.Length; i++) { + cols4[i] = new LayoutItem(); + } + rows[3].AddChildren(cols4); + + // row 5 should be 10 pixels tall after layout, and each of its columns should be 1 pixel wide + rows[4] = new LayoutItem { + Contain = ContainFlags.Row, + Behave = BehaveFlags.Fill, + }; + var cols5 = new LayoutItem[50]; + for (int i = 0; i < cols5.Length; i++) { + cols5[i] = new LayoutItem { + Behave = BehaveFlags.Fill, + }; + } + rows[4].AddChildren(cols5); + + mainChild.AddChildren(rows); + + return root; + } + } +} + diff --git a/Nimble.Layout.Tests/Nimble.Layout.Tests.csproj b/Nimble.Layout.Tests/Nimble.Layout.Tests.csproj new file mode 100644 index 0000000..a280c6d --- /dev/null +++ b/Nimble.Layout.Tests/Nimble.Layout.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/Nimble.Layout.Tests/TestCases.cs b/Nimble.Layout.Tests/TestCases.cs new file mode 100644 index 0000000..8b9b0e0 --- /dev/null +++ b/Nimble.Layout.Tests/TestCases.cs @@ -0,0 +1,697 @@ +namespace Nimble.Layout.Tests +{ + [TestClass] + public class TestCases + { + [TestMethod] + public void SimpleFill() + { + var child = new LayoutItem { + Behave = BehaveFlags.Fill, + }; + + var root = new LayoutItem { + Size = new(30, 40), + }; + + root.AddChild(child); + root.Run(); + + Assert.AreEqual(new(0, 0, 30, 40), root.Rect); + Assert.AreEqual(new(0, 0, 30, 40), child.Rect); + Assert.AreEqual(new(30, 40), root.Size); + } + + [TestMethod] + public void MultipleUninserted() + { + var root = new LayoutItem { + Size = new(155, 177), + }; + var child1 = new LayoutItem(); + var child2 = new LayoutItem { + Size = new(1, 1), + }; + + root.Run(); + + Assert.AreEqual(new(0, 0, 155, 177), root.Rect); + Assert.AreEqual(new(), child1.Rect); // Should be 0, because it is not inserted into root + Assert.AreEqual(new(), child2.Rect); // Should be 0, because it is not inserted into root, so Rect is not calculated + } + + [TestMethod] + public void ColumnEvenFill() + { + var root = new LayoutItem { + Size = new(50, 60), + Contain = ContainFlags.Column, + }; + + var childA = new LayoutItem { Behave = BehaveFlags.Fill }; + var childB = new LayoutItem { Behave = BehaveFlags.Fill }; + var childC = new LayoutItem { Behave = BehaveFlags.Fill }; + + root.AddChildren([childA, childB, childC]); + root.Run(); + + Assert.AreEqual(new(0, 0, 50, 60), root.Rect); + Assert.AreEqual(new(0, 0, 50, 20), childA.Rect); + Assert.AreEqual(new(0, 20, 50, 20), childB.Rect); + Assert.AreEqual(new(0, 40, 50, 20), childC.Rect); + } + + [TestMethod] + public void RowEvenFill() + { + var root = new LayoutItem { + Size = new(90, 3), + Contain = ContainFlags.Row, + }; + + var childA = new LayoutItem { + Size = new(0, 1), + Behave = BehaveFlags.HFill | BehaveFlags.Top, + }; + var childB = new LayoutItem { + Size = new(0, 1), + Behave = BehaveFlags.HFill | BehaveFlags.VCenter, + }; + var childC = new LayoutItem { + Size = new(0, 1), + Behave = BehaveFlags.HFill | BehaveFlags.Bottom, + }; + + root.AddChildren([childA, childB, childC]); + root.Run(); + + Assert.AreEqual(new(0, 0, 90, 3), root.Rect); + Assert.AreEqual(new(0, 0, 30, 1), childA.Rect); + Assert.AreEqual(new(30, 1, 30, 1), childB.Rect); + Assert.AreEqual(new(60, 2, 30, 1), childC.Rect); + } + + [TestMethod] + public void FixedAndFill() + { + var root = new LayoutItem { + Size = new(50, 60), + Contain = ContainFlags.Column, + }; + + var fixedA = new LayoutItem { + Size = new(50, 15), + }; + var fixedB = new LayoutItem { + Size = new(50, 15), + }; + var filler = new LayoutItem { + Behave = BehaveFlags.Fill, + }; + + root.AddChildren([fixedA, filler, fixedB]); + root.Run(); + + Assert.AreEqual(new(0, 0, 50, 60), root.Rect); + Assert.AreEqual(new(0, 0, 50, 15), fixedA.Rect); + Assert.AreEqual(new(0, 15, 50, 30), filler.Rect); + Assert.AreEqual(new(0, 45, 50, 15), fixedB.Rect); + } + + [TestMethod] + public void SimpleMargins1() + { + var root = new LayoutItem { + Size = new(100, 90), + Contain = ContainFlags.Column, + }; + + var childA = new LayoutItem { + Size = new(0, 30 - (5 + 10)), + Margins = new(3, 5, 7, 10), + Behave = BehaveFlags.HFill, + }; + var childB = new LayoutItem { + Behave = BehaveFlags.Fill, + }; + var childC = new LayoutItem { + Size = new(0, 30), + Behave = BehaveFlags.HFill, + }; + + root.AddChildren([childA, childB, childC]); + root.Run(); + + Assert.AreEqual(3, childA.Margins.Left); + Assert.AreEqual(5, childA.Margins.Top); + Assert.AreEqual(7, childA.Margins.Right); + Assert.AreEqual(10, childA.Margins.Bottom); + + Assert.AreEqual(new(3, 5, 90, 5 + 10), childA.Rect); + Assert.AreEqual(new(0, 30, 100, 30), childB.Rect); + Assert.AreEqual(new(0, 60, 100, 30), childC.Rect); + } + + [TestMethod] + public void NestedBoxes1() + { + const int numRows = 5; + // One of the rows is "fake" and will have 0 units tall height + const int numRowsWithHeight = numRows - 1; + + var root = new LayoutItem { + Size = new(70, numRowsWithHeight * 10 + 2 * 10), + }; + + var mainChild = new LayoutItem { + Margins = new(10), + Contain = ContainFlags.Column, + Behave = BehaveFlags.Fill, + }; + root.AddChild(mainChild); + + var rows = new LayoutItem[numRows]; + + // Auto-filling columns-in-row, each one should end up being 10 units wide + rows[0] = new LayoutItem { + Contain = ContainFlags.Row, + Behave = BehaveFlags.Fill, + }; + var cols1 = new LayoutItem[5]; + for (int i = 0; i < cols1.Length; i++) { + cols1[i] = new LayoutItem { + Behave = BehaveFlags.Fill, + }; + } + rows[0].AddChildren(cols1); + + rows[1] = new LayoutItem { + Contain = ContainFlags.Row, + Behave = BehaveFlags.VFill, + }; + var cols2 = new LayoutItem[5]; + for (int i = 0; i < cols2.Length; i++) { + // Fixed-size horizontally, fill vertically + cols2[i] = new LayoutItem { + Size = new(10, 0), + Behave = BehaveFlags.VFill, + }; + } + rows[1].AddChildren(cols2); + + // These columns have an inner item which sizes them + rows[2] = new LayoutItem { + Contain = ContainFlags.Row, + }; + var cols3 = new LayoutItem[2]; + for (int i = 0; i < cols3.Length; i++) { + var col = new LayoutItem { + Behave = BehaveFlags.Bottom, + }; + var innerSize = new LayoutItem { + Size = new(25, 10 * i), + }; + col.AddChild(innerSize); + cols3[i] = col; + } + rows[2].AddChildren(cols3); + + // Row 4 should end up being 0 units tall after layout + rows[3] = new LayoutItem { + Contain = ContainFlags.Row, + Behave = BehaveFlags.HFill, + }; + var cols4 = new LayoutItem[99]; + for (int i = 0; i < cols4.Length; i++) { + cols4[i] = new LayoutItem(); + } + rows[3].AddChildren(cols4); + + // row 5 should be 10 pixels tall after layout, and each of its columns should be 1 pixel wide + rows[4] = new LayoutItem { + Contain = ContainFlags.Row, + Behave = BehaveFlags.Fill, + }; + var cols5 = new LayoutItem[50]; + for (int i = 0; i < cols5.Length; i++) { + cols5[i] = new LayoutItem { + Behave = BehaveFlags.Fill, + }; + } + rows[4].AddChildren(cols5); + + mainChild.AddChildren(rows); + + // Repeat the run and tests multiple times to make sure we get the expected + // results each time. The original version of oui would overwrite its input + // state (intentionally) with the output state, so the context's input data + // (margins, size) had to be "rebuilt" by the client code by doing a reset + // and then filling it back up for each run. 'lay' does not have that + // restriction. + // + // This is one of the more complex tests, so it's a good + // choice for testing multiple runs of the same context. + for (int run = 0; run < 5; run++) { + root.Run(); + + Assert.AreEqual(new(10, 10, 50, 40), mainChild.Rect); + // These rows should all be 10 units in height + Assert.AreEqual(new(10, 10, 50, 10), rows[0].Rect); + Assert.AreEqual(new(10, 20, 50, 10), rows[1].Rect); + Assert.AreEqual(new(10, 30, 50, 10), rows[2].Rect); + // This row should have 0 height + Assert.AreEqual(new(10, 40, 50, 0), rows[3].Rect); + Assert.AreEqual(new(10, 40, 50, 10), rows[4].Rect); + + // Each of these should be 10 units wide, and stacked horizontally + Assert.AreEqual(5, cols1.Length); + for (int i = 0; i < cols1.Length; i++) { + Assert.AreEqual(new(10 + 10 * i, 10, 10, 10), cols1[i].Rect); + } + + // The cols in the second row are similar to first row + Assert.AreEqual(5, cols2.Length); + for (int i = 0; i < cols2.Length; i++) { + Assert.AreEqual(new(10 + 10 * i, 20, 10, 10), cols2[i].Rect); + } + + // Leftmost (first of two items), aligned to bottom of row, 0 units tall + Assert.AreEqual(new(10, 40, 25, 0), cols3[0].Rect); + // Rightmost (second of two items), same height as row, which is 10 units tall + Assert.AreEqual(new(35, 30, 25, 10), cols3[1].Rect); + + // These should all have size 0 and be in the middle of the row + Assert.AreEqual(99, cols4.Length); + for (int i = 0; i < cols4.Length; i++) { + Assert.AreEqual(new(25 + 10, 40, 0, 0), cols4[i].Rect); + } + + // These should all be 1 unit wide and 10 units tall + Assert.AreEqual(50, cols5.Length); + for (int i = 0; i < cols5.Length; i++) { + Assert.AreEqual(new(10 + i, 40, 1, 10), cols5[i].Rect); + } + } + } + + [TestMethod] + public void DeepNest1() + { + const int numItems = 500; + + var root = new LayoutItem(); + var parent = root; + for (int i = 0; i < numItems; i++) { + var item = new LayoutItem(); + parent.AddChild(item); + parent = item; + } + + parent.Size = new(77, 99); + root.Run(); + + Assert.AreEqual(new(0, 0, 77, 99), root.Rect); + } + + private static IEnumerable ManyChildrenGenerate(int num) + { + for (int i = 0; i < num; i++) { + yield return new() { + Size = new(1, 1), + }; + } + } + + [TestMethod] + public void ManyChildren1() + { + const int numItems = 20000; + + var root = new LayoutItem { + Size = new(1, 0), + Contain = ContainFlags.Column, + }; + + root.AddChildren(ManyChildrenGenerate(numItems)); + root.Run(); + + Assert.AreEqual(new(0, 0, 1, numItems), root.Rect); + } + + [TestMethod] + public void ChildAlign1() + { + var root = new LayoutItem { + Size = new(50, 50), + }; + + var alignedBoxes = new LayoutItem[9]; + + root.AddChild(alignedBoxes[0] = new() { Size = new(10, 10), Behave = BehaveFlags.Top | BehaveFlags.Left }); + root.AddChild(alignedBoxes[1] = new() { Size = new(10, 10), Behave = BehaveFlags.Top | BehaveFlags.Right }); + root.AddChild(alignedBoxes[2] = new() { Size = new(10, 10), Behave = BehaveFlags.Top | BehaveFlags.HCenter }); + + root.AddChild(alignedBoxes[3] = new() { Size = new(10, 10), Behave = BehaveFlags.VCenter | BehaveFlags.Left }); + root.AddChild(alignedBoxes[4] = new() { Size = new(10, 10), Behave = BehaveFlags.VCenter | BehaveFlags.Right }); + root.AddChild(alignedBoxes[5] = new() { Size = new(10, 10), Behave = BehaveFlags.VCenter | BehaveFlags.HCenter }); + + root.AddChild(alignedBoxes[6] = new() { Size = new(10, 10), Behave = BehaveFlags.Bottom | BehaveFlags.Left }); + root.AddChild(alignedBoxes[7] = new() { Size = new(10, 10), Behave = BehaveFlags.Bottom | BehaveFlags.Right }); + root.AddChild(alignedBoxes[8] = new() { Size = new(10, 10), Behave = BehaveFlags.Bottom | BehaveFlags.HCenter }); + + root.Run(); + + Assert.AreEqual(new(0, 0, 10, 10), alignedBoxes[0].Rect); + Assert.AreEqual(new(40, 0, 10, 10), alignedBoxes[1].Rect); + Assert.AreEqual(new(20, 0, 10, 10), alignedBoxes[2].Rect); + + Assert.AreEqual(new(0, 20, 10, 10), alignedBoxes[3].Rect); + Assert.AreEqual(new(40, 20, 10, 10), alignedBoxes[4].Rect); + Assert.AreEqual(new(20, 20, 10, 10), alignedBoxes[5].Rect); + + Assert.AreEqual(new(0, 40, 10, 10), alignedBoxes[6].Rect); + Assert.AreEqual(new(40, 40, 10, 10), alignedBoxes[7].Rect); + Assert.AreEqual(new(20, 40, 10, 10), alignedBoxes[8].Rect); + } + + [TestMethod] + public void ChildAlign2() + { + var root = new LayoutItem { + Size = new(50, 50), + }; + + var alignedBoxes = new LayoutItem[6]; + + root.AddChild(alignedBoxes[0] = new() { Size = new(10, 10), Behave = BehaveFlags.Top | BehaveFlags.HFill }); + root.AddChild(alignedBoxes[1] = new() { Size = new(10, 10), Behave = BehaveFlags.VCenter | BehaveFlags.HFill }); + root.AddChild(alignedBoxes[2] = new() { Size = new(10, 10), Behave = BehaveFlags.Bottom | BehaveFlags.HFill }); + + root.AddChild(alignedBoxes[3] = new() { Size = new(10, 10), Behave = BehaveFlags.VFill | BehaveFlags.Left }); + root.AddChild(alignedBoxes[4] = new() { Size = new(10, 10), Behave = BehaveFlags.VFill | BehaveFlags.Right }); + root.AddChild(alignedBoxes[5] = new() { Size = new(10, 10), Behave = BehaveFlags.VFill | BehaveFlags.HCenter }); + + root.Run(); + + Assert.AreEqual(new(0, 0, 50, 10), alignedBoxes[0].Rect); + Assert.AreEqual(new(0, 20, 50, 10), alignedBoxes[1].Rect); + Assert.AreEqual(new(0, 40, 50, 10), alignedBoxes[2].Rect); + + Assert.AreEqual(new(0, 0, 10, 50), alignedBoxes[3].Rect); + Assert.AreEqual(new(40, 0, 10, 50), alignedBoxes[4].Rect); + Assert.AreEqual(new(20, 0, 10, 50), alignedBoxes[5].Rect); + } + + [TestMethod] + public void WrapRow1() + { + var root = new LayoutItem { + Size = new(50, 50), + Contain = ContainFlags.Row | ContainFlags.Wrap, + }; + + // We will create a 5x5 grid of boxes that are 10x10 units per each box. + // There should be no empty space, gaps, or extra wrapping. + + var items = new LayoutItem[5 * 5]; + for (int i = 0; i < items.Length; i++) { + items[i] = new LayoutItem { + Size = new(10, 10), + }; + } + root.AddChildren(items); + root.Run(); + + for (int i = 0; i < items.Length; i++) { + int x = i % 5; + int y = i / 5; + Assert.AreEqual(new(x * 10, y * 10, 10, 10), items[i].Rect); + } + } + + [TestMethod] + public void WrapRow2() + { + var root = new LayoutItem { + Size = new(57, 57), + Contain = ContainFlags.Row | ContainFlags.Wrap, + Align = AlignFlags.AlignStart, + }; + + // This one should ahve extra space on the right edge and bottom (7 units) + + var items = new LayoutItem[5 * 5]; + for (int i = 0; i < items.Length; i++) { + items[i] = new LayoutItem { + Size = new(10, 10), + }; + } + root.AddChildren(items); + root.Run(); + + for (int i = 0; i < items.Length; i++) { + int x = i % 5; + int y = i / 5; + Assert.AreEqual(new(x * 10, y * 10, 10, 10), items[i].Rect); + } + } + + [TestMethod] + public void WrapRow3() + { + var root = new LayoutItem { + Size = new(57, 57), + Contain = ContainFlags.Row | ContainFlags.Wrap, + Align = AlignFlags.AlignEnd, + }; + + // This one should have extra space on the left edge and bottom (7 units) + + var items = new LayoutItem[5 * 5]; + for (int i = 0; i < items.Length; i++) { + items[i] = new LayoutItem { + Size = new(10, 10), + }; + } + root.AddChildren(items); + root.Run(); + + for (int i = 0; i < items.Length; i++) { + int x = i % 5; + int y = i / 5; + Assert.AreEqual(new(7 + x * 10, y * 10, 10, 10), items[i].Rect); + } + } + + [TestMethod] + public void WrapRow4() + { + var root = new LayoutItem { + Size = new(58, 57), + Contain = ContainFlags.Row | ContainFlags.Wrap, + Align = AlignFlags.AlignMiddle, + }; + + root.AddChild(new LayoutItem { + Size = new(58, 7), + }); + + // This one should split the horizontal extra space between the left and + // right, and have the vertical extra space at the top (via extra inserted + // spacer item, with explicit size) + + var items = new LayoutItem[5 * 5]; + for (int i = 0; i < items.Length; i++) { + items[i] = new LayoutItem { + Size = new(10, 10), + }; + } + root.AddChildren(items); + root.Run(); + + for (int i = 0; i < items.Length; i++) { + int x = i % 5; + int y = i / 5; + Assert.AreEqual(new(4 + x * 10, 7 + y * 10, 10, 10), items[i].Rect); + } + } + + [TestMethod] + public void WrapRow5() + { + var root = new LayoutItem { + Size = new(54, 50), + Contain = ContainFlags.Row | ContainFlags.Wrap, + Align = AlignFlags.AlignJustify, + }; + + var items = new LayoutItem[5 * 5]; + for (int i = 0; i < items.Length; i++) { + items[i] = new LayoutItem { + Size = new(10, 10), + }; + } + root.AddChildren(items); + root.Run(); + + // Note that we are ignoring the last line here, as it will behave like AlignFlags.AlignStart. + // Typically justifying does not need to be done here (it will behave like AlignFlags.AlignStart instead). + // The author of layout.h calls this a bug, but we deem this intentional behavior. + for (int i = 0; i < items.Length - 5; i++) { + int x = i % 5; + int y = i / 5; + Assert.AreEqual(new(x * 11, y * 10, 10, 10), items[i].Rect); + } + } + + [TestMethod] + public void WrapColumn1() + { + var root = new LayoutItem { + Size = new(50, 50), + Contain = ContainFlags.Column | ContainFlags.Wrap, + }; + + // We will create a 5x5 grid of boxes that are 10x10 units per each box. + // There should be no empty space, gaps, or extra wrapping. + + var items = new LayoutItem[5 * 5]; + for (int i = 0; i < items.Length; i++) { + items[i] = new LayoutItem { + Size = new(10, 10), + }; + } + root.AddChildren(items); + root.Run(); + + for (int i = 0; i < items.Length; i++) { + int y = i % 5; + int x = i / 5; + Assert.AreEqual(new(x * 10, y * 10, 10, 10), items[i].Rect); + } + } + + [TestMethod] + public void WrapColumn2() + { + var root = new LayoutItem { + Size = new(57, 57), + Contain = ContainFlags.Column | ContainFlags.Wrap, + Align = AlignFlags.AlignStart, + }; + + // This one should have extra space on the right and bottom (7 units) + + var items = new LayoutItem[5 * 5]; + for (int i = 0; i < items.Length; i++) { + items[i] = new LayoutItem { + Size = new(10, 10), + }; + } + root.AddChildren(items); + root.Run(); + + for (int i = 0; i < items.Length; i++) { + int y = i % 5; + int x = i / 5; + Assert.AreEqual(new(x * 10, y * 10, 10, 10), items[i].Rect); + } + } + + [TestMethod] + public void WrapColumn3() + { + var root = new LayoutItem { + Size = new(57, 57), + Contain = ContainFlags.Column | ContainFlags.Wrap, + Align = AlignFlags.AlignEnd, + }; + + // This one should have extra space on the top and right (7 units) + + var items = new LayoutItem[5 * 5]; + for (int i = 0; i < items.Length; i++) { + items[i] = new LayoutItem { + Size = new(10, 10), + }; + } + root.AddChildren(items); + root.Run(); + + for (int i = 0; i < items.Length; i++) { + int y = i % 5; + int x = i / 5; + Assert.AreEqual(new(x * 10, 7 + y * 10, 10, 10), items[i].Rect); + } + } + + [TestMethod] + public void WrapColumn4() + { + var root = new LayoutItem { + Size = new(57, 58), + Contain = ContainFlags.Column | ContainFlags.Wrap, + Align = AlignFlags.AlignMiddle, + }; + + root.AddChild(new LayoutItem { + Size = new(7, 58), + }); + + // Just like wrap_row_4, but as columns instead of rows + + var items = new LayoutItem[5 * 5]; + for (int i = 0; i < items.Length; i++) { + items[i] = new LayoutItem { + Size = new(10, 10), + }; + } + root.AddChildren(items); + root.Run(); + + for (int i = 0; i < items.Length; i++) { + int y = i % 5; + int x = i / 5; + Assert.AreEqual(new(7 + x * 10, 4 + y * 10, 10, 10), items[i].Rect); + } + } + + [TestMethod] + public void AnchorRightMargin1() + { + var root = new LayoutItem { + Size = new(100, 100), + }; + + var child = new LayoutItem { + Size = new(50, 50), + Margins = new(5, 5, 0, 0), + Behave = BehaveFlags.Bottom | BehaveFlags.Right, + }; + + root.AddChild(child); + root.Run(); + + Assert.AreEqual(new(50, 50, 50, 50), child.Rect); + } + + [TestMethod] + public void AnchorRightMargin2() + { + var root = new LayoutItem { + Size = new(100, 100), + }; + + var child = new LayoutItem { + Size = new(50, 50), + Margins = new(5, 5, 10, 10), + Behave = BehaveFlags.Bottom | BehaveFlags.Right, + }; + + root.AddChild(child); + root.Run(); + + Assert.AreEqual(new(40, 40, 50, 50), child.Rect); + } + } +} diff --git a/Nimble.Layout.sln b/Nimble.Layout.sln new file mode 100644 index 0000000..4945697 --- /dev/null +++ b/Nimble.Layout.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35122.118 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nimble.Layout", "Nimble.Layout\Nimble.Layout.csproj", "{ED2E4471-32B4-44BD-8595-408366B60914}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nimble.Layout.Tests", "Nimble.Layout.Tests\Nimble.Layout.Tests.csproj", "{019F8503-F1CE-4761-9634-6B91FEFFC663}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimble.Layout.Benchmark", "Nimble.Layout.Benchmark\Nimble.Layout.Benchmark.csproj", "{53C07FD1-82AF-4D8F-9B51-D951DA03BB0B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {ED2E4471-32B4-44BD-8595-408366B60914}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED2E4471-32B4-44BD-8595-408366B60914}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED2E4471-32B4-44BD-8595-408366B60914}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED2E4471-32B4-44BD-8595-408366B60914}.Release|Any CPU.Build.0 = Release|Any CPU + {019F8503-F1CE-4761-9634-6B91FEFFC663}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {019F8503-F1CE-4761-9634-6B91FEFFC663}.Debug|Any CPU.Build.0 = Debug|Any CPU + {019F8503-F1CE-4761-9634-6B91FEFFC663}.Release|Any CPU.ActiveCfg = Release|Any CPU + {019F8503-F1CE-4761-9634-6B91FEFFC663}.Release|Any CPU.Build.0 = Release|Any CPU + {53C07FD1-82AF-4D8F-9B51-D951DA03BB0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53C07FD1-82AF-4D8F-9B51-D951DA03BB0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53C07FD1-82AF-4D8F-9B51-D951DA03BB0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53C07FD1-82AF-4D8F-9B51-D951DA03BB0B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {708E1E4E-941E-45D0-9D3C-03408306DEEA} + EndGlobalSection +EndGlobal diff --git a/Nimble.Layout/AlignFlags.cs b/Nimble.Layout/AlignFlags.cs new file mode 100644 index 0000000..a9d1419 --- /dev/null +++ b/Nimble.Layout/AlignFlags.cs @@ -0,0 +1,16 @@ +namespace Nimble.Layout +{ + [Flags] + public enum AlignFlags : uint // aka: box flags + { + // justify-content (start, end, center, space-between) + // at start of row/column + AlignStart = 0x008, + // at center of row/column + AlignMiddle = 0x000, + // at end of row/column + AlignEnd = 0x010, + // insert spacing to stretch across whole row/column + AlignJustify = 0x018, + } +} diff --git a/Nimble.Layout/BehaveFlags.cs b/Nimble.Layout/BehaveFlags.cs new file mode 100644 index 0000000..a93ff8c --- /dev/null +++ b/Nimble.Layout/BehaveFlags.cs @@ -0,0 +1,39 @@ +namespace Nimble.Layout +{ + [Flags] + public enum BehaveFlags : uint // aka: layout flags + { + // attachments (bit 5-8) + // fully valid when parent uses LAY_LAYOUT model + // partially valid when in LAY_FLEX model + + // anchor to left item or left side of parent + Left = 0x020, + // anchor to top item or top side of parent + Top = 0x040, + // anchor to right item or right side of parent + Right = 0x080, + // anchor to bottom item or bottom side of parent + Bottom = 0x100, + // anchor to both left and right item or parent borders + HFill = 0x0a0, + // anchor to both top and bottom item or parent borders + VFill = 0x140, + // center horizontally, with left margin as offset + HCenter = 0x000, + // center vertically, with top margin as offset + VCenter = 0x000, + // center in both directions, with left/top margin as offset + Center = 0x000, + // anchor to all four directions + Fill = 0x1e0, + + // When in a wrapping container, put this element on a new line. Wrapping + // layout code auto-inserts LAY_BREAK flags as needed. See GitHub issues for + // TODO related to this. + // + // Drawing routines can read this via item pointers as needed after + // performing layout calculations. + Break = 0x200, + } +} diff --git a/Nimble.Layout/ContainFlags.cs b/Nimble.Layout/ContainFlags.cs new file mode 100644 index 0000000..6c7ed94 --- /dev/null +++ b/Nimble.Layout/ContainFlags.cs @@ -0,0 +1,38 @@ +namespace Nimble.Layout +{ + [Flags] + public enum ContainFlags : uint // aka: box flags + { + // flex-direction (bit 0+1) + + // left to right + Row = 0x002, + // top to bottom + Column = 0x003, + + // model (bit 1) + + // free layout + Layout = 0x000, + // flex model + Flex = 0x002, + + // flex-wrap (bit 2) + + // single-line + NoWrap = 0x000, + // multi-line, wrap left to right + Wrap = 0x004, + + + // align-items + // can be implemented by putting a flex container in a layout container, + // then using LAY_TOP, LAY_BOTTOM, LAY_VFILL, LAY_VCENTER, etc. + // FILL is equivalent to stretch/grow + + // align-content (start, end, center, stretch) + // can be implemented by putting a flex container in a layout container, + // then using LAY_TOP, LAY_BOTTOM, LAY_VFILL, LAY_VCENTER, etc. + // FILL is equivalent to stretch; space-between is not supported. + } +} diff --git a/Nimble.Layout/ItemFlags.cs b/Nimble.Layout/ItemFlags.cs new file mode 100644 index 0000000..184c28e --- /dev/null +++ b/Nimble.Layout/ItemFlags.cs @@ -0,0 +1,14 @@ +namespace Nimble.Layout +{ + [Flags] + public enum ItemFlags : uint + { + // item has been inserted + Inserted = 0x400, + + // horizontal size has been explicitly set + HFixed = 0x800, + // vertical size has been explicitly set + VFixed = 0x1000, + } +} diff --git a/Nimble.Layout/LayoutItem.cs b/Nimble.Layout/LayoutItem.cs new file mode 100644 index 0000000..3b0fc53 --- /dev/null +++ b/Nimble.Layout/LayoutItem.cs @@ -0,0 +1,563 @@ +using System.Diagnostics; + +namespace Nimble.Layout +{ + public class LayoutItem + { + public const uint ContainFlagsMask = 0x000007; + public const uint AlignFlagsMask = 0x000018; + public const uint BehaveFlagsMask = 0x0003E0; + public const uint ItemFlagsMask = 0x001C00; + public const uint ItemFlagsFixedMask = 0x001800; + + public uint Flags { get; internal set; } + + public LayoutItem? FirstChild { get; internal set; } + public LayoutItem? NextSibling { get; internal set; } + public LayoutMargins Margins { get; set; } = new(); + public LayoutVector Size { get; set; } = new(); + + public LayoutRect Rect => m_computedRect; + private LayoutRect m_computedRect = new(); + + public ContainFlags Contain + { + get => (ContainFlags)(Flags & ContainFlagsMask); + set => Flags = (Flags & ~ContainFlagsMask) | ((uint)value & ContainFlagsMask); + } + + public AlignFlags Align + { + get => (AlignFlags)(Flags & AlignFlagsMask); + set => Flags = (Flags & ~AlignFlagsMask) | ((uint)value & AlignFlagsMask); + } + + public BehaveFlags Behave + { + get => (BehaveFlags)(Flags & BehaveFlagsMask); + set => Flags = (Flags & ~BehaveFlagsMask) | ((uint)value & BehaveFlagsMask); + } + + public ItemFlags ItemFlags + { + get => (ItemFlags)(Flags & ItemFlagsMask); + set => Flags = (Flags & ~ItemFlagsMask) | ((uint)value & ItemFlagsMask); + } + + private ItemFlags ItemFlagsHFixed => (ItemFlags)(Flags & ItemFlagsFixedMask); + + public bool IsInserted + { + get => ItemFlags.HasFlag(ItemFlags.Inserted); + internal set { + if (value) { + ItemFlags |= ItemFlags.Inserted; + } else { + ItemFlags &= ~ItemFlags.Inserted; + } + } + } + + public bool IsItemBreak + { + get => Behave.HasFlag(BehaveFlags.Break); + set { + if (value) { + Behave |= BehaveFlags.Break; + } else { + Behave &= ~BehaveFlags.Break; + } + } + } + + public IEnumerable Children + { + get { + var child = FirstChild; + while (child != null) { + yield return child; + child = child.NextSibling; + } + } + } + + public IEnumerable Siblings + { + get { + var item = this; + while (item.NextSibling != null) { + yield return item.NextSibling; + item = item.NextSibling; + } + } + } + + public LayoutItem? LastChild => FirstChild?.LastSibling; + public LayoutItem LastSibling => Siblings.LastOrDefault(this); + + /// + /// Resets this item so it may be re-used. + /// + /// Whether to recursively reset all children as well. + public void Reset(bool withChildren = false) + { + if (withChildren) { + foreach (var child in Children) { + child.Reset(); + } + } + + Flags = 0; + FirstChild = null; + NextSibling = null; + Margins = new(); + Size = new(); + m_computedRect = new(); + } + + /// + /// Inserts an item as a sibling after another item. This allows inserting an item into the middle of an existing list of items within a + /// parent. It's also more efficient than repeatedly using in a loop to create a list of items in + /// a parent, because it does not need to traverse the parent's children each time. So if you're creating a long list of children inside + /// of a parent, you might prefer to use this after using to insert the first child. + /// + public void AddSibling(LayoutItem sibling) + { + Debug.Assert(sibling != this, "Must not be the same item"); + Debug.Assert(!sibling.IsInserted, "Must not already be inserted"); + + sibling.NextSibling = NextSibling; + sibling.IsInserted = true; + + NextSibling = sibling; + } + + /// + /// Inserts an item into this item, forming a parent - child relationship. An item can contain any number of child items. Items inserted into + /// a parent are put at the end of the ordering, after any existing siblings. + /// + public void AddChild(LayoutItem child) + { + Debug.Assert(child != this, "Must not be the same item"); + Debug.Assert(!child.IsInserted, "Must not already be inserted"); + + if (FirstChild == null) { + // We have no existing children, make inserted item the first child. + FirstChild = child; + child.IsInserted = true; + } else { + // We have existing items, iterate to find the last child and append the inserted item after it. + LastChild?.AddSibling(child); + } + } + + /// + /// Inserts multiple items into this item. Calling this is much faster than repeatedly calling , as it + /// will use on each child and avoid traversing the children each time. + /// + public void AddChildren(IEnumerable children) + { + LayoutItem? lastChild = null; + foreach (var child in children) { + if (lastChild == null) { + AddChild(child); + } else { + lastChild.AddSibling(child); + } + lastChild = child; + } + } + + /// + /// Like , but puts the new item as the first child in a parent instead of as the last. + /// + public void PushChild(LayoutItem child) + { + Debug.Assert(child != this, "Must not be same item"); + Debug.Assert(!child.IsInserted, "Must not already be inserted"); + + var oldChild = FirstChild; + FirstChild = child; + + child.IsInserted = true; + child.NextSibling = oldChild; + } + + /// + /// Run layout calculations starting from this item. + /// + /// Running the layout calculations from a specific item is useful if you want + /// to iteratively re-run parts of your layout hierarchy, or if you are only + /// interested in updating certain subsets of it. Be careful when using this -- + /// it's easy to generate bad output if the parent items haven't yet had their + /// output rectangles calculated, or if they've been invalidated (e.g. due to + /// re-allocation). + /// + public void Run() + { + CalcSize(0); + Arrange(0); + CalcSize(1); + Arrange(1); + } + + private void CalcSize(int dim) + { + foreach (var child in Children) { + // NOTE: this is recursive and will run out of stack space if items are nested too deeply. + child.CalcSize(dim); + } + + // Set the mutable rect output data to the starting input data + m_computedRect[dim] = Margins[dim]; + + // If we have an explicit input size, just set our output size (which other + // calc_size and arrange procedures will use) to it. + if (Size[dim] != 0) { + m_computedRect[2 + dim] = Size[dim]; + return; + } + + // Calculate our size based on children items. Note that we've already + // called lay_calc_size on our children at this point. + float cal_size; + switch (Contain) { + case ContainFlags.Column | ContainFlags.Wrap: + // flex model + if (dim == 1) { // direction + cal_size = CalcStackedSize(1); + } else { + cal_size = CalcOverlayedSize(0); + } + break; + + case ContainFlags.Row | ContainFlags.Wrap: + // flex model + if (dim == 0) { // direction + cal_size = CalcWrappedStackedSize(0); + } else { + cal_size = CalcWrappedOverlayedSize(1); + } + break; + + case ContainFlags.Column: + case ContainFlags.Row: + // flex model + if ((Flags & 1) == (uint)dim) { // direction + cal_size = CalcStackedSize(dim); + } else { + cal_size = CalcOverlayedSize(dim); + } + break; + + default: + // layout model + cal_size = CalcOverlayedSize(dim); + break; + } + + // Set our output data size. Will be used by parent calc_size procedures, + // and by arrange procedures. + m_computedRect[2 + dim] = cal_size; + } + + private float CalcStackedSize(int dim) + { + int wdim = dim + 2; + float need_size = 0; + foreach (var child in Children) { + need_size += child.m_computedRect[dim] + child.m_computedRect[2 + dim] + child.Margins[wdim]; + } + return need_size; + } + + private float CalcOverlayedSize(int dim) + { + int wdim = dim + 2; + float need_size = 0; + foreach (var child in Children) { + // width = start margin + calculated width + end margin + float child_size = child.m_computedRect[dim] + child.m_computedRect[2 + dim] + child.Margins[wdim]; + need_size = Math.Max(need_size, child_size); + } + return need_size; + } + + private float CalcWrappedStackedSize(int dim) + { + int wdim = dim + 2; + float need_size = 0; + float need_size2 = 0; + foreach (var child in Children) { + if (child.Behave.HasFlag(BehaveFlags.Break)) { + need_size2 = Math.Max(need_size2, need_size); + need_size = 0; + } + need_size += child.m_computedRect[dim] + child.m_computedRect[2 + dim] + child.Margins[wdim]; + } + return Math.Max(need_size2, need_size); + } + + private float CalcWrappedOverlayedSize(int dim) + { + int wdim = dim + 2; + float need_size = 0; + float need_size2 = 0; + foreach (var child in Children) { + if (child.Behave.HasFlag(BehaveFlags.Break)) { + need_size2 += need_size; + need_size = 0; + } + float child_size = child.m_computedRect[dim] + child.m_computedRect[2 + dim] + child.Margins[wdim]; + need_size = Math.Max(need_size, child_size); + } + return need_size2 + need_size; + } + + private void Arrange(int dim) + { + switch (Contain) { + case ContainFlags.Column | ContainFlags.Wrap: + if (dim != 0) { + ArrangeStacked(1, true); + float offset = ArrangeWrappedOverlaySqueezed(0); + m_computedRect[2] = offset - m_computedRect[0]; + } + break; + + case ContainFlags.Row | ContainFlags.Wrap: + if (dim == 0) { + ArrangeStacked(0, true); + } else { + // discard return value + ArrangeWrappedOverlaySqueezed(1); + } + break; + + case ContainFlags.Column: + case ContainFlags.Row: + if ((Flags & 1) == (uint)dim) { + ArrangeStacked(dim, false); + } else { + ArrangeOverlaySqueezedRange(dim, FirstChild, null, m_computedRect[dim], m_computedRect[2 + dim]); + } + break; + + default: + ArrangeOverlay(dim); + break; + } + + foreach (var child in Children) { + // NOTE: this is recursive and will run out of stack space if items are nested too deeply. + child.Arrange(dim); + } + } + + private void ArrangeStacked(int dim, bool wrap) + { + int wdim = dim + 2; + + float space = m_computedRect[2 + dim]; + float max_x2 = m_computedRect[dim] + space; + + var startChild = FirstChild; + while (startChild != null) { + float used = 0; + int count = 0; // count of fillers + int squeezed_count = 0; // count of squeezable elements + int total = 0; + bool hardbreak = false; + // first pass: count items that need to be expanded, + // and the space that is used + var child = startChild; + LayoutItem? endChild = null; + while (child != null) { + var flags = (BehaveFlags)((uint)child.Behave >> dim); + var fflags = (ItemFlags)((uint)child.ItemFlagsHFixed >> dim); + float extend = used; + if (flags.HasFlag(BehaveFlags.HFill)) { + count++; + extend += child.m_computedRect[dim] + child.Margins[wdim]; + } else { + if (!fflags.HasFlag(ItemFlags.HFixed)) { + squeezed_count++; + } + extend += child.m_computedRect[dim] + child.m_computedRect[2 + dim] + child.Margins[wdim]; + } + // wrap on end of line or manual flag + if (wrap && total > 0 && ((extend > space) || child.Behave.HasFlag(BehaveFlags.Break))) { + endChild = child; + hardbreak = child.Behave.HasFlag(BehaveFlags.Break); + // add marker for subsequent queries + child.Flags |= (uint)BehaveFlags.Break; + break; + } else { + used = extend; + child = child.NextSibling; + } + total++; + } + + float extra_space = space - used; + float filler = 0; + float spacer = 0; + float extra_margin = 0; + float eater = 0; + + if (extra_space > 0) { + if (count > 0) { + filler = extra_space / count; + } else if (total > 0) { + switch (Align) { + case AlignFlags.AlignJustify: + // justify when not wrapping or not in last line, + // or not manually breaking + if (!wrap || ((endChild != null) && !hardbreak)) { + spacer = extra_space / (total - 1); + } + break; + + case AlignFlags.AlignStart: + break; + + case AlignFlags.AlignEnd: + extra_margin = extra_space; + break; + + default: + extra_margin = extra_space / 2; + break; + } + } + } else if (!wrap && (squeezed_count > 0)) { + // In floating point, it's possible to end up with some small negative + // value for extra_space, while also have a 0.0 squeezed_count. This + // would cause divide by zero. Instead, we'll check to see if + // squeezed_count is > 0. I believe this produces the same results as + // the original oui int-only code. However, I don't have any tests for + // it, so I'll leave it if-def'd for now. + eater = extra_space / squeezed_count; + } + + // distribute width among items + float x = m_computedRect[dim]; + float x1; + // second pass: distribute and rescale + child = startChild; + while (child != endChild && child != null) { + float ix0, ix1; + var flags = (BehaveFlags)((uint)child.Behave >> dim); + var fflags = (ItemFlags)((uint)child.ItemFlagsHFixed >> dim); + + x += child.m_computedRect[dim] + extra_margin; + if (flags.HasFlag(BehaveFlags.HFill)) { // grow + x1 = x + filler; + } else if (fflags.HasFlag(ItemFlags.HFixed)) { + x1 = x + child.m_computedRect[2 + dim]; + } else { // squeeze + x1 = x + Math.Max(0.0f, child.m_computedRect[2 + dim] + eater); + } + + ix0 = x; + if (wrap) { + ix1 = Math.Min(max_x2 - child.Margins[wdim], x1); + } else { + ix1 = x1; + } + child.m_computedRect[dim] = ix0; // pos + child.m_computedRect[dim + 2] = ix1 - ix0; // size + x = x1 + child.Margins[wdim]; + child = child.NextSibling; + extra_margin = spacer; + } + + startChild = endChild; + } + } + + private void ArrangeOverlay(int dim) + { + int wdim = dim + 2; + float offset = m_computedRect[dim]; + float space = m_computedRect[2 + dim]; + + foreach (var child in Children) { + var b_flags = (BehaveFlags)((uint)child.Behave >> dim); + + switch (b_flags & BehaveFlags.HFill) { + case BehaveFlags.HCenter: + child.m_computedRect[dim] += (space - child.m_computedRect[2 + dim]) / 2 - child.Margins[wdim]; + break; + + case BehaveFlags.Right: + child.m_computedRect[dim] += space - child.m_computedRect[2 + dim] - child.Margins[dim] - child.Margins[wdim]; + break; + + case BehaveFlags.HFill: + child.m_computedRect[2 + dim] = Math.Max(0.0f, space - child.m_computedRect[dim] - child.Margins[wdim]); + break; + + default: + break; + } + + child.m_computedRect[dim] += offset; + } + } + + private float ArrangeWrappedOverlaySqueezed(int dim) + { + int wdim = dim + 2; + float offset = m_computedRect[dim]; + float need_size = 0; + var startChild = FirstChild; + foreach (var child in Children) { + if (child.Behave.HasFlag(BehaveFlags.Break)) { + ArrangeOverlaySqueezedRange(dim, startChild, child, offset, need_size); + offset += need_size; + startChild = child; + need_size = 0; + } + float child_size = child.m_computedRect[dim] + child.m_computedRect[2 + dim] + child.Margins[wdim]; + need_size = Math.Max(need_size, child_size); + } + ArrangeOverlaySqueezedRange(dim, startChild, null, offset, need_size); + offset += need_size; + return offset; + } + + private static void ArrangeOverlaySqueezedRange(int dim, LayoutItem? startItem, LayoutItem? endItem, float offset, float space) + { + int wdim = dim + 2; + var item = startItem; + while (item != endItem && item != null) { + var b_flags = (BehaveFlags)((uint)item.Behave >> dim); + + float min_size = Math.Max(0.0f, space - item.m_computedRect[dim] - item.Margins[wdim]); + switch (b_flags & BehaveFlags.HFill) { + case BehaveFlags.HCenter: + item.m_computedRect[2 + dim] = Math.Min(item.m_computedRect[2 + dim], min_size); + item.m_computedRect[dim] += (space - item.m_computedRect[2 + dim]) / 2 - item.Margins[wdim]; + break; + + case BehaveFlags.Right: + item.m_computedRect[2 + dim] = Math.Min(item.m_computedRect[2 + dim], min_size); + item.m_computedRect[dim] = space - item.m_computedRect[2 + dim] - item.Margins[wdim]; + break; + + case BehaveFlags.HFill: + item.m_computedRect[2 + dim] = min_size; + break; + + default: + item.m_computedRect[2 + dim] = Math.Min(item.m_computedRect[2 + dim], min_size); + break; + } + + item.m_computedRect[dim] += offset; + item = item.NextSibling; + } + } + } +} diff --git a/Nimble.Layout/LayoutMargins.cs b/Nimble.Layout/LayoutMargins.cs new file mode 100644 index 0000000..c002bfd --- /dev/null +++ b/Nimble.Layout/LayoutMargins.cs @@ -0,0 +1,36 @@ +namespace Nimble.Layout +{ + public struct LayoutMargins(float left, float top, float right, float bottom) + { + public float Left { get; set; } = left; + public float Top { get; set; } = top; + public float Right { get; set; } = right; + public float Bottom { get; set; } = bottom; + + public LayoutMargins() : this(0, 0, 0, 0) { } + public LayoutMargins(float m) : this(m, m, m, m) { } + public LayoutMargins(float h, float v) : this(h, v, h, v) { } + + public float this[int index] + { + get => index switch { + 0 => Left, + 1 => Top, + 2 => Right, + 3 => Bottom, + _ => throw new IndexOutOfRangeException(), + }; + set { + switch (index) { + case 0: Left = value; break; + case 1: Top = value; break; + case 2: Right = value; break; + case 3: Bottom = value; break; + default: throw new IndexOutOfRangeException(); + } + } + } + + public override readonly string ToString() => $""; + } +} diff --git a/Nimble.Layout/LayoutRect.cs b/Nimble.Layout/LayoutRect.cs new file mode 100644 index 0000000..694ca70 --- /dev/null +++ b/Nimble.Layout/LayoutRect.cs @@ -0,0 +1,41 @@ +namespace Nimble.Layout +{ + public struct LayoutRect(float x, float y, float width, float height) + { + public float X { get; set; } = x; + public float Y { get; set; } = y; + public float Width { get; set; } = width; + public float Height { get; set; } = height; + + public LayoutRect() : this(0, 0, 0, 0) { } + + public float this[int index] + { + readonly get => index switch { + 0 => X, + 1 => Y, + 2 => Width, + 3 => Height, + _ => throw new IndexOutOfRangeException(), + }; + set { + switch (index) { + case 0: X = value; break; + case 1: Y = value; break; + case 2: Width = value; break; + case 3: Height = value; break; + default: throw new IndexOutOfRangeException(); + } + } + } + + public override readonly string ToString() => $"<{X}, {Y}> <{Width}x{Height}>"; + + public readonly bool Equals(LayoutRect other) => X == other.X && Y == other.Y && Width == other.Width && Height == other.Height; + public override readonly bool Equals(object? obj) => obj is LayoutRect other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(X, Y, Width, Height); + + public static bool operator ==(LayoutRect left, LayoutRect right) => left.Equals(right); + public static bool operator !=(LayoutRect left, LayoutRect right) => !(left == right); + } +} diff --git a/Nimble.Layout/LayoutVector.cs b/Nimble.Layout/LayoutVector.cs new file mode 100644 index 0000000..288868d --- /dev/null +++ b/Nimble.Layout/LayoutVector.cs @@ -0,0 +1,35 @@ +namespace Nimble.Layout +{ + public struct LayoutVector(float x, float y) + { + public float X { get; set; } = x; + public float Y { get; set; } = y; + + public LayoutVector() : this(0, 0) { } + + public float this[int index] + { + readonly get => index switch { + 0 => X, + 1 => Y, + _ => throw new IndexOutOfRangeException(), + }; + set { + switch (index) { + case 0: X = value; break; + case 1: Y = value; break; + default: throw new IndexOutOfRangeException(); + } + } + } + + public override readonly string ToString() => $"<{X}, {Y}>"; + + public readonly bool Equals(LayoutVector other) => X == other.X && Y == other.Y; + public override readonly bool Equals(object? obj) => obj is LayoutVector other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(X, Y); + + public static bool operator ==(LayoutVector left, LayoutVector right) => left.Equals(right); + public static bool operator !=(LayoutVector left, LayoutVector right) => !(left == right); + } +} diff --git a/Nimble.Layout/Nimble.Layout.csproj b/Nimble.Layout/Nimble.Layout.csproj new file mode 100644 index 0000000..412d83d --- /dev/null +++ b/Nimble.Layout/Nimble.Layout.csproj @@ -0,0 +1,10 @@ + + + + Library + net8.0 + enable + enable + + + diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..d9ba53e --- /dev/null +++ b/Readme.md @@ -0,0 +1,38 @@ +# Nimble.Layout +Layout system for .Net based on [`layout.h`](https://github.com/randrew/layout). + +## Example +```cs +var root = new LayoutItem { + Size = new(1280, 720), + Contain = ContainFlags.Row, +}; + +var masterList = new LayoutItem { + Size = new(400, 0), + Behave = BehaveFlags.VFill, + Contain = ContainFlags.Column, +}; +root.AddChild(masterList); + +var contentView = new LayoutItem { + Behave = BehaveFlags.HFill | BehaveFlags.VFill, +}; +root.AddChild(contentView); + +root.Run(); + +MyUiLibrary.DrawBoxXYWH( + masterList.Rect.X, masterList.Rect.Y, + masterList.Rect.Width, masterList.Rect.Height +); +``` + +## Notes +This is a direct port of `layout.h` without too many modifications besides making it more OOP-friendly and some common C# things. + +Tests from `layout.h` were ported to C#, and they all pass, so I'm rather confident in the reliability of this library. + +Performance is still mostly untested, although there is a `Nimble.Layout.Benchmark` project that should match the one in `layout.h`. Initial testing shows that this library is about 4 times slower than its C counterpart. I have not yet narrowed down the exact cause. + +Most of the documentation is copied straight from the C code. This will be fixed eventually.