Files
microsoft-ui-xaml/controls/dev/TableView/TableView_Selection.cpp
T
Saharsh JaiswalandCopilot 927f474d48 TableView TiP: blame by exception type and count contained app faults
Review round 3. Attribution matched the last app HRESULT against a
control-wide counter, so a nested callback could overwrite it and an
unrelated exception with the same HRESULT could match. App code's
exceptions are now tagged where TableView calls it (CallerError, and
CallerOutOfMemory for an allocation failure, which stays a std::bad_alloc),
and the catch that decides an outcome reads the type. SelectionModel calls
TableView back across the ABI, which drops the C++ type, so the call into
it tags the exception again by its error object.

Column code is TableView's unless app code ran it: the editing hooks are
internal, so only a CellEditingTemplate's content (while the editor is
prepared) is the app's there, and only an app-derived column class owns its
display factory and sort member path. Expansion is always tracked and its
catch reads the tag; only a request that changes one group or node runs a
test.

A filter over a large source no longer drops into the small bucket: the
large flag counts the source before the filter, and the metrics and
TableView_Instance carry both counts. Initialize closes as
unloaded_before_render when the table is unloaded with rows and a body
viewport before any row was laid out. An app filter predicate, sort or group
key selector or custom comparer that throws is still contained, and now
writes one recoverable TableView_Error per operation with FaultCount.

Internal pipeline builds fail when Tabular falls back to the TiP stub. All
versions start at 1, since none has shipped.

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

944 lines
32 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 "TableViewRow.h"
#include <utility>
#include <vector>
// Row selection: single-item, row-scoped.
//
// SelectionModel owns the selected index and reconciles it across collection changes - the same
// component ItemsView uses. This file is the layer around it: the DP projections, the row chrome,
// the gestures and the automation events. SelectedItem and SelectedIndex are read-only to apps,
// so this control is their only writer.
namespace
{
// Restores the previous value rather than clearing, so nesting cannot unlatch an outer scope.
struct ScopedFlag
{
ScopedFlag(bool& flag, bool value) noexcept : m_flag(flag), m_previous(flag) { m_flag = value; }
~ScopedFlag() { m_flag = m_previous; }
ScopedFlag(ScopedFlag const&) = delete;
ScopedFlag& operator=(ScopedFlag const&) = delete;
private:
bool& m_flag;
bool m_previous;
};
bool IsResolvableSelectionAnnouncementRow(
winrt::TableView const& owner,
winrt::TableViewRow const& row,
winrt::AutomationPeer const& peer,
winrt::IInspectable const& expectedItem)
{
if (!owner || !row || !peer || !expectedItem ||
winrt::get_self<TableViewRow>(row)->GetOwningTableView() != owner)
{
return false;
}
auto const repeater = winrt::get_self<TableView>(owner)->GetRowsRepeaterInternal();
if (!repeater)
{
return false;
}
const int32_t index = repeater.GetElementIndex(row);
auto const element = index >= 0 ? repeater.TryGetElement(index).try_as<winrt::TableViewRow>() : nullptr;
// Identity, not just index: a recycled container can still report an index while it has
// already been re-bound to a different item, so either announcement - selected or removed
// from selection - would name the wrong record.
auto const actualItem = winrt::get_self<TableView>(owner)->UnwrapEditingDataItem(row.DataContext());
return element == row && actualItem && TableView::SameInspectableIdentity(actualItem, expectedItem);
}
}
bool TableView::CanSelectRows()
{
return SelectionMode() != winrt::TableViewSelectionMode::None;
}
bool TableView::HasRowsSource() const
{
if (auto const repeater = m_rowsRepeater.get())
{
return repeater.ItemsSourceView() != nullptr;
}
return false;
}
// ----- SelectionModel plumbing -----
template <typename Call>
void TableView::CallSelectionModel(Call&& call)
{
m_selectionModelAppFault = {};
++m_selectionModelCallDepth;
auto const depth = wil::scope_exit([this]() noexcept { --m_selectionModelCallDepth; });
try
{
std::forward<Call>(call)();
// A hop whose exception SelectionModel swallowed must not keep the app's error object alive.
m_selectionModelAppFault = {};
}
catch (...)
{
TableViewTip::RestoreAppFault(std::exchange(m_selectionModelAppFault, {}));
throw;
}
}
void TableView::EnsureSelectionModel()
{
if (m_selectionModel)
{
return;
}
m_selectionModel = winrt::SelectionModel{};
m_selectionModel.SingleSelect(true);
auto weakThis = get_weak();
m_selectionModelChangedRevoker = m_selectionModel.SelectionChanged(
winrt::auto_revoke,
[weakThis](winrt::SelectionModel const& sender, winrt::SelectionModelSelectionChangedEventArgs const& args)
{
if (auto strongThis = weakThis.get())
{
strongThis->OnSelectionModelSelectionChanged(sender, args);
}
});
}
void TableView::UpdateSelectionModelSource()
{
if (!m_selectionModel)
{
return;
}
winrt::IInspectable rowsSource{ nullptr };
if (auto const repeater = m_rowsRepeater.get())
{
// The repeater's view, not the raw source: SelectionNode reuses an ItemsSourceView it is
// handed, so the model and the repeater observe one shared view in one subscription order
// instead of racing two independent views over the same collection.
rowsSource = repeater.ItemsSourceView();
}
// Source has no identity short-circuit: assigning the same source again clears the selection.
if (m_selectionModel.Source() != rowsSource)
{
CallSelectionModel([&]() { m_selectionModel.Source(rowsSource); });
}
}
int32_t TableView::SelectedIndexInternal() const
{
if (m_selectionModel)
{
// Flat source, so the path is one deep when there is a selection.
if (auto const path = m_selectionModel.SelectedIndex(); path && path.GetSize() > 0)
{
return path.GetAt(0);
}
}
return -1;
}
winrt::IInspectable TableView::SelectedItemInternal() const
{
return SelectedItemForIndex(SelectedIndexInternal());
}
winrt::IInspectable TableView::SelectedItemForIndex(int32_t index) const
{
// SelectionModel::SelectedItem indexes its source without a bounds check, and the model can be
// momentarily ahead of or behind the collection while a change is being dispatched.
if (!m_selectionModel || index < 0 || index >= GetItemsSourceCount())
{
return nullptr;
}
return m_selectionModel.SelectedItem();
}
// ----- Deferred reload request -----
//
// Only one thing defers now: unload drains the repeater's source, and re-sourcing on load hands
// SelectionModel a new view, which clears it. The selected item is held across that round trip.
// ItemsView needs no equivalent because it never drains its repeater on unload.
bool TableView::ShouldDeferSelectionRequest()
{
return !CanSelectRows() || !HasRowsSource();
}
void TableView::ClearPendingSelection()
{
m_pendingSelectedItem.set(nullptr);
m_pendingSelectedIdentity.clear();
}
winrt::hstring TableView::HierarchyIdentityForIndex(int32_t index) const
{
if (index < 0 || !m_tableViewSourceRowMetadata || !m_tableViewSourceRowMetadata->IsHierarchicalSource())
{
return {};
}
try
{
return m_tableViewSourceRowMetadata->GetIdentity(index);
}
catch (...)
{
// Mid-reshape the row may not be addressable; the object anchor still applies.
return {};
}
}
bool TableView::DrainPendingSelection()
{
if (ShouldDeferSelectionRequest())
{
return false;
}
if (auto const pendingItem = m_pendingSelectedItem.get())
{
auto const pendingIdentity = m_pendingSelectedIdentity;
ClearPendingSelection();
// Tree: re-anchor on node key first (app may have replaced the object); object identity is
// the fallback.
int32_t index = -1;
if (!pendingIdentity.empty() && m_tableViewSourceRowMetadata &&
m_tableViewSourceRowMetadata->IsHierarchicalSource())
{
int32_t candidate = -1;
TableViewRowInfo info{};
if (m_tableViewSourceRowMetadata->TryGetIndexForIdentity(pendingIdentity, candidate) &&
TryGetTableViewSourceRowInfo(candidate, info) && info.Kind == TableViewRowKind::Data)
{
index = candidate;
}
}
ApplySelection(index >= 0 ? index : IndexOfItem(pendingItem));
return true;
}
return false;
}
int32_t TableView::IndexOfItem(winrt::IInspectable const& item) const
{
if (!item)
{
return -1;
}
auto const repeater = m_rowsRepeater.get();
if (!repeater)
{
return -1;
}
auto const view = repeater.ItemsSourceView();
if (!view)
{
return -1;
}
// SelectionModel indexes but does not look items up, so resolving a held item on reload still
// needs this. Linear scan: it runs at most once per reload.
auto const target = UnwrapEditingDataItem(item);
const int32_t count = view.Count();
for (int32_t index = 0; index < count; ++index)
{
// Identity, not ABI-pointer equality: the same object reached through a different
// interface (boxed values, projected interfaces) must still match.
if (auto const candidate = UnwrapEditingDataItem(view.GetAt(index));
candidate && SameInspectableIdentity(candidate, target))
{
return index;
}
}
return -1;
}
// ----- The single writer -----
void TableView::ApplySelection(int32_t index, bool measureTip)
{
bool validRequest = index >= -1;
if (!CanSelectRows())
{
index = -1;
validRequest = false;
}
if (index >= 0 && index >= GetItemsSourceCount())
{
index = -1;
validRequest = false;
}
const bool accepted = m_telemetrySession.active && measureTip && !m_isRestoringSelection &&
validRequest && m_lastPublishedIndex != index;
const auto version = m_selectionVersion;
const auto sourceGeneration = m_rowMetadataGeneration;
ActionTip tip{ *this, TableViewTip::Operation::Selection, TableViewTip::Stage::Selection, accepted };
try
{
if (index < 0 && !m_selectionModel)
{
// Nothing selected and no model yet - nothing to clear, but publish so the projections
// start out agreeing with the model.
m_stickySelectedItem.set(nullptr);
m_stickySelectedIdentity.clear();
PushSelectionProperties();
return;
}
EnsureSelectionModel();
if (index < 0)
{
CallSelectionModel([&]() { m_selectionModel.ClearSelection(); });
}
else
{
CallSelectionModel([&]() { m_selectionModel.Select(index); });
}
// Capture the intentional selection by object identity. This is the single selection writer, so
// every gesture / programmatic set / restore passes through here; the anchor otherwise moves only
// in OnSelectionModelSelectionChanged (incremental clear outside a restore). A Reset-driven clear
// runs under the restore flag, keeping it for the identity restore.
m_stickySelectedItem.set(index >= 0 ? SelectedItemForIndex(index) : nullptr);
m_stickySelectedIdentity = (index >= 0 && m_stickySelectedItem.get()) ? HierarchyIdentityForIndex(index) : winrt::hstring{};
// The model raises SelectionChanged only when the selection actually moved; publish here too so
// a rejected write still leaves the DPs agreeing with the model.
PushSelectionProperties();
if (accepted)
{
if (m_selectionVersion == version + 1 && m_rowMetadataGeneration == sourceGeneration &&
m_lastPublishedIndex == index)
{
tip.Complete();
}
else
{
tip.Ignore(TableViewTip::IgnoreReason::Superseded);
}
}
}
catch (...)
{
tip.Escaping(TableViewTip::CurrentFault());
throw;
}
}
void TableView::OnSelectionModelSelectionChanged(
const winrt::SelectionModel& /*sender*/,
const winrt::SelectionModelSelectionChangedEventArgs& /*args*/)
try
{
const int32_t newIndex = SelectedIndexInternal();
auto const newItem = SelectedItemInternal();
auto const deselectedItem = UnwrapEditingDataItem(SelectedItem());
auto const selectedItem = UnwrapEditingDataItem(newItem);
auto const deselectedRow = FindRealizedRowForIndex(m_lastPublishedIndex);
auto const selectedRow = FindRealizedRowForIndex(newIndex);
m_lastPublishedIndex = newIndex;
// Arm the guard BEFORE any DP write. Both the row IsSelected pushes below and
// PushSelectionProperties notify synchronously, so an observer can select something else from
// inside either one. When that happens the nested pass has already published and raised for the
// newer selection; finishing this one would overwrite it and raise a bogus delta.
const uint32_t version = ++m_selectionVersion;
// Restamp before notifying, so a handler that walks the rows sees settled chrome.
if (deselectedRow)
{
winrt::get_self<TableViewRow>(deselectedRow)->SetIsSelectedInternal(false);
}
if (selectedRow)
{
winrt::get_self<TableViewRow>(selectedRow)->SetIsSelectedInternal(true);
}
if (m_selectionVersion != version)
{
return;
}
// While a reload is being restored, SelectionModel::Source clears before the stashed item is
// re-selected. Chrome is already settled above; suppressing the rest keeps that round trip from
// publishing a transient null and raising a clear-then-reselect pair for a selection that never
// actually changed. ResolveSelectionAfterSourceChange publishes once when it finishes.
if (m_isRestoringSelection)
{
return;
}
// Outside a restore, a model clear means the row left the view (a removal, or a group or subtree
// collapse). Drop the anchor so a later Reset doesn't resurrect it.
if (newIndex < 0)
{
m_stickySelectedItem.set(nullptr);
m_stickySelectedIdentity.clear();
}
PushSelectionProperties();
if (m_selectionVersion != version)
{
return;
}
RaiseSelectionAutomationEvents(deselectedRow, deselectedItem, selectedRow, selectedItem);
RaiseSelectionChanged(newItem);
}
catch (...)
{
// SelectionModel called this through the ABI, which drops the exception's tag; CallSelectionModel restores it.
// Outside such a call nothing would consume it.
if (m_selectionModelCallDepth) { m_selectionModelAppFault = TableViewTip::CurrentAppFaultHop(); }
throw;
}
void TableView::PushSelectionProperties()
{
// The properties are read-only to apps, so this is the only writer and there is no echo to
// suppress and nothing to re-assert against an observer writing back.
// Read the index once and derive the item from it: each SelectedIndexInternal() call builds an
// IndexPath, and SelectedItemInternal() would read it again.
const int32_t index = SelectedIndexInternal();
auto const item = SelectedItemForIndex(index);
// Only app property observers and bindings run from these writes; a throw from them is the app's.
try
{
if (SelectedIndex() != index)
{
SelectedIndex(index);
}
if (!SameInspectableIdentity(SelectedItem(), item))
{
SelectedItem(item);
}
}
catch (...)
{
TableViewTip::RethrowAppFault(TableViewTip::AppFaultKind::Callback);
}
}
winrt::TableViewRow TableView::FindRealizedRowForIndex(int32_t index)
{
if (index < 0)
{
return nullptr;
}
if (auto const repeater = m_rowsRepeater.get())
{
// TryGetElement only returns containers the repeater currently considers realized, so this
// cannot hand back a pooled ghost.
return repeater.TryGetElement(index).try_as<winrt::TableViewRow>();
}
return nullptr;
}
void TableView::RaiseSelectionChanged(winrt::IInspectable const& addedItem)
{
// `removed` is the last item actually REPORTED, so a superseded transition's removal still
// surfaces exactly once rather than being lost with the suppressed event.
//
// Single-selection only: the delta is derived from one cached item. Under Multiple this
// short-circuit would SILENTLY suppress a real change whose first item happened not to move -
// selecting a second row while the first stays selected. Multiple must diff the selected sets
// instead, the way Selector does; SelectionModelSelectionChangedEventArgs carries no delta.
auto const removedItem = m_lastRaisedSelectedItem.get();
if (SameInspectableIdentity(removedItem, addedItem))
{
return;
}
m_lastRaisedSelectedItem.set(addedItem);
std::vector<winrt::IInspectable> added;
if (addedItem)
{
added.push_back(addedItem);
}
std::vector<winrt::IInspectable> removed;
if (removedItem)
{
removed.push_back(removedItem);
}
// The platform args type, not a bespoke one, so a handler can be shared with ListView.
// Constructor order is (removedItems, addedItems).
auto const args = winrt::Microsoft::UI::Xaml::Controls::SelectionChangedEventArgs{
winrt::single_threaded_vector<winrt::IInspectable>(std::move(removed)),
winrt::single_threaded_vector<winrt::IInspectable>(std::move(added)) };
try { m_selectionChangedEventSource(*this, args); }
catch (...) { TableViewTip::RethrowAppFault(TableViewTip::AppFaultKind::Callback); }
}
void TableView::RaiseSelectionAutomationEvents(
winrt::TableViewRow const& deselectedRow,
winrt::IInspectable const& deselectedItem,
winrt::TableViewRow const& selectedRow,
winrt::IInspectable const& selectedItem)
{
// Container-level first: it is the only signal available when the selected row is unrealized
// and there is no row peer to raise a per-element event on.
if (winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::SelectionPatternOnInvalidated))
{
if (auto const peer = winrt::FrameworkElementAutomationPeer::FromElement(*this))
{
peer.RaiseAutomationEvent(winrt::AutomationEvents::SelectionPatternOnInvalidated);
}
}
// FromElement returns an existing peer or null - it never forces one into existence.
if (selectedRow &&
winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::SelectionItemPatternOnElementSelected))
{
if (auto const peer = winrt::FrameworkElementAutomationPeer::FromElement(selectedRow))
{
if (IsResolvableSelectionAnnouncementRow(*this, selectedRow, peer, selectedItem))
{
peer.RaiseAutomationEvent(winrt::AutomationEvents::SelectionItemPatternOnElementSelected);
}
}
}
if (deselectedRow &&
winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::SelectionItemPatternOnElementRemovedFromSelection))
{
if (auto const peer = winrt::FrameworkElementAutomationPeer::FromElement(deselectedRow))
{
if (IsResolvableSelectionAnnouncementRow(*this, deselectedRow, peer, deselectedItem))
{
peer.RaiseAutomationEvent(winrt::AutomationEvents::SelectionItemPatternOnElementRemovedFromSelection);
}
}
}
// Narrator keys off the IsSelected property change, not only the pattern events above;
// TreeViewItem raises both for the same reason.
if (winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::PropertyChanged))
{
if (selectedRow)
{
if (auto const peer = winrt::FrameworkElementAutomationPeer::FromElement(selectedRow))
{
if (IsResolvableSelectionAnnouncementRow(*this, selectedRow, peer, selectedItem))
{
peer.RaisePropertyChangedEvent(
winrt::SelectionItemPatternIdentifiers::IsSelectedProperty(),
box_value(false),
box_value(true));
}
}
}
if (deselectedRow)
{
if (auto const peer = winrt::FrameworkElementAutomationPeer::FromElement(deselectedRow))
{
if (IsResolvableSelectionAnnouncementRow(*this, deselectedRow, peer, deselectedItem))
{
peer.RaisePropertyChangedEvent(
winrt::SelectionItemPatternIdentifiers::IsSelectedProperty(),
box_value(true),
box_value(false));
}
}
}
}
}
// ----- Property-changed callbacks -----
void TableView::OnSelectionModePropertyChanged(const winrt::DependencyPropertyChangedEventArgs& /*args*/)
{
if (!CanSelectRows())
{
// Turning selection off is an explicit app action: clear, and drop anything held for a
// reload so it cannot resurrect after the app asked for nothing to be selected.
ClearPendingSelection();
ApplySelection(-1);
}
}
// ----- Source changes -----
void TableView::UpdateSelectionCollectionChangedSubscription()
{
winrt::ItemsSourceView view{ nullptr };
if (auto const repeater = m_rowsRepeater.get())
{
view = repeater.ItemsSourceView();
}
if (m_selectionCollectionChangedRevoker && SameInspectableIdentity(view, m_selectionCollectionChangedView))
{
return;
}
// auto_revoke drops the prior source's subscription.
m_selectionCollectionChangedRevoker = {};
m_selectionCollectionChangedView = nullptr;
if (view)
{
m_selectionCollectionChangedRevoker = view.CollectionChanged(
winrt::auto_revoke, { this, &TableView::OnSelectionItemsSourceCollectionChanged });
m_selectionCollectionChangedView = view;
}
}
void TableView::UpdateSelectionResetDetectorSubscription()
{
winrt::ItemsSourceView view{ nullptr };
if (auto const repeater = m_rowsRepeater.get())
{
view = repeater.ItemsSourceView();
}
if (m_selectionResetDetectorRevoker && SameInspectableIdentity(view, m_selectionResetDetectorView))
{
return;
}
// auto_revoke drops the prior source's subscription.
m_selectionResetDetectorRevoker = {};
m_selectionResetDetectorView = nullptr;
if (view)
{
m_selectionResetDetectorRevoker = view.CollectionChanged(
winrt::auto_revoke, { this, &TableView::OnSelectionSourceReset });
m_selectionResetDetectorView = view;
}
}
void TableView::OnSelectionSourceReset(
const winrt::IInspectable& /*sender*/,
const winrt::NotifyCollectionChangedEventArgs& args)
{
// Only a Reset drops the model's indices wholesale (an Add/Remove shifts the selected index and
// SelectionModel reconciles it in place, so selection survives without help). This must run
// FIRST, before the model reconciles the same Reset - else the app sees a spurious
// SelectionChanged clearing the selection, immediately followed by another restoring it.
if (args.Action() != winrt::NotifyCollectionChangedAction::Reset)
{
return;
}
// A reload restore already owns the stash/suppress protocol; don't stack a second one.
if (m_isRestoringSelection || m_resetSelectionRestorePending)
{
return;
}
auto const sticky = m_stickySelectedItem.get();
if (!sticky)
{
return;
}
// Stash by identity and arm the restore. Setting m_isRestoringSelection now suppresses the
// transient clear the model is about to publish, so the restore reads as one atomic event (or
// as silence when the same row is re-selected) rather than a clear-then-reselect pair.
m_pendingSelectedItem.set(sticky);
m_pendingSelectedIdentity = m_stickySelectedIdentity;
m_isRestoringSelection = true;
m_resetSelectionRestorePending = true;
}
void TableView::OnSelectionItemsSourceCollectionChanged(
const winrt::IInspectable& /*sender*/,
const winrt::IInspectable& /*args*/)
{
if (m_resetSelectionRestorePending)
{
// The model has now reconciled the Reset (this handler is subscribed after it), so the view
// is settled and the identity lookup is valid. Restore the stashed item: DrainPendingSelection
// re-selects it when it survived the reshape, or clears when the filter removed it. Mirrors
// ResolveSelectionAfterSourceChange - drain under the restoring flag so the internal write is
// suppressed, then publish exactly once.
m_resetSelectionRestorePending = false;
DrainPendingSelection();
m_isRestoringSelection = false;
m_lastPublishedIndex = SelectedIndexInternal();
RestampAllRealizedRowSelection(m_lastPublishedIndex);
PushSelectionProperties();
RaiseSelectionChanged(SelectedItemInternal());
return;
}
// SelectionModel has already reconciled the index - and deliberately does not raise when an
// insert merely shifts it. All that is left is the chrome: ItemsRepeater re-indexes its
// realized containers around now, so re-derive every one of them rather than trusting the
// index any single row was last stamped against.
m_lastPublishedIndex = SelectedIndexInternal();
RestampAllRealizedRowSelection(m_lastPublishedIndex);
PushSelectionProperties();
}
void TableView::RestampAllRealizedRowSelection()
{
RestampAllRealizedRowSelection(SelectedIndexInternal());
}
void TableView::RestampAllRealizedRowSelection(int32_t selectedIndex)
{
// The index is passed in because SelectedIndexInternal() builds an IndexPath per call, and this
// runs once per realized row on every collection notification.
ForEachRealizedRow([this, selectedIndex](winrt::TableViewRow const& row)
{
RefreshRowSelectionState(row, selectedIndex);
});
}
void TableView::StashSelectionForReload()
{
if (m_pendingSelectedItem.get())
{
return;
}
if (auto const item = SelectedItemInternal())
{
m_pendingSelectedItem.set(item);
}
}
void TableView::ResolveSelectionAfterSourceChange()
{
// Create the model before anything else subscribes. UpdateSelectionModelSource is a no-op while
// the model is null, so leaving it lazy here would let this control register on the shared view
// first and restamp rows from a not-yet-reconciled index. ItemsView constructs its model inline
// for the same reason.
EnsureSelectionModel();
// A source/shape swap (grouped<->flat, an ItemsSource replacement) reassigns the model's Source,
// which clears it. If nothing was explicitly stashed, seed the restore from the sticky anchor so
// a selection that survives the swap by identity is preserved here too - the same guarantee the
// in-place Reset path gives. A stale item (new dataset) simply resolves to -1 and clears.
if (!m_pendingSelectedItem.get())
{
if (auto const sticky = m_stickySelectedItem.get())
{
m_pendingSelectedItem.set(sticky);
}
}
const bool hasStashedSelection = m_pendingSelectedItem.get() != nullptr;
{
// Setting Source clears, and the drain then re-selects. Publishing between the two would
// emit a transient null and a clear-then-reselect event pair for a selection that did not
// actually change, so the whole restore is published once, at the end.
ScopedFlag const restoring{ m_isRestoringSelection, hasStashedSelection };
// Detector first, then model, then the restamp handler: the detector must observe a later
// in-place Reset ahead of the model so it can capture the selection before the model drops
// it, and the restamp handler must observe after the model so it restores against a
// reconciled view.
UpdateSelectionResetDetectorSubscription();
// Model second: it must be subscribed to the shared view ahead of the restamp handler, so
// that by the time OnSelectionItemsSourceCollectionChanged runs the index is reconciled.
UpdateSelectionModelSource();
UpdateSelectionCollectionChangedSubscription();
// A selection held across a reload outranks the live one: the live value was resolved
// against the source being replaced, whereas the held item is what was selected before.
DrainPendingSelection();
}
m_lastPublishedIndex = SelectedIndexInternal();
PushSelectionProperties();
RestampAllRealizedRowSelection(m_lastPublishedIndex);
// Only raises when the item actually differs from the last one reported, so a round trip that
// restores the same selection stays silent.
RaiseSelectionChanged(SelectedItemInternal());
}
// ----- Row plumbing -----
void TableView::RefreshRowSelectionState(winrt::TableViewRow const& row)
{
RefreshRowSelectionState(row, SelectedIndexInternal());
}
void TableView::RefreshRowSelectionState(winrt::TableViewRow const& row, int32_t selectedIndex)
{
if (!row)
{
return;
}
bool isSelected = false;
if (selectedIndex >= 0)
{
if (auto const repeater = m_rowsRepeater.get())
{
// By INDEX, not item identity: a collection may hold the same object twice, and an
// identity match would light up every row showing it.
isSelected = repeater.GetElementIndex(row) == selectedIndex;
}
}
winrt::get_self<TableViewRow>(row)->SetIsSelectedInternal(isSelected);
}
void TableView::OnRowPointerSelect(winrt::TableViewRow const& row)
{
if (!row)
{
return;
}
if (auto const repeater = m_rowsRepeater.get())
{
const auto index = repeater.GetElementIndex(row);
if (index >= 0)
{
// SelectRowIndexFromInteraction gates on SelectionMode.
SelectRowIndexFromInteraction(index);
}
}
}
void TableView::SelectRowIndexFromInteraction(int32_t index)
{
// Ctrl toggles, matching SingleSelector::OnInteractedAction and ListViewBase. Without it there
// is no pointer or keyboard gesture that can clear a selection once one is made - the app would
// have to call DeselectAll. Read live rather than off the args, as ItemsView's interaction
// layer does, so the row's pointer handlers do not have to carry modifier state.
const bool isControlDown =
(winrt::InputKeyboardSource::GetKeyStateForCurrentThread(winrt::VirtualKey::Control) &
winrt::CoreVirtualKeyStates::Down) == winrt::CoreVirtualKeyStates::Down;
SelectRowIndexFromInteraction(index, isControlDown);
}
void TableView::SelectRowIndexFromInteraction(int32_t index, bool toggle)
{
if (!CanSelectRows())
{
// A user gesture while selection is off is a no-op.
return;
}
// Group headers share the flat projection's index space with data rows but are not selectable.
// Keyboard navigation legitimately lands focus on a header (to expand/collapse it); when it
// does, leave the existing data selection untouched rather than moving it to - or clearing it
// against - a header index. Selection-follows-focus resumes on the next data row.
if (index >= 0 && IsGroupHeaderRow(index))
{
return;
}
// An explicit gesture settles the question - drop anything held for a reload.
ClearPendingSelection();
if (toggle && IsSelected(index))
{
ApplySelection(-1, true);
return;
}
ApplySelection(index, true);
}
void TableView::SelectRowIndexFromKeyboardFocus(int32_t index)
{
SelectRowIndexFromInteraction(index, false /* toggle */);
}
// ----- Public API -----
void TableView::Select(int32_t index)
{
if (index < 0)
{
// Explicit "select nothing".
DeselectAll();
return;
}
// Reject rather than coerce. ApplySelection turns an unresolvable index into "clear", which is
// right for a coercion path but wrong here: Select(999) must not wipe an existing selection.
// ItemsView::Select is a straight pass-through to SelectionModel and never clears either.
if (!CanSelectRows() || index >= GetItemsSourceCount())
{
return;
}
// A group header shares the flat projection's index space with data rows but is not selectable,
// so it is rejected here. Without this the internal GroupedEntry would reach the app.
if (IsGroupHeaderRow(index))
{
return;
}
ApplySelection(index, true);
}
void TableView::Deselect(int32_t index)
{
// Only clears when `index` IS the selection, so a stale call cannot clobber a newer one.
if (IsSelected(index))
{
ApplySelection(-1, true);
}
}
bool TableView::IsSelected(int32_t index)
{
// Ask the model rather than comparing against the single selected index, so this stays correct
// when Multiple lands. The guard is required: SelectionModel::IsSelected asserts on index < 0.
if (index < 0 || !m_selectionModel || index >= GetItemsSourceCount())
{
return false;
}
auto const selected = m_selectionModel.IsSelected(index);
return selected && selected.Value();
}
void TableView::DeselectAll()
{
ClearPendingSelection();
ApplySelection(-1, true);
}