From 941285e7cc79ad3afeee9e52defb34a71d3d3312 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Mon, 17 Aug 2026 21:52:42 -0400 Subject: [PATCH] Fix scroll-anchored selection (shadowing) + IME insets keyboard handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two user-reported bugs in the post-wrap build: 1. Selection 'jumped' when scrolling: frameOf's chunked branch shadowed the outer start/end with 'start, end, winLine := cb.VisibleByteRange(...)', leaving IMEWindowStartByte at 0 for chunked files while scrolled, so the window-relative selection highlight (and IME commits) mis-mapped near the file top. Fix: declare winLine outside, assign. Mutation-verified regression tests: real_file_scroll_selection_test.go. 2. Bottom bar hidden behind the keyboard: GioView extends SurfaceView, which unconditionally marks the window FORMAT_TRANSLUCENT; translucent windows are never IME-resized, so adjustResize is dead. Fix (build scripts): smali-patch a PadInsetsListener OnApplyWindowInsetsListener onto the GioView (shrinks it by the IME inset bottom) + setDecorFitsSystemWindows (false) on API 30+ so insets are dispatched. targetSdk kept at 34. 3. (Found while fixing 2) Keyboard could not be dismissed: TextField.Draw issued SoftKeyboardCmd{Show:true} every frame; with insets-driven resizes, the hide animation triggered redraws that re-showed the keyboard mid-animation. Fix: ShowIMESeq pulse from the logic layer (open/tap/ double-tap); renderer shows only on pulse change, re-arming on focus loss — matching widget.Editor. On-device (emulator): BACK dismisses and stays down; tap re-shows; typing works; bottom bar above keyboard; word selection anchored across flick scroll. go test -race ./... green. Phone APK rebuilt with the same patch. --- doc/development_plan.md | 49 +++++ internal/editor/state.go | 21 +- .../e2e/real_file_scroll_selection_test.go | 197 ++++++++++++++++++ internal/ui/element.go | 23 +- internal/ui/render.go | 5 +- scripts/build_emu.sh | 161 +++++++++++++- scripts/build_phone.sh | 162 +++++++++++++- 7 files changed, 610 insertions(+), 8 deletions(-) create mode 100644 internal/test/e2e/real_file_scroll_selection_test.go diff --git a/doc/development_plan.md b/doc/development_plan.md index fe42a9e..8016e8a 100644 --- a/doc/development_plan.md +++ b/doc/development_plan.md @@ -626,6 +626,55 @@ LINE005-vl0 — pixel-exact 1:1 finger↔content, no jump at the wrapped boundaries (dp 134 is where the old code jumped to LINE008); bottom clamp lands exactly on the file end via the wrap-aware TotalVisuals(). +### Phase 14 — scroll-anchored selection + keyboard/bottom-bar — DONE (2026-08-17) + +Two user-reported bugs in the post-wrap build (`83f7aff`): + +**Bug 1 — selection "jumps" when scrolling.** Root cause was a Go variable +shadowing regression introduced by the wrap fix: in `frameOf` the chunked +branch declared `start, end, winLine := cb.VisibleByteRange(...)` inside an +inner scope, shadowing the function's outer `start, end` — so +`IMEWindowStartByte` stayed 0 for chunked (multi-chunk) files even while +scrolled. The selection highlight (window-relative coords, mapped against +`IMEWindowStartByte`) and IME commits while scrolled were both mis-mapped +to near the file top. Fixed by declaring `winLine` in the outer scope and +assigning (not re-declaring). Regression tests: +`internal/test/e2e/real_file_scroll_selection_test.go` (window selection at +scroll: `IMEWindowStartByte` and window-relative selection asserted; IME +commit at scroll lands on the right line) — both mutation-verified against +the shadowing. + +**Bug 2 — bottom bar hidden behind the keyboard.** Root cause chain: +`GioView extends SurfaceView` (GioView.java) → SurfaceView unconditionally +calls `requestTransparentRegion` → the window is marked +`FORMAT_TRANSLUCENT` (ViewRootImpl) → translucent windows are never resized +by the IME → `windowSoftInputMode=adjustResize` is dead. No theme or +background change can override this (verified: both patches attempted, +format stayed TRANSLUCENT). Fix (emulator + phone build scripts): a smali +patch adds `PadInsetsListener` (a `View$OnApplyWindowInsetsListener` on the +GioView) and, in `GioActivity.onCreate` (API 30+ only), +`window.setDecorFitsSystemWindows(false)` so IME insets are dispatched to +the view; the listener shrinks the GioView by the IME inset bottom +(`insets.getInsets(Type.ime()).bottom`), so the Go side sees a smaller +surface and lays the bottom bar above the keyboard. On API < 30 there are +no IME insets; the keyboard overlaps there (accepted limitation). + +**Bug 3 (found while fixing 2) — keyboard impossible to dismiss.** +`TextField.Draw` issued `key.SoftKeyboardCmd{Show: true}` every frame while +focused. With the insets patch, each step of the keyboard's HIDE animation +dispatches insets → requestLayout → surface resize → Go frame → Draw → +show → the keyboard re-shows mid-animation. Fix: a `ShowIMESeq` pulse from +the logic layer (bumped on file open, tap, double-tap) — the renderer +issues show only when the pulse changes (and re-arms on focus loss), +matching `widget.Editor`, which shows on focus gain/click only, never per +frame. + +**On-device (emulator):** keyboard dismisses with BACK and stays down; +tapping the editor re-shows it; typing still works; bottom bar visible +above the keyboard; double-tap word selection stays anchored to the same +word across a flick scroll. `go test -race ./...` green. Phone APK rebuilt +with the same smali patch. + ## 6. File-size decision (re-framed) v1 framed this as "accept a limit vs build a windowed editor." The live repo diff --git a/internal/editor/state.go b/internal/editor/state.go index 2645ffa..36d559b 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -85,6 +85,11 @@ type EditorState struct { // when its EditSeq matches, so a layout shaped before an edit can never // stamp stale wrap counts onto shifted lines. EditSeq uint64 + // ShowIMESeq pulses whenever the logic layer wants the soft keyboard up + // (file open, tap/double-tap on the editor). The renderer issues + // SoftKeyboardCmd{Show:true} only on a change (see TextField.ShowIMESeq), + // so a user-dismissed keyboard stays down until the next pulse. + ShowIMESeq uint64 // --- Touch selection (v1) --- // CaretDrag: after a long press on blank space a single draggable caret // handle is shown (no selection). MenuVisible/MenuRect/MenuItems: the @@ -381,6 +386,7 @@ func OpenFile(data any) { TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file TheState.page = EditorPage TheState.FocusedElementID = "editor_text" // Set focus to editor + TheState.Editor.ShowIMESeq++ // raise the keyboard for the newly opened file // The tap that opened this file is delivered to the browser row, but its // gesture can leak into the now-visible editor and move the cursor/scroll. // Record the open time so the editor can swallow taps in a short window @@ -882,6 +888,7 @@ func HandleTapAt(x, y ui.Dp) { localX := float64(x - TheState.EditorRegion.X) localY := tapLocalY(y, TheState.EditorRegion.Y) SetCursorFromPoint(localX, localY) + TheState.Editor.ShowIMESeq++ // tap = intent to type: re-raise a dismissed keyboard } // HandleLongPressAt implements Android long-press: on a word it selects the @@ -941,6 +948,7 @@ func HandleDoubleTapAt(x, y ui.Dp) { } else { SetCursorFromPoint(localX, localY) } + TheState.Editor.ShowIMESeq++ // tap = intent to type: re-raise a dismissed keyboard } // HandleSelDragEvt is the registered SelDrag interaction handler. The @@ -1699,7 +1707,14 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 { lineHeight = lh } - start, end, winLine := cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil) + // Assign (not :=) into the outer start/end: they are read below the + // block to derive IMEWindowStartByte and the window-relative + // selection. A `:=` here would shadow them (winLine is new) and the + // outer pair would stay 0 — every scrolled frame would then map the + // selection against byte 0 (the selection "jumps" with the scroll) and + // IME commits would land at the wrong buffer position. + var winLine int + start, end, winLine = cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil) // Ship with the frame: the shaped layout's wrap counts belong to THIS // window's lines (a scroll may move the window before the layout // arrives, but an edit invalidates it — see EditorState.EditSeq). @@ -1824,7 +1839,9 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { }, ) // Set Focused so TextField.Draw() issues key.FocusCmd, which is required - // for Gio to deliver key events to this element. + // for Gio to deliver key events to this element. ShowIMESeq carries the + // keyboard-raise pulse (see TextField.ShowIMESeq). + editorElem.ShowIMESeq = TheState.Editor.ShowIMESeq editorElem.Focused = TheState.FocusedElementID == "editor_text" // Caret handle visibility (long press on blank space). editorElem.CaretDrag = TheState.Editor.CaretDrag diff --git a/internal/test/e2e/real_file_scroll_selection_test.go b/internal/test/e2e/real_file_scroll_selection_test.go new file mode 100644 index 0000000..7af42b2 --- /dev/null +++ b/internal/test/e2e/real_file_scroll_selection_test.go @@ -0,0 +1,197 @@ +package e2e_test + +import ( + "fmt" + "strings" + "testing" + "time" + + "gioui.org/io/key" + "pad/internal/editor" + "pad/internal/test/e2e" + "pad/internal/ui" +) + +// uniformContent returns a file of n lines, each exactly 21 bytes: +// "Lxxx" + 16 filler chars (derived from the line number, so every line is +// unique) + "\n". Line i therefore starts at byte i*21. +func uniformContent(n int) string { + var b strings.Builder + for i := 0; i < n; i++ { + b.WriteString(fmt.Sprintf("L%03d", i)) + b.WriteString(strings.Repeat(string(rune('a'+i%26)), 16)) + b.WriteString("\n") + } + return b.String() +} + +// The chunked-buffer window bookkeeping must stay in lockstep with the +// visible window text: +// - Editor.IMEWindowStartByte is the absolute byte where the window starts +// (it offsets the IME's window-relative EditEvent.Range into the buffer); +// - the TextField's SelectionStart/End are window-relative. +// +// A classic shadowing regression broke both (a `:=` inside the +// `cb != nil` block of frameOf shadowed the outer start/end, so both were +// always 0 for chunked files): at non-zero scroll the selection highlight +// was mapped against byte 0 (it "jumped" to different text as the user +// scrolled) and IME commits landed near the top of the file instead of at +// the caret — silent data corruption. These tests scroll to a non-zero +// window start and pin both mappings. + +// lastEditorTextField finds the editor_text TextField in the latest captured +// frame and reports whether it was found. +func lastEditorTextField(t *testing.T, h *e2e.Harness) (ui.TextField, bool) { + t.Helper() + frames := h.GetFrames() + if len(frames) == 0 { + t.Fatal("no frames captured") + } + for _, elem := range frames[len(frames)-1] { + if tf, ok := elem.(ui.TextField); ok && tf.ID() == "editor_text" { + return tf, true + } + } + return ui.TextField{}, false +} + +func TestRealFile_WindowSelectionAtScroll(t *testing.T) { + const ( + lines = 60 + lineLen = 21 + scrollLn = 10 // window starts at line 10 (byte 210) + ) + // 60 lines * 16.8 dp = 1008 dp of content; a 400 dp window gives a + // ~304 dp editor region (~18 lines), so the file is scrollable and the + // window at line 10 is fully inside the file. + h, _ := realFileHarness(t, "win.txt", uniformContent(lines)) + defer h.Cleanup() + + h.SendConfig(780, 400) + if _, err := h.WaitForFrame(5 * time.Second); err != nil { + t.Fatalf("wait for frame: %v", err) + } + + // Select 6 filler chars on line 12 (well inside the window that starts + // at line 10). Filler starts at in-line offset 4 (after "L012"). + selStart := 12*lineLen + 9 // byte 261 + selEnd := 12*lineLen + 15 // byte 267 + // Scroll 5 dp INTO line 10: the app's line pitch is a float32 (16.8000007…) + // that differs in the last bits from this file's float64 16.8, so scrolling + // to the exact line boundary is fragile; an interior offset is robust. + if err := h.WithState(func(st *editor.State) { + st.ScrollOffset = ui.Dp(16.8*scrollLn + 5) + st.Editor.SelectionStart = selStart + st.Editor.SelectionEnd = selEnd + st.Editor.CursorPosition = selEnd + }); err != nil { + t.Fatalf("WithState: %v", err) + } + + // Config re-triggers a frame (state changes alone do not emit frames). + prev := h.FrameCount() + h.SendConfig(780, 400) + if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil { + t.Fatalf("wait for scrolled frame: %v", err) + } + + // 1) The IME window start must be the window's absolute byte start. + v, err := h.Inspect(func(st *editor.State) any { + return st.Editor.IMEWindowStartByte + }) + if err != nil { + t.Fatalf("Inspect: %v", err) + } + if got, want := v.(int), scrollLn*lineLen; got != want { + t.Fatalf("IMEWindowStartByte = %d, want %d (window starts at line %d)", got, want, scrollLn) + } + + // 2) The rendered window-relative selection must be offset by that same + // start (the pre-fix code mapped it against byte 0). + tf, ok := lastEditorTextField(t, h) + if !ok { + t.Fatal("no editor_text TextField in latest frame") + } + wantS, wantE := selStart-scrollLn*lineLen, selEnd-scrollLn*lineLen + if tf.SelectionStart != wantS || tf.SelectionEnd != wantE { + t.Fatalf("window selection = [%d,%d), want [%d,%d)", tf.SelectionStart, tf.SelectionEnd, wantS, wantE) + } + // The window text must actually begin at the window start. + if want, got := "L010", tf.Value[:4]; got != want { + t.Fatalf("window text starts with %q, want %q", got, want) + } +} + +func TestRealFile_IMECommitAtScroll(t *testing.T) { + const ( + lines = 60 + lineLen = 21 + scrollLn = 10 + editLn = 12 + ) + h, path := realFileHarness(t, "win2.txt", uniformContent(lines)) + defer h.Cleanup() + + h.SendConfig(780, 400) + if _, err := h.WaitForFrame(5 * time.Second); err != nil { + t.Fatalf("wait for frame: %v", err) + } + + // The selection (absolute) and the matching window-relative EditEvent + // range: the IME reports offsets relative to the pushed snippet, which + // is the visible window. + selStart := editLn*lineLen + 5 + selEnd := editLn*lineLen + 11 + // Scroll 5 dp into line 10 (see TestRealFile_WindowSelectionAtScroll for + // why the offset avoids the exact line boundary). + if err := h.WithState(func(st *editor.State) { + st.ScrollOffset = ui.Dp(16.8*scrollLn + 5) + st.Editor.SelectionStart = selStart + st.Editor.SelectionEnd = selEnd + st.Editor.CursorPosition = selEnd + }); err != nil { + t.Fatalf("WithState: %v", err) + } + prev := h.FrameCount() + h.SendConfig(780, 400) + if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil { + t.Fatalf("wait for scrolled frame: %v", err) + } + + // Typing replaces the selection: "ZZ" over the 6 selected filler chars + // (in-line offsets [5,11)) on line 12. The pre-fix code (windowStart=0) + // applied this at byte 47 — line 2 — corrupting the file. + h.SendInput([]ui.InputEvent{{ + Handler: editor.HandleKeyDown, + Data: key.EditEvent{ + Range: key.Range{Start: selStart - scrollLn*lineLen, End: selEnd - scrollLn*lineLen}, + Text: "ZZ", + }, + }}) + time.Sleep(100 * time.Millisecond) + + got, err := h.FullContent() + if err != nil { + t.Fatalf("FullContent: %v", err) + } + linesOut := strings.Split(got, "\n") + // Expectation derived from the original line: in-line [5,11) replaced. + filler := strings.Repeat(string(rune('a'+editLn%26)), 16) + origLn := fmt.Sprintf("L%03d", editLn) + filler + wantLn12 := origLn[:5] + "ZZ" + origLn[11:] + if linesOut[editLn] != wantLn12 { + t.Fatalf("line %d = %q, want %q (IME commit must land in the scrolled window, not at byte 0)", editLn, linesOut[editLn], wantLn12) + } + // Line 2 must be untouched (the pre-fix corruption target). + if want := fmt.Sprintf("L%03d", 2) + strings.Repeat("c", 16); linesOut[2] != want { + t.Fatalf("line 2 = %q, want %q (must not be clobbered by a scrolled commit)", linesOut[2], want) + } + + // Persistence check: the on-disk file matches. + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != got { + t.Fatalf("disk = %q, want in-memory content", disk) + } +} diff --git a/internal/ui/element.go b/internal/ui/element.go index 8dad62d..f0a6bad 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -199,6 +199,13 @@ type TextField struct { VisibleLines []Line WordWrap bool WrapWidth Dp + // ShowIMESeq is a monotonically increasing pulse from the logic layer + // (incremented on focus gain, file open, and editor taps). The renderer + // issues SoftKeyboardCmd{Show:true} only when it changes, never every + // frame: a per-frame show re-shows the keyboard while its hide animation + // is running (the IME insets dispatches redraw the app), so the keyboard + // could not be dismissed. See TextField.Draw. + ShowIMESeq uint64 } func (tf TextField) Type() string { return "textfield" } @@ -228,7 +235,17 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { r.lastIMEField = tf.id r.lastWasFocused = true gtx.Execute(key.FocusCmd{Tag: tf.id}) - gtx.Execute(key.SoftKeyboardCmd{Show: true}) + // Raise the soft keyboard only when the logic layer pulses it (focus + // gain, file open, editor tap), not every frame. A per-frame show was + // harmless while the app only redrew on user events, but the IME + // insets now redraw the app during the keyboard's hide animation; a + // per-frame show re-shows the keyboard mid-animation, so BACK/chevron + // could never dismiss it (matches widget.Editor, which shows on focus + // gain and click only). + if tf.ShowIMESeq != r.lastIMEShowSeq { + r.lastIMEShowSeq = tf.ShowIMESeq + gtx.Execute(key.SoftKeyboardCmd{Show: true}) + } // IME wiring. The visible window (tf.Value) is pushed as the snippet // with Range {0, len}, so the IME treats the window as the document and // reports EditEvent.Range window-relative. This lets swipe/autocorrect @@ -286,12 +303,14 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { } } else if r.lastIMEField == tf.id { // This (previously-focused) field lost focus: forget it so the next focus - // pushes a fresh snippet/selection. + // pushes a fresh snippet/selection. Resetting lastIMEShowSeq makes the + // next focus re-raise the keyboard even without a fresh pulse. r.lastIMEField = "" r.lastWasFocused = false r.lastSnippet = key.Snippet{} r.lastSelStart = -1 r.lastSelCaret = -1 + r.lastIMEShowSeq = 0 } r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd, tf.CaretDrag) } diff --git a/internal/ui/render.go b/internal/ui/render.go index e15f859..1794e5e 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -88,8 +88,9 @@ type Renderer struct { lastIMEField string lastWasFocused bool lastSnippet key.Snippet - lastSelStart int // window-relative rune index of last-pushed selection start; -1 = no selection - lastSelCaret int // window-relative rune index of last-pushed selection end/caret + lastSelStart int // window-relative rune index of last-pushed selection start; -1 = no selection + lastSelCaret int // window-relative rune index of last-pushed selection end/caret + lastIMEShowSeq uint64 // last ShowIMESeq value that issued SoftKeyboardCmd{Show:true} // Long-press detection. pressProbe is a plain event tag observing raw // pointer events inside the editor text region: gesture.Click reports diff --git a/scripts/build_emu.sh b/scripts/build_emu.sh index 88571f0..7c16983 100755 --- a/scripts/build_emu.sh +++ b/scripts/build_emu.sh @@ -52,7 +52,11 @@ WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT echo "=== gogio (Go -> APK) ===" -(cd "$REPO/cmd/pad" && gogio -target android -targetsdk 35 -arch amd64 -o "$WORK/pad-raw.apk" .) +# targetSdk 34: targeting 35 makes Android 15 force edge-to-edge. We keep 34 +# and instead consume the IME insets ourselves (PadInsetsListener patch below); +# on API 30+ devices setDecorFitsSystemWindows(false) gets us the same +# inset delivery without the blanket edge-to-edge enforcement. +(cd "$REPO/cmd/pad" && gogio -target android -targetsdk 34 -arch amd64 -o "$WORK/pad-raw.apk" .) echo "=== apktool decode ===" java -jar "$APKTOOL" d "$WORK/pad-raw.apk" -o "$WORK/decoded" -f >/dev/null @@ -63,6 +67,161 @@ grep -q "MANAGE_EXTERNAL_STORAGE" "$WORK/decoded/AndroidManifest.xml" || \ "$WORK/decoded/AndroidManifest.xml" grep -q "MANAGE_EXTERNAL_STORAGE" "$WORK/decoded/AndroidManifest.xml" || die "permission injection failed" +# Consume the IME insets in the app. The Gio window contains a SurfaceView +# (GioView), which unconditionally marks the window FORMAT_TRANSLUCENT +# (SurfaceView.requestTransparentRegion -> ViewRootImpl). Translucent windows +# are never resized by the IME, so windowSoftInputMode=adjustResize is dead +# and the keyboard covers the bottom bar. The supported fix: on API 30+ opt +# out of decor auto-fitting (setDecorFitsSystemWindows(false)) and shrink the +# GioView by the IME inset height; the Go side sees the surface resize and +# lays the bottom bar above the keyboard. On API < 30 there are no IME insets +# and the keyboard overlaps (accepted limitation). +echo "=== patch IME insets handling ===" +python3 - "$WORK/decoded" << 'PYEOF' +import sys, pathlib +base = pathlib.Path(sys.argv[1]) + +# 1. New class: shrinks the GioView by the current IME inset. +listener = ''' +.class public Lorg/gioui/PadInsetsListener; +.super Ljava/lang/Object; +.source "PadInsetsListener.java" + +# interfaces +.implements Landroid/view/View$OnApplyWindowInsetsListener; + + +# instance fields +.field private final mView:Landroid/view/View; + + +# direct methods +.method public constructor (Landroid/view/View;)V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + iput-object p1, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + + return-void +.end method + + +# virtual methods +.method public onApplyWindowInsets(Landroid/view/View;Landroid/view/WindowInsets;)Landroid/view/WindowInsets; + .locals 4 + + # 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/16 v2, 0x1e + + if-lt v1, v2, :cond_pad_noime + + const/16 v1, 0x8 + + invoke-virtual {p2, v1}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets; + + move-result-object v1 + + iget v0, v1, Landroid/graphics/Insets;->bottom:I + + :cond_pad_noime + # int h = displayHeight - imeBottom; + iget-object v1, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + + invoke-virtual {v1}, Landroid/view/View;->getResources()Landroid/content/res/Resources; + + move-result-object v1 + + invoke-virtual {v1}, Landroid/content/res/Resources;->getDisplayMetrics()Landroid/util/DisplayMetrics; + + move-result-object v1 + + iget v1, v1, Landroid/util/DisplayMetrics;->heightPixels:I + + sub-int v1, v1, v0 + + # if (mView.getLayoutParams().height != h) { ... = h; requestLayout(); } + iget-object v2, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + + invoke-virtual {v2}, Landroid/view/View;->getLayoutParams()Landroid/view/ViewGroup$LayoutParams; + + move-result-object v2 + + iget v3, v2, Landroid/view/ViewGroup$LayoutParams;->height:I + + if-eq v3, v1, :cond_pad_done + + iput v1, v2, Landroid/view/ViewGroup$LayoutParams;->height:I + + iget-object v3, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + + invoke-virtual {v3}, Landroid/view/View;->requestLayout()V + + :cond_pad_done + return-object p2 +.end method +''' +(base / "smali" / "org" / "gioui" / "PadInsetsListener.smali").write_text(listener) + +# 2. Wire it up in GioActivity.onCreate (and opt out of decor auto-fit). +p = base / "smali" / "org" / "gioui" / "GioActivity.smali" +t = p.read_text() +old = """ invoke-virtual {p1, v0}, Landroid/widget/FrameLayout;->addView(Landroid/view/View;)V + + .line 32""" +new = """ invoke-virtual {p1, v0}, Landroid/widget/FrameLayout;->addView(Landroid/view/View;)V + + # Pad: consume IME insets by shrinking the GioView (API 21+). + sget v1, Landroid/os/Build$VERSION;->SDK_INT:I + + const/16 v2, 0x15 + + if-lt v1, v2, :cond_pad_insets + + new-instance v1, Lorg/gioui/PadInsetsListener; + + invoke-direct {v1, v0}, Lorg/gioui/PadInsetsListener;->(Landroid/view/View;)V + + invoke-virtual {v0, v1}, Landroid/view/View;->setOnApplyWindowInsetsListener(Landroid/view/View$OnApplyWindowInsetsListener;)V + + :cond_pad_insets + + # Pad: API 30+ opt out of decor auto-fit so IME insets are dispatched. + sget v1, Landroid/os/Build$VERSION;->SDK_INT:I + + const/16 v2, 0x1e + + if-lt v1, v2, :cond_pad_nofit + + invoke-virtual {p0}, Lorg/gioui/GioActivity;->getWindow()Landroid/view/Window; + + move-result-object v1 + + const/4 v2, 0x0 + + invoke-virtual {v1, v2}, Landroid/view/Window;->setDecorFitsSystemWindows(Z)V + + :cond_pad_nofit + + .line 32""" +if old not in t: + sys.exit("ERROR: GioActivity addView anchor not found") +t = t.replace(old, new) +old2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 2" +new2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 3" +if old2 not in t: + sys.exit("ERROR: GioActivity onCreate .locals anchor not found") +t = t.replace(old2, new2) +p.write_text(t) +print("patched: GioActivity IME insets wiring") +PYEOF + echo "=== apktool rebuild ===" java -jar "$APKTOOL" b "$WORK/decoded" -o "$WORK/pad-unsigned.apk" >/dev/null diff --git a/scripts/build_phone.sh b/scripts/build_phone.sh index 5e1bb7b..fa409fb 100755 --- a/scripts/build_phone.sh +++ b/scripts/build_phone.sh @@ -44,7 +44,11 @@ WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT echo "=== gogio (Go -> APK, arm64+arm) ===" -(cd "$REPO/cmd/pad" && gogio -target android -targetsdk 35 -arch arm64,arm -o "$WORK/pad-raw.apk" .) +# targetSdk 34: targeting 35 makes Android 15 force edge-to-edge. We keep 34 +# and instead consume the IME insets ourselves (PadInsetsListener patch below); +# on API 30+ devices setDecorFitsSystemWindows(false) gets us the same inset +# delivery without the blanket edge-to-edge enforcement. +(cd "$REPO/cmd/pad" && gogio -target android -targetsdk 34 -arch arm64,arm -o "$WORK/pad-raw.apk" .) echo "=== apktool decode ===" java -jar "$APKTOOL" d "$WORK/pad-raw.apk" -o "$WORK/decoded" -f >/dev/null @@ -55,6 +59,162 @@ grep -q "MANAGE_EXTERNAL_STORAGE" "$WORK/decoded/AndroidManifest.xml" || \ "$WORK/decoded/AndroidManifest.xml" grep -q "MANAGE_EXTERNAL_STORAGE" "$WORK/decoded/AndroidManifest.xml" || die "permission injection failed" +# Consume the IME insets in the app. The Gio window contains a SurfaceView +# (GioView), which unconditionally marks the window FORMAT_TRANSLUCENT +# (SurfaceView.requestTransparentRegion -> ViewRootImpl). Translucent windows +# are never resized by the IME, so windowSoftInputMode=adjustResize is dead +# and the keyboard covers the bottom bar. The supported fix: on API 30+ opt +# out of decor auto-fitting (setDecorFitsSystemWindows(false)) and shrink the +# GioView by the IME inset height; the Go side sees the surface resize and +# lays the bottom bar above the keyboard. On API < 30 there are no IME insets +# and the keyboard overlaps (accepted limitation). +# NOTE: must stay in sync with the identical block in build_emu.sh. +echo "=== patch IME insets handling ===" +python3 - "$WORK/decoded" << 'PYEOF' +import sys, pathlib +base = pathlib.Path(sys.argv[1]) + +# 1. New class: shrinks the GioView by the current IME inset. +listener = ''' +.class public Lorg/gioui/PadInsetsListener; +.super Ljava/lang/Object; +.source "PadInsetsListener.java" + +# interfaces +.implements Landroid/view/View$OnApplyWindowInsetsListener; + + +# instance fields +.field private final mView:Landroid/view/View; + + +# direct methods +.method public constructor (Landroid/view/View;)V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + iput-object p1, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + + return-void +.end method + + +# virtual methods +.method public onApplyWindowInsets(Landroid/view/View;Landroid/view/WindowInsets;)Landroid/view/WindowInsets; + .locals 4 + + # 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/16 v2, 0x1e + + if-lt v1, v2, :cond_pad_noime + + const/16 v1, 0x8 + + invoke-virtual {p2, v1}, Landroid/view/WindowInsets;->getInsets(I)Landroid/graphics/Insets; + + move-result-object v1 + + iget v0, v1, Landroid/graphics/Insets;->bottom:I + + :cond_pad_noime + # int h = displayHeight - imeBottom; + iget-object v1, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + + invoke-virtual {v1}, Landroid/view/View;->getResources()Landroid/content/res/Resources; + + move-result-object v1 + + invoke-virtual {v1}, Landroid/content/res/Resources;->getDisplayMetrics()Landroid/util/DisplayMetrics; + + move-result-object v1 + + iget v1, v1, Landroid/util/DisplayMetrics;->heightPixels:I + + sub-int v1, v1, v0 + + # if (mView.getLayoutParams().height != h) { ... = h; requestLayout(); } + iget-object v2, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + + invoke-virtual {v2}, Landroid/view/View;->getLayoutParams()Landroid/view/ViewGroup$LayoutParams; + + move-result-object v2 + + iget v3, v2, Landroid/view/ViewGroup$LayoutParams;->height:I + + if-eq v3, v1, :cond_pad_done + + iput v1, v2, Landroid/view/ViewGroup$LayoutParams;->height:I + + iget-object v3, p0, Lorg/gioui/PadInsetsListener;->mView:Landroid/view/View; + + invoke-virtual {v3}, Landroid/view/View;->requestLayout()V + + :cond_pad_done + return-object p2 +.end method +''' +(base / "smali" / "org" / "gioui" / "PadInsetsListener.smali").write_text(listener) + +# 2. Wire it up in GioActivity.onCreate (and opt out of decor auto-fit). +p = base / "smali" / "org" / "gioui" / "GioActivity.smali" +t = p.read_text() +old = """ invoke-virtual {p1, v0}, Landroid/widget/FrameLayout;->addView(Landroid/view/View;)V + + .line 32""" +new = """ invoke-virtual {p1, v0}, Landroid/widget/FrameLayout;->addView(Landroid/view/View;)V + + # Pad: consume IME insets by shrinking the GioView (API 21+). + sget v1, Landroid/os/Build$VERSION;->SDK_INT:I + + const/16 v2, 0x15 + + if-lt v1, v2, :cond_pad_insets + + new-instance v1, Lorg/gioui/PadInsetsListener; + + invoke-direct {v1, v0}, Lorg/gioui/PadInsetsListener;->(Landroid/view/View;)V + + invoke-virtual {v0, v1}, Landroid/view/View;->setOnApplyWindowInsetsListener(Landroid/view/View$OnApplyWindowInsetsListener;)V + + :cond_pad_insets + + # Pad: API 30+ opt out of decor auto-fit so IME insets are dispatched. + sget v1, Landroid/os/Build$VERSION;->SDK_INT:I + + const/16 v2, 0x1e + + if-lt v1, v2, :cond_pad_nofit + + invoke-virtual {p0}, Lorg/gioui/GioActivity;->getWindow()Landroid/view/Window; + + move-result-object v1 + + const/4 v2, 0x0 + + invoke-virtual {v1, v2}, Landroid/view/Window;->setDecorFitsSystemWindows(Z)V + + :cond_pad_nofit + + .line 32""" +if old not in t: + sys.exit("ERROR: GioActivity addView anchor not found") +t = t.replace(old, new) +old2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 2" +new2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 3" +if old2 not in t: + sys.exit("ERROR: GioActivity onCreate .locals anchor not found") +t = t.replace(old2, new2) +p.write_text(t) +print("patched: GioActivity IME insets wiring") +PYEOF + echo "=== apktool rebuild ===" java -jar "$APKTOOL" b "$WORK/decoded" -o "$WORK/pad-unsigned.apk" >/dev/null