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); + } + } } }