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