Files
microsoft-ui-xaml/controls/dev/TableView/TableView.cpp
T
Saharsh JaiswalandCopilot 42b575bc75 Merge main into user/sahjaiswal/tableview-tip-followup
Brings in #12307 (TableView: templated column headers, UIA resize, sort
fixes, recycle perf, theming) and 11 other commits from main.

Conflicts, resolved by keeping both sides:
- TableView_Editing.cpp (BeginEdit): the TiP try/catch now wraps #12307's
  unwind-on-throw guard and ResetPendingBeginEdit.
- TableView_Sort.cpp: the typed Sorted rethrow and failure capture keep
  #12307's explicitClear, keptSourceSort and reconcile changes, and the
  comparer tally reset follows its early return for a source another
  table owns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 53d996ee-1cb6-468a-bfc9-e11af03b1f80
2026-10-23 15:03:50 +05:30

4168 lines
158 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 "TableViewColumnHeader.h"
#include "TableViewColumn.h"
#include "TableViewRow.h"
#include "TableViewCellsPanel.h"
#include "TableViewToolTipHelpers.h"
#include "TableViewAutomationPeer.h"
#include "TableViewSource.h"
#include "TableViewRowTemplateSelector.h"
#include "TableViewGroupHeader.h"
#include "SortIndicator.h"
#include "RuntimeProfiler.h"
#include "TVDiag.h"
#include "TabularControlsResources.h"
#include <string>
#include <cmath>
#include <limits>
#include <algorithm>
// Not in CppWinRTIncludes.h: Microsoft.UI.h's WindowId conflicts with Microsoft.UI.Content.h's.
#include <winrt\Microsoft.UI.Interop.h>
static constexpr std::wstring_view s_RowsRepeaterPartName{ L"PART_RowsRepeater"sv };
static constexpr std::wstring_view s_HeaderRowPartName{ L"PART_HeaderRow"sv };
static constexpr std::wstring_view s_HeaderHostPartName{ L"PART_HeaderHost"sv };
static constexpr std::wstring_view s_EmptyStatePresenterPartName{ L"PART_EmptyStatePresenter"sv };
static constexpr std::wstring_view s_ResizeGripperWidthKey{ L"TableViewResizeGripperWidth"sv };
// Matches TableViewResizeGripperWidth in the theme dictionaries; used when that key is missing or
// unusable.
static constexpr double c_resizeGripperWidthFallback{ 8.0 };
// Fallbacks mirroring the resources shipped in TableView.xaml.
static constexpr double c_defaultRowIndentSize{ 16.0 };
static constexpr double c_defaultRowExpanderSize{ 24.0 };
static constexpr double c_defaultGroupExpanderSize{ 24.0 };
static constexpr double c_defaultGroupExpanderSpacing{ 8.0 };
static constexpr std::wstring_view s_SortIndicatorSizeKey{ L"SortIndicatorSize"sv };
// Matches SortIndicatorSize in SortIndicator_themeresources.xaml; used only when that key is
// missing or unusable.
static constexpr double c_sortIndicatorSizeFallback{ 16.0 };
namespace
{
// cppwinrt's == compares raw ABI pointers, which can differ for the same object across a QI,
// so fall back to canonical IUnknown identity.
bool IsSameObject(winrt::IInspectable const& left, winrt::IInspectable const& right)
{
if (left == right)
{
return true;
}
if (!left || !right)
{
return false;
}
return left.as<winrt::Windows::Foundation::IUnknown>() ==
right.as<winrt::Windows::Foundation::IUnknown>();
}
winrt::ScrollViewer FindScrollViewerAncestor(winrt::DependencyObject const& start, winrt::DependencyObject const& boundary)
{
winrt::DependencyObject node = start;
while (node && !IsSameObject(node, boundary))
{
if (auto sv = node.try_as<winrt::ScrollViewer>())
{
return sv;
}
node = winrt::VisualTreeHelper::GetParent(node);
}
return nullptr;
}
// Misses are the common case here; HasKey does not originate an error on a miss the way
// Lookup (and therefore TryLookup) does.
winrt::IInspectable QuietLookup(winrt::IMap<winrt::IInspectable, winrt::IInspectable> const& map, winrt::IInspectable const& boxedKey)
{
if (map && map.HasKey(boxedKey))
{
return map.Lookup(boxedKey);
}
return nullptr;
}
// Reference-typed resources keep their identity across lookups; value-typed ones (x:Double,
// Thickness, Color, ...) come back as a fresh box every time, so they compare by value.
bool IsSameResource(winrt::IInspectable const& left, winrt::IInspectable const& right)
{
if (!left || !right)
{
return false;
}
if (IsSameObject(left, right))
{
return true;
}
const auto leftValue = left.try_as<winrt::IPropertyValue>();
const auto rightValue = right.try_as<winrt::IPropertyValue>();
if (!leftValue || !rightValue || leftValue.Type() != rightValue.Type())
{
return false;
}
switch (leftValue.Type())
{
case winrt::PropertyType::Boolean: return leftValue.GetBoolean() == rightValue.GetBoolean();
case winrt::PropertyType::Double: return leftValue.GetDouble() == rightValue.GetDouble();
case winrt::PropertyType::Single: return leftValue.GetSingle() == rightValue.GetSingle();
case winrt::PropertyType::Int32: return leftValue.GetInt32() == rightValue.GetInt32();
case winrt::PropertyType::UInt32: return leftValue.GetUInt32() == rightValue.GetUInt32();
case winrt::PropertyType::Int64: return leftValue.GetInt64() == rightValue.GetInt64();
case winrt::PropertyType::String: return leftValue.GetString() == rightValue.GetString();
default: break;
}
if (auto const l = left.try_as<winrt::IReference<winrt::Thickness>>())
{
auto const r = right.try_as<winrt::IReference<winrt::Thickness>>();
return r && l.Value() == r.Value();
}
if (auto const l = left.try_as<winrt::IReference<winrt::Color>>())
{
auto const r = right.try_as<winrt::IReference<winrt::Color>>();
return r && l.Value() == r.Value();
}
if (auto const l = left.try_as<winrt::IReference<winrt::CornerRadius>>())
{
auto const r = right.try_as<winrt::IReference<winrt::CornerRadius>>();
return r && l.Value() == r.Value();
}
if (auto const l = left.try_as<winrt::IReference<winrt::Windows::UI::Text::FontWeight>>())
{
auto const r = right.try_as<winrt::IReference<winrt::Windows::UI::Text::FontWeight>>();
return r && l.Value().Weight == r.Value().Weight;
}
return false;
}
// Every key EnsureActiveThemeDictionary can select.
constexpr std::wstring_view s_highContrastWhiteKey{ L"HighContrastWhite"sv };
constexpr std::wstring_view s_highContrastBlackKey{ L"HighContrastBlack"sv };
constexpr std::wstring_view s_highContrastCustomKey{ L"HighContrastCustom"sv };
// SystemThemingInterop::GetSystemHighContrastTheme: the variant comes from the system window
// colours (including Xbox's video-safe pair). Empty when High Contrast is off.
std::wstring_view SystemHighContrastVariantKey()
{
HIGHCONTRASTW highContrast{};
highContrast.cbSize = sizeof(highContrast);
if (!::SystemParametersInfoW(SPI_GETHIGHCONTRAST, sizeof(highContrast), &highContrast, 0) ||
!(highContrast.dwFlags & HCF_HIGHCONTRASTON))
{
return {};
}
const auto foreground = static_cast<unsigned int>(::GetSysColor(COLOR_WINDOWTEXT));
const auto background = static_cast<unsigned int>(::GetSysColor(COLOR_WINDOW));
if ((background == 0x00FFFFFF && foreground == 0x0) || (background == 0x00EBEBEB && foreground == 0x00101010))
{
return s_highContrastWhiteKey;
}
if ((background == 0x0 && foreground == 0x00FFFFFF) || (background == 0x00101010 && foreground == 0x00EBEBEB))
{
return s_highContrastBlackKey;
}
return s_highContrastCustomKey;
}
// EnsureActiveThemeDictionary's selection order: the HC variant (a subtree-requested Light/Dark
// maps to HighContrastWhite/Black, otherwise the system variant), then "HighContrast", then the
// base theme, then "Default".
std::vector<std::wstring_view> ThemeDictionaryKeys(
bool highContrast,
std::wstring_view highContrastVariant,
bool isLight)
{
std::vector<std::wstring_view> keys;
if (highContrast)
{
if (!highContrastVariant.empty())
{
keys.push_back(highContrastVariant);
}
keys.push_back(L"HighContrast"sv);
}
keys.push_back(isLight ? L"Light"sv : L"Dark"sv);
keys.push_back(L"Default"sv);
return keys;
}
struct ElementResourceQuery
{
winrt::IInspectable boxedKey;
// Candidate theme-dictionary keys for the ELEMENT's theme, in selection order.
std::vector<std::wstring_view> themeKeys;
// The same for the APP-wide theme, which is what a code-issued Lookup selects with.
std::vector<std::wstring_view> appThemeKeys;
// Global theme resources for this key, which Lookup appends to every dictionary's scope.
// Resolved lazily through an empty dictionary, which has no scope of its own.
winrt::IInspectable globalValue{ nullptr };
bool globalResolved{ false };
winrt::IInspectable const& GlobalValue()
{
if (!globalResolved)
{
globalResolved = true;
globalValue = QuietLookup(winrt::ResourceDictionary{}, boxedKey);
}
return globalValue;
}
};
winrt::ResourceDictionary SelectThemeDictionary(winrt::ResourceDictionary const& dict, std::vector<std::wstring_view> const& themeKeys)
{
if (auto const themeDicts = dict.ThemeDictionaries())
{
for (auto const themeKey : themeKeys)
{
if (auto const themed = QuietLookup(themeDicts, winrt::box_value(winrt::hstring{ themeKey })).try_as<winrt::ResourceDictionary>())
{
return themed;
}
}
}
return nullptr;
}
// Lookup/HasKey are full-scope (own, merged, APP-theme dictionary, global), and enumerating a
// dictionary forces CResourceDictionary::EnsureAll (realizes every deferred resource - the whole
// WinUI theme). So "own entry" is decided by comparing the hit with the one value that full-scope
// Lookup would have returned had there been no own entry: the nearest merged scope (last-to-first),
// then the app-theme dictionary, then global. Comparing with ONE candidate rather than any scope
// keeps an own entry that merely equals an unselected merged or theme value.
//
// Approximations, both deliberate:
// - A merged hit equal to global is skipped as the global fallback. GetKeyNoRefImpl actually lets
// the last merged dictionary answer with global first, but Tabular keys are global (the dll's
// theme resources), so mirroring that sends nearly every lookup down through the app's merged
// dictionaries (XamlControlsResources included) - prohibitively expensive.
// - An own value-typed entry equal to the global default is indistinguishable from the fallback
// (value types come back as fresh boxes) and is treated as not own; only an ancestor override of
// the same key can then differ from what the framework returns.
bool IsOwnEntry(winrt::ResourceDictionary const& dict, winrt::IInspectable const& found, ElementResourceQuery& query)
{
// GetKeyNoRefImpl searches the LAST merged dictionary with global scope, so when the key
// exists globally that first search always answers and the loop stops; only a key with no
// global value reaches earlier merged dictionaries, and there a full-scope Lookup is exact.
winrt::IInspectable fallback{ nullptr };
auto const& global = query.GlobalValue();
if (auto const merged = dict.MergedDictionaries())
{
for (uint32_t i = merged.Size(); i-- > 0 && !fallback;)
{
// Every Lookup ends at global; only a hit that differs from it is the merged scope's.
auto value = QuietLookup(merged.GetAt(i), query.boxedKey);
if (value && !IsSameResource(value, global))
{
fallback = std::move(value);
}
}
}
if (!fallback)
{
if (auto const themed = SelectThemeDictionary(dict, query.appThemeKeys))
{
auto value = QuietLookup(themed, query.boxedKey);
if (value && !IsSameResource(value, global))
{
fallback = std::move(value);
}
}
}
if (!fallback)
{
fallback = query.GlobalValue();
}
return !IsSameResource(found, fallback);
}
// Framework (GetKeyNoRefImpl) order: own entries, MergedDictionaries last-to-first, then the
// active theme dictionary - chosen from the ELEMENT's theme, which a code-issued Lookup cannot see.
winrt::IInspectable LookupInDictionaryScope(winrt::ResourceDictionary const& dict, ElementResourceQuery& query)
{
if (!dict)
{
return nullptr;
}
if (auto const found = QuietLookup(dict, query.boxedKey))
{
if (IsOwnEntry(dict, found, query))
{
return found;
}
}
if (auto const merged = dict.MergedDictionaries())
{
for (uint32_t i = merged.Size(); i-- > 0;)
{
if (auto found = LookupInDictionaryScope(merged.GetAt(i), query))
{
return found;
}
}
}
if (auto const themed = SelectThemeDictionary(dict, query.themeKeys))
{
return LookupInDictionaryScope(themed, query);
}
return nullptr;
}
// Resolves a key the way {ThemeResource} would from `start`: ancestors nearest first, then
// Application.Resources, then global theme resources, with theme dictionaries selected as
// EnsureActiveThemeDictionary does (see ThemeDictionaryKeys).
winrt::IInspectable LookupElementResource(winrt::FrameworkElement const& start, std::wstring_view key, bool highContrast = false)
{
ElementResourceQuery query;
query.boxedKey = winrt::box_value(winrt::hstring{ key });
const auto systemHighContrastVariant = SystemHighContrastVariantKey();
// Theme-scoped resources must resolve against the element's ActualTheme.
// High Contrast is orthogonal to ActualTheme; callers pass cached HC state for hot-path brush lookups.
const auto theme = start ? start.ActualTheme() : winrt::ElementTheme::Default;
std::wstring_view elementHighContrastVariant = systemHighContrastVariant;
if (highContrast)
{
// A subtree that requested a theme selects the matching HC variant, not the system's.
for (auto requester = start; requester; requester = requester.Parent().try_as<winrt::FrameworkElement>())
{
if (requester.RequestedTheme() != winrt::ElementTheme::Default)
{
elementHighContrastVariant = theme == winrt::ElementTheme::Light ? s_highContrastWhiteKey : s_highContrastBlackKey;
break;
}
}
}
query.themeKeys = ThemeDictionaryKeys(highContrast, elementHighContrastVariant, theme == winrt::ElementTheme::Light);
// What a code-issued Lookup selects: no subtree theme, so the app-wide base and system HC.
auto appIsLight = theme == winrt::ElementTheme::Light;
if (auto const app = winrt::Application::Current())
{
appIsLight = app.RequestedTheme() == winrt::ApplicationTheme::Light;
}
query.appThemeKeys = ThemeDictionaryKeys(!systemHighContrastVariant.empty(), systemHighContrastVariant, appIsLight);
winrt::FrameworkElement walker = start;
while (walker)
{
if (auto found = LookupInDictionaryScope(walker.Resources(), query))
{
return found;
}
walker = walker.Parent().try_as<winrt::FrameworkElement>();
}
if (auto app = winrt::Application::Current())
{
if (auto found = LookupInDictionaryScope(app.Resources(), query))
{
return found;
}
}
return query.GlobalValue();
}
bool WantsHorizontalLines(winrt::TableViewGridLinesVisibility visibility) noexcept
{
return visibility == winrt::TableViewGridLinesVisibility::Horizontal ||
visibility == winrt::TableViewGridLinesVisibility::All;
}
double TerminalEdgeTolerance(const winrt::FrameworkElement& element) noexcept
{
double scale = 1.0;
try
{
if (element)
{
if (auto root = element.XamlRoot())
{
const auto rasterizationScale = root.RasterizationScale();
if (std::isfinite(rasterizationScale) && rasterizationScale > 0.0)
{
scale = rasterizationScale;
}
}
}
}
catch (...)
{
}
// Half a physical pixel, widened slightly because transformed bounds are float-backed and
// can land microscopically beyond that boundary after layout rounding.
constexpr double layoutEpsilonPixels = 1.0 / 64.0;
return (0.5 + layoutEpsilonPixels) / scale;
}
bool TryGetBoundsRelativeTo(
const winrt::FrameworkElement& element,
const winrt::UIElement& relativeTo,
winrt::Rect& bounds) noexcept
{
try
{
if (!element || !relativeTo || !element.IsLoaded() ||
element.ActualWidth() <= 0.0 || element.ActualHeight() <= 0.0)
{
return false;
}
bounds = element.TransformToVisual(relativeTo).TransformBounds(
{ 0.0f, 0.0f, static_cast<float>(element.ActualWidth()), static_cast<float>(element.ActualHeight()) });
}
catch (...)
{
return false;
}
return std::isfinite(bounds.X) &&
std::isfinite(bounds.Y) &&
std::isfinite(bounds.Width) &&
std::isfinite(bounds.Height);
}
TableViewResourceCache& GetTableViewResourceCache(TableView* owner)
{
// Per-instance member (not a process-global map) so multi-UI-thread instances never share state.
return owner->GetResourceCacheInternal();
}
void InvalidateTableViewResourceCache(TableView* owner)
{
auto& cache = owner->GetResourceCacheInternal();
cache.density.hasRowMinHeight = false;
cache.density.hasCellPadding = false;
cache.density.hasHeaderCellPadding = false;
cache.font.hasCellFontSize = false;
cache.hierarchy.hasRowIndentSize = false;
cache.hierarchy.hasRowExpanderSize = false;
cache.hierarchy.hasGroupHeaderContentOffset = false;
cache.gridLine.hasBrush = false;
}
bool ShouldRefreshFrozenColumnsForScroll(TableView* owner, double horizontalOffset)
{
auto& cache = GetTableViewResourceCache(owner);
if (!cache.hasLastFrozenColumnsHorizontalOffset ||
std::abs(cache.lastFrozenColumnsHorizontalOffset - horizontalOffset) >= 0.5)
{
cache.hasLastFrozenColumnsHorizontalOffset = true;
cache.lastFrozenColumnsHorizontalOffset = horizontalOffset;
return true;
}
return false;
}
double DensityRowMinHeightFallback(winrt::TableViewDensity density)
{
switch (density)
{
case winrt::TableViewDensity::Compact: return 30.0;
case winrt::TableViewDensity::Comfortable: return 48.0;
default: return 40.0; // Standard
}
}
winrt::Thickness DensityCellPaddingFallback(winrt::TableViewDensity density)
{
switch (density)
{
case winrt::TableViewDensity::Compact: return winrt::ThicknessHelper::FromLengths(8, 2, 8, 2);
case winrt::TableViewDensity::Comfortable: return winrt::ThicknessHelper::FromLengths(8, 8, 8, 8);
default: return winrt::ThicknessHelper::FromLengths(8, 4, 8, 4); // Standard
}
}
// The stock style always supplies TabularSurfaceGridLineBrush; on a miss draw only in High Contrast.
winrt::Brush CreateGridLineFallbackBrush(winrt::FrameworkElement const& start, bool highContrast)
{
if (!highContrast)
{
return nullptr;
}
return winrt::SolidColorBrush(winrt::unbox_value_or<winrt::Color>(
LookupElementResource(start, L"SystemColorWindowTextColor", true),
winrt::Colors::White()));
}
}
winrt::Brush TableView::GetGridLineBrush()
{
auto& cache = GetTableViewResourceCache(this);
const bool highContrast = IsHighContrast();
const auto theme = ActualTheme();
if (cache.gridLine.hasBrush &&
cache.gridLine.theme == theme &&
cache.gridLine.highContrast == highContrast)
{
return cache.gridLine.brush;
}
auto brush = LookupElementResource(*this, L"TabularSurfaceGridLineBrush", highContrast).try_as<winrt::Brush>();
if (!brush)
{
brush = CreateGridLineFallbackBrush(*this, highContrast);
}
cache.gridLine.hasBrush = true;
cache.gridLine.theme = theme;
cache.gridLine.highContrast = highContrast;
cache.gridLine.brush = brush;
return brush;
}
TableView::~TableView()
{
IgnoreTip(TableViewTip::IgnoreReason::Unloaded);
// Unloaded can run after the window is destroyed, or not at all; TableView_Instance goes out at most once
// either way.
TableViewTelemetry::EndSession(m_telemetrySession);
// Must run while this control still holds the selector: once anything has been recycled the
// pools hang off the cached templates and close a cycle the reference tracker cannot walk.
// Destructor runs off the reference tracker's teardown path (e.g. UIAffinityReleaseQueue),
// not necessarily via a direct Release() call, so plain get() can observe the tracker handle
// already invalidated and assert/fail-fast in chk builds. safe_get() is the documented-safe
// accessor for tracker_ref from a destructor (see tracker_ref.h and ItemsView/ScrollView).
if (auto const selector = m_rowTemplateSelector.safe_get())
{
winrt::get_self<::TableViewRowTemplateSelector>(selector)->Detach();
}
// Unloaded may not have run; release ThemeSettings and drop the Destroying subscription.
ReleaseThemeSettings();
}
TableView::TableView()
{
__RP_Marker_ClassById(RuntimeProfiler::ProfId_TableView);
SetDefaultStyleKey(this);
// Construction is only a setup heuristic; equivalent resources or custom templates are valid.
static thread_local bool s_reportedMissingResources{ false };
if (!TabularControlsResources::HasBeenCreatedOnCurrentThread() && !s_reportedMissingResources)
{
s_reportedMissingResources = TVDiag::LogDebugger(
L"TableView setup: if using the default template, merge "
L"<TabularControlsResources xmlns=\"using:Microsoft.UI.Xaml.Controls.Tabular\" /> into "
L"App.xaml > Application.Resources.MergedDictionaries after XamlControlsResources, before overrides. "
L"Equivalent resources or a complete custom template are also supported.");
}
// Columns must be observable; OnColumnsPropertyChanged owns the VectorChanged subscription to avoid duplicate callbacks.
auto columns = winrt::single_threaded_observable_vector<winrt::TableViewColumn>();
Columns(columns);
auto weakThis = get_weak();
// Use bubbling KeyDown so focused editors can consume typing keys before row navigation.
m_keyDownHandler = winrt::KeyEventHandler(
[weakThis](winrt::IInspectable const& sender, winrt::KeyRoutedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
if (std::exchange(strongThis->m_navKeyHandledEarly, false))
{
return;
}
strongThis->OnKeyDownForNavigation(sender, args);
}
});
// Ancestor PART_BodyScroller marks nav keys Handled before they bubble here; handledEventsToo:true lets us still act on them.
// AddHandler takes the handler as IInspectable, so the delegate must be boxed (see RoutedEventHelpers.h).
AddHandler(winrt::UIElement::KeyDownEvent(), winrt::box_value(m_keyDownHandler), true /* handledEventsToo */);
m_keyUpHandler = winrt::KeyEventHandler(
[weakThis](winrt::IInspectable const& sender, winrt::KeyRoutedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnKeyUpForHeaderSort(sender, args);
}
});
// Space on a focused header arms on KeyDown and sorts on an unhandled KeyUp; handledEventsToo
// lets a handled KeyUp leave the arm intact rather than consuming it.
AddHandler(winrt::UIElement::KeyUpEvent(), winrt::box_value(m_keyUpHandler), true /* handledEventsToo */);
m_headerSortLostFocusRevoker = LostFocus(winrt::auto_revoke,
[weakThis](winrt::IInspectable const&, winrt::RoutedEventArgs const&)
{
if (auto strongThis = weakThis.get())
{
strongThis->m_headerSortSpaceArmedColumn = nullptr;
}
});
// Tunneling PreviewKeyDown runs before the framework's built-in focus navigation; snapshot the
// currently focused row there so OnKeyDownForNavigation anchors on the pre-move index.
m_previewKeyDownHandler = winrt::KeyEventHandler(
[weakThis](winrt::IInspectable const& sender, winrt::KeyRoutedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnPreviewKeyDownForNavigation(sender, args);
}
});
AddHandler(winrt::UIElement::PreviewKeyDownEvent(), winrt::box_value(m_previewKeyDownHandler), false /* handledEventsToo */);
// handledEventsToo: rows and headers mark their presses handled, and a press anywhere in the
// table is the user taking focus over from a pending re-shape restore.
AddHandler(
winrt::UIElement::PointerPressedEvent(),
winrt::box_value(winrt::PointerEventHandler(
[weakThis](winrt::IInspectable const&, winrt::PointerRoutedEventArgs const&)
{
if (auto strongThis = weakThis.get())
{
strongThis->CancelReshapeFocusRestoreForUserInput();
}
})),
true /* handledEventsToo */);
// Headers need no direction stamp (see RebuildHeaders); the rebuild keeps them in step with the body.
RegisterPropertyChangedCallback(
winrt::FrameworkElement::FlowDirectionProperty(),
[weakThis](winrt::DependencyObject const&, winrt::DependencyProperty const&)
{
if (auto strongThis = weakThis.get())
{
strongThis->QueueRebuildHeaders();
// Body cells carry the same stamp as a one-sided BorderThickness, and realized rows
// are not rebuilt by the header pass - refresh them or the body grid lines stay on
// the edge the previous direction chose.
strongThis->RefreshGridLinesOnRealizedRows();
strongThis->QueueTerminalGridLineRefresh();
}
});
// Editing gestures. Separate from the navigation handlers above; the key sets are disjoint.
// handledEventsToo is required because a single-line TextBox marks Enter handled and the commit
// must still run - each key case in OnKeyDownForEditing owns its own Handled policy.
m_editingKeyDownHandler = winrt::KeyEventHandler(
[weakThis](winrt::IInspectable const& sender, winrt::KeyRoutedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnKeyDownForEditing(sender, args);
}
});
AddHandler(winrt::UIElement::KeyDownEvent(), winrt::box_value(m_editingKeyDownHandler), true /* handledEventsToo */);
// Commit when focus leaves the open editor. Uses the typed LosingFocus event so the incoming
// focus target is known - a plain LostFocus cannot tell "moved inside the editor" from
// "clicked another cell".
m_editingLosingFocusRevoker = LosingFocus(winrt::auto_revoke,
[weakThis](winrt::UIElement const& sender, winrt::Microsoft::UI::Xaml::Input::LosingFocusEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnLosingFocusForEditing(sender, args);
}
});
// ThemeSettings needs a WindowId, so it can only be created once we are in a tree with a XamlRoot.
// Create it (and subscribe to Changed) on Loaded; its Changed event is raised on this UI thread,
// which is why the old AccessibilitySettings dispatcher-marshaling plumbing is gone.
m_loadedRevoker = Loaded(winrt::auto_revoke,
[weakThis](winrt::IInspectable const& sender, winrt::RoutedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnTableViewLoaded(sender, args);
}
});
// Null ItemsSource on unload so queued repeater work cannot run on a detached subtree.
m_unloadedRevoker = Unloaded(winrt::auto_revoke,
[weakThis](winrt::IInspectable const&, winrt::RoutedEventArgs const&)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnTableViewUnloaded();
}
});
}
void TableView::OnTableViewLoaded(const winrt::IInspectable& /*sender*/, const winrt::RoutedEventArgs& /*args*/)
{
TableViewTelemetry::BeginSession(m_telemetrySession);
TableViewTelemetry::ReportFirstLoad();
ReclaimItemsSource();
// The first of the session's two samples; a session that ends without Unloaded carries this one alone.
SampleInstanceTelemetry();
// ThemeSettings requires a WindowId, so it can only be created once we have a XamlRoot.
if (m_themeSettings)
{
return; // already created for this hosting session
}
auto xamlRoot = XamlRoot();
if (!xamlRoot)
{
return;
}
try
{
// ContentIslandEnvironment can be null during teardown / unusual hosts.
if (auto env = xamlRoot.ContentIslandEnvironment())
{
m_themeSettings = winrt::Microsoft::UI::System::ThemeSettings::CreateForWindowId(env.AppWindowId());
m_isHighContrast = m_themeSettings.HighContrast();
// Changed is raised on this UI thread, so the handler can touch XAML directly.
m_themeSettingsChangedRevoker = m_themeSettings.Changed(
winrt::auto_revoke, { get_weak(), &TableView::OnThemeSettingsChanged });
// Destroying is raised on the window's thread; a cross-thread host keeps the Unloaded-path release.
auto const appWindowId = env.AppWindowId();
auto const hwnd = winrt::Microsoft::UI::GetWindowFromWindowId(appWindowId);
if (::GetWindowThreadProcessId(hwnd, nullptr) != ::GetCurrentThreadId())
{
return;
}
if (auto appWindow = winrt::Microsoft::UI::Windowing::AppWindow::GetFromWindowId(appWindowId))
{
m_appWindowDestroyingToken = appWindow.Destroying(
[weakThis = get_weak()](auto const&, auto const&)
{
if (auto strongThis = weakThis.get())
{
strongThis->ReleaseThemeSettings();
}
});
m_appWindow = std::move(appWindow);
}
}
}
catch (...)
{
// Best-effort; IsHighContrast falls back to a one-shot AccessibilitySettings read.
}
}
void TableView::OnThemeSettingsChanged(
const winrt::Microsoft::UI::System::ThemeSettings& sender, const winrt::IInspectable& /*args*/)
{
// Raised on the control's UI thread (no marshaling required). HC can toggle without a theme
// change, so invalidate the HC-dependent resource cache and refresh realized visuals directly.
try
{
m_isHighContrast = sender.HighContrast();
}
catch (...)
{
// Best-effort during teardown; keep the previous HC state if the read fails.
}
InvalidateTableViewResourceCache(this);
if (IsLoaded())
{
RebuildHeaders();
RefreshGridLinesOnRealizedRows();
QueueTerminalGridLineRefresh();
// Lead-cell padding and indent are imperative; re-derive from the new metrics.
RefreshRealizedRowHierarchyState();
}
}
void TableView::OnApplyTemplate()
try
{
m_headerSortSpaceArmedColumn = nullptr;
IgnoreTip(TableViewTip::IgnoreReason::Retemplated);
RevokeTipInitializationLayout();
TableViewTip::ResetInitialization(m_tip);
m_templateApplied = true;
m_templateIncomplete = false;
// The template is expanded before this runs, so with a source attached Initialize's inputs are ready.
if (m_activeSource.get()) { StartTipInitialization(); }
__super::OnApplyTemplate();
InvalidateTableViewResourceCache(this);
CancelHeaderFocusRestore();
if (m_pendingGroupFocusLayoutToken.value)
{
LayoutUpdated(m_pendingGroupFocusLayoutToken);
m_pendingGroupFocusLayoutToken = {};
}
if (m_pendingGroupRowRefreshLayoutToken.value)
{
LayoutUpdated(m_pendingGroupRowRefreshLayoutToken);
m_pendingGroupRowRefreshLayoutToken = {};
}
if (m_terminalGridLinesLayoutToken.value)
{
LayoutUpdated(m_terminalGridLinesLayoutToken);
m_terminalGridLinesLayoutToken = {};
}
if (auto terminalRow = m_terminalGridLineRow.get())
{
winrt::get_self<TableViewRow>(terminalRow)->SetTerminalGridLineSuppression({});
}
if (auto terminalHeader = m_terminalGridLineGroupHeader.get())
{
winrt::get_self<TableViewGroupHeader>(terminalHeader)->SetTerminalBottomGridLineSuppression(false);
}
m_terminalGridLineRowSizeChangedRevoker.revoke();
m_terminalGridLineRow = {};
m_terminalGridLineGroupHeader = {};
m_suppressTrailingGridLine = false;
m_suppressBottomGridLine = false;
m_terminalGridLineGeometryRetryAvailable = true;
m_terminalGridLineColumnIndex = -1;
m_terminalGridLineHorizontalOffset = std::numeric_limits<double>::quiet_NaN();
m_terminalGridLineVerticalOffset = std::numeric_limits<double>::quiet_NaN();
if (auto oldRepeater = m_rowsRepeater.get())
{
// Drop per-template Loaded handlers so old elements cannot keep this alive.
if (m_rowsRepeaterLoadedToken.value)
{
if (auto oldRepeaterFE = oldRepeater.try_as<winrt::FrameworkElement>())
{
oldRepeaterFE.Loaded(m_rowsRepeaterLoadedToken);
}
m_rowsRepeaterLoadedToken = {};
}
// Release realized rows before detaching ElementClearing so rows can clear their owner.
try { oldRepeater.ItemsSource(nullptr); } catch (...) {}
oldRepeater.ElementPrepared(m_rowElementPreparedToken);
oldRepeater.ElementClearing(m_rowElementClearingToken);
oldRepeater.ElementIndexChanged(m_rowElementIndexChangedToken);
m_rowElementPreparedToken = {};
m_rowElementClearingToken = {};
m_rowElementIndexChangedToken = {};
}
if (auto oldHeaderHost = m_headerHost.get())
{
// The abandoned band still parents its tooltips and UIElement Headers.
ReleaseHeaderCells(oldHeaderHost);
// Mirror the rowsRepeater Loaded cleanup for the header host.
if (m_headerHostLoadedToken.value)
{
if (auto oldHeaderHostFE = oldHeaderHost.try_as<winrt::FrameworkElement>())
{
oldHeaderHostFE.Loaded(m_headerHostLoadedToken);
}
m_headerHostLoadedToken = {};
}
// Auto-revoke would also release these on reassignment below, but the old band must not
// raise focus events into a control whose template has already been swapped.
m_headerHostGettingFocusRevoker.revoke();
m_headerHostGotFocusRevoker.revoke();
}
m_headerContextRequestedRevoker.revoke();
m_templateRootContextRequestedRevoker.revoke();
// Below the TableView so TableView.ContextFlyout (and any ancestor's) stays closed while a cell
// is editing; the framework shows an element's ContextFlyout before its own handlers run.
m_templateRootContextRequestedRevoker = TableViewDetails::HookTemplateRootContextRequested(*this,
[weakThis = get_weak()](auto&&, winrt::ContextRequestedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->SuppressContextRequestWhileEditing(args);
}
});
if (auto oldBodyScroller = m_bodyScroller.get())
{
oldBodyScroller.ViewChanged(m_bodyScrollerViewChangedToken);
m_bodyScrollerViewChangedToken = {};
m_bodyScrollerSizeChangedRevoker.revoke();
}
// Reset resolved-on-Loaded refs so re-templating re-resolves them against the new tree.
m_headerRow.set(nullptr);
m_headerScroller.set(nullptr);
m_bodyScroller.set(nullptr);
m_bodyBringIntoViewRevoker.revoke();
m_rowsRepeater.set(GetTemplateChild(hstring{ s_RowsRepeaterPartName }).try_as<winrt::ItemsRepeater>());
m_headerRow.set(GetTemplateChild(hstring{ s_HeaderRowPartName }).try_as<winrt::FrameworkElement>());
m_headerHost.set(GetTemplateChild(hstring{ s_HeaderHostPartName }).try_as<winrt::Panel>());
m_emptyStatePresenter.set(GetTemplateChild(hstring{ s_EmptyStatePresenterPartName }).try_as<winrt::ContentControl>());
// Every part is looked up as optional, so nothing else reports a template without one. TableView's own template
// has all three, so a missing one is the app's template.
if (!m_rowsRepeater.get() || !m_headerRow.get() || !m_headerHost.get())
{
m_templateIncomplete = true;
TVDiag::LogRetailF(L"[TableView] The control template lacks PART_RowsRepeater, PART_HeaderRow or PART_HeaderHost.");
StartTipInitialization();
TableViewTip::IgnoreInitialization(m_tip, TableViewTip::IgnoreReason::AppTemplateIncomplete);
}
auto weakThis = get_weak();
// Defer ScrollViewer ancestor lookup until Loaded because template parts are not fully connected here.
if (auto headerHost = m_headerHost.get())
{
// One tab stop for the header band, matching Explorer and WinUI list controls: Tab crosses
// bands, arrows stay inside. Apply it at PART_HeaderHost, not TableViewCellsPanel; the panel
// is shared layout, while the focus policy belongs to the two host bands.
headerHost.TabFocusNavigation(winrt::KeyboardNavigationMode::Once);
// Redirect band entry from the first header to the remembered column.
m_headerHostGettingFocusRevoker = headerHost.GettingFocus(winrt::auto_revoke,
[weakThis](winrt::IInspectable const& sender,
winrt::Microsoft::UI::Xaml::Input::GettingFocusEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnHeaderHostGettingFocus(sender, args);
}
});
// Update the shared column cursor whenever a header actually takes focus.
m_headerHostGotFocusRevoker = headerHost.GotFocus(winrt::auto_revoke,
[weakThis](winrt::IInspectable const& sender, winrt::RoutedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnHeaderHostGotFocus(sender, args);
}
});
m_headerContextRequestedRevoker = headerHost.ContextRequested(winrt::auto_revoke,
[weakThis](winrt::IInspectable const&, winrt::ContextRequestedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnHeaderContextRequested(args);
}
});
// Focus on an off-screen header must not scroll PART_HeaderScroller: header/body sync is
// one-way, so the band would end up offset from the columns it labels.
m_headerBringIntoViewRevoker = headerHost.BringIntoViewRequested(winrt::auto_revoke,
[weakThis](winrt::IInspectable const& /*sender*/, winrt::BringIntoViewRequestedEventArgs const& args)
{
auto strongThis = weakThis.get();
if (!strongThis)
{
return;
}
strongThis->OnHeaderBringIntoViewRequested(args);
});
if (auto headerHostFE = headerHost.try_as<winrt::FrameworkElement>())
{
m_headerHostLoadedToken = headerHostFE.Loaded(
[weakThis](winrt::IInspectable const& sender, winrt::RoutedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnHeaderHostLoaded(sender, args);
}
});
}
QueueTerminalGridLineRefresh();
}
if (auto repeater = m_rowsRepeater.get())
{
// Assigned here rather than in the template: the selector needs an owning TableView to map
// an item to its row kind, and XAML has no way to hand it one.
auto selector = winrt::make<::TableViewRowTemplateSelector>();
winrt::get_self<::TableViewRowTemplateSelector>(selector)->SetOwningTableViewInternal(*this);
m_rowTemplateSelector.set(selector);
repeater.ItemTemplate(selector);
m_bodyBringIntoViewRevoker = repeater.BringIntoViewRequested(winrt::auto_revoke,
[weakThis](winrt::IInspectable const& /*sender*/, winrt::BringIntoViewRequestedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnBodyBringIntoViewRequested(args);
}
});
m_rowElementPreparedToken = repeater.ElementPrepared(
[weakThis](winrt::ItemsRepeater const& sender, winrt::ItemsRepeaterElementPreparedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnRowElementPrepared(sender, args);
}
});
m_rowElementClearingToken = repeater.ElementClearing(
[weakThis](winrt::ItemsRepeater const& sender, winrt::ItemsRepeaterElementClearingEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnRowElementClearing(sender, args);
}
});
m_rowElementIndexChangedToken = repeater.ElementIndexChanged(
[weakThis](winrt::ItemsRepeater const& sender, winrt::ItemsRepeaterElementIndexChangedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnRowElementIndexChanged(sender, args);
}
});
if (auto repeaterFE = repeater.try_as<winrt::FrameworkElement>())
{
m_rowsRepeaterLoadedToken = repeaterFE.Loaded(
[weakThis](winrt::IInspectable const& sender, winrt::RoutedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnRowsRepeaterLoaded(sender, args);
}
});
}
}
// Drive the repeater from the active source only after its item template and lifecycle hooks
// are wired. ItemsRepeater may react to ItemsSource immediately; doing this earlier leaves it
// briefly sourced without the selector/ElementPrepared owner hookup TableView rows require.
RefreshRowsPipeline();
// Track the body cursor as focus moves: re-shape notifications arrive after ItemsRepeater has
// recycled the focused row, so the pre-reshape position is only observable here.
m_bodyFocusTrackerGotFocusRevoker = GotFocus(
winrt::auto_revoke, { this, &TableView::OnTableViewGotFocusForBodyTracking });
m_bodyFocusTrackerLosingFocusRevoker = LosingFocus(
winrt::auto_revoke, { this, &TableView::OnTableViewLosingFocusForBodyTracking });
// Body horizontal scrolling drives the header ScrollViewer; vertical stickiness is structural.
RebuildHeaders();
UpdateHeaderVisibility();
// Theme switches require refreshing imperatively-resolved grid-line brushes.
if (m_actualThemeChangedToken.value)
{
try { this->ActualThemeChanged(m_actualThemeChangedToken); } catch (...) {}
m_actualThemeChangedToken = {};
}
{
auto weakThis2 = get_weak();
m_actualThemeChangedToken = this->ActualThemeChanged(
[weakThis2](winrt::FrameworkElement const& /*sender*/, winrt::IInspectable const& /*args*/)
{
auto strongThis = weakThis2.get();
if (!strongThis)
{
return;
}
if (!strongThis->IsLoaded())
{
return;
}
try
{
InvalidateTableViewResourceCache(strongThis.get());
// Rebuild headers and realized rows so grid-line brushes re-resolve.
strongThis->RebuildHeaders();
strongThis->RefreshGridLinesOnRealizedRows();
strongThis->QueueTerminalGridLineRefresh();
// Re-apply indent and lead-cell reservation against the new theme's metrics.
strongThis->RefreshRealizedRowHierarchyState();
}
catch (...)
{
// Theme-switch refresh is best-effort.
}
});
}
CompleteTipInitializationIfEmpty();
}
catch (...)
{
// The control, row and cell templates are expanded outside this method; an exception from app code is the
// app's, and any other throw fails Initialize unless it has its verdict.
FailTipInitialization(TableViewTip::Stage::Template, TableViewTip::CurrentFault());
throw;
}
void TableView::OnHeaderHostLoaded(const winrt::IInspectable& /*sender*/, const winrt::RoutedEventArgs& /*args*/)
{
if (m_headerScroller.get())
{
return; // already resolved
}
if (auto headerHost = m_headerHost.get())
{
winrt::ScrollViewer scroller{ nullptr };
try { scroller = FindScrollViewerAncestor(headerHost, *this); } catch (...) {}
m_headerScroller.set(scroller);
// Header pans can transiently desync; reverse-sync can clamp when header/body extents differ.
UpdateHeaderVisibility();
}
}
void TableView::OnRowsRepeaterLoaded(const winrt::IInspectable& /*sender*/, const winrt::RoutedEventArgs& /*args*/)
{
if (m_rowsSourceDrained)
{
// Only re-source cached pages after Unloaded actually drained the repeater.
m_rowsSourceDrained = false;
RefreshRowsPipeline();
}
if (m_bodyScroller.get())
{
return; // already resolved
}
if (auto repeater = m_rowsRepeater.get())
{
winrt::ScrollViewer scroller{ nullptr };
try { scroller = FindScrollViewerAncestor(repeater, *this); } catch (...) {}
m_bodyScroller.set(scroller);
if (auto bodyScroller = m_bodyScroller.get())
{
auto weakThis = get_weak();
m_bodyScrollerViewChangedToken = bodyScroller.ViewChanged(
[weakThis](winrt::IInspectable const& sender, winrt::ScrollViewerViewChangedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnBodyScrollerViewChanged(sender, args);
}
});
// Viewport resize (ViewChanged only covers scroll/zoom) must rerun table-level measure
// so Star widths resolve after the subtree has refreshed its measured-width caches.
m_bodyScrollerSizeChangedRevoker = bodyScroller.SizeChanged(winrt::auto_revoke,
[weakThis](winrt::IInspectable const& /*sender*/, winrt::SizeChangedEventArgs const& /*args*/)
{
if (auto strongThis = weakThis.get())
{
strongThis->InvalidateMeasure();
strongThis->RefreshFrozenColumns();
strongThis->QueueTerminalGridLineRefresh();
}
});
// Resolve during the next table measure now that the viewport is known (initial layout).
InvalidateMeasure();
RefreshFrozenColumns();
QueueTerminalGridLineRefresh();
}
}
}
bool TableView::HasRowsViewport()
{
// The scroller is resolved when the rows repeater loads, which a repeater in a collapsed body never does.
auto scroller = m_bodyScroller.get();
auto const repeater = m_rowsRepeater.get();
if (!scroller && repeater) { scroller = FindScrollViewerAncestor(repeater, *this); }
return scroller ? scroller.ViewportWidth() > 0 && scroller.ViewportHeight() > 0 :
repeater && repeater.ActualWidth() > 0 && repeater.ActualHeight() > 0;
}
void TableView::OnBodyScrollerViewChanged(
const winrt::IInspectable& /*sender*/,
const winrt::ScrollViewerViewChangedEventArgs& /*args*/)
{
auto bodyScroller = m_bodyScroller.get();
if (!bodyScroller)
{
return;
}
// A keyboard move's ChangeView has applied; its pending focus may re-measure positions now.
m_requestedBodyVerticalOffset.reset();
const double bodyHOffset = bodyScroller.HorizontalOffset();
// Re-pin leading-frozen columns only when horizontal scroll moves.
if (ShouldRefreshFrozenColumnsForScroll(this, bodyHOffset))
{
RefreshFrozenColumns();
}
const double bodyVOffset = bodyScroller.VerticalOffset();
const bool horizontalMoved =
!std::isfinite(m_terminalGridLineHorizontalOffset) ||
std::abs(m_terminalGridLineHorizontalOffset - bodyHOffset) >= 0.25;
const bool verticalMoved =
!std::isfinite(m_terminalGridLineVerticalOffset) ||
std::abs(m_terminalGridLineVerticalOffset - bodyVOffset) >= 0.25;
if (horizontalMoved || verticalMoved)
{
m_terminalGridLineHorizontalOffset = bodyHOffset;
m_terminalGridLineVerticalOffset = bodyVOffset;
m_terminalGridLineGeometryRetryAvailable = true;
RefreshTerminalGridLines();
}
auto headerScroller = m_headerScroller.get();
if (!headerScroller)
{
return;
}
if (std::abs(headerScroller.HorizontalOffset() - bodyHOffset) < 0.5)
{
// Skip near-equal offsets to avoid ViewChanged ping-pong.
return;
}
// Instant tracking keeps header and body visually glued.
headerScroller.ChangeView(bodyHOffset, nullptr, nullptr, true);
}
winrt::AutomationPeer TableView::OnCreateAutomationPeer()
{
return winrt::make<TableViewAutomationPeer>(*this);
}
void TableView::OnItemsSourcePropertyChanged(const winrt::DependencyPropertyChangedEventArgs& args)
{
// Ignore same-reference ItemsSource updates to avoid a no-op row rebuild.
if (args.OldValue() == args.NewValue())
{
return;
}
// Until Initialize has its verdict, attaching a source is part of initialization rather than a tracked swap.
auto const initializing = !m_tip.initializationDone;
auto const tipGeneration = BeginTipOperation(TableViewTip::Operation::ReplaceSource, !initializing);
bool completed = false;
std::optional<TableViewTip::Fault> failure;
auto tipCompletion = wil::scope_exit([this, tipGeneration, initializing, &completed, &failure]() noexcept
{
if (initializing)
{
if (completed) { NoteTipInitializationSource(); }
else { FailTipInitialization(TableViewTip::Stage::Source, failure.value_or(TableViewTip::Fault{})); }
}
else if (!completed)
{
FailTipOperation(tipGeneration, TableViewTip::Stage::Source, failure);
}
else { CompleteTipOperation(tipGeneration); }
EndTipOperation();
});
try
{
// The edited item is about to leave the control. Forced, because the data set is already gone
// by the time a handler could react, so the close cannot be vetoed. No-focus teardown: this is
// a dependency-property change callback, and moving focus from one re-enters the framework.
if (IsEditing())
{
TerminateEditWithoutVisualRestore(false /* insideLayoutPass */, true /* sourceReplaced */);
}
// The current cell belongs to the old data set. Left alone, CurrentItem keeps returning an item
// that is not in the new source, BeginEdit fails with no diagnostic, and the discarded item
// stays rooted by the tracker. WPF DataGrid likewise resets CurrentItem/CurrentCell here.
//
// Per-row begin-edit press state needs no reset: it is compared by item identity, so an entry
// left over from the previous data set can never match an item from the new one.
SetCurrentCell(nullptr, nullptr);
// The column set changed; reset the shared cursor so first header entry does not skip an
// unvisited column.
ResetColumnCursorInternal();
// New data set: clear the grow-only Auto accumulators so widths recompute from scratch. The next
// table measure pass pulls measured widths from the by-then re-realized rows, so the outgoing rows'
// stale content no longer pins the columns.
ResetColumnDesiredWidths();
// A different collection invalidates any projection we synthesized over the old one, so the
// shaping state that lived in it goes with it. AdoptItemsSource mints a fresh projection here
// (the active source is still available to it as the previous source to detach), and
// RefreshRowsPipeline then pushes the new view into the repeater. This is the only path that
// reassigns the active source - every other re-entry keeps it and only refreshes the pipeline.
// PART_RowsRepeater is driven from the flat ItemsSource DP. The sort belonged to the discarded
// projection, so the reported sort state and the chevron go with it - otherwise they describe
// an order the new rows are not in.
ResetSortStateForNewItemsSource();
AdoptItemsSource();
RefreshRowsPipeline();
}
catch (...)
{
// App code's exceptions (the new collection, its contract, a callout) carry their kind.
failure = TableViewTip::CurrentFault();
throw;
}
completed = true;
}
void TableView::OnHeadersVisibilityPropertyChanged(const winrt::DependencyPropertyChangedEventArgs& args)
{
if (args.OldValue() == args.NewValue())
{
return;
}
UpdateHeaderVisibility();
InvalidateMeasure();
QueueTerminalGridLineRefresh();
// UIA: every realized cell's name and header items follow whether column headers are shown.
const auto columnBit = static_cast<uint32_t>(winrt::TableViewHeadersVisibility::Column);
const bool showedColumnHeaders =
(static_cast<uint32_t>(winrt::unbox_value<winrt::TableViewHeadersVisibility>(args.OldValue())) & columnBit) != 0;
if (showedColumnHeaders != ShouldShowColumnHeaders() &&
winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::PropertyChanged))
{
ForEachRealizedRow([](winrt::TableViewRow const& row)
{
winrt::get_self<TableViewRow>(row)->RaiseCellAutomationNamesChangedInternal();
});
}
}
void TableView::OnGridLinesVisibilityPropertyChanged(const winrt::DependencyPropertyChangedEventArgs& args)
{
if (args.OldValue() == args.NewValue())
{
return;
}
ApplyGridLinesToHeader();
RefreshGridLinesOnRealizedRows();
QueueTerminalGridLineRefresh();
}
void TableView::OnRowBackgroundPropertyChanged(const winrt::DependencyPropertyChangedEventArgs& args)
{
if (args.OldValue() == args.NewValue())
{
return;
}
// Re-tint realized rows so opt-in banding refreshes.
RefreshRowBackgroundsOnRealizedRows();
}
void TableView::OnAlternatingRowBackgroundPropertyChanged(const winrt::DependencyPropertyChangedEventArgs& args)
{
if (args.OldValue() == args.NewValue())
{
return;
}
RefreshRowBackgroundsOnRealizedRows();
}
// Header lines are always drawn; only the last separator yields to the outer border.
void TableView::ApplyGridLinesToHeader()
{
auto host = m_headerHost.get();
if (!host)
{
return;
}
const auto headerCells = host.Children();
const uint32_t headerCellCount = headerCells.Size();
uint32_t lastVisibleHeaderCell = headerCellCount;
for (uint32_t i = headerCellCount; i > 0; --i)
{
if (auto headerCell = headerCells.GetAt(i - 1).try_as<winrt::FrameworkElement>())
{
const auto column = headerCell.Tag().try_as<winrt::TableViewColumn>();
if (headerCell.Visibility() == winrt::Visibility::Visible &&
column &&
column.ActualWidth() > 0.0)
{
lastVisibleHeaderCell = i - 1;
break;
}
}
}
for (uint32_t i = 0; i < headerCellCount; ++i)
{
if (auto header = headerCells.GetAt(i).try_as<winrt::TableViewColumnHeader>())
{
if (auto const gridLine = winrt::get_self<TableViewColumnHeader>(header)->GetGridLineInternal())
{
gridLine.Visibility(
!(m_suppressTrailingGridLine && i == lastVisibleHeaderCell)
? winrt::Visibility::Visible
: winrt::Visibility::Collapsed);
}
}
}
}
void TableView::OnColumnHeaderTemplateApplied(winrt::TableViewColumnHeader const& /*header*/)
{
if (!m_rebuildingHeaders)
{
ApplyGridLinesToHeader();
RefreshResizeGripperAvailability();
}
}
void TableView::ForEachRealizedRow(std::function<void(winrt::TableViewRow const&)> const& fn)
{
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>())
{
fn(row);
}
}
}
}
void TableView::RefreshGridLinesOnRealizedRows()
{
const auto terminalRow = m_terminalGridLineRow.get();
ForEachRealizedRow([&](winrt::TableViewRow const& row)
{
const bool suppressBottom = terminalRow && IsSameObject(row, terminalRow) && m_suppressBottomGridLine;
winrt::get_self<TableViewRow>(row)->SetTerminalGridLineSuppression({
.suppressTrailing = m_suppressTrailingGridLine,
.suppressBottom = suppressBottom });
});
}
void TableView::QueueTerminalGridLineRefresh(bool isGeometryRetry)
{
if (!isGeometryRetry)
{
m_terminalGridLineGeometryRetryAvailable = true;
}
if (m_terminalGridLinesLayoutToken.value)
{
return;
}
if (isGeometryRetry)
{
if (!m_terminalGridLineGeometryRetryAvailable)
{
return;
}
m_terminalGridLineGeometryRetryAvailable = false;
}
auto weakThis = get_weak();
m_terminalGridLinesLayoutToken = LayoutUpdated(
[weakThis](winrt::IInspectable const&, winrt::IInspectable const&)
{
if (auto strongThis = weakThis.get())
{
if (strongThis->m_terminalGridLinesLayoutToken.value)
{
strongThis->LayoutUpdated(strongThis->m_terminalGridLinesLayoutToken);
strongThis->m_terminalGridLinesLayoutToken = {};
}
strongThis->RefreshTerminalGridLines();
}
});
}
std::optional<bool> TableView::ShouldSuppressTrailingGridLine()
{
const auto border = BorderThickness();
// Both edges are read in the panel's logical coordinate space, which XAML mirrors wholesale
// under RTL, so the logical trailing edge meets BorderThickness.Right in either direction.
const double outerThickness = (std::max)(0.0, border.Right);
if (outerThickness <= 0.0 || ActualWidth() <= 0.0)
{
return false;
}
winrt::FrameworkElement candidate{ nullptr };
ForEachRealizedRow([&](winrt::TableViewRow const& row)
{
if (!candidate)
{
candidate = winrt::get_self<TableViewRow>(row)->GetLastVisibleCellInternal();
}
});
if (!candidate && ShouldShowColumnHeaders())
{
if (auto host = m_headerHost.get())
{
const auto cells = host.Children();
for (uint32_t i = cells.Size(); i > 0; --i)
{
if (auto cell = cells.GetAt(i - 1).try_as<winrt::FrameworkElement>();
cell &&
cell.Visibility() == winrt::Visibility::Visible &&
cell.ActualWidth() > 0.0)
{
candidate = cell;
break;
}
}
}
}
if (!candidate)
{
return false;
}
winrt::Rect bounds{};
if (!TryGetBoundsRelativeTo(candidate, *this, bounds))
{
return std::nullopt;
}
// TransformToVisual reports the panel's logical coordinate space.
const double candidateEdge = bounds.X + bounds.Width;
const double outerEdge = ActualWidth() - outerThickness;
return std::abs(candidateEdge - outerEdge) <= TerminalEdgeTolerance(*this);
}
std::optional<bool> TableView::ShouldSuppressBottomGridLine(
const winrt::FrameworkElement& element,
bool hasBottomGridLine)
{
if (!element || !hasBottomGridLine)
{
return false;
}
const double bottomThickness = (std::max)(0.0, BorderThickness().Bottom);
if (bottomThickness <= 0.0 || ActualHeight() <= 0.0)
{
return false;
}
winrt::Rect bounds{};
if (!TryGetBoundsRelativeTo(element, *this, bounds))
{
return std::nullopt;
}
const double innerBottom = ActualHeight() - bottomThickness;
return std::abs(bounds.Y + bounds.Height - innerBottom) <= TerminalEdgeTolerance(*this);
}
void TableView::RefreshTerminalGridLines()
{
int32_t terminalColumnIndex = -1;
if (auto columns = Columns())
{
for (uint32_t i = columns.Size(); i > 0; --i)
{
const auto column = columns.GetAt(i - 1);
if (column &&
column.Visibility() == winrt::Visibility::Visible &&
column.ActualWidth() > 0.0)
{
terminalColumnIndex = static_cast<int32_t>(i - 1);
break;
}
}
}
const bool terminalColumnChanged = terminalColumnIndex != m_terminalGridLineColumnIndex;
m_terminalGridLineColumnIndex = terminalColumnIndex;
const auto trailingResult = ShouldSuppressTrailingGridLine();
const bool suppressTrailing = trailingResult.value_or(m_suppressTrailingGridLine);
const bool trailingChanged = suppressTrailing != m_suppressTrailingGridLine;
m_suppressTrailingGridLine = suppressTrailing;
winrt::FrameworkElement terminalElement{ nullptr };
winrt::TableViewRow terminalRow{ nullptr };
winrt::TableViewGroupHeader terminalGroupHeader{ nullptr };
bool terminalGeometryUnavailable = false;
if (auto repeater = m_rowsRepeater.get())
{
// Select by geometry rather than by item index: once content overflows and scrolls, the
// container meeting the inner bottom edge is not the last item.
const double innerBottom = ActualHeight() - (std::max)(0.0, BorderThickness().Bottom);
double closestDistance = std::numeric_limits<double>::infinity();
const int32_t childCount = winrt::VisualTreeHelper::GetChildrenCount(repeater);
for (int32_t i = 0; i < childCount; ++i)
{
const auto child =
winrt::VisualTreeHelper::GetChild(repeater, i).try_as<winrt::FrameworkElement>();
if (!child)
{
continue;
}
winrt::Rect bounds{};
if (!TryGetBoundsRelativeTo(child, *this, bounds))
{
terminalGeometryUnavailable = true;
continue;
}
if (const double distance = std::abs(bounds.Y + bounds.Height - innerBottom);
distance < closestDistance)
{
closestDistance = distance;
terminalElement = child;
}
}
terminalRow = terminalElement.try_as<winrt::TableViewRow>();
terminalGroupHeader = terminalElement.try_as<winrt::TableViewGroupHeader>();
}
const auto previousTerminalRow = m_terminalGridLineRow.get();
const auto previousTerminalGroupHeader = m_terminalGridLineGroupHeader.get();
const bool terminalChanged =
!IsSameObject(previousTerminalRow, terminalRow) ||
!IsSameObject(previousTerminalGroupHeader, terminalGroupHeader);
if (terminalChanged)
{
if (previousTerminalRow)
{
winrt::get_self<TableViewRow>(previousTerminalRow)->SetTerminalGridLineSuppression({
.suppressTrailing = m_suppressTrailingGridLine,
.suppressBottom = false });
}
if (previousTerminalGroupHeader)
{
winrt::get_self<TableViewGroupHeader>(previousTerminalGroupHeader)
->SetTerminalBottomGridLineSuppression(false);
}
m_terminalGridLineRowSizeChangedRevoker.revoke();
m_terminalGridLineRow = terminalRow ? winrt::make_weak(terminalRow) : nullptr;
m_terminalGridLineGroupHeader =
terminalGroupHeader ? winrt::make_weak(terminalGroupHeader) : nullptr;
if (terminalElement)
{
auto weakThis = get_weak();
m_terminalGridLineRowSizeChangedRevoker = terminalElement.SizeChanged(
winrt::auto_revoke,
[weakThis](winrt::IInspectable const&, winrt::SizeChangedEventArgs const&)
{
if (auto strongThis = weakThis.get())
{
strongThis->QueueTerminalGridLineRefresh();
}
});
}
}
const bool hasBottomGridLine =
terminalRow
? WantsHorizontalLines(GridLinesVisibility())
: terminalGroupHeader && terminalGroupHeader.BorderThickness().Bottom > 0.0;
auto bottomResult = terminalElement
? ShouldSuppressBottomGridLine(terminalElement, hasBottomGridLine)
: std::optional<bool>{ false };
// A container that failed its transform could be the real edge container, so a negative
// result is only trustworthy once every container was measurable.
if (terminalGeometryUnavailable && !bottomResult.value_or(false))
{
bottomResult = std::nullopt;
}
const bool suppressBottom = bottomResult.value_or(m_suppressBottomGridLine);
m_suppressBottomGridLine = suppressBottom;
if (trailingChanged || terminalColumnChanged)
{
ApplyGridLinesToHeader();
RefreshGridLinesOnRealizedRows();
}
// Push unconditionally rather than only on a detected change. A container can be recycled or
// re-prepared while this state is applied, so its own copy can disagree with the table's; a
// change-gated push would leave that disagreement permanent. This is also the only thing that
// re-derives the overlay from the container's current BorderThickness.
if (auto repeater = m_rowsRepeater.get())
{
const int32_t childCount = winrt::VisualTreeHelper::GetChildrenCount(repeater);
for (int32_t i = 0; i < childCount; ++i)
{
if (auto header = winrt::VisualTreeHelper::GetChild(repeater, i)
.try_as<winrt::TableViewGroupHeader>();
header && !IsSameObject(header, terminalGroupHeader))
{
winrt::get_self<TableViewGroupHeader>(header)
->SetTerminalBottomGridLineSuppression(false);
}
}
}
if (terminalRow)
{
winrt::get_self<TableViewRow>(terminalRow)->SetTerminalGridLineSuppression({
.suppressTrailing = m_suppressTrailingGridLine,
.suppressBottom = m_suppressBottomGridLine });
}
if (terminalGroupHeader)
{
winrt::get_self<TableViewGroupHeader>(terminalGroupHeader)
->SetTerminalBottomGridLineSuppression(m_suppressBottomGridLine);
}
if (!trailingResult.has_value() || !bottomResult.has_value())
{
QueueTerminalGridLineRefresh(true);
}
else
{
m_terminalGridLineGeometryRetryAvailable = true;
}
}
void TableView::RefreshRowBackgroundsOnRealizedRows()
{
ForEachRealizedRow([](winrt::TableViewRow const& row)
{
winrt::get_self<TableViewRow>(row)->RefreshRowBackground();
});
}
void TableView::QueueGroupExpansionRowRefresh()
{
if (m_pendingGroupRowRefreshLayoutToken.value)
{
return;
}
auto weakThis = get_weak();
m_pendingGroupRowRefreshLayoutToken = LayoutUpdated(
[weakThis](winrt::IInspectable const&, winrt::IInspectable const&)
{
auto strongThis = weakThis.get();
if (!strongThis)
{
return;
}
if (strongThis->m_pendingGroupRowRefreshLayoutToken.value)
{
strongThis->LayoutUpdated(strongThis->m_pendingGroupRowRefreshLayoutToken);
strongThis->m_pendingGroupRowRefreshLayoutToken = {};
}
try
{
strongThis->RefreshRealizedRowsAfterGroupExpansion();
}
catch (...)
{
// Best-effort repair after a deferred grouped reshape.
}
});
}
void TableView::RefreshRealizedRowsAfterGroupExpansion()
{
// A reshape can rebind a pooled header in place without ElementPrepared/ElementIndexChanged,
// leaving a stale IsExpanded (read by the ExpandCollapse peer and chevron states).
RefreshRealizedGroupHeadersAfterExpansion();
ForEachRealizedRow([this](winrt::TableViewRow const& row)
{
auto* const rowImpl = winrt::get_self<TableViewRow>(row);
rowImpl->EnsureOwningTableViewInternal(*this);
RefreshRowSelectionState(row);
});
InvalidateMeasure();
}
void TableView::AdoptItemsSource(bool reclaimActiveSource)
{
auto const itemsSource = ItemsSource();
// Reclaiming keeps the projection this table already minted, and with it the shaping state.
winrt::TableViewSource tableViewSource = reclaimActiveSource ? m_activeSource.get() : winrt::TableViewSource{ nullptr };
if (!tableViewSource)
{
tableViewSource = itemsSource.try_as<winrt::TableViewSource>();
m_telemetrySourceKinds = tableViewSource ? TableViewTelemetry::SourceKind::AppSupplied : 0u;
}
// Normalize the source. When the app hands us a plain collection, project it through a
// TableViewSource of our own so the control has exactly one row pipeline rather than a shaped
// path and a raw one. This mirrors ItemsControl, which always routes ItemsSource through a
// collection view, so the grid above it never has to ask what kind of source it was given.
//
// This runs only when ItemsSource actually changes, so there is never a prior projection to
// reuse here - a different collection invalidated it, and the re-entries that must keep the
// active projection (OnApplyTemplate, repeater Loaded, a shaping verb) go through
// RefreshRowsPipeline and never reach this method.
if (!tableViewSource && itemsSource)
{
// Deliberately unguarded. The shaping engine accepts exactly the collection interfaces
// ItemsSourceView does, so a source it refuses is one XAML cannot project either, and
// ItemsRepeater raises that as an error rather than degrading. Let it surface.
tableViewSource = winrt::TableViewSource::From(itemsSource);
m_telemetrySourceKinds = TableViewTelemetry::SourceKind::Projected;
}
StopAwaitingSource();
if (tableViewSource && !winrt::get_self<::TableViewSource>(tableViewSource)->SetOwningTableView(*this))
{
TVDiag::LogRetailF(L"[TableView] ItemsSource is a TableViewSource already bound to another TableView; ignored.");
// Retry when the owner lets go; OnTableViewLoaded retries too.
auto weakThis = get_weak();
winrt::get_self<::TableViewSource>(tableViewSource)->AddOwnerReleasedHandler(*this, [weakThis]()
{
if (auto strongThis = weakThis.get(); strongThis && strongThis->IsLoaded())
{
strongThis->ReclaimItemsSource();
}
});
m_awaitedSource = winrt::make_weak(tableViewSource);
tableViewSource = nullptr;
}
// Publish the new source before releasing the previous one: the release synchronously runs
// other tables' waiters, and an app handler reached from there can assign this table's
// ItemsSource again. That nested adoption must see coherent state and must win.
auto const previouslyOwned = m_activeSource.get();
m_activeSource.set(tableViewSource);
m_activeSourceReleased = false;
// A remembered body position names a row of the old source.
ForgetBodyFocusPosition();
m_reshapeFocusValid = false;
m_reshapeFocusRow = -1;
CancelReshapeFocusRestore();
if (tableViewSource)
{
auto* const sourceImpl = winrt::get_self<::TableViewSource>(tableViewSource);
// The source raises this rather than calling back into TableView by name, so the shaping
// stack stays below the control. Weak, because the control owns the source through
// m_activeSource and a strong capture would be a cycle.
auto weakThis = get_weak();
sourceImpl->SetProjectionChangedHandler([weakThis]()
{
if (auto strongThis = weakThis.get())
{
strongThis->OnTableViewSourceProjectionChanged();
}
});
sourceImpl->SetShapingChangedHandler([weakThis](bool reorderOnly)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnTableViewSourceShapingChanged(reorderOnly);
}
});
AttachSourceTip(tableViewSource);
// Live reshape must not end the user's edit; DrainCoalescedEditReshape resumes it.
sourceImpl->SetLiveShapingHold([weakThis]()
{
auto strongThis = weakThis.get();
return strongThis && strongThis->IsEditing();
});
// CustomSortComparer ranks are frozen at header sort; re-place the edited row.
sourceImpl->SetLiveItemChangedHook([weakThis](winrt::IInspectable const& item, bool reshapePending)
{
auto strongThis = weakThis.get();
return strongThis && strongThis->RepositionCustomSortItem(item, reshapePending);
});
sourceImpl->SetRowsReleasedHook([weakThis](winrt::IInspectable const& item, std::vector<winrt::IInspectable> const& liveRows)
{
if (auto strongThis = weakThis.get())
{
strongThis->ReleaseCustomSortRows(item, liveRows);
}
});
sourceImpl->SetLiveItemReshapeHandler([weakThis]()
{
if (auto strongThis = weakThis.get())
{
TableViewTelemetry::MarkUsed(strongThis->m_telemetrySession, strongThis->m_telemetrySession.generation,
TableViewTelemetry::Feature::LiveShaping);
}
});
if (IsLoaded())
{
// The source may have been sorted while another table owned it.
m_rerankCustomSortOnReconcile = reclaimActiveSource;
QueueReconcileSortStateWithSource();
}
else
{
// No Unloaded will come to release it; OnTableViewLoaded reclaims it.
m_activeSourceReleased = true;
sourceImpl->ReleaseOwningTableView(*this);
}
}
// Detach the previous source last. Swapping ItemsSource between two TableViewSources leaves
// the old one alive and still subscribed to the app's collection, so without this it keeps a
// back-pointer to this control and a later rebuild of that discarded source would drive a
// TableView it no longer belongs to. A nested adoption may have made it active again.
if (previouslyOwned && previouslyOwned != tableViewSource && previouslyOwned != m_activeSource.get())
{
winrt::get_self<::TableViewSource>(previouslyOwned)->ReleaseOwningTableView(*this);
}
}
void TableView::RefreshRowsPipeline()
{
// Recompute the cached row view from whatever the active source currently projects. A shaping
// verb can swap the projection underneath us, so this is refreshed on every re-entry, not just
// on a source change.
winrt::IInspectable rowsSource{ nullptr };
// Held so the generation bump below can tell a genuine provider swap from a re-entry that
// merely re-reads the same one.
auto const previousRowMetadata = m_tableViewSourceRowMetadata;
m_tableViewSourceRowMetadata = nullptr;
if (auto const activeSource = m_activeSource.get())
{
auto* const sourceImpl = winrt::get_self<::TableViewSource>(activeSource);
// Straight through: for a TableViewSource the row view IS the projection's view.
m_rowsItemsSourceView = sourceImpl->GetItemsSourceView();
m_tableViewSourceRowMetadata = sourceImpl->GetRowMetadata();
rowsSource = m_rowsItemsSourceView ? m_rowsItemsSourceView.as<winrt::IInspectable>() : nullptr;
}
else
{
// Null ItemsSource: nothing to project, so the repeater empties out below.
m_rowsItemsSourceView = nullptr;
}
// Bump only when the provider that produced previously handed-out row identities has actually
// been replaced, so a request captured against the old one can tell it is stale. Identities are
// value-based strings, so without the bump the same string could name an unrelated group in a
// new projection. Bumping unconditionally is equally wrong in the other direction: this method
// also runs on re-entries that keep the very same projection (OnApplyTemplate, a Loaded repump
// after an unload drain, an applied sort), and a bump there silently discards a queued group
// toggle that is still perfectly valid.
if (m_tableViewSourceRowMetadata != previousRowMetadata)
{
++m_rowMetadataGeneration;
}
if (auto repeater = m_rowsRepeater.get())
{
// Subscribe BEFORE the repeater adopts the view: handlers run in subscription order, so we
// see a Reset while the focused row is still realized.
UpdateReshapeFocusDetectorSubscription(m_rowsItemsSourceView);
// ItemsRepeater has no identity short-circuit: re-assigning the same source tears down
// every container and resets scroll. Guard so a theme-change or Loaded repump does not
// blow away realized rows.
if (!IsSameObject(repeater.ItemsSource(), rowsSource))
{
repeater.ItemsSource(rowsSource);
}
UpdateItemsSourceCollectionChangedSubscription();
UpdateEmptyState();
UpdateRowHierarchyResetSubscription();
// Re-point selection at the new source. SelectionModel::Source clears unconditionally, so a
// swap always drops the selection; then drain anything requested before a source existed.
ResolveSelectionAfterSourceChange();
}
// else: OnApplyTemplate hasn't run yet; the repeater will be sourced from there.
}
void TableView::OnTableViewSourceProjectionChanged()
{
SupersedeTipOperation();
// The repeater's focus rescue during recycling must not be mistaken for the user's position.
SuppressBodyFocusTrackingForThisTurn();
// Capture first: RefreshRowsPipeline tears down realized rows synchronously, and
// TerminateEditWithoutVisualRestore can move focus before that.
CaptureBodyFocusForReshape();
// A shaping verb swapped the projected shape after we bound, so the cached view and row
// metadata describe the previous projection. Re-read them and re-drive the rows.
if (IsEditing())
{
// The edited item may not exist in the new projection. Forced, for the same reason as an
// ItemsSource swap: the shape is already gone by the time a handler could veto it.
TerminateEditWithoutVisualRestore();
}
RefreshRowsPipeline();
QueueGroupExpansionRowRefresh();
// In-place projection rewrites (e.g. declaring a hierarchy over the same view) re-prepare no
// rows, so restamp them. Deferred until the repeater reconciles.
QueueRefreshRealizedRowHierarchyState();
}
void TableView::UpdateRowHierarchyResetSubscription()
{
winrt::ItemsSourceView view{ nullptr };
if (auto const repeater = m_rowsRepeater.get())
{
view = repeater.ItemsSourceView();
}
if (m_rowHierarchyResetRevoker && SameInspectableIdentity(view, m_rowHierarchyResetView))
{
return;
}
m_rowHierarchyResetRevoker = {};
m_rowHierarchyResetView = nullptr;
if (view)
{
m_rowHierarchyResetRevoker = view.CollectionChanged(
winrt::auto_revoke, { this, &TableView::OnRowsSourceResetForHierarchy });
m_rowHierarchyResetView = view;
}
}
void TableView::OnRowsSourceResetForHierarchy(
const winrt::IInspectable& /*sender*/,
const winrt::NotifyCollectionChangedEventArgs& args)
{
// Reset only: splices restamp via ElementIndexChanged or the toggling verb. A Reset may
// republish metadata for the same rows (e.g. a collapsed node losing its last child).
if (!args || args.Action() != winrt::NotifyCollectionChangedAction::Reset)
{
return;
}
QueueRefreshRealizedRowHierarchyState();
}
void TableView::QueueRefreshRealizedRowHierarchyState()
{
// Deferred and coalesced: raised mid-rebuild, before the repeater reconciles indices.
if (m_rowHierarchyRefreshQueued)
{
return;
}
auto weakThis = get_weak();
auto const queue = winrt::DispatcherQueue::GetForCurrentThread();
// Unlatch if the post fails (queue shutting down) so later Resets can still queue a restamp.
m_rowHierarchyRefreshQueued = queue && queue.TryEnqueue([weakThis]()
{
if (auto strongThis = weakThis.get())
{
strongThis->m_rowHierarchyRefreshQueued = false;
strongThis->RefreshRealizedRowHierarchyState();
}
});
}
void TableView::OnTableViewSourceShapingChanged(bool reorderOnly)
{
SupersedeTipOperation();
if (!reorderOnly)
{
QueueGroupExpansionRowRefresh();
}
// The new shape is in place by now (re-order and rebuild have both run).
RestoreBodyFocusAfterReshape();
// The app may have declared or cleared a sort straight on the source, which the control has no
// other way to learn about. Reconcile before anything else so the chevrons never outlive the
// axis they describe.
if (!m_isApplyingControlInitiatedSort)
{
QueueReconcileSortStateWithSource();
}
// A programmatic shaping verb rewrites the projection with no input event behind it, so
// nothing else tells a UIA client that the rows it cached are stale. A pure re-order keeps the
// same children in a new order; anything else can add or remove them.
if (!winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::StructureChanged))
{
return;
}
auto peer = winrt::FrameworkElementAutomationPeer::FromElement(*this);
if (!peer)
{
return;
}
if (auto const tableViewPeer = peer.try_as<winrt::TableViewAutomationPeer>())
{
auto* const impl = winrt::get_self<TableViewAutomationPeer>(tableViewPeer);
if (reorderOnly)
{
impl->RaiseStructureChangedForSortChange();
}
else
{
impl->RaiseStructureChangedForVirtualizationReset();
}
}
}
void TableView::OnEmptyTemplatePropertyChanged(const winrt::DependencyPropertyChangedEventArgs& args)
{
// Skip same-value sets (no re-subscription / re-evaluation), matching the other DP callbacks.
if (args.OldValue() == args.NewValue())
{
return;
}
UpdateItemsSourceCollectionChangedSubscription();
UpdateEmptyState();
}
void TableView::UpdateItemsSourceCollectionChangedSubscription()
{
// Count changes also move the terminal row separator, so keep this subscription even when no
// EmptyTemplate is configured.
m_itemsSourceCollectionChangedRevoker = {};
if (auto repeater = m_rowsRepeater.get())
{
if (auto view = repeater.ItemsSourceView())
{
m_itemsSourceCollectionChangedRevoker = view.CollectionChanged(
winrt::auto_revoke, { this, &TableView::OnItemsSourceCollectionChanged });
}
}
}
void TableView::OnItemsSourceCollectionChanged(const winrt::IInspectable& /*sender*/, const winrt::IInspectable& /*args*/)
{
SupersedeTipOperation();
UpdateEmptyState();
QueueTerminalGridLineRefresh();
}
void TableView::UpdateReshapeFocusDetectorSubscription(const winrt::ItemsSourceView& view)
{
if (m_reshapeFocusDetectorRevoker && SameInspectableIdentity(view, m_reshapeFocusDetectorView))
{
// Re-subscribing would move us behind the repeater.
return;
}
m_reshapeFocusDetectorRevoker = {};
m_reshapeFocusDetectorView = nullptr;
if (view)
{
m_reshapeFocusDetectorRevoker = view.CollectionChanged(
winrt::auto_revoke, { this, &TableView::OnRowsSourceResetForFocus });
m_reshapeFocusDetectorView = view;
}
}
void TableView::OnRowsSourceResetForFocus(
const winrt::IInspectable& /*sender*/,
const winrt::NotifyCollectionChangedEventArgs& args)
{
// Only a Reset recycles every realized row; on Add/Remove focus rides along with its element.
if (args.Action() != winrt::NotifyCollectionChangedAction::Reset)
{
return;
}
SuppressBodyFocusTrackingForThisTurn();
CaptureBodyFocusForReshape();
// Close the edit before the repeater recycles the editor's container for another record.
if (IsEditing())
{
TerminateEditWithoutVisualRestore();
}
}
void TableView::UpdateEmptyState()
{
auto presenter = m_emptyStatePresenter.get();
if (!presenter)
{
// Template hasn't applied, or this template carries no empty-state part.
return;
}
auto const emptyTemplate = EmptyTemplate();
auto repeater = m_rowsRepeater.get();
if (!emptyTemplate)
{
// Default opt-out keeps rows visible and never shows the empty surface.
presenter.Visibility(winrt::Visibility::Collapsed);
presenter.ContentTemplate(nullptr);
if (repeater) { repeater.Visibility(winrt::Visibility::Visible); }
return;
}
bool isEmpty = true;
if (repeater)
{
if (auto view = repeater.ItemsSourceView())
{
isEmpty = view.Count() == 0;
}
}
if (isEmpty)
{
if (presenter.ContentTemplate() != emptyTemplate)
{
presenter.ContentTemplate(emptyTemplate);
}
// Ensure the ContentControl inflates the template without a data item.
if (!presenter.Content())
{
presenter.Content(box_value(winrt::hstring{}));
}
presenter.Visibility(winrt::Visibility::Visible);
if (repeater) { repeater.Visibility(winrt::Visibility::Collapsed); }
}
else
{
presenter.Visibility(winrt::Visibility::Collapsed);
if (repeater) { repeater.Visibility(winrt::Visibility::Visible); }
}
}
static std::wstring_view DensitySuffix(winrt::TableViewDensity density)
{
switch (density)
{
case winrt::TableViewDensity::Compact: return L"Compact"sv;
case winrt::TableViewDensity::Comfortable: return L"Comfortable"sv;
default: return L""sv;
}
}
bool TableView::IsHighContrast()
{
// ActualTheme cannot report HC. Prefer the cached ThemeSettings value (kept fresh by Changed);
// before Loaded (no WindowId yet) fall back to a one-shot AccessibilitySettings read.
if (m_themeSettings)
{
return m_isHighContrast;
}
try
{
return winrt::Windows::UI::ViewManagement::AccessibilitySettings{}.HighContrast();
}
catch (...)
{
return false;
}
}
double TableView::GetDensityRowMinHeight()
{
auto& cache = GetTableViewResourceCache(this);
if (cache.density.hasRowMinHeight)
{
return cache.density.rowMinHeight;
}
std::wstring key{ L"TableViewRowMinHeight" };
key += DensitySuffix(Density());
const auto fallback = DensityRowMinHeightFallback(Density());
if (auto raw = LookupElementResource(*this, key))
{
cache.density.rowMinHeight = winrt::unbox_value_or<double>(raw, fallback);
cache.density.hasRowMinHeight = true;
return cache.density.rowMinHeight;
}
// Resource-miss fallback mirrors Fluent density defaults so Standard stays taller than Compact.
cache.density.rowMinHeight = fallback;
cache.density.hasRowMinHeight = true;
return cache.density.rowMinHeight;
}
winrt::Thickness TableView::GetDensityCellPadding()
{
auto& cache = GetTableViewResourceCache(this);
if (cache.density.hasCellPadding)
{
return cache.density.cellPadding;
}
std::wstring key{ L"TableViewCellPadding" };
key += DensitySuffix(Density());
const auto fallback = DensityCellPaddingFallback(Density());
if (auto raw = LookupElementResource(*this, key))
{
cache.density.cellPadding = winrt::unbox_value_or<winrt::Thickness>(raw, fallback);
cache.density.hasCellPadding = true;
return cache.density.cellPadding;
}
// Resource-miss fallback mirrors density padding presets; Standard keeps the legacy padding.
cache.density.cellPadding = fallback;
cache.density.hasCellPadding = true;
return cache.density.cellPadding;
}
winrt::Thickness TableView::GetDensityHeaderCellPadding()
{
auto& cache = GetTableViewResourceCache(this);
if (cache.density.hasHeaderCellPadding)
{
return cache.density.headerCellPadding;
}
std::wstring key{ L"TableViewHeaderCellPadding" };
key += DensitySuffix(Density());
const auto fallback = DensityCellPaddingFallback(Density());
if (auto raw = LookupElementResource(*this, key))
{
cache.density.headerCellPadding = winrt::unbox_value_or<winrt::Thickness>(raw, fallback);
cache.density.hasHeaderCellPadding = true;
return cache.density.headerCellPadding;
}
// Resource-miss fallback mirrors cell-padding density presets.
cache.density.headerCellPadding = fallback;
cache.density.hasHeaderCellPadding = true;
return cache.density.headerCellPadding;
}
double TableView::GetCellFontSize()
{
auto& cache = GetTableViewResourceCache(this);
if (cache.font.hasCellFontSize)
{
return cache.font.cellFontSize;
}
constexpr double fallback = 14.0;
if (auto raw = LookupElementResource(*this, L"TableViewCellFontSize"))
{
cache.font.cellFontSize = winrt::unbox_value_or<double>(raw, fallback);
cache.font.hasCellFontSize = true;
return cache.font.cellFontSize;
}
cache.font.cellFontSize = fallback;
cache.font.hasCellFontSize = true;
return cache.font.cellFontSize;
}
// The system focus visual takes its corners from the focused element's CornerRadius, but a rounded
// root also clips its children and hit-testing, so round only while the focus rect shows. Programmatic
// focus is coerced to Keyboard or Pointer by the last input device, so Keyboard is exactly that case.
void TableView::AttachFocusCornerRadius(winrt::FrameworkElement const& target)
{
auto const radiusProperty = target.try_as<winrt::Control>()
? winrt::Control::CornerRadiusProperty()
: winrt::Grid::CornerRadiusProperty();
target.RegisterPropertyChangedCallback(winrt::UIElement::FocusStateProperty(),
[weakThis = get_weak(), radiusProperty](winrt::DependencyObject const& sender, winrt::DependencyProperty const&)
{
auto const element = sender.as<winrt::UIElement>();
auto const self = weakThis.get();
if (self && element.FocusState() == winrt::FocusState::Keyboard)
{
// The TableView style sets CornerRadius from ControlCornerRadius, so the value stays in XAML.
sender.SetValue(radiusProperty, winrt::box_value(self->CornerRadius()));
}
else
{
sender.ClearValue(radiusProperty);
}
});
}
double TableView::GetRowIndentSize()
{
// Cached: hit per realized row on every pass, and resource lookup walks the whole tree. Cleared
// by density/theme/HC invalidation; other runtime swaps need rows re-prepared.
auto& cache = GetTableViewResourceCache(this);
if (cache.hierarchy.hasRowIndentSize)
{
return cache.hierarchy.rowIndentSize;
}
double resolved = c_defaultRowIndentSize;
if (auto raw = LookupElementResource(*this, L"TableViewRowIndentSize"))
{
const double value = winrt::unbox_value_or<double>(raw, c_defaultRowIndentSize);
// Negative or non-finite indent would pull content under the chevron; fall back.
if (std::isfinite(value) && value >= 0.0)
{
resolved = value;
}
}
cache.hierarchy.rowIndentSize = resolved;
cache.hierarchy.hasRowIndentSize = true;
return resolved;
}
double TableView::GetRowExpanderSize()
{
auto& cache = GetTableViewResourceCache(this);
if (cache.hierarchy.hasRowExpanderSize)
{
return cache.hierarchy.rowExpanderSize;
}
double resolved = c_defaultRowExpanderSize;
if (auto raw = LookupElementResource(*this, L"TableViewRowExpanderSize"))
{
const double value = winrt::unbox_value_or<double>(raw, c_defaultRowExpanderSize);
if (std::isfinite(value) && value >= 0.0)
{
resolved = value;
}
}
cache.hierarchy.rowExpanderSize = resolved;
cache.hierarchy.hasRowExpanderSize = true;
return resolved;
}
// Grouped root-row indent: the group header's expander gutter plus TableViewGroupExpanderSpacing.
// The header's Padding is not included, so a root chevron sits Padding.Left (8px by default) left of
// the group key text.
double TableView::GetGroupHeaderContentOffset()
{
auto& cache = GetTableViewResourceCache(this);
if (!cache.hierarchy.hasGroupHeaderContentOffset)
{
auto const lookup = [this](std::wstring_view key, double fallback)
{
if (auto raw = LookupElementResource(*this, key))
{
const double value = winrt::unbox_value_or<double>(raw, fallback);
if (std::isfinite(value) && value >= 0.0)
{
return value;
}
}
return fallback;
};
cache.hierarchy.groupHeaderContentOffset =
lookup(L"TableViewGroupExpanderSize", c_defaultGroupExpanderSize) +
lookup(L"TableViewGroupExpanderSpacing", c_defaultGroupExpanderSpacing);
cache.hierarchy.hasGroupHeaderContentOffset = true;
}
return cache.hierarchy.groupHeaderContentOffset;
}
void TableView::OnDensityPropertyChanged(const winrt::DependencyPropertyChangedEventArgs& args)
{
if (args.OldValue() == args.NewValue())
{
return;
}
InvalidateTableViewResourceCache(this);
// Density changes require rebuilding headers and refreshing realized rows.
RebuildHeaders();
ForEachRealizedRow([](winrt::TableViewRow const& row)
{
winrt::get_self<TableViewRow>(row)->RefreshDensity();
});
// Density changes only vertical padding and row height (horizontal cell padding is identical
// across all presets), so it does not alter Auto content *width*. Re-measure for the new row
// metrics and re-pin frozen columns (clips depend on row height); the grow-only Auto accumulator
// is deliberately left intact since density is not a data-set change.
InvalidateMeasure();
RefreshFrozenColumns();
}
// A column becoming read-only must close an edit open ON THAT COLUMN, for the same reason the
// control-level IsReadOnly does: otherwise the user keeps an editor, and a committable value, on a
// cell that now reports it cannot be edited. Uses the no-focus teardown because this arrives from a
// dependency-property change callback, where moving focus re-enters focus/layout processing.
void TableView::OnColumnIsReadOnlyChanged(winrt::TableViewColumn const& column)
{
if (!column || !IsEditing())
{
return;
}
if (m_currentEditColumn.get() == column)
{
TerminateEditWithoutVisualRestore();
}
}
void TableView::OnIsReadOnlyPropertyChanged(const winrt::DependencyPropertyChangedEventArgs& /*args*/)
{
// Turning the control read-only must close an open cell, or the user keeps an editor - and a
// committable value - on a control that now reports it cannot be edited. Forced, because
// read-only is a control-level statement a handler must not veto. Uses the no-focus teardown:
// this runs from a DP change callback, where moving focus re-enters focus/layout processing.
if (IsReadOnly())
{
if (IsEditing())
{
TerminateEditWithoutVisualRestore();
}
}
}
bool TableView::ShouldShowColumnHeaders()
{
return (static_cast<uint32_t>(HeadersVisibility()) & static_cast<uint32_t>(winrt::TableViewHeadersVisibility::Column)) != 0;
}
void TableView::UpdateHeaderVisibility()
{
const bool showColumnHeaders = ShouldShowColumnHeaders();
const auto columnVisibility = showColumnHeaders ?
winrt::Visibility::Visible : winrt::Visibility::Collapsed;
if (auto headerHost = m_headerHost.get())
{
headerHost.Visibility(columnVisibility);
}
if (auto headerScroller = m_headerScroller.get())
{
headerScroller.Visibility(columnVisibility);
}
if (auto headerRow = m_headerRow.get())
{
headerRow.Visibility(columnVisibility);
}
}
int32_t TableView::GetItemsSourceCount() const
{
if (auto repeater = m_rowsRepeater.get())
{
if (auto sourceView = repeater.ItemsSourceView())
{
return sourceView.Count();
}
}
return 0;
}
void TableView::OnRowElementPrepared(
const winrt::ItemsRepeater& /*sender*/,
const winrt::ItemsRepeaterElementPreparedEventArgs& args)
{
TableViewTelemetry::NoteContent(m_telemetrySession, TableViewTelemetry::ContentShown::Rows);
if (auto row = args.Element().try_as<winrt::TableViewRow>())
{
auto rowImpl = winrt::get_self<TableViewRow>(row);
// Stage the suppression state first so the cell rebuild's chrome refresh paints it; refresh
// grid lines and banding here only if the rebuild did not (each grid-line pass walks every cell).
rowImpl->SetTerminalGridLineSuppression({
.suppressTrailing = m_suppressTrailingGridLine,
.suppressBottom = false }, false /* refresh */);
if (!rowImpl->EnsureOwningTableViewInternal(*this))
{
rowImpl->RefreshGridLines();
rowImpl->RefreshRowBackground();
}
rowImpl->RefreshCellToolTips();
RefreshRowHierarchyState(row, args.Index());
RefreshRowSelectionState(row);
InvalidateMeasureForRowRealizationChange();
QueueTerminalGridLineRefresh();
}
else if (auto header = args.Element().try_as<winrt::TableViewGroupHeader>())
{
// A pooled container must not carry a press that began on its previous group.
winrt::get_self<TableViewGroupHeader>(header)->ResetPointerStateInternal();
PrepareGroupHeaderElement(header, args.Index());
InvalidateMeasure();
QueueTerminalGridLineRefresh();
}
if (m_tip.initializationPending && !m_tipInitializationLayoutToken.value) { ArmTipInitializationLayout(); }
}
void TableView::OnRowElementClearing(
const winrt::ItemsRepeater& /*sender*/,
const winrt::ItemsRepeaterElementClearingEventArgs& args)
{
if (auto row = args.Element().try_as<winrt::TableViewRow>())
{
// A recycled row is about to be re-bound to a different item; an edit left open would keep
// an editor over the new item, and the next commit would write into the wrong data object.
// ElementClearing runs inside the repeater's measure pass, so this must not restore the
// display visual.
if (row == m_currentEditRow.get())
{
if (!TerminateEditWithoutVisualRestore(true /* insideLayoutPass */))
{
// The teardown was refused - the only way that happens here is an edit still in the
// Beginning window, where app code (a BeginningEdit handler or the editing binding)
// mutated the collection and recycled the row underneath us. The row
// must not go back to the pool holding a live editor, so abandon it directly and
// make BeginEdit unwind instead of promoting to Editing.
winrt::get_self<TableViewRow>(row)->AbandonCellEdit();
m_abandonPendingBeginEdit = true;
++m_editGeneration;
m_currentEditRow.set(nullptr);
m_currentEditItem.set(nullptr);
m_currentEditColumn.set(nullptr);
m_editUneditedValue.set(nullptr);
TVDiag::LogRetailF(
L"[TableView] A row was recycled while an edit was still opening; the edit was abandoned.");
}
}
auto const rowImpl = winrt::get_self<TableViewRow>(row);
// No longer the drilled row; the cursor level and column survive for re-entry.
if (auto const drilled = m_cellLevelRow.get(); drilled && IsSameObject(row, drilled))
{
m_cellLevelRow = nullptr;
}
// Release app-supplied tooltip content rather than pinning it in the recycle pool.
rowImpl->ReleaseCellToolTips();
rowImpl->SetTerminalGridLineSuppression({}, false /* refresh */);
if (auto terminalRow = m_terminalGridLineRow.get(); terminalRow && IsSameObject(row, terminalRow))
{
m_terminalGridLineRowSizeChangedRevoker.revoke();
m_terminalGridLineRow = {};
m_suppressBottomGridLine = false;
}
rowImpl->SetOwningTableViewInternal(nullptr);
// Grouped reshapes can rebind a pooled row through DataContext without a fresh
// ElementPrepared/ElementIndexChanged callback. Keep the weak owner available so that path
// can rebuild cells and publish a live row peer; item-identity tracking still rejects stale
// peers after the rebind.
if (!rowImpl->EnsureOwningTableViewInternal(*this))
{
rowImpl->RefreshGridLines();
}
InvalidateMeasureForRowRealizationChange();
QueueTerminalGridLineRefresh();
}
else if (auto header = args.Element().try_as<winrt::TableViewGroupHeader>())
{
winrt::get_self<TableViewGroupHeader>(header)->SetTerminalBottomGridLineSuppression(false);
if (auto terminalHeader = m_terminalGridLineGroupHeader.get();
terminalHeader && IsSameObject(header, terminalHeader))
{
m_terminalGridLineRowSizeChangedRevoker.revoke();
m_terminalGridLineGroupHeader = {};
m_suppressBottomGridLine = false;
}
ClearGroupHeaderElement(header);
InvalidateMeasure();
QueueTerminalGridLineRefresh();
}
}
void TableView::OnRowElementIndexChanged(
const winrt::ItemsRepeater& /*sender*/,
const winrt::ItemsRepeaterElementIndexChangedEventArgs& args)
{
auto const row = args.Element().try_as<winrt::TableViewRow>();
if (!row)
{
// A realized header keeps its element but moves to a new index, and its expansion state is
// read from that index's metadata, so it has to be re-prepared.
if (auto const header = args.Element().try_as<winrt::TableViewGroupHeader>())
{
PrepareGroupHeaderElement(header, args.NewIndex());
QueueTerminalGridLineRefresh();
}
return;
}
auto rowImpl = winrt::get_self<TableViewRow>(row);
// Realized rows can be preserved through grouped projection reshapes without a fresh
// ElementPrepared callback, so keep the owner/cells invariant true on index changes too.
rowImpl->SetOwningTableViewInternal(*this);
rowImpl->SetTerminalGridLineSuppression({
.suppressTrailing = m_suppressTrailingGridLine,
.suppressBottom = false });
QueueTerminalGridLineRefresh();
// Realized rows keep their element but get a new index, so banding parity must refresh.
if (RowBackground() != nullptr || AlternatingRowBackground() != nullptr)
{
rowImpl->RefreshRowBackground();
}
// ...and so must hierarchy state: a moved row may be at a new depth.
RefreshRowHierarchyState(row, args.NewIndex());
// ...and so must selected chrome. The element keeps its item here (only its index moved), so
// this normally re-derives the same answer - it is the cheap guarantee that a row whose index
// shifted under an insert cannot end up disagreeing with the model.
RefreshRowSelectionState(row);
}
// Pushes this index's hierarchy metadata onto the row (Level 0 for flat/grouped sources).
void TableView::RefreshRowHierarchyState(winrt::TableViewRow const& row, int32_t index)
{
if (!row)
{
return;
}
TableViewRowInfo rowInfo{};
// Use only data-row metadata: a recycled row can be re-prepared at an index that just became
// a group header (same guard as PrepareGroupHeaderElement).
const bool hasRowInfo =
TryGetTableViewSourceRowInfo(index, rowInfo) && rowInfo.Kind == TableViewRowKind::Data;
auto const rowImpl = winrt::get_self<TableViewRow>(row);
if (!hasRowInfo)
{
rowImpl->SetHierarchyStateInternal(0, false, false);
return;
}
// Ask the source whether it is a tree: a leaf root (Level 1, not expandable) looks like a
// grouped data row but must still reserve chevron width.
const bool isHierarchicalRow =
m_tableViewSourceRowMetadata && m_tableViewSourceRowMetadata->IsHierarchicalSource();
rowImpl->SetHierarchyStateInternal(
isHierarchicalRow ? (std::max)(1, rowInfo.Level) : 0,
rowInfo.IsExpandable,
rowInfo.IsExpanded);
}
using TableViewDetails::GetColumnHeaderText;
// Includes BasedOn styles.
static bool StyleSetsProperty(const winrt::Style& style, const winrt::DependencyProperty& property)
{
for (auto current = style; current; current = current.BasedOn())
{
for (auto const& setterBase : current.Setters())
{
if (auto const setter = setterBase.try_as<winrt::Setter>(); setter && setter.Property() == property)
{
return true;
}
}
}
return false;
}
// GetColumnHeaderText renders an untemplated IStringable for automation purposes, but only a genuine string
// may be swapped for a TextBlock -- a UIElement or a type with an implicit DataTemplate must keep
// the ContentPresenter's content model.
static bool IsPlainStringHeader(const winrt::TableViewColumn& column)
{
const auto propValue = column.Header().try_as<winrt::IPropertyValue>();
return propValue && propValue.Type() == winrt::PropertyType::String;
}
// Applying a Style whose TargetType the header is not, at any level of its BasedOn chain, would throw
// out of RebuildHeaders.
winrt::Style TableView::CompatibleHeaderStyle(const winrt::Style& style)
{
if (!style)
{
return nullptr;
}
// Depth in TableViewColumnHeader's base chain, or -1 outside it. A BasedOn Style may only target
// the same type as the Style it bases, or a base type. A Style without a TargetType hangs when
// applied, so it is outside the chain too.
auto const targetDepth = [](const winrt::Style& level) -> int
{
// TargetType returns a TypeName by value; the view must not outlive its Name.
const winrt::hstring targetName = level.TargetType().Name;
const std::wstring_view targetType = targetName;
if (targetType == winrt::name_of<winrt::TableViewColumnHeader>()) return 5;
if (targetType == winrt::name_of<winrt::ContentControl>()) return 4;
if (targetType == winrt::name_of<winrt::Control>()) return 3;
if (targetType == winrt::name_of<winrt::FrameworkElement>()) return 2;
if (targetType == winrt::name_of<winrt::UIElement>()) return 1;
if (targetType == winrt::name_of<winrt::DependencyObject>()) return 0;
return -1;
};
bool compatible = true;
int derivedDepth = INT_MAX;
winrt::Style level = style;
// Bounded: a BasedOn cycle would otherwise never end.
for (int guard = 0; level && compatible && guard < 64; ++guard, level = level.BasedOn())
{
const int depth = targetDepth(level);
compatible = depth >= 0 && depth <= derivedDepth;
derivedDepth = depth;
}
// A chain still going when the guard runs out loops, and applying it would throw.
if (level)
{
compatible = false;
}
if (compatible)
{
return style;
}
// Once per Style: a rebuild runs on every Density, sort or column change.
auto const known = std::find_if(m_loggedIncompatibleHeaderStyles.begin(), m_loggedIncompatibleHeaderStyles.end(),
[&style](auto const& weakStyle) { return weakStyle.get() == style; });
if (known == m_loggedIncompatibleHeaderStyles.end())
{
m_loggedIncompatibleHeaderStyles.erase(std::remove_if(m_loggedIncompatibleHeaderStyles.begin(), m_loggedIncompatibleHeaderStyles.end(),
[](auto const& weakStyle) { return !weakStyle.get(); }), m_loggedIncompatibleHeaderStyles.end());
m_loggedIncompatibleHeaderStyles.push_back(winrt::make_weak(style));
TVDiag::LogRetailF(L"[TableView] Ignored a column header Style whose TargetType or BasedOn chain is not TableViewColumnHeader or a base type.");
}
return nullptr;
}
// A header cell keeps its column's UIElement Header and tooltip content parented until released, and
// no other header can host them meanwhile. Every path that drops a header cell must release it first.
void TableView::ReleaseHeaderCells(const winrt::Panel& host, HeaderRelease scope, const winrt::TableViewColumn& column)
{
if (!host)
{
return;
}
// Snapshot: Closed and the Content change run app code that can re-enter RebuildHeaders and
// clear this collection.
auto const children = host.Children();
std::vector<winrt::FrameworkElement> cells;
cells.reserve(children.Size());
for (uint32_t i = 0; i < children.Size(); ++i)
{
if (auto const headerCell = children.GetAt(i).try_as<winrt::FrameworkElement>())
{
if (scope != HeaderRelease::All)
{
auto const cellColumn = headerCell.Tag().try_as<winrt::TableViewColumn>();
const bool released = scope == HeaderRelease::Column
? cellColumn && cellColumn == column
: cellColumn && winrt::get_self<TableViewColumn>(cellColumn)->GetOwningTableView() != *this;
if (!released)
{
continue;
}
}
cells.push_back(headerCell);
}
}
if (cells.empty())
{
return;
}
// Only a rebuild captures and restores header focus itself. Elsewhere the cell stays until the
// queued rebuild, so park focus on it rather than lose it with the content. Focus leaves a removed
// column's cell for the nearest remaining header, and stays parked on it only when none can take it.
if (scope != HeaderRelease::All)
{
winrt::FocusState focusState = winrt::FocusState::Unfocused;
if (auto const focusedColumn = GetFocusedHeaderColumn(host, focusState))
{
auto const focusedCell = std::find_if(cells.begin(), cells.end(),
[&focusedColumn](auto const& cell) { return cell.Tag().try_as<winrt::TableViewColumn>() == focusedColumn; });
if (focusedCell != cells.end())
{
if (scope == HeaderRelease::Column)
{
focusedCell->Focus(focusState);
}
else
{
MoveHeaderFocusOffRemovedColumn(host, *focusedCell, focusedColumn, focusState);
}
}
}
}
for (auto const& headerCell : cells)
{
TableViewDetails::ClearOwnedToolTip(headerCell);
}
for (auto const& headerCell : cells)
{
if (auto const header = headerCell.try_as<winrt::ContentControl>())
{
header.ClearValue(winrt::ContentControl::ContentProperty());
}
}
}
void TableView::MoveHeaderFocusOffRemovedColumn(
const winrt::Panel& host,
const winrt::FrameworkElement& removedCell,
const winrt::TableViewColumn& removedColumn,
winrt::FocusState focusState)
{
// Snapshot: Focus raises app focus handlers that can re-enter RebuildHeaders and clear Children.
std::vector<winrt::UIElement> cells;
{
auto const children = host.Children();
const uint32_t size = children.Size();
cells.reserve(size);
for (uint32_t i = 0; i < size; ++i)
{
cells.push_back(children.GetAt(i));
}
}
auto const removedIt = std::find(cells.begin(), cells.end(), removedCell);
if (removedIt == cells.end())
{
return;
}
const size_t removedIndex = static_cast<size_t>(removedIt - cells.begin());
auto const isHosted = [&host](const winrt::UIElement& cell)
{
return winrt::VisualTreeHelper::GetParent(cell) == host;
};
auto const tryFocus = [this, &cells, &isHosted, focusState](size_t index) -> winrt::TableViewColumn
{
auto const cell = cells[index].try_as<winrt::Control>();
winrt::TableViewColumn column{ nullptr };
if (cell)
{
column = cell.Tag().try_as<winrt::TableViewColumn>();
}
if (column
&& winrt::get_self<TableViewColumn>(column)->GetOwningTableView() == *this
&& cell.Visibility() == winrt::Visibility::Visible
&& isHosted(cell)
&& cell.Focus(focusState))
{
return column;
}
return nullptr;
};
winrt::TableViewColumn fallbackColumn{ nullptr };
const size_t count = cells.size();
for (size_t distance = 1; !fallbackColumn && distance < count; ++distance)
{
if (removedIndex + distance < count)
{
fallbackColumn = tryFocus(removedIndex + distance);
}
if (!fallbackColumn && removedIndex >= distance)
{
fallbackColumn = tryFocus(removedIndex - distance);
}
}
// With no header to take it, focus parks on the removed cell until the queued rebuild, which
// returns it to the column if it came back and otherwise moves it to the first header or the body.
m_headerFocusRemovedColumn = winrt::make_weak(removedColumn);
m_headerFocusFallbackColumn = nullptr;
if (fallbackColumn)
{
m_headerFocusFallbackColumn = winrt::make_weak(fallbackColumn);
}
m_headerFocusRemovedState = focusState;
if (!fallbackColumn)
{
if (auto const parkCell = removedCell.try_as<winrt::Control>(); parkCell && isHosted(parkCell))
{
parkCell.Focus(winrt::FocusState::Programmatic);
}
}
}
// The body element focus enters on from outside: the current row or cell, else the viewport edge.
bool TableView::FocusBodyAfterHeaderRemoval(winrt::FocusState focusState)
{
winrt::UIElement target{ nullptr };
if (auto const edgeRow = FindViewportEdgeRowInternal(false))
{
target = ResolveFocusEntryTarget(edgeRow, nullptr, winrt::FocusNavigationDirection::None).Element;
}
if (!target)
{
target = FindViewportEdgeElementInternal(false, true /* includeGroupHeaders */);
}
return target && target.Focus(focusState);
}
void TableView::RebuildHeaders()
{
// App code run while the old headers are released (focus, tooltip and content changes) can ask
// for a rebuild. A nested one would build cells the outer one then drops without releasing.
if (m_inRebuildHeaders)
{
m_rebuildHeadersRequested = true;
return;
}
m_rebuildHeadersRequested = false;
{
m_inRebuildHeaders = true;
auto const inRebuildGuard = wil::scope_exit([this]() noexcept { m_inRebuildHeaders = false; });
RebuildHeadersCore();
}
if (std::exchange(m_rebuildHeadersRequested, false))
{
QueueRebuildHeaders();
}
}
void TableView::RebuildHeadersCore()
{
m_headerSortSpaceArmedColumn = nullptr;
auto const removedFocusColumn = m_headerFocusRemovedColumn.get();
auto const fallbackFocusColumn = m_headerFocusFallbackColumn.get();
auto removedFocusState = m_headerFocusRemovedState;
m_headerFocusRemovedColumn = nullptr;
m_headerFocusFallbackColumn = nullptr;
auto host = m_headerHost.get();
if (!host)
{
return;
}
// Clearing the host drops focus with the cell; remember which header held it.
winrt::FocusState focusedHeaderState = winrt::FocusState::Unfocused;
auto focusedHeaderColumn = GetFocusedHeaderColumn(host, focusedHeaderState);
// A removed column that came back (a move) gets back the focus that left its header. Focus
// still parked on a removed column that did not come back moves to the first header or the body.
bool focusBody = false;
if (removedFocusColumn && focusedHeaderColumn
&& (focusedHeaderColumn == fallbackFocusColumn || focusedHeaderColumn == removedFocusColumn))
{
if (winrt::get_self<TableViewColumn>(removedFocusColumn)->GetOwningTableView() == *this)
{
focusedHeaderColumn = removedFocusColumn;
focusedHeaderState = removedFocusState;
}
else if (focusedHeaderColumn == removedFocusColumn)
{
focusedHeaderColumn = nullptr;
focusBody = true;
}
}
// Replacing the Columns vector skips MoveHeaderFocusOffRemovedColumn.
if (focusedHeaderColumn && winrt::get_self<TableViewColumn>(focusedHeaderColumn)->GetOwningTableView() != *this)
{
removedFocusState = focusedHeaderState;
focusedHeaderColumn = nullptr;
focusBody = true;
}
// Any focus move during the rebuild other than XAML's rescue off the removed cell belongs to
// the app or user, and the restore below must not pull focus back from it.
bool headerFocusMovedAway = false;
// Focus inside a header's content would leave with it, and an app moving focus as that content
// is released could not be told from XAML's rescue. Park it on the header first; an app that
// sends that move out of the header band has moved focus itself.
if (focusedHeaderColumn)
{
if (auto const focused = winrt::FocusManager::GetFocusedElement(XamlRoot()).try_as<winrt::DependencyObject>())
{
winrt::DependencyObject headerCell = focused;
for (auto parent = winrt::VisualTreeHelper::GetParent(headerCell); parent && parent != host;
parent = winrt::VisualTreeHelper::GetParent(parent))
{
headerCell = parent;
}
if (headerCell != focused && winrt::VisualTreeHelper::GetParent(headerCell) == host)
{
if (auto const control = headerCell.try_as<winrt::Control>())
{
control.Focus(winrt::FocusState::Programmatic);
winrt::FocusState ignored{};
headerFocusMovedAway = !GetFocusedHeaderColumn(host, ignored);
}
}
}
}
// XAML rescues focus only while the focused element is being removed.
bool removingFocused = false;
winrt::DependencyObject focusedAtTeardown{ nullptr };
winrt::FocusManager::GettingFocus_revoker headerFocusMoveRevoker{};
if (focusedHeaderColumn || focusBody)
{
auto const root = XamlRoot();
focusedAtTeardown = winrt::FocusManager::GetFocusedElement(root).try_as<winrt::DependencyObject>();
headerFocusMoveRevoker = winrt::FocusManager::GettingFocus(winrt::auto_revoke,
[&headerFocusMovedAway, &removingFocused, root, focusedAtTeardown, weakHost = winrt::make_weak(host), rescueSeen = false](
winrt::IInspectable const&, winrt::Microsoft::UI::Xaml::Input::GettingFocusEventArgs const& args) mutable
{
auto const newFocus = args.NewFocusedElement().try_as<winrt::UIElement>();
if (headerFocusMovedAway || !newFocus || newFocus.XamlRoot() != root)
{
return;
}
auto const liveHost = weakHost.get();
for (winrt::DependencyObject current = newFocus; current; current = winrt::VisualTreeHelper::GetParent(current))
{
if (liveHost && current == liveHost)
{
return;
}
}
if (removingFocused && !rescueSeen && focusedAtTeardown && args.OldFocusedElement() == focusedAtTeardown)
{
rescueSeen = true;
return;
}
headerFocusMovedAway = true;
});
}
// Clearing the host under a live popup tears its child down reentrantly. Releasing the cells
// removes only focus inside a header's content; clearing the host removes the headers too.
removingFocused = focusedAtTeardown && winrt::VisualTreeHelper::GetParent(focusedAtTeardown) != host;
ReleaseHeaderCells(host);
removingFocused = true;
host.Children().Clear();
removingFocused = false;
m_rebuildingHeaders = true;
auto const rebuildingGuard = wil::scope_exit([this]() noexcept { m_rebuildingHeaders = false; });
const winrt::Thickness cachedHeaderCellPadding = GetDensityHeaderCellPadding();
const double cachedRowMinHeight = GetDensityRowMinHeight();
// unbox_value_or, not unbox_value: the key is app-overridable and a non-double would throw out
// of RebuildHeaders; a non-positive or non-finite override would flow straight into Width().
double cachedResizeGripperWidth = winrt::unbox_value_or<double>(
LookupElementResource(*this, s_ResizeGripperWidthKey), c_resizeGripperWidthFallback);
if (!std::isfinite(cachedResizeGripperWidth) || cachedResizeGripperWidth <= 0.0)
{
cachedResizeGripperWidth = c_resizeGripperWidthFallback;
}
double cachedSortIndicatorWidth = winrt::unbox_value_or<double>(
LookupElementResource(*this, s_SortIndicatorSizeKey), c_sortIndicatorSizeFallback);
if (!std::isfinite(cachedSortIndicatorWidth) || cachedSortIndicatorWidth <= 0.0)
{
cachedSortIndicatorWidth = c_sortIndicatorSizeFallback;
}
const bool canUserSortColumns = CanUserSortColumns();
auto const tableHeaderStyle = CompatibleHeaderStyle(ColumnHeaderStyle());
if (auto columns = Columns())
{
for (auto const& column : columns)
{
if (!column || winrt::get_self<TableViewColumn>(column)->GetOwningTableView() != *this)
{
continue;
}
auto const header = winrt::make<TableViewColumnHeader>();
auto const headerImpl = winrt::get_self<TableViewColumnHeader>(header);
headerImpl->SetOwnerInternal(*this, column);
header.Visibility(column.Visibility());
auto const columnHeaderStyle = CompatibleHeaderStyle(column.HeaderStyle());
auto const headerStyle = columnHeaderStyle ? columnHeaderStyle : tableHeaderStyle;
// Read the Setters before applying the Style seals it: reading a sealed BasedOn Style's
// never-accessed Setters reseals it and re-merges its BasedOn setters.
const bool styleSetsMinHeight = StyleSetsProperty(headerStyle, winrt::FrameworkElement::MinHeightProperty());
const bool styleSetsPadding = StyleSetsProperty(headerStyle, winrt::Control::PaddingProperty());
if (headerStyle)
{
header.Style(headerStyle);
}
const bool headerIsResizable = CanUserResizeColumns() && column.CanResize();
const bool headerIsSortable = canUserSortColumns && column.CanSort();
AttachFocusCornerRadius(header);
const winrt::hstring headerText = GetColumnHeaderText(column);
if (!headerText.empty())
{
winrt::AutomationProperties::SetName(header, headerText);
}
winrt::AutomationProperties::SetAccessibilityView(header, winrt::AccessibilityView::Content);
if (!styleSetsMinHeight)
{
header.MinHeight(cachedRowMinHeight);
}
if (!styleSetsPadding)
{
header.Padding(cachedHeaderCellPadding);
}
if (auto headerTemplateSelector = column.HeaderTemplateSelector())
{
header.Content(column.Header());
header.ContentTemplateSelector(headerTemplateSelector);
}
else if (auto headerTemplate = column.HeaderTemplate())
{
header.Content(column.Header());
header.ContentTemplate(headerTemplate);
}
else if (!headerText.empty() && IsPlainStringHeader(column))
{
// A ContentPresenter renders a bare string through an implicit TextBlock carrying no
// TextTrimming, so a too-wide header hard-clips mid-glyph while its cells ellipsize.
winrt::TextBlock headerBlock;
headerBlock.Text(headerText);
headerBlock.TextTrimming(winrt::TextTrimming::CharacterEllipsis);
headerBlock.VerticalAlignment(winrt::VerticalAlignment::Center);
// The header's peer already announces this text.
winrt::AutomationProperties::SetAccessibilityView(headerBlock, winrt::AccessibilityView::Raw);
header.Content(headerBlock);
headerImpl->SetOwnsContentInternal(true);
}
else
{
header.Content(column.Header());
}
header.Tag(column);
TableViewDetails::ApplyHeaderToolTip(header, column.HeaderToolTip());
headerImpl->SetSortableInternal(headerIsSortable, cachedSortIndicatorWidth);
headerImpl->SetSortDirectionInternal(column.SortDirection());
if (headerIsSortable)
{
// Weak: the handler is owned by a visual the control also owns, so a strong
// capture would keep the TableView alive through its own header.
auto weakThis = get_weak();
header.Tapped([weakThis, column](auto const&, winrt::TappedRoutedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
if (strongThis->ToggleSortDirection(column))
{
args.Handled(true);
}
}
});
}
// The gripper marks the press handled, which also keeps a resize drag from reaching the
// header's Tapped handler and sorting the column.
if (headerIsResizable)
{
headerImpl->SetResizeGripperInternal(
CreateResizeGripper(header, column, cachedResizeGripperWidth, headerText));
}
host.Children().Append(header);
// Template now so the parts exist for the passes below and for callers right after.
header.ApplyTemplate();
}
}
// Pin (or refresh) leading-frozen header cells at the current scroll offset.
RefreshFrozenColumns();
ApplyGridLinesToHeader();
RefreshResizeGripperAvailability();
headerFocusMoveRevoker.revoke();
if (focusedHeaderColumn && !headerFocusMovedAway)
{
RestoreHeaderFocus(focusedHeaderColumn, focusedHeaderState);
}
else if (focusBody && !headerFocusMovedAway)
{
// Focus parks only when no other header could take it, so headers built now replaced the
// removed columns; keep focus in the header band before falling back to the body.
winrt::TableViewColumn firstHeaderColumn{ nullptr };
if (auto const columns = Columns())
{
for (auto const& column : columns)
{
if (column && column.Visibility() == winrt::Visibility::Visible &&
winrt::get_self<TableViewColumn>(column)->GetOwningTableView() == *this)
{
firstHeaderColumn = column;
break;
}
}
}
if (firstHeaderColumn)
{
RestoreHeaderFocus(firstHeaderColumn, removedFocusState);
}
else
{
FocusBodyAfterHeaderRemoval(removedFocusState);
}
}
}
winrt::TableViewColumn TableView::GetFocusedHeaderColumn(const winrt::Panel& host, winrt::FocusState& focusState) const
{
focusState = winrt::FocusState::Unfocused;
auto const root = XamlRoot();
if (!host || !root)
{
return nullptr;
}
auto const focusedElement = winrt::FocusManager::GetFocusedElement(root);
auto current = focusedElement.try_as<winrt::DependencyObject>();
while (current)
{
auto const parent = winrt::VisualTreeHelper::GetParent(current);
if (parent == host)
{
winrt::TableViewColumn column{ nullptr };
if (auto const cell = current.try_as<winrt::FrameworkElement>())
{
column = cell.Tag().try_as<winrt::TableViewColumn>();
}
if (column)
{
focusState = winrt::FocusState::Keyboard;
if (auto const ui = focusedElement.try_as<winrt::UIElement>())
{
focusState = ui.FocusState() == winrt::FocusState::Keyboard ? winrt::FocusState::Keyboard : winrt::FocusState::Programmatic;
}
}
return column;
}
current = parent;
}
return nullptr;
}
void TableView::RestoreHeaderFocus(const winrt::TableViewColumn& column, winrt::FocusState focusState)
{
CancelHeaderFocusRestore();
if (TryFocusHeaderCell(column, focusState))
{
return;
}
// Whatever holds focus now is what the rebuild left behind; anything else later is a user/app move.
winrt::weak_ref<winrt::DependencyObject> weakFocusAtSchedule{ nullptr };
if (auto const root = XamlRoot())
{
if (auto const focused = winrt::FocusManager::GetFocusedElement(root).try_as<winrt::DependencyObject>())
{
weakFocusAtSchedule = winrt::make_weak(focused);
}
}
auto weakThis = get_weak();
m_headerFocusRestoreLayoutToken = LayoutUpdated(
[weakThis, weakColumn = winrt::make_weak(column), focusState, weakFocusAtSchedule](winrt::IInspectable const&, winrt::IInspectable const&)
{
if (auto strongThis = weakThis.get())
{
strongThis->CancelHeaderFocusRestore();
if (!strongThis->CanRestoreHeaderFocus(weakFocusAtSchedule.get()))
{
return;
}
if (auto const liveColumn = weakColumn.get())
{
strongThis->TryFocusHeaderCell(liveColumn, focusState);
}
}
});
}
// Focus the user or app moved elsewhere while the restore was pending is never pulled back.
bool TableView::CanRestoreHeaderFocus(const winrt::DependencyObject& focusAtSchedule) const
{
auto const root = XamlRoot();
if (!root)
{
return false;
}
auto const focused = winrt::FocusManager::GetFocusedElement(root).try_as<winrt::DependencyObject>();
if (!focused || focused == focusAtSchedule)
{
return true;
}
auto const headerHost = m_headerHost.get();
for (auto current = focused; current; current = winrt::VisualTreeHelper::GetParent(current))
{
if (headerHost && current == headerHost)
{
return true;
}
}
return false;
}
bool TableView::TryFocusHeaderCell(const winrt::TableViewColumn& column, winrt::FocusState focusState)
{
auto const cell = TableViewCellsPanel::CellForColumn(m_headerHost.get(), column);
return cell && cell.Focus(focusState);
}
void TableView::CancelHeaderFocusRestore()
{
if (m_headerFocusRestoreLayoutToken.value)
{
LayoutUpdated(m_headerFocusRestoreLayoutToken);
m_headerFocusRestoreLayoutToken = {};
}
}
winrt::SortIndicatorDirection TableView::ToSortIndicatorDirection(winrt::SortDirection direction)
{
// Two distinct WinRT enums with matching numeric values; map explicitly rather than casting so
// a future divergence is a compile error rather than a wrong glyph.
switch (direction)
{
case winrt::SortDirection::Ascending:
return winrt::SortIndicatorDirection::Ascending;
case winrt::SortDirection::Descending:
return winrt::SortIndicatorDirection::Descending;
case winrt::SortDirection::None:
default:
return winrt::SortIndicatorDirection::None;
}
}
void TableView::RefreshSortIndicators()
{
auto host = m_headerHost.get();
if (!host)
{
return;
}
for (auto const& child : host.Children())
{
auto const header = child.try_as<winrt::TableViewColumnHeader>();
if (!header)
{
continue;
}
if (auto const column = header.Tag().try_as<winrt::TableViewColumn>())
{
winrt::get_self<TableViewColumnHeader>(header)->SetSortDirectionInternal(column.SortDirection());
}
}
}
void TableView::OnColumnHeaderStylePropertyChanged(const winrt::DependencyPropertyChangedEventArgs& /*args*/)
{
// Rebuild, not restyle in place: the style also decides whether density sets MinHeight/Padding.
QueueRebuildHeaders();
}
void TableView::OnCanUserSortColumnsPropertyChanged(const winrt::DependencyPropertyChangedEventArgs& /*args*/)
{
// The chevron and the click handler are stamped at header-build time. Queued first so it still
// runs when a Sorting or Sorted handler throws.
QueueRebuildHeaders();
// The gate turning off must also drop any sort it was responsible for; leaving the rows in a
// sorted order with no affordance to change it would strand the user.
if (!CanUserSortColumns())
{
// A sort cannot run while an edit is opening, and must not close it once open; clear once no edit is open.
if (m_editState == EditState::Beginning)
{
QueueReshapeAfterEditCloses([this]()
{
if (!CanUserSortColumns())
{
ClearSortInternal(false);
}
});
}
else
{
ClearSortInternal(false);
}
}
}
void TableView::OnColumnCanSortChanged(const winrt::TableViewColumn& column)
{
// The chevron and the click handler are stamped at header-build time. Queued first so it still
// runs when a Sorting or Sorted handler throws.
QueueRebuildHeaders();
// A column that just opted out must not keep an active sort applied to it. One lit only by the
// app's source sort keeps it: the app owns that axis.
if (column && !column.CanSort() && column.SortDirection() != winrt::SortDirection::None && !IsSortLitOnlyByApp())
{
if (m_editState == EditState::Beginning)
{
QueueReshapeAfterEditCloses([this, weakColumn = winrt::make_weak(column)]()
{
if (auto const optedOut = weakColumn.get(); optedOut && !optedOut.CanSort() && !IsSortLitOnlyByApp())
{
SortByColumnInternal(optedOut, winrt::SortDirection::None, false, false);
}
});
}
else
{
// Bypasses the CanSort gate that just closed; the public methods keep refusing.
SortByColumnInternal(column, winrt::SortDirection::None, false, false);
}
}
}
double TableView::GetHeaderMeasuredWidthForColumn(const winrt::TableViewColumn& column) const{
// Own the header host's concrete panel type here so the layout engine (TableView_Layout.cpp)
// pulls the header's measured width through this seam and never casts to TableViewCellsPanel.
if (auto headerHost = m_headerHost.get())
{
if (auto cellsPanel = headerHost.try_as<winrt::TableViewCellsPanel>())
{
return winrt::get_self<TableViewCellsPanel>(cellsPanel)->MeasuredWidthForColumn(column);
}
}
return 0.0;
}
void TableView::QueueRebuildHeaders()
{
// Before the template applies there is no header host; OnApplyTemplate builds headers once, so a
// rebuild queued now would be a wasted no-op (RebuildHeaders early-returns on a null host anyway).
if (!m_headerHost.get())
{
return;
}
// A rebuild is already scheduled for this tick -- collapse the burst into one.
if (m_rebuildHeadersQueued)
{
return;
}
auto dispatcher = DispatcherQueue();
if (!dispatcher)
{
// No dispatcher (teardown) -- rebuild synchronously so headers are not left stale.
RebuildHeaders();
return;
}
m_rebuildHeadersQueued = true;
auto weakThis = get_weak();
if (!dispatcher.TryEnqueue([weakThis]()
{
if (auto strongThis = weakThis.get())
{
strongThis->m_rebuildHeadersQueued = false;
try
{
strongThis->RebuildHeaders();
// Header sizes may have changed; re-resolve column widths against the new headers.
strongThis->InvalidateMeasure();
}
catch (...)
{
TableViewTelemetry::ReportError(strongThis->m_telemetrySession, 0,
TableViewTip::Operation::HeaderRefresh, TableViewTip::Stage::HeaderRefresh, true);
// Coalesced header rebuild is best-effort; never fail-fast the dispatcher.
}
}
}))
{
// Enqueue failed -- fall back to a synchronous rebuild so headers are not left stale.
m_rebuildHeadersQueued = false;
RebuildHeaders();
}
}
void TableView::RaiseColumnsStructureChanged()
{
// FromElement only: don't create a peer just to announce when no client is connected.
if (auto const peer = winrt::FrameworkElementAutomationPeer::FromElement(*this).try_as<winrt::TableViewAutomationPeer>())
{
winrt::get_self<TableViewAutomationPeer>(peer)->RaiseStructureChangedForColumnsChange();
}
}
void TableView::QueueRaiseColumnsStructureChanged()
{
if (!winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::StructureChanged))
{
return;
}
if (m_columnsStructureChangedQueued)
{
return;
}
auto dispatcher = DispatcherQueue();
if (!dispatcher)
{
// No dispatcher (test host / teardown): nothing to coalesce against, so raise inline.
RaiseColumnsStructureChanged();
return;
}
// Deferred so a burst of Columns VectorChanged callbacks announces once, with the final shape.
m_columnsStructureChangedQueued = true;
auto weakThis = get_weak();
if (!dispatcher.TryEnqueue([weakThis]()
{
if (auto strongThis = weakThis.get())
{
strongThis->m_columnsStructureChangedQueued = false;
try
{
strongThis->RaiseColumnsStructureChanged();
}
catch (...)
{
// Best-effort announcement; never fail-fast the dispatcher.
}
}
}))
{
m_columnsStructureChangedQueued = false;
RaiseColumnsStructureChanged();
}
}
void TableView::StopAwaitingSource()
{
if (auto const awaitedSource = m_awaitedSource.get())
{
winrt::get_self<::TableViewSource>(awaitedSource)->RemoveOwnerReleasedHandler(*this);
}
m_awaitedSource = nullptr;
}
void TableView::ReclaimItemsSource()
{
if (m_activeSource.get() ? !m_activeSourceReleased : !ItemsSource().try_as<winrt::TableViewSource>())
{
return;
}
AdoptItemsSource(true /* reclaimActiveSource */);
// While drained, OnRowsRepeaterLoaded re-sources the repeater.
if (!m_rowsSourceDrained)
{
RefreshRowsPipeline();
}
// A source refused while another table owned it arrives here.
NoteTipInitializationSource();
}
void TableView::OnTableViewUnloaded()
{
// A re-parent can raise Loaded before the stale Unloaded; tearing down now would leave a live
// table detached from its source, drained repeater, scrollers and ThemeSettings.
if (IsLoaded())
{
return;
}
m_headerSortSpaceArmedColumn = nullptr;
IgnoreTipInitializationUnloadedBeforeRender();
IgnoreTip(TableViewTip::IgnoreReason::Unloaded);
SampleInstanceTelemetry();
TableViewTelemetry::EndSession(m_telemetrySession);
// Neither a pending restore nor a remembered position may outlive this tree.
CancelReshapeFocusRestore();
ForgetBodyFocusPosition();
m_reshapeFocusValid = false;
m_reshapeFocusRow = -1;
CancelHeaderFocusRestore();
RevokeTipInitializationLayout();
if (m_pendingGroupFocusLayoutToken.value)
{
LayoutUpdated(m_pendingGroupFocusLayoutToken);
m_pendingGroupFocusLayoutToken = {};
}
if (m_pendingGroupRowRefreshLayoutToken.value)
{
LayoutUpdated(m_pendingGroupRowRefreshLayoutToken);
m_pendingGroupRowRefreshLayoutToken = {};
}
if (m_terminalGridLinesLayoutToken.value)
{
LayoutUpdated(m_terminalGridLinesLayoutToken);
m_terminalGridLinesLayoutToken = {};
}
if (auto terminalRow = m_terminalGridLineRow.get())
{
winrt::get_self<TableViewRow>(terminalRow)->SetTerminalGridLineSuppression({});
}
if (auto terminalHeader = m_terminalGridLineGroupHeader.get())
{
winrt::get_self<TableViewGroupHeader>(terminalHeader)->SetTerminalBottomGridLineSuppression(false);
}
m_terminalGridLineRowSizeChangedRevoker.revoke();
m_terminalGridLineRow = {};
m_terminalGridLineGroupHeader = {};
m_suppressTrailingGridLine = false;
m_suppressBottomGridLine = false;
m_terminalGridLineGeometryRetryAvailable = true;
m_terminalGridLineColumnIndex = -1;
m_terminalGridLineHorizontalOffset = std::numeric_limits<double>::quiet_NaN();
m_terminalGridLineVerticalOffset = std::numeric_limits<double>::quiet_NaN();
m_pendingGroupFocusIdentity.clear();
m_pendingGroupFocusState = winrt::FocusState::Unfocused;
m_pendingGroupFocusFromPointerGesture = false;
// Null ItemsSource on unload to release repeater cache work before it ticks on a detached subtree.
// OnRowsRepeaterLoaded re-sources cached pages when they return.
if (auto repeater = m_rowsRepeater.get())
{
// Re-sourcing on load hands SelectionModel a new view, and setting Source always clears.
// Hold the selected item so the reload re-selects it instead of dropping it.
StashSelectionForReload();
try { repeater.ItemsSource(nullptr); }
catch (...) {}
m_rowsSourceDrained = true; // Remember that Loaded must restore the source.
}
// An unloaded table may stay alive until GC; let a newly loaded one bind the source meanwhile.
// OnTableViewLoaded reclaims it.
// Flag first: the release runs other tables' waiters synchronously.
if (auto const activeSource = m_activeSource.get(); activeSource && !m_activeSourceReleased)
{
m_activeSourceReleased = true;
winrt::get_self<::TableViewSource>(activeSource)->ReleaseOwningTableView(*this);
}
StopAwaitingSource();
// Detach ViewChanged so deferred scroll callbacks do not run after unload.
if (auto bodyScroller = m_bodyScroller.get())
{
if (m_bodyScrollerViewChangedToken.value)
{
try { bodyScroller.ViewChanged(m_bodyScrollerViewChangedToken); }
catch (...) {}
m_bodyScrollerViewChangedToken = {};
}
}
m_bodyScrollerSizeChangedRevoker.revoke();
m_bodyScroller.set(nullptr);
m_headerScroller.set(nullptr);
// Drop the ThemeSettings subscription and instance so a subsequent Loaded re-creates it against
// the (possibly different) window's WindowId. IsHighContrast falls back to AccessibilitySettings
// while detached.
ReleaseThemeSettings();
}
void TableView::ReleaseThemeSettings()
{
if (auto appWindow = std::exchange(m_appWindow, nullptr))
{
try
{
appWindow.Destroying(std::exchange(m_appWindowDestroyingToken, {}));
}
catch (...)
{
}
}
// Release can surface a teardown failure; keep it off the noexcept path.
// Release before revoke() so a dead object's stale weak_ref makes it a no-op.
if (auto const themeSettings = static_cast<::IUnknown*>(winrt::detach_abi(m_themeSettings)))
{
try
{
themeSettings->Release();
}
catch (...)
{
}
}
m_themeSettingsChangedRevoker.revoke();
}
void TableView::OnHeaderBringIntoViewRequested(const winrt::BringIntoViewRequestedEventArgs& args)
{
auto const headerHost = m_headerHost.get();
auto const target = args.TargetElement();
auto const bodyScroller = m_bodyScroller.get();
if (!headerHost || !target || !bodyScroller)
{
return;
}
const double viewport = bodyScroller.ViewportWidth();
if (viewport <= 0.0)
{
return;
}
auto targetRect = args.TargetRect();
if (targetRect.Width <= 0.0 && targetRect.Height <= 0.0)
{
// Focus-driven BringIntoView passes an empty rect; using it as-is makes the right-edge
// branch below under-scroll by the element's width.
if (auto const targetFe = target.try_as<winrt::FrameworkElement>())
{
targetRect = winrt::Rect{ 0.0f, 0.0f,
static_cast<float>(targetFe.ActualWidth()), static_cast<float>(targetFe.ActualHeight()) };
}
}
winrt::Rect bounds{};
try
{
bounds = target.TransformToVisual(headerHost).TransformBounds(targetRect);
}
catch (...)
{
return; // Not connected yet; a stale rect would scroll to the wrong place.
}
const double current = bodyScroller.HorizontalOffset();
double offset = current;
if (bounds.X < current)
{
offset = bounds.X;
}
else if (bounds.X + bounds.Width > current + viewport)
{
offset = bounds.X + bounds.Width - viewport;
}
// Clamped before the comparison: a negative offset would otherwise mark the event handled
// while the clamped scroll went nowhere.
offset = std::max(0.0, offset);
if (std::abs(offset - current) >= 0.5)
{
// Handled only when we actually redirect. Marking it unconditionally also silenced
// ancestor scrollers, so a TableView below the fold never scrolled into view on header
// focus. The header scroller cannot scroll itself anyway (HorizontalScrollMode=Disabled).
args.Handled(true);
bodyScroller.ChangeView(offset, nullptr, nullptr, true /* disableAnimation */);
}
}
bool TableView::ApplyColumnResizeDrag(TableView* owner, const winrt::TableViewColumn& col, ColumnResizeDragState& state, double totalDelta)
{
double lo = NonNegativeFiniteOrZero(col.MinWidth());
double hi = MaxWidthForColumn(col, lo);
// The upper bound never falls below the width the drag started from, so a table that
// already overflows can still shrink.
lo = std::max(lo, state.bounds.Min);
hi = std::max(lo, std::min(hi, std::max(state.startValue, state.bounds.Max)));
const double next = std::clamp(state.startValue + totalDelta, lo, hi);
// Pinned at a bound the pointer keeps moving but the width does not. Writing anyway would
// re-run measure, and on a Star column it would also latch the width to pixels.
auto const current = col.Width();
const double currentValue =
current.GridUnitType == winrt::GridUnitType::Pixel ? current.Value : col.ActualWidth();
if (std::abs(currentValue - next) < 0.0001)
{
return false;
}
auto const columnImpl = winrt::get_self<TableViewColumn>(col);
if (!state.didWrite && owner)
{
owner->FreezeColumnsBeforeResize(col, state.frozen);
}
auto const resizeScope = columnImpl->BeginUserResizeScope();
col.Width(winrt::GridLengthHelper::FromPixels(next));
state.didWrite = true;
return true;
}
winrt::ResizeGripper TableView::CreateResizeGripper(
const winrt::FrameworkElement& headerCell,
const winrt::TableViewColumn& column,
double gripperWidth,
const winrt::hstring& headerText)
{
auto weakThis = get_weak();
winrt::ResizeGripper gripperVisual;
gripperVisual.DragOrientation(winrt::Orientation::Horizontal);
// Pointer affordance only here: the header cell owns keyboard focus, and one tab stop per
// column would sit between the user and the data.
gripperVisual.IsTabStop(false);
winrt::AutomationProperties::SetAccessibilityView(gripperVisual, winrt::AccessibilityView::Raw);
// Trailing edge in both flow directions; deliberately NOT swapped for RTL (see RebuildHeaders).
gripperVisual.HorizontalAlignment(winrt::HorizontalAlignment::Right);
gripperVisual.Width(gripperWidth);
if (!headerText.empty())
{
gripperVisual.OwnerName(headerText);
}
// Deltas must be measured against the TableView: its frame does not move while a column
// resizes, and unlike the XamlRoot it is below any scale an app applies above the control.
gripperVisual.ManipulationContainer(*this);
// shared_ptr so the handler closures keep it alive, and so Escape can reach the live drag.
auto state = std::make_shared<ColumnResizeDragState>();
auto weakColumn = winrt::make_weak(column);
// The column owns the width: capture it when the drag starts, then apply the reported offset
// against that anchor and clamp. Nothing is written until the user actually drags.
gripperVisual.DragStarted(
[weakColumn, weakThis, state](winrt::IInspectable const& sender, winrt::IInspectable const&)
{
auto const strongThis = weakThis.get();
// Before the guard below: a stale didWrite would revert to the previous drag's start width.
state->didWrite = false;
state->didDelta = false;
state->frozen.clear();
// One resize at a time. Manipulation arbitrates per element, so a second contact on a
// DIFFERENT gripper would otherwise run a concurrent drag that Escape could not reach.
if (strongThis)
{
if (auto const active = strongThis->m_activeColumnResizeDrag)
{
if (auto const activeGripper = active->gripper.get(); activeGripper && activeGripper.IsDragging())
{
if (auto const self = sender.try_as<winrt::ResizeGripper>())
{
self.EndDrag(true /* canceled */);
}
return;
}
}
}
if (auto const col = weakColumn.get())
{
state->startValue = col.ActualWidth();
state->startWidth = col.ReadLocalValue(winrt::TableViewColumn::WidthProperty());
if (strongThis)
{
state->bounds = strongThis->ResizeBoundsForColumn(col);
}
}
// Published so Escape can find the gesture in flight; the gripper owns everything else
// about it.
if (strongThis)
{
state->gripper = winrt::make_weak(sender.try_as<winrt::ResizeGripper>());
strongThis->m_activeColumnResizeDrag = state;
}
});
gripperVisual.DragDelta(
[weakColumn, weakThis, state](winrt::ResizeGripper const&, winrt::ResizeGripperDragDeltaEventArgs const& vargs)
{
auto const col = weakColumn.get();
if (!col)
{
return;
}
state->didDelta = true;
ApplyColumnResizeDrag(weakThis.get().get(), col, *state, vargs.TotalDelta());
});
// Canceled means the gesture was torn down rather than released (Escape, palm rejection, the
// header rebuilt mid-drag): revert. A clean release announces the new width instead.
auto weakHeaderCell = winrt::make_weak(headerCell);
gripperVisual.DragCompleted(
[weakColumn, weakThis, weakHeaderCell, state](winrt::ResizeGripper const&, winrt::ResizeGripperDragCompletedEventArgs const& cargs)
{
auto const strongThis = weakThis.get();
// Cleared first: reverting the width below can rebuild the headers, and a stale pointer
// here would let Escape end a gesture that no longer exists.
if (strongThis && strongThis->m_activeColumnResizeDrag == state)
{
strongThis->m_activeColumnResizeDrag.reset();
}
state->gripper = nullptr;
auto const col = weakColumn.get();
if (cargs.Canceled())
{
// Only when a write actually happened, so a press that never moved cannot pin an
// Auto/Star column.
// Non-empty only when a freeze actually ran, so this needs no didWrite gate: a write
// that threw after freezing would otherwise strand the predecessors as pixels.
for (auto const& entry : state->frozen)
{
if (auto const frozenCol = entry.column.get())
{
TableView::RestoreColumnWidth(frozenCol, entry.width);
}
}
if (state->didWrite && col)
{
TableView::RestoreColumnWidth(col, state->startWidth);
}
// Mid-drag refreshes were suppressed, so a sibling width change during the gesture
// still needs its availability applied.
if (strongThis)
{
strongThis->RefreshResizeGripperAvailability();
}
return;
}
// Attributed to the header cell: it is the focusable element that represents the column,
// and the one assistive technology is already on during a keyboard resize.
if (strongThis && col && state->didDelta)
{
strongThis->AnnounceColumnWidth(weakHeaderCell.get(), col);
}
if (strongThis)
{
strongThis->RefreshResizeGripperAvailability();
}
});
return gripperVisual;
}
// Escape is the only host-driven cancel left: the gripper ends its own gesture on release, on a
// canceled contact and on unload. Idempotent - EndDrag no-ops when no drag is in flight.
void TableView::CancelColumnResizeDrag()
{
auto const state = m_activeColumnResizeDrag;
if (!state)
{
return;
}
if (auto const gripper = state->gripper.get(); gripper && gripper.IsDragging())
{
// The DragCompleted handler clears m_activeColumnResizeDrag.
try { gripper.EndDrag(true /* canceled */); }
catch (...) { /* best-effort: EndDrag consumer handlers must not strand state. */ }
}
else
{
m_activeColumnResizeDrag.reset();
}
}
// The same gates that decide whether the gripper exists and is shown, so UIA never offers a
// resize the pointer cannot perform.
bool TableView::IsColumnResizeAllowed(const winrt::TableViewColumn& column)
{
return column &&
CanUserResizeColumns() &&
column.CanResize() &&
column.Visibility() == winrt::Visibility::Visible &&
winrt::get_self<TableViewColumn>(column)->GetOwningTableView() == *this;
}
winrt::ResizeGripper TableView::GetAvailableResizeGripper(const winrt::TableViewColumn& column)
{
if (!IsColumnResizeAllowed(column))
{
return nullptr;
}
auto const host = m_headerHost.get();
if (!host)
{
return nullptr;
}
auto const headerCell = TableViewCellsPanel::CellForColumn(host, column);
if (!headerCell || IsColumnResizePinned(column))
{
return nullptr;
}
return FindResizeGripperInCell(headerCell);
}
bool TableView::CanResizeColumnFromAutomation(const winrt::TableViewColumn& column)
{
return GetAvailableResizeGripper(column) != nullptr;
}
bool TableView::TryResizeColumnFromAutomation(const winrt::TableViewColumn& column, double width)
{
auto const gripper = GetAvailableResizeGripper(column);
if (!gripper || gripper.IsDragging())
{
return false;
}
// DragStarted anchors the gesture at ActualWidth, so the offset is taken from the same value.
const double totalDelta = width - column.ActualWidth();
// Rounding noise from the pixel conversion; writing it would pin an Auto or Star column.
if (std::abs(totalDelta) < 1e-3)
{
return true;
}
gripper.BeginDrag();
// DragStarted ends the gesture at once while another column's drag is in flight.
if (!gripper.IsDragging())
{
return false;
}
bool applied = true;
try
{
// TryDrag drops a step under its jitter deadband, but an absolute UIA width is no jitter:
// apply it as DragDelta would, announcing only if it wrote.
if (!gripper.TryDrag(totalDelta))
{
auto const state = m_activeColumnResizeDrag;
applied = state && state->gripper.get() == gripper;
if (applied)
{
state->didDelta = ApplyColumnResizeDrag(this, column, *state, totalDelta);
}
}
}
catch (...)
{
gripper.EndDrag(true /* canceled */);
throw;
}
gripper.EndDrag(!applied /* canceled */);
return applied;
}
winrt::ResizeGripper TableView::FindResizeGripperInCell(const winrt::FrameworkElement& headerCell) const
{
if (auto const header = headerCell.try_as<winrt::TableViewColumnHeader>())
{
return winrt::get_self<TableViewColumnHeader>(header)->GetResizeGripperInternal();
}
return nullptr;
}
void TableView::OnCanUserResizeColumnsPropertyChanged(const winrt::DependencyPropertyChangedEventArgs& args)
{
if (args.OldValue() == args.NewValue())
{
return;
}
RebuildHeaders();
}