diff --git a/.github/workflows/eepp-linux-build-check.yml b/.github/workflows/eepp-linux-build-check.yml index ef0e1da06..723a64fa2 100644 --- a/.github/workflows/eepp-linux-build-check.yml +++ b/.github/workflows/eepp-linux-build-check.yml @@ -22,7 +22,8 @@ jobs: sudo add-apt-repository -y universe sudo add-apt-repository -y multiverse sudo apt update - sudo apt install -y gcc-13 g++-13 wget libsdl2-2.0-0 libsdl2-dev mesa-utils xvfb gdb + sudo apt install -y gcc-13 g++-13 wget libsdl2-2.0-0 libsdl2-dev mesa-utils xvfb gdb \ + python3-gi gir1.2-atspi-2.0 at-spi2-core dbus-x11 wget https://cdn.ensoft.dev/eepp-assets/premake-5.0.0-beta6-linux.tar.gz tar xvzf premake-5.0.0-beta6-linux.tar.gz - name: Build @@ -74,6 +75,22 @@ jobs: run: | cd projects/scripts bash ./run_gdb_tests.sh + - name: Inactive Accessibility Benchmark + timeout-minutes: 2 + run: | + mkdir -p bin/unit_tests/output + env -u AT_SPI_BUS_ADDRESS dbus-run-session -- projects/scripts/xvfb-run-eepp \ + /usr/bin/python3 projects/scripts/benchmark_accessibility_inactive.py --json \ + > bin/unit_tests/output/accessibility-inactive-benchmark.json + cat bin/unit_tests/output/accessibility-inactive-benchmark.json + - name: AT-SPI Benchmark + timeout-minutes: 5 + run: | + mkdir -p bin/unit_tests/output + env -u AT_SPI_BUS_ADDRESS dbus-run-session -- projects/scripts/xvfb-run-eepp \ + /usr/bin/python3 projects/scripts/benchmark_atspi.py --json \ + > bin/unit_tests/output/atspi-benchmark.json + cat bin/unit_tests/output/atspi-benchmark.json - name: Upload artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/projects/scripts/benchmark_accessibility_inactive.py b/projects/scripts/benchmark_accessibility_inactive.py new file mode 100644 index 000000000..99dae168d --- /dev/null +++ b/projects/scripts/benchmark_accessibility_inactive.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import statistics +import subprocess +import time + + +def check(condition, message): + if not condition: + raise RuntimeError(message) + + +def run_sample(args, disabled): + environment = os.environ.copy() + if disabled: + environment["EEPP_DISABLE_ACCESSIBILITY"] = "1" + else: + environment.pop("EEPP_DISABLE_ACCESSIBILITY", None) + start = time.perf_counter() + completed = subprocess.run( + [ + args.executable, + "--benchmark-inactive", + "--benchmark-iterations", + str(args.iterations), + ], + check=True, + capture_output=True, + text=True, + timeout=args.timeout, + env=environment, + ) + wall_ms = (time.perf_counter() - start) * 1000 + result = json.loads(completed.stdout) + result["wall_ms"] = wall_ms + result["initialization_ms"] = result.pop("initialization_us") / 1000 + result["notifications_ms"] = result.pop("notifications_us") / 1000 + return result + + +def median_sample(samples): + return { + key: statistics.median(sample[key] for sample in samples) + for key in samples[0] + } + + +def main(): + parser = argparse.ArgumentParser(description="Benchmark inactive accessibility overhead") + parser.add_argument("--executable", default="bin/eepp-ui-accessibility") + parser.add_argument("--iterations", type=int, default=100000) + parser.add_argument("--samples", type=int, default=5) + parser.add_argument("--timeout", type=float, default=10) + parser.add_argument("--max-initialization-ms", type=float, default=250) + parser.add_argument("--max-notification-ratio", type=float, default=1.1) + parser.add_argument("--max-wall-ratio", type=float, default=1.25) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + check(args.iterations > 0 and args.samples > 0, "iterations and samples must be positive") + + # Alternate modes so thermal and scheduler drift affect both populations similarly. + enabled_samples = [] + disabled_samples = [] + for index in range(args.samples * 2): + disabled = index % 2 == 0 + result = run_sample(args, disabled) + (disabled_samples if disabled else enabled_samples).append(result) + + enabled = median_sample(enabled_samples) + disabled = median_sample(disabled_samples) + notification_ratio = enabled["notifications_ms"] / disabled["notifications_ms"] + wall_ratio = enabled["wall_ms"] / disabled["wall_ms"] + check( + enabled["initialization_ms"] <= args.max_initialization_ms, + "inactive native backend initialization exceeded threshold", + ) + check( + notification_ratio <= args.max_notification_ratio, + "inactive accessibility notification overhead exceeded threshold", + ) + check(wall_ratio <= args.max_wall_ratio, "inactive accessibility wall-time overhead exceeded threshold") + output = { + "disabled": disabled, + "enabled": enabled, + "notification_ratio": notification_ratio, + "wall_ratio": wall_ratio, + } + if args.json: + print(json.dumps(output, sort_keys=True)) + else: + print( + "Inactive accessibility benchmark passed: " + f"initialization {enabled['initialization_ms']:.3f}ms, " + f"notifications {enabled['notifications_ms']:.3f}ms " + f"({notification_ratio:.3f}x disabled), wall {wall_ratio:.3f}x disabled" + ) + + +if __name__ == "__main__": + main() diff --git a/projects/scripts/benchmark_atspi.py b/projects/scripts/benchmark_atspi.py index cdda69cdd..49fb9152d 100644 --- a/projects/scripts/benchmark_atspi.py +++ b/projects/scripts/benchmark_atspi.py @@ -92,19 +92,172 @@ def action_index(node, name): raise RuntimeError(f'action "{name}" was not exposed by "{node.get_name()}"') +def run_sample(args, item_count, target_process_id, model_changed, model_change_count): + process = None + try: + start = time.perf_counter() + process = subprocess.Popen( + [args.executable, "--benchmark", "--benchmark-items", str(item_count)], + stdout=subprocess.DEVNULL, + ) + target_process_id[0] = process.pid + application = find_application(args.application, process.pid, args.timeout) + discovery_ms = (time.perf_counter() - start) * 1000 + + measurements = Measurements() + start = time.perf_counter() + nodes = traverse(application, measurements) + traversal_ms = (time.perf_counter() - start) * 1000 + check(len(nodes) >= item_count + 50, f"benchmark tree is incomplete: {len(nodes)} nodes") + find_named(nodes, "Benchmark depth leaf") + benchmark_list = find_named(nodes, "Benchmark items") + check( + benchmark_list.get_child_count() == item_count, + f"benchmark list must expose {item_count} items", + ) + + start = time.perf_counter() + for node in nodes[: min(args.sample_size, len(nodes))]: + measurements.call("description", node.get_description) + measurements.call("state", node.get_state_set) + measurements.call("attributes", node.get_attributes) + measurements.call("extents", lambda node=node: node.get_extents(Atspi.CoordType.SCREEN)) + property_batch_ms = (time.perf_counter() - start) * 1000 + + project_name = find_named(nodes, "Project name") + check( + measurements.call("text-character-count", project_name.get_character_count) == 4, + "text character count query failed", + ) + check( + measurements.call( + "text-content", lambda: Atspi.Text.get_text(project_name, 0, -1) + ) == "eepp", + "text content query failed", + ) + check( + measurements.call( + "editable-text", lambda: Atspi.EditableText.set_text_contents(project_name, "eepp2") + ), + "editable text action failed", + ) + check( + measurements.call( + "focus", lambda: project_name.do_action(action_index(project_name, "focus")) + ), + "focus action failed", + ) + + checkbox = find_named(nodes, "Enable autosave") + check( + measurements.call( + "toggle", lambda: checkbox.do_action(action_index(checkbox, "toggle")) + ), + "toggle action failed", + ) + check( + measurements.call("toggle-state", checkbox.get_state_set).contains( + Atspi.StateType.CHECKED + ), + "toggle state query failed", + ) + + spin = find_named(nodes, "Retry count") + check(measurements.call("range-value", spin.get_current_value) == 3, "range value query failed") + check( + measurements.call( + "set-range-value", lambda: Atspi.Value.set_current_value(spin, 4) + ), + "range value action failed", + ) + + window = measurements.call("application-window", lambda: application.get_child_at_index(0)) + window_extents = measurements.call( + "window-extents", lambda: window.get_extents(Atspi.CoordType.SCREEN) + ) + hit = measurements.call( + "component-hit-test", + lambda: window.get_accessible_at_point( + window_extents.x + window_extents.width // 2, + window_extents.y + window_extents.height // 2, + Atspi.CoordType.SCREEN, + ), + ) + check(hit is not None, "component hit test failed") + + mutate = find_named(nodes, "Replace benchmark model") + model_changed.clear() + model_change_count[0] = 0 + start = time.perf_counter() + check( + mutate.do_action(action_index(mutate, "click")), + "benchmark model replacement action failed", + ) + check(model_changed.wait(args.timeout), "model replacement event was not received") + while benchmark_list.get_child_count() != item_count and time.monotonic() - start < args.timeout: + time.sleep(0.001) + mutation_ms = (time.perf_counter() - start) * 1000 + first_item = benchmark_list.get_child_at_index(0) + check(first_item.get_name() == "Updated benchmark item 0", "model replacement stayed stale") + check(model_change_count[0] == 1, "model-change burst was not coalesced") + + return { + "nodes": len(nodes), + "item_count": item_count, + "discovery_ms": discovery_ms, + "traversal_ms": traversal_ms, + "property_batch_ms": property_batch_ms, + "mutation_ms": mutation_ms, + "model_change_events": model_change_count[0], + **measurements.summary(), + } + finally: + target_process_id[0] = None + if process: + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def median_result(samples): + result = {"item_count": samples[0]["item_count"], "iterations": len(samples)} + for key in samples[0]: + if key not in {"item_count", "model_change_events"}: + result[key] = statistics.median(sample[key] for sample in samples) + result["model_change_events"] = max(sample["model_change_events"] for sample in samples) + result["traversal_ms_per_1k"] = result["traversal_ms"] * 1000 / result["nodes"] + result["mutation_ms_per_1k"] = result["mutation_ms"] * 1000 / result["item_count"] + return result + + +def parse_item_counts(value): + counts = sorted(set(int(count) for count in value.split(","))) + check(counts and counts[0] > 0, "item counts must be positive") + return counts + + def main(): parser = argparse.ArgumentParser(description="Benchmark eepp's AT-SPI query path") parser.add_argument("--application", default="eepp - Accessibility") parser.add_argument("--executable", default="bin/eepp-ui-accessibility") parser.add_argument("--timeout", type=float, default=5.0) parser.add_argument("--sample-size", type=int, default=100) - parser.add_argument("--max-discovery-ms", type=float, default=1000) - parser.add_argument("--max-traversal-ms", type=float, default=1500) - parser.add_argument("--max-property-batch-ms", type=float, default=250) - parser.add_argument("--max-mutation-ms", type=float, default=250) - parser.add_argument("--max-request-ms", type=float, default=100) + parser.add_argument("--item-counts", default="250,1000,4000") + parser.add_argument("--iterations", type=int, default=3) + parser.add_argument("--max-discovery-ms", type=float, default=2000) + parser.add_argument("--max-traversal-ms-per-1k", type=float, default=2000) + parser.add_argument("--max-property-batch-ms", type=float, default=500) + parser.add_argument("--max-mutation-ms-per-1k", type=float, default=1000) + parser.add_argument("--max-request-ms", type=float, default=250) + parser.add_argument("--max-p95-ms", type=float, default=5) + parser.add_argument("--max-scaling-ratio", type=float, default=2.5) parser.add_argument("--json", action="store_true") args = parser.parse_args() + check(args.iterations > 0, "iterations must be positive") + item_counts = parse_item_counts(args.item_counts) model_changed = threading.Event() model_change_count = [0] @@ -123,86 +276,52 @@ def main(): event_thread = threading.Thread(target=Atspi.event_main, daemon=True) event_thread.start() - process = None try: - start = time.perf_counter() - process = subprocess.Popen([args.executable, "--benchmark"]) - target_process_id[0] = process.pid - application = find_application(args.application, process.pid, args.timeout) - discovery_ms = (time.perf_counter() - start) * 1000 + results = [] + for item_count in item_counts: + samples = [ + run_sample( + args, item_count, target_process_id, model_changed, model_change_count + ) + for _ in range(args.iterations) + ] + results.append(median_result(samples)) - measurements = Measurements() - start = time.perf_counter() - nodes = traverse(application, measurements) - traversal_ms = (time.perf_counter() - start) * 1000 - check(len(nodes) >= 1050, f"benchmark tree is incomplete: {len(nodes)} nodes") - find_named(nodes, "Benchmark depth leaf") - benchmark_list = find_named(nodes, "Benchmark items") - check(benchmark_list.get_child_count() == 1000, "benchmark list must expose 1000 items") - - start = time.perf_counter() - for node in nodes[: args.sample_size]: - measurements.call("description", node.get_description) - measurements.call("state", node.get_state_set) - measurements.call("attributes", node.get_attributes) - measurements.call("extents", lambda node=node: node.get_extents(Atspi.CoordType.SCREEN)) - property_batch_ms = (time.perf_counter() - start) * 1000 - - mutate = find_named(nodes, "Replace benchmark model") - model_changed.clear() - start = time.perf_counter() - check( - mutate.do_action(action_index(mutate, "click")), - "benchmark model replacement action failed", - ) - check(model_changed.wait(args.timeout), "model replacement event was not received") - while benchmark_list.get_child_count() != 1000 and time.monotonic() - start < args.timeout: - time.sleep(0.001) - mutation_ms = (time.perf_counter() - start) * 1000 - first_item = benchmark_list.get_child_at_index(0) - check(first_item.get_name() == "Updated benchmark item 0", "model replacement stayed stale") - check(model_change_count[0] == 1, "model-change burst was not coalesced") - - request_summary = measurements.summary() - result = { - "nodes": len(nodes), - "discovery_ms": discovery_ms, - "traversal_ms": traversal_ms, - "property_batch_ms": property_batch_ms, - "mutation_ms": mutation_ms, - "model_change_events": model_change_count[0], - **request_summary, - } - check(discovery_ms <= args.max_discovery_ms, "application discovery exceeded threshold") - check(traversal_ms <= args.max_traversal_ms, "tree traversal exceeded threshold") - check(property_batch_ms <= args.max_property_batch_ms, "property batch exceeded threshold") - check(mutation_ms <= args.max_mutation_ms, "model mutation exceeded threshold") - check( - request_summary["maximum_ms"] <= args.max_request_ms, - "an individual accessibility request exceeded threshold", - ) - if args.json: - print(json.dumps(result, sort_keys=True)) - else: - print( - "AT-SPI benchmark passed: " - f"{result['nodes']} nodes, {result['calls']} calls, " - f"discovery {discovery_ms:.1f}ms, traversal {traversal_ms:.1f}ms, " - f"properties {property_batch_ms:.1f}ms, mutation {mutation_ms:.1f}ms, " - f"median {result['median_ms']:.3f}ms, p95 {result['p95_ms']:.3f}ms, " - f"maximum {result['maximum_ms']:.3f}ms" + for result in results: + check(result["discovery_ms"] <= args.max_discovery_ms, "application discovery exceeded threshold") + check( + result["traversal_ms_per_1k"] <= args.max_traversal_ms_per_1k, + "normalized tree traversal exceeded threshold", ) + check(result["property_batch_ms"] <= args.max_property_batch_ms, "property batch exceeded threshold") + check( + result["mutation_ms_per_1k"] <= args.max_mutation_ms_per_1k, + "normalized model mutation exceeded threshold", + ) + check(result["maximum_ms"] <= args.max_request_ms, "an individual accessibility request exceeded threshold") + check(result["p95_ms"] <= args.max_p95_ms, "p95 accessibility request latency exceeded threshold") + + baseline_cost = results[0]["traversal_ms_per_1k"] + scaling_ratio = max( + result["traversal_ms_per_1k"] / baseline_cost for result in results[1:] + ) if len(results) > 1 else 1 + check(scaling_ratio <= args.max_scaling_ratio, "tree traversal scaling became nonlinear") + output = {"worst_scaling_ratio": scaling_ratio, "samples": results} + if args.json: + print(json.dumps(output, sort_keys=True)) + else: + for result in results: + print( + f"{result['item_count']} items: {result['nodes']:.0f} nodes, " + f"traversal {result['traversal_ms']:.1f}ms " + f"({result['traversal_ms_per_1k']:.1f}ms/1k), " + f"p95 {result['p95_ms']:.3f}ms, maximum {result['maximum_ms']:.3f}ms" + ) + print(f"AT-SPI benchmark passed: worst normalized growth {scaling_ratio:.2f}x") finally: listener.deregister("object:model-changed") Atspi.event_quit() event_thread.join(timeout=1) - if process: - process.terminate() - try: - process.wait(timeout=1) - except subprocess.TimeoutExpired: - process.kill() - process.wait() if __name__ == "__main__": diff --git a/src/eepp/ui/accessibility/accessibilitybackend.cpp b/src/eepp/ui/accessibility/accessibilitybackend.cpp index dfb0fdae8..f6b23f315 100644 --- a/src/eepp/ui/accessibility/accessibilitybackend.cpp +++ b/src/eepp/ui/accessibility/accessibilitybackend.cpp @@ -9,10 +9,14 @@ class NullAccessibilityBackend final : public AccessibilityBackend { void onEvent( const AccessibilityPendingEvent& ) {} }; +std::unique_ptr createNullAccessibilityBackend() { + return std::make_unique(); +} + #if EE_PLATFORM != EE_PLATFORM_LINUX && EE_PLATFORM != EE_PLATFORM_FREEBSD && \ EE_PLATFORM != EE_PLATFORM_WIN std::unique_ptr createAccessibilityBackend( AccessibilityManager& ) { - return std::make_unique(); + return createNullAccessibilityBackend(); } #endif diff --git a/src/eepp/ui/accessibility/accessibilitybackend.hpp b/src/eepp/ui/accessibility/accessibilitybackend.hpp index fb321d04e..c4e762df7 100644 --- a/src/eepp/ui/accessibility/accessibilitybackend.hpp +++ b/src/eepp/ui/accessibility/accessibilitybackend.hpp @@ -23,6 +23,8 @@ class AccessibilityBackend { std::unique_ptr createAccessibilityBackend( AccessibilityManager& manager ); +std::unique_ptr createNullAccessibilityBackend(); + }} // namespace EE::UI #endif diff --git a/src/eepp/ui/accessibility/accessibilitybackendatspi.cpp b/src/eepp/ui/accessibility/accessibilitybackendatspi.cpp index 80322c41e..57e417e3a 100644 --- a/src/eepp/ui/accessibility/accessibilitybackendatspi.cpp +++ b/src/eepp/ui/accessibility/accessibilitybackendatspi.cpp @@ -225,7 +225,8 @@ class AtSpiApplication final : public std::enable_shared_from_this> mProviders; }; @@ -593,14 +596,17 @@ LRESULT CALLBACK UIAutomationAccessibilityBackend::windowProcedure( HWND window, static_cast( GetPropW( window, BackendProperty ) ); if ( backend && message == WM_GETOBJECT && static_cast( lParam ) == UiaRootObjectId ) { auto root = backend->provider( backend->manager().getRoot() ); + if ( !root ) + return DefWindowProcW( window, message, wParam, lParam ); LRESULT result = UiaReturnRawElementProvider( window, wParam, lParam, static_cast( root ) ); root->Release(); return result; } - return backend ? CallWindowProcW( backend->mPreviousWindowProcedure, window, message, wParam, - lParam ) - : DefWindowProcW( window, message, wParam, lParam ); + WNDPROC previous = backend ? backend->mPreviousWindowProcedure : nullptr; + return previous && previous != &UIAutomationAccessibilityBackend::windowProcedure + ? CallWindowProcW( previous, window, message, wParam, lParam ) + : DefWindowProcW( window, message, wParam, lParam ); } UIAutomationProvider* UIAutomationAccessibilityBackend::provider( AccessibilityNodeRef ref ) { diff --git a/src/eepp/ui/accessibility/accessibilitymanager.cpp b/src/eepp/ui/accessibility/accessibilitymanager.cpp index 552f14460..2a9c2934d 100644 --- a/src/eepp/ui/accessibility/accessibilitymanager.cpp +++ b/src/eepp/ui/accessibility/accessibilitymanager.cpp @@ -7,6 +7,8 @@ #include #include +#include + namespace EE { namespace UI { namespace { @@ -262,8 +264,11 @@ bool AccessibilityManager::hasActiveNativeClients() const { } void AccessibilityManager::update() { - if ( !mBackend ) - mBackend = createAccessibilityBackend( *this ); + if ( !mBackend ) { + mBackend = std::getenv( "EEPP_DISABLE_ACCESSIBILITY" ) + ? createNullAccessibilityBackend() + : createAccessibilityBackend( *this ); + } if ( mBackend ) { mBackend->update(); mPendingEvents.clear(); diff --git a/src/examples/ui_accessibility/ui_accessibility.cpp b/src/examples/ui_accessibility/ui_accessibility.cpp index 6624017f1..9830b43d3 100644 --- a/src/examples/ui_accessibility/ui_accessibility.cpp +++ b/src/examples/ui_accessibility/ui_accessibility.cpp @@ -1,18 +1,31 @@ #include +#include #include +#include +#include + EE_MAIN_FUNC int main( int argc, char** argv ) { bool multiWindow = false; bool closePrimary = false; bool benchmark = false; + bool benchmarkInactive = false; + size_t benchmarkItems = 1000; + size_t benchmarkIterations = 100000; for ( int i = 1; i < argc; ++i ) { multiWindow |= std::string_view( argv[i] ) == "--multi-window"; closePrimary |= std::string_view( argv[i] ) == "--close-primary"; benchmark |= std::string_view( argv[i] ) == "--benchmark"; + benchmarkInactive |= std::string_view( argv[i] ) == "--benchmark-inactive"; + if ( std::string_view( argv[i] ) == "--benchmark-items" && i + 1 < argc ) + benchmarkItems = std::max( 1, std::strtoull( argv[++i], nullptr, 10 ) ); + if ( std::string_view( argv[i] ) == "--benchmark-iterations" && i + 1 < argc ) + benchmarkIterations = std::max( 1, std::strtoull( argv[++i], nullptr, 10 ) ); } UIApplication app( { 1280, 720, "eepp - Accessibility", - static_cast( benchmark ? WindowStyle::Default | WindowStyle::Hidden - : WindowStyle::Default ) } ); + static_cast( benchmark || benchmarkInactive + ? WindowStyle::Default | WindowStyle::Hidden + : WindowStyle::Default ) } ); multiWindow |= closePrimary; if ( closePrimary ) app.setQuitPolicy( UIApplication::QuitPolicy::OnLastWindowClosed ); @@ -59,8 +72,8 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { auto list = content->find( "items" ); if ( benchmark ) { std::vector items; - items.reserve( 1000 ); - for ( size_t i = 0; i < 1000; ++i ) + items.reserve( benchmarkItems ); + for ( size_t i = 0; i < benchmarkItems; ++i ) items.emplace_back( "Benchmark item " + std::to_string( i ) ); list->setAccessibilityLabel( "Benchmark items" ); list->setModel( Models::ItemListOwnerModel::create( std::move( items ) ) ); @@ -76,6 +89,19 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { table->setModel( tableModel ); auto status = content->find( "status" ); + if ( benchmarkInactive ) { + Clock clock; + scene->getAccessibilityManager()->update(); + const auto initializationUs = clock.getElapsedTime().asMicroseconds(); + clock.restart(); + for ( size_t i = 0; i < benchmarkIterations; ++i ) + status->setText( i & 1 ? "Ready" : "Idle" ); + const auto notificationsUs = clock.getElapsedTime().asMicroseconds(); + std::cout << "{\"initialization_us\":" << initializationUs + << ",\"iterations\":" << benchmarkIterations + << ",\"notifications_us\":" << notificationsUs << "}\n"; + return EXIT_SUCCESS; + } content->find( "save" )->onClick( [status]( auto ) { status->setText( "Settings saved" ); } ); content->find( "inspect" )->onClick( [scene]( auto ) { @@ -115,11 +141,11 @@ EE_MAIN_FUNC int main( int argc, char** argv ) { auto mutate = UIPushButton::New(); mutate->setText( "Replace benchmark model" ); mutate->setParent( content ); - mutate->onClick( [list]( auto ) { + mutate->onClick( [list, benchmarkItems]( auto ) { for ( size_t generation = 0; generation < 16; ++generation ) { std::vector items; - items.reserve( 1000 ); - for ( size_t i = 0; i < 1000; ++i ) + items.reserve( benchmarkItems ); + for ( size_t i = 0; i < benchmarkItems; ++i ) items.emplace_back( "Updated benchmark item " + std::to_string( i ) ); list->setModel( Models::ItemListOwnerModel::create( std::move( items ) ) );