From 37cf68661d99543c379029013a242f273902ec1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 13:55:15 +0000 Subject: [PATCH 01/12] Initial plan From 144ef9e481e6f3d280724cd12cf34a962f2c0dc5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 14:33:39 +0000 Subject: [PATCH 02/12] Add scrolling benchmarks, performance smoke tests, and CI performance gate Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/3b443135-7f15-4c33-8e7e-15547a017689 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .github/workflows/perf-gate.yml | 213 ++++++++++++ Tests/Benchmarks/README.md | 87 ++++- .../Scrolling/BaselineScrollBenchmark.cs | 112 +++++++ .../Scrolling/ListViewScrollBenchmark.cs | 135 ++++++++ .../Scrolling/TableViewScrollBenchmark.cs | 163 ++++++++++ .../Scrolling/TextViewScrollBenchmark.cs | 142 ++++++++ Tests/Benchmarks/baseline.json | 147 +++++++++ .../Views/ScrollingPerformanceTests.cs | 305 ++++++++++++++++++ 8 files changed, 1297 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/perf-gate.yml create mode 100644 Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs create mode 100644 Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs create mode 100644 Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs create mode 100644 Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs create mode 100644 Tests/Benchmarks/baseline.json create mode 100644 Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs diff --git a/.github/workflows/perf-gate.yml b/.github/workflows/perf-gate.yml new file mode 100644 index 0000000000..aa22f2758b --- /dev/null +++ b/.github/workflows/perf-gate.yml @@ -0,0 +1,213 @@ +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/UnitTestsParallelizable --no-restore -property:NoWarn=0618%3B0612 + + - name: Run performance smoke tests (Layer 1 gate) + id: smoke_tests + run: | + dotnet test \ + --project Tests/UnitTestsParallelizable \ + --no-build \ + --filter-class "*ScrollingPerformanceTests" \ + --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/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..717ea62011 --- /dev/null +++ b/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs @@ -0,0 +1,112 @@ +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 ScreenWidth = 80; + private const int ScreenHeight = 25; + + private IApplication _app = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + private View _view = null!; + + /// Total virtual content height of the view (rows). + [Params (1_000, 10_000)] + public int ContentHeight { get; set; } + + /// 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 (ScreenWidth, ScreenHeight); + + _runnable = new () { Width = ScreenWidth, Height = ScreenHeight }; + _session = _app.Begin (_runnable); + + _view = new () + { + X = 0, + Y = 0, + Width = ScreenWidth, + Height = ScreenHeight, + ViewportSettings = ViewportSettingsFlags.HasVerticalScrollBar + }; + _view.SetContentSize (new (ScreenWidth, ContentHeight)); + _runnable.Add (_view); + + // Warm up: prime JIT and layout caches before measurement. + _app.LayoutAndDraw (true); + } + + /// + /// 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 (); + } + + /// + /// 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 up by one row and redraws. + [Benchmark] + public void ViewportScroll_Up () + { + _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 + ScreenHeight, ContentHeight - ScreenHeight); + _view.Viewport = _view.Viewport with { Y = newY }; + _app.LayoutAndDraw (); + } + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is not null) + { + _app.End (_session); + } + + _app.Dispose (); + } +} diff --git a/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs new file mode 100644 index 0000000000..0034331649 --- /dev/null +++ b/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs @@ -0,0 +1,135 @@ +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.ViewBase; +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 ScreenWidth = 80; + private const int ScreenHeight = 25; + + private IApplication _app = null!; + private IInputInjector _injector = null!; + private ListView _listView = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + + /// Total number of items loaded into the . + [Params (1_000, 10_000)] + public int Items { get; set; } + + /// 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 (ScreenWidth, ScreenHeight); + + _runnable = new () { Width = ScreenWidth, Height = ScreenHeight }; + _session = _app.Begin (_runnable); + + _listView = new () + { + X = 0, + Y = 0, + Width = ScreenWidth, + Height = ScreenHeight + }; + _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); + } + + /// + /// Positions the selection at the last visible row so the next down-arrow triggers a scroll. + /// + [IterationSetup] + public void IterationSetup () + { + _listView.SelectedItem = ScreenHeight - 1; + _listView.SetNeedsDraw (); + } + + /// + /// 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 (); + } + + /// + /// Injects a single keystroke and redraws. + /// + [Benchmark] + public void PageDown_OneStep () + { + _injector.InjectKey (Key.PageDown); + _app.LayoutAndDraw (); + } + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is not null) + { + _app.End (_session); + } + + _app.Dispose (); + } + + 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..7b7d65325a --- /dev/null +++ b/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs @@ -0,0 +1,163 @@ +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.ViewBase; +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 ColumnCount = 10; + private const int ScreenWidth = 120; + private const int ScreenHeight = 25; + + private IApplication _app = null!; + private IInputInjector _injector = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + private TableView _tableView = null!; + + /// Total number of data rows loaded into the . + [Params (100, 1_000)] + public int Rows { get; set; } + + /// 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 (ScreenWidth, ScreenHeight); + + _runnable = new () { Width = ScreenWidth, Height = ScreenHeight }; + _session = _app.Begin (_runnable); + + DataTable dt = BuildDataTable (Rows, ColumnCount); + _tableView = new (new DataTableSource (dt)) + { + X = 0, + Y = 0, + Width = ScreenWidth, + Height = ScreenHeight + }; + _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); + } + + /// + /// 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 = ScreenHeight - 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. + /// 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. + /// + [Benchmark] + public void ScrollUp_OneStep () + { + _injector.InjectKey (Key.CursorUp); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// + [Benchmark] + public void PageDown_OneStep () + { + _injector.InjectKey (Key.PageDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// Measures horizontal scrolling / column navigation. + /// + [Benchmark] + public void ScrollRight_OneStep () + { + _injector.InjectKey (Key.CursorRight); + _app.LayoutAndDraw (); + } + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is not null) + { + _app.End (_session); + } + + _app.Dispose (); + } + + 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++) + { + object [] 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..278991ac22 --- /dev/null +++ b/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs @@ -0,0 +1,142 @@ +using BenchmarkDotNet.Attributes; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Terminal.Gui.ViewBase; +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 ScreenWidth = 80; + private const int ScreenHeight = 25; + + private IApplication _app = null!; + private IInputInjector _injector = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + private TextView _textView = null!; + + /// Total number of lines loaded into the . + [Params (1_000, 5_000)] + public int Lines { get; set; } + + /// 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 (ScreenWidth, ScreenHeight); + + _runnable = new () { Width = ScreenWidth, Height = ScreenHeight }; + _session = _app.Begin (_runnable); + + string text = BuildText (Lines); + _textView = new () + { + X = 0, + Y = 0, + Width = ScreenWidth, + Height = ScreenHeight, + 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); + } + + /// + /// 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 (0, ScreenHeight - 1); + _textView.SetNeedsDraw (); + } + + /// + /// 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 (); + } + + /// + /// 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 (); + } + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is not null) + { + _app.End (_session); + } + + _app.Dispose (); + } + + private static string BuildText (int lineCount) + { + System.Text.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/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs new file mode 100644 index 0000000000..64ab4f1b9c --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs @@ -0,0 +1,305 @@ +// Copilot + +using System.Collections.ObjectModel; +using System.Data; +using System.Diagnostics; +using UnitTests; + +namespace ViewsTests; + +/// +/// 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 described in the GitHub issue. +/// Layer 2 (BenchmarkDotNet baseline comparison) lives in the perf-gate CI workflow. +/// +[Trait ("Category", "Performance")] +public class ScrollingPerformanceTests : TestDriverBase +{ + // ────────────────────────────────────────────────────────────────────────── + // 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 lineCount = 1_000; + const int screenWidth = 80; + const int screenHeight = 25; + + IDriver driver = CreateTestDriver (screenWidth, screenHeight); + TextView tv = new () + { + X = 0, + Y = 0, + Width = screenWidth, + Height = screenHeight, + Text = BuildTextViewContent (lineCount), + 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 = lineCount / 2 }; + + Stopwatch sw = Stopwatch.StartNew (); + tv.SetNeedsDraw (); + tv.Draw (); + sw.Stop (); + + // 500 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 (500), + $"TextView single viewport draw ({lineCount} lines, mid-doc) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 500 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 rowCount = 10_000; + const int colCount = 10; + const int screenWidth = 120; + const int screenHeight = 30; + + IDriver driver = CreateTestDriver (screenWidth, screenHeight); + TableView tableView = new (new DataTableSource (BuildDataTable (rowCount, colCount))) + { + X = 0, + Y = 0, + Width = screenWidth, + Height = screenHeight, + Driver = driver + }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.Layout (); + + // Warm up. + tableView.SetNeedsDraw (); + tableView.Draw (); + + Stopwatch 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 ({rowCount} 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 rowCount = 10_000; + const int colCount = 10; + const int screenWidth = 120; + const int screenHeight = 30; + + IDriver driver = CreateTestDriver (screenWidth, screenHeight); + TableView tableView = new (new DataTableSource (BuildDataTable (rowCount, colCount))) + { + X = 0, + Y = 0, + Width = screenWidth, + Height = screenHeight, + Driver = driver + }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.Layout (); + + // Warm up. + tableView.SetNeedsDraw (); + tableView.Draw (); + + // Scroll to mid-document. + tableView.RowOffset = rowCount / 2; + + Stopwatch 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 ({rowCount} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); + } + + // ────────────────────────────────────────────────────────────────────────── + // 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 itemCount = 100_000; + const int screenWidth = 80; + const int screenHeight = 30; + + IDriver driver = CreateTestDriver (screenWidth, screenHeight); + ListView listView = new () + { + X = 0, + Y = 0, + Width = screenWidth, + Height = screenHeight, + Driver = driver + }; + listView.SetSource (new ObservableCollection (BuildListItems (itemCount))); + listView.BeginInit (); + listView.EndInit (); + listView.Layout (); + + // Warm up. + listView.SetNeedsDraw (); + listView.Draw (); + + Stopwatch sw = Stopwatch.StartNew (); + listView.SetNeedsDraw (); + listView.Draw (); + sw.Stop (); + + // 100 ms is ~50Γ— what a single viewport draw takes on a typical machine. + Assert.True ( + sw.Elapsed < TimeSpan.FromMilliseconds (100), + $"ListView layout+draw ({itemCount} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 100 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 itemCount = 100_000; + const int screenWidth = 80; + const int screenHeight = 30; + + IDriver driver = CreateTestDriver (screenWidth, screenHeight); + ListView listView = new () + { + X = 0, + Y = 0, + Width = screenWidth, + Height = screenHeight, + Driver = driver + }; + listView.SetSource (new ObservableCollection (BuildListItems (itemCount))); + 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 = itemCount / 2 }; + + Stopwatch sw = Stopwatch.StartNew (); + listView.SetNeedsDraw (); + listView.Draw (); + sw.Stop (); + + // 100 ms threshold β€” if ListView iterates all 100 000 items per draw, this will fail. + Assert.True ( + sw.Elapsed < TimeSpan.FromMilliseconds (100), + $"ListView mid-doc viewport draw ({itemCount} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 100 ms"); + } + + // ────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────── + + private static string BuildTextViewContent (int lineCount) + { + System.Text.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 (); + } + + 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++) + { + object [] 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; + } +} + + From e646c046057ee83a123a573c3f0b1d65e59ce790 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 11 May 2026 09:38:24 -0600 Subject: [PATCH 03/12] Refactor scrolling benchmarks and tests for code style Refactored all scrolling benchmark and performance test classes to follow project code style: - Adopted uppercase constants for screen dimensions. - Reordered and grouped setup/cleanup and Params for clarity. - Used explicit types and target-typed new() per guidelines. - Moved PageDown_OneStep benchmarks for logical grouping. - Added new ListView performance tests for large datasets. - Relocated helper methods and updated var usage for built-in types. No functional changes; all updates are for style, structure, and maintainability. --- .../Scrolling/BaselineScrollBenchmark.cs | 75 +++--- .../Scrolling/ListViewScrollBenchmark.cs | 95 ++++---- .../Scrolling/TableViewScrollBenchmark.cs | 113 ++++----- .../Scrolling/TextViewScrollBenchmark.cs | 112 ++++----- .../Views/ScrollingPerformanceTests.cs | 229 +++++++++--------- 5 files changed, 306 insertions(+), 318 deletions(-) diff --git a/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs index 717ea62011..1cfe1cea72 100644 --- a/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs +++ b/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs @@ -1,3 +1,4 @@ +using System.Drawing; using BenchmarkDotNet.Attributes; using Terminal.Gui.App; using Terminal.Gui.Drivers; @@ -21,55 +22,67 @@ namespace Terminal.Gui.Benchmarks.Scrolling; [BenchmarkCategory ("Scrolling", "Baseline")] public class BaselineScrollBenchmark { - private const int ScreenWidth = 80; - private const int ScreenHeight = 25; + 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 (ScreenWidth, ScreenHeight); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); - _runnable = new () { Width = ScreenWidth, Height = ScreenHeight }; + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; _session = _app.Begin (_runnable); - _view = new () + _view = new View { X = 0, Y = 0, - Width = ScreenWidth, - Height = ScreenHeight, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, ViewportSettings = ViewportSettingsFlags.HasVerticalScrollBar }; - _view.SetContentSize (new (ScreenWidth, ContentHeight)); + _view.SetContentSize (new Size (SCREEN_WIDTH, ContentHeight)); _runnable.Add (_view); // Warm up: prime JIT and layout caches before measurement. _app.LayoutAndDraw (true); } - /// - /// 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 (); - } - /// /// Scrolls the viewport down by one row and redraws. /// Measures the minimal per-row cost of the layout+draw pipeline with no content rendering. @@ -81,32 +94,20 @@ public void ViewportScroll_Down () _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 (); - } - /// Scrolls the viewport down by one page (ScreenHeight rows) and redraws. [Benchmark] public void ViewportScroll_PageDown () { - int newY = Math.Min (_view.Viewport.Y + ScreenHeight, ContentHeight - ScreenHeight); + int newY = Math.Min (_view.Viewport.Y + SCREEN_HEIGHT, ContentHeight - SCREEN_HEIGHT); _view.Viewport = _view.Viewport with { Y = newY }; _app.LayoutAndDraw (); } - /// Disposes the application after all iterations. - [GlobalCleanup] - public void Cleanup () + /// Scrolls the viewport up by one row and redraws. + [Benchmark] + public void ViewportScroll_Up () { - if (_session is not null) - { - _app.End (_session); - } - - _app.Dispose (); + _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 index 0034331649..0c93bc896b 100644 --- a/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs +++ b/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs @@ -4,7 +4,6 @@ using Terminal.Gui.Drivers; using Terminal.Gui.Input; using Terminal.Gui.Testing; -using Terminal.Gui.ViewBase; using Terminal.Gui.Views; namespace Terminal.Gui.Benchmarks.Scrolling; @@ -24,8 +23,8 @@ namespace Terminal.Gui.Benchmarks.Scrolling; [BenchmarkCategory ("Scrolling", "ListView")] public class ListViewScrollBenchmark { - private const int ScreenWidth = 80; - private const int ScreenHeight = 25; + private const int SCREEN_HEIGHT = 25; + private const int SCREEN_WIDTH = 80; private IApplication _app = null!; private IInputInjector _injector = null!; @@ -33,51 +32,42 @@ public class ListViewScrollBenchmark private Runnable _runnable = null!; private SessionToken? _session; - /// Total number of items loaded into the . - [Params (1_000, 10_000)] - public int Items { get; set; } - - /// Creates the application, populates the list, and warms up the view. - [GlobalSetup] - public void Setup () + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () { - _app = Application.Create (); - _app.Init (DriverRegistry.Names.ANSI); - _app.Driver!.SetScreenSize (ScreenWidth, ScreenHeight); - - _runnable = new () { Width = ScreenWidth, Height = ScreenHeight }; - _session = _app.Begin (_runnable); - - _listView = new () + if (_session is { }) { - X = 0, - Y = 0, - Width = ScreenWidth, - Height = ScreenHeight - }; - _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 (); + _app.End (_session); + } - // Warm up. - _app.LayoutAndDraw (true); + _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 = ScreenHeight - 1; + _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. @@ -99,26 +89,29 @@ public void ScrollUp_OneStep () _app.LayoutAndDraw (); } - /// - /// Injects a single keystroke and redraws. - /// - [Benchmark] - public void PageDown_OneStep () + /// Creates the application, populates the list, and warms up the view. + [GlobalSetup] + public void Setup () { - _injector.InjectKey (Key.PageDown); - _app.LayoutAndDraw (); - } + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); - /// Disposes the application after all iterations. - [GlobalCleanup] - public void Cleanup () - { - if (_session is not null) - { - _app.End (_session); - } + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _session = _app.Begin (_runnable); - _app.Dispose (); + _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) diff --git a/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs index 7b7d65325a..ce239bff07 100644 --- a/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs +++ b/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs @@ -5,7 +5,6 @@ using Terminal.Gui.Drivers; using Terminal.Gui.Input; using Terminal.Gui.Testing; -using Terminal.Gui.ViewBase; using Terminal.Gui.Views; namespace Terminal.Gui.Benchmarks.Scrolling; @@ -24,9 +23,9 @@ namespace Terminal.Gui.Benchmarks.Scrolling; [BenchmarkCategory ("Scrolling", "TableView")] public class TableViewScrollBenchmark { - private const int ColumnCount = 10; - private const int ScreenWidth = 120; - private const int ScreenHeight = 25; + 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!; @@ -34,39 +33,16 @@ public class TableViewScrollBenchmark private SessionToken? _session; private TableView _tableView = null!; - /// Total number of data rows loaded into the . - [Params (100, 1_000)] - public int Rows { get; set; } - - /// Creates the application, builds the data table, and warms up the view. - [GlobalSetup] - public void Setup () + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () { - _app = Application.Create (); - _app.Init (DriverRegistry.Names.ANSI); - _app.Driver!.SetScreenSize (ScreenWidth, ScreenHeight); - - _runnable = new () { Width = ScreenWidth, Height = ScreenHeight }; - _session = _app.Begin (_runnable); - - DataTable dt = BuildDataTable (Rows, ColumnCount); - _tableView = new (new DataTableSource (dt)) + if (_session is { }) { - X = 0, - Y = 0, - Width = ScreenWidth, - Height = ScreenHeight - }; - _runnable.Add (_tableView); - - // Focus so key bindings resolve. - _tableView.SetFocus (); - - // Cache injector to avoid per-call lookup overhead. - _injector = _app.GetInputInjector (); + _app.End (_session); + } - // Warm up. - _app.LayoutAndDraw (true); + _app.Dispose (); } /// @@ -77,64 +53,81 @@ 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 = ScreenHeight - 1; // subtract header 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. - /// With the selection at the viewport boundary this triggers a row scroll. + /// Injects a single keystroke and redraws. /// - [Benchmark (Baseline = true)] - public void ScrollDown_OneStep () + [Benchmark] + public void PageDown_OneStep () { - _injector.InjectKey (Key.CursorDown); + _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. + /// Injects a single keystroke and redraws. + /// With the selection at the viewport boundary this triggers a row scroll. /// - [Benchmark] - public void ScrollUp_OneStep () + [Benchmark (Baseline = true)] + public void ScrollDown_OneStep () { - _injector.InjectKey (Key.CursorUp); + _injector.InjectKey (Key.CursorDown); _app.LayoutAndDraw (); } /// - /// Injects a single keystroke and redraws. + /// Injects a single keystroke and redraws. + /// Measures horizontal scrolling / column navigation. /// [Benchmark] - public void PageDown_OneStep () + public void ScrollRight_OneStep () { - _injector.InjectKey (Key.PageDown); + _injector.InjectKey (Key.CursorRight); _app.LayoutAndDraw (); } /// - /// Injects a single keystroke and redraws. - /// Measures horizontal scrolling / column navigation. + /// Injects a single keystroke and redraws. /// [Benchmark] - public void ScrollRight_OneStep () + public void ScrollUp_OneStep () { - _injector.InjectKey (Key.CursorRight); + _injector.InjectKey (Key.CursorUp); _app.LayoutAndDraw (); } - /// Disposes the application after all iterations. - [GlobalCleanup] - public void Cleanup () + /// Creates the application, builds the data table, and warms up the view. + [GlobalSetup] + public void Setup () { - if (_session is not null) - { - _app.End (_session); - } + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); - _app.Dispose (); + _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) @@ -148,7 +141,7 @@ private static DataTable BuildDataTable (int rows, int cols) for (var rowIndex = 0; rowIndex < rows; rowIndex++) { - object [] row = new object [cols]; + var row = new object [cols]; for (var colIndex = 0; colIndex < cols; colIndex++) { diff --git a/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs index 278991ac22..a476def1b6 100644 --- a/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs +++ b/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs @@ -1,9 +1,10 @@ +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.ViewBase; using Terminal.Gui.Views; namespace Terminal.Gui.Benchmarks.Scrolling; @@ -23,8 +24,8 @@ namespace Terminal.Gui.Benchmarks.Scrolling; [BenchmarkCategory ("Scrolling", "TextView")] public class TextViewScrollBenchmark { - private const int ScreenWidth = 80; - private const int ScreenHeight = 25; + private const int SCREEN_HEIGHT = 25; + private const int SCREEN_WIDTH = 80; private IApplication _app = null!; private IInputInjector _injector = null!; @@ -32,42 +33,16 @@ public class TextViewScrollBenchmark private SessionToken? _session; private TextView _textView = null!; - /// Total number of lines loaded into the . - [Params (1_000, 5_000)] - public int Lines { get; set; } - - /// Creates the application, builds the document, and warms up the view. - [GlobalSetup] - public void Setup () + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () { - _app = Application.Create (); - _app.Init (DriverRegistry.Names.ANSI); - _app.Driver!.SetScreenSize (ScreenWidth, ScreenHeight); - - _runnable = new () { Width = ScreenWidth, Height = ScreenHeight }; - _session = _app.Begin (_runnable); - - string text = BuildText (Lines); - _textView = new () + if (_session is { }) { - X = 0, - Y = 0, - Width = ScreenWidth, - Height = ScreenHeight, - 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 (); + _app.End (_session); + } - // Warm up: prime JIT and layout caches. - _app.LayoutAndDraw (true); + _app.Dispose (); } /// @@ -78,10 +53,25 @@ public void Setup () public void IterationSetup () { // Place caret at bottom-right of the visible viewport so CursorDown scrolls. - _textView.InsertionPoint = new (0, ScreenHeight - 1); + _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. @@ -104,32 +94,44 @@ public void ScrollUp_OneStep () _app.LayoutAndDraw (); } - /// - /// Injects a single keystroke and redraws. - /// Each iteration rebuilds a full page β€” measures viewport-sized jump cost. - /// - [Benchmark] - public void PageDown_OneStep () + /// Creates the application, builds the document, and warms up the view. + [GlobalSetup] + public void Setup () { - _injector.InjectKey (Key.PageDown); - _app.LayoutAndDraw (); - } + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); - /// Disposes the application after all iterations. - [GlobalCleanup] - public void Cleanup () - { - if (_session is not null) + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _session = _app.Begin (_runnable); + + string text = BuildText (Lines); + + _textView = new TextView { - _app.End (_session); - } + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Text = text, + WordWrap = false, + ReadOnly = true + }; + _runnable.Add (_textView); - _app.Dispose (); + // 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) { - System.Text.StringBuilder sb = new (lineCount * 85); + StringBuilder sb = new (lineCount * 85); for (var lineIndex = 0; lineIndex < lineCount; lineIndex++) { diff --git a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs index 64ab4f1b9c..7d3c901cb1 100644 --- a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Data; using System.Diagnostics; +using System.Text; using UnitTests; namespace ViewsTests; @@ -23,55 +24,90 @@ namespace ViewsTests; public class ScrollingPerformanceTests : TestDriverBase { // ────────────────────────────────────────────────────────────────────────── - // TextView smoke tests + // ListView 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. + /// Builds a 100 000-item and renders a single viewport. + /// Asserts layout + one full draw completes under a generous threshold. /// [Fact] - public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () + public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () { - const int lineCount = 1_000; + const int itemCount = 100_000; const int screenWidth = 80; - const int screenHeight = 25; + const int screenHeight = 30; IDriver driver = CreateTestDriver (screenWidth, screenHeight); - TextView tv = new () + + ListView listView = new () { X = 0, Y = 0, Width = screenWidth, Height = screenHeight, - Text = BuildTextViewContent (lineCount), - ReadOnly = true, - WordWrap = false, Driver = driver }; - tv.BeginInit (); - tv.EndInit (); - tv.Layout (); + listView.SetSource (new ObservableCollection (BuildListItems (itemCount))); + listView.BeginInit (); + listView.EndInit (); + listView.Layout (); - // Warm up: prime JIT and layout caches. - tv.SetNeedsDraw (); - tv.Draw (); + // Warm up. + listView.SetNeedsDraw (); + listView.Draw (); - // Scroll to the middle of the document. - tv.Viewport = tv.Viewport with { Y = lineCount / 2 }; + var sw = Stopwatch.StartNew (); + listView.SetNeedsDraw (); + listView.Draw (); + sw.Stop (); - Stopwatch sw = Stopwatch.StartNew (); - tv.SetNeedsDraw (); - tv.Draw (); + // 100 ms is ~50Γ— what a single viewport draw takes on a typical machine. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (100), + $"ListView layout+draw ({itemCount} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 100 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 itemCount = 100_000; + const int screenWidth = 80; + const int screenHeight = 30; + + IDriver driver = CreateTestDriver (screenWidth, screenHeight); + + ListView listView = new () + { + X = 0, + Y = 0, + Width = screenWidth, + Height = screenHeight, + Driver = driver + }; + listView.SetSource (new ObservableCollection (BuildListItems (itemCount))); + 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 = itemCount / 2 }; + + var sw = Stopwatch.StartNew (); + listView.SetNeedsDraw (); + listView.Draw (); sw.Stop (); - // 500 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 (500), - $"TextView single viewport draw ({lineCount} lines, mid-doc) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 500 ms"); + // 100 ms threshold β€” if ListView iterates all 100 000 items per draw, this will fail. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (100), + $"ListView mid-doc viewport draw ({itemCount} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 100 ms"); } // ────────────────────────────────────────────────────────────────────────── @@ -91,6 +127,7 @@ public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () const int screenHeight = 30; IDriver driver = CreateTestDriver (screenWidth, screenHeight); + TableView tableView = new (new DataTableSource (BuildDataTable (rowCount, colCount))) { X = 0, @@ -107,14 +144,13 @@ public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () tableView.SetNeedsDraw (); tableView.Draw (); - Stopwatch sw = Stopwatch.StartNew (); + 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), + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (200), $"TableView layout+draw ({rowCount} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); } @@ -132,6 +168,7 @@ public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () const int screenHeight = 30; IDriver driver = CreateTestDriver (screenWidth, screenHeight); + TableView tableView = new (new DataTableSource (BuildDataTable (rowCount, colCount))) { X = 0, @@ -151,118 +188,66 @@ public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () // Scroll to mid-document. tableView.RowOffset = rowCount / 2; - Stopwatch sw = Stopwatch.StartNew (); + 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), + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (200), $"TableView single viewport draw at mid ({rowCount} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); } // ────────────────────────────────────────────────────────────────────────── - // ListView smoke tests + // TextView smoke tests // ────────────────────────────────────────────────────────────────────────── /// - /// Builds a 100 000-item and renders a single viewport. - /// Asserts layout + one full draw completes under a generous threshold. + /// 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 ListView_LayoutAndDraw_100K_Items_UnderThreshold () + public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () { - const int itemCount = 100_000; + const int lineCount = 1_000; const int screenWidth = 80; - const int screenHeight = 30; - - IDriver driver = CreateTestDriver (screenWidth, screenHeight); - ListView listView = new () - { - X = 0, - Y = 0, - Width = screenWidth, - Height = screenHeight, - Driver = driver - }; - listView.SetSource (new ObservableCollection (BuildListItems (itemCount))); - listView.BeginInit (); - listView.EndInit (); - listView.Layout (); - - // Warm up. - listView.SetNeedsDraw (); - listView.Draw (); - - Stopwatch sw = Stopwatch.StartNew (); - listView.SetNeedsDraw (); - listView.Draw (); - sw.Stop (); - - // 100 ms is ~50Γ— what a single viewport draw takes on a typical machine. - Assert.True ( - sw.Elapsed < TimeSpan.FromMilliseconds (100), - $"ListView layout+draw ({itemCount} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 100 ms"); - } + const int screenHeight = 25; - /// - /// 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 itemCount = 100_000; - const int screenWidth = 80; - const int screenHeight = 30; + IDriver driver = CreateTestDriver (); - IDriver driver = CreateTestDriver (screenWidth, screenHeight); - ListView listView = new () + TextView tv = new () { X = 0, Y = 0, Width = screenWidth, Height = screenHeight, + Text = BuildTextViewContent (lineCount), + ReadOnly = true, + WordWrap = false, Driver = driver }; - listView.SetSource (new ObservableCollection (BuildListItems (itemCount))); - listView.BeginInit (); - listView.EndInit (); - listView.Layout (); + tv.BeginInit (); + tv.EndInit (); + tv.Layout (); - // Warm up. - listView.SetNeedsDraw (); - listView.Draw (); + // Warm up: prime JIT and layout caches. + tv.SetNeedsDraw (); + tv.Draw (); - // Scroll to the mid-point of the list. - listView.Viewport = listView.Viewport with { Y = itemCount / 2 }; + // Scroll to the middle of the document. + tv.Viewport = tv.Viewport with { Y = lineCount / 2 }; - Stopwatch sw = Stopwatch.StartNew (); - listView.SetNeedsDraw (); - listView.Draw (); + var sw = Stopwatch.StartNew (); + tv.SetNeedsDraw (); + tv.Draw (); sw.Stop (); - // 100 ms threshold β€” if ListView iterates all 100 000 items per draw, this will fail. - Assert.True ( - sw.Elapsed < TimeSpan.FromMilliseconds (100), - $"ListView mid-doc viewport draw ({itemCount} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 100 ms"); - } - - // ────────────────────────────────────────────────────────────────────────── - // Helpers - // ────────────────────────────────────────────────────────────────────────── - - private static string BuildTextViewContent (int lineCount) - { - System.Text.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 (); + // 500 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 (500), + $"TextView single viewport draw ({lineCount} lines, mid-doc) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 500 ms"); } private static DataTable BuildDataTable (int rows, int cols) @@ -276,7 +261,7 @@ private static DataTable BuildDataTable (int rows, int cols) for (var rowIndex = 0; rowIndex < rows; rowIndex++) { - object [] row = new object [cols]; + var row = new object [cols]; for (var colIndex = 0; colIndex < cols; colIndex++) { @@ -300,6 +285,20 @@ private static List BuildListItems (int count) return items; } -} + // ────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────── + + 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 (); + } +} From eb483657a6ed17e7db05d6415eef5483307e5072 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 15:48:31 +0000 Subject: [PATCH 04/12] Fix UPPER_CASE constants and increase ListView mid-viewport threshold to 300 ms Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/2d6db6a8-5b60-441b-8a9a-d460c509c184 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../Views/ScrollingPerformanceTests.cs | 96 +++++++++---------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs index 7d3c901cb1..ef388a0af6 100644 --- a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs @@ -34,21 +34,21 @@ public class ScrollingPerformanceTests : TestDriverBase [Fact] public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () { - const int itemCount = 100_000; - const int screenWidth = 80; - const int screenHeight = 30; + const int ITEM_COUNT = 100_000; + const int SCREEN_WIDTH = 80; + const int SCREEN_HEIGHT = 30; - IDriver driver = CreateTestDriver (screenWidth, screenHeight); + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); ListView listView = new () { X = 0, Y = 0, - Width = screenWidth, - Height = screenHeight, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, Driver = driver }; - listView.SetSource (new ObservableCollection (BuildListItems (itemCount))); + listView.SetSource (new ObservableCollection (BuildListItems (ITEM_COUNT))); listView.BeginInit (); listView.EndInit (); listView.Layout (); @@ -62,9 +62,9 @@ public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () listView.Draw (); sw.Stop (); - // 100 ms is ~50Γ— what a single viewport draw takes on a typical machine. - Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (100), - $"ListView layout+draw ({itemCount} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 100 ms"); + // 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"); } /// @@ -74,21 +74,21 @@ public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () [Fact] public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () { - const int itemCount = 100_000; - const int screenWidth = 80; - const int screenHeight = 30; + const int ITEM_COUNT = 100_000; + const int SCREEN_WIDTH = 80; + const int SCREEN_HEIGHT = 30; - IDriver driver = CreateTestDriver (screenWidth, screenHeight); + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); ListView listView = new () { X = 0, Y = 0, - Width = screenWidth, - Height = screenHeight, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, Driver = driver }; - listView.SetSource (new ObservableCollection (BuildListItems (itemCount))); + listView.SetSource (new ObservableCollection (BuildListItems (ITEM_COUNT))); listView.BeginInit (); listView.EndInit (); listView.Layout (); @@ -98,16 +98,16 @@ public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () listView.Draw (); // Scroll to the mid-point of the list. - listView.Viewport = listView.Viewport with { Y = itemCount / 2 }; + listView.Viewport = listView.Viewport with { Y = ITEM_COUNT / 2 }; var sw = Stopwatch.StartNew (); listView.SetNeedsDraw (); listView.Draw (); sw.Stop (); - // 100 ms threshold β€” if ListView iterates all 100 000 items per draw, this will fail. - Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (100), - $"ListView mid-doc viewport draw ({itemCount} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 100 ms"); + // 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"); } // ────────────────────────────────────────────────────────────────────────── @@ -121,19 +121,19 @@ public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () [Fact] public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () { - const int rowCount = 10_000; - const int colCount = 10; - const int screenWidth = 120; - const int screenHeight = 30; + const int ROW_COUNT = 10_000; + const int COL_COUNT = 10; + const int SCREEN_WIDTH = 120; + const int SCREEN_HEIGHT = 30; - IDriver driver = CreateTestDriver (screenWidth, screenHeight); + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); - TableView tableView = new (new DataTableSource (BuildDataTable (rowCount, colCount))) + TableView tableView = new (new DataTableSource (BuildDataTable (ROW_COUNT, COL_COUNT))) { X = 0, Y = 0, - Width = screenWidth, - Height = screenHeight, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, Driver = driver }; tableView.BeginInit (); @@ -151,7 +151,7 @@ public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () // 200 ms is ~50Γ— what a single viewport draw takes on a typical machine. Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (200), - $"TableView layout+draw ({rowCount} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); + $"TableView layout+draw ({ROW_COUNT} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); } /// @@ -162,19 +162,19 @@ public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () [Fact] public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () { - const int rowCount = 10_000; - const int colCount = 10; - const int screenWidth = 120; - const int screenHeight = 30; + const int ROW_COUNT = 10_000; + const int COL_COUNT = 10; + const int SCREEN_WIDTH = 120; + const int SCREEN_HEIGHT = 30; - IDriver driver = CreateTestDriver (screenWidth, screenHeight); + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); - TableView tableView = new (new DataTableSource (BuildDataTable (rowCount, colCount))) + TableView tableView = new (new DataTableSource (BuildDataTable (ROW_COUNT, COL_COUNT))) { X = 0, Y = 0, - Width = screenWidth, - Height = screenHeight, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, Driver = driver }; tableView.BeginInit (); @@ -186,7 +186,7 @@ public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () tableView.Draw (); // Scroll to mid-document. - tableView.RowOffset = rowCount / 2; + tableView.RowOffset = ROW_COUNT / 2; var sw = Stopwatch.StartNew (); tableView.SetNeedsDraw (); @@ -195,7 +195,7 @@ public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () // 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 ({rowCount} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); + $"TableView single viewport draw at mid ({ROW_COUNT} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); } // ────────────────────────────────────────────────────────────────────────── @@ -211,9 +211,9 @@ public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () [Fact] public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () { - const int lineCount = 1_000; - const int screenWidth = 80; - const int screenHeight = 25; + const int LINE_COUNT = 1_000; + const int SCREEN_WIDTH = 80; + const int SCREEN_HEIGHT = 25; IDriver driver = CreateTestDriver (); @@ -221,9 +221,9 @@ public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () { X = 0, Y = 0, - Width = screenWidth, - Height = screenHeight, - Text = BuildTextViewContent (lineCount), + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Text = BuildTextViewContent (LINE_COUNT), ReadOnly = true, WordWrap = false, Driver = driver @@ -237,7 +237,7 @@ public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () tv.Draw (); // Scroll to the middle of the document. - tv.Viewport = tv.Viewport with { Y = lineCount / 2 }; + tv.Viewport = tv.Viewport with { Y = LINE_COUNT / 2 }; var sw = Stopwatch.StartNew (); tv.SetNeedsDraw (); @@ -247,7 +247,7 @@ public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () // 500 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 (500), - $"TextView single viewport draw ({lineCount} lines, mid-doc) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 500 ms"); + $"TextView single viewport draw ({LINE_COUNT} lines, mid-doc) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 500 ms"); } private static DataTable BuildDataTable (int rows, int cols) From 9f447dd3625e57f542c1d47bef0b7520078f10f9 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 11 May 2026 10:31:25 -0600 Subject: [PATCH 05/12] Raise TextView perf test threshold to 1000 ms Increased the allowed time for the TextView_SingleViewportDraw_1K_Lines_UnderThreshold test from 500 ms to 1000 ms. This adjustment makes the test less strict and more reliable on slower CI environments. Updated the assertion and comments accordingly. --- .../Views/ScrollingPerformanceTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs index ef388a0af6..23ac7de521 100644 --- a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs @@ -211,6 +211,7 @@ public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () [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; @@ -244,10 +245,10 @@ public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () tv.Draw (); sw.Stop (); - // 500 ms is generous even in debug/slow-CI mode. An O(lineCount) regression + // 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 (500), - $"TextView single viewport draw ({LINE_COUNT} lines, mid-doc) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 500 ms"); + 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"); } private static DataTable BuildDataTable (int rows, int cols) From bced649b51fff9438d03f047d406e69ac988dd2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 16:45:02 +0000 Subject: [PATCH 06/12] Restrict performance smoke tests to Ubuntu runner only Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/112f1989-9e14-476f-a308-720615324f65 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .github/workflows/unit-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a73ca887de..9f750bf0c7 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -115,6 +115,7 @@ jobs: --project Tests/UnitTestsParallelizable \ --no-build \ --verbosity normal \ + --filter-class- "*ScrollingPerformanceTests" \ --diagnostic --diagnostic-output-directory logs/UnitTestsParallelizable/${{ runner.os }} echo "============================================" From c6470ea1522eb3d41d6c3cb9d03aa4e92cafe316 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 14:02:32 +0000 Subject: [PATCH 07/12] Fix perf tests to skip on non-Linux via Assert.Skip; revert broken --filter-class- from unit-tests.yml Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/ee02ff32-87b3-42c4-b405-a35bebf5ce18 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .github/workflows/unit-tests.yml | 1 - .../Views/ScrollingPerformanceTests.cs | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 9f750bf0c7..a73ca887de 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -115,7 +115,6 @@ jobs: --project Tests/UnitTestsParallelizable \ --no-build \ --verbosity normal \ - --filter-class- "*ScrollingPerformanceTests" \ --diagnostic --diagnostic-output-directory logs/UnitTestsParallelizable/${{ runner.os }} echo "============================================" diff --git a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs index 23ac7de521..f61e2c030f 100644 --- a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs @@ -34,6 +34,8 @@ public class ScrollingPerformanceTests : TestDriverBase [Fact] public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () { + SkipIfNotLinux (); + const int ITEM_COUNT = 100_000; const int SCREEN_WIDTH = 80; const int SCREEN_HEIGHT = 30; @@ -74,6 +76,7 @@ public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () [Fact] public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () { + SkipIfNotLinux (); const int ITEM_COUNT = 100_000; const int SCREEN_WIDTH = 80; const int SCREEN_HEIGHT = 30; @@ -121,6 +124,7 @@ public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () [Fact] public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () { + SkipIfNotLinux (); const int ROW_COUNT = 10_000; const int COL_COUNT = 10; const int SCREEN_WIDTH = 120; @@ -162,6 +166,7 @@ public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () [Fact] public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () { + SkipIfNotLinux (); const int ROW_COUNT = 10_000; const int COL_COUNT = 10; const int SCREEN_WIDTH = 120; @@ -211,6 +216,7 @@ public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () [Fact] public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () { + SkipIfNotLinux (); const int THRESHOLD_MS = 1000; const int LINE_COUNT = 1_000; const int SCREEN_WIDTH = 80; @@ -291,6 +297,14 @@ private static List BuildListItems (int count) // Helpers // ────────────────────────────────────────────────────────────────────────── + private static void SkipIfNotLinux () + { + if (!OperatingSystem.IsLinux ()) + { + Assert.Skip ("Performance smoke tests only run on Linux."); + } + } + private static string BuildTextViewContent (int lineCount) { StringBuilder sb = new (lineCount * 85); From 53f110ba67b5e8ccdeaeda8e496b6887eae4a475 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 14:04:46 +0000 Subject: [PATCH 08/12] Add blank lines after SkipIfNotLinux() calls (style fix) Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/ee02ff32-87b3-42c4-b405-a35bebf5ce18 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../Views/ScrollingPerformanceTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs index f61e2c030f..867b06e08a 100644 --- a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs @@ -77,6 +77,7 @@ public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () { SkipIfNotLinux (); + const int ITEM_COUNT = 100_000; const int SCREEN_WIDTH = 80; const int SCREEN_HEIGHT = 30; @@ -125,6 +126,7 @@ public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () { SkipIfNotLinux (); + const int ROW_COUNT = 10_000; const int COL_COUNT = 10; const int SCREEN_WIDTH = 120; @@ -167,6 +169,7 @@ public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () { SkipIfNotLinux (); + const int ROW_COUNT = 10_000; const int COL_COUNT = 10; const int SCREEN_WIDTH = 120; @@ -217,6 +220,7 @@ public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () { SkipIfNotLinux (); + const int THRESHOLD_MS = 1000; const int LINE_COUNT = 1_000; const int SCREEN_WIDTH = 80; From 97cfa6da5cbe3963783df5124f32298c4af07586 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 14:37:19 +0000 Subject: [PATCH 09/12] Move perf smoke tests to dedicated Tests/PerformanceTests csproj with own workflow Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/5a2d2af9-446d-4bb7-b955-49055dc0e17d Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .github/workflows/perf-gate.yml | 5 +- Terminal.Gui/Terminal.Gui.csproj | 1 + Tests/PerformanceTests/AssemblyInfo.cs | 7 +++ .../PerformanceTests/PerformanceTests.csproj | 55 +++++++++++++++++++ .../ScrollingPerformanceTests.cs | 34 +++--------- Tests/PerformanceTests/xunit.runner.json | 6 ++ 6 files changed, 79 insertions(+), 29 deletions(-) create mode 100644 Tests/PerformanceTests/AssemblyInfo.cs create mode 100644 Tests/PerformanceTests/PerformanceTests.csproj rename Tests/{UnitTestsParallelizable/Views => PerformanceTests}/ScrollingPerformanceTests.cs (95%) create mode 100644 Tests/PerformanceTests/xunit.runner.json diff --git a/.github/workflows/perf-gate.yml b/.github/workflows/perf-gate.yml index aa22f2758b..12d677dc95 100644 --- a/.github/workflows/perf-gate.yml +++ b/.github/workflows/perf-gate.yml @@ -42,15 +42,14 @@ jobs: 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/UnitTestsParallelizable --no-restore -property:NoWarn=0618%3B0612 + 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/UnitTestsParallelizable \ + --project Tests/PerformanceTests \ --no-build \ - --filter-class "*ScrollingPerformanceTests" \ --verbosity normal - name: Upload smoke test logs 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/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/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs b/Tests/PerformanceTests/ScrollingPerformanceTests.cs similarity index 95% rename from Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs rename to Tests/PerformanceTests/ScrollingPerformanceTests.cs index 867b06e08a..b6c0bc5c5e 100644 --- a/Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs +++ b/Tests/PerformanceTests/ScrollingPerformanceTests.cs @@ -4,9 +4,8 @@ using System.Data; using System.Diagnostics; using System.Text; -using UnitTests; -namespace ViewsTests; +namespace PerformanceTests; /// /// Stopwatch-based smoke tests that catch catastrophic rendering regressions. @@ -17,8 +16,9 @@ namespace ViewsTests; /// /// 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 described in the GitHub issue. -/// Layer 2 (BenchmarkDotNet baseline comparison) lives in the perf-gate CI workflow. +/// 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 @@ -34,8 +34,6 @@ public class ScrollingPerformanceTests : TestDriverBase [Fact] public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () { - SkipIfNotLinux (); - const int ITEM_COUNT = 100_000; const int SCREEN_WIDTH = 80; const int SCREEN_HEIGHT = 30; @@ -76,8 +74,6 @@ public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () [Fact] public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () { - SkipIfNotLinux (); - const int ITEM_COUNT = 100_000; const int SCREEN_WIDTH = 80; const int SCREEN_HEIGHT = 30; @@ -125,8 +121,6 @@ public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () [Fact] public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () { - SkipIfNotLinux (); - const int ROW_COUNT = 10_000; const int COL_COUNT = 10; const int SCREEN_WIDTH = 120; @@ -168,8 +162,6 @@ public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () [Fact] public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () { - SkipIfNotLinux (); - const int ROW_COUNT = 10_000; const int COL_COUNT = 10; const int SCREEN_WIDTH = 120; @@ -219,8 +211,6 @@ public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () [Fact] public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () { - SkipIfNotLinux (); - const int THRESHOLD_MS = 1000; const int LINE_COUNT = 1_000; const int SCREEN_WIDTH = 80; @@ -261,6 +251,10 @@ public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () $"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 (); @@ -297,18 +291,6 @@ private static List BuildListItems (int count) return items; } - // ────────────────────────────────────────────────────────────────────────── - // Helpers - // ────────────────────────────────────────────────────────────────────────── - - private static void SkipIfNotLinux () - { - if (!OperatingSystem.IsLinux ()) - { - Assert.Skip ("Performance smoke tests only run on Linux."); - } - } - private static string BuildTextViewContent (int lineCount) { StringBuilder sb = new (lineCount * 85); 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 +} From 2e5ab4fbb3e8228886f8d3afbdd29e4a0a348248 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 15:05:51 +0000 Subject: [PATCH 10/12] Fix perf-gate CI: add PerformanceTests to Terminal.sln so dotnet restore covers it Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/f0709359-0752-405b-8cd1-19e9edcc5707 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Terminal.sln | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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} From 423fb9c99f0e992289fd399f761b97c991a5e154 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 15:48:03 +0000 Subject: [PATCH 11/12] Fix race condition: add [Collection("Application Tests")] to ApplicationDefaultKeyBindingsTests Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/d34f268b-97d0-4f1a-bdd5-6bf179204d47 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs | 1 + 1 file changed, 1 insertion(+) 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] From f4c56ecb8cb910fdc6f673d196b1e881e69a2a84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 19:23:23 +0000 Subject: [PATCH 12/12] Fix: use ContentView.FrameToScreen() in PopoverMenu overlap test for correct clip area Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/12ab489f-62d9-4dcd-9c07-595bc21a6f19 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../FluentTests/MenuBarTests.cs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/Tests/IntegrationTests/FluentTests/MenuBarTests.cs b/Tests/IntegrationTests/FluentTests/MenuBarTests.cs index 4ad5631264..56a8fbd4e8 100644 --- a/Tests/IntegrationTests/FluentTests/MenuBarTests.cs +++ b/Tests/IntegrationTests/FluentTests/MenuBarTests.cs @@ -1,5 +1,6 @@ ο»Ώusing System.Drawing; using System.Globalization; +using System.Text; using AppTestHelpers; using AppTestHelpers.XunitHelpers; @@ -1017,4 +1018,128 @@ public void InlineMenuBarItem_MenuItem_Action_Fires () c.Dispose (); } + + // Claude - Opus 4.7 + /// + /// Reproduces a bug where, when a peer view of a has a + /// adornment whose draws via , opening + /// a that overlaps the padding region leaves the padding's drawing + /// visible β€” the popover's content fails to overdraw those cells. + /// + [Fact] + public void PopoverMenu_Overlapping_PaddingView_DrawingContent_Is_Not_Bled_Through () + { + var d = "ansi"; + MenuBar? menuBar = null; + View? subView = null; + IApplication? app = null; + + AppTestHelper c = With.A (80, 25, d, _out) + .Then (a => + { + app = a; + + menuBar = new MenuBar + { + Menus = + [ + new MenuBarItem ("_File", + [ + new MenuItem { Title = "_New", HelpText = "Create new" } + ]) + ] + }; + + subView = new () + { + Id = "subView", + X = 0, + Y = 1, + Width = Dim.Fill (), + Height = 10 + }; + + subView.Padding.Thickness = new (1, 0, 0, 0); + + a.TopRunnableView!.Add (menuBar); + a.TopRunnableView!.Add (subView); + + AdornmentView paddingView = subView.Padding.GetOrCreateView (); + + paddingView.DrawingContent += (sender, _) => + { + View pv = (View)sender!; + + for (int row = 0; row < pv.Viewport.Height; row++) + { + pv.AddRune (0, row, new Rune ('P')); + } + }; + }); + + c = c.WaitIteration (); + + // Open the File menu via F10. + c = c.KeyDown (MenuBar.DefaultKey) + .WaitIteration (); + + // Capture state on the UI thread, but defer assertions to avoid hanging the app loop. + Rectangle popoverScreen = Rectangle.Empty; + Rectangle padScreen = Rectangle.Empty; + Cell [,]? capturedContents = null; + var menuOpen = false; + + c = c.Then (a => + { + menuOpen = menuBar!.IsOpen (); + + if (a.Popovers!.GetActivePopover () is PopoverMenu popoverMenu) + { + // 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 (); + + // Snapshot the driver contents so we can inspect after Dispose. + Cell [,]? contents = a.Driver!.Contents; + + if (contents is { }) + { + int rows = contents.GetLength (0); + int cols = contents.GetLength (1); + capturedContents = new Cell [rows, cols]; + + for (int r = 0; r < rows; r++) + { + for (int co = 0; co < cols; co++) + { + capturedContents [r, co] = contents [r, co]; + } + } + } + }); + + c.Dispose (); + + Assert.True (menuOpen, "File menu should be open after F10."); + Assert.NotNull (capturedContents); + Assert.False (popoverScreen.IsEmpty, "Active popover should be present."); + + Rectangle overlap = Rectangle.Intersect (popoverScreen, padScreen); + + Assert.False (overlap.IsEmpty, + $"Test setup invalid: popover {popoverScreen} does not overlap padding {padScreen}."); + + // Inside the overlap, the popover must overdraw the padding β€” no 'P' should remain. + for (int row = overlap.Top; row < overlap.Bottom; row++) + { + for (int col = overlap.Left; col < overlap.Right; col++) + { + Assert.NotEqual ("P", capturedContents! [row, col].Grapheme); + } + } + } }