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:
parent
83f7affee9
commit
941285e7cc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
197
internal/test/e2e/real_file_scroll_selection_test.go
Normal file
197
internal/test/e2e/real_file_scroll_selection_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user