mirror of
https://github.com/microsoft/microsoft-ui-xaml.git
synced 2026-10-28 08:25:50 +08:00
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
This commit is contained in:
commit
42b575bc75
74 files changed
+9466
-819
No files matched your search
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: code-coverage-calculation
|
||||
description: >-
|
||||
EXPERIMENTAL. Calculate code coverage of a WinUI control or component (for example
|
||||
"calculate coverage of control TitleBar") by running only that component's tests on a local
|
||||
Hyper-V VM against the local build. Supports controls in Microsoft.UI.Xaml.Controls.dll and
|
||||
Microsoft.UI.Xaml.Controls.Tabular.dll, and dxaml types in Microsoft.ui.xaml.dll. Reports per-file
|
||||
and per-function line coverage that matches
|
||||
the ADO Code Coverage tab. Use for "calculate/measure/get coverage of <control>", "how much do
|
||||
<control>'s tests cover", or "coverage before/after my tests" when you have a local build and a VM.
|
||||
---
|
||||
|
||||
# WinUI component coverage from a local VM test run
|
||||
|
||||
## Agent workflow (follow in order; every gate is mandatory)
|
||||
|
||||
All commands run from the repo root. `<skill>` = `.github\skills\code-coverage-calculation`.
|
||||
|
||||
### Gate 0 - Prerequisites (check; stop and tell the user on failure)
|
||||
|
||||
| Check | How | If missing |
|
||||
|---|---|---|
|
||||
| Local build of the flavor (default `amd64chk`), from the current branch | The module DLL exists under `BuildOutput\bin\amd64chk\Product\`, and for MUXC/Tabular the link log `BuildOutput\obj\amd64chk\controls\dev\dll` (or `dll-tabular`) `\**\link.command.1.tlog` exists | Ask before building (10+ min), then run `.\initrun.ps1 .\build.cmd /q` from the repo root. |
|
||||
| VS Enterprise code coverage | `Microsoft.CodeCoverage.Console.exe` under `%ProgramFiles%\Microsoft Visual Studio` | Stop; the user must install it. |
|
||||
| `dotnet-coverage` | `Get-Command dotnet-coverage` | Tell the user: `dotnet tool install --global dotnet-coverage` |
|
||||
| Hyper-V rights | `Get-VM` succeeds | Stop. Ask the user to add their account (from `whoami`) to **Hyper-V Administrators** in an elevated PowerShell window, then sign out and back in. Never elevate or add group members yourself. |
|
||||
|
||||
### Gate 1 - VM details (strict: never start a run without them)
|
||||
|
||||
**Before every coverage run, the user must explicitly confirm the VM or enter its details in this
|
||||
session.** Never pick a VM silently, even if only one credential is cached. Never reuse a VM name,
|
||||
username or password from memory, an earlier session, or an earlier run without asking again.
|
||||
|
||||
1. Look for cached credentials. Match them against the real VM names, because the cache file name
|
||||
replaces non-alphanumeric characters with `_` and can't be turned back into the VM name. This
|
||||
shows the username only, never the password:
|
||||
```powershell
|
||||
Get-VM | ForEach-Object {
|
||||
$f = Join-Path $env:USERPROFILE ".winui-test\vmcred-$($_.Name -replace '[^a-zA-Z0-9]','_').xml"
|
||||
if (Test-Path $f) { [pscustomobject]@{ VM = $_.Name; State = $_.State; UserName = (Import-Clixml $f).UserName; CacheFile = Split-Path $f -Leaf } }
|
||||
}
|
||||
```
|
||||
If two VMs share one `CacheFile` (for example `Test-VM` and `Test_VM`), don't offer the cache for
|
||||
either; ask for details and re-enter the password (`-Force`).
|
||||
2. Use `ask_user` every time:
|
||||
- **Cached credentials exist:** ask "Is this the VM to use?" Offer one choice per VM and
|
||||
username listed above, plus **"Enter different VM details"**.
|
||||
- **No cache, or the user chooses different details:** ask for the **VM name** and **VM username**
|
||||
(the plain account name as shown on the VM, with no `.\` or `VM\` prefix).
|
||||
- If the user declines or cancels, stop. Do not continue with a default.
|
||||
3. Run `tools\Set-VmCredential.ps1 -VMName <vm> -UserName <user>` (a shared repo tool):
|
||||
- For new details, add `-Force`. The script asks for the **password in a separate window** (never in
|
||||
chat; chat text is kept in session history) and writes the encrypted per-user cache
|
||||
`%USERPROFILE%\.winui-test\vmcred-<vm>.xml`. This is the cache `tools\run-tests-on-vm.ps1` and Steps 3-4 read;
|
||||
Step 3 does not pass `-Credential` to the runner, so the cache must exist.
|
||||
- For a confirmed cached VM, run it without `-Force`. It validates without prompting.
|
||||
- Either way it checks that the VM exists and is Running, that the logon works, and that the user has an
|
||||
Active desktop session. A new credential replaces the cache only after it validates.
|
||||
4. If the script fails (VM missing or stopped, bad password, no active desktop), report the message,
|
||||
ask the user to fix it or give new details, and repeat this gate. Do not continue.
|
||||
|
||||
### Gate 2 - Resolve the component and its tests
|
||||
|
||||
**A. `controls\dev` (`Microsoft.UI.Xaml.Controls.dll` or `Microsoft.UI.Xaml.Controls.Tabular.dll`)**
|
||||
|
||||
1. Folder: match the name case-insensitively against `controls\dev\*` folders. If there's no match,
|
||||
search `controls\dev\*\*.idl` for `runtimeclass <Name>` and use that folder (for example
|
||||
`ItemsRepeater` resolves to `Repeater`). If there are zero or several candidates, ask the user and offer the candidates.
|
||||
**Module:** if `controls\Tabular.ProjectImports.targets` imports `dev\<F>\<F>.vcxitems` (today: TableView,
|
||||
TabularShaping, ShapedItemsSource, GroupedSourceAdapter, HierarchicalSourceAdapter, SortIndicator,
|
||||
ResizeGripper), the code is in `Microsoft.UI.Xaml.Controls.Tabular.dll`: pass
|
||||
`-Module Microsoft.UI.Xaml.Controls.Tabular.dll`. Otherwise it's `Microsoft.UI.Xaml.Controls.dll` (the default).
|
||||
2. Tests: list the `[TestClass]` classes (with their namespaces) in `controls\dev\<F>\APITests\**\*.cs`
|
||||
and `controls\dev\<F>\InteractionTests\**\*.cs`. TAEF test names are `<namespace>.<class>.<method>`,
|
||||
and `runtests` selects `@Name='*<filter>'`.
|
||||
- Prefer one wildcard that covers every class, usually `*.<F>Tests.*`. It covers both the `ApiTests`
|
||||
and `InteractionTests` classes, and namespaces such as `...ApiTests.RepeaterTests.*`.
|
||||
- If no single wildcard covers them all, pass several filters (`-TestFilter 'a','b'`). Each runs
|
||||
with its own collector, and the results are merged and deduplicated in the report.
|
||||
- If the folder has no tests, tell the user and ask which tests to run.
|
||||
3. `-ComponentPathRegex`: take every `runtimeclass` `<RC>` declared in `controls\dev\<F>\*.idl` and match
|
||||
the folder plus both kinds of generated sources:
|
||||
`\\controls\\dev\\<F>\\|\\controls\\dev\\Generated\\(<RC1>|<RC2>|...)\.|\\dll(-tabular)?\\generated\\(.+\\)?(<RC1>|<RC2>|...)\.g\.`
|
||||
The second part covers `controls\dev\Generated\<RC>.properties.*` and the third covers the build-generated
|
||||
`BuildOutput\obj\<flavor>\controls\dev\dll\generated\...\<RC>.g.cpp/.h` (or `dll-tabular\generated`) files.
|
||||
4. Show the resolved plan (folder, module, test classes, filter(s), regex), then proceed. Ask only if
|
||||
something is ambiguous.
|
||||
5. The build must contain those tests. Before instrumenting, the orchestrator lists the matching tests in
|
||||
`TestPayload\<flavor>` with TAEF (the same selection `runtests` uses on the VM) and stops in seconds if a
|
||||
filter matches none. That usually means `BuildOutput` was built from an older commit or another branch.
|
||||
Tell the user, ask before rebuilding (`.\initrun.ps1 .\build.cmd /q`), then rerun with `-RefreshPayload`.
|
||||
|
||||
**B. Not in `controls\dev`: investigate `dxaml` (MUX, `Microsoft.ui.xaml.dll`) and get approval**
|
||||
|
||||
1. Sources: search `dxaml\xcp` for the type, for example `core\core\elements\<Name>.cpp`, `core\inc\<Name>.h`,
|
||||
`dxaml\lib\<Name>_Partial.*`, `dxaml\lib\winrtgeneratedclasses\<Name>.g.*`, `components\**`, and
|
||||
`core\controls\generated\C<Name>*`. Decide which are in scope (for example whether `ButtonBase` counts for `Button`).
|
||||
2. Tests: `dxaml\test\native\external\<area>\<name>\` (C++ TAEF; names look like
|
||||
`Microsoft::UI::Xaml::Tests::Controls::<Name>::<Class>::<Method>`). Note each class's `Hosting:Mode`;
|
||||
`runtests` cannot mix hosting modes in one filter, so use one filter per mode.
|
||||
3. Use `ask_user` to present the proposed source files and regex, test classes and filter(s), and module
|
||||
`Microsoft.ui.xaml.dll`. Warn that MUX is about 71 MB, so the first instrumentation is likely to take
|
||||
well over 40 minutes. **Do not run until the user approves.** Revise the plan with their feedback.
|
||||
|
||||
**C. Found in neither place:** ask the user which source folder and tests to use. Other DLLs (for example
|
||||
`Microsoft.UI.Xaml.Phone.dll`) aren't supported yet; say so instead of measuring a different module.
|
||||
|
||||
### Tell the user what the run does on the VM (before every run)
|
||||
|
||||
The run makes changes on the test VM, so say this once before starting:
|
||||
- It creates `C:\WinUICoverage` (coverage tools, logs and output) and runs short-lived scheduled tasks
|
||||
as the VM user. With `-MoveSessionToConsole` it also runs `tscon` as SYSTEM to move the session to the console.
|
||||
- It opens the coverage collector's named pipe (`\\.\pipe\CodeCoverage.pipe.<id>`) with a NULL DACL, so
|
||||
low-integrity test processes can report coverage. **Any local user on the VM can connect to that pipe
|
||||
until the run ends.** This is intended for a dedicated test VM; don't use it on a shared machine.
|
||||
- It temporarily replaces the module in the local `TestPayload` and in the VM's deployed payload with the
|
||||
instrumented copy, and restores both afterwards (hash-checked). `BuildOutput` and sources are never changed.
|
||||
|
||||
### Run
|
||||
|
||||
- Output: `<repo>\BuildOutput\Coverage\<flavor>\<Component>` by default (git-ignored with `BuildOutput`).
|
||||
Its `work\` folder holds the relinked and instrumented DLLs and the payload backups (several hundred MB).
|
||||
- Add `-RefreshPayload` if `TestPayload\x64chk` is missing or older than the build output. A failed
|
||||
refresh stops the run.
|
||||
- Add `-MoveSessionToConsole` when the host is an RDP or Cloud PC session (`$env:SESSIONNAME -like 'RDP-*'`).
|
||||
- Tell the user the expected time first (about 30-60 min the first time for MUXC, mostly instrumentation; a few
|
||||
minutes for the 6.5 MB Tabular DLL; about 6-10 min with the cache).
|
||||
Run it async with a long wait. **Never stop the process during Step 2 or 3**; the restore runs in `finally`,
|
||||
and if a run is killed anyway, the next run restores the payload before doing anything else.
|
||||
|
||||
Run from the repo root in PowerShell, invoking the script directly. Don't use `pwsh -File`: it turns
|
||||
`'a','b'` into a single string.
|
||||
|
||||
```powershell
|
||||
& .\.github\skills\code-coverage-calculation\scripts\Invoke-WinUIVmCoverage.ps1 `
|
||||
-RepoRoot (Get-Location).Path -VMName <vm> -TestFilter '<filter>'[,'<filter2>'] `
|
||||
-ComponentName <F> -ComponentPathRegex '<regex>' `
|
||||
-Module <Microsoft.UI.Xaml.Controls.dll|Microsoft.UI.Xaml.Controls.Tabular.dll|Microsoft.ui.xaml.dll> `
|
||||
[-RefreshPayload] [-MoveSessionToConsole]
|
||||
```
|
||||
|
||||
### Report
|
||||
|
||||
- If restoring the payload or VM fails, the command **fails** (after writing the report, if coverage was
|
||||
collected) and `instrument-state.json` is kept. Tell the user prominently, and offer to rerun
|
||||
`<skill>\scripts\Step4-Restore-Payload.ps1 -RepoRoot <repo> -WorkDir <OutputDir>\work -VMName <vm>`
|
||||
(or simply rerun the coverage command, which restores first).
|
||||
- From `report-<ts>\coverage-summary.md`, present the component total, the per-file table, and the
|
||||
uncovered and partially covered functions. Point to `TreeReport\coverage-tree.html` and the CSVs.
|
||||
- Check the test summary. If `Total=0`, the filter matched nothing; fix the filter instead of reporting 0 %.
|
||||
|
||||
---
|
||||
|
||||
# Reference
|
||||
|
||||
Measures **which lines of one component your selected tests execute**. It doesn't need a pipeline
|
||||
run: it uses your local build and the same Hyper-V VM and runner (`tools\run-tests-on-vm.ps1`) you
|
||||
already use for WinUI tests. Everything it needs is in this folder.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Instrumentable MUXC:** static native instrumentation needs CV and FIXUP debug info
|
||||
(`/DEBUGTYPE:CV,FIXUP` with `/INCREMENTAL:NO`), which a chk MUXC link doesn't emit. Step 1 relinks a
|
||||
copy with those flags from the recorded link command, without touching the repo or `BuildOutput`.
|
||||
- **Collection:** the DLL is instrumented once with a per-module session id and copied over every
|
||||
payload copy, together with `static_covrun*.dll`. On the VM, a server-mode collector listens on a named pipe
|
||||
that `lib\Set-CoveragePipeAcl.ps1` opens to low-integrity test processes. Shutting the collector down
|
||||
flushes the `.coverage` file.
|
||||
- **Accurate numbers:** merging native `.coverage` files to Cobertura repeats every line once per input
|
||||
file, so naive totals undercount coverage. `lib\Repair-CoverageDuplicates.ps1` and
|
||||
`lib\Split-CoverageByModule.ps1` keep one entry per (file, line), which matches the Azure DevOps Code
|
||||
Coverage tab. `lib\Get-FunctionCoverage.ps1` adds per-function detail from the native XML merge, and the
|
||||
`Build-*TreeReport.ps1` scripts render single-file HTML reports. The PDB is only needed at instrument time.
|
||||
|
||||
## Quick start
|
||||
|
||||
```powershell
|
||||
cd <repo-root>\.github\skills\code-coverage-calculation\scripts
|
||||
.\Invoke-WinUIVmCoverage.ps1 `
|
||||
-RepoRoot <repo-root> `
|
||||
-VMName <vm> `
|
||||
-TestFilter '*.TitleBarTests.*' `
|
||||
-ComponentName TitleBar `
|
||||
-ComponentPathRegex '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar' `
|
||||
-MoveSessionToConsole `
|
||||
-BaselinePerFileCsv '<baseline-run>\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv' `
|
||||
-BaselineLabel 'Full test pass'
|
||||
```
|
||||
|
||||
Output goes to `<repo-root>\BuildOutput\Coverage\amd64chk\TitleBar` unless you pass `-OutputDir`. If the baseline came from another checkout, also pass `-BaselineRepoRoot <that checkout's root>`.
|
||||
|
||||
Open `<OutputDir>\report-<timestamp>\coverage-summary.md`. Other outputs in that folder:
|
||||
- `component-per-file.csv`
|
||||
- `component-functions.csv`
|
||||
- `TreeReport\coverage-tree.html`
|
||||
- `PerModule\<Module>\corrected-per-file.csv` (whole module)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Local build:** a full local build of the flavor you test (default `amd64chk`), so that `BuildOutput\obj\...\link.command.1.tlog`, the objs and the PDB exist. Also an existing `TestPayload\x64chk`, created by any earlier `tools\run-tests-on-vm.ps1` run, or pass `-RefreshPayload`.
|
||||
- **Visual Studio Enterprise (host):** supplies `Microsoft.CodeCoverage.Console.exe` for instrument, collect and shutdown, and the MSVC toolset that built the objs.
|
||||
- **`dotnet-coverage`:** `dotnet tool install --global dotnet-coverage`, used for the merge.
|
||||
- **VM access:**
|
||||
- A running Hyper-V VM with the test user logged on, an unlocked interactive desktop, and the WinUI test prerun already done (any earlier `run-tests-on-vm.ps1` run does this).
|
||||
- The host account must be a local admin or a member of Hyper-V Administrators.
|
||||
- The VM credential must be cached in `%USERPROFILE%\.winui-test\vmcred-<VM>.xml`, the same cache `run-tests-on-vm.ps1` uses.
|
||||
- **No VS or dotnet needed on the VM:** the collector tools are staged to `C:\WinUICoverage\CoverageTool` automatically.
|
||||
|
||||
## What it does (and why)
|
||||
|
||||
| Step | Script | Notes |
|
||||
|---|---|---|
|
||||
| 1 | `Step1-New-InstrumentableDll.ps1` | **MUXC and Tabular** (`Microsoft.UI.Xaml.Controls.Tabular.dll`, relinked from `controls\dev\dll-tabular`). chk MUXC is not instrumentable: the tool prints "*Unable to obtain debug information. Link with the /PROFILE linker switch.*" and then **exits 0** with the image untouched. It needs `/DEBUGTYPE:CV,FIXUP` with `/INCREMENTAL:NO` at link time, and this skill never edits repo files, so the script re-runs the **exact link command MSBuild recorded** (`link.command.1.tlog`) with `/DEBUGTYPE:CV,FIXUP` added and `/INCREMENTAL:NO`. `/OUT`, `/PDB`, `/IMPLIB` and `/MAP` are redirected to `work\relink`, and `/WINMD:NO` is set. It takes about 15 s, and BuildOutput is never touched. MUX (`Microsoft.ui.xaml.dll`) already links with `/debugtype:cv,fixup,pdata` and skips this step. |
|
||||
| 2 | `Step2-Instrument-Payload.ps1` | Static native instrumentation with a fresh 16-hex session id. **This is slow for a 38 MB chk DLL (about 20-50 min)**, so the result is cached in `work\instrumented`, keyed by the input DLL's SHA256, and reused on later runs together with its baked-in session id. The cache is reused only while the tool that instrumented it still exists at the same version. The script catches the "exit 0 but not instrumented" case. It backs up every payload copy and writes `instrument-state.json` **before** overwriting anything (so an interrupted run can always be restored), then overwrites **every** payload copy of the DLL, plus `static_covrun64.dll`, because each test app loads its own co-located copy (`Test\UnpackagedApps\MUXControlsTestApp\`). Backups are refreshed from the payload whenever a copy isn't instrumented, so a rebuilt payload is never restored to an older DLL. |
|
||||
| 3 | `Step3-Invoke-VmCoverageRun.ps1` | Stages the collector on the VM once. It starts `collect --server-mode --session-id <sid>` as the VM user through an interactive scheduled task, waits for `\\.\pipe\CodeCoverage.pipe.<sid>`, and sets a NULL DACL on it. It then runs `initrun.ps1 .\tools\run-tests-on-vm.ps1 -SkipPayload <filter>`. `-SkipPayload` only stops the runner from regenerating `TestPayload` from BuildOutput (which would overwrite the instrumented DLLs); the runner still copies the changed payload files to the VM and runs the tests on the desktop. In `finally` it calls `shutdown <sid>` so the collector writes the `.coverage` file, downloads it and the collector log (also when the run failed), and closes the VM session. |
|
||||
| 4 | `Step4-Restore-Payload.ps1` | Always runs, from `finally`, and again at the start of the next run if a previous run was killed before restoring. It restores every original DLL (copies that were never overwritten are skipped), hash-verified, removes `static_covrun*.dll`, and does the same in the VM's deployed payload. Each distinct original is restored to its own copies. If any VM copy doesn't match afterwards, it fails and keeps `instrument-state.json`, so you can simply run it again. A restore failure makes the whole command fail. |
|
||||
| 5 | `Step5-Get-ComponentCoverage.ps1` | Merges the coverage to Cobertura (`dotnet-coverage merge -f cobertura`) and applies the per-module (file, line) dedup (`lib\Split-CoverageByModule.ps1`), the same correction that matches the ADO tab. It then filters to the component by `-ComponentPathRegex` and optionally compares with a baseline `corrected-per-file.csv`. Paths are matched repo-relative: the checkout root (`-RepoRoot`, or `-BaselineRepoRoot` for a baseline from another checkout) or a build-agent root such as `C:\__w\1\s\` is removed, the report warns if the baseline matches none or under half of the component files, and `x86chk` versus `amd64chk` doesn't matter. It also produces function-level coverage (`lib\Get-FunctionCoverage.ps1`) and the HTML tree (`lib\Build-CoverageTreeReport.ps1`). |
|
||||
|
||||
You can run the steps one at a time. Each writes its state or results under `<OutputDir>\work`.
|
||||
|
||||
## Timing
|
||||
|
||||
- **First run:** about 30-60 min, almost all of it Step 2 instrumenting a 38 MB chk MUXC (20-50 min; it runs on a single core). The rest is a few seconds to relink, then deploy, the tests, flush and the report.
|
||||
- **Tabular (6.5 MB):** instrumentation takes only a few minutes.
|
||||
- **Later runs with an unchanged build:** about 6-10 min. The relink and the instrumented DLL are both reused from `<OutputDir>\work`.
|
||||
- **Report:** parsing the Cobertura and native XML takes about 1-2 min each; skip the second with `-SkipFunctionCoverage`.
|
||||
|
||||
## Verified result (TitleBar, 2026-10-06)
|
||||
|
||||
`-TestFilter '*.TitleBarTests.*'` on a local Hyper-V VM (Windows 11, amd64chk): 24/24 tests passed, and the collector produced an 88 MB `.coverage` file. These are the numbers from the full skill run, next to a baseline from a CI coverage run of all tests:
|
||||
|
||||
| File | TitleBar tests only (this skill) | CI coverage run (all tests) |
|
||||
|---|---|---|
|
||||
| `controls\dev\TitleBar\TitleBar.cpp` | **98.07 %** (507/517) | 10.64 % (55/517) |
|
||||
| `controls\dev\TitleBar\TitleBarAutomationPeer.cpp` | **100 %** (17/17) | 0 % (0/17) |
|
||||
| `controls\dev\Generated\TitleBar.properties.cpp` | **90.23 %** (157/174) | 28.74 % (50/174) |
|
||||
| All 15 TitleBar source files | **81.03 %** (773/954) | 23.69 % (226/954) |
|
||||
|
||||
Of 45 functions in the TitleBar `.cpp` files, 40 are fully covered, 4 partially, and 1 not at all (`OnWindowRectChanged`). Afterwards, the payload, the VM and BuildOutput were all verified back at their original hashes.
|
||||
|
||||
**Tabular module (TableView, 2026-10-20):** `-Module Microsoft.UI.Xaml.Controls.Tabular.dll -TestFilter '*.TableViewSortingTests.*'`: 35/35 tests passed in 3 min 41 s end to end (instrumenting the 6.5 MB DLL took 45 s). From that one test class, TableView was at 3557/11779 lines (30.2 %), with `TableView.cpp` at 63.8 %. The payload and the VM were restored afterwards.
|
||||
|
||||
## Gotchas (learned the hard way)
|
||||
|
||||
- **Host session disconnects (Cloud PC / RDP):** if the host's session disconnects, the Hyper-V *enhanced session* stops delivering input. UI tests then fail with `InjectMouseInput ... Access is denied` and "Test window lost focus". Use `-MoveSessionToConsole`, which runs `tscon <id> /dest:console` as SYSTEM inside the VM, so the VM desktop stays interactive. Reconnecting with vmconnect moves the session back.
|
||||
- **Instrumenting is slow:** about 20-50 min and about 3 GB RAM for chk MUXC. Don't interrupt it; later runs reuse the cache. Use `-ForceReinstrument` after a rebuild if the hash didn't change but you want a new session id.
|
||||
- **Stale build:** if `BuildOutput` comes from another branch or an older commit, the payload may not contain the component's tests. The test check before Step 1 catches this; without it, TAEF reports "The selection criteria did not match any tests" on the VM and Step 3 fails after the long instrumentation.
|
||||
- **Incremental deploy manifest:** `tools\run-tests-on-vm.ps1` only copies files whose size or timestamp changed since its last deploy (`TestPayload\.deploy-manifest-<VM>-<flavor>.json`). Step 4 restores the VM behind its back, and `static_covrun*.dll` always keep the tool's timestamp, so Step 3 removes the instrumented files from that manifest before every run. Without that, a rerun silently tests the original DLL (empty coverage), or the instrumented DLL is deployed without `static_covrun64.dll` and the test app crashes with "ClassFactory cannot supply requested class".
|
||||
- **Session id must match:** the DLL the test process loads must carry the collector's session id. If the coverage file is tiny or empty, check `work\raw\<run>\collector.log` and confirm the app-local copy (`MUXControlsTestApp\Microsoft.UI.Xaml.Controls.dll`) is the instrumented one.
|
||||
- **Rebuilds overwrite the instrumented copy:** rebuilding `MUXControlsTestApp` re-stages an older Controls DLL into `BuildOutput\...\UnpackagedApps\MUXControlsTestApp`. That doesn't matter here because Step 2 overwrites the *payload* copies, but don't run `run-tests-on-vm.ps1` **without** `-SkipPayload` in between, because that recreates the payload.
|
||||
- **Expected failures still count toward coverage:** failing or ignored tests still execute product code until they fail, and Ignored tests don't run at all.
|
||||
- **Known crash at exit is harmless:** TitleBar runs fail-fast at app exit, which shows as cleanup errors. Coverage still flushes because the collector is out of process.
|
||||
- **Numbers differ from CI:** a local VM run uses a different OS build, flavor (x64 vs CI's x86) and test subset. Compare per file, not the module total.
|
||||
- **NULL DACL:** the collector pipe is open to every local user until shutdown, so that low-integrity test processes can connect (otherwise coverage is empty with ACCESS_DENIED). That's fine on a dedicated test VM, but avoid it on shared machines; the agent workflow tells the user before every run.
|
||||
- **`cmd /c` quoting:** starting the collector via `cmd /c "<exe>" ... > "<log>"` silently fails, because cmd strips the outer quotes. Step 3 therefore writes a `.cmd` wrapper. If the collector pipe never appears, check that wrapper and `C:\WinUICoverage\out\collector-<run>.log` on the VM.
|
||||
- **Same tool for instrument and collect:** the collector must be the same `Microsoft.CodeCoverage.Console.exe` version that instrumented the DLL. Step 2 records the tool, and Step 3 stages that exact version to `C:\WinUICoverage\CoverageTool\<version>`. If that tool is gone or changed version (for example after a VS update), Step 2 re-instruments instead of reusing the cache. When several VS installs exist, the highest file version wins; path string order is not used.
|
||||
- **Relink is not byte-deterministic:** Step 1 therefore reuses its previous output while the recorded link inputs are unchanged. Otherwise the Step 2 cache, keyed by DLL hash, would miss and re-instrument for 45 minutes.
|
||||
- **Single-file bug in `lib\Get-FunctionCoverage.ps1`:** with a single `.coverage` input, the script's own merge produces an empty `<results/>`. An if-expression unwraps the one-element array, so `@inputFiles` splats nothing. Step 5 works around this by pre-merging (`dotnet-coverage merge -f xml`) and calling the script with `-SkipMerge`, so the script itself is left as is.
|
||||
- **Host suspends pause the run:** a Cloud PC host that sleeps or suspends pauses instrumentation; it resumes and finishes afterwards. Keep the host awake for predictable timing.
|
||||
|
||||
## Parameters (`Invoke-WinUIVmCoverage.ps1`)
|
||||
|
||||
| Parameter | Default | Notes |
|
||||
|---|---|---|
|
||||
| `-RepoRoot` | required | WinUI checkout with a completed local build |
|
||||
| `-VMName` | required | Hyper-V VM |
|
||||
| `-TestFilter` | required | One or more filters passed to `run-tests-on-vm.ps1`, e.g. `*.TitleBarTests.*`. With several, Step 3 runs once per filter and Step 5 merges the `.coverage` files. |
|
||||
| `-OutputDir` | `<RepoRoot>\BuildOutput\Coverage\<Flavor>\<ComponentName>` | Work and report root (git-ignored by default) |
|
||||
| `-ComponentName` / `-ComponentPathRegex` | TitleBar | Regex on the source path selecting the component's files |
|
||||
| `-Module` | `Microsoft.UI.Xaml.Controls.dll` | or `Microsoft.UI.Xaml.Controls.Tabular.dll` (TableView and the tabular helpers), or `Microsoft.ui.xaml.dll` |
|
||||
| `-Flavor` | `amd64chk` | Build flavor; payload `x64chk` |
|
||||
| `-BaselinePerFileCsv` / `-BaselineLabel` / `-BaselineRepoRoot` | none | A `corrected-per-file.csv` from another coverage run to compare with, e.g. `PerModule\<Module>\corrected-per-file.csv` from an earlier run of this skill, or `lib\Split-CoverageByModule.ps1` run over a CI build's merged Cobertura file |
|
||||
| `-MoveSessionToConsole` | off | Recommended on Cloud PC / RDP hosts |
|
||||
| `-RefreshPayload` | off | Run `test\CreateTestPayload.ps1` first; stops if it fails |
|
||||
| `-ForceReinstrument` | off | Ignore the instrumented-DLL cache |
|
||||
| `-SkipTestCheck` | off | Skip listing the matching tests in the payload (same test files and selection as `runtests`) before instrumenting |
|
||||
| `-SkipFunctionCoverage` | off | Skip the function-level merge |
|
||||
|
||||
## Files in this skill
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `scripts\Invoke-WinUIVmCoverage.ps1` | One-command orchestrator for Steps 1-5. Step 3 runs once per `-TestFilter`. The payload and VM are restored in `finally` (and at the start of the next run if a run was killed). |
|
||||
| `scripts\Step1-New-InstrumentableDll.ps1` | Relinks MUXC or Tabular with `/DEBUGTYPE:CV,FIXUP` from the recorded link command, outside BuildOutput. |
|
||||
| `scripts\Step2-Instrument-Payload.ps1` | Static instrumentation (cached) and broadcast into `TestPayload\<flavor>`, with backups. |
|
||||
| `scripts\Step3-Invoke-VmCoverageRun.ps1` | Starts the collector on the VM, runs the tests through `tools\run-tests-on-vm.ps1 -SkipPayload`, shuts the collector down and downloads the coverage file. |
|
||||
| `scripts\Step4-Restore-Payload.ps1` | Restores the payload and the VM copies, hash-verified. |
|
||||
| `scripts\Step5-Get-ComponentCoverage.ps1` | Merge, per-module dedup, component filter, baseline comparison, function coverage, HTML tree and `coverage-summary.md`. |
|
||||
| `scripts\lib\Set-CoveragePipeAcl.ps1` | Puts a NULL DACL on the collector's named pipe so low-integrity TAEF test processes can connect. |
|
||||
| `scripts\lib\Repair-CoverageDuplicates.ps1`, `Split-CoverageByModule.ps1` | Deduplicate merged Cobertura by (file, line), overall and per module, so the numbers match the Azure DevOps Code Coverage tab. |
|
||||
| `scripts\lib\Get-FunctionCoverage.ps1` | Per-function coverage from the native XML merge. |
|
||||
| `scripts\lib\Build-CoverageTreeReport.ps1`, `Build-FunctionCoverageTreeReport.ps1` | Single-file HTML folder/file (and function) tree reports. |
|
||||
|
||||
Shared repo tool used by Gate 1: `tools\Set-VmCredential.ps1` validates the VM, takes the password in a separate window, writes the encrypted credential cache (only after every check passes), and checks the logon and the active desktop.
|
||||
@@ -0,0 +1,181 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
One command: code coverage of a WinUI component from ONLY the tests you choose, run on a local
|
||||
Hyper-V VM.
|
||||
|
||||
.DESCRIPTION
|
||||
relink (instrumentable copy) -> instrument + broadcast into TestPayload -> collector + tests on VM
|
||||
-> download .coverage -> restore payload/VM -> component report (optionally vs. a baseline).
|
||||
* The payload and the VM are restored in a finally block even if the run fails. If a previous
|
||||
run was killed before it could restore, this script restores that first.
|
||||
* A restore failure fails the command (after the report is written, if coverage was collected).
|
||||
* Only the generated TestPayload\<flavor> folder and the VM's deployed payload are modified;
|
||||
BuildOutput and repo sources are never touched.
|
||||
|
||||
.EXAMPLE
|
||||
# TitleBar coverage from the TitleBar tests only (output: BuildOutput\Coverage\amd64chk\TitleBar)
|
||||
.\Invoke-WinUIVmCoverage.ps1 -RepoRoot C:\src\microsoft-ui-xaml -VMName MyTestVM `
|
||||
-TestFilter '*.TitleBarTests.*' -ComponentName TitleBar `
|
||||
-ComponentPathRegex '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar' `
|
||||
-MoveSessionToConsole
|
||||
|
||||
.EXAMPLE
|
||||
# TableView lives in Microsoft.UI.Xaml.Controls.Tabular.dll
|
||||
.\Invoke-WinUIVmCoverage.ps1 -RepoRoot C:\src\microsoft-ui-xaml -VMName MyTestVM `
|
||||
-TestFilter '*.TableViewSortingTests.*' -ComponentName TableView -Module Microsoft.UI.Xaml.Controls.Tabular.dll `
|
||||
-ComponentPathRegex '\\controls\\dev\\TableView\\'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$RepoRoot,
|
||||
[Parameter(Mandatory)][string]$VMName,
|
||||
# One or more runtests filters; each runs with its own collector session, then all are merged.
|
||||
[Parameter(Mandatory)][string[]]$TestFilter,
|
||||
# Work + report folder. Default: <RepoRoot>\BuildOutput\Coverage\<Flavor>\<ComponentName> (git-ignored).
|
||||
[string]$OutputDir = '',
|
||||
[string]$ComponentName = 'TitleBar',
|
||||
[string]$ComponentPathRegex = '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar',
|
||||
[ValidateSet('Microsoft.UI.Xaml.Controls.dll', 'Microsoft.UI.Xaml.Controls.Tabular.dll', 'Microsoft.ui.xaml.dll')]
|
||||
[string]$Module = 'Microsoft.UI.Xaml.Controls.dll',
|
||||
[string]$Flavor = 'amd64chk',
|
||||
[string]$BaselinePerFileCsv = '',
|
||||
[string]$BaselineLabel = 'Baseline',
|
||||
# Root of the checkout that produced -BaselinePerFileCsv, if it is not a build-agent root.
|
||||
[string]$BaselineRepoRoot = '',
|
||||
[PSCredential]$Credential,
|
||||
[switch]$MoveSessionToConsole,
|
||||
[switch]$RefreshPayload,
|
||||
[switch]$ForceReinstrument,
|
||||
# Skip the pre-check that lists the matching tests in the payload before instrumenting.
|
||||
[switch]$SkipTestCheck,
|
||||
[switch]$SkipFunctionCoverage
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$scripts = $PSScriptRoot
|
||||
$RepoRoot = (Resolve-Path $RepoRoot).Path
|
||||
if (-not $OutputDir) { $OutputDir = Join-Path $RepoRoot "BuildOutput\Coverage\$Flavor\$ComponentName" }
|
||||
New-Item -ItemType Directory -Force $OutputDir | Out-Null
|
||||
$OutputDir = (Resolve-Path $OutputDir).Path
|
||||
$work = Join-Path $OutputDir 'work'
|
||||
$plat = if ($Flavor -match '^(amd64|x86|arm64)') { @{ amd64 = 'x64'; x86 = 'x86'; arm64 = 'arm64' }[$Matches[1]] } else { 'x64' }
|
||||
$cfg = if ($Flavor -match 'fre$') { 'fre' } else { 'chk' }
|
||||
$payloadFlavor = "$plat$cfg"
|
||||
$payloadDir = Join-Path $RepoRoot "TestPayload\$payloadFlavor"
|
||||
# Native link project of each module that needs the Step 1 relink (MUX already links with CV/FIXUP).
|
||||
$relinkProject = @{ 'Microsoft.UI.Xaml.Controls.dll' = 'controls\dev\dll'; 'Microsoft.UI.Xaml.Controls.Tabular.dll' = 'controls\dev\dll-tabular' }
|
||||
$runLabel = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$transcript = Join-Path $OutputDir "coverage-run-$runLabel.log"
|
||||
Start-Transcript $transcript | Out-Null
|
||||
Write-Host "== Output: $OutputDir"
|
||||
# Outer try/finally: the transcript is stopped whatever happens.
|
||||
try {
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
# A previous run that was killed before Step 4 leaves instrument-state.json behind: restore first.
|
||||
if (Test-Path (Join-Path $work 'instrument-state.json')) {
|
||||
Write-Host "== A previous run did not finish restoring the payload; restoring it first"
|
||||
& (Join-Path $scripts 'Step4-Restore-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -Credential $Credential
|
||||
}
|
||||
|
||||
$runs = @()
|
||||
$runError = $null
|
||||
$restoreError = $null
|
||||
try {
|
||||
if ($RefreshPayload) {
|
||||
Write-Host "== Refreshing TestPayload\$payloadFlavor from BuildOutput"
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
& .\initrun.ps1 -Flavor $Flavor .\test\CreateTestPayload.ps1 -Platform $plat -Configuration $cfg -Mode DevTestSuite -SkipSymbols -Quiet
|
||||
$code = $LASTEXITCODE
|
||||
} finally { Pop-Location }
|
||||
if ($code -ne 0) {
|
||||
throw "CreateTestPayload failed (exit code $code); see $payloadDir\CreateTestPayload.log. Fix the build or payload, then retry."
|
||||
}
|
||||
}
|
||||
$missing = @('runtests.cmd', 'te.exe' | Where-Object { -not (Test-Path (Join-Path $payloadDir $_)) })
|
||||
if (-not (Test-Path $payloadDir) -or $missing) {
|
||||
throw "Test payload $payloadDir is missing or incomplete ($($missing -join ', ')). Build the repo, then rerun with -RefreshPayload."
|
||||
}
|
||||
if (-not (Get-ChildItem $payloadDir -Recurse -Filter $Module -File | Select-Object -First 1)) {
|
||||
throw "No copy of $Module in $payloadDir. Build the repo, then rerun with -RefreshPayload."
|
||||
}
|
||||
|
||||
# Fail fast, before the long instrumentation, if the payload that will be deployed has no
|
||||
# tests matching a filter (for example because the build predates the component's tests).
|
||||
# Mirrors the test files and Desktop selection that runtests.ps1 uses on the VM.
|
||||
if (-not $SkipTestCheck) {
|
||||
Push-Location $payloadDir
|
||||
# TAEF needs /select:"<query>" passed verbatim; PowerShell 7's default quoting turns the
|
||||
# query into a file name (TAEF then lists every test).
|
||||
$savedArgPassing = $PSNativeCommandArgumentPassing
|
||||
$PSNativeCommandArgumentPassing = 'Legacy'
|
||||
try {
|
||||
$testFiles = @(Get-ChildItem Test -Filter 'Microsoft.UI.Xaml.Tests.*.dll' -EA SilentlyContinue | ForEach-Object { "Test\$($_.Name)" }) +
|
||||
@('Test\MUXControls.Test.dll', 'Test\UnpackagedApps\MUXControlsTestApp\MUXControlsTestApp.dll', 'Test\IXMPTestApp.appx' | Where-Object { Test-Path $_ })
|
||||
foreach ($f in $TestFilter) {
|
||||
$select = "@Name='*$f' and (@TestPass:IncludeOnlyOn='*Desktop*' OR (NOT(@TestPass:IncludeOnlyOn=*) AND NOT(@TestPass:ExcludeOn='*Desktop*')))"
|
||||
$nameRx = '^' + ([regex]::Escape("*$f") -replace '\\\*', '.*') + '$'
|
||||
$listing = @(& .\te.exe @testFiles /list "/select:`"$select`"" 2>&1 | ForEach-Object { "$_".Trim() })
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Listing tests with te.exe failed (exit code $LASTEXITCODE) for '$f':`n$(($listing | Where-Object { $_ } | Select-Object -Last 10) -join "`n")"
|
||||
}
|
||||
$count = @($listing | Where-Object { $_ -match $nameRx }).Count
|
||||
Write-Host "== Test check: '$f' matches $count test(s) in TestPayload\$payloadFlavor"
|
||||
if (-not $count) {
|
||||
throw "No tests in TestPayload\$payloadFlavor match '$f'. Check the filter, and that the build is current and contains the component's tests (rebuild, then use -RefreshPayload). Use -SkipTestCheck to bypass this check."
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { $PSNativeCommandArgumentPassing = $savedArgPassing; Pop-Location }
|
||||
}
|
||||
|
||||
Write-Host "== Step 1: instrumentable $Module"
|
||||
if ($relinkProject.ContainsKey($Module)) {
|
||||
$rl = & (Join-Path $scripts 'Step1-New-InstrumentableDll.ps1') -RepoRoot $RepoRoot -OutputDir (Join-Path $work 'relink') -Flavor $Flavor `
|
||||
-ObjSubDir $relinkProject[$Module] -ProjectSubDir $relinkProject[$Module] -ModuleName $Module
|
||||
$srcDll = $rl.dll
|
||||
} else {
|
||||
# MUX already links with /debugtype:cv,fixup,pdata; stage its DLL+PDB side by side.
|
||||
$d = Join-Path $work 'mux'; New-Item -ItemType Directory -Force $d | Out-Null
|
||||
Copy-Item (Join-Path $RepoRoot "BuildOutput\bin\$Flavor\Product\Microsoft.ui.xaml.dll") $d -Force
|
||||
Copy-Item (Get-ChildItem (Join-Path $RepoRoot "BuildOutput\bin\$Flavor") -Recurse -Filter Microsoft.ui.xaml.pdb | Select-Object -First 1).FullName $d -Force
|
||||
$srcDll = Join-Path $d 'Microsoft.ui.xaml.dll'
|
||||
}
|
||||
|
||||
Write-Host "== Step 2: instrument + broadcast into payload"
|
||||
$st = & (Join-Path $scripts 'Step2-Instrument-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -SourceDll $srcDll -PayloadFlavor $payloadFlavor -ForceReinstrument:$ForceReinstrument
|
||||
|
||||
Write-Host "== Step 3: collector + tests on $VMName"
|
||||
for ($i = 0; $i -lt $TestFilter.Count; $i++) {
|
||||
$label = if ($TestFilter.Count -gt 1) { "$runLabel-$($i + 1)" } else { $runLabel }
|
||||
Write-Host " filter $($i + 1)/$($TestFilter.Count): $($TestFilter[$i])"
|
||||
$runs += & (Join-Path $scripts 'Step3-Invoke-VmCoverageRun.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -TestFilter $TestFilter[$i] `
|
||||
-Flavor $Flavor -Credential $Credential -RunLabel $label -MoveSessionToConsole:$MoveSessionToConsole
|
||||
}
|
||||
}
|
||||
catch { $runError = $_; throw }
|
||||
finally {
|
||||
Write-Host "== Step 4: restore payload + VM"
|
||||
try { & (Join-Path $scripts 'Step4-Restore-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -Credential $Credential }
|
||||
catch {
|
||||
$restoreError = $_
|
||||
Write-Warning "RESTORE FAILED: $_`n instrument-state.json was kept. Rerun this script (it restores first) or run:`n $scripts\Step4-Restore-Payload.ps1 -RepoRoot '$RepoRoot' -WorkDir '$work' -VMName $VMName"
|
||||
# The original failure (if any) is rethrown after this block; only note the restore error here.
|
||||
if ($runError) { Write-Warning "The run itself had already failed: $runError" }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "== Step 5: $ComponentName report"
|
||||
$rep = & (Join-Path $scripts 'Step5-Get-ComponentCoverage.ps1') -CoverageFiles @($runs.coverageFile) -OutputDir (Join-Path $OutputDir "report-$runLabel") -RepoRoot $RepoRoot `
|
||||
-Module ([IO.Path]::GetFileNameWithoutExtension($Module)) -ComponentPathRegex $ComponentPathRegex -ComponentName $ComponentName `
|
||||
-BaselinePerFileCsv $BaselinePerFileCsv -BaselineLabel $BaselineLabel -BaselineRepoRoot $BaselineRepoRoot `
|
||||
-RunLabel "tests '$($TestFilter -join "', '")'" -SkipFunctionCoverage:$SkipFunctionCoverage
|
||||
$testSummary = ($runs | ForEach-Object testSummary) -join ' | '
|
||||
Write-Host ("== DONE in {0:hh\:mm\:ss}: {1} {2}/{3} lines = {4} % | tests: {5}" -f $sw.Elapsed, $ComponentName, $rep.coveredLines, $rep.totalLines, $rep.lineCoveragePct, $testSummary)
|
||||
Write-Host " summary: $($rep.summary)"
|
||||
if ($restoreError) {
|
||||
throw "Coverage report written ($($rep.summary)), but restoring the payload/VM FAILED: $restoreError. The payload may still be instrumented; rerun this script (it restores first) or run Step4-Restore-Payload.ps1 -WorkDir '$work'."
|
||||
}
|
||||
$rep | Add-Member testSummary $testSummary -PassThru | Add-Member coverageFile @($runs.coverageFile) -PassThru
|
||||
}
|
||||
finally { Stop-Transcript | Out-Null }
|
||||
@@ -0,0 +1,120 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Step 1 - Produce an instrumentable copy of a WinUI chk product DLL (default:
|
||||
Microsoft.UI.Xaml.Controls.dll) WITHOUT touching the repo or BuildOutput.
|
||||
|
||||
.DESCRIPTION
|
||||
Static native instrumentation (Microsoft.CodeCoverage.Console instrument) needs the CV + FIXUP
|
||||
debug streams. MUXC's Debug (chk) link does not emit them, so instrumenting the normal chk DLL
|
||||
prints "Unable to obtain debug information. Link with the /PROFILE linker switch." and exits 0
|
||||
with the image untouched. Emitting them needs /DEBUGTYPE:CV,FIXUP with /INCREMENTAL:NO at link
|
||||
time.
|
||||
|
||||
This skill must not edit repo files, so instead of changing the build this script re-runs the
|
||||
EXACT link command MSBuild last recorded (link.command.1.tlog) with:
|
||||
* /DEBUGTYPE:CV,FIXUP added (and /INCREMENTAL:NO enforced),
|
||||
* /OUT, /PDB, /IMPLIB, /MAP redirected into -OutputDir,
|
||||
* /WINMD disabled (the .winmd is not needed for coverage).
|
||||
The repo's BuildOutput DLL/PDB are never overwritten. Object files are reused as-is, so the code
|
||||
is byte-for-byte the same build - only the debug info differs.
|
||||
|
||||
Microsoft.ui.xaml.dll (MUX) already links with /debugtype:cv,fixup,pdata, so for MUX you can skip
|
||||
this step and instrument BuildOutput\bin\<flavor>\Product\Microsoft.ui.xaml.dll directly.
|
||||
|
||||
.EXAMPLE
|
||||
.\Step1-New-InstrumentableDll.ps1 -RepoRoot C:\src\microsoft-ui-xaml -OutputDir C:\cov\work\relink
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$RepoRoot,
|
||||
[Parameter(Mandatory)][string]$OutputDir,
|
||||
[string]$Flavor = 'amd64chk',
|
||||
# Project-relative obj dir that holds the link .tlog for the module.
|
||||
[string]$ObjSubDir = 'controls\dev\dll',
|
||||
# Directory the original link ran in (relative paths such as /DEF are resolved from here).
|
||||
[string]$ProjectSubDir = 'controls\dev\dll',
|
||||
[string]$ModuleName = 'Microsoft.UI.Xaml.Controls.dll'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
function Info($m) { Write-Host "[relink] $m" }
|
||||
|
||||
$objDir = Join-Path $RepoRoot "BuildOutput\obj\$Flavor\$ObjSubDir"
|
||||
$projDir = Join-Path $RepoRoot $ProjectSubDir
|
||||
if (-not (Test-Path $objDir)) { throw "obj dir not found: $objDir (build the repo first)" }
|
||||
|
||||
$stem = [IO.Path]::GetFileNameWithoutExtension($ModuleName)
|
||||
$tlog = Get-ChildItem $objDir -Recurse -Filter 'link.command.1.tlog' |
|
||||
Where-Object { ([IO.File]::ReadAllText($_.FullName)) -match [regex]::Escape("\$stem.dll".ToUpperInvariant()) } |
|
||||
Select-Object -First 1
|
||||
if (-not $tlog) { throw "No link.command.1.tlog under $objDir mentions $ModuleName" }
|
||||
Info "link tlog : $($tlog.FullName)"
|
||||
|
||||
# tlog layout: line 0 = '^<sources>' (input list), remaining lines = the command line (options + objs).
|
||||
$lines = [IO.File]::ReadAllText($tlog.FullName) -split "`r?`n"
|
||||
$cmd = ($lines | Select-Object -Skip 1 | Where-Object { $_ -and -not $_.StartsWith('^') }) -join "`n"
|
||||
if (-not $cmd) { throw "Could not read link command from $($tlog.FullName)" }
|
||||
|
||||
New-Item -ItemType Directory -Force $OutputDir | Out-Null
|
||||
$OutputDir = (Resolve-Path $OutputDir).Path
|
||||
$outDll = Join-Path $OutputDir $ModuleName
|
||||
$outPdb = Join-Path $OutputDir "$stem.pdb"
|
||||
$outLib = Join-Path $OutputDir "$stem.lib"
|
||||
$outMap = Join-Path $OutputDir "$stem.map"
|
||||
|
||||
# The link is not byte-deterministic, and Step 2 caches the (slow) instrumentation by the relinked
|
||||
# DLL's hash, so reuse the previous relink when the recorded link command/inputs are unchanged.
|
||||
$resultFile = Join-Path $OutputDir 'relink-result.json'
|
||||
$inputsStamp = '{0}|{1}' -f (Get-FileHash $tlog.FullName).Hash,
|
||||
((Get-ChildItem (Split-Path $tlog.FullName) -Filter 'link.read.*.tlog' | Get-FileHash | ForEach-Object Hash) -join ',')
|
||||
$newestObj = Get-ChildItem $objDir -Filter *.obj | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1
|
||||
if ((Test-Path $resultFile) -and (Test-Path $outDll) -and (Test-Path $outPdb)) {
|
||||
$prev = Get-Content $resultFile -Raw | ConvertFrom-Json
|
||||
if ($prev.inputsStamp -eq $inputsStamp -and (-not $newestObj -or $newestObj.LastWriteTimeUtc -lt (Get-Item $outDll).LastWriteTimeUtc)) {
|
||||
Info "reusing previous relink (link inputs unchanged): $outDll"
|
||||
return [pscustomobject]@{ dll = $outDll; pdb = $outPdb }
|
||||
}
|
||||
}
|
||||
|
||||
$q = '(?:"[^"]*"|\S+)'
|
||||
$cmd = [regex]::Replace($cmd, "(?i)/OUT:$q", "/OUT:`"$outDll`"")
|
||||
$cmd = [regex]::Replace($cmd, "(?i)/PDB:$q", "/PDB:`"$outPdb`"")
|
||||
$cmd = [regex]::Replace($cmd, "(?i)/IMPLIB:$q", "/IMPLIB:`"$outLib`"")
|
||||
$cmd = [regex]::Replace($cmd, "(?i)/WINMDFILE:$q", '')
|
||||
$cmd = [regex]::Replace($cmd, "(?i)/WINMD(?=\s)", '/WINMD:NO')
|
||||
$cmd = [regex]::Replace($cmd, "(?i)/MAP(?=\s)", "/MAP:`"$outMap`"")
|
||||
$cmd = [regex]::Replace($cmd, "(?i)/INCREMENTAL(:\w+)?", '/INCREMENTAL:NO')
|
||||
$cmd = [regex]::Replace($cmd, "(?i)/DEBUGTYPE:$q", '')
|
||||
$cmd += "`n/DEBUGTYPE:CV,FIXUP"
|
||||
|
||||
$rsp = Join-Path $OutputDir 'relink.rsp'
|
||||
[IO.File]::WriteAllText($rsp, $cmd, [Text.Encoding]::Unicode)
|
||||
Info "response : $rsp"
|
||||
|
||||
# Resolve link.exe from the MSVC toolset recorded in the command's /LIBPATH (same toolset that built the objs).
|
||||
$link = $null
|
||||
$m = [regex]::Match($cmd, '(?i)"([^"]*\\VC\\TOOLS\\MSVC\\[\d\.]+)\\LIB\\X64"')
|
||||
if ($m.Success) {
|
||||
$cand = Join-Path $m.Groups[1].Value 'bin\Hostx64\x64\link.exe'
|
||||
if (Test-Path $cand) { $link = $cand }
|
||||
}
|
||||
if (-not $link) {
|
||||
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
$vs = & $vswhere -latest -products * -property installationPath
|
||||
$link = Get-ChildItem "$vs\VC\Tools\MSVC" -Recurse -Filter link.exe | Where-Object FullName -match 'Hostx64\\x64' | Select-Object -First 1 -ExpandProperty FullName
|
||||
}
|
||||
if (-not $link) { throw 'link.exe not found' }
|
||||
Info "linker : $link"
|
||||
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
Push-Location $projDir
|
||||
try {
|
||||
$out = & $link "@$rsp" 2>&1
|
||||
$exit = $LASTEXITCODE
|
||||
} finally { Pop-Location }
|
||||
$out | Where-Object { $_ -match 'error|warning LNK' } | Select-Object -First 20 | ForEach-Object { Info " $_" }
|
||||
if ($exit -ne 0 -or -not (Test-Path $outDll)) { throw "relink failed (exit $exit)" }
|
||||
Info ("relinked in {0:mm\:ss}: {1} ({2:N0} bytes)" -f $sw.Elapsed, $outDll, (Get-Item $outDll).Length)
|
||||
|
||||
[pscustomobject]@{ dll = $outDll; pdb = $outPdb; linkTlog = $tlog.FullName; linker = $link; rsp = $rsp; inputsStamp = $inputsStamp } |
|
||||
ConvertTo-Json | Set-Content $resultFile -Encoding UTF8
|
||||
[pscustomobject]@{ dll = $outDll; pdb = $outPdb }
|
||||
@@ -0,0 +1,171 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Step 2 - Statically instrument the module and broadcast it into the local test payload.
|
||||
|
||||
.DESCRIPTION
|
||||
Static native instrumentation of one product DLL for a local payload that is deployed to a
|
||||
Hyper-V VM:
|
||||
|
||||
* Instruments ONE copy (VS Microsoft.CodeCoverage.Console.exe, static native instrumentation,
|
||||
a fresh 16-hex session id) - this is slow for a 38 MB chk DLL (~20-50 min), so the result is
|
||||
CACHED in <WorkDir>\instrumented keyed by the SHA256 of the input DLL and reused next time
|
||||
(together with the session id that is baked into it). The cache is only reused while the
|
||||
coverage tool that instrumented it still exists at the same file version, because the
|
||||
collector (Step 3) must be that same tool.
|
||||
* Detects "fails while exiting 0" (no /PROFILE-style debug info) by checking the tool output
|
||||
and that the image grew, and requires static_covrun*.dll to be produced.
|
||||
* Backs up every payload copy of the module to <WorkDir>\payload-backup, then copies the
|
||||
instrumented DLL + static_covrun*.dll over every copy (a test app loads its own co-located
|
||||
copy, e.g. Test\UnpackagedApps\MUXControlsTestApp\). The backup is refreshed from the payload
|
||||
whenever that copy is not instrumented, so a rebuilt payload is never restored to an older
|
||||
build's DLL; an existing backup is kept only while the copy is still instrumented.
|
||||
* Writes <WorkDir>\instrument-state.json (session id, copies, backups, runtimes added) used by
|
||||
the run and restore steps. It is written after every backup is taken but BEFORE the first
|
||||
payload file is overwritten ("complete": false until all copies are done), so an interrupted
|
||||
run can always be restored by Step4-Restore-Payload.ps1 (the orchestrator does that
|
||||
automatically at the start of the next run).
|
||||
|
||||
Never edits repo source or BuildOutput. Only TestPayload\<flavor> (a generated, git-ignored
|
||||
folder) is modified, and Step4-Restore-Payload.ps1 restores it byte-for-byte.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$RepoRoot,
|
||||
[Parameter(Mandatory)][string]$WorkDir,
|
||||
# The instrumentable DLL (Step 1 output) and its PDB next to it.
|
||||
[Parameter(Mandatory)][string]$SourceDll,
|
||||
[string]$PayloadFlavor = 'x64chk',
|
||||
[string]$CoverageConsole = '',
|
||||
[switch]$ForceReinstrument
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
function Info($m) { Write-Host "[instrument] $m" }
|
||||
|
||||
if (-not $CoverageConsole) {
|
||||
# Highest file version wins (string-sorting install paths would rank "2022" above "18").
|
||||
$CoverageConsole = Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio" -Recurse -Filter Microsoft.CodeCoverage.Console.exe -Depth 8 -EA SilentlyContinue |
|
||||
Sort-Object { [version]$_.VersionInfo.FileVersion } -Descending | Select-Object -First 1 -ExpandProperty FullName
|
||||
}
|
||||
if (-not $CoverageConsole -or -not (Test-Path $CoverageConsole)) { throw 'Microsoft.CodeCoverage.Console.exe not found (install VS Enterprise code coverage).' }
|
||||
$toolVersion = (Get-Item $CoverageConsole).VersionInfo.FileVersion
|
||||
Info "tool : $CoverageConsole ($toolVersion)"
|
||||
|
||||
$moduleName = Split-Path $SourceDll -Leaf
|
||||
$stem = [IO.Path]::GetFileNameWithoutExtension($moduleName)
|
||||
$pdb = Join-Path (Split-Path $SourceDll) "$stem.pdb"
|
||||
if (-not (Test-Path $pdb)) { throw "PDB must sit next to the DLL: $pdb" }
|
||||
|
||||
New-Item -ItemType Directory -Force $WorkDir | Out-Null
|
||||
$WorkDir = (Resolve-Path $WorkDir).Path
|
||||
$instrDir = Join-Path $WorkDir 'instrumented'
|
||||
$cacheFile = Join-Path $instrDir 'instrumented-cache.json'
|
||||
$srcHash = (Get-FileHash $SourceDll).Hash
|
||||
|
||||
# ---- 1. instrument (or reuse cache) ---------------------------------------------------------
|
||||
$cache = if (Test-Path $cacheFile) { Get-Content $cacheFile -Raw | ConvertFrom-Json } else { $null }
|
||||
$instrDll = Join-Path $instrDir $moduleName
|
||||
$cacheHit = -not $ForceReinstrument -and $cache -and $cache.sourceHash -eq $srcHash -and (Test-Path $instrDll) -and
|
||||
(Get-FileHash $instrDll).Hash -eq $cache.instrumentedHash
|
||||
# The collector must come from the same tool (and version) that instrumented the binary.
|
||||
$cacheToolOk = $cache -and $cache.coverageTool -and (Test-Path $cache.coverageTool) -and
|
||||
"$((Get-Item $cache.coverageTool).VersionInfo.FileVersion)" -eq "$($cache.toolVersion)"
|
||||
if ($cacheHit -and $cacheToolOk) {
|
||||
$sessionId = $cache.sessionId
|
||||
$CoverageConsole = $cache.coverageTool; $toolVersion = $cache.toolVersion
|
||||
Info "reusing cached instrumented DLL (session $sessionId, tool $toolVersion)"
|
||||
} else {
|
||||
if ($cacheHit) { Info "cached DLL was instrumented by '$($cache.coverageTool)' $($cache.toolVersion), which is no longer available at that version; re-instrumenting" }
|
||||
Remove-Item $instrDir -Recurse -Force -EA SilentlyContinue
|
||||
New-Item -ItemType Directory -Force $instrDir | Out-Null
|
||||
Copy-Item $SourceDll, $pdb $instrDir
|
||||
$sessionId = [guid]::NewGuid().ToString('N').Substring(0, 16)
|
||||
$cfg = Join-Path $instrDir 'instrument.config'
|
||||
@'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Configuration>
|
||||
<CodeCoverage>
|
||||
<EnableStaticNativeInstrumentation>True</EnableStaticNativeInstrumentation>
|
||||
<EnableDynamicNativeInstrumentation>False</EnableDynamicNativeInstrumentation>
|
||||
<ModulePaths><Include><ModulePath>.*</ModulePath></Include></ModulePaths>
|
||||
</CodeCoverage>
|
||||
</Configuration>
|
||||
'@ | Set-Content $cfg -Encoding UTF8
|
||||
$before = (Get-Item $instrDll).Length
|
||||
Info "instrumenting $moduleName with session $sessionId (slow for large chk DLLs)..."
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
$out = & $CoverageConsole instrument $instrDll --session-id $sessionId --settings $cfg --nologo 2>&1
|
||||
$exit = $LASTEXITCODE
|
||||
$text = ($out | ForEach-Object { "$_" }) -join "`n"
|
||||
$text | Set-Content (Join-Path $instrDir 'instrument.log')
|
||||
$after = (Get-Item $instrDll).Length
|
||||
Info ("instrument exit={0} in {1:mm\:ss}; size {2:N0} -> {3:N0}" -f $exit, $sw.Elapsed, $before, $after)
|
||||
if ($exit -ne 0 -or $text -match 'Unable to obtain debug information|/PROFILE linker switch' -or
|
||||
-not ($text -match 'successfully instrumented' -or $after -gt $before)) {
|
||||
throw "Instrumentation failed or silently skipped (see $instrDir\instrument.log). If it mentions /PROFILE, run Step1 (relink with /DEBUGTYPE:CV,FIXUP) first."
|
||||
}
|
||||
if (-not (Get-ChildItem $instrDir -Filter 'static_covrun*.dll')) { throw "static_covrun*.dll was not produced next to $instrDll" }
|
||||
[pscustomobject]@{ sessionId = $sessionId; sourceDll = $SourceDll; sourceHash = $srcHash
|
||||
instrumentedHash = (Get-FileHash $instrDll).Hash; coverageTool = $CoverageConsole; toolVersion = $toolVersion
|
||||
createdAt = (Get-Date).ToString('o') } |
|
||||
ConvertTo-Json | Set-Content $cacheFile -Encoding UTF8
|
||||
}
|
||||
$runtimes = @(Get-ChildItem $instrDir -Filter 'static_covrun*.dll')
|
||||
|
||||
# ---- 2. broadcast into the payload (back up originals) --------------------------------------
|
||||
$payload = Join-Path $RepoRoot "TestPayload\$PayloadFlavor"
|
||||
if (-not (Test-Path (Join-Path $payload 'runtests.cmd'))) { throw "Test payload not found at $payload. Run tools\run-tests-on-vm.ps1 (or test\CreateTestPayload.ps1) once first." }
|
||||
$backupRoot = Join-Path $WorkDir 'payload-backup'
|
||||
$statePath = Join-Path $WorkDir 'instrument-state.json'
|
||||
$state = if (Test-Path $statePath) { Get-Content $statePath -Raw | ConvertFrom-Json } else { $null }
|
||||
if ($state -and $state.payloadDir -ne $payload) { throw "instrument-state.json belongs to another payload ($($state.payloadDir)); restore it first." }
|
||||
|
||||
$copies = @(Get-ChildItem $payload -Recurse -Filter $moduleName -File)
|
||||
if (-not $copies) { throw "No copies of $moduleName under $payload" }
|
||||
$instrHash = (Get-FileHash $instrDll).Hash
|
||||
$knownInstrumented = @($instrHash, $(if ($state) { $state.instrumentedHash })) | Where-Object { $_ }
|
||||
|
||||
# Pass 1 (no destructive writes): back up every copy and work out which runtimes will be added.
|
||||
$entries = @()
|
||||
$runtimesAdded = @()
|
||||
foreach ($c in $copies) {
|
||||
$rel = $c.FullName.Substring($payload.Length + 1)
|
||||
$bak = Join-Path $backupRoot $rel
|
||||
# A copy that is not instrumented is the payload's current original (possibly from a newer build
|
||||
# than an earlier backup), so always back it up. Keep an existing backup only while the copy is
|
||||
# still instrumented (e.g. an earlier run was not restored yet).
|
||||
$isInstrumented = ($knownInstrumented -contains (Get-FileHash $c.FullName).Hash) -or
|
||||
[bool](Get-ChildItem $c.DirectoryName -Filter 'static_covrun*.dll' -File -EA SilentlyContinue)
|
||||
if (-not $isInstrumented) {
|
||||
New-Item -ItemType Directory -Force (Split-Path $bak) | Out-Null
|
||||
Copy-Item $c.FullName $bak -Force
|
||||
} elseif (-not (Test-Path $bak)) {
|
||||
throw "$rel is already instrumented but has no backup; recreate the payload (-RefreshPayload)."
|
||||
}
|
||||
foreach ($rt in $runtimes) {
|
||||
$rtDest = Join-Path $c.DirectoryName $rt.Name
|
||||
if (-not (Test-Path $rtDest)) { $runtimesAdded += $rtDest.Substring($payload.Length + 1) }
|
||||
}
|
||||
$entries += [pscustomobject]@{ relativePath = $rel; backup = $bak; originalHash = (Get-FileHash $bak).Hash }
|
||||
}
|
||||
if ($state -and $state.runtimesAdded) { $runtimesAdded = @($state.runtimesAdded) + $runtimesAdded | Select-Object -Unique }
|
||||
|
||||
# Persist the recovery state BEFORE the first overwrite, so Step 4 can always restore, even if
|
||||
# a copy below fails or the process is killed part-way ("complete" stays false until the end).
|
||||
$newState = [pscustomobject]@{
|
||||
module = $moduleName; moduleStem = $stem; sessionId = $sessionId; payloadDir = $payload
|
||||
instrumentedDll = $instrDll; instrumentedHash = $instrHash; coverageTool = $CoverageConsole; toolVersion = $toolVersion
|
||||
copies = $entries; runtimesAdded = @($runtimesAdded); complete = $false; instrumentedAt = (Get-Date).ToString('o')
|
||||
}
|
||||
$newState | ConvertTo-Json -Depth 5 | Set-Content $statePath -Encoding UTF8
|
||||
|
||||
# Pass 2: overwrite every copy and add the coverage runtimes next to it.
|
||||
foreach ($e in $entries) {
|
||||
$dest = Join-Path $payload $e.relativePath
|
||||
Copy-Item $instrDll $dest -Force
|
||||
foreach ($rt in $runtimes) { Copy-Item $rt.FullName (Join-Path (Split-Path $dest) $rt.Name) -Force }
|
||||
Info " instrumented copy -> $($e.relativePath)"
|
||||
}
|
||||
$newState.complete = $true
|
||||
$newState | ConvertTo-Json -Depth 5 | Set-Content $statePath -Encoding UTF8
|
||||
Info "payload instrumented: $($entries.Count) copies, session $sessionId (state: $statePath)"
|
||||
$newState
|
||||
@@ -0,0 +1,219 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Step 3 - Run the selected tests on a Hyper-V VM with a code-coverage collector attached and
|
||||
download the resulting .coverage file.
|
||||
|
||||
.DESCRIPTION
|
||||
How the collector is wired up:
|
||||
1. Stage the VS code-coverage tools (CodeCoverage.Console + Dynamic Code Coverage Tools +
|
||||
InstrumentationEngine) on the VM once (C:\WinUICoverage\CoverageTool), no VS/dotnet needed.
|
||||
2. Start "Microsoft.CodeCoverage.Console.exe collect --session-id <sid> --server-mode --output <file>"
|
||||
as the logged-in VM user (interactive scheduled task - it must outlive the PS Direct session).
|
||||
3. Wait for \\.\pipe\CodeCoverage.pipe.<sid>, then give it a NULL DACL
|
||||
(lib\Set-CoveragePipeAcl.ps1) so low-integrity test processes can connect.
|
||||
4. Run the tests with the repo's own tools\run-tests-on-vm.ps1 -SkipPayload. -SkipPayload only
|
||||
skips regenerating TestPayload from BuildOutput (which would overwrite the instrumented
|
||||
DLLs); the runner still copies the changed payload files to the VM and runs runtests.cmd
|
||||
on the interactive desktop.
|
||||
5. "Microsoft.CodeCoverage.Console.exe shutdown <sid>" -> collector finalizes the .coverage.
|
||||
6. Copy the .coverage + collector logs back to <WorkDir>\raw (also when the run fails), and
|
||||
always close the PowerShell Direct session.
|
||||
|
||||
Precondition: the VM user's desktop must be interactive. If the host is a Cloud PC / RDP session
|
||||
that can disconnect, use -MoveSessionToConsole so input does not depend on vmconnect's enhanced
|
||||
session (runs "tscon <id> /dest:console" as SYSTEM inside the VM).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$RepoRoot,
|
||||
[Parameter(Mandatory)][string]$WorkDir,
|
||||
[Parameter(Mandatory)][string]$VMName,
|
||||
[Parameter(Mandatory)][string]$TestFilter,
|
||||
[string]$Flavor = 'amd64chk',
|
||||
[PSCredential]$Credential,
|
||||
[string]$RunLabel = '',
|
||||
[switch]$MoveSessionToConsole,
|
||||
[int]$PipeTimeoutSec = 90,
|
||||
[int]$FlushTimeoutSec = 600
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
function Info($m) { Write-Host "[vm-run] $m" }
|
||||
|
||||
$WorkDir = (Resolve-Path $WorkDir).Path
|
||||
$state = Get-Content (Join-Path $WorkDir 'instrument-state.json') -Raw | ConvertFrom-Json
|
||||
$sid = $state.sessionId
|
||||
if (-not $RunLabel) { $RunLabel = Get-Date -Format 'yyyyMMdd-HHmmss' }
|
||||
$rawDir = Join-Path $WorkDir "raw\$RunLabel"
|
||||
New-Item -ItemType Directory -Force $rawDir | Out-Null
|
||||
|
||||
# Credential: same per-user DPAPI cache tools\run-tests-on-vm.ps1 uses.
|
||||
if (-not $Credential) {
|
||||
$credFile = Join-Path $env:USERPROFILE ".winui-test\vmcred-$($VMName -replace '[^a-zA-Z0-9]','_').xml"
|
||||
if (-not (Test-Path $credFile)) { throw "No cached VM credential ($credFile). Run tools\run-tests-on-vm.ps1 once, or pass -Credential." }
|
||||
$Credential = Import-Clixml $credFile
|
||||
}
|
||||
$user = $Credential.UserName
|
||||
$session = New-PSSession -VMName $VMName -Credential $Credential
|
||||
|
||||
$vmRoot = 'C:\WinUICoverage'
|
||||
# Collector must be the same tool version that instrumented the binary (recorded by Step 2).
|
||||
$hostTool = $state.coverageTool
|
||||
if (-not $hostTool -or -not (Test-Path $hostTool)) {
|
||||
$hostTool = Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio" -Recurse -Filter Microsoft.CodeCoverage.Console.exe -Depth 8 -EA SilentlyContinue |
|
||||
Sort-Object { [version]$_.VersionInfo.FileVersion } -Descending | Select-Object -First 1 -ExpandProperty FullName
|
||||
}
|
||||
$toolVer = (Get-Item $hostTool).VersionInfo.FileVersion
|
||||
$vmToolDir = "$vmRoot\CoverageTool\$toolVer"
|
||||
$vmTool = "$vmToolDir\Microsoft.CodeCoverage.Console.exe"
|
||||
$vmOut = "$vmRoot\out\coverage-$RunLabel-$($state.moduleStem).coverage"
|
||||
$vmLog = "$vmRoot\out\collector-$RunLabel.log"
|
||||
|
||||
try {
|
||||
# ---- interactive desktop check -----------------------------------------------------------
|
||||
$desk = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($u)
|
||||
$rows = (qwinsta 2>$null) | Select-Object -Skip 1
|
||||
$mine = $rows | Where-Object { $_ -match "\s$u\s" } | Select-Object -First 1
|
||||
[pscustomobject]@{ line = "$mine"; onConsole = ("$mine" -match '^\s*>?console\s'); active = ("$mine" -match '\sActive') ;
|
||||
id = if ("$mine" -match "\s$u\s+(\d+)\s") { [int]$Matches[1] } else { -1 } }
|
||||
} -ArgumentList $user
|
||||
Info "VM session: $($desk.line.Trim())"
|
||||
if (-not $desk.active) { throw "User $user has no Active session on $VMName. Log on to the VM desktop first." }
|
||||
if (-not $desk.onConsole) {
|
||||
if ($MoveSessionToConsole) {
|
||||
Info "moving session $($desk.id) to the VM console (tscon as SYSTEM)"
|
||||
Invoke-Command -Session $session -ScriptBlock {
|
||||
param($id)
|
||||
$a = New-ScheduledTaskAction -Execute "$env:windir\System32\tscon.exe" -Argument "$id /dest:console"
|
||||
$p = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName 'WinUICov-tscon' -Action $a -Principal $p -Force | Out-Null
|
||||
Start-ScheduledTask 'WinUICov-tscon'; Start-Sleep 5
|
||||
Unregister-ScheduledTask 'WinUICov-tscon' -Confirm:$false
|
||||
} -ArgumentList $desk.id
|
||||
} else {
|
||||
Write-Warning "User session is not on the VM console. If the host's vmconnect/RDP session disconnects, UI input fails ('Access is denied'). Re-run with -MoveSessionToConsole to avoid that."
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 1. stage collector tools (once) ------------------------------------------------------
|
||||
$hasTool = Invoke-Command -Session $session -ScriptBlock { param($t) Test-Path $t } -ArgumentList $vmTool
|
||||
if (-not $hasTool) {
|
||||
$cc = $hostTool
|
||||
$vsBase = $cc.Substring(0, $cc.IndexOf('\Common7\'))
|
||||
$stage = Join-Path $WorkDir "CoverageTool\$toolVer"
|
||||
Remove-Item $stage -Recurse -Force -EA SilentlyContinue
|
||||
New-Item -ItemType Directory -Force $stage, "$stage\amd64", "$stage\x64", "$stage\x86" | Out-Null
|
||||
Copy-Item "$vsBase\Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\*" $stage -Recurse -Force
|
||||
$van = "$vsBase\Team Tools\Dynamic Code Coverage Tools"
|
||||
if (Test-Path $van) { Copy-Item "$van\*" $stage -Recurse -Force; if (Test-Path "$van\amd64") { Copy-Item "$van\amd64\*" "$stage\amd64" -Recurse -Force } }
|
||||
$ie = "$vsBase\Common7\IDE\CommonExtensions\Platform\InstrumentationEngine"
|
||||
foreach ($a in 'x64', 'x86') { if (Test-Path "$ie\$a") { Copy-Item "$ie\$a\*" "$stage\$a" -Recurse -Force } }
|
||||
$zip = Join-Path $WorkDir "CoverageTool-$toolVer.zip"
|
||||
Remove-Item $zip -EA SilentlyContinue
|
||||
Compress-Archive -Path "$stage\*" -DestinationPath $zip
|
||||
Invoke-Command -Session $session -ScriptBlock { param($r, $d) New-Item -ItemType Directory -Force $d, "$r\out" | Out-Null } -ArgumentList $vmRoot, $vmToolDir
|
||||
Copy-Item $zip "$vmRoot\CoverageTool.zip" -ToSession $session -Force
|
||||
Invoke-Command -Session $session -ScriptBlock { param($r, $d) Expand-Archive "$r\CoverageTool.zip" $d -Force; Remove-Item "$r\CoverageTool.zip" } -ArgumentList $vmRoot, $vmToolDir
|
||||
Info "staged collector tools ($toolVer) on VM: $vmTool"
|
||||
}
|
||||
Invoke-Command -Session $session -ScriptBlock { param($r) New-Item -ItemType Directory -Force "$r\out" | Out-Null } -ArgumentList $vmRoot
|
||||
Copy-Item (Join-Path $PSScriptRoot 'lib\Set-CoveragePipeAcl.ps1') "$vmRoot\Set-CoveragePipeAcl.ps1" -ToSession $session -Force
|
||||
|
||||
# ---- 2. start the collector as the interactive user ---------------------------------------
|
||||
Invoke-Command -Session $session -ScriptBlock {
|
||||
param($tool, $sid, $out, $log, $u, $r)
|
||||
if (Test-Path "\\.\pipe\CodeCoverage.pipe.$sid") { throw "A collector for session $sid is already running on the VM. Run with a fresh session or shut it down: `"$tool`" shutdown $sid" }
|
||||
Remove-Item $out, $log -EA SilentlyContinue
|
||||
# Use a .cmd wrapper: "cmd /c" strips the outer quotes of a command line that starts and
|
||||
# ends with a quote, which silently breaks a quoted exe path + quoted redirections.
|
||||
$wrapper = "$r\collector-wrapper.cmd"
|
||||
"@echo off`r`n`"$tool`" collect --session-id $sid --server-mode --output `"$out`" > `"$log`" 2>&1" | Set-Content $wrapper -Encoding ASCII
|
||||
$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument "/c `"$wrapper`""
|
||||
$p = New-ScheduledTaskPrincipal -UserId $u -LogonType Interactive -RunLevel Highest
|
||||
$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
Register-ScheduledTask -TaskName 'WinUICov-Collector' -Action $a -Principal $p -Settings $s -Force | Out-Null
|
||||
Start-ScheduledTask 'WinUICov-Collector'
|
||||
} -ArgumentList $vmTool, $sid, $vmOut, $vmLog, $user, $vmRoot
|
||||
Info "collector started (session $sid) -> $vmOut"
|
||||
|
||||
# ---- 3. wait for the pipe, open its DACL ---------------------------------------------------
|
||||
$ready = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($sid, $timeout, $log)
|
||||
$deadline = (Get-Date).AddSeconds($timeout)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path "\\.\pipe\CodeCoverage.pipe.$sid") { return $true }
|
||||
if ((Get-ScheduledTask 'WinUICov-Collector').State -ne 'Running') { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
Get-Content $log -EA SilentlyContinue | Select-Object -Last 20 | ForEach-Object { Write-Host " collector: $_" }
|
||||
return $false
|
||||
} -ArgumentList $sid, $PipeTimeoutSec, $vmLog
|
||||
if (-not $ready) { throw "Collector pipe for session $sid did not appear on the VM." }
|
||||
Invoke-Command -Session $session -ScriptBlock {
|
||||
param($r, $sid) & powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$r\Set-CoveragePipeAcl.ps1" -SessionId $sid
|
||||
} -ArgumentList $vmRoot, $sid | ForEach-Object { Info " $_" }
|
||||
|
||||
# ---- 4. run the tests with the repo's VM runner (payload already instrumented) -------------
|
||||
# run-tests-on-vm.ps1 deploys incrementally using a size+mtime manifest of what it copied last
|
||||
# time. Step 4 restores the VM without updating that manifest, and static_covrun*.dll keep the
|
||||
# tool's own timestamp, so a later run would skip them: the VM keeps the original DLL (empty
|
||||
# coverage) or gets the instrumented DLL without its runtime (the app fails to activate MUXC
|
||||
# types). Drop those entries so the instrumented files are always deployed.
|
||||
$instrumentedRel = @($state.copies.relativePath) + @($state.runtimesAdded)
|
||||
$manifestPattern = ".deploy-manifest-$($VMName -replace '[^a-zA-Z0-9]', '_')-*.json"
|
||||
foreach ($mf in Get-ChildItem (Join-Path $RepoRoot 'TestPayload') -Filter $manifestPattern -File -EA SilentlyContinue) {
|
||||
$entries = Get-Content $mf.FullName -Raw | ConvertFrom-Json
|
||||
$keep = [ordered]@{}
|
||||
foreach ($p in $entries.PSObject.Properties) {
|
||||
if ($instrumentedRel -notcontains $p.Name -and $p.Name -notmatch '(^|\\)static_covrun\w*\.dll$') { $keep[$p.Name] = $p.Value }
|
||||
}
|
||||
($keep | ConvertTo-Json -Depth 1) | Set-Content $mf.FullName -Encoding UTF8
|
||||
}
|
||||
$testLog = Join-Path $rawDir 'vm-test.log'
|
||||
Info "running tests: run-tests-on-vm.ps1 -SkipPayload $TestFilter (log: $testLog)"
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
& (Join-Path $RepoRoot 'initrun.ps1') -Flavor $Flavor .\tools\run-tests-on-vm.ps1 -VMName $VMName -SkipPayload $TestFilter *>&1 |
|
||||
Tee-Object -FilePath $testLog | Where-Object { "$_" -match 'Summary:|EndGroup:|Tests (PASSED|FAILED)|Error|\[\d/3\]' } | ForEach-Object { Write-Host " $_" }
|
||||
} finally { Pop-Location }
|
||||
$summary = Select-String $testLog -Pattern 'Summary:\s+Total=' | Select-Object -Last 1
|
||||
if (-not $summary) {
|
||||
if (Select-String $testLog -Pattern 'selection criteria did not match any tests' -Quiet) {
|
||||
throw "No tests matched '$TestFilter' in the deployed payload. Check the filter, and that the build (and TestPayload) is current and contains the component's tests."
|
||||
}
|
||||
throw "The test run produced no TAEF summary; see $testLog."
|
||||
}
|
||||
Info "test summary: $($summary.Line.Trim())"
|
||||
}
|
||||
finally {
|
||||
# ---- 5. always flush: shut the collector down, wait for the file --------------------------
|
||||
try {
|
||||
$flush = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($tool, $sid, $out, $timeout)
|
||||
$o = if (Test-Path "\\.\pipe\CodeCoverage.pipe.$sid") { & $tool shutdown $sid 2>&1 | Out-String } else { 'no collector pipe (collector not running)' }
|
||||
$deadline = (Get-Date).AddSeconds($timeout)
|
||||
do { Start-Sleep 3; $t = Get-ScheduledTask 'WinUICov-Collector' -EA SilentlyContinue } while ($t -and $t.State -eq 'Running' -and (Get-Date) -lt $deadline)
|
||||
if ($t -and $t.State -eq 'Running') { Stop-ScheduledTask 'WinUICov-Collector' }
|
||||
Unregister-ScheduledTask 'WinUICov-Collector' -Confirm:$false -EA SilentlyContinue
|
||||
[pscustomobject]@{ shutdown = $o.Trim(); size = if (Test-Path $out) { (Get-Item $out).Length } else { 0 } }
|
||||
} -ArgumentList $vmTool, $sid, $vmOut, $FlushTimeoutSec
|
||||
Info ("collector shutdown: {0}; coverage size {1:N0} bytes" -f $flush.shutdown, $flush.size)
|
||||
} catch { Write-Warning "collector shutdown failed: $_" }
|
||||
|
||||
# ---- 6. download (also on failure, so the collector log is kept) and always close the session -
|
||||
# Copy errors are not raised here (they would hide the original failure); a missing or empty
|
||||
# coverage file is reported below.
|
||||
$local = Join-Path $rawDir (Split-Path $vmOut -Leaf)
|
||||
try {
|
||||
Copy-Item $vmOut $local -FromSession $session -Force -EA SilentlyContinue
|
||||
Copy-Item $vmLog (Join-Path $rawDir 'collector.log') -FromSession $session -Force -EA SilentlyContinue
|
||||
} finally { Remove-PSSession $session -EA SilentlyContinue }
|
||||
}
|
||||
|
||||
if (-not (Test-Path $local) -or (Get-Item $local).Length -lt 1024) {
|
||||
throw "Coverage file missing or empty ($local). Check $rawDir\collector.log and that the test process loaded the instrumented DLL (session $sid)."
|
||||
}
|
||||
Info ("downloaded {0} ({1:N1} MB)" -f $local, ((Get-Item $local).Length / 1MB))
|
||||
[pscustomobject]@{ coverageFile = $local; testLog = $testLog; testSummary = "$($summary.Line)".Trim(); sessionId = $sid; rawDir = $rawDir } |
|
||||
Tee-Object -Variable result | ConvertTo-Json | Set-Content (Join-Path $rawDir 'run-result.json') -Encoding UTF8
|
||||
$result
|
||||
@@ -0,0 +1,82 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Step 4 - Restore the original (uninstrumented) module in the local payload and on the VM.
|
||||
|
||||
.DESCRIPTION
|
||||
Uses <WorkDir>\instrument-state.json written by Step 2:
|
||||
* copies every backed-up original back over its payload copy (hash-verified; copies that
|
||||
already match their original, e.g. because Step 2 was interrupted before overwriting them,
|
||||
are left alone),
|
||||
* deletes the static_covrun*.dll files that Step 2 added,
|
||||
* optionally (-VMName) does the same inside the VM's deployed payload
|
||||
(C:\TestPayload\<HOSTNAME>-<repo-folder>, the path tools\run-tests-on-vm.ps1 uses) and
|
||||
throws if any VM copy does not match its original afterwards,
|
||||
* removes instrument-state.json only after everything was restored (on failure it is kept,
|
||||
so this script can simply be run again; backups are kept for audit).
|
||||
The deploy manifest of tools\run-tests-on-vm.ps1 is size+mtime based, so the next normal run
|
||||
re-deploys the restored files anyway; the VM step just makes the VM clean immediately.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$RepoRoot,
|
||||
[Parameter(Mandatory)][string]$WorkDir,
|
||||
[string]$VMName,
|
||||
[PSCredential]$Credential
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
function Info($m) { Write-Host "[restore] $m" }
|
||||
|
||||
$statePath = Join-Path $WorkDir 'instrument-state.json'
|
||||
if (-not (Test-Path $statePath)) { Info 'nothing to restore (no instrument-state.json)'; return }
|
||||
$state = Get-Content $statePath -Raw | ConvertFrom-Json
|
||||
$payload = $state.payloadDir
|
||||
|
||||
foreach ($c in $state.copies) {
|
||||
$dest = Join-Path $payload $c.relativePath
|
||||
# Tolerate copies that were never overwritten (e.g. Step 2 was interrupted part-way).
|
||||
if ((Test-Path $dest) -and (Get-FileHash $dest).Hash -eq $c.originalHash) { Info "unchanged $($c.relativePath)"; continue }
|
||||
Copy-Item $c.backup $dest -Force
|
||||
if ((Get-FileHash $dest).Hash -ne $c.originalHash) { throw "restore hash mismatch: $dest" }
|
||||
Info "restored $($c.relativePath)"
|
||||
}
|
||||
foreach ($r in $state.runtimesAdded) {
|
||||
$p = Join-Path $payload $r
|
||||
if (Test-Path $p) { Remove-Item $p -Force; Info "removed $r" }
|
||||
}
|
||||
|
||||
if ($VMName) {
|
||||
if (-not $Credential) {
|
||||
$Credential = Import-Clixml (Join-Path $env:USERPROFILE ".winui-test\vmcred-$($VMName -replace '[^a-zA-Z0-9]','_').xml")
|
||||
}
|
||||
$remoteDir = "C:\TestPayload\$env:COMPUTERNAME-$(Split-Path $RepoRoot -Leaf)"
|
||||
$s = New-PSSession -VMName $VMName -Credential $Credential
|
||||
try {
|
||||
$vmErrors = @()
|
||||
Invoke-Command -Session $s -ScriptBlock { New-Item -ItemType Directory -Force C:\WinUICoverage | Out-Null }
|
||||
# Copies can come from different originals (e.g. an app folder with an older staged DLL),
|
||||
# so restore each distinct original to exactly the copies it was backed up from.
|
||||
foreach ($g in ($state.copies | Group-Object originalHash)) {
|
||||
$tmp = "C:\WinUICoverage\restore-$($g.Name.Substring(0,12))-$($state.module)"
|
||||
Copy-Item $g.Group[0].backup $tmp -ToSession $s -Force
|
||||
Invoke-Command -Session $s -ScriptBlock {
|
||||
param($dir, $tmp, [string[]]$rels, $hash)
|
||||
if (-not (Test-Path $dir)) { return "remote payload $dir not found; skipped" }
|
||||
foreach ($r in $rels) { $d = Join-Path $dir $r; if (Test-Path (Split-Path $d)) { Copy-Item $tmp $d -Force } }
|
||||
Remove-Item $tmp -Force
|
||||
$bad = @($rels | Where-Object { (Test-Path (Join-Path $dir $_)) -and (Get-FileHash (Join-Path $dir $_)).Hash -ne $hash })
|
||||
if ($bad) { "WARNING: hash mismatch on VM for $($bad -join ', ')" } else { "VM restored $($rels.Count) copy(ies) of original $($hash.Substring(0,8))" }
|
||||
} -ArgumentList $remoteDir, $tmp, @($g.Group.relativePath), $g.Name | ForEach-Object {
|
||||
Info $_
|
||||
if ("$_" -like 'WARNING:*') { $vmErrors += "$_" }
|
||||
}
|
||||
}
|
||||
Invoke-Command -Session $s -ScriptBlock {
|
||||
param($dir, [string[]]$rts) foreach ($r in $rts) { Remove-Item (Join-Path $dir $r) -Force -EA SilentlyContinue }
|
||||
} -ArgumentList $remoteDir, @($state.runtimesAdded)
|
||||
if ($vmErrors) {
|
||||
throw "VM restore incomplete ($($vmErrors -join '; ')). instrument-state.json was kept; run Step4-Restore-Payload.ps1 again."
|
||||
}
|
||||
} finally { Remove-PSSession $s }
|
||||
}
|
||||
Remove-Item $statePath -Force
|
||||
Info 'done'
|
||||
@@ -0,0 +1,181 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Step 5 - Turn the downloaded .coverage file(s) into a component-scoped coverage report.
|
||||
|
||||
.DESCRIPTION
|
||||
Uses the scripts in .\lib:
|
||||
* dotnet-coverage merge -f cobertura -> merged.cobertura.xml
|
||||
* lib\Split-CoverageByModule.ps1 -> per-module, (file,line)-deduplicated numbers
|
||||
(same correction that makes numbers match ADO)
|
||||
* lib\Build-CoverageTreeReport.ps1 -> single-file HTML tree for the component
|
||||
* lib\Get-FunctionCoverage.ps1 -> per-function coverage (filtered to the component)
|
||||
and adds:
|
||||
* component filtering (-ComponentPathRegex, matched against the source path),
|
||||
* an optional before/after comparison against a baseline corrected-per-file.csv (e.g. from a
|
||||
CI coverage build), matched on the repo-relative path so checkout roots, build-agent
|
||||
prefixes (C:\__w\1\s\...) and flavors (x86chk/amd64chk) do not matter,
|
||||
* coverage-summary.md + component-per-file.csv + component-functions.csv.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string[]]$CoverageFiles,
|
||||
[Parameter(Mandatory)][string]$OutputDir,
|
||||
[string]$Module = 'Microsoft.UI.Xaml.Controls',
|
||||
# Regex on the source file path selecting the component's files.
|
||||
[string]$ComponentPathRegex = '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar',
|
||||
[string]$ComponentName = 'TitleBar',
|
||||
[string]$BaselinePerFileCsv = '',
|
||||
[string]$BaselineLabel = 'Baseline',
|
||||
[string]$RunLabel = 'This run',
|
||||
# Root of the checkout that produced the coverage; used to make its paths repo-relative.
|
||||
[string]$RepoRoot = '',
|
||||
# Root of the checkout (or build agent) that produced -BaselinePerFileCsv, when it is not a
|
||||
# standard build-agent root such as C:\__w\1\s\ (e.g. D:\src\other-checkout).
|
||||
[string]$BaselineRepoRoot = '',
|
||||
[switch]$SkipFunctionCoverage
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
function Info($m) { Write-Host "[report] $m" }
|
||||
$lib = Join-Path $PSScriptRoot 'lib'
|
||||
|
||||
$dc = (Get-Command dotnet-coverage -EA SilentlyContinue).Source
|
||||
if (-not $dc) { throw 'dotnet-coverage not found: dotnet tool install --global dotnet-coverage' }
|
||||
New-Item -ItemType Directory -Force $OutputDir | Out-Null
|
||||
$OutputDir = (Resolve-Path $OutputDir).Path
|
||||
|
||||
# ---- merge -> cobertura -------------------------------------------------------------------------
|
||||
$cob = Join-Path $OutputDir 'merged.cobertura.xml'
|
||||
Info "merging $($CoverageFiles.Count) file(s) -> $cob"
|
||||
& $dc merge @CoverageFiles -f cobertura -o $cob --nologo 2>&1 | Select-Object -Last 3 | ForEach-Object { Info " $_" }
|
||||
if (-not (Test-Path $cob)) { throw 'merge produced no cobertura file' }
|
||||
|
||||
# ---- per-module dedup (verified to match ADO's coverage tab) -------------------------------------
|
||||
$perModule = Join-Path $OutputDir 'PerModule'
|
||||
& (Join-Path $lib 'Split-CoverageByModule.ps1') -InputCobertura $cob -OutputDir $perModule -Modules $Module | Out-Null
|
||||
$modDir = Get-ChildItem $perModule -Directory | Where-Object { $_.Name -ieq $Module } | Select-Object -First 1
|
||||
if (-not $modDir) { throw "Module $Module not found in coverage (modules: $((Get-ChildItem $perModule -Directory).Name -join ', '))" }
|
||||
$modCsv = Join-Path $modDir.FullName 'corrected-per-file.csv'
|
||||
$modSummary = Get-Content (Join-Path $modDir.FullName 'corrected-summary.json') -Raw | ConvertFrom-Json
|
||||
|
||||
function Get-RootKey([string]$r) { if ($r) { ($r.ToLowerInvariant() -replace '/', '\').TrimEnd('\') + '\' } else { '' } }
|
||||
$rootKeys = @((Get-RootKey $RepoRoot), (Get-RootKey $BaselineRepoRoot)) | Where-Object { $_ }
|
||||
function Get-RepoKey([string]$p) {
|
||||
# repo-relative key: strip a known checkout root (this run's -RepoRoot or -BaselineRepoRoot),
|
||||
# else a build-agent root (e.g. a CI baseline: C:\__w\1\s\), else everything up to the first
|
||||
# distinctive top-level repo folder (controls, dxaml, BuildOutput) and only then the generic
|
||||
# ones (src, test); then normalize build flavor dirs.
|
||||
$k = $p.ToLowerInvariant() -replace '/', '\'
|
||||
$root = $rootKeys | Where-Object { $k.StartsWith($_) } | Select-Object -First 1
|
||||
if ($root) { $k = $k.Substring($root.Length) }
|
||||
elseif ($k -match '^[a-z]:\\+(__w|a)\\+\d+\\+s\\+') { $k = $k.Substring($Matches[0].Length) }
|
||||
elseif ($k -match '\\(controls|dxaml|buildoutput)\\') { $k = $k -replace '^.*?\\(?=(controls|dxaml|buildoutput)\\)', '' }
|
||||
else { $k = $k -replace '^.*?\\(?=(src|test)\\)', '' }
|
||||
$k -replace '\\obj\\(x86|amd64|arm64)(chk|fre)\\', '\obj\<flavor>\'
|
||||
}
|
||||
|
||||
$rows = Import-Csv $modCsv | Where-Object { $_.File -match $ComponentPathRegex }
|
||||
if (-not $rows) { throw "No files matched -ComponentPathRegex '$ComponentPathRegex' in $Module coverage." }
|
||||
$base = @{}
|
||||
if ($BaselinePerFileCsv) {
|
||||
Import-Csv $BaselinePerFileCsv | Where-Object { $_.File -match $ComponentPathRegex } | ForEach-Object { $base[(Get-RepoKey $_.File)] = $_ }
|
||||
}
|
||||
$comp = foreach ($r in $rows) {
|
||||
$k = Get-RepoKey $r.File
|
||||
$b = $base[$k]
|
||||
[pscustomobject]@{
|
||||
File = $k
|
||||
CoveredLines = [int]$r.CoveredLines; TotalLines = [int]$r.TotalLines
|
||||
LineCoverage = if ([int]$r.TotalLines) { [math]::Round(100.0 * $r.CoveredLines / $r.TotalLines, 2) } else { 0 }
|
||||
BaselineCovered = if ($b) { [int]$b.CoveredLines } else { $null }
|
||||
BaselineTotal = if ($b) { [int]$b.TotalLines } else { $null }
|
||||
BaselineCoverage = if ($b -and [int]$b.TotalLines) { [math]::Round(100.0 * $b.CoveredLines / $b.TotalLines, 2) } else { $null }
|
||||
SourcePath = $r.File
|
||||
}
|
||||
}
|
||||
$comp = $comp | Sort-Object File
|
||||
$baselineWarning = ''
|
||||
if ($BaselinePerFileCsv) {
|
||||
$matched = @($comp | Where-Object { $null -ne $_.BaselineTotal }).Count
|
||||
if ($matched -eq 0) {
|
||||
$baselineWarning = "The baseline matched none of the $(@($comp).Count) component files, so every baseline value is n/a. If the baseline came from a different checkout, pass its root with -BaselineRepoRoot."
|
||||
} elseif ($matched -lt @($comp).Count / 2) {
|
||||
$baselineWarning = "The baseline matched only $matched of $(@($comp).Count) component files. If it came from a different checkout, pass its root with -BaselineRepoRoot."
|
||||
}
|
||||
if ($baselineWarning) { Write-Warning $baselineWarning }
|
||||
}
|
||||
$comp | Export-Csv (Join-Path $OutputDir 'component-per-file.csv') -NoTypeInformation
|
||||
$tot = ($comp | Measure-Object TotalLines -Sum).Sum
|
||||
$cov = ($comp | Measure-Object CoveredLines -Sum).Sum
|
||||
$pct = if ($tot) { [math]::Round(100.0 * $cov / $tot, 2) } else { 0 }
|
||||
|
||||
# HTML tree for the component (lib\Build-CoverageTreeReport.ps1; it needs File,CoveredLines,TotalLines)
|
||||
$treeCsv = Join-Path $OutputDir 'component-tree-input.csv'
|
||||
$comp | Select-Object @{n='File';e={$_.SourcePath}}, CoveredLines, TotalLines, LineCoverage | Export-Csv $treeCsv -NoTypeInformation
|
||||
& (Join-Path $lib 'Build-CoverageTreeReport.ps1') -InputCsv $treeCsv -OutputDir (Join-Path $OutputDir 'TreeReport') `
|
||||
-Title "$ComponentName coverage ($RunLabel)" -ModuleNames "$Module.dll" | Out-Null
|
||||
|
||||
# ---- function coverage (optional) -----------------------------------------------------------------
|
||||
$funcRows = @()
|
||||
if (-not $SkipFunctionCoverage) {
|
||||
$fcDir = Join-Path $OutputDir 'FunctionCoverage'
|
||||
try {
|
||||
# Pre-merge here and call the reused script with -SkipMerge: its own merge passes the inputs
|
||||
# via "@inputFiles" after an if-expression, which unwraps a SINGLE file to a scalar string and
|
||||
# splats nothing (empty <results/>). The pipeline always had 20 slices, so it never hit this.
|
||||
$native = Join-Path $fcDir 'NativeMerge\merged-native.xml'
|
||||
New-Item -ItemType Directory -Force (Split-Path $native) | Out-Null
|
||||
& $dc merge @CoverageFiles -f xml -o $native --nologo 2>&1 | Select-Object -Last 1 | ForEach-Object { Info " $_" }
|
||||
& (Join-Path $lib 'Get-FunctionCoverage.ps1') -CoverageFiles $CoverageFiles -OutputDir $fcDir -ExistingPerFileCsv $modCsv -SkipMerge | Out-Null
|
||||
$fcCsv = Join-Path $fcDir 'corrected-per-function.csv'
|
||||
if (Test-Path $fcCsv) {
|
||||
$funcRows = Import-Csv $fcCsv | Where-Object { $_.File -match $ComponentPathRegex } |
|
||||
Sort-Object File, @{e={[double]$_.LineCoveragePct}}
|
||||
$funcRows | Export-Csv (Join-Path $OutputDir 'component-functions.csv') -NoTypeInformation
|
||||
}
|
||||
} catch { Write-Warning "function coverage failed: $_" }
|
||||
}
|
||||
|
||||
# ---- summary markdown -----------------------------------------------------------------------------
|
||||
$md = New-Object Text.StringBuilder
|
||||
[void]$md.AppendLine("# $ComponentName code coverage - $RunLabel")
|
||||
[void]$md.AppendLine()
|
||||
[void]$md.AppendLine("Module: ``$Module.dll`` | Coverage file(s): $($CoverageFiles.Count) | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm')")
|
||||
[void]$md.AppendLine()
|
||||
[void]$md.AppendLine("**$ComponentName total: $cov / $tot lines = $pct %** (whole module in this run: $($modSummary.CoveredLines) / $($modSummary.TotalLines) = $($modSummary.LineCoverage) %)")
|
||||
if ($BaselinePerFileCsv) {
|
||||
$bt = ($comp | Where-Object { $null -ne $_.BaselineTotal } | Measure-Object BaselineTotal -Sum).Sum
|
||||
$bc = ($comp | Where-Object { $null -ne $_.BaselineTotal } | Measure-Object BaselineCovered -Sum).Sum
|
||||
if ($bt) { [void]$md.AppendLine("`n$BaselineLabel for the same files: $bc / $bt = $([math]::Round(100.0*$bc/$bt,2)) %") }
|
||||
if ($baselineWarning) { [void]$md.AppendLine("`n> **Warning:** $baselineWarning") }
|
||||
}
|
||||
[void]$md.AppendLine()
|
||||
[void]$md.AppendLine("| File | Covered / Total | $RunLabel % | $BaselineLabel % |")
|
||||
[void]$md.AppendLine('|---|---|---|---|')
|
||||
foreach ($c in $comp) {
|
||||
$bp = if ($null -ne $c.BaselineCoverage) { "$($c.BaselineCoverage) % ($($c.BaselineCovered)/$($c.BaselineTotal))" } else { 'n/a' }
|
||||
[void]$md.AppendLine("| ``$($c.File)`` | $($c.CoveredLines) / $($c.TotalLines) | **$($c.LineCoverage) %** | $bp |")
|
||||
}
|
||||
if ($funcRows) {
|
||||
# Hand-written .cpp files of the component (works for controls\dev and dxaml components alike).
|
||||
$hand = $funcRows | Where-Object { $_.File -match '\.cpp$' -and $_.File -notmatch '\\generated\\|\\winrtgeneratedclasses\\|\.g\.cpp$' }
|
||||
$unc = @($hand | Where-Object { [int]$_.CoveredLines -eq 0 })
|
||||
$part = @($hand | Where-Object { [int]$_.CoveredLines -gt 0 -and [int]$_.CoveredLines -lt [int]$_.TotalLines })
|
||||
[void]$md.AppendLine()
|
||||
[void]$md.AppendLine("## Functions in $ComponentName .cpp files")
|
||||
[void]$md.AppendLine("$(@($hand).Count) functions: $(@($hand).Count - $unc.Count - $part.Count) fully covered, $($part.Count) partially, $($unc.Count) not covered.")
|
||||
if ($unc) {
|
||||
[void]$md.AppendLine("`n**Not covered:**")
|
||||
$unc | ForEach-Object { [void]$md.AppendLine("- ``$($_.Function)`` ($(Split-Path $_.File -Leaf), $($_.TotalLines) lines)") }
|
||||
}
|
||||
if ($part) {
|
||||
[void]$md.AppendLine("`n**Partially covered (lowest first):**")
|
||||
$part | Select-Object -First 25 | ForEach-Object { [void]$md.AppendLine("- ``$($_.Function)`` $($_.CoveredLines)/$($_.TotalLines) ($($_.LineCoveragePct) %)") }
|
||||
}
|
||||
}
|
||||
[void]$md.AppendLine()
|
||||
[void]$md.AppendLine('Files: `component-per-file.csv`, `component-functions.csv`, `TreeReport\coverage-tree.html`, `PerModule\`, `merged.cobertura.xml`.')
|
||||
$md.ToString() | Set-Content (Join-Path $OutputDir 'coverage-summary.md') -Encoding UTF8
|
||||
Info "$ComponentName : $cov / $tot = $pct % -> $(Join-Path $OutputDir 'coverage-summary.md')"
|
||||
[pscustomobject]@{ component = $ComponentName; coveredLines = $cov; totalLines = $tot; lineCoveragePct = $pct
|
||||
summary = (Join-Path $OutputDir 'coverage-summary.md'); perFile = (Join-Path $OutputDir 'component-per-file.csv')
|
||||
baselineWarning = $baselineWarning }
|
||||
@@ -0,0 +1,455 @@
|
||||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT License.
|
||||
#
|
||||
# Build-CoverageTreeReport.ps1
|
||||
#
|
||||
# Renders a SINGLE, self-contained HTML file with a collapsible folder-tree view of code
|
||||
# coverage - as opposed to reportgenerator's default `Html` report type, which emits one .html
|
||||
# page PER SOURCE FILE/CLASS (thousands of files for a full WinUI build). This script performs
|
||||
# no new coverage computation of its own - it is a pure rendering step on top of data already
|
||||
# produced (and already verified to match Azure DevOps's own Code Coverage tab exactly) by
|
||||
# Repair-CoverageDuplicates.ps1: `corrected-per-file.csv` and `corrected-summary.json`.
|
||||
#
|
||||
# OUTPUT: exactly one .html file. No external .css/.js/.svg assets, no per-file pages, no CDN
|
||||
# dependencies - everything (styles, script, and the coverage data itself) is embedded inline,
|
||||
# so the file can be opened directly from disk (or emailed, attached to a work item, etc.) with
|
||||
# no network access and no companion folder.
|
||||
#
|
||||
# TREE UI: folders are collapsed by default at every level (only top-level root folders are
|
||||
# visible on first load); clicking a folder row expands/collapses just that folder's immediate
|
||||
# children (subfolders and files) one level at a time. Each folder shows a coverage percentage
|
||||
# that is the SUM of covered/total lines across every file nested anywhere beneath it (computed
|
||||
# bottom-up); each file shows its own exact percentage. Both use the same 3-tier ADO-style
|
||||
# color scale: green >= 75%, yellow 40-75%, red < 40%.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Path to corrected-per-file.csv, as produced by Repair-CoverageDuplicates.ps1.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InputCsv,
|
||||
|
||||
# Path to corrected-summary.json (for the overall KPI header). Optional - if omitted or
|
||||
# missing, the KPI header is computed from the per-file rows instead.
|
||||
[string]$InputSummaryJson = "",
|
||||
|
||||
# Folder to write the single HTML report into. Created if it doesn't exist. Kept separate
|
||||
# from reportgenerator's own (multi-file) `html\` output folder by convention.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDir,
|
||||
|
||||
# Report title shown at the top of the page.
|
||||
[string]$Title = "Code Coverage Report",
|
||||
|
||||
# Regex matched against the START of each file path and stripped before display (the
|
||||
# "boilerplate" build-agent prefix). Default matches the Azure Pipelines agent convention
|
||||
# (e.g. C:\__w\1\s\, D:\a\3\s\ etc. - a drive letter, "__w" or "a", a numeric leg id, then
|
||||
# "s\") case-insensitively, on either slash direction. If a given path doesn't match, it is
|
||||
# left completely unchanged - this is a display-only trim, never a hard requirement.
|
||||
[string]$StripPathPrefix = '^[A-Za-z]:[\\/]+(__w|a)[\\/]+\d+[\\/]+s[\\/]+',
|
||||
|
||||
# Output file name.
|
||||
[string]$OutputFileName = 'coverage-tree.html',
|
||||
|
||||
# Name(s) of the instrumented product DLL(s) this coverage measures (e.g.
|
||||
# "Microsoft.ui.xaml.dll"). Shown at the top of the report next to the title. If not
|
||||
# supplied, falls back to the "InstrumentedModules" field in -InputSummaryJson (as written
|
||||
# by Repair-CoverageDuplicates.ps1's -ModuleNames parameter); if that's also unavailable,
|
||||
# the report simply omits the module line rather than guessing.
|
||||
[string[]]$ModuleNames = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $InputCsv)) {
|
||||
throw "Input CSV not found: $InputCsv"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
|
||||
Write-Host "Building single-file folder-tree coverage report from $InputCsv ..."
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
$rows = Import-Csv $InputCsv
|
||||
if ($rows.Count -eq 0) {
|
||||
throw "No rows found in $InputCsv - nothing to render."
|
||||
}
|
||||
|
||||
$stripRegex = $null
|
||||
if ($StripPathPrefix) {
|
||||
try { $stripRegex = New-Object System.Text.RegularExpressions.Regex($StripPathPrefix, 'IgnoreCase') }
|
||||
catch { Write-Host "WARNING: -StripPathPrefix is not a valid regex ('$StripPathPrefix'); paths will be shown unmodified. $($_.Exception.Message)" -ForegroundColor Yellow }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Build an in-memory folder tree. Each node: @{ Name; IsFile; Covered; Total; Children (ordered
|
||||
# dictionary, folders/files keyed by name) }. Folder Covered/Total are computed bottom-up after
|
||||
# every file has been inserted.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
function New-TreeNode {
|
||||
param([string]$Name, [bool]$IsFile)
|
||||
[pscustomobject]@{
|
||||
Name = $Name
|
||||
IsFile = $IsFile
|
||||
Covered = 0
|
||||
Total = 0
|
||||
Children = [ordered]@{}
|
||||
}
|
||||
}
|
||||
|
||||
$root = New-TreeNode -Name '' -IsFile $false
|
||||
$strippedCount = 0
|
||||
$totalCount = 0
|
||||
|
||||
foreach ($row in $rows) {
|
||||
$totalCount++
|
||||
$path = $row.File
|
||||
if ($stripRegex -and $stripRegex.IsMatch($path)) {
|
||||
$path = $stripRegex.Replace($path, '', 1)
|
||||
$strippedCount++
|
||||
}
|
||||
$path = $path -replace '/', '\'
|
||||
$segments = $path.Split('\') | Where-Object { $_ -ne '' }
|
||||
if ($segments.Count -eq 0) { continue }
|
||||
|
||||
$covered = [int]$row.CoveredLines
|
||||
$total = [int]$row.TotalLines
|
||||
|
||||
$node = $root
|
||||
for ($i = 0; $i -lt $segments.Count; $i++) {
|
||||
$seg = $segments[$i]
|
||||
$isLeaf = ($i -eq $segments.Count - 1)
|
||||
if (-not $node.Children.Contains($seg)) {
|
||||
$node.Children[$seg] = New-TreeNode -Name $seg -IsFile $isLeaf
|
||||
}
|
||||
$node = $node.Children[$seg]
|
||||
}
|
||||
# $node is now the leaf (file) node.
|
||||
$node.Covered = $covered
|
||||
$node.Total = $total
|
||||
}
|
||||
|
||||
Write-Host ("Stripped the boilerplate prefix from {0}/{1} paths." -f $strippedCount, $totalCount)
|
||||
|
||||
# Bottom-up rollup: a folder's Covered/Total = sum of its children's (already-rolled-up) values.
|
||||
function Get-Rollup {
|
||||
param($Node)
|
||||
if ($Node.IsFile) { return }
|
||||
$c = 0; $t = 0
|
||||
foreach ($child in $Node.Children.Values) {
|
||||
Get-Rollup -Node $child
|
||||
$c += $child.Covered
|
||||
$t += $child.Total
|
||||
}
|
||||
$Node.Covered = $c
|
||||
$Node.Total = $t
|
||||
}
|
||||
Get-Rollup -Node $root
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Convert to a compact, JSON-serializable shape: folders sorted before files, then alphabetical.
|
||||
# Keys are kept short (n/t/c/f/k) since this blob is embedded directly in the HTML and a full
|
||||
# WinUI build can have several thousand files.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
function ConvertTo-JsonNode {
|
||||
param($Node)
|
||||
$pct = if ($Node.Total -gt 0) { [math]::Round(100.0 * $Node.Covered / $Node.Total, 2) } else { 0 }
|
||||
$obj = [ordered]@{
|
||||
n = $Node.Name
|
||||
c = $Node.Covered
|
||||
t = $Node.Total
|
||||
p = $pct
|
||||
}
|
||||
if ($Node.IsFile) {
|
||||
$obj['f'] = 1
|
||||
} else {
|
||||
$sortedChildren = $Node.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } }
|
||||
$obj['k'] = @($sortedChildren | ForEach-Object { ConvertTo-JsonNode -Node $_ })
|
||||
}
|
||||
return $obj
|
||||
}
|
||||
|
||||
$treeData = @($root.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } } | ForEach-Object { ConvertTo-JsonNode -Node $_ })
|
||||
$treeJson = $treeData | ConvertTo-Json -Depth 100 -Compress
|
||||
if ($treeData.Count -eq 1) {
|
||||
# ConvertTo-Json unwraps single-element arrays unless -AsArray is available (PS 6.1+); force array shape.
|
||||
$treeJson = "[$treeJson]"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Overall KPI header - prefer the already-computed summary (exact figures verified against ADO);
|
||||
# fall back to summing the per-file rows if the summary file wasn't supplied/found.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
$summary = $null
|
||||
if ($InputSummaryJson -and (Test-Path $InputSummaryJson)) {
|
||||
$summary = Get-Content $InputSummaryJson -Raw | ConvertFrom-Json
|
||||
}
|
||||
if (-not $summary) {
|
||||
$totalLines = ($rows | Measure-Object -Property TotalLines -Sum).Sum
|
||||
$coveredLines = ($rows | Measure-Object -Property CoveredLines -Sum).Sum
|
||||
$summary = [pscustomobject]@{
|
||||
Modules = $rows.Count
|
||||
TotalLines = $totalLines
|
||||
CoveredLines = $coveredLines
|
||||
LineCoverage = if ($totalLines -gt 0) { [math]::Round(100.0 * $coveredLines / $totalLines, 2) } else { 0 }
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve which DLL(s) this report is measuring: an explicit -ModuleNames wins; otherwise fall
|
||||
# back to the "InstrumentedModules" field already recorded in corrected-summary.json (when the
|
||||
# producer recorded one); if neither is available the
|
||||
# report simply omits the module line rather than guessing at a DLL name.
|
||||
$resolvedModuleNames = @($ModuleNames)
|
||||
if ($resolvedModuleNames.Count -eq 0 -and $summary.PSObject.Properties.Name -contains 'InstrumentedModules') {
|
||||
$resolvedModuleNames = @($summary.InstrumentedModules)
|
||||
}
|
||||
$moduleDisplay = if ($resolvedModuleNames -and $resolvedModuleNames.Count -gt 0) { ($resolvedModuleNames -join ', ') } else { '' }
|
||||
|
||||
# Make sure SUMMARY (embedded in the HTML for the KPI cards) reflects the resolved module list
|
||||
# regardless of which path it came from (explicit -ModuleNames vs. corrected-summary.json).
|
||||
if ($summary.PSObject.Properties.Name -contains 'InstrumentedModules') {
|
||||
$summary.InstrumentedModules = @($resolvedModuleNames)
|
||||
} else {
|
||||
$summary | Add-Member -NotePropertyName 'InstrumentedModules' -NotePropertyValue @($resolvedModuleNames)
|
||||
}
|
||||
|
||||
$summaryJson = $summary | ConvertTo-Json -Compress
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# HTML template. Single-quoted here-string (no PowerShell variable expansion) with placeholder
|
||||
# tokens swapped in afterward - this keeps every '$' in the embedded JavaScript (template
|
||||
# literals, etc.) completely literal and avoids any PowerShell-escaping gymnastics.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
$htmlTemplate = @'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>__TITLE__</title>
|
||||
<style>
|
||||
:root {
|
||||
--green: #2ea043; --green-bg: #e6f4ea;
|
||||
--yellow: #b08800; --yellow-bg: #fff8e1;
|
||||
--red: #cf222e; --red-bg: #fdecea;
|
||||
--border: #d0d7de; --muted: #57606a; --bg: #ffffff; --row-hover: #f6f8fa;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 24px; background: var(--bg); color: #1f2328; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px 0; }
|
||||
.subtitle { color: var(--muted); font-size: 13px; margin-bottom: 20px; }
|
||||
.kpis { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.kpi { border: 1px solid var(--border); border-radius: 8px; padding: 14px 18px; min-width: 140px; }
|
||||
.kpi .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.kpi .value { font-size: 26px; font-weight: 600; margin-top: 4px; }
|
||||
.legend { font-size: 12px; color: var(--muted); margin-bottom: 12px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.legend .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
|
||||
#search { margin-bottom: 12px; }
|
||||
#searchBox { width: 100%; max-width: 480px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; }
|
||||
.tree { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
ul.node-children { list-style: none; margin: 0; padding: 0; }
|
||||
ul.node-children.collapsed { display: none; }
|
||||
li.node { border-top: 1px solid var(--border); }
|
||||
li.node:first-child { border-top: none; }
|
||||
.row { display: flex; align-items: center; padding: 7px 10px; cursor: default; }
|
||||
.row.folder { cursor: pointer; }
|
||||
.row.folder:hover { background: var(--row-hover); }
|
||||
.caret { width: 14px; display: inline-block; text-align: center; color: var(--muted); font-size: 11px; user-select: none; flex: 0 0 auto; }
|
||||
.row.file .caret { visibility: hidden; }
|
||||
.name { flex: 1 1 auto; font-size: 13px; font-family: Consolas, "Cascadia Mono", monospace; overflow-wrap: anywhere; padding-right: 12px; }
|
||||
.row.folder .name { font-weight: 600; }
|
||||
.bar-wrap { flex: 0 0 140px; height: 8px; background: #eaeef2; border-radius: 4px; overflow: hidden; margin-right: 10px; }
|
||||
.bar-fill { height: 100%; }
|
||||
.pct { flex: 0 0 62px; text-align: right; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.lines { flex: 0 0 110px; text-align: right; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; padding-left: 10px; }
|
||||
.pct-hi, .bar-fill.hi { color: var(--green); }
|
||||
.bar-fill.hi { background: var(--green); }
|
||||
.pct-mid, .bar-fill.mid { color: var(--yellow); }
|
||||
.bar-fill.mid { background: var(--yellow); }
|
||||
.pct-lo, .bar-fill.lo { color: var(--red); }
|
||||
.bar-fill.lo { background: var(--red); }
|
||||
ul.node-children { padding-left: 22px; }
|
||||
.tree > ul.node-children { padding-left: 0; }
|
||||
.hidden-by-search { display: none !important; }
|
||||
.module-line { font-size: 13px; color: #1f2328; margin-bottom: 2px; }
|
||||
.module-line .module-badge { display: inline-block; background: #ddf4ff; color: #0969da; border: 1px solid #b6e3ff; border-radius: 12px; padding: 2px 10px; font-family: Consolas, "Cascadia Mono", monospace; font-size: 12px; margin-left: 6px; }
|
||||
footer { margin-top: 20px; font-size: 11px; color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>__TITLE__</h1>
|
||||
__MODULE_LINE__
|
||||
<div class="subtitle">Generated __GENERATED_AT__ · boilerplate path prefix stripped from every entry below</div>
|
||||
|
||||
<div class="kpis" id="kpis"></div>
|
||||
|
||||
<div class="legend">
|
||||
<span><span class="swatch" style="background:var(--green)"></span>≥ 75%</span>
|
||||
<span><span class="swatch" style="background:var(--yellow)"></span>40–75%</span>
|
||||
<span><span class="swatch" style="background:var(--red)"></span>< 40%</span>
|
||||
<span style="margin-left:auto">Click a folder row to expand/collapse it</span>
|
||||
</div>
|
||||
|
||||
<div id="search">
|
||||
<input id="searchBox" type="text" placeholder="Filter by file or folder name..." />
|
||||
</div>
|
||||
|
||||
<div class="tree" id="tree"></div>
|
||||
|
||||
<footer>Self-contained report - no external files. Coverage data is bug-fixed for a verified duplicate-line defect in the native (dotnet-coverage / Microsoft.CodeCoverage.Console) merge engine; these numbers match the Azure DevOps Code Coverage tab.</footer>
|
||||
|
||||
<script>
|
||||
const TREE_DATA = __TREE_JSON__;
|
||||
const SUMMARY = __SUMMARY_JSON__;
|
||||
|
||||
function pctClass(p) {
|
||||
if (p >= 75) return 'hi';
|
||||
if (p >= 40) return 'mid';
|
||||
return 'lo';
|
||||
}
|
||||
|
||||
function fmtInt(n) {
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function renderKpis() {
|
||||
const kpis = document.getElementById('kpis');
|
||||
const items = [
|
||||
['Line coverage', (SUMMARY.LineCoverage !== undefined ? SUMMARY.LineCoverage : 0) + '%'],
|
||||
['Covered lines', fmtInt(SUMMARY.CoveredLines || 0)],
|
||||
['Total lines', fmtInt(SUMMARY.TotalLines || 0)],
|
||||
['Files', fmtInt(SUMMARY.Modules || 0)]
|
||||
];
|
||||
items.forEach(([label, value]) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'kpi';
|
||||
const l = document.createElement('div'); l.className = 'label'; l.textContent = label;
|
||||
const v = document.createElement('div'); v.className = 'value'; v.textContent = value;
|
||||
div.appendChild(l); div.appendChild(v);
|
||||
kpis.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function buildRow(node) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row ' + (node.f ? 'file' : 'folder');
|
||||
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'caret';
|
||||
caret.textContent = node.f ? '' : '\u25B8';
|
||||
row.appendChild(caret);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'name';
|
||||
name.textContent = node.n;
|
||||
row.appendChild(name);
|
||||
|
||||
const barWrap = document.createElement('span');
|
||||
barWrap.className = 'bar-wrap';
|
||||
const barFill = document.createElement('span');
|
||||
barFill.className = 'bar-fill ' + pctClass(node.p);
|
||||
barFill.style.width = Math.min(node.p, 100) + '%';
|
||||
barWrap.appendChild(barFill);
|
||||
row.appendChild(barWrap);
|
||||
|
||||
const pct = document.createElement('span');
|
||||
pct.className = 'pct pct-' + pctClass(node.p);
|
||||
pct.textContent = node.p.toFixed(1) + '%';
|
||||
row.appendChild(pct);
|
||||
|
||||
const lines = document.createElement('span');
|
||||
lines.className = 'lines';
|
||||
lines.textContent = fmtInt(node.c) + ' / ' + fmtInt(node.t);
|
||||
row.appendChild(lines);
|
||||
|
||||
return { row, caret };
|
||||
}
|
||||
|
||||
function buildNodeLi(node) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'node';
|
||||
li.dataset.name = node.n.toLowerCase();
|
||||
|
||||
const { row, caret } = buildRow(node);
|
||||
li.appendChild(row);
|
||||
|
||||
if (!node.f && node.k && node.k.length) {
|
||||
const childUl = document.createElement('ul');
|
||||
childUl.className = 'node-children collapsed';
|
||||
node.k.forEach(child => childUl.appendChild(buildNodeLi(child)));
|
||||
li.appendChild(childUl);
|
||||
|
||||
row.addEventListener('click', () => {
|
||||
const collapsed = childUl.classList.toggle('collapsed');
|
||||
caret.textContent = collapsed ? '\u25B8' : '\u25BE';
|
||||
});
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
const container = document.getElementById('tree');
|
||||
const rootUl = document.createElement('ul');
|
||||
rootUl.className = 'node-children';
|
||||
TREE_DATA.forEach(node => rootUl.appendChild(buildNodeLi(node)));
|
||||
container.appendChild(rootUl);
|
||||
}
|
||||
|
||||
// Filter: matching folders/files stay visible (and their ancestor chain is force-expanded);
|
||||
// non-matching leaves are hidden. Clearing the box restores the normal collapsed tree.
|
||||
function applyFilter(term) {
|
||||
const allLis = document.querySelectorAll('#tree li.node');
|
||||
if (!term) {
|
||||
allLis.forEach(li => {
|
||||
li.classList.remove('hidden-by-search');
|
||||
const ul = li.querySelector(':scope > ul.node-children');
|
||||
if (ul) ul.classList.add('collapsed');
|
||||
const caret = li.querySelector(':scope > .row .caret');
|
||||
if (caret && !li.querySelector(':scope > .row.file')) caret.textContent = '\u25B8';
|
||||
});
|
||||
return;
|
||||
}
|
||||
const lower = term.toLowerCase();
|
||||
allLis.forEach(li => {
|
||||
const nameEl = li.querySelector(':scope > .row .name');
|
||||
const isMatch = nameEl && nameEl.textContent.toLowerCase().includes(lower);
|
||||
li.dataset.match = isMatch ? '1' : '0';
|
||||
});
|
||||
allLis.forEach(li => {
|
||||
const anyDescendantMatches = li.querySelector('li.node[data-match="1"]') !== null;
|
||||
const isSelfMatch = li.dataset.match === '1';
|
||||
const show = isSelfMatch || anyDescendantMatches;
|
||||
li.classList.toggle('hidden-by-search', !show);
|
||||
if (show) {
|
||||
const ul = li.querySelector(':scope > ul.node-children');
|
||||
if (ul && anyDescendantMatches) {
|
||||
ul.classList.remove('collapsed');
|
||||
const caret = li.querySelector(':scope > .row .caret');
|
||||
if (caret) caret.textContent = '\u25BE';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderKpis();
|
||||
renderTree();
|
||||
document.getElementById('searchBox').addEventListener('input', (e) => applyFilter(e.target.value.trim()));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'@
|
||||
|
||||
$outFile = Join-Path $OutputDir $OutputFileName
|
||||
$moduleLineHtml = if ($moduleDisplay) {
|
||||
'<div class="module-line">Code coverage measured for: <span class="module-badge">' + [System.Net.WebUtility]::HtmlEncode($moduleDisplay) + '</span></div>'
|
||||
} else {
|
||||
''
|
||||
}
|
||||
$html = $htmlTemplate.Replace('__TITLE__', [System.Net.WebUtility]::HtmlEncode($Title))
|
||||
$html = $html.Replace('__MODULE_LINE__', $moduleLineHtml)
|
||||
$html = $html.Replace('__GENERATED_AT__', (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'))
|
||||
$html = $html.Replace('__TREE_JSON__', $treeJson)
|
||||
$html = $html.Replace('__SUMMARY_JSON__', $summaryJson)
|
||||
|
||||
Set-Content -Path $outFile -Value $html -Encoding UTF8
|
||||
|
||||
Write-Host ("`nWrote single-file tree report in {0:N1}s -> {1}" -f $sw.Elapsed.TotalSeconds, $outFile)
|
||||
if ($moduleDisplay) { Write-Host (" Module(s): {0}" -f $moduleDisplay) }
|
||||
Write-Host (" Overall: {0}% ({1} / {2} lines across {3} files)" -f $summary.LineCoverage, $summary.CoveredLines, $summary.TotalLines, $summary.Modules)
|
||||
+540
@@ -0,0 +1,540 @@
|
||||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT License.
|
||||
#
|
||||
# Build-FunctionCoverageTreeReport.ps1
|
||||
#
|
||||
# Renders a SINGLE, self-contained HTML file that extends Build-CoverageTreeReport.ps1's
|
||||
# collapsible folder/file tree with one more level: expand a FILE row to reveal the individual
|
||||
# C++ functions defined in it (from Get-FunctionCoverage.ps1's corrected-per-function.csv), each
|
||||
# with its own covered/total lines and %.
|
||||
#
|
||||
# The folder/file levels (and their rollup percentages/coloring) are built from
|
||||
# corrected-per-file.csv exactly like Build-CoverageTreeReport.ps1 - i.e. they still show the
|
||||
# Azure-DevOps-verified, authoritative file/folder totals. The function level underneath a file
|
||||
# is a separate, ADDITIONAL data source (corrected-per-function.csv) laid on top purely for
|
||||
# drill-down - it is NOT summed into the file/folder rollup, and deliberately so: in C++, many
|
||||
# distinct compiled functions (template instantiations, inlined helpers, macro expansions) can
|
||||
# legitimately share the same physical source line, so summing per-function totals does not
|
||||
# reproduce a file's true unique-line total (see Get-FunctionCoverage.ps1's header comment for
|
||||
# the full explanation and a worked example). This report keeps both views honest by computing
|
||||
# them independently and never mixing one into the other.
|
||||
#
|
||||
# PERFORMANCE: a full WinUI build can have 250k+ functions. Folder/file rows are built eagerly
|
||||
# (same as Build-CoverageTreeReport.ps1 - a few thousand nodes, cheap), but each file's function
|
||||
# list is only built into the DOM the first time that file row is expanded (lazy), so the report
|
||||
# stays fast to load even though the underlying embedded JSON contains every function.
|
||||
#
|
||||
# OUTPUT: exactly one .html file - no external assets, works fully offline.
|
||||
#
|
||||
# EXAMPLE
|
||||
# .\Build-FunctionCoverageTreeReport.ps1 `
|
||||
# -InputPerFileCsv CorrectedReport\corrected-per-file.csv `
|
||||
# -InputPerFunctionCsv FunctionCoverage\corrected-per-function.csv `
|
||||
# -InputSummaryJson CorrectedReport\corrected-summary.json `
|
||||
# -OutputDir FunctionCoverage\TreeReport `
|
||||
# -ModuleNames Microsoft.ui.xaml.dll
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Path to corrected-per-file.csv, as produced by Repair-CoverageDuplicates.ps1 /
|
||||
# Split-CoverageByModule.ps1. Drives the folder/file levels of the tree (authoritative
|
||||
# totals - matches the Azure DevOps Code Coverage tab).
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InputPerFileCsv,
|
||||
|
||||
# Path to corrected-per-function.csv, as produced by Get-FunctionCoverage.ps1. Drives the
|
||||
# function-level drill-down nested under each file row.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InputPerFunctionCsv,
|
||||
|
||||
# Path to corrected-summary.json (for the overall KPI header). Optional - if omitted or
|
||||
# missing, the KPI header is computed from the per-file rows instead.
|
||||
[string]$InputSummaryJson = "",
|
||||
|
||||
# Folder to write the single HTML report into. Created if it doesn't exist.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDir,
|
||||
|
||||
# Report title shown at the top of the page.
|
||||
[string]$Title = "Function Coverage Report",
|
||||
|
||||
# Regex matched against the START of each file path and stripped before display - same
|
||||
# convention/default as Build-CoverageTreeReport.ps1. Applied identically to both input CSVs
|
||||
# so file paths line up between them.
|
||||
[string]$StripPathPrefix = '^[A-Za-z]:[\\/]+(__w|a)[\\/]+\d+[\\/]+s[\\/]+',
|
||||
|
||||
# Output file name.
|
||||
[string]$OutputFileName = 'function-coverage-tree.html',
|
||||
|
||||
# Name(s) of the instrumented product DLL(s) this coverage measures. Same semantics as
|
||||
# Build-CoverageTreeReport.ps1's -ModuleNames.
|
||||
[string[]]$ModuleNames = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $InputPerFileCsv)) { throw "Input per-file CSV not found: $InputPerFileCsv" }
|
||||
if (-not (Test-Path $InputPerFunctionCsv)) { throw "Input per-function CSV not found: $InputPerFunctionCsv" }
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
|
||||
Write-Host "Building single-file function-drill-down tree report..."
|
||||
Write-Host " Per-file (authoritative): $InputPerFileCsv"
|
||||
Write-Host " Per-function (drill-down): $InputPerFunctionCsv"
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
$fileRows = Import-Csv $InputPerFileCsv
|
||||
if ($fileRows.Count -eq 0) { throw "No rows found in $InputPerFileCsv - nothing to render." }
|
||||
$functionRows = Import-Csv $InputPerFunctionCsv
|
||||
Write-Host ("Loaded {0:N0} file row(s) and {1:N0} function row(s)." -f $fileRows.Count, $functionRows.Count)
|
||||
|
||||
$stripRegex = $null
|
||||
if ($StripPathPrefix) {
|
||||
try { $stripRegex = New-Object System.Text.RegularExpressions.Regex($StripPathPrefix, 'IgnoreCase') }
|
||||
catch { Write-Host "WARNING: -StripPathPrefix is not a valid regex ('$StripPathPrefix'); paths will be shown unmodified. $($_.Exception.Message)" -ForegroundColor Yellow }
|
||||
}
|
||||
|
||||
function Get-NormalizedPath {
|
||||
param([string]$Path)
|
||||
$p = $Path
|
||||
if ($stripRegex -and $stripRegex.IsMatch($p)) { $p = $stripRegex.Replace($p, '', 1) }
|
||||
return ($p -replace '/', '\')
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# 1. Group corrected-per-function.csv by (normalized) file path, sorting each file's own
|
||||
# function list worst-covered-first (most useful for spotting gaps) - ties broken by name.
|
||||
# Kept as plain arrays of compact records; NOT summed into anything - purely for drill-down.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
Write-Host "Grouping functions by file..."
|
||||
$functionsByFile = @{}
|
||||
foreach ($fr in $functionRows) {
|
||||
$key = Get-NormalizedPath -Path $fr.File
|
||||
if (-not $functionsByFile.ContainsKey($key)) { $functionsByFile[$key] = New-Object System.Collections.Generic.List[object] }
|
||||
$total = [int]$fr.TotalLines
|
||||
$covered = [int]$fr.CoveredLines
|
||||
$pct = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 }
|
||||
$displayName = if ($fr.Type -and $fr.Type -ne '') { "$($fr.Type)::$($fr.Function)" } else { $fr.Function }
|
||||
$tooltip = "$($fr.Namespace)::$($fr.Type)::$($fr.Function)" -replace '^::+', ''
|
||||
[void]$functionsByFile[$key].Add([pscustomobject]@{
|
||||
n = $displayName
|
||||
q = $tooltip
|
||||
c = $covered
|
||||
t = $total
|
||||
p = $pct
|
||||
})
|
||||
}
|
||||
foreach ($k in @($functionsByFile.Keys)) {
|
||||
$functionsByFile[$k] = @($functionsByFile[$k] | Sort-Object p, n)
|
||||
}
|
||||
Write-Host ("Grouped functions for {0:N0} distinct file(s)." -f $functionsByFile.Count)
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# 2. Build the folder/file tree from corrected-per-file.csv - identical logic/rollup to
|
||||
# Build-CoverageTreeReport.ps1 - then attach each file node's function list (if any) as a
|
||||
# separate 'fk' (function-kids) property, kept apart from folder 'k' children.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
function New-TreeNode {
|
||||
param([string]$Name, [bool]$IsFile)
|
||||
[pscustomobject]@{
|
||||
Name = $Name
|
||||
IsFile = $IsFile
|
||||
Covered = 0
|
||||
Total = 0
|
||||
Functions = $null
|
||||
Children = [ordered]@{}
|
||||
}
|
||||
}
|
||||
|
||||
$root = New-TreeNode -Name '' -IsFile $false
|
||||
$strippedCount = 0
|
||||
$totalCount = 0
|
||||
|
||||
foreach ($row in $fileRows) {
|
||||
$totalCount++
|
||||
$rawPath = $row.File
|
||||
$path = Get-NormalizedPath -Path $rawPath
|
||||
if ($path -ne $rawPath) { $strippedCount++ }
|
||||
$segments = $path.Split('\') | Where-Object { $_ -ne '' }
|
||||
if ($segments.Count -eq 0) { continue }
|
||||
|
||||
$covered = [int]$row.CoveredLines
|
||||
$total = [int]$row.TotalLines
|
||||
|
||||
$node = $root
|
||||
for ($i = 0; $i -lt $segments.Count; $i++) {
|
||||
$seg = $segments[$i]
|
||||
$isLeaf = ($i -eq $segments.Count - 1)
|
||||
if (-not $node.Children.Contains($seg)) {
|
||||
$node.Children[$seg] = New-TreeNode -Name $seg -IsFile $isLeaf
|
||||
}
|
||||
$node = $node.Children[$seg]
|
||||
}
|
||||
$node.Covered = $covered
|
||||
$node.Total = $total
|
||||
$fns = $null
|
||||
if ($functionsByFile.ContainsKey($path)) { $fns = $functionsByFile[$path] }
|
||||
$node.Functions = $fns
|
||||
}
|
||||
|
||||
Write-Host ("Stripped the boilerplate prefix from {0}/{1} file paths." -f $strippedCount, $totalCount)
|
||||
|
||||
function Get-Rollup {
|
||||
param($Node)
|
||||
if ($Node.IsFile) { return }
|
||||
$c = 0; $t = 0
|
||||
foreach ($child in $Node.Children.Values) {
|
||||
Get-Rollup -Node $child
|
||||
$c += $child.Covered
|
||||
$t += $child.Total
|
||||
}
|
||||
$Node.Covered = $c
|
||||
$Node.Total = $t
|
||||
}
|
||||
Get-Rollup -Node $root
|
||||
|
||||
function ConvertTo-JsonNode {
|
||||
param($Node)
|
||||
$pct = if ($Node.Total -gt 0) { [math]::Round(100.0 * $Node.Covered / $Node.Total, 2) } else { 0 }
|
||||
$obj = [ordered]@{
|
||||
n = $Node.Name
|
||||
c = $Node.Covered
|
||||
t = $Node.Total
|
||||
p = $pct
|
||||
}
|
||||
if ($Node.IsFile) {
|
||||
$obj['f'] = 1
|
||||
if ($Node.Functions -and $Node.Functions.Count -gt 0) { $obj['fk'] = $Node.Functions }
|
||||
} else {
|
||||
$sortedChildren = $Node.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } }
|
||||
$obj['k'] = @($sortedChildren | ForEach-Object { ConvertTo-JsonNode -Node $_ })
|
||||
}
|
||||
return $obj
|
||||
}
|
||||
|
||||
$treeData = @($root.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } } | ForEach-Object { ConvertTo-JsonNode -Node $_ })
|
||||
$treeJson = $treeData | ConvertTo-Json -Depth 100 -Compress
|
||||
if ($treeData.Count -eq 1) { $treeJson = "[$treeJson]" }
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# 3. KPI header - file-level KPIs from the (already-verified) summary/per-file rows, plus new
|
||||
# function-level KPIs computed straight from corrected-per-function.csv.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
$summary = $null
|
||||
if ($InputSummaryJson -and (Test-Path $InputSummaryJson)) {
|
||||
$summary = Get-Content $InputSummaryJson -Raw | ConvertFrom-Json
|
||||
}
|
||||
if (-not $summary) {
|
||||
$totalLines = ($fileRows | Measure-Object -Property TotalLines -Sum).Sum
|
||||
$coveredLines = ($fileRows | Measure-Object -Property CoveredLines -Sum).Sum
|
||||
$summary = [pscustomobject]@{
|
||||
Modules = $fileRows.Count
|
||||
TotalLines = $totalLines
|
||||
CoveredLines = $coveredLines
|
||||
LineCoverage = if ($totalLines -gt 0) { [math]::Round(100.0 * $coveredLines / $totalLines, 2) } else { 0 }
|
||||
}
|
||||
}
|
||||
|
||||
$totalFunctions = $functionRows.Count
|
||||
$functionsWithCoverage = @($functionRows | Where-Object { [int]$_.CoveredLines -gt 0 }).Count
|
||||
$functionsFullyCovered = @($functionRows | Where-Object { [int]$_.TotalLines -gt 0 -and [int]$_.CoveredLines -eq [int]$_.TotalLines }).Count
|
||||
|
||||
$resolvedModuleNames = @($ModuleNames)
|
||||
if ($resolvedModuleNames.Count -eq 0 -and $summary.PSObject.Properties.Name -contains 'InstrumentedModules') {
|
||||
$resolvedModuleNames = @($summary.InstrumentedModules)
|
||||
}
|
||||
$moduleDisplay = if ($resolvedModuleNames -and $resolvedModuleNames.Count -gt 0) { ($resolvedModuleNames -join ', ') } else { '' }
|
||||
|
||||
$summaryObj = [ordered]@{
|
||||
LineCoverage = $summary.LineCoverage
|
||||
CoveredLines = $summary.CoveredLines
|
||||
TotalLines = $summary.TotalLines
|
||||
Modules = $summary.Modules
|
||||
TotalFunctions = $totalFunctions
|
||||
FunctionsWithCoverage = $functionsWithCoverage
|
||||
FunctionsFullyCovered = $functionsFullyCovered
|
||||
}
|
||||
$summaryJson = ($summaryObj | ConvertTo-Json -Compress)
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# HTML template. Single-quoted here-string (no PowerShell variable expansion).
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
$htmlTemplate = @'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>__TITLE__</title>
|
||||
<style>
|
||||
:root {
|
||||
--green: #2ea043; --green-bg: #e6f4ea;
|
||||
--yellow: #b08800; --yellow-bg: #fff8e1;
|
||||
--red: #cf222e; --red-bg: #fdecea;
|
||||
--border: #d0d7de; --muted: #57606a; --bg: #ffffff; --row-hover: #f6f8fa;
|
||||
--fn-bg: #fbfcfd;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 24px; background: var(--bg); color: #1f2328; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px 0; }
|
||||
.subtitle { color: var(--muted); font-size: 13px; margin-bottom: 20px; }
|
||||
.kpis { display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.kpi { border: 1px solid var(--border); border-radius: 8px; padding: 14px 18px; min-width: 140px; }
|
||||
.kpi .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.kpi .value { font-size: 26px; font-weight: 600; margin-top: 4px; }
|
||||
.note { font-size: 12px; color: var(--muted); background: #fff8e1; border: 1px solid #f0e2b3; border-radius: 6px; padding: 10px 12px; margin-bottom: 16px; }
|
||||
.legend { font-size: 12px; color: var(--muted); margin-bottom: 12px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.legend .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
|
||||
#search { margin-bottom: 12px; }
|
||||
#searchBox { width: 100%; max-width: 480px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; }
|
||||
.tree { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
ul.node-children { list-style: none; margin: 0; padding: 0; }
|
||||
ul.node-children.collapsed { display: none; }
|
||||
li.node { border-top: 1px solid var(--border); }
|
||||
li.node:first-child { border-top: none; }
|
||||
.row { display: flex; align-items: center; padding: 7px 10px; cursor: default; }
|
||||
.row.folder, .row.file.expandable { cursor: pointer; }
|
||||
.row.folder:hover, .row.file.expandable:hover { background: var(--row-hover); }
|
||||
.caret { width: 14px; display: inline-block; text-align: center; color: var(--muted); font-size: 11px; user-select: none; flex: 0 0 auto; }
|
||||
.row.file:not(.expandable) .caret { visibility: hidden; }
|
||||
.row.function .caret { visibility: hidden; }
|
||||
.name { flex: 1 1 auto; font-size: 13px; font-family: Consolas, "Cascadia Mono", monospace; overflow-wrap: anywhere; padding-right: 12px; }
|
||||
.row.folder .name { font-weight: 600; }
|
||||
.row.function .name { color: #57606a; }
|
||||
.row.function .name .fn-icon { color: #8250df; margin-right: 5px; }
|
||||
.row.function { background: var(--fn-bg); }
|
||||
.bar-wrap { flex: 0 0 140px; height: 8px; background: #eaeef2; border-radius: 4px; overflow: hidden; margin-right: 10px; }
|
||||
.bar-fill { height: 100%; }
|
||||
.pct { flex: 0 0 62px; text-align: right; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.lines { flex: 0 0 110px; text-align: right; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; padding-left: 10px; }
|
||||
.pct-hi, .bar-fill.hi { color: var(--green); }
|
||||
.bar-fill.hi { background: var(--green); }
|
||||
.pct-mid, .bar-fill.mid { color: var(--yellow); }
|
||||
.bar-fill.mid { background: var(--yellow); }
|
||||
.pct-lo, .bar-fill.lo { color: var(--red); }
|
||||
.bar-fill.lo { background: var(--red); }
|
||||
ul.node-children { padding-left: 22px; }
|
||||
.tree > ul.node-children { padding-left: 0; }
|
||||
.hidden-by-search { display: none !important; }
|
||||
.module-line { font-size: 13px; color: #1f2328; margin-bottom: 2px; }
|
||||
.module-line .module-badge { display: inline-block; background: #ddf4ff; color: #0969da; border: 1px solid #b6e3ff; border-radius: 12px; padding: 2px 10px; font-family: Consolas, "Cascadia Mono", monospace; font-size: 12px; margin-left: 6px; }
|
||||
footer { margin-top: 20px; font-size: 11px; color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>__TITLE__</h1>
|
||||
__MODULE_LINE__
|
||||
<div class="subtitle">Generated __GENERATED_AT__ · boilerplate path prefix stripped from every entry below</div>
|
||||
|
||||
<div class="kpis" id="kpis"></div>
|
||||
|
||||
<div class="note">Folder/file percentages and line totals are the authoritative, Azure-DevOps-verified numbers. Expand a file to see its individual functions - each function's own covered/total lines are shown for that function alone, and are <strong>not</strong> summed into the file total above it (in C++, distinct functions such as template instantiations can legitimately share the same physical source line, so a naive sum would double-count).</div>
|
||||
|
||||
<div class="legend">
|
||||
<span><span class="swatch" style="background:var(--green)"></span>≥ 75%</span>
|
||||
<span><span class="swatch" style="background:var(--yellow)"></span>40–75%</span>
|
||||
<span><span class="swatch" style="background:var(--red)"></span>< 40%</span>
|
||||
<span><span class="fn-icon" style="color:#8250df">ƒ</span> = function (click a file with a caret to reveal its functions)</span>
|
||||
<span style="margin-left:auto">Search filters folders/files only</span>
|
||||
</div>
|
||||
|
||||
<div id="search">
|
||||
<input id="searchBox" type="text" placeholder="Filter by file or folder name..." />
|
||||
</div>
|
||||
|
||||
<div class="tree" id="tree"></div>
|
||||
|
||||
<footer>Self-contained report - no external files. File/folder coverage matches the Azure DevOps Code Coverage tab exactly. Function-level detail comes from the coverage merge engine's native (non-Cobertura) output format, which preserves per-function boundaries that Cobertura collapses away.</footer>
|
||||
|
||||
<script>
|
||||
const TREE_DATA = __TREE_JSON__;
|
||||
const SUMMARY = __SUMMARY_JSON__;
|
||||
|
||||
function pctClass(p) {
|
||||
if (p >= 75) return 'hi';
|
||||
if (p >= 40) return 'mid';
|
||||
return 'lo';
|
||||
}
|
||||
|
||||
function fmtInt(n) {
|
||||
return (n || 0).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function renderKpis() {
|
||||
const kpis = document.getElementById('kpis');
|
||||
const items = [
|
||||
['Line coverage', (SUMMARY.LineCoverage !== undefined ? SUMMARY.LineCoverage : 0) + '%'],
|
||||
['Covered lines', fmtInt(SUMMARY.CoveredLines)],
|
||||
['Total lines', fmtInt(SUMMARY.TotalLines)],
|
||||
['Files', fmtInt(SUMMARY.Modules)],
|
||||
['Functions', fmtInt(SUMMARY.TotalFunctions)],
|
||||
['Functions w/ coverage', fmtInt(SUMMARY.FunctionsWithCoverage)],
|
||||
];
|
||||
items.forEach(([label, value]) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'kpi';
|
||||
const l = document.createElement('div'); l.className = 'label'; l.textContent = label;
|
||||
const v = document.createElement('div'); v.className = 'value'; v.textContent = value;
|
||||
div.appendChild(l); div.appendChild(v);
|
||||
kpis.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function buildRow(node, kind) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row ' + kind;
|
||||
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'caret';
|
||||
caret.textContent = kind === 'function' ? '' : '\u25B8';
|
||||
row.appendChild(caret);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'name';
|
||||
if (kind === 'function') {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'fn-icon';
|
||||
icon.textContent = '\u0192';
|
||||
name.appendChild(icon);
|
||||
const text = document.createTextNode(node.n);
|
||||
name.appendChild(text);
|
||||
if (node.q) { name.title = node.q; }
|
||||
} else {
|
||||
name.textContent = node.n;
|
||||
}
|
||||
row.appendChild(name);
|
||||
|
||||
const barWrap = document.createElement('span');
|
||||
barWrap.className = 'bar-wrap';
|
||||
const barFill = document.createElement('span');
|
||||
barFill.className = 'bar-fill ' + pctClass(node.p);
|
||||
barFill.style.width = Math.min(node.p, 100) + '%';
|
||||
barWrap.appendChild(barFill);
|
||||
row.appendChild(barWrap);
|
||||
|
||||
const pct = document.createElement('span');
|
||||
pct.className = 'pct pct-' + pctClass(node.p);
|
||||
pct.textContent = node.p.toFixed(1) + '%';
|
||||
row.appendChild(pct);
|
||||
|
||||
const lines = document.createElement('span');
|
||||
lines.className = 'lines';
|
||||
lines.textContent = fmtInt(node.c) + ' / ' + fmtInt(node.t);
|
||||
row.appendChild(lines);
|
||||
|
||||
return { row, caret };
|
||||
}
|
||||
|
||||
function buildFunctionLi(fn) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'node';
|
||||
const { row } = buildRow(fn, 'function');
|
||||
li.appendChild(row);
|
||||
return li;
|
||||
}
|
||||
|
||||
function buildNodeLi(node) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'node';
|
||||
li.dataset.name = node.n.toLowerCase();
|
||||
|
||||
const hasFolderChildren = !node.f && node.k && node.k.length;
|
||||
const hasFunctionChildren = !!(node.f && node.fk && node.fk.length);
|
||||
const kind = node.f ? 'file' : 'folder';
|
||||
const { row, caret } = buildRow(node, kind);
|
||||
if (hasFunctionChildren) { row.classList.add('expandable'); caret.textContent = '\u25B8'; }
|
||||
li.appendChild(row);
|
||||
|
||||
if (hasFolderChildren) {
|
||||
const childUl = document.createElement('ul');
|
||||
childUl.className = 'node-children collapsed';
|
||||
node.k.forEach(child => childUl.appendChild(buildNodeLi(child)));
|
||||
li.appendChild(childUl);
|
||||
row.addEventListener('click', () => {
|
||||
const collapsed = childUl.classList.toggle('collapsed');
|
||||
caret.textContent = collapsed ? '\u25B8' : '\u25BE';
|
||||
});
|
||||
} else if (hasFunctionChildren) {
|
||||
// Lazy: build the (potentially large) function list only on first expand.
|
||||
let built = false;
|
||||
let childUl = null;
|
||||
row.addEventListener('click', () => {
|
||||
if (!built) {
|
||||
childUl = document.createElement('ul');
|
||||
childUl.className = 'node-children collapsed';
|
||||
node.fk.forEach(fn => childUl.appendChild(buildFunctionLi(fn)));
|
||||
li.appendChild(childUl);
|
||||
built = true;
|
||||
}
|
||||
const collapsed = childUl.classList.toggle('collapsed');
|
||||
caret.textContent = collapsed ? '\u25B8' : '\u25BE';
|
||||
});
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
const container = document.getElementById('tree');
|
||||
const rootUl = document.createElement('ul');
|
||||
rootUl.className = 'node-children';
|
||||
TREE_DATA.forEach(node => rootUl.appendChild(buildNodeLi(node)));
|
||||
container.appendChild(rootUl);
|
||||
}
|
||||
|
||||
// Filter over folder/file rows only (function rows are lazily built and not indexed here).
|
||||
function applyFilter(term) {
|
||||
const allLis = document.querySelectorAll('#tree li.node');
|
||||
if (!term) {
|
||||
allLis.forEach(li => {
|
||||
li.classList.remove('hidden-by-search');
|
||||
});
|
||||
return;
|
||||
}
|
||||
const lower = term.toLowerCase();
|
||||
allLis.forEach(li => {
|
||||
const nameEl = li.querySelector(':scope > .row .name');
|
||||
const isFn = li.querySelector(':scope > .row.function') !== null;
|
||||
const isMatch = !isFn && nameEl && nameEl.textContent.toLowerCase().includes(lower);
|
||||
li.dataset.match = isMatch ? '1' : '0';
|
||||
});
|
||||
allLis.forEach(li => {
|
||||
const anyDescendantMatches = li.querySelector('li.node[data-match="1"]') !== null;
|
||||
const isSelfMatch = li.dataset.match === '1';
|
||||
const isFn = li.querySelector(':scope > .row.function') !== null;
|
||||
const show = isFn ? true : (isSelfMatch || anyDescendantMatches);
|
||||
li.classList.toggle('hidden-by-search', !show);
|
||||
if (show && !isFn) {
|
||||
const ul = li.querySelector(':scope > ul.node-children');
|
||||
if (ul && anyDescendantMatches) {
|
||||
ul.classList.remove('collapsed');
|
||||
const caret = li.querySelector(':scope > .row .caret');
|
||||
if (caret) caret.textContent = '\u25BE';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderKpis();
|
||||
renderTree();
|
||||
document.getElementById('searchBox').addEventListener('input', (e) => applyFilter(e.target.value.trim()));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'@
|
||||
|
||||
$outFile = Join-Path $OutputDir $OutputFileName
|
||||
$moduleLineHtml = if ($moduleDisplay) {
|
||||
'<div class="module-line">Code coverage measured for: <span class="module-badge">' + [System.Net.WebUtility]::HtmlEncode($moduleDisplay) + '</span></div>'
|
||||
} else {
|
||||
''
|
||||
}
|
||||
$html = $htmlTemplate.Replace('__TITLE__', [System.Net.WebUtility]::HtmlEncode($Title))
|
||||
$html = $html.Replace('__MODULE_LINE__', $moduleLineHtml)
|
||||
$html = $html.Replace('__GENERATED_AT__', (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'))
|
||||
$html = $html.Replace('__TREE_JSON__', $treeJson)
|
||||
$html = $html.Replace('__SUMMARY_JSON__', $summaryJson)
|
||||
|
||||
Set-Content -Path $outFile -Value $html -Encoding UTF8
|
||||
|
||||
Write-Host ("`nWrote single-file function-drill-down tree report in {0:N1}s -> {1} ({2:N1} MB)" -f $sw.Elapsed.TotalSeconds, $outFile, ((Get-Item $outFile).Length / 1MB))
|
||||
if ($moduleDisplay) { Write-Host (" Module(s): {0}" -f $moduleDisplay) }
|
||||
Write-Host (" File-level: {0}% ({1} / {2} lines across {3} files)" -f $summary.LineCoverage, $summary.CoveredLines, $summary.TotalLines, $summary.Modules)
|
||||
Write-Host (" Functions: {0:N0} total, {1:N0} with any coverage, {2:N0} fully covered" -f $totalFunctions, $functionsWithCoverage, $functionsFullyCovered)
|
||||
@@ -0,0 +1,349 @@
|
||||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT License.
|
||||
#
|
||||
# Get-FunctionCoverage.ps1
|
||||
#
|
||||
# Produces a FUNCTION-LEVEL coverage breakdown ("which functions in this file were exercised by
|
||||
# the tests, and by how much") - one row per C++ function/overload, not just one row per source
|
||||
# file. This is a companion to (not a replacement for) Repair-CoverageDuplicates.ps1, which only
|
||||
# ever reconstructs FILE-level totals because Cobertura (the format that script's input is in)
|
||||
# collapses per-function detail down to one <class> per source file. To get real function
|
||||
# boundaries this script uses a different raw input: the coverage merge engine's native "xml"
|
||||
# output format (`dotnet-coverage merge -f xml` / `Microsoft.CodeCoverage.Console merge -f xml`),
|
||||
# which - unlike Cobertura - keeps one <function> element per C++ function/template instantiation
|
||||
# (name, namespace, containing class/type, and its own per-line <range> hit/miss list), with a
|
||||
# <source_files> table mapping each line back to a real source path.
|
||||
#
|
||||
# THIS SCRIPT WORKS DIRECTLY FROM THE RAW PER-SLICE .coverage FILES - it does NOT need the
|
||||
# product DLL/PDB. That symbol info (function names, namespaces, source file + line numbers) is
|
||||
# already embedded in each .coverage file at collection time; this was explicitly verified by
|
||||
# stripping the DLL/PDB out of a test run folder and re-merging - the function-level XML still
|
||||
# resolved perfectly.
|
||||
#
|
||||
# PER-FUNCTION DEDUPLICATION: within a single function's own <ranges> list, this script keeps the
|
||||
# max covered-state (yes > partial > no) seen per (source file, line number) - guarding against
|
||||
# the same duplicate-range-on-merge defect Repair-CoverageDuplicates.ps1 fixes for Cobertura, in
|
||||
# case it ever resurfaces for a given function. In practice, `dotnet-coverage merge -f xml` was
|
||||
# observed to already correctly union same-function-id ranges across contributing slices without
|
||||
# duplication (a real build's `AccessKey.cpp`, merged from all 20 slices, summed its 8 functions'
|
||||
# corrected totals to exactly 52 - matching that file's independently-verified true line count) -
|
||||
# so this per-function dedup is mostly a defensive safety net, not something that was seen to
|
||||
# fire heavily in practice.
|
||||
#
|
||||
# IMPORTANT NUANCE - per-function totals do NOT sum to the file's true unique-line total, and
|
||||
# THAT IS EXPECTED, NOT A BUG: in C++, many distinct COMPILED functions can legitimately share
|
||||
# the same physical source line - template instantiations (e.g. `_tlgCreate1Sz<char>` and
|
||||
# `_tlgCreate1Sz<wchar_t>` are two separate compiled functions that both report the identical 9
|
||||
# header lines of their shared template body), inlined helpers, and macro expansions are the
|
||||
# common causes. Verified on a real build: TraceLoggingProvider.h has 163 distinct instantiated
|
||||
# functions whose own totals sum to 593 lines, even though the header itself has far fewer
|
||||
# physical lines - each instantiation is legitimately "using" the same textual lines as its
|
||||
# siblings. Because of this, DO NOT use this script's per-function rows to re-derive a file's
|
||||
# true aggregate line coverage - use the existing, Azure-DevOps-verified `corrected-per-file.csv`
|
||||
# (from Repair-CoverageDuplicates.ps1 / Split-CoverageByModule.ps1) for that; pass it via
|
||||
# -ExistingPerFileCsv to have this script merge those authoritative file totals alongside the new
|
||||
# per-function/function-count breakdown in corrected-per-file-function-summary.csv.
|
||||
#
|
||||
# OUTPUT (under -OutputDir)
|
||||
# NativeMerge\merged-native.xml - raw dotnet-coverage -f xml merge.
|
||||
# corrected-per-function.csv - one row per function: File, Namespace, Type, Function,
|
||||
# TotalLines, CoveredLines, FullyCoveredLines,
|
||||
# PartiallyCoveredLines, NotCoveredLines,
|
||||
# LineCoveragePct (all "own view" totals for that one
|
||||
# compiled function - see the nuance above before
|
||||
# summing this column across functions).
|
||||
# Sorted by File then Function.
|
||||
# corrected-per-file-function-summary.csv - one row per source file: TotalFunctions,
|
||||
# FunctionsWithAnyCoverage, FunctionsFullyCovered,
|
||||
# FunctionsNotCovered - plus, only when
|
||||
# -ExistingPerFileCsv is supplied, that file's
|
||||
# authoritative FileTotalLines/FileCoveredLines/
|
||||
# FileLineCoveragePct joined in from corrected-per-file.csv.
|
||||
#
|
||||
# EXAMPLE (all .coverage files under a folder, joining in the authoritative per-file totals that
|
||||
# Split-CoverageByModule.ps1 already computed for them)
|
||||
# .\Get-FunctionCoverage.ps1 -SliceDir C:\cov\run1\raw `
|
||||
# -OutputDir C:\cov\run1\FunctionCoverage `
|
||||
# -ExistingPerFileCsv C:\cov\run1\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv
|
||||
#
|
||||
# EXAMPLE (explicit list of .coverage files)
|
||||
# .\Get-FunctionCoverage.ps1 -CoverageFiles file1.coverage,file2.coverage -OutputDir .\out
|
||||
|
||||
[CmdletBinding(DefaultParameterSetName = 'FromSliceDir')]
|
||||
param(
|
||||
# Directory to search recursively for raw *.coverage files (e.g. a folder holding one
|
||||
# .coverage file per test run or slice). All matches are merged together.
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'FromSliceDir')]
|
||||
[string]$SliceDir,
|
||||
|
||||
# Explicit list of raw .coverage files to merge (alternative to -SliceDir).
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'FromFiles')]
|
||||
[string[]]$CoverageFiles,
|
||||
|
||||
# Where to write outputs. Created if it doesn't exist.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDir,
|
||||
|
||||
# Explicit path to dotnet-coverage / Microsoft.CodeCoverage.Console.exe. Auto-resolved
|
||||
# (dotnet-coverage on PATH, else VS's Microsoft.CodeCoverage.Console.exe, else auto-installs
|
||||
# dotnet-coverage) if not supplied.
|
||||
[string]$CoverageToolPath = "",
|
||||
|
||||
# Reuse an already-produced NativeMerge\merged-native.xml from a prior run instead of
|
||||
# re-merging (useful for iterating on the dedup/CSV logic without re-running the - often
|
||||
# multi-minute - merge step).
|
||||
[switch]$SkipMerge,
|
||||
|
||||
# Count a "partially covered" line (some but not all blocks on that line executed - common
|
||||
# with multiple statements/branches per line in C++) as covered when computing CoveredLines
|
||||
# and LineCoveragePct. On by default (matches the common-sense "was this line touched at
|
||||
# all" reading). Fully-covered and partially-covered are still also broken out into their
|
||||
# own separate columns regardless of this switch, so no information is lost either way.
|
||||
[bool]$TreatPartialAsCovered = $true,
|
||||
|
||||
# Keep NativeMerge\merged-native.xml after the run instead of leaving it (it is NOT deleted
|
||||
# by default - this file is the expensive-to-regenerate one, so it is kept unless this switch
|
||||
# is explicitly reversed by passing -KeepIntermediateXml:$false).
|
||||
[bool]$KeepIntermediateXml = $true,
|
||||
|
||||
# Optional path to an existing corrected-per-file.csv (as produced by
|
||||
# Repair-CoverageDuplicates.ps1 / Split-CoverageByModule.ps1). When supplied, that file's
|
||||
# already Azure-DevOps-verified per-file TotalLines/CoveredLines/LineCoverage are joined into
|
||||
# corrected-per-file-function-summary.csv as FileTotalLines/FileCoveredLines/
|
||||
# FileLineCoveragePct, alongside this script's own function-count columns. Recommended -
|
||||
# without it, the summary only has function counts (no line totals), by design, to avoid
|
||||
# presenting a second, non-equivalent "file total" next to the one everyone already trusts
|
||||
# (see the "IMPORTANT NUANCE" note above).
|
||||
[string]$ExistingPerFileCsv = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$nativeMergeDir = Join-Path $OutputDir 'NativeMerge'
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir, $nativeMergeDir | Out-Null
|
||||
$nativeXmlPath = Join-Path $nativeMergeDir 'merged-native.xml'
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# 1. Resolve a coverage merge tool (dotnet-coverage on PATH, else VS's console tool, else install).
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
function Resolve-CoverageTool {
|
||||
param([string]$Explicit)
|
||||
if ($Explicit -and (Test-Path $Explicit)) { return $Explicit }
|
||||
$dc = Get-Command 'dotnet-coverage' -ErrorAction SilentlyContinue
|
||||
if ($dc) { return $dc.Source }
|
||||
$vs = Get-ChildItem -Path @(
|
||||
"${env:ProgramFiles}\Microsoft Visual Studio",
|
||||
"${env:ProgramFiles(x86)}\Microsoft Visual Studio"
|
||||
) -Recurse -Filter 'Microsoft.CodeCoverage.Console.exe' -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($vs) { return $vs.FullName }
|
||||
Write-Host "dotnet-coverage not found; installing the global tool..."
|
||||
& dotnet tool install --global dotnet-coverage --add-source https://api.nuget.org/v3/index.json --ignore-failed-sources | Write-Host
|
||||
$env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools"
|
||||
$dc = Get-Command 'dotnet-coverage' -ErrorAction SilentlyContinue
|
||||
if ($dc) { return $dc.Source }
|
||||
throw "Could not find or install a code coverage merge tool (dotnet-coverage or Microsoft.CodeCoverage.Console.exe)."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# 2. Merge raw .coverage files into the native (function-granular) xml format.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
if (-not $SkipMerge) {
|
||||
$inputFiles = if ($PSCmdlet.ParameterSetName -eq 'FromSliceDir') {
|
||||
Write-Host "=== Discovering raw .coverage files under $SliceDir ==="
|
||||
@(Get-ChildItem $SliceDir -Recurse -Filter '*.coverage' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName)
|
||||
} else {
|
||||
@($CoverageFiles)
|
||||
}
|
||||
if ($inputFiles.Count -eq 0) {
|
||||
throw "No .coverage files found (checked $(if ($PSCmdlet.ParameterSetName -eq 'FromSliceDir') { $SliceDir } else { '-CoverageFiles list' }))."
|
||||
}
|
||||
Write-Host "Merging $($inputFiles.Count) raw .coverage file(s) into the native function-granular xml format..."
|
||||
Write-Host "(This does NOT require the product DLL/PDB - function/line symbol info is already embedded in each .coverage file.)"
|
||||
|
||||
$covTool = Resolve-CoverageTool -Explicit $CoverageToolPath
|
||||
Write-Host "Using: $covTool"
|
||||
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
& $covTool merge @inputFiles -f xml -o $nativeXmlPath --nologo | Write-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "Native xml merge failed (exit $LASTEXITCODE)." }
|
||||
Write-Host ("Merge complete in {0:N1}s -> {1} ({2:N0} MB)" -f $sw.Elapsed.TotalSeconds, $nativeXmlPath, ((Get-Item $nativeXmlPath).Length / 1MB))
|
||||
Write-Host "NOTE: this raw merged file still has the duplicate-range bug - do not read its <module>/<function> line/block-rate attributes directly; this script re-derives corrected values below."
|
||||
} else {
|
||||
if (-not (Test-Path $nativeXmlPath)) { throw "-SkipMerge was set but $nativeXmlPath does not exist." }
|
||||
Write-Host "=== -SkipMerge set: reusing existing $nativeXmlPath ==="
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# 3. Stream-parse the native xml once, deduplicating as we go:
|
||||
# - source_files: source_id -> file path
|
||||
# - functions: function id -> aggregated { Name, Namespace, TypeName, per-(sourceId,line)
|
||||
# max covered-state, per-sourceId line-count (to pick the function's "primary" file) }
|
||||
# A single forward-only XmlReader pass keeps this feasible on multi-hundred-MB inputs without
|
||||
# loading a full DOM.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
Write-Host "`n=== Parsing and deduplicating $nativeXmlPath ==="
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
Add-Type -Language CSharp -TypeDefinition @"
|
||||
using System.Collections.Generic;
|
||||
public class FuncAgg {
|
||||
public string Name;
|
||||
public string Namespace;
|
||||
public string TypeName;
|
||||
// "sourceId|line" -> state (2=yes, 1=partial, 0=no)
|
||||
public Dictionary<string,int> LineStates = new Dictionary<string,int>();
|
||||
// sourceId -> count of distinct lines seen for that source, used to pick the "primary" file.
|
||||
public Dictionary<string,int> SourceLineCounts = new Dictionary<string,int>();
|
||||
}
|
||||
"@
|
||||
|
||||
$sourceFiles = New-Object 'System.Collections.Generic.Dictionary[string,string]'
|
||||
$functions = New-Object 'System.Collections.Generic.Dictionary[string,FuncAgg]'
|
||||
|
||||
$reader = [System.Xml.XmlReader]::Create($nativeXmlPath)
|
||||
$currentFunc = $null
|
||||
$rawFunctionElementCount = 0
|
||||
$rawRangeElementCount = 0
|
||||
try {
|
||||
while ($reader.Read()) {
|
||||
if ($reader.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
|
||||
switch ($reader.Name) {
|
||||
'source_file' {
|
||||
$id = $reader.GetAttribute('id')
|
||||
$path = $reader.GetAttribute('path')
|
||||
if ($id -and -not $sourceFiles.ContainsKey($id)) { $sourceFiles[$id] = $path }
|
||||
}
|
||||
'function' {
|
||||
$rawFunctionElementCount++
|
||||
$id = $reader.GetAttribute('id')
|
||||
$agg = $null
|
||||
if (-not $functions.TryGetValue($id, [ref]$agg)) {
|
||||
$agg = New-Object FuncAgg
|
||||
$agg.Name = $reader.GetAttribute('name')
|
||||
$agg.Namespace = $reader.GetAttribute('namespace')
|
||||
$agg.TypeName = $reader.GetAttribute('type_name')
|
||||
$functions[$id] = $agg
|
||||
}
|
||||
$currentFunc = $agg
|
||||
}
|
||||
'range' {
|
||||
$rawRangeElementCount++
|
||||
if (-not $currentFunc) { continue }
|
||||
$sid = $reader.GetAttribute('source_id')
|
||||
$sln = [int]$reader.GetAttribute('start_line')
|
||||
$eln = [int]$reader.GetAttribute('end_line')
|
||||
$cov = $reader.GetAttribute('covered')
|
||||
$state = if ($cov -eq 'yes') { 2 } elseif ($cov -eq 'partial') { 1 } else { 0 }
|
||||
for ($ln = $sln; $ln -le $eln; $ln++) {
|
||||
$key = "$sid|$ln"
|
||||
$existing = 0
|
||||
if ($currentFunc.LineStates.TryGetValue($key, [ref]$existing)) {
|
||||
if ($state -gt $existing) { $currentFunc.LineStates[$key] = $state }
|
||||
} else {
|
||||
$currentFunc.LineStates[$key] = $state
|
||||
$cnt = 0
|
||||
[void]$currentFunc.SourceLineCounts.TryGetValue($sid, [ref]$cnt)
|
||||
$currentFunc.SourceLineCounts[$sid] = $cnt + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$reader.Close()
|
||||
}
|
||||
Write-Host ("Parsed {0:N0} raw <function> elements ({1:N0} unique) and {2:N0} raw <range> elements in {3:N1}s." -f $rawFunctionElementCount, $functions.Count, $rawRangeElementCount, $sw.Elapsed.TotalSeconds)
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# 4. Compute corrected per-function totals, attributing each function to its "primary" file
|
||||
# (whichever source_id contributed the most distinct lines) and only counting that file's
|
||||
# lines toward the function's own totals - this keeps a constructor's one stray reference to
|
||||
# a header's default-member-initializer line, for example, from polluting its own .cpp totals.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
Write-Host "`n=== Computing corrected per-function totals ==="
|
||||
$perFunctionRows = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($kvp in $functions.GetEnumerator()) {
|
||||
$agg = $kvp.Value
|
||||
if ($agg.SourceLineCounts.Count -eq 0) { continue }
|
||||
$primarySourceId = ($agg.SourceLineCounts.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 1).Key
|
||||
$filePath = $null
|
||||
if (-not $sourceFiles.TryGetValue($primarySourceId, [ref]$filePath)) { $filePath = "<unknown source_id $primarySourceId>" }
|
||||
|
||||
$total = 0; $fully = 0; $partial = 0
|
||||
foreach ($entry in $agg.LineStates.GetEnumerator()) {
|
||||
if (-not $entry.Key.StartsWith("$primarySourceId|")) { continue }
|
||||
$total++
|
||||
if ($entry.Value -eq 2) { $fully++ }
|
||||
elseif ($entry.Value -eq 1) { $partial++ }
|
||||
}
|
||||
$notCovered = $total - $fully - $partial
|
||||
$covered = if ($TreatPartialAsCovered) { $fully + $partial } else { $fully }
|
||||
$pct = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 }
|
||||
|
||||
$perFunctionRows.Add([pscustomobject]@{
|
||||
File = $filePath
|
||||
Namespace = $agg.Namespace
|
||||
Type = $agg.TypeName
|
||||
Function = $agg.Name
|
||||
TotalLines = $total
|
||||
CoveredLines = $covered
|
||||
FullyCoveredLines = $fully
|
||||
PartiallyCoveredLines = $partial
|
||||
NotCoveredLines = $notCovered
|
||||
LineCoveragePct = $pct
|
||||
})
|
||||
}
|
||||
Write-Host "Computed totals for $($perFunctionRows.Count) function(s)."
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# 5. Roll up to a per-file function summary. This intentionally reports FUNCTION COUNTS only
|
||||
# (not a re-derived line total - see the "IMPORTANT NUANCE" note at the top of this script for
|
||||
# why summing per-function line totals is not a valid stand-in for a file's true unique-line
|
||||
# total in C++). If -ExistingPerFileCsv was supplied, that already-verified file-level total
|
||||
# is joined in alongside the function counts.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
Write-Host "`n=== Rolling up to per-file function summary ==="
|
||||
$existingPerFile = $null
|
||||
if ($ExistingPerFileCsv) {
|
||||
if (-not (Test-Path $ExistingPerFileCsv)) { throw "-ExistingPerFileCsv path not found: $ExistingPerFileCsv" }
|
||||
$existingPerFile = @{}
|
||||
foreach ($row in (Import-Csv $ExistingPerFileCsv)) { $existingPerFile[$row.File] = $row }
|
||||
Write-Host "Joining authoritative file totals from $ExistingPerFileCsv ($($existingPerFile.Count) file(s))."
|
||||
}
|
||||
|
||||
$perFileSummary = $perFunctionRows | Group-Object -Property File | ForEach-Object {
|
||||
$rows = $_.Group
|
||||
$row = [ordered]@{
|
||||
File = $_.Name
|
||||
TotalFunctions = $rows.Count
|
||||
FunctionsWithAnyCoverage = ($rows | Where-Object { $_.CoveredLines -gt 0 }).Count
|
||||
FunctionsFullyCovered = ($rows | Where-Object { $_.TotalLines -gt 0 -and $_.CoveredLines -eq $_.TotalLines }).Count
|
||||
FunctionsNotCovered = ($rows | Where-Object { $_.CoveredLines -eq 0 }).Count
|
||||
}
|
||||
if ($existingPerFile) {
|
||||
$match = if ($existingPerFile.ContainsKey($_.Name)) { $existingPerFile[$_.Name] } else { $null }
|
||||
$row['FileTotalLines'] = if ($match) { $match.TotalLines } else { '' }
|
||||
$row['FileCoveredLines'] = if ($match) { $match.CoveredLines } else { '' }
|
||||
$row['FileLineCoveragePct'] = if ($match) { $match.LineCoverage } else { '' }
|
||||
}
|
||||
[pscustomobject]$row
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# 6. Write outputs.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
$perFunctionCsvPath = Join-Path $OutputDir 'corrected-per-function.csv'
|
||||
$perFileSummaryCsvPath = Join-Path $OutputDir 'corrected-per-file-function-summary.csv'
|
||||
|
||||
$perFunctionRows | Sort-Object File, Function | Export-Csv $perFunctionCsvPath -NoTypeInformation
|
||||
$perFileSummary | Sort-Object File | Export-Csv $perFileSummaryCsvPath -NoTypeInformation
|
||||
|
||||
if (-not $KeepIntermediateXml) {
|
||||
Remove-Item $nativeXmlPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "`n=== Done ==="
|
||||
Write-Host " $perFunctionCsvPath"
|
||||
Write-Host " $perFileSummaryCsvPath"
|
||||
if ($KeepIntermediateXml) { Write-Host " $nativeXmlPath (kept; pass -KeepIntermediateXml `$false to discard)" }
|
||||
@@ -0,0 +1,231 @@
|
||||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT License.
|
||||
#
|
||||
# Repair-CoverageDuplicates.ps1
|
||||
#
|
||||
# Fixes a real, verified defect in Microsoft's native (C++ static-instrumentation) code coverage
|
||||
# merge engine - the same engine backing BOTH the `dotnet-coverage` global tool and Visual
|
||||
# Studio's bundled `Microsoft.CodeCoverage.Console.exe`.
|
||||
#
|
||||
# THE BUG: when `<tool> merge <n .coverage files> -f cobertura` merges multiple raw .coverage
|
||||
# files that all instrument the SAME native module (e.g. one module instrumented once, then
|
||||
# exercised by N separate collector processes on N separate machines - exactly what happens when
|
||||
# a test pass is split into parallel slices with one collector per slice, or when one module is
|
||||
# collected by several separate test runs), the merge
|
||||
# does NOT consolidate matching (file, line-number) entries into one. Instead it appends a
|
||||
# fresh copy of that class's <line> elements per contributing input file. This was verified to
|
||||
# be 100% systemic (every single <class> element in a real merged report exhibited it) and to
|
||||
# exist even in Visual Studio's own native (non-Cobertura) merge output - so it is a merge-time
|
||||
# defect, not a Cobertura-serialization-only quirk.
|
||||
#
|
||||
# EFFECT: any tool that iterates every <line> element and sums them (dotnet-coverage's own
|
||||
# printed merge summary, reportgenerator's TextSummary, etc.) overcounts both total and covered
|
||||
# lines, understating the true coverage percentage. Azure DevOps's own "Code Coverage" build tab
|
||||
# is NOT affected, because it deduplicates by (file, line-number) when it renders - this script
|
||||
# reproduces that same deduplication so a locally-computed number matches the ADO tab exactly.
|
||||
# (Verified: deduplicating a real build's merged.cobertura.xml this way reproduced the ADO tab's
|
||||
# 4,500 files / 451,305 total lines / 337,879 covered lines / 74.87% exactly.)
|
||||
#
|
||||
# THE FIX (this script): parse the buggy merged Cobertura XML, and for every unique source file
|
||||
# (grouping across ALL <class> elements that map to it - some files have many classes, e.g. one
|
||||
# per C++ template instantiation, that all reference the same physical source lines), keep only
|
||||
# one entry per unique line number (hits = max across duplicates). Emit:
|
||||
# 1. corrected-summary.json / corrected-summary.csv - overall + per-file corrected totals.
|
||||
# 2. corrected.cobertura.xml - a schema-valid Cobertura file (one <class> per source file,
|
||||
# already deduplicated) that any standard Cobertura consumer (reportgenerator, ADO's own
|
||||
# publish task, etc.) will read back with the SAME correct numbers.
|
||||
#
|
||||
# This script is self-contained and works on ANY Cobertura XML produced by dotnet-coverage /
|
||||
# Microsoft.CodeCoverage.Console's merge.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Path to the (buggy, duplicate-line) merged Cobertura XML to repair.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InputCobertura,
|
||||
|
||||
# Where to write corrected-summary.json/.csv and corrected.cobertura.xml.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDir,
|
||||
|
||||
# Name(s) of the product DLL(s) that were instrumented to produce this coverage data (e.g.
|
||||
# "Microsoft.ui.xaml.dll", or multiple space-separated names if more than one module was
|
||||
# instrumented). Purely descriptive - recorded in corrected-summary.json's
|
||||
# "InstrumentedModules" field and used as the <package name="..."> in the corrected Cobertura
|
||||
# output, so downstream reports (e.g. Build-CoverageTreeReport.ps1's HTML) can show which
|
||||
# binary this coverage percentage actually measures. Optional - falls back to a generic
|
||||
# placeholder if not supplied.
|
||||
[string[]]$ModuleNames = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Xml
|
||||
|
||||
if (-not (Test-Path $InputCobertura)) {
|
||||
throw "Input Cobertura file not found: $InputCobertura"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
|
||||
Write-Host "Parsing and deduplicating $InputCobertura ..."
|
||||
Write-Host "(This streams the XML rather than loading a full DOM, so large multi-hundred-MB merges are fine.)"
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
# fileName -> Dictionary<lineNumber, hits> (hits = max seen across all duplicate/class entries)
|
||||
$fileLineMap = New-Object 'System.Collections.Generic.Dictionary[string,System.Collections.Generic.Dictionary[int,int]]'
|
||||
# Preserve first-seen file order for stable, readable output.
|
||||
$fileOrder = New-Object 'System.Collections.Generic.List[string]'
|
||||
|
||||
$reader = [System.Xml.XmlReader]::Create($InputCobertura)
|
||||
$currentDict = $null
|
||||
$classElementCount = 0
|
||||
$rawLineElementCount = 0
|
||||
try {
|
||||
while ($reader.Read()) {
|
||||
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'class') {
|
||||
$classElementCount++
|
||||
$fn = $reader.GetAttribute('filename')
|
||||
if (-not $fn) { $fn = $reader.GetAttribute('name') }
|
||||
if (-not $fileLineMap.TryGetValue($fn, [ref]$currentDict)) {
|
||||
$currentDict = New-Object 'System.Collections.Generic.Dictionary[int,int]'
|
||||
$fileLineMap[$fn] = $currentDict
|
||||
$fileOrder.Add($fn)
|
||||
}
|
||||
}
|
||||
elseif ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'line') {
|
||||
$rawLineElementCount++
|
||||
$num = [int]$reader.GetAttribute('number')
|
||||
$hits = [int]$reader.GetAttribute('hits')
|
||||
$existing = 0
|
||||
if ($currentDict.TryGetValue($num, [ref]$existing)) {
|
||||
if ($hits -gt $existing) { $currentDict[$num] = $hits }
|
||||
} else {
|
||||
$currentDict[$num] = $hits
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$reader.Close()
|
||||
}
|
||||
|
||||
Write-Host ("Parsed {0} raw <class> elements and {1} raw <line> elements in {2:N1}s." -f $classElementCount, $rawLineElementCount, $sw.Elapsed.TotalSeconds)
|
||||
|
||||
# Aggregate corrected per-file and overall totals.
|
||||
$perFile = New-Object System.Collections.Generic.List[object]
|
||||
$overallTotal = 0
|
||||
$overallCovered = 0
|
||||
foreach ($fn in $fileOrder) {
|
||||
$dict = $fileLineMap[$fn]
|
||||
$total = $dict.Count
|
||||
$covered = 0
|
||||
foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } }
|
||||
$overallTotal += $total
|
||||
$overallCovered += $covered
|
||||
$perFile.Add([pscustomobject]@{
|
||||
File = $fn
|
||||
CoveredLines = $covered
|
||||
TotalLines = $total
|
||||
LineCoverage = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 }
|
||||
})
|
||||
}
|
||||
|
||||
$overallRate = if ($overallTotal -gt 0) { $overallCovered / $overallTotal } else { 0 }
|
||||
$moduleDisplayName = if ($ModuleNames -and $ModuleNames.Count -gt 0) { $ModuleNames -join ', ' } else { '(not specified)' }
|
||||
|
||||
$summary = [pscustomobject]@{
|
||||
Modules = $fileOrder.Count
|
||||
TotalLines = $overallTotal
|
||||
CoveredLines = $overallCovered
|
||||
LineCoverage = [math]::Round(100.0 * $overallRate, 2)
|
||||
InstrumentedModules = @($ModuleNames)
|
||||
RawClassCount = $classElementCount
|
||||
RawLineCount = $rawLineElementCount
|
||||
SourceFile = (Resolve-Path $InputCobertura).Path
|
||||
GeneratedAt = (Get-Date).ToString('o')
|
||||
}
|
||||
|
||||
$summaryJsonPath = Join-Path $OutputDir 'corrected-summary.json'
|
||||
$summaryCsvPath = Join-Path $OutputDir 'corrected-summary.csv'
|
||||
$perFileCsvPath = Join-Path $OutputDir 'corrected-per-file.csv'
|
||||
$correctedXmlPath = Join-Path $OutputDir 'corrected.cobertura.xml'
|
||||
|
||||
$summary | ConvertTo-Json | Set-Content $summaryJsonPath
|
||||
$summary | Export-Csv $summaryCsvPath -NoTypeInformation
|
||||
$perFile | Sort-Object File | Export-Csv $perFileCsvPath -NoTypeInformation
|
||||
|
||||
Write-Host "`nWriting corrected, deduplicated Cobertura XML (one <class> per source file) -> $correctedXmlPath"
|
||||
$xmlSettings = New-Object System.Xml.XmlWriterSettings
|
||||
$xmlSettings.Indent = $false
|
||||
$xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$writer = [System.Xml.XmlWriter]::Create($correctedXmlPath, $xmlSettings)
|
||||
try {
|
||||
$writer.WriteStartDocument()
|
||||
$writer.WriteStartElement('coverage')
|
||||
$writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture))
|
||||
$writer.WriteAttributeString('branch-rate', '0')
|
||||
$writer.WriteAttributeString('lines-covered', $overallCovered)
|
||||
$writer.WriteAttributeString('lines-valid', $overallTotal)
|
||||
$writer.WriteAttributeString('branches-covered', '0')
|
||||
$writer.WriteAttributeString('branches-valid', '0')
|
||||
$writer.WriteAttributeString('complexity', '0')
|
||||
$writer.WriteAttributeString('version', '1.9')
|
||||
$writer.WriteAttributeString('timestamp', [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString())
|
||||
|
||||
$writer.WriteStartElement('sources')
|
||||
$writer.WriteElementString('source', '.')
|
||||
$writer.WriteEndElement() # sources
|
||||
|
||||
$writer.WriteStartElement('packages')
|
||||
$writer.WriteStartElement('package')
|
||||
$writer.WriteAttributeString('name', $moduleDisplayName)
|
||||
$writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture))
|
||||
$writer.WriteAttributeString('branch-rate', '0')
|
||||
$writer.WriteAttributeString('complexity', '0')
|
||||
$writer.WriteStartElement('classes')
|
||||
|
||||
foreach ($fn in $fileOrder) {
|
||||
$dict = $fileLineMap[$fn]
|
||||
$total = $dict.Count
|
||||
$covered = 0
|
||||
foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } }
|
||||
$rate = if ($total -gt 0) { $covered / $total } else { 0 }
|
||||
|
||||
$writer.WriteStartElement('class')
|
||||
$writer.WriteAttributeString('name', $fn)
|
||||
$writer.WriteAttributeString('filename', $fn)
|
||||
$writer.WriteAttributeString('line-rate', $rate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture))
|
||||
$writer.WriteAttributeString('branch-rate', '0')
|
||||
$writer.WriteAttributeString('complexity', '0')
|
||||
$writer.WriteStartElement('methods')
|
||||
$writer.WriteEndElement() # methods
|
||||
$writer.WriteStartElement('lines')
|
||||
foreach ($num in ($dict.Keys | Sort-Object)) {
|
||||
$writer.WriteStartElement('line')
|
||||
$writer.WriteAttributeString('number', $num)
|
||||
$writer.WriteAttributeString('hits', $dict[$num])
|
||||
$writer.WriteAttributeString('branch', 'false')
|
||||
$writer.WriteEndElement() # line
|
||||
}
|
||||
$writer.WriteEndElement() # lines
|
||||
$writer.WriteEndElement() # class
|
||||
}
|
||||
|
||||
$writer.WriteEndElement() # classes
|
||||
$writer.WriteEndElement() # package
|
||||
$writer.WriteEndElement() # packages
|
||||
$writer.WriteEndElement() # coverage
|
||||
$writer.WriteEndDocument()
|
||||
} finally {
|
||||
$writer.Close()
|
||||
}
|
||||
|
||||
Write-Host "`n=== Corrected coverage (bug-fixed, matches Azure DevOps's Code Coverage tab) ==="
|
||||
Write-Host (" Instrumented DLL(s): {0}" -f $moduleDisplayName)
|
||||
Write-Host (" Modules (files): {0}" -f $summary.Modules)
|
||||
Write-Host (" Total lines: {0}" -f $summary.TotalLines)
|
||||
Write-Host (" Covered lines: {0}" -f $summary.CoveredLines)
|
||||
Write-Host (" Line coverage: {0}%" -f $summary.LineCoverage)
|
||||
Write-Host "`nWrote:"
|
||||
Write-Host " $summaryJsonPath"
|
||||
Write-Host " $summaryCsvPath"
|
||||
Write-Host " $perFileCsvPath"
|
||||
Write-Host " $correctedXmlPath"
|
||||
@@ -0,0 +1,84 @@
|
||||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT License.
|
||||
#
|
||||
# Set-CoveragePipeAcl.ps1 - Put a NULL DACL on the code-coverage collector's named pipe.
|
||||
#
|
||||
# This sets the DACL in-process via P/Invoke, so no native helper has to be shipped to the
|
||||
# test machine. TAEF's te.processhost runs at
|
||||
# low integrity (Centennial); by default it cannot connect to the collector's pipe and
|
||||
# coverage comes back empty with ACCESS_DENIED. A NULL DACL opens the gate.
|
||||
#
|
||||
# Usage:
|
||||
# .\Set-CoveragePipeAcl.ps1 -SessionId aabbccdd11223344
|
||||
# .\Set-CoveragePipeAcl.ps1 -PipeName '\\.\pipe\CodeCoverage.pipe.aabbccdd11223344'
|
||||
|
||||
[CmdletBinding(DefaultParameterSetName = 'SessionId')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'SessionId')]
|
||||
[string]$SessionId,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'PipeName')]
|
||||
[string]$PipeName
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($PSCmdlet.ParameterSetName -eq 'SessionId') {
|
||||
$PipeName = "\\.\pipe\CodeCoverage.pipe.$SessionId"
|
||||
}
|
||||
|
||||
# P/Invoke: open the pipe with WRITE_DAC and hand it a NULL DACL via SetSecurityInfo.
|
||||
# (Add-Type compiles this with the in-box .NET Framework C# compiler on any Windows agent.)
|
||||
if (-not ('WinUI.Coverage.PipeAcl' -as [type])) {
|
||||
Add-Type -Namespace 'WinUI.Coverage' -Name 'PipeAcl' -MemberDefinition @'
|
||||
[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
|
||||
public static extern System.IntPtr CreateFileW(
|
||||
string lpFileName, uint dwDesiredAccess, uint dwShareMode, System.IntPtr lpSecurityAttributes,
|
||||
uint dwCreationDisposition, uint dwFlagsAndAttributes, System.IntPtr hTemplateFile);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("advapi32.dll", SetLastError = false)]
|
||||
public static extern uint SetSecurityInfo(
|
||||
System.IntPtr handle, int ObjectType, uint SecurityInfo,
|
||||
System.IntPtr psidOwner, System.IntPtr psidGroup, System.IntPtr pDacl, System.IntPtr pSacl);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool CloseHandle(System.IntPtr hObject);
|
||||
|
||||
// Open an existing pipe for WRITE_DAC | READ_CONTROL and set a NULL (allow-all) DACL.
|
||||
public static uint SetNullDacl(string pipeName)
|
||||
{
|
||||
const uint WRITE_DAC = 0x00040000;
|
||||
const uint READ_CONTROL = 0x00020000;
|
||||
const uint OPEN_EXISTING = 3;
|
||||
const int SE_KERNEL_OBJECT = 6;
|
||||
const uint DACL_SECURITY_INFORMATION = 0x00000004;
|
||||
|
||||
System.IntPtr h = CreateFileW(pipeName, WRITE_DAC | READ_CONTROL, 0, System.IntPtr.Zero,
|
||||
OPEN_EXISTING, 0, System.IntPtr.Zero);
|
||||
if (h == new System.IntPtr(-1))
|
||||
{
|
||||
throw new System.ComponentModel.Win32Exception(
|
||||
System.Runtime.InteropServices.Marshal.GetLastWin32Error(),
|
||||
"Failed to open pipe " + pipeName);
|
||||
}
|
||||
try
|
||||
{
|
||||
// pDacl = NULL with DACL_SECURITY_INFORMATION set => NULL DACL (everyone allowed).
|
||||
return SetSecurityInfo(h, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION,
|
||||
System.IntPtr.Zero, System.IntPtr.Zero, System.IntPtr.Zero, System.IntPtr.Zero);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(h);
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
Write-Warning "Opening coverage pipe '$PipeName' with a NULL DACL - every local user can access it until the collector exits. Fine on an ephemeral CI agent; avoid running this on a shared machine."
|
||||
Write-Host "Opening pipe: $PipeName"
|
||||
$result = [WinUI.Coverage.PipeAcl]::SetNullDacl($PipeName)
|
||||
if ($result -ne 0) {
|
||||
throw "SetSecurityInfo failed on '$PipeName' with error $result"
|
||||
}
|
||||
Write-Host "Successfully set NULL DACL on pipe"
|
||||
@@ -0,0 +1,255 @@
|
||||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT License.
|
||||
#
|
||||
# Split-CoverageByModule.ps1
|
||||
#
|
||||
# Companion to Repair-CoverageDuplicates.ps1. That script applies the duplicate-line dedup fix
|
||||
# across the WHOLE merged Cobertura file, producing a single aggregate number for every
|
||||
# instrumented module combined. When a coverage run instruments MORE THAN ONE product DLL (e.g.
|
||||
# Microsoft.ui.xaml.dll AND Microsoft.UI.Xaml.Controls.dll), the raw merge keeps them in separate
|
||||
# <package> elements - this script preserves that split, applying the exact same
|
||||
# (file, line-number) dedup INDEPENDENTLY per package/module, so each DLL gets its own
|
||||
# ADO-tab-accurate coverage number and per-file breakdown.
|
||||
#
|
||||
# Output (under -OutputDir):
|
||||
# <Module>\corrected-summary.json / .csv
|
||||
# <Module>\corrected-per-file.csv
|
||||
# <Module>\corrected.cobertura.xml
|
||||
# per-module-summary.csv - one row per module, side by side
|
||||
#
|
||||
# NOTE on totals: summing the per-module line totals will NOT necessarily equal the combined
|
||||
# (all-modules) total from Repair-CoverageDuplicates.ps1, because a source file (typically a
|
||||
# shared header) can legitimately be compiled into more than one module - the combined run
|
||||
# dedups that file's lines once globally, whereas each module counts its own copy. Both numbers
|
||||
# are correct for what they measure; use the per-module number when asking "how well is THIS DLL
|
||||
# covered".
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Path to the (buggy, duplicate-line) merged Cobertura XML produced by
|
||||
# `dotnet-coverage merge ... -f cobertura` / Microsoft.CodeCoverage.Console.exe.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InputCobertura,
|
||||
|
||||
# Where to write the per-module folders and per-module-summary.csv.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDir,
|
||||
|
||||
# Optional filter: only emit modules whose <package name> matches one of these (matching is
|
||||
# case-insensitive and ignores a trailing ".dll", so "Microsoft.UI.Xaml.Controls.dll" matches
|
||||
# a package named "Microsoft.ui.xaml.controls"). Omit to emit every module found.
|
||||
[string[]]$Modules = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Xml
|
||||
|
||||
if (-not (Test-Path $InputCobertura)) {
|
||||
throw "Input Cobertura file not found: $InputCobertura"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
|
||||
function Get-NormalizedModuleName {
|
||||
param([string]$Name)
|
||||
return ($Name -replace '\.dll$', '').ToLowerInvariant()
|
||||
}
|
||||
|
||||
$wanted = @()
|
||||
foreach ($m in $Modules) { if ($m) { $wanted += (Get-NormalizedModuleName $m) } }
|
||||
|
||||
Write-Host "Streaming and deduplicating (per module) $InputCobertura ..."
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
# module -> (file -> (line -> hits))
|
||||
$moduleMap = New-Object 'System.Collections.Generic.Dictionary[string,object]'
|
||||
$moduleOrder = New-Object 'System.Collections.Generic.List[string]'
|
||||
# module -> ordered file list
|
||||
$moduleFileOrder = New-Object 'System.Collections.Generic.Dictionary[string,object]'
|
||||
|
||||
$reader = [System.Xml.XmlReader]::Create($InputCobertura)
|
||||
$currentModule = $null
|
||||
$currentFileMap = $null
|
||||
$currentFileOrder = $null
|
||||
$currentDict = $null
|
||||
$classCount = 0
|
||||
$lineCount = 0
|
||||
try {
|
||||
while ($reader.Read()) {
|
||||
if ($reader.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
|
||||
switch ($reader.Name) {
|
||||
'package' {
|
||||
$currentModule = $reader.GetAttribute('name')
|
||||
if (-not $currentModule) { $currentModule = '(unnamed)' }
|
||||
if (-not $moduleMap.ContainsKey($currentModule)) {
|
||||
$moduleMap[$currentModule] = New-Object 'System.Collections.Generic.Dictionary[string,System.Collections.Generic.Dictionary[int,int]]'
|
||||
$moduleFileOrder[$currentModule] = New-Object 'System.Collections.Generic.List[string]'
|
||||
$moduleOrder.Add($currentModule)
|
||||
}
|
||||
$currentFileMap = $moduleMap[$currentModule]
|
||||
$currentFileOrder = $moduleFileOrder[$currentModule]
|
||||
$currentDict = $null
|
||||
}
|
||||
'class' {
|
||||
$classCount++
|
||||
$fn = $reader.GetAttribute('filename')
|
||||
if (-not $fn) { $fn = $reader.GetAttribute('name') }
|
||||
if ($null -eq $currentFileMap) { continue }
|
||||
if (-not $currentFileMap.TryGetValue($fn, [ref]$currentDict)) {
|
||||
$currentDict = New-Object 'System.Collections.Generic.Dictionary[int,int]'
|
||||
$currentFileMap[$fn] = $currentDict
|
||||
$currentFileOrder.Add($fn)
|
||||
}
|
||||
}
|
||||
'line' {
|
||||
if ($null -eq $currentDict) { continue }
|
||||
$lineCount++
|
||||
$num = [int]$reader.GetAttribute('number')
|
||||
$hits = [int]$reader.GetAttribute('hits')
|
||||
$existing = 0
|
||||
if ($currentDict.TryGetValue($num, [ref]$existing)) {
|
||||
if ($hits -gt $existing) { $currentDict[$num] = $hits }
|
||||
} else {
|
||||
$currentDict[$num] = $hits
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$reader.Close()
|
||||
}
|
||||
|
||||
Write-Host ("Parsed {0} module(s), {1} <class> and {2} <line> elements in {3:N1}s." -f $moduleOrder.Count, $classCount, $lineCount, $sw.Elapsed.TotalSeconds)
|
||||
Write-Host ("Modules found: {0}" -f ($moduleOrder -join ', '))
|
||||
|
||||
$perModuleRows = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
foreach ($module in $moduleOrder) {
|
||||
if ($wanted.Count -gt 0 -and -not ($wanted -contains (Get-NormalizedModuleName $module))) {
|
||||
Write-Host "Skipping module '$module' (not in -Modules filter)."
|
||||
continue
|
||||
}
|
||||
|
||||
$fileMap = $moduleMap[$module]
|
||||
$fileOrder = $moduleFileOrder[$module]
|
||||
$safeName = ($module -replace '[\\/:*?"<>|]', '_')
|
||||
$moduleDir = Join-Path $OutputDir $safeName
|
||||
New-Item -ItemType Directory -Force -Path $moduleDir | Out-Null
|
||||
|
||||
$perFile = New-Object System.Collections.Generic.List[object]
|
||||
$overallTotal = 0
|
||||
$overallCovered = 0
|
||||
foreach ($fn in $fileOrder) {
|
||||
$dict = $fileMap[$fn]
|
||||
$total = $dict.Count
|
||||
$covered = 0
|
||||
foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } }
|
||||
$overallTotal += $total
|
||||
$overallCovered += $covered
|
||||
$perFile.Add([pscustomobject]@{
|
||||
File = $fn
|
||||
CoveredLines = $covered
|
||||
TotalLines = $total
|
||||
LineCoverage = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 }
|
||||
})
|
||||
}
|
||||
|
||||
$overallRate = if ($overallTotal -gt 0) { $overallCovered / $overallTotal } else { 0 }
|
||||
$summary = [pscustomobject]@{
|
||||
Modules = $fileOrder.Count
|
||||
TotalLines = $overallTotal
|
||||
CoveredLines = $overallCovered
|
||||
LineCoverage = [math]::Round(100.0 * $overallRate, 2)
|
||||
InstrumentedModules = @($module)
|
||||
SourceFile = (Resolve-Path $InputCobertura).Path
|
||||
GeneratedAt = (Get-Date).ToString('o')
|
||||
}
|
||||
|
||||
$summary | ConvertTo-Json | Set-Content (Join-Path $moduleDir 'corrected-summary.json')
|
||||
$summary | Export-Csv (Join-Path $moduleDir 'corrected-summary.csv') -NoTypeInformation
|
||||
$perFile | Sort-Object File | Export-Csv (Join-Path $moduleDir 'corrected-per-file.csv') -NoTypeInformation
|
||||
|
||||
$correctedXmlPath = Join-Path $moduleDir 'corrected.cobertura.xml'
|
||||
$xmlSettings = New-Object System.Xml.XmlWriterSettings
|
||||
$xmlSettings.Indent = $false
|
||||
$xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$writer = [System.Xml.XmlWriter]::Create($correctedXmlPath, $xmlSettings)
|
||||
try {
|
||||
$writer.WriteStartDocument()
|
||||
$writer.WriteStartElement('coverage')
|
||||
$writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture))
|
||||
$writer.WriteAttributeString('branch-rate', '0')
|
||||
$writer.WriteAttributeString('lines-covered', $overallCovered)
|
||||
$writer.WriteAttributeString('lines-valid', $overallTotal)
|
||||
$writer.WriteAttributeString('branches-covered', '0')
|
||||
$writer.WriteAttributeString('branches-valid', '0')
|
||||
$writer.WriteAttributeString('complexity', '0')
|
||||
$writer.WriteAttributeString('version', '1.9')
|
||||
$writer.WriteAttributeString('timestamp', [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString())
|
||||
$writer.WriteStartElement('sources')
|
||||
$writer.WriteElementString('source', '.')
|
||||
$writer.WriteEndElement()
|
||||
$writer.WriteStartElement('packages')
|
||||
$writer.WriteStartElement('package')
|
||||
$writer.WriteAttributeString('name', $module)
|
||||
$writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture))
|
||||
$writer.WriteAttributeString('branch-rate', '0')
|
||||
$writer.WriteAttributeString('complexity', '0')
|
||||
$writer.WriteStartElement('classes')
|
||||
foreach ($fn in $fileOrder) {
|
||||
$dict = $fileMap[$fn]
|
||||
$total = $dict.Count
|
||||
$covered = 0
|
||||
foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } }
|
||||
$rate = if ($total -gt 0) { $covered / $total } else { 0 }
|
||||
$writer.WriteStartElement('class')
|
||||
$writer.WriteAttributeString('name', $fn)
|
||||
$writer.WriteAttributeString('filename', $fn)
|
||||
$writer.WriteAttributeString('line-rate', $rate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture))
|
||||
$writer.WriteAttributeString('branch-rate', '0')
|
||||
$writer.WriteAttributeString('complexity', '0')
|
||||
$writer.WriteStartElement('methods')
|
||||
$writer.WriteEndElement()
|
||||
$writer.WriteStartElement('lines')
|
||||
foreach ($num in ($dict.Keys | Sort-Object)) {
|
||||
$writer.WriteStartElement('line')
|
||||
$writer.WriteAttributeString('number', $num)
|
||||
$writer.WriteAttributeString('hits', $dict[$num])
|
||||
$writer.WriteAttributeString('branch', 'false')
|
||||
$writer.WriteEndElement()
|
||||
}
|
||||
$writer.WriteEndElement() # lines
|
||||
$writer.WriteEndElement() # class
|
||||
}
|
||||
$writer.WriteEndElement() # classes
|
||||
$writer.WriteEndElement() # package
|
||||
$writer.WriteEndElement() # packages
|
||||
$writer.WriteEndElement() # coverage
|
||||
$writer.WriteEndDocument()
|
||||
} finally {
|
||||
$writer.Close()
|
||||
}
|
||||
|
||||
$perModuleRows.Add([pscustomobject]@{
|
||||
Module = $module
|
||||
Files = $fileOrder.Count
|
||||
TotalLines = $overallTotal
|
||||
CoveredLines = $overallCovered
|
||||
LineCoverage = $summary.LineCoverage
|
||||
OutputFolder = $moduleDir
|
||||
})
|
||||
|
||||
Write-Host ("`n=== {0} ===" -f $module)
|
||||
Write-Host (" Files: {0}" -f $fileOrder.Count)
|
||||
Write-Host (" Total lines: {0}" -f $overallTotal)
|
||||
Write-Host (" Covered lines: {0}" -f $overallCovered)
|
||||
Write-Host (" Line coverage: {0}%" -f $summary.LineCoverage)
|
||||
}
|
||||
|
||||
if ($perModuleRows.Count -eq 0) {
|
||||
throw "No modules were emitted. Modules present in the input were: $($moduleOrder -join ', '). Check the -Modules filter."
|
||||
}
|
||||
|
||||
$perModuleCsv = Join-Path $OutputDir 'per-module-summary.csv'
|
||||
$perModuleRows | Export-Csv $perModuleCsv -NoTypeInformation
|
||||
Write-Host "`nWrote per-module summary: $perModuleCsv"
|
||||
@@ -9,8 +9,6 @@ namespace winrt::TableViewAppCppPackaged::implementation
|
||||
{
|
||||
App::App()
|
||||
{
|
||||
HighContrastAdjustment(ApplicationHighContrastAdjustment::None);
|
||||
|
||||
#if defined _DEBUG && !defined DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION
|
||||
UnhandledException([](IInspectable const&, UnhandledExceptionEventArgs const& e)
|
||||
{
|
||||
|
||||
@@ -9,8 +9,6 @@ namespace winrt::TableViewAppCppUnpackaged::implementation
|
||||
{
|
||||
App::App()
|
||||
{
|
||||
HighContrastAdjustment(ApplicationHighContrastAdjustment::None);
|
||||
|
||||
#if defined _DEBUG && !defined DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION
|
||||
UnhandledException([](IInspectable const&, UnhandledExceptionEventArgs const& e)
|
||||
{
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace TableViewAppCsPackaged
|
||||
public App()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
HighContrastAdjustment = ApplicationHighContrastAdjustment.None;
|
||||
}
|
||||
|
||||
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace TableViewAppCsUnpackaged
|
||||
public App()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
HighContrastAdjustment = ApplicationHighContrastAdjustment.None;
|
||||
}
|
||||
|
||||
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
|
||||
|
||||
@@ -30,7 +30,6 @@ public partial class App : Application
|
||||
{
|
||||
_commandLineArguments = commandLineArguments ?? Array.Empty<string>();
|
||||
InitializeComponent();
|
||||
HighContrastAdjustment = ApplicationHighContrastAdjustment.None;
|
||||
}
|
||||
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
|
||||
@@ -62,14 +62,14 @@
|
||||
<ColumnDefinition x:Name="OptionsColumn" Width="0"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Example (live demo). No card frame: tables draw their own border. -->
|
||||
<!-- No card frame: tables draw their own border. -->
|
||||
<!-- No x:Name on the InfoBars: WinUI reports an unset AutomationId as the x:Name. -->
|
||||
<Grid x:Name="ExampleGrid" Grid.Row="0" Grid.Column="0" RowSpacing="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition x:Name="ExampleHostRow" Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<!-- Not an InfoBar: it announces on open (64477279). Unnamed: WinUI reports an unset AutomationId as the x:Name. -->
|
||||
<!-- Not an InfoBar, which announces on open. Unnamed for the same AutomationId reason. -->
|
||||
<Border
|
||||
Grid.Row="0"
|
||||
MinHeight="104"
|
||||
|
||||
@@ -73,12 +73,13 @@
|
||||
<!-- Group-header row surfaces (P2.8 grouped rows) -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceGroupHeaderBackgroundBrush" Color="#4C3A3A3A" /> <!-- LayerFillColorDefault -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceGroupHeaderForegroundBrush" Color="#FFFFFF" /> <!-- TextFillColorPrimary -->
|
||||
<x:Double x:Key="TabularSurfaceGroupHeaderCountOpacity">0.7</x:Double>
|
||||
<x:Double x:Key="TabularSurfaceGroupHeaderCountOpacity">0.77</x:Double> <!-- TextFillColorSecondary over TextFillColorPrimary -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceGroupHeaderBorderBrush" Color="Transparent" />
|
||||
<Thickness x:Key="TabularSurfaceGroupHeaderBorderThickness">0</Thickness>
|
||||
|
||||
<!-- Grid lines -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceGridLineBrush" Color="#15FFFFFF" /> <!-- DividerStrokeColorDefault -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceOuterBorderBrush" Color="#19000000" /> <!-- CardStrokeColorDefault -->
|
||||
</ResourceDictionary>
|
||||
|
||||
<ResourceDictionary x:Key="Light">
|
||||
@@ -119,12 +120,13 @@
|
||||
<!-- Group header surfaces (parity with Default theme) -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceGroupHeaderBackgroundBrush" Color="#14000000" /> <!-- subtle group-header band, a touch darker than the rows (Light) -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceGroupHeaderForegroundBrush" Color="#E4000000" /> <!-- TextFillColorPrimary (Light) -->
|
||||
<x:Double x:Key="TabularSurfaceGroupHeaderCountOpacity">0.7</x:Double>
|
||||
<x:Double x:Key="TabularSurfaceGroupHeaderCountOpacity">0.7</x:Double> <!-- TextFillColorSecondary over TextFillColorPrimary (Light) -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceGroupHeaderBorderBrush" Color="Transparent" />
|
||||
<Thickness x:Key="TabularSurfaceGroupHeaderBorderThickness">0</Thickness>
|
||||
|
||||
<!-- Grid lines -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceGridLineBrush" Color="#0F000000" /> <!-- DividerStrokeColorDefault (Light) -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceOuterBorderBrush" Color="#0F000000" /> <!-- CardStrokeColorDefault (Light) -->
|
||||
</ResourceDictionary>
|
||||
|
||||
<ResourceDictionary x:Key="HighContrast">
|
||||
@@ -167,6 +169,7 @@
|
||||
|
||||
<!-- Grid lines -->
|
||||
<SolidColorBrush x:Key="TabularSurfaceGridLineBrush" Color="{ThemeResource SystemColorWindowTextColor}" />
|
||||
<SolidColorBrush x:Key="TabularSurfaceOuterBorderBrush" Color="{ThemeResource SystemColorWindowTextColor}" />
|
||||
</ResourceDictionary>
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
</ResourceDictionary>
|
||||
@@ -16,6 +16,7 @@ namespace winrt::Microsoft::UI::Xaml::Controls::Tabular
|
||||
GlobalDependencyProperty TableViewProperties::s_AlternatingRowBackgroundProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewProperties::s_CanUserResizeColumnsProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewProperties::s_CanUserSortColumnsProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewProperties::s_ColumnHeaderStyleProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewProperties::s_ColumnsProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewProperties::s_DensityProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewProperties::s_EmptyTemplateProperty{ nullptr };
|
||||
@@ -74,6 +75,17 @@ void TableViewProperties::EnsureProperties()
|
||||
ValueHelper<bool>::BoxValueIfNecessary(true),
|
||||
winrt::PropertyChangedCallback(&OnCanUserSortColumnsPropertyChanged));
|
||||
}
|
||||
if (!s_ColumnHeaderStyleProperty)
|
||||
{
|
||||
s_ColumnHeaderStyleProperty =
|
||||
InitializeDependencyProperty(
|
||||
L"ColumnHeaderStyle",
|
||||
winrt::name_of<winrt::Style>(),
|
||||
winrt::name_of<winrt::TableView>(),
|
||||
false /* isAttached */,
|
||||
ValueHelper<winrt::Style>::BoxedDefaultValue(),
|
||||
winrt::PropertyChangedCallback(&OnColumnHeaderStylePropertyChanged));
|
||||
}
|
||||
if (!s_ColumnsProperty)
|
||||
{
|
||||
s_ColumnsProperty =
|
||||
@@ -213,6 +225,7 @@ void TableViewProperties::ClearProperties()
|
||||
s_AlternatingRowBackgroundProperty = nullptr;
|
||||
s_CanUserResizeColumnsProperty = nullptr;
|
||||
s_CanUserSortColumnsProperty = nullptr;
|
||||
s_ColumnHeaderStyleProperty = nullptr;
|
||||
s_ColumnsProperty = nullptr;
|
||||
s_DensityProperty = nullptr;
|
||||
s_EmptyTemplateProperty = nullptr;
|
||||
@@ -251,6 +264,14 @@ void TableViewProperties::OnCanUserSortColumnsPropertyChanged(
|
||||
winrt::get_self<TableView>(owner)->OnCanUserSortColumnsPropertyChanged(args);
|
||||
}
|
||||
|
||||
void TableViewProperties::OnColumnHeaderStylePropertyChanged(
|
||||
winrt::DependencyObject const& sender,
|
||||
winrt::DependencyPropertyChangedEventArgs const& args)
|
||||
{
|
||||
auto owner = sender.as<winrt::TableView>();
|
||||
winrt::get_self<TableView>(owner)->OnColumnHeaderStylePropertyChanged(args);
|
||||
}
|
||||
|
||||
void TableViewProperties::OnColumnsPropertyChanged(
|
||||
winrt::DependencyObject const& sender,
|
||||
winrt::DependencyPropertyChangedEventArgs const& args)
|
||||
@@ -370,6 +391,19 @@ bool TableViewProperties::CanUserSortColumns()
|
||||
return ValueHelper<bool>::CastOrUnbox(static_cast<TableView*>(this)->GetValue(s_CanUserSortColumnsProperty));
|
||||
}
|
||||
|
||||
void TableViewProperties::ColumnHeaderStyle(winrt::Style const& value)
|
||||
{
|
||||
[[gsl::suppress(con)]]
|
||||
{
|
||||
static_cast<TableView*>(this)->SetValue(s_ColumnHeaderStyleProperty, ValueHelper<winrt::Style>::BoxValueIfNecessary(value));
|
||||
}
|
||||
}
|
||||
|
||||
winrt::Style TableViewProperties::ColumnHeaderStyle()
|
||||
{
|
||||
return ValueHelper<winrt::Style>::CastOrUnbox(static_cast<TableView*>(this)->GetValue(s_ColumnHeaderStyleProperty));
|
||||
}
|
||||
|
||||
void TableViewProperties::Columns(winrt::IVector<winrt::TableViewColumn> const& value)
|
||||
{
|
||||
[[gsl::suppress(con)]]
|
||||
|
||||
@@ -18,6 +18,9 @@ public:
|
||||
void CanUserSortColumns(bool value);
|
||||
bool CanUserSortColumns();
|
||||
|
||||
void ColumnHeaderStyle(winrt::Style const& value);
|
||||
winrt::Style ColumnHeaderStyle();
|
||||
|
||||
void Columns(winrt::IVector<winrt::TableViewColumn> const& value);
|
||||
winrt::IVector<winrt::TableViewColumn> Columns();
|
||||
|
||||
@@ -57,6 +60,7 @@ public:
|
||||
static winrt::DependencyProperty AlternatingRowBackgroundProperty() { return s_AlternatingRowBackgroundProperty; }
|
||||
static winrt::DependencyProperty CanUserResizeColumnsProperty() { return s_CanUserResizeColumnsProperty; }
|
||||
static winrt::DependencyProperty CanUserSortColumnsProperty() { return s_CanUserSortColumnsProperty; }
|
||||
static winrt::DependencyProperty ColumnHeaderStyleProperty() { return s_ColumnHeaderStyleProperty; }
|
||||
static winrt::DependencyProperty ColumnsProperty() { return s_ColumnsProperty; }
|
||||
static winrt::DependencyProperty DensityProperty() { return s_DensityProperty; }
|
||||
static winrt::DependencyProperty EmptyTemplateProperty() { return s_EmptyTemplateProperty; }
|
||||
@@ -73,6 +77,7 @@ public:
|
||||
static GlobalDependencyProperty s_AlternatingRowBackgroundProperty;
|
||||
static GlobalDependencyProperty s_CanUserResizeColumnsProperty;
|
||||
static GlobalDependencyProperty s_CanUserSortColumnsProperty;
|
||||
static GlobalDependencyProperty s_ColumnHeaderStyleProperty;
|
||||
static GlobalDependencyProperty s_ColumnsProperty;
|
||||
static GlobalDependencyProperty s_DensityProperty;
|
||||
static GlobalDependencyProperty s_EmptyTemplateProperty;
|
||||
@@ -118,6 +123,10 @@ public:
|
||||
winrt::DependencyObject const& sender,
|
||||
winrt::DependencyPropertyChangedEventArgs const& args);
|
||||
|
||||
static void OnColumnHeaderStylePropertyChanged(
|
||||
winrt::DependencyObject const& sender,
|
||||
winrt::DependencyPropertyChangedEventArgs const& args);
|
||||
|
||||
static void OnColumnsPropertyChanged(
|
||||
winrt::DependencyObject const& sender,
|
||||
winrt::DependencyPropertyChangedEventArgs const& args);
|
||||
|
||||
@@ -21,6 +21,7 @@ GlobalDependencyProperty TableViewColumnProperties::s_CellEditingTemplatePropert
|
||||
GlobalDependencyProperty TableViewColumnProperties::s_FrozenEdgeProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewColumnProperties::s_HeaderProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewColumnProperties::s_HeaderContextFlyoutProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewColumnProperties::s_HeaderStyleProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewColumnProperties::s_HeaderTemplateProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewColumnProperties::s_HeaderTemplateSelectorProperty{ nullptr };
|
||||
GlobalDependencyProperty TableViewColumnProperties::s_HeaderToolTipProperty{ nullptr };
|
||||
@@ -128,6 +129,17 @@ void TableViewColumnProperties::EnsureProperties()
|
||||
ValueHelper<winrt::FlyoutBase>::BoxedDefaultValue(),
|
||||
nullptr);
|
||||
}
|
||||
if (!s_HeaderStyleProperty)
|
||||
{
|
||||
s_HeaderStyleProperty =
|
||||
InitializeDependencyProperty(
|
||||
L"HeaderStyle",
|
||||
winrt::name_of<winrt::Style>(),
|
||||
winrt::name_of<winrt::TableViewColumn>(),
|
||||
false /* isAttached */,
|
||||
ValueHelper<winrt::Style>::BoxedDefaultValue(),
|
||||
winrt::PropertyChangedCallback(&OnHeaderStylePropertyChanged));
|
||||
}
|
||||
if (!s_HeaderTemplateProperty)
|
||||
{
|
||||
s_HeaderTemplateProperty =
|
||||
@@ -261,6 +273,7 @@ void TableViewColumnProperties::ClearProperties()
|
||||
s_FrozenEdgeProperty = nullptr;
|
||||
s_HeaderProperty = nullptr;
|
||||
s_HeaderContextFlyoutProperty = nullptr;
|
||||
s_HeaderStyleProperty = nullptr;
|
||||
s_HeaderTemplateProperty = nullptr;
|
||||
s_HeaderTemplateSelectorProperty = nullptr;
|
||||
s_HeaderToolTipProperty = nullptr;
|
||||
@@ -314,6 +327,14 @@ void TableViewColumnProperties::OnHeaderPropertyChanged(
|
||||
winrt::get_self<TableViewColumn>(owner)->OnPropertyChanged(args);
|
||||
}
|
||||
|
||||
void TableViewColumnProperties::OnHeaderStylePropertyChanged(
|
||||
winrt::DependencyObject const& sender,
|
||||
winrt::DependencyPropertyChangedEventArgs const& args)
|
||||
{
|
||||
auto owner = sender.as<winrt::TableViewColumn>();
|
||||
winrt::get_self<TableViewColumn>(owner)->OnPropertyChanged(args);
|
||||
}
|
||||
|
||||
void TableViewColumnProperties::OnHeaderTemplatePropertyChanged(
|
||||
winrt::DependencyObject const& sender,
|
||||
winrt::DependencyPropertyChangedEventArgs const& args)
|
||||
@@ -482,6 +503,19 @@ winrt::FlyoutBase TableViewColumnProperties::HeaderContextFlyout()
|
||||
return ValueHelper<winrt::FlyoutBase>::CastOrUnbox(static_cast<TableViewColumn*>(this)->GetValue(s_HeaderContextFlyoutProperty));
|
||||
}
|
||||
|
||||
void TableViewColumnProperties::HeaderStyle(winrt::Style const& value)
|
||||
{
|
||||
[[gsl::suppress(con)]]
|
||||
{
|
||||
static_cast<TableViewColumn*>(this)->SetValue(s_HeaderStyleProperty, ValueHelper<winrt::Style>::BoxValueIfNecessary(value));
|
||||
}
|
||||
}
|
||||
|
||||
winrt::Style TableViewColumnProperties::HeaderStyle()
|
||||
{
|
||||
return ValueHelper<winrt::Style>::CastOrUnbox(static_cast<TableViewColumn*>(this)->GetValue(s_HeaderStyleProperty));
|
||||
}
|
||||
|
||||
void TableViewColumnProperties::HeaderTemplate(winrt::DataTemplate const& value)
|
||||
{
|
||||
[[gsl::suppress(con)]]
|
||||
|
||||
@@ -33,6 +33,9 @@ public:
|
||||
void HeaderContextFlyout(winrt::FlyoutBase const& value);
|
||||
winrt::FlyoutBase HeaderContextFlyout();
|
||||
|
||||
void HeaderStyle(winrt::Style const& value);
|
||||
winrt::Style HeaderStyle();
|
||||
|
||||
void HeaderTemplate(winrt::DataTemplate const& value);
|
||||
winrt::DataTemplate HeaderTemplate();
|
||||
|
||||
@@ -74,6 +77,7 @@ public:
|
||||
static winrt::DependencyProperty FrozenEdgeProperty() { return s_FrozenEdgeProperty; }
|
||||
static winrt::DependencyProperty HeaderProperty() { return s_HeaderProperty; }
|
||||
static winrt::DependencyProperty HeaderContextFlyoutProperty() { return s_HeaderContextFlyoutProperty; }
|
||||
static winrt::DependencyProperty HeaderStyleProperty() { return s_HeaderStyleProperty; }
|
||||
static winrt::DependencyProperty HeaderTemplateProperty() { return s_HeaderTemplateProperty; }
|
||||
static winrt::DependencyProperty HeaderTemplateSelectorProperty() { return s_HeaderTemplateSelectorProperty; }
|
||||
static winrt::DependencyProperty HeaderToolTipProperty() { return s_HeaderToolTipProperty; }
|
||||
@@ -94,6 +98,7 @@ public:
|
||||
static GlobalDependencyProperty s_FrozenEdgeProperty;
|
||||
static GlobalDependencyProperty s_HeaderProperty;
|
||||
static GlobalDependencyProperty s_HeaderContextFlyoutProperty;
|
||||
static GlobalDependencyProperty s_HeaderStyleProperty;
|
||||
static GlobalDependencyProperty s_HeaderTemplateProperty;
|
||||
static GlobalDependencyProperty s_HeaderTemplateSelectorProperty;
|
||||
static GlobalDependencyProperty s_HeaderToolTipProperty;
|
||||
@@ -129,6 +134,10 @@ public:
|
||||
winrt::DependencyObject const& sender,
|
||||
winrt::DependencyPropertyChangedEventArgs const& args);
|
||||
|
||||
static void OnHeaderStylePropertyChanged(
|
||||
winrt::DependencyObject const& sender,
|
||||
winrt::DependencyPropertyChangedEventArgs const& args);
|
||||
|
||||
static void OnHeaderTemplatePropertyChanged(
|
||||
winrt::DependencyObject const& sender,
|
||||
winrt::DependencyPropertyChangedEventArgs const& args);
|
||||
|
||||
@@ -696,6 +696,7 @@ void NavigationView::OnApplyTemplate()
|
||||
m_shadowCasterEaseOutStoryboard.set(GetTemplateChildT<winrt::Storyboard>(c_shadowCasterEaseOutStoryboard, controlProtected));
|
||||
|
||||
m_appliedTemplate = true;
|
||||
m_templateAppliedOnce = true;
|
||||
|
||||
// Do initial setup
|
||||
UpdatePaneDisplayMode();
|
||||
@@ -4224,7 +4225,12 @@ void NavigationView::OnPropertyChanged(const winrt::DependencyPropertyChangedEve
|
||||
{
|
||||
// m_wasForceClosed is set to true because ToggleButton is clicked and Pane is closed.
|
||||
// When PaneDisplayMode is changed, reset the force flag to make the Pane can be opened automatically again.
|
||||
m_wasForceClosed = false;
|
||||
// Before the template is first applied, the flag can only have come from the app setting IsPaneOpen=false
|
||||
// (e.g. in XAML ahead of PaneDisplayMode), so keep it to honor that initial state. Re-templating still resets it.
|
||||
if (m_templateAppliedOnce)
|
||||
{
|
||||
m_wasForceClosed = false;
|
||||
}
|
||||
|
||||
UpdatePaneToggleButtonVisibility();
|
||||
UpdatePaneDisplayMode(auto_unbox(args.OldValue()), auto_unbox(args.NewValue()));
|
||||
|
||||
@@ -482,6 +482,8 @@ private:
|
||||
NavigationRecommendedTransitionDirection m_pendingSelectionChangedDirection{ NavigationRecommendedTransitionDirection::Default };
|
||||
|
||||
bool m_appliedTemplate{ false };
|
||||
// Unlike m_appliedTemplate, this is never reset, so it tells first-time setup apart from re-templating.
|
||||
bool m_templateAppliedOnce{ false };
|
||||
|
||||
// Identifies whenever a call is the result of OnApplyTemplate
|
||||
bool m_fromOnApplyTemplate{ false };
|
||||
|
||||
@@ -543,6 +543,144 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies IsPaneOpen=false set before PaneDisplayMode=Left is respected on launch (GitHub #7609).")]
|
||||
public void VerifyIsPaneOpenFalseRespectedWhenSetBeforePaneDisplayModeLeft()
|
||||
{
|
||||
Log.Comment("Code: IsPaneOpen=false, then PaneDisplayMode=Left");
|
||||
VerifyLaunchState(() =>
|
||||
{
|
||||
var navView = new NavigationView();
|
||||
navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" });
|
||||
navView.IsPaneOpen = false;
|
||||
navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left;
|
||||
return navView;
|
||||
}, expectedIsPaneOpen: false);
|
||||
|
||||
Log.Comment("XAML: IsPaneOpen='False' PaneDisplayMode='Left'");
|
||||
VerifyLaunchState(() =>
|
||||
{
|
||||
return (NavigationView)XamlReader.Load(@"
|
||||
<controls:NavigationView
|
||||
xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'
|
||||
xmlns:controls='using:Microsoft.UI.Xaml.Controls'
|
||||
IsPaneOpen='False'
|
||||
PaneDisplayMode='Left'>
|
||||
<controls:NavigationView.MenuItems>
|
||||
<controls:NavigationViewItem Content='MenuItem' />
|
||||
</controls:NavigationView.MenuItems>
|
||||
</controls:NavigationView>");
|
||||
}, expectedIsPaneOpen: false);
|
||||
|
||||
Log.Comment("Code: IsPaneOpen=false, PaneDisplayMode=LeftCompact, then PaneDisplayMode=Left");
|
||||
VerifyLaunchState(() =>
|
||||
{
|
||||
var navView = new NavigationView();
|
||||
navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" });
|
||||
navView.IsPaneOpen = false;
|
||||
navView.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact;
|
||||
navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left;
|
||||
return navView;
|
||||
}, expectedIsPaneOpen: false);
|
||||
|
||||
Log.Comment("Code: IsPaneOpen not set, PaneDisplayMode=Left (default open pane is unchanged)");
|
||||
VerifyLaunchState(() =>
|
||||
{
|
||||
var navView = new NavigationView();
|
||||
navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" });
|
||||
navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left;
|
||||
return navView;
|
||||
}, expectedIsPaneOpen: true);
|
||||
|
||||
Log.Comment("Code: IsPaneOpen=false, PaneDisplayMode=Left, then IsPaneOpen=true before load (open wins)");
|
||||
VerifyLaunchState(() =>
|
||||
{
|
||||
var navView = new NavigationView();
|
||||
navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" });
|
||||
navView.IsPaneOpen = false;
|
||||
navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left;
|
||||
navView.IsPaneOpen = true;
|
||||
return navView;
|
||||
}, expectedIsPaneOpen: true);
|
||||
|
||||
Log.Comment("Code: IsPaneOpen=false, PaneDisplayMode=LeftCompact, then Auto launching in Expanded");
|
||||
VerifyLaunchState(() =>
|
||||
{
|
||||
var navView = new NavigationView();
|
||||
navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" });
|
||||
navView.IsPaneOpen = false;
|
||||
navView.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact;
|
||||
navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Auto;
|
||||
navView.ExpandedModeThresholdWidth = 600.0;
|
||||
navView.CompactModeThresholdWidth = 400.0;
|
||||
navView.Width = 610.0;
|
||||
return navView;
|
||||
}, expectedIsPaneOpen: false);
|
||||
|
||||
Log.Comment("Runtime: after launching closed, LeftCompact -> Left opens the pane (post-template PaneDisplayMode change still clears the force-close flag)");
|
||||
var launchedNavViewCompact = VerifyLaunchState(() =>
|
||||
{
|
||||
var navView = new NavigationView();
|
||||
navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" });
|
||||
navView.IsPaneOpen = false;
|
||||
navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left;
|
||||
return navView;
|
||||
}, expectedIsPaneOpen: false);
|
||||
|
||||
RunOnUIThread.Execute(() => launchedNavViewCompact.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact);
|
||||
IdleSynchronizer.Wait();
|
||||
RunOnUIThread.Execute(() => launchedNavViewCompact.PaneDisplayMode = NavigationViewPaneDisplayMode.Left);
|
||||
IdleSynchronizer.Wait();
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(launchedNavViewCompact.IsPaneOpen, "NavigationView.IsPaneOpen after runtime LeftCompact -> Left");
|
||||
var splitView = VisualTreeUtils.FindVisualChildByName(launchedNavViewCompact, "RootSplitView") as SplitView;
|
||||
Verify.IsTrue(splitView.IsPaneOpen, "RootSplitView.IsPaneOpen after runtime LeftCompact -> Left");
|
||||
});
|
||||
|
||||
Log.Comment("Runtime: after launching closed, LeftMinimal -> Left opens the pane (by-design explicit OpenPane in UpdatePaneDisplayMode)");
|
||||
var launchedNavView = VerifyLaunchState(() =>
|
||||
{
|
||||
var navView = new NavigationView();
|
||||
navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" });
|
||||
navView.IsPaneOpen = false;
|
||||
navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left;
|
||||
return navView;
|
||||
}, expectedIsPaneOpen: false);
|
||||
|
||||
RunOnUIThread.Execute(() => launchedNavView.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftMinimal);
|
||||
IdleSynchronizer.Wait();
|
||||
RunOnUIThread.Execute(() => launchedNavView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left);
|
||||
IdleSynchronizer.Wait();
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(launchedNavView.IsPaneOpen, "Pane should open on a runtime LeftMinimal -> Left switch");
|
||||
});
|
||||
|
||||
NavigationView VerifyLaunchState(Func<NavigationView> createNavView, bool expectedIsPaneOpen)
|
||||
{
|
||||
NavigationView navView = null;
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
navView = createNavView();
|
||||
Content = navView;
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(NavigationViewDisplayMode.Expanded, navView.DisplayMode);
|
||||
Verify.AreEqual(expectedIsPaneOpen, navView.IsPaneOpen, "NavigationView.IsPaneOpen");
|
||||
|
||||
var splitView = VisualTreeUtils.FindVisualChildByName(navView, "RootSplitView") as SplitView;
|
||||
Verify.IsNotNull(splitView);
|
||||
Verify.AreEqual(expectedIsPaneOpen, splitView.IsPaneOpen, "RootSplitView.IsPaneOpen");
|
||||
});
|
||||
|
||||
return navView;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyDefaultsAndBasicSetting()
|
||||
{
|
||||
|
||||
@@ -782,6 +782,66 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies Sort(keySelector, None) removes the axis keyed by that delegate instance, a None for an unknown delegate leaves the sort unchanged, and ClearSort removes every key-selector axis.")]
|
||||
public void VerifyKeySelectorSortNoneFollowsDelegateIdentity()
|
||||
{
|
||||
TableView tableView = null;
|
||||
TableViewSource source = null;
|
||||
List<ShapedPerson> items = null;
|
||||
TableViewKeySelector byLength = o => Person(o).Name.Length;
|
||||
var byLengthAscending = new List<string> { "Mei", "Asha", "Rafa", "Ines", "Owen", "Diego" };
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
items = MakeShapedPeople();
|
||||
source = TableViewSource.From(items).Sort(byLength, SortDirection.Ascending);
|
||||
tableView = CreateShapedTable(source);
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifyProjection(tableView, byLengthAscending, "baseline: sorted by name length");
|
||||
TableViewKeySelector sameKeyOtherInstance = o => Person(o).Name.Length;
|
||||
source.Sort(sameKeyOtherInstance, SortDirection.None);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifyProjection(tableView, byLengthAscending, "None with a delegate that keys no axis must leave the sort unchanged");
|
||||
source.Sort(byLength, SortDirection.None);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifyProjection(tableView, items.Select(p => p.Name).ToList(), "None with the same delegate instance must remove its axis");
|
||||
source.Sort(byLength, SortDirection.Ascending).Sort(o => Person(o).Role, SortDirection.Descending);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var labels = GetProjectedLabels(tableView, includeCount: true);
|
||||
Verify.IsTrue(labels.First() == "Mei" && labels.Last() == "Diego", "Precondition: two key-selector axes, name length primary.");
|
||||
source.ClearSort();
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifyProjection(tableView, items.Select(p => p.Name).ToList(), "ClearSort must remove every key-selector axis");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies SortDirection.None removes that axis rather than seeding a stage.")]
|
||||
public void VerifySortDirectionNoneRemovesThatAxis()
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Tabular;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.UI.Xaml.Markup;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using MUXControlsTestApp.Utilities;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -632,6 +633,48 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies the public cell peer constructor rejects arguments that cannot describe a cell, and accepts ones that can.")]
|
||||
public void VerifyCellPeerConstructorValidatesArguments()
|
||||
{
|
||||
TableView tableView = null;
|
||||
TableView otherTable = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateAutomationTable();
|
||||
otherTable = CreateAutomationTable();
|
||||
var host = new StackPanel();
|
||||
host.Children.Add(tableView);
|
||||
host.Children.Add(otherTable);
|
||||
LoadContent(host);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var row = GetRealizedRows(tableView).First();
|
||||
var cell = GetRowCell(row, 0);
|
||||
var column = tableView.Columns[0];
|
||||
int count = tableView.Columns.Count;
|
||||
|
||||
Verify.Throws<ArgumentException>(() => new TableViewCellAutomationPeer(null, row, column, 0), "A null cell must be rejected.");
|
||||
Verify.Throws<ArgumentException>(() => new TableViewCellAutomationPeer(cell, null, column, 0), "A null row must be rejected.");
|
||||
Verify.Throws<ArgumentException>(() => new TableViewCellAutomationPeer(cell, row, null, 0), "A null column must be rejected.");
|
||||
Verify.Throws<ArgumentException>(() => new TableViewCellAutomationPeer(cell, row, column, -1), "A negative column index must be rejected.");
|
||||
Verify.Throws<ArgumentException>(() => new TableViewCellAutomationPeer(cell, row, column, count), "A column index past the table's columns must be rejected.");
|
||||
Verify.Throws<ArgumentException>(() => new TableViewCellAutomationPeer(cell, row, otherTable.Columns[0], 0), "A column from another table must be rejected.");
|
||||
|
||||
var peer = new TableViewCellAutomationPeer(cell, row, column, count - 1);
|
||||
Verify.IsNotNull(peer, "In-range arguments must construct a peer.");
|
||||
|
||||
// A row no TableView owns has no column set to check against, so only the index sign is enforced.
|
||||
Verify.IsNotNull(new TableViewCellAutomationPeer(new Grid(), new TableViewRow(), new TableViewTextColumn(), 3),
|
||||
"An unowned row must accept any non-negative index.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a cell peer whose row is gone throws ElementNotAvailable from GridItem.Row instead of reporting -1.")]
|
||||
public void VerifyStaleCellPeerGridItemRowThrowsElementNotAvailable()
|
||||
@@ -1627,6 +1670,411 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a header disabled by its HeaderStyle reports IsEnabled=false on its realized and table-owned peers and refuses Invoke.")]
|
||||
public void VerifyHeaderDisabledByStyleIsNotEnabledOrInvokable()
|
||||
{
|
||||
TableView tableView = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateAutomationTable();
|
||||
tableView.CanUserSortColumns = true;
|
||||
tableView.Columns[0].CanSort = true;
|
||||
var disabledStyle = new Style(typeof(TableViewColumnHeader));
|
||||
disabledStyle.Setters.Add(new Setter(Control.IsEnabledProperty, false));
|
||||
tableView.Columns[0].HeaderStyle = disabledStyle;
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var column = tableView.Columns[0];
|
||||
var header = GetHeaderCell(tableView, 0) as Control;
|
||||
Verify.IsNotNull(header, "Precondition: the header cell is a Control.");
|
||||
Verify.IsFalse(header.IsEnabled, "Precondition: the HeaderStyle disables the header.");
|
||||
|
||||
var peers = new (AutomationPeer Peer, string Kind)[]
|
||||
{
|
||||
(FrameworkElementAutomationPeer.CreatePeerForElement(header), "realized"),
|
||||
(new TableViewColumnHeaderAutomationPeer(tableView, column), "table-owned"),
|
||||
};
|
||||
foreach (var (peer, kind) in peers)
|
||||
{
|
||||
Verify.IsFalse(peer.IsEnabled(), $"The {kind} peer of a disabled header must report IsEnabled=false.");
|
||||
var invoke = peer.GetPattern(PatternInterface.Invoke) as IInvokeProvider;
|
||||
Verify.IsNotNull(invoke, $"Precondition: the {kind} peer offers Invoke for a sortable column.");
|
||||
VerifyFailsWithHResult(UIA_E_ELEMENTNOTENABLED, () => invoke.Invoke(),
|
||||
$"Invoke on the {kind} peer of a disabled header must fail.");
|
||||
}
|
||||
|
||||
Verify.AreEqual(SortDirection.None, column.SortDirection, "A disabled header must not sort through UIA.");
|
||||
});
|
||||
}
|
||||
|
||||
private const int UIA_E_ELEMENTNOTENABLED = unchecked((int)0x80040200);
|
||||
private const int UIA_E_INVALIDOPERATION = unchecked((int)0x80131509);
|
||||
private const int E_INVALIDARG = unchecked((int)0x80070057);
|
||||
|
||||
// By HRESULT rather than exception type: the projected type for a UIA_E_* code is not a contract.
|
||||
private static void VerifyFailsWithHResult(int expectedHResult, Action action, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Verify.AreEqual(expectedHResult, e.HResult, $"{message} ({e.GetType().Name})");
|
||||
return;
|
||||
}
|
||||
|
||||
Verify.Fail($"{message} The call did not fail.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies the Transform pattern is offered only for columns the user may resize, and never moves or rotates.")]
|
||||
public void VerifyTransformPatternTracksColumnResizability()
|
||||
{
|
||||
TableView tableView = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = TableViewSizingTestHelpers.CreateResizableTable(
|
||||
("First", new GridLength(150.0, GridUnitType.Pixel)),
|
||||
("Second", new GridLength(150.0, GridUnitType.Pixel)));
|
||||
tableView.Columns[1].CanResize = false;
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
// UIA requires Transform on a HeaderItem the user can resize.
|
||||
var transform = new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[0])
|
||||
.GetPattern(PatternInterface.Transform) as ITransformProvider;
|
||||
Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern.");
|
||||
Verify.IsTrue(transform.CanResize, "A resizable column must report CanResize.");
|
||||
Verify.IsFalse(transform.CanMove, "A column header cannot be moved through Transform.");
|
||||
Verify.IsFalse(transform.CanRotate, "A column header cannot be rotated.");
|
||||
|
||||
Verify.IsNull(new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[1]).GetPattern(PatternInterface.Transform),
|
||||
"Transform must be withheld for a column whose CanResize is false.");
|
||||
|
||||
tableView.CanUserResizeColumns = false;
|
||||
Verify.IsNull(new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[0]).GetPattern(PatternInterface.Transform),
|
||||
"Transform must be withheld while CanUserResizeColumns is false.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a pinned column keeps the Transform pattern but reports CanResize=false and rejects Resize.")]
|
||||
public void VerifyTransformCanResizeIsFalseForPinnedColumns()
|
||||
{
|
||||
TableView starTable = null;
|
||||
TableView pixelTable = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
starTable = TableViewSizingTestHelpers.CreateResizableTable(
|
||||
("First", new GridLength(1.0, GridUnitType.Star)),
|
||||
("Second", new GridLength(1.0, GridUnitType.Star)));
|
||||
LoadContent(starTable);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
// Same rule as gripper visibility: the last Star column has no later Star column to trade with.
|
||||
var first = new TableViewColumnHeaderAutomationPeer(starTable, starTable.Columns[0])
|
||||
.GetPattern(PatternInterface.Transform) as ITransformProvider;
|
||||
var last = new TableViewColumnHeaderAutomationPeer(starTable, starTable.Columns[1])
|
||||
.GetPattern(PatternInterface.Transform) as ITransformProvider;
|
||||
Verify.IsNotNull(first, "The first Star column opts in to resizing, so Transform must be offered.");
|
||||
Verify.IsNotNull(last, "The pinned Star column still opts in to resizing, so Transform must be offered.");
|
||||
Verify.IsTrue(first.CanResize, "A Star column with a later Star donor can be resized.");
|
||||
Verify.IsFalse(last.CanResize, "The last Star column is pinned, so CanResize must be false.");
|
||||
|
||||
var widthBefore = starTable.Columns[1].Width;
|
||||
VerifyFailsWithHResult(UIA_E_INVALIDOPERATION, () => last.Resize(80.0, 0.0),
|
||||
"Resize on a pinned column must fail.");
|
||||
Verify.AreEqual(widthBefore, starTable.Columns[1].Width, "A rejected Resize must not touch Width.");
|
||||
|
||||
pixelTable = TableViewSizingTestHelpers.CreateResizableTable(
|
||||
("First", new GridLength(120.0, GridUnitType.Pixel)));
|
||||
LoadContent(pixelTable);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var fixedColumn = pixelTable.Columns[0];
|
||||
fixedColumn.MinWidth = 120.0;
|
||||
fixedColumn.MaxWidth = 120.0;
|
||||
var fixedTransform = new TableViewColumnHeaderAutomationPeer(pixelTable, fixedColumn)
|
||||
.GetPattern(PatternInterface.Transform) as ITransformProvider;
|
||||
Verify.IsNotNull(fixedTransform, "A fixed-width column that opts in to resizing still offers Transform.");
|
||||
Verify.IsFalse(fixedTransform.CanResize, "MinWidth == MaxWidth pins the column, so CanResize must be false.");
|
||||
|
||||
fixedColumn.MaxWidth = double.PositiveInfinity;
|
||||
Verify.IsTrue(fixedTransform.CanResize, "Lifting MaxWidth must make the column resizable again.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies Transform.Resize sets the column width through the resize path and clamps to MinWidth/MaxWidth.")]
|
||||
public void VerifyTransformResizeSetsWidthAndClampsToBounds()
|
||||
{
|
||||
TableView tableView = null;
|
||||
ITransformProvider transform = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = TableViewSizingTestHelpers.CreateResizableTable(
|
||||
("First", new GridLength(150.0, GridUnitType.Pixel)),
|
||||
("Second", new GridLength(150.0, GridUnitType.Pixel)));
|
||||
tableView.Columns[0].MinWidth = 80.0;
|
||||
tableView.Columns[0].MaxWidth = 260.0;
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var column = tableView.Columns[0];
|
||||
transform = new TableViewColumnHeaderAutomationPeer(tableView, column)
|
||||
.GetPattern(PatternInterface.Transform) as ITransformProvider;
|
||||
Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern.");
|
||||
|
||||
// Height is ignored: a column only resizes along its width. UIA widths are physical pixels.
|
||||
transform.Resize(200.0 * tableView.XamlRoot.RasterizationScale, 999.0);
|
||||
Verify.AreEqual(GridUnitType.Pixel, column.Width.GridUnitType, "A resize writes a pixel width.");
|
||||
Verify.AreEqual(200.0, column.Width.Value, "Resize must set the requested width.");
|
||||
Content.UpdateLayout();
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var column = tableView.Columns[0];
|
||||
Verify.AreEqual(200.0, column.ActualWidth, "ActualWidth must follow the width Resize wrote.");
|
||||
Verify.AreEqual(150.0, tableView.Columns[1].Width.Value, "Resizing one column must not change another fixed column.");
|
||||
|
||||
transform.Resize(1000.0 * tableView.XamlRoot.RasterizationScale, 0.0);
|
||||
Verify.AreEqual(260.0, column.Width.Value, "Resize must clamp to MaxWidth.");
|
||||
Content.UpdateLayout();
|
||||
|
||||
transform.Resize(10.0 * tableView.XamlRoot.RasterizationScale, 0.0);
|
||||
Verify.AreEqual(80.0, column.Width.Value, "Resize must clamp to MinWidth.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies Transform.Resize reads its width in physical pixels, the units UIA reports bounds in.")]
|
||||
public void VerifyTransformResizeWidthIsPhysicalPixels()
|
||||
{
|
||||
TableView tableView = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = TableViewSizingTestHelpers.CreateResizableTable(
|
||||
("First", new GridLength(150.0, GridUnitType.Pixel)),
|
||||
("Second", new GridLength(150.0, GridUnitType.Pixel)));
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var column = tableView.Columns[0];
|
||||
var transform = new TableViewColumnHeaderAutomationPeer(tableView, column)
|
||||
.GetPattern(PatternInterface.Transform) as ITransformProvider;
|
||||
Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern.");
|
||||
|
||||
var scale = tableView.XamlRoot.RasterizationScale;
|
||||
Log.Comment($"RasterizationScale = {scale}");
|
||||
if (scale == 1.0)
|
||||
{
|
||||
// Not settable from a test, and at 1.0 pixels equal DIPs, so the conversion is unobservable.
|
||||
Log.Warning("SKIPPED (reported as passed): the physical-pixel conversion cannot be observed at RasterizationScale 1.0; run on a scaled display to cover it.");
|
||||
return;
|
||||
}
|
||||
|
||||
transform.Resize(180.0 * scale, 0.0);
|
||||
Verify.IsTrue(Math.Abs(column.Width.Value - 180.0) < 0.01,
|
||||
$"Resize must convert physical pixels to DIPs; expected 180, got {column.Width.Value}.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies Transform.Resize converts physical pixels through a scale applied above the table, not only the display scale.")]
|
||||
public void VerifyTransformResizeAccountsForAncestorScale()
|
||||
{
|
||||
TableView tableView = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = TableViewSizingTestHelpers.CreateResizableTable(
|
||||
("First", new GridLength(150.0, GridUnitType.Pixel)),
|
||||
("Second", new GridLength(150.0, GridUnitType.Pixel)));
|
||||
var scaledHost = new Grid { RenderTransform = new ScaleTransform { ScaleX = 2.0, ScaleY = 2.0 } };
|
||||
scaledHost.Children.Add(tableView);
|
||||
LoadContent(scaledHost);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var column = tableView.Columns[0];
|
||||
var transform = new TableViewColumnHeaderAutomationPeer(tableView, column)
|
||||
.GetPattern(PatternInterface.Transform) as ITransformProvider;
|
||||
Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern.");
|
||||
|
||||
// On screen each DIP of the column spans this many physical pixels.
|
||||
var pixelsPerDip = tableView.XamlRoot.RasterizationScale * 2.0;
|
||||
transform.Resize(170.0 * pixelsPerDip, 0.0);
|
||||
Verify.IsTrue(Math.Abs(column.Width.Value - 170.0) < 0.01,
|
||||
$"Resize must convert physical pixels through the ancestor scale; expected 170, got {column.Width.Value}.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies Transform.Resize applies a width change smaller than the pointer drag's jitter threshold.")]
|
||||
public void VerifyTransformResizeAppliesSmallWidthChange()
|
||||
{
|
||||
TableView tableView = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = TableViewSizingTestHelpers.CreateResizableTable(
|
||||
("First", new GridLength(150.0, GridUnitType.Pixel)),
|
||||
("Second", new GridLength(150.0, GridUnitType.Pixel)));
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var column = tableView.Columns[0];
|
||||
var transform = new TableViewColumnHeaderAutomationPeer(tableView, column)
|
||||
.GetPattern(PatternInterface.Transform) as ITransformProvider;
|
||||
Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern.");
|
||||
Verify.AreEqual(150.0, column.ActualWidth, "Precondition: the column starts at 150.");
|
||||
|
||||
// One physical pixel at 300% scale; the pointer drag treats under half a DIP as jitter.
|
||||
transform.Resize(150.3 * tableView.XamlRoot.RasterizationScale, 0.0);
|
||||
Verify.IsTrue(Math.Abs(column.Width.Value - 150.3) < 0.01,
|
||||
$"An absolute UIA width must be applied however small the change; expected 150.3, got {column.Width.Value}.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a Transform.Resize below the drag jitter threshold writes Width at most once, lands exactly next to a bound, and leaves an Auto column Auto when it asks for its current width.")]
|
||||
[TestProperty("data:Case", "{NearMin, NearMax, AtMin, AutoNoop}")]
|
||||
public void VerifyTransformResizeSmallWidthChangeWritesOnce()
|
||||
{
|
||||
var testCase = ((string)TestContext.DataRow["Case"]).Trim();
|
||||
TableView tableView = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = TableViewSizingTestHelpers.CreateResizableTable(
|
||||
("First", testCase == "AutoNoop" ? GridLength.Auto : new GridLength(150.0, GridUnitType.Pixel)),
|
||||
("Second", new GridLength(150.0, GridUnitType.Pixel)));
|
||||
var first = tableView.Columns[0];
|
||||
switch (testCase)
|
||||
{
|
||||
case "NearMin": first.MinWidth = 100.0; first.Width = new GridLength(100.4, GridUnitType.Pixel); break;
|
||||
case "NearMax": first.MaxWidth = 200.0; first.Width = new GridLength(199.6, GridUnitType.Pixel); break;
|
||||
case "AtMin": first.MinWidth = 100.0; first.Width = new GridLength(100.0, GridUnitType.Pixel); break;
|
||||
}
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var column = tableView.Columns[0];
|
||||
var transform = new TableViewColumnHeaderAutomationPeer(tableView, column)
|
||||
.GetPattern(PatternInterface.Transform) as ITransformProvider;
|
||||
Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern.");
|
||||
|
||||
double target = column.ActualWidth + 0.0001;
|
||||
double expected = column.ActualWidth;
|
||||
switch (testCase)
|
||||
{
|
||||
case "NearMin": target = expected = 100.1; break;
|
||||
case "NearMax": target = expected = 199.9; break;
|
||||
case "AtMin": target = 99.7; expected = 100.0; break;
|
||||
}
|
||||
int expectedWrites = testCase == "NearMin" || testCase == "NearMax" ? 1 : 0;
|
||||
|
||||
int widthWrites = 0;
|
||||
var widths = new List<string>();
|
||||
column.RegisterPropertyChangedCallback(TableViewColumn.WidthProperty, (s, dp) =>
|
||||
{
|
||||
widthWrites++;
|
||||
widths.Add(column.Width.ToString());
|
||||
});
|
||||
|
||||
transform.Resize(target * tableView.XamlRoot.RasterizationScale, 0.0);
|
||||
Log.Comment($"{testCase}: Width writes [{string.Join(", ", widths)}], final {column.Width}.");
|
||||
|
||||
Verify.AreEqual(expectedWrites, widthWrites, $"{testCase}: Width must be written {expectedWrites} time(s).");
|
||||
if (testCase == "AutoNoop")
|
||||
{
|
||||
Verify.AreEqual(GridUnitType.Auto, column.Width.GridUnitType, "A request for the current width must leave an Auto column Auto.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Verify.IsTrue(Math.Abs(column.Width.Value - expected) < 0.01, $"{testCase}: expected {expected}, got {column.Width.Value}.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies Transform.Move and Rotate fail, and Resize rejects a non-finite or negative width.")]
|
||||
public void VerifyTransformMoveRotateAndInvalidResizeFail()
|
||||
{
|
||||
TableView tableView = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = TableViewSizingTestHelpers.CreateResizableTable(
|
||||
("First", new GridLength(150.0, GridUnitType.Pixel)));
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var column = tableView.Columns[0];
|
||||
var transform = new TableViewColumnHeaderAutomationPeer(tableView, column)
|
||||
.GetPattern(PatternInterface.Transform) as ITransformProvider;
|
||||
Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern.");
|
||||
|
||||
VerifyFailsWithHResult(UIA_E_INVALIDOPERATION, () => transform.Move(10.0, 10.0), "Move must fail: CanMove is false.");
|
||||
VerifyFailsWithHResult(UIA_E_INVALIDOPERATION, () => transform.Rotate(90.0), "Rotate must fail: CanRotate is false.");
|
||||
VerifyFailsWithHResult(E_INVALIDARG, () => transform.Resize(double.NaN, 0.0), "Resize must reject NaN.");
|
||||
VerifyFailsWithHResult(E_INVALIDARG, () => transform.Resize(double.PositiveInfinity, 0.0), "Resize must reject infinity.");
|
||||
VerifyFailsWithHResult(E_INVALIDARG, () => transform.Resize(-1.0, 0.0), "Resize must reject a negative width.");
|
||||
|
||||
Verify.AreEqual(150.0, column.Width.Value, "A failed Transform call must leave the width untouched.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a column header peer is a childless HeaderItem.")]
|
||||
public void VerifyColumnHeaderPeerIsALeafHeaderItem()
|
||||
|
||||
@@ -25,8 +25,8 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
// Category 3.1 of the TableView test plan: the Columns collection.
|
||||
//
|
||||
// These tests assert against two structures the control builds from Columns:
|
||||
// - PART_HeaderHost, whose children are header cell Grids, each tagged with its column
|
||||
// (TableView.cpp:1610, headerCell.Tag(column)).
|
||||
// - PART_HeaderHost, whose children are TableViewColumnHeader controls, each tagged with its column
|
||||
// (TableView::RebuildHeaders, header.Tag(column)).
|
||||
// - each realized TableViewRow's PART_CellsHost, whose children are Grid cell wrappers,
|
||||
// each tagged with its column (TableViewRow.cpp:762, cellWrapper.Tag(column)).
|
||||
// The Tag is the supported way to map a rendered element back to the column that produced it,
|
||||
@@ -416,6 +416,118 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a header rebuild leaves focus where the app moved it while the focused header's Content was being cleared.")]
|
||||
public void VerifyHeaderRebuildKeepsAppFocusMoveFromFocusedHeader()
|
||||
{
|
||||
TableView tableView = null;
|
||||
TextBox external = null;
|
||||
bool moved = false;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateTableView(headers: new[] { "Name", "Role", "City" });
|
||||
external = new TextBox();
|
||||
var host = new StackPanel();
|
||||
host.Children.Add(external);
|
||||
host.Children.Add(tableView);
|
||||
LoadContent(host);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var header = GetHeaderCell(tableView, 1) as ContentControl;
|
||||
Verify.IsNotNull(header, "Precondition: the header cell is a ContentControl.");
|
||||
Verify.IsTrue(header.Focus(FocusState.Keyboard), "Precondition: header 1 takes keyboard focus.");
|
||||
header.RegisterPropertyChangedCallback(ContentControl.ContentProperty, (sender, property) =>
|
||||
{
|
||||
if (!moved && ((ContentControl)sender).Content == null)
|
||||
{
|
||||
moved = true;
|
||||
external.Focus(FocusState.Programmatic);
|
||||
}
|
||||
});
|
||||
tableView.CanUserSortColumns = false;
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(moved, "Precondition: the rebuild cleared the focused header's Content.");
|
||||
Verify.IsTrue(ReferenceEquals(external, FocusManager.GetFocusedElement(tableView.XamlRoot)),
|
||||
"The rebuild must not pull focus back from where the app moved it.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a header rebuild leaves focus where the app moved it, out of the table, while the header's focused Button content is being released.")]
|
||||
[TestProperty("data:Move", "{ContentCallback, LosingFocusRedirect}")]
|
||||
public void VerifyHeaderRebuildKeepsAppFocusMoveFromFocusedHeaderContent()
|
||||
{
|
||||
var move = ((string)TestContext.DataRow["Move"]).Trim();
|
||||
TableView tableView = null;
|
||||
TextBox external = null;
|
||||
Button headerButton = null;
|
||||
bool moved = false;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateTableView(headers: new[] { "Name", "Role", "City" });
|
||||
headerButton = new Button { Content = "Role" };
|
||||
tableView.Columns[1].Header = headerButton;
|
||||
external = new TextBox();
|
||||
var host = new StackPanel();
|
||||
host.Children.Add(external);
|
||||
host.Children.Add(tableView);
|
||||
LoadContent(host);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var header = GetHeaderCell(tableView, 1) as ContentControl;
|
||||
Verify.IsNotNull(header, "Precondition: the header cell is a ContentControl.");
|
||||
Verify.IsTrue(ReferenceEquals(headerButton, header.Content), "Precondition: the header hosts the app's Button.");
|
||||
Verify.IsTrue(headerButton.Focus(FocusState.Keyboard), "Precondition: the header's Button takes keyboard focus.");
|
||||
if (move == "ContentCallback")
|
||||
{
|
||||
header.RegisterPropertyChangedCallback(ContentControl.ContentProperty, (sender, property) =>
|
||||
{
|
||||
if (!moved && ((ContentControl)sender).Content == null)
|
||||
{
|
||||
moved = external.Focus(FocusState.Programmatic);
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// The app sends focus out of the table when its header content gives it up.
|
||||
headerButton.LosingFocus += (sender, args) =>
|
||||
{
|
||||
if (!moved)
|
||||
{
|
||||
moved = args.TrySetNewFocusedElement(external);
|
||||
}
|
||||
};
|
||||
}
|
||||
tableView.CanUserSortColumns = false;
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(moved, "Precondition: the app moved focus during the rebuild.");
|
||||
var focused = FocusManager.GetFocusedElement(tableView.XamlRoot);
|
||||
Log.Comment($"Focused after rebuild: {(focused as FrameworkElement)?.GetType().Name}");
|
||||
Verify.IsTrue(ReferenceEquals(external, focused),
|
||||
"The rebuild must not pull focus back from where the app moved it.");
|
||||
});
|
||||
}
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a focused header keeps focus across the header rebuild a theme change performs.")]
|
||||
public void VerifyHeaderFocusSurvivesThemeChange()
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||
using Microsoft.UI.Xaml.Controls.Tabular;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Microsoft.UI.Xaml.Markup;
|
||||
using MUXControlsTestApp.Utilities;
|
||||
using System;
|
||||
@@ -548,6 +549,208 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
Verify.AreEqual("Asha", items[0].Name, "The cancel must leave the pre-edit value in place.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a CanSort = false clear queued behind a sort on another column, both requested from CellEditEnding, does not clear that newer sort.")]
|
||||
public void VerifyQueuedCanSortClearDoesNotClearNewerSortRequestedDuringEditEnding()
|
||||
{
|
||||
TableView tableView = null;
|
||||
List<EditablePerson> items = null;
|
||||
bool requested = false;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
items = MakeEditableItems();
|
||||
tableView = CreateEditableTable(items);
|
||||
tableView.IsReadOnly = false;
|
||||
LoadContent(tableView);
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the Name column sorts.");
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var name = tableView.Columns[0];
|
||||
var role = tableView.Columns[1];
|
||||
tableView.CellEditEnding += (s, e) =>
|
||||
{
|
||||
if (requested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
requested = true;
|
||||
tableView.SortByColumn(role, SortDirection.Ascending);
|
||||
name.CanSort = false;
|
||||
};
|
||||
|
||||
var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 0), "Asha K");
|
||||
Verify.IsNull(error, "The commit must succeed.");
|
||||
Verify.IsTrue(requested, "Precondition: CellEditEnding requested both changes.");
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The opted-out column must not stay sorted.");
|
||||
Verify.AreEqual(SortDirection.Ascending, tableView.Columns[1].SortDirection,
|
||||
"The stale opt-out clear must not remove the newer sort on another column.");
|
||||
var roles = GetProjectedItems(tableView).Select(item => ((EditablePerson)item).Role).ToList();
|
||||
Verify.AreEqual("Architect,Designer,Engineer", string.Join(",", roles), "The rows must stay sorted by Role.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a sort opt-out made from a BeginningEdit handler, cancelled or not, still clears the opted-out sort.")]
|
||||
[TestProperty("data:Change", "{CanSortCancel, CanSortContinue, CanUserSortColumnsCancel}")]
|
||||
public void VerifySortOptOutDuringBeginningEditClearsSort()
|
||||
{
|
||||
var change = ((string)TestContext.DataRow["Change"]).Trim();
|
||||
TableView tableView = null;
|
||||
List<EditablePerson> items = null;
|
||||
bool requested = false;
|
||||
int clearedCount = 0;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
items = MakeEditableItems();
|
||||
tableView = CreateEditableTable(items);
|
||||
tableView.IsReadOnly = false;
|
||||
LoadContent(tableView);
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the Name column sorts.");
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var name = tableView.Columns[0];
|
||||
tableView.Sorted += (s, e) =>
|
||||
{
|
||||
if (e.Direction == SortDirection.None)
|
||||
{
|
||||
clearedCount++;
|
||||
}
|
||||
};
|
||||
tableView.BeginningEdit += (s, e) =>
|
||||
{
|
||||
if (requested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
requested = true;
|
||||
if (change == "CanUserSortColumnsCancel")
|
||||
{
|
||||
tableView.CanUserSortColumns = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
name.CanSort = false;
|
||||
}
|
||||
e.Cancel = change != "CanSortContinue";
|
||||
};
|
||||
|
||||
var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 1), "Pilot");
|
||||
Log.Comment($"SetValue: {error?.Message ?? "succeeded"}");
|
||||
Verify.IsTrue(requested, "Precondition: BeginningEdit opted the sort out.");
|
||||
if (change == "CanSortContinue")
|
||||
{
|
||||
Verify.IsNull(error, "An edit BeginningEdit let continue must accept the value.");
|
||||
Verify.AreEqual("Pilot", items[0].Role, "The value must be committed.");
|
||||
Verify.IsFalse(tableView.IsEditing, "The committed edit must be closed.");
|
||||
}
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, $"{change}: the opted-out sort must be cleared.");
|
||||
Verify.AreEqual(1, clearedCount, $"{change}: the clear must be reported once.");
|
||||
var names = GetProjectedItems(tableView).Select(item => ((EditablePerson)item).Name).ToList();
|
||||
Verify.AreEqual("Asha,Diego,Mei", string.Join(",", names), $"{change}: the rows must return to source order.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a sort opt-out made from a BeginningEdit handler that lets the edit open keeps the edit open, and clears the sort when the edit closes.")]
|
||||
public void VerifySortOptOutDuringBeginningEditKeepsTheEditOpen()
|
||||
{
|
||||
TableView tableView = null;
|
||||
List<EditablePerson> items = null;
|
||||
bool requested = false;
|
||||
bool vetoClose = true;
|
||||
int clearedCount = 0;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
items = MakeEditableItems();
|
||||
tableView = CreateEditableTable(items);
|
||||
tableView.IsReadOnly = false;
|
||||
LoadContent(tableView);
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the Name column sorts.");
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var name = tableView.Columns[0];
|
||||
tableView.Sorted += (s, e) =>
|
||||
{
|
||||
if (e.Direction == SortDirection.None)
|
||||
{
|
||||
clearedCount++;
|
||||
}
|
||||
};
|
||||
tableView.BeginningEdit += (s, e) =>
|
||||
{
|
||||
if (!requested)
|
||||
{
|
||||
requested = true;
|
||||
name.CanSort = false;
|
||||
}
|
||||
};
|
||||
// Holds the edit open after SetValue so its state can be observed.
|
||||
tableView.CellEditEnding += (s, e) => e.Cancel = vetoClose;
|
||||
|
||||
var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 1), "Pilot");
|
||||
Log.Comment($"SetValue: {error?.Message ?? "succeeded"}");
|
||||
Verify.IsTrue(requested, "Precondition: BeginningEdit opted the sort out.");
|
||||
Verify.IsNotNull(error, "Precondition: the vetoed commit is reported.");
|
||||
Verify.AreEqual(unchecked((int)0x80004005), error.HResult, "The edit must open and only the vetoed commit fail.");
|
||||
Verify.IsTrue(tableView.IsEditing, "The opened edit must stay open.");
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(tableView.IsEditing, "The deferred sort clear must not close the open edit.");
|
||||
Verify.AreEqual(0, clearedCount, "The sort clear waits for the edit to close.");
|
||||
|
||||
vetoClose = false;
|
||||
Verify.IsTrue(tableView.CommitEdit(), "The edit commits.");
|
||||
Verify.AreEqual("Pilot", items[0].Role, "The value must be committed.");
|
||||
});
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The opted-out sort must be cleared once the edit closes.");
|
||||
Verify.AreEqual(1, clearedCount, "The clear must be reported once.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Category 10.5 of the TableView test plan: validation, teardown and reentrancy.
|
||||
@@ -1632,6 +1835,95 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
Verify.IsFalse(tableView.IsEditing, "And must close.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a BeginEdit whose editor creation throws rolls back: no edit is left open, the display cell and focus are restored, and a later edit works.")]
|
||||
public void VerifyBeginEditThatThrowsRollsBackAndAllowsALaterEdit()
|
||||
{
|
||||
TableView tableView = null;
|
||||
List<EditablePerson> items = null;
|
||||
var converter = new ArmableThrowingConverter();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
items = MakeEditableItems();
|
||||
tableView = CreateTableViewShell(items, 500, 300);
|
||||
tableView.IsReadOnly = false;
|
||||
|
||||
// An explicit Source makes the editor's binding evaluate inside the editor factory, so
|
||||
// an armed converter throws out of BeginEdit itself rather than into a swallowed refresh.
|
||||
tableView.Columns.Add(new TableViewTextColumn
|
||||
{
|
||||
Header = "Name",
|
||||
Binding = new Binding { Path = new PropertyPath("Name"), Source = items[0], Converter = converter, Mode = BindingMode.OneWay },
|
||||
});
|
||||
tableView.Columns.Add(MakeTextColumn("Role"));
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
Grid cell = null;
|
||||
UIElement displayContent = null;
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
cell = GetRowCell(GetRowForItem(tableView, items[0]), 0);
|
||||
displayContent = GetCellContent(cell);
|
||||
GetCellPeer(tableView, items[0], columnIndex: 0).SetFocus();
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(ReferenceEquals(cell, FocusManager.GetFocusedElement(tableView.XamlRoot)),
|
||||
"Precondition: the cell has focus before the edit is attempted.");
|
||||
|
||||
converter.Armed = true;
|
||||
var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 0), "Asha Kapoor");
|
||||
converter.Armed = false;
|
||||
|
||||
Verify.IsTrue(error != null, "The throwing editor factory must surface as a failed edit.");
|
||||
Verify.IsFalse(tableView.IsEditing, "A BeginEdit that threw must not leave an edit open.");
|
||||
Verify.IsTrue(ReferenceEquals(displayContent, GetCellContent(cell)), "The cell must show its display content again.");
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(ReferenceEquals(cell, FocusManager.GetFocusedElement(tableView.XamlRoot)),
|
||||
"Focus must be back on the cell after the rollback.");
|
||||
|
||||
var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 0), "Asha Kapoor");
|
||||
Verify.IsTrue(error == null, $"A later edit on the same cell must start and commit. Instead: {error?.Message}");
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual("Asha Kapoor", items[0].Name, "The later edit must commit.");
|
||||
Verify.IsFalse(tableView.IsEditing, "And must close.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Throws from Convert only while armed, so the display cells build normally.
|
||||
internal sealed class ArmableThrowingConverter : IValueConverter
|
||||
{
|
||||
public bool Armed { get; set; }
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
if (Armed)
|
||||
{
|
||||
throw new InvalidOperationException("Deliberate editor-binding failure.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language) => value;
|
||||
}
|
||||
|
||||
internal static class TableViewEditingTestHelpers
|
||||
|
||||
@@ -12,7 +12,9 @@ using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Windows.Foundation;
|
||||
|
||||
using WEX.Logging.Interop;
|
||||
using WEX.TestExecution;
|
||||
using WEX.TestExecution.Markup;
|
||||
|
||||
@@ -1294,6 +1296,32 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a grouped root row is indented by the group header's expander gutter plus its spacing.")]
|
||||
public void VerifyGroupedRootIndentMatchesGroupHeaderGutterAndSpacing()
|
||||
{
|
||||
TableViewSource source = null;
|
||||
RunOnUIThread.Execute(() => source = Tree(MakeTree()).GroupBy(ByDept));
|
||||
var tableView = Place(source);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var header = GetProjectedElements(tableView).OfType<TableViewGroupHeader>().First();
|
||||
var gutter = header.FindVisualChildByName("PART_ExpanderGutter") as FrameworkElement;
|
||||
var gutterGrid = gutter == null ? null : Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(gutter) as Grid;
|
||||
Verify.IsNotNull(gutterGrid, "The group header should host PART_ExpanderGutter in a Grid.");
|
||||
double expected = gutter.ActualWidth + gutterGrid.ColumnSpacing;
|
||||
|
||||
var row = FindTreeRow(tableView, "Ada");
|
||||
double rowX = row.TransformToVisual(tableView).TransformPoint(new Point(0, 0)).X;
|
||||
double chevronX = GetChevron(tableView, "Ada").TransformToVisual(tableView).TransformPoint(new Point(0, 0)).X;
|
||||
Log.Comment($"Root indent={chevronX - rowX}, gutter + TableViewGroupExpanderSpacing={expected}, header Padding.Left={header.Padding.Left}");
|
||||
Verify.AreEqual(24.0 + 8.0, expected, "Default gutter (TableViewGroupExpanderSize) plus TableViewGroupExpanderSpacing.");
|
||||
Verify.IsLessThan(Math.Abs(chevronX - rowX - expected), 0.5,
|
||||
"A grouped root row's indent must equal the group header's gutter plus TableViewGroupExpanderSpacing.");
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
@@ -63,6 +63,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
resources["TabularSurfaceRowBackgroundBrush"] = new SolidColorBrush(Microsoft.UI.Colors.White);
|
||||
resources["TabularSurfaceRowForegroundBrush"] = new SolidColorBrush(Microsoft.UI.Colors.Black);
|
||||
resources["TabularSurfaceGridLineBrush"] = new SolidColorBrush(Microsoft.UI.Colors.Gray);
|
||||
resources["TabularSurfaceOuterBorderBrush"] = new SolidColorBrush(Microsoft.UI.Colors.Gray);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Tabular;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using MUXControlsTestApp.Utilities;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -257,6 +258,220 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
Verify.IsTrue(checkedAny, "Precondition: at least one realized row must be locatable in the projection.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a recycled row whose row peer exists but whose cells have no peers keeps its cell elements and shows the new item.")]
|
||||
public void VerifyRecycledRowWithoutCellPeersReusesItsCells()
|
||||
{
|
||||
TableView tableView = null;
|
||||
List<Person> items = null;
|
||||
var cellsBefore = new Dictionary<TableViewRow, List<UIElement>>();
|
||||
var contentBefore = new Dictionary<TableViewRow, List<object>>();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
items = MakeManyItems(RecyclingSourceCount);
|
||||
tableView = CreateScrollableTable(items);
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
foreach (var row in RealizedProjectedRows(tableView))
|
||||
{
|
||||
// Cells are only enumerated, never asked for peers (FromElement would create one).
|
||||
GetRowPeer(row);
|
||||
cellsBefore[row] = GetCellsHost(row).Children.ToList();
|
||||
contentBefore[row] = cellsBefore[row].Select(cell => (object)GetCellContent((Grid)cell)).ToList();
|
||||
}
|
||||
|
||||
Verify.IsGreaterThan(cellsBefore.Count, 0, "Precondition: rows must be realized before scrolling.");
|
||||
});
|
||||
|
||||
ScrollToEnd(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var recycledCount = 0;
|
||||
|
||||
foreach (var row in RealizedProjectedRows(tableView))
|
||||
{
|
||||
var item = row.DataContext as Person;
|
||||
Verify.IsNotNull(item, "Every realized row must carry a data item.");
|
||||
|
||||
var cells = GetCellsHost(row).Children.ToList();
|
||||
Verify.AreEqual(GetRowCellColumns(row).Count, cells.Count, "Every column must have a cell.");
|
||||
Verify.AreEqual(item.Name, GetCellText((Grid)cells[0]), "A recycled row's first cell must show its current item.");
|
||||
Verify.AreEqual(item.Role, GetCellText((Grid)cells[1]), "A recycled row's second cell must show its current item.");
|
||||
|
||||
if (!cellsBefore.TryGetValue(row, out var previous))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
recycledCount++;
|
||||
for (var i = 0; i < cells.Count; i++)
|
||||
{
|
||||
Verify.IsTrue(ReferenceEquals(previous[i], cells[i]),
|
||||
$"Recycling '{item.Name}' into a row with no cell peers must reuse cell {i}, not regenerate it.");
|
||||
Verify.IsNotNull(contentBefore[row][i], $"Precondition: cell {i} had generated content before recycling.");
|
||||
Verify.IsTrue(ReferenceEquals(contentBefore[row][i], GetCellContent((Grid)cells[i])),
|
||||
$"Recycling '{item.Name}' must keep the generated content of cell {i}.");
|
||||
}
|
||||
}
|
||||
|
||||
Log.Comment($"{recycledCount} recycled rows reused their cells.");
|
||||
Verify.IsGreaterThan(recycledCount, 0, "Precondition: at least one container must have been recycled to a new item.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a recycled row whose cells already have automation peers replaces them, so a held cell provider never silently describes another item.")]
|
||||
public void VerifyRecycledRowWithCellPeersReplacesItsCells()
|
||||
{
|
||||
TableView tableView = null;
|
||||
List<Person> items = null;
|
||||
var peersBefore = new Dictionary<TableViewRow, TableViewCellAutomationPeer>();
|
||||
var cellsBefore = new Dictionary<TableViewRow, UIElement>();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
items = MakeManyItems(RecyclingSourceCount);
|
||||
tableView = CreateScrollableTable(items);
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
foreach (var row in RealizedProjectedRows(tableView))
|
||||
{
|
||||
peersBefore[row] = GetCellPeer(row, 0);
|
||||
cellsBefore[row] = GetRowCell(row, 0);
|
||||
}
|
||||
|
||||
Verify.IsGreaterThan(peersBefore.Count, 0, "Precondition: rows must be realized before scrolling.");
|
||||
});
|
||||
|
||||
ScrollToEnd(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var recycledCount = 0;
|
||||
|
||||
foreach (var row in RealizedProjectedRows(tableView))
|
||||
{
|
||||
var item = row.DataContext as Person;
|
||||
Verify.IsNotNull(item, "Every realized row must carry a data item.");
|
||||
|
||||
var cellPeer = GetCellPeer(row, 0);
|
||||
var name = cellPeer.GetName();
|
||||
Verify.IsTrue(name != null && name.Contains(item.Name),
|
||||
$"A recycled cell peer must report the current item's value; '{name}' does not mention '{item.Name}'.");
|
||||
Verify.AreEqual(item.Name, GetCellText(GetRowCell(row, 0)), "A recycled row must show its current item.");
|
||||
|
||||
if (!peersBefore.TryGetValue(row, out var previousPeer))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
recycledCount++;
|
||||
Verify.IsFalse(ReferenceEquals(previousPeer, cellPeer),
|
||||
$"A row recycled to '{item.Name}' must not hand out the cell provider of its previous item.");
|
||||
Verify.IsFalse(ReferenceEquals(cellsBefore[row], GetRowCell(row, 0)),
|
||||
$"A row recycled to '{item.Name}' while its cells had peers must replace those cells.");
|
||||
}
|
||||
|
||||
Log.Comment($"{recycledCount} recycled rows replaced their cells.");
|
||||
Verify.IsGreaterThan(recycledCount, 0, "Precondition: at least one container must have been recycled to a new item.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Recycled rows must repaint index- and table-dependent chrome for the slot they now occupy.
|
||||
[TestClass]
|
||||
public class TableViewRowChromeRecyclingTests : TableViewApiTestBase
|
||||
{
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies recycled rows carry the banding for their new index and draw grid lines, with and without a row peer.")]
|
||||
public void VerifyRecycledRowsRepaintBandingAndGridLines()
|
||||
{
|
||||
TableView tableView = null;
|
||||
List<Person> items = null;
|
||||
Brush baseBrush = null;
|
||||
Brush alternateBrush = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
items = MakeManyItems(RecyclingSourceCount);
|
||||
baseBrush = new SolidColorBrush(Microsoft.UI.Colors.LightGray);
|
||||
alternateBrush = new SolidColorBrush(Microsoft.UI.Colors.LightBlue);
|
||||
tableView = CreateScrollableTable(items);
|
||||
tableView.RowBackground = baseBrush;
|
||||
tableView.AlternatingRowBackground = alternateBrush;
|
||||
tableView.GridLinesVisibility = TableViewGridLinesVisibility.All;
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
// Every other row gets a peer so both recycle routes (with and without item tracking) are covered.
|
||||
var rows = RealizedProjectedRows(tableView);
|
||||
for (var i = 0; i < rows.Count; i += 2)
|
||||
{
|
||||
GetRowPeer(rows[i]);
|
||||
}
|
||||
|
||||
VerifyChrome(tableView, items, baseBrush, alternateBrush, "before scrolling");
|
||||
});
|
||||
|
||||
ScrollBodyTo(tableView, ScrollAxis.Vertical, 37.0 * 7);
|
||||
RunOnUIThread.Execute(() => VerifyChrome(tableView, items, baseBrush, alternateBrush, "after a partial scroll"));
|
||||
|
||||
ScrollToEnd(tableView);
|
||||
RunOnUIThread.Execute(() => VerifyChrome(tableView, items, baseBrush, alternateBrush, "after scrolling to the end"));
|
||||
|
||||
ScrollToStart(tableView);
|
||||
RunOnUIThread.Execute(() => VerifyChrome(tableView, items, baseBrush, alternateBrush, "after scrolling back"));
|
||||
}
|
||||
|
||||
private static void VerifyChrome(
|
||||
TableView tableView,
|
||||
List<Person> items,
|
||||
Brush baseBrush,
|
||||
Brush alternateBrush,
|
||||
string context)
|
||||
{
|
||||
var rows = RealizedProjectedRows(tableView);
|
||||
Verify.IsGreaterThan(rows.Count, 0, $"Rows must be realized ({context}).");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var item = row.DataContext as Person;
|
||||
var index = items.IndexOf(item);
|
||||
Verify.IsGreaterThanOrEqual(index, 0, $"Every realized row must map to a source item ({context}).");
|
||||
|
||||
Verify.AreEqual(item.Name, GetCellText(GetRowCell(row, 0)), $"Row {index} must show its own item ({context}).");
|
||||
|
||||
var expected = (index % 2) == 0 ? baseBrush : alternateBrush;
|
||||
Verify.AreEqual(expected, row.Background, $"Row {index} must carry the brush for its parity ({context}).");
|
||||
|
||||
// The final row may suppress its bottom line where it meets the table border.
|
||||
if (index != items.Count - 1)
|
||||
{
|
||||
Verify.IsGreaterThan(row.BorderThickness.Bottom, 0.0, $"Row {index} must draw its horizontal grid line ({context}).");
|
||||
}
|
||||
|
||||
var cell = GetRowCell(row, 0);
|
||||
Verify.IsGreaterThan(cell.BorderThickness.Right, 0.0, $"Row {index} must draw its vertical grid line ({context}).");
|
||||
Verify.IsNotNull(cell.BorderBrush, $"Row {index}'s vertical grid line must have a brush ({context}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grouping and sorting must not defeat virtualization.
|
||||
|
||||
@@ -1364,6 +1364,58 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies re-parenting a gripper mid-drag keeps the drag: the stale Unloaded after the new Loaded must not cancel it.")]
|
||||
public void VerifyReparentingGripperMidDragKeepsDrag()
|
||||
{
|
||||
ResizeGripper gripper = null;
|
||||
StackPanel first = null;
|
||||
StackPanel second = null;
|
||||
var completions = new List<bool>();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
gripper = new ResizeGripper { Width = 8, Height = 24 };
|
||||
gripper.DragCompleted += (s, e) => completions.Add(e.Canceled);
|
||||
first = new StackPanel();
|
||||
second = new StackPanel();
|
||||
first.Children.Add(gripper);
|
||||
var root = new StackPanel();
|
||||
root.Children.Add(first);
|
||||
root.Children.Add(second);
|
||||
LoadContent(root);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
gripper.BeginDrag();
|
||||
Verify.IsTrue(gripper.IsDragging, "Precondition: the drag starts.");
|
||||
first.Children.Remove(gripper);
|
||||
second.Children.Add(gripper);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(gripper.IsLoaded, "Precondition: the gripper is loaded in its new parent.");
|
||||
Verify.IsTrue(gripper.IsDragging, "A re-parent must not end the drag.");
|
||||
Verify.AreEqual(0, completions.Count, "A re-parent must not raise DragCompleted.");
|
||||
|
||||
second.Children.Remove(gripper);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsFalse(gripper.IsDragging, "A real unload must end the drag.");
|
||||
Verify.IsTrue(completions.Count == 1 && completions[0], "A real unload cancels the drag once.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a drag on a gripper that was never loaded completes without touching pointer capture.")]
|
||||
public void VerifyEndDragOnUnloadedGripperRaisesDragCompleted()
|
||||
@@ -2167,7 +2219,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
var cellRight = GetVisualRight(headerCell, wrapper);
|
||||
Log.Comment($"Header cell spans {cellLeft}..{cellRight}.");
|
||||
|
||||
var gridLine = headerCell.FindVisualChildByName("TableViewHeaderGridLine");
|
||||
var gridLine = headerCell.FindVisualChildByName("PART_GridLine");
|
||||
Verify.IsNotNull(gridLine, "The header cell must contain its vertical grid line.");
|
||||
Verify.AreEqual(Visibility.Visible, gridLine.Visibility,
|
||||
"Precondition: a non-terminal header grid line must be visible with GridLinesVisibility.All.");
|
||||
|
||||
@@ -379,6 +379,186 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies SortByColumn refuses a column whose CanSort is false.")]
|
||||
public void VerifySortByColumnRespectsCanSort()
|
||||
{
|
||||
TableView tableView = null;
|
||||
var returned = true;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateSortTable();
|
||||
tableView.Columns[0].CanSort = false;
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() => returned = tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending));
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsFalse(returned, "SortByColumn must refuse a column with CanSort = false.");
|
||||
Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection,
|
||||
"A column with CanSort = false must not acquire a sort direction.");
|
||||
|
||||
VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name),
|
||||
"a column that opted out of sorting must leave the rows in source order");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies setting CanSort = false on the sorted column clears its sort and raises Sorting/Sorted.")]
|
||||
public void VerifyCanSortFalseClearsActiveSort()
|
||||
{
|
||||
TableView tableView = null;
|
||||
var sortingArgs = new List<TableViewSortingEventArgs>();
|
||||
var sortedArgs = new List<TableViewSortedEventArgs>();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateSortTable();
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending),
|
||||
"Baseline: the column must sort.");
|
||||
tableView.Sorting += (sender, args) => sortingArgs.Add(args);
|
||||
tableView.Sorted += (sender, args) => sortedArgs.Add(args);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifySequence(NameAscendingOrder, RowValues(tableView, person => person.Name),
|
||||
"baseline: the rows must be sorted");
|
||||
tableView.Columns[0].CanSort = false;
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var column = tableView.Columns[0];
|
||||
Verify.AreEqual(SortDirection.None, column.SortDirection,
|
||||
"Opting a sorted column out of sorting must clear its sort direction.");
|
||||
VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name),
|
||||
"clearing the opted-out column's sort must restore source order");
|
||||
|
||||
var indicator = FindSortIndicator(tableView, 0);
|
||||
Verify.IsTrue(indicator == null || indicator.Direction == SortIndicatorDirection.None,
|
||||
"The header arrow must be cleared.");
|
||||
|
||||
Verify.AreEqual(1, sortingArgs.Count, "The clear must raise Sorting once.");
|
||||
Verify.AreEqual(column, sortingArgs[0].Column, "Sorting must name the opted-out column.");
|
||||
Verify.AreEqual(SortDirection.None, sortingArgs[0].Direction, "Sorting must request None.");
|
||||
Verify.AreEqual(1, sortedArgs.Count, "The clear must raise Sorted once.");
|
||||
Verify.AreEqual(column, sortedArgs[0].Column, "Sorted must name the opted-out column.");
|
||||
Verify.AreEqual(SortDirection.None, sortedArgs[0].Direction, "Sorted must report None.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a CanSort = false clear still removes the installed sort when the Sorting handler leaves the column without a sort path.")]
|
||||
public void VerifyCanSortFalseClearRemovesSortWhenHandlerDropsSortPath()
|
||||
{
|
||||
TableView tableView = null;
|
||||
var sortedArgs = new List<TableViewSortedEventArgs>();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateSortTable();
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending),
|
||||
"Baseline: the column must sort.");
|
||||
tableView.Sorting += (sender, args) =>
|
||||
{
|
||||
if (args.Direction == SortDirection.None && args.Column is TableViewTextColumn column)
|
||||
{
|
||||
column.Binding = null;
|
||||
}
|
||||
};
|
||||
tableView.Sorted += (sender, args) => sortedArgs.Add(args);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifySequence(NameAscendingOrder, RowValues(tableView, person => person.Name),
|
||||
"baseline: the rows must be sorted");
|
||||
tableView.Columns[0].CanSort = false;
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The opted-out column's sort must be cleared.");
|
||||
VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name),
|
||||
"the clear must remove the sort the column installed");
|
||||
Verify.AreEqual(1, sortedArgs.Count, "The clear must raise Sorted once.");
|
||||
Verify.AreEqual(SortDirection.None, sortedArgs[0].Direction, "Sorted must report None.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a canceled CanSort = false clear keeps the sort, and SortByColumn(column, None) can still clear it.")]
|
||||
public void VerifyCanceledCanSortClearCanBeClearedBySortByColumnNone()
|
||||
{
|
||||
TableView tableView = null;
|
||||
var cancel = false;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateSortTable();
|
||||
tableView.Sorting += (sender, args) => args.Cancel = cancel;
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var column = tableView.Columns[0];
|
||||
Verify.IsTrue(tableView.SortByColumn(column, SortDirection.Ascending), "Baseline: the column must sort.");
|
||||
|
||||
cancel = true;
|
||||
column.CanSort = false;
|
||||
cancel = false;
|
||||
Verify.AreEqual(SortDirection.Ascending, column.SortDirection,
|
||||
"A Sorting handler that cancels the opt-out clear keeps the sort.");
|
||||
|
||||
Verify.IsFalse(tableView.SortByColumn(column, SortDirection.Descending),
|
||||
"CanSort = false must still refuse a new direction.");
|
||||
Verify.IsTrue(tableView.SortByColumn(column, SortDirection.None),
|
||||
"SortByColumn(column, None) must clear a sort the opted-out column still holds.");
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The sort must be cleared.");
|
||||
VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name),
|
||||
"clearing the sort must restore source order");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies ClearSort resets every column and restores source order.")]
|
||||
public void VerifyClearSortClearsEveryColumnAndRestoresSourceOrder()
|
||||
@@ -1139,6 +1319,356 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies clearing a column lit by an app-declared TableViewSource sort removes that sort.")]
|
||||
[TestProperty("data:Clear", "{SortByColumnNone, HeaderInvoke}")]
|
||||
public void VerifyClearingColumnLitBySourceSortRemovesSourceSort()
|
||||
{
|
||||
var clear = ((string)TestContext.DataRow["Clear"]).Trim();
|
||||
TableView tableView = null;
|
||||
TableViewSource source = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
source = TableViewSource.From(MakeShapedPeople());
|
||||
tableView = CreateSortTable(source);
|
||||
tableView.Columns[0].SortCycle = TableViewSortCycle.AscendingDescendingNone;
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() => source.Sort("Name", SortDirection.Descending));
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.Descending, tableView.Columns[0].SortDirection,
|
||||
"Precondition: the source sort lights the Name column.");
|
||||
VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(tableView, person => person.Name),
|
||||
"precondition: the source sort orders the rows");
|
||||
|
||||
switch (clear)
|
||||
{
|
||||
case "SortByColumnNone":
|
||||
tableView.SortByColumn(tableView.Columns[0], SortDirection.None);
|
||||
break;
|
||||
default:
|
||||
var provider = new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[0])
|
||||
.GetPattern(PatternInterface.Invoke) as IInvokeProvider;
|
||||
provider.Invoke();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, $"{clear}: the column must be cleared.");
|
||||
VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name),
|
||||
$"{clear}: the clear must remove the app-declared sort");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies moving, replacing or ungating the columns keeps an app-declared TableViewSource sort and re-lights the matching column.")]
|
||||
[TestProperty("data:Change", "{MoveColumn, ReplaceColumns, CanUserSortColumnsFalse}")]
|
||||
public void VerifyImplicitClearKeepsSourceSort()
|
||||
{
|
||||
var change = ((string)TestContext.DataRow["Change"]).Trim();
|
||||
TableView tableView = null;
|
||||
TableViewSource source = null;
|
||||
var sortedArgs = new List<TableViewSortedEventArgs>();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
source = TableViewSource.From(MakeShapedPeople());
|
||||
tableView = CreateSortTable(source);
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() => source.Sort("Name", SortDirection.Descending));
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.Descending, tableView.Columns[0].SortDirection,
|
||||
"Precondition: the source sort lights the Name column.");
|
||||
tableView.Sorted += (s, e) => sortedArgs.Add(e);
|
||||
|
||||
switch (change)
|
||||
{
|
||||
case "MoveColumn":
|
||||
var column = tableView.Columns[0];
|
||||
tableView.Columns.RemoveAt(0);
|
||||
tableView.Columns.Insert(1, column);
|
||||
break;
|
||||
case "ReplaceColumns":
|
||||
tableView.Columns.Clear();
|
||||
tableView.Columns.Add(MakeTextColumn("Name"));
|
||||
tableView.Columns.Add(MakeTextColumn("Role"));
|
||||
break;
|
||||
default:
|
||||
tableView.CanUserSortColumns = false;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(tableView, person => person.Name),
|
||||
$"{change}: the app-declared sort must stay on the source");
|
||||
var nameColumn = tableView.Columns.First(c => c.Header as string == "Name");
|
||||
Verify.AreEqual(SortDirection.Descending, nameColumn.SortDirection,
|
||||
$"{change}: the Name column must re-light from the source sort");
|
||||
Verify.IsFalse(sortedArgs.Any(e => e.Column == null && e.Direction == SortDirection.None),
|
||||
$"{change}: a clear that left the source sort in place must not report that sorting was cleared");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies turning CanUserSortColumns off right after the app sorts the source removes only the table's axis and reports no clear.")]
|
||||
public void VerifyUngatingAfterAppSourceSortDoesNotReportClear()
|
||||
{
|
||||
TableView tableView = null;
|
||||
TableViewSource source = null;
|
||||
var sortedArgs = new List<TableViewSortedEventArgs>();
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
source = TableViewSource.From(MakeShapedPeople());
|
||||
tableView = CreateSortTable(source);
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the Name column sorts.");
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView.Sorted += (s, e) => sortedArgs.Add(e);
|
||||
// Same tick: the table's own axis is still installed when the gate closes.
|
||||
source.Sort("Role", SortDirection.Descending);
|
||||
tableView.CanUserSortColumns = false;
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsFalse(sortedArgs.Any(e => e.Column == null && e.Direction == SortDirection.None),
|
||||
"Removing the table's axis while the app's sort remains must not report that sorting was cleared.");
|
||||
var roleColumn = tableView.Columns.First(c => c.Header as string == "Role");
|
||||
var nameColumn = tableView.Columns.First(c => c.Header as string == "Name");
|
||||
Verify.AreEqual(SortDirection.Descending, roleColumn.SortDirection, "The app's Role sort must light its column.");
|
||||
Verify.AreEqual(SortDirection.None, nameColumn.SortDirection, "The table's own Name sort must be gone.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies filter changes under an app sort no column describes raise no Sorted.")]
|
||||
[TestProperty("data:Sort", "{KeySelector, UnmatchedPath}")]
|
||||
public void VerifyFilterUnderUnmatchedAppSortRaisesNoSorted()
|
||||
{
|
||||
var sort = ((string)TestContext.DataRow["Sort"]).Trim();
|
||||
TableView tableView = null;
|
||||
TableViewSource source = null;
|
||||
int sortedCount = 0;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
source = TableViewSource.From(MakeShapedPeople());
|
||||
tableView = CreateSortTable(source);
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
if (sort == "KeySelector")
|
||||
{
|
||||
source.Sort(item => ((ShapedPerson)item).Name, SortDirection.Ascending);
|
||||
}
|
||||
else
|
||||
{
|
||||
source.Sort("DepartmentName", SortDirection.Ascending);
|
||||
}
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
if (i == 0)
|
||||
{
|
||||
tableView.Sorted += (s, e) => sortedCount++;
|
||||
}
|
||||
source.Filter(_ => true);
|
||||
});
|
||||
SettleLayout(tableView);
|
||||
}
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(0, sortedCount, $"{sort}: a filter change must not report a sort change.");
|
||||
Verify.IsTrue(tableView.Columns.All(c => c.SortDirection == SortDirection.None), $"{sort}: no column describes the app's sort.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies turning sorting off for the table or the column leaves a column the app's source sort lit untouched and raises no sort events.")]
|
||||
[TestProperty("data:Change", "{CanUserSortColumnsFalse, CanSortFalse}")]
|
||||
public void VerifyUngatingKeepsAppLitColumnWithoutEvents()
|
||||
{
|
||||
var change = ((string)TestContext.DataRow["Change"]).Trim();
|
||||
TableView tableView = null;
|
||||
TableViewSource source = null;
|
||||
int sortingCount = 0;
|
||||
int sortedCount = 0;
|
||||
int directionChanges = 0;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
source = TableViewSource.From(MakeShapedPeople());
|
||||
tableView = CreateSortTable(source);
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() => source.Sort("Name", SortDirection.Ascending));
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.Ascending, tableView.Columns[0].SortDirection, "Precondition: the source sort lights the Name column.");
|
||||
tableView.Sorting += (s, e) => sortingCount++;
|
||||
tableView.Sorted += (s, e) => sortedCount++;
|
||||
tableView.Columns[0].RegisterPropertyChangedCallback(TableViewColumn.SortDirectionProperty, (s, dp) => directionChanges++);
|
||||
if (change == "CanSortFalse")
|
||||
{
|
||||
tableView.Columns[0].CanSort = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
tableView.CanUserSortColumns = false;
|
||||
}
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(0, sortingCount, "The table installed no sort, so there is nothing to clear and no Sorting.");
|
||||
Verify.AreEqual(0, sortedCount, "No Sorted may be raised.");
|
||||
Verify.AreEqual(0, directionChanges, "The column's SortDirection must not flip.");
|
||||
Verify.AreEqual(SortDirection.Ascending, tableView.Columns[0].SortDirection, "The column keeps describing the app's sort.");
|
||||
VerifySequence(NameAscendingOrder, RowValues(tableView, person => person.Name), "the app's sort stays on the source");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a sort opt-out rebuilds the headers even when a Sorted handler throws.")]
|
||||
[TestProperty("data:OptOut", "{CanSort, CanUserSortColumns}")]
|
||||
public void VerifySortOptOutRebuildsHeadersWhenSortedHandlerThrows()
|
||||
{
|
||||
var optOut = ((string)TestContext.DataRow["OptOut"]).Trim();
|
||||
TableView tableView = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateSortTable();
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts.");
|
||||
tableView.Sorted += (s, e) => throw new InvalidOperationException("Sorted handler fault");
|
||||
try
|
||||
{
|
||||
if (optOut == "CanSort")
|
||||
{
|
||||
tableView.Columns[0].CanSort = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
tableView.CanUserSortColumns = false;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Comment($"{optOut}: the handler fault surfaced: {e.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var indicator = FindSortIndicator(tableView, 0);
|
||||
Verify.IsTrue(indicator == null || indicator.Visibility == Visibility.Collapsed,
|
||||
$"{optOut}: the header must drop its sort affordance even though a Sorted handler threw.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a clear queued by removing the sorted column leaves the app-declared sort of the next ItemsSource alone.")]
|
||||
public void VerifyColumnRemovalClearKeepsNewItemsSourceSort()
|
||||
{
|
||||
TableView tableView = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateSortTable(TableViewSource.From(MakeShapedPeople()));
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() => Verify.IsTrue(tableView.SortByColumn(tableView.Columns[1], SortDirection.Ascending),
|
||||
"Precondition: the Role column sorts."));
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView.Columns.RemoveAt(1);
|
||||
var next = TableViewSource.From(MakeShapedPeople());
|
||||
next.Sort("Name", SortDirection.Descending);
|
||||
tableView.ItemsSource = next;
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(tableView, person => person.Name),
|
||||
"the new ItemsSource must keep the sort the app declared on it");
|
||||
Verify.AreEqual(SortDirection.Descending, tableView.Columns[0].SortDirection,
|
||||
"The Name column must reflect the new source's sort.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies invoking a column header toggles the sort and updates the sort indicator.")]
|
||||
public void VerifyHeaderInvokeTogglesSortAndUpdatesIndicator()
|
||||
|
||||
@@ -153,6 +153,136 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies clearing a column on an unloaded table leaves the sort of the shared TableViewSource its new owner shows.")]
|
||||
public void VerifyUnloadedTableClearKeepsSharedSourceSort()
|
||||
{
|
||||
TableView first = null;
|
||||
TableView second = null;
|
||||
TableViewSource shared = null;
|
||||
StackPanel host = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
shared = TableViewSource.From(MakeShapedPeople());
|
||||
first = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300);
|
||||
host = new StackPanel();
|
||||
host.Children.Add(first);
|
||||
LoadContent(host);
|
||||
});
|
||||
|
||||
SettleLayout(first);
|
||||
RunOnUIThread.Execute(() => shared.Sort("Name", SortDirection.Descending));
|
||||
SettleLayout(first);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.Descending, first.Columns[0].SortDirection, "Precondition: the source sort lights the first table.");
|
||||
host.Children.Remove(first);
|
||||
second = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300);
|
||||
host.Children.Add(second);
|
||||
});
|
||||
|
||||
SettleLayout(second);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name),
|
||||
"precondition: the new owner shows the source sort");
|
||||
first.SortByColumn(first.Columns[0], SortDirection.None);
|
||||
Verify.AreEqual(SortDirection.None, first.Columns[0].SortDirection, "The unloaded table clears its own column.");
|
||||
});
|
||||
|
||||
SettleLayout(second);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name),
|
||||
"a table that released the shared source must not clear the sort its new owner shows");
|
||||
Verify.AreEqual(SortDirection.Descending, second.Columns[0].SortDirection, "The new owner keeps its indicator.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a clear made before the table first loads still holds after it loads.")]
|
||||
public void VerifyClearBeforeLoadedStaysCleared()
|
||||
{
|
||||
TableView tableView = null;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateTableView(TableViewSource.From(MakeShapedPeople()), headers: new[] { "Name", "Role" }, width: 500, height: 300);
|
||||
Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the unloaded table sorts.");
|
||||
Verify.IsTrue(tableView.ClearSort(), "Precondition: the unloaded table clears its sort.");
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
SettleLayout(tableView);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The cleared column must stay cleared once the table loads.");
|
||||
VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), "the rows must stay in source order");
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies sorting an unloaded table leaves the shared TableViewSource its new owner shows unchanged.")]
|
||||
public void VerifyUnloadedTableSortKeepsSharedSourceOrder()
|
||||
{
|
||||
TableView first = null;
|
||||
TableView second = null;
|
||||
TableViewSource shared = null;
|
||||
StackPanel host = null;
|
||||
int sortingCount = 0;
|
||||
int sortedCount = 0;
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
shared = TableViewSource.From(MakeShapedPeople());
|
||||
first = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300);
|
||||
host = new StackPanel();
|
||||
host.Children.Add(first);
|
||||
LoadContent(host);
|
||||
});
|
||||
|
||||
SettleLayout(first);
|
||||
RunOnUIThread.Execute(() => shared.Sort("Name", SortDirection.Descending));
|
||||
SettleLayout(first);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
Verify.AreEqual(SortDirection.Descending, first.Columns[0].SortDirection, "Precondition: the source sort lights the first table.");
|
||||
host.Children.Remove(first);
|
||||
second = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300);
|
||||
host.Children.Add(second);
|
||||
});
|
||||
|
||||
SettleLayout(second);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name),
|
||||
"precondition: the new owner shows the source sort");
|
||||
first.Sorting += (s, e) => sortingCount++;
|
||||
first.Sorted += (s, e) => sortedCount++;
|
||||
Verify.IsFalse(first.SortByColumn(first.Columns[0], SortDirection.Ascending),
|
||||
"A sort the shared source cannot take must report failure.");
|
||||
});
|
||||
|
||||
SettleLayout(second);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name),
|
||||
"a table that released the shared source must not re-sort the rows its new owner shows");
|
||||
Verify.AreEqual(SortDirection.Descending, second.Columns[0].SortDirection, "The new owner keeps its indicator.");
|
||||
Verify.AreEqual(0, sortingCount, "A refused sort must not raise Sorting.");
|
||||
Verify.AreEqual(0, sortedCount, "A refused sort must not raise Sorted.");
|
||||
Verify.AreEqual(SortDirection.Descending, first.Columns[0].SortDirection, "A refused sort leaves the column state unchanged.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -324,6 +324,45 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies recycled rows keep their tooltip and help text when the bound value does not change between items.")]
|
||||
public void VerifyRecycledRowKeepsToolTipWhenItemsShareTheValue()
|
||||
{
|
||||
TableView tableView = null;
|
||||
var items = MakeManyItems(200);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
tableView = CreateToolTipTable(items, cellToolTipPath: null);
|
||||
// An explicit Source keeps the value constant across the DataContext reset, so a
|
||||
// recycled cell gets no property-changed callback to revive its tooltip.
|
||||
tableView.Columns[0].CellToolTipBinding = new Binding { Source = "Shared role", Mode = BindingMode.OneWay };
|
||||
LoadContent(tableView);
|
||||
});
|
||||
|
||||
IdleSynchronizer.Wait();
|
||||
|
||||
ScrollBodyTo(tableView, ScrollAxis.Vertical, 2000.0);
|
||||
ScrollBodyTo(tableView, ScrollAxis.Vertical, 4000.0);
|
||||
|
||||
RunOnUIThread.Execute(() =>
|
||||
{
|
||||
var rows = GetRealizedRows(tableView);
|
||||
Verify.IsGreaterThan(rows.Count, 0, "Rows must be realized after the scroll.");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var item = row.DataContext as Person;
|
||||
var wrapper = GetRowCell(row, 0);
|
||||
var toolTip = RequireToolTip(wrapper, $"the row for {item?.Name}");
|
||||
Verify.AreEqual("Shared role", toolTip.Content as string, "A recycled row must keep its tooltip content.");
|
||||
Verify.IsTrue(toolTip.IsEnabled, "A recycled row's tooltip must be enabled.");
|
||||
Verify.AreEqual("Shared role", AutomationProperties.GetHelpText(wrapper),
|
||||
"A recycled row must re-publish the tooltip as help text.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies a source PropertyChanged updates a live cell tooltip in place, on the same ToolTip instance.")]
|
||||
public void VerifyItemPropertyChangeUpdatesCellToolTipInPlace()
|
||||
|
||||
@@ -721,6 +721,25 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests
|
||||
return value;
|
||||
}
|
||||
|
||||
internal static bool WaitForStatusText(string expected, int timeoutMs = 10000)
|
||||
{
|
||||
var status = FindElement.ById<TextBlock>(StatusText);
|
||||
string value = null;
|
||||
DateTime deadline = DateTime.Now.AddMilliseconds(timeoutMs);
|
||||
while (status != null)
|
||||
{
|
||||
value = status.DocumentText;
|
||||
if (value == expected || DateTime.Now >= deadline)
|
||||
{
|
||||
break;
|
||||
}
|
||||
Wait.ForMilliseconds(100);
|
||||
}
|
||||
|
||||
Log.Comment("Status = '{0}' (expected '{1}').", value, expected);
|
||||
return value == expected;
|
||||
}
|
||||
|
||||
internal static string ReadEditReport() => ReadPageReadout(EditColumnReport);
|
||||
|
||||
internal static string ReadEditorProbe() => ReadPageReadout(EditorProbe);
|
||||
|
||||
@@ -163,6 +163,41 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies pinning and unpinning a column raises a Transform.CanResize property-changed event on its header, so a UIA client's cached value stays current.")]
|
||||
public void ColumnPinChangeRaisesCanResizePropertyChanged()
|
||||
{
|
||||
using (var setup = new TestSetupHelper(PageName))
|
||||
{
|
||||
UIObject score = GetHeader(BasicTable, "Score");
|
||||
var togglePin = FindElement.ById<Button>(ToggleScorePinButton);
|
||||
if (score == null || togglePin == null)
|
||||
{
|
||||
Verify.Fail("The Score header and/or ToggleScorePinButton were not found on the test page.");
|
||||
return;
|
||||
}
|
||||
|
||||
UIProperty canResize = UIProperty.Get("Transform.CanResize");
|
||||
Verify.IsTrue(Convert.ToBoolean(score.GetProperty(canResize)), "Precondition: Score starts resizable.");
|
||||
|
||||
using (var waiter = new PropertyChangedEventWaiter(score, Scope.Element, canResize))
|
||||
{
|
||||
togglePin.InvokeAndWait();
|
||||
Wait.ForIdle();
|
||||
Verify.IsTrue(waiter.TryWait(TimeSpan.FromSeconds(5)), "Pinning must raise a CanResize property-changed event.");
|
||||
}
|
||||
Verify.IsFalse(Convert.ToBoolean(score.GetProperty(canResize)), "A pinned column must report CanResize=false.");
|
||||
|
||||
using (var waiter = new PropertyChangedEventWaiter(score, Scope.Element, canResize))
|
||||
{
|
||||
togglePin.InvokeAndWait();
|
||||
Wait.ForIdle();
|
||||
Verify.IsTrue(waiter.TryWait(TimeSpan.FromSeconds(5)), "Unpinning must raise a CanResize property-changed event.");
|
||||
}
|
||||
Verify.IsTrue(Convert.ToBoolean(score.GetProperty(canResize)), "An unpinned column must report CanResize=true.");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Automation peer geometry
|
||||
|
||||
@@ -1448,13 +1448,10 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "A header click on a CanSort=False column does not sort, while a sortable sibling column still does (IDL:156-158).")]
|
||||
[TestProperty("Description", "A header click on a CanSort=False column does not sort, while a sortable sibling column still does (TableView.idl CanSort).")]
|
||||
public void HeaderClickDoesNotSortColumnWithCanSortFalse()
|
||||
{
|
||||
// TableView.idl:156-158 states the gate as a fact about the CLICK: "Per-column opt-out for the
|
||||
// click-to-sort UX. Default true. When false the header click handler ignores this column;
|
||||
// programmatic SortByColumn still works." No API test can assert that sentence - API 7 can only
|
||||
// cover the programmatic half - so the route test is the only place the opt-out is checked.
|
||||
// The only check of the click half of CanSort; API tests cover SortByColumn/ToggleSortDirection.
|
||||
//
|
||||
// The ReadOnlyCity column of BasicTableView is authored CanSort="False"; Age is CanSort="True".
|
||||
// Clicking Age afterwards is a NEGATIVE CONTROL and is the point of the test: without it, a click
|
||||
|
||||
@@ -258,6 +258,111 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies losing the gripper's pointer capture mid-drag cancels the resize and leaves the gripper usable.")]
|
||||
public void PointerResizeCaptureLostMidDragCancelsResize()
|
||||
{
|
||||
HoldResizeThroughDelayedTeardown(DelayedGripperCaptureLossButton, pressOnly: false,
|
||||
"Gripper capture released", null, "Losing capture mid-drag");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies disabling the table while the gripper holds the pointer cancels the resize and leaves the gripper usable once re-enabled.")]
|
||||
public void PointerResizeDisabledWhileCapturedCancelsResize()
|
||||
{
|
||||
HoldResizeThroughDelayedTeardown(DelayedDisableButton, pressOnly: false,
|
||||
"Disabled", "Enabled", "Disabling while captured");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestProperty("Description", "Verifies unloading the table while a gripper press holds capture, before any drag starts, leaves the gripper usable after reload.")]
|
||||
public void PointerPressOnGripperThenUnloadLeavesNoWedgedState()
|
||||
{
|
||||
HoldResizeThroughDelayedTeardown(DelayedUnloadButton, pressOnly: true,
|
||||
"Unloaded", "Reloaded", "Unloading after a press");
|
||||
}
|
||||
|
||||
private static void HoldResizeThroughDelayedTeardown(string teardownButtonId, bool pressOnly, string teardownStatus, string restoredStatus, string scenario)
|
||||
{
|
||||
using (var setup = new TestSetupHelper(PageName))
|
||||
{
|
||||
UIObject nameHeader = FindColumnHeader(FindElement.ById(BasicTable), "Name");
|
||||
if (nameHeader == null)
|
||||
{
|
||||
Verify.Fail("The 'Name' column header peer was not found on the header host.");
|
||||
return;
|
||||
}
|
||||
|
||||
var teardown = FindElement.ById<Button>(teardownButtonId);
|
||||
if (teardown == null)
|
||||
{
|
||||
Verify.Fail(teardownButtonId + " was not found on the test page.");
|
||||
return;
|
||||
}
|
||||
|
||||
double widthBefore = nameHeader.BoundingRectangle.Width;
|
||||
var bounds = nameHeader.BoundingRectangle;
|
||||
int grabOffsetX = (bounds.Width / 2) - 1;
|
||||
int startX = bounds.Left + bounds.Width - 1;
|
||||
int y = bounds.Top + (bounds.Height / 2);
|
||||
|
||||
InputHelper.LeftMouseButtonDown(nameHeader, grabOffsetX, 0);
|
||||
try
|
||||
{
|
||||
if (!pressOnly)
|
||||
{
|
||||
InputHelper.MoveMouse(new Point(startX + 40, y));
|
||||
}
|
||||
// UIA, not a click: the pointer stays pressed on the gripper.
|
||||
teardown.Invoke();
|
||||
if (!WaitForStatusText(teardownStatus))
|
||||
{
|
||||
Verify.Fail("The page did not report '" + teardownStatus + "' while the pointer was held.");
|
||||
return;
|
||||
}
|
||||
Wait.ForIdle();
|
||||
if (!pressOnly)
|
||||
{
|
||||
InputHelper.MoveMouse(new Point(startX + 80, y));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
InputHelper.LeftMouseButtonUp();
|
||||
Wait.ForIdle();
|
||||
}
|
||||
|
||||
if (restoredStatus != null)
|
||||
{
|
||||
if (!WaitForStatusText(restoredStatus))
|
||||
{
|
||||
Verify.Fail("The page did not report '" + restoredStatus + "' after the teardown.");
|
||||
return;
|
||||
}
|
||||
Wait.ForIdle();
|
||||
}
|
||||
|
||||
UIObject header = FindColumnHeader(FindElement.ById(BasicTable), "Name");
|
||||
if (header == null)
|
||||
{
|
||||
Verify.Fail("The 'Name' column header peer was not found after the teardown.");
|
||||
return;
|
||||
}
|
||||
|
||||
double widthAfterTeardown = header.BoundingRectangle.Width;
|
||||
Log.Comment("{0}: Name width before={1}, after the release={2}.", scenario, widthBefore, widthAfterTeardown);
|
||||
Verify.IsLessThanOrEqual(Math.Abs(widthAfterTeardown - widthBefore), 8.0,
|
||||
scenario + " must cancel the resize: the abandoned drag must not keep its width.");
|
||||
|
||||
DragColumnBoundary(header, 80, cancelWithEscape: false);
|
||||
Wait.ForIdle();
|
||||
double widthAfterResize = FindColumnHeader(FindElement.ById(BasicTable), "Name").BoundingRectangle.Width;
|
||||
Log.Comment("{0}: Name width after a normal resize={1}.", scenario, widthAfterResize);
|
||||
Verify.IsGreaterThan(widthAfterResize, widthAfterTeardown + 20.0,
|
||||
scenario + " must leave the gripper usable: a later resize must widen the column.");
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct CURSORINFO
|
||||
{
|
||||
@@ -304,7 +409,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests
|
||||
|
||||
// The cursor is updated asynchronously after the pointer moves, so poll until several
|
||||
// consecutive samples agree rather than accepting a single transient match. Returns false,
|
||||
// with a warning, when the cursor is not showing: there is no cursor to verify.
|
||||
// reporting the test Skipped, when the cursor is not showing: there is no cursor to verify.
|
||||
private static bool VerifyCursorBecomes(IntPtr expected, string message, int requiredMatches = 3)
|
||||
{
|
||||
var samples = new List<string>();
|
||||
@@ -329,7 +434,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests
|
||||
|
||||
if (matches < requiredMatches && !cursor.HasValue)
|
||||
{
|
||||
Log.Warning(string.Format("{0} Skipped: the system cursor is not showing; sampled [{1}].", message, string.Join(", ", samples)));
|
||||
Log.Result(TestResult.Skipped, string.Format("{0} The system cursor is not showing; sampled [{1}].", message, string.Join(", ", samples)));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -172,10 +172,6 @@ struct TableViewResourceCache
|
||||
{
|
||||
bool hasCellFontSize{ false };
|
||||
double cellFontSize{ 0.0 };
|
||||
bool hasHeaderFontSize{ false };
|
||||
double headerFontSize{ 0.0 };
|
||||
bool hasHeaderFontWeight{ false };
|
||||
winrt::Windows::UI::Text::FontWeight headerFontWeight{};
|
||||
};
|
||||
FontInfo font{};
|
||||
|
||||
@@ -187,8 +183,8 @@ struct TableViewResourceCache
|
||||
double rowIndentSize{ 0.0 };
|
||||
bool hasRowExpanderSize{ false };
|
||||
double rowExpanderSize{ 0.0 };
|
||||
bool hasGroupExpanderSize{ false };
|
||||
double groupExpanderSize{ 0.0 };
|
||||
bool hasGroupHeaderContentOffset{ false };
|
||||
double groupHeaderContentOffset{ 0.0 };
|
||||
};
|
||||
HierarchyInfo hierarchy{};
|
||||
|
||||
@@ -257,9 +253,7 @@ public:
|
||||
winrt::Thickness GetDensityCellPadding();
|
||||
winrt::Thickness GetDensityHeaderCellPadding();
|
||||
double GetCellFontSize();
|
||||
double GetHeaderFontSize();
|
||||
winrt::Windows::UI::Text::FontWeight GetHeaderFontWeight();
|
||||
void AttachFocusCornerRadius(winrt::Grid const& target);
|
||||
void AttachFocusCornerRadius(winrt::FrameworkElement const& target);
|
||||
|
||||
// Per-level indent from the TableViewRowIndentSize resource, cached for rows.
|
||||
double GetRowIndentSize();
|
||||
@@ -272,11 +266,10 @@ public:
|
||||
// density/font accessors above.
|
||||
winrt::Brush GetGridLineBrush();
|
||||
|
||||
// Header pointer-state brushes; looked up per state change, so not cached.
|
||||
winrt::Brush LookupHeaderStateBrush(std::wstring_view key);
|
||||
static winrt::SortIndicatorDirection ToSortIndicatorDirection(winrt::SortDirection direction);
|
||||
|
||||
// Code-created, so its Name is in no XAML namescope and FindName cannot resolve it.
|
||||
static winrt::SortIndicator FindSortIndicator(const winrt::Panel& root);
|
||||
void OnColumnHeaderTemplateApplied(winrt::TableViewColumnHeader const& header);
|
||||
void OnColumnHeaderStylePropertyChanged(const winrt::DependencyPropertyChangedEventArgs& args);
|
||||
|
||||
// Per-instance resource cache (density metrics + gridline brush); accessed by the
|
||||
// file-scope resource helpers in TableView.cpp through this owner pointer.
|
||||
@@ -290,9 +283,9 @@ public:
|
||||
// Toggling it adds or removes every gripper, so the header band is rebuilt.
|
||||
void OnCanUserResizeColumnsPropertyChanged(const winrt::DependencyPropertyChangedEventArgs& args);
|
||||
|
||||
// The gripper owns the resize mechanics; this only positions it and forwards the pointer drag.
|
||||
void AppendResizeGripperVisual(
|
||||
const winrt::Grid& headerCell,
|
||||
// The gripper owns the resize mechanics; this only sizes it and forwards the pointer drag.
|
||||
winrt::ResizeGripper CreateResizeGripper(
|
||||
const winrt::FrameworkElement& headerCell,
|
||||
const winrt::TableViewColumn& column,
|
||||
double gripperWidth,
|
||||
const winrt::hstring& headerText);
|
||||
@@ -301,6 +294,11 @@ public:
|
||||
|
||||
// Idempotent; safe from any pointer end-event, the gripper's Unloaded, or TableView's own.
|
||||
void CancelColumnResizeDrag();
|
||||
|
||||
// UIA Transform support for the column header peer. Driven through the column's gripper so the
|
||||
// bounds, Star handling and announcement match a pointer or Alt+Arrow resize exactly.
|
||||
bool CanResizeColumnFromAutomation(const winrt::TableViewColumn& column);
|
||||
bool TryResizeColumnFromAutomation(const winrt::TableViewColumn& column, double width);
|
||||
void AnnounceColumnWidth(const winrt::IInspectable& announcer, const winrt::TableViewColumn& column);
|
||||
|
||||
// Coalesces bursts of column-driven header rebuilds (bulk Columns edits, per-column Header /
|
||||
@@ -522,6 +520,8 @@ public:
|
||||
// Defer a source-reshaping operation until the open edit closes. Opaque action, so editing
|
||||
// needs no compile-time knowledge of sorting, grouping or expansion.
|
||||
void QueueCoalescedEditReshape(std::function<void()> operation);
|
||||
// Unlike the queue above, survives a vetoed close and replays only once no edit is open.
|
||||
void QueueReshapeAfterEditCloses(std::function<void()> operation);
|
||||
void ClearCoalescedEditReshape();
|
||||
void DrainCoalescedEditReshape();
|
||||
|
||||
@@ -642,7 +642,12 @@ private:
|
||||
// Drives the SelectionModel; its SelectionChanged is the single funnel that publishes.
|
||||
void ApplySelection(int32_t index, bool measureTip = false);
|
||||
|
||||
void ReleaseHeaderToolTips(const winrt::Panel& host);
|
||||
enum class HeaderRelease { All, DetachedColumns, Column };
|
||||
void ReleaseHeaderCells(const winrt::Panel& host, HeaderRelease scope = HeaderRelease::All, const winrt::TableViewColumn& column = nullptr);
|
||||
void MoveHeaderFocusOffRemovedColumn(const winrt::Panel& host, const winrt::FrameworkElement& removedCell, const winrt::TableViewColumn& removedColumn, winrt::FocusState focusState);
|
||||
bool FocusBodyAfterHeaderRemoval(winrt::FocusState focusState);
|
||||
void RebuildHeadersCore();
|
||||
winrt::Style CompatibleHeaderStyle(const winrt::Style& style);
|
||||
winrt::TableViewColumn GetFocusedHeaderColumn(const winrt::Panel& host, winrt::FocusState& focusState) const;
|
||||
// Refocuses the rebuilt header for column; retries once after layout if it cannot take focus yet.
|
||||
void RestoreHeaderFocus(const winrt::TableViewColumn& column, winrt::FocusState focusState);
|
||||
@@ -812,6 +817,7 @@ private:
|
||||
// Pending source-reshaping operations. A deque, not a vector: the drain pops from the front so
|
||||
// replay order matches arrival order.
|
||||
std::deque<std::function<void()>> m_pendingEditReshapes;
|
||||
std::deque<std::function<void()>> m_reshapesAfterEditCloses;
|
||||
|
||||
// Keyboard/focus position. Deliberately NOT redefined while an edit is open, so a value read
|
||||
// inside an EditEnding handler stays valid once the edit closes.
|
||||
@@ -854,6 +860,8 @@ private:
|
||||
bool TryGetItemAtRowIndex(int32_t rowIndex, winrt::IInspectable& item) const;
|
||||
winrt::TableViewRow FindRealizedRowForItem(winrt::IInspectable const& item);
|
||||
bool TryBeginEditVisual(winrt::IInspectable const& item, winrt::TableViewColumn const& column);
|
||||
// Beginning -> None without touching the row's visual or the item transaction.
|
||||
void ResetPendingBeginEdit();
|
||||
|
||||
void EndEditVisual(winrt::TableViewEditAction action, ActionTip* tip);
|
||||
|
||||
@@ -1183,9 +1191,10 @@ private:
|
||||
// Re-validated after every point where app code could have run (a Sorting handler, or an
|
||||
// edit-ending handler): the columns collection may have changed underneath the request that is
|
||||
// still in flight.
|
||||
bool IsSortRequestStillValid(const winrt::TableViewColumn& column) const;
|
||||
// requireCanSort=false is only for clearing a column that has just opted out of sorting.
|
||||
bool IsSortRequestStillValid(const winrt::TableViewColumn& column, bool requireCanSort = true) const;
|
||||
bool IsSortClearStillValid() const;
|
||||
bool CanSortColumnNow(const winrt::TableViewColumn& column) const;
|
||||
bool CanSortColumnNow(const winrt::TableViewColumn& column, bool requireCanSort = true) const;
|
||||
// False when the active source cannot order its rows (a null item or one object twice).
|
||||
bool CanApplySortTo(winrt::SortDirection direction) const;
|
||||
// The source the rows are projected through, app-assigned or synthesized. Non-null means the
|
||||
@@ -1195,9 +1204,13 @@ private:
|
||||
void ApplySingleColumnSortState(const winrt::TableViewColumn& column, winrt::SortDirection direction);
|
||||
// Applies the current sort state to the bound TableViewSource. Returns false when there is no
|
||||
// TableViewSource, or the trigger column resolves no sort key.
|
||||
bool SyncTableViewSourceSort(const winrt::TableViewColumn& trigger, winrt::SortDirection direction, uint64_t tipGeneration);
|
||||
bool SortByColumnInternal(const winrt::TableViewColumn& column, winrt::SortDirection direction, bool measureTip);
|
||||
bool ClearSortInternal(bool measureTip);
|
||||
// explicitClear: the app or user asked to clear, so a clear also removes app-declared axes.
|
||||
// Any other clear (a column leaving, CanUserSortColumns turning off) removes only this table's axis.
|
||||
bool SyncTableViewSourceSort(const winrt::TableViewColumn& trigger, winrt::SortDirection direction, uint64_t tipGeneration, bool explicitClear = false);
|
||||
bool SortByColumnInternal(const winrt::TableViewColumn& column, winrt::SortDirection direction, bool measureTip, bool requireCanSort = true);
|
||||
bool ClearSortInternal(bool measureTip, bool explicitClear = false);
|
||||
// The lit column describes the app's source sort, not one this table installed.
|
||||
bool IsSortLitOnlyByApp() const;
|
||||
winrt::TableViewKeySelector GetTableViewSourceSortKeySelector(const winrt::hstring& sortMemberPath);
|
||||
// Re-places a live-tracked item among CustomSortComparer ranks. True if any sort key moved.
|
||||
bool RepositionCustomSortItem(const winrt::IInspectable& item, bool reshapePending);
|
||||
@@ -1209,7 +1222,8 @@ private:
|
||||
bool RaiseSortingAndCheckCanceled(const winrt::TableViewColumn& trigger, winrt::SortDirection direction);
|
||||
// Single funnel for "the sort state has been written to the columns": reshapes, restores the
|
||||
// selection, raises Sorted, and announces.
|
||||
void RecomputeSortDPsAndRaiseInternal(const winrt::TableViewColumn& trigger, bool measureTip = true);
|
||||
// A non-null trigger is always an explicit request; a null one is explicit only when asked.
|
||||
void RecomputeSortDPsAndRaiseInternal(const winrt::TableViewColumn& trigger, bool measureTip = true, bool explicitClear = false);
|
||||
|
||||
// Silently drops the active sort when the data set is replaced. See the definition for why this
|
||||
// is not ClearSort.
|
||||
@@ -1221,8 +1235,6 @@ private:
|
||||
// re-enter the collection that is still mutating, so the reshape runs on the next turn.
|
||||
void QueueClearSortAfterColumnRemoval();
|
||||
bool m_clearSortAfterColumnRemovalQueued{ false };
|
||||
void AppendSortIndicatorVisual(const winrt::Panel& host, const winrt::TableViewColumn& column);
|
||||
static winrt::SortIndicatorDirection ToSortIndicatorDirection(winrt::SortDirection direction);
|
||||
|
||||
// v1 is single-column sort, so this holds at most one entry. It stays a vector because the
|
||||
// clear/purge walks are written against the collection and multi-column sort is the expected
|
||||
@@ -1262,13 +1274,17 @@ private:
|
||||
// changes without changing row/cell layout thickness.
|
||||
winrt::event_token m_terminalGridLinesLayoutToken{};
|
||||
winrt::event_token m_headerFocusRestoreLayoutToken{};
|
||||
// Focus left a removed column's header, for a remaining one or parked on the removed cell; the
|
||||
// rebuild returns it to the column if the column came back (a move), else moves it to the body.
|
||||
winrt::weak_ref<winrt::TableViewColumn> m_headerFocusRemovedColumn{ nullptr };
|
||||
winrt::weak_ref<winrt::TableViewColumn> m_headerFocusFallbackColumn{ nullptr };
|
||||
winrt::FocusState m_headerFocusRemovedState{ winrt::FocusState::Unfocused };
|
||||
winrt::weak_ref<winrt::TableViewRow> m_terminalGridLineRow{};
|
||||
winrt::weak_ref<winrt::TableViewGroupHeader> m_terminalGridLineGroupHeader{};
|
||||
winrt::FrameworkElement::SizeChanged_revoker m_terminalGridLineRowSizeChangedRevoker{};
|
||||
bool m_suppressTrailingGridLine{ false };
|
||||
bool m_suppressBottomGridLine{ false };
|
||||
bool m_terminalGridLineGeometryRetryAvailable{ true };
|
||||
bool m_reportedMissingGridLineBrush{ false };
|
||||
int32_t m_terminalGridLineColumnIndex{ -1 };
|
||||
double m_terminalGridLineHorizontalOffset{ std::numeric_limits<double>::quiet_NaN() };
|
||||
double m_terminalGridLineVerticalOffset{ std::numeric_limits<double>::quiet_NaN() };
|
||||
@@ -1424,6 +1440,9 @@ private:
|
||||
|
||||
double GetHeaderMeasuredWidthForColumn(const winrt::TableViewColumn& column) const;
|
||||
void ResolveColumnWidths();
|
||||
// Only Auto columns read realized-row widths, so row prepare/clear re-resolves only when one exists.
|
||||
bool HasVisibleAutoColumn();
|
||||
void InvalidateMeasureForRowRealizationChange();
|
||||
void ResetColumnDesiredWidths();
|
||||
// Limits a resize gesture to the width the other columns can absorb. Unbounded when nothing
|
||||
// constrains it.
|
||||
@@ -1432,13 +1451,22 @@ private:
|
||||
// Collapses the gripper of every pinned column; it stays in the tree for keyboard resize lookup.
|
||||
void RefreshResizeGripperAvailability();
|
||||
void FreezeColumnsBeforeResize(const winrt::TableViewColumn& column, std::vector<ColumnResizeFrozenColumn>& frozen);
|
||||
winrt::ResizeGripper GetAvailableResizeGripper(const winrt::TableViewColumn& column);
|
||||
bool IsColumnResizeAllowed(const winrt::TableViewColumn& column);
|
||||
static void RestoreColumnWidth(const winrt::TableViewColumn& column, const winrt::IInspectable& localWidth);
|
||||
// The drag's width write, clamped; false when the width is already there.
|
||||
static bool ApplyColumnResizeDrag(TableView* owner, const winrt::TableViewColumn& col, ColumnResizeDragState& state, double totalDelta);
|
||||
// Re-invalidate the header + realized row cells panels so they re-measure/arrange after a resolve.
|
||||
void InvalidateCellPanels();
|
||||
|
||||
bool m_frozenColumnsActive{ false };
|
||||
|
||||
bool m_rebuildHeadersQueued{ false };
|
||||
bool m_rebuildingHeaders{ false };
|
||||
bool m_inRebuildHeaders{ false };
|
||||
bool m_rebuildHeadersRequested{ false };
|
||||
// Incompatible header Styles already reported, so each is logged once.
|
||||
std::vector<winrt::weak_ref<winrt::Style>> m_loggedIncompatibleHeaderStyles;
|
||||
|
||||
bool m_columnsStructureChangedQueued{ false };
|
||||
|
||||
|
||||
@@ -119,6 +119,10 @@ unsealed runtimeclass TableViewColumn : Microsoft.UI.Xaml.DependencyObject
|
||||
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
|
||||
Object HeaderToolTip { get; set; };
|
||||
|
||||
// Style for this column's TableViewColumnHeader; wins over TableView.ColumnHeaderStyle.
|
||||
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
|
||||
Microsoft.UI.Xaml.Style HeaderStyle { get; set; };
|
||||
|
||||
// Width carries GridLength intent; ActualWidth is the resolved, MinWidth/MaxWidth-clamped
|
||||
// pixels (see TableView_Layout.cpp). Pixel = exact; Auto = widest realized cell (grows within a
|
||||
// data set); Star = a proportional share of the body viewport after fixed columns.
|
||||
@@ -154,8 +158,8 @@ unsealed runtimeclass TableViewColumn : Microsoft.UI.Xaml.DependencyObject
|
||||
[MUX_DEFAULT_VALUE("winrt::Visibility::Visible")]
|
||||
Microsoft.UI.Xaml.Visibility Visibility { get; set; };
|
||||
|
||||
// Per-column opt-out for the click-to-sort UX. Default true. When false the header click
|
||||
// handler ignores this column; programmatic SortByColumn still works.
|
||||
// Per-column sort opt-out (default true). False blocks header clicks, SortByColumn (except None) and
|
||||
// ToggleSortDirection; setting it false on the sorted column clears that sort.
|
||||
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
|
||||
[MUX_DEFAULT_VALUE("true")]
|
||||
Boolean CanSort { get; set; };
|
||||
@@ -222,8 +226,8 @@ unsealed runtimeclass TableViewColumn : Microsoft.UI.Xaml.DependencyObject
|
||||
// headers never use it.
|
||||
Microsoft.UI.Xaml.Controls.Primitives.FlyoutBase CellContextFlyout { get; set; };
|
||||
|
||||
// Shown at the header cell. First match wins: a ContextFlyout inside the HeaderTemplate, this,
|
||||
// then TableView.ContextFlyout. Collapsed columns and an editing table open no header menu.
|
||||
// Shown at the header cell, after a Style/HeaderTemplate ContextFlyout and before
|
||||
// TableView.ContextFlyout. Not opened for collapsed columns or while editing.
|
||||
Microsoft.UI.Xaml.Controls.Primitives.FlyoutBase HeaderContextFlyout { get; set; };
|
||||
|
||||
// The editing hooks a derived column would override - GenerateEditingElement, PrepareCellForEdit,
|
||||
@@ -234,6 +238,7 @@ unsealed runtimeclass TableViewColumn : Microsoft.UI.Xaml.DependencyObject
|
||||
static Microsoft.UI.Xaml.DependencyProperty HeaderTemplateProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty HeaderTemplateSelectorProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty HeaderToolTipProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty HeaderStyleProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty WidthProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty MinWidthProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty MaxWidthProperty { get; };
|
||||
@@ -377,11 +382,8 @@ runtimeclass TableViewGroupHeaderToggleRequestedEventArgs
|
||||
// The whole band is the toggle target (matching ListView / TreeView) rather than a
|
||||
// nested Button, so there is one interactive element and one automation story.
|
||||
//
|
||||
// Parts (all optional; omitting one degrades rather than fails):
|
||||
// PART_ExpanderGutter Border reserves the chevron column so content stays aligned
|
||||
// PART_ExpanderIcon FontIcon glyph driven by ExpansionStates, not by code
|
||||
// PART_GridLineBorder Border renders border chrome and terminal-line suppression
|
||||
// content region inherited from ContentControl
|
||||
// Parts: PART_Root, PART_ContentPresenter, PART_ExpanderIcon (targeted by the default states), PART_ExpanderGutter;
|
||||
// optional PART_GridLineBorder.
|
||||
//
|
||||
// States: CommonStates Normal|PointerOver|Pressed|Disabled
|
||||
// ExpansionStates Expanded|Collapsed
|
||||
@@ -412,6 +414,16 @@ unsealed runtimeclass TableViewGroupHeader : Microsoft.UI.Xaml.Controls.ContentC
|
||||
static Microsoft.UI.Xaml.DependencyProperty IsExpandableProperty { get; };
|
||||
}
|
||||
|
||||
// Templated header, one per column, created only by TableView; restyled via TableView.ColumnHeaderStyle
|
||||
// or TableViewColumn.HeaderStyle.
|
||||
// Optional parts: PART_ContentPresenter, PART_GridLine, PART_SortIndicatorHost, PART_ResizeGripperHost.
|
||||
[MUX_PREVIEW]
|
||||
[webhosthidden]
|
||||
runtimeclass TableViewColumnHeader : Microsoft.UI.Xaml.Controls.ContentControl
|
||||
{
|
||||
TableViewColumn Column { get; };
|
||||
}
|
||||
|
||||
[MUX_PREVIEW]
|
||||
[webhosthidden]
|
||||
//
|
||||
@@ -509,7 +521,7 @@ unsealed runtimeclass TableView : Microsoft.UI.Xaml.Controls.Control
|
||||
[MUX_DEFAULT_VALUE("winrt::TableViewHeadersVisibility::Column")]
|
||||
TableViewHeadersVisibility HeadersVisibility { get; set; };
|
||||
|
||||
// Controls row dividers and column separators; row banding is separate.
|
||||
// Body row dividers and column separators. Header lines are always drawn.
|
||||
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
|
||||
[MUX_DEFAULT_VALUE("winrt::TableViewGridLinesVisibility::All")]
|
||||
TableViewGridLinesVisibility GridLinesVisibility { get; set; };
|
||||
@@ -533,6 +545,10 @@ unsealed runtimeclass TableView : Microsoft.UI.Xaml.Controls.Control
|
||||
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
|
||||
Microsoft.UI.Xaml.DataTemplate GroupHeaderTemplate { get; set; };
|
||||
|
||||
// Style for every TableViewColumnHeader; TableViewColumn.HeaderStyle overrides it per column.
|
||||
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
|
||||
Microsoft.UI.Xaml.Style ColumnHeaderStyle { get; set; };
|
||||
|
||||
// Vertical density preset for row height and built-in cell/header padding.
|
||||
[MUX_PROPERTY_CHANGED_CALLBACK(TRUE)]
|
||||
[MUX_DEFAULT_VALUE("winrt::TableViewDensity::Standard")]
|
||||
@@ -636,12 +652,12 @@ unsealed runtimeclass TableView : Microsoft.UI.Xaml.Controls.Control
|
||||
|
||||
// Clears any other sort state, then sets this column's SortDirection. Pass SortDirection.None
|
||||
// to clear. Returns true when the sort state changed; returns false when the sort could not be
|
||||
// applied (for example, when the rows cannot be ordered).
|
||||
// applied (for example, the column cannot sort). Clearing with None works even when CanSort is false.
|
||||
Boolean SortByColumn(MU_XC_NAMESPACE.TableViewColumn column, SortDirection direction);
|
||||
|
||||
// Cycles the trigger column's direction per SortCycle, clearing any other sort state first.
|
||||
// Returns true when the sort state changed; returns false when the sort could not be applied
|
||||
// (for example, when the rows cannot be ordered).
|
||||
// (for example, when the rows cannot be ordered, or the column cannot sort, as for SortByColumn).
|
||||
Boolean ToggleSortDirection(MU_XC_NAMESPACE.TableViewColumn column);
|
||||
|
||||
// Clears every column's sort state in one batch. Returns true when anything was cleared.
|
||||
@@ -663,6 +679,7 @@ unsealed runtimeclass TableView : Microsoft.UI.Xaml.Controls.Control
|
||||
static Microsoft.UI.Xaml.DependencyProperty AlternatingRowBackgroundProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty EmptyTemplateProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty GroupHeaderTemplateProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty ColumnHeaderStyleProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty DensityProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty IsReadOnlyProperty { get; };
|
||||
static Microsoft.UI.Xaml.DependencyProperty SelectionModeProperty { get; };
|
||||
@@ -721,7 +738,7 @@ unsealed runtimeclass TableViewGroupHeaderAutomationPeer
|
||||
TableViewGroupHeaderAutomationPeer(MU_XC_NAMESPACE.TableViewGroupHeader owner);
|
||||
}
|
||||
|
||||
// Header peer gives plain Grid header visuals distinct automation metadata.
|
||||
// Peer of a TableViewColumnHeader and of the TableView-owned header entries.
|
||||
[MUX_PREVIEW]
|
||||
[webhosthidden]
|
||||
unsealed runtimeclass TableViewColumnHeaderAutomationPeer
|
||||
@@ -736,6 +753,7 @@ unsealed runtimeclass TableViewColumnHeaderAutomationPeer
|
||||
// are shown and the column is visible. Only when column headers are hidden does the name become
|
||||
// "{column header}, {cell value}". A cell holding an interactive control is named after its column
|
||||
// header, since the control names itself.
|
||||
// The constructor throws E_INVALIDARG for null arguments, a negative columnIndex, or a column/index outside the owning table.
|
||||
[MUX_PREVIEW]
|
||||
[webhosthidden]
|
||||
unsealed runtimeclass TableViewCellAutomationPeer
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<ClInclude Include="$(MSBuildThisFileDirectory)TableViewAutomationPeer.h" />
|
||||
<ClInclude Include="$(MSBuildThisFileDirectory)TableViewRowAutomationPeer.h" />
|
||||
<ClInclude Include="$(MSBuildThisFileDirectory)TableViewColumnHeaderAutomationPeer.h" />
|
||||
<ClInclude Include="$(MSBuildThisFileDirectory)TableViewHeaderCell.h" />
|
||||
<ClInclude Include="$(MSBuildThisFileDirectory)TableViewColumnHeader.h" />
|
||||
<ClInclude Include="$(MSBuildThisFileDirectory)TableViewCell.h" />
|
||||
<ClInclude Include="$(MSBuildThisFileDirectory)TableViewCellAutomationPeer.h" />
|
||||
<ClInclude Include="$(MSBuildThisFileDirectory)TableViewGroupHeader.h" />
|
||||
@@ -85,6 +85,7 @@
|
||||
<ClCompile Include="$(MSBuildThisFileDirectory)TableViewColumnHeaderAutomationPeer.cpp" />
|
||||
<ClCompile Include="$(MSBuildThisFileDirectory)TableViewCellAutomationPeer.cpp" />
|
||||
<ClCompile Include="$(MSBuildThisFileDirectory)TableViewGroupHeader.cpp" />
|
||||
<ClCompile Include="$(MSBuildThisFileDirectory)TableViewColumnHeader.cpp" />
|
||||
<ClCompile Include="$(MSBuildThisFileDirectory)TableViewGroupHeaderAutomationPeer.cpp" />
|
||||
<ClCompile Include="$(MSBuildThisFileDirectory)TableViewGroupInfo.cpp" />
|
||||
<ClCompile Include="$(MSBuildThisFileDirectory)TableViewRowInfo.cpp" />
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" />
|
||||
<Setter Property="Background" Value="{ThemeResource TabularSurfaceRowBackgroundBrush}" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource TabularSurfaceRowForegroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{ThemeResource TabularSurfaceGridLineBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{ThemeResource TabularSurfaceOuterBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||
@@ -48,12 +48,8 @@
|
||||
<Border x:Name="PART_HeaderRow"
|
||||
Grid.Row="0"
|
||||
Background="{ThemeResource TabularSurfaceHeaderBackgroundBrush}"
|
||||
BorderBrush="{ThemeResource TabularSurfaceGridLineBrush}">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
</Style>
|
||||
</Border.Style>
|
||||
BorderBrush="{ThemeResource TabularSurfaceHeaderSeparatorBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid>
|
||||
<ScrollViewer x:Name="PART_HeaderScroller"
|
||||
HorizontalScrollMode="Disabled"
|
||||
@@ -99,6 +95,107 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="controls:TableViewColumnHeader">
|
||||
<Setter Property="IsTabStop" Value="True" />
|
||||
<Setter Property="UseSystemFocusVisuals" Value="True" />
|
||||
<!-- Without a fill the padding takes no pointer input, killing the tooltip and click-to-sort there. -->
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="FontSize" Value="{ThemeResource TableViewHeaderFontSize}" />
|
||||
<Setter Property="FontWeight" Value="{ThemeResource TableViewHeaderFontWeight}" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="controls:TableViewColumnHeader">
|
||||
<!-- CornerRadius is set only while keyboard-focused: a rounded root clips. -->
|
||||
<Grid x:Name="PART_Root"
|
||||
Background="{TemplateBinding Background}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}">
|
||||
<!-- See TableViewRow: state groups must sit on the template root. -->
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="PointerOver">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="PART_Root.Background" Value="{ThemeResource TabularSurfaceHeaderBackgroundPointerOverBrush}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Pressed">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="PART_Root.Background" Value="{ThemeResource TabularSurfaceHeaderBackgroundPressedBrush}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="PART_ContentPresenter.Foreground" Value="{ThemeResource TabularSurfaceRowForegroundDisabledBrush}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
|
||||
<VisualStateGroup x:Name="HighContrastStates">
|
||||
<VisualState x:Name="HighContrastNormal" />
|
||||
<VisualState x:Name="HighContrastPointerOver">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="PART_ContentPresenter.Foreground" Value="{ThemeResource TabularSurfaceHeaderForegroundPointerOverBrush}" />
|
||||
<Setter Target="PART_ContentPresenter.HighContrastAdjustment" Value="None" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="HighContrastPressed">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="PART_ContentPresenter.Foreground" Value="{ThemeResource TabularSurfaceHeaderForegroundPressedBrush}" />
|
||||
<Setter Target="PART_ContentPresenter.HighContrastAdjustment" Value="None" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
|
||||
<VisualStateGroup x:Name="SortStates">
|
||||
<VisualState x:Name="Unsorted" />
|
||||
<VisualState x:Name="SortAscending">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="PART_SortIndicatorHost.Visibility" Value="Visible" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="SortDescending">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="PART_SortIndicatorHost.Visibility" Value="Visible" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
|
||||
<ContentPresenter x:Name="PART_ContentPresenter"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
|
||||
ContentTransitions="{TemplateBinding ContentTransitions}"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
|
||||
AutomationProperties.AccessibilityView="Raw" />
|
||||
|
||||
<Border x:Name="PART_GridLine"
|
||||
Width="1"
|
||||
HorizontalAlignment="Right"
|
||||
Background="{ThemeResource TabularSurfaceHeaderSeparatorBrush}"
|
||||
IsHitTestVisible="False"
|
||||
Visibility="Collapsed"
|
||||
AutomationProperties.AccessibilityView="Raw" />
|
||||
|
||||
<Border x:Name="PART_SortIndicatorHost"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
IsHitTestVisible="False"
|
||||
Visibility="Collapsed"
|
||||
AutomationProperties.AccessibilityView="Raw" />
|
||||
|
||||
<!-- Last, so the gripper wins the hit test on the shared edge. -->
|
||||
<Border x:Name="PART_ResizeGripperHost" />
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<x:Double x:Key="TableViewRowExpanderSize">24</x:Double>
|
||||
|
||||
<!-- Indent added per hierarchy level on a tree row's first column. Override in an app's
|
||||
@@ -360,19 +457,32 @@
|
||||
is declared here rather than assigned from C++.
|
||||
-->
|
||||
<x:Double x:Key="TableViewGroupExpanderSize">24</x:Double>
|
||||
<Thickness x:Key="TableViewGroupHeaderPadding">8,6,16,6</Thickness>
|
||||
<!-- Gap between the expander gutter and the content; root rows are indented by size + spacing. -->
|
||||
<x:Double x:Key="TableViewGroupExpanderSpacing">8</x:Double>
|
||||
<Thickness x:Key="TableViewGroupHeaderPadding">8,8,12,8</Thickness>
|
||||
|
||||
<!-- BodyStrongTextBlockStyle equivalent, single line. -->
|
||||
<Style x:Key="TableViewGroupHeaderKeyTextStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
<Setter Property="TextWrapping" Value="NoWrap" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- Opacity is a Style setter, not a local value, so TableViewGroupHeader can override it while disabled and clear it back. -->
|
||||
<Style x:Key="TableViewGroupHeaderCountTextStyle" TargetType="TextBlock">
|
||||
<Setter Property="Opacity" Value="{ThemeResource TabularSurfaceGroupHeaderCountOpacity}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<DataTemplate x:Key="TableViewDefaultGroupHeaderContentTemplate">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<StackPanel x:Name="TableViewDefaultGroupHeaderContentRoot" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding KeyText, Mode=OneWay}"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<!-- Inherits Foreground so it follows the PointerOver / Pressed / Disabled states
|
||||
exactly like the key text; a local brush would win over the state setters. -->
|
||||
<TextBlock Text="{Binding ItemCountText, Mode=OneWay}"
|
||||
Opacity="{ThemeResource TabularSurfaceGroupHeaderCountOpacity}"
|
||||
VerticalAlignment="Center" />
|
||||
Style="{StaticResource TableViewGroupHeaderKeyTextStyle}" />
|
||||
<!-- Inherits Foreground so it follows the header's states and an app's Foreground. -->
|
||||
<TextBlock x:Name="TableViewGroupHeaderItemCountText"
|
||||
Text="{Binding ItemCountText, Mode=OneWay}"
|
||||
Style="{StaticResource TableViewGroupHeaderCountTextStyle}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -456,7 +566,7 @@
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<Grid ColumnSpacing="6">
|
||||
<Grid ColumnSpacing="{ThemeResource TableViewGroupExpanderSpacing}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
|
||||
@@ -52,6 +52,7 @@ public:
|
||||
return peer;
|
||||
}
|
||||
winrt::AutomationPeer const peer = winrt::make<TableViewCellAutomationPeer>(
|
||||
TableViewCellAutomationPeer::TrustedOwnerTag{},
|
||||
get_strong().as<winrt::FrameworkElement>(), m_row.get(), m_column.get(), m_columnIndex);
|
||||
m_automationPeer = winrt::make_weak(peer);
|
||||
return peer;
|
||||
|
||||
@@ -22,7 +22,44 @@
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
namespace
|
||||
{
|
||||
winrt::FrameworkElement CellForPublicConstructor(
|
||||
winrt::FrameworkElement const& cell,
|
||||
winrt::TableViewRow const& row,
|
||||
winrt::TableViewColumn const& column,
|
||||
int32_t columnIndex)
|
||||
{
|
||||
if (!cell || !row || !column || columnIndex < 0)
|
||||
{
|
||||
throw winrt::hresult_invalid_argument();
|
||||
}
|
||||
|
||||
// A row not yet owned by a TableView has no column set to check against.
|
||||
if (auto const table = winrt::get_self<TableViewRow>(row)->GetOwningTableView())
|
||||
{
|
||||
if (winrt::get_self<TableViewColumn>(column)->GetOwningTableView() != table ||
|
||||
static_cast<uint32_t>(columnIndex) >= table.Columns().Size())
|
||||
{
|
||||
throw winrt::hresult_invalid_argument();
|
||||
}
|
||||
}
|
||||
|
||||
return cell;
|
||||
}
|
||||
}
|
||||
|
||||
TableViewCellAutomationPeer::TableViewCellAutomationPeer(
|
||||
winrt::FrameworkElement const& cell,
|
||||
winrt::TableViewRow const& row,
|
||||
winrt::TableViewColumn const& column,
|
||||
int32_t columnIndex)
|
||||
: TableViewCellAutomationPeer(TrustedOwnerTag{}, CellForPublicConstructor(cell, row, column, columnIndex), row, column, columnIndex)
|
||||
{
|
||||
}
|
||||
|
||||
TableViewCellAutomationPeer::TableViewCellAutomationPeer(
|
||||
TrustedOwnerTag,
|
||||
winrt::FrameworkElement const& cell,
|
||||
winrt::TableViewRow const& row,
|
||||
winrt::TableViewColumn const& column,
|
||||
|
||||
@@ -15,11 +15,21 @@ class TableViewCellAutomationPeer :
|
||||
public ReferenceTracker<TableViewCellAutomationPeer, winrt::implementation::TableViewCellAutomationPeerT, winrt::IVirtualizedItemProvider>
|
||||
{
|
||||
public:
|
||||
// Lets TableViewCell skip the public constructor's argument validation.
|
||||
struct TrustedOwnerTag {};
|
||||
|
||||
// Throws E_INVALIDARG for arguments that cannot describe a cell.
|
||||
TableViewCellAutomationPeer(
|
||||
winrt::FrameworkElement const& cell,
|
||||
winrt::TableViewRow const& row,
|
||||
winrt::TableViewColumn const& column,
|
||||
int32_t columnIndex);
|
||||
TableViewCellAutomationPeer(
|
||||
TrustedOwnerTag,
|
||||
winrt::FrameworkElement const& cell,
|
||||
winrt::TableViewRow const& row,
|
||||
winrt::TableViewColumn const& column,
|
||||
int32_t columnIndex);
|
||||
~TableViewCellAutomationPeer();
|
||||
|
||||
// IAutomationPeerOverrides
|
||||
|
||||
@@ -346,6 +346,13 @@ void TableViewColumn::OnPropertyChanged(const winrt::DependencyPropertyChangedEv
|
||||
winrt::get_self<TableView>(owner)->OnColumnHeaderToolTipChanged(*this);
|
||||
}
|
||||
}
|
||||
else if (property == s_HeaderStyleProperty)
|
||||
{
|
||||
if (auto owner = GetOwningTableView())
|
||||
{
|
||||
winrt::get_self<TableView>(owner)->QueueRebuildHeaders();
|
||||
}
|
||||
}
|
||||
else if (property == s_FrozenEdgeProperty)
|
||||
{
|
||||
if (auto owner = GetOwningTableView())
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
// 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 "TableViewColumnHeader.h"
|
||||
#include "TableView.h"
|
||||
#include "TableViewContextMenu.h"
|
||||
#include "TableViewColumnHeaderAutomationPeer.h"
|
||||
|
||||
static constexpr std::wstring_view s_ContentPresenterPartName{ L"PART_ContentPresenter"sv };
|
||||
static constexpr std::wstring_view s_GridLinePartName{ L"PART_GridLine"sv };
|
||||
static constexpr std::wstring_view s_SortIndicatorHostPartName{ L"PART_SortIndicatorHost"sv };
|
||||
static constexpr std::wstring_view s_ResizeGripperHostPartName{ L"PART_ResizeGripperHost"sv };
|
||||
static constexpr std::wstring_view s_SortIndicatorName{ L"TableViewSortIndicator"sv };
|
||||
|
||||
TableViewColumnHeader::TableViewColumnHeader()
|
||||
{
|
||||
SetDefaultStyleKey(this);
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::SetOwnerInternal(winrt::TableView const& table, winrt::TableViewColumn const& column)
|
||||
{
|
||||
m_table = table ? winrt::make_weak(table) : nullptr;
|
||||
m_column = column ? winrt::make_weak(column) : nullptr;
|
||||
}
|
||||
|
||||
winrt::AutomationPeer TableViewColumnHeader::OnCreateAutomationPeer()
|
||||
{
|
||||
auto const table = m_table.get();
|
||||
auto const column = m_column.get();
|
||||
if (!table || !column)
|
||||
{
|
||||
return __super::OnCreateAutomationPeer();
|
||||
}
|
||||
|
||||
winrt::AutomationPeer const peer = winrt::make<TableViewColumnHeaderAutomationPeer>(*this, table, column);
|
||||
m_automationPeer = winrt::make_weak(peer);
|
||||
return peer;
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::OnApplyTemplate()
|
||||
{
|
||||
__super::OnApplyTemplate();
|
||||
|
||||
m_isEnabledChangedRevoker.revoke();
|
||||
m_templateRootContextRequestedRevoker.revoke();
|
||||
|
||||
if (auto const oldGripperHost = m_resizeGripperHost.get())
|
||||
{
|
||||
oldGripperHost.Child(nullptr);
|
||||
}
|
||||
if (auto const oldIndicatorHost = m_sortIndicatorHost.get())
|
||||
{
|
||||
oldIndicatorHost.Child(nullptr);
|
||||
}
|
||||
|
||||
m_contentPresenter.set(GetTemplateChild(hstring{ s_ContentPresenterPartName }).try_as<winrt::ContentPresenter>());
|
||||
m_gridLine.set(GetTemplateChild(hstring{ s_GridLinePartName }).try_as<winrt::Border>());
|
||||
m_sortIndicatorHost.set(GetTemplateChild(hstring{ s_SortIndicatorHostPartName }).try_as<winrt::Border>());
|
||||
m_resizeGripperHost.set(GetTemplateChild(hstring{ s_ResizeGripperHostPartName }).try_as<winrt::Border>());
|
||||
|
||||
m_templateContentMargin = {};
|
||||
if (auto const presenter = m_contentPresenter.get())
|
||||
{
|
||||
m_templateContentMargin = presenter.Margin();
|
||||
}
|
||||
UpdateContentReserve();
|
||||
HostSortIndicator();
|
||||
|
||||
if (auto const gripperHost = m_resizeGripperHost.get())
|
||||
{
|
||||
if (auto const gripper = m_resizeGripper.get())
|
||||
{
|
||||
gripperHost.Child(gripper);
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps a Style-set ContextFlyout closed while a cell is editing.
|
||||
m_templateRootContextRequestedRevoker = TableViewDetails::HookTemplateRootContextRequested(*this,
|
||||
[weakThis = get_weak()](auto&&, winrt::ContextRequestedEventArgs const& args)
|
||||
{
|
||||
if (auto strongThis = weakThis.get())
|
||||
{
|
||||
if (auto const owner = strongThis->GetOwningTableView())
|
||||
{
|
||||
winrt::get_self<TableView>(owner)->SuppressContextRequestWhileEditing(args);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
m_isEnabledChangedRevoker = IsEnabledChanged(
|
||||
winrt::auto_revoke,
|
||||
[weakThis = get_weak()](auto const&, auto const&)
|
||||
{
|
||||
if (auto strongThis = weakThis.get())
|
||||
{
|
||||
strongThis->UpdateVisualStates(true /* useTransitions */);
|
||||
}
|
||||
});
|
||||
|
||||
UpdateVisualStates(false /* useTransitions */);
|
||||
|
||||
if (auto const owner = GetOwningTableView())
|
||||
{
|
||||
winrt::get_self<TableView>(owner)->OnColumnHeaderTemplateApplied(*this);
|
||||
}
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::SetSortableInternal(bool sortable, double indicatorReserve)
|
||||
{
|
||||
m_isSortable = sortable;
|
||||
m_indicatorReserve = sortable ? indicatorReserve : 0.0;
|
||||
UpdateContentReserve();
|
||||
HostSortIndicator();
|
||||
UpdateVisualStates(false /* useTransitions */);
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::SetSortDirectionInternal(winrt::SortDirection direction)
|
||||
{
|
||||
m_sortDirection = direction;
|
||||
if (auto const indicator = m_sortIndicator.get())
|
||||
{
|
||||
indicator.Direction(TableView::ToSortIndicatorDirection(direction));
|
||||
}
|
||||
UpdateVisualStates(true /* useTransitions */);
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::SetResizeGripperInternal(winrt::ResizeGripper const& gripper)
|
||||
{
|
||||
if (auto const host = m_resizeGripperHost.get())
|
||||
{
|
||||
host.Child(gripper);
|
||||
}
|
||||
m_resizeGripper.set(gripper);
|
||||
}
|
||||
|
||||
winrt::ResizeGripper TableViewColumnHeader::GetResizeGripperInternal() const
|
||||
{
|
||||
auto const gripper = m_resizeGripper.get();
|
||||
auto const host = m_resizeGripperHost.get();
|
||||
if (gripper && host && host.Child() == gripper)
|
||||
{
|
||||
return gripper;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::UpdateContentReserve()
|
||||
{
|
||||
if (auto const presenter = m_contentPresenter.get())
|
||||
{
|
||||
auto margin = m_templateContentMargin;
|
||||
if (m_isSortable)
|
||||
{
|
||||
margin.Right += m_indicatorReserve;
|
||||
}
|
||||
if (presenter.Margin() != margin)
|
||||
{
|
||||
presenter.Margin(margin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::HostSortIndicator()
|
||||
{
|
||||
auto const host = m_sortIndicatorHost.get();
|
||||
if (!host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_isSortable)
|
||||
{
|
||||
host.Child(nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
auto indicator = m_sortIndicator.get();
|
||||
if (!indicator)
|
||||
{
|
||||
indicator = winrt::SortIndicator{};
|
||||
indicator.Name(winrt::hstring{ s_SortIndicatorName });
|
||||
indicator.VerticalAlignment(winrt::VerticalAlignment::Center);
|
||||
// The header peer already reports the sort state.
|
||||
winrt::AutomationProperties::SetAccessibilityView(indicator, winrt::AccessibilityView::Raw);
|
||||
m_sortIndicator.set(indicator);
|
||||
}
|
||||
indicator.Direction(TableView::ToSortIndicatorDirection(m_sortDirection));
|
||||
|
||||
if (host.Child() != indicator)
|
||||
{
|
||||
host.Child(indicator);
|
||||
}
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::SyncSortIndicatorForeground(bool followText, bool highlighted)
|
||||
{
|
||||
auto const indicator = m_sortIndicator.get();
|
||||
if (!indicator)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The glyph's style sets Foreground, so it cannot inherit; when a state recolors the text
|
||||
// (Disabled, or a High Contrast highlight) the glyph must follow it. Bound, not copied, so it
|
||||
// also follows a re-resolved theme brush (High Contrast toggle) without a state change.
|
||||
auto const presenter = m_contentPresenter.get();
|
||||
if (followText && presenter)
|
||||
{
|
||||
// Rebinding on every state change would rebuild an identical expression.
|
||||
auto const bound = indicator.GetBindingExpression(winrt::Control::ForegroundProperty());
|
||||
if (!bound || bound.ParentBinding().Source() != presenter)
|
||||
{
|
||||
winrt::Binding binding;
|
||||
binding.Source(presenter);
|
||||
binding.Path(winrt::PropertyPath(L"Foreground"));
|
||||
indicator.SetBinding(winrt::Control::ForegroundProperty(), binding);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
indicator.ClearValue(winrt::Control::ForegroundProperty());
|
||||
}
|
||||
|
||||
if (highlighted)
|
||||
{
|
||||
indicator.HighContrastAdjustment(winrt::ElementHighContrastAdjustment::None);
|
||||
}
|
||||
else
|
||||
{
|
||||
indicator.ClearValue(winrt::UIElement::HighContrastAdjustmentProperty());
|
||||
}
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::UpdateVisualStates(bool useTransitions)
|
||||
{
|
||||
// Hover and press advertise click-to-sort, so a header that cannot sort stays Normal.
|
||||
const wchar_t* commonState = L"Normal";
|
||||
if (!IsEnabled())
|
||||
{
|
||||
commonState = L"Disabled";
|
||||
}
|
||||
else if (m_isSortable && m_isPressed)
|
||||
{
|
||||
commonState = L"Pressed";
|
||||
}
|
||||
else if (m_isSortable && m_isPointerOver)
|
||||
{
|
||||
commonState = L"PointerOver";
|
||||
}
|
||||
|
||||
const wchar_t* highContrastState = L"HighContrastNormal";
|
||||
if (commonState != std::wstring_view{ L"Normal" } && commonState != std::wstring_view{ L"Disabled" })
|
||||
{
|
||||
if (auto const owner = GetOwningTableView(); owner && winrt::get_self<TableView>(owner)->IsHighContrast())
|
||||
{
|
||||
highContrastState = m_isPressed ? L"HighContrastPressed" : L"HighContrastPointerOver";
|
||||
}
|
||||
}
|
||||
|
||||
// Both groups set the text Foreground, and leaving a state clears it whichever group set it,
|
||||
// so the group that sets it must transition last.
|
||||
if (IsEnabled())
|
||||
{
|
||||
winrt::VisualStateManager::GoToState(*this, commonState, useTransitions);
|
||||
winrt::VisualStateManager::GoToState(*this, highContrastState, useTransitions);
|
||||
}
|
||||
else
|
||||
{
|
||||
winrt::VisualStateManager::GoToState(*this, highContrastState, useTransitions);
|
||||
winrt::VisualStateManager::GoToState(*this, commonState, useTransitions);
|
||||
}
|
||||
const bool highlighted = highContrastState != std::wstring_view{ L"HighContrastNormal" };
|
||||
SyncSortIndicatorForeground(highlighted || !IsEnabled(), highlighted);
|
||||
|
||||
const wchar_t* sortState = L"Unsorted";
|
||||
if (m_sortDirection == winrt::SortDirection::Ascending)
|
||||
{
|
||||
sortState = L"SortAscending";
|
||||
}
|
||||
else if (m_sortDirection == winrt::SortDirection::Descending)
|
||||
{
|
||||
sortState = L"SortDescending";
|
||||
}
|
||||
winrt::VisualStateManager::GoToState(*this, sortState, useTransitions);
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::OnPointerEntered(winrt::PointerRoutedEventArgs const& args)
|
||||
{
|
||||
__super::OnPointerEntered(args);
|
||||
m_isPointerOver = true;
|
||||
UpdateVisualStates(true /* useTransitions */);
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::OnPointerExited(winrt::PointerRoutedEventArgs const& args)
|
||||
{
|
||||
__super::OnPointerExited(args);
|
||||
m_isPointerOver = false;
|
||||
m_isPressed = false;
|
||||
UpdateVisualStates(true /* useTransitions */);
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::OnPointerPressed(winrt::PointerRoutedEventArgs const& args)
|
||||
{
|
||||
__super::OnPointerPressed(args);
|
||||
m_isPressed = true;
|
||||
UpdateVisualStates(true /* useTransitions */);
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::OnPointerReleased(winrt::PointerRoutedEventArgs const& args)
|
||||
{
|
||||
__super::OnPointerReleased(args);
|
||||
m_isPressed = false;
|
||||
UpdateVisualStates(true /* useTransitions */);
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::OnPointerCaptureLost(winrt::PointerRoutedEventArgs const& args)
|
||||
{
|
||||
__super::OnPointerCaptureLost(args);
|
||||
ResetPointerState(args);
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::OnPointerCanceled(winrt::PointerRoutedEventArgs const& args)
|
||||
{
|
||||
__super::OnPointerCanceled(args);
|
||||
ResetPointerState(args);
|
||||
}
|
||||
|
||||
void TableViewColumnHeader::ResetPointerState(winrt::PointerRoutedEventArgs const& args)
|
||||
{
|
||||
// The gripper captures the pointer for its drag; that capture ending bubbles here while the
|
||||
// pointer is still over the header, so it must not reset the hover state.
|
||||
if (IsInsideResizeGripper(args.OriginalSource()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_isPointerOver = false;
|
||||
m_isPressed = false;
|
||||
UpdateVisualStates(true /* useTransitions */);
|
||||
}
|
||||
|
||||
bool TableViewColumnHeader::IsInsideResizeGripper(winrt::IInspectable const& source)
|
||||
{
|
||||
winrt::DependencyObject const self = *this;
|
||||
for (auto current = source.try_as<winrt::DependencyObject>();
|
||||
current && current != self;
|
||||
current = winrt::VisualTreeHelper::GetParent(current))
|
||||
{
|
||||
if (current.try_as<winrt::ResizeGripper>())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
// Licensed under the MIT License. See LICENSE in the project root for license information.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "pch.h"
|
||||
#include "common.h"
|
||||
|
||||
#include "TableViewColumnHeader.g.h"
|
||||
|
||||
// The owning TableView decides sortability/resizability, wires the gripper and drives the grid
|
||||
// line; this type hosts those in its template parts and maps state onto VisualStates.
|
||||
class TableViewColumnHeader :
|
||||
public ReferenceTracker<TableViewColumnHeader, winrt::implementation::TableViewColumnHeaderT, winrt::composable>
|
||||
{
|
||||
public:
|
||||
TableViewColumnHeader();
|
||||
|
||||
void OnApplyTemplate();
|
||||
winrt::AutomationPeer OnCreateAutomationPeer();
|
||||
winrt::AutomationPeer GetExistingAutomationPeerInternal() const { return m_automationPeer.get(); }
|
||||
|
||||
// None of these mark the event handled, so Tapped-to-sort and focus are unaffected.
|
||||
void OnPointerEntered(winrt::PointerRoutedEventArgs const& args);
|
||||
void OnPointerExited(winrt::PointerRoutedEventArgs const& args);
|
||||
void OnPointerPressed(winrt::PointerRoutedEventArgs const& args);
|
||||
void OnPointerReleased(winrt::PointerRoutedEventArgs const& args);
|
||||
void OnPointerCaptureLost(winrt::PointerRoutedEventArgs const& args);
|
||||
void OnPointerCanceled(winrt::PointerRoutedEventArgs const& args);
|
||||
|
||||
void SetOwnerInternal(winrt::TableView const& table, winrt::TableViewColumn const& column);
|
||||
winrt::TableView GetOwningTableView() const { return m_table.get(); }
|
||||
winrt::TableViewColumn Column() const { return m_column.get(); }
|
||||
|
||||
void SetSortableInternal(bool sortable, double indicatorReserve);
|
||||
void SetSortDirectionInternal(winrt::SortDirection direction);
|
||||
|
||||
// Returns the gripper only while it is hosted: an unhosted gripper cannot drag.
|
||||
void SetResizeGripperInternal(winrt::ResizeGripper const& gripper);
|
||||
winrt::ResizeGripper GetResizeGripperInternal() const;
|
||||
|
||||
winrt::ContentPresenter GetContentPresenterInternal() const { return m_contentPresenter.get(); }
|
||||
winrt::Border GetGridLineInternal() const { return m_gridLine.get(); }
|
||||
|
||||
// True while Content is the TextBlock the TableView made for a string Header.
|
||||
void SetOwnsContentInternal(bool ownsContent) { m_ownsContent = ownsContent; }
|
||||
bool OwnsContentInternal() const { return m_ownsContent; }
|
||||
|
||||
private:
|
||||
void UpdateVisualStates(bool useTransitions);
|
||||
void UpdateContentReserve();
|
||||
void HostSortIndicator();
|
||||
void SyncSortIndicatorForeground(bool followText, bool highlighted);
|
||||
void ResetPointerState(winrt::PointerRoutedEventArgs const& args);
|
||||
bool IsInsideResizeGripper(winrt::IInspectable const& source);
|
||||
|
||||
winrt::weak_ref<winrt::TableView> m_table{ nullptr };
|
||||
winrt::weak_ref<winrt::TableViewColumn> m_column{ nullptr };
|
||||
winrt::weak_ref<winrt::AutomationPeer> m_automationPeer{ nullptr };
|
||||
|
||||
tracker_ref<winrt::ContentPresenter> m_contentPresenter{ this };
|
||||
tracker_ref<winrt::Border> m_gridLine{ this };
|
||||
tracker_ref<winrt::Border> m_sortIndicatorHost{ this };
|
||||
tracker_ref<winrt::Border> m_resizeGripperHost{ this };
|
||||
tracker_ref<winrt::SortIndicator> m_sortIndicator{ this };
|
||||
tracker_ref<winrt::ResizeGripper> m_resizeGripper{ this };
|
||||
|
||||
winrt::Control::IsEnabledChanged_revoker m_isEnabledChangedRevoker{};
|
||||
winrt::UIElement::ContextRequested_revoker m_templateRootContextRequestedRevoker{};
|
||||
|
||||
winrt::Thickness m_templateContentMargin{};
|
||||
double m_indicatorReserve{ 0.0 };
|
||||
bool m_isSortable{ false };
|
||||
winrt::SortDirection m_sortDirection{ winrt::SortDirection::None };
|
||||
bool m_isPointerOver{ false };
|
||||
bool m_isPressed{ false };
|
||||
bool m_ownsContent{ false };
|
||||
};
|
||||
@@ -7,12 +7,14 @@
|
||||
#include "TableViewCellsPanel.h"
|
||||
#include "TableViewAutomationHelpers.h"
|
||||
#include "TableViewColumnHeaderAutomationPeer.h"
|
||||
#include "TableViewColumnHeader.h"
|
||||
#include "TableViewToolTipHelpers.h"
|
||||
#include "TableViewColumnHeaderAutomationPeer.properties.cpp"
|
||||
#include "ResourceAccessor.h"
|
||||
#include "Utils.h"
|
||||
#include <UIAutomationCore.h>
|
||||
#include <UIAutomationCoreApi.h>
|
||||
#include <cmath>
|
||||
#include <cwctype>
|
||||
#include <limits>
|
||||
#include <string_view>
|
||||
@@ -84,12 +86,12 @@ hstring TableViewColumnHeaderAutomationPeer::GetNameCore()
|
||||
return *headerString;
|
||||
}
|
||||
|
||||
// Read only the header content presenter (the first child): the grid line, sort indicator and
|
||||
// resize gripper siblings must never name the header.
|
||||
if (auto const header = GetHeaderElement().try_as<winrt::Panel>(); header && header.Children().Size() > 0)
|
||||
// Only the content region names the header, never the grid line, glyph or gripper.
|
||||
if (auto const header = GetHeaderElement().try_as<winrt::TableViewColumnHeader>())
|
||||
{
|
||||
uint32_t remaining = 64;
|
||||
if (auto const name = GetCellContentName(header.Children().GetAt(0).try_as<winrt::FrameworkElement>(), true, 8, remaining);
|
||||
if (auto const name = GetCellContentName(
|
||||
winrt::get_self<TableViewColumnHeader>(header)->GetContentPresenterInternal(), true, 8, remaining);
|
||||
!name.empty())
|
||||
{
|
||||
return name;
|
||||
@@ -202,21 +204,30 @@ winrt::IInspectable TableViewColumnHeaderAutomationPeer::GetPatternCore(winrt::P
|
||||
return *this;
|
||||
}
|
||||
|
||||
if (patternInterface == winrt::PatternInterface::Transform && IsResizableColumn())
|
||||
{
|
||||
return *this;
|
||||
}
|
||||
|
||||
return __super::GetPatternCore(patternInterface);
|
||||
}
|
||||
|
||||
bool TableViewColumnHeaderAutomationPeer::IsEnabledCore()
|
||||
{
|
||||
// Grid is not a Control, so its base peer does not report inherited disabled state.
|
||||
auto const table = m_table.get();
|
||||
if (!table || !table.IsEnabled())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A template control (for example the header ScrollViewer) can be disabled
|
||||
// independently of the table. Its coerced state applies to this header too.
|
||||
auto ancestor = winrt::VisualTreeHelper::GetParent(Owner());
|
||||
// The header's own state, and that of any template control around it (for example the header
|
||||
// ScrollViewer), can be disabled independently of the table.
|
||||
auto const header = GetHeaderElement();
|
||||
if (auto const headerControl = header.try_as<winrt::Control>(); headerControl && !headerControl.IsEnabled())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
auto ancestor = winrt::VisualTreeHelper::GetParent(header ? header : Owner());
|
||||
while (ancestor && ancestor != table)
|
||||
{
|
||||
if (auto const control = ancestor.try_as<winrt::Control>(); control && !control.IsEnabled())
|
||||
@@ -250,6 +261,89 @@ void TableViewColumnHeaderAutomationPeer::Invoke()
|
||||
throw winrt::hresult_error(UIA_E_ELEMENTNOTAVAILABLE);
|
||||
}
|
||||
|
||||
bool TableViewColumnHeaderAutomationPeer::CanMove()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool TableViewColumnHeaderAutomationPeer::CanResize()
|
||||
{
|
||||
if (!IsResizableColumn())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
auto const owner = m_table.get();
|
||||
return owner && winrt::get_self<TableView>(owner)->CanResizeColumnFromAutomation(m_column.get());
|
||||
}
|
||||
|
||||
bool TableViewColumnHeaderAutomationPeer::CanRotate()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
void TableViewColumnHeaderAutomationPeer::Move(double, double)
|
||||
{
|
||||
throw winrt::hresult_error(UIA_E_INVALIDOPERATION);
|
||||
}
|
||||
|
||||
void TableViewColumnHeaderAutomationPeer::Resize(double width, double)
|
||||
{
|
||||
if (!IsEnabled())
|
||||
{
|
||||
throw winrt::hresult_error(UIA_E_ELEMENTNOTENABLED);
|
||||
}
|
||||
if (!std::isfinite(width) || width < 0.0)
|
||||
{
|
||||
throw winrt::hresult_invalid_argument();
|
||||
}
|
||||
if (!CanResize())
|
||||
{
|
||||
throw winrt::hresult_error(UIA_E_INVALIDOPERATION);
|
||||
}
|
||||
|
||||
// Height is ignored: a column header only resizes along the column axis.
|
||||
auto const column = m_column.get();
|
||||
auto const owner = m_table.get();
|
||||
if (!column || !owner)
|
||||
{
|
||||
throw winrt::hresult_error(UIA_E_ELEMENTNOTAVAILABLE);
|
||||
}
|
||||
|
||||
// UIA sizes are physical pixels, like BoundingRectangle; column widths are DIPs in the table's
|
||||
// own frame, below any scale applied above it.
|
||||
if (auto const xamlRoot = owner.XamlRoot())
|
||||
{
|
||||
double scale = xamlRoot.RasterizationScale();
|
||||
try
|
||||
{
|
||||
const auto unit = owner.TransformToVisual(nullptr).TransformBounds({ 0.0f, 0.0f, 1.0f, 0.0f });
|
||||
if (unit.Width > 0.0f)
|
||||
{
|
||||
scale *= unit.Width;
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Not in the live tree; the display scale alone is the best answer.
|
||||
}
|
||||
if (scale > 0.0)
|
||||
{
|
||||
width /= scale;
|
||||
}
|
||||
}
|
||||
|
||||
if (!winrt::get_self<TableView>(owner)->TryResizeColumnFromAutomation(column, width))
|
||||
{
|
||||
throw winrt::hresult_error(UIA_E_INVALIDOPERATION);
|
||||
}
|
||||
}
|
||||
|
||||
void TableViewColumnHeaderAutomationPeer::Rotate(double)
|
||||
{
|
||||
throw winrt::hresult_error(UIA_E_INVALIDOPERATION);
|
||||
}
|
||||
|
||||
winrt::Windows::Foundation::Collections::IVector<winrt::AutomationPeer> TableViewColumnHeaderAutomationPeer::GetChildrenCore()
|
||||
{
|
||||
if (!IsTableViewOwned())
|
||||
@@ -325,6 +419,19 @@ bool TableViewColumnHeaderAutomationPeer::IsSortableColumn()
|
||||
return owner && owner.IsLoaded() && owner.CanUserSortColumns();
|
||||
}
|
||||
|
||||
// The static opt-in only; whether the gripper is usable right now is CanResize's job.
|
||||
bool TableViewColumnHeaderAutomationPeer::IsResizableColumn()
|
||||
{
|
||||
auto const column = m_column.get();
|
||||
if (!column || !column.CanResize() || !IsVisibleColumn(column))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
auto const owner = m_table.get();
|
||||
return owner && owner.CanUserResizeColumns();
|
||||
}
|
||||
|
||||
int32_t TableViewColumnHeaderAutomationPeer::GetPositionInSetCore()
|
||||
{
|
||||
// The header's attached override wins over the visible-column position.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// Cached/public peers are owned by the TableView so headers can be enumerated before the
|
||||
// templates realize; realized header cells use the same per-column identity.
|
||||
class TableViewColumnHeaderAutomationPeer :
|
||||
public ReferenceTracker<TableViewColumnHeaderAutomationPeer, winrt::implementation::TableViewColumnHeaderAutomationPeerT>
|
||||
public ReferenceTracker<TableViewColumnHeaderAutomationPeer, winrt::implementation::TableViewColumnHeaderAutomationPeerT, winrt::ITransformProvider>
|
||||
{
|
||||
public:
|
||||
TableViewColumnHeaderAutomationPeer(winrt::TableView const& owner, winrt::TableViewColumn const& column);
|
||||
@@ -35,6 +35,16 @@ public:
|
||||
|
||||
// IInvokeProvider
|
||||
void Invoke();
|
||||
|
||||
// ITransformProvider. Offered whenever the column opts in to resizing; CanResize then tracks
|
||||
// whether the gripper is currently usable (for example a pinned Star column reports false).
|
||||
bool CanMove();
|
||||
bool CanResize();
|
||||
bool CanRotate();
|
||||
void Move(double x, double y);
|
||||
void Resize(double width, double height);
|
||||
void Rotate(double degrees);
|
||||
|
||||
// Complements the distinct RuntimeId: AT announces "column i of n" for spatial context.
|
||||
int32_t GetPositionInSetCore();
|
||||
int32_t GetSizeOfSetCore();
|
||||
@@ -53,6 +63,7 @@ public:
|
||||
private:
|
||||
winrt::FrameworkElement GetHeaderElement();
|
||||
bool IsSortableColumn();
|
||||
bool IsResizableColumn();
|
||||
int32_t GetColumnIndex();
|
||||
|
||||
winrt::weak_ref<winrt::TableViewColumn> m_column{ nullptr };
|
||||
|
||||
@@ -36,6 +36,19 @@ winrt::AutomationPeer TableViewGroupHeader::OnCreateAutomationPeer()
|
||||
}
|
||||
|
||||
winrt::Size TableViewGroupHeader::MeasureOverride(winrt::Size const& availableSize)
|
||||
{
|
||||
auto const desired = MeasureBand(availableSize);
|
||||
|
||||
// The base measure realizes the content template, so a header created disabled, or given a new
|
||||
// content template while disabled, is handled here.
|
||||
if (!IsEnabled() && ContentTemplateRoot() != m_inspectedContentRoot.get())
|
||||
{
|
||||
UpdateDefaultCountOpacity();
|
||||
}
|
||||
return desired;
|
||||
}
|
||||
|
||||
winrt::Size TableViewGroupHeader::MeasureBand(winrt::Size const& availableSize)
|
||||
{
|
||||
// Same sum TableViewCellsPanel uses for a row, evaluated at the same point in the layout
|
||||
// pass, so the band and the rows can never disagree.
|
||||
@@ -109,6 +122,7 @@ void TableViewGroupHeader::OnApplyTemplate()
|
||||
if (auto strongThis = weakThis.get())
|
||||
{
|
||||
strongThis->UpdateVisualStates(true /* useTransitions */);
|
||||
strongThis->UpdateDefaultCountOpacity();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -116,6 +130,47 @@ void TableViewGroupHeader::OnApplyTemplate()
|
||||
UpdateTerminalBottomGridLineSuppression();
|
||||
}
|
||||
|
||||
void TableViewGroupHeader::UpdateDefaultCountOpacity()
|
||||
{
|
||||
// The disabled brush already dims the default count; its Style opacity would dim it twice.
|
||||
if (IsEnabled())
|
||||
{
|
||||
if (auto const count = m_defaultCount.get())
|
||||
{
|
||||
count.ClearValue(winrt::UIElement::OpacityProperty());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
TrackDefaultCount(ContentTemplateRoot());
|
||||
if (auto const count = m_defaultCount.get())
|
||||
{
|
||||
count.Opacity(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
void TableViewGroupHeader::TrackDefaultCount(winrt::UIElement const& root)
|
||||
{
|
||||
if (root == m_inspectedContentRoot.get())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (auto const previous = m_defaultCount.get())
|
||||
{
|
||||
previous.ClearValue(winrt::UIElement::OpacityProperty());
|
||||
m_defaultCount.set(nullptr);
|
||||
}
|
||||
m_inspectedContentRoot = root ? winrt::make_weak(root) : nullptr;
|
||||
|
||||
// Only the built-in template is touched: an app template's same-named element keeps its Opacity.
|
||||
auto const rootElement = root.try_as<winrt::FrameworkElement>();
|
||||
if (rootElement && rootElement.Name() == L"TableViewDefaultGroupHeaderContentRoot")
|
||||
{
|
||||
m_defaultCount.set(rootElement.FindName(L"TableViewGroupHeaderItemCountText").try_as<winrt::TextBlock>());
|
||||
}
|
||||
}
|
||||
|
||||
void TableViewGroupHeader::SetTerminalBottomGridLineSuppression(bool suppress)
|
||||
{
|
||||
// Re-applies on every call rather than returning early on an unchanged flag: the overlay is
|
||||
|
||||
@@ -100,6 +100,9 @@ public:
|
||||
|
||||
private:
|
||||
void UpdateVisualStates(bool useTransitions);
|
||||
void UpdateDefaultCountOpacity();
|
||||
void TrackDefaultCount(winrt::UIElement const& root);
|
||||
winrt::Size MeasureBand(winrt::Size const& availableSize);
|
||||
void UpdateTerminalBottomGridLineSuppression();
|
||||
|
||||
winrt::hstring GetGroupIdentity();
|
||||
@@ -111,6 +114,9 @@ private:
|
||||
winrt::UIElement::GettingFocus_revoker m_gettingFocusRevoker{};
|
||||
winrt::UIElement::ContextRequested_revoker m_templateRootContextRequestedRevoker{};
|
||||
tracker_ref<winrt::Border> m_gridLineBorder{ this };
|
||||
// The count in the default content template, cached per inspected ContentTemplateRoot.
|
||||
tracker_ref<winrt::TextBlock> m_defaultCount{ this };
|
||||
winrt::weak_ref<winrt::UIElement> m_inspectedContentRoot{ nullptr };
|
||||
tracker_ref<winrt::IInspectable> m_groupEntry{ this };
|
||||
|
||||
bool m_isPointerOver{ false };
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
// Licensed under the MIT License. See LICENSE in the project root for license information.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "TableViewColumnHeaderAutomationPeer.h"
|
||||
|
||||
// Keep the existing Grid layout/input behavior, but attach the header peer to the
|
||||
// actual focus and hit-test target. This implementation-only type needs no WinRT API.
|
||||
class TableViewHeaderCell :
|
||||
public ReferenceTracker<TableViewHeaderCell, winrt::Microsoft::UI::Xaml::Controls::GridT, winrt::composable>
|
||||
{
|
||||
public:
|
||||
TableViewHeaderCell(winrt::TableView const& table, winrt::TableViewColumn const& column)
|
||||
: m_table(winrt::make_weak(table)), m_column(winrt::make_weak(column))
|
||||
{
|
||||
// A Grid has no pointer overrides. Never marks the events handled, so Tapped-to-sort and
|
||||
// focus are unaffected.
|
||||
PointerEntered({ this, &TableViewHeaderCell::OnPointerEntered });
|
||||
PointerExited({ this, &TableViewHeaderCell::OnPointerExited });
|
||||
PointerPressed({ this, &TableViewHeaderCell::OnPointerPressed });
|
||||
PointerReleased({ this, &TableViewHeaderCell::OnPointerReleased });
|
||||
PointerCaptureLost({ this, &TableViewHeaderCell::OnPointerLost });
|
||||
PointerCanceled({ this, &TableViewHeaderCell::OnPointerLost });
|
||||
// Theme brushes are resolved per state, so a theme change while hovered must re-resolve them.
|
||||
ActualThemeChanged({ this, &TableViewHeaderCell::OnActualThemeChanged });
|
||||
}
|
||||
|
||||
winrt::hstring GetRuntimeClassName() const
|
||||
{
|
||||
return winrt::hstring_name_of<winrt::Grid>();
|
||||
}
|
||||
|
||||
winrt::AutomationPeer OnCreateAutomationPeer()
|
||||
{
|
||||
return winrt::make<TableViewColumnHeaderAutomationPeer>(
|
||||
get_strong().as<winrt::FrameworkElement>(), m_table.get(), m_column.get());
|
||||
}
|
||||
|
||||
private:
|
||||
enum class PointerVisualState
|
||||
{
|
||||
Normal,
|
||||
PointerOver,
|
||||
Pressed,
|
||||
};
|
||||
|
||||
void OnPointerEntered(const winrt::IInspectable&, const winrt::PointerRoutedEventArgs&)
|
||||
{
|
||||
m_isPointerOver = true;
|
||||
UpdatePointerVisualState();
|
||||
}
|
||||
|
||||
void OnPointerExited(const winrt::IInspectable&, const winrt::PointerRoutedEventArgs&)
|
||||
{
|
||||
m_isPointerOver = false;
|
||||
m_isPressed = false;
|
||||
UpdatePointerVisualState();
|
||||
}
|
||||
|
||||
void OnPointerPressed(const winrt::IInspectable&, const winrt::PointerRoutedEventArgs&)
|
||||
{
|
||||
m_isPressed = true;
|
||||
UpdatePointerVisualState();
|
||||
}
|
||||
|
||||
void OnPointerReleased(const winrt::IInspectable&, const winrt::PointerRoutedEventArgs&)
|
||||
{
|
||||
m_isPressed = false;
|
||||
UpdatePointerVisualState();
|
||||
}
|
||||
|
||||
void OnPointerLost(const winrt::IInspectable&, const winrt::PointerRoutedEventArgs& args)
|
||||
{
|
||||
// The resize gripper captures the pointer for its drag; that capture ending bubbles here
|
||||
// while the pointer is still over the header, so it must not reset the hover state.
|
||||
if (IsInsideResizeGripper(args.OriginalSource()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_isPointerOver = false;
|
||||
m_isPressed = false;
|
||||
UpdatePointerVisualState();
|
||||
}
|
||||
|
||||
void OnActualThemeChanged(const winrt::FrameworkElement&, const winrt::IInspectable&)
|
||||
{
|
||||
UpdatePointerVisualState(true /* force */);
|
||||
}
|
||||
|
||||
bool IsInsideResizeGripper(const winrt::IInspectable& source)
|
||||
{
|
||||
winrt::Panel const self = *this;
|
||||
for (auto current = source.try_as<winrt::DependencyObject>();
|
||||
current && current != self;
|
||||
current = winrt::VisualTreeHelper::GetParent(current))
|
||||
{
|
||||
if (current.try_as<winrt::ResizeGripper>())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool IsSortable() const
|
||||
{
|
||||
auto const table = m_table.get();
|
||||
auto const column = m_column.get();
|
||||
return table && column && table.CanUserSortColumns() && column.CanSort();
|
||||
}
|
||||
|
||||
void UpdatePointerVisualState(bool force = false)
|
||||
{
|
||||
auto state = PointerVisualState::Normal;
|
||||
if (IsSortable())
|
||||
{
|
||||
state = m_isPressed ? PointerVisualState::Pressed
|
||||
: m_isPointerOver ? PointerVisualState::PointerOver
|
||||
: PointerVisualState::Normal;
|
||||
}
|
||||
|
||||
if (state == m_visualState && !force)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_visualState = state;
|
||||
|
||||
winrt::ContentPresenter content{ nullptr };
|
||||
if (Children().Size() > 0)
|
||||
{
|
||||
content = Children().GetAt(0).try_as<winrt::ContentPresenter>();
|
||||
}
|
||||
|
||||
auto const indicator = TableView::FindSortIndicator(*this);
|
||||
|
||||
auto const table = m_table.get();
|
||||
if (state == PointerVisualState::Normal || !table)
|
||||
{
|
||||
Background(winrt::SolidColorBrush{ winrt::Colors::Transparent() });
|
||||
ApplyStateForeground(content, indicator, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
auto const tableImpl = winrt::get_self<TableView>(table);
|
||||
const bool pressed = state == PointerVisualState::Pressed;
|
||||
// A null brush would make the cell stop taking pointer input.
|
||||
winrt::Brush background = tableImpl->LookupHeaderStateBrush(pressed
|
||||
? L"TabularSurfaceHeaderBackgroundPressedBrush"
|
||||
: L"TabularSurfaceHeaderBackgroundPointerOverBrush");
|
||||
if (!background)
|
||||
{
|
||||
background = winrt::SolidColorBrush{ winrt::Colors::Transparent() };
|
||||
}
|
||||
Background(background);
|
||||
|
||||
winrt::Brush foreground{ nullptr };
|
||||
if (tableImpl->IsHighContrast())
|
||||
{
|
||||
foreground = tableImpl->LookupHeaderStateBrush(pressed
|
||||
? L"TabularSurfaceHeaderForegroundPressedBrush"
|
||||
: L"TabularSurfaceHeaderForegroundPointerOverBrush");
|
||||
}
|
||||
ApplyStateForeground(content, indicator, foreground);
|
||||
}
|
||||
|
||||
// The chevron needs the same HC foreground as the text, or it vanishes on the highlight fill. The
|
||||
// text backplate is turned off with it, as the row and group header templates do, so it does not
|
||||
// fight the highlight fill.
|
||||
static void ApplyStateForeground(
|
||||
winrt::ContentPresenter const& content,
|
||||
winrt::SortIndicator const& indicator,
|
||||
winrt::Brush const& foreground)
|
||||
{
|
||||
if (content)
|
||||
{
|
||||
if (foreground)
|
||||
{
|
||||
content.Foreground(foreground);
|
||||
content.HighContrastAdjustment(winrt::ElementHighContrastAdjustment::None);
|
||||
}
|
||||
else
|
||||
{
|
||||
content.ClearValue(winrt::ContentPresenter::ForegroundProperty());
|
||||
content.ClearValue(winrt::UIElement::HighContrastAdjustmentProperty());
|
||||
}
|
||||
}
|
||||
if (indicator)
|
||||
{
|
||||
if (foreground)
|
||||
{
|
||||
indicator.Foreground(foreground);
|
||||
indicator.HighContrastAdjustment(winrt::ElementHighContrastAdjustment::None);
|
||||
}
|
||||
else
|
||||
{
|
||||
indicator.ClearValue(winrt::Control::ForegroundProperty());
|
||||
indicator.ClearValue(winrt::UIElement::HighContrastAdjustmentProperty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
winrt::weak_ref<winrt::TableView> m_table;
|
||||
winrt::weak_ref<winrt::TableViewColumn> m_column;
|
||||
PointerVisualState m_visualState{ PointerVisualState::Normal };
|
||||
bool m_isPointerOver{ false };
|
||||
bool m_isPressed{ false };
|
||||
};
|
||||
@@ -322,11 +322,14 @@ void TableViewRow::OnApplyTemplate()
|
||||
UpdateVisualState(false /* useTransitions */);
|
||||
}
|
||||
|
||||
void TableViewRow::SetTerminalGridLineSuppression(TerminalGridLineSuppressionState state)
|
||||
void TableViewRow::SetTerminalGridLineSuppression(TerminalGridLineSuppressionState state, bool refresh)
|
||||
{
|
||||
m_suppressTrailingGridLine = state.suppressTrailing;
|
||||
m_suppressBottomGridLine = state.suppressBottom;
|
||||
RefreshGridLines();
|
||||
if (refresh)
|
||||
{
|
||||
RefreshGridLines();
|
||||
}
|
||||
}
|
||||
|
||||
winrt::AutomationPeer TableViewRow::OnCreateAutomationPeer()
|
||||
@@ -1061,13 +1064,13 @@ void TableViewRow::AttachColumnsSubscription(winrt::TableView const& owner)
|
||||
}
|
||||
}
|
||||
|
||||
void TableViewRow::SetOwningTableViewInternal(winrt::TableView const& owner)
|
||||
bool TableViewRow::SetOwningTableViewInternal(winrt::TableView const& owner)
|
||||
{
|
||||
auto const currentOwner = GetOwningTableView();
|
||||
// Compare through the typed accessor so unchanged weak refs hit the no-op path.
|
||||
if (currentOwner == owner)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Single-writer: the recycle cycle clears the owner to null between tables; a direct
|
||||
@@ -1077,7 +1080,7 @@ void TableViewRow::SetOwningTableViewInternal(winrt::TableView const& owner)
|
||||
// Runs inside an ItemsRepeater callback; an escaping throw would failfast.
|
||||
// Assert in debug, no-op in retail (keep current owner, skip re-attach).
|
||||
MUX_ASSERT_MSG(false, "TableViewRow re-owned without an intervening clear");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
DetachColumnsSubscription();
|
||||
@@ -1116,20 +1119,19 @@ void TableViewRow::SetOwningTableViewInternal(winrt::TableView const& owner)
|
||||
// Observe the new owner's Columns vector (no-op on recycle-out when owner is null).
|
||||
AttachColumnsSubscription(owner);
|
||||
|
||||
RebuildCells();
|
||||
return RebuildCells();
|
||||
}
|
||||
|
||||
void TableViewRow::EnsureOwningTableViewInternal(winrt::TableView const& owner)
|
||||
bool TableViewRow::EnsureOwningTableViewInternal(winrt::TableView const& owner)
|
||||
{
|
||||
if (!owner)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_owningTableView.get() != owner)
|
||||
{
|
||||
SetOwningTableViewInternal(owner);
|
||||
return;
|
||||
return SetOwningTableViewInternal(owner);
|
||||
}
|
||||
|
||||
auto const rowPeer = winrt::FrameworkElementAutomationPeer::FromElement(*this)
|
||||
@@ -1137,23 +1139,36 @@ void TableViewRow::EnsureOwningTableViewInternal(winrt::TableView const& owner)
|
||||
auto* const peerImpl = rowPeer ? winrt::get_self<TableViewRowAutomationPeer>(rowPeer) : nullptr;
|
||||
if (peerImpl && !peerImpl->CanReuseForRowItem(*this, owner))
|
||||
{
|
||||
if (auto host = m_cellsHost.get())
|
||||
// Cell providers are bound to their element, so replace the cells only if a cell peer exists;
|
||||
// otherwise keep them and let them restamp from the inherited DataContext.
|
||||
bool chromeRefreshed = false;
|
||||
if (HasCellAutomationPeers())
|
||||
{
|
||||
ResetCellAutomationNames();
|
||||
host.Children().Clear();
|
||||
if (auto host = m_cellsHost.get())
|
||||
{
|
||||
ResetCellAutomationNames();
|
||||
host.Children().Clear();
|
||||
}
|
||||
peerImpl->DropCellPeerCache();
|
||||
chromeRefreshed = RebuildCells(false /* updateExistingCellPeerItems */);
|
||||
}
|
||||
else
|
||||
{
|
||||
peerImpl->DropCellPeerCache();
|
||||
chromeRefreshed = RebuildCells();
|
||||
}
|
||||
peerImpl->DropCellPeerCache();
|
||||
RebuildCells(false /* updateExistingCellPeerItems */);
|
||||
peerImpl->TrackCurrentRowItem(*this, owner);
|
||||
return;
|
||||
return chromeRefreshed;
|
||||
}
|
||||
|
||||
RebuildCells();
|
||||
const bool chromeRefreshed = RebuildCells();
|
||||
|
||||
if (peerImpl)
|
||||
{
|
||||
peerImpl->TrackCurrentRowItem(*this, owner);
|
||||
}
|
||||
|
||||
return chromeRefreshed;
|
||||
}
|
||||
|
||||
// Rewire realized rows when Columns changes but the owner identity does not.
|
||||
@@ -1566,7 +1581,8 @@ double TableViewRow::HierarchyIndent()
|
||||
|
||||
indentSize = ownerImpl->GetRowIndentSize();
|
||||
|
||||
// Under a group header, roots start at the header's text; ungrouped roots stay flush.
|
||||
// Under a group header, roots are indented by its gutter + spacing (not its Padding);
|
||||
// ungrouped roots stay flush.
|
||||
if (ownerImpl->IsTableViewSourceGrouped())
|
||||
{
|
||||
baseIndent = ownerImpl->GetGroupHeaderContentOffset();
|
||||
@@ -1864,6 +1880,29 @@ void TableViewRow::ResetCellAutomationNames()
|
||||
}
|
||||
}
|
||||
|
||||
bool TableViewRow::HasCellAutomationPeers() const
|
||||
{
|
||||
if (auto const host = m_cellsHost.get())
|
||||
{
|
||||
for (auto const& cell : host.Children())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (TableViewCell::TryGetExistingPeer(cell))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Unknown provider state: take the replace path, which is always correct.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void TableViewRow::RaiseCellAutomationNamesChangedInternal(winrt::UIElement const& skipCell)
|
||||
{
|
||||
if (auto const host = m_cellsHost.get())
|
||||
@@ -1898,20 +1937,20 @@ void TableViewRow::RaiseCellAutomationNamesChangedInternal(winrt::UIElement cons
|
||||
}
|
||||
}
|
||||
|
||||
void TableViewRow::RebuildCells(bool updateExistingCellPeerItems)
|
||||
bool TableViewRow::RebuildCells(bool updateExistingCellPeerItems)
|
||||
{
|
||||
RestoreCellContentTabStopsInternal();
|
||||
|
||||
auto host = m_cellsHost.get();
|
||||
if (!host)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-entry guard. See OnDataContextChanged.
|
||||
if (m_isRebuildingCells)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
m_isRebuildingCells = true;
|
||||
// Synchronous RAII guard: captures this only until RebuildCells returns.
|
||||
@@ -1922,7 +1961,7 @@ void TableViewRow::RebuildCells(bool updateExistingCellPeerItems)
|
||||
{
|
||||
// Keep realized cells on recycle-out so the next ElementPrepared restamps
|
||||
// instead of re-generating every cell (the restamp fast-path exists for this).
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Density: apply the owning TableView's row min-height (Fluent Standard = 40,
|
||||
@@ -1961,13 +2000,13 @@ void TableViewRow::RebuildCells(bool updateExistingCellPeerItems)
|
||||
// rather than orphan the editor; the next pass retries once the edit has closed.
|
||||
if (m_editingCellWrapper.get())
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
ResetCellAutomationNames();
|
||||
host.Children().Clear();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
auto dataContext = DataContext();
|
||||
@@ -2096,7 +2135,7 @@ void TableViewRow::RebuildCells(bool updateExistingCellPeerItems)
|
||||
RefreshGridLines();
|
||||
RefreshRowBackground();
|
||||
ApplyHierarchyAffordance();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Column adds/removes/reorders or template changes rebuild cells; width-only changes are picked
|
||||
@@ -2125,7 +2164,7 @@ void TableViewRow::RebuildCells(bool updateExistingCellPeerItems)
|
||||
// rebuild happens on the next pass once the edit has actually closed.
|
||||
if (m_editingCellWrapper.get())
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2188,10 +2227,11 @@ void TableViewRow::RebuildCells(bool updateExistingCellPeerItems)
|
||||
RefreshGridLines();
|
||||
RefreshRowBackground();
|
||||
ApplyHierarchyAffordance();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Recycle-out. Always walks: the restamp fast-path revives tooltips through the binding, so a
|
||||
// cached per-row flag would go stale and strand app content in the pool.
|
||||
// Recycle-out. Always walks: the bound value can revive a tooltip at any time, so a cached
|
||||
// per-row flag would go stale and strand app content in the pool.
|
||||
void TableViewRow::ReleaseCellToolTips()
|
||||
{
|
||||
if (auto const host = m_cellsHost.get())
|
||||
@@ -2200,6 +2240,44 @@ void TableViewRow::ReleaseCellToolTips()
|
||||
}
|
||||
}
|
||||
|
||||
// Recycle-in. ReleaseCellToolTips neutralized the tooltips, and a bound value that did not change
|
||||
// (an explicit Source, or equal across the DataContext reset) raises no callback to revive them.
|
||||
void TableViewRow::RefreshCellToolTips()
|
||||
{
|
||||
auto const host = m_cellsHost.get();
|
||||
if (!host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
auto const editingWrapper = m_editingCellWrapper.get();
|
||||
auto const children = host.Children();
|
||||
const uint32_t count = children.Size();
|
||||
for (uint32_t i = 0; i < count; ++i)
|
||||
{
|
||||
if (auto const cellWrapper = children.GetAt(i).try_as<winrt::Grid>();
|
||||
cellWrapper && cellWrapper != editingWrapper)
|
||||
{
|
||||
// Usually the value changed with the DataContext and the tooltip is already live.
|
||||
auto const record = TableViewDetails::GetRecord(cellWrapper);
|
||||
if (!record || !record->ToolTip || record->ToolTip.IsEnabled())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
TableViewDetails::RefreshOwnedToolTip(cellWrapper);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
TVDiag::LogRetailF(L"[TableView] Re-applying a cell tooltip failed (HRESULT 0x%08X).",
|
||||
static_cast<unsigned int>(winrt::to_hresult()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TableViewRow::ClearOwnedCellToolTips(const winrt::Panel& host)
|
||||
{
|
||||
auto const children = host.Children();
|
||||
@@ -2486,9 +2564,83 @@ bool TableViewRow::BeginCellEdit(const winrt::TableViewColumn& column, const win
|
||||
return false;
|
||||
}
|
||||
|
||||
// A throw past here must restore the display element and drop the half-captured edit state, or
|
||||
// the cell is left blank and the row half-editing.
|
||||
auto const displayElement = TableViewCell::Child(cellWrapper);
|
||||
auto const focusRoot = XamlRoot();
|
||||
auto const restoreFocusState = [&]()
|
||||
{
|
||||
auto const focused = focusRoot ? winrt::FocusManager::GetFocusedElement(focusRoot) : nullptr;
|
||||
if (!TableViewDetails::IsWithinElementPopupAware(focused, cellWrapper, focusRoot))
|
||||
{
|
||||
return winrt::FocusState::Unfocused;
|
||||
}
|
||||
auto const focusedElement = focused.try_as<winrt::UIElement>();
|
||||
return focusedElement && focusedElement.FocusState() != winrt::FocusState::Unfocused
|
||||
? focusedElement.FocusState() : winrt::FocusState::Programmatic;
|
||||
}();
|
||||
auto unwindOnThrow = wil::scope_exit([this, cellWrapper, displayElement, focusRoot, restoreFocusState]() noexcept
|
||||
{
|
||||
try
|
||||
{
|
||||
if (auto const peer = TableViewCell::TryGetExistingPeer(cellWrapper)
|
||||
.try_as<winrt::TableViewCellAutomationPeer>())
|
||||
{
|
||||
winrt::get_self<TableViewCellAutomationPeer>(peer)->ResetEditName();
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
TVDiag::LogRetailF(L"[TableView] Optional abandoned-cell name state could not be reset.");
|
||||
}
|
||||
try
|
||||
{
|
||||
if (TableViewCell::Child(cellWrapper) != displayElement)
|
||||
{
|
||||
TableViewCell::Child(cellWrapper, displayElement);
|
||||
TableViewDetails::RefreshOwnedToolTip(cellWrapper);
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
TVDiag::LogRetailF(L"[TableView] A failed BeginCellEdit could not restore the display cell.");
|
||||
}
|
||||
m_editingAutomationPeer = nullptr;
|
||||
m_editingAutomationItem.set(nullptr);
|
||||
m_editingAutomationValue = {};
|
||||
m_editingAutomationName = {};
|
||||
m_editingRowAutomationName.reset();
|
||||
m_editingColumn.set(nullptr);
|
||||
m_editingCellWrapper.set(nullptr);
|
||||
m_editingElement.set(nullptr);
|
||||
m_editingDisplayElement.set(nullptr);
|
||||
|
||||
// Not a layout pass (unlike AbandonCellEdit's callers), so focus the editor took or dropped
|
||||
// can go back to the cell.
|
||||
try
|
||||
{
|
||||
if (restoreFocusState != winrt::FocusState::Unfocused &&
|
||||
!TableViewDetails::IsWithinElementPopupAware(
|
||||
focusRoot ? winrt::FocusManager::GetFocusedElement(focusRoot) : nullptr, cellWrapper, focusRoot))
|
||||
{
|
||||
SetCellLevelInternal(true);
|
||||
if (!cellWrapper.Focus(restoreFocusState))
|
||||
{
|
||||
SetCellLevelInternal(false);
|
||||
Focus(restoreFocusState);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
TVDiag::LogRetailF(L"[TableView] A failed BeginCellEdit could not restore focus to the cell.");
|
||||
}
|
||||
});
|
||||
|
||||
auto const editingElement = winrt::get_self<TableViewColumn>(column)->GenerateEditingElement(dataItem);
|
||||
if (!editingElement)
|
||||
{
|
||||
unwindOnThrow.release();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2542,7 +2694,7 @@ bool TableViewRow::BeginCellEdit(const winrt::TableViewColumn& column, const win
|
||||
{
|
||||
TVDiag::LogRetailF(L"[TableView] Optional stable cell-name capture failed.");
|
||||
}
|
||||
m_editingDisplayElement.set(TableViewCell::Child(cellWrapper));
|
||||
m_editingDisplayElement.set(displayElement);
|
||||
m_pendingEditingCell = winrt::make_weak(cellWrapper.as<winrt::UIElement>());
|
||||
auto pendingCellGuard = wil::scope_exit([this]() noexcept { m_pendingEditingCell = nullptr; });
|
||||
TableViewCell::Child(cellWrapper, editingElement);
|
||||
@@ -2552,7 +2704,7 @@ bool TableViewRow::BeginCellEdit(const winrt::TableViewColumn& column, const win
|
||||
TableViewDetails::ClearOwnedToolTip(cellWrapper);
|
||||
m_editingElement.set(editingElement);
|
||||
|
||||
|
||||
unwindOnThrow.release();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,10 @@ public:
|
||||
// Keeps the body one tab stop, and expands a tree row's subtree; see the definition.
|
||||
void OnKeyDown(winrt::KeyRoutedEventArgs const& args);
|
||||
|
||||
// Updates the weak owner ref, column subscription, and realized cells.
|
||||
void SetOwningTableViewInternal(winrt::TableView const& owner);
|
||||
void EnsureOwningTableViewInternal(winrt::TableView const& owner);
|
||||
// Updates the weak owner ref, column subscription, and realized cells. Both return true when the
|
||||
// cell rebuild also refreshed the row chrome (grid lines and row background).
|
||||
bool SetOwningTableViewInternal(winrt::TableView const& owner);
|
||||
bool EnsureOwningTableViewInternal(winrt::TableView const& owner);
|
||||
|
||||
// Rewire realized rows when the owner keeps the same identity but Columns changes.
|
||||
void RefreshColumnsSubscriptionInternal();
|
||||
@@ -55,7 +56,7 @@ public:
|
||||
bool ShowContextMenuFromPeer();
|
||||
|
||||
void RefreshGridLines();
|
||||
void SetTerminalGridLineSuppression(TerminalGridLineSuppressionState state);
|
||||
void SetTerminalGridLineSuppression(TerminalGridLineSuppressionState state, bool refresh = true);
|
||||
void RefreshRowBackground();
|
||||
|
||||
// Owner-only writer for the read-only IsSelected DP. Selection is owned by the TableView, so
|
||||
@@ -114,6 +115,7 @@ public:
|
||||
|
||||
// Releases control-owned cell tooltips (recycle-out).
|
||||
void ReleaseCellToolTips();
|
||||
void RefreshCellToolTips();
|
||||
// Raises NameProperty changed on cell peers that already exist; creates none.
|
||||
void RaiseCellAutomationNamesChangedInternal(winrt::UIElement const& skipCell = nullptr);
|
||||
|
||||
@@ -147,6 +149,7 @@ public:
|
||||
|
||||
private:
|
||||
void ResetCellAutomationNames();
|
||||
bool HasCellAutomationPeers() const;
|
||||
|
||||
// Restamps the current level after rebuilds because new/recycled cells arrive as tab stops.
|
||||
void ApplyFocusLevelInternal();
|
||||
@@ -223,7 +226,8 @@ private:
|
||||
bool IsWithinExpanderGutter(const winrt::IInspectable& source) const;
|
||||
void UpdateExpanderGutterClip();
|
||||
|
||||
void RebuildCells(bool updateExistingCellPeerItems = true);
|
||||
// Returns true when the rebuild reached the row-chrome refresh (grid lines and row background).
|
||||
bool RebuildCells(bool updateExistingCellPeerItems = true);
|
||||
void ClearOwnedCellToolTips(const winrt::Panel& host);
|
||||
|
||||
// Coalesces a burst of Columns-collection changes into a single cell rebuild on the next
|
||||
|
||||
@@ -122,6 +122,12 @@ public:
|
||||
bool SetOwningTableView(winrt::IInspectable const& owner);
|
||||
// Detaches `owner`; a no-op when another TableView owns this source.
|
||||
void ReleaseOwningTableView(winrt::IInspectable const& owner);
|
||||
// True when a TableView other than `candidate` owns this source.
|
||||
bool IsOwnedByOther(winrt::IInspectable const& candidate) const
|
||||
{
|
||||
auto const owner = m_owningTableView.get();
|
||||
return owner && owner != candidate;
|
||||
}
|
||||
// A refused TableView waits here. On release, waiters run in order until one claims the
|
||||
// source; each waiter that runs is dropped. Keyed by the waiter's identity, one entry each.
|
||||
void AddOwnerReleasedHandler(winrt::IInspectable const& waiter, std::function<void()> handler);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "common.h"
|
||||
#include "TableView.h"
|
||||
#include "TableViewColumn.h"
|
||||
#include "TableViewColumnHeader.h"
|
||||
#include "TableViewRow.h"
|
||||
#include "TableViewCellsPanel.h"
|
||||
#include "TableViewToolTipHelpers.h"
|
||||
@@ -344,6 +345,13 @@ void TableView::OnColumnsVectorChanged(
|
||||
// re-resolve to pick up the new fixed total and star share.
|
||||
InvalidateMeasure();
|
||||
}
|
||||
|
||||
// A column that left may be added to another TableView before the queued rebuild runs. Last,
|
||||
// because releasing runs app code that can change Columns again.
|
||||
if (change != winrt::CollectionChange::ItemInserted)
|
||||
{
|
||||
ReleaseHeaderCells(m_headerHost.get(), HeaderRelease::DetachedColumns);
|
||||
}
|
||||
}
|
||||
|
||||
void TableView::OnColumnVisibilityChanged(const winrt::TableViewColumn& column)
|
||||
@@ -496,6 +504,17 @@ void TableView::OnColumnHeaderChanged(const winrt::TableViewColumn& column)
|
||||
// grow-only accumulator -- a global reset would shrink UNRELATED Auto columns to only the
|
||||
// currently-realized rows. Resetting even when the column is currently Pixel/Star preserves the
|
||||
// "switched back to Auto" stale-width safeguard for that one column.
|
||||
// An old UIElement Header is the app's again now, as with ContentControl.Content, not after the
|
||||
// rebuild. Any other content stays until the rebuild, so the header does not go blank meanwhile.
|
||||
auto const headerHost = m_headerHost.get();
|
||||
if (auto const headerCell = TableViewCellsPanel::CellForColumn(headerHost, column).try_as<winrt::TableViewColumnHeader>())
|
||||
{
|
||||
if (headerCell.Content().try_as<winrt::UIElement>()
|
||||
&& !winrt::get_self<TableViewColumnHeader>(headerCell)->OwnsContentInternal())
|
||||
{
|
||||
ReleaseHeaderCells(headerHost, HeaderRelease::Column, column);
|
||||
}
|
||||
}
|
||||
QueueRebuildHeaders();
|
||||
if (column)
|
||||
{
|
||||
|
||||
@@ -137,8 +137,8 @@ namespace
|
||||
void LogRevertUnavailable(wchar_t const* reason)
|
||||
{
|
||||
TVDiag::LogRetailF(
|
||||
L"[TableView] Cancel cannot revert this edit (%ls). Implement ITableViewEditableItem "
|
||||
L"on the data item to make rollback reliable.",
|
||||
L"[TableView] Cancel cannot revert this edit (%ls). Buffer the editing binding with "
|
||||
L"UpdateSourceTrigger=Explicit so a cancelled value never reaches the data item.",
|
||||
reason);
|
||||
}
|
||||
|
||||
@@ -1178,6 +1178,14 @@ void TableView::QueueCoalescedEditReshape(std::function<void()> operation)
|
||||
}
|
||||
}
|
||||
|
||||
void TableView::QueueReshapeAfterEditCloses(std::function<void()> operation)
|
||||
{
|
||||
if (operation)
|
||||
{
|
||||
m_reshapesAfterEditCloses.push_back(std::move(operation));
|
||||
}
|
||||
}
|
||||
|
||||
void TableView::ClearCoalescedEditReshape()
|
||||
{
|
||||
m_pendingEditReshapes.clear();
|
||||
@@ -1204,6 +1212,13 @@ void TableView::DrainCoalescedEditReshape()
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsEditing() && !m_reshapesAfterEditCloses.empty())
|
||||
{
|
||||
m_pendingEditReshapes.insert(m_pendingEditReshapes.begin(),
|
||||
std::make_move_iterator(m_reshapesAfterEditCloses.begin()), std::make_move_iterator(m_reshapesAfterEditCloses.end()));
|
||||
m_reshapesAfterEditCloses.clear();
|
||||
}
|
||||
|
||||
if (m_isApplyingCoalescedEditReshape || m_pendingEditReshapes.empty())
|
||||
{
|
||||
return;
|
||||
@@ -1225,6 +1240,15 @@ void TableView::DrainCoalescedEditReshape()
|
||||
}
|
||||
}
|
||||
|
||||
void TableView::ResetPendingBeginEdit()
|
||||
{
|
||||
m_currentEditItem.set(nullptr);
|
||||
m_currentEditColumn.set(nullptr);
|
||||
m_editUneditedValue.set(nullptr);
|
||||
m_editState = EditState::None;
|
||||
m_abandonPendingBeginEdit = false;
|
||||
}
|
||||
|
||||
bool TableView::BeginEdit()
|
||||
{
|
||||
winrt::IInspectable item{ nullptr };
|
||||
@@ -1253,8 +1277,7 @@ bool TableView::BeginEdit(winrt::IInspectable const& item, winrt::TableViewColum
|
||||
if (m_editState == EditState::Editing)
|
||||
{
|
||||
// Moving commits the open edit. WPF parity: leaving the *row* is what ends the row-level
|
||||
// transaction, so moving to a different item commits with unit Row - which is what raises
|
||||
// RowEditEnding and calls ITableViewEditableItem.EndEdit at row scope. Moving between cells
|
||||
// transaction, so moving to a different item commits with unit Row. Moving between cells
|
||||
// of the same row stays a Cell-unit commit.
|
||||
//
|
||||
// A veto, a validation failure or a deferral all leave the existing edit open, so the move
|
||||
@@ -1278,12 +1301,45 @@ bool TableView::BeginEdit(winrt::IInspectable const& item, winrt::TableViewColum
|
||||
const auto generation = m_editGeneration;
|
||||
m_currentEditItem.set(item);
|
||||
m_currentEditColumn.set(column);
|
||||
m_currentEditRow.set(nullptr);
|
||||
m_editState = EditState::Beginning;
|
||||
m_abandonPendingBeginEdit = false;
|
||||
m_editSourceWritten = false;
|
||||
|
||||
// Only a throw reaches here still Beginning; without this unwind IsEditing would stay true and
|
||||
// every later edit would be rejected as reentrant.
|
||||
auto unwindOnThrow = wil::scope_exit([this]() noexcept
|
||||
{
|
||||
if (m_editState != EditState::Beginning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
// Not a layout pass, so unlike AbandonCellEdit this may hand focus back to the cell.
|
||||
if (auto const row = m_currentEditRow.get())
|
||||
{
|
||||
winrt::get_self<TableViewRow>(row)->EndCellEdit(winrt::TableViewEditAction::Cancel);
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
TVDiag::LogRetailF(L"[TableView] Abandoning a failed BeginEdit could not restore the display cell.");
|
||||
}
|
||||
m_currentEditRow.set(nullptr);
|
||||
ResetPendingBeginEdit();
|
||||
try
|
||||
{
|
||||
DrainCoalescedEditReshape();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
TVDiag::LogRetailF(L"[TableView] A reshape queued during a failed BeginEdit threw while draining.");
|
||||
}
|
||||
});
|
||||
|
||||
// Everything from here to the promotion below runs app code - a BeginningEdit handler, the
|
||||
// editing binding's getter, ITableViewEditableItem.BeginEdit - any of which can mutate the
|
||||
// editing binding's getter, PrepareCellForEdit - any of which can mutate the
|
||||
// collection and have ItemsRepeater recycle the row underneath us. A forced teardown cannot
|
||||
// close an edit that is still Beginning, so OnRowElementClearing raises this flag instead and
|
||||
// the checks below unwind rather than promoting to Editing over a recycled row.
|
||||
@@ -1298,11 +1354,7 @@ bool TableView::BeginEdit(winrt::IInspectable const& item, winrt::TableViewColum
|
||||
// The item transaction is deliberately NOT touched: a cell commit keeps the row
|
||||
// transaction open, so anything held here belongs to a previous cell in the same row and
|
||||
// is still legitimately live. Clearing it would leave the item with an unpaired BeginEdit.
|
||||
m_currentEditItem.set(nullptr);
|
||||
m_currentEditColumn.set(nullptr);
|
||||
m_editUneditedValue.set(nullptr);
|
||||
m_editState = EditState::None;
|
||||
m_abandonPendingBeginEdit = false;
|
||||
ResetPendingBeginEdit();
|
||||
DrainCoalescedEditReshape();
|
||||
return false;
|
||||
}
|
||||
@@ -1333,11 +1385,7 @@ bool TableView::BeginEdit(winrt::IInspectable const& item, winrt::TableViewColum
|
||||
if (m_abandonPendingBeginEdit || !m_currentEditRow.get())
|
||||
{
|
||||
tip.Ignore(TableViewTip::IgnoreReason::Stale);
|
||||
m_abandonPendingBeginEdit = false;
|
||||
m_currentEditItem.set(nullptr);
|
||||
m_currentEditColumn.set(nullptr);
|
||||
m_editUneditedValue.set(nullptr);
|
||||
m_editState = EditState::None;
|
||||
ResetPendingBeginEdit();
|
||||
DrainCoalescedEditReshape();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#include "TableViewColumn.h"
|
||||
#include "TableViewRow.h"
|
||||
#include "TableViewGroupHeader.h"
|
||||
#include "TableViewCell.h"
|
||||
#include "TableViewColumnHeader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
@@ -144,6 +146,34 @@ void TableView::RequestColumnWidthResolve()
|
||||
InvalidateMeasure();
|
||||
}
|
||||
|
||||
bool TableView::HasVisibleAutoColumn()
|
||||
{
|
||||
if (auto const columns = Columns())
|
||||
{
|
||||
for (auto const& column : columns)
|
||||
{
|
||||
if (column &&
|
||||
column.Visibility() == winrt::Visibility::Visible &&
|
||||
column.Width().GridUnitType == winrt::GridUnitType::Auto &&
|
||||
winrt::get_self<TableViewColumn>(column)->GetOwningTableView() == *this)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A newly prepared row's first measure raises no width-change signal, so Auto columns need this
|
||||
// table pass to pick its cells up.
|
||||
void TableView::InvalidateMeasureForRowRealizationChange()
|
||||
{
|
||||
if (HasVisibleAutoColumn())
|
||||
{
|
||||
InvalidateMeasure();
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the monotonic Auto desired widths. The next table measure pass remeasures the template
|
||||
// subtree, pulls header + realized-row measured widths, and resolves once from those caches.
|
||||
void TableView::ResetColumnDesiredWidths()
|
||||
@@ -206,23 +236,29 @@ void TableView::ResolveColumnWidths()
|
||||
std::vector<winrt::TableViewColumn> starColumns;
|
||||
|
||||
// Collect the currently realized, BOUND rows ONCE (a single repeater walk) and reuse the list for
|
||||
// every Auto column below, instead of re-walking the visual tree (GetChild + try_as per row) once
|
||||
// per Auto column.
|
||||
// every Auto column below. Lazy, so tables without an Auto column skip the walk entirely.
|
||||
std::vector<winrt::TableViewRow> realizedRows;
|
||||
if (auto repeater = m_rowsRepeater.get())
|
||||
bool realizedRowsCollected = false;
|
||||
auto ensureRealizedRows = [this, &realizedRows, &realizedRowsCollected]()
|
||||
{
|
||||
ForEachRealizedRow([&realizedRows, &repeater](winrt::TableViewRow const& row)
|
||||
if (realizedRowsCollected)
|
||||
{
|
||||
// Skip pooled/recycled rows: ItemsRepeater keeps them parented in the pool, but they carry
|
||||
// stale measured widths (GetElementIndex < 0). Consuming them would re-pin Auto columns at
|
||||
// the previous width after ItemsSource shrinks, making ResetColumnDesiredWidths a no-op
|
||||
// (grow-only keeps the max). Only genuinely bound rows contribute to Auto sizing.
|
||||
if (repeater.GetElementIndex(row) >= 0)
|
||||
return;
|
||||
}
|
||||
realizedRowsCollected = true;
|
||||
if (auto repeater = m_rowsRepeater.get())
|
||||
{
|
||||
ForEachRealizedRow([&realizedRows, &repeater](winrt::TableViewRow const& row)
|
||||
{
|
||||
realizedRows.push_back(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Skip pooled rows (GetElementIndex < 0): their stale widths would re-pin Auto
|
||||
// columns after ItemsSource shrinks.
|
||||
if (repeater.GetElementIndex(row) >= 0)
|
||||
{
|
||||
realizedRows.push_back(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (auto const& column : columns)
|
||||
{
|
||||
@@ -261,6 +297,7 @@ void TableView::ResolveColumnWidths()
|
||||
}
|
||||
|
||||
double rowsMeasured = 0.0;
|
||||
ensureRealizedRows();
|
||||
for (auto const& row : realizedRows)
|
||||
{
|
||||
// Ask the row for its cell's measured width instead of reaching into its panel.
|
||||
@@ -632,7 +669,30 @@ void TableView::RefreshResizeGripperAvailability()
|
||||
continue;
|
||||
}
|
||||
|
||||
gripper.Visibility(IsColumnResizePinned(column) ? winrt::Visibility::Collapsed : winrt::Visibility::Visible);
|
||||
auto const visibility = IsColumnResizePinned(column) ? winrt::Visibility::Collapsed : winrt::Visibility::Visible;
|
||||
if (gripper.Visibility() == visibility)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only a peer a client already holds is notified; FromElement would create one.
|
||||
auto const header = headerCell.try_as<winrt::TableViewColumnHeader>();
|
||||
auto const headerPeer = header && winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::PropertyChanged)
|
||||
? winrt::get_self<TableViewColumnHeader>(header)->GetExistingAutomationPeerInternal() : nullptr;
|
||||
// The pin state is already updated, so the old value comes from the visibility being replaced.
|
||||
const bool couldResize = gripper.Visibility() == winrt::Visibility::Visible && IsColumnResizeAllowed(column);
|
||||
gripper.Visibility(visibility);
|
||||
|
||||
if (headerPeer)
|
||||
{
|
||||
if (const bool canResize = CanResizeColumnFromAutomation(column); canResize != couldResize)
|
||||
{
|
||||
headerPeer.RaisePropertyChangedEvent(
|
||||
winrt::TransformPatternIdentifiers::CanResizeProperty(),
|
||||
winrt::box_value(couldResize),
|
||||
winrt::box_value(canResize));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,9 +191,13 @@ bool TableView::IsSortClearStillValid() const
|
||||
|
||||
// Refused a source another table owns: there is nothing to sort until it is released, and the
|
||||
// columns keep their state for the reconcile that follows.
|
||||
bool TableView::CanSortColumnNow(const winrt::TableViewColumn& column) const
|
||||
bool TableView::CanSortColumnNow(const winrt::TableViewColumn& column, bool requireCanSort) const
|
||||
{
|
||||
return !m_awaitedSource.get() && CanSortColumn(column);
|
||||
if (m_awaitedSource.get())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return requireCanSort ? CanSortColumn(column) : static_cast<bool>(column);
|
||||
}
|
||||
|
||||
bool TableView::CanApplySortTo(winrt::SortDirection direction) const
|
||||
@@ -203,12 +207,23 @@ bool TableView::CanApplySortTo(winrt::SortDirection direction) const
|
||||
return true;
|
||||
}
|
||||
auto const source = ShapingSourceInternal();
|
||||
if (source && m_activeSourceReleased && winrt::get_self<::TableViewSource>(source)->IsOwnedByOther(*this))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return !source || winrt::get_self<::TableViewSource>(source)->CanApplySort();
|
||||
}
|
||||
|
||||
bool TableView::IsSortRequestStillValid(const winrt::TableViewColumn& column) const
|
||||
bool TableView::IsSortRequestStillValid(const winrt::TableViewColumn& column, bool requireCanSort) const
|
||||
{
|
||||
if (!column || !CanSortColumnNow(column))
|
||||
if (!column || !CanSortColumnNow(column, requireCanSort))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A clear that skips the CanSort gate clears only the column's own sort. A replayed or
|
||||
// re-entered one may find another column sorted since, and must not clear that sort.
|
||||
if (!requireCanSort && column.SortDirection() == winrt::SortDirection::None)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -230,13 +245,18 @@ bool TableView::IsSortRequestStillValid(const winrt::TableViewColumn& column) co
|
||||
|
||||
bool TableView::SortByColumn(const winrt::TableViewColumn& column, winrt::SortDirection direction)
|
||||
{
|
||||
return SortByColumnInternal(column, direction, true);
|
||||
// A column that opted out of sorting can still have the sort it holds cleared.
|
||||
const bool clearsOwnSort = direction == winrt::SortDirection::None &&
|
||||
column && column.SortDirection() != winrt::SortDirection::None;
|
||||
return SortByColumnInternal(column, direction, true, !clearsOwnSort);
|
||||
}
|
||||
|
||||
bool TableView::SortByColumnInternal(
|
||||
const winrt::TableViewColumn& column, winrt::SortDirection direction, bool measureTip)
|
||||
const winrt::TableViewColumn& column, winrt::SortDirection direction, bool measureTip, bool requireCanSort)
|
||||
{
|
||||
if (!CanSortColumnNow(column))
|
||||
// Only a clear may skip the CanSort gate.
|
||||
requireCanSort = requireCanSort || direction != winrt::SortDirection::None;
|
||||
if (!CanSortColumnNow(column, requireCanSort))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -269,14 +289,14 @@ bool TableView::SortByColumnInternal(
|
||||
{
|
||||
if (m_editState == EditState::Ending && !m_isApplyingCoalescedEditReshape)
|
||||
{
|
||||
QueueCoalescedEditReshape([this, column, direction, measureTip]()
|
||||
QueueCoalescedEditReshape([this, column, direction, measureTip, requireCanSort]()
|
||||
{
|
||||
SortByColumnInternal(column, direction, measureTip);
|
||||
SortByColumnInternal(column, direction, measureTip, requireCanSort);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!IsSortRequestStillValid(column))
|
||||
if (!IsSortRequestStillValid(column, requireCanSort))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -287,7 +307,7 @@ bool TableView::SortByColumnInternal(
|
||||
}
|
||||
|
||||
// The edit commit and the Sorting handler are app code that can change the rows.
|
||||
if (!IsSortRequestStillValid(column) || !CanApplySortTo(direction))
|
||||
if (!IsSortRequestStillValid(column, requireCanSort) || !CanApplySortTo(direction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -382,23 +402,38 @@ bool TableView::ToggleSortDirection(const winrt::TableViewColumn& column)
|
||||
|
||||
bool TableView::ClearSort()
|
||||
{
|
||||
return ClearSortInternal(true);
|
||||
return ClearSortInternal(true, true);
|
||||
}
|
||||
|
||||
bool TableView::ClearSortInternal(bool measureTip)
|
||||
bool TableView::IsSortLitOnlyByApp() const
|
||||
{
|
||||
if (m_activeSourceReleased || !m_tableViewSourceSort.AxisToken.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
auto const source = ShapingSourceInternal();
|
||||
return source && !winrt::get_self<::TableViewSource>(source)->ActiveSortAxisInfos().empty();
|
||||
}
|
||||
|
||||
bool TableView::ClearSortInternal(bool measureTip, bool explicitClear)
|
||||
{
|
||||
if (m_sortedColumns.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!explicitClear && IsSortLitOnlyByApp())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryTerminateEditForControlInitiatedReshape())
|
||||
{
|
||||
if (m_editState == EditState::Ending && !m_isApplyingCoalescedEditReshape)
|
||||
{
|
||||
QueueCoalescedEditReshape([this, measureTip]()
|
||||
QueueCoalescedEditReshape([this, measureTip, explicitClear]()
|
||||
{
|
||||
ClearSortInternal(measureTip);
|
||||
ClearSortInternal(measureTip, explicitClear);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
@@ -428,7 +463,7 @@ bool TableView::ClearSortInternal(bool measureTip)
|
||||
m_sortedColumns.clear();
|
||||
|
||||
// Null trigger: Sorted fires with Column=null to signal a full clear.
|
||||
RecomputeSortDPsAndRaiseInternal(nullptr, measureTip);
|
||||
RecomputeSortDPsAndRaiseInternal(nullptr, measureTip, explicitClear);
|
||||
DrainCoalescedEditReshape();
|
||||
return true;
|
||||
}
|
||||
@@ -534,7 +569,8 @@ winrt::TableViewKeySelector TableView::GetTableViewSourceSortKeySelector(
|
||||
bool TableView::SyncTableViewSourceSort(
|
||||
const winrt::TableViewColumn& trigger,
|
||||
winrt::SortDirection direction,
|
||||
uint64_t tipGeneration)
|
||||
uint64_t tipGeneration,
|
||||
bool explicitClear)
|
||||
{
|
||||
auto tableViewSource = ShapingSourceInternal();
|
||||
if (!tableViewSource)
|
||||
@@ -542,6 +578,17 @@ bool TableView::SyncTableViewSourceSort(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Released on Unloaded or before the first Loaded. While another table owns the source, this
|
||||
// table changes only its own state; a path axis left behind re-lights the column on reclaim.
|
||||
if (m_activeSourceReleased && winrt::get_self<::TableViewSource>(tableViewSource)->IsOwnedByOther(*this))
|
||||
{
|
||||
if (direction == winrt::SortDirection::None)
|
||||
{
|
||||
m_tableViewSourceSort.Clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Comparer faults contained before this sort (a live re-sort) are not its own.
|
||||
if (auto const& rankAdapter = m_tableViewSourceSort.CustomSortState) { rankAdapter->TakeSwallowedFaults(); }
|
||||
|
||||
@@ -551,7 +598,9 @@ bool TableView::SyncTableViewSourceSort(
|
||||
// ordering is the comparer's, not the path's, so nothing else may claim the axis sorts by a
|
||||
// property.
|
||||
winrt::hstring axisSortMemberPath;
|
||||
if (trigger)
|
||||
// A clear removes the installed axis by its token, so it must not depend on the trigger's sort
|
||||
// path, which a Sorting handler may have changed or emptied since the axis was installed.
|
||||
if (trigger && direction != winrt::SortDirection::None)
|
||||
{
|
||||
if (const auto customComparer = trigger.CustomSortComparer())
|
||||
{
|
||||
@@ -620,7 +669,7 @@ bool TableView::SyncTableViewSourceSort(
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clear-all: reuse whatever axis is currently installed so it can be removed by token.
|
||||
// A clear: reuse whatever axis is currently installed so it can be removed by token.
|
||||
keySelector = m_tableViewSourceSort.KeySelector;
|
||||
sortAxisToken = m_tableViewSourceSort.AxisToken;
|
||||
if (direction == winrt::SortDirection::None)
|
||||
@@ -629,26 +678,35 @@ bool TableView::SyncTableViewSourceSort(
|
||||
}
|
||||
}
|
||||
|
||||
if (!keySelector)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Limitation: this reshapes on explicit sort and on collection notifications only.
|
||||
// INotifyPropertyChanged-only mutations of the active sort key do not live re-sort until the
|
||||
// next collection change.
|
||||
auto tableViewSourceImpl = winrt::get_self<::TableViewSource>(tableViewSource);
|
||||
// An explicit clear needs no key selector: an app-declared axis lit on a column was never
|
||||
// installed by this control, yet clearing that column must still remove it. Any other clear
|
||||
// removes only the axis this control installed.
|
||||
const bool clearsAppAxes = direction == winrt::SortDirection::None && explicitClear;
|
||||
if (clearsAppAxes
|
||||
? !keySelector && tableViewSourceImpl->ActiveSortAxisInfos().empty()
|
||||
: !keySelector || (direction == winrt::SortDirection::None && sortAxisToken.empty()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// The control is the last writer here, so its axis becomes the only one. Without this an
|
||||
// app-declared fluent Sort axis would survive alongside it and, being declared earlier, would
|
||||
// outrank it as the primary sort while the chevron advertised this column.
|
||||
auto const reconcileGuard = BeginControlInitiatedSortScope(tipGeneration);
|
||||
if (direction == winrt::SortDirection::None)
|
||||
if (clearsAppAxes)
|
||||
{
|
||||
// A control-initiated clear means "nothing is sorted", so it clears EVERY axis, not just
|
||||
// the one this control installed. Clearing only its own token would leave an app-declared
|
||||
// fluent axis ordering the rows while every chevron said the sort was off.
|
||||
// An explicit clear means "nothing is sorted", so it clears EVERY axis, not just the one
|
||||
// this control installed. Clearing only its own token would leave an app-declared fluent
|
||||
// axis ordering the rows while every chevron said the sort was off.
|
||||
tableViewSourceImpl->ClearSort();
|
||||
}
|
||||
else if (direction == winrt::SortDirection::None)
|
||||
{
|
||||
tableViewSourceImpl->ClearSort(sortAxisToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Replacing rather than adding: re-sorting a column must swap its axis, not stack a
|
||||
@@ -755,7 +813,7 @@ int32_t TableView::FindEntryIndexForDataItem(const winrt::IInspectable& item) co
|
||||
}
|
||||
|
||||
void TableView::RecomputeSortDPsAndRaiseInternal(
|
||||
const winrt::TableViewColumn& trigger, bool measureTip)
|
||||
const winrt::TableViewColumn& trigger, bool measureTip, bool explicitClear)
|
||||
{
|
||||
// Stale-deferred-clear guard. A null trigger means "clear", and every legitimate null-trigger
|
||||
// caller - ClearSort, SortByColumn(col, None), ToggleSortDirection cycling to None - clears
|
||||
@@ -801,7 +859,7 @@ void TableView::RecomputeSortDPsAndRaiseInternal(
|
||||
// Selection is index-based, and a re-sort moves every index. Capture the item, then re-find it
|
||||
// afterwards so the selection follows the row rather than the slot.
|
||||
const auto preservedSelection = SelectedItemInternal();
|
||||
const bool reshaped = SyncTableViewSourceSort(trigger, requestedDirection, tipGeneration);
|
||||
const bool reshaped = SyncTableViewSourceSort(trigger, requestedDirection, tipGeneration, explicitClear || static_cast<bool>(trigger));
|
||||
if (reshaped && invalidatedSort)
|
||||
{
|
||||
TableViewTip::IgnoreOperation(m_tip, invalidatedSort, TableViewTip::IgnoreReason::Stale);
|
||||
@@ -818,7 +876,17 @@ void TableView::RecomputeSortDPsAndRaiseInternal(
|
||||
ApplySelection(FindEntryIndexForDataItem(preservedSelection));
|
||||
}
|
||||
|
||||
if (m_sortedEventSource)
|
||||
// The app's sort remains on the source, so an implicit clear is not reported as a clear.
|
||||
bool keptSourceSort = false;
|
||||
if (!trigger && !explicitClear && !m_activeSourceReleased)
|
||||
{
|
||||
if (auto const source = ShapingSourceInternal())
|
||||
{
|
||||
keptSourceSort = !winrt::get_self<::TableViewSource>(source)->ActiveSortAxisInfos().empty();
|
||||
}
|
||||
}
|
||||
|
||||
if (m_sortedEventSource && !keptSourceSort)
|
||||
{
|
||||
const auto appliedDirection = trigger ? trigger.SortDirection() : winrt::SortDirection::None;
|
||||
auto args = winrt::make_self<TableViewSortedEventArgs>(trigger, appliedDirection);
|
||||
@@ -832,7 +900,7 @@ void TableView::RecomputeSortDPsAndRaiseInternal(
|
||||
}
|
||||
}
|
||||
|
||||
if (winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::Notification))
|
||||
if (!keptSourceSort && winrt::AutomationPeer::ListenerExists(winrt::AutomationEvents::Notification))
|
||||
{
|
||||
// A programmatic or header-driven re-sort has no input event behind it, so without an
|
||||
// announcement a screen-reader user has no way to learn the order changed.
|
||||
@@ -882,6 +950,12 @@ void TableView::RecomputeSortDPsAndRaiseInternal(
|
||||
// The chevrons are already current: SetSortStateInternal republished them through
|
||||
// RefreshSortIndicators as each column's DP was written.
|
||||
completed = true;
|
||||
|
||||
// An app-declared axis that survived the clear re-lights its column (for example a moved one).
|
||||
if (!trigger && !explicitClear)
|
||||
{
|
||||
QueueReconcileSortStateWithSource();
|
||||
}
|
||||
}
|
||||
|
||||
void TableView::QueueReconcileSortStateWithSource()
|
||||
@@ -970,13 +1044,6 @@ void TableView::ReconcileSortStateWithSource()
|
||||
return;
|
||||
}
|
||||
|
||||
if (primaryForeignAxis == nullptr && ownToken.empty() && m_sortedColumns.empty())
|
||||
{
|
||||
// Nothing sorted anywhere and no chevron lit. Reached on every shaping notification -
|
||||
// a filter or group change also lands here - so it must not raise a spurious Sorted.
|
||||
return;
|
||||
}
|
||||
|
||||
if (primaryForeignAxis != nullptr && ownAxisPresent)
|
||||
{
|
||||
// The app declared a sort while ours was installed. Last writer wins, so ours goes: left
|
||||
@@ -1012,6 +1079,13 @@ void TableView::ReconcileSortStateWithSource()
|
||||
? primaryForeignAxis->Direction
|
||||
: winrt::SortDirection::None;
|
||||
|
||||
// No axis of ours, nothing a column can describe and no chevron lit. Reached on every shaping
|
||||
// notification - a filter or group change also lands here - so it must not raise a spurious Sorted.
|
||||
if (ownToken.empty() && !matchedColumn && m_sortedColumns.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Already reconciled to exactly this state. Reached whenever an unrelated shaping change - a
|
||||
// filter or a group - renotifies while an app-owned sort is standing; re-raising Sorted for it
|
||||
// would report a sort change that did not happen.
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
AutomationProperties.Name="RemoveColumnButton"
|
||||
AutomationProperties.AutomationId="RemoveColumnButton"
|
||||
Click="OnRemoveColumnClick" />
|
||||
<Button Content="Toggle Score Pin"
|
||||
AutomationProperties.Name="ToggleScorePinButton"
|
||||
AutomationProperties.AutomationId="ToggleScorePinButton"
|
||||
Click="OnToggleScorePinClick" />
|
||||
<Button Content="Filter Source"
|
||||
AutomationProperties.Name="FilterSourceButton"
|
||||
AutomationProperties.AutomationId="FilterSourceButton"
|
||||
@@ -99,6 +103,20 @@
|
||||
IsTabStop="False"
|
||||
AllowFocusOnInteraction="False"
|
||||
Click="OnDisableFocusedRowClick" />
|
||||
<!-- Mid-gesture teardown triggers for the resize capture tests (UIA Invoke only): each acts 1.5s
|
||||
after the click, while the test holds a pointer drag open. -->
|
||||
<Button Content="Delayed Gripper Capture Loss"
|
||||
AutomationProperties.Name="DelayedGripperCaptureLossButton"
|
||||
AutomationProperties.AutomationId="DelayedGripperCaptureLossButton"
|
||||
IsTabStop="False"
|
||||
AllowFocusOnInteraction="False"
|
||||
Click="OnDelayedGripperCaptureLossClick" />
|
||||
<Button Content="Delayed Disable"
|
||||
AutomationProperties.Name="DelayedDisableButton"
|
||||
AutomationProperties.AutomationId="DelayedDisableButton"
|
||||
IsTabStop="False"
|
||||
AllowFocusOnInteraction="False"
|
||||
Click="OnDelayedDisableClick" />
|
||||
<Button x:Name="navigateToTouch"
|
||||
Content="Touch Page"
|
||||
AutomationProperties.Name="navigateToTouch"
|
||||
|
||||
@@ -870,23 +870,71 @@ namespace MUXControlsTestApp
|
||||
// direct click cannot do while the control holds pointer capture.
|
||||
private void OnDelayedUnloadClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var unloadTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) };
|
||||
unloadTimer.Tick += (s, args) =>
|
||||
RunAfterDelay(() =>
|
||||
{
|
||||
unloadTimer.Stop();
|
||||
BasicTableHost.Children.Remove(BasicTableView);
|
||||
StatusTextBlock.Text = "Unloaded";
|
||||
|
||||
var reloadTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) };
|
||||
reloadTimer.Tick += (s2, args2) =>
|
||||
RunAfterDelay(() =>
|
||||
{
|
||||
reloadTimer.Stop();
|
||||
BasicTableHost.Children.Add(BasicTableView);
|
||||
StatusTextBlock.Text = "Reloaded";
|
||||
};
|
||||
reloadTimer.Start();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void RunAfterDelay(Action action)
|
||||
{
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) };
|
||||
timer.Tick += (s, args) =>
|
||||
{
|
||||
timer.Stop();
|
||||
action();
|
||||
};
|
||||
unloadTimer.Start();
|
||||
timer.Start();
|
||||
}
|
||||
|
||||
private void OnDelayedGripperCaptureLossClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
RunAfterDelay(() =>
|
||||
{
|
||||
foreach (var gripper in FindDescendants<Microsoft.UI.Private.Controls.ResizeGripper>(BasicTableView))
|
||||
{
|
||||
gripper.ReleasePointerCaptures();
|
||||
}
|
||||
StatusTextBlock.Text = "Gripper capture released";
|
||||
});
|
||||
}
|
||||
|
||||
private void OnDelayedDisableClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
RunAfterDelay(() =>
|
||||
{
|
||||
BasicTableView.IsEnabled = false;
|
||||
StatusTextBlock.Text = "Disabled";
|
||||
RunAfterDelay(() =>
|
||||
{
|
||||
BasicTableView.IsEnabled = true;
|
||||
StatusTextBlock.Text = "Enabled";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static IEnumerable<T> FindDescendants<T>(DependencyObject root) where T : DependencyObject
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(root);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(root, i);
|
||||
if (child is T typed)
|
||||
{
|
||||
yield return typed;
|
||||
}
|
||||
|
||||
foreach (var nested in FindDescendants<T>(child))
|
||||
{
|
||||
yield return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnIsReadOnlyToggled(object sender, RoutedEventArgs e)
|
||||
@@ -988,6 +1036,21 @@ namespace MUXControlsTestApp
|
||||
}
|
||||
}
|
||||
|
||||
// MinWidth == MaxWidth pins the column, which withdraws its resize gripper.
|
||||
private void OnToggleScorePinClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (double.IsPositiveInfinity(ScoreColumn.MaxWidth))
|
||||
{
|
||||
ScoreColumn.MinWidth = ScoreColumn.ActualWidth;
|
||||
ScoreColumn.MaxWidth = ScoreColumn.ActualWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
ScoreColumn.ClearValue(TableViewColumn.MaxWidthProperty);
|
||||
ScoreColumn.ClearValue(TableViewColumn.MinWidthProperty);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExpandAllGroupsClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
GroupedTableView.ExpandAllGroups();
|
||||
|
||||
@@ -39,10 +39,13 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.TableViewShared
|
||||
internal const string AfterTableButton = "AfterTableButton";
|
||||
internal const string AddColumnButton = "AddColumnButton";
|
||||
internal const string RemoveColumnButton = "RemoveColumnButton";
|
||||
internal const string ToggleScorePinButton = "ToggleScorePinButton";
|
||||
internal const string FilterSourceButton = "FilterSourceButton";
|
||||
internal const string ExpandAllGroupsButton = "ExpandAllGroupsButton";
|
||||
internal const string CollapseAllGroupsButton = "CollapseAllGroupsButton";
|
||||
internal const string DelayedUnloadButton = "DelayedUnloadButton";
|
||||
internal const string DelayedGripperCaptureLossButton = "DelayedGripperCaptureLossButton";
|
||||
internal const string DelayedDisableButton = "DelayedDisableButton";
|
||||
internal const string SelectionModeComboBox = "SelectionModeComboBox";
|
||||
internal const string SortCycleComboBox = "SortCycleComboBox";
|
||||
internal const string CanUserSortColumnsCheckBox = "CanUserSortColumnsCheckBox";
|
||||
|
||||
@@ -170,6 +170,7 @@ Entry c_typeEntries[] =
|
||||
xamlType.AddDPMember(L"AlternatingRowBackground", L"Microsoft.UI.Xaml.Media.Brush", statics.AlternatingRowBackgroundProperty(), false /* isContent */);
|
||||
xamlType.AddDPMember(L"CanUserResizeColumns", L"Boolean", statics.CanUserResizeColumnsProperty(), false /* isContent */);
|
||||
xamlType.AddDPMember(L"CanUserSortColumns", L"Boolean", statics.CanUserSortColumnsProperty(), false /* isContent */);
|
||||
xamlType.AddDPMember(L"ColumnHeaderStyle", L"Microsoft.UI.Xaml.Style", statics.ColumnHeaderStyleProperty(), false /* isContent */);
|
||||
xamlType.AddDPMember(L"Columns", L"Windows.Foundation.Collections.IVector`1<Microsoft.UI.Xaml.Controls.Tabular.TableViewColumn>", statics.ColumnsProperty(), true /* isContent */);
|
||||
xamlType.AddDPMember(L"Density", L"Microsoft.UI.Xaml.Controls.Tabular.TableViewDensity", statics.DensityProperty(), false /* isContent */);
|
||||
xamlType.AddDPMember(L"EmptyTemplate", L"Microsoft.UI.Xaml.DataTemplate", statics.EmptyTemplateProperty(), false /* isContent */);
|
||||
@@ -243,6 +244,7 @@ Entry c_typeEntries[] =
|
||||
xamlType.AddDPMember(L"FrozenEdge", L"Microsoft.UI.Xaml.Controls.Tabular.TableViewFrozenEdge", statics.FrozenEdgeProperty(), false /* isContent */);
|
||||
xamlType.AddDPMember(L"HeaderContextFlyout", L"Microsoft.UI.Xaml.Controls.Primitives.FlyoutBase", statics.HeaderContextFlyoutProperty(), false /* isContent */);
|
||||
xamlType.AddDPMember(L"Header", L"Object", statics.HeaderProperty(), true /* isContent */);
|
||||
xamlType.AddDPMember(L"HeaderStyle", L"Microsoft.UI.Xaml.Style", statics.HeaderStyleProperty(), false /* isContent */);
|
||||
xamlType.AddDPMember(L"HeaderTemplate", L"Microsoft.UI.Xaml.DataTemplate", statics.HeaderTemplateProperty(), false /* isContent */);
|
||||
xamlType.AddDPMember(L"HeaderTemplateSelector", L"Microsoft.UI.Xaml.Controls.DataTemplateSelector", statics.HeaderTemplateSelectorProperty(), false /* isContent */);
|
||||
xamlType.AddDPMember(L"HeaderToolTip", L"Object", statics.HeaderToolTipProperty(), false /* isContent */);
|
||||
@@ -277,6 +279,35 @@ Entry c_typeEntries[] =
|
||||
return static_cast<winrt::IXamlType>(*xamlType);
|
||||
}
|
||||
},
|
||||
{
|
||||
/* Arg1 TypeName */
|
||||
L"Microsoft.UI.Xaml.Controls.Tabular.TableViewColumnHeader",
|
||||
/* Arg2 CreateXamlTypeCallback */
|
||||
[]()
|
||||
{
|
||||
auto xamlType = winrt::make_self<XamlType>(
|
||||
/* Arg 1 - TypeName */
|
||||
(PCWSTR)L"Microsoft.UI.Xaml.Controls.Tabular.TableViewColumnHeader",
|
||||
/* Arg 2 - BaseTypeName */
|
||||
(PCWSTR)L"Microsoft.UI.Xaml.Controls.ContentControl",
|
||||
/* Arg 3 - Activator func */
|
||||
nullptr,
|
||||
/* Arg 4 - Populate properties func */
|
||||
(std::function<void(XamlTypeBase&)>)[](XamlTypeBase& xamlType)
|
||||
{
|
||||
xamlType.AddMember(
|
||||
L"Column", /* propertyName */
|
||||
L"Microsoft.UI.Xaml.Controls.Tabular.TableViewColumn", /* propertyType */
|
||||
[](winrt::IInspectable instance) { return instance.as<winrt::TableViewColumnHeader>().Column(); },
|
||||
nullptr, /* setter */
|
||||
false, /* isContent */
|
||||
false, /* isDependencyProperty */
|
||||
false /* isAttachable */);
|
||||
});
|
||||
|
||||
return static_cast<winrt::IXamlType>(*xamlType);
|
||||
}
|
||||
},
|
||||
{
|
||||
/* Arg1 TypeName */
|
||||
L"Microsoft.UI.Xaml.Controls.Tabular.TableViewDensity",
|
||||
@@ -812,6 +843,12 @@ Entry c_typeEntries[] =
|
||||
/* Arg2 CreateXamlTypeCallback */
|
||||
[]() { return winrt::make<PrimitiveXamlType>((PCWSTR)L"Microsoft.UI.Xaml.ResourceDictionary"); }
|
||||
},
|
||||
{
|
||||
/* Arg1 TypeName */
|
||||
L"Microsoft.UI.Xaml.Style",
|
||||
/* Arg2 CreateXamlTypeCallback */
|
||||
[]() { return winrt::make<PrimitiveXamlType>((PCWSTR)L"Microsoft.UI.Xaml.Style"); }
|
||||
},
|
||||
{
|
||||
/* Arg1 TypeName */
|
||||
L"Microsoft.UI.Xaml.UIElement",
|
||||
|
||||
@@ -143,13 +143,15 @@ public sealed record Order(string OrderNumber, string Customer, string Status);
|
||||
|
||||
`HeadersVisibility` controls column-header visibility (`None` or `Column`). Row headers are out of scope, so there are no `Row`/`All` values.
|
||||
|
||||
`GridLinesVisibility` controls gridlines. `Density` controls row and cell spacing. Header height matches row height; built-in header and text-cell content is vertically centered.
|
||||
`GridLinesVisibility` controls the body gridlines. The column-header lines (the separators between header cells and the rule under the header) are always drawn, whatever `GridLinesVisibility` is; both paint `TabularSurfaceHeaderSeparatorBrush`, so an app hides them by overriding that resource with `Transparent`. `Density` controls row and cell spacing. Header height matches row height; built-in header and text-cell content is vertically centered.
|
||||
|
||||
> **Migrating from earlier previews:** `HeaderGridLinesVisibility` was removed. `GridLinesVisibility` affects only the body lines; to hide the header separators and the rule under the header, override `TabularSurfaceHeaderSeparatorBrush` with `Transparent`.
|
||||
|
||||
```xaml
|
||||
<tabular:TableView
|
||||
ItemsSource="{x:Bind People}"
|
||||
HeadersVisibility="Column"
|
||||
GridLinesVisibility="All"
|
||||
GridLinesVisibility="Horizontal"
|
||||
Density="Compact"
|
||||
RowBackground="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
AlternatingRowBackground="{ThemeResource SubtleFillColorSecondaryBrush}">
|
||||
@@ -165,7 +167,7 @@ public sealed record Order(string OrderNumber, string Customer, string Status);
|
||||
using Microsoft.UI.Xaml.Controls.Tabular;
|
||||
|
||||
PeopleTable.HeadersVisibility = TableViewHeadersVisibility.Column;
|
||||
PeopleTable.GridLinesVisibility = TableViewGridLinesVisibility.All;
|
||||
PeopleTable.GridLinesVisibility = TableViewGridLinesVisibility.Horizontal;
|
||||
PeopleTable.Density = TableViewDensity.Compact;
|
||||
```
|
||||
|
||||
@@ -175,13 +177,18 @@ In all themes, an application-set table or row `Foreground` supplies the normal-
|
||||
pointer-over, pressed, selected, and disabled states use the corresponding `TabularSurface*`
|
||||
foreground brush. Cell content inherits that foreground unless its template supplies an explicit brush.
|
||||
|
||||
Override the named `TabularSurface*` brush keys to customize colors. Low-level Fluent color-token
|
||||
Override the named `TabularSurface*` brush keys to customize colors. To restyle column headers
|
||||
beyond their brushes (typography, padding, the template), set `ColumnHeaderStyle`. Low-level Fluent color-token
|
||||
overrides no longer flow through the removed default-style brush fallbacks.
|
||||
|
||||
Override `TabularSurfaceSelectionIndicatorDisabledBrush` and `TabularSurfaceRowForegroundDisabledBrush`
|
||||
independently to customize the disabled-selected indicator and disabled row text.
|
||||
By default, the disabled-selected indicator uses disabled-text colors rather than the accent color.
|
||||
|
||||
The default group header's item count inherits the header `Foreground` and is dimmed by
|
||||
`TabularSurfaceGroupHeaderCountOpacity` (1.0 in High Contrast). A disabled header shows the count
|
||||
at full opacity, since the disabled brush already dims it.
|
||||
|
||||
### Frozen (pinned) leading columns
|
||||
|
||||
Set `FrozenEdge="Leading"` on a contiguous prefix starting at column 0 to pin those columns to the leading edge. A later `Leading` column is ignored. `Trailing` is reserved.
|
||||
@@ -440,10 +447,12 @@ Template parts:
|
||||
| Part | Type | Description |
|
||||
|---|---|---|
|
||||
| `PART_HeaderRow` | FrameworkElement | Header row root. |
|
||||
| `PART_HeaderHost` | `Panel` | Host for column header elements. |
|
||||
| `PART_HeaderHost` | `TableViewCellsPanel` (typed `Panel`) | Host for column header elements. See [TableViewCellsPanel class](#tableviewcellspanel-class). |
|
||||
| `PART_RowsRepeater` | `ItemsRepeater` | Repeater that displays rows. |
|
||||
| `PART_EmptyStatePresenter` | `ContentControl` | Optional presenter for `EmptyTemplate`. |
|
||||
|
||||
Scrolling is structural rather than a named part. The header scroller and the body scroller are the nearest `ScrollViewer` ancestors of `PART_HeaderHost` / `PART_RowsRepeater` **inside the template**; their names are not looked up. The search stops at the `TableView`, so a template that omits them never latches onto a `ScrollViewer` in the app's own tree. The header scroller is horizontal-only and kept in sync with the body. The body scroller supplies the viewport for `*` column widths, the offset for frozen-column pinning, and the target of keyboard paging and scroll-into-view; without it, `*` columns keep their provisional width, frozen columns are not pinned, and the header is not scroll-synced. The default template supplies both, named `PART_HeaderScroller` and `PART_BodyScroller` for readability only.
|
||||
|
||||
### Example Usage
|
||||
|
||||
```xaml
|
||||
@@ -469,12 +478,13 @@ Template parts:
|
||||
| `ItemsSource` | `Object` | `null` | Source collection for table rows. |
|
||||
| `Columns` | `IVector<TableViewColumn>` | Empty vector | Developer-defined column collection. This is the content property. |
|
||||
| `HeadersVisibility` | `TableViewHeadersVisibility` | `Column` | Controls column-header visibility. |
|
||||
| `GridLinesVisibility` | `TableViewGridLinesVisibility` | `All` | Controls horizontal and vertical gridlines. |
|
||||
| `GridLinesVisibility` | `TableViewGridLinesVisibility` | `All` | Controls the body's horizontal (row) and vertical (column) gridlines. |
|
||||
| `Density` | `TableViewDensity` | `Standard` | Controls row/cell spacing. |
|
||||
| `RowBackground` | `Brush` | `null` | Background brush for rows. |
|
||||
| `AlternatingRowBackground` | `Brush` | `null` | Optional alternating row background for banding. |
|
||||
| `EmptyTemplate` | `DataTemplate` | `null` | Template displayed when there are no rows. |
|
||||
| `GroupHeaderTemplate` | `DataTemplate` | `null` | Content template for the group-header band shown when the source is grouped. Fills only the header's content region; the chevron and themed band remain control-owned. The data context is a `TableViewGroupInfo`. Ignored when the source is not grouped. |
|
||||
| `ColumnHeaderStyle` | `Style` | `null` | `Style` applied to every `TableViewColumnHeader`. `TableViewColumn.HeaderStyle` overrides it for one column. Layered over the default header style, so it need not be `BasedOn` it. See [TableViewColumnHeader class](#tableviewcolumnheader-class). |
|
||||
| `IsReadOnly` | `Boolean` | `true` | Gates editing for the whole control. Editing is opt-in: while `true`, user gestures do not open an editor regardless of per-column `IsReadOnly`. Setting it to `true` while a cell is open closes that edit. |
|
||||
| `IsEditing` | `Boolean` | `false` | `true` while a cell editor is open, through the matching commit/cancel close. Read-only. |
|
||||
| `SelectionMode` | `TableViewSelectionMode` | `Single` | Gates row selection for the whole control. Selection is on by default, matching `ItemsView`, `ListView` and WPF's `DataGrid`; set `None` for a display-only table, which also clears any selection. |
|
||||
@@ -614,17 +624,54 @@ The realized group-header container: a templated `ContentControl` whose `Content
|
||||
| `IsExpandable` | `Boolean` DP | Whether this header can expand. |
|
||||
| `ToggleRequested` | event (`TableViewGroupHeaderToggleRequestedEventArgs`) | Raised when the band is activated. The args carry `GroupKey`, so a handler that re-enters and mutates the header still sees the key that was actually activated. |
|
||||
|
||||
Template parts (all optional; omitting one degrades rather than fails):
|
||||
Template parts. Code looks up `PART_ContentPresenter` and `PART_GridLineBorder`, and degrades without either. The default visual states, however, target `PART_Root`, `PART_ContentPresenter` and `PART_ExpanderIcon` by name, so a template that keeps those state definitions must declare all three:
|
||||
|
||||
| Part | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `PART_ExpanderGutter` | `Border` | Reserves the chevron column so header content stays aligned with the rows below. |
|
||||
| `PART_ExpanderIcon` | `FontIcon` | Glyph driven by `ExpansionStates`, not by code. |
|
||||
| `PART_Root` | `Border` | The band. `CommonStates` sets its `Background` for `PointerOver` and `Pressed`. |
|
||||
| `PART_ContentPresenter` | `ContentPresenter` | The app-customizable content region, showing `ContentTemplate` over the `TableViewGroupInfo`. `CommonStates` sets its `Foreground` and `HighContrastAdjustment`. |
|
||||
| `PART_ExpanderGutter` | `Border` | Reserves the chevron column so header content stays aligned with the rows below. It keeps its width when the chevron hides. |
|
||||
| `PART_ExpanderIcon` | `FontIcon` | Glyph driven by `ExpansionStates`, not by code; hidden by `NotExpandable`; recoloured by `CommonStates`. |
|
||||
| `PART_GridLineBorder` | `Border` | Optional. Renders the border chrome; on the last row the control clears its bottom edge so it does not double the table's own border. |
|
||||
|
||||
Visual states: `CommonStates` (`Normal`/`PointerOver`/`Pressed`/`Disabled`), `ExpansionStates` (`Expanded`/`Collapsed`), `ExpandabilityStates` (`Expandable`/`NotExpandable`).
|
||||
|
||||
The **whole band** is the toggle target, matching `ListView` and `TreeView`, rather than a nested button inside it — one interactive element, one automation story.
|
||||
|
||||
## TableViewColumnHeader class
|
||||
|
||||
The realized column header: a templated `ContentControl`, one per column in `PART_HeaderHost`. The `TableView` creates and owns the headers, so the class is sealed and has no public constructor; apps restyle them through `TableView.ColumnHeaderStyle` or `TableViewColumn.HeaderStyle`, and fill the content region through the column's `Header`, `HeaderTemplate` or `HeaderTemplateSelector`. Its only member of its own is the read-only `Column`, the `TableViewColumn` the header represents.
|
||||
|
||||
```xml
|
||||
<tabular:TableView.ColumnHeaderStyle>
|
||||
<Style TargetType="tabular:TableViewColumnHeader">
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="Background" Value="{ThemeResource LayerFillColorDefaultBrush}" />
|
||||
</Style>
|
||||
</tabular:TableView.ColumnHeaderStyle>
|
||||
```
|
||||
|
||||
The default style sets `FontSize` and `FontWeight` from `{ThemeResource TableViewHeaderFontSize}` and `{ThemeResource TableViewHeaderFontWeight}` (14, `SemiBold`), a transparent `Background` so the whole header takes pointer input, and `IsTabStop`/`UseSystemFocusVisuals`. The header inherits `Foreground` from the `TableView`. `MinHeight` and `Padding` follow `Density` unless the applied `ColumnHeaderStyle`/`HeaderStyle` sets them. A `ColumnHeaderStyle` or `HeaderStyle` whose `TargetType` is not `TableViewColumnHeader` or one of its base types is ignored.
|
||||
|
||||
Template parts. All are optional; a missing part disables only its feature:
|
||||
|
||||
| Part | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `PART_ContentPresenter` | `ContentPresenter` | Content region. On a sortable column the control adds the sort glyph's width to its right `Margin`, so text trims before the glyph. |
|
||||
| `PART_GridLine` | `Border` | Vertical separator. Always shown, except the last column's where it would double the outer border; the default template paints it with `TabularSurfaceHeaderSeparatorBrush`. |
|
||||
| `PART_SortIndicatorHost` | `Border` | Hosts the sort glyph on a sortable column. Under a High Contrast highlight state the glyph takes `PART_ContentPresenter`'s `Foreground`. |
|
||||
| `PART_ResizeGripperHost` | `Border` | Hosts the resize gripper on a resizable column. Without it the column cannot be resized from the header, by pointer, keyboard or UI Automation. |
|
||||
|
||||
Visual states:
|
||||
|
||||
| Group | States | Notes |
|
||||
|---|---|---|
|
||||
| `CommonStates` | `Normal`, `PointerOver`, `Pressed`, `Disabled` | `PointerOver`/`Pressed` are entered only on a column the user can sort, and change only the fill (`TabularSurfaceHeaderBackgroundPointerOverBrush` / `...PressedBrush`). `Disabled` dims the text and sort glyph with `TabularSurfaceRowForegroundDisabledBrush`, as on rows. |
|
||||
| `HighContrastStates` | `HighContrastNormal`, `HighContrastPointerOver`, `HighContrastPressed` | Entered alongside `PointerOver`/`Pressed` only under High Contrast: text and glyph switch to `TabularSurfaceHeaderForeground*Brush` and drop the text backplate. |
|
||||
| `SortStates` | `Unsorted`, `SortAscending`, `SortDescending` | Show or hide the sort glyph; the glyph is hidden until the column is sorted. |
|
||||
|
||||
The header's automation peer is `TableViewColumnHeaderAutomationPeer` (`HeaderItem`, with `Invoke` to sort and `Transform` to resize). A `ContextFlyout` set through a header style takes precedence over `HeaderContextFlyout` and, like it, stays closed while a cell is editing.
|
||||
|
||||
## TableViewColumn class
|
||||
|
||||
Base class for TableView columns.
|
||||
@@ -638,6 +685,7 @@ Base class for TableView columns.
|
||||
| `Header` | `Object` | `null` | Header content for the column. |
|
||||
| `HeaderTemplate` | `DataTemplate` | `null` | Template used to display the header. |
|
||||
| `HeaderTemplateSelector` | `DataTemplateSelector` | `null` | Template selector used to display the header; takes precedence over `HeaderTemplate` when both are set. |
|
||||
| `HeaderStyle` | `Style` | `null` | `Style` for this column's `TableViewColumnHeader`; wins over `TableView.ColumnHeaderStyle`. |
|
||||
| `Width` | `GridLength` | Default column width | `Pixel` = exact; `Auto` = widest realized cell (grows within a data set) or the current header; `*` = a proportional share of the body viewport after fixed columns. |
|
||||
| `MinWidth` | `Double` | `20` | Minimum column width. |
|
||||
| `MaxWidth` | `Double` | Infinity | Maximum column width. |
|
||||
@@ -765,7 +813,7 @@ Template parts:
|
||||
| Part | Type | Description |
|
||||
|---|---|---|
|
||||
| `PART_RootBorder` | `Border` | Row root border. Its `Background` is driven by `CommonStates`. |
|
||||
| `PART_CellsHost` | `Panel` | Host for generated cell elements. |
|
||||
| `PART_CellsHost` | `TableViewCellsPanel` (typed `Panel`) | Host for generated cell elements. See [TableViewCellsPanel class](#tableviewcellspanel-class). |
|
||||
| `PART_SelectionIndicator` | `Shape` (default: `Rectangle`) | **Required by the default state definitions.** Leading-edge selection strip; the `Selected*` states animate `Opacity` to `1`, and `SelectedDisabled` also animates `Fill`. Custom templates using other indicator types must adapt the corresponding state targets. |
|
||||
|
||||
Visual states:
|
||||
@@ -782,6 +830,37 @@ Properties:
|
||||
|---|---|---|---|
|
||||
| `IsSelected` | `Boolean` | `false` | Whether the row is the selected row. Read-only: selection is owned by the `TableView`, so a row cannot select itself — apps go through `Select(Int32)` / `Deselect(Int32)` / `DeselectAll()`. Rows are recycled, so this is always re-derived. |
|
||||
|
||||
## TableViewCellsPanel class
|
||||
|
||||
The layout panel that lines cells up under their columns. The default templates use it as `TableView`'s `PART_HeaderHost` and as each row's `PART_CellsHost`; it is public so that a re-templated `TableView` or `TableViewRow` can declare it in XAML.
|
||||
|
||||
`TableViewCellsPanel` derives from `Microsoft.UI.Xaml.Controls.Panel`.
|
||||
|
||||
| Member | Kind | Description |
|
||||
|---|---|---|
|
||||
| `TableViewCellsPanel()` | constructor | Creates the panel. Needed for XAML activation in a control template. |
|
||||
|
||||
It adds no properties, methods or events of its own; everything else is inherited from `Panel`.
|
||||
|
||||
Behavior:
|
||||
|
||||
- The **owning control populates `Children`**: one header cell (in `PART_HeaderHost`) or one cell wrapper (in `PART_CellsHost`) per column, in `Columns` order, each tagged with its `TableViewColumn`. Apps should not add, remove or reorder children; the control rebuilds them when the columns change.
|
||||
- **Measure:** a cell in an `Auto` column is measured unconstrained, and its width is reported to the `TableView`, which takes the widest header or realized cell when it resolves the column's `ActualWidth`. A cell in a `Pixel` or `*` column is measured at the column's resolved width. A cell in a collapsed column is measured at zero width.
|
||||
- **Arrange:** cells are placed left to right at each column's resolved `ActualWidth`, so the header band and every row stay aligned without a per-cell `Width` binding. Content wider than its column is clipped or ellipsized.
|
||||
- **Frozen columns:** the leading frozen prefix is pinned against the body's horizontal offset through `UIElement.Translation`, `Canvas.ZIndex` and a clip on the cells.
|
||||
|
||||
Usage in a re-template:
|
||||
|
||||
```xaml
|
||||
<!-- Inside a TableViewRow ControlTemplate. HorizontalAlignment="Left" matters: the panel reports
|
||||
the summed column width, and a Stretch alignment would centre it in a wider presenter. -->
|
||||
<tabular:TableViewCellsPanel x:Name="PART_CellsHost" HorizontalAlignment="Left" />
|
||||
```
|
||||
|
||||
`PART_HeaderHost` and `PART_CellsHost` are typed `Panel`, and any `Panel` is accepted, but only a `TableViewCellsPanel` reports measured widths and arranges at the column widths. With another panel, `Auto` columns ignore that host's content and its cells are not column-aligned.
|
||||
|
||||
Outside a `TableView` the panel has no columns to read. Untagged children are measured unconstrained and arranged at their desired width, side by side; this is a defensive fallback, not a supported layout.
|
||||
|
||||
## Enums
|
||||
|
||||
### TableViewFrozenEdge
|
||||
@@ -803,7 +882,7 @@ Flags enum mirroring the `None`/`Column` slots of WPF `DataGridHeadersVisibility
|
||||
|
||||
### TableViewGridLinesVisibility
|
||||
|
||||
Mirrors WPF `DataGridGridLinesVisibility` values exactly.
|
||||
Mirrors WPF `DataGridGridLinesVisibility` values exactly. Used by `GridLinesVisibility`.
|
||||
|
||||
| Value | Numeric value | Description |
|
||||
|---|---:|---|
|
||||
@@ -824,6 +903,8 @@ Mirrors WPF `DataGridGridLinesVisibility` values exactly.
|
||||
|
||||
Only `None` and `Single` exist in this release: `Multiple`/`Extended` need an anchor, a range model and Ctrl/Shift gesture handling, none of which single selection implies. New members are appended, so these numeric values stay stable when they arrive.
|
||||
|
||||
Why a TableView-specific enum rather than reusing `ItemsViewSelectionMode` or `ListViewSelectionMode`: both already define `Multiple` and `Extended`, which this release cannot honor. Reusing either would make `SelectionMode = Multiple` compile, bind and round-trip through markup, and then fail or silently behave as `Single` at run time; an enum that only names the supported values makes the limit visible in IntelliSense and the type system instead. The names and numeric values (`None = 0`, `Single = 1`) match the first two `ItemsViewSelectionMode` members, so code and persisted values transfer. The cost is a third selection-mode enum in the namespace; if API review prefers reuse, the alternative is `ItemsViewSelectionMode` with `Multiple`/`Extended` rejected by `E_INVALIDARG` until they are implemented.
|
||||
|
||||
| Value | Numeric value | Description |
|
||||
|---|---:|---|
|
||||
| `None` | 0 | Selection is off; a display-only table. |
|
||||
@@ -873,7 +954,7 @@ TableView provides UI Automation peers for grid/table accessibility, cell value
|
||||
|---|---|---|
|
||||
| `TableViewAutomationPeer` | `FrameworkElementAutomationPeer` | `ISelectionProvider`, `IGridProvider`, `ITableProvider`, `IItemContainerProvider` |
|
||||
| `TableViewRowAutomationPeer` | `FrameworkElementAutomationPeer` | `ISelectionItemProvider` |
|
||||
| `TableViewColumnHeaderAutomationPeer` | `FrameworkElementAutomationPeer` | `IInvokeProvider` (sort) |
|
||||
| `TableViewColumnHeaderAutomationPeer` | `FrameworkElementAutomationPeer` | `IInvokeProvider` (sort), `ITransformProvider` (resize; implementation-only, not in the IDL) |
|
||||
| `TableViewGroupHeaderAutomationPeer` | `FrameworkElementAutomationPeer` | `IExpandCollapseProvider`, `IGridItemProvider` |
|
||||
| `TableViewCellAutomationPeer` | `FrameworkElementAutomationPeer` | `IGridItemProvider`, `ITableItemProvider`, `IValueProvider` |
|
||||
|
||||
@@ -902,6 +983,9 @@ Each realized column-header visual owns its `HeaderItem` peer. `ITableProvider.G
|
||||
|
||||
Header `IsEnabled` reflects the owning table's effective state and disabled control ancestors between the header visual and the table (including template controls); Invoke rejects a disabled header without changing sort state. Cell header lookup resolves the realized visual independently of the table's peer type, so application-supplied table peers preserve canonical header identity. A cell with no reachable header (including a collapsed or removed column) returns an empty header array.
|
||||
|
||||
The preview `TableViewCellAutomationPeer(cell, row, column, columnIndex)` constructor validates its arguments and throws `E_INVALIDARG` (`ArgumentException` in .NET) when `cell`, `row` or `column` is null, when `columnIndex` is negative, or — when `row` belongs to a `TableView` — when `column` belongs to a different table or `columnIndex` is not less than that table's `Columns.Count`. `columnIndex` is only a fallback: `IGridItemProvider.Column` is computed live from the row's visible cells, and the constructor argument is reported only when the row has no cell host. Peers the control creates for its own cells do not go through this check.
|
||||
A column header exposes the Transform pattern (required by UIA for a resizable `HeaderItem`) while `CanUserResizeColumns` and the column's `CanResize` are both true. `CanResize` follows the same rule as the resize gripper's visibility, so it reports `false` for a pinned column (`MinWidth` equal to `MaxWidth`, or a Star column with no later resizable Star column to trade width with) and raises a property-changed event when that changes. `Resize(width, height)` takes `width` in physical pixels, the units of `BoundingRectangle`, and converts it to DIPs with the `XamlRoot.RasterizationScale`. It runs the same resize path as a pointer drag or Alt+Arrow, so it honors `MinWidth` / `MaxWidth` and the viewport bounds and announces the new width; `height` is ignored. `CanMove` and `CanRotate` are `false`, and `Move` / `Rotate` fail with `UIA_E_INVALIDOPERATION`.
|
||||
|
||||
`ScrollItem` is not implemented explicitly — `FrameworkElementAutomationPeer` already supplies a `ScrollItemAdapter` for every peer, so a client can bring a row or cell into view after reaching it through `IGridProvider.GetItem`.
|
||||
|
||||
`Selection`/`SelectionItem` are advertised only while `SelectionMode` allows selection — advertising them while it is `None` would tell an AT client the grid is selectable when every `Select()` would be refused. `CanSelectMultiple` is `false` and `IsSelectionRequired` is `false`. `GetSelection()` returns the selected row's provider when that row is realized; a selected row scrolled out of the realization window is reached through `IItemContainerProvider.FindItemByProperty`, which realizes it.
|
||||
@@ -1088,6 +1172,12 @@ namespace Microsoft.UI.Xaml.Controls.Tabular
|
||||
static Microsoft.UI.Xaml.DependencyProperty CellTemplateProperty { get; };
|
||||
};
|
||||
|
||||
[MUX_PREVIEW, webhosthidden]
|
||||
unsealed runtimeclass TableViewCellsPanel : Microsoft.UI.Xaml.Controls.Panel
|
||||
{
|
||||
TableViewCellsPanel();
|
||||
};
|
||||
|
||||
[MUX_PREVIEW, webhosthidden]
|
||||
unsealed runtimeclass TableViewRow : Microsoft.UI.Xaml.Controls.Control
|
||||
{
|
||||
|
||||
@@ -60,7 +60,7 @@ TableView — Default Template
|
||||
├─ Header band [Row 0, Auto]
|
||||
│ └─ Border PART_HeaderRow (header background + 1px bottom gridline)
|
||||
│ └─ ScrollViewer PART_HeaderScroller (H-only; scrollbar hidden; V disabled; synced to body)
|
||||
│ └─ TableViewCellsPanel PART_HeaderHost -> one Grid header cell per column (L->R)
|
||||
│ └─ TableViewCellsPanel PART_HeaderHost -> one TableViewColumnHeader per column (L->R)
|
||||
└─ Body band [Row 1, *]
|
||||
└─ ScrollViewer PART_BodyScroller (H + V, auto scrollbars)
|
||||
└─ Grid PART_BodyContent
|
||||
@@ -80,11 +80,9 @@ Each `TableViewCellsPanel` measures its cells unconstrained and arranges them at
|
||||
|
||||
`Border` `PART_HeaderRow` → `ScrollViewer` `PART_HeaderScroller` → `TableViewCellsPanel` `PART_HeaderHost`.
|
||||
|
||||
`PART_HeaderRow` provides the header-surface background and a 1px bottom gridline. `PART_HeaderScroller` is horizontal-only (`HorizontalScrollMode="Auto"`, horizontal scrollbar hidden, vertical disabled). `PART_HeaderHost` is a `TableViewCellsPanel` (inherently horizontal); `RebuildHeaders` fills it with one `Grid` header cell per non-null column (each cell mirrors the column's `Visibility`), in `Columns` order, left-to-right. Header cells use the density row minimum height, so the header band matches body rows. Like rows, header cells carry `ControlCornerRadius`, consumed only by the system focus visual, so header keyboard focus is rounded. Header content comes from `TableViewColumn.Header`, displayed via `HeaderTemplateSelector` when set, otherwise `HeaderTemplate`, otherwise the default text presenter. A focused header keeps focus when `RebuildHeaders` replaces the band (a header, template or theme change); hiding the focused column moves focus to the next visible header, or the previous one when it was last.
|
||||
`PART_HeaderRow` provides the header-surface background and a 1px bottom rule. `PART_HeaderScroller` is horizontal-only (`HorizontalScrollMode="Auto"`, horizontal scrollbar hidden, vertical disabled). `PART_HeaderHost` is a `TableViewCellsPanel` (inherently horizontal); `RebuildHeaders` fills it with one `TableViewColumnHeader` per non-null column (each cell mirrors the column's `Visibility`), in `Columns` order, left-to-right. Header cells use the density row minimum height, so the header band matches body rows. Like rows, header cells carry `ControlCornerRadius`, consumed only by the system focus visual, so header keyboard focus is rounded. Header content comes from `TableViewColumn.Header`, displayed via `HeaderTemplateSelector` when set, otherwise `HeaderTemplate`, otherwise the default text presenter. Header lines are always drawn, independently of the body's `GridLinesVisibility`: each header's full-height 1px `PART_GridLine` separator on its trailing edge and the 1px rule under `PART_HeaderRow`, both with `TabularSurfaceHeaderSeparatorBrush`; overriding that resource with `Transparent` hides them. `TableView::ApplyGridLinesToHeader` owns the separators; the template draws the bottom rule (`PART_HeaderRow`'s `BorderThickness="0,0,0,1"`). Like the body's vertical lines, the last visible column's separator is suppressed only where it would double the outer border. A focused header keeps focus when `RebuildHeaders` replaces the band (a header, template or theme change); hiding the focused column moves focus to the next visible header, or the previous one when it was last.
|
||||
|
||||
Custom templates using a `Border` for `PART_HeaderRow` must supply its gridline `BorderThickness` through a style setter rather than a local value, so `GridLinesVisibility` can restore it with `ClearValue` for `Horizontal` or `All`.
|
||||
|
||||
**Pointer feedback.** A sortable header (`CanUserSortColumns` and `TableViewColumn.CanSort`) reacts to the pointer like a row: on hover it paints `TabularSurfaceHeaderBackgroundPointerOverBrush`, while pressed `TabularSurfaceHeaderBackgroundPressedBrush`, and it returns to its transparent fill on exit, release, capture loss or cancel (a capture loss or cancel from the resize gripper's own drag capture is ignored, so a header still under the pointer stays hovered). In High Contrast only, the default header text presenter and sort indicator also take `TabularSurfaceHeaderForegroundPointerOverBrush` / `TabularSurfaceHeaderForegroundPressedBrush` (`SystemColorHighlightTextColor` on a `SystemColorHighlightColor` fill). In Light and Dark the header text keeps its inherited foreground, so an app-set `TableView.Foreground`, `HeaderTemplate` foreground or `SortIndicatorForeground` is not overridden on hover; there those foreground keys equal the default rest foreground (`TabularSurfaceRowForegroundBrush`), and the fills equal the row pointer-over/pressed fills. Brushes are looked up on each state change, and again when the header's `ActualTheme` changes. A header that cannot sort shows no feedback, the pointer events are never marked handled (click-to-sort and focus are unchanged), and a press on the resize gripper stays with the gripper.
|
||||
**Pointer feedback.** A sortable header (`CanUserSortColumns` and `TableViewColumn.CanSort`) reacts to the pointer like a row: on hover it paints `TabularSurfaceHeaderBackgroundPointerOverBrush`, while pressed `TabularSurfaceHeaderBackgroundPressedBrush`, and it returns to its transparent fill on exit, release, capture loss or cancel (a capture loss or cancel from the resize gripper's own drag capture is ignored, so a header still under the pointer stays hovered). In High Contrast only, the default header text presenter and sort indicator also take `TabularSurfaceHeaderForegroundPointerOverBrush` / `TabularSurfaceHeaderForegroundPressedBrush` (`SystemColorHighlightTextColor` on a `SystemColorHighlightColor` fill). In Light and Dark the header text keeps its inherited foreground, so an app-set `TableView.Foreground`, `HeaderTemplate` foreground or `SortIndicatorForeground` is not overridden on hover; there those foreground keys equal the default rest foreground (`TabularSurfaceRowForegroundBrush`), and the fills equal the row pointer-over/pressed fills. The fills and High Contrast foregrounds are `{ThemeResource}` setters in the `TableViewColumnHeader` template's `CommonStates` and `HighContrastStates`; a theme or High Contrast change rebuilds the header band, so each header restarts in `Normal`/`HighContrastNormal`. `Disabled` dims the text and sort glyph with `TabularSurfaceRowForegroundDisabledBrush`. A header that cannot sort shows no feedback, the pointer events are never marked handled (click-to-sort and focus are unchanged), and a press on the resize gripper stays with the gripper.
|
||||
|
||||
## Body band
|
||||
|
||||
@@ -176,7 +174,7 @@ Two-column expectations, viewport 478, `MinWidth` 50 (`!` = `CanResize="False"`)
|
||||
|
||||
Rows are virtualized by `ItemsRepeater` on the vertical axis. With the vertical `StackLayout`, only rows whose realization rect intersects the body viewport — plus two viewports of cache on each side — are materialized as `TableViewRow` instances; off-screen rows return to the recycle pool. `StackLayout` is used instead of a uniform two-axis layout because row heights vary with density and template content.
|
||||
|
||||
On recycle, `ItemsRepeater` re-points a pooled row's `DataContext` to the new item and cell **data** updates **reactively** through `DataContext` inheritance + bindings — cells are **not** re-stamped imperatively (mutating a live cell during the repeater's measure pass re-enters framework layout and fails fast). `TableViewTextColumn` binds `TextBlock.Text` to the consumer `Binding` (resolved against the inherited `DataContext`); `TableViewTemplateColumn` binds its `ContentPresenter.Content` to the cell-wrapper `Border`'s inherited `DataContext` — bound to the *wrapper*, not the presenter's own `DataContext`, because `ContentPresenter` pins its `DataContext` to its `Content` (a self-referential binding would freeze after the first item). Consequently `RebuildCells` is needed only for structural changes (columns / template / density); a pure recycle refreshes only index-dependent visuals — alternating-row banding (`RefreshRowBackground`) and frozen-column pinning. Custom columns (overridable `GenerateElementCore`) must bind reactively to the inherited `DataContext` for the same reason.
|
||||
On recycle, `ItemsRepeater` re-points a pooled row's `DataContext` to the new item and cell **data** updates **reactively** through `DataContext` inheritance + bindings — cells are **not** re-stamped imperatively (mutating a live cell during the repeater's measure pass re-enters framework layout and fails fast). `TableViewTextColumn` binds `TextBlock.Text` to the consumer `Binding` (resolved against the inherited `DataContext`); `TableViewTemplateColumn` binds its `ContentPresenter.Content` to the cell-wrapper `Border`'s inherited `DataContext` — bound to the *wrapper*, not the presenter's own `DataContext`, because `ContentPresenter` pins its `DataContext` to its `Content` (a self-referential binding would freeze after the first item). Consequently `RebuildCells` is needed only for structural changes (columns / template / density); a pure recycle refreshes only index-dependent visuals — alternating-row banding (`RefreshRowBackground`) and frozen-column pinning. Custom columns (overridable `GenerateElementCore`) must bind reactively to the inherited `DataContext` for the same reason. This holds on every recycle to a new item: unless a client has realized a cell automation peer, the row reuses its cells, so a custom column that bakes `dataItem` into its element, or template content initialized once per element (in `Loaded`, or through `x:Bind` with `OneTime`), keeps showing the previous item after a scroll.
|
||||
|
||||
Column virtualization is intentionally not implemented: each realized row renders a cell for every non-null column (collapsed columns get a hidden cell), which is the right trade for typical 5–50 column tables. Because every realized row materializes one cell wrapper per non-null column, `TableView` targets typical application tables (~5–50 columns) and is **not** designed for spreadsheet-scale column counts (100+). Column virtualization is independent of the width engine: `Auto`/`*` sizing ships in this PR (see *Column width sizing*) without column virtualization, which stays out of scope — each realized row still renders one cell per non-null column.
|
||||
|
||||
@@ -202,7 +200,7 @@ Cell generation is column-specific:
|
||||
- `TableViewTemplateColumn` instantiates `CellTemplate`; a null template produces an empty cell.
|
||||
- Custom columns override `GenerateElementCore(Object dataItem)`.
|
||||
|
||||
`RowBackground` and `AlternatingRowBackground` provide opt-in row banding; when both are null, rows stay unbanded. `GridLinesVisibility` controls row/cell gridline borders. `Density` selects row minimum height and built-in cell/header padding for `Compact`, `Standard`, or `Comfortable`.
|
||||
`RowBackground` and `AlternatingRowBackground` provide opt-in row banding; when both are null, rows stay unbanded. `GridLinesVisibility` controls row/cell gridline borders; the column-header separators and bottom rule are always drawn. `Density` selects row minimum height and built-in cell/header padding for `Compact`, `Standard`, or `Comfortable`.
|
||||
|
||||
Keyboard handling is cell-aware and follows the WAI-ARIA treegrid model. `TableView` listens to bubbling `KeyDown` so template-column descendants handle input first. The table itself is not a tab stop (`IsTabStop=False`). It has **two tab stops**: the header band, then the body. The two share one column cursor, so Tab and Shift+Tab between them keep the current column.
|
||||
|
||||
@@ -247,7 +245,7 @@ Tabular's compiled `ResourceAccessor` reads `Microsoft.UI.Xaml.Controls.Tabular/
|
||||
|
||||
Lifetime rules: columns and rows use weak owner back-pointers (`GetOwningTableView()` resolves a strong owner for synchronous work); runtime classes use `ReferenceTracker` where required; cross-object events use `auto_revoke`; recycled rows reset transient visual state before reuse.
|
||||
|
||||
Theme values resolve through `TabularSurfaces` resources and re-resolve across theme (and high-contrast) changes. `TabularSurfaceGridLineBrush` (grid lines, header lines and the control border) uses the Fluent `DividerStrokeColorDefault` value, and `SystemColorWindowTextColor` under High Contrast. The stock style always provides the key; if it is missing, only High Contrast draws grid lines.
|
||||
Theme values resolve through `TabularSurfaces` resources and re-resolve across theme (and high-contrast) changes. `TabularSurfaceGridLineBrush` (body grid lines) and `TabularSurfaceHeaderSeparatorBrush` (header separators and bottom rule) use the Fluent `DividerStrokeColorDefault` value and `TabularSurfaceOuterBorderBrush` (the control border) uses `CardStrokeColorDefault`; High Contrast uses `SystemColorWindowTextColor` for both. `TabularSurfaceHeaderSeparatorBrush` is the header line brush: overriding `TabularSurfaceGridLineBrush` does not recolor header lines, so override both to recolor every line. The stock style always provides the key; if it is missing, High Contrast falls back to `SystemColorWindowTextColor` and every other theme draws no grid line.
|
||||
|
||||
`TableView.xaml` does not redeclare the `TabularSurface*` brush keys: generic-style fallbacks would shadow the canonical palette and bypass its HighContrast system-color brushes. Consumers merge `TabularControlsResources` as documented by the sample.
|
||||
|
||||
@@ -264,6 +262,17 @@ Sort has two front-ends and exactly one axis is ever in force; they reconcile ra
|
||||
column's `SortMemberPath` and light that column's chevron. The delegate overload
|
||||
(`Sort(keySelector, direction)`) is opaque — the key may not correspond to a column at all
|
||||
(computed key, multi-field, custom comparer) — so no chevron is shown.
|
||||
- `TableViewColumn.SortDirection` is read-only to apps. An app that cancels `Sorting` to order the
|
||||
rows itself can make the chevron follow only by declaring that order with the path overload on
|
||||
the bound `TableViewSource`.
|
||||
|
||||
**Axis identity on the source.** A path axis is keyed on its path, so re-sorting the same path
|
||||
replaces it in place. A delegate axis is keyed on the delegate object's COM identity
|
||||
(`ShapingPipeline::SetSort`): the same delegate instance re-sorts that axis in place and `None`
|
||||
removes it, while any other instance, even one with identical logic, is a new, least significant
|
||||
axis, and `None` with it removes nothing. Path and delegate axes never match each other, and the
|
||||
control's own axis is keyed on column identity, so two columns sharing a `SortMemberPath` stay
|
||||
separate.
|
||||
|
||||
Reconciliation rules:
|
||||
|
||||
@@ -281,7 +290,16 @@ the new page before unloading the old one resolves on its own. While refused, a
|
||||
|
||||
**Refused sorts.** `SortByColumn` and `ToggleSortDirection` return `false`, with no state changed,
|
||||
when the sort cannot be applied: besides a canceled `Sorting` or an edit that will not close, a
|
||||
source containing a null row object or the same object on two rows has no row identity to sort by.
|
||||
column with `CanSort=False` (or with neither a sort member path nor a `CustomSortComparer`) is not
|
||||
sortable, and a source containing a null row object or the same object on two rows has no row
|
||||
identity to sort by. `CanUserSortColumns=False` gates only header clicks, not these methods.
|
||||
Setting `CanSort=False` on the sorted column clears its sort through an internal path that skips
|
||||
that gate, raising `Sorting`/`Sorted` exactly as `SortByColumn(column, None)` does. A `Sorting`
|
||||
handler can cancel that clear, and it is skipped while the source is refused; the column then keeps
|
||||
its sort, and `SortByColumn(column, None)` (allowed on a column still holding a sort, whatever its
|
||||
`CanSort`) or `ClearSort` removes it. A column lit only by the app's `TableViewSource` sort, with
|
||||
no sort installed by the table, keeps that state and raises no events, as when
|
||||
`CanUserSortColumns` turns off.
|
||||
|
||||
Without this, the earlier-declared axis would silently outrank the later one while the chevron
|
||||
advertised the loser. WPF splits the same way: `SortDescriptions` carry a property name, which is
|
||||
@@ -358,6 +376,11 @@ recycling a header does not re-evaluate every binding in the template. `KeyText`
|
||||
are computed lazily through a `DecimalFormatter`, so a template that binds only `Key`/`ItemCount`
|
||||
pays nothing for them.
|
||||
|
||||
Under grouping, a hierarchical root row's lead cell is indented by the group header's expander
|
||||
gutter plus its spacing (`TableViewGroupExpanderSize` + `TableViewGroupExpanderSpacing`, 32px by
|
||||
default). The header's own `Padding` is not part of the indent, so the root row's chevron starts
|
||||
`Padding.Left` (8px by default) left of the group key text rather than under it.
|
||||
|
||||
**Interaction with selection.** Group headers share the flat projection's index space with data
|
||||
rows but are **not** selectable. `Select(index)` on a header index is rejected rather than coerced
|
||||
(without that, the internal grouped entry would be handed to the app as `SelectedItem`), and
|
||||
@@ -382,7 +405,8 @@ only the bulk commands plus the header's own gesture and UIA pattern. Grouping i
|
||||
|
||||
Styling is layered so the display-only base stays minimal while a richer surface can grow
|
||||
additively — the realized row/cell/header are already styleable targets (`TableViewRow` is a
|
||||
public `Control`; cell wrappers are `Border`s; header cells are `Grid`s), so the additions below
|
||||
public `Control`; cell wrappers are `Border`s; column headers are public, sealed
|
||||
`TableViewColumnHeader` controls that only the TableView creates), so the additions below
|
||||
need no new base API or re-plumbing.
|
||||
|
||||
**v1 (this base):**
|
||||
@@ -394,18 +418,30 @@ need no new base API or re-plumbing.
|
||||
default brushes, re-resolved on theme and high-contrast changes.
|
||||
- Typography theme keys — `TableViewCellFontSize` (14), `TableViewHeaderFontSize` (14) and
|
||||
`TableViewHeaderFontWeight` (`SemiBold`). They do not vary with `Density`; an override in the
|
||||
TableView's or an ancestor's `Resources`, or in `Application.Resources`, wins. Values are cached
|
||||
per instance, so a later override applies after a theme, high-contrast or density change.
|
||||
TableView's or an ancestor's `Resources`, or in `Application.Resources`, wins. The cell size is
|
||||
cached per instance, so a later override applies after a theme, high-contrast or density change.
|
||||
The header keys are `{ThemeResource}` setters in the `TableViewColumnHeader` default style, so a
|
||||
header style's `FontSize`/`FontWeight` setter wins over them and nothing is written as a local value.
|
||||
- `ColumnHeaderStyle` / `TableViewColumn.HeaderStyle` — `Style` for the templated
|
||||
`TableViewColumnHeader` (`ContentControl`); the column's style wins. Both are layered over the
|
||||
default header style, so they need not be `BasedOn` it. Pointer-over and pressed are `CommonStates`
|
||||
(`Normal`/`PointerOver`/`Pressed`/`Disabled`) that change only the fill (the `TabularSurfaceHeader*`
|
||||
brushes), so a custom `TableView.Foreground` survives hover; under High Contrast a separate
|
||||
`HighContrastStates` group (`HighContrastNormal`/`HighContrastPointerOver`/`HighContrastPressed`)
|
||||
swaps the text and glyph to the highlight-text brush. `SortStates`
|
||||
(`Unsorted`/`SortAscending`/`SortDescending`) only show or hide the sort glyph. `MinHeight` and
|
||||
`Padding` follow `Density` unless the applied header style sets them (an implicit header style's
|
||||
setters for those two are overridden by the density values).
|
||||
|
||||
**Rich styling (later):** delivered as **per-element `Style` dependency properties**, matching WPF
|
||||
`DataGrid` (`RowStyle` / `CellStyle` / `ColumnHeaderStyle`) — **not** a monolithic `TableStyle`.
|
||||
`ColumnHeaderStyle` has shipped (above); `RowStyle` and `CellStyle` follow the same shape.
|
||||
`RowStyle` targets the realized `TableViewRow`; `CellStyle` the per-cell container (the cell-wrapper
|
||||
`Border`; cell *content* is customized via the column's template / `GenerateElementCore`, matching
|
||||
WPF where `CellStyle` targets `DataGridCell`, not the content); `ColumnHeaderStyle` the generated
|
||||
header cell. They form a cohesive set and land together, since shipping one alone would leave an
|
||||
asymmetric styling surface. Header restyling beyond the `TabularSurfaceHeader*` keys (for example
|
||||
replacing the pointer-over/pressed visuals) needs a public templated header type and
|
||||
`ColumnHeaderStyle`, both of which require API review; today's header cell is an internal `Grid`.
|
||||
header. The header went first because its pointer, sort and typography visuals were the ones
|
||||
built in code; the public `TableViewColumnHeader` type and `ColumnHeaderStyle` are preview API
|
||||
pending API review.
|
||||
|
||||
**Why per-element Styles + theme resources, not a monolithic `TableStyle`:** per-element `Style`s
|
||||
compose with the standard WinUI `Style`/`Setter`/`ControlTemplate` machinery and scale to any
|
||||
@@ -485,7 +521,7 @@ Editing bindings use `UpdateSourceTrigger=Explicit`, so the typed value lives in
|
||||
|
||||
A forced close (the source changes under the edit, or the control turns read-only) commits a valid pending value while the edited item is still in the source and passes the filter, and reports `Cancel` on a failed write or validation error. Because the pending value is still in the editor, a forced close first transfers it to the item, then validates; a value that fails validation is rolled back to the pre-edit value, so the item never keeps a value the control rejected. Inside a layout pass (a recycled or rebuilt row) restoring the editor would re-enter layout, so that rollback is posted to the dispatcher, and it is skipped if the item's value has changed in the meantime — another edit of the same cell is open, or the property no longer holds the rejected value — so a newer write is never overwritten. If the item was removed, filtered out, or `ItemsSource` was replaced, it reports `Cancel` and writes nothing, as WPF `DataGrid` does; an item that is only under a collapsed group or node has not been removed. A forced close during a pending commit deferral can finish as `Cancel` without a second `CellEditEnding`.
|
||||
|
||||
Rollback prefers `ITableViewEditableItem` on the data item. Without it the control snapshots the value at the column's `GetEditingPropertyPathCore()` path, falling back to scraping the editor's bindings — inherently incomplete for an arbitrary editor, so the inability to capture anything is recorded and reported rather than silently ignored.
|
||||
There is no item-owned transaction interface in this release (`ITableViewEditableItem` is deferred with row editing). The control snapshots the value at the edited property path, falling back to scraping the editor's bindings — inherently incomplete for an arbitrary editor, so the inability to capture anything is recorded and reported rather than silently ignored.
|
||||
|
||||
## Re-entrancy rules (load-bearing)
|
||||
|
||||
@@ -498,9 +534,9 @@ Row recycling, cell rebuild and dependency-property change callbacks can all run
|
||||
|
||||
## Key internal components
|
||||
|
||||
- **`PART_HeaderScroller`** — horizontal-only header scroller synchronized with the body.
|
||||
- **`PART_HeaderScroller`** — horizontal-only header scroller synchronized with the body. Found as `PART_HeaderHost`'s nearest `ScrollViewer` ancestor; the name is for readability only.
|
||||
- **`PART_HeaderHost`** — `TableViewCellsPanel` header host rebuilt from the non-null columns.
|
||||
- **`PART_BodyScroller`** — body scroll presenter and source of horizontal offset.
|
||||
- **`PART_BodyScroller`** — body scroll presenter and source of horizontal offset. Found as `PART_RowsRepeater`'s nearest `ScrollViewer` ancestor; the name is for readability only.
|
||||
- **`PART_RowsRepeater`** — vertical row realization surface.
|
||||
- **`PART_CellsHost`** — row-local `TableViewCellsPanel` cell host.
|
||||
- **`TableViewCellsPanel`** — the custom `Panel` used for `PART_HeaderHost` and each row's `PART_CellsHost`; its `MeasureOverride` reports each cell's measured width and its `ArrangeOverride` places cells at the column's resolved `ActualWidth` (see *Column alignment*).
|
||||
|
||||
@@ -99,9 +99,9 @@ the source publishes, so the same engine serves an app that never touches `Table
|
||||
- Opt-in cell editing (`IsReadOnly = false` on the control; per-column `IsReadOnly`). — **cell editing**
|
||||
- Editors supplied by the column: `TableViewTextColumn` produces a `TextBox`; `TableViewTemplateColumn` uses `CellEditingTemplate`. — **cell editing**
|
||||
- Gestures: double-click / double-tap and `F2` begin an edit; `Enter` commits; `Esc` cancels; moving focus off the cell commits. — **cell editing**
|
||||
- Programmatic API: `BeginEdit`, `CommitEdit`, `CancelEdit`, `IsEditing`, `CurrentItem`/`CurrentColumn`/`SetCurrentCell`. — **cell editing**
|
||||
- Vetoable lifecycle events `BeginningEdit`, `CellEditEnding`, `RowEditEnding`, plus `EditEnded`; the two `*EditEnding` args expose `GetDeferral()` so a handler can validate or save asynchronously without blocking the UI thread. — **cell editing**
|
||||
- Rollback: `ITableViewEditableItem` on the data item when the app wants to own the transaction; otherwise the control snapshots the edited value and restores it on cancel. — **cell editing**
|
||||
- Programmatic API: `CommitEdit()` and `CancelEdit()` close the open cell edit and return `false` when it stays open (veto or failed validation); read-only `IsEditing` reports whether a cell editor is open. Editing **starts only from user gestures**: there is no public `BeginEdit` and no public current-cell surface (`CurrentItem` / `CurrentColumn` / `SetCurrentCell`) in this release. — **cell editing** · programmatic begin + current cell *deferred* (land together when a scenario such as a context menu or view-model command needs them)
|
||||
- Vetoable lifecycle events `BeginningEdit` and `CellEditEnding`. Both are synchronous: a handler sets `Cancel` before it returns, and there is no `GetDeferral()`. `CellEditEnding` reports the outcome through `EditAction` and is raised for every close, so there is no separate post-close event. — **cell editing** · `RowEditEnding`, `EditEnded` and async deferrals *deferred*
|
||||
- Rollback: the built-in text editor uses `UpdateSourceTrigger=Explicit`, so a cancelled edit never reaches the data item; application editing templates must buffer the same way. — **cell editing** · item-owned transactions (`ITableViewEditableItem`, the WinRT counterpart of `IEditableObject`) *deferred* with row editing
|
||||
- Validation: `INotifyDataErrorInfo` on the data item, scoped to the property the column edits, blocks a commit. — **cell editing**
|
||||
- Multi-cell row transactions (`CancelEdit(Row)` rolling back siblings already committed). — *deferred*
|
||||
- Editing a11y announcements (live-region/UIA notification on begin/commit/cancel). — *deferred, needs localized strings*
|
||||
@@ -112,7 +112,7 @@ the source publishes, so the same engine serves an app that never touches `Table
|
||||
- Column virtualization intentionally omitted (typical ~5–50 columns).
|
||||
|
||||
### Styling & theming
|
||||
- Light / Dark / High Contrast theme tokens + inline fallbacks (`DividerStrokeColorDefault` gridlines). — **PR2** (full self-themed Theme-XBF emission deferred)
|
||||
- Light / Dark / High Contrast theme tokens + inline fallbacks (`DividerStrokeColorDefault` gridlines, `CardStrokeColorDefault` outer border). — **PR2** (full self-themed Theme-XBF emission deferred)
|
||||
- Cell-level styling: custom cells via `TableViewTemplateColumn` + built-in text-cell defaults (left, vertically centered). A public per-column alignment/weight API — **deferred (PR3+)**.
|
||||
|
||||
### Tooltips
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validate a Hyper-V test VM and cache its credential for the VM test tools, WITHOUT the password
|
||||
ever passing through a chat or agent session.
|
||||
|
||||
.DESCRIPTION
|
||||
Shared by tools\run-tests-on-vm.ps1 users and the repo skills that run tests on a VM (for
|
||||
example .github\skills\code-coverage-calculation).
|
||||
* Fails unless the Hyper-V VM exists and is Running.
|
||||
* Prompts for the password in a SEPARATE console window (username pre-filled), the same way
|
||||
tools\run-tests-on-vm.ps1 does, and writes the DPAPI-encrypted cache
|
||||
%USERPROFILE%\.winui-test\vmcred-<VM>.xml - the cache that run-tests-on-vm.ps1 reads.
|
||||
* Validates the login over PowerShell Direct and that the user has an Active desktop session
|
||||
(UI tests need one).
|
||||
* A newly entered credential replaces the existing cache only after ALL of these checks pass;
|
||||
otherwise the previous cache is left untouched.
|
||||
|
||||
.EXAMPLE
|
||||
.\tools\Set-VmCredential.ps1 -VMName MyTestVM -UserName TestUser
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$VMName,
|
||||
# Plain account name exactly as qwinsta shows it on the VM (no ".\" or "VM\" prefix): the
|
||||
# active-session check matches the session row by this name.
|
||||
[Parameter(Mandatory)][string]$UserName,
|
||||
# Re-prompt even if a cached credential for this VM/user exists.
|
||||
[switch]$Force
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($UserName -match '\\') { throw "Use the plain account name without a domain/machine prefix (got '$UserName')." }
|
||||
$vm = Get-VM -Name $VMName -ErrorAction SilentlyContinue
|
||||
if (-not $vm) { throw "Hyper-V VM '$VMName' not found (or this account is not in Hyper-V Administrators)." }
|
||||
if ($vm.State -ne 'Running') { throw "VM '$VMName' is $($vm.State). Start it and log on to its desktop first." }
|
||||
|
||||
$credDir = Join-Path $env:USERPROFILE '.winui-test'
|
||||
New-Item -ItemType Directory -Force $credDir | Out-Null
|
||||
$credFile = Join-Path $credDir "vmcred-$($VMName -replace '[^a-zA-Z0-9]','_').xml"
|
||||
$newFile = "$credFile.new"
|
||||
Remove-Item $newFile -ErrorAction SilentlyContinue
|
||||
|
||||
$cached = if (Test-Path $credFile) { Import-Clixml $credFile } else { $null }
|
||||
if ($Force -or -not $cached -or $cached.UserName -ne $UserName) {
|
||||
$helper = Join-Path $credDir 'prompt-coverage-cred.ps1'
|
||||
@'
|
||||
param([string]$VMName, [string]$UserName, [string]$CredFile)
|
||||
Write-Host "Password for '$UserName' on VM '$VMName'" -ForegroundColor Cyan
|
||||
$p = Read-Host 'Password' -AsSecureString
|
||||
[pscredential]::new($UserName, $p) | Export-Clixml $CredFile
|
||||
'@ | Set-Content $helper -Encoding UTF8
|
||||
Write-Host "A password prompt opened in a new window..." -ForegroundColor Cyan
|
||||
Start-Process powershell -Wait -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $helper,
|
||||
'-VMName', $VMName, '-UserName', $UserName, '-CredFile', $newFile
|
||||
Remove-Item $helper -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path $newFile)) { throw 'No password was entered; cannot continue.' }
|
||||
$cred = Import-Clixml $newFile
|
||||
} else {
|
||||
$cred = $cached
|
||||
}
|
||||
|
||||
try {
|
||||
$s = New-PSSession -VMName $VMName -Credential $cred
|
||||
} catch {
|
||||
Remove-Item $newFile -ErrorAction SilentlyContinue
|
||||
throw "Could not log on to '$VMName' as '$UserName': $($_.Exception.Message)"
|
||||
}
|
||||
try {
|
||||
$row = Invoke-Command -Session $s -ScriptBlock {
|
||||
param($u) (qwinsta 2>$null) | Where-Object { $_ -match "\s$([regex]::Escape($u))\s" } | Select-Object -First 1
|
||||
} -ArgumentList $UserName
|
||||
} finally { Remove-PSSession $s }
|
||||
|
||||
if ("$row" -notmatch '\sActive') {
|
||||
# Keep the previous cache: a valid password for an account that is not logged on to the
|
||||
# desktop must not replace a working credential.
|
||||
Remove-Item $newFile -ErrorAction SilentlyContinue
|
||||
throw "'$UserName' has no Active desktop session on '$VMName' (session: '$("$row".Trim())'). Log on to the VM desktop as that user, then retry."
|
||||
}
|
||||
|
||||
if (Test-Path $newFile) { Move-Item $newFile $credFile -Force }
|
||||
[pscustomobject]@{
|
||||
VMName = $VMName; UserName = $UserName; CredentialCache = $credFile
|
||||
LogonOk = $true; DesktopSession = "$row".Trim(); DesktopActive = $true
|
||||
}
|
||||
Reference in new issue
Block a user