Files
microsoft-ui-xaml/controls/dev/TableView/TableView_Layout.cpp
T
Hitesh KumarandCopilot d5132fd2e4 TableView: templated column headers, UIA resize, sort fixes, recycle perf, theming (#12307)
## Fixes

| Internal issue | Type | Change |
|---|---|---|
| https://task.ms/64475625 | Bug | Column headers are a templated,
restylable `TableViewColumnHeader` with visual states |
| https://task.ms/64475623 | Bug | Header typography comes from the
header Style, not C++ local values |
| https://task.ms/64143451 | Bug | Header column separators are always
drawn (no new property); hide them by overriding
`TabularSurfaceHeaderSeparatorBrush` |
| https://task.ms/63996584 | Task | Column headers expose the UIA
Transform pattern so UIA clients can resize columns |
| https://task.ms/64475611 | Bug | Outer border uses new
`TabularSurfaceOuterBorderBrush` (`CardStrokeColorDefault`); grid-line
values were in #12349 |
| https://task.ms/64475629 | Bug | Default group header uses theme
tokens (SemiBold key style, 8px spacing); the item count inherits the
header `Foreground` |
| https://task.ms/64652961 | Task | Row recycling no longer regenerates
cells or refreshes row chrome twice (scroll performance) |
| https://task.ms/63790855 | Task | Sort: `CanSort` is enforced and
`SortByColumn(column, None)` always clears; a sort through a source
another table owns returns false; no spurious `Sorted`; an app-owned
sort survives a `CanSort`/`CanUserSortColumns` opt-out |
| https://task.ms/63787021 | Task | Cell automation-peer constructor
validates its arguments; `BeginEdit` rolls back when it throws |

Not changed: https://task.ms/64512732 (by design: Auto columns follow
content width), https://task.ms/64475627 (design decision: keep chevron
sort glyphs), https://task.ms/64513223 (investigation only; fix
deferred).

## PR Type

- [x] Bugfix
- [x] Feature
- [ ] Code style update (formatting, renaming)
- [ ] Refactoring (no functional changes, no API changes)
- [ ] Build related changes
- [x] Documentation content changes
- [ ] Other (please describe):

## Description

Follow-up to #12349 (merged) and #12285.

Column headers move from code-built Grids to a new templated
`TableViewColumnHeader`, restylable through
`TableView.ColumnHeaderStyle` or `TableViewColumn.HeaderStyle`. Header
peers gain the UIA Transform pattern for column resize. Sort behavior is
tightened around `CanSort`, opt-outs and shared `TableViewSource`
ownership. Row recycling reuses cells. Outer border, header lines and
the default group header follow Fluent tokens.

Review-driven fixes included here:
- The header Style's `TargetType` `TypeName` is kept alive while it is
compared; a Style with only `BasedOn` typed is applied, and its setters
are read before applying it seals the Style. Incompatible, untyped or
cyclic `BasedOn` Styles are ignored with a debug diagnostic.
- A header disabled by its Style reports `IsEnabled=false` and is not
invokable through UIA.
- UIA `Transform.Resize` converts physical pixels through the table's
full visual scale (not only `RasterizationScale`), and a width change
below the drag jitter deadband is written once, exactly.
- Disabling a header in its High Contrast hover/pressed state keeps the
disabled foreground (state order fixed); the disabled sort glyph follows
the text brush across theme changes.
- A `CanSort` opt-out made inside `BeginningEdit` is replayed after the
edit it let open closes, and rebuilds headers even when a `Sorted`
handler throws.
- Header rebuilds caused by `Content`/`Header` or `Columns` changes keep
focus in the header band, respect an app's own focus move, and release
`UIElement` headers back to the app.
- A disabled group header does not dim its item count twice. The count
opacity follows `ContentTemplate` changes while the header is disabled.

<details><summary>Product changes by file</summary>

| File | +/- | What changed | Items |
|---|---|---|---|
| `TableView/TableView.cpp` (+ .h) | +684 / −382 | `RebuildHeaders`
creates `TableViewColumnHeader` controls; applies
`ColumnHeaderStyle`/`HeaderStyle` with TargetType/BasedOn checks;
density MinHeight/Padding only when the Style doesn't set
them<br>`ReleaseHeaderCells` and header-focus handling for
removed/replaced columns, re-entrant rebuilds and app focus
moves<br>Header bottom rule thickness set on `PART_HeaderRow`; header
lines no longer follow `GridLinesVisibility`<br>Row prepare/clear
refresh chrome once; `TryResizeColumnFromAutomation` writes small widths
exactly | 64475625, 64475623, 64143451, 64652961, 63996584, 63790855 |
| `TableView/TableViewColumnHeader.cpp` (+ .h; replaces
`TableViewHeaderCell.h`) | +435 / −210 | New sealed templated
`ContentControl` with read-only `Column`<br>Resolves
`PART_ContentPresenter`, `PART_GridLine`, `PART_SortIndicatorHost`,
`PART_ResizeGripperHost`; hosts the sort glyph and gripper<br>Drives
`CommonStates`/`HighContrastStates`/`SortStates`; disabled sort glyph
bound to the text brush | 64475625, 64475623 |
| `TableView/TableViewRow.cpp` (+ .h) | +191 / −35 | Recycled rows keep
their cells unless cell automation peers exist; chrome refreshed once;
tooltips re-applied on recycle<br>`BeginCellEdit` restores display
element, edit state and focus on throw | 64652961, 63787021 |
| `TableView/TableView.xaml` | +128 / −18 | Default
`TableViewColumnHeader` Style/template: typography setters, visual
states, parts<br>Outer border uses `TabularSurfaceOuterBorderBrush`;
header lines use `TabularSurfaceHeaderSeparatorBrush`<br>Group header:
`TableViewGroupExpanderSpacing` (8), padding 8,8,12,8, key/count text
styles, count inherits `Foreground` | 64475625, 64475623, 64475611,
64475629 |
| `TableView/TableViewColumnHeaderAutomationPeer.cpp` (+ .h) | +127 / −9
| `ITransformProvider`: `Resize` converts physical px through the visual
scale; `CanResize` tracks gripper availability (pinned →
false)<br>`IsEnabled` follows the header; name read from the content
presenter | 63996584, 64475625 |
| `TableView/TableView_Sort.cpp` | +113 / −39 | `SortByColumn(None)`
bypasses the `CanSort` gate; other sorts refused<br>Sort through a
source owned by another table returns false; implicit clears keep the
app's sort and raise no `Sorted`; app-lit columns left alone on opt-out
| 63790855 |
| `TableView/TableView_Layout.cpp` | +74 / −14 | Realized-row walk only
when a visible Auto column exists<br>Transform `CanResize` changes
raised for existing header peers | 64652961, 63996584 |
| `TableView/TableView_Editing.cpp` | +63 / −15 | `BeginEdit` unwinds on
throw; `CanSort` opt-out during `BeginningEdit` queued until the edit
closes | 63787021, 63790855 |
| `TableView/TableViewCellAutomationPeer.cpp` (+ .h, `TableViewCell.h`)
| +48 / −0 | Public constructor throws `E_INVALIDARG` for null args,
negative/out-of-range index or a foreign column; internal path uses
`TrustedOwnerTag` | 63787021 |
| `TableView/TableViewGroupHeader.cpp` (+ .h) | +39 / −0 | Default
count's opacity is not applied twice when disabled | 64475629 |
| `TableView/TableView.idl` | +31 / −13 | `TableViewColumnHeader`,
`TableView.ColumnHeaderStyle`,
`TableViewColumn.HeaderStyle`<br>`CanSort`/`SortByColumn`/`ToggleSortDirection`/`GridLinesVisibility`
docs; cell-peer `E_INVALIDARG`; group-header parts | 64475625, 63790855,
64143451, 63787021 |
| `TableView/TableView_Columns.cpp` | +19 / −0 | Releases removed
columns' header cells; old `UIElement` `Header` returned to the app on
change | 64475625 |
| `TableView/TableViewColumn.cpp` | +7 / −0 | `HeaderStyle` change
queues a header rebuild | 64475625 |
| `TableView/TableViewSource.h` | +6 / −0 | `IsOwnedByOther` helper for
sort sync | 63790855 |
| `CommonStyles/TabularSurfaces_themeresources.xaml` | +5 / −2 | New
`TabularSurfaceOuterBorderBrush` (Dark/Light/HC); group count opacity
documented as secondary-over-primary text (0.77 Dark) | 64475611,
64475629 |
| `TableView/TableView.vcxitems` | +2 / −1 | Swap
`TableViewHeaderCell.h` for `TableViewColumnHeader.*` | 64475625 |
| Generated / metadata (`Generated/TableView.properties.*`,
`Generated/TableViewColumn.properties.*`,
`dll-tabular/XamlMetadataProviderGenerated.h`) | +123 / −0 | Codegen for
the new properties and type | 64475625 |

**Total: 17 rows, 29 product files, +2117 / −738.** Also: 19 test files
(+3718 / −32), 3 docs (+161 / −35), 6 sample files (+2 / −9).

</details>

### New preview API (needs API review)

- **`TableViewColumnHeader`**: sealed, templated `ContentControl`,
created only by `TableView`.
  - Read-only `Column` property.
- Template parts (optional): `PART_ContentPresenter`, `PART_GridLine`,
`PART_SortIndicatorHost`, `PART_ResizeGripperHost`.
- Visual state groups: `CommonStates`, `HighContrastStates`,
`SortStates`.
- **`TableView.ColumnHeaderStyle`**: Style applied to every column
header.
- **`TableViewColumn.HeaderStyle`**: per-column Style; wins over
`TableView.ColumnHeaderStyle`.

There is no `HeaderGridLinesVisibility` property. `GridLinesVisibility`
affects body lines only; header lines are always drawn. To hide them,
override `TabularSurfaceHeaderSeparatorBrush` with `Transparent`.

### Current Behavior
- Headers are built in C++ with no template, no visual states and forced
local typography, so apps can't restyle them. Header separators don't
show.
- Header peers report `HeaderItem` without Transform, so UIA clients
can't resize columns.
- `SortByColumn` ignores `CanSort`; `CanSort=false` on a sorted column
leaves the sort in place; sorting through a source another table owns
reports success; some implicit clears raise `Sorted(None)` while the
app's sort remains.
- The cell peer constructor accepts invalid arguments; a throw in
`BeginEdit` leaves edit state half-open.
- Recycling a row rebuilt every cell, refreshed chrome up to twice and
invalidated the table's measure.
- The outer border has no dedicated brush; the default group header uses
raw weights and off-grid spacing.

### New Behavior
- Headers use the templated `TableViewColumnHeader` (see API above);
typography, padding and brushes come from Style setters.
- Header peers implement `ITransformProvider` through the existing
resize path (Min/Max clamping, scale conversion; pinned columns report
`CanResize=false`).
- `CanSort=false` blocks header clicks, `SortByColumn` (except `None`)
and `ToggleSortDirection`, and clears the column's own active sort;
sorts through a source owned by another table return false; `Sorted` is
raised only when the table's sort state changes.
- Cell peer constructor throws `E_INVALIDARG` on bad input; `BeginEdit`
rolls back on failure.
- Recycled rows reuse their cells unless cell automation peers exist;
chrome refreshes once.

> **Visible changes**
> - Outer border uses `CardStrokeColorDefault`.
> - Header lines are always shown, even when `GridLinesVisibility` is
`None` or `Horizontal`.
> - Group header restyled: 8px spacing; the count follows the header
`Foreground`.
> - Disabled header text and sort glyph dim.
> - TableView samples no longer opt out of High Contrast backplates
app-wide.

## Customer Impact

Restylable column headers, visible header separators, Fluent-aligned
outer border and group headers, UIA column resize for assistive
technology, smoother scrolling and predictable sort events.

## Regression Potential

- [ ] Low risk — isolated change, limited scope
- [x] Medium risk — touches shared components or public APIs
- [ ] High risk — architectural or breaking API change

Header construction (focus restore, frozen columns, UIA peer, tooltips,
resize, sort glyph) moved to a templated control. Sort event semantics
are tighter: `CanSort` is enforced on programmatic sorts and fewer
`Sorted` events fire. Outer border color and always-on header lines
change visibly. Covered by new and existing tests.

## How Has This Been Tested?

- [x] I have performed a self-review of my own code
- [x] I have added tests to cover my changes
- [x] Existing tests pass locally

Full TableView API and interaction suites on test VMs: 877/882 pass at
224d10d5c; 3 Localization tests are [Ignore] and 2 intermittent failures
passed 3/3 on rerun. Product, test apps and TableView sample apps build
(x64 chk).

Also covered:
`VerifyDisabledGroupHeaderCountFollowsContentTemplateChanges`.

Key new tests:
- `VerifyColumnHeaderTemplateParts`, `VerifyColumnHeaderSortStates`,
`VerifyColumnHeaderTypographyHasNoLocalValues`
- `VerifyColumnHeaderStyleApplies`,
`VerifyBasedOnOnlyColumnHeaderStyleApplies`,
`VerifyIncompatibleColumnHeaderStyleIsIgnored`
- `VerifyHeaderGridLinesShowWhateverTheBodyGridLines`,
`VerifyTransparentHeaderSeparatorBrushHidesHeaderLines`,
`VerifyGridLineAndOuterBorderBrushesMatchFluentStrokeTokens`
- `VerifyDisablingHighContrastHoveredHeaderKeepsDisabledForeground`,
`VerifyDefaultGroupHeaderCountInheritsForegroundThatFollowsStates`
- `VerifyTransformResizeAccountsForAncestorScale`,
`VerifyTransformResizeSmallWidthChangeWritesOnce`,
`VerifyHeaderDisabledByStyleIsNotEnabledOrInvokable`
- `VerifySortByColumnRespectsCanSort`,
`VerifyImplicitClearKeepsSourceSort`,
`VerifyUngatingKeepsAppLitColumnWithoutEvents`,
`VerifySortOptOutDuringBeginningEditKeepsTheEditOpen`
- `VerifyRecycledRowWithoutCellPeersReusesItsCells`,
`VerifyHeaderFocusStaysInHeaderBandWhenColumnsReplaced`,
`VerifyBeginEditThatThrowsRollsBackAndAllowsALaterEdit`,
`VerifyCellPeerConstructorValidatesArguments`

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 94961bb8-d187-483c-bece-19e203752c67
2026-10-23 14:10:17 +05:30

728 lines
28 KiB
C++

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
#include "pch.h"
#include "common.h"
#include "TableView.h"
#include "TableViewColumn.h"
#include "TableViewRow.h"
#include "TableViewGroupHeader.h"
#include "TableViewCell.h"
#include "TableViewColumnHeader.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <vector>
// -----------------------------------------------------------------------------
// Column-width layout engine (all of it lives here).
//
// TableView owns the column-width policy; rows and headers are thin plumbing. Each cell host panel
// measures its Auto-column children unconstrained and caches those measured widths by column;
// Pixel/Star cells are measured once at their resolved width (no unconstrained probe), mirroring
// CGrid. After the template subtree measures, TableView::MeasureOverride pulls those cached values
// from the header host and realized rows once, then resolves Pixel / Auto / Star widths into
// TableViewColumn.ActualWidth.
//
// Only realized rows contribute, so Auto sizes to the widest cell realized so far. Auto is grow-only
// within a data set, so a column never narrows as rows scroll out (ResetColumnDesiredWidths).
//
// Resolved widths (Pixel / Auto / Star) are snapped to device pixels when UseLayoutRounding is set,
// using the XamlRoot rasterization scale -- again mirroring CGrid.
// -----------------------------------------------------------------------------
namespace
{
bool IsFixedWidthColumn(winrt::TableViewColumn const& column)
{
const double lo = NonNegativeFiniteOrZero(column.MinWidth());
return MaxWidthForColumn(column, lo) <= lo;
}
double MinWidthForStarFactor(winrt::TableViewColumn const& column, double factor)
{
if (factor > 0.0)
{
return NonNegativeFiniteOrZero(column.MinWidth());
}
// WPF gives 0* a zero share. Preserve that for the default MinWidth, but still honor an
// explicitly-set MinWidth on a 0* column.
const auto localMinWidth = column.ReadLocalValue(winrt::TableViewColumn::MinWidthProperty());
return localMinWidth == winrt::DependencyProperty::UnsetValue()
? 0.0
: NonNegativeFiniteOrZero(column.MinWidth());
}
// Divides `available` in proportion to each column's Star factor. A column that would clamp at
// its Min/MaxWidth is fixed there and dropped, then the rest re-divide what is left (the WPF
// ComputeStarColumnWidths shape).
template <typename TFactor, typename TRound, typename TResolve>
void DistributeStarWidths(
std::vector<winrt::TableViewColumn> pool,
double available,
TFactor&& factorOf,
TRound&& layoutRound,
TResolve&& resolve)
{
auto totalFactor = [&]()
{
double total = 0.0;
for (auto const& c : pool)
{
total += NonNegativeFiniteOrZero(factorOf(c));
}
return total;
};
for (bool adjusted = true; adjusted && !pool.empty(); )
{
adjusted = false;
const double factorSum = totalFactor();
const double unit = factorSum > 0.0 ? available / factorSum : 0.0;
for (size_t i = 0; i < pool.size(); ++i)
{
auto const& c = pool[i];
const double factor = NonNegativeFiniteOrZero(factorOf(c));
const double desired = unit * factor;
const double lo = MinWidthForStarFactor(c, factor);
const double hi = MaxWidthForColumn(c, lo);
const double clamped = std::clamp(desired, lo, hi);
// std::clamp returns desired exactly when it is already in [lo, hi], so any
// inequality is a real Min/MaxWidth clamp.
if (clamped != desired)
{
resolve(c, clamped);
available -= clamped;
pool.erase(pool.begin() + i);
adjusted = true;
break;
}
}
}
const double factorSum = totalFactor();
const double unit = factorSum > 0.0 ? std::max(0.0, available) / factorSum : 0.0;
// Rounded on the running total rather than per column, so the widths still sum to
// `available` once snapped; rounding each independently can overshoot the viewport and
// leave a permanent one-pixel scrollbar.
double exactConsumed = 0.0;
double roundedConsumed = 0.0;
for (auto const& c : pool)
{
const double factor = NonNegativeFiniteOrZero(factorOf(c));
const double lo = MinWidthForStarFactor(c, factor);
const double hi = MaxWidthForColumn(c, lo);
exactConsumed += std::clamp(unit * factor, lo, hi);
const double edge = layoutRound(exactConsumed);
resolve(c, edge - roundedConsumed);
roundedConsumed = edge;
}
}
}
winrt::Size TableView::MeasureOverride(winrt::Size const& availableSize)
{
auto desired = __super::MeasureOverride(availableSize);
ResolveColumnWidths();
QueueTerminalGridLineRefresh();
return desired;
}
// Requested from a cell panel's MeasureOverride when a realized cell's own measured width changed
// (grow or shrink). The request comes from below the body ScrollViewer, which absorbs the child's
// measure invalidation, so TableView::MeasureOverride (and ResolveColumnWidths) would not otherwise
// re-run. Invalidate our measure SYNCHRONOUSLY (not via the DispatcherQueue): InvalidateMeasure only
// marks us dirty, so the layout manager re-measures the TableView within the SAME layout tick -- the
// column resizes in the same frame as the content change (no one-frame lag, so drags stay smooth).
// It converges without a debounce: the delta detector records each cell's new width in the pass that
// detects it, so the follow-up measure sees no delta and stops. Calling InvalidateMeasure during a
// descendant's measure is safe -- it schedules, it does not re-enter layout.
void TableView::RequestColumnWidthResolve()
{
InvalidateMeasure();
}
bool TableView::HasVisibleAutoColumn()
{
if (auto const columns = Columns())
{
for (auto const& column : columns)
{
if (column &&
column.Visibility() == winrt::Visibility::Visible &&
column.Width().GridUnitType == winrt::GridUnitType::Auto &&
winrt::get_self<TableViewColumn>(column)->GetOwningTableView() == *this)
{
return true;
}
}
}
return false;
}
// A newly prepared row's first measure raises no width-change signal, so Auto columns need this
// table pass to pick its cells up.
void TableView::InvalidateMeasureForRowRealizationChange()
{
if (HasVisibleAutoColumn())
{
InvalidateMeasure();
}
}
// Reset the monotonic Auto desired widths. The next table measure pass remeasures the template
// subtree, pulls header + realized-row measured widths, and resolves once from those caches.
void TableView::ResetColumnDesiredWidths()
{
if (auto columns = Columns())
{
for (auto const& column : columns)
{
// Reset EVERY column's grow-only accumulator regardless of its current Width mode. A
// column that was Auto, temporarily switched to Pixel/Star, then switched back must not
// retain a stale max from a previous data set. This runs only on real data-set boundaries
// (ItemsSource / Columns replaced / CellTemplate / Header), so it correctly clears across
// them while Density / Min / Max / HeadersVisibility changes (which do not call this)
// preserve the max within a data set.
if (column && winrt::get_self<TableViewColumn>(column)->GetOwningTableView() == *this)
{
winrt::get_self<TableViewColumn>(column)->ResetDesiredWidthInternal();
}
}
}
InvalidateMeasure();
}
// Resolve every visible column into ActualWidth. Fixed columns (Pixel + measured Auto) are sized
// first; Star columns then split the viewport width left over between them.
void TableView::ResolveColumnWidths()
{
auto columns = Columns();
if (!columns)
{
return;
}
// Snap resolved column widths to device pixels when layout rounding is enabled, mirroring CGrid's
// use of the rasterization scale. A zero factor leaves values unrounded.
double layoutRoundFactor = 0.0;
if (UseLayoutRounding())
{
if (auto const xamlRoot = XamlRoot())
{
layoutRoundFactor = xamlRoot.RasterizationScale();
}
}
auto layoutRound = [layoutRoundFactor](double value)
{
return layoutRoundFactor > 0.0 ? std::round(value * layoutRoundFactor) / layoutRoundFactor : value;
};
auto setResolvedActualWidth = [&layoutRound](winrt::TableViewColumn const& target, double resolved)
{
resolved = layoutRound(resolved);
const bool changed = std::abs(resolved - target.ActualWidth()) > 0.0001;
winrt::get_self<TableViewColumn>(target)->SetResolvedActualWidthInternal(resolved);
return changed;
};
double fixedTotal = 0.0;
bool changed = false;
std::vector<winrt::TableViewColumn> starColumns;
// Collect the currently realized, BOUND rows ONCE (a single repeater walk) and reuse the list for
// every Auto column below. Lazy, so tables without an Auto column skip the walk entirely.
std::vector<winrt::TableViewRow> realizedRows;
bool realizedRowsCollected = false;
auto ensureRealizedRows = [this, &realizedRows, &realizedRowsCollected]()
{
if (realizedRowsCollected)
{
return;
}
realizedRowsCollected = true;
if (auto repeater = m_rowsRepeater.get())
{
ForEachRealizedRow([&realizedRows, &repeater](winrt::TableViewRow const& row)
{
// Skip pooled rows (GetElementIndex < 0): their stale widths would re-pin Auto
// columns after ItemsSource shrinks.
if (repeater.GetElementIndex(row) >= 0)
{
realizedRows.push_back(row);
}
});
}
};
for (auto const& column : columns)
{
// Rejected columns still appear in the app-owned vector; do not resolve widths for another
// TableView's column.
if (!column ||
winrt::get_self<TableViewColumn>(column)->GetOwningTableView() != *this ||
column.Visibility() != winrt::Visibility::Visible)
{
continue;
}
const auto width = column.Width();
const double lo = NonNegativeFiniteOrZero(column.MinWidth());
const double hi = MaxWidthForColumn(column, lo);
switch (width.GridUnitType)
{
case winrt::GridUnitType::Star:
// Sized below once the fixed total is known.
starColumns.push_back(column);
break;
case winrt::GridUnitType::Auto:
{
// Pull measured widths from the header and currently realized row panels. Rows and header
// are tracked separately, so toggling the headers never drops the rows' width.
double headerMeasured = 0.0;
// Only consume the header host's cache when headers are actually shown: a hidden header is
// not measured this pass, so its per-pass cache is stale (and, with the raw-identity key,
// a freed column's address could be reused by a new column and false-match a stale entry).
// A hidden header must not drive column width regardless.
if (ShouldShowColumnHeaders())
{
headerMeasured = NonNegativeFiniteOrZero(GetHeaderMeasuredWidthForColumn(column));
}
double rowsMeasured = 0.0;
ensureRealizedRows();
for (auto const& row : realizedRows)
{
// Ask the row for its cell's measured width instead of reaching into its panel.
rowsMeasured = std::max(rowsMeasured, winrt::get_self<TableViewRow>(row)->MeasuredWidthForColumn(column));
}
auto columnImpl = winrt::get_self<TableViewColumn>(column);
// Rows are grow-only within a data set (WPF / WCT DataGrid parity), so scrolling never
// narrows the column. Headers are never virtualized, so the current measurement is used.
columnImpl->SetDesiredWidthInternal(
std::max(columnImpl->RowsDesiredWidthInternal(), NonNegativeFiniteOrZero(rowsMeasured)),
headerMeasured);
const double desired = columnImpl->DesiredWidthInternal();
const double resolved = layoutRound(
std::clamp(desired > 0.0 ? desired : c_widthDefault.Value, lo, hi));
changed |= setResolvedActualWidth(column, resolved);
fixedTotal += resolved;
break;
}
case winrt::GridUnitType::Pixel:
default:
{
// A locked column the authored pass below sizes from its Star share must not also be
// booked as fixed here, or it lands in fixedTotal twice and starves the real donors.
if (CanUserResizeColumns() && !column.CanResize() &&
winrt::get_self<TableViewColumn>(column)->AuthoredWidthInternal().GridUnitType == winrt::GridUnitType::Star)
{
starColumns.push_back(column);
break;
}
const double desired = NonNegativeFiniteOrDefault(width.Value);
const double resolved = layoutRound(
std::clamp(desired, lo, hi));
changed |= setResolvedActualWidth(column, resolved);
fixedTotal += resolved;
break;
}
}
}
if (starColumns.empty())
{
if (changed)
{
InvalidateCellPanels();
RefreshFrozenColumns();
}
return;
}
// Star needs a finite viewport to divide. The horizontally-scrolling body panel is measured at
// infinite width, so we pull the ScrollViewer viewport explicitly. Before it is known, leave the
// Star columns at their provisional width; the body-scroller SizeChanged re-resolves later.
auto bodyScroller = m_bodyScroller.get();
const double viewport = bodyScroller ? bodyScroller.ViewportWidth() : 0.0;
// A non-finite or not-yet-known viewport has no finite space to divide (e.g. the table hosted
// in a width-to-content parent); leave Star columns at their provisional width rather than
// arranging an infinite cell. The body scroller's SizeChanged re-resolves once a real width lands.
if (!(viewport > 0.0) || std::isinf(viewport))
{
if (changed)
{
InvalidateCellPanels();
RefreshFrozenColumns();
}
return;
}
const bool resizeEnabled = CanUserResizeColumns();
auto authoredOf = [](winrt::TableViewColumn const& c)
{
return winrt::get_self<TableViewColumn>(c)->AuthoredWidthInternal();
};
// Only while a resize can actually happen; otherwise no column's width is at risk and the
// ordinary pool already clamps correctly.
auto isLocked = [resizeEnabled](winrt::TableViewColumn const& c)
{
return resizeEnabled && !c.CanResize();
};
if (std::any_of(starColumns.begin(), starColumns.end(), isLocked))
{
// A locked Star column takes its share of the authored layout rather than of whatever a
// neighbor's resize left behind, so a drag cannot change its width.
double authoredFixedTotal = 0.0;
std::vector<winrt::TableViewColumn> authoredPool;
for (auto const& c : columns)
{
if (!c ||
winrt::get_self<TableViewColumn>(c)->GetOwningTableView() != *this ||
c.Visibility() != winrt::Visibility::Visible)
{
continue;
}
const auto authored = authoredOf(c);
if (authored.GridUnitType == winrt::GridUnitType::Star)
{
authoredPool.push_back(c);
continue;
}
// Clamped and rounded exactly as the resolved pass above does, so the authored basis
// and the real layout agree on what the fixed columns take.
const double lo = NonNegativeFiniteOrZero(c.MinWidth());
const double hi = MaxWidthForColumn(c, lo);
if (authored.GridUnitType == winrt::GridUnitType::Auto)
{
// The content width, not the width a resize gave it: a dragged Auto column must not
// change what the authored layout leaves for the Star columns.
const double desired = winrt::get_self<TableViewColumn>(c)->DesiredWidthInternal();
authoredFixedTotal += layoutRound(std::clamp(desired > 0.0 ? desired : c.ActualWidth(), lo, hi));
}
else
{
const double desired = NonNegativeFiniteOrDefault(authored.Value);
authoredFixedTotal += layoutRound(
std::clamp(desired, lo, hi));
}
}
DistributeStarWidths(
authoredPool,
std::max(0.0, layoutRound(viewport) - authoredFixedTotal),
[&authoredOf](winrt::TableViewColumn const& c) { return std::max(0.0, authoredOf(c).Value); },
layoutRound,
[&](winrt::TableViewColumn const& c, double width)
{
if (isLocked(c))
{
changed |= setResolvedActualWidth(c, width);
fixedTotal += c.ActualWidth();
}
});
starColumns.erase(
std::remove_if(starColumns.begin(), starColumns.end(), isLocked), starColumns.end());
}
// The viewport basis is layout-rounded so the divided space is snapped consistently with the
// fixed columns (CGrid rounds availableSize before distribution); per-column Star widths are
// then snapped in setResolvedActualWidth.
DistributeStarWidths(
starColumns,
std::max(0.0, layoutRound(viewport) - fixedTotal),
[](winrt::TableViewColumn const& c) { return std::max(0.0, c.Width().Value); },
layoutRound,
[&](winrt::TableViewColumn const& c, double width)
{
changed |= setResolvedActualWidth(c, width);
});
if (changed)
{
InvalidateCellPanels();
RefreshFrozenColumns();
}
}
// Puts Width back the way the app left it. Restoring the effective value would convert a binding
// or an inherited default into a local value the app never set.
void TableView::RestoreColumnWidth(const winrt::TableViewColumn& column, const winrt::IInspectable& localWidth)
{
auto const columnImpl = winrt::get_self<TableViewColumn>(column);
auto const resizeScope = columnImpl->BeginUserResizeScope();
if (!localWidth || localWidth == winrt::DependencyProperty::UnsetValue())
{
column.ClearValue(winrt::TableViewColumn::WidthProperty());
}
else
{
column.SetValue(winrt::TableViewColumn::WidthProperty(), localWidth);
}
}
// A resize takes space only from the columns after the dragged one (the WPF DataGrid contract).
// Holding the earlier Star columns at the width they already render keeps them out of the
// redistribution pass without changing what the user sees. A locked column is skipped: the
// authored pass below already pins it, and freezing it would double-count it there.
void TableView::FreezeColumnsBeforeResize(const winrt::TableViewColumn& column, std::vector<ColumnResizeFrozenColumn>& frozen)
{
auto columns = Columns();
uint32_t index = 0;
if (!columns || !columns.IndexOf(column, index))
{
return;
}
// Collected before any write: writing Width runs app callbacks that may mutate Columns, and
// indexing a live vector across that would throw out of the manipulation.
std::vector<winrt::TableViewColumn> candidates;
for (uint32_t i = 0; i < index && i < columns.Size(); ++i)
{
auto const other = columns.GetAt(i);
if (!other ||
winrt::get_self<TableViewColumn>(other)->GetOwningTableView() != *this ||
other.Visibility() != winrt::Visibility::Visible ||
other.Width().GridUnitType != winrt::GridUnitType::Star ||
!other.CanResize())
{
continue;
}
candidates.push_back(other);
}
for (auto const& other : candidates)
{
frozen.push_back({ winrt::make_weak(other), other.ReadLocalValue(winrt::TableViewColumn::WidthProperty()) });
auto const columnImpl = winrt::get_self<TableViewColumn>(other);
auto const resizeScope = columnImpl->BeginUserResizeScope();
other.Width(winrt::GridLengthHelper::FromPixels(other.ActualWidth()));
}
}
// How far a drag may take this column. A table whose columns divide the viewport may not grow past
// it, and a column the user may not resize neither gives width away nor takes any.
ColumnResizeBounds TableView::ResizeBoundsForColumn(const winrt::TableViewColumn& column)
{
ColumnResizeBounds bounds;
auto columns = Columns();
auto bodyScroller = m_bodyScroller.get();
const double viewport = bodyScroller ? bodyScroller.ViewportWidth() : 0.0;
if (!columns || !(viewport > 0.0) || std::isinf(viewport))
{
return bounds;
}
auto authoredType = [](winrt::TableViewColumn const& c)
{
return winrt::get_self<TableViewColumn>(c)->AuthoredWidthInternal().GridUnitType;
};
const bool resizeEnabled = CanUserResizeColumns();
uint32_t draggedIndex = 0;
if (!columns.IndexOf(column, draggedIndex))
{
return bounds;
}
double reservedForOthers = 0.0;
bool dividesViewport = authoredType(column) == winrt::GridUnitType::Star;
for (uint32_t i = 0; i < columns.Size(); ++i)
{
auto const other = columns.GetAt(i);
if (!other ||
i == draggedIndex ||
winrt::get_self<TableViewColumn>(other)->GetOwningTableView() != *this ||
other.Visibility() != winrt::Visibility::Visible)
{
continue;
}
dividesViewport |= authoredType(other) == winrt::GridUnitType::Star;
// Only a column that is Star *now* and sits after the dragged one can yield space: the
// layout pass re-divides by current Width, and a resize never takes from its left.
const bool participates =
i > draggedIndex &&
other.Width().GridUnitType == winrt::GridUnitType::Star &&
resizeEnabled && other.CanResize() &&
!IsFixedWidthColumn(other);
if (!participates)
{
reservedForOthers += other.ActualWidth();
}
else
{
reservedForOthers += MinWidthForStarFactor(other, std::max(0.0, other.Width().Value));
}
}
// Without a Star column anywhere the extent is meant to grow and scroll.
if (!dividesViewport)
{
return bounds;
}
// Pinned in both directions: bounding only growth would let the drag hand width to a column
// that is then not allowed to give it back. A Pixel or Auto column owns its width outright, so
// it may still shrink -- that only makes the table narrower and needs nothing from a neighbour.
if (IsColumnResizePinned(column))
{
bounds.Min = column.ActualWidth();
bounds.Max = bounds.Min;
return bounds;
}
bounds.Max = std::max(0.0, viewport - reservedForOthers);
return bounds;
}
// MinWidth meets MaxWidth, or a Star column with no later resizable Star column to trade with.
// Shared by the drag bounds and gripper visibility so the two cannot disagree.
bool TableView::IsColumnResizePinned(const winrt::TableViewColumn& column)
{
if (!column)
{
return false;
}
if (IsFixedWidthColumn(column))
{
return true;
}
if (column.Width().GridUnitType != winrt::GridUnitType::Star)
{
return false;
}
auto columns = Columns();
uint32_t columnIndex = 0;
if (!columns || !columns.IndexOf(column, columnIndex))
{
return false;
}
if (!CanUserResizeColumns())
{
return true;
}
for (uint32_t i = columnIndex + 1; i < columns.Size(); ++i)
{
auto const other = columns.GetAt(i);
if (other &&
winrt::get_self<TableViewColumn>(other)->GetOwningTableView() == *this &&
other.Visibility() == winrt::Visibility::Visible &&
other.Width().GridUnitType == winrt::GridUnitType::Star &&
other.CanResize() &&
!IsFixedWidthColumn(other))
{
return false;
}
}
return true;
}
void TableView::RefreshResizeGripperAvailability()
{
auto const host = m_headerHost.get();
if (!host)
{
return;
}
auto const children = host.Children();
const uint32_t count = children.Size();
for (uint32_t i = 0; i < count; ++i)
{
auto const headerCell = children.GetAt(i).try_as<winrt::FrameworkElement>();
if (!headerCell)
{
continue;
}
auto const gripper = FindResizeGripperInCell(headerCell);
auto const column = headerCell.Tag().try_as<winrt::TableViewColumn>();
if (!gripper || !column)
{
continue;
}
auto const visibility = IsColumnResizePinned(column) ? winrt::Visibility::Collapsed : winrt::Visibility::Visible;
if (gripper.Visibility() == visibility)
{
continue;
}
// Only a peer a client already holds is notified; FromElement would create one.
auto const header = headerCell.try_as<winrt::TableViewColumnHeader>();
auto const headerPeer = header && winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::PropertyChanged)
? winrt::get_self<TableViewColumnHeader>(header)->GetExistingAutomationPeerInternal() : nullptr;
// The pin state is already updated, so the old value comes from the visibility being replaced.
const bool couldResize = gripper.Visibility() == winrt::Visibility::Visible && IsColumnResizeAllowed(column);
gripper.Visibility(visibility);
if (headerPeer)
{
if (const bool canResize = CanResizeColumnFromAutomation(column); canResize != couldResize)
{
headerPeer.RaisePropertyChangedEvent(
winrt::TransformPatternIdentifiers::CanResizeProperty(),
winrt::box_value(couldResize),
winrt::box_value(canResize));
}
}
}
}
// Re-run the cell panels' measure/arrange so the header band and all rows reflect the newly resolved
// column widths (Auto growth in one row must widen the header and every other row).
void TableView::InvalidateCellPanels()
{
if (auto headerHost = m_headerHost.get())
{
headerHost.InvalidateMeasure();
}
if (auto repeater = m_rowsRepeater.get())
{
const auto childCount = winrt::VisualTreeHelper::GetChildrenCount(repeater);
for (int32_t i = 0; i < childCount; ++i)
{
if (auto row = winrt::VisualTreeHelper::GetChild(repeater, i).try_as<winrt::TableViewRow>())
{
// Ask the row to invalidate its own cell panel instead of reaching into it.
winrt::get_self<TableViewRow>(row)->InvalidateCells();
}
else if (auto header = winrt::VisualTreeHelper::GetChild(repeater, i).try_as<winrt::TableViewGroupHeader>())
{
// The group-header band spans the same columns and sizes itself during measure, but
// it is a direct repeater child rather than a cells panel, so the row walk above
// never reaches it. Handled in this one walk so the two cannot drift apart.
UpdateGroupHeaderWidth(header);
}
}
}
}