From d8a3bb046d2bdf597b51e08a041375538e1aad16 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 05:49:41 -0700 Subject: [PATCH] feat(android): type into the page The page takes the system keyboard. Its input connection speaks the active outline's text in UTF-16 offsets, the unit the page measures in: typed and composed words go to the editor as iOS sends them, the keyboard's Backspace deletes as the Delete key does, and Enter, Tab and the arrow keys arrive as keys, from a hardware keyboard too. A tap or Tab that moves to another outline has the keyboard read it afresh. The caret and selection are drawn over the page in the system's accent colour, and the page shrinks above the keyboard. The keyboard can't yet reopen a word already typed for correction, as the page has no way to mark existing text. Refs #24 Assisted-by: claude-opus-5.5 --- apps/android/AndroidManifest.xml | 2 +- apps/android/jni/snowbound.c | 86 ++++++++++ .../src/net/paperclover/snowbound/Editor.java | 11 ++ .../paperclover/snowbound/PageActivity.java | 1 + .../net/paperclover/snowbound/PageInput.java | 129 +++++++++++++++ .../net/paperclover/snowbound/PageView.java | 149 +++++++++++++++++- 6 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 apps/android/src/net/paperclover/snowbound/PageInput.java diff --git a/apps/android/AndroidManifest.xml b/apps/android/AndroidManifest.xml index ae6e67eac5a93af0bec3b5f8a800703bc342a13b..9dab42eb3262ff23e059f61496ce08d8f12e72da 100644 --- a/apps/android/AndroidManifest.xml +++ b/apps/android/AndroidManifest.xml @@ -2,7 +2,7 @@ android:versionCode="1" android:versionName="0.1"> - + diff --git a/apps/android/jni/snowbound.c b/apps/android/jni/snowbound.c index dca8047d4992ac73a2ec2dc05e672860c019771d..3c46ce604a2e10f3def4960f50dbf2de27e862d1 100644 --- a/apps/android/jni/snowbound.c +++ b/apps/android/jni/snowbound.c @@ -212,3 +212,89 @@ JNIEXPORT void JNICALL JNI(release)(JNIEnv *env, jclass cls, jlong view) { (void)env, (void)cls; sb_view_release((View *)(intptr_t)view); } + +JNIEXPORT jint JNICALL JNI(length)(JNIEnv *env, jclass cls, jlong view) { + (void)env, (void)cls; + return (jint)sb_text_length((const View *)(intptr_t)view); +} + +JNIEXPORT jstring JNICALL JNI(text)(JNIEnv *env, jclass cls, jlong view, jint start, jint end) { + (void)cls; + return output(env, sb_text((const View *)(intptr_t)view, (uint32_t)start, (uint32_t)end)); +} + +static jintArray range(JNIEnv *env, const uint32_t range[2]) { + jint values[2] = {(jint)range[0], (jint)range[1]}; + jintArray result = (*env)->NewIntArray(env, 2); + if (result) (*env)->SetIntArrayRegion(env, result, 0, 2, values); + return result; +} + +JNIEXPORT jintArray JNICALL JNI(selection)(JNIEnv *env, jclass cls, jlong view) { + (void)cls; + uint32_t selected[2] = {0, 0}; + sb_selection((const View *)(intptr_t)view, selected); + return range(env, selected); +} + +JNIEXPORT jintArray JNICALL JNI(marked)(JNIEnv *env, jclass cls, jlong view) { + (void)cls; + uint32_t marked[2]; + return sb_marked((const View *)(intptr_t)view, marked) ? range(env, marked) : NULL; +} + +JNIEXPORT jboolean JNICALL JNI(select)(JNIEnv *env, jclass cls, jlong view, jint start, jint end) { + (void)env, (void)cls; + return sb_select((View *)(intptr_t)view, (uint32_t)start, (uint32_t)end); +} + +JNIEXPORT jboolean JNICALL JNI(insert)(JNIEnv *env, jclass cls, jlong view, jstring text) { + (void)cls; + char *t = utf8(env, text); + bool changed = t && sb_insert((View *)(intptr_t)view, t); + free(t); + return changed; +} + +JNIEXPORT jboolean JNICALL JNI(setMarked)(JNIEnv *env, jclass cls, jlong view, jstring text, jint start, jint end) { + (void)cls; + char *t = utf8(env, text); + bool changed = t && sb_set_marked((View *)(intptr_t)view, t, (uint32_t)start, (uint32_t)end); + free(t); + return changed; +} + +JNIEXPORT void JNICALL JNI(unmark)(JNIEnv *env, jclass cls, jlong view) { + (void)env, (void)cls; + sb_unmark((View *)(intptr_t)view); +} + +JNIEXPORT jboolean JNICALL JNI(deleteBackward)(JNIEnv *env, jclass cls, jlong view) { + (void)env, (void)cls; + return sb_delete_backward((View *)(intptr_t)view); +} + +// The caret at `start` when `end` is the same, else the rectangles covering the range: each +// `x, y, width, height` in the view's points. +JNIEXPORT jfloatArray JNICALL JNI(rects)(JNIEnv *env, jclass cls, jlong view, jint start, jint end) { + (void)cls; + const View *v = (const View *)(intptr_t)view; + float caret[1][4]; + size_t count = start == end ? sb_caret_rect(v, (uint32_t)start, caret[0]) + : sb_range_rects(v, (uint32_t)start, (uint32_t)end, NULL, 0); + float (*rects)[4] = start == end ? caret : malloc(sizeof(*rects) * (count ? count : 1)); + if (!rects) return NULL; + if (start != end) { + size_t written = sb_range_rects(v, (uint32_t)start, (uint32_t)end, rects, count); + if (written < count) count = written; + } + jfloatArray result = (*env)->NewFloatArray(env, (jsize)(count * 4)); + if (result) (*env)->SetFloatArrayRegion(env, result, 0, (jsize)(count * 4), (const jfloat *)rects); + if (rects != caret) free(rects); + return result; +} + +JNIEXPORT jint JNICALL JNI(closest)(JNIEnv *env, jclass cls, jlong view, jfloat x, jfloat y) { + (void)env, (void)cls; + return (jint)sb_closest((const View *)(intptr_t)view, x, y); +} diff --git a/apps/android/src/net/paperclover/snowbound/Editor.java b/apps/android/src/net/paperclover/snowbound/Editor.java index f4e0f76ab1bdc8e6a9080038d0352a45f15eac35..8a27b8b40b9994ccfbcb4e2236c68b87ec1ef31a 100644 --- a/apps/android/src/net/paperclover/snowbound/Editor.java +++ b/apps/android/src/net/paperclover/snowbound/Editor.java @@ -82,4 +82,15 @@ final class Editor { static native void press(long view, float x, float y); static native void drag(long view, float x, float y); static native void release(long view); + static native int length(long view); + static native String text(long view, int start, int end); + static native int[] selection(long view); + static native int[] marked(long view); + static native boolean select(long view, int start, int end); + static native boolean insert(long view, String text); + static native boolean setMarked(long view, String text, int start, int end); + static native void unmark(long view); + static native boolean deleteBackward(long view); + static native float[] rects(long view, int start, int end); + static native int closest(long view, float x, float y); } diff --git a/apps/android/src/net/paperclover/snowbound/PageActivity.java b/apps/android/src/net/paperclover/snowbound/PageActivity.java index 20b8a660a248661bdad4b833b558ed8e24c4c768..903f0629ea2758c72873f7fdbe5831e7906f358a 100644 --- a/apps/android/src/net/paperclover/snowbound/PageActivity.java +++ b/apps/android/src/net/paperclover/snowbound/PageActivity.java @@ -14,6 +14,7 @@ public final class PageActivity extends Activity { FrameLayout frame = new FrameLayout(this); frame.setFitsSystemWindows(true); frame.addView(page); + frame.addView(page.caret); setContentView(frame); Editor.shown = page; Editor.open(this); diff --git a/apps/android/src/net/paperclover/snowbound/PageInput.java b/apps/android/src/net/paperclover/snowbound/PageInput.java new file mode 100644 index 0000000000000000000000000000000000000000..5ee441b83c21c682d6e47962ef18929743d86e91 --- /dev/null +++ b/apps/android/src/net/paperclover/snowbound/PageInput.java @@ -0,0 +1,129 @@ +package net.paperclover.snowbound; + +import android.text.TextUtils; +import android.view.inputmethod.BaseInputConnection; + +/** The keyboard's view of the page: the active outline's text, in UTF-16 offsets. */ +final class PageInput extends BaseInputConnection { + private final PageView page; + /** Nested batch edits, until whose end the keyboard hears of no change. */ + int batch; + + PageInput(PageView page) { + super(page, false); + this.page = page; + } + + private static int clamp(int offset) { + return Math.max(0, Math.min(offset, Editor.length(Editor.view))); + } + + private boolean edited() { + page.edited(); + return true; + } + + @Override + public boolean beginBatchEdit() { + batch++; + return true; + } + + @Override + public boolean endBatchEdit() { + if (batch > 0 && --batch == 0) page.edited(); + return batch > 0; + } + + @Override + public boolean commitText(CharSequence text, int position) { + long view = Editor.view; + String value = text.toString(); + int[] marked = Editor.marked(view); + int start = marked != null ? marked[0] : Editor.selection(view)[0]; + Editor.insert(view, value); + if (position != 1) { + int cursor = clamp(position > 0 ? start + value.length() + position - 1 : start + position); + Editor.select(view, cursor, cursor); + } + return edited(); + } + + @Override + public boolean setComposingText(CharSequence text, int position) { + String value = text.toString(); + int cursor = Math.max(0, Math.min(position > 0 ? value.length() + position - 1 : position, value.length())); + Editor.setMarked(Editor.view, value, cursor, cursor); + return edited(); + } + + /** The page can't mark text already typed, so the keyboard composes only new text. */ + @Override + public boolean setComposingRegion(int start, int end) { + return false; + } + + @Override + public boolean finishComposingText() { + Editor.unmark(Editor.view); + return edited(); + } + + @Override + public boolean setSelection(int start, int end) { + Editor.select(Editor.view, clamp(Math.min(start, end)), clamp(Math.max(start, end))); + return edited(); + } + + @Override + public boolean deleteSurroundingText(int before, int after) { + long view = Editor.view; + int[] selected = Editor.selection(view); + int start = clamp(selected[0] - before), end = clamp(selected[1] + after); + if (end > selected[1]) { + Editor.select(view, selected[1], end); + Editor.deleteBackward(view); + } + if (start < selected[0]) { + // Backspace itself deletes a whole character at the caret, as the keyboard asks. + if (selected[0] != selected[1] || before != 1) Editor.select(view, start, selected[0]); + Editor.deleteBackward(view); + } + if (selected[0] != selected[1]) Editor.select(view, start, start + selected[1] - selected[0]); + return edited(); + } + + @Override + public CharSequence getTextBeforeCursor(int count, int flags) { + long view = Editor.view; + int end = Editor.selection(view)[0], start = Math.max(0, end - count); + // One unit more, so that a surrogate pair cut at the start is dropped whole. + String text = Editor.text(view, Math.max(0, start - 1), end); + if (start == 0) return text; + boolean pair = text.length() > 1 && Character.isSurrogatePair(text.charAt(0), text.charAt(1)); + return text.substring(pair ? 2 : 1); + } + + @Override + public CharSequence getTextAfterCursor(int count, int flags) { + long view = Editor.view; + int start = Editor.selection(view)[1]; + String text = Editor.text(view, start, clamp(start + Math.max(0, count) + 1)); + if (text.length() <= count) return text; + if (count <= 0) return ""; + boolean pair = Character.isSurrogatePair(text.charAt(count - 1), text.charAt(count)); + return text.substring(0, pair ? count - 1 : count); + } + + @Override + public CharSequence getSelectedText(int flags) { + int[] selected = Editor.selection(Editor.view); + return selected[0] == selected[1] ? null : Editor.text(Editor.view, selected[0], selected[1]); + } + + @Override + public int getCursorCapsMode(int modes) { + CharSequence before = getTextBeforeCursor(64, 0); + return TextUtils.getCapsMode(before, before.length(), modes); + } +} diff --git a/apps/android/src/net/paperclover/snowbound/PageView.java b/apps/android/src/net/paperclover/snowbound/PageView.java index 973050ed6b7f3f078e866a7f2a1885e668c92df1..18d2bd5dd8b2c3e3603c1a065b9c847fa8530f44 100644 --- a/apps/android/src/net/paperclover/snowbound/PageView.java +++ b/apps/android/src/net/paperclover/snowbound/PageView.java @@ -1,20 +1,36 @@ package net.paperclover.snowbound; import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; import android.graphics.Rect; +import android.text.InputType; import android.view.Choreographer; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; import android.view.MotionEvent; import android.view.SurfaceHolder; import android.view.SurfaceView; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; -/** Draws the editor's page into its surface, a frame whenever the page changes. */ +/** Draws the editor's page into its surface, a frame whenever the page changes, and takes its typing. */ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Choreographer.FrameCallback { + /** The caret and selection, drawn over the page by the view above it. */ + final View caret; + private final InputMethodManager keyboard; + private PageInput input; private boolean attached, dirty; PageView(Context context) { super(context); setFocusableInTouchMode(true); getHolder().addCallback(this); + caret = new Caret(context); + keyboard = context.getSystemService(InputMethodManager.class); } private float scale() { @@ -43,6 +59,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor float scale = scale(); Editor.resize(Editor.view, width / scale, height / scale, scale); dirty = true; + caret.invalidate(); } /** Android destroys the surface once this returns, so the editor lets go of it first. */ @@ -63,6 +80,22 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor Editor.render(Editor.view); } + /** Draws the change and tells the keyboard where the selection and composition are now. */ + void edited() { + dirty = true; + caret.invalidate(); + if (input == null || input.batch > 0) return; + int[] selected = Editor.selection(Editor.view), marked = Editor.marked(Editor.view); + keyboard.updateSelection(this, selected[0], selected[1], marked == null ? -1 : marked[0], + marked == null ? -1 : marked[1]); + } + + /** The keyboard reads the active outline afresh, as a tap or Tab may have changed it. */ + private void restartInput() { + keyboard.restartInput(this); + edited(); + } + @Override public boolean onTouchEvent(MotionEvent event) { if (!attached) return false; @@ -78,14 +111,98 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: Editor.release(Editor.view); + restartInput(); + keyboard.showSoftInput(this, 0); break; default: return false; } dirty = true; + caret.invalidate(); return true; } + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo info) { + if (!attached) return null; + info.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE + | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES | InputType.TYPE_TEXT_FLAG_AUTO_CORRECT; + info.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN; + int[] selected = Editor.selection(Editor.view); + info.initialSelStart = selected[0]; + info.initialSelEnd = selected[1]; + return input = new PageInput(this); + } + + /** Keys the keyboard sends rather than types, and those of a hardware keyboard. */ + @Override + public boolean onKeyDown(int code, KeyEvent event) { + if (!attached || event.isCtrlPressed() || event.isAltPressed() || event.isMetaPressed()) { + return super.onKeyDown(code, event); + } + long view = Editor.view; + switch (code) { + case KeyEvent.KEYCODE_DEL: + Editor.deleteBackward(view); + break; + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_NUMPAD_ENTER: + Editor.insert(view, "\n"); + break; + case KeyEvent.KEYCODE_TAB: + Editor.insert(view, "\t"); + restartInput(); + return true; + case KeyEvent.KEYCODE_DPAD_LEFT: + case KeyEvent.KEYCODE_DPAD_RIGHT: + across(code == KeyEvent.KEYCODE_DPAD_RIGHT); + break; + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_DOWN: + upOrDown(code == KeyEvent.KEYCODE_DPAD_DOWN); + break; + default: + int character = event.getUnicodeChar(); + if (character == 0 || (character & KeyCharacterMap.COMBINING_ACCENT) != 0) { + return super.onKeyDown(code, event); + } + Editor.insert(view, new String(Character.toChars(character))); + } + edited(); + return true; + } + + /** Collapses a selection to its side, or moves the caret a character, a surrogate pair whole. */ + private static void across(boolean right) { + long view = Editor.view; + int[] selected = Editor.selection(view); + int offset = right ? selected[1] : selected[0]; + if (selected[0] == selected[1]) { + offset = Math.max(0, Math.min(offset + (right ? 1 : -1), Editor.length(view))); + String around = offset > 0 ? Editor.text(view, offset - 1, offset + 1) : ""; + if (around.length() == 2 && Character.isSurrogatePair(around.charAt(0), around.charAt(1))) { + offset += right ? 1 : -1; + } + } + Editor.select(view, offset, offset); + } + + /** Moves the caret to the nearest place a line above or below. */ + private static void upOrDown(boolean down) { + long view = Editor.view; + int[] selected = Editor.selection(view); + float[] caret = Editor.rects(view, down ? selected[1] : selected[0], down ? selected[1] : selected[0]); + if (caret == null || caret.length < 4) return; + float y = caret[1] + caret[3] / 2 + (down ? caret[3] : -caret[3]); + int offset = Editor.closest(view, caret[0], y); + Editor.select(view, offset, offset); + } + @Override protected void onFocusChanged(boolean focused, int direction, Rect previous) { super.onFocusChanged(focused, direction, previous); @@ -102,5 +219,35 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor if (!attached) return; Editor.focus(Editor.view, hasFocus() && hasWindowFocus()); dirty = true; + caret.invalidate(); + } + + /** The caret, a line in the system's accent colour, or the selection tinted with it. */ + private final class Caret extends View { + private final Paint paint = new Paint(); + private final int accent; + + Caret(Context context) { + super(context); + TypedArray theme = context.obtainStyledAttributes(new int[] {android.R.attr.colorAccent}); + accent = theme.getColor(0, 0xff000000); + theme.recycle(); + } + + @Override + protected void onDraw(Canvas canvas) { + if (!attached || !PageView.this.hasFocus()) return; + int[] selected = Editor.selection(Editor.view); + float[] rects = Editor.rects(Editor.view, selected[0], selected[1]); + if (rects == null) return; + float scale = scale(); + boolean collapsed = selected[0] == selected[1]; + paint.setColor(collapsed ? accent : accent & 0x00ffffff | 0x40000000); + for (int i = 0; i + 3 < rects.length; i += 4) { + float left = rects[i] * scale, top = rects[i + 1] * scale, bottom = (rects[i + 1] + rects[i + 3]) * scale; + float right = collapsed ? left + scale : (rects[i] + rects[i + 2]) * scale; + canvas.drawRect(collapsed ? left - scale : left, top, right, bottom, paint); + } + } } } -- 2.54.0