public/layout/New-UiGrid.ps1
|
function New-UiGrid { <# .SYNOPSIS Creates a Grid layout container with simplified column/row management. .DESCRIPTION Provides a declarative Grid control that auto-flows children into cells. Supports column sizing via simple syntax, FormLayout for label unwrapping, and AutoLayout for simple single-column stacking. .PARAMETER Columns Column definitions in flexible formats: - Integer: Number of equal-width columns (e.g., 3) - String: Comma-separated definitions (e.g., 'Auto,*' or 'Auto, *, 100') - Array: Array of definitions (e.g., @('Auto', '*', '2*', '100')) Valid definitions: 'Auto', '*' (star), '2*' (weighted star), or number (fixed pixels). .PARAMETER Rows Row definitions in same flexible formats as Columns. If omitted, rows are created automatically as needed. .PARAMETER AutoLayout Simple single-column layout where each control gets its own row. Does not unwrap label+control pairs - controls handle their own labels. This is the recommended layout for mixed control types (inputs, toggles, sliders). .PARAMETER FormLayout Optimized for label+control pairs. Automatically uses a 2-column layout with auto-width labels on the left and stretching controls on the right. Note: May not work well with controls that have complex internal labels. .PARAMETER RowSpacing Vertical spacing between rows in pixels. Default is 4. .PARAMETER ColumnSpacing Horizontal spacing between columns in pixels. Default is 8. .PARAMETER Content ScriptBlock containing child controls. .PARAMETER FillParent Makes the grid fill its parent's available vertical space. Use this when star-sized rows need to divide height evenly (e.g. dashboard grids). Works by reading the parent's ActualHeight and subtracting sibling heights. .PARAMETER FullWidth Stretches the grid to fill available width. .PARAMETER WPFProperties Hashtable of additional WPF properties to set on the Grid. .EXAMPLE New-UiGrid -Columns 2 -Rows '*,*' -FillParent -Content { New-UiChart -Type Bar -Data $sales -Title "Sales" New-UiChart -Type Line -Data $trend -Title "Trend" New-UiChart -Type Pie -Data $share -Title "Share" New-UiChart -Type Bar -Data $revenue -Title "Revenue" } # Dashboard: 4 equal cells that fill the tab .EXAMPLE New-UiGrid -Columns 3 -Content { New-UiLabel -Text "A" New-UiLabel -Text "B" New-UiLabel -Text "C" New-UiLabel -Text "D" # Wraps to row 1, col 0 } .EXAMPLE New-UiGrid -FormLayout -Content { New-UiInput -Label "Username" -Variable "user" New-UiInput -Label "Password" -Variable "pass" } # Creates a clean 2-column form with labels auto-sized on the left .EXAMPLE New-UiGrid -AutoLayout -Content { New-UiInput -Label "Name" -Variable "name" New-UiDropdown -Label "Role" -Variable "role" -Items @('Admin', 'User') New-UiToggle -Label "Active" -Variable "active" New-UiSlider -Label "Volume" -Variable "vol" -ShowValueLabel } # Each control gets its own row, labels handled internally .EXAMPLE New-UiGrid -Columns 'Auto, *, 100' -Content { New-UiLabel -Text "Name:" New-UiInput -Variable "name" New-UiButton -Text "..." } .EXAMPLE New-UiGrid -Columns 2 -Rows '*, Auto' -Content { # Content area spanning top New-UiLabel -Text "Main content here" # Button row at bottom with fixed height } #> [CmdletBinding()] param( [Parameter()] $Columns = 2, [Parameter()] $Rows, [Parameter(Mandatory)] [scriptblock]$Content, [switch]$AutoLayout, [switch]$FormLayout, [int]$RowSpacing = 4, [int]$ColumnSpacing = 8, [switch]$FullWidth, [switch]$FillParent, [Parameter()] [hashtable]$WPFProperties ) # Helper to parse column/row definitions from various input formats function ConvertTo-GridDefinitions { param($Spec, [string]$Type) # Integer = that many equal-width columns/rows if ($Spec -is [int]) { return @('*') * $Spec } # Comma-separated string like 'Auto,*' or 'Auto, *, 100' if ($Spec -is [string]) { return $Spec -split '\s*,\s*' | Where-Object { $_ } } # Array passed directly if ($Spec -is [array]) { return $Spec } # Fallback return @('*') } $session = Assert-UiSession -CallerName 'New-UiGrid' $parent = $session.CurrentParent $oldParent = $parent Write-Debug "AutoLayout: $($AutoLayout.IsPresent), FormLayout: $($FormLayout.IsPresent), Columns: $Columns, Parent: $($parent.GetType().Name)" # AutoLayout: single stretching column, each child = one row if ($AutoLayout) { $Columns = '*' } # Default FormLayout to 2-column Auto/* if no columns specified elseif ($FormLayout -and $Columns -eq 2 -and $Columns -isnot [array] -and $Columns -isnot [string]) { $Columns = 'Auto,*' } # Parse column definitions $columnSpecs = ConvertTo-GridDefinitions -Spec $Columns -Type 'Column' $grid = [System.Windows.Controls.Grid]@{ Margin = [System.Windows.Thickness]::new(4) } # Helper to parse a single size definition (Auto, *, 2*, 100) function ConvertTo-GridLength { param([string]$Def) switch -Regex ($Def) { '^Auto$' { return [System.Windows.GridLength]::Auto } '^(\d+)\*$' { $weight = [double]$matches[1] return [System.Windows.GridLength]::new($weight, [System.Windows.GridUnitType]::Star) } '^\*$' { return [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) } '^\d+$' { return [System.Windows.GridLength]::new([double]$Def, [System.Windows.GridUnitType]::Pixel) } default { Write-Warning "Unknown grid definition '$Def', using Star" return [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) } } } # Create column definitions $columnDefs = [System.Collections.Generic.List[object]]::new() foreach ($colSpec in $columnSpecs) { $colDefinition = [System.Windows.Controls.ColumnDefinition]::new() $colDefinition.Width = ConvertTo-GridLength -Def $colSpec $columnDefs.Add($colDefinition) } foreach ($col in $columnDefs) { [void]$grid.ColumnDefinitions.Add($col) } Write-Debug "Created $($columnDefs.Count) column definitions" $columnCount = $columnDefs.Count # Parse and create row definitions if specified if ($Rows) { $rowSpecs = ConvertTo-GridDefinitions -Spec $Rows -Type 'Row' foreach ($rowSpec in $rowSpecs) { $rowDefinition = [System.Windows.Controls.RowDefinition]::new() $rowDefinition.Height = ConvertTo-GridLength -Def $rowSpec [void]$grid.RowDefinitions.Add($rowDefinition) } } # Save previous context to support nested grids $previousGridContext = $script:GridContext # Store grid state for child placement (using script scope since session is C# object) $script:GridContext = @{ Grid = $grid ColumnCount = $columnCount CurrentRow = 0 CurrentCol = 0 AutoLayout = $AutoLayout.IsPresent FormLayout = $FormLayout.IsPresent RowSpacing = $RowSpacing ColSpacing = $ColumnSpacing } # Set grid as current parent so children add to it $session.CurrentParent = $grid Write-Debug "Entering content block" # Execute content - restore state outside try/finally for PS 5.1 closure compatibility try { Invoke-UiContent -Content $Content -CallerName 'New-UiGrid' -ErrorAction Stop } catch { # Restore state before re-throwing $session.CurrentParent = $oldParent $script:GridContext = $previousGridContext throw } # Restore state after successful content execution $session.CurrentParent = $oldParent $script:GridContext = $previousGridContext Write-Debug "Content block complete" Write-Debug "POST-PROCESS: Starting" Write-Debug "POST-PROCESS: grid is null = $($null -eq $grid)" # AutoLayout skips unwrapping - each child stays as-is $skipUnwrap = $AutoLayout try { $childrenToProcess = @($grid.Children) $unwrappedChildren = [System.Collections.Generic.List[object]]::new() foreach ($child in $childrenToProcess) { Write-Debug "Processing child: $($child.GetType().FullName)" # Check for FormControl tag (preferred method) $formTag = $null if (!$skipUnwrap -and $child -is [System.Windows.Controls.StackPanel]) { $tagValue = $child.Tag if ($tagValue -is [hashtable] -and $tagValue.FormControl -eq $true) { $formTag = $tagValue } } # Fallback: detect label+control wrapper by structure (for backward compat) $isLabelWrapper = $false if (!$skipUnwrap -and !$formTag -and $child -is [System.Windows.Controls.StackPanel]) { if ($child.Children.Count -eq 2 -and $child.Children[0] -is [System.Windows.Controls.TextBlock]) { $isLabelWrapper = $true } } if ($formTag) { # Use tagged references (no index assumptions) try { $labelBlock = $formTag.Label $control = $formTag.Control # Disconnect label from its parent (may be nested in a DockPanel) $labelParent = $labelBlock.Parent if ($labelParent -is [System.Windows.Controls.Panel]) { [void]$labelParent.Children.Remove($labelBlock) } # Disconnect control from its parent $controlParent = $control.Parent if ($controlParent -is [System.Windows.Controls.Panel]) { [void]$controlParent.Children.Remove($control) } # Remove the wrapper from grid (no longer needed) [void]$grid.Children.Remove($child) # Add label and control as separate items [void]$unwrappedChildren.Add($labelBlock) [void]$unwrappedChildren.Add($control) Write-Debug "Unwrapped via FormControl tag" } catch { Write-Debug "Error unwrapping via tag: $($_.Exception.Message)" throw } } elseif ($isLabelWrapper) { # Legacy fallback: extract by index try { $labelBlock = $child.Children[0] $control = $child.Children[1] # Remove from wrapper (must remove in reverse order) $child.Children.RemoveAt(1) $child.Children.RemoveAt(0) # Skip if control is invalid (can happen with child window sessions) if ($null -ne $control -and $control -isnot [System.Windows.UIElement]) { Write-Debug "Invalid control extracted: $($control.GetType().FullName)" continue } # Remove wrapper from grid [void]$grid.Children.Remove($child) # Add label and control as separate items [void]$unwrappedChildren.Add($labelBlock) [void]$unwrappedChildren.Add($control) Write-Debug "Unwrapped via legacy index detection" } catch { Write-Debug "Error unwrapping label/control: $($_.Exception.Message)" throw } } else { try { [void]$unwrappedChildren.Add($child) # Remove so we can re-add in order [void]$grid.Children.Remove($child) } catch { Write-Debug "Error processing child removal: $($_.Exception.Message)" throw } } } } catch { Write-Debug "Post-process (unwrap) error: $($_.Exception.Message)" throw } # Now place all children (unwrapped) into the grid with auto-flow Write-Debug "Unwrapped children: $($unwrappedChildren.Count)" $ctx = @{ Row = 0 Col = 0 } foreach ($child in $unwrappedChildren) { if ($null -eq $child) { Write-Debug "Skipping null child during placement" continue } if ($child -isnot [System.Windows.UIElement]) { Write-Debug "Skipping non-UIElement child: $($child.GetType().FullName)" continue } try { # Add child to grid [void]$grid.Children.Add($child) # Auto-place this child (all unwrapped children need placement) [System.Windows.Controls.Grid]::SetRow($child, $ctx.Row) [System.Windows.Controls.Grid]::SetColumn($child, $ctx.Col) # For labels in first column, vertically center them if ($ctx.Col -eq 0 -and $child -is [System.Windows.Controls.TextBlock]) { $child.VerticalAlignment = 'Center' } # Apply spacing via margin when supported $isFrameworkElement = $child -is [System.Windows.FrameworkElement] $existingMargin = if ($isFrameworkElement) { $child.Margin } else { [System.Windows.Thickness]::new() } $leftMargin = if ($ctx.Col -gt 0) { $ColumnSpacing / 2 } else { 0 } $rightMargin = if ($ctx.Col -lt ($columnCount - 1)) { $ColumnSpacing / 2 } else { 0 } $topMargin = if ($ctx.Row -gt 0) { $RowSpacing / 2 } else { 0 } $bottomMargin = $RowSpacing / 2 if ($isFrameworkElement) { $child.Margin = [System.Windows.Thickness]::new( $existingMargin.Left + $leftMargin, $existingMargin.Top + $topMargin, $existingMargin.Right + $rightMargin, $existingMargin.Bottom + $bottomMargin ) } } catch { Write-Debug "Placement error: $($_.Exception.Message)" Write-Debug "Child type: $($child.GetType().FullName); Row=$($ctx.Row), Col=$($ctx.Col)" throw } # Advance to next cell $ctx.Col++ if ($ctx.Col -ge $columnCount) { $ctx.Col = 0 $ctx.Row++ # Add row definition if needed if ($ctx.Row -ge $grid.RowDefinitions.Count) { $newRow = [System.Windows.Controls.RowDefinition]@{ Height = [System.Windows.GridLength]::Auto } [void]$grid.RowDefinitions.Add($newRow) } } } Write-Debug "Placement phase complete" # FullWidth mode — WrapPanel parents need explicit Width since they size to content if ($FullWidth -or $parent -is [System.Windows.Controls.WrapPanel]) { $grid.HorizontalAlignment = 'Stretch' if ($parent -is [System.Windows.Controls.WrapPanel]) { $grid.Width = $parent.ActualWidth if ($grid.Width -eq 0) { $grid.Width = 800 } # Track parent resizes so the grid width stays in sync with the tab/window $gridWidthRef = $grid $parentWidthRef = $parent $parentWidthRef.Add_SizeChanged({ param($sender, $sizeArgs) $newWidth = $sender.ActualWidth if ($newWidth -gt 50) { $gridWidthRef.Width = $newWidth } }.GetNewClosure()) } } # Apply custom WPF properties if ($WPFProperties) { Set-UiProperties -Control $grid -Properties $WPFProperties } [void]$parent.Children.Add($grid) Write-Debug "Grid added to parent with $($grid.Children.Count) children" # FillParent: walk up the visual tree to find the ScrollViewer that wraps # all window content. The ScrollViewer's ViewportHeight is the real visible # area — everything inside it measures with infinite height, so star rows # collapse to Auto without an explicit Height on the grid. # Strategy: shrink the grid to a small initial height so the ScrollViewer # content fits the viewport, then fire a DispatcherTimer (100ms) to read # TranslatePoint and ViewportHeight once layout has stabilized. if ($FillParent -and $parent -is [System.Windows.Controls.Panel]) { $gridRef = $grid $parentRef = $parent # Width: WrapPanel tracks horizontal size correctly, keep it in sync on resize $parentRef.Add_SizeChanged({ param($sizeSender, $sizeArgs) $newWidth = $sizeSender.ActualWidth if ($newWidth -gt 50) { $gridRef.Width = $newWidth } }.GetNewClosure()) # Height: discover the ScrollViewer after the visual tree is built $gridRef.Add_Loaded({ param($loadSender, $loadArgs) # Set width immediately (may have been 0 at creation time) if ($parentRef.ActualWidth -gt 50) { $gridRef.Width = $parentRef.ActualWidth } # Shrink the grid so its natural content height doesn't push it # below the ScrollViewer viewport (which makes TranslatePoint > ViewportHeight) $gridRef.Height = 200 # Walk up the visual tree to find the window's ScrollViewer $sv = $null $walker = $parentRef while ($walker) { $nextUp = [System.Windows.Media.VisualTreeHelper]::GetParent($walker) if (!$nextUp) { break } if ($nextUp -is [System.Windows.Controls.ScrollViewer]) { $sv = $nextUp; break } $walker = $nextUp } if (!$sv) { return } # Capture references for closures — keep names short to avoid nested scope issues $gr = $gridRef # Compute height: viewport minus the grid's Y offset within the ScrollViewer $computeHeight = { $vh = $sv.ViewportHeight if ($vh -le 0) { return } $offset = 80.0 try { $pt = $gr.TranslatePoint([System.Windows.Point]::new(0, 0), $sv) $offset = $pt.Y } catch { Write-Debug 'TranslatePoint failed during auto-size' } $target = $vh - $offset - 10 $current = $gr.ActualHeight # Only update if meaningfully different (prevents infinite layout cycles) if ($target -gt 50 -and ([double]::IsNaN($current) -or [Math]::Abs($target - $current) -gt 5)) { $gr.Height = $target } }.GetNewClosure() # DispatcherTimer is the most reliable deferred-execution pattern # in PowerShell closures. BeginInvoke [Action] casting can fail silently. $heightFn = $computeHeight $timer = [System.Windows.Threading.DispatcherTimer]::new() $timer.Interval = [TimeSpan]::FromMilliseconds(100) $tRef = $timer $timer.Add_Tick({ $tRef.Stop() & $heightFn }.GetNewClosure()) $timer.Start() # Recalculate whenever the viewport resizes (window drag, maximize, etc.) $resizeFn = $computeHeight $sv.Add_SizeChanged({ param($sizeSender, $sizeArgs) & $resizeFn }.GetNewClosure()) }.GetNewClosure()) } } |