From b3a7323b0321dc5a23bb4c9db11c933c8285412f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Tue, 22 Sep 2026 11:06:06 +0300 Subject: [PATCH 1/8] Detach fetch threads from the JVM and free the JNI lock across a request Two faults in the same file, both reachable only through fetch. This target has one app that fetches, so neither has surfaced before. A thread that attaches to the VM has to detach before it exits or ART aborts the process. currentEnv() called AttachCurrentThread and nothing here ever called DetachCurrentThread, while host/host/fetch.cpp runs every request on its own detached std::thread -- so each completed request left a thread exiting while still attached. The attachment now rides that thread's storage and detaches as the thread unwinds, and only for threads this file attached: detaching one that GetEnv found already attached (the UI thread) would tear down its JNI state. androidFetch also held g_jni_mutex across the blocking call into Java, which parks for as long as the request takes. androidConnected() takes the same mutex and the frame thread asks it every tick through wifi().connected(), so the UI stalled for the whole request. The bridge globals are written once, so both functions now copy them under the lock and release it before calling. Co-Authored-By: Claude Opus 5 (1M context) --- targets/android/native/android_network.cpp | 50 +++++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/targets/android/native/android_network.cpp b/targets/android/native/android_network.cpp index ad7f741..89b9372 100644 --- a/targets/android/native/android_network.cpp +++ b/targets/android/native/android_network.cpp @@ -21,15 +21,53 @@ jmethodID g_fetch_method = nullptr; jmethodID g_connected_method = nullptr; std::mutex g_jni_mutex; +// A thread this file attaches to the VM has to detach before it exits, or ART +// aborts the process ("native thread exited without detaching"). Every fetch runs +// on its own detached std::thread (host/host/fetch.cpp), so the guard lives in +// that thread's storage and detaches while the thread unwinds. Threads the VM +// already knows -- the UI thread, which GetEnv finds attached -- are never armed, +// because detaching one we did not attach would tear down its JNI state. +class JniThreadAttachment { +public: + ~JniThreadAttachment() + { + if (attached_ && g_java_vm) g_java_vm->DetachCurrentThread(); + } + + void arm() { attached_ = true; } + +private: + bool attached_ = false; +}; + JNIEnv *currentEnv() { if (!g_java_vm) return nullptr; + thread_local JniThreadAttachment attachment; JNIEnv *env = nullptr; if (g_java_vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) == JNI_OK) return env; if (g_java_vm->AttachCurrentThread(&env, nullptr) != JNI_OK) return nullptr; + attachment.arm(); return env; } +struct BridgeRefs { + jclass clazz = nullptr; + jmethodID fetchMethod = nullptr; + jmethodID connectedMethod = nullptr; +}; + +// The globals are written once, by gea_android_network_set_bridge_class. Copy +// them under the lock and let it go before calling into Java: a fetch parks its +// thread inside HttpURLConnection for up to 35s, and the frame thread asks +// wifi().connected() every tick, so holding the lock across the call would stall +// the UI for the whole request. +BridgeRefs bridgeRefs() +{ + std::scoped_lock guard(g_jni_mutex); + return BridgeRefs{g_bridge_class, g_fetch_method, g_connected_method}; +} + std::int32_t readInt32LE(const std::vector &bytes, std::size_t offset) { if (offset + 4 > bytes.size()) return 0; @@ -103,9 +141,9 @@ gea::host::FetchResponse parsePackedFetch(const std::vector &packe bool androidConnected() { JNIEnv *env = currentEnv(); - std::scoped_lock guard(g_jni_mutex); - if (!env || !g_bridge_class || !g_connected_method) return true; - const jboolean connected = env->CallStaticBooleanMethod(g_bridge_class, g_connected_method); + const BridgeRefs refs = bridgeRefs(); + if (!env || !refs.clazz || !refs.connectedMethod) return true; + const jboolean connected = env->CallStaticBooleanMethod(refs.clazz, refs.connectedMethod); if (env->ExceptionCheck()) { env->ExceptionClear(); return true; @@ -116,11 +154,11 @@ bool androidConnected() gea::host::FetchResponse androidFetch(const std::string &url) { JNIEnv *env = currentEnv(); - std::scoped_lock guard(g_jni_mutex); - if (!env || !g_bridge_class || !g_fetch_method) return {}; + const BridgeRefs refs = bridgeRefs(); + if (!env || !refs.clazz || !refs.fetchMethod) return {}; jstring jUrl = env->NewStringUTF(url.c_str()); if (!jUrl) return {}; - jobject resultObject = env->CallStaticObjectMethod(g_bridge_class, g_fetch_method, jUrl); + jobject resultObject = env->CallStaticObjectMethod(refs.clazz, refs.fetchMethod, jUrl); env->DeleteLocalRef(jUrl); if (env->ExceptionCheck()) { env->ExceptionClear(); From e35e1f1022433594c865344ee5e56d003100ef77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Tue, 22 Sep 2026 11:06:13 +0300 Subject: [PATCH 2/8] Scale an app's declared design width to the real surface width The device pixel ratio was baked in at build time and defaulted to 1.5, which has nothing to do with the panel the app ends up on. On a 1080x2400 phone that makes the logical viewport 720x1600, so an app whose stylesheets carry a desktop breakpoint sees it fire and collapses to the small fixed size behind it -- a card stranded in the middle of the screen. When the app declares gea.designWidth, the view now divides its real width by that instead, which pins the logical width to what the stylesheets were written for and scales every CSS px to fill the panel. The build script has to stand the default down to 0 for such an app, because a configured ratio wins over the computed one; the env override still beats both, for debugging a specific device. Co-Authored-By: Claude Opus 5 (1M context) --- targets/android/build-android.sh | 14 +++++++++++++- targets/android/native/GeaNativeView.java.in | 7 +++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/targets/android/build-android.sh b/targets/android/build-android.sh index 9f30dde..4c70edf 100755 --- a/targets/android/build-android.sh +++ b/targets/android/build-android.sh @@ -340,6 +340,10 @@ APP_DIR="$(node -e "const app = JSON.parse(process.argv[1]); process.stdout.writ APP_ENTRY="$(node -e "const app = JSON.parse(process.argv[1]); process.stdout.write(app.entry)" "$APP_JSON")" APP_RUNTIME="$(node -e "const app = JSON.parse(process.argv[1]); process.stdout.write(app.runtime)" "$APP_JSON")" APP_NAME="$(node -e "const app = JSON.parse(process.argv[1]); process.stdout.write(app.name || app.id)" "$APP_JSON")" +# Logical CSS width the app's stylesheets were authored against, 0 when it +# declares none. The view turns it into a device pixel ratio at runtime, once it +# knows the real surface width. +APP_DESIGN_WIDTH="$(node -e "const app = JSON.parse(process.argv[1]); process.stdout.write(String(app.designWidth || 0))" "$APP_JSON")" if [ "$APP_RUNTIME" != "gea" ]; then echo "ERROR: Android target only supports runtime=gea apps for now: $APP_ID is runtime=$APP_RUNTIME" >&2 @@ -377,7 +381,14 @@ MIN_SDK="${GEA_ANDROID_MIN_SDK:-23}" TARGET_SDK="${ANDROID_PLATFORM_DIR##*-}" ABI="${GEA_ANDROID_ABI:-arm64-v8a}" ORIENTATION="${GEA_ANDROID_SCREEN_ORIENTATION:-portrait}" -DEVICE_PIXEL_RATIO="${GEA_ANDROID_DEVICE_PIXEL_RATIO:-1.5}" +# An app that declares a design width derives its ratio from the real surface +# width, so the baked-in default has to step aside (0 = "let the view decide"). +# The env var still wins over both, for layout debugging on a specific device. +if [ "$APP_DESIGN_WIDTH" != "0" ]; then + DEVICE_PIXEL_RATIO="${GEA_ANDROID_DEVICE_PIXEL_RATIO:-0}" +else + DEVICE_PIXEL_RATIO="${GEA_ANDROID_DEVICE_PIXEL_RATIO:-1.5}" +fi BUILD_DIR="$ANDROID_DIR/build/$APP_ID" DIST_DIR="$ANDROID_DIR/dist/$APP_ID" GENERATED_DIR="$BUILD_DIR/generated" @@ -524,6 +535,7 @@ for template in MainActivity.java GeaNativeBridge.java GeaNativeView.java GeaNat sed \ -e "s/@PACKAGE@/$PACKAGE_NAME/g" \ -e "s/@DEVICE_PIXEL_RATIO@/$DEVICE_PIXEL_RATIO/g" \ + -e "s/@DESIGN_WIDTH@/$APP_DESIGN_WIDTH/g" \ -e "s/@DEBUG_VIEW_BOUNDS@/$DEBUG_VIEW_BOUNDS/g" \ "$NATIVE_TEMPLATE_DIR/$template.in" > "$JAVA_SRC_DIR/$PACKAGE_PATH/$template" done diff --git a/targets/android/native/GeaNativeView.java.in b/targets/android/native/GeaNativeView.java.in index 8cbcd4d..d0fc47f 100644 --- a/targets/android/native/GeaNativeView.java.in +++ b/targets/android/native/GeaNativeView.java.in @@ -56,6 +56,7 @@ import java.util.Set; public final class GeaNativeView extends FrameLayout implements Choreographer.FrameCallback { private static final int FIELDS = 88; private static final float CONFIGURED_DEVICE_PIXEL_RATIO = @DEVICE_PIXEL_RATIO@f; + private static final float DESIGN_WIDTH = @DESIGN_WIDTH@f; private static final boolean DEBUG_VIEW_BOUNDS = @DEBUG_VIEW_BOUNDS@; private static final int DISPLAY_NONE = 1; private static final int DISPLAY_FLEX = 3; @@ -712,6 +713,12 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr private float devicePixelRatio() { if (CONFIGURED_DEVICE_PIXEL_RATIO > 0.0f) return CONFIGURED_DEVICE_PIXEL_RATIO; + // An app that declares a design width was laid out in those logical px, so + // scale it to fill this surface instead of following the panel's density: + // the engine multiplies every CSS px by the ratio we report. Recomputed per + // frame, so a surface resize reflows (ensureInitialized picks up the change). + int width = getWidth(); + if (DESIGN_WIDTH > 0.0f && width > 0) return width / DESIGN_WIDTH; float density = getResources().getDisplayMetrics().density; return density > 0.0f ? density : 1.0f; } From 4a2fef2b6db458379e4c0d03904ca541c2d830bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Tue, 22 Sep 2026 12:15:12 +0300 Subject: [PATCH 3/8] Apply object-fit to images instead of always stretching them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageNodeView hard-coded ScaleType.FIT_XY, so every image was stretched to fill its box whatever the CSS said. object-fit never reached this side at all — the node array carried image_id but not image_fit — so the value had to be added before it could be honoured. Weather's forecast icons show the cost: 30x30 artwork in a 24x20 slot declared `object-fit: contain`, drawn 20% too wide on every hour and day in the rail. The field is appended at the end of the array so existing indices are untouched; the count moves 88 -> 89 on both sides. ImageView has no scale-down equivalent, so that value maps to FIT_CENTER, the nearest honest behaviour — it differs from CSS only for an image smaller than its box. Co-Authored-By: Claude Opus 5 (1M context) --- targets/android/native/GeaNativeView.java.in | 20 +++++++++++++++++++- targets/android/native/android_jni.cpp.in | 5 ++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/targets/android/native/GeaNativeView.java.in b/targets/android/native/GeaNativeView.java.in index d0fc47f..0999d84 100644 --- a/targets/android/native/GeaNativeView.java.in +++ b/targets/android/native/GeaNativeView.java.in @@ -54,7 +54,7 @@ import java.util.Map; import java.util.Set; public final class GeaNativeView extends FrameLayout implements Choreographer.FrameCallback { - private static final int FIELDS = 88; + private static final int FIELDS = 89; private static final float CONFIGURED_DEVICE_PIXEL_RATIO = @DEVICE_PIXEL_RATIO@f; private static final float DESIGN_WIDTH = @DESIGN_WIDTH@f; private static final boolean DEBUG_VIEW_BOUNDS = @DEBUG_VIEW_BOUNDS@; @@ -155,6 +155,7 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr private static final int O_PADDING_BOTTOM = 85; private static final int O_PADDING_LEFT = 86; private static final int O_FONT_WEIGHT = 87; + private static final int O_IMAGE_FIT = 88; private static final int TAG_P = tagHash("p"); private static final int TAG_INPUT = tagHash("input"); private static final int MAX_TOUCH_SLOTS = 10; @@ -1017,6 +1018,7 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr } else if (view instanceof EditTextNodeView) { applyEditTextProps((EditTextNodeView) view, nodes, texts, base); } else if (view instanceof ImageNodeView) { + ((ImageNodeView) view).setImageFit(nodes[base + O_IMAGE_FIT]); ((ImageNodeView) view).setImageId(nodes[base + O_IMAGE_ID], imageBitmaps); view.setContentDescription("gea-image:" + id + ":" + nodes[base + O_IMAGE_ID]); } else { @@ -1925,6 +1927,22 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr setClipToOutline(false); } + // object-fit. FIT_XY was hard-coded here, so every image stretched to its box + // regardless of what the CSS asked for — weather's forecast icons are square + // in a 24x20 slot and came out 20% too wide. ImageView has no scale-down, so + // that value takes the nearest honest behaviour, FIT_CENTER. + void setImageFit(int fit) { + final ImageView.ScaleType scaleType; + switch (fit) { + case 1: scaleType = ImageView.ScaleType.FIT_CENTER; break; // contain + case 2: scaleType = ImageView.ScaleType.CENTER_CROP; break; // cover + case 3: scaleType = ImageView.ScaleType.CENTER; break; // none + case 4: scaleType = ImageView.ScaleType.FIT_CENTER; break; // scale-down + default: scaleType = ImageView.ScaleType.FIT_XY; // fill + } + if (getScaleType() != scaleType) setScaleType(scaleType); + } + void setImageId(int imageId, Map cache) { if (imageId < 0) { if (currentImageId != imageId || getDrawable() != null) setImageDrawable(null); diff --git a/targets/android/native/android_jni.cpp.in b/targets/android/native/android_jni.cpp.in index 71d2c71..0a6f617 100644 --- a/targets/android/native/android_jni.cpp.in +++ b/targets/android/native/android_jni.cpp.in @@ -43,7 +43,7 @@ extern "C" void gea_android_touch_set_state(bool touching, int x, int y); namespace { -constexpr int kFields = 88; +constexpr int kFields = 89; bool g_initialized = false; bool g_app_update_requested = false; bool g_css_animation_scan_pending = false; @@ -490,6 +490,9 @@ void appendNode(gea::embedded::ui::Tree &tree, int nodeId, std::vector &ou out.push_back(std::max(0, node.style.padding[2])); out.push_back(std::max(0, node.style.padding[3])); out.push_back(std::clamp(node.style.font_weight, 1, 1000)); + // object-fit: 0 fill, 1 contain, 2 cover, 3 none, 4 scale-down (imageFitValue + // in the style engine). Appended last so the existing field indices stay put. + out.push_back(node.style.image_fit); for (int child = node.first_child; child >= 0; child = tree.node(child).next_sibling) { appendNode(tree, child, out); From a94ca56e1247e2af32e5b41f2fb38a6f4fae8e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Mon, 28 Sep 2026 00:34:46 +0300 Subject: [PATCH 4/8] Fall back to package.json for an app's design width The build read designWidth only from the CLI's app summary, and the published CLI's `inspect` does not report the field yet. An app built with it got 0, so the view rendered at panel density in a corner of the surface instead of scaling its layout to the width. Read `gea.designWidth` from the app's own package.json when the summary has none, as build-windows.mjs does. Co-Authored-By: Claude Opus 5.5 (1M context) --- targets/android/build-android.sh | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/targets/android/build-android.sh b/targets/android/build-android.sh index 4c70edf..15b31fd 100755 --- a/targets/android/build-android.sh +++ b/targets/android/build-android.sh @@ -342,8 +342,24 @@ APP_RUNTIME="$(node -e "const app = JSON.parse(process.argv[1]); process.stdout. APP_NAME="$(node -e "const app = JSON.parse(process.argv[1]); process.stdout.write(app.name || app.id)" "$APP_JSON")" # Logical CSS width the app's stylesheets were authored against, 0 when it # declares none. The view turns it into a device pixel ratio at runtime, once it -# knows the real surface width. -APP_DESIGN_WIDTH="$(node -e "const app = JSON.parse(process.argv[1]); process.stdout.write(String(app.designWidth || 0))" "$APP_JSON")" +# knows the real surface width. The published CLI's `inspect` does not report +# the field yet, so fall back to the app's own package.json `gea.designWidth` +# (as build-windows.mjs does); without it the layout would render at panel +# density instead of filling the width. +APP_DESIGN_WIDTH="$(node -e " +const fs = require('fs') +const path = require('path') +const app = JSON.parse(process.argv[1]) +let width = Number(app.designWidth || 0) +if (!(width > 0)) { + try { + width = Number(JSON.parse(fs.readFileSync(path.join(app.root, 'package.json'), 'utf8')).gea?.designWidth || 0) + } catch { + width = 0 + } +} +process.stdout.write(String(width > 0 ? width : 0)) +" "$APP_JSON")" if [ "$APP_RUNTIME" != "gea" ]; then echo "ERROR: Android target only supports runtime=gea apps for now: $APP_ID is runtime=$APP_RUNTIME" >&2 From e0e4b4de547fd04d955b942aae72e28851e01bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Mon, 28 Sep 2026 00:34:46 +0300 Subject: [PATCH 5/8] Stop moving a scroller's children by its offset twice The engine already places a scroller's children at their scrolled positions: resolveAbsoluteCoords subtracts scroll_x/scroll_y from every child. The view placed them there inside the ScrollView's content and then scrolled the content by the same offset (setNativeScroll), so a rail moved twice as far as the finger, a blank strip opened at its end, and a tap after scrolling landed on a different chip than the one drawn under it -- the engine hit-tests the single-offset positions. Undo the engine's share for a ScrollView's children, using the offset the view actually took, so only the native scroll moves them and what is drawn is what the engine hit-tests. This holds for vertical lists and virtual lists too, and a scroll step no longer re-lays-out every child. Co-Authored-By: Claude Opus 5.5 (1M context) --- targets/android/native/GeaNativeView.java.in | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/targets/android/native/GeaNativeView.java.in b/targets/android/native/GeaNativeView.java.in index 0999d84..d6994e3 100644 --- a/targets/android/native/GeaNativeView.java.in +++ b/targets/android/native/GeaNativeView.java.in @@ -841,6 +841,18 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr parentX = nodes[parentBase + O_X]; parentY = nodes[parentBase + O_Y]; } + // The engine has already moved a scroller's children by its offset + // (resolveAbsoluteCoords subtracts scroll_x/scroll_y), and the ScrollView + // moves its content by it again (place -> setNativeScroll). Undo the + // engine's share so the children sit in the content's unscrolled space and + // only the native scroll moves them: content then tracks the finger 1:1, + // what is drawn is what the engine hit-tests, and a scroll step no longer + // re-lays-out every child. The offset the ScrollView actually took is used, + // so one it had to clamp still draws where the engine placed the children. + if (parentView instanceof ScrollNodeView) { + parentX -= parentView.getScrollX(); + parentY -= parentView.getScrollY(); + } } if (view.getParent() != parent) { if (view.getParent() instanceof ViewGroup) ((ViewGroup) view.getParent()).removeView(view); From 4ed0a76a4cbd16a455eab1e48d28915762662c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Mon, 28 Sep 2026 00:34:58 +0300 Subject: [PATCH 6/8] Use the platform touch slop and pan a rail only on sideways travel A press on a scroller became a drag after 8 raw px, which is about 2 CSS px on a 1080-wide phone. The jitter of an ordinary tap on a rail's chip crossed it, turned into a pan and lost its click. Take the slop from ViewConfiguration instead, which is sized to a fingertip on this panel. A rail that scrolls only sideways also claimed any travel past the slop, vertical included. Lock it only when the travel is mostly sideways, and hand a vertical swipe that happens to start on a rail back to the engine. Co-Authored-By: Claude Opus 5.5 (1M context) --- targets/android/native/GeaNativeView.java.in | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/targets/android/native/GeaNativeView.java.in b/targets/android/native/GeaNativeView.java.in index d6994e3..63973cb 100644 --- a/targets/android/native/GeaNativeView.java.in +++ b/targets/android/native/GeaNativeView.java.in @@ -159,7 +159,6 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr private static final int TAG_P = tagHash("p"); private static final int TAG_INPUT = tagHash("input"); private static final int MAX_TOUCH_SLOTS = 10; - private static final int NATIVE_SCROLL_TOUCH_SLOP = 8; private static final float NATIVE_SCROLL_FLING_BOOST = 1.35f; private final Map views = new HashMap<>(); @@ -191,6 +190,7 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr private boolean nativeScrollStarted; private final OverScroller nativeScrollFling; private final Runnable nativeScrollFlingStep; + private final int nativeScrollTouchSlop; private final int nativeScrollMinimumFlingVelocity; private final int nativeScrollMaximumFlingVelocity; private VelocityTracker nativeScrollVelocityTracker; @@ -210,6 +210,11 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr super(context); nativeScrollFling = new OverScroller(context); ViewConfiguration viewConfig = ViewConfiguration.get(context); + // How far a finger may wander before a press on a scroller becomes a drag. + // A fixed 8 raw px was ~2 CSS px on a 1080-wide phone, so the jitter of an + // ordinary tap on a rail's chip turned into a pan and lost its click. The + // platform's slop is sized to a fingertip on this panel. + nativeScrollTouchSlop = viewConfig.getScaledTouchSlop(); nativeScrollMinimumFlingVelocity = viewConfig.getScaledMinimumFlingVelocity(); nativeScrollMaximumFlingVelocity = viewConfig.getScaledMaximumFlingVelocity(); nativeScrollFlingStep = new Runnable() { @@ -325,11 +330,15 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr if (nativeScrollAxis == 0) { int totalDx = x - nativeScrollStartX; int totalDy = y - nativeScrollStartY; - if (Math.abs(totalDx) > NATIVE_SCROLL_TOUCH_SLOP || Math.abs(totalDy) > NATIVE_SCROLL_TOUCH_SLOP) { + if (Math.abs(totalDx) > nativeScrollTouchSlop || Math.abs(totalDy) > nativeScrollTouchSlop) { boolean canScrollX = nativeScrollTouchTarget.canScrollX(); boolean canScrollY = nativeScrollTouchTarget.canScrollY(); - if (canScrollX && (!canScrollY || Math.abs(totalDx) >= Math.abs(totalDy))) nativeScrollAxis = 1; + // Only a mostly sideways travel pans sideways, even on a rail that + // scrolls no other way: a vertical swipe that happens to start on + // one is not the rail's, so it goes back to the engine unclaimed. + if (canScrollX && Math.abs(totalDx) >= Math.abs(totalDy)) nativeScrollAxis = 1; else if (canScrollY) nativeScrollAxis = 2; + else resetNativeScrollTouch(); } } boolean changed = false; From 9af8556eba9cd28dd922d5730c3b8131d791f595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Mon, 28 Sep 2026 00:35:10 +0300 Subject: [PATCH 7/8] Clip overflow boxes and cropped images to their own bounds O_OVERFLOW reached the view but nothing read it, so `overflow: hidden` never clipped, and a scroll rail's content drew past the rail's edge. Neither ViewGroup switch can express the clip here: clipToPadding is skipped in dispatchDraw while the padding is all zero, which it is for every container, and clipChildren is the parent's say over its children, which stays off so text ink can spill out of its box. Clip a box with any overflow but visible, and every scroller, to its own BOUNDS outline instead; it holds whatever the parent does and stays put while the content scrolls beneath it. An ImageView with object-fit cover or none draws the bitmap larger than its box and only crops it when asked, so the backdrop spilled over the neighbouring layout. Set cropToPadding on image nodes. Co-Authored-By: Claude Opus 5.5 (1M context) --- targets/android/native/GeaNativeView.java.in | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/targets/android/native/GeaNativeView.java.in b/targets/android/native/GeaNativeView.java.in index 63973cb..f67fc05 100644 --- a/targets/android/native/GeaNativeView.java.in +++ b/targets/android/native/GeaNativeView.java.in @@ -39,6 +39,7 @@ import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; +import android.view.ViewOutlineProvider; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; @@ -1032,6 +1033,7 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr applyTransform(view, nodes, base); applyBackgroundIfChanged(id, view, nodes, base); + applyOverflowClip(view, nodes, base); if (view instanceof TextNodeView) applyTextContainerPadding((TextNodeView) view, nodes, base); if (view instanceof ParagraphNodeView) { @@ -1089,6 +1091,23 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr input.setGravity(textGravity(nodes, base)); } + // Any overflow but visible (hidden, clip, and an auto/scroll box whose content + // happens to fit) keeps a box's children inside it, as win32's clipsChildren + // does; a scroller always clips to its viewport. Neither ViewGroup switch can + // say that here: clipToPadding is skipped in dispatchDraw while the padding is + // all zero (containers carry none), and clipChildren is the parent's say over + // its children, which every container turns off so text ink can spill out of + // its box. Clipping to the view's own outline holds whatever the parent does, + // and a BOUNDS outline stays put while the content scrolls beneath it. Text + // and image nodes are not containers. + private void applyOverflowClip(View view, int[] nodes, int base) { + if (!(view instanceof FrameLayout) || view instanceof TextNodeView) return; + boolean clip = view instanceof ScrollNodeView || nodes[base + O_OVERFLOW] != 0; + if (view.getClipToOutline() == clip) return; + if (clip) view.setOutlineProvider(ViewOutlineProvider.BOUNDS); + view.setClipToOutline(clip); + } + private int fadedColor(int color, int alpha) { int baseAlpha = (color >>> 24) & 0xff; int outAlpha = baseAlpha == 0 ? alpha : (baseAlpha * alpha + 127) / 255; @@ -1946,6 +1965,11 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr setScaleType(ImageView.ScaleType.FIT_XY); setAdjustViewBounds(false); setClipToOutline(false); + // cover and none draw the bitmap larger than the box; CSS crops that at + // the content box. ImageView only clips its drawable when asked to, and + // the parent's clip (clipChildren) is off, so cover spilled out over the + // neighbouring layout. + setCropToPadding(true); } // object-fit. FIT_XY was hard-coded here, so every image stretched to its box From 665a1ac28a82e5a3993549f03bc5a79693fa638a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Mon, 28 Sep 2026 00:35:10 +0300 Subject: [PATCH 8/8] Centre text in its CSS line box and honour text-align on one line Text was pinned to the top of its box and then lifted by visualTextShiftY, a nudge with absolute px thresholds tuned at a device pixel ratio of 1.5. At the ~4 a phone gets under a design width they no longer scale: text sat several CSS px too high, the clamp meant it could never move down, and the degree-sign special case stopped firing. Replace it with the CSS half-leading taken from the paint's own metrics, in either direction: a line box shorter than the font's natural line lets the glyphs spill out of it unclipped, one taller centres them. The TextView already advances lines by the line-height, so one shift centres every line. A single line is measured at its natural width, so the TextView's gravity had no room to act on it and `text-align: center` rendered left-aligned (the forecast labels and temperatures). Place the line in its box by the horizontal text-align instead; a line too long for its box stays start-aligned, as CSS specifies. Co-Authored-By: Claude Opus 5.5 (1M context) --- targets/android/native/GeaNativeView.java.in | 59 +++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/targets/android/native/GeaNativeView.java.in b/targets/android/native/GeaNativeView.java.in index f67fc05..fafd666 100644 --- a/targets/android/native/GeaNativeView.java.in +++ b/targets/android/native/GeaNativeView.java.in @@ -1059,12 +1059,14 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr setTypefaceIfChanged(text, typefaceForFamily(fontFamily, nodes[base + O_FONT_WEIGHT])); applyLineHeight(text, nodes[base + O_LINE_HEIGHT]); boolean boundedWidth = applyTextWrapping(text, nodes, base); + int gravity = textGravity(nodes, base); if (view instanceof TextNodeView) { TextNodeView textNode = (TextNodeView) view; textNode.setBoundedTextWidth(boundedWidth); - textNode.setVisualTextShiftY(visualTextShiftY(fontSize, nodes[base + O_LINE_HEIGHT], displayValue)); + textNode.setLineGravity(gravity & Gravity.HORIZONTAL_GRAVITY_MASK); + textNode.setLineShiftY(halfLeading(text, nodes[base + O_LINE_HEIGHT])); } - setGravityIfChanged(text, textGravity(nodes, base)); + setGravityIfChanged(text, gravity); } } @@ -1517,16 +1519,18 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr text.setLineSpacing(target - fontHeight, 1.0f); } - private int visualTextShiftY(int fontSize, int lineHeight, String value) { - int size = Math.max(1, fontSize); - int targetLineHeight = Math.max(0, lineHeight); - int compressed = targetLineHeight > 0 ? Math.max(0, size - targetLineHeight) : 0; - int shift = Math.round(size * 0.38f - 5.0f + compressed / 3.0f); - if (shift <= 0) return 0; - if (size < 18) return Math.min(shift, 1); - if (size < 24) return Math.min(shift, 4); - if ("°".equals(value) && size <= 30) return shift + 5; - return shift; + // CSS centres each line's glyph run (ascent + descent) in its line box. The + // TextView already advances lines by the line-height (applyLineHeight) but + // starts every run at the top of its box, so the block moves by the + // half-leading: down when the line box is taller than the font's natural + // line, up -- glyphs spilling out of the box, as in a browser -- when shorter. + // A normal line-height is the natural line, which the engine measured the box + // with, so it needs none. Taken from the paint's metrics rather than tuned px + // offsets so it holds at any density and for any font. + private int halfLeading(TextView text, int lineHeight) { + if (lineHeight <= 0) return 0; + Paint.FontMetricsInt metrics = text.getPaint().getFontMetricsInt(); + return Math.round((lineHeight - (metrics.descent - metrics.ascent)) / 2.0f); } private Typeface typefaceForFamily(String family) { @@ -2469,7 +2473,9 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr private final Rect parentBounds = new Rect(); private final Rect childBounds = new Rect(); private boolean boundedTextWidth = true; - private int visualTextShiftY = 0; + private int lineGravity = Gravity.LEFT; + // Half-leading, positive down (see halfLeading). + private int lineShiftY = 0; TextNodeView(Context context) { super(context); @@ -2487,10 +2493,21 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr requestLayout(); } - void setVisualTextShiftY(int shiftY) { - int next = Math.max(0, shiftY); - if (visualTextShiftY == next) return; - visualTextShiftY = next; + // The horizontal text-align. A single line is measured at its natural width, + // so the TextView's own gravity has no room to act on it: the line itself is + // placed in the box instead (onLayout). + void setLineGravity(int horizontalGravity) { + if (lineGravity == horizontalGravity) return; + lineGravity = horizontalGravity; + requestLayout(); + } + + // Either direction: a line box shorter than the font's natural line lifts the + // glyphs above the box. Nothing between here and an overflow box clips them + // (clipChildren stays off), matching a browser's visible ink overflow. + void setLineShiftY(int shiftY) { + if (lineShiftY == shiftY) return; + lineShiftY = shiftY; requestLayout(); } @@ -2508,10 +2525,12 @@ public final class GeaNativeView extends FrameLayout implements Choreographer.Fr @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) text.getLayoutParams(); parentBounds.set(getPaddingLeft(), getPaddingTop(), right - left - getPaddingRight(), bottom - top - getPaddingBottom()); - Gravity.apply(lp.gravity, text.getMeasuredWidth(), text.getMeasuredHeight(), parentBounds, childBounds); - text.layout(childBounds.left, childBounds.top - visualTextShiftY, childBounds.right, childBounds.bottom - visualTextShiftY); + // A line too long for its box is start-aligned whatever the text-align, + // overflowing the end edge (CSS Text, text-align). + int horizontal = text.getMeasuredWidth() <= parentBounds.width() ? lineGravity : Gravity.LEFT; + Gravity.apply(horizontal | Gravity.TOP, text.getMeasuredWidth(), text.getMeasuredHeight(), parentBounds, childBounds); + text.layout(childBounds.left, childBounds.top + lineShiftY, childBounds.right, childBounds.bottom + lineShiftY); } private static int childMeasureSpec(int parentSpec, int padding) {