harness: egtest — egui 8580-row grid (egui port control experiment)
This commit is contained in:
parent
f2a7b32a75
commit
2cfb32c746
2670
harnesses/egtest/Cargo.lock
generated
Normal file
2670
harnesses/egtest/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
harnesses/egtest/Cargo.toml
Normal file
45
harnesses/egtest/Cargo.toml
Normal 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"]
|
||||||
40
harnesses/egtest/scripts/build.sh
Executable file
40
harnesses/egtest/scripts/build.sh
Executable 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
352
harnesses/egtest/src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user