authorclover caruso <git@paperclover.net> 2026-10-15 05:49:41-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 06:30:07-07:00
logd8a3bb046d2bdf597b51e08a041375538e1aad16
tree0160a5960e8351b2ae3cf7cb185565ae7dc3a0b7
parent10cde9f3e120e69e22c88fe33ecfe264bd350fb0
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

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

6 files changed, 376 insertions(+), 2 deletions(-)

apps/android/AndroidManifest.xml+1-1
......@@ -2,7 +2,7 @@
22 android:versionCode="1" android:versionName="0.1">
33 <uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36" />
44 <application android:label="Snowbound" android:theme="@style/Page" android:extractNativeLibs="false">
5 <activity android:name=".PageActivity" android:exported="true">
5 <activity android:name=".PageActivity" android:exported="true" android:windowSoftInputMode="adjustResize">
66 <intent-filter>
77 <action android:name="android.intent.action.MAIN" />
88 <category android:name="android.intent.category.LAUNCHER" />
apps/android/jni/snowbound.c+86
......@@ -212,3 +212,89 @@ JNIEXPORT void JNICALL JNI(release)(JNIEnv *env, jclass cls, jlong view) {
212212 (void)env, (void)cls;
213213 sb_view_release((View *)(intptr_t)view);
214214}
215
216JNIEXPORT jint JNICALL JNI(length)(JNIEnv *env, jclass cls, jlong view) {
217 (void)env, (void)cls;
218 return (jint)sb_text_length((const View *)(intptr_t)view);
219}
220
221JNIEXPORT jstring JNICALL JNI(text)(JNIEnv *env, jclass cls, jlong view, jint start, jint end) {
222 (void)cls;
223 return output(env, sb_text((const View *)(intptr_t)view, (uint32_t)start, (uint32_t)end));
224}
225
226static jintArray range(JNIEnv *env, const uint32_t range[2]) {
227 jint values[2] = {(jint)range[0], (jint)range[1]};
228 jintArray result = (*env)->NewIntArray(env, 2);
229 if (result) (*env)->SetIntArrayRegion(env, result, 0, 2, values);
230 return result;
231}
232
233JNIEXPORT jintArray JNICALL JNI(selection)(JNIEnv *env, jclass cls, jlong view) {
234 (void)cls;
235 uint32_t selected[2] = {0, 0};
236 sb_selection((const View *)(intptr_t)view, selected);
237 return range(env, selected);
238}
239
240JNIEXPORT jintArray JNICALL JNI(marked)(JNIEnv *env, jclass cls, jlong view) {
241 (void)cls;
242 uint32_t marked[2];
243 return sb_marked((const View *)(intptr_t)view, marked) ? range(env, marked) : NULL;
244}
245
246JNIEXPORT jboolean JNICALL JNI(select)(JNIEnv *env, jclass cls, jlong view, jint start, jint end) {
247 (void)env, (void)cls;
248 return sb_select((View *)(intptr_t)view, (uint32_t)start, (uint32_t)end);
249}
250
251JNIEXPORT jboolean JNICALL JNI(insert)(JNIEnv *env, jclass cls, jlong view, jstring text) {
252 (void)cls;
253 char *t = utf8(env, text);
254 bool changed = t && sb_insert((View *)(intptr_t)view, t);
255 free(t);
256 return changed;
257}
258
259JNIEXPORT jboolean JNICALL JNI(setMarked)(JNIEnv *env, jclass cls, jlong view, jstring text, jint start, jint end) {
260 (void)cls;
261 char *t = utf8(env, text);
262 bool changed = t && sb_set_marked((View *)(intptr_t)view, t, (uint32_t)start, (uint32_t)end);
263 free(t);
264 return changed;
265}
266
267JNIEXPORT void JNICALL JNI(unmark)(JNIEnv *env, jclass cls, jlong view) {
268 (void)env, (void)cls;
269 sb_unmark((View *)(intptr_t)view);
270}
271
272JNIEXPORT jboolean JNICALL JNI(deleteBackward)(JNIEnv *env, jclass cls, jlong view) {
273 (void)env, (void)cls;
274 return sb_delete_backward((View *)(intptr_t)view);
275}
276
277// The caret at `start` when `end` is the same, else the rectangles covering the range: each
278// `x, y, width, height` in the view's points.
279JNIEXPORT jfloatArray JNICALL JNI(rects)(JNIEnv *env, jclass cls, jlong view, jint start, jint end) {
280 (void)cls;
281 const View *v = (const View *)(intptr_t)view;
282 float caret[1][4];
283 size_t count = start == end ? sb_caret_rect(v, (uint32_t)start, caret[0])
284 : sb_range_rects(v, (uint32_t)start, (uint32_t)end, NULL, 0);
285 float (*rects)[4] = start == end ? caret : malloc(sizeof(*rects) * (count ? count : 1));
286 if (!rects) return NULL;
287 if (start != end) {
288 size_t written = sb_range_rects(v, (uint32_t)start, (uint32_t)end, rects, count);
289 if (written < count) count = written;
290 }
291 jfloatArray result = (*env)->NewFloatArray(env, (jsize)(count * 4));
292 if (result) (*env)->SetFloatArrayRegion(env, result, 0, (jsize)(count * 4), (const jfloat *)rects);
293 if (rects != caret) free(rects);
294 return result;
295}
296
297JNIEXPORT jint JNICALL JNI(closest)(JNIEnv *env, jclass cls, jlong view, jfloat x, jfloat y) {
298 (void)env, (void)cls;
299 return (jint)sb_closest((const View *)(intptr_t)view, x, y);
300}
apps/android/src/net/paperclover/snowbound/Editor.java+11
......@@ -82,4 +82,15 @@ final class Editor {
8282 static native void press(long view, float x, float y);
8383 static native void drag(long view, float x, float y);
8484 static native void release(long view);
85 static native int length(long view);
86 static native String text(long view, int start, int end);
87 static native int[] selection(long view);
88 static native int[] marked(long view);
89 static native boolean select(long view, int start, int end);
90 static native boolean insert(long view, String text);
91 static native boolean setMarked(long view, String text, int start, int end);
92 static native void unmark(long view);
93 static native boolean deleteBackward(long view);
94 static native float[] rects(long view, int start, int end);
95 static native int closest(long view, float x, float y);
8596}
apps/android/src/net/paperclover/snowbound/PageActivity.java+1
......@@ -14,6 +14,7 @@ public final class PageActivity extends Activity {
1414 FrameLayout frame = new FrameLayout(this);
1515 frame.setFitsSystemWindows(true);
1616 frame.addView(page);
17 frame.addView(page.caret);
1718 setContentView(frame);
1819 Editor.shown = page;
1920 Editor.open(this);
apps/android/src/net/paperclover/snowbound/PageInput.java created+129
......@@ -0,0 +1,129 @@
1package net.paperclover.snowbound;
2
3import android.text.TextUtils;
4import android.view.inputmethod.BaseInputConnection;
5
6/** The keyboard's view of the page: the active outline's text, in UTF-16 offsets. */
7final class PageInput extends BaseInputConnection {
8 private final PageView page;
9 /** Nested batch edits, until whose end the keyboard hears of no change. */
10 int batch;
11
12 PageInput(PageView page) {
13 super(page, false);
14 this.page = page;
15 }
16
17 private static int clamp(int offset) {
18 return Math.max(0, Math.min(offset, Editor.length(Editor.view)));
19 }
20
21 private boolean edited() {
22 page.edited();
23 return true;
24 }
25
26 @Override
27 public boolean beginBatchEdit() {
28 batch++;
29 return true;
30 }
31
32 @Override
33 public boolean endBatchEdit() {
34 if (batch > 0 && --batch == 0) page.edited();
35 return batch > 0;
36 }
37
38 @Override
39 public boolean commitText(CharSequence text, int position) {
40 long view = Editor.view;
41 String value = text.toString();
42 int[] marked = Editor.marked(view);
43 int start = marked != null ? marked[0] : Editor.selection(view)[0];
44 Editor.insert(view, value);
45 if (position != 1) {
46 int cursor = clamp(position > 0 ? start + value.length() + position - 1 : start + position);
47 Editor.select(view, cursor, cursor);
48 }
49 return edited();
50 }
51
52 @Override
53 public boolean setComposingText(CharSequence text, int position) {
54 String value = text.toString();
55 int cursor = Math.max(0, Math.min(position > 0 ? value.length() + position - 1 : position, value.length()));
56 Editor.setMarked(Editor.view, value, cursor, cursor);
57 return edited();
58 }
59
60 /** The page can't mark text already typed, so the keyboard composes only new text. */
61 @Override
62 public boolean setComposingRegion(int start, int end) {
63 return false;
64 }
65
66 @Override
67 public boolean finishComposingText() {
68 Editor.unmark(Editor.view);
69 return edited();
70 }
71
72 @Override
73 public boolean setSelection(int start, int end) {
74 Editor.select(Editor.view, clamp(Math.min(start, end)), clamp(Math.max(start, end)));
75 return edited();
76 }
77
78 @Override
79 public boolean deleteSurroundingText(int before, int after) {
80 long view = Editor.view;
81 int[] selected = Editor.selection(view);
82 int start = clamp(selected[0] - before), end = clamp(selected[1] + after);
83 if (end > selected[1]) {
84 Editor.select(view, selected[1], end);
85 Editor.deleteBackward(view);
86 }
87 if (start < selected[0]) {
88 // Backspace itself deletes a whole character at the caret, as the keyboard asks.
89 if (selected[0] != selected[1] || before != 1) Editor.select(view, start, selected[0]);
90 Editor.deleteBackward(view);
91 }
92 if (selected[0] != selected[1]) Editor.select(view, start, start + selected[1] - selected[0]);
93 return edited();
94 }
95
96 @Override
97 public CharSequence getTextBeforeCursor(int count, int flags) {
98 long view = Editor.view;
99 int end = Editor.selection(view)[0], start = Math.max(0, end - count);
100 // One unit more, so that a surrogate pair cut at the start is dropped whole.
101 String text = Editor.text(view, Math.max(0, start - 1), end);
102 if (start == 0) return text;
103 boolean pair = text.length() > 1 && Character.isSurrogatePair(text.charAt(0), text.charAt(1));
104 return text.substring(pair ? 2 : 1);
105 }
106
107 @Override
108 public CharSequence getTextAfterCursor(int count, int flags) {
109 long view = Editor.view;
110 int start = Editor.selection(view)[1];
111 String text = Editor.text(view, start, clamp(start + Math.max(0, count) + 1));
112 if (text.length() <= count) return text;
113 if (count <= 0) return "";
114 boolean pair = Character.isSurrogatePair(text.charAt(count - 1), text.charAt(count));
115 return text.substring(0, pair ? count - 1 : count);
116 }
117
118 @Override
119 public CharSequence getSelectedText(int flags) {
120 int[] selected = Editor.selection(Editor.view);
121 return selected[0] == selected[1] ? null : Editor.text(Editor.view, selected[0], selected[1]);
122 }
123
124 @Override
125 public int getCursorCapsMode(int modes) {
126 CharSequence before = getTextBeforeCursor(64, 0);
127 return TextUtils.getCapsMode(before, before.length(), modes);
128 }
129}
apps/android/src/net/paperclover/snowbound/PageView.java+148-1
......@@ -1,20 +1,36 @@
11package net.paperclover.snowbound;
22
33import android.content.Context;
4import android.content.res.TypedArray;
5import android.graphics.Canvas;
6import android.graphics.Paint;
47import android.graphics.Rect;
8import android.text.InputType;
59import android.view.Choreographer;
10import android.view.KeyCharacterMap;
11import android.view.KeyEvent;
612import android.view.MotionEvent;
713import android.view.SurfaceHolder;
814import android.view.SurfaceView;
15import android.view.View;
16import android.view.inputmethod.EditorInfo;
17import android.view.inputmethod.InputConnection;
18import android.view.inputmethod.InputMethodManager;
919
10/** Draws the editor's page into its surface, a frame whenever the page changes. */
20/** Draws the editor's page into its surface, a frame whenever the page changes, and takes its typing. */
1121final class PageView extends SurfaceView implements SurfaceHolder.Callback, Choreographer.FrameCallback {
22 /** The caret and selection, drawn over the page by the view above it. */
23 final View caret;
24 private final InputMethodManager keyboard;
25 private PageInput input;
1226 private boolean attached, dirty;
1327
1428 PageView(Context context) {
1529 super(context);
1630 setFocusableInTouchMode(true);
1731 getHolder().addCallback(this);
32 caret = new Caret(context);
33 keyboard = context.getSystemService(InputMethodManager.class);
1834 }
1935
2036 private float scale() {
......@@ -43,6 +59,7 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
4359 float scale = scale();
4460 Editor.resize(Editor.view, width / scale, height / scale, scale);
4561 dirty = true;
62 caret.invalidate();
4663 }
4764
4865 /** 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
6380 Editor.render(Editor.view);
6481 }
6582
83 /** Draws the change and tells the keyboard where the selection and composition are now. */
84 void edited() {
85 dirty = true;
86 caret.invalidate();
87 if (input == null || input.batch > 0) return;
88 int[] selected = Editor.selection(Editor.view), marked = Editor.marked(Editor.view);
89 keyboard.updateSelection(this, selected[0], selected[1], marked == null ? -1 : marked[0],
90 marked == null ? -1 : marked[1]);
91 }
92
93 /** The keyboard reads the active outline afresh, as a tap or Tab may have changed it. */
94 private void restartInput() {
95 keyboard.restartInput(this);
96 edited();
97 }
98
6699 @Override
67100 public boolean onTouchEvent(MotionEvent event) {
68101 if (!attached) return false;
......@@ -78,14 +111,98 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
78111 case MotionEvent.ACTION_UP:
79112 case MotionEvent.ACTION_CANCEL:
80113 Editor.release(Editor.view);
114 restartInput();
115 keyboard.showSoftInput(this, 0);
81116 break;
82117 default:
83118 return false;
84119 }
85120 dirty = true;
121 caret.invalidate();
122 return true;
123 }
124
125 @Override
126 public boolean onCheckIsTextEditor() {
127 return true;
128 }
129
130 @Override
131 public InputConnection onCreateInputConnection(EditorInfo info) {
132 if (!attached) return null;
133 info.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE
134 | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES | InputType.TYPE_TEXT_FLAG_AUTO_CORRECT;
135 info.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN;
136 int[] selected = Editor.selection(Editor.view);
137 info.initialSelStart = selected[0];
138 info.initialSelEnd = selected[1];
139 return input = new PageInput(this);
140 }
141
142 /** Keys the keyboard sends rather than types, and those of a hardware keyboard. */
143 @Override
144 public boolean onKeyDown(int code, KeyEvent event) {
145 if (!attached || event.isCtrlPressed() || event.isAltPressed() || event.isMetaPressed()) {
146 return super.onKeyDown(code, event);
147 }
148 long view = Editor.view;
149 switch (code) {
150 case KeyEvent.KEYCODE_DEL:
151 Editor.deleteBackward(view);
152 break;
153 case KeyEvent.KEYCODE_ENTER:
154 case KeyEvent.KEYCODE_NUMPAD_ENTER:
155 Editor.insert(view, "\n");
156 break;
157 case KeyEvent.KEYCODE_TAB:
158 Editor.insert(view, "\t");
159 restartInput();
160 return true;
161 case KeyEvent.KEYCODE_DPAD_LEFT:
162 case KeyEvent.KEYCODE_DPAD_RIGHT:
163 across(code == KeyEvent.KEYCODE_DPAD_RIGHT);
164 break;
165 case KeyEvent.KEYCODE_DPAD_UP:
166 case KeyEvent.KEYCODE_DPAD_DOWN:
167 upOrDown(code == KeyEvent.KEYCODE_DPAD_DOWN);
168 break;
169 default:
170 int character = event.getUnicodeChar();
171 if (character == 0 || (character & KeyCharacterMap.COMBINING_ACCENT) != 0) {
172 return super.onKeyDown(code, event);
173 }
174 Editor.insert(view, new String(Character.toChars(character)));
175 }
176 edited();
86177 return true;
87178 }
88179
180 /** Collapses a selection to its side, or moves the caret a character, a surrogate pair whole. */
181 private static void across(boolean right) {
182 long view = Editor.view;
183 int[] selected = Editor.selection(view);
184 int offset = right ? selected[1] : selected[0];
185 if (selected[0] == selected[1]) {
186 offset = Math.max(0, Math.min(offset + (right ? 1 : -1), Editor.length(view)));
187 String around = offset > 0 ? Editor.text(view, offset - 1, offset + 1) : "";
188 if (around.length() == 2 && Character.isSurrogatePair(around.charAt(0), around.charAt(1))) {
189 offset += right ? 1 : -1;
190 }
191 }
192 Editor.select(view, offset, offset);
193 }
194
195 /** Moves the caret to the nearest place a line above or below. */
196 private static void upOrDown(boolean down) {
197 long view = Editor.view;
198 int[] selected = Editor.selection(view);
199 float[] caret = Editor.rects(view, down ? selected[1] : selected[0], down ? selected[1] : selected[0]);
200 if (caret == null || caret.length < 4) return;
201 float y = caret[1] + caret[3] / 2 + (down ? caret[3] : -caret[3]);
202 int offset = Editor.closest(view, caret[0], y);
203 Editor.select(view, offset, offset);
204 }
205
89206 @Override
90207 protected void onFocusChanged(boolean focused, int direction, Rect previous) {
91208 super.onFocusChanged(focused, direction, previous);
......@@ -102,5 +219,35 @@ final class PageView extends SurfaceView implements SurfaceHolder.Callback, Chor
102219 if (!attached) return;
103220 Editor.focus(Editor.view, hasFocus() && hasWindowFocus());
104221 dirty = true;
222 caret.invalidate();
223 }
224
225 /** The caret, a line in the system's accent colour, or the selection tinted with it. */
226 private final class Caret extends View {
227 private final Paint paint = new Paint();
228 private final int accent;
229
230 Caret(Context context) {
231 super(context);
232 TypedArray theme = context.obtainStyledAttributes(new int[] {android.R.attr.colorAccent});
233 accent = theme.getColor(0, 0xff000000);
234 theme.recycle();
235 }
236
237 @Override
238 protected void onDraw(Canvas canvas) {
239 if (!attached || !PageView.this.hasFocus()) return;
240 int[] selected = Editor.selection(Editor.view);
241 float[] rects = Editor.rects(Editor.view, selected[0], selected[1]);
242 if (rects == null) return;
243 float scale = scale();
244 boolean collapsed = selected[0] == selected[1];
245 paint.setColor(collapsed ? accent : accent & 0x00ffffff | 0x40000000);
246 for (int i = 0; i + 3 < rects.length; i += 4) {
247 float left = rects[i] * scale, top = rects[i + 1] * scale, bottom = (rects[i + 1] + rects[i + 3]) * scale;
248 float right = collapsed ? left + scale : (rects[i] + rects[i + 2]) * scale;
249 canvas.drawRect(collapsed ? left - scale : left, top, right, bottom, paint);
250 }
251 }
105252 }
106253}