Skip to content

Fix negative-width header clip Rect in TableViewHeaderRow.ArrangeOverride (#437) - #438

Open
SolidRockProgrammer wants to merge 1 commit into
w-ahmad:mainfrom
Datgel:fix-header-clip-width
Open

SolidRockProgrammer wants to merge 1 commit into
w-ahmad:mainfrom
Datgel:fix-header-clip-width

Conversation

@SolidRockProgrammer

Copy link
Copy Markdown

Description

TableViewHeaderRow.ArrangeOverride clips the scrollable headers panel to

Rect(xClip, 0, _scrollableHeadersPanel.ActualWidth - xClip, finalSize.Height)

where xClip = (headersOffset * -1) + frozenOffset reduces algebraically to TableView.HorizontalOffset. The guard on the enclosing if only checks that the panel has been measured (ActualWidth > 0); nothing keeps ActualWidth - xClip positive, and a non-positive width is not a valid Rect.

The two inputs come from different places — HorizontalOffset is read live off the scroll viewer, ActualWidth is the scrollable headers panel's own extent — so there is no invariant tying one to the other. With frozen columns wider than the viewport it is a steady state: the scroll viewer's extent covers every column, frozen ones included, so scrolling to the end leaves the offset past the scrollable panel's much smaller extent. It is also reachable transiently by narrowing a column while the view is scrolled right.

What happens then differs by platform, and neither outcome is good:

  • WinUIWindows.Foundation.Rect's constructor validates, so the arrange pass throws:
    System.ArgumentOutOfRangeException: Non-negative number required. (Parameter 'width')
       at Windows.Foundation.Rect..ctor(Single x, Single y, Single width, Single height)
       at WinUI.TableView.TableViewHeaderRow.ArrangeOverride(Size finalSize)
       ...
       at Microsoft.UI.Xaml.UIElement.UpdateLayout()
    
  • Uno Platform — its Rect allows negative width by default, so the invalid rectangle is stored verbatim, the clip resolves to a region outside the panel, and every column header disappears while the header band keeps its full height. No exception, no error — just a blank band over rows that render normally.

The change

Clamp xClip into [0, ActualWidth] — the same Math.Clamp treatment TableView.OnScrollContentPresenterPointerWheelChanged already gives the offset — so the geometry is always well formed. Behaviour is unchanged for every offset inside the panel's extent, which is every ordinary scroll position.

Tests

New tests/TableViewHeaderRowTests.cs:

  • HeaderClip_StaysAValidRect_WhenOffsetExceedsTheHeadersPanelExtent — two frozen columns wider than the viewport, scroll to the end, assert the clip is still a valid rectangle inside the panel's extent. Without the change this test fails with the ArgumentOutOfRangeException above (verified by reverting only the clamp, confirming the reverted IL in the built assembly, and re-running).
  • HeaderClip_IsUnchanged_WhenScrolledWithinTheHeadersPanelExtent — pins the ordinary case: clip X is the offset, clip width is the unscrolled remainder.

Full suite on this branch: 168/168 passed (vstest.console against WinUI.TableView.Tests.build.appxrecipe, x64 Debug, net9.0-windows10.0.19041.0).

Related Issue

Closes #437

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • 📝 Documentation update
  • ♻️ Refactor
  • 🧪 Test
  • 🔧 Chore / maintenance

Checklist

  • This PR is not from my main branch
  • Tested with WinUI target
  • Tested with Uno Platform target — see note below
  • Unit / integration tests added or updated
  • Documentation updated to reflect changes
  • Code follows the project's coding conventions

Additional Notes

On the Uno checkbox, precisely: src/WinUI.TableView.csproj builds clean for net10.0 (the Uno target) with this change, and the Uno-side behaviour described above was measured by reading the shipped Uno.Foundation.dllWindows.Foundation.Rect's constructor only validates when Uno.FoundationFeatureConfiguration.Rect.AllowNegativeWidthHeight is false, and it defaults to true. The test project targets net9.0-windows10.0.19041.0 only, so the suite itself has not been run on an Uno head.

The branch is cut from the v1.4.1 tag rather than main — that is the build these measurements were taken against, and src/TableViewHeaderRow.cs's ArrangeOverride is byte-identical on main, so it applies unchanged.

On the Uno side the clamp makes the geometry valid and stops the silent invalid-rect path; it does not by itself repaint headers for an offset that is genuinely past the panel's extent (the clip is then degenerate rather than invalid). Deciding what should be shown in that state looks like a separate question, so this change is deliberately limited to keeping the rectangle well formed.

🤖 Generated with Claude Code

TableViewHeaderRow.ArrangeOverride clips the scrollable headers panel to

    Rect(xClip, 0, panel.ActualWidth - xClip, finalSize.Height)

where xClip = (headersOffset * -1) + frozenOffset reduces algebraically to
TableView.HorizontalOffset. The enclosing guard only checks that the panel
has been measured (ActualWidth > 0); nothing keeps ActualWidth - xClip
positive, and a non-positive width is not a valid Rect.

It is reachable as a steady state whenever the frozen columns are wider than
the viewport: the scroll viewer's extent covers every column, so scrolling
to the end leaves HorizontalOffset past the scrollable headers panel's own
(much smaller) extent. On WinUI that throws straight out of the arrange
pass:

    System.ArgumentOutOfRangeException: Non-negative number required.
        (Parameter 'width')
      at Windows.Foundation.Rect..ctor(Single x, Single y, Single width, ...)
      at WinUI.TableView.TableViewHeaderRow.ArrangeOverride(Size finalSize)

On Uno the same construction is accepted verbatim (its Rect allows negative
width by default), and the resulting clip lands outside the panel, so every
header is hidden at the band's full height instead.

Clamp xClip into [0, ActualWidth] - the same Math.Clamp treatment
TableView.OnScrollContentPresenterPointerWheelChanged already gives the
offset - so the geometry is always well formed. Behaviour is unchanged
whenever the offset is inside the panel's extent, which is every ordinary
scroll position.

Adds TableViewHeaderRowTests: one test drives the offset past the panel's
extent and asserts the clip stays a valid rectangle (it throws the exception
above without this change), one pins the ordinary in-extent clip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The new UI tests load content into the test window but don’t unload it, which can cause cross-test interference/flakiness in the test suite.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
What changed in this PR

Fixes a layout/clip crash (WinUI) and rendering failure (Uno) in TableViewHeaderRow.ArrangeOverride by ensuring the scrollable header clip rectangle never has a negative width when the horizontal offset exceeds the scrollable headers panel extent.

Changes:

  • Clamp the computed clip X into [0, _scrollableHeadersPanel.ActualWidth] to guarantee a well-formed Rect.
  • Add UI tests that validate the clip stays valid when offset exceeds extent, and remains unchanged for normal in-range scrolling.
File Description
src/​TableViewHeaderRow.cs Clamps header clip geometry inputs to prevent negative-width Rect during arrange.
tests/​TableViewHeaderRowTests.cs Adds regression tests covering out-of-range and in-range header clipping behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +15 to +35
[UITestMethod]
public async Task HeaderClip_StaysAValidRect_WhenOffsetExceedsTheHeadersPanelExtent()
{
// Frozen columns wider than the viewport let HorizontalOffset run past the scrollable
// headers panel's own extent, which is what drives the clip width below zero.
var tableView = await CreateTableViewAsync(frozenColumnCount: 2);
var panel = GetScrollableHeadersPanel(tableView);

tableView.SetValue(TableView.HorizontalOffsetProperty, 5000d);
tableView.UpdateLayout();

Assert.IsTrue(tableView.HorizontalOffset > panel.ActualWidth,
$"Precondition: the offset ({tableView.HorizontalOffset}) is past the panel's extent ({panel.ActualWidth})");

var clip = panel.Clip;
Assert.IsNotNull(clip, "A clip is applied once the headers are scrolled");
Assert.IsTrue(clip!.Rect.Width >= 0,
$"Header clip width must never be negative (was {clip.Rect.Width})");
Assert.IsTrue(clip.Rect.X >= 0 && clip.Rect.X <= panel.ActualWidth,
$"Header clip X must stay inside the panel's extent (was {clip.Rect.X} for a panel {panel.ActualWidth} wide)");
}

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TableViewHeaderRow.ArrangeOverride can build a negative-width clip Rect (throws on WinUI, hides all headers on Uno)

3 participants