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. Does not handle controls with complex internal labels cleanly. .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 Fill Makes the grid fill the parent's remaining vertical space. Use this when star sized rows need to divide height evenly (e.g. dashboard grids). Height comes from the outer ScrollViewer's viewport, minus this grid's offset and the heights of the siblings below it. -FillParent is the legacy alias. .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 '*,*' -Fill -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, [Alias('FillParent')] [switch]$Fill, [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,*' } $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) } } } $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 the label+control panel by structure (for backward compat) $isLabelWrapper = $false if ($FormLayout -and !$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 $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 emptied panel from the 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 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 the panel (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 the panel from the 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 then add back in display order below [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) [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" if ($Fill -and $parent -is [System.Windows.Controls.Panel]) { Set-UiFillParentHeight -Control $grid -TrackParentWidth $parent } } |