Fix scroll-anchored selection (shadowing) + IME insets keyboard handling

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.
This commit is contained in:
Greg Pomerantz 2026-08-17 21:52:42 -04:00
parent 83f7affee9
commit 941285e7cc
7 changed files with 610 additions and 8 deletions

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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 <init>(Landroid/view/View;)V
.locals 0
invoke-direct {p0}, Ljava/lang/Object;-><init>()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;-><init>(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

View File

@ -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 <init>(Landroid/view/View;)V
.locals 0
invoke-direct {p0}, Ljava/lang/Object;-><init>()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;-><init>(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