harness: egtest — egui 8580-row grid (egui port control experiment)

This commit is contained in:
Greg Pomerantz 2026-10-31 00:22:02 -04:00
parent f2a7b32a75
commit 2cfb32c746
4 changed files with 3107 additions and 0 deletions

2670
harnesses/egtest/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
[package]
name = "egtest"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
# egui stack — all stock crates.io, nothing vendored:
# egui/egui_glow/egui_winit are released in lockstep (same version).
[dependencies]
egui = "0.31"
egui_glow = { version = "0.31", features = ["winit"] }
egui-winit = "0.31"
glow = "0.16"
winit = { version = "0.30", features = ["android-native-activity"] }
android-activity = "0.6"
egl = "0.2"
libc = "0.2"
log = "0.4"
[build-dependencies]
[package.metadata.android]
package = "wow.gmp.egtest"
build_targets = ["x86_64-linux-android", "aarch64-linux-android"]
[package.metadata.android.sdk]
min_sdk_version = 26
target_sdk_version = 35
[package.metadata.android.application]
label = "Egtest"
[[package.metadata.android.application.activity]]
name = "android.app.NativeActivity"
config_changes = "orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|touchscreen"
[[package.metadata.android.application.activity.meta_data]]
name = "android.app.lib_name"
value = "egtest"
[[package.metadata.android.application.activity.intent_filter]]
actions = ["android.intent.action.MAIN"]
categories = ["android.intent.category.LAUNCHER"]

View File

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Build + install egtest (egui grid, no textures) on the emulator or phone.
# ./build.sh # x86_64 (emulator) + install
# ./build.sh aarch64 # phone + install
# ./build.sh <arch> no-install
set -euo pipefail
cd "$(dirname "$0")/.."
export ANDROID_HOME=$HOME/android-sdk
export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973
export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH"
SDK_PLATFORM=android-35
export ANDROID_JAR=$HOME/android-sdk/platforms/$SDK_PLATFORM/android.jar
NDK_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin
arg1=${1:-x86_64}
arg2=${2:-install}
case $arg1 in
x86_64) export ANDROID_ABI=x86_64; export CC_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++ AR_x86_64_linux_android=$NDK_BIN/llvm-ar; target=x86_64-linux-android ;;
aarch64) export ANDROID_ABI=arm64-v8a; export CC_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang CXX_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang++ AR_aarch64_linux_android=$NDK_BIN/llvm-ar; target=aarch64-linux-android ;;
*) echo "unknown: $arg1"; exit 1 ;;
esac
# 16KB-page kernel (Pixel 9 Pro) rejects .so with p_align that the segment
# offsets don't satisfy — pin max-page-size to 4 KB (sltest-verified layout).
export RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=4096"
export CARGO_APK_RELEASE_KEYSTORE=$HOME/.android/debug.keystore
export CARGO_APK_RELEASE_KEYSTORE_PASSWORD=android
adb=(); [ -n "${ADB_SERIAL:-}" ] && adb=(adb -s "$ADB_SERIAL")
cargo apk2 build --release --target "$target" --lib
apk=target/release/apk/egtest.apk
echo "APK: $apk ($(du -h "$apk" | cut -f1))"
if [ "$arg2" = install ] && command -v adb >/dev/null; then
"${adb[@]}" install -r "$apk"
"${adb[@]}" shell am start -n wow.gmp.egtest/android.app.NativeActivity || true
fi

352
harnesses/egtest/src/lib.rs Normal file
View File

@ -0,0 +1,352 @@
//! egtest — the egui port of sltest: 8,580 full-width rows (148 px color
//! band + 2 px white separator), vertical flickable with momentum, on a
//! 120 Hz panel. The CONTROL experiment for the Slint jank verdict:
//! if this is smooth to the user's finger where sltest (identical scene)
//! is janky, the Slint render path is the cause and the gallery gets
//! ported to egui.
//!
//! Architecture (idiomatic egui for a long list — what the gallery port
//! will use): NO 8,580 live widgets. The scroll offset is app state; each
//! frame we paint only the visible rows (+ margin) with the painter.
//!
//! Loop: winit 0.30 ApplicationHandler. about_to_wait requests a redraw
//! every idle tick → we render at the panel rate continuously (also
//! measures idle cost; the FR log distinguishes drew=0 vs drew=1).
//!
//! Instrumentation: FR lines (t, frame gap, cost, drew) via a
//! non-blocking file logger (sltest pattern).
use std::sync::{mpsc, Arc, OnceLock};
use std::time::Instant;
use egui::{Color32, Pos2, Rect, Sense, Ui, Vec2};
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::platform::android::{ActiveEventLoopExtAndroid, EventLoopBuilderExtAndroid};
use winit::window::WindowId;
const ROWS: usize = 8580;
const ROW_PX: f32 = 150.0; // 148 color + 2 separator
const COLOR_PX: f32 = 148.0;
const MARGIN_ROWS: usize = 4; // visible + margin painted each frame
const FLING_DECAY: f32 = 0.94;
const FLING_MIN: f32 = 8.0; // px/frame — below this the fling ends
// ---------------------------------------------------------------------------
// Instrumentation (sltest pattern: non-blocking, batch-flush)
// ---------------------------------------------------------------------------
static T0: OnceLock<Instant> = OnceLock::new();
static LAST_FRAME_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn now_us() -> u64 {
T0.get_or_init(Instant::now).elapsed().as_micros() as u64
}
fn now_ms() -> f64 {
now_us() as f64 / 1000.0
}
struct LogTx(mpsc::Sender<String>);
static LOG: OnceLock<LogTx> = OnceLock::new();
fn init_log(path: &str) {
let path = path.to_string();
let (tx, rx) = mpsc::channel::<String>();
std::thread::Builder::new()
.name("log-drain".into())
.spawn(move || {
let mut f = match std::fs::File::create(&path) {
Ok(f) => f,
Err(e) => {
eprintln!("log: cannot create {path}: {e}");
return;
}
};
use std::io::Write as _;
loop {
match rx.recv() {
Ok(line) => {
let _ = writeln!(f, "{line}");
let _ = f.flush();
}
Err(_) => break,
}
}
})
.ok();
let _ = LOG.set(LogTx(tx));
}
fn log_line(s: String) {
if let Some(l) = LOG.get() {
let _ = l.0.send(s);
}
}
// ---------------------------------------------------------------------------
// Scroll state (the flickable)
// ---------------------------------------------------------------------------
struct Flick {
offset: f32, // px scrolled up (0 = top)
velocity: Option<f32>, // px per frame while flinging
last_delta: f32,
}
impl Flick {
fn new() -> Self {
Self { offset: 0.0, velocity: None, last_delta: 0.0 }
}
fn max_offset(&self, view_h: f32) -> f32 {
(ROWS as f32 * ROW_PX - view_h).max(0.0)
}
/// One physics step, called once per rendered frame.
fn step(&mut self, view_h: f32) {
if let Some(v) = self.velocity {
if v.abs() < FLING_MIN {
self.velocity = None;
} else {
self.offset += v;
self.velocity = Some(v * FLING_DECAY);
}
}
self.offset = self.offset.clamp(0.0, self.max_offset(view_h));
}
}
struct App {
flick: Flick,
}
impl App {
fn new() -> Self {
Self { flick: Flick::new() }
}
fn ui(&mut self, ui: &mut Ui) {
let (rect, resp) =
ui.allocate_exact_size(ui.available_rect_before_wrap().size(), Sense::drag());
let view_h = rect.height();
let d = resp.drag_delta();
if d != Vec2::ZERO {
self.flick.offset -= d.y;
self.flick.last_delta = d.y;
self.flick.velocity = None;
}
if resp.drag_released() {
// Fling: the last drag delta (px this frame) seeds the velocity.
if self.flick.last_delta.abs() > 1.0 {
self.flick.velocity = Some(self.flick.last_delta * 1.5);
}
self.flick.last_delta = 0.0;
}
self.flick.step(view_h);
// While flinging we need the next frame.
if self.flick.velocity.is_some() {
ui.ctx().request_repaint();
}
let min_y = self.flick.offset;
let max_y = self.flick.offset + view_h;
let first = ((min_y / ROW_PX).floor() as usize).saturating_sub(MARGIN_ROWS);
let last = ((max_y / ROW_PX).ceil() as usize).saturating_add(MARGIN_ROWS).min(ROWS);
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, Color32::WHITE);
for r in first..last {
let y = r as f32 * ROW_PX - self.flick.offset;
if y + ROW_PX < rect.min.y || y > rect.max.y {
continue;
}
let band = Rect::from_min_max(
Pos2::new(rect.min.x, y),
Pos2::new(rect.max.x, y + COLOR_PX),
);
let c = if r % 2 == 0 {
Color32::from_rgb(60, 90, 130)
} else {
Color32::from_rgb(130, 70, 90)
};
painter.rect_filled(band, 0.0, c);
}
}
}
// ---------------------------------------------------------------------------
// EGL (Android)
// ---------------------------------------------------------------------------
struct Egl {
display: *mut std::ffi::c_void,
surface: *mut std::ffi::c_void,
}
fn init_gl(native_window: *mut libc::c_void) -> (Arc<glow::Context>, Egl) {
unsafe {
let display = egl::get_display(egl::EGL_DEFAULT_DISPLAY).expect("get_display");
let (mut maj, mut min) = (0i32, 0i32);
assert!(egl::initialize(display, &mut maj, &mut min), "eglInitialize");
assert!(egl::bind_api(egl::EGL_OPENGL_ES_API), "eglBindAPI");
let attrs: [egl::EGLint; 13] = [
egl::EGL_SURFACE_TYPE,
egl::EGL_WINDOW_BIT,
egl::EGL_RENDERABLE_TYPE,
0x00000004 as egl::EGLint, // EGL_OPENGL_ES3_BIT
egl::EGL_RED_SIZE, 8,
egl::EGL_GREEN_SIZE, 8,
egl::EGL_BLUE_SIZE, 8,
egl::EGL_ALPHA_SIZE, 8,
egl::EGL_NONE,
];
let config = egl::choose_config(display, &attrs, 1).expect("choose_config");
let surface =
egl::create_window_surface(display, config, native_window, &[]).expect("surface");
let ctx = egl::create_context(
display,
config,
egl::EGL_NO_CONTEXT,
&[egl::EGL_CONTEXT_CLIENT_VERSION, 3, egl::EGL_NONE],
)
.expect("context");
assert!(egl::make_current(display, surface, surface, ctx), "make_current");
let gl = Arc::new(glow::Context::from_loader_function(|name| {
egl::get_proc_address(name) as *mut libc::c_void
}));
(gl, Egl { display, surface })
}
}
// ---------------------------------------------------------------------------
// The app / event loop
// ---------------------------------------------------------------------------
struct Harness {
egui_glow: Option<egui_glow::winit::EguiGlow>,
window: Option<winit::window::Window>,
egl: Option<Egl>,
app: App,
}
impl ApplicationHandler for Harness {
fn resumed(&mut self, ael: &ActiveEventLoop) {
if self.window.is_none() {
let w = ael
.create_window(winit::window::WindowAttributes::new().with_title("egtest"))
.expect("window");
let nw = ael.android_app().native_window().expect("surface").ptr().as_ptr()
as *mut libc::c_void;
let (gl, e) = init_gl(nw);
self.egl = Some(e);
self.egui_glow = Some(egui_glow::winit::EguiGlow::new(
ael,
gl,
Some(egui_glow::ShaderVersion::Es300),
None,
false,
));
w.request_redraw();
self.window = Some(w);
}
}
fn window_event(&mut self, ael: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
if matches!(event, WindowEvent::RedrawRequested) {
self.render();
return;
}
if matches!(event, WindowEvent::CloseRequested) {
ael.exit();
return;
}
if self.window.is_none() {
return;
}
let w = self.window.as_ref().unwrap();
let eg = self.egui_glow.as_mut().unwrap();
let repaint = eg.on_window_event(w, &event).repaint;
if repaint {
w.request_redraw();
}
}
fn about_to_wait(&mut self, ael: &ActiveEventLoop) {
// winit's RedrawRequested dispatch is dead on this device
// (MainEvent::Resume never arrives -> running=false -> the
// dispatch is suppressed), so we drive frames ourselves:
// render now, then pace the next poll at ~120 Hz.
if self.window.is_some() {
self.render();
ael.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
std::time::Instant::now() + std::time::Duration::from_micros(8333),
));
}
}
}
impl Harness {
fn render(&mut self) {
let Some(w) = self.window.as_ref() else { return };
let Some(eg) = self.egui_glow.as_mut() else { return };
let egl = self.egl.as_ref().unwrap();
let app = &mut self.app;
let t_enter = now_ms();
eg.run(w, |ctx| {
egui::CentralPanel::default()
.frame(egui::Frame::none().fill(Color32::WHITE))
.show(ctx, |ui| {
app.ui(ui);
});
});
eg.paint(w);
unsafe {
egl::swap_buffers(egl.display, egl.surface);
}
let t_exit = now_ms();
let prev = LAST_FRAME_US.swap(
(t_exit * 1000.0) as u64,
std::sync::atomic::Ordering::Relaxed,
);
let gap = if prev == 0 { 0.0 } else { t_exit - (prev as f64 / 1000.0) };
log_line(format!(
"FR {:11.2} gap={:7.2} cost={:6.2} drew=1",
t_enter,
gap,
t_exit - t_enter
));
}
}
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub fn android_main(app: android_activity::AndroidApp) {
init_log("/sdcard/Android/data/wow.gmp.egtest/files/fr.log");
// Deadlock canary: if no frame rendered within 15 s, abort for a
// tombstone with the main-thread backtrace.
std::thread::Builder::new()
.name("canary".into())
.spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(15));
if std::sync::atomic::AtomicBool::new(false).load(std::sync::atomic::Ordering::Relaxed)
|| LAST_FRAME_US.load(std::sync::atomic::Ordering::Relaxed) == 0
{
eprintln!("canary: no frame after 15s — aborting for tombstone");
std::process::abort();
}
})
.ok();
let mut builder = EventLoop::with_user_event();
builder.with_android_app(app);
let event_loop = builder.build().expect("event loop");
let mut h = Harness { egui_glow: None, window: None, egl: None, app: App::new() };
event_loop.run_app(&mut h).expect("run");
}
#[cfg(not(target_os = "android"))]
fn main() {
eprintln!("egtest is Android-only");
}