private/Controls/Add-UiDataGridStarResizeUnlock.ps1
|
function Add-UiDataGridStarResizeUnlock { <# .SYNOPSIS Keeps the rightmost star sized column resizable when the grid overflows. #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.Windows.Controls.DataGrid]$DataGrid ) # WPF locks column resize once a Star column gets clamped to zero past the viewport, the remaining drag delta has nowhere to go. # ScrollChanged ties the star to its pixel width on overflow, PreviewMouseLeftButtonDown catches the mid drag transition, MouseUp restores the star on slack. # Attach once. A second call (grid built through New-StyledDataGrid, then attached again by the calling script) added duplicate Loaded closures and a second real Window.Closed hook per grid. if ($DataGrid.Resources.Contains('__StarResize_Attached')) { return } $DataGrid.Resources['__StarResize_Attached'] = $true # Fires once the template is applied. That's when DG_ScrollViewer resolves. # ScrollChanged reaches the grid via $gridRef, a plain closure local off $sender, since the handler runs as a delegate on the ScrollViewer. $DataGrid.Add_Loaded({ param($sender, $eventArgs) if ($sender.Resources.Contains('__StarResize_ScrollViewer')) { return } $sv = $null if ($sender.Template) { $sv = $sender.Template.FindName('DG_ScrollViewer', $sender) } if (!$sv) { $stack = [System.Collections.Generic.Stack[object]]::new() $stack.Push($sender) while ($stack.Count -gt 0 -and !$sv) { $node = $stack.Pop() $count = [System.Windows.Media.VisualTreeHelper]::GetChildrenCount($node) for ($i = 0; $i -lt $count; $i++) { $child = [System.Windows.Media.VisualTreeHelper]::GetChild($node, $i) if ($child -is [System.Windows.Controls.ScrollViewer]) { $sv = $child; break } $stack.Push($child) } } } if (!$sv) { Write-Debug "ResizeUnlock: ScrollViewer not found - reactive arm disabled" return } $sender.Resources['__StarResize_ScrollViewer'] = $sv $gridRef = $sender $scrollChangedDelegate = [System.Windows.Controls.ScrollChangedEventHandler]{ param($svSender, $svArgs) $isOverflow = [double]$svArgs.ExtentWidth -gt [double]$svArgs.ViewportWidth + 1 if ($isOverflow) { # Overflow path: find a live star and pin it. Once pinned this branch does nothing on later fires (no star left to find). $starCol = $null foreach ($col in $gridRef.Columns) { if ($col.Width.IsStar) { $starCol = $col; break } } if (!$starCol) { return } # Changing Width inline during ScrollChanged screws up WPF's layout state. The event fires in the middle of a layout pass and Width affects layout. # Defer to Background so it runs after layout settles. # PS's CheckActionPreference NREs on normal try block exit off pipeline. The IsStar check in the deferred action is the only way ahead needed. $colToPin = $starCol $grid = $gridRef $extent = [double]$svArgs.ExtentWidth $viewport = [double]$svArgs.ViewportWidth [void]$gridRef.Dispatcher.BeginInvoke( [System.Windows.Threading.DispatcherPriority]::Background, [Action]{ if ($colToPin.Width.IsStar) { $pinned = [Math]::Max(0, [double]$colToPin.ActualWidth) $colToPin.Width = [System.Windows.Controls.DataGridLength]::new( $pinned, [System.Windows.Controls.DataGridLengthUnitType]::Pixel) # Record the pinned column + width so the slack branch can restore the star later. A user drag changes the width and the marker gets dropped. $grid.Resources['__StarResize_OriginalStar'] = @{ Col = $colToPin; PinnedWidth = $pinned } Write-Debug "ResizeUnlock: deferred auto-pin to Pixel($pinned) on overflow (extent=$extent, viewport=$viewport)" } }.GetNewClosure()) } else { # A pinned but untouched column gets restored to Star so the row fills the viewable area again. if (!$gridRef.Resources.Contains('__StarResize_OriginalStar')) { return } $entry = $gridRef.Resources['__StarResize_OriginalStar'] $col = $entry.Col $width = $col.Width if ($width.IsStar) { # Something else already restored the star (mouseup). Clean up the marker. $gridRef.Resources.Remove('__StarResize_OriginalStar') return } # Pixel drift from PinnedWidth means the user resized it manually. Good for them. # Honour their width and stop watching. if (!$width.IsAbsolute -or [Math]::Abs([double]$width.Value - [double]$entry.PinnedWidth) -gt 1) { $gridRef.Resources.Remove('__StarResize_OriginalStar') return } # Safe to restore the star. Defer via Background like the pin path, changing Width inside ScrollChanged is the same dangerous reentry. $colToStar = $col $grid = $gridRef [void]$gridRef.Dispatcher.BeginInvoke( [System.Windows.Threading.DispatcherPriority]::Background, [Action]{ if (!$colToStar.Width.IsStar) { $colToStar.Width = [System.Windows.Controls.DataGridLength]::new( 1, [System.Windows.Controls.DataGridLengthUnitType]::Star) Write-Debug "ResizeUnlock: slack returned - re-stared previously-pinned column" } $grid.Resources.Remove('__StarResize_OriginalStar') }.GetNewClosure()) } }.GetNewClosure() $sv.add_ScrollChanged($scrollChangedDelegate) # Stash the ScrollViewer + delegate so Window.Closed can detach. ScrollChanged on an unrelated ScrollViewer would otherwise keep the grid rooted through the routed event multicast. $sender.Resources['__StarResize_State'] = @{ ScrollViewer = $sv ScrollDelegate = $scrollChangedDelegate } }) # Catches the case where a user resize is what pushes the grid into overflow. The reactive arm only fires once WPF has already rejected the drag. $downHandler = [System.Windows.Input.MouseButtonEventHandler]{ param($sender, $eventArgs) $node = $eventArgs.OriginalSource if ($node -isnot [System.Windows.Controls.Primitives.Thumb]) { return } $header = $node while ($header -and $header -isnot [System.Windows.Controls.Primitives.DataGridColumnHeader]) { $header = [System.Windows.Media.VisualTreeHelper]::GetParent($header) } if (!$header) { return } Write-Debug "ResizeUnlock: MouseDown on header thumb for column '$($header.Column.Header)'" $starCol = $null foreach ($col in $sender.Columns) { if ($col.Width.IsStar) { $starCol = $col; break } } if (!$starCol) { Write-Debug "ResizeUnlock: no star column at MouseDown (already pinned by reactive arm)" return } $pinned = [Math]::Max(0, [double]$starCol.ActualWidth) $sender.Resources['__StarResize_Gesture'] = @{ StarColumn = $starCol ResizedColumn = $header.Column PinnedWidth = $pinned } $starCol.Width = [System.Windows.Controls.DataGridLength]::new( $pinned, [System.Windows.Controls.DataGridLengthUnitType]::Pixel) # Same marker the reactive arm writes. Without it, a gesture that never restores the star (click with no drag, resize that stays in overflow) left the ScrollChanged slack branch with nothing to restore and the pin would be permanent. $sender.Resources['__StarResize_OriginalStar'] = @{ Col = $starCol; PinnedWidth = $pinned } Write-Debug "ResizeUnlock: preempted star to Pixel($pinned) on resize gesture" } $upHandler = [System.Windows.Input.MouseButtonEventHandler]{ param($sender, $eventArgs) if (!$sender.Resources.Contains('__StarResize_Gesture')) { return } $gesture = $sender.Resources['__StarResize_Gesture'] $sender.Resources.Remove('__StarResize_Gesture') # Star column's own gripper. A real drag changed the width, so keep it and stop tracking. A bare click or doubleclick (no drag) left the width at the pin value. That pin was never a user choice, so put the star back. if ($gesture.ResizedColumn -eq $gesture.StarColumn) { $width = $gesture.StarColumn.Width $moved = !$width.IsAbsolute -or [Math]::Abs([double]$width.Value - [double]$gesture.PinnedWidth) -gt 1 if (!$moved) { $gesture.StarColumn.Width = [System.Windows.Controls.DataGridLength]::new( 1, [System.Windows.Controls.DataGridLengthUnitType]::Star) Write-Debug "ResizeUnlock: no-drag click on star gripper - restored star" } $sender.Resources.Remove('__StarResize_OriginalStar') return } $sumOthers = 0.0 foreach ($col in $sender.Columns) { if ($col -eq $gesture.StarColumn) { continue } if ($col.Visibility -ne [System.Windows.Visibility]::Visible) { continue } $sumOthers += [double]$col.ActualWidth } $viewport = if ($sender.Resources.Contains('__StarResize_ScrollViewer')) { [double]$sender.Resources['__StarResize_ScrollViewer'].ViewportWidth } else { [double]$sender.ActualWidth - 20 } if ($sumOthers -lt $viewport - 1) { $gesture.StarColumn.Width = [System.Windows.Controls.DataGridLength]::new( 1, [System.Windows.Controls.DataGridLengthUnitType]::Star) $sender.Resources.Remove('__StarResize_OriginalStar') Write-Debug "ResizeUnlock: re-stared previous column on MouseUp (slack restored)" } } # Guard against double register on Loaded refires (tab virtualization etc.). Mirrors the ScrollViewer guard up top. if (!$DataGrid.Resources.Contains('__StarResize_Handlers')) { $DataGrid.AddHandler([System.Windows.UIElement]::PreviewMouseLeftButtonDownEvent, $downHandler, $true) $DataGrid.AddHandler([System.Windows.UIElement]::PreviewMouseLeftButtonUpEvent, $upHandler, $true) $DataGrid.Resources['__StarResize_Handlers'] = $true } # Drop the down/up handlers and the ScrollChanged sub on Window.Closed so the grid doesn't stay rooted via the routed event registrations after teardown. # Window.Closed, not DataGrid.Unloaded, because tabs and expanders fire Unloaded on every show/hide and the gesture would lose its arm mid resize. $detachState = @{ Done = $false } $hookWindow = { if ($detachState.Done) { return } $window = [System.Windows.Window]::GetWindow($DataGrid) if (!$window) { return } # Rebind to locals... PS .GetNewClosure() only captures the imediate parent scope, so the inner Closed scriptblock needs the handler refs copied into this body. $grid = $DataGrid $localDown = $downHandler $localUp = $upHandler $window.Add_Closed({ try { $grid.RemoveHandler([System.Windows.UIElement]::PreviewMouseLeftButtonDownEvent, $localDown) } catch { Write-Debug "StarResize down detach: $_" } try { $grid.RemoveHandler([System.Windows.UIElement]::PreviewMouseLeftButtonUpEvent, $localUp) } catch { Write-Debug "StarResize up detach: $_" } if ($grid.Resources.Contains('__StarResize_State')) { $state = $grid.Resources['__StarResize_State'] try { $state.ScrollViewer.remove_ScrollChanged($state.ScrollDelegate) } catch { Write-Debug "StarResize ScrollChanged detach: $_" } } }.GetNewClosure()) $detachState.Done = $true }.GetNewClosure() # Initialized covers grids that get torn down without ever entering Loaded. Loaded is the normal path. # Whichever fires first wins via $detachState.Done. $DataGrid.Add_Initialized({ & $hookWindow }.GetNewClosure()) $DataGrid.Add_Loaded({ & $hookWindow }.GetNewClosure()) } |