Add accessibility performance regression coverage

- Add inactive and active accessibility benchmarks to protect against startup, notification, and AT-SPI scaling regressions.

  - Extend the AT-SPI benchmark with text, focus, action, value, geometry, hit-testing, and model-update operations. Run both benchmarks in Linux CI and preserve their JSON results as artifacts.

  - Add an explicit null accessibility backend for baseline measurements and optional accessibility disabling.

  - Harden the Windows UIA backend by caching client availability per update, validating root providers, and preventing recursive window procedure forwarding.
This commit is contained in:
Martín Lucas Golini
2026-09-11 14:57:16 -03:00
parent 3751e05df6
commit d6d52032b0
9 changed files with 377 additions and 94 deletions
+18 -1
View File
@@ -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
@@ -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()
+197 -78
View File
@@ -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__":
@@ -9,10 +9,14 @@ class NullAccessibilityBackend final : public AccessibilityBackend {
void onEvent( const AccessibilityPendingEvent& ) {}
};
std::unique_ptr<AccessibilityBackend> createNullAccessibilityBackend() {
return std::make_unique<NullAccessibilityBackend>();
}
#if EE_PLATFORM != EE_PLATFORM_LINUX && EE_PLATFORM != EE_PLATFORM_FREEBSD && \
EE_PLATFORM != EE_PLATFORM_WIN
std::unique_ptr<AccessibilityBackend> createAccessibilityBackend( AccessibilityManager& ) {
return std::make_unique<NullAccessibilityBackend>();
return createNullAccessibilityBackend();
}
#endif
@@ -23,6 +23,8 @@ class AccessibilityBackend {
std::unique_ptr<AccessibilityBackend> createAccessibilityBackend( AccessibilityManager& manager );
std::unique_ptr<AccessibilityBackend> createNullAccessibilityBackend();
}} // namespace EE::UI
#endif
@@ -225,7 +225,8 @@ class AtSpiApplication final : public std::enable_shared_from_this<AtSpiApplicat
mRunning.store( false, std::memory_order_release );
if ( mIOThread.joinable() ) {
const char stop = 1;
write( mWakePipe[1], &stop, sizeof( stop ) );
const ssize_t written = write( mWakePipe[1], &stop, sizeof( stop ) );
(void)written;
mIOThread.join();
close( mWakePipe[0] );
close( mWakePipe[1] );
@@ -24,7 +24,9 @@ class UIAutomationAccessibilityBackend final : public AccessibilityBackend {
bool isAvailable() const { return mWindow != nullptr; }
bool hasActiveClients() const { return isAvailable() && UiaClientsAreListening(); }
bool hasActiveClients() const { return isAvailable() && mHasActiveClients; }
void update() { mHasActiveClients = UiaClientsAreListening(); }
void onEvent( const AccessibilityPendingEvent& event );
@@ -50,6 +52,7 @@ class UIAutomationAccessibilityBackend final : public AccessibilityBackend {
AccessibilityManager& mManager;
HWND mWindow{};
WNDPROC mPreviousWindowProcedure{};
bool mHasActiveClients{};
UnorderedMap<AccessibilitySourceId, UnorderedMap<Uint64, UIAutomationProvider*>> mProviders;
};
@@ -593,14 +596,17 @@ LRESULT CALLBACK UIAutomationAccessibilityBackend::windowProcedure( HWND window,
static_cast<UIAutomationAccessibilityBackend*>( GetPropW( window, BackendProperty ) );
if ( backend && message == WM_GETOBJECT && static_cast<LONG>( 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<IRawElementProviderSimple*>( 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 ) {
@@ -7,6 +7,8 @@
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uiwidget.hpp>
#include <cstdlib>
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();
@@ -1,18 +1,31 @@
#include <eepp/ee.hpp>
#include <eepp/ui/accessibility/accessibilitymanager.hpp>
#include <eepp/ui/tools/uiwidgetinspector.hpp>
#include <cstdlib>
#include <iostream>
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<size_t>( 1, std::strtoull( argv[++i], nullptr, 10 ) );
if ( std::string_view( argv[i] ) == "--benchmark-iterations" && i + 1 < argc )
benchmarkIterations = std::max<size_t>( 1, std::strtoull( argv[++i], nullptr, 10 ) );
}
UIApplication app( { 1280, 720, "eepp - Accessibility",
static_cast<Uint32>( benchmark ? WindowStyle::Default | WindowStyle::Hidden
: WindowStyle::Default ) } );
static_cast<Uint32>( 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<UIListView>( "items" );
if ( benchmark ) {
std::vector<std::string> 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<std::string>::create( std::move( items ) ) );
@@ -76,6 +89,19 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
table->setModel( tableModel );
auto status = content->find<UITextView>( "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<UIPushButton>( "save" )->onClick(
[status]( auto ) { status->setText( "Settings saved" ); } );
content->find<UIPushButton>( "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<std::string> 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<std::string>::create( std::move( items ) ) );