diff --git a/.github/workflows/perf-gate.yml b/.github/workflows/perf-gate.yml
new file mode 100644
index 0000000000..12d677dc95
--- /dev/null
+++ b/.github/workflows/perf-gate.yml
@@ -0,0 +1,212 @@
+name: Scrolling Performance Gate
+
+on:
+ push:
+ branches: [ main, develop ]
+ paths-ignore:
+ - '**.md'
+ pull_request:
+ branches: [ main, develop ]
+ paths-ignore:
+ - '**.md'
+
+# Only run on Linux to keep results comparable across runs.
+# Windows/macOS times vary too much to use as a performance baseline.
+permissions:
+ contents: read
+
+jobs:
+ perf-smoke-tests:
+ name: Performance Smoke Tests (Linux)
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ env:
+ DisableRealDriverIO: "1"
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # GitVersion needs full history
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.x
+ dotnet-quality: 'ga'
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build (Release)
+ run: dotnet build --configuration Release --no-restore -property:NoWarn=0618%3B0612
+
+ - name: Build Tests (Debug โ smoke tests run in Debug to match CI unit tests)
+ run: dotnet build Tests/PerformanceTests --no-restore -property:NoWarn=0618%3B0612
+
+ - name: Run performance smoke tests (Layer 1 gate)
+ id: smoke_tests
+ run: |
+ dotnet test \
+ --project Tests/PerformanceTests \
+ --no-build \
+ --verbosity normal
+
+ - name: Upload smoke test logs
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: perf-smoke-test-logs
+ path: |
+ TestResults/
+ if-no-files-found: ignore
+ retention-days: 7
+
+ perf-benchmarks:
+ name: Scrolling Benchmarks (Linux, ShortRun)
+ runs-on: ubuntu-latest
+ # Only run on pushes to develop/main, not on every PR (slow and not blocking).
+ if: github.event_name == 'push'
+ timeout-minutes: 30
+ env:
+ DisableRealDriverIO: "1"
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.x
+ dotnet-quality: 'ga'
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build Release
+ run: dotnet build --configuration Release --no-restore -property:NoWarn=0618%3B0612
+
+ - name: Run scrolling benchmarks (ShortRun โ 30โ60 s)
+ id: run_benchmarks
+ run: |
+ dotnet run \
+ --project Tests/Benchmarks \
+ --configuration Release \
+ --no-build \
+ -- \
+ --filter '*Scroll*' \
+ --job short \
+ --exporters json \
+ --artifacts ./BenchmarkResults
+ continue-on-error: true # Don't block the workflow; comparison step decides outcome
+
+ - name: Compare results to baseline
+ id: compare
+ run: |
+ python3 - << 'PYEOF'
+ import json, os, sys, glob
+
+ REGRESSION_FACTOR = 3.0 # Fail if any benchmark is >3ร baseline
+ IMPROVEMENT_FACTOR = 0.8 # Celebrate ๐ if any benchmark drops below 0.8ร baseline
+
+ baseline_path = "Tests/Benchmarks/baseline.json"
+ results_dir = "BenchmarkResults"
+
+ # --- Load baseline ---
+ try:
+ with open(baseline_path) as f:
+ baseline_data = json.load(f)
+ baseline = {
+ f"{b['type']}/{b['method']}/{b['params']}": b["meanNs"]
+ for b in baseline_data["benchmarks"]
+ }
+ except FileNotFoundError:
+ print("::warning::baseline.json not found โ skipping comparison")
+ sys.exit(0)
+
+ # --- Find BenchmarkDotNet JSON results ---
+ result_files = glob.glob(f"{results_dir}/**/*.json", recursive=True)
+ result_files = [f for f in result_files if "results" in f.lower() or "report" in f.lower()]
+ if not result_files:
+ print("::warning::No BenchmarkDotNet result files found โ skipping comparison")
+ sys.exit(0)
+
+ # --- Parse results ---
+ results = {}
+ for fpath in result_files:
+ try:
+ with open(fpath) as f:
+ data = json.load(f)
+ for bm in data.get("Benchmarks", []):
+ key = f"{bm['Type']}/{bm['Method']}/{bm.get('Parameters', '')}"
+ results[key] = bm.get("Statistics", {}).get("Mean", None)
+ except Exception as e:
+ print(f"::warning::Could not parse {fpath}: {e}")
+
+ # --- Build comparison table ---
+ rows = []
+ regressions = []
+ improvements = []
+
+ for key, base_ns in baseline.items():
+ if base_ns <= 0:
+ continue
+ cur_ns = results.get(key)
+ if cur_ns is None:
+ rows.append(f"| {key} | {base_ns/1000:.1f} ยตs | โ (not measured) | โ |")
+ continue
+
+ ratio = cur_ns / base_ns
+ emoji = "โ
"
+ if ratio >= REGRESSION_FACTOR:
+ emoji = "โ"
+ regressions.append((key, base_ns, cur_ns, ratio))
+ elif ratio <= IMPROVEMENT_FACTOR:
+ emoji = "๐"
+ improvements.append((key, base_ns, cur_ns, ratio))
+ rows.append(
+ f"| {key} | {base_ns/1000:.1f} ยตs | {cur_ns/1000:.1f} ยตs | {ratio:.2f}ร {emoji} |"
+ )
+
+ # --- Write step summary ---
+ summary = "## ๐ Scrolling Benchmark Comparison\n\n"
+ summary += "| Benchmark | Baseline | Current | Ratio |\n"
+ summary += "|-----------|----------|---------|-------|\n"
+ summary += "\n".join(rows) + "\n\n"
+
+ if improvements:
+ summary += "### ๐ Performance Improvements\n"
+ for k, b, c, r in improvements:
+ summary += f"- **{k}**: {b/1000:.1f} ยตs โ {c/1000:.1f} ยตs ({r:.2f}ร)\n"
+ summary += "\n"
+
+ if regressions:
+ summary += "### โ Regressions Detected\n"
+ for k, b, c, r in regressions:
+ summary += f"- **{k}**: {b/1000:.1f} ยตs โ {c/1000:.1f} ยตs ({r:.2f}ร) โ exceeds {REGRESSION_FACTOR}ร threshold\n"
+ summary += "\n"
+
+ with open(os.environ.get("GITHUB_STEP_SUMMARY", "/dev/null"), "a") as f:
+ f.write(summary)
+
+ print(summary)
+
+ if regressions:
+ print(f"::error::Performance regressions detected: {len(regressions)} benchmark(s) exceeded {REGRESSION_FACTOR}ร baseline")
+ sys.exit(1)
+
+ if improvements:
+ print(f"Performance improvements detected: {len(improvements)} benchmark(s) improved!")
+ PYEOF
+
+ - name: Upload benchmark results
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: benchmark-results-${{ github.sha }}
+ path: BenchmarkResults/
+ if-no-files-found: ignore
+ retention-days: 30
diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj
index cf510856ff..09ce0f7645 100644
--- a/Terminal.Gui/Terminal.Gui.csproj
+++ b/Terminal.Gui/Terminal.Gui.csproj
@@ -93,6 +93,7 @@
+
diff --git a/Terminal.sln b/Terminal.sln
index 4c9bc79d78..de4c68a853 100644
--- a/Terminal.sln
+++ b/Terminal.sln
@@ -156,6 +156,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InlineSelect", "Examples\In
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.Analyzers.Internal", "Terminal.Gui.Analyzers.Internal\Terminal.Gui.Analyzers.Internal.csproj", "{927CCC07-F00C-409C-BE42-458EB03DD4E8}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceTests", "Tests\PerformanceTests\PerformanceTests.csproj", "{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -454,6 +456,18 @@ Global
{927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x64.Build.0 = Release|Any CPU
{927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x86.ActiveCfg = Release|Any CPU
{927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x86.Build.0 = Release|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x64.Build.0 = Debug|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x86.Build.0 = Debug|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x64.ActiveCfg = Release|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x64.Build.0 = Release|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.ActiveCfg = Release|Any CPU
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -484,6 +498,7 @@ Global
{90A42AE4-301D-4B05-8892-60BE5209C1B5} = {3DD033C0-E023-47BF-A808-9CCE30873C3E}
{70802F77-F259-44C6-9522-46FCE2FD754E} = {3DD033C0-E023-47BF-A808-9CCE30873C3E}
{3116547F-A8F2-4189-BC22-0B47C757164C} = {3DD033C0-E023-47BF-A808-9CCE30873C3E}
+ {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2} = {A589126F-C71A-4FEE-B7EA-2DCA1ADF6A46}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {9F8F8A4D-7B8D-4C2A-AC5E-CD7117F74C03}
diff --git a/Tests/Benchmarks/README.md b/Tests/Benchmarks/README.md
index 334ec2582b..f08f70d27f 100644
--- a/Tests/Benchmarks/README.md
+++ b/Tests/Benchmarks/README.md
@@ -47,6 +47,9 @@ dotnet run -c Release
# Run only DimAuto benchmarks
dotnet run -c Release -- --filter '*DimAuto*'
+# Run only Scrolling benchmarks
+dotnet run -c Release -- --filter '*Scroll*'
+
# Run only TextFormatter benchmarks
dotnet run -c Release -- --filter '*TextFormatter*'
```
@@ -56,6 +59,9 @@ dotnet run -c Release -- --filter '*TextFormatter*'
```bash
# Run only the ComplexLayout benchmark
dotnet run -c Release -- --filter '*DimAutoBenchmark.ComplexLayout*'
+
+# Run only TextView scrolling benchmarks
+dotnet run -c Release -- --filter '*TextViewScroll*'
```
### Quick Run (Shorter but Less Accurate)
@@ -63,7 +69,7 @@ dotnet run -c Release -- --filter '*DimAutoBenchmark.ComplexLayout*'
For faster iteration during development:
```bash
-dotnet run -c Release -- --filter '*DimAuto*' -j short
+dotnet run -c Release -- --filter '*Scroll*' -j short
```
### List Available Benchmarks
@@ -80,12 +86,52 @@ The `DimAutoBenchmark` class tests layout performance with `Dim.Auto()` in vario
- **ComplexLayout**: 20 subviews with mixed Pos/Dim types (tests iteration overhead)
- **DeeplyNestedLayout**: 5 levels of nested views with DimAuto (tests recursive performance)
+## Scrolling Benchmarks
+
+The `Scrolling/` directory contains end-to-end scrolling benchmarks that cover the full input โ layout โ draw pipeline.
+
+### BaselineScrollBenchmark
+
+Minimal `View` subclass with a large `ContentSize` and no rendering logic. Isolates framework scrolling overhead from any view-specific work.
+
+- **ViewportScroll_Down / Up**: Direct viewport manipulation (no key injection). Measures pure framework overhead.
+- **ViewportScroll_PageDown**: Viewport-sized jump.
+- Parameterized by `ContentHeight` = [1 000, 10 000]
+
+### TextViewScrollBenchmark
+
+`TextView` with read-only content of 1 000 / 5 000 lines of ~80-char text.
+
+- **ScrollDown_OneStep / ScrollUp_OneStep**: Single `Key.CursorDown` / `Key.CursorUp` injection. With the caret at the viewport boundary, every keystroke triggers a viewport scroll.
+- **PageDown_OneStep**: Single `Key.PageDown` injection.
+- Parameterized by `Lines` = [1 000, 5 000]
+
+### ListViewScrollBenchmark
+
+`ListView` with 1 000 / 10 000 string items.
+
+- **ScrollDown_OneStep / ScrollUp_OneStep / PageDown_OneStep**
+- Parameterized by `Items` = [1 000, 10 000]
+
+### TableViewScrollBenchmark
+
+`TableView` with 100 / 1 000 rows ร 10 columns.
+
+- **ScrollDown_OneStep / ScrollUp_OneStep / PageDown_OneStep / ScrollRight_OneStep**
+- Parameterized by `Rows` = [100, 1 000]
+
+### Run all scrolling benchmarks
+
+```bash
+dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*'
+```
+
## Adding New Benchmarks
-1. Create a new class in an appropriate subdirectory (e.g., `Layout/`, `Text/`, `ViewBase/`)
+1. Create a new class in an appropriate subdirectory (e.g., `Layout/`, `Text/`, `ViewBase/`, `Scrolling/`)
2. For BenchmarkDotNet: add `[MemoryDiagnoser]`, `[BenchmarkCategory]`, `[Benchmark(Baseline = true)]`
3. For memory profilers: add a `public static void Run()` method and route it from `Program.cs`
-4. Use `[GlobalSetup]`/`[GlobalCleanup]` for `Application.Init`/`Shutdown`
+4. Use `[GlobalSetup]`/`[GlobalCleanup]` for application init/dispose
## Best Practices
@@ -96,10 +142,37 @@ The `DimAutoBenchmark` class tests layout performance with `Dim.Auto()` in vario
## Continuous Integration
-Benchmarks are not run automatically in CI. Run them locally when:
-- Making performance-critical changes
-- Implementing performance optimizations
-- Before releasing a new version
+### Layer 1: Performance Smoke Tests
+
+Stopwatch-based xUnit tests in `Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs` run on every CI build via the standard unit test workflow. They use generous thresholds (50โ100ร typical) to catch catastrophic O(nยฒ) regressions without flaking on slow runners.
+
+Each test:
+- Creates a large document (10 000 rows / 100 000 items)
+- Measures the cost of a **single viewport draw** after scrolling to the mid-point
+- Asserts completion under a generous threshold (e.g., < 200 ms for TableView)
+
+This detects if a draw function accidentally iterates the entire document instead of just the visible viewport.
+
+### Layer 2: Baseline Comparison
+
+The `.github/workflows/perf-gate.yml` workflow runs on every push to `main` / `develop` (not PRs) and:
+
+1. Runs the `*Scroll*` benchmarks with `--job short` (~30โ60 s total)
+2. Compares results to `Tests/Benchmarks/baseline.json`
+3. **Fails** if any benchmark exceeds **3ร** the baseline
+4. **Celebrates** ๐ if any benchmark drops below **0.8ร** the baseline
+5. Posts a markdown comparison table to the GitHub step summary
+
+### Updating the Baseline
+
+After a deliberate performance change, re-run the focused scrolling benchmarks, then update `baseline.json`:
+
+```bash
+# Run ShortRun and export JSON results
+dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*' -j short --exporters json
+
+# Inspect the JSON output in BenchmarkDotNet.Artifacts/ and update baseline.json
+```
## Resources
diff --git a/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs
new file mode 100644
index 0000000000..1cfe1cea72
--- /dev/null
+++ b/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs
@@ -0,0 +1,113 @@
+using System.Drawing;
+using BenchmarkDotNet.Attributes;
+using Terminal.Gui.App;
+using Terminal.Gui.Drivers;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+
+namespace Terminal.Gui.Benchmarks.Scrolling;
+
+///
+/// Measures framework-level scrolling overhead using a minimal subclass that has a large
+/// but performs no custom rendering. Isolates the viewport-math, layout, and
+/// draw-dispatch costs from any view-specific rendering work.
+///
+///
+///
+/// Run:
+/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*BaselineScroll*'
+///
+///
+[MemoryDiagnoser]
+[BenchmarkCategory ("Scrolling", "Baseline")]
+public class BaselineScrollBenchmark
+{
+ private const int SCREEN_HEIGHT = 25;
+ private const int SCREEN_WIDTH = 80;
+
+ private IApplication _app = null!;
+ private Runnable _runnable = null!;
+ private SessionToken? _session;
+ private View _view = null!;
+
+ /// Disposes the application after all iterations.
+ [GlobalCleanup]
+ public void Cleanup ()
+ {
+ if (_session is { })
+ {
+ _app.End (_session);
+ }
+
+ _app.Dispose ();
+ }
+
+ /// Total virtual content height of the view (rows).
+ [Params (1_000, 10_000)]
+ public int ContentHeight { get; set; }
+
+ ///
+ /// Positions the viewport at the mid-point of the document so that each benchmark iteration
+ /// scrolls from a stable, representative location.
+ ///
+ [IterationSetup]
+ public void IterationSetup ()
+ {
+ _view.Viewport = _view.Viewport with { Y = ContentHeight / 2 };
+ _view.SetNeedsDraw ();
+ }
+
+ /// Creates the application context, view hierarchy, and performs one warm-up draw.
+ [GlobalSetup]
+ public void Setup ()
+ {
+ _app = Application.Create ();
+ _app.Init (DriverRegistry.Names.ANSI);
+ _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT);
+
+ _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT };
+ _session = _app.Begin (_runnable);
+
+ _view = new View
+ {
+ X = 0,
+ Y = 0,
+ Width = SCREEN_WIDTH,
+ Height = SCREEN_HEIGHT,
+ ViewportSettings = ViewportSettingsFlags.HasVerticalScrollBar
+ };
+ _view.SetContentSize (new Size (SCREEN_WIDTH, ContentHeight));
+ _runnable.Add (_view);
+
+ // Warm up: prime JIT and layout caches before measurement.
+ _app.LayoutAndDraw (true);
+ }
+
+ ///
+ /// Scrolls the viewport down by one row and redraws.
+ /// Measures the minimal per-row cost of the layout+draw pipeline with no content rendering.
+ ///
+ [Benchmark (Baseline = true)]
+ public void ViewportScroll_Down ()
+ {
+ _view.Viewport = _view.Viewport with { Y = _view.Viewport.Y + 1 };
+ _app.LayoutAndDraw ();
+ }
+
+ /// Scrolls the viewport down by one page (ScreenHeight rows) and redraws.
+ [Benchmark]
+ public void ViewportScroll_PageDown ()
+ {
+ int newY = Math.Min (_view.Viewport.Y + SCREEN_HEIGHT, ContentHeight - SCREEN_HEIGHT);
+ _view.Viewport = _view.Viewport with { Y = newY };
+ _app.LayoutAndDraw ();
+ }
+
+ /// Scrolls the viewport up by one row and redraws.
+ [Benchmark]
+ public void ViewportScroll_Up ()
+ {
+ _view.Viewport = _view.Viewport with { Y = _view.Viewport.Y - 1 };
+ _app.LayoutAndDraw ();
+ }
+}
diff --git a/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs
new file mode 100644
index 0000000000..0c93bc896b
--- /dev/null
+++ b/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs
@@ -0,0 +1,128 @@
+using System.Collections.ObjectModel;
+using BenchmarkDotNet.Attributes;
+using Terminal.Gui.App;
+using Terminal.Gui.Drivers;
+using Terminal.Gui.Input;
+using Terminal.Gui.Testing;
+using Terminal.Gui.Views;
+
+namespace Terminal.Gui.Benchmarks.Scrolling;
+
+///
+/// Measures end-to-end scrolling performance for .
+/// Covers item rendering with mark glyphs, selection role highlighting,
+/// and per-item draw cost at varying collection sizes.
+///
+///
+///
+/// Run:
+/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*ListViewScroll*'
+///
+///
+[MemoryDiagnoser]
+[BenchmarkCategory ("Scrolling", "ListView")]
+public class ListViewScrollBenchmark
+{
+ private const int SCREEN_HEIGHT = 25;
+ private const int SCREEN_WIDTH = 80;
+
+ private IApplication _app = null!;
+ private IInputInjector _injector = null!;
+ private ListView _listView = null!;
+ private Runnable _runnable = null!;
+ private SessionToken? _session;
+
+ /// Disposes the application after all iterations.
+ [GlobalCleanup]
+ public void Cleanup ()
+ {
+ if (_session is { })
+ {
+ _app.End (_session);
+ }
+
+ _app.Dispose ();
+ }
+
+ /// Total number of items loaded into the .
+ [Params (1_000, 10_000)]
+ public int Items { get; set; }
+
+ ///
+ /// Positions the selection at the last visible row so the next down-arrow triggers a scroll.
+ ///
+ [IterationSetup]
+ public void IterationSetup ()
+ {
+ _listView.SelectedItem = SCREEN_HEIGHT - 1;
+ _listView.SetNeedsDraw ();
+ }
+
+ ///
+ /// Injects a single keystroke and redraws.
+ ///
+ [Benchmark]
+ public void PageDown_OneStep ()
+ {
+ _injector.InjectKey (Key.PageDown);
+ _app.LayoutAndDraw ();
+ }
+
+ ///
+ /// Injects a single keystroke and redraws.
+ /// With the selection at the viewport boundary this always scrolls.
+ ///
+ [Benchmark (Baseline = true)]
+ public void ScrollDown_OneStep ()
+ {
+ _injector.InjectKey (Key.CursorDown);
+ _app.LayoutAndDraw ();
+ }
+
+ ///
+ /// Injects a single keystroke and redraws.
+ ///
+ [Benchmark]
+ public void ScrollUp_OneStep ()
+ {
+ _injector.InjectKey (Key.CursorUp);
+ _app.LayoutAndDraw ();
+ }
+
+ /// Creates the application, populates the list, and warms up the view.
+ [GlobalSetup]
+ public void Setup ()
+ {
+ _app = Application.Create ();
+ _app.Init (DriverRegistry.Names.ANSI);
+ _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT);
+
+ _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT };
+ _session = _app.Begin (_runnable);
+
+ _listView = new ListView { X = 0, Y = 0, Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT };
+ _listView.SetSource (new ObservableCollection (BuildItems (Items)));
+ _runnable.Add (_listView);
+
+ // Focus so key bindings resolve.
+ _listView.SetFocus ();
+
+ // Cache injector to avoid per-call lookup overhead.
+ _injector = _app.GetInputInjector ();
+
+ // Warm up.
+ _app.LayoutAndDraw (true);
+ }
+
+ private static List BuildItems (int count)
+ {
+ List items = new (count);
+
+ for (var itemIndex = 0; itemIndex < count; itemIndex++)
+ {
+ items.Add ($"Item {itemIndex,6}: data value = {itemIndex * 17 % 100:D3}");
+ }
+
+ return items;
+ }
+}
diff --git a/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs
new file mode 100644
index 0000000000..ce239bff07
--- /dev/null
+++ b/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs
@@ -0,0 +1,156 @@
+using System.Data;
+using System.Drawing;
+using BenchmarkDotNet.Attributes;
+using Terminal.Gui.App;
+using Terminal.Gui.Drivers;
+using Terminal.Gui.Input;
+using Terminal.Gui.Testing;
+using Terminal.Gui.Views;
+
+namespace Terminal.Gui.Benchmarks.Scrolling;
+
+///
+/// Measures end-to-end scrolling performance for .
+/// Covers cell rendering, column alignment, and header drawing at varying row counts.
+///
+///
+///
+/// Run:
+/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*TableViewScroll*'
+///
+///
+[MemoryDiagnoser]
+[BenchmarkCategory ("Scrolling", "TableView")]
+public class TableViewScrollBenchmark
+{
+ private const int COLUMN_COUNT = 10;
+ private const int SCREEN_HEIGHT = 25;
+ private const int SCREEN_WIDTH = 120;
+
+ private IApplication _app = null!;
+ private IInputInjector _injector = null!;
+ private Runnable _runnable = null!;
+ private SessionToken? _session;
+ private TableView _tableView = null!;
+
+ /// Disposes the application after all iterations.
+ [GlobalCleanup]
+ public void Cleanup ()
+ {
+ if (_session is { })
+ {
+ _app.End (_session);
+ }
+
+ _app.Dispose ();
+ }
+
+ ///
+ /// Positions the selected row at the last visible data row so the next down-arrow scrolls.
+ ///
+ [IterationSetup]
+ public void IterationSetup ()
+ {
+ // TableView reserves row 0 for the header; data rows start at display row 1.
+ // Place selection at the last visible data row.
+ int visibleDataRows = SCREEN_HEIGHT - 1; // subtract header row
+ _tableView.RowOffset = 0;
+ _tableView.Value = new TableSelection (new Point (0, Math.Min (visibleDataRows - 1, Rows - 1)));
+ _tableView.SetNeedsDraw ();
+ }
+
+ ///
+ /// Injects a single keystroke and redraws.
+ ///
+ [Benchmark]
+ public void PageDown_OneStep ()
+ {
+ _injector.InjectKey (Key.PageDown);
+ _app.LayoutAndDraw ();
+ }
+
+ /// Total number of data rows loaded into the .
+ [Params (100, 1_000)]
+ public int Rows { get; set; }
+
+ ///
+ /// Injects a single keystroke and redraws.
+ /// With the selection at the viewport boundary this triggers a row scroll.
+ ///
+ [Benchmark (Baseline = true)]
+ public void ScrollDown_OneStep ()
+ {
+ _injector.InjectKey (Key.CursorDown);
+ _app.LayoutAndDraw ();
+ }
+
+ ///
+ /// Injects a single keystroke and redraws.
+ /// Measures horizontal scrolling / column navigation.
+ ///
+ [Benchmark]
+ public void ScrollRight_OneStep ()
+ {
+ _injector.InjectKey (Key.CursorRight);
+ _app.LayoutAndDraw ();
+ }
+
+ ///
+ /// Injects a single keystroke and redraws.
+ ///
+ [Benchmark]
+ public void ScrollUp_OneStep ()
+ {
+ _injector.InjectKey (Key.CursorUp);
+ _app.LayoutAndDraw ();
+ }
+
+ /// Creates the application, builds the data table, and warms up the view.
+ [GlobalSetup]
+ public void Setup ()
+ {
+ _app = Application.Create ();
+ _app.Init (DriverRegistry.Names.ANSI);
+ _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT);
+
+ _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT };
+ _session = _app.Begin (_runnable);
+
+ DataTable dt = BuildDataTable (Rows, COLUMN_COUNT);
+ _tableView = new TableView (new DataTableSource (dt)) { X = 0, Y = 0, Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT };
+ _runnable.Add (_tableView);
+
+ // Focus so key bindings resolve.
+ _tableView.SetFocus ();
+
+ // Cache injector to avoid per-call lookup overhead.
+ _injector = _app.GetInputInjector ();
+
+ // Warm up.
+ _app.LayoutAndDraw (true);
+ }
+
+ private static DataTable BuildDataTable (int rows, int cols)
+ {
+ DataTable dt = new ();
+
+ for (var colIndex = 0; colIndex < cols; colIndex++)
+ {
+ dt.Columns.Add ($"Col{colIndex,2}", typeof (string));
+ }
+
+ for (var rowIndex = 0; rowIndex < rows; rowIndex++)
+ {
+ var row = new object [cols];
+
+ for (var colIndex = 0; colIndex < cols; colIndex++)
+ {
+ row [colIndex] = $"R{rowIndex}C{colIndex}";
+ }
+
+ dt.Rows.Add (row);
+ }
+
+ return dt;
+ }
+}
diff --git a/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs
new file mode 100644
index 0000000000..a476def1b6
--- /dev/null
+++ b/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs
@@ -0,0 +1,144 @@
+using System.Drawing;
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using Terminal.Gui.App;
+using Terminal.Gui.Drivers;
+using Terminal.Gui.Input;
+using Terminal.Gui.Testing;
+using Terminal.Gui.Views;
+
+namespace Terminal.Gui.Benchmarks.Scrolling;
+
+///
+/// Measures end-to-end scrolling performance for .
+/// Covers the most complex rendering path in Terminal.Gui: line tracking, word-wrap decisions,
+/// tab expansion, selection, and caret movement.
+///
+///
+///
+/// Run:
+/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*TextViewScroll*'
+///
+///
+[MemoryDiagnoser]
+[BenchmarkCategory ("Scrolling", "TextView")]
+public class TextViewScrollBenchmark
+{
+ private const int SCREEN_HEIGHT = 25;
+ private const int SCREEN_WIDTH = 80;
+
+ private IApplication _app = null!;
+ private IInputInjector _injector = null!;
+ private Runnable _runnable = null!;
+ private SessionToken? _session;
+ private TextView _textView = null!;
+
+ /// Disposes the application after all iterations.
+ [GlobalCleanup]
+ public void Cleanup ()
+ {
+ if (_session is { })
+ {
+ _app.End (_session);
+ }
+
+ _app.Dispose ();
+ }
+
+ ///
+ /// Resets the caret to the last line in the first visible page so the next
+ /// call triggers a viewport scroll.
+ ///
+ [IterationSetup]
+ public void IterationSetup ()
+ {
+ // Place caret at bottom-right of the visible viewport so CursorDown scrolls.
+ _textView.InsertionPoint = new Point (0, SCREEN_HEIGHT - 1);
+ _textView.SetNeedsDraw ();
+ }
+
+ /// Total number of lines loaded into the .
+ [Params (1_000, 5_000)]
+ public int Lines { get; set; }
+
+ ///
+ /// Injects a single keystroke and redraws.
+ /// Each iteration rebuilds a full page โ measures viewport-sized jump cost.
+ ///
+ [Benchmark]
+ public void PageDown_OneStep ()
+ {
+ _injector.InjectKey (Key.PageDown);
+ _app.LayoutAndDraw ();
+ }
+
+ ///
+ /// Injects a single keystroke and redraws.
+ /// With the caret at the bottom edge of the viewport this always triggers a viewport scroll.
+ ///
+ [Benchmark (Baseline = true)]
+ public void ScrollDown_OneStep ()
+ {
+ _injector.InjectKey (Key.CursorDown);
+ _app.LayoutAndDraw ();
+ }
+
+ ///
+ /// Injects a single keystroke and redraws.
+ /// Symmetric reverse-scroll measurement.
+ ///
+ [Benchmark]
+ public void ScrollUp_OneStep ()
+ {
+ _injector.InjectKey (Key.CursorUp);
+ _app.LayoutAndDraw ();
+ }
+
+ /// Creates the application, builds the document, and warms up the view.
+ [GlobalSetup]
+ public void Setup ()
+ {
+ _app = Application.Create ();
+ _app.Init (DriverRegistry.Names.ANSI);
+ _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT);
+
+ _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT };
+ _session = _app.Begin (_runnable);
+
+ string text = BuildText (Lines);
+
+ _textView = new TextView
+ {
+ X = 0,
+ Y = 0,
+ Width = SCREEN_WIDTH,
+ Height = SCREEN_HEIGHT,
+ Text = text,
+ WordWrap = false,
+ ReadOnly = true
+ };
+ _runnable.Add (_textView);
+
+ // Focus the text view so key bindings resolve to it.
+ _textView.SetFocus ();
+
+ // Cache injector to avoid per-call lookup overhead.
+ _injector = _app.GetInputInjector ();
+
+ // Warm up: prime JIT and layout caches.
+ _app.LayoutAndDraw (true);
+ }
+
+ private static string BuildText (int lineCount)
+ {
+ StringBuilder sb = new (lineCount * 85);
+
+ for (var lineIndex = 0; lineIndex < lineCount; lineIndex++)
+ {
+ // ~80-character lines matching the issue specification.
+ sb.AppendLine ($"Line {lineIndex,6}: The quick brown fox jumps over the lazy dog. Extra padding {lineIndex % 10}.");
+ }
+
+ return sb.ToString ();
+ }
+}
diff --git a/Tests/Benchmarks/baseline.json b/Tests/Benchmarks/baseline.json
new file mode 100644
index 0000000000..63a907f4e5
--- /dev/null
+++ b/Tests/Benchmarks/baseline.json
@@ -0,0 +1,147 @@
+{
+ "_comment": "Baseline benchmark results for the scrolling performance gate.",
+ "_howto": "Re-run 'dotnet run --project Tests/Benchmarks -c Release -- --filter \"*Scroll*\" -j short --exporters json' then update this file.",
+ "_version": "1",
+ "benchmarks": [
+ {
+ "type": "BaselineScrollBenchmark",
+ "method": "ViewportScroll_Down",
+ "params": "ContentHeight=1000",
+ "meanNs": 150000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "BaselineScrollBenchmark",
+ "method": "ViewportScroll_Down",
+ "params": "ContentHeight=10000",
+ "meanNs": 150000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "BaselineScrollBenchmark",
+ "method": "ViewportScroll_Up",
+ "params": "ContentHeight=1000",
+ "meanNs": 150000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "BaselineScrollBenchmark",
+ "method": "ViewportScroll_Up",
+ "params": "ContentHeight=10000",
+ "meanNs": 150000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "BaselineScrollBenchmark",
+ "method": "ViewportScroll_PageDown",
+ "params": "ContentHeight=1000",
+ "meanNs": 150000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "BaselineScrollBenchmark",
+ "method": "ViewportScroll_PageDown",
+ "params": "ContentHeight=10000",
+ "meanNs": 150000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "TextViewScrollBenchmark",
+ "method": "ScrollDown_OneStep",
+ "params": "Lines=1000",
+ "meanNs": 500000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "TextViewScrollBenchmark",
+ "method": "ScrollDown_OneStep",
+ "params": "Lines=5000",
+ "meanNs": 500000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "TextViewScrollBenchmark",
+ "method": "ScrollUp_OneStep",
+ "params": "Lines=1000",
+ "meanNs": 500000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "TextViewScrollBenchmark",
+ "method": "ScrollUp_OneStep",
+ "params": "Lines=5000",
+ "meanNs": 500000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "TextViewScrollBenchmark",
+ "method": "PageDown_OneStep",
+ "params": "Lines=1000",
+ "meanNs": 500000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "TextViewScrollBenchmark",
+ "method": "PageDown_OneStep",
+ "params": "Lines=5000",
+ "meanNs": 500000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "ListViewScrollBenchmark",
+ "method": "ScrollDown_OneStep",
+ "params": "Items=1000",
+ "meanNs": 200000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "ListViewScrollBenchmark",
+ "method": "ScrollDown_OneStep",
+ "params": "Items=10000",
+ "meanNs": 200000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "ListViewScrollBenchmark",
+ "method": "PageDown_OneStep",
+ "params": "Items=1000",
+ "meanNs": 200000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "ListViewScrollBenchmark",
+ "method": "PageDown_OneStep",
+ "params": "Items=10000",
+ "meanNs": 200000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "TableViewScrollBenchmark",
+ "method": "ScrollDown_OneStep",
+ "params": "Rows=100",
+ "meanNs": 300000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "TableViewScrollBenchmark",
+ "method": "ScrollDown_OneStep",
+ "params": "Rows=1000",
+ "meanNs": 300000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "TableViewScrollBenchmark",
+ "method": "PageDown_OneStep",
+ "params": "Rows=100",
+ "meanNs": 300000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ },
+ {
+ "type": "TableViewScrollBenchmark",
+ "method": "PageDown_OneStep",
+ "params": "Rows=1000",
+ "meanNs": 300000,
+ "comment": "Placeholder โ run actual benchmarks to set real baseline"
+ }
+ ]
+}
diff --git a/Tests/IntegrationTests/FluentTests/MenuBarTests.cs b/Tests/IntegrationTests/FluentTests/MenuBarTests.cs
index b68aa9ad4c..56a8fbd4e8 100644
--- a/Tests/IntegrationTests/FluentTests/MenuBarTests.cs
+++ b/Tests/IntegrationTests/FluentTests/MenuBarTests.cs
@@ -1093,9 +1093,12 @@ public void PopoverMenu_Overlapping_PaddingView_DrawingContent_Is_Not_Bled_Throu
{
menuOpen = menuBar!.IsOpen ();
- if (a.Popovers!.GetActivePopover () is View popover)
+ if (a.Popovers!.GetActivePopover () is PopoverMenu popoverMenu)
{
- popoverScreen = popover.FrameToScreen ();
+ // Use the actual Menu content view's frame (not the full-screen
+ // transparent wrapper) so the overlap is the region where the
+ // menu popup genuinely draws its content.
+ popoverScreen = popoverMenu.ContentView?.FrameToScreen () ?? Rectangle.Empty;
}
padScreen = subView!.Padding.GetOrCreateView ().FrameToScreen ();
diff --git a/Tests/PerformanceTests/AssemblyInfo.cs b/Tests/PerformanceTests/AssemblyInfo.cs
new file mode 100644
index 0000000000..13ce8a0a0c
--- /dev/null
+++ b/Tests/PerformanceTests/AssemblyInfo.cs
@@ -0,0 +1,7 @@
+global using Terminal.Gui.App;
+global using Terminal.Gui.Drivers;
+global using Terminal.Gui.Input;
+global using Terminal.Gui.ViewBase;
+global using Terminal.Gui.Views;
+global using Terminal.Gui.Drawing;
+global using UnitTests;
diff --git a/Tests/PerformanceTests/PerformanceTests.csproj b/Tests/PerformanceTests/PerformanceTests.csproj
new file mode 100644
index 0000000000..6513b94358
--- /dev/null
+++ b/Tests/PerformanceTests/PerformanceTests.csproj
@@ -0,0 +1,55 @@
+
+
+
+ PerformanceTests
+ Exe
+ true
+ true
+ false
+ enable
+ enable
+ true
+ true
+ portable
+ $(DefineConstants);JETBRAINS_ANNOTATIONS;CONTRACTS_FULL
+ true
+ $(NoWarn);AD0001
+ true
+
+
+
+ true
+ $(DefineConstants);DEBUG_IDISPOSABLE
+
+
+
+ true
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/PerformanceTests/ScrollingPerformanceTests.cs b/Tests/PerformanceTests/ScrollingPerformanceTests.cs
new file mode 100644
index 0000000000..b6c0bc5c5e
--- /dev/null
+++ b/Tests/PerformanceTests/ScrollingPerformanceTests.cs
@@ -0,0 +1,305 @@
+// Copilot
+
+using System.Collections.ObjectModel;
+using System.Data;
+using System.Diagnostics;
+using System.Text;
+
+namespace PerformanceTests;
+
+///
+/// Stopwatch-based smoke tests that catch catastrophic rendering regressions.
+/// Thresholds are intentionally generous (~50โ100ร typical) so these never flake on slow CI runners
+/// but still catch accidental O(nยฒ) regressions where a single viewport draw inadvertently
+/// iterates over the entire document instead of just the visible rows.
+///
+///
+/// Each test measures the cost of a SINGLE viewport draw on a large document so that an
+/// O(document-size) regression is immediately detectable without needing a full scroll loop.
+/// These are Layer 1 of the two-layer CI performance gate.
+/// Layer 2 (BenchmarkDotNet baseline comparison) lives in Tests/Benchmarks/.
+/// This project runs exclusively on Linux via the perf-gate CI workflow.
+///
+[Trait ("Category", "Performance")]
+public class ScrollingPerformanceTests : TestDriverBase
+{
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // ListView smoke tests
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ ///
+ /// Builds a 100 000-item and renders a single viewport.
+ /// Asserts layout + one full draw completes under a generous threshold.
+ ///
+ [Fact]
+ public void ListView_LayoutAndDraw_100K_Items_UnderThreshold ()
+ {
+ const int ITEM_COUNT = 100_000;
+ const int SCREEN_WIDTH = 80;
+ const int SCREEN_HEIGHT = 30;
+
+ IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT);
+
+ ListView listView = new ()
+ {
+ X = 0,
+ Y = 0,
+ Width = SCREEN_WIDTH,
+ Height = SCREEN_HEIGHT,
+ Driver = driver
+ };
+ listView.SetSource (new ObservableCollection (BuildListItems (ITEM_COUNT)));
+ listView.BeginInit ();
+ listView.EndInit ();
+ listView.Layout ();
+
+ // Warm up.
+ listView.SetNeedsDraw ();
+ listView.Draw ();
+
+ var sw = Stopwatch.StartNew ();
+ listView.SetNeedsDraw ();
+ listView.Draw ();
+ sw.Stop ();
+
+ // 300 ms is ~150ร what a single viewport draw takes on a typical machine.
+ Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (300),
+ $"ListView layout+draw ({ITEM_COUNT} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 300 ms");
+ }
+
+ ///
+ /// Scrolls a 100 000-item to the mid-point and measures the cost of
+ /// a single redraw. Detects O(total-items) regressions in the per-draw path.
+ ///
+ [Fact]
+ public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold ()
+ {
+ const int ITEM_COUNT = 100_000;
+ const int SCREEN_WIDTH = 80;
+ const int SCREEN_HEIGHT = 30;
+
+ IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT);
+
+ ListView listView = new ()
+ {
+ X = 0,
+ Y = 0,
+ Width = SCREEN_WIDTH,
+ Height = SCREEN_HEIGHT,
+ Driver = driver
+ };
+ listView.SetSource (new ObservableCollection (BuildListItems (ITEM_COUNT)));
+ listView.BeginInit ();
+ listView.EndInit ();
+ listView.Layout ();
+
+ // Warm up.
+ listView.SetNeedsDraw ();
+ listView.Draw ();
+
+ // Scroll to the mid-point of the list.
+ listView.Viewport = listView.Viewport with { Y = ITEM_COUNT / 2 };
+
+ var sw = Stopwatch.StartNew ();
+ listView.SetNeedsDraw ();
+ listView.Draw ();
+ sw.Stop ();
+
+ // 300 ms threshold. A genuine O(100 000) regression would scan all items and take >> 1 s.
+ Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (300),
+ $"ListView mid-doc viewport draw ({ITEM_COUNT} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 300 ms");
+ }
+
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // TableView smoke tests
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ ///
+ /// Builds a 10 000-row and renders a single viewport.
+ /// Asserts layout + one full draw completes under a generous threshold.
+ ///
+ [Fact]
+ public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold ()
+ {
+ const int ROW_COUNT = 10_000;
+ const int COL_COUNT = 10;
+ const int SCREEN_WIDTH = 120;
+ const int SCREEN_HEIGHT = 30;
+
+ IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT);
+
+ TableView tableView = new (new DataTableSource (BuildDataTable (ROW_COUNT, COL_COUNT)))
+ {
+ X = 0,
+ Y = 0,
+ Width = SCREEN_WIDTH,
+ Height = SCREEN_HEIGHT,
+ Driver = driver
+ };
+ tableView.BeginInit ();
+ tableView.EndInit ();
+ tableView.Layout ();
+
+ // Warm up.
+ tableView.SetNeedsDraw ();
+ tableView.Draw ();
+
+ var sw = Stopwatch.StartNew ();
+ tableView.SetNeedsDraw ();
+ tableView.Draw ();
+ sw.Stop ();
+
+ // 200 ms is ~50ร what a single viewport draw takes on a typical machine.
+ Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (200),
+ $"TableView layout+draw ({ROW_COUNT} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms");
+ }
+
+ ///
+ /// Scrolls a to the mid-point of a 10 000-row table and measures
+ /// the cost of a single redraw from that offset.
+ /// Detects O(total-rows) regressions in the per-draw path.
+ ///
+ [Fact]
+ public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold ()
+ {
+ const int ROW_COUNT = 10_000;
+ const int COL_COUNT = 10;
+ const int SCREEN_WIDTH = 120;
+ const int SCREEN_HEIGHT = 30;
+
+ IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT);
+
+ TableView tableView = new (new DataTableSource (BuildDataTable (ROW_COUNT, COL_COUNT)))
+ {
+ X = 0,
+ Y = 0,
+ Width = SCREEN_WIDTH,
+ Height = SCREEN_HEIGHT,
+ Driver = driver
+ };
+ tableView.BeginInit ();
+ tableView.EndInit ();
+ tableView.Layout ();
+
+ // Warm up.
+ tableView.SetNeedsDraw ();
+ tableView.Draw ();
+
+ // Scroll to mid-document.
+ tableView.RowOffset = ROW_COUNT / 2;
+
+ var sw = Stopwatch.StartNew ();
+ tableView.SetNeedsDraw ();
+ tableView.Draw ();
+ sw.Stop ();
+
+ // 200 ms threshold โ an O(total-rows) regression would scan 10 000 rows.
+ Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (200),
+ $"TableView single viewport draw at mid ({ROW_COUNT} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms");
+ }
+
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // TextView smoke tests
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ ///
+ /// Builds a 1 000-line and measures the cost of a single viewport
+ /// draw after scrolling to the middle of the document.
+ /// An O(document-size) regression in the rendering path would exceed the threshold even
+ /// on a slow CI runner.
+ ///
+ [Fact]
+ public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold ()
+ {
+ const int THRESHOLD_MS = 1000;
+ const int LINE_COUNT = 1_000;
+ const int SCREEN_WIDTH = 80;
+ const int SCREEN_HEIGHT = 25;
+
+ IDriver driver = CreateTestDriver ();
+
+ TextView tv = new ()
+ {
+ X = 0,
+ Y = 0,
+ Width = SCREEN_WIDTH,
+ Height = SCREEN_HEIGHT,
+ Text = BuildTextViewContent (LINE_COUNT),
+ ReadOnly = true,
+ WordWrap = false,
+ Driver = driver
+ };
+ tv.BeginInit ();
+ tv.EndInit ();
+ tv.Layout ();
+
+ // Warm up: prime JIT and layout caches.
+ tv.SetNeedsDraw ();
+ tv.Draw ();
+
+ // Scroll to the middle of the document.
+ tv.Viewport = tv.Viewport with { Y = LINE_COUNT / 2 };
+
+ var sw = Stopwatch.StartNew ();
+ tv.SetNeedsDraw ();
+ tv.Draw ();
+ sw.Stop ();
+
+ // 1000 ms is generous even in debug/slow-CI mode. An O(lineCount) regression
+ // scanning 1 000 lines in the draw path would take at least 5โ10ร longer.
+ Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (THRESHOLD_MS),
+ $"TextView single viewport draw ({LINE_COUNT} lines, mid-doc) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < {THRESHOLD_MS}ms");
+ }
+
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // Helpers
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ private static DataTable BuildDataTable (int rows, int cols)
+ {
+ DataTable dt = new ();
+
+ for (var colIndex = 0; colIndex < cols; colIndex++)
+ {
+ dt.Columns.Add ($"Col{colIndex}", typeof (string));
+ }
+
+ for (var rowIndex = 0; rowIndex < rows; rowIndex++)
+ {
+ var row = new object [cols];
+
+ for (var colIndex = 0; colIndex < cols; colIndex++)
+ {
+ row [colIndex] = $"R{rowIndex}C{colIndex}";
+ }
+
+ dt.Rows.Add (row);
+ }
+
+ return dt;
+ }
+
+ private static List BuildListItems (int count)
+ {
+ List items = new (count);
+
+ for (var itemIndex = 0; itemIndex < count; itemIndex++)
+ {
+ items.Add ($"Item {itemIndex,6}: value = {itemIndex * 17 % 100:D3}");
+ }
+
+ return items;
+ }
+
+ private static string BuildTextViewContent (int lineCount)
+ {
+ StringBuilder sb = new (lineCount * 85);
+
+ for (var lineIndex = 0; lineIndex < lineCount; lineIndex++)
+ {
+ sb.AppendLine ($"Line {lineIndex,6}: The quick brown fox jumps over the lazy dog. Extra padding {lineIndex % 10}.");
+ }
+
+ return sb.ToString ();
+ }
+}
diff --git a/Tests/PerformanceTests/xunit.runner.json b/Tests/PerformanceTests/xunit.runner.json
new file mode 100644
index 0000000000..608c553c4e
--- /dev/null
+++ b/Tests/PerformanceTests/xunit.runner.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
+ "parallelizeAssembly": false,
+ "parallelizeTestCollections": false,
+ "stopOnFail": false
+}
diff --git a/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs b/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs
index 74f0a2fc5d..d84c1fe29f 100644
--- a/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs
+++ b/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs
@@ -7,6 +7,7 @@ namespace ApplicationTests.Keyboard;
///
/// Tests for static property.
///
+[Collection ("Application Tests")]
public class ApplicationDefaultKeyBindingsTests
{
[Fact]