mirror of
https://github.com/microsoft/microsoft-ui-xaml.git
synced 2026-10-28 16:35:19 +08:00
## Fixes Brings the TableView context menu work (reviewed in #12299, merged into `user/dipesh/feature/tableview-experimental`) into main. ## PR Type - [ ] Bugfix - [x] Feature - [ ] Code style update (formatting, renaming) - [ ] Refactoring (no functional changes, no API changes) - [ ] Build related changes - [ ] Documentation content changes - [ ] Other (please describe): ## Description Adds context menu support to TableView for cells, column headers, rows, group headers and the table itself, from mouse, touch, keyboard (Shift+F10 / Menu key) and UI Automation. ### Current Behavior TableView has no per-column menus. A right-click only shows whatever `ContextFlyout` the framework finds on the hit element, keyboard and UIA requests have no defined target, and menus do not get the row's item as their DataContext. ### New Behavior - **New API (preview):** `TableViewColumn.CellContextFlyout` and `TableViewColumn.HeaderContextFlyout` (+ DP properties). - **Resolution, first match wins:** - Cell: a `ContextFlyout` inside the cell content → `CellContextFlyout` → the row's `ContextFlyout` → `TableView.ContextFlyout`. - Header: a `ContextFlyout` inside the `HeaderTemplate` → `HeaderContextFlyout` → `TableView.ContextFlyout`. - Rows and group headers get menus from implicit `TableViewRow` / `TableViewGroupHeader` styles; a group header's DataContext is its `TableViewGroupInfo`. - Menu items inherit the requested row's item as DataContext, so one shared menu works for every row. - Opening a menu focuses the target cell/row and never changes selection. Keyboard and UIA requests open at the focused cell. - No TableView menu opens while a cell is editing. Nested TableViews / items hosts and text controls' default flyouts are not picked up by the outer table. - Automation peers (row, cell, header, group header, table) implement `ShowContextMenu`; pooled/collapsed elements open nothing. - Editing: focus moving into a flyout the editor owns (attached flyout, button flyout, picker) keeps the edit open, including while focus rests on the flyout's `Popup`; focus in an unrelated popup commits. - Sample app: new **Context menus** page; README updated. - Tests: context menu API tests, interaction tests, editing focus tests, shared interaction test helpers. ## Customer Impact User-facing: apps can give TableView cells, headers and rows their own context menus with no code-behind wiring. Additive, preview API. ## Regression Potential - [ ] Low risk — isolated change, limited scope - [x] Medium risk — touches shared components or public APIs - [ ] High risk — architectural or breaking API change New public properties on `TableViewColumn`. Touches row/cell focus on context requests, editing focus-loss commit, and TableView automation peers. ## How Has This Been Tested? - [x] I have performed a self-review of my own code - [x] I have added tests to cover my changes - [x] Existing tests pass locally Builds (x64chk, 0 errors): - `.\initrun.ps1 .\build.cmd /q` — passed - `.\initrun.ps1 msb /q /m controls\test\MUXControlsTestApp\MUXControlsTestApp.csproj /p:Platform=x64 /p:Configuration=Debug` — passed - `.\initrun.ps1 msb /q /m controls\test\MUXControls.Test\MUXControls.Test.csproj /p:Platform=x64 /p:Configuration=Debug` — passed - `.\initrun.ps1 msb /q /restore Samples\TableViewSampleApp\TableViewSampleApp.csproj /p:Platform=x64 /p:WinUIVersion=3.0.0-dev` — passed TAEF on a test VM (115 tests: context menu API + interaction, editing API, hierarchy interaction), run in 9 batches: - `.\initrun.ps1 .\tools\run-tests-on-vm.ps1 -VMName winui-test2 -SkipPayload * -fromFile:batchN.txt` — 113/115 passed on the first run. The 2 failures (`VerifyFocusInEditorOwnedFlyoutKeepsEditOpen`, `PressAndHoldOnCellOpensCellMenu`) were fixed (product + test) and the rerun passed 4/4 including neighbouring tests. Runtime: sample app Context menus and Hierarchy pages navigated through UIA after the fixes; app stays up and tables render. ## Screenshots (if appropriate) N/A --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 75efebd4-6de1-4fe8-b075-fab226b722c6 Copilot-Session: 128a3e81-37b9-46df-b080-757f110ae417
405 lines
14 KiB
C++
405 lines
14 KiB
C++
// Copyright (c) Microsoft Corporation. All rights reserved.
|
|
// Licensed under the MIT License. See LICENSE in the project root for license information.
|
|
|
|
#include "pch.h"
|
|
#include "common.h"
|
|
#include "TableView.h"
|
|
#include "TableViewColumn.h"
|
|
#include "TableViewRow.h"
|
|
#include "TableViewFocusHelpers.h"
|
|
#include "TVDiag.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
|
|
// Input gestures that drive editing.
|
|
//
|
|
// Kept separate from TableView_Editing.cpp: that file owns the edit state machine, reachable
|
|
// entirely through BeginEdit/CommitEdit/CancelEdit; this one owns the policy of which gestures map
|
|
// onto that API. The split lets a future keyboard or selection layer re-route gestures without
|
|
// reopening the state machine, and keeps the state machine testable without synthesizing input.
|
|
|
|
namespace
|
|
{
|
|
// True when `candidate` is `ancestor` or sits underneath it, including the ancestor's own
|
|
// popups (a ComboBox editor's dropdown items).
|
|
bool IsWithinElement(winrt::IInspectable const& candidate, winrt::FrameworkElement const& ancestor)
|
|
{
|
|
if (!ancestor)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return TableViewDetails::IsWithinElementPopupAware(candidate, ancestor, ancestor.XamlRoot());
|
|
}
|
|
|
|
// An open flyout owned by the editor or one of its descendants: a ContextFlyout, or a text
|
|
// control's SelectionFlyout. Its popup is not in the visual tree, so focus inside it is not
|
|
// within the editor.
|
|
winrt::FlyoutBase FindOpenEditorFlyout(winrt::DependencyObject const& element)
|
|
{
|
|
if (auto const uiElement = element.try_as<winrt::UIElement>())
|
|
{
|
|
if (auto const flyout = uiElement.ContextFlyout(); flyout && flyout.IsOpen())
|
|
{
|
|
return flyout;
|
|
}
|
|
}
|
|
|
|
winrt::FlyoutBase selectionFlyout{ nullptr };
|
|
if (auto const textBox = element.try_as<winrt::TextBox>())
|
|
{
|
|
selectionFlyout = textBox.SelectionFlyout();
|
|
}
|
|
else if (auto const richEditBox = element.try_as<winrt::RichEditBox>())
|
|
{
|
|
selectionFlyout = richEditBox.SelectionFlyout();
|
|
}
|
|
if (selectionFlyout && selectionFlyout.IsOpen())
|
|
{
|
|
return selectionFlyout;
|
|
}
|
|
|
|
const int32_t count = winrt::VisualTreeHelper::GetChildrenCount(element);
|
|
for (int32_t i = 0; i < count; ++i)
|
|
{
|
|
if (auto const flyout = FindOpenEditorFlyout(winrt::VisualTreeHelper::GetChild(element, i)))
|
|
{
|
|
return flyout;
|
|
}
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
// The open popup that is or holds `element`. Flyouts that are not reachable from the editor's
|
|
// properties (a DatePicker's picker, a Button.Flyout, an attached flyout) surface only here.
|
|
// While a flyout opens, focus can rest on its Popup before reaching the content.
|
|
winrt::Popup FindOpenPopupContaining(winrt::IInspectable const& element, winrt::XamlRoot const& root)
|
|
{
|
|
if (!element || !root)
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
auto const elementAsPopup = element.try_as<winrt::Popup>();
|
|
for (auto const& popup : winrt::VisualTreeHelper::GetOpenPopupsForXamlRoot(root))
|
|
{
|
|
if (popup == elementAsPopup)
|
|
{
|
|
return popup;
|
|
}
|
|
if (auto const child = popup.Child().try_as<winrt::FrameworkElement>(); child && IsWithinElement(element, child))
|
|
{
|
|
return popup;
|
|
}
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
// An open Button.Flyout, SplitButton.Flyout or attached flyout under the editor (a
|
|
// CalendarDatePicker's calendar is attached in its template).
|
|
bool HasOpenOwnedFlyout(winrt::DependencyObject const& element)
|
|
{
|
|
auto const isOpen = [](winrt::FlyoutBase const& flyout) { return flyout && flyout.IsOpen(); };
|
|
|
|
if (auto const fe = element.try_as<winrt::FrameworkElement>(); fe && isOpen(winrt::FlyoutBase::GetAttachedFlyout(fe)))
|
|
{
|
|
return true;
|
|
}
|
|
if (auto const button = element.try_as<winrt::Button>(); button && isOpen(button.Flyout()))
|
|
{
|
|
return true;
|
|
}
|
|
if (auto const splitButton = element.try_as<winrt::SplitButton>(); splitButton && isOpen(splitButton.Flyout()))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
const int32_t count = winrt::VisualTreeHelper::GetChildrenCount(element);
|
|
for (int32_t i = 0; i < count; ++i)
|
|
{
|
|
if (HasOpenOwnedFlyout(winrt::VisualTreeHelper::GetChild(element, i)))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
template <typename T>
|
|
bool ContainsElementOfType(winrt::DependencyObject const& element)
|
|
{
|
|
if (element.try_as<T>())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
const int32_t count = winrt::VisualTreeHelper::GetChildrenCount(element);
|
|
for (int32_t i = 0; i < count; ++i)
|
|
{
|
|
if (ContainsElementOfType<T>(winrt::VisualTreeHelper::GetChild(element, i)))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// True when an open popup outside the editor's tree was opened by the editor. A popup has no
|
|
// public link to its owner, so only the editor's own flyouts count; a ContentDialog, a
|
|
// TeachingTip or another table's menu does not.
|
|
bool IsEditorOwnedPopup(winrt::Popup const& popup, winrt::FrameworkElement const& editor)
|
|
{
|
|
// Only one top-level flyout is open per root, so an open flyout under the editor is this one.
|
|
if (HasOpenOwnedFlyout(editor))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// A DatePicker/TimePicker shows its picker flyout from code, out of reach of the walk above.
|
|
auto const child = popup.Child();
|
|
if (child.try_as<winrt::DatePickerFlyoutPresenter>())
|
|
{
|
|
return ContainsElementOfType<winrt::DatePicker>(editor);
|
|
}
|
|
if (child.try_as<winrt::TimePickerFlyoutPresenter>())
|
|
{
|
|
return ContainsElementOfType<winrt::TimePicker>(editor);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
}
|
|
|
|
// F2 / Enter / Escape. Registered WITH handledEventsToo, because a single-line TextBox reports Enter
|
|
// as handled and the commit must still run. Each case therefore owns its own Handled policy
|
|
// explicitly: F2 and Escape defer to an editor that genuinely consumed the key, Enter does not.
|
|
void TableView::OnKeyDownForEditing(
|
|
const winrt::IInspectable& /*sender*/,
|
|
const winrt::KeyRoutedEventArgs& args)
|
|
{
|
|
switch (args.Key())
|
|
{
|
|
case winrt::Windows::System::VirtualKey::F2:
|
|
// Keyboard equivalent of double-click, on the cell the user navigated to. Only when the
|
|
// key is still unhandled: F2 belongs to whatever focused control claimed it first.
|
|
if (!args.Handled() && !IsEditing() && BeginEdit())
|
|
{
|
|
args.Handled(true);
|
|
}
|
|
break;
|
|
|
|
case winrt::Windows::System::VirtualKey::Enter:
|
|
if (IsEditing())
|
|
{
|
|
// Acted on even if the editor marked it handled: a single-line TextBox reports Enter as
|
|
// handled, which would leave the editor with no way to close from the keyboard.
|
|
// Handled regardless of the result - a veto or pending deferral still owns the key, and
|
|
// letting it bubble would scroll the table under an open editor.
|
|
CommitEdit();
|
|
args.Handled(true);
|
|
}
|
|
break;
|
|
|
|
case winrt::Windows::System::VirtualKey::Escape:
|
|
// Like F2, this defers to an editor that genuinely consumed the key - a ComboBox in a
|
|
// CellEditingTemplate swallows Escape to close its popup, and that must not also cancel
|
|
// and roll back the whole cell edit. The same goes for the editor's own menu: Escape
|
|
// light-dismisses it and focus is back in the editor by the time the key routes there.
|
|
if (!args.Handled() && IsEditing() && !m_editorFlyoutClosing)
|
|
{
|
|
CancelEdit();
|
|
args.Handled(true);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Click-away / tab-away commit. Without it the editor stays open when focus leaves, and a second
|
|
// edit elsewhere leaves two editors on screen. Committing matches the platform grid convention.
|
|
//
|
|
// The decision is deliberately NOT made here. Opening an editor produces a burst of focus traffic,
|
|
// so a synchronous commit on the first LosingFocus closes the edit in the gesture that opened it.
|
|
// The check is posted and re-evaluated once focus has settled.
|
|
void TableView::OnLosingFocusForEditing(
|
|
const winrt::IInspectable& /*sender*/,
|
|
const winrt::Microsoft::UI::Xaml::Input::LosingFocusEventArgs& args)
|
|
{
|
|
if (!IsEditing())
|
|
{
|
|
return;
|
|
}
|
|
|
|
auto const row = m_currentEditRow.get();
|
|
if (!row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
auto const editingElement = winrt::get_self<TableViewRow>(row)->GetEditingElement();
|
|
if (!editingElement)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Focus must actually be leaving the editor, and not merely moving within it (a ComboBox
|
|
// opening its popup, or a template column containing several controls).
|
|
if (!IsWithinElement(args.OldFocusedElement(), editingElement) ||
|
|
IsWithinElement(args.NewFocusedElement(), editingElement))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (m_focusLossCommitQueued)
|
|
{
|
|
return;
|
|
}
|
|
|
|
QueueFocusLossCommit();
|
|
}
|
|
|
|
void TableView::QueueFocusLossCommit()
|
|
{
|
|
m_focusLossCommitQueued = true;
|
|
|
|
auto weakThis = get_weak();
|
|
bool queued = false;
|
|
if (auto const dispatcher = DispatcherQueue())
|
|
{
|
|
queued = dispatcher.TryEnqueue([weakThis]()
|
|
{
|
|
if (auto strongThis = weakThis.get())
|
|
{
|
|
strongThis->CompleteFocusLossCommit();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!queued)
|
|
{
|
|
// Nothing will clear the flag for us. Leaving it set would silently disable focus-loss
|
|
// commit for the rest of the control's life.
|
|
m_focusLossCommitQueued = false;
|
|
}
|
|
}
|
|
|
|
void TableView::CompleteFocusLossCommit()
|
|
{
|
|
m_focusLossCommitQueued = false;
|
|
m_editorFlyoutClosedRevoker.revoke();
|
|
m_editorFlyoutClosingRevoker.revoke();
|
|
m_editorPopupClosedRevoker.revoke();
|
|
|
|
if (!IsEditing())
|
|
{
|
|
return;
|
|
}
|
|
|
|
auto const row = m_currentEditRow.get();
|
|
if (!row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
auto const editingElement = winrt::get_self<TableViewRow>(row)->GetEditingElement();
|
|
if (!editingElement)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Focus may have come back to the editor while this was queued - which is exactly what the
|
|
// open-an-editor gesture itself does. Only a genuine move away closes the edit.
|
|
auto const root = XamlRoot();
|
|
winrt::IInspectable focused{ nullptr };
|
|
if (root)
|
|
{
|
|
focused = winrt::FocusManager::GetFocusedElement(root);
|
|
|
|
if (IsWithinElement(focused, editingElement))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Refocus the editor only when focus lands on the row/cell host from the opening gesture;
|
|
// hosted controls in other cells remain valid focus targets.
|
|
auto const editingCell = winrt::get_self<TableViewRow>(row)->GetEditingCellWrapper();
|
|
if (focused == row.try_as<winrt::DependencyObject>() ||
|
|
(editingCell && focused == editingCell.try_as<winrt::DependencyObject>()))
|
|
{
|
|
editingElement.Focus(winrt::FocusState::Programmatic);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Focus went into the editor's own menu (a TextBox's copy/paste flyout): the user is still
|
|
// working in the editor. Decide again once the menu closes - it hands focus back.
|
|
if (auto const flyout = FindOpenEditorFlyout(editingElement))
|
|
{
|
|
// Escape light-dismisses the menu before the key is routed, so the editor then receives
|
|
// that same Escape. Mark the close so the key handler leaves the edit alone.
|
|
m_editorFlyoutClosingRevoker = flyout.Closing(winrt::auto_revoke,
|
|
[weakThis = get_weak()](auto&&, auto&&)
|
|
{
|
|
if (auto strongThis = weakThis.get())
|
|
{
|
|
strongThis->m_editorFlyoutClosingRevoker.revoke();
|
|
strongThis->MarkEditorFlyoutClosing();
|
|
}
|
|
});
|
|
|
|
m_editorFlyoutClosedRevoker = flyout.Closed(winrt::auto_revoke,
|
|
[weakThis = get_weak()](auto&&, auto&&)
|
|
{
|
|
if (auto strongThis = weakThis.get(); strongThis && !strongThis->m_focusLossCommitQueued)
|
|
{
|
|
strongThis->QueueFocusLossCommit();
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Focus went into a popup the editor opened - a picker or a button flyout inside it. Decide again
|
|
// once it closes. Focus in any other popup is a genuine move away and commits now.
|
|
if (auto const popup = FindOpenPopupContaining(focused, root); popup && IsEditorOwnedPopup(popup, editingElement))
|
|
{
|
|
m_editorPopupClosedRevoker = popup.Closed(winrt::auto_revoke,
|
|
[weakThis = get_weak()](auto&&, auto&&)
|
|
{
|
|
if (auto strongThis = weakThis.get())
|
|
{
|
|
strongThis->m_editorPopupClosedRevoker.revoke();
|
|
strongThis->MarkEditorFlyoutClosing();
|
|
if (!strongThis->m_focusLossCommitQueued)
|
|
{
|
|
strongThis->QueueFocusLossCommit();
|
|
}
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
CommitEdit();
|
|
}
|
|
|
|
void TableView::MarkEditorFlyoutClosing()
|
|
{
|
|
// Lives only until the current input has been dispatched.
|
|
m_editorFlyoutClosing = true;
|
|
auto const dispatcher = DispatcherQueue();
|
|
if (!dispatcher || !dispatcher.TryEnqueue([weakThis = get_weak()]()
|
|
{
|
|
if (auto strongThis = weakThis.get())
|
|
{
|
|
strongThis->m_editorFlyoutClosing = false;
|
|
}
|
|
}))
|
|
{
|
|
m_editorFlyoutClosing = false;
|
|
}
|
|
}
|