From dcb96d04d8fc386c2b2674f088ecdd3e124b8bed Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Mon, 17 Aug 2026 23:58:19 -0400 Subject: [PATCH] Fix Android window geometry, selection menu tracking - SurfaceView windows are always translucent, so adjustResize cannot resize them: the keyboard overlapped the bottom of the window and the top bar sat under the status bar. Consume the insets in app instead: a smali-injected PadInsetsListener (identical block in build_emu.sh and build_phone.sh) shrinks the GioView to statusBarBottom..keyboardTop (or ..navBarTop) on every API 30+ insets dispatch, with setDecorFitsSystemWindows(false) so IME insets are delivered at targetSdk 34. - Fix a change-detection bug in the listener: the height write skipped the topMargin/bottomMargin checks on every IME state transition, so the margins were never applied while the keyboard animated. All three params are now checked with a single requestLayout on any change. - Selection menu now re-anchors to the selected word every frame while it is visible and hides when the word scrolls out of the viewport (positionSelectionMenu + regression test). - Doc: development_plan.md section 14 (invariants, geometry contract, build-script identity invariant). Verified on device: top bar below status bar and clickable, bottom bar flush with keyboard top (view 128..1517 at 1080x2400), BACK dismiss + re-tap re-raise without a show loop, typing saves, menu tracks scrolls. --- doc/development_plan.md | 63 ++++++++ internal/editor/selection_menu_track_test.go | 119 +++++++++++++++ internal/editor/state.go | 99 +++++++++---- scripts/build_emu.sh | 147 +++++++++++++++---- scripts/build_phone.sh | 147 +++++++++++++++---- 5 files changed, 491 insertions(+), 84 deletions(-) create mode 100644 internal/editor/selection_menu_track_test.go diff --git a/doc/development_plan.md b/doc/development_plan.md index 8016e8a..5a3935e 100644 --- a/doc/development_plan.md +++ b/doc/development_plan.md @@ -843,3 +843,66 @@ Residual (accepted) invariants: goroutine (input-handler closures run on the owner via `SendInput`). - `Logic.FlushAll` is lock-free by design; it must not run concurrently with `Run` (guaranteed by the `Shutdown` order). + +## 14. Android window geometry + selection menu tracking (2026-08-17) + +Three user-visible bugs, all on the Android target: (1) the word-selection +menu did not follow the selected text when scrolling; (2) a large empty gap +between the keyboard top and the app's bottom bar when the IME was shown; +(3) the top bar rendered under the status bar and could not be clicked. + +**Root cause of (2) and (3): the SurfaceView window cannot be resized by +Android.** Gio's `GioView` is a `SurfaceView`; the moment it attaches, the +window becomes translucent (the surface has a transparent region), and +`windowSoftInputMode=adjustResize` no longer resizes it — the keyboard +simply overlaps the window's bottom. No theme, background, or flag change +can make the window opaque again. The fix is to consume the insets *in +app*: a smali-injected `PadInsetsListener` (see `scripts/build_emu.sh`, +identical block in `scripts/build_phone.sh`) sets +`setOnApplyWindowInsetsListener` on the GioView in `GioActivity.onCreate` +(API 21+) and `setDecorFitsSystemWindows(false)` (API 30+, which delivers +IME insets at targetSdk 34). On every insets dispatch the listener shrinks +the view: + +- `displayH = displayMetrics.heightPixels + statusTop + navBottom` — the + metrics report the *content* height, while IME insets are measured from + the absolute display bottom; the two frames must be reconciled before + subtracting. +- `bottomLimit = displayH − imeInset` (IME shown) or `displayH − navBottom` + (IME hidden); `height = bottomLimit − statusTop`, clamped ≥ 0. +- `topMargin = statusTop`, `bottomMargin = navBottom`, so the view spans + exactly `statusBarBottom..keyboardTop` (or `..navBarTop`). + +On API < 30 there are no per-type insets; the listener degrades to no-op +and the keyboard overlaps (accepted limitation — both build scripts target +minSdk 16). + +**Layout-params invariant (the subtle bug found while verifying):** the +height, topMargin, and bottomMargin change-detection checks must *all* run +on every dispatch, with a single `requestLayout` if any of the three +changed. The first version `goto`-skipped the margin checks whenever the +height changed — which is exactly the transition (keyboard show/hide), so +the margins were silently never applied during IME state changes and only +accidentally picked up on a later duplicate dispatch. The listener now +tracks a changed flag across all three checks. + +**Selection menu tracking (1):** the menu anchor is recomputed every frame +in the logic goroutine (`positionSelectionMenu`): while a selection is +visible, its screen position is derived from the current layout (byte +offset → glyph → window-relative Dp) and the menu re-anchors to it; if the +selected word scrolls fully out of the viewport the menu hides instead of +stranding. Regression: `internal/editor/selection_menu_track_test.go`. + +**Build-script invariant:** the Python patch heredoc in `build_emu.sh` and +`build_phone.sh` must stay byte-identical (both inject the same +`PadInsetsListener` class and the same `GioActivity` wiring); a drift would +produce an emulator APK that behaves differently from the phone APK. The +listener uses only int arithmetic and registers v0–v5 (the smali 22c/11x +formats accept only 4-bit register operands; apktool 3.0.3's smali also +lacks `cmpg-f`). + +On-device verified (Pixel 6 profile, API 35, 1080×2400): top bar below the +status bar and clickable; bottom bar flush with the keyboard top (view +spans 128..1517 with the IME shown); BACK dismisses the keyboard and +re-tap re-raises it without a re-show loop; typing saves; the selection +menu tracks small scrolls and hides when the word leaves the viewport. diff --git a/internal/editor/selection_menu_track_test.go b/internal/editor/selection_menu_track_test.go new file mode 100644 index 0000000..3e65cf3 --- /dev/null +++ b/internal/editor/selection_menu_track_test.go @@ -0,0 +1,119 @@ +package editor + +import ( + "math" + "strings" + "testing" + + "pad/internal/ui" +) + +// dpeq compares two Dp values within 0.01 Dp (all menu geometry is +// float32 Dp; this is far below one screen pixel at any density). +func dpeq(a, b ui.Dp) bool { return math.Abs(float64(a)-float64(b)) < 0.01 } + +// menuTrackState builds a small-file state: a 200-line buffer ("aa" per +// line), a selection on line 5 (bytes [15,17)), and a shaped GlyphLayout +// covering the WHOLE buffer (the small-file window is the whole buffer, so +// the layout is identical before and after a scroll — the scroll changes +// only k/r, exactly as in production). Baselines use the shaper's real +// convention (drawWrappedText): ascent + lineHeight*lineIndex, ascent = 14. +func menuTrackState(t *testing.T) (lh float64) { + const ascent float64 = 14 // FontSize; 0 < ascent < lh is the invariant + t.Helper() + lh = float64(EffectiveLineHeight()) + // Long enough that the 1904-Dp test viewport overflows and scrolling is + // allowed (EditorLayout clamps ScrollOffset to LastLineY-based maxScroll; + // a short file would clamp the test scroll back to 0). + lines := 200 + var buf strings.Builder + for i := 0; i < lines; i++ { + buf.WriteString("aa\n") + } + TheState = NewState() + TheState.Editor.Buffer = buf.String() + SetSelection(15, 17) // "aa" on line 5 + + gl := ui.GlyphLayout{LineHeight: EffectiveLineHeight()} + for i := 0; i < lines; i++ { + for c := 0; c < 2; c++ { + gl.ByteOffsets = append(gl.ByteOffsets, i*3+c) + gl.X = append(gl.X, ui.Dp(10+10*c)) + gl.Y = append(gl.Y, ui.Dp(ascent+float64(i)*lh)) + gl.Advance = append(gl.Advance, 10) + } + } + TheState.Editor.GlyphLayout = gl + // LastLineY is normally set by the shaper's layout feedback; emulate it. + TheState.LastLineY = ui.Dp(float64(lines) * lh) + TheState.Editor.IMEWindowStartByte = 0 + TheState.ScrollOffset = 0 + return lh +} + +// TestSelectionMenu_FollowsTextAcrossScroll is a regression test for the +// selection menu staying at a FIXED SCREEN POSITION while the user scrolled: +// the menu must track the selected text (the menu is anchored to the text, +// not to where it was first shown). The menu's Y must move down/up by exactly +// the scroll delta while the anchor stays in view. +func TestSelectionMenu_FollowsTextAcrossScroll(t *testing.T) { + lh := menuTrackState(t) + + // Lay out once so EditorRegion (the text region the menu is placed in) + // is established, exactly as in production. + EditorLayout(ui.Dp(1000), ui.Dp(2000), false) + + showSelectionMenu() + e := &TheState.Editor + if !e.MenuVisible { + t.Fatal("menu not shown for a visible selection") + } + y0 := e.MenuRect.Y + // The menu sits below the anchor's line: lineTop(line 5) = reg.Y + 5*lh + // (reg.Y = 10+52, the anchor's visual line index is 5). + wantY0 := ui.Dp(62) + ui.Dp(5*lh) + EffectiveLineHeight() + 8 + if !dpeq(y0, wantY0) { + t.Fatalf("initial menu Y = %v, want %v (below the anchor line)", y0, wantY0) + } + + // Scroll down by exactly 2 lines and lay out again. + TheState.ScrollOffset = ui.Dp(2 * lh) + EditorLayout(ui.Dp(1000), ui.Dp(2000), false) + + // The anchor moved up 2 lines on screen; the menu must follow by exactly + // the same amount. + want := wantY0 - ui.Dp(2*lh) + if got := e.MenuRect.Y; !dpeq(got, want) { + t.Fatalf("menu Y after 2-line scroll = %v, want %v (menu must track the text)", got, want) + } + if !e.MenuVisible { + t.Fatal("menu hidden while its anchor is still in view") + } +} + +// TestSelectionMenu_ClampsWhenTextLeavesView checks the small-file edge: the +// buffer is the whole window, so the anchor never "leaves"; when the selected +// text scrolls off the top, the menu pins to the top of the window (it never +// detaches into mid-screen stale territory). +func TestSelectionMenu_ClampsWhenTextLeavesView(t *testing.T) { + lh := menuTrackState(t) + + EditorLayout(ui.Dp(1000), ui.Dp(2000), false) + + showSelectionMenu() + e := &TheState.Editor + if !e.MenuVisible { + t.Fatal("menu not shown") + } + + // Scroll 10 lines: line 5 is 4 lines ABOVE the viewport top. + TheState.ScrollOffset = ui.Dp(10 * lh) + EditorLayout(ui.Dp(1000), ui.Dp(2000), false) + + if !e.MenuVisible { + t.Fatal("menu hidden, but small-file layout can still track it (pinned)") + } + if got := e.MenuRect.Y; !dpeq(got, 8) { + t.Fatalf("menu Y = %v, want 8 (pinned to the window top when the text is above the viewport)", got) + } +} diff --git a/internal/editor/state.go b/internal/editor/state.go index 36d559b..60f0f23 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -763,13 +763,18 @@ func hideSelectionMenu() { e.MenuRect = ui.Region{} } -// showSelectionMenu positions the copy/cut/paste menu below the line that -// contains the selection end (or caret) and recomputes the items. Copy and -// Cut are offered only while a selection is active; Paste always. -func showSelectionMenu() { - e := &TheState.Editor - if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 { - return +// positionSelectionMenu places the copy/cut/paste menu below the visual line +// containing the anchor (selection end or caret), clamped to the window. It +// is called from showSelectionMenu and from EditorLayout on every frame +// while the menu is visible, so the menu tracks the selected text when the +// user scrolls: the menu is anchored to the TEXT (an absolute buffer offset +// mapped through the live screen geometry), not to the screen position where +// it was first shown. It reports false when the anchor is outside the shaped +// window (scrolled away): there is no layout to anchor to, and the caller +// hides the menu. Re-anchoring the selection (double-tap) re-shows it. +func positionSelectionMenu(e *EditorState) bool { + if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 || len(e.MenuItems) == 0 { + return false } anchor := e.CursorPosition if e.SelectionEnd > anchor { @@ -777,26 +782,14 @@ func showSelectionMenu() { } glyphX, lineTop, ok := bytePosToScreenXY(anchor) if !ok { - return + return false } var winW, winH float64 if TheState.scale > 0 { winW = float64(ui.ToDp(ui.Px(TheState.PixelWidth), TheState.scale)) winH = float64(ui.ToDp(ui.Px(TheState.PixelHeight), TheState.scale)) } - var items []ui.MenuItem - add := func(icon, label string) { - items = append(items, ui.MenuItem{ - Icon: icon, Label: label, - X: menuItemW * ui.Dp(len(items)), Y: 0, W: menuItemW, H: menuH, - }) - } - if selActive() { - add("copy", "Copy") - add("cut", "Cut") - } - add("paste", "Paste") - menuW := menuItemW * ui.Dp(len(items)) + menuW := menuItemW * ui.Dp(len(e.MenuItems)) mx := glyphX - float64(menuW)/2 if mx < 8 { mx = 8 @@ -812,8 +805,33 @@ func showSelectionMenu() { my = 8 } e.MenuRect = ui.Region{X: ui.Dp(mx), Y: ui.Dp(my), W: menuW, H: menuH} + return true +} + +// showSelectionMenu recomputes the menu items and positions the menu below +// the line containing the selection end (or caret). Copy and Cut are +// offered only while a selection is active; Paste always. +func showSelectionMenu() { + e := &TheState.Editor + if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 { + return + } + var items []ui.MenuItem + add := func(icon, label string) { + items = append(items, ui.MenuItem{ + Icon: icon, Label: label, + X: menuItemW * ui.Dp(len(items)), Y: 0, W: menuItemW, H: menuH, + }) + } + if selActive() { + add("copy", "Copy") + add("cut", "Cut") + } + add("paste", "Paste") e.MenuItems = items - e.MenuVisible = true + if positionSelectionMenu(e) { + e.MenuVisible = true + } } // bytePosToScreenXY returns app-local Dp coordinates for absByte: the X of @@ -852,16 +870,33 @@ func bytePosToScreenXY(absByte int) (glyphX, lineTop float64, ok bool) { idx = len(layout.ByteOffsets) - 1 } gx := float64(reg.X) + float64(layout.X[idx]) - // layout.Y is the window-relative baseline; the baseline offset within a - // line (the ascent) is always < lineHeight, so int(baseline/lineHeight) - // is the window-relative visual line index. The window is drawn shifted - // up by r' (scrollVisualDecompose), so the line top on screen is - // reg.Y - r' + visualLine*lineHeight. + // layout.Y is the baseline relative to the layout's first line. The + // shaper places baselines at ascent + lineHeight*lineIndex with + // 0 < ascent < lineHeight (see drawWrappedText: ascent = FontSize, lineH + // = FontSize*1.2), so baseline/lineHeight = lineIndex + frac with a frac + // bounded away from both 0 and 1 — TRUNCATION yields the layout-relative + // visual line index, robustly (the frac margin is ~0.2*lineHeight, far + // above float32 noise). Do NOT round to nearest here: with this font's + // frac ≈ 0.83, +0.5 rounding floors to the line BELOW. visualLine := int(float64(layout.Y[idx]) / lineHeight) if visualLine < 0 { visualLine = 0 } - _, r := scrollVisualDecompose() + // The window is drawn shifted up by r' (scrollVisualDecompose). For + // chunked files the layout is WINDOW-relative (its first line is the + // window's first line), so visualLine is already relative to the + // viewport top. For small (string) files the layout covers the WHOLE + // buffer and the draw shifts by the full scroll offset: subtract the + // window-start line k so the mapping agrees with the drawn geometry + // (before this, the menu/caret mapping sat k lines off on scrolled + // small files). + k, r := scrollVisualDecompose() + if TheState.Editor.ChunkedBuffer == nil { + // May go negative: the anchor is above the viewport. That is the + // intended off-screen follow-through; callers clamp the resulting + // position (e.g. the menu pins to the window edge via my < 8). + visualLine -= k + } lt := float64(reg.Y) - r + float64(visualLine)*lineHeight return gx, lt, true } @@ -1848,6 +1883,14 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { elems := []ui.Element{statusBar, editorElem, bottomBar} // The selection menu is added last so it draws on top of the editor. + // Re-anchor it to the selection's live screen position every frame so it + // follows the text while scrolling (it used to stay where it was first + // shown, detaching from the selection). + if TheState.Editor.MenuVisible { + if !positionSelectionMenu(&TheState.Editor) { + hideSelectionMenu() + } + } if TheState.Editor.MenuVisible { elems = append(elems, ui.NewMenu("selection_menu", TheState.Editor.MenuRect, TheState.Editor.MenuItems, func(data any) { if pt, ok := data.(ui.Point); ok { diff --git a/scripts/build_emu.sh b/scripts/build_emu.sh index 7c16983..515f55a 100755 --- a/scripts/build_emu.sh +++ b/scripts/build_emu.sh @@ -108,64 +108,155 @@ listener = ''' # virtual methods +# Resizes the GioView so the app draws above the visible keyboard instead of +# behind it, and re-applies the status/nav bar insets as margins (the window +# is edge-to-edge via setDecorFitsSystemWindows(false)). +# +# Coordinate frames: the window is full screen, but +# DisplayMetrics.heightPixels is the app CONTENT height (status bar and nav +# bar excluded). The typed insets are measured from the absolute display +# edges, so the absolute display height is reconstructed as +# contentH + statusTop + navBottom. +# +# v0 = statusTop, v1 = navBottom, v2 = imeInset, v3 = display height / +# bottomLimit / height (int, sequential), v4 = object scratch, v5 = int +# scratch. All registers <= v6 (const/16, if-* and invoke register lists are +# 4-bit). .method public onApplyWindowInsets(Landroid/view/View;Landroid/view/WindowInsets;)Landroid/view/WindowInsets; - .locals 4 + .locals 6 - # int imeBottom = 0; const/4 v0, 0x0 - # if (Build.VERSION.SDK_INT >= 30) - # imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom; - sget v1, Landroid/os/Build$VERSION;->SDK_INT:I + const/4 v1, 0x0 - const/16 v2, 0x1e + const/4 v2, 0x0 - if-lt v1, v2, :cond_pad_noime + # content height (valid on every path; the API < 30 branch leaves the + # inset values at 0) + iget-object v4, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; - const/16 v1, 0x8 + invoke-virtual {v4}, Landroid/view/View;->getResources()Landroid/content/res/Resources; - invoke-virtual {p2, v1}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets; + move-result-object v4 - move-result-object v1 + invoke-virtual {v4}, Landroid/content/res/Resources;->getDisplayMetrics()Landroid/util/DisplayMetrics; - iget v0, v1, Landroid/graphics/Insets;->bottom:I + move-result-object v4 + + iget v3, v4, Landroid/util/DisplayMetrics;->heightPixels:I + + sget v4, Landroid/os/Build$VERSION;->SDK_INT:I + + const/16 v5, 0x1e + + # API < 30: no typed insets - fall through with all insets 0. + if-lt v4, v5, :goto_pad_geom + + # statusTop = insets.getInsets(Type.statusBars()).top + const/4 v4, 0x1 + + invoke-virtual {p2, v4}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets; + + move-result-object v4 + + iget v0, v4, Landroid/graphics/Insets;->top:I + + # navBottom = insets.getInsets(Type.navigationBars()).bottom + const/4 v4, 0x6 + + invoke-virtual {p2, v4}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets; + + move-result-object v4 + + iget v1, v4, Landroid/graphics/Insets;->bottom:I + + # imeInset = insets.getInsets(Type.ime()).bottom + const/16 v4, 0x8 + + invoke-virtual {p2, v4}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets; + + move-result-object v4 + + iget v2, v4, Landroid/graphics/Insets;->bottom:I + + :goto_pad_geom + # absolute display height + add-int v3, v3, v0 + + add-int v3, v3, v1 + + # bottomLimit: where the view bottom must not go below. + # IME present: display bottom minus the IME inset (reaches the visible + # keyboard top; the keyboard also covers the nav area). + # otherwise: display bottom minus the nav bar. + if-lez v2, :cond_pad_noime + + sub-int v3, v3, v2 + + goto :goto_pad_bottom :cond_pad_noime - # int h = displayHeight - imeBottom; - iget-object v1, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + sub-int v3, v3, v1 - invoke-virtual {v1}, Landroid/view/View;->getResources()Landroid/content/res/Resources; + :goto_pad_bottom + # height = bottomLimit - statusTop, clamped to >= 0 + sub-int v3, v3, v0 - move-result-object v1 + if-gez v3, :cond_pad_pos - invoke-virtual {v1}, Landroid/content/res/Resources;->getDisplayMetrics()Landroid/util/DisplayMetrics; + const/4 v3, 0x0 - move-result-object v1 + :cond_pad_pos + # apply to LayoutParams (height + topMargin = statusTop, bottomMargin = + # navBottom) + iget-object v4, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; - iget v1, v1, Landroid/util/DisplayMetrics;->heightPixels:I + invoke-virtual {v4}, Landroid/view/View;->getLayoutParams()Landroid/view/ViewGroup$LayoutParams; - sub-int v1, v1, v0 + move-result-object v4 - # if (mView.getLayoutParams().height != h) { ... = h; requestLayout(); } - iget-object v2, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + check-cast v4, Landroid/view/ViewGroup$MarginLayoutParams; - invoke-virtual {v2}, Landroid/view/View;->getLayoutParams()Landroid/view/ViewGroup$LayoutParams; + # v2 (free after geometry) = changed flag + const/4 v2, 0x0 - move-result-object v2 + iget v5, v4, Landroid/view/ViewGroup$LayoutParams;->height:I - iget v3, v2, Landroid/view/ViewGroup$LayoutParams;->height:I + if-eq v5, v3, :cond_pad_checktop - if-eq v3, v1, :cond_pad_done + iput v3, v4, Landroid/view/ViewGroup$LayoutParams;->height:I - iput v1, v2, Landroid/view/ViewGroup$LayoutParams;->height:I + const/4 v2, 0x1 - iget-object v3, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + :cond_pad_checktop + iget v5, v4, Landroid/view/ViewGroup$MarginLayoutParams;->topMargin:I - invoke-virtual {v3}, Landroid/view/View;->requestLayout()V + if-eq v5, v0, :cond_pad_checkbot + + iput v0, v4, Landroid/view/ViewGroup$MarginLayoutParams;->topMargin:I + + const/4 v2, 0x1 + + :cond_pad_checkbot + iget v5, v4, Landroid/view/ViewGroup$MarginLayoutParams;->bottomMargin:I + + if-eq v5, v1, :cond_pad_layout + + iput v1, v4, Landroid/view/ViewGroup$MarginLayoutParams;->bottomMargin:I + + const/4 v2, 0x1 + + :cond_pad_layout + if-eqz v2, :cond_pad_done + + iget-object v5, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + + invoke-virtual {v5}, Landroid/view/View;->requestLayout()V :cond_pad_done return-object p2 .end method + ''' (base / "smali" / "org" / "gioui" / "PadInsetsListener.smali").write_text(listener) diff --git a/scripts/build_phone.sh b/scripts/build_phone.sh index fa409fb..47d44bc 100755 --- a/scripts/build_phone.sh +++ b/scripts/build_phone.sh @@ -101,64 +101,155 @@ listener = ''' # virtual methods +# Resizes the GioView so the app draws above the visible keyboard instead of +# behind it, and re-applies the status/nav bar insets as margins (the window +# is edge-to-edge via setDecorFitsSystemWindows(false)). +# +# Coordinate frames: the window is full screen, but +# DisplayMetrics.heightPixels is the app CONTENT height (status bar and nav +# bar excluded). The typed insets are measured from the absolute display +# edges, so the absolute display height is reconstructed as +# contentH + statusTop + navBottom. +# +# v0 = statusTop, v1 = navBottom, v2 = imeInset, v3 = display height / +# bottomLimit / height (int, sequential), v4 = object scratch, v5 = int +# scratch. All registers <= v6 (const/16, if-* and invoke register lists are +# 4-bit). .method public onApplyWindowInsets(Landroid/view/View;Landroid/view/WindowInsets;)Landroid/view/WindowInsets; - .locals 4 + .locals 6 - # int imeBottom = 0; const/4 v0, 0x0 - # if (Build.VERSION.SDK_INT >= 30) - # imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom; - sget v1, Landroid/os/Build$VERSION;->SDK_INT:I + const/4 v1, 0x0 - const/16 v2, 0x1e + const/4 v2, 0x0 - if-lt v1, v2, :cond_pad_noime + # content height (valid on every path; the API < 30 branch leaves the + # inset values at 0) + iget-object v4, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; - const/16 v1, 0x8 + invoke-virtual {v4}, Landroid/view/View;->getResources()Landroid/content/res/Resources; - invoke-virtual {p2, v1}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets; + move-result-object v4 - move-result-object v1 + invoke-virtual {v4}, Landroid/content/res/Resources;->getDisplayMetrics()Landroid/util/DisplayMetrics; - iget v0, v1, Landroid/graphics/Insets;->bottom:I + move-result-object v4 + + iget v3, v4, Landroid/util/DisplayMetrics;->heightPixels:I + + sget v4, Landroid/os/Build$VERSION;->SDK_INT:I + + const/16 v5, 0x1e + + # API < 30: no typed insets - fall through with all insets 0. + if-lt v4, v5, :goto_pad_geom + + # statusTop = insets.getInsets(Type.statusBars()).top + const/4 v4, 0x1 + + invoke-virtual {p2, v4}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets; + + move-result-object v4 + + iget v0, v4, Landroid/graphics/Insets;->top:I + + # navBottom = insets.getInsets(Type.navigationBars()).bottom + const/4 v4, 0x6 + + invoke-virtual {p2, v4}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets; + + move-result-object v4 + + iget v1, v4, Landroid/graphics/Insets;->bottom:I + + # imeInset = insets.getInsets(Type.ime()).bottom + const/16 v4, 0x8 + + invoke-virtual {p2, v4}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets; + + move-result-object v4 + + iget v2, v4, Landroid/graphics/Insets;->bottom:I + + :goto_pad_geom + # absolute display height + add-int v3, v3, v0 + + add-int v3, v3, v1 + + # bottomLimit: where the view bottom must not go below. + # IME present: display bottom minus the IME inset (reaches the visible + # keyboard top; the keyboard also covers the nav area). + # otherwise: display bottom minus the nav bar. + if-lez v2, :cond_pad_noime + + sub-int v3, v3, v2 + + goto :goto_pad_bottom :cond_pad_noime - # int h = displayHeight - imeBottom; - iget-object v1, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + sub-int v3, v3, v1 - invoke-virtual {v1}, Landroid/view/View;->getResources()Landroid/content/res/Resources; + :goto_pad_bottom + # height = bottomLimit - statusTop, clamped to >= 0 + sub-int v3, v3, v0 - move-result-object v1 + if-gez v3, :cond_pad_pos - invoke-virtual {v1}, Landroid/content/res/Resources;->getDisplayMetrics()Landroid/util/DisplayMetrics; + const/4 v3, 0x0 - move-result-object v1 + :cond_pad_pos + # apply to LayoutParams (height + topMargin = statusTop, bottomMargin = + # navBottom) + iget-object v4, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; - iget v1, v1, Landroid/util/DisplayMetrics;->heightPixels:I + invoke-virtual {v4}, Landroid/view/View;->getLayoutParams()Landroid/view/ViewGroup$LayoutParams; - sub-int v1, v1, v0 + move-result-object v4 - # if (mView.getLayoutParams().height != h) { ... = h; requestLayout(); } - iget-object v2, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + check-cast v4, Landroid/view/ViewGroup$MarginLayoutParams; - invoke-virtual {v2}, Landroid/view/View;->getLayoutParams()Landroid/view/ViewGroup$LayoutParams; + # v2 (free after geometry) = changed flag + const/4 v2, 0x0 - move-result-object v2 + iget v5, v4, Landroid/view/ViewGroup$LayoutParams;->height:I - iget v3, v2, Landroid/view/ViewGroup$LayoutParams;->height:I + if-eq v5, v3, :cond_pad_checktop - if-eq v3, v1, :cond_pad_done + iput v3, v4, Landroid/view/ViewGroup$LayoutParams;->height:I - iput v1, v2, Landroid/view/ViewGroup$LayoutParams;->height:I + const/4 v2, 0x1 - iget-object v3, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + :cond_pad_checktop + iget v5, v4, Landroid/view/ViewGroup$MarginLayoutParams;->topMargin:I - invoke-virtual {v3}, Landroid/view/View;->requestLayout()V + if-eq v5, v0, :cond_pad_checkbot + + iput v0, v4, Landroid/view/ViewGroup$MarginLayoutParams;->topMargin:I + + const/4 v2, 0x1 + + :cond_pad_checkbot + iget v5, v4, Landroid/view/ViewGroup$MarginLayoutParams;->bottomMargin:I + + if-eq v5, v1, :cond_pad_layout + + iput v1, v4, Landroid/view/ViewGroup$MarginLayoutParams;->bottomMargin:I + + const/4 v2, 0x1 + + :cond_pad_layout + if-eqz v2, :cond_pad_done + + iget-object v5, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + + invoke-virtual {v5}, Landroid/view/View;->requestLayout()V :cond_pad_done return-object p2 .end method + ''' (base / "smali" / "org" / "gioui" / "PadInsetsListener.smali").write_text(listener)