#nullable disable using System; using System.Buffers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; namespace WPFDark.StandardControls.Internal { // Based on this code // https://github.com/dotnet/wpf/blob/master/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/Primitives/TabPanel.cs public class TabPanelInternal : TabPanel { static TabPanelInternal() { KeyboardNavigation.TabNavigationProperty.OverrideMetadata(typeof(TabPanelInternal), new FrameworkPropertyMetadata(KeyboardNavigationMode.Once)); KeyboardNavigation.DirectionalNavigationProperty.OverrideMetadata(typeof(TabPanelInternal), new FrameworkPropertyMetadata(KeyboardNavigationMode.Cycle)); } /// /// Updates DesiredSize of the TabPanelInternal. Called by parent UIElement. This is the first pass of layout. /// /// /// TabPanelInternal /// /// Constraint size is an "upper limit" that TabPanelInternal should not exceed. /// TabPanelInternal' desired size. protected override Size MeasureOverride(Size constraint) { var contentSize = new Size(); var tabAlignment = TabStripPlacement; _numRows = 1; _numHeaders = 0; _rowHeight = 0; switch (tabAlignment) { // For top and bottom placement the panel flow its children to calculate the number of rows and // desired vertical size case Dock.Top: case Dock.Bottom: { var numInCurrentRow = 0; var currentRowWidth = 0.0; var maxRowWidth = 0.0; foreach (UIElement child in InternalChildren) { if (child.Visibility == Visibility.Collapsed) continue; _numHeaders++; // Helper measures child, and deals with Min, Max, and base Width & Height properties. // Helper returns the size a child needs to take up (DesiredSize or property specified size). child.Measure(constraint); var childSize = GetDesiredSizeWithoutMargin(child); if (_rowHeight < childSize.Height) _rowHeight = childSize.Height; if (currentRowWidth + childSize.Width > constraint.Width && numInCurrentRow > 0) { // If child does not fit in the current row - create a new row if (maxRowWidth < currentRowWidth) maxRowWidth = currentRowWidth; currentRowWidth = childSize.Width; numInCurrentRow = 1; _numRows++; } else { currentRowWidth += childSize.Width; numInCurrentRow++; } } if (maxRowWidth < currentRowWidth) maxRowWidth = currentRowWidth; contentSize.Height = _rowHeight * _numRows; // If we don't have constraint or content width is smaller than constraint width then size to content if (double.IsInfinity(contentSize.Width) || double.IsNaN(contentSize.Width) || maxRowWidth < constraint.Width) contentSize.Width = maxRowWidth; else contentSize.Width = constraint.Width; break; } case Dock.Left: case Dock.Right: { foreach (UIElement child in InternalChildren) { if (child.Visibility == Visibility.Collapsed) continue; _numHeaders++; // Helper measures child, and deals with Min, Max, and base Width & Height properties. // Helper returns the size a child needs to take up (DesiredSize or property specified size). child.Measure(constraint); var childSize = GetDesiredSizeWithoutMargin(child); if (contentSize.Width < childSize.Width) contentSize.Width = childSize.Width; contentSize.Height += childSize.Height; } break; } default: throw new ArgumentOutOfRangeException(); } // Returns our minimum size & sets DesiredSize. return contentSize; } /// /// TabPanelInternal arranges each of its children. /// /// Size that TabPanelInternal will assume to position children. protected override Size ArrangeOverride(Size arrangeSize) { var tabAlignment = TabStripPlacement; switch (tabAlignment) { case Dock.Top: case Dock.Bottom: ArrangeHorizontal(arrangeSize); break; case Dock.Left: case Dock.Right: ArrangeVertical(arrangeSize); break; default: throw new ArgumentOutOfRangeException(); } return arrangeSize; } /// /// Override of . /// /// Geometry to use as additional clip in case when element is larger then available space protected override Geometry GetLayoutClip(Size layoutSlotSize) { return null; } private static Size GetDesiredSizeWithoutMargin(UIElement element) { var margin = (Thickness) element.GetValue(MarginProperty); Size desiredSizeWithoutMargin = default; desiredSizeWithoutMargin.Height = Math.Max(0d, element.DesiredSize.Height - margin.Top - margin.Bottom); desiredSizeWithoutMargin.Width = Math.Max(0d, element.DesiredSize.Width - margin.Left - margin.Right); return desiredSizeWithoutMargin; } private void GetHeadersSize(Span headerSize) { var childIndex = 0; foreach (UIElement child in InternalChildren) { if (child.Visibility == Visibility.Collapsed) continue; var childSize = GetDesiredSizeWithoutMargin(child); headerSize[childIndex] = Math.Floor(childSize.Width); childIndex++; } } private void ArrangeHorizontal(Size arrangeSize) { var numSeparators = _numRows - 1; // ReSharper disable MergeConditionalExpression var bufferSize = Unsafe.SizeOf() * _numHeaders + Unsafe.SizeOf() * numSeparators; var bufferArray = bufferSize >= 512 ? ArrayPool.Shared.Rent(bufferSize) : null; var buffer = bufferArray != null ? bufferArray.AsSpan(0, bufferSize) : stackalloc byte[bufferSize]; // ReSharper restore MergeConditionalExpression var headerSize = MemoryMarshal.Cast(buffer.Slice(0, Unsafe.SizeOf() * _numHeaders)); var solution = MemoryMarshal.Cast(buffer.Slice(Unsafe.SizeOf() * _numHeaders, Unsafe.SizeOf() * numSeparators)); try { var tabAlignment = TabStripPlacement; var isMultiRow = _numRows > 1; var activeRow = 0; var childOffset = new Vector(); GetHeadersSize(headerSize); // If we have multirows, then calculate the best header distribution if (isMultiRow) { solution = CalculateHeaderDistribution(arrangeSize.Width, headerSize, solution); activeRow = GetActiveRow(solution); childOffset.Y = tabAlignment switch { // TabPanelInternal starts to layout children depend on activeRow which should be always on bottom (top) // The first row should start from Y = (_numRows - 1 - activeRow) * _rowHeight Dock.Top => ((_numRows - 1 - activeRow) * _rowHeight), Dock.Bottom when activeRow != 0 => ((_numRows - activeRow) * _rowHeight), _ => childOffset.Y }; } else { solution = Array.Empty(); } var childIndex = 0; var separatorIndex = 0; foreach (UIElement child in InternalChildren) { if (child.Visibility == Visibility.Collapsed) continue; var margin = (Thickness) child.GetValue(MarginProperty); var leftOffset = margin.Left; var rightOffset = margin.Right; var topOffset = margin.Top; var bottomOffset = margin.Bottom; var lastHeaderInRow = isMultiRow && (separatorIndex < solution.Length && solution[separatorIndex] == childIndex || childIndex == _numHeaders - 1); //Length left, top, right, bottom; var cellSize = new Size(headerSize[childIndex], _rowHeight); // Align the last header in the row; If headers are not aligned directional nav would not work correctly if (lastHeaderInRow) cellSize.Width = arrangeSize.Width - childOffset.X; child.Arrange(new Rect(childOffset.X, childOffset.Y, cellSize.Width, cellSize.Height)); var childSize = cellSize; childSize.Height = Math.Max(0d, childSize.Height - topOffset - bottomOffset); childSize.Width = Math.Max(0d, childSize.Width - leftOffset - rightOffset); // Calculate the offset for the next child childOffset.X += cellSize.Width; if (lastHeaderInRow) { if ((separatorIndex == activeRow && tabAlignment == Dock.Top) || (separatorIndex == activeRow - 1 && tabAlignment == Dock.Bottom)) childOffset.Y = 0d; else childOffset.Y += _rowHeight; childOffset.X = 0d; separatorIndex++; } childIndex++; } } finally { if (bufferArray != null) ArrayPool.Shared.Return(bufferArray); } } private void ArrangeVertical(Size arrangeSize) { var childOffsetY = 0d; foreach (UIElement child in InternalChildren) { if (child.Visibility != Visibility.Collapsed) { var childSize = GetDesiredSizeWithoutMargin(child); child.Arrange(new Rect(0, childOffsetY, arrangeSize.Width, childSize.Height)); // Calculate the offset for the next child childOffsetY += childSize.Height; } } } // Returns the row which contain the child with IsSelected==true private int GetActiveRow(Span solution) { var activeRow = 0; var childIndex = 0; if (solution.Length > 0) { foreach (UIElement child in InternalChildren) { if (child.Visibility == Visibility.Collapsed) continue; var isActiveTab = (bool) child.GetValue(Selector.IsSelectedProperty); if (isActiveTab) return activeRow; if (activeRow < solution.Length && solution[activeRow] == childIndex) activeRow++; childIndex++; } } // If the is no selected element and alignment is Top - then the active row is the last row if (TabStripPlacement == Dock.Top) activeRow = _numRows - 1; return activeRow; } /* TabPanelInternal layout calculation: After measure call we have: rowWidthLimit - width of the TabPanelInternal Header[0..n-1] - headers headerWidth[0..n-1] - header width Calculated values: numSeparators - number of separators between numSeparators+1 rows rowWidth[0..numSeparators] - row width rowHeaderCount[0..numSeparators] - Row Count = number of headers on that row rowAverageGap[0..numSeparators] - Average Gap for the row i = (rowWidth - rowWidth[i])/rowHeaderCount[i] currentSolution[0..numSeparators-1] - separator currentSolution[i]=x means Header[x] and h[x+1] are separated with new line bestSolution[0..numSeparators-1] - keep the last Best Solution bestSolutionRowAverageGap - keep the last Best Solution Average Gap Between all separators distribution the best solution have minimum Average Gap - this is the amount of pixels added to the header (to justify) in the row How does it work: First we flow the headers to calculate the number of necessary rows (numSeparators+1). That means we need to insert numSeparators separators between n headers (numSeparators CalculateHeaderDistribution(double rowWidthLimit, Span headerWidth, Span bestSolution) { var numSeparators = _numRows - 1; // ReSharper disable MergeConditionalExpression var doubleArraySize = _numRows + _numRows + _numRows; var intArraySize = numSeparators + _numRows; var doubleArray = doubleArraySize > 128 ? ArrayPool.Shared.Rent(_numRows + _numRows + _numRows) : null; var intArray = intArraySize > 128 ? ArrayPool.Shared.Rent(numSeparators + _numRows) : null; var doubleBuffer = doubleArray != null ? doubleArray : stackalloc double[doubleArraySize]; var intBuffer = intArray != null ? intArray : stackalloc int[intArraySize]; // ReSharper restore MergeConditionalExpression var rowWidth = doubleBuffer.Slice(0, _numRows); var rowAverageGap = doubleBuffer.Slice(_numRows, _numRows); var bestSolutionRowAverageGap = doubleBuffer.Slice(_numRows + _numRows, _numRows); var currentSolution = intBuffer.Slice(0, numSeparators); var rowHeaderCount = intBuffer.Slice(numSeparators, _numRows); try { var bestSolutionMaxRowAverageGap = 0.0; var numHeaders = headerWidth.Length; var currentRowWidth = 0.0; var numberOfHeadersInCurrentRow = 0; double currentAverageGap; // Initialize the current state; Do the initial flow of the headers var currentRowIndex = 0; for (var index = 0; index < numHeaders; index++) { if (currentRowWidth + headerWidth[index] > rowWidthLimit && numberOfHeadersInCurrentRow > 0) { // if we cannot add next header - flow to next row // Store current row before we go to the next rowWidth[currentRowIndex] = currentRowWidth; // Store the current row width rowHeaderCount[currentRowIndex] = numberOfHeadersInCurrentRow; // For each row we store the number os headers inside currentAverageGap = Math.Max(0d, (rowWidthLimit - currentRowWidth) / numberOfHeadersInCurrentRow); // The amount of width that should be added to justify the header rowAverageGap[currentRowIndex] = currentAverageGap; currentSolution[currentRowIndex] = index - 1; // Separator points to the last header in the row if (bestSolutionMaxRowAverageGap < currentAverageGap ) // Remember the maximum of all currentAverageGap bestSolutionMaxRowAverageGap = currentAverageGap; // Iterate to next row currentRowIndex++; currentRowWidth = headerWidth[index]; // Accumulate header widths on the same row numberOfHeadersInCurrentRow = 1; } else { currentRowWidth += headerWidth[index]; // Accumulate header widths on the same row // Increase the number of headers only if they are not collapsed (width=0) // ReSharper disable once CompareOfFloatsByEqualityOperator if (headerWidth[index] != 0) numberOfHeadersInCurrentRow++; } } // If everything fit in 1 row then exit (no separators needed) if (currentRowIndex == 0) return bestSolution.Slice(0, 0); // Add the last row rowWidth[currentRowIndex] = currentRowWidth; rowHeaderCount[currentRowIndex] = numberOfHeadersInCurrentRow; currentAverageGap = (rowWidthLimit - currentRowWidth) / numberOfHeadersInCurrentRow; rowAverageGap[currentRowIndex] = currentAverageGap; if (bestSolutionMaxRowAverageGap < currentAverageGap) bestSolutionMaxRowAverageGap = currentAverageGap; currentSolution.CopyTo(bestSolution); // Remember the first solution as initial bestSolution rowAverageGap.CopyTo(bestSolutionRowAverageGap); // bestSolutionRowAverageGap is used in ArrangeOverride to calculate header sizes // Search for the best solution // The exit condition if when we cannot move header to the next row while (true) { // Find the row with maximum AverageGap var worstRowIndex = 0; // Keep the row index with maximum AverageGap var maxAg = 0.0; for (var i = 0; i < _numRows; i++) // for all rows { if (maxAg < rowAverageGap[i]) { maxAg = rowAverageGap[i]; worstRowIndex = i; } } // If we are on the first row - cannot move from previous if (worstRowIndex == 0) break; // From the row with maximum AverageGap we try to move a header from previous row var moveToRow = worstRowIndex; var moveFromRow = moveToRow - 1; var moveHeader = currentSolution[moveFromRow]; var movedHeaderWidth = headerWidth[moveHeader]; rowWidth[moveToRow] += movedHeaderWidth; // If the moved header cannot fit - exit. We have the best solution already. if (rowWidth[moveToRow] > rowWidthLimit) break; // If header is moved successfully to the worst row // we update the arrays keeping the row state currentSolution[moveFromRow]--; rowHeaderCount[moveToRow]++; rowWidth[moveFromRow] -= movedHeaderWidth; rowHeaderCount[moveFromRow]--; rowAverageGap[moveFromRow] = (rowWidthLimit - rowWidth[moveFromRow]) / rowHeaderCount[moveFromRow]; rowAverageGap[moveToRow] = (rowWidthLimit - rowWidth[moveToRow]) / rowHeaderCount[moveToRow]; // EvaluateSolution: // If the current solution is better than bestSolution - keep it in bestSolution maxAg = 0; for (var i = 0; i < _numRows; i++) // for all rows { if (maxAg < rowAverageGap[i]) { maxAg = rowAverageGap[i]; } } if (maxAg < bestSolutionMaxRowAverageGap) { bestSolutionMaxRowAverageGap = maxAg; currentSolution.CopyTo(bestSolution); rowAverageGap.CopyTo(bestSolutionRowAverageGap); } } // Each header size should be increased so headers in the row stretch to fit the row currentRowIndex = 0; for (var index = 0; index < numHeaders; index++) { headerWidth[index] += bestSolutionRowAverageGap[currentRowIndex]; if (currentRowIndex < numSeparators && bestSolution[currentRowIndex] == index) currentRowIndex++; } } finally { if (doubleArray != null) ArrayPool.Shared.Return(doubleArray); if (intArray != null) ArrayPool.Shared.Return(intArray); } return bestSolution; } private Dock TabStripPlacement => (TemplatedParent is TabControl tc) ? tc.TabStripPlacement : Dock.Top; private int _numRows = 1; // Number of row calculated in measure and used in arrange private int _numHeaders; // Number of headers excluding the collapsed items private double _rowHeight; // Maximum of all headers height } }