capacitor-dex-editor 0.0.38 → 0.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/android/src/main/AndroidManifest.xml +8 -0
  2. package/android/src/main/assets/availableSyntax.json +54 -0
  3. package/android/src/main/assets/c.json +42 -0
  4. package/android/src/main/assets/colors.json +21 -0
  5. package/android/src/main/assets/cpp.json +79 -0
  6. package/android/src/main/assets/dart.json +108 -0
  7. package/android/src/main/assets/java.json +46 -0
  8. package/android/src/main/assets/js.json +54 -0
  9. package/android/src/main/assets/json.json +33 -0
  10. package/android/src/main/assets/kotlin.json +53 -0
  11. package/android/src/main/assets/lua.json +54 -0
  12. package/android/src/main/assets/php.json +69 -0
  13. package/android/src/main/assets/python.json +87 -0
  14. package/android/src/main/assets/rust.json +139 -0
  15. package/android/src/main/assets/smali.json +131 -0
  16. package/android/src/main/assets/xml.json +96 -0
  17. package/android/src/main/java/com/aetherlink/dexeditor/DexEditorPluginPlugin.java +48 -0
  18. package/android/src/main/java/com/aetherlink/dexeditor/SmaliEditorActivity.java +205 -0
  19. package/android/src/main/java/com/aetherlink/dexeditor/editor/EditView.java +4022 -0
  20. package/android/src/main/java/com/aetherlink/dexeditor/editor/WordWrapLayout.java +275 -0
  21. package/android/src/main/java/com/aetherlink/dexeditor/editor/buffer/BufferCache.java +113 -0
  22. package/android/src/main/java/com/aetherlink/dexeditor/editor/buffer/GapBuffer.java +685 -0
  23. package/android/src/main/java/com/aetherlink/dexeditor/editor/component/ClipboardPanel.java +1380 -0
  24. package/android/src/main/java/com/aetherlink/dexeditor/editor/component/Magnifier.java +363 -0
  25. package/android/src/main/java/com/aetherlink/dexeditor/editor/highlight/Candidate.java +52 -0
  26. package/android/src/main/java/com/aetherlink/dexeditor/editor/highlight/CommentDef.java +47 -0
  27. package/android/src/main/java/com/aetherlink/dexeditor/editor/highlight/LineResult.java +49 -0
  28. package/android/src/main/java/com/aetherlink/dexeditor/editor/highlight/MHSyntaxHighlightEngine.java +841 -0
  29. package/android/src/main/java/com/aetherlink/dexeditor/editor/highlight/Rule.java +53 -0
  30. package/android/src/main/java/com/aetherlink/dexeditor/editor/highlight/Token.java +48 -0
  31. package/android/src/main/java/com/aetherlink/dexeditor/editor/listener/OnTextChangedListener.java +6 -0
  32. package/android/src/main/java/com/aetherlink/dexeditor/editor/utils/Pair.java +80 -0
  33. package/android/src/main/java/com/aetherlink/dexeditor/editor/utils/ScreenUtils.java +63 -0
  34. package/android/src/main/res/drawable/abc_text_cursor_material.xml +9 -0
  35. package/android/src/main/res/drawable/abc_text_select_handle_left_mtrl.png +0 -0
  36. package/android/src/main/res/drawable/abc_text_select_handle_middle_mtrl.png +0 -0
  37. package/android/src/main/res/drawable/abc_text_select_handle_right_mtrl.png +0 -0
  38. package/android/src/main/res/drawable/ic_arrow_back.xml +12 -0
  39. package/android/src/main/res/drawable/ic_copy.xml +11 -0
  40. package/android/src/main/res/drawable/ic_cut.xml +10 -0
  41. package/android/src/main/res/drawable/ic_delete.xml +12 -0
  42. package/android/src/main/res/drawable/ic_edit_white_24dp.xml +10 -0
  43. package/android/src/main/res/drawable/ic_goto.xml +10 -0
  44. package/android/src/main/res/drawable/ic_launcher_background.xml +186 -0
  45. package/android/src/main/res/drawable/ic_look_white_24dp.xml +11 -0
  46. package/android/src/main/res/drawable/ic_more.xml +10 -0
  47. package/android/src/main/res/drawable/ic_open_link.xml +11 -0
  48. package/android/src/main/res/drawable/ic_paste.xml +11 -0
  49. package/android/src/main/res/drawable/ic_redo_white_24dp.xml +10 -0
  50. package/android/src/main/res/drawable/ic_select.xml +11 -0
  51. package/android/src/main/res/drawable/ic_select_all.xml +10 -0
  52. package/android/src/main/res/drawable/ic_share.xml +11 -0
  53. package/android/src/main/res/drawable/ic_toggle_comment.xml +12 -0
  54. package/android/src/main/res/drawable/ic_translate.xml +10 -0
  55. package/android/src/main/res/drawable/ic_undo_white_24dp.xml +11 -0
  56. package/android/src/main/res/drawable/magnifier_bg.xml +5 -0
  57. package/android/src/main/res/drawable/popup_background.xml +8 -0
  58. package/android/src/main/res/drawable/ripple_effect.xml +9 -0
  59. package/android/src/main/res/drawable/selection_menu_background.xml +5 -0
  60. package/android/src/main/res/layout/custom_selection_menu.xml +44 -0
  61. package/android/src/main/res/layout/expand_button.xml +23 -0
  62. package/android/src/main/res/layout/item_autocomplete.xml +25 -0
  63. package/android/src/main/res/layout/magnifier_popup.xml +17 -0
  64. package/android/src/main/res/layout/menu_item.xml +30 -0
  65. package/android/src/main/res/layout/text_selection_menu.xml +36 -0
  66. package/package.json +1 -1
@@ -0,0 +1,4022 @@
1
+ /*
2
+ * MH-TextEditor - An Advanced and optimized TextEditor for android
3
+ * Copyright 2025, developer-krushna
4
+ *
5
+ * Redistribution and use in source and binary forms, with or without
6
+ * modification, are permitted provided that the following conditions are
7
+ * met:
8
+ *
9
+ * * Redistributions of source code must retain the above copyright
10
+ * notice, this list of conditions and the following disclaimer.
11
+ * * Redistributions in binary form must reproduce the above
12
+ * copyright notice, this list of conditions and the following disclaimer
13
+ * in the documentation and/or other materials provided with the
14
+ * distribution.
15
+ * * Neither the name of developer-krushna nor the names of its
16
+ * contributors may be used to endorse or promote products derived from
17
+ * this software without specific prior written permission.
18
+ *
19
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30
+
31
+
32
+ * Please contact Krushna by email modder-hub@zohomail.in if you need
33
+ * additional information or have any questions
34
+ */
35
+
36
+ package com.aetherlink.dexeditor.editor;
37
+
38
+ import android.content.ClipData;
39
+ import android.content.ClipDescription;
40
+ import android.content.ClipboardManager;
41
+ import android.content.Context;
42
+ import android.content.Intent;
43
+ import android.graphics.Canvas;
44
+ import android.graphics.Color;
45
+ import android.graphics.Paint;
46
+ import android.graphics.Rect;
47
+ import android.graphics.Typeface;
48
+ import android.graphics.drawable.Drawable;
49
+ import android.os.Handler;
50
+ import android.text.InputType;
51
+ import android.text.SpannableString;
52
+ import android.text.Spanned;
53
+ import android.text.TextPaint;
54
+ import android.text.TextUtils;
55
+ import android.text.style.ForegroundColorSpan;
56
+ import android.util.AttributeSet;
57
+ import android.util.Log;
58
+ import android.util.Pair;
59
+ import android.view.GestureDetector;
60
+ import android.view.HapticFeedbackConstants;
61
+ import android.view.KeyEvent;
62
+ import android.view.MotionEvent;
63
+ import android.view.ScaleGestureDetector;
64
+ import android.view.View;
65
+ import android.view.ViewGroup;
66
+ import android.view.animation.AnimationUtils;
67
+ import android.view.inputmethod.BaseInputConnection;
68
+ import android.view.inputmethod.EditorInfo;
69
+ import android.view.inputmethod.InputConnection;
70
+ import android.view.inputmethod.InputMethodManager;
71
+ import android.widget.AdapterView;
72
+ import android.widget.ArrayAdapter;
73
+ import android.widget.ListPopupWindow;
74
+ import android.widget.OverScroller;
75
+ import android.widget.PopupWindow;
76
+ import android.widget.TextView;
77
+ import java.util.ArrayList;
78
+ import java.util.Collections;
79
+ import java.util.Comparator;
80
+ import java.util.HashSet;
81
+ import java.util.List;
82
+ import java.util.Set;
83
+ import java.util.regex.Matcher;
84
+ import java.util.regex.Pattern;
85
+ import com.aetherlink.dexeditor.R;
86
+ import com.aetherlink.dexeditor.editor.buffer.GapBuffer;
87
+ import com.aetherlink.dexeditor.editor.component.ClipboardPanel;
88
+ import com.aetherlink.dexeditor.editor.component.Magnifier;
89
+ import com.aetherlink.dexeditor.editor.highlight.MHSyntaxHighlightEngine;
90
+ import com.aetherlink.dexeditor.editor.listener.OnTextChangedListener;
91
+ import com.aetherlink.dexeditor.editor.utils.ScreenUtils;
92
+
93
+ /* Author : Krushna Chandra Maharna(@developer-krushna)
94
+ This project was actually started by some one using gap buffer
95
+ But i forgot his name and his repository link because i was started
96
+ working on this project in 2024 .. During that time due to some personal problem
97
+ I closed this project and saved it in sdcard for future task .
98
+ But unfortunately i unable to save the original author name. I am really sorry.
99
+ But if you are the creator then please let me know so that i can update this
100
+ part. Thank You
101
+
102
+ Optmization , code refactorinh and comments are made by ChatGPT
103
+ */
104
+
105
+ /*
106
+ * I have not included many useful helper methods as i was working for something
107
+ * Feel free to include them
108
+ * But basic fetures are already introduced so no need to worry about it Lol
109
+
110
+ */
111
+
112
+ public class EditView extends View {
113
+
114
+ private static final String COPYRIGHT = "MH-TextEditor\nCopyright (C) Krushna Chandra modder-hub@zohomail.in\nThis project is distributed under the LGPL v2.1 license";
115
+
116
+ private final String TAG = this.getClass().getSimpleName();
117
+
118
+ // ---------- Fields (state, resources, helpers) ----------
119
+ private Paint mPaint;
120
+ private TextPaint mTextPaint;
121
+ private GapBuffer mGapBuffer;
122
+
123
+ // cursor and select handle drawable resources
124
+ private Drawable mDrawableCursorRes;
125
+ private Drawable mTextSelectHandleLeftRes;
126
+ private Drawable mTextSelectHandleRightRes;
127
+ private Drawable mTextSelectHandleMiddleRes;
128
+
129
+ private int mCursorPosX, mCursorPosY;
130
+ private int mCursorLine, mCursorIndex;
131
+ private int mCursorWidth, mCursorHeight;
132
+ private int screenWidth, screenHeight;
133
+ private int lineWidth, spaceWidth;
134
+ private int handleMiddleWidth, handleMiddleHeight;
135
+ private int selectionStart, selectionEnd;
136
+ private int selectHandleWidth, selectHandleHeight;
137
+ private int selectHandleLeftX, selectHandleLeftY;
138
+ private int selectHandleRightX, selectHandleRightY;
139
+
140
+ private int mMetaState = 0;
141
+
142
+ private OnTextChangedListener mTextListener;
143
+ private OverScroller mScroller;
144
+ private GestureDetector mGestureDetector;
145
+ private GestureListener mGestureListener;
146
+ private ScaleGestureDetector mScaleGestureDetector;
147
+ private ClipboardManager mClipboard;
148
+ private ArrayList<Pair<Integer, Integer>> mReplaceList;
149
+
150
+ private boolean mCursorVisiable = true;
151
+ private boolean mHandleMiddleVisable = false;
152
+ private boolean isEditedMode = true;
153
+ private boolean isSelectMode = false;
154
+
155
+ private long mLastScroll;
156
+ // record last single tap time
157
+ private long mLastTapTime;
158
+ // left margin for draw text
159
+ private final int SPACEING = 2;
160
+ // animation duration 250ms
161
+ private final int DEFAULT_DURATION = 250;
162
+ // cursor blink BLINK_TIMEOUT 500ms
163
+ private final int BLINK_TIMEOUT = 500;
164
+
165
+ // Magnifier
166
+ private Magnifier mMagnifier;
167
+ private boolean mMagnifierEnabled = true;
168
+ private float mMagnifierX, mMagnifierY;
169
+ private boolean mIsMagnifierShowing = false;
170
+
171
+ private ClipboardPanel mClipboardPanel;
172
+
173
+ private MHSyntaxHighlightEngine mHighlighter;
174
+ private boolean isSyntaxDarkMode = false;
175
+
176
+ // Auto-complete
177
+ private Set<String> mWordSet = new HashSet<>();
178
+ private ListPopupWindow mAutoCompletePopup;
179
+ private ArrayAdapter<String> mAutoCompleteAdapter;
180
+
181
+ private static final Pattern WORD_PATTERN = Pattern.compile("\\w+");
182
+ private static final int MIN_WORD_LEN = 2; // Filter short words
183
+ private static final int WORD_UPDATE_DELAY = 200; // ms throttle
184
+ private Runnable mWordUpdateRunnable = new Runnable() {
185
+ @Override
186
+ public void run() {
187
+ updateWordSet();
188
+ }
189
+ };
190
+ private String mCurrentPrefix = "";
191
+ private long mLastInputTime = 0;
192
+ private String mLastCommittedText = "";
193
+ private boolean mProcessingInput = false;
194
+ private final long INPUT_DEBOUNCE_DELAY = 50; // ms
195
+
196
+ private boolean mAutoIndentEnabled = true; // Default on
197
+
198
+ private int mFirstSelectedLine = -1;
199
+ private int mSecondSelectedLine = -1;
200
+ private boolean mWaitingForSecondSelection = false;
201
+ private int mStartSelectionLine = -1;
202
+ private int mEndSelectionLine = -1;
203
+ private boolean mIsLineSelectionMode = false;
204
+ private Runnable mClearSelectionRunnable = new Runnable() {
205
+ @Override
206
+ public void run() {
207
+ // If user doesn't select second line within timeout, clear selection
208
+ mWaitingForSecondSelection = false;
209
+ mFirstSelectedLine = -1;
210
+ }
211
+ };
212
+
213
+ // ---------- Blink / auto-hide ----------
214
+ // cursor blink runnable toggling visibility
215
+ private Runnable blinkAction = new Runnable() {
216
+ @Override
217
+ public void run() {
218
+ // TODO: Implement this method
219
+ mCursorVisiable = !mCursorVisiable;
220
+ postDelayed(blinkAction, BLINK_TIMEOUT);
221
+
222
+ if (System.currentTimeMillis() - mLastTapTime >= 5 * BLINK_TIMEOUT) {
223
+ mHandleMiddleVisable = false;
224
+ }
225
+ postInvalidate();
226
+ }
227
+ };
228
+
229
+ private Handler mSelectionHandler = new Handler();
230
+ private Runnable mUpdateSelectionPosition = new Runnable() {
231
+ @Override
232
+ public void run() {
233
+ if (mClipboardPanel != null && (isSelectMode || mHandleMiddleVisable)) {
234
+ mClipboardPanel.updatePosition();
235
+ }
236
+ }
237
+ };
238
+
239
+ private Runnable mAutoHideRunnable = new Runnable() {
240
+ @Override
241
+ public void run() {
242
+ if (!isSelectMode && !mHandleMiddleVisable) {
243
+ hideTextSelectionWindow();
244
+ }
245
+ }
246
+ };
247
+
248
+ // ---------- Constructors ----------
249
+ // Constructor (Context)
250
+ public EditView(Context context) {
251
+ super(context);
252
+ initView(context);
253
+ }
254
+
255
+ // Constructor (Context, AttributeSet)
256
+ public EditView(Context context, AttributeSet attrs) {
257
+ super(context, attrs);
258
+ initView(context);
259
+ }
260
+
261
+ // Constructor (Context, AttributeSet, defStyle)
262
+ public EditView(Context context, AttributeSet attrs, int defStyle) {
263
+ super(context, attrs, defStyle);
264
+ initView(context);
265
+ }
266
+
267
+ private void initView(Context context) {
268
+ Log.v(TAG, COPYRIGHT);
269
+
270
+ // Initialize Gapbuffer
271
+ mGapBuffer = new GapBuffer();
272
+ mCursorLine = getLineCount();
273
+ setBackgroundColor(Color.WHITE);
274
+
275
+ screenWidth = ScreenUtils.getScreenWidth(context);
276
+ screenHeight = ScreenUtils.getScreenHeight(context);
277
+
278
+ mDrawableCursorRes = context.getDrawable(R.drawable.abc_text_cursor_material);
279
+ mDrawableCursorRes.setTint(Color.BLACK);
280
+
281
+ mCursorWidth = mDrawableCursorRes.getIntrinsicWidth();
282
+ mCursorHeight = mDrawableCursorRes.getIntrinsicHeight();
283
+
284
+ mClipboardPanel = new ClipboardPanel(this);
285
+
286
+ // Initialize magnifier
287
+ mMagnifier = new Magnifier(this);
288
+
289
+ // Reduce cursor width and make it responsive
290
+ int density = (int) getResources().getDisplayMetrics().density;
291
+ mCursorWidth = Math.max(2, density); // Minimum 2px, scales with density
292
+ if (mCursorWidth > 5) mCursorWidth = 5; // Max 4px
293
+
294
+ // handle left - scale down selection handles
295
+ mTextSelectHandleLeftRes = context.getDrawable(R.drawable.abc_text_select_handle_left_mtrl);
296
+ mTextSelectHandleLeftRes.setTint(Color.parseColor("#63B5F7"));
297
+
298
+ // Scale down selection handles based on screen density
299
+ selectHandleWidth = (int) (mTextSelectHandleLeftRes.getIntrinsicWidth() * 0.3f);
300
+ selectHandleHeight = (int) (mTextSelectHandleLeftRes.getIntrinsicHeight() * 0.3f);
301
+
302
+ // handle right
303
+ mTextSelectHandleRightRes = context.getDrawable(R.drawable.abc_text_select_handle_right_mtrl);
304
+ mTextSelectHandleRightRes.setTint(Color.parseColor("#63B5F7"));
305
+
306
+ // handle middle - scale down
307
+ mTextSelectHandleMiddleRes = context.getDrawable(R.drawable.abc_text_select_handle_middle_mtrl);
308
+ mTextSelectHandleMiddleRes.setTint(Color.parseColor("#63B5F7"));
309
+ handleMiddleWidth = (int) (mTextSelectHandleMiddleRes.getIntrinsicWidth() * 0.5f);
310
+ handleMiddleHeight = (int) (mTextSelectHandleMiddleRes.getIntrinsicHeight() * 0.5f);
311
+
312
+ mGestureListener = new GestureListener();
313
+ mGestureDetector = new GestureDetector(context, mGestureListener);
314
+ mScaleGestureDetector = new ScaleGestureDetector(context, new ScaleGestureListener());
315
+
316
+ mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
317
+ mTextPaint.setColor(Color.parseColor("#B0B0B0"));
318
+
319
+ setTextSize(ScreenUtils.dip2px(context, 18));
320
+ mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
321
+ mPaint.setColor(Color.parseColor("#FFFAE3"));
322
+ mPaint.setStrokeWidth(0);
323
+
324
+ mScroller = new OverScroller(context);
325
+ mClipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
326
+ mReplaceList = new ArrayList<>();
327
+
328
+ spaceWidth = (int) mTextPaint.measureText(" ");
329
+
330
+ // Explicitly set initial scroll position to (0, 0)
331
+ scrollTo(0, 0);
332
+
333
+ requestFocus();
334
+ setFocusable(true);
335
+ setFocusableInTouchMode(true);
336
+ postDelayed(blinkAction, BLINK_TIMEOUT);
337
+
338
+ mAutoCompletePopup = new ListPopupWindow(getContext());
339
+ mAutoCompleteAdapter = new ArrayAdapter<String>(
340
+ getContext(),
341
+ R.layout.item_autocomplete,
342
+ R.id.text1, // 👈 explicitly tell it which TextView to use
343
+ new ArrayList<String>()
344
+ ) {
345
+ @Override
346
+ public View getView(int position, View convertView, ViewGroup parent) {
347
+ View view = super.getView(position, convertView, parent);
348
+ TextView textView = view.findViewById(R.id.text1);
349
+ String item = getItem(position);
350
+ if (item != null && !mCurrentPrefix.isEmpty()) {
351
+ int index = item.toLowerCase().indexOf(mCurrentPrefix.toLowerCase());
352
+ if (index >= 0) {
353
+ SpannableString spannable = new SpannableString(item);
354
+ spannable.setSpan(new ForegroundColorSpan(Color.parseColor("#2196F3")),
355
+ index, index + mCurrentPrefix.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
356
+ textView.setText(spannable);
357
+ } else {
358
+ textView.setText(item);
359
+ }
360
+ } else {
361
+ textView.setText(item);
362
+ }
363
+ return view;
364
+ }
365
+ };
366
+ mAutoCompletePopup.setAdapter(mAutoCompleteAdapter);
367
+ mAutoCompletePopup.setHeight(ScreenUtils.dip2px(context, 150)); // fits about 3-4 rows
368
+ mAutoCompletePopup.setModal(false); // allow typing while shown
369
+ mAutoCompletePopup.setAnchorView(this);
370
+
371
+ mAutoCompletePopup.setOnItemClickListener(new AdapterView.OnItemClickListener() {
372
+ @Override
373
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
374
+ String selected = (String) parent.getItemAtPosition(position);
375
+ if (selected != null) {
376
+ replacePrefixWithWord(selected);
377
+ dismissAutoComplete();
378
+ }
379
+ }
380
+ });
381
+ mAutoCompletePopup.setOnDismissListener(new PopupWindow.OnDismissListener() {
382
+ @Override
383
+ public void onDismiss() {
384
+ mCurrentPrefix = ""; // Reset on dismiss
385
+ }
386
+ });
387
+ // Initial word set
388
+ post(mWordUpdateRunnable);
389
+ }
390
+
391
+ // ---------- Lifecycle ----------
392
+ // Called when view detached from window
393
+ @Override
394
+ protected void onDetachedFromWindow() {
395
+ removeCallbacks(mWordUpdateRunnable);
396
+ dismissAutoComplete();
397
+ super.onDetachedFromWindow();
398
+ }
399
+
400
+ // Called when view size/layout changes
401
+ @Override
402
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
403
+ super.onLayout(changed, left, top, right, bottom);
404
+ if (changed) {
405
+ // Only adjust scroll if cursor is significantly out of view
406
+ adjustCursorPosition();
407
+ if (isSelectMode) {
408
+ adjustSelectRange(selectionStart, selectionEnd);
409
+ }
410
+ // Check if initial scroll is needed
411
+ /* if (getScrollY() > 0 || getScrollX() > 0) {
412
+ scrollToVisable();
413
+ } else {
414
+ // Ensure we're at the top-left initially
415
+ scrollTo(0, 0);
416
+ }*/
417
+ }
418
+ }
419
+
420
+ // ---------- Rendering / Drawing ----------
421
+ // Top-level draw method
422
+ @Override
423
+ protected void onDraw(Canvas canvas) {
424
+ super.onDraw(canvas);
425
+ canvas.save();
426
+ canvas.clipRect(getScrollX(),
427
+ getScrollY(),
428
+ getScrollX() + getWidth() - getPaddingRight(),
429
+ getScrollY() + getHeight() - getPaddingBottom());
430
+
431
+ canvas.translate(getPaddingLeft(), getPaddingTop());
432
+
433
+ Drawable background = getBackground();
434
+ if (background != null) {
435
+ background.draw(canvas);
436
+ }
437
+
438
+ drawMatchText(canvas);
439
+ drawLineBackground(canvas);
440
+ drawEditableText(canvas); // Remove the background drawing from this method
441
+ drawSelectHandle(canvas);
442
+ drawCursor(canvas);
443
+
444
+ canvas.restore();
445
+ }
446
+
447
+ // Draw the editor content (helper)
448
+ public void drawEditorContent(Canvas canvas, int captureTop, int captureBottom) {
449
+ int saveCount = canvas.save();
450
+ canvas.translate(getScrollX(), getScrollY());
451
+ drawMatchText(canvas);
452
+ drawLineBackground(canvas);
453
+ drawEditableText(canvas);
454
+ drawCursor(canvas);
455
+ drawSelectHandle(canvas);
456
+ canvas.restoreToCount(saveCount);
457
+ }
458
+
459
+ // Draw current line background or selection highlight
460
+ public void drawLineBackground(Canvas canvas) {
461
+ if (mIsLineSelectionMode && mStartSelectionLine > 0 && mEndSelectionLine > 0) {
462
+ mPaint.setColor(Color.parseColor("#E3F2FD")); // Light blue background
463
+ int lineNumberWidth = getLineNumberWidth() + SPACEING * 2;
464
+
465
+ for (int i = mStartSelectionLine; i <= mEndSelectionLine; i++) {
466
+ int top = (i - 1) * getLineHeight();
467
+ int bottom = i * getLineHeight();
468
+
469
+ canvas.drawRect(getPaddingLeft(), top,
470
+ getPaddingLeft() + lineNumberWidth, bottom, mPaint);
471
+ }
472
+ }
473
+
474
+ if (!isSelectMode) {
475
+ // draw current line background
476
+ mPaint.setColor(Color.parseColor("#FFFAE3"));
477
+ int left = getPaddingLeft() + getLineNumberWidth() + SPACEING;
478
+ canvas.drawRect(left,
479
+ getPaddingTop() + mCursorPosY,
480
+ getScrollX() + getWidth(),
481
+ mCursorPosY + getLineHeight(),
482
+ mPaint);
483
+ } else {
484
+ // draw select text background - BLOCK STYLE (like before)
485
+ mPaint.setColor(Color.parseColor("#B3DBFB"));
486
+
487
+ int left = getLeftSpace();
488
+ int lineHeight = getLineHeight();
489
+
490
+ int startLine = getOffsetLine(selectionStart);
491
+ int endLine = getOffsetLine(selectionEnd);
492
+
493
+ // Only draw visible selection ranges to reduce lag
494
+ int visibleStartLine = Math.max(startLine, canvas.getClipBounds().top / lineHeight);
495
+ int visibleEndLine = Math.min(endLine, canvas.getClipBounds().bottom / lineHeight + 1);
496
+
497
+ // start line < end line
498
+ if (startLine != endLine) {
499
+ for (int i = visibleStartLine; i <= visibleEndLine; i++) {
500
+ int lineWidth = getLineWidth(i) + spaceWidth;
501
+ if (i == startLine) {
502
+ // First line - from selection start to end of line
503
+ int lineStart = getLineStart(startLine);
504
+ String beforeText = mGapBuffer.substring(lineStart, selectionStart);
505
+ int selectStartX = left + (int) mTextPaint.measureText(beforeText);
506
+
507
+ canvas.drawRect(selectStartX,
508
+ (startLine - 1) * lineHeight,
509
+ left + lineWidth,
510
+ startLine * lineHeight,
511
+ mPaint);
512
+ } else if (i == endLine) {
513
+ // Last line - from line start to selection end
514
+ int lineStart = getLineStart(endLine);
515
+ String beforeText = mGapBuffer.substring(lineStart, selectionEnd);
516
+ int selectEndX = left + (int) mTextPaint.measureText(beforeText);
517
+
518
+ canvas.drawRect(left,
519
+ (endLine - 1) * lineHeight,
520
+ selectEndX,
521
+ endLine * lineHeight,
522
+ mPaint);
523
+ } else {
524
+ // Middle lines - full line width
525
+ canvas.drawRect(left,
526
+ (i - 1) * lineHeight,
527
+ left + lineWidth,
528
+ i * lineHeight,
529
+ mPaint);
530
+ }
531
+ }
532
+ } else {
533
+ // start line = end line - single line selection
534
+ int lineStart = getLineStart(startLine);
535
+ String beforeStartText = mGapBuffer.substring(lineStart, selectionStart);
536
+ String selectedText = mGapBuffer.substring(selectionStart, selectionEnd);
537
+
538
+ int selectStartX = left + (int) mTextPaint.measureText(beforeStartText);
539
+ int selectEndX = selectStartX + (int) mTextPaint.measureText(selectedText);
540
+
541
+ canvas.drawRect(selectStartX,
542
+ (startLine - 1) * lineHeight,
543
+ selectEndX,
544
+ startLine * lineHeight,
545
+ mPaint);
546
+ }
547
+ }
548
+ }
549
+
550
+ // Draw select handles (left/right)
551
+ public void drawSelectHandle(Canvas canvas) {
552
+ if (isSelectMode) {
553
+ mTextSelectHandleLeftRes.setBounds(selectHandleLeftX - selectHandleWidth + selectHandleWidth / 4,
554
+ selectHandleLeftY,
555
+ selectHandleLeftX + selectHandleWidth / 4,
556
+ selectHandleLeftY + selectHandleHeight
557
+ );
558
+
559
+ mTextSelectHandleLeftRes.draw(canvas);
560
+
561
+ // select handle right
562
+ mTextSelectHandleRightRes.setBounds(selectHandleRightX - selectHandleWidth / 4,
563
+ selectHandleRightY,
564
+ selectHandleRightX + selectHandleWidth - selectHandleWidth / 4,
565
+ selectHandleRightY + selectHandleHeight
566
+ );
567
+ mTextSelectHandleRightRes.draw(canvas);
568
+ }
569
+ }
570
+
571
+ // Draw match/replace highlights
572
+ public void drawMatchText(Canvas canvas) {
573
+ if (isSelectMode) {
574
+ int size = mReplaceList.size();
575
+ int left = getLeftSpace();
576
+
577
+ for (int i = 0; i < size; ++i) {
578
+ int start = mReplaceList.get(i).first;
579
+ int end = mReplaceList.get(i).second;
580
+
581
+ if (start == selectionStart && end == selectionEnd)
582
+ mPaint.setColor(Color.YELLOW);
583
+ else
584
+ mPaint.setColor(Color.parseColor("#FFFD54"));
585
+
586
+ int line = mGapBuffer.findLineNumber(start);
587
+ int lineStart = getLineStart(line);
588
+
589
+ canvas.drawRect(left + measureText(mGapBuffer.substring(lineStart, start)),
590
+ (line - 1) * getLineHeight(),
591
+ left + measureText(mGapBuffer.substring(lineStart, end)),
592
+ line * getLineHeight(),
593
+ mPaint
594
+ );
595
+ }
596
+ }
597
+ }
598
+
599
+ // Draw the cursor and middle handle
600
+ public void drawCursor(Canvas canvas) {
601
+ if (mCursorVisiable) {
602
+ int left = getLeftSpace();
603
+ int half = 0;
604
+ if (mCursorPosX >= left) {
605
+ half = mCursorWidth / 2;
606
+ } else {
607
+ mCursorPosX = left;
608
+ }
609
+
610
+ // draw text cursor
611
+ mDrawableCursorRes.setBounds(mCursorPosX - half,
612
+ getPaddingTop() + mCursorPosY,
613
+ mCursorPosX - half + mCursorWidth,
614
+ mCursorPosY + getLineHeight()
615
+ );
616
+ mDrawableCursorRes.draw(canvas);
617
+ }
618
+
619
+ if (mHandleMiddleVisable) {
620
+ // draw text select handle middle
621
+ mTextSelectHandleMiddleRes.setBounds(mCursorPosX - handleMiddleWidth / 2,
622
+ mCursorPosY + getLineHeight(),
623
+ mCursorPosX + handleMiddleWidth / 2,
624
+ mCursorPosY + getLineHeight() + handleMiddleHeight
625
+ );
626
+ mTextSelectHandleMiddleRes.draw(canvas);
627
+ }
628
+ }
629
+
630
+ // Draw editable text lines with numbers and syntax highlighting
631
+
632
+ public void drawEditableText(Canvas canvas) {
633
+ int startLine = Math.max(canvas.getClipBounds().top / getLineHeight(), 1);
634
+ int endLine = Math.min(canvas.getClipBounds().bottom / getLineHeight() + 1, getLineCount());
635
+
636
+ int lineNumberWidth = getLineNumberWidth();
637
+ lineWidth = getWidth() - lineNumberWidth;
638
+
639
+ int totalContentHeight = getLineCount() * getLineHeight();
640
+
641
+ // Draw full-height line number bar background
642
+ mPaint.setColor(Color.parseColor("#F8F8F8"));
643
+ canvas.drawRect(
644
+ getPaddingLeft(),
645
+ 0,
646
+ getPaddingLeft() + lineNumberWidth + SPACEING * 2,
647
+ Math.max(getHeight(), totalContentHeight),
648
+ mPaint
649
+ );
650
+
651
+ // Draw separator line
652
+ int separatorWidth = 2;
653
+ int separatorX = getPaddingLeft() + lineNumberWidth + SPACEING * 2 - separatorWidth;
654
+ mPaint.setColor(Color.parseColor("#E4E4E4"));
655
+ mPaint.setStrokeWidth(separatorWidth);
656
+ canvas.drawLine(
657
+ separatorX,
658
+ 0,
659
+ separatorX,
660
+ Math.max(getHeight(), totalContentHeight),
661
+ mPaint
662
+ );
663
+
664
+ // Margins
665
+ int leftMargin = 10; // ✅ Space from left edge for line numbers
666
+ int rightMargin = 13; // Space between line numbers and separator
667
+
668
+ for (int i = startLine; i <= endLine; i++) {
669
+ int paintY = i * getLineHeight() - (int) mTextPaint.descent();
670
+ mTextPaint.setColor(Color.parseColor("#B0B0B0"));
671
+
672
+ String lineNumberText = String.valueOf(i);
673
+ int textWidth = (int) mTextPaint.measureText(lineNumberText);
674
+
675
+ // RIGHT aligned, with left and right margins respected
676
+ int lineNumberX = getPaddingLeft() + leftMargin
677
+ + (lineNumberWidth - rightMargin - textWidth);
678
+
679
+ canvas.drawText(lineNumberText, lineNumberX, paintY, mTextPaint);
680
+
681
+ // Draw text content
682
+ int contentStartX = separatorX + separatorWidth + 10;
683
+ String text = getLine(i);
684
+ lineWidth = Math.max(measureText(text), lineWidth);
685
+
686
+ if (mHighlighter != null && text != null && !text.isEmpty()) {
687
+ int lineHeight = getLineHeight();
688
+ int top = (i - 1) * lineHeight;
689
+ int bottom = i * lineHeight;
690
+ int left = getPaddingLeft() + getLineNumberWidth() + SPACEING;
691
+ int right = getScrollX() + getWidth();
692
+ // Special Cases, like for smali print method line bg
693
+ // Bugs : You cant see the selected visual when you select method line
694
+ mHighlighter.drawLineBackground(canvas, text, i, left, top, right, bottom);
695
+ // Draw line text
696
+ mHighlighter.drawLineText(canvas, text, i, contentStartX, paintY);
697
+ } else {
698
+ mTextPaint.setColor(Color.BLACK);
699
+ canvas.drawText(text, contentStartX, paintY, mTextPaint);
700
+ }
701
+ }
702
+
703
+ mPaint.setColor(Color.parseColor("#FFFAE3"));
704
+ mPaint.setStrokeWidth(0);
705
+ }
706
+
707
+ // ---------- Input Handling (touch/keyboard/IME) ----------
708
+ // Handle touch events (dispatch gesture detectors)
709
+ @Override
710
+ public boolean onTouchEvent(MotionEvent event) {
711
+ // Reset auto-hide timer on any touch
712
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
713
+ requestFocus();
714
+ resetAutoHideTimer();
715
+ }
716
+
717
+ switch (event.getAction()) {
718
+ case MotionEvent.ACTION_DOWN:
719
+ mScroller.abortAnimation();
720
+ break;
721
+ case MotionEvent.ACTION_UP:
722
+ mGestureListener.onUp(event);
723
+ break;
724
+ }
725
+
726
+ mGestureDetector.onTouchEvent(event);
727
+ mScaleGestureDetector.onTouchEvent(event);
728
+ return true;
729
+ }
730
+
731
+ // Reset clipboard auto-hide timer helper
732
+ private void resetAutoHideTimer() {
733
+ if (mClipboardPanel != null && (isSelectMode || mHandleMiddleVisable)) {
734
+ scheduleAutoHide();
735
+ }
736
+ }
737
+
738
+ // Handle key down events for editing keys
739
+ @Override
740
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
741
+ if (!isEditedMode) return super.onKeyDown(keyCode, event);
742
+
743
+ // Skip if we're already processing input to avoid duplicates
744
+ if (mProcessingInput) {
745
+ Log.d(TAG, "Skipping onKeyDown - processing input");
746
+ return true;
747
+ }
748
+
749
+ if (keyCode == KeyEvent.KEYCODE_SHIFT_LEFT ||
750
+ keyCode == KeyEvent.KEYCODE_SHIFT_RIGHT) {
751
+ mMetaState |= KeyEvent.META_SHIFT_ON;
752
+ return true;
753
+ }
754
+
755
+ if (keyCode == KeyEvent.KEYCODE_CTRL_LEFT ||
756
+ keyCode == KeyEvent.KEYCODE_CTRL_RIGHT) {
757
+ mMetaState |= KeyEvent.META_CTRL_ON;
758
+ return true;
759
+ }
760
+
761
+ if (keyCode == KeyEvent.KEYCODE_ALT_LEFT ||
762
+ keyCode == KeyEvent.KEYCODE_ALT_RIGHT) {
763
+ mMetaState |= KeyEvent.META_ALT_ON;
764
+ return true;
765
+ }
766
+
767
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
768
+ Log.d(TAG, "onKeyDown: keyCode=" + keyCode + ", unicode=" + event.getUnicodeChar());
769
+
770
+ switch (keyCode) {
771
+ case KeyEvent.KEYCODE_ENTER:
772
+ case KeyEvent.KEYCODE_NUMPAD_ENTER:
773
+ mProcessingInput = true;
774
+ insert("\n");
775
+ postDelayed(new Runnable() {
776
+ @Override
777
+ public void run() {
778
+ mProcessingInput = false;
779
+ }
780
+ }, INPUT_DEBOUNCE_DELAY);
781
+ return true;
782
+
783
+ case KeyEvent.KEYCODE_DEL:
784
+ mProcessingInput = true;
785
+ delete();
786
+ postDelayed(new Runnable() {
787
+ @Override
788
+ public void run() {
789
+ mProcessingInput = false;
790
+ }
791
+ }, INPUT_DEBOUNCE_DELAY);
792
+ return true;
793
+
794
+ case KeyEvent.KEYCODE_FORWARD_DEL:
795
+ mProcessingInput = true;
796
+ handleForwardDelete();
797
+ postDelayed(new Runnable() {
798
+ @Override
799
+ public void run() {
800
+ mProcessingInput = false;
801
+ }
802
+ }, INPUT_DEBOUNCE_DELAY);
803
+ return true;
804
+
805
+ case KeyEvent.KEYCODE_SPACE:
806
+ mProcessingInput = true;
807
+ insert(" ");
808
+ postDelayed(new Runnable() {
809
+ @Override
810
+ public void run() {
811
+ mProcessingInput = false;
812
+ }
813
+ }, INPUT_DEBOUNCE_DELAY);
814
+ return true;
815
+
816
+ case KeyEvent.KEYCODE_TAB:
817
+ mProcessingInput = true;
818
+ insert("\t");
819
+ postDelayed(new Runnable() {
820
+ @Override
821
+ public void run() {
822
+ mProcessingInput = false;
823
+ }
824
+ }, INPUT_DEBOUNCE_DELAY);
825
+ return true;
826
+
827
+ // Let the input connection handle these to avoid duplicates
828
+ case KeyEvent.KEYCODE_0:
829
+ case KeyEvent.KEYCODE_1:
830
+ case KeyEvent.KEYCODE_2:
831
+ case KeyEvent.KEYCODE_3:
832
+ case KeyEvent.KEYCODE_4:
833
+ case KeyEvent.KEYCODE_5:
834
+ case KeyEvent.KEYCODE_6:
835
+ case KeyEvent.KEYCODE_7:
836
+ case KeyEvent.KEYCODE_8:
837
+ case KeyEvent.KEYCODE_9:
838
+ case KeyEvent.KEYCODE_NUMPAD_0:
839
+ case KeyEvent.KEYCODE_NUMPAD_1:
840
+ case KeyEvent.KEYCODE_NUMPAD_2:
841
+ case KeyEvent.KEYCODE_NUMPAD_3:
842
+ case KeyEvent.KEYCODE_NUMPAD_4:
843
+ case KeyEvent.KEYCODE_NUMPAD_5:
844
+ case KeyEvent.KEYCODE_NUMPAD_6:
845
+ case KeyEvent.KEYCODE_NUMPAD_7:
846
+ case KeyEvent.KEYCODE_NUMPAD_8:
847
+ case KeyEvent.KEYCODE_NUMPAD_9:
848
+ return false; // Let input connection handle via commitText
849
+
850
+ default:
851
+ // For other keys, let the input connection handle them
852
+ return false;
853
+ }
854
+ }
855
+ return super.onKeyDown(keyCode, event);
856
+ }
857
+
858
+ // Handle key up events for modifier keys
859
+ @Override
860
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
861
+ if (keyCode == KeyEvent.KEYCODE_SHIFT_LEFT ||
862
+ keyCode == KeyEvent.KEYCODE_SHIFT_RIGHT) {
863
+ mMetaState &= ~KeyEvent.META_SHIFT_ON;
864
+ return true;
865
+ }
866
+
867
+ if (keyCode == KeyEvent.KEYCODE_CTRL_LEFT ||
868
+ keyCode == KeyEvent.KEYCODE_CTRL_RIGHT) {
869
+ mMetaState &= ~KeyEvent.META_CTRL_ON;
870
+ return true;
871
+ }
872
+
873
+ if (keyCode == KeyEvent.KEYCODE_ALT_LEFT ||
874
+ keyCode == KeyEvent.KEYCODE_ALT_RIGHT) {
875
+ mMetaState &= ~KeyEvent.META_ALT_ON;
876
+ return true;
877
+ }
878
+
879
+ return super.onKeyUp(keyCode, event);
880
+ }
881
+
882
+ // Create input connection for IME
883
+ @Override
884
+ public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
885
+ // TODO: Implement this method
886
+ outAttrs.inputType = InputType.TYPE_CLASS_TEXT
887
+ | InputType.TYPE_TEXT_FLAG_MULTI_LINE;
888
+ outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_ENTER_ACTION
889
+ | EditorInfo.IME_ACTION_DONE
890
+ | EditorInfo.IME_FLAG_NO_EXTRACT_UI;
891
+
892
+ return new TextInputConnection(this, true);
893
+ }
894
+
895
+ // Toggle software keyboard
896
+ public void showSoftInput(boolean show) {
897
+ if (isEditedMode) {
898
+ InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
899
+ if (show)
900
+ imm.showSoftInput(this, 0);
901
+ else
902
+ imm.hideSoftInputFromWindow(getWindowToken(), 0);
903
+ }
904
+ }
905
+
906
+ // ---------- Text and Buffer Operations ----------
907
+ // Set an external buffer
908
+ public void setBuffer(GapBuffer buffer) {
909
+ mGapBuffer = buffer;
910
+ clearSyntaxCache();
911
+ dismissAutoComplete();
912
+ invalidate();
913
+ }
914
+
915
+ // Get current buffer
916
+ public GapBuffer getBuffer() {
917
+ return this.mGapBuffer;
918
+ }
919
+
920
+ // Set text directly
921
+ public void setText(String text) {
922
+ mGapBuffer = new GapBuffer(text);
923
+ clearSyntaxCache();
924
+ dismissAutoComplete();
925
+ invalidate();
926
+ }
927
+
928
+ // Set font size in px with bounds and adjust scroll to keep relative position
929
+ public void setTextSize(float px) {
930
+ float min = ScreenUtils.dip2px(getContext(), 10);
931
+ float max = ScreenUtils.dip2px(getContext(), 30);
932
+
933
+ if (px < min) px = min;
934
+ if (px > max) px = max;
935
+
936
+ if (px == mTextPaint.getTextSize()) return;
937
+
938
+ int currentScrollY = getScrollY();
939
+ int currentLine = Math.max(1, currentScrollY / getLineHeight());
940
+ float lineFraction = (float) (currentScrollY % getLineHeight()) / getLineHeight();
941
+
942
+ mTextPaint.setTextSize(px);
943
+
944
+ adjustCursorPosition();
945
+ if (isSelectMode) {
946
+ adjustSelectRange(selectionStart, selectionEnd);
947
+ }
948
+
949
+ // Update magnifier position if active
950
+ if (mIsMagnifierShowing && mMagnifierEnabled) {
951
+ // updateMagnifier(mCursorPosX, mCursorPosY + getLineHeight());
952
+ }
953
+
954
+ int newLineHeight = getLineHeight();
955
+ int newScrollY = (int) (currentLine * newLineHeight + lineFraction * newLineHeight);
956
+ newScrollY = Math.max(0, Math.min(newScrollY, getMaxScrollY()));
957
+ int newScrollX = getScrollX();
958
+
959
+ scrollTo(newScrollX, newScrollY);
960
+ postInvalidate();
961
+ }
962
+
963
+ // Toggle edit mode
964
+ public void setEditedMode(boolean editMode) {
965
+ isEditedMode = editMode;
966
+ }
967
+
968
+ // Get edit mode
969
+ public boolean getEditedMode() {
970
+ return isEditedMode;
971
+ }
972
+
973
+ // Set typeface
974
+ public void setTypeface(Typeface typeface) {
975
+ mTextPaint.setTypeface(typeface);
976
+ }
977
+
978
+ // Set listener for text change
979
+ public void setOnTextChangedListener(OnTextChangedListener listener) {
980
+ mTextListener = listener;
981
+ }
982
+
983
+ // Return left padding + line number width
984
+
985
+ public int getLeftSpace() {
986
+ int lineNumberWidth = getLineNumberWidth();
987
+ int separatorWidth = 2;
988
+ int contentPadding = 10;
989
+
990
+ return getPaddingLeft() + lineNumberWidth + SPACEING * 2 + separatorWidth + contentPadding;
991
+ }
992
+
993
+ // Measure text width
994
+ public int measureText(String text) {
995
+ return (int) Math.ceil(mTextPaint.measureText(text));
996
+ }
997
+
998
+ // Get single line height in px
999
+ public int getLineHeight() {
1000
+ TextPaint.FontMetricsInt metrics = mTextPaint.getFontMetricsInt();
1001
+ return metrics.bottom - metrics.top;
1002
+ }
1003
+
1004
+ // Get the start offset of a line
1005
+ private int getLineStart(int lineNumber) {
1006
+ return mGapBuffer.getLineOffset(lineNumber);
1007
+ }
1008
+
1009
+ // Get the last line offset of a line
1010
+ private int getLineEnd(int line) {
1011
+ int nextLine = line + 1;
1012
+
1013
+ if (nextLine > getLineCount()) {
1014
+ // last line → return end of buffer
1015
+ return mGapBuffer.length();
1016
+ }
1017
+
1018
+ return getLineStart(nextLine) - 1; // end before newline
1019
+ }
1020
+
1021
+ // Find which line an offset is on
1022
+ private int getOffsetLine(int offset) {
1023
+ return mGapBuffer.findLineNumber(offset);
1024
+ }
1025
+
1026
+ // Get text size px
1027
+ public float getTextSize() {
1028
+ return mTextPaint.getTextSize();
1029
+ }
1030
+
1031
+ // Get number of lines
1032
+ public int getLineCount() {
1033
+ return mGapBuffer.getLineCount();
1034
+ }
1035
+
1036
+ // Width needed to draw line numbers
1037
+ private int getLineNumberWidth() {
1038
+ return measureText(Integer.toString(getLineCount()));
1039
+ }
1040
+
1041
+ // get the column where the cursor is placed
1042
+ public int getColumn() {
1043
+ if (mCursorLine < 1 || mCursorLine > getLineCount()) {
1044
+ return 0;
1045
+ }
1046
+ int lineStart = getLineStart(mCursorLine);
1047
+ return mCursorIndex - lineStart;
1048
+ }
1049
+
1050
+ // Helper used by clipboard panel to show selection
1051
+ public void selectText(boolean enable) {
1052
+ if (!enable) clearSelectionMenu();
1053
+ }
1054
+
1055
+ // Get bounding box for caret at index (for popup positioning)
1056
+ public Rect getBoundingBox(int index) {
1057
+ int left = getLeftSpace();
1058
+ int line = getOffsetLine(index);
1059
+ int lineStart = getLineStart(line);
1060
+ String text = mGapBuffer.substring(lineStart, Math.min(index, mGapBuffer.length()));
1061
+ int x = left + measureText(text);
1062
+ int y = (line - 1) * getLineHeight();
1063
+
1064
+ // Translate for scroll and padding
1065
+ int viewX = x - getScrollX() + getPaddingLeft();
1066
+ int viewY = y - getScrollY() + getPaddingTop();
1067
+
1068
+ // Make sure panel appears above caret line
1069
+ return new Rect(viewX, viewY - getLineHeight() * 2, viewX + getLineHeight(), viewY);
1070
+ }
1071
+
1072
+ // Get caret index
1073
+ public int getCaretPosition() {
1074
+ return mCursorIndex;
1075
+ }
1076
+
1077
+ // Get selected text string
1078
+ public String getSelectedText() {
1079
+ if (isSelectMode && selectionStart < selectionEnd) {
1080
+ return mGapBuffer.substring(selectionStart, selectionEnd);
1081
+ }
1082
+ return "";
1083
+ }
1084
+
1085
+ // Getting the selected text length
1086
+ public int getSelectionLength() {
1087
+ if (!isSelectMode) return 0;
1088
+ return selectionEnd - selectionStart;
1089
+ }
1090
+
1091
+ // Insert text at caret, with auto-indent, autocomplete, and blinking handling
1092
+ public void insertText(String text) {
1093
+ if (text == null || text.isEmpty()) return;
1094
+
1095
+ int insertAt = mCursorIndex;
1096
+
1097
+ // If selection exists → replace it
1098
+ if (isSelectMode && selectionStart != selectionEnd) {
1099
+
1100
+ int s = Math.min(selectionStart, selectionEnd);
1101
+ int e = Math.max(selectionStart, selectionEnd);
1102
+
1103
+ mGapBuffer.beginBatchEdit();
1104
+ mGapBuffer.markSelectionBefore(selectionStart, selectionEnd, true);
1105
+
1106
+ // Replace selection in one undo-able step
1107
+ mGapBuffer.replace(s, e, text, true);
1108
+
1109
+ // Set new cursor after inserted text
1110
+ insertAt = s + text.length();
1111
+ isSelectMode = false;
1112
+ selectionStart = selectionEnd = -1;
1113
+
1114
+ mGapBuffer.markSelectionAfter(insertAt, insertAt, false);
1115
+ mGapBuffer.endBatchEdit();
1116
+
1117
+ } else {
1118
+
1119
+ // No selection → simple insert with undo snapshot
1120
+ mGapBuffer.beginBatchEdit();
1121
+ mGapBuffer.markSelectionBefore(mCursorIndex, mCursorIndex, false);
1122
+
1123
+ mGapBuffer.insert(mCursorIndex, text, true);
1124
+ insertAt = mCursorIndex + text.length();
1125
+
1126
+ mGapBuffer.markSelectionAfter(insertAt, insertAt, false);
1127
+ mGapBuffer.endBatchEdit();
1128
+ }
1129
+
1130
+ mCursorIndex = insertAt;
1131
+ mCursorLine = getOffsetLine(mCursorIndex);
1132
+
1133
+ adjustCursorPosition();
1134
+ scrollToVisable();
1135
+ postInvalidate();
1136
+ }
1137
+
1138
+ // Selection getters
1139
+ public int getSelectionStart() {
1140
+ return selectionStart;
1141
+ }
1142
+
1143
+ public int getSelectionEnd() {
1144
+ return selectionEnd;
1145
+ }
1146
+
1147
+ // Delete selected text if in selection mode
1148
+ public void deleteSelectedText() {
1149
+ if (isSelectMode && selectionStart < selectionEnd) {
1150
+ mGapBuffer.delete(selectionStart, selectionEnd, true);
1151
+ mCursorIndex = selectionStart;
1152
+ isSelectMode = false;
1153
+ adjustCursorPosition();
1154
+ scrollToVisable();
1155
+ dismissAutoComplete();
1156
+ postDelayed(mWordUpdateRunnable, WORD_UPDATE_DELAY);
1157
+ postInvalidate();
1158
+ }
1159
+ }
1160
+
1161
+ // Select word at caret
1162
+ public void selectWordAtCursor() {
1163
+ if (mGapBuffer.length() == 0) return;
1164
+
1165
+ int start = mCursorIndex;
1166
+ int end = mCursorIndex;
1167
+
1168
+ // Find word start
1169
+ while (start > 0 && Character.isJavaIdentifierPart(mGapBuffer.charAt(start - 1))) {
1170
+ start--;
1171
+ }
1172
+
1173
+ // Find word end
1174
+ while (end < mGapBuffer.length() && Character.isJavaIdentifierPart(mGapBuffer.charAt(end))) {
1175
+ end++;
1176
+ }
1177
+
1178
+ if (start < end) {
1179
+ selectionStart = start;
1180
+ selectionEnd = end;
1181
+ isSelectMode = true;
1182
+ adjustSelectRange(start, end);
1183
+ postInvalidate();
1184
+ }
1185
+ }
1186
+
1187
+ // Move caret to next word start
1188
+ public void moveToNextWord() {
1189
+ if (mCursorIndex >= mGapBuffer.length()) return;
1190
+
1191
+ int newPos = mCursorIndex;
1192
+
1193
+ // Skip current word if we're in one
1194
+ while (newPos < mGapBuffer.length() && Character.isJavaIdentifierPart(mGapBuffer.charAt(newPos))) {
1195
+ newPos++;
1196
+ }
1197
+
1198
+ // Skip non-word characters
1199
+ while (newPos < mGapBuffer.length() && !Character.isJavaIdentifierPart(mGapBuffer.charAt(newPos))) {
1200
+ newPos++;
1201
+ }
1202
+
1203
+ setCursorPosition(newPos);
1204
+ scrollToVisable();
1205
+ postInvalidate();
1206
+ }
1207
+
1208
+ // Move caret to previous word start
1209
+ public void moveToPreviousWord() {
1210
+ if (mCursorIndex <= 0) return;
1211
+
1212
+ int newPos = mCursorIndex - 1;
1213
+
1214
+ // Skip non-word characters backwards
1215
+ while (newPos > 0 && !Character.isJavaIdentifierPart(mGapBuffer.charAt(newPos))) {
1216
+ newPos--;
1217
+ }
1218
+
1219
+ // Skip word characters backwards
1220
+ while (newPos > 0 && Character.isJavaIdentifierPart(mGapBuffer.charAt(newPos - 1))) {
1221
+ newPos--;
1222
+ }
1223
+
1224
+ setCursorPosition(newPos);
1225
+ scrollToVisable();
1226
+ postInvalidate();
1227
+ }
1228
+
1229
+ // Select all text
1230
+ public void selectAll() {
1231
+ removeCallbacks(blinkAction);
1232
+ mCursorVisiable = false;
1233
+ mHandleMiddleVisable = false; // ← CHANGE TO FALSE
1234
+ isSelectMode = true;
1235
+
1236
+ // at first index
1237
+ selectionStart = 0;
1238
+ // at last index
1239
+ selectionEnd = mGapBuffer.length();
1240
+
1241
+ // set handle left at first position
1242
+ selectHandleLeftX = getLeftSpace();
1243
+ selectHandleLeftY = getLineHeight();
1244
+
1245
+ // set handle right at last position
1246
+ selectHandleRightX = getLeftSpace() + getLineWidth(getLineCount());
1247
+ selectHandleRightY = getLineCount() * getLineHeight();
1248
+
1249
+ // set cursor index and position
1250
+ setCursorPosition(selectionEnd);
1251
+
1252
+ if (!mReplaceList.isEmpty())
1253
+ mReplaceList.clear();
1254
+ smoothScrollTo(0, Math.max(getLineCount() * getLineHeight() - getHeight() + getLineHeight() * 2, 0));
1255
+ showTextSelectionWindow();
1256
+ postInvalidate();
1257
+ }
1258
+
1259
+ // Helper method to check if all texts are selected
1260
+ public boolean isAllTextSelected() {
1261
+ return selectionStart == 0 &&
1262
+ selectionEnd == mGapBuffer.length() &&
1263
+ selectionStart != selectionEnd &&
1264
+ mGapBuffer.length() > 0;
1265
+ }
1266
+
1267
+ // Clear selection and related UI
1268
+ public void clearSelectionMenu() {
1269
+ isSelectMode = false;
1270
+ mHandleMiddleVisable = false;
1271
+ mIsLineSelectionMode = false;
1272
+ mStartSelectionLine = -1;
1273
+ mEndSelectionLine = -1;
1274
+ mFirstSelectedLine = -1;
1275
+ mSecondSelectedLine = -1;
1276
+ mWaitingForSecondSelection = false;
1277
+ mSelectionHandler.removeCallbacks(mClearSelectionRunnable);
1278
+ dismissAutoComplete();
1279
+ onCursorOrSelectionChanged();
1280
+ postInvalidate();
1281
+ }
1282
+
1283
+ // Check selection mode
1284
+ public boolean isSelectMode() {
1285
+ return isSelectMode;
1286
+ }
1287
+
1288
+ // Check if focused on line number area
1289
+ private boolean isInLineNumberArea(float x, float y) {
1290
+ int lineNumberWidth = getLineNumberWidth() + SPACEING * 2;
1291
+ return x >= getPaddingLeft() && x <= getPaddingLeft() + lineNumberWidth;
1292
+ }
1293
+
1294
+ // Get line from Y dir.
1295
+ private int getLineFromY(float y) {
1296
+ int line = (int) (y / getLineHeight()) + 1;
1297
+ return Math.max(1, Math.min(line, getLineCount()));
1298
+ }
1299
+
1300
+ // Get raw line string
1301
+ public String getLine(int lineNumber) {
1302
+ return mGapBuffer.getLine(lineNumber);
1303
+ }
1304
+
1305
+ // Width of specified line text
1306
+ private int getLineWidth(int lineNumber) {
1307
+ return measureText(getLine(lineNumber));
1308
+ }
1309
+
1310
+ // Width of lines
1311
+ public int getLineWidth() {
1312
+ return lineWidth;
1313
+ }
1314
+
1315
+ // Width of space
1316
+ public int getSpaceWidth() {
1317
+ return spaceWidth;
1318
+ }
1319
+
1320
+ // Get maximum scrollable X
1321
+ public int getMaxScrollX() {
1322
+ return Math.max(0, getLeftSpace() + lineWidth + spaceWidth * 4 - getWidth());
1323
+ }
1324
+
1325
+ // Get maximum scrollable Y
1326
+ public int getMaxScrollY() {
1327
+ return Math.max(0, (getLineCount() + 2) * getLineHeight() - getHeight());
1328
+ }
1329
+
1330
+ // ---------- Magnifier ----------
1331
+ // Enable or disable magnifier usage, only for Android 8+
1332
+ public void setMagnifierEnabled(boolean enabled) {
1333
+ mMagnifierEnabled = enabled;
1334
+ if (!enabled && mIsMagnifierShowing) {
1335
+ dismissMagnifier();
1336
+ }
1337
+ }
1338
+
1339
+ // Check magnifier enabled
1340
+ public boolean isMagnifierEnabled() {
1341
+ return mMagnifierEnabled;
1342
+ }
1343
+
1344
+ // Show magnifier centered near content coords
1345
+ private void showMagnifier(float x, float y) {
1346
+ if (!mMagnifierEnabled || mMagnifier == null) return;
1347
+ try {
1348
+ hideTextSelectionWindow();
1349
+ // Convert content coordinates to screen coordinates
1350
+ float adjustedX = x - getScrollX();
1351
+ float adjustedY = y - getScrollY();
1352
+
1353
+ // Adjust Y to center magnifier on the current line's text
1354
+ float lineHeight = getLineHeight();
1355
+ adjustedY -= lineHeight * 0.5f; // Center on the current line (baseline)
1356
+
1357
+ // Account for padding and canvas translation
1358
+ adjustedY += getPaddingTop();
1359
+
1360
+ // Ensure magnifier stays within view bounds
1361
+ adjustedX = Math.max(50, Math.min(adjustedX, getWidth() - 50));
1362
+ adjustedY = Math.max(50, Math.min(adjustedY, getHeight() - 50));
1363
+
1364
+ // Debug logging
1365
+ Log.d(TAG, "showMagnifier: x=" + adjustedX + ", y=" + adjustedY + ", rawX=" + x + ", rawY=" + y + ", scrollY=" + getScrollY() + ", lineHeight=" + lineHeight);
1366
+
1367
+ mMagnifier.show((int) adjustedX, (int) adjustedY);
1368
+ mMagnifierX = adjustedX;
1369
+ mMagnifierY = adjustedY;
1370
+ mIsMagnifierShowing = true;
1371
+ } catch (Exception e) {
1372
+ Log.e(TAG, "Error showing magnifier: " + e.getMessage());
1373
+ dismissMagnifier();
1374
+ }
1375
+ }
1376
+
1377
+ // Update magnifier position smoothly
1378
+ private void updateMagnifier(float x, float y) {
1379
+ if (!mIsMagnifierShowing || !mMagnifierEnabled || mMagnifier == null) return;
1380
+ try {
1381
+ // Convert content coordinates to screen coordinates
1382
+ float adjustedX = x - getScrollX();
1383
+ float adjustedY = y - getScrollY();
1384
+
1385
+ // Adjust Y to center magnifier on the current line's text
1386
+ float lineHeight = getLineHeight();
1387
+ adjustedY -= lineHeight * 0.5f; // Center on the current line (baseline)
1388
+
1389
+ // Account for padding and canvas translation
1390
+ adjustedY += getPaddingTop();
1391
+
1392
+ // Ensure magnifier stays within view bounds
1393
+ adjustedX = Math.max(50, Math.min(adjustedX, getWidth() - 50));
1394
+ adjustedY = Math.max(50, Math.min(adjustedY, getHeight() - 50));
1395
+
1396
+ // Smooth update only if significant movement
1397
+ if (Math.abs(adjustedX - mMagnifierX) > 1 || Math.abs(adjustedY - mMagnifierY) > 1) {
1398
+ Log.d(TAG, "updateMagnifier: x=" + adjustedX + ", y=" + adjustedY + ", rawX=" + x + ", rawY=" + y + ", scrollY=" + getScrollY() + ", lineHeight=" + lineHeight);
1399
+
1400
+ mMagnifier.show((int) adjustedX, (int) adjustedY);
1401
+ mMagnifierX = adjustedX;
1402
+ mMagnifierY = adjustedY;
1403
+ }
1404
+ } catch (Exception e) {
1405
+ Log.e(TAG, "Error updating magnifier: " + e.getMessage());
1406
+ dismissMagnifier();
1407
+ }
1408
+ }
1409
+
1410
+ // Dismiss magnifier if visible
1411
+ private void dismissMagnifier() {
1412
+ if (mIsMagnifierShowing && mMagnifier != null) {
1413
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
1414
+ try {
1415
+ mMagnifier.dismiss();
1416
+ mIsMagnifierShowing = false;
1417
+ } catch (Exception e) {
1418
+ Log.e(TAG, "Error dismissing magnifier: " + e.getMessage());
1419
+ }
1420
+ }
1421
+ }
1422
+ }
1423
+
1424
+ // Syntax Helpers
1425
+ public void setSyntaxLanguageFileName(String languageFile) {
1426
+ mHighlighter = new MHSyntaxHighlightEngine(getContext(), mTextPaint, languageFile, isSyntaxDarkMode);
1427
+ }
1428
+
1429
+ // Syntax dark mode helper
1430
+ public void setSyntaxDarkMode(boolean isDark) {
1431
+ isSyntaxDarkMode = isDark;
1432
+ }
1433
+
1434
+ // Get syntax comment block of exists
1435
+ public String getCommentBlock() {
1436
+ String block = mHighlighter.getCommentSyntaxBlock();
1437
+ if (block == null) return "";
1438
+ return block;
1439
+ }
1440
+
1441
+ public void setMenuStyle(ClipboardPanel.MenuDisplayMode mode) {
1442
+ mClipboardPanel.setMenuDisplayMode(mode);
1443
+ }
1444
+
1445
+ // ---------- Scrolling Helpers ----------
1446
+ /**
1447
+ * Like {@link View#scrollBy}, but scroll smoothly instead of immediately.
1448
+ *
1449
+ * @param dx the number of pixels to scroll by on the X axis
1450
+ * @param dy the number of pixels to scroll by on the Y axis
1451
+ */
1452
+ public final void smoothScrollBy(int dx, int dy) {
1453
+ if (getHeight() == 0) {
1454
+ // Nothing to do.
1455
+ return;
1456
+ }
1457
+ long duration = AnimationUtils.currentAnimationTimeMillis() - mLastScroll;
1458
+ if (duration > DEFAULT_DURATION) {
1459
+ mScroller.startScroll(getScrollX(), getScrollY(), dx, dy);
1460
+ postInvalidateOnAnimation();
1461
+ } else {
1462
+ if (!mScroller.isFinished()) {
1463
+ mScroller.abortAnimation();
1464
+ }
1465
+ scrollBy(dx, dy);
1466
+ }
1467
+ mLastScroll = AnimationUtils.currentAnimationTimeMillis();
1468
+ }
1469
+
1470
+ /**
1471
+ * Like {@link #scrollTo}, but scroll smoothly instead of immediately.
1472
+ *
1473
+ * @param x the position where to scroll on the X axis
1474
+ * @param y the position where to scroll on the Y axis
1475
+ */
1476
+ public final void smoothScrollTo(int x, int y) {
1477
+ smoothScrollBy(x - getScrollX(), y - getScrollY());
1478
+ }
1479
+
1480
+ // Compute fling/scroll animation progress
1481
+ @Override
1482
+ public void computeScroll() {
1483
+ super.computeScroll();
1484
+ if (mScroller.computeScrollOffset()) {
1485
+ scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
1486
+ postInvalidate();
1487
+ }
1488
+ }
1489
+
1490
+ // Scroll to ensure caret is visible with margins
1491
+ private void scrollToVisable() {
1492
+ // Only scroll if cursor is significantly out of view
1493
+ int dx = 0;
1494
+ int leftMargin = spaceWidth * 3;
1495
+ int rightMargin = screenWidth - spaceWidth * 2;
1496
+
1497
+ if (mCursorPosX - getScrollX() < leftMargin) {
1498
+ dx = mCursorPosX - getScrollX() - leftMargin;
1499
+ } else if (mCursorPosX - getScrollX() > rightMargin) {
1500
+ dx = mCursorPosX - getScrollX() - rightMargin;
1501
+ }
1502
+
1503
+ int dy = 0;
1504
+ int topMargin = getLineHeight();
1505
+ int bottomMargin = getHeight() - getLineHeight();
1506
+
1507
+ if (mCursorPosY - getScrollY() < topMargin) {
1508
+ dy = mCursorPosY - getScrollY() - topMargin;
1509
+ } else if (mCursorPosY - getScrollY() > bottomMargin) {
1510
+ dy = mCursorPosY - getScrollY() - bottomMargin;
1511
+ }
1512
+
1513
+ // Only scroll if necessary
1514
+ if (dx != 0 || dy != 0) {
1515
+ smoothScrollBy(dx, dy);
1516
+ }
1517
+ }
1518
+
1519
+ // ---------- Syntax / Highlights ----------
1520
+ // Clear syntax highlight cache
1521
+ private void clearSyntaxCache() {
1522
+ if (mHighlighter != null) {
1523
+ mHighlighter.clearCache();
1524
+ }
1525
+ }
1526
+
1527
+ // Call this method whenever the text content changes
1528
+ public void onTextChanged() {
1529
+ clearSyntaxCache(); // keep cache fresh
1530
+ mTextListener.onTextChanged();
1531
+ }
1532
+
1533
+ // ---------- Insert / Delete with handling ----------
1534
+ // Insert text with auto-indent, autocomplete and selection handling
1535
+ private void insert(String text) {
1536
+ if (!isEditedMode) return; // nothing to do
1537
+ if (isSelectMode) {
1538
+ mGapBuffer.beginBatchEdit();
1539
+ delete();
1540
+ }
1541
+
1542
+ if (text.equals("\n") && mAutoIndentEnabled) {
1543
+ String indent = getAutoIndent();
1544
+ if (!indent.isEmpty()) {
1545
+ text = "\n" + indent;
1546
+ }
1547
+ }
1548
+
1549
+ removeCallbacks(blinkAction);
1550
+ mCursorVisiable = true;
1551
+ mHandleMiddleVisable = false;
1552
+
1553
+ mCurrentPrefix = getCurrentPrefix();
1554
+
1555
+ mGapBuffer.insert(mCursorIndex, text, true);
1556
+
1557
+ // Handle auto-complete
1558
+ if (!text.trim().isEmpty()) {
1559
+ if (!mCurrentPrefix.isEmpty()) {
1560
+ Log.d(TAG, "Prefix: " + mCurrentPrefix);
1561
+ filterAndShowSuggestions(mCurrentPrefix + text);
1562
+ } else {
1563
+ dismissAutoComplete();
1564
+ }
1565
+ } else {
1566
+ dismissAutoComplete();
1567
+ }
1568
+
1569
+ if (mGapBuffer.isBatchEdit())
1570
+ mGapBuffer.endBatchEdit();
1571
+
1572
+ // calculate the cursor index and line
1573
+ int length = text.length();
1574
+ mCursorIndex += length;
1575
+ mCursorLine = getOffsetLine(mCursorIndex);
1576
+ adjustCursorPosition();
1577
+
1578
+ onTextChanged();
1579
+ scrollToVisable();
1580
+ postInvalidate();
1581
+ postDelayed(blinkAction, BLINK_TIMEOUT);
1582
+ }
1583
+
1584
+ // Insert words from typed text to word set
1585
+ private void extractWordsFromText(String newText) {
1586
+ if (newText == null || newText.isEmpty()) return;
1587
+ mWordSet.add(newText); // Fast O(1) operation
1588
+ }
1589
+
1590
+ // Delete char before caret or currently selected range
1591
+ public void delete() {
1592
+ if (!isEditedMode) return; // nothing to do
1593
+ if (mCursorIndex <= 0) return;
1594
+
1595
+ removeCallbacks(blinkAction);
1596
+ mCursorVisiable = true;
1597
+ mHandleMiddleVisable = false;
1598
+
1599
+ if (isSelectMode) {
1600
+ isSelectMode = false;
1601
+ mGapBuffer.delete(selectionStart, selectionEnd, true);
1602
+ mCursorIndex -= selectionEnd - selectionStart;
1603
+ } else {
1604
+ mGapBuffer.delete(mCursorIndex - 1, mCursorIndex, true);
1605
+ mCursorIndex--;
1606
+ }
1607
+
1608
+ // calculate cursor index and line
1609
+ mCursorLine = getOffsetLine(mCursorIndex);
1610
+ adjustCursorPosition();
1611
+ onCursorOrSelectionChanged();
1612
+
1613
+ onTextChanged();
1614
+ scrollToVisable();
1615
+ postInvalidate();
1616
+ postDelayed(blinkAction, BLINK_TIMEOUT);
1617
+ }
1618
+
1619
+ // Handle forward delete (placeholder call used earlier)
1620
+ private void handleForwardDelete() {
1621
+ if (!isEditedMode) return;
1622
+ if (mCursorIndex >= mGapBuffer.length()) return;
1623
+
1624
+ removeCallbacks(blinkAction);
1625
+ mCursorVisiable = true;
1626
+ mHandleMiddleVisable = false;
1627
+
1628
+ if (isSelectMode) {
1629
+ isSelectMode = false;
1630
+ mGapBuffer.delete(selectionStart, selectionEnd, true);
1631
+ mCursorIndex = selectionStart;
1632
+ } else {
1633
+ mGapBuffer.delete(mCursorIndex, mCursorIndex + 1, true);
1634
+ // Cursor index stays the same when forward deleting
1635
+ }
1636
+
1637
+ mCursorLine = getOffsetLine(mCursorIndex);
1638
+ adjustCursorPosition();
1639
+
1640
+ onCursorOrSelectionChanged();
1641
+
1642
+ clearSyntaxCache();
1643
+ onTextChanged();
1644
+ scrollToVisable();
1645
+
1646
+ mCurrentPrefix = getCurrentPrefix();
1647
+ if (mCurrentPrefix.isEmpty()) {
1648
+ dismissAutoComplete();
1649
+ } else {
1650
+ filterAndShowSuggestions(mCurrentPrefix);
1651
+ }
1652
+
1653
+ postInvalidate();
1654
+ postDelayed(blinkAction, BLINK_TIMEOUT);
1655
+ }
1656
+
1657
+ // ---------- Clipboard (copy/cut/paste/share) ----------
1658
+ // Copy selected text to clipboard
1659
+ public void copy() {
1660
+ String text = getSelectedText();
1661
+ if (text != null && !text.equals("")) {
1662
+ ClipData data = ClipData.newPlainText("content", text);
1663
+ mClipboard.setPrimaryClip(data);
1664
+ }
1665
+ }
1666
+
1667
+ // Cut selected text to clipboard (copy then delete)
1668
+ public void cut() {
1669
+ copy();
1670
+ delete();
1671
+ isSelectMode = false;
1672
+ }
1673
+
1674
+ // Paste from clipboard at caret
1675
+ public void paste() {
1676
+ if (mClipboard.hasPrimaryClip()) {
1677
+ ClipDescription description = mClipboard.getPrimaryClipDescription();
1678
+ if (description.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
1679
+ ClipData data = mClipboard.getPrimaryClip();
1680
+ ClipData.Item item = data.getItemAt(0);
1681
+ String text = item.getText().toString();
1682
+ insert(text);
1683
+ }
1684
+ }
1685
+ }
1686
+
1687
+ // Share currently selected text via ACTION_SEND
1688
+ public void shareText() {
1689
+ if (isSelectMode) {
1690
+ String selectedText = getSelectedText();
1691
+ Intent shareIntent = new Intent(Intent.ACTION_SEND);
1692
+ shareIntent.setType("text/plain");
1693
+ shareIntent.putExtra(Intent.EXTRA_TEXT, selectedText);
1694
+ getContext().startActivity(Intent.createChooser(shareIntent, "Share text"));
1695
+ }
1696
+ }
1697
+
1698
+ // ---------- Search / Replace operations ----------
1699
+ // Scroll to the found match position
1700
+ private void scrollToFindPosition(int curr) {
1701
+ int first = mReplaceList.get(curr).first;
1702
+ int second = mReplaceList.get(curr).second;
1703
+
1704
+ setCursorPosition(second);
1705
+ adjustSelectRange(first, second);
1706
+
1707
+ smoothScrollTo(Math.max(0, selectHandleLeftX - getWidth() / 2),
1708
+ Math.max(0, selectHandleLeftY - getHeight() / 2));
1709
+ postInvalidate();
1710
+ }
1711
+
1712
+ // Find current replace list index for selectionStart/End via binary search
1713
+ private int current() {
1714
+ // Comparator implementation
1715
+ Comparator<Pair<Integer, Integer>> comparator = new Comparator<Pair<Integer, Integer>>() {
1716
+ @Override
1717
+ public int compare(Pair<Integer, Integer> a, Pair<Integer, Integer> b) {
1718
+ int result = a.first - b.first;
1719
+ return result == 0 ? a.second - b.second : result;
1720
+ }
1721
+ };
1722
+
1723
+ // binarySearch using the comparator
1724
+ return Collections.binarySearch(mReplaceList,
1725
+ new Pair<Integer, Integer>(selectionStart, selectionEnd),
1726
+ comparator);
1727
+ }
1728
+
1729
+ // Move to previous match
1730
+ public void previous() {
1731
+ int currIndex = current();
1732
+ int prev = --currIndex;
1733
+ if (prev < 0) {
1734
+ prev = mReplaceList.size() - 1;
1735
+ }
1736
+ scrollToFindPosition(prev);
1737
+ }
1738
+
1739
+ // Move to next match
1740
+ public void next() {
1741
+ int currIndex = current();
1742
+ int next = ++currIndex;
1743
+ if (next >= mReplaceList.size()) {
1744
+ next = 0;
1745
+ }
1746
+ scrollToFindPosition(next);
1747
+ }
1748
+
1749
+ // Find all matches for regex in buffer
1750
+ public void find(String regex) {
1751
+ if (!mReplaceList.isEmpty())
1752
+ mReplaceList.clear();
1753
+
1754
+ Matcher matcher = Pattern.compile(regex).matcher(mGapBuffer.toString());
1755
+
1756
+ while (matcher.find()) {
1757
+ mReplaceList.add(new Pair<Integer, Integer>(matcher.start(), matcher.end()));
1758
+ }
1759
+ }
1760
+
1761
+ // Replace first match
1762
+ public void replaceFirst(String replacement) {
1763
+ if (!mReplaceList.isEmpty() && isEditedMode) {
1764
+ int start = mReplaceList.get(0).first;
1765
+ int end = mReplaceList.get(0).second;
1766
+
1767
+ mGapBuffer.beginBatchEdit();
1768
+ mGapBuffer.replace(start, end, replacement, true);
1769
+ mGapBuffer.endBatchEdit();
1770
+
1771
+ int length = replacement.length();
1772
+ setCursorPosition(start + length);
1773
+ adjustSelectRange(start + length, start + length);
1774
+
1775
+ // remove the first item
1776
+ mReplaceList.remove(0);
1777
+
1778
+ int delta = start + length - end;
1779
+ // do not use the find(regex) method to re-find
1780
+ // recalculate replace list by index
1781
+ for (int i = 0; i < mReplaceList.size(); ++i) {
1782
+ int first = (int) mReplaceList.get(i).first + delta;
1783
+ int second = (int) mReplaceList.get(i).second + delta;
1784
+ mReplaceList.set(i, new Pair<Integer, Integer>(first, second));
1785
+ }
1786
+ } else {
1787
+ // if the replace Lists is empty
1788
+ // set the select mode false
1789
+ isSelectMode = false;
1790
+ }
1791
+ postInvalidate();
1792
+ }
1793
+
1794
+ // Replace all matches iteratively
1795
+ public void replaceAll(String replacement) {
1796
+ while (!mReplaceList.isEmpty() && isEditedMode) {
1797
+ replaceFirst(replacement);
1798
+ }
1799
+ }
1800
+
1801
+ // ---------- Cursor positioning ----------
1802
+ // Goto line number (1-based)
1803
+ public void gotoLine(int line) {
1804
+ line = Math.min(Math.max(line, 1), getLineCount());
1805
+
1806
+ // Clear any active selection to cancel it before navigating
1807
+ if (isSelectMode) {
1808
+ clearSelectionMenu();
1809
+ }
1810
+
1811
+ mCursorIndex = getLineStart(line);
1812
+ mCursorLine = line;
1813
+ mCursorPosX = getLeftSpace();
1814
+ mCursorPosY = (line - 1) * getLineHeight();
1815
+
1816
+ smoothScrollTo(0, Math.max(line * getLineHeight() - getHeight() + getLineHeight() * 2, 0));
1817
+ postInvalidate(); // Ensure immediate redraw after clearing selection
1818
+ }
1819
+
1820
+ // Check undo available
1821
+ public boolean canUndo() {
1822
+ return mGapBuffer.canUndo();
1823
+ }
1824
+
1825
+ // Check redo available
1826
+ public boolean canRedo() {
1827
+ return mGapBuffer.canRedo();
1828
+ }
1829
+
1830
+ // Undo operation and restore cursor
1831
+ public void undo() {
1832
+ int index = mGapBuffer.undo();
1833
+ if (index >= 0) {
1834
+ mCursorIndex = index;
1835
+ mCursorLine = getOffsetLine(index);
1836
+ adjustCursorPosition();
1837
+ onTextChanged();
1838
+ scrollToVisable();
1839
+
1840
+ // restore selection (GapBuffer exposes the snapshot)
1841
+ int s = mGapBuffer.getLastUndoSelectionStart();
1842
+ int e = mGapBuffer.getLastUndoSelectionEnd();
1843
+ boolean mode = mGapBuffer.getLastUndoSelectionMode();
1844
+
1845
+ if (s >= 0 && e >= 0) {
1846
+ selectionStart = Math.max(0, Math.min(s, mGapBuffer.length()));
1847
+ selectionEnd = Math.max(0, Math.min(e, mGapBuffer.length()));
1848
+ isSelectMode = mode;
1849
+ updateSelectionHandles();
1850
+ } else {
1851
+ // no selection snapshot stored for this undo — clear selection
1852
+ isSelectMode = false;
1853
+ selectionStart = selectionEnd = -1;
1854
+ }
1855
+ postInvalidate();
1856
+ }
1857
+ }
1858
+
1859
+ // Redo operation and restore cursor
1860
+ public void redo() {
1861
+ int index = mGapBuffer.redo();
1862
+ if (index >= 0) {
1863
+ mCursorIndex = index;
1864
+ mCursorLine = getOffsetLine(index);
1865
+ adjustCursorPosition();
1866
+ onTextChanged();
1867
+ scrollToVisable();
1868
+
1869
+ // restore selection for redo
1870
+ int s = mGapBuffer.getLastRedoSelectionStart();
1871
+ int e = mGapBuffer.getLastRedoSelectionEnd();
1872
+ boolean mode = mGapBuffer.getLastRedoSelectionMode();
1873
+
1874
+ if (s >= 0 && e >= 0) {
1875
+ selectionStart = Math.max(0, Math.min(s, mGapBuffer.length()));
1876
+ selectionEnd = Math.max(0, Math.min(e, mGapBuffer.length()));
1877
+ isSelectMode = mode;
1878
+ updateSelectionHandles();
1879
+ } else {
1880
+ isSelectMode = false;
1881
+ selectionStart = selectionEnd = -1;
1882
+ }
1883
+ postInvalidate();
1884
+ }
1885
+ }
1886
+
1887
+ // ---------- Selection handle updates ----------
1888
+ // Update selection handle screen coordinates and caret
1889
+ private void updateSelectionHandles() {
1890
+ if (!isSelectMode) return;
1891
+
1892
+ int left = getLeftSpace();
1893
+
1894
+ // Start handle
1895
+ int startLine = getOffsetLine(selectionStart);
1896
+ int lineStart = getLineStart(startLine);
1897
+ String startText = mGapBuffer.substring(lineStart, Math.min(selectionStart, mGapBuffer.length()));
1898
+ selectHandleLeftX = left + (int) mTextPaint.measureText(startText);
1899
+ selectHandleLeftY = startLine * getLineHeight();
1900
+
1901
+ // End handle
1902
+ int endLine = getOffsetLine(selectionEnd);
1903
+ lineStart = getLineStart(endLine);
1904
+ String endText = mGapBuffer.substring(lineStart, Math.min(selectionEnd, mGapBuffer.length()));
1905
+ selectHandleRightX = left + (int) mTextPaint.measureText(endText);
1906
+ selectHandleRightY = endLine * getLineHeight();
1907
+
1908
+ // Update middle handle position
1909
+ mCursorPosX = (selectHandleLeftX + selectHandleRightX) / 2;
1910
+ mCursorPosY = (selectHandleLeftY + selectHandleRightY) / 2;
1911
+ }
1912
+
1913
+ // Adjust cursor's screen coordinates based on mCursorLine and mCursorIndex
1914
+ private void adjustCursorPosition() {
1915
+ int start = getLineStart(mCursorLine);
1916
+ String text = mGapBuffer.substring(start, mCursorIndex);
1917
+
1918
+ // Use precise text measurement with the current text paint
1919
+ mCursorPosX = getLeftSpace() + (int) mTextPaint.measureText(text);
1920
+ mCursorPosY = (mCursorLine - 1) * getLineHeight();
1921
+
1922
+ // Ensure cursor stays within bounds
1923
+ if (mCursorPosX < getLeftSpace()) {
1924
+ mCursorPosX = getLeftSpace();
1925
+ }
1926
+ }
1927
+
1928
+ // Adjust selected range and update handles
1929
+ public void adjustSelectRange(int start, int end) {
1930
+ selectionStart = start;
1931
+ selectionEnd = end;
1932
+ updateSelectionHandles(); // Call the new method
1933
+ onCursorOrSelectionChanged();
1934
+ }
1935
+
1936
+ // ---------- Auto-indent ----------
1937
+ // Get current line's leading whitespace for auto-indent
1938
+ private String getAutoIndent() {
1939
+ if (!mAutoIndentEnabled || mCursorIndex == 0) return "";
1940
+
1941
+ try {
1942
+ // Find the start of current line
1943
+ int lineStart = getLineStart(mCursorLine);
1944
+ if (lineStart < 0 || lineStart >= mGapBuffer.length()) return "";
1945
+
1946
+ String currentLine = mGapBuffer.substring(lineStart, Math.min(mCursorIndex, mGapBuffer.length()));
1947
+
1948
+ // Count leading spaces/tabs
1949
+ StringBuilder indent = new StringBuilder();
1950
+ for (int i = 0; i < currentLine.length(); i++) {
1951
+ char c = currentLine.charAt(i);
1952
+ if (c == ' ' || c == '\t') {
1953
+ indent.append(c);
1954
+ } else {
1955
+ break;
1956
+ }
1957
+ }
1958
+
1959
+ return indent.toString();
1960
+ } catch (Exception e) {
1961
+ Log.e(TAG, "Error in getAutoIndent: " + e.getMessage());
1962
+ return "";
1963
+ }
1964
+ }
1965
+
1966
+ // Set cursor position by index and adjust coordinates
1967
+ private void setCursorPosition(int index) {
1968
+ // calculate the cursor index and position
1969
+ mCursorIndex = index;
1970
+ mCursorLine = getOffsetLine(index);
1971
+
1972
+ String text = mGapBuffer.substring(getLineStart(mCursorLine), index);
1973
+ int width = measureText(text);
1974
+ mCursorPosX = getLeftSpace() + width;
1975
+ mCursorPosY = (mCursorLine - 1) * getLineHeight();
1976
+ }
1977
+
1978
+ // Set selection of texts from start to end
1979
+ private void setSelection(int start, int end) {
1980
+ selectionStart = Math.max(0, Math.min(start, mGapBuffer.length()));
1981
+ selectionEnd = Math.max(0, Math.min(end, mGapBuffer.length()));
1982
+ isSelectMode = true;
1983
+ mHandleMiddleVisable = false;
1984
+ updateSelectionHandles();
1985
+ }
1986
+
1987
+ // Set cursor position by pixel coordinates and compute nearest index
1988
+ public void setCursorPosition(float x, float y) {
1989
+ // calculation the cursor y coordinate
1990
+ mCursorPosY = (int) y / getLineHeight() * getLineHeight();
1991
+ int bottom = getLineCount() * getLineHeight();
1992
+
1993
+ if (mCursorPosY < getPaddingTop())
1994
+ mCursorPosY = getPaddingTop();
1995
+
1996
+ if (mCursorPosY > bottom - getLineHeight())
1997
+ mCursorPosY = bottom - getLineHeight();
1998
+
1999
+ // estimate the cursor x position
2000
+ int left = getLeftSpace();
2001
+
2002
+ int prev = left;
2003
+ int next = left;
2004
+
2005
+ mCursorLine = mCursorPosY / getLineHeight() + 1;
2006
+ mCursorIndex = getLineStart(mCursorLine);
2007
+
2008
+ String text = getLine(mCursorLine);
2009
+ int length = text.length();
2010
+
2011
+ float[] widths = new float[length];
2012
+ mTextPaint.getTextWidths(text, widths);
2013
+
2014
+ for (int i = 0; next < x && i < length; ++i) {
2015
+ if (i > 0) {
2016
+ prev += widths[i - 1];
2017
+ }
2018
+ next += widths[i];
2019
+ }
2020
+ onCursorOrSelectionChanged();
2021
+
2022
+ // calculation the cursor x coordinate
2023
+ if (Math.abs(x - prev) <= Math.abs(next - x)) {
2024
+ mCursorPosX = prev;
2025
+ } else {
2026
+ mCursorPosX = next;
2027
+ }
2028
+
2029
+ // calculation the cursor index
2030
+ if (mCursorPosX > left) {
2031
+ for (int j = 0; left < mCursorPosX && j < length; ++j) {
2032
+ left += widths[j];
2033
+ ++mCursorIndex;
2034
+ }
2035
+ }
2036
+ }
2037
+
2038
+ // Called when cursor or selection changed (placeholder for external UI)
2039
+ private void onCursorOrSelectionChanged() {
2040
+ scheduleSelectionUpdate();
2041
+ // Auto-hide after 5 seconds if no interaction
2042
+ if (mClipboardPanel != null) {
2043
+ mSelectionHandler.removeCallbacks(mAutoHideRunnable);
2044
+ mSelectionHandler.postDelayed(mAutoHideRunnable, 5000); // 5 seconds
2045
+ }
2046
+ }
2047
+
2048
+ // ---------- Autocomplete / Word extraction ----------
2049
+ // Update word set from buffer asynchronously
2050
+ private void updateWordSet() {
2051
+ removeCallbacks(mWordUpdateRunnable);
2052
+ if (!isEditedMode) return;
2053
+
2054
+ new Thread(new Runnable() {
2055
+ @Override
2056
+ public void run() {
2057
+ // Off UI for large files
2058
+ String fullText = mGapBuffer.toString();
2059
+ final Set<String> words = new HashSet<>();
2060
+ java.util.regex.Matcher matcher = WORD_PATTERN.matcher(fullText);
2061
+
2062
+ while (matcher.find()) {
2063
+ String word = matcher.group();
2064
+ if (word.length() >= MIN_WORD_LEN) {
2065
+ words.add(word);
2066
+ }
2067
+ }
2068
+
2069
+ post(new Runnable() {
2070
+ @Override
2071
+ public void run() {
2072
+ // Back to UI
2073
+ mWordSet = words;
2074
+ if (!mCurrentPrefix.isEmpty()) {
2075
+ showAutoComplete(mCurrentPrefix);
2076
+ }
2077
+ }
2078
+ });
2079
+ }
2080
+ }).start();
2081
+ }
2082
+
2083
+ // Get the current identifier-like prefix near caret
2084
+ private String getCurrentPrefix() {
2085
+ if (mCursorIndex <= 0) return "";
2086
+
2087
+ // Don't convert entire buffer to string - work with the buffer directly
2088
+ int start = mCursorIndex;
2089
+
2090
+ // Move backwards to find the start of the word
2091
+ while (start > 0) {
2092
+ char prevChar = mGapBuffer.charAt(start - 1);
2093
+ if (!(Character.isLetterOrDigit(prevChar) || prevChar == '_')) {
2094
+ break;
2095
+ }
2096
+ start--;
2097
+ }
2098
+
2099
+ // Extract only the needed substring
2100
+ if (start < mCursorIndex) {
2101
+ return mGapBuffer.substring(start, mCursorIndex);
2102
+ }
2103
+
2104
+ return "";
2105
+ }
2106
+
2107
+ // Filter word set and show suggestions
2108
+ private void filterAndShowSuggestions(String prefix) {
2109
+ if (prefix.isEmpty()) {
2110
+ dismissAutoComplete();
2111
+ return;
2112
+ }
2113
+
2114
+ List<String> priorityList = new ArrayList<>();
2115
+ List<String> containsList = new ArrayList<>();
2116
+
2117
+ for (String word : mWordSet) {
2118
+ String lowerWord = word.toLowerCase();
2119
+ String lowerPrefix = prefix.toLowerCase();
2120
+
2121
+ if (lowerWord.startsWith(lowerPrefix) && !word.equals(prefix)) {
2122
+ priorityList.add(word);
2123
+ } else if (lowerWord.contains(lowerPrefix) && !word.equals(prefix)) {
2124
+ containsList.add(word);
2125
+ }
2126
+ }
2127
+
2128
+ List<String> suggestions = new ArrayList<>(priorityList);
2129
+ suggestions.addAll(containsList);
2130
+
2131
+ if (suggestions.isEmpty()) {
2132
+ dismissAutoComplete();
2133
+ return;
2134
+ }
2135
+
2136
+ // If list is already shown, just update silently
2137
+ if (mAutoCompletePopup.isShowing()) {
2138
+ mAutoCompleteAdapter.clear();
2139
+ mAutoCompleteAdapter.addAll(suggestions);
2140
+ mAutoCompleteAdapter.notifyDataSetChanged();
2141
+ } else {
2142
+ mAutoCompleteAdapter.clear();
2143
+ mAutoCompleteAdapter.addAll(suggestions);
2144
+ mAutoCompleteAdapter.notifyDataSetChanged();
2145
+ showAutoComplete(prefix);
2146
+ }
2147
+ }
2148
+
2149
+ // Show the autocomplete popup near the caret
2150
+ private void showAutoComplete(String prefix) {
2151
+ Rect cursorRect = getBoundingBox(mCursorIndex);
2152
+
2153
+ hideTextSelectionWindow();
2154
+
2155
+ // Calculate full width with margin
2156
+ int margin = (int) (getResources().getDisplayMetrics().density * 8); // 8dp margin on each
2157
+ // side
2158
+ int popupWidth = getWidth() - (margin * 2);
2159
+
2160
+ // Dynamically size the height — wrap up to 4 visible items
2161
+ int itemHeight = (int) (getResources().getDisplayMetrics().density * 40); // ≈40dp per item
2162
+ int visibleCount = Math.min(mAutoCompleteAdapter.getCount(), 4);
2163
+ int popupHeight = visibleCount == 0 ? 0 : itemHeight * visibleCount;
2164
+ if (popupHeight == 0) popupHeight = ListPopupWindow.WRAP_CONTENT;
2165
+
2166
+ // If already visible, only update position and size — don’t recreate (prevents flicker)
2167
+ if (mAutoCompletePopup.isShowing()) {
2168
+ mAutoCompletePopup.setHeight(popupHeight);
2169
+ mAutoCompleteAdapter.notifyDataSetChanged();
2170
+ return;
2171
+ }
2172
+
2173
+ // Configure initial show
2174
+ mAutoCompletePopup.setWidth(popupWidth);
2175
+ mAutoCompletePopup.setHeight(popupHeight);
2176
+ mAutoCompletePopup.setAnchorView(this);
2177
+ mAutoCompletePopup.setModal(false); // Allow typing
2178
+ mAutoCompletePopup.setBackgroundDrawable(
2179
+ getResources().getDrawable(android.R.drawable.dialog_holo_light_frame)
2180
+ );
2181
+
2182
+ // Offset popup slightly below cursor, centered with margins
2183
+ mAutoCompletePopup.setHorizontalOffset(margin);
2184
+ mAutoCompletePopup.setVerticalOffset(cursorRect.bottom + (int) (getLineHeight() * 0.6f));
2185
+
2186
+ mAutoCompletePopup.show();
2187
+ mCurrentPrefix = prefix;
2188
+ }
2189
+
2190
+ // Replace current prefix with a chosen completion
2191
+ private void replacePrefixWithWord(String fullWord) {
2192
+ String prefix = getCurrentPrefix();
2193
+ if (prefix.isEmpty()) {
2194
+ insertText(fullWord); // Insert if no prefix
2195
+ return;
2196
+ }
2197
+
2198
+ int prefixStart = mCursorIndex - prefix.length();
2199
+ mGapBuffer.replace(prefixStart, mCursorIndex, fullWord, true); // Replace via GapBuffer
2200
+ mCursorIndex = prefixStart + fullWord.length();
2201
+ adjustCursorPosition();
2202
+ scrollToVisable();
2203
+ postInvalidate();
2204
+ onTextChanged();
2205
+ }
2206
+
2207
+ // Dismiss autocomplete popup
2208
+ private void dismissAutoComplete() {
2209
+ if (mAutoCompletePopup.isShowing()) {
2210
+ mAutoCompletePopup.dismiss();
2211
+ }
2212
+ mCurrentPrefix = "";
2213
+ }
2214
+
2215
+ // ---------- Selection UI / Clipboard Panel ----------
2216
+ // Placeholder: show text selection window (clipboard panel)
2217
+ public void showTextSelectionWindow() {
2218
+ if (mClipboardPanel != null && (isSelectMode || mHandleMiddleVisable)) {
2219
+ post(new Runnable() {
2220
+ @Override
2221
+ public void run() {
2222
+ Rect optimalRect = getOptimalClipboardPosition();
2223
+ mClipboardPanel.showAtLocation(optimalRect);
2224
+
2225
+ // Schedule auto-hide
2226
+ scheduleAutoHide();
2227
+ }
2228
+ });
2229
+ }
2230
+ }
2231
+
2232
+ // Hide text selection window (clipboard panel)
2233
+ public void hideTextSelectionWindow() {
2234
+ if (mClipboardPanel != null) {
2235
+ // Cancel any pending auto-hide
2236
+ mSelectionHandler.removeCallbacks(mAutoHideRunnable);
2237
+
2238
+ post(new Runnable() {
2239
+ @Override
2240
+ public void run() {
2241
+ mClipboardPanel.hide();
2242
+ }
2243
+ });
2244
+ }
2245
+ }
2246
+
2247
+ // Schedule auto-hide for selection UI
2248
+ private void scheduleAutoHide() {
2249
+ mSelectionHandler.removeCallbacks(mAutoHideRunnable);
2250
+ if (!isSelectMode && !mHandleMiddleVisable) {
2251
+ mSelectionHandler.postDelayed(mAutoHideRunnable, 3000); // 3 seconds for normal taps
2252
+ } else {
2253
+ mSelectionHandler.postDelayed(mAutoHideRunnable, 5000); // 5 seconds for selections
2254
+ }
2255
+ }
2256
+
2257
+ private Rect getOptimalClipboardPosition() {
2258
+ if (isSelectMode) {
2259
+ // For selections, position near the middle using your direct variables
2260
+ int middle = (selectionStart + selectionEnd) / 2;
2261
+ Rect middleRect = getBoundingBox(middle);
2262
+ if (middleRect != null) {
2263
+ // Position above selection middle
2264
+ middleRect.top -= getLineHeight() * 3;
2265
+ middleRect.bottom = middleRect.top + getLineHeight();
2266
+ return middleRect;
2267
+ }
2268
+ }
2269
+
2270
+ // For cursor, position above cursor
2271
+ Rect cursorRect = getBoundingBox(mCursorIndex);
2272
+ if (cursorRect != null) {
2273
+ cursorRect.top -= getLineHeight() * 3;
2274
+ cursorRect.bottom = cursorRect.top + getLineHeight();
2275
+ return cursorRect;
2276
+ }
2277
+
2278
+ return null; // Let ClipboardPanel calculate automatically
2279
+ }
2280
+
2281
+ @Override
2282
+ protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
2283
+ super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
2284
+ if (!gainFocus) dismissAutoComplete();
2285
+ }
2286
+
2287
+ @Override
2288
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
2289
+
2290
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
2291
+
2292
+ setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
2293
+ MeasureSpec.getSize(heightMeasureSpec));
2294
+ }
2295
+
2296
+ // ---------- Gesture related helpers ----------
2297
+ // Auto scroll select handle and cursor when dragging near edge
2298
+ private void onMove(int slopX, int slopY) {
2299
+ int dx = 0;
2300
+
2301
+ if (mGapBuffer == null || mGapBuffer.length() == 0) {
2302
+ return;
2303
+ }
2304
+
2305
+ if (mCursorPosX - getScrollX() <= slopX) {
2306
+ if (mCursorIndex > 0 && mCursorIndex - 1 < mGapBuffer.length()) {
2307
+ try {
2308
+ char prevChar = mGapBuffer.charAt(mCursorIndex - 1);
2309
+ dx = -measureText(String.valueOf(prevChar));
2310
+ } catch (Exception e) {
2311
+ dx = -spaceWidth;
2312
+ }
2313
+ }
2314
+ } else if (mCursorPosX - getScrollX() >= screenWidth - slopX) {
2315
+ if (mCursorIndex >= 0 && mCursorIndex < mGapBuffer.length()) {
2316
+ try {
2317
+ char nextChar = mGapBuffer.charAt(mCursorIndex);
2318
+ dx = measureText(String.valueOf(nextChar));
2319
+ } catch (Exception e) {
2320
+ dx = spaceWidth;
2321
+ }
2322
+ } else if (mCursorIndex == mGapBuffer.length()) {
2323
+ dx = spaceWidth;
2324
+ }
2325
+ }
2326
+
2327
+ if (getHeight() > screenHeight / 2) {
2328
+ slopY = slopY * 3;
2329
+ }
2330
+
2331
+ int dy = 0;
2332
+ if (mCursorPosY - getScrollY() <= 0) {
2333
+ dy = -getLineHeight();
2334
+ } else if (mCursorPosY - getScrollY() >= getHeight() - slopY) {
2335
+ dy = getLineHeight();
2336
+ }
2337
+
2338
+ int newScrollX = getScrollX() + dx;
2339
+ int newScrollY = getScrollY() + dy;
2340
+
2341
+ newScrollX = Math.max(0, Math.min(newScrollX, getMaxScrollX()));
2342
+ newScrollY = Math.max(0, Math.min(newScrollY, getMaxScrollY()));
2343
+
2344
+ smoothScrollTo(newScrollX, newScrollY);
2345
+
2346
+ // Update magnifier during auto-scroll
2347
+ if (mIsMagnifierShowing && mMagnifierEnabled) {
2348
+ updateMagnifier(mCursorPosX, mCursorPosY + getLineHeight());
2349
+ }
2350
+ }
2351
+
2352
+ // ---------- Gesture listener inner class ----------
2353
+ class GestureListener extends GestureDetector.SimpleOnGestureListener {
2354
+
2355
+ private boolean touchOnSelectHandleMiddle = false;
2356
+ private boolean touchOnSelectHandleLeft = false;
2357
+ private boolean touchOnSelectHandleRight = false;
2358
+
2359
+ private boolean mIsMagnifierActive = false;
2360
+
2361
+ private int mInitialLine = -1;
2362
+
2363
+ // for auto scroll select handle
2364
+ private Runnable moveAction = new Runnable() {
2365
+ @Override
2366
+ public void run() {
2367
+ try {
2368
+ if (mIsMagnifierActive && mMagnifierEnabled) {
2369
+ // Use cursor position for magnifier
2370
+ updateMagnifier(mCursorPosX, mCursorPosY + getLineHeight());
2371
+ }
2372
+ onMove(spaceWidth * 4, getLineHeight());
2373
+ if (EditView.this.isAttachedToWindow()) {
2374
+ postDelayed(moveAction, DEFAULT_DURATION);
2375
+ }
2376
+ } catch (Exception e) {
2377
+ Log.e(TAG, "Error in moveAction: " + e.getMessage());
2378
+ dismissMagnifier();
2379
+ }
2380
+ }
2381
+ };
2382
+
2383
+ // Swap left/right select handle coordinates and selection indices
2384
+ private void reverse() {
2385
+ selectHandleLeftX = selectHandleLeftX ^ selectHandleRightX;
2386
+ selectHandleRightX = selectHandleLeftX ^ selectHandleRightX;
2387
+ selectHandleLeftX = selectHandleLeftX ^ selectHandleRightX;
2388
+
2389
+ selectHandleLeftY = selectHandleLeftY ^ selectHandleRightY;
2390
+ selectHandleRightY = selectHandleLeftY ^ selectHandleRightY;
2391
+ selectHandleLeftY = selectHandleLeftY ^ selectHandleRightY;
2392
+
2393
+ selectionStart = selectionStart ^ selectionEnd;
2394
+ selectionEnd = selectionStart ^ selectionEnd;
2395
+ selectionStart = selectionStart ^ selectionEnd;
2396
+
2397
+ touchOnSelectHandleLeft = !touchOnSelectHandleLeft;
2398
+ touchOnSelectHandleRight = !touchOnSelectHandleRight;
2399
+ }
2400
+
2401
+ // when single tap to check the select region
2402
+ private boolean checkSelectRange(float x, float y) {
2403
+
2404
+ if (y < selectHandleLeftY - getLineHeight() || y > selectHandleRightY)
2405
+ return false;
2406
+
2407
+ // on the same line
2408
+ if (selectHandleLeftY == selectHandleRightY) {
2409
+ if (x < selectHandleLeftX || x > selectHandleRightX)
2410
+ return false;
2411
+ } else {
2412
+ // not on the same line
2413
+ int left = getLeftSpace();
2414
+ int line = (int) y / getLineHeight() + 1;
2415
+ int width = getLineWidth(line) + spaceWidth;
2416
+ // select start line
2417
+ if (line == selectHandleLeftY / getLineHeight()) {
2418
+ if (x < selectHandleLeftX || x > left + width)
2419
+ return false;
2420
+ } else if (line == selectHandleRightY / getLineHeight()) {
2421
+ // select end line
2422
+ if (x < left || x > selectHandleRightX)
2423
+ return false;
2424
+ } else {
2425
+ if (x < left || x > left + width)
2426
+ return false;
2427
+ }
2428
+ }
2429
+ return true;
2430
+ }
2431
+
2432
+ @Override
2433
+ public boolean onDown(MotionEvent e) {
2434
+ float x = e.getX() + getScrollX();
2435
+ float y = e.getY() + getScrollY();
2436
+
2437
+ // touch handle middle (show clipboard panel)
2438
+ if (mHandleMiddleVisable &&
2439
+ x >= mCursorPosX - handleMiddleWidth / 2 &&
2440
+ x <= mCursorPosX + handleMiddleWidth / 2 &&
2441
+ y >= mCursorPosY + getLineHeight() &&
2442
+ y <= mCursorPosY + getLineHeight() + handleMiddleHeight) {
2443
+
2444
+ touchOnSelectHandleMiddle = true;
2445
+ removeCallbacks(blinkAction);
2446
+ mCursorVisiable = mHandleMiddleVisable = true;
2447
+
2448
+ // 🔹 Show clipboard panel exactly above this handle
2449
+ showTextSelectionWindow();
2450
+
2451
+ // 🔹 Keep magnifier logic working
2452
+ if (mMagnifierEnabled) {
2453
+ mIsMagnifierActive = true;
2454
+ showMagnifier(mCursorPosX, mCursorPosY + getLineHeight());
2455
+ }
2456
+
2457
+ // ✅ Prevent cursor from moving or triggering a new position
2458
+ return true;
2459
+ }
2460
+
2461
+ // touch handle left
2462
+ if (isSelectMode && x >= selectHandleLeftX - selectHandleWidth + selectHandleWidth / 4
2463
+ && x <= selectHandleLeftX + selectHandleWidth / 4
2464
+ && y >= selectHandleLeftY && y <= selectHandleLeftY + selectHandleHeight) {
2465
+ touchOnSelectHandleLeft = true;
2466
+ removeCallbacks(blinkAction);
2467
+ mCursorVisiable = mHandleMiddleVisable = false;
2468
+
2469
+ showTextSelectionWindow();
2470
+ if (mMagnifierEnabled) {
2471
+ mIsMagnifierActive = true;
2472
+ showMagnifier(selectHandleLeftX, selectHandleLeftY);
2473
+ }
2474
+ }
2475
+
2476
+ // touch handle right
2477
+ if (isSelectMode && x >= selectHandleRightX - selectHandleWidth / 4
2478
+ && x <= selectHandleRightX + selectHandleWidth - selectHandleWidth / 4
2479
+ && y >= selectHandleRightY && y <= selectHandleRightY + selectHandleHeight) {
2480
+ touchOnSelectHandleRight = true;
2481
+ removeCallbacks(blinkAction);
2482
+ mCursorVisiable = mHandleMiddleVisable = false;
2483
+ showTextSelectionWindow();
2484
+ if (mMagnifierEnabled) {
2485
+ mIsMagnifierActive = true;
2486
+ showMagnifier(selectHandleRightX, selectHandleRightY);
2487
+ }
2488
+ }
2489
+
2490
+ return super.onDown(e);
2491
+ }
2492
+
2493
+ @Override
2494
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
2495
+ try {
2496
+ float x = e2.getX() + getScrollX();
2497
+ float y = e2.getY() + getScrollY();
2498
+
2499
+ if (mIsMagnifierActive && mMagnifierEnabled) {
2500
+ if (touchOnSelectHandleMiddle) {
2501
+ updateMagnifier(mCursorPosX, mCursorPosY + getLineHeight());
2502
+ } else if (touchOnSelectHandleLeft) {
2503
+ updateMagnifier(selectHandleLeftX, selectHandleLeftY);
2504
+ } else if (touchOnSelectHandleRight) {
2505
+ updateMagnifier(selectHandleRightX, selectHandleRightY);
2506
+ }
2507
+ }
2508
+
2509
+ if (touchOnSelectHandleMiddle) {
2510
+ removeCallbacks(moveAction);
2511
+ post(moveAction);
2512
+ setCursorPosition(x, y - getLineHeight() - Math.min(getLineHeight(), selectHandleHeight) / 2);
2513
+ } else if (touchOnSelectHandleLeft) {
2514
+ removeCallbacks(moveAction);
2515
+ post(moveAction);
2516
+ setCursorPosition(x, y - getLineHeight() - Math.min(getLineHeight(), selectHandleHeight) / 2);
2517
+ selectHandleLeftX = mCursorPosX;
2518
+ selectHandleLeftY = mCursorPosY + getLineHeight();
2519
+ selectionStart = mCursorIndex;
2520
+ } else if (touchOnSelectHandleRight) {
2521
+ removeCallbacks(moveAction);
2522
+ post(moveAction);
2523
+ setCursorPosition(x, y - getLineHeight() - Math.min(getLineHeight(), selectHandleHeight) / 2);
2524
+ selectHandleRightX = mCursorPosX;
2525
+ selectHandleRightY = mCursorPosY + getLineHeight();
2526
+ selectionEnd = mCursorIndex;
2527
+ } else {
2528
+ if (Math.abs(distanceY) > Math.abs(distanceX))
2529
+ distanceX = 0;
2530
+ else
2531
+ distanceY = 0;
2532
+
2533
+ int newX = (int) distanceX + getScrollX();
2534
+ if (newX < 0) {
2535
+ newX = 0;
2536
+ } else if (newX > getMaxScrollX()) {
2537
+ newX = getMaxScrollX();
2538
+ }
2539
+
2540
+ int newY = (int) distanceY + getScrollY();
2541
+ if (newY < 0) {
2542
+ newY = 0;
2543
+ } else if (newY > getMaxScrollY()) {
2544
+ newY = getMaxScrollY();
2545
+ }
2546
+ smoothScrollTo(newX, newY);
2547
+ }
2548
+
2549
+ if (isSelectMode && ((selectHandleLeftY > selectHandleRightY)
2550
+ || (selectHandleLeftY == selectHandleRightY && selectHandleLeftX > selectHandleRightX))) {
2551
+ reverse();
2552
+ }
2553
+
2554
+ postInvalidate();
2555
+ } catch (Exception e) {
2556
+ dismissMagnifier();
2557
+ mIsMagnifierActive = false;
2558
+ removeCallbacks(moveAction);
2559
+ Log.e(TAG, "Error in onScroll: " + e.getMessage());
2560
+ }
2561
+ return super.onScroll(e1, e2, distanceX, distanceY);
2562
+ }
2563
+
2564
+ @Override
2565
+ public boolean onSingleTapUp(MotionEvent e) {
2566
+ float x = e.getX() + getScrollX();
2567
+ float y = e.getY() + getScrollY();
2568
+ if (isEditedMode) {
2569
+ showSoftInput(true);
2570
+ }
2571
+
2572
+ showTextSelectionWindow();
2573
+ if (!isSelectMode || !checkSelectRange(x, y)) {
2574
+ // stop cursor blink
2575
+ removeCallbacks(blinkAction);
2576
+ mCursorVisiable = mHandleMiddleVisable = true;
2577
+ isSelectMode = false;
2578
+
2579
+ if (!mReplaceList.isEmpty())
2580
+ mReplaceList.clear();
2581
+
2582
+ setCursorPosition(x, y);
2583
+ postInvalidate();
2584
+ mLastTapTime = System.currentTimeMillis();
2585
+ // clear long selection process
2586
+ clearLineSelection();
2587
+ // cursor start blink
2588
+ postDelayed(blinkAction, BLINK_TIMEOUT);
2589
+ }
2590
+
2591
+ return super.onSingleTapUp(e);
2592
+ }
2593
+
2594
+ @Override
2595
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
2596
+ // TODO: Implement this method
2597
+ if (Math.abs(velocityY) > Math.abs(velocityX))
2598
+ velocityX = 0;
2599
+ else
2600
+ velocityY = 0;
2601
+
2602
+ mScroller.fling(getScrollX(), getScrollY(), (int) -velocityX, (int) -velocityY,
2603
+ 0, getMaxScrollX(), 0, getMaxScrollY());
2604
+
2605
+ postInvalidate();
2606
+ return super.onFling(e1, e2, velocityX, velocityY);
2607
+ }
2608
+
2609
+ @Override
2610
+ public void onLongPress(MotionEvent e) {
2611
+ super.onLongPress(e);
2612
+ float x = e.getX() + getScrollX();
2613
+ float y = e.getY() + getScrollY();
2614
+
2615
+ removeCallbacks(blinkAction);
2616
+ showTextSelectionWindow();
2617
+ mCursorVisiable = mHandleMiddleVisable = true;
2618
+
2619
+ // Handle line number long press
2620
+ if (isInLineNumberArea(x, y)) {
2621
+ int currentLine = getLineFromY(y);
2622
+ handleLineNumberLongPress(currentLine);
2623
+ return;
2624
+ }
2625
+
2626
+ if (!touchOnSelectHandleMiddle && mGapBuffer.length() > 0) {
2627
+
2628
+ setCursorPosition(x, y);
2629
+
2630
+ String selectWord = findNearestWord();
2631
+ if (selectWord != null) {
2632
+ removeCallbacks(blinkAction);
2633
+ mCursorVisiable = mHandleMiddleVisable = false;
2634
+ isSelectMode = true;
2635
+
2636
+ int left = getLeftSpace();
2637
+ int lineStart = getLineStart(mCursorLine);
2638
+ selectHandleLeftX = left + measureText(mGapBuffer.substring(lineStart, selectionStart));
2639
+ selectHandleRightX = left + measureText(mGapBuffer.substring(lineStart, selectionEnd));
2640
+ selectHandleLeftY = selectHandleRightY = mCursorPosY + getLineHeight();
2641
+
2642
+ setCursorPosition(selectionEnd);
2643
+
2644
+ if (mMagnifierEnabled) {
2645
+ mIsMagnifierActive = true;
2646
+ showMagnifier(selectHandleRightX, selectHandleRightY);
2647
+ }
2648
+ }
2649
+ }
2650
+ postInvalidate();
2651
+ }
2652
+
2653
+ @Override
2654
+ public boolean onDoubleTap(MotionEvent e) {
2655
+ super.onDoubleTap(e);
2656
+
2657
+ float x = e.getX() + getScrollX();
2658
+ float y = e.getY() + getScrollY();
2659
+
2660
+ removeCallbacks(blinkAction);
2661
+ mCursorVisiable = mHandleMiddleVisable = true;
2662
+ showTextSelectionWindow();
2663
+
2664
+ if (!touchOnSelectHandleMiddle && mGapBuffer.length() > 0) {
2665
+ setCursorPosition(x, y);
2666
+
2667
+ String selectWord = findNearestWord();
2668
+ if (selectWord != null) {
2669
+ removeCallbacks(blinkAction);
2670
+ mCursorVisiable = mHandleMiddleVisable = false;
2671
+ isSelectMode = true;
2672
+
2673
+ int left = getLeftSpace();
2674
+ int lineStart = getLineStart(mCursorLine);
2675
+ selectHandleLeftX = left + measureText(mGapBuffer.substring(lineStart, selectionStart));
2676
+ selectHandleRightX = left + measureText(mGapBuffer.substring(lineStart, selectionEnd));
2677
+ selectHandleLeftY = selectHandleRightY = mCursorPosY + getLineHeight();
2678
+
2679
+ setCursorPosition(selectionEnd);
2680
+
2681
+ // Show what was selected (for debugging)
2682
+ Log.d(TAG, "Double tap selected: '" + selectWord + "'");
2683
+ }
2684
+ }
2685
+ postInvalidate();
2686
+ return super.onDoubleTap(e);
2687
+ }
2688
+
2689
+ // Expose onUp for outside calls
2690
+ public void onUp(MotionEvent e) {
2691
+ if (mIsMagnifierActive) {
2692
+ dismissMagnifier();
2693
+ mIsMagnifierActive = false;
2694
+ }
2695
+
2696
+ if (touchOnSelectHandleLeft || touchOnSelectHandleRight || touchOnSelectHandleMiddle) {
2697
+ removeCallbacks(moveAction);
2698
+ touchOnSelectHandleMiddle = false;
2699
+ touchOnSelectHandleLeft = false;
2700
+ touchOnSelectHandleRight = false;
2701
+
2702
+ if (isSelectMode) {
2703
+ setCursorPosition(selectionEnd);
2704
+ // Show immediately and schedule auto-hide
2705
+ showTextSelectionWindow();
2706
+ scheduleAutoHide();
2707
+ } else {
2708
+ mLastTapTime = System.currentTimeMillis();
2709
+ postDelayed(blinkAction, BLINK_TIMEOUT);
2710
+ // Hide after delay if not in selection mode
2711
+ scheduleAutoHide();
2712
+ }
2713
+ }
2714
+ }
2715
+
2716
+ private void handleLineNumberLongPress(int currentLine) {
2717
+ if (!mWaitingForSecondSelection) {
2718
+ // First long press - select single line and wait for second selection
2719
+ selectSingleLine(currentLine);
2720
+ mFirstSelectedLine = currentLine;
2721
+ mWaitingForSecondSelection = true;
2722
+
2723
+ // Show visual hint that we're waiting for second selection
2724
+ showSelectionHint();
2725
+
2726
+ } else {
2727
+ // Second long press - select range between first and current line
2728
+ mSecondSelectedLine = currentLine;
2729
+ selectLineRange(mFirstSelectedLine, mSecondSelectedLine);
2730
+ mWaitingForSecondSelection = false;
2731
+ mSelectionHandler.removeCallbacks(mClearSelectionRunnable);
2732
+ }
2733
+ }
2734
+ }
2735
+
2736
+ private void scheduleSelectionUpdate() {
2737
+ mSelectionHandler.removeCallbacks(mUpdateSelectionPosition);
2738
+ mSelectionHandler.postDelayed(mUpdateSelectionPosition, 100); // Small delay for smoothness
2739
+ }
2740
+
2741
+ private void performHapticFeedback() {
2742
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) {
2743
+ performHapticFeedback(HapticFeedbackConstants.TEXT_HANDLE_MOVE);
2744
+ } else {
2745
+ performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
2746
+ }
2747
+ }
2748
+
2749
+ private void selectLineRange(int startLine, int endLine) {
2750
+ if (startLine < 1 || endLine < 1 || startLine > getLineCount() || endLine > getLineCount()) {
2751
+ return;
2752
+ }
2753
+
2754
+ removeCallbacks(blinkAction);
2755
+ mCursorVisiable = false;
2756
+ mHandleMiddleVisable = false;
2757
+ isSelectMode = true;
2758
+ mIsLineSelectionMode = true;
2759
+
2760
+ // Calculate the actual selection range
2761
+ int actualStartLine = Math.min(startLine, endLine);
2762
+ int actualEndLine = Math.max(startLine, endLine);
2763
+
2764
+ // Start from the beginning of first line
2765
+ selectionStart = getLineStart(actualStartLine);
2766
+
2767
+ // End at the end of last line (including newline if present)
2768
+ int endLineStart = getLineStart(actualEndLine);
2769
+ String endLineText = getLine(actualEndLine);
2770
+ selectionEnd = endLineStart + endLineText.length();
2771
+
2772
+ // Include newline character if it's not the last line
2773
+ if (actualEndLine < getLineCount()) {
2774
+ selectionEnd++;
2775
+ }
2776
+
2777
+ mStartSelectionLine = actualStartLine;
2778
+ mEndSelectionLine = actualEndLine;
2779
+
2780
+ updateSelectionHandles();
2781
+ showTextSelectionWindow();
2782
+ postInvalidate();
2783
+
2784
+ // Haptic feedback
2785
+ performHapticFeedback();
2786
+ }
2787
+
2788
+ private void showSelectionHint() {
2789
+ // Flash the selected line or show some visual feedback
2790
+ post(new Runnable() {
2791
+ @Override
2792
+ public void run() {
2793
+ // You can change the background color or add animation
2794
+ mHandleMiddleVisable = false;
2795
+ postInvalidate();
2796
+
2797
+ // Reset after a short time
2798
+ postDelayed(new Runnable() {
2799
+ @Override
2800
+ public void run() {
2801
+ if (mWaitingForSecondSelection) {
2802
+ mHandleMiddleVisable = false;
2803
+ postInvalidate();
2804
+ }
2805
+ }
2806
+ }, 500);
2807
+ }
2808
+ });
2809
+ }
2810
+
2811
+ private void selectSingleLine(int line) {
2812
+ if (line < 1 || line > getLineCount()) return;
2813
+
2814
+ removeCallbacks(blinkAction);
2815
+ mCursorVisiable = false;
2816
+ mHandleMiddleVisable = false;
2817
+ isSelectMode = true;
2818
+ mIsLineSelectionMode = true;
2819
+
2820
+ // Calculate selection range for the entire line
2821
+ int lineStart = getLineStart(line);
2822
+ int lineEnd = lineStart + getLine(line).length();
2823
+
2824
+ // If it's the last line, include the newline character if present
2825
+ if (line < getLineCount()) {
2826
+ lineEnd++; // Include the newline character
2827
+ }
2828
+
2829
+ selectionStart = lineStart;
2830
+ selectionEnd = lineEnd;
2831
+ mStartSelectionLine = line;
2832
+ mEndSelectionLine = line;
2833
+
2834
+ updateSelectionHandles();
2835
+ showTextSelectionWindow();
2836
+ postInvalidate();
2837
+ }
2838
+
2839
+ // clear long selection process
2840
+ private void clearLineSelection() {
2841
+ mWaitingForSecondSelection = false;
2842
+ mFirstSelectedLine = -1;
2843
+ }
2844
+
2845
+ // Helper method that used for selecting nearby word or special symbol
2846
+ public void selectNearestWord() {
2847
+ String selectWord = findNearestWord();
2848
+ if (selectWord != null) {
2849
+ removeCallbacks(blinkAction);
2850
+ mCursorVisiable = false;
2851
+ mHandleMiddleVisable = false;
2852
+ isSelectMode = true;
2853
+ mIsLineSelectionMode = true;
2854
+
2855
+ int left = getLeftSpace();
2856
+ int lineStart = getLineStart(mCursorLine);
2857
+ selectHandleLeftX = left + measureText(mGapBuffer.substring(lineStart, selectionStart));
2858
+ selectHandleRightX = left + measureText(mGapBuffer.substring(lineStart, selectionEnd));
2859
+ selectHandleLeftY = selectHandleRightY = mCursorPosY + getLineHeight();
2860
+
2861
+ setCursorPosition(selectHandleLeftX, selectHandleRightX);
2862
+ setCursorPosition(selectionEnd);
2863
+ showTextSelectionWindow();
2864
+ }
2865
+ postInvalidate();
2866
+ }
2867
+
2868
+ // when on long press to select a word
2869
+ public String findNearestWord() {
2870
+ int length = mGapBuffer.length();
2871
+ if (length == 0) return null;
2872
+
2873
+ if (mCursorIndex >= length) {
2874
+ mCursorIndex = Math.max(0, length - 1);
2875
+ }
2876
+
2877
+ if (isCurrentLineEmptyOrWhitespace()) {
2878
+ return null;
2879
+ }
2880
+
2881
+ // FIRST: Always try to find single special character
2882
+ String selected = findSingleSpecialChar();
2883
+ if (selected != null) return selected;
2884
+
2885
+ // SECOND: Then try word selection
2886
+ selected = findWordAtCursor();
2887
+ if (selected != null) return selected;
2888
+
2889
+ selected = findWordInVicinity();
2890
+ if (selected != null) return selected;
2891
+
2892
+ selected = findAnyNonWhitespace();
2893
+ return selected;
2894
+ }
2895
+
2896
+ // Only select single special characters
2897
+ private String findSingleSpecialChar() {
2898
+ // Check current cursor position
2899
+ if (mCursorIndex < mGapBuffer.length()) {
2900
+ char currentChar = mGapBuffer.charAt(mCursorIndex);
2901
+ if (isSpecialChar(currentChar)) {
2902
+ selectionStart = mCursorIndex;
2903
+ selectionEnd = mCursorIndex + 1;
2904
+ return String.valueOf(currentChar);
2905
+ }
2906
+ }
2907
+
2908
+ // Check position before cursor
2909
+ if (mCursorIndex > 0) {
2910
+ char prevChar = mGapBuffer.charAt(mCursorIndex - 1);
2911
+ if (isSpecialChar(prevChar)) {
2912
+ selectionStart = mCursorIndex - 1;
2913
+ selectionEnd = mCursorIndex;
2914
+ return String.valueOf(prevChar);
2915
+ }
2916
+ }
2917
+
2918
+ return null;
2919
+ }
2920
+
2921
+ // Special character detection
2922
+ private boolean isSpecialChar(char c) {
2923
+ // All special characters that should be selected individually
2924
+ String specialChars = ":;\"\'`.,!?@#$%^&*()-+=[]{}<>/~|\\";
2925
+ return specialChars.indexOf(c) >= 0;
2926
+ }
2927
+
2928
+ // WORD selection only - stops at special characters
2929
+ private String expandSelectionFrom(int position) {
2930
+ int length = mGapBuffer.length();
2931
+ if (position < 0 || position >= length) return null;
2932
+
2933
+ char startChar = mGapBuffer.charAt(position);
2934
+
2935
+ // If it's a special char, don't expand - let findSingleSpecialChar handle it
2936
+ if (isSpecialChar(startChar)) {
2937
+ return null;
2938
+ }
2939
+
2940
+ // Expand left until whitespace OR special char
2941
+ selectionStart = position;
2942
+ while (selectionStart > 0) {
2943
+ char c = mGapBuffer.charAt(selectionStart - 1);
2944
+ if (Character.isWhitespace(c) || isSpecialChar(c)) break;
2945
+ selectionStart--;
2946
+ }
2947
+
2948
+ // Expand right until whitespace OR special char
2949
+ selectionEnd = position;
2950
+ while (selectionEnd < length) {
2951
+ char c = mGapBuffer.charAt(selectionEnd);
2952
+ if (Character.isWhitespace(c) || isSpecialChar(c)) break;
2953
+ selectionEnd++;
2954
+ }
2955
+
2956
+ if (selectionStart < selectionEnd) {
2957
+ return mGapBuffer.substring(selectionStart, selectionEnd);
2958
+ }
2959
+
2960
+ return null;
2961
+ }
2962
+
2963
+ // Find word at cursor (EXCLUDING special chars)
2964
+ private String findWordAtCursor() {
2965
+ // First, check if cursor is directly on a WORD character (not special char)
2966
+ if (mCursorIndex < mGapBuffer.length()) {
2967
+ char currentChar = mGapBuffer.charAt(mCursorIndex);
2968
+ if (!Character.isWhitespace(currentChar) && !isSpecialChar(currentChar)) {
2969
+ return expandSelectionFrom(mCursorIndex);
2970
+ }
2971
+ }
2972
+
2973
+ // Check character before cursor (only if it's a WORD character)
2974
+ if (mCursorIndex > 0) {
2975
+ char prevChar = mGapBuffer.charAt(mCursorIndex - 1);
2976
+ if (!Character.isWhitespace(prevChar) && !isSpecialChar(prevChar)) {
2977
+ return expandSelectionFrom(mCursorIndex - 1);
2978
+ }
2979
+ }
2980
+
2981
+ return null;
2982
+ }
2983
+
2984
+ // Find word in vicinity (EXCLUDING special chars)
2985
+ private String findWordInVicinity() {
2986
+ int length = mGapBuffer.length();
2987
+ if (length == 0) return null;
2988
+
2989
+ for (int radius = 1; radius <= 20; radius++) {
2990
+ // Check forward
2991
+ int forwardPos = mCursorIndex + radius;
2992
+ if (forwardPos < length) {
2993
+ char c = mGapBuffer.charAt(forwardPos);
2994
+ // If it's a special char, select it individually
2995
+ if (isSpecialChar(c)) {
2996
+ selectionStart = forwardPos;
2997
+ selectionEnd = forwardPos + 1;
2998
+ return String.valueOf(c);
2999
+ }
3000
+ // If it's a word char (not special, not whitespace), expand word
3001
+ if (!Character.isWhitespace(c) && !isSpecialChar(c)) {
3002
+ return expandSelectionFrom(forwardPos);
3003
+ }
3004
+ }
3005
+
3006
+ // Check backward
3007
+ int backwardPos = mCursorIndex - radius;
3008
+ if (backwardPos >= 0) {
3009
+ char c = mGapBuffer.charAt(backwardPos);
3010
+ // If it's a special char, select it individually
3011
+ if (isSpecialChar(c)) {
3012
+ selectionStart = backwardPos;
3013
+ selectionEnd = backwardPos + 1;
3014
+ return String.valueOf(c);
3015
+ }
3016
+ // If it's a word char (not special, not whitespace), expand word
3017
+ if (!Character.isWhitespace(c) && !isSpecialChar(c)) {
3018
+ return expandSelectionFrom(backwardPos);
3019
+ }
3020
+ }
3021
+ }
3022
+
3023
+ return null;
3024
+ }
3025
+
3026
+ // Find any non-whitespace (but still respect special chars)
3027
+ private String findAnyNonWhitespace() {
3028
+ // Check if current line is empty first
3029
+ String currentLine = getLine(mCursorLine);
3030
+ if (currentLine == null || currentLine.trim().isEmpty()) {
3031
+ return null;
3032
+ }
3033
+
3034
+ int lineStart = getLineStart(mCursorLine);
3035
+ int lineEnd = lineStart + getLine(mCursorLine).length();
3036
+
3037
+ for (int i = lineStart; i < lineEnd; i++) {
3038
+ if (i < mGapBuffer.length()) {
3039
+ char c = mGapBuffer.charAt(i);
3040
+ if (!Character.isWhitespace(c)) {
3041
+ // If it's a special char, select only that char
3042
+ if (isSpecialChar(c)) {
3043
+ selectionStart = i;
3044
+ selectionEnd = i + 1;
3045
+ return String.valueOf(c);
3046
+ }
3047
+ // Otherwise expand word (will stop at special chars)
3048
+ return expandSelectionFrom(i);
3049
+ }
3050
+ }
3051
+ }
3052
+
3053
+ return null;
3054
+ }
3055
+
3056
+ // helper method to check line is empty or not
3057
+ private boolean isCurrentLineEmptyOrWhitespace() {
3058
+ String currentLine = getLine(mCursorLine);
3059
+ if (currentLine == null || currentLine.isEmpty()) {
3060
+ return true;
3061
+ }
3062
+
3063
+ // Check if line contains only whitespace
3064
+ for (int i = 0; i < currentLine.length(); i++) {
3065
+ if (!Character.isWhitespace(currentLine.charAt(i))) {
3066
+ return false;
3067
+ }
3068
+ }
3069
+ return true;
3070
+ }
3071
+
3072
+ // ===== Editor Custom Functionalities ===== //
3073
+
3074
+ // copy line texts
3075
+ public void copyLine() {
3076
+ if (isSelectMode) {
3077
+ int[] sel = normalizeSelection();
3078
+ int s = sel[0], e = sel[1];
3079
+ int[] range = fullLineRangeForSelection(s, e);
3080
+ if (range != null) {
3081
+ copyRangeToClipboard(range[0], range[1], "lines");
3082
+ return;
3083
+ }
3084
+ // degenerate -> fallthrough to no-selection behavior
3085
+ }
3086
+
3087
+ // no selection path: copy current line and select it in UI
3088
+ int[] range = fullLineRangeForCursorLine(mCursorLine);
3089
+ copyRangeToClipboard(range[0], range[1], "line");
3090
+ setSelection(range[0], range[1]);
3091
+ }
3092
+
3093
+ // cut line texts
3094
+ public void cutLine() {
3095
+ if (isSelectMode) {
3096
+ int[] sel = normalizeSelection();
3097
+ int s = sel[0], e = sel[1];
3098
+ int[] range = fullLineRangeForSelection(s, e);
3099
+ if (range != null) {
3100
+ // copy then delete as a batch (single undo)
3101
+ copyRangeToClipboard(range[0], range[1], "lines");
3102
+ batchDeleteWithSelectionSnapshot(range[0], range[1],
3103
+ true, selectionStart, selectionEnd,
3104
+ false, -1, -1);
3105
+ clearSelection();
3106
+ mCursorIndex = range[0];
3107
+ mCursorLine = getOffsetLine(mCursorIndex);
3108
+ adjustCursorPosition();
3109
+ onTextChanged();
3110
+ postInvalidate();
3111
+ return;
3112
+ }
3113
+ // degenerate -> fallthrough to single-line cut
3114
+ }
3115
+
3116
+ // no selection: cut current line
3117
+ int[] range = fullLineRangeForCursorLine(mCursorLine);
3118
+ copyRangeToClipboard(range[0], range[1], "line");
3119
+ batchDeleteWithSelectionSnapshot(range[0], range[1],
3120
+ false, selectionStart, selectionEnd,
3121
+ false, -1, -1);
3122
+ mCursorIndex = range[0];
3123
+ mCursorLine = getOffsetLine(mCursorIndex);
3124
+ adjustCursorPosition();
3125
+ clearSelectionMenu();
3126
+ onTextChanged();
3127
+ postInvalidate();
3128
+ }
3129
+
3130
+ // Replace line with clipboard text
3131
+ public void replaceLine() {
3132
+ if (!mClipboard.hasPrimaryClip()) return;
3133
+ ClipData data = mClipboard.getPrimaryClip();
3134
+ if (data == null || data.getItemCount() == 0) return;
3135
+ ClipData.Item item = data.getItemAt(0);
3136
+ CharSequence raw = item.getText();
3137
+ if (raw == null) return;
3138
+ String clipboardText = raw.toString();
3139
+
3140
+ if (isSelectMode) {
3141
+ int[] sel = normalizeSelection();
3142
+ int s = sel[0], e = sel[1];
3143
+ int[] range = fullLineRangeForSelection(s, e);
3144
+ if (range != null) {
3145
+ int replaceStart = range[0];
3146
+ int replaceEnd = range[1];
3147
+ batchReplaceWithSelectionSnapshot(replaceStart, replaceEnd, clipboardText,
3148
+ true, selectionStart, selectionEnd,
3149
+ true, replaceStart, replaceStart + clipboardText.length());
3150
+ // update UI to reflect selection of replaced block
3151
+ setSelection(replaceStart, replaceStart + clipboardText.length());
3152
+ clearSelectionMenu(); // optional; original logic kept
3153
+ mCursorIndex = selectionEnd;
3154
+ mCursorLine = getOffsetLine(mCursorIndex);
3155
+ adjustCursorPosition();
3156
+ onTextChanged();
3157
+ postInvalidate();
3158
+ return;
3159
+ }
3160
+ // degenerate -> fallthrough
3161
+ }
3162
+
3163
+ // no selection: replace current line content
3164
+ int[] range = fullLineRangeForCursorLine(mCursorLine);
3165
+ int lineStart = range[0];
3166
+ int lineEnd = range[1];
3167
+ batchReplaceWithSelectionSnapshot(lineStart, lineEnd, clipboardText,
3168
+ false, selectionStart, selectionEnd,
3169
+ false, -1, -1);
3170
+ clearSelection();
3171
+ mCursorIndex = lineStart + clipboardText.length();
3172
+ mCursorLine = getOffsetLine(mCursorIndex);
3173
+ adjustCursorPosition();
3174
+ onTextChanged();
3175
+ postInvalidate();
3176
+ }
3177
+
3178
+ // clear line texts
3179
+ public void deleteLine() {
3180
+ if (isSelectMode) {
3181
+ int[] sel = normalizeSelection();
3182
+ int s = sel[0], e = sel[1];
3183
+ int[] range = fullLineRangeForSelection(s, e);
3184
+ if (range != null) {
3185
+ batchDeleteWithSelectionSnapshot(range[0], range[1],
3186
+ true, selectionStart, selectionEnd,
3187
+ false, -1, -1);
3188
+ clearSelection();
3189
+ mCursorIndex = Math.min(range[0], mGapBuffer.length());
3190
+ mCursorLine = getOffsetLine(mCursorIndex);
3191
+ adjustCursorPosition();
3192
+ onTextChanged();
3193
+ postInvalidate();
3194
+ return;
3195
+ }
3196
+ // degenerate -> fallthrough
3197
+ }
3198
+
3199
+ // no selection: delete current line
3200
+ int[] range = fullLineRangeForCursorLine(mCursorLine);
3201
+ batchDeleteWithSelectionSnapshot(range[0], range[1],
3202
+ false, selectionStart, selectionEnd,
3203
+ false, -1, -1);
3204
+ mCursorIndex = Math.min(range[0], mGapBuffer.length());
3205
+ mCursorLine = getOffsetLine(mCursorIndex);
3206
+ adjustCursorPosition();
3207
+ clearSelectionMenu();
3208
+ onTextChanged();
3209
+ postInvalidate();
3210
+ }
3211
+
3212
+ // Clear line texts but keep line number
3213
+ public void emptyLine() {
3214
+ if (isSelectMode) {
3215
+ int[] sel = normalizeSelection();
3216
+ int s = sel[0], e = sel[1];
3217
+ int[] range = fullLineRangeForSelection(s, e);
3218
+ if (range != null) {
3219
+ // delete each line's content but keep newlines (we delete content range per-line)
3220
+ mGapBuffer.markSelectionBefore(selectionStart, selectionEnd, true);
3221
+ mGapBuffer.beginBatchEdit();
3222
+ int firstLine = getOffsetLine(s);
3223
+ int lastLine = getOffsetLine(Math.max(0, e - 1));
3224
+ for (int line = firstLine; line <= lastLine; line++) {
3225
+ int start = getLineStart(line);
3226
+ int end = start + getLine(line).length();
3227
+ if (end > start) {
3228
+ mGapBuffer.delete(start, end, true);
3229
+ }
3230
+ }
3231
+ mGapBuffer.markSelectionAfter(-1, -1, false);
3232
+ mGapBuffer.endBatchEdit();
3233
+
3234
+ clearSelection();
3235
+ mCursorLine = getOffsetLine(range[0]);
3236
+ mCursorIndex = getLineStart(mCursorLine);
3237
+ adjustCursorPosition();
3238
+ onTextChanged();
3239
+ postInvalidate();
3240
+ return;
3241
+ }
3242
+ // degenerate -> fallthrough
3243
+ }
3244
+
3245
+ // no selection: empty current line
3246
+ int[] range = fullLineRangeForCursorLine(mCursorLine);
3247
+ int lineStart = range[0];
3248
+ int lineEnd = range[1];
3249
+ if (lineEnd > lineStart) {
3250
+ mGapBuffer.beginBatchEdit();
3251
+ mGapBuffer.delete(lineStart, lineEnd, true);
3252
+ mGapBuffer.endBatchEdit();
3253
+ }
3254
+ mCursorIndex = lineStart;
3255
+ adjustCursorPosition();
3256
+ clearSelectionMenu();
3257
+ onTextChanged();
3258
+ postInvalidate();
3259
+ }
3260
+
3261
+ public void duplicateLine() {
3262
+ // --- CASE 1: Selection Mode ---
3263
+ if (isSelectMode) {
3264
+
3265
+ int start = Math.min(selectionStart, selectionEnd);
3266
+ int end = Math.max(selectionStart, selectionEnd);
3267
+
3268
+ // Expand selection to full lines
3269
+ int startLine = getOffsetLine(start);
3270
+ int endLine = getOffsetLine(end);
3271
+
3272
+ int lineStart = getLineStart(startLine);
3273
+ int lineEnd = getLineEnd(endLine);
3274
+
3275
+ String block = mGapBuffer.substring(lineStart, lineEnd);
3276
+
3277
+ // Insert duplicated block (ALWAYS add newline before block)
3278
+ mGapBuffer.insert(lineEnd, "\n" + block, true);
3279
+
3280
+ // Reselect duplicated block
3281
+ int dupStart = lineEnd + 1;
3282
+ int dupEnd = dupStart + block.length();
3283
+
3284
+ selectionStart = dupStart;
3285
+ selectionEnd = dupEnd;
3286
+
3287
+ updateSelectionHandles();
3288
+ onTextChanged();
3289
+ postInvalidate();
3290
+ return;
3291
+ }
3292
+
3293
+ // --- CASE 2: No Selection (Cursor only) ---
3294
+ int currentLine = mCursorLine;
3295
+
3296
+ int lineStart = getLineStart(currentLine);
3297
+ int lineEnd = getLineEnd(currentLine);
3298
+
3299
+ String lineText = mGapBuffer.substring(lineStart, lineEnd);
3300
+
3301
+ // Insert duplicated line
3302
+ mGapBuffer.insert(lineEnd, "\n" + lineText, true);
3303
+
3304
+ // Maintain cursor column
3305
+ int col = getColumn();
3306
+
3307
+ // Move cursor to new duplicated line
3308
+ int newLine = currentLine + 1;
3309
+ int newLineStart = getLineStart(newLine);
3310
+
3311
+ int target = newLineStart + col;
3312
+
3313
+ // Clamp column if line shorter
3314
+ int newLineEnd = getLineEnd(newLine);
3315
+ if (target > newLineEnd) {
3316
+ target = newLineEnd;
3317
+ }
3318
+
3319
+ // APPLY cursor move
3320
+ setCursorPosition(target);
3321
+
3322
+ // Ensure no selection mode active
3323
+ isSelectMode = false;
3324
+ clearLineSelection();
3325
+
3326
+ onTextChanged();
3327
+ postInvalidate();
3328
+ }
3329
+
3330
+ // Case conversion methods
3331
+ public void convertSelectionToLowerCase() {
3332
+ if (isSelectMode) {
3333
+ // normalize & clamp selection (defensive)
3334
+ int s = Math.max(0, Math.min(selectionStart, selectionEnd));
3335
+ int e = Math.max(0, Math.max(selectionStart, selectionEnd));
3336
+ if (s == e) return;
3337
+
3338
+ String selectedText = mGapBuffer.substring(s, e);
3339
+ if (selectedText != null && !selectedText.isEmpty()) {
3340
+ String lowerCaseText = selectedText.toLowerCase();
3341
+
3342
+ mGapBuffer.beginBatchEdit(); // <<< group undo
3343
+ mGapBuffer.replace(s, e, lowerCaseText, true);
3344
+ mGapBuffer.endBatchEdit(); // <<< end group
3345
+
3346
+ // update selection to cover replaced text (handles length changes)
3347
+ selectionStart = s;
3348
+ selectionEnd = s + lowerCaseText.length();
3349
+
3350
+ // clamp
3351
+ selectionStart = Math.max(0, Math.min(selectionStart, mGapBuffer.length()));
3352
+ selectionEnd = Math.max(0, Math.min(selectionEnd, mGapBuffer.length()));
3353
+
3354
+ updateSelectionHandles();
3355
+ }
3356
+ } else {
3357
+ // no selection: operate on current line
3358
+ int lineStart = getLineStart(mCursorLine);
3359
+ String currentLine = getLine(mCursorLine);
3360
+ if (currentLine != null && !currentLine.isEmpty()) {
3361
+ String lowerCaseText = currentLine.toLowerCase();
3362
+
3363
+ mGapBuffer.beginBatchEdit();
3364
+ mGapBuffer.replace(lineStart, lineStart + currentLine.length(), lowerCaseText, true);
3365
+ mGapBuffer.endBatchEdit();
3366
+
3367
+ clearSelectionMenu();
3368
+
3369
+ // move cursor to end of the replaced line
3370
+ mCursorIndex = lineStart + lowerCaseText.length();
3371
+ mCursorLine = getOffsetLine(mCursorIndex);
3372
+ adjustCursorPosition();
3373
+ }
3374
+ }
3375
+ onTextChanged();
3376
+ postInvalidate();
3377
+ }
3378
+
3379
+ public void convertSelectionToUpperCase() {
3380
+ if (isSelectMode) {
3381
+ // normalize & clamp selection (defensive)
3382
+ int s = Math.max(0, Math.min(selectionStart, selectionEnd));
3383
+ int e = Math.max(0, Math.max(selectionStart, selectionEnd));
3384
+ if (s == e) return;
3385
+
3386
+ String selectedText = mGapBuffer.substring(s, e);
3387
+ if (selectedText != null && !selectedText.isEmpty()) {
3388
+ String upperCaseText = selectedText.toUpperCase();
3389
+
3390
+ mGapBuffer.beginBatchEdit(); // <<< group undo
3391
+ mGapBuffer.replace(s, e, upperCaseText, true);
3392
+ mGapBuffer.endBatchEdit(); // <<< end group
3393
+
3394
+ // update selection to cover replaced text (handles length changes)
3395
+ selectionStart = s;
3396
+ selectionEnd = s + upperCaseText.length();
3397
+
3398
+ // clamp
3399
+ selectionStart = Math.max(0, Math.min(selectionStart, mGapBuffer.length()));
3400
+ selectionEnd = Math.max(0, Math.min(selectionEnd, mGapBuffer.length()));
3401
+
3402
+ updateSelectionHandles();
3403
+ }
3404
+ } else {
3405
+ // no selection: operate on current line
3406
+ int lineStart = getLineStart(mCursorLine);
3407
+ String currentLine = getLine(mCursorLine);
3408
+ if (currentLine != null && !currentLine.isEmpty()) {
3409
+ String upperCaseText = currentLine.toUpperCase();
3410
+
3411
+ mGapBuffer.beginBatchEdit();
3412
+ mGapBuffer.replace(lineStart, lineStart + currentLine.length(), upperCaseText, true);
3413
+ mGapBuffer.endBatchEdit();
3414
+
3415
+ clearSelectionMenu();
3416
+
3417
+ // move cursor to end of the replaced line
3418
+ mCursorIndex = lineStart + upperCaseText.length();
3419
+ mCursorLine = getOffsetLine(mCursorIndex);
3420
+ adjustCursorPosition();
3421
+ }
3422
+ }
3423
+ onTextChanged();
3424
+ postInvalidate();
3425
+ }
3426
+
3427
+ // Indentation methods
3428
+ public void increaseIndent() {
3429
+ mGapBuffer.beginBatchEdit(); // UNDO batch start
3430
+
3431
+ if (isSelectMode) {
3432
+ int startLine = getOffsetLine(selectionStart);
3433
+ int endLine = getOffsetLine(selectionEnd);
3434
+
3435
+ for (int i = startLine; i <= endLine; i++) {
3436
+ int lineStart = getLineStart(i);
3437
+ mGapBuffer.insert(lineStart, " ", true); // record in undo
3438
+ }
3439
+
3440
+ int indentSize = 4;
3441
+ selectionStart += indentSize;
3442
+ selectionEnd += (endLine - startLine + 1) * indentSize;
3443
+ updateSelectionHandles();
3444
+ } else {
3445
+ int lineStart = getLineStart(mCursorLine);
3446
+ mGapBuffer.insert(lineStart, " ", true);
3447
+
3448
+ mCursorIndex += 4;
3449
+ adjustCursorPosition();
3450
+ clearSelectionMenu();
3451
+ }
3452
+
3453
+ mGapBuffer.endBatchEdit(); // UNDO batch end
3454
+
3455
+ // Restore selection state after undo/redo cycles
3456
+ if (isSelectMode) {
3457
+ selectionStart = Math.min(mGapBuffer.length(), selectionStart);
3458
+ selectionEnd = Math.min(mGapBuffer.length(), selectionEnd);
3459
+ updateSelectionHandles();
3460
+ } else {
3461
+ isSelectMode = false;
3462
+ }
3463
+
3464
+ onTextChanged();
3465
+ postInvalidate();
3466
+ }
3467
+
3468
+ // Decrease indent
3469
+ public void decreaseIndent() {
3470
+ if (!isSelectMode) {
3471
+ // single-line path
3472
+ mGapBuffer.beginBatchEdit();
3473
+
3474
+ int lineStart = getLineStart(mCursorLine);
3475
+ String line = getLine(mCursorLine);
3476
+
3477
+ int spacesToRemove = 0;
3478
+ if (line.startsWith(" ")) spacesToRemove = 4;
3479
+ else if (line.startsWith(" ")) spacesToRemove = 2;
3480
+ else if (line.startsWith("\t")) spacesToRemove = 1;
3481
+ else if (line.startsWith(" ")) spacesToRemove = 1;
3482
+
3483
+ if (spacesToRemove > 0) {
3484
+ mGapBuffer.delete(lineStart, lineStart + spacesToRemove, true);
3485
+ mCursorIndex = Math.max(0, mCursorIndex - spacesToRemove);
3486
+ adjustCursorPosition();
3487
+ }
3488
+
3489
+ mGapBuffer.endBatchEdit();
3490
+ onTextChanged();
3491
+ postInvalidate();
3492
+ return;
3493
+ }
3494
+
3495
+ // --- Multi-line selection
3496
+ int selStartOrig = Math.max(0, Math.min(selectionStart, selectionEnd));
3497
+ int selEndOrig = Math.max(0, Math.max(selectionStart, selectionEnd));
3498
+
3499
+ int startLine = getOffsetLine(selStartOrig);
3500
+ int endLine = getOffsetLine(selEndOrig == 0 ? 0 : Math.max(0, selEndOrig - 1));
3501
+ // build snapshot of line starts and text BEFORE any edits
3502
+ int lineCount = endLine - startLine + 1;
3503
+ int[] lineStarts = new int[lineCount];
3504
+ String[] lineTexts = new String[lineCount];
3505
+
3506
+ for (int i = 0; i < lineCount; i++) {
3507
+ int lineNo = startLine + i;
3508
+ lineStarts[i] = getLineStart(lineNo);
3509
+ lineTexts[i] = getLine(lineNo);
3510
+ }
3511
+
3512
+ mGapBuffer.beginBatchEdit();
3513
+
3514
+ int totalRemoved = 0; // cumulative removed so far (affects later delete positions)
3515
+
3516
+ for (int i = 0; i < lineCount; i++) {
3517
+ int origLineStart = lineStarts[i];
3518
+ String line = lineTexts[i];
3519
+
3520
+ int spacesToRemove = 0;
3521
+ if (line.startsWith(" ")) spacesToRemove = 4;
3522
+ else if (line.startsWith(" ")) spacesToRemove = 2;
3523
+ else if (line.startsWith("\t")) spacesToRemove = 1;
3524
+ else if (line.startsWith(" ")) spacesToRemove = 1;
3525
+ else continue; // nothing to remove on this line
3526
+
3527
+ // delete position must be adjusted by how many chars we removed earlier
3528
+ int deleteStart = origLineStart - totalRemoved;
3529
+ // defensive clamp
3530
+ deleteStart = Math.max(0, Math.min(deleteStart, mGapBuffer.length()));
3531
+
3532
+ mGapBuffer.delete(deleteStart, deleteStart + spacesToRemove, true);
3533
+
3534
+ // update cumulative removed
3535
+ totalRemoved += spacesToRemove;
3536
+
3537
+ // adjust original selection endpoints relative to original line starts:
3538
+ // if the deleted area was strictly before the original selection start/end, shift them
3539
+ // left
3540
+ if (origLineStart < selStartOrig)
3541
+ selStartOrig = Math.max(0, selStartOrig - spacesToRemove);
3542
+ if (origLineStart < selEndOrig) selEndOrig = Math.max(0, selEndOrig - spacesToRemove);
3543
+ }
3544
+
3545
+ mGapBuffer.endBatchEdit();
3546
+
3547
+ // Apply adjusted selection values and clamp to buffer
3548
+ selectionStart = Math.max(0, Math.min(selStartOrig, mGapBuffer.length()));
3549
+ selectionEnd = Math.max(0, Math.min(selEndOrig, mGapBuffer.length()));
3550
+ updateSelectionHandles();
3551
+
3552
+ onTextChanged();
3553
+ postInvalidate();
3554
+ }
3555
+
3556
+ // Add Comment
3557
+ public void toggleComment() {
3558
+ // Determine the affected lines depending on selection mode
3559
+ int startLine, endLine;
3560
+
3561
+ if (isSelectMode) {
3562
+ // ensure selection indices are normalized and clamped
3563
+ int s = Math.max(0, Math.min(selectionStart, selectionEnd));
3564
+ int e = Math.max(0, Math.max(selectionStart, selectionEnd));
3565
+ startLine = getOffsetLine(s);
3566
+ endLine = getOffsetLine(e == 0 ? 0 : Math.max(0, e - 1)); // ensure we pick the line
3567
+ // that contains e-1
3568
+ } else {
3569
+ // single line: operate only on cursor line
3570
+ startLine = endLine = mCursorLine;
3571
+ }
3572
+
3573
+ mGapBuffer.beginBatchEdit(); // group all changes into one undo step
3574
+
3575
+ boolean allCommented = true;
3576
+ for (int i = startLine; i <= endLine; i++) {
3577
+ String line = getLine(i);
3578
+ if (!isLineCommented(line) && !line.trim().isEmpty()) {
3579
+ allCommented = false;
3580
+ break;
3581
+ }
3582
+ }
3583
+
3584
+ // If all lines are commented -> remove, else add comments for uncommented lines
3585
+ for (int i = startLine; i <= endLine; i++) {
3586
+ String line = getLine(i);
3587
+ if (allCommented) {
3588
+ // only remove if commented
3589
+ if (isLineCommented(line)) {
3590
+ removeCommentFromLine(i);
3591
+ }
3592
+ } else {
3593
+ // add comment if not commented and not blank
3594
+ if (!isLineCommented(line) && !line.trim().isEmpty()) {
3595
+ addCommentToLine(i);
3596
+ }
3597
+ }
3598
+ }
3599
+
3600
+ mGapBuffer.endBatchEdit(); // end grouping
3601
+
3602
+ // After editing, update UI and selection/cursor
3603
+ if (isSelectMode) {
3604
+ // Keep selection mode, but selection offsets were adjusted in add/remove helpers.
3605
+ updateSelectionHandles();
3606
+ } else {
3607
+ clearSelectionMenu();
3608
+ adjustCursorPosition();
3609
+ }
3610
+
3611
+ onTextChanged();
3612
+ postInvalidate();
3613
+ }
3614
+
3615
+ private boolean isLineCommented(String line) {
3616
+ if (line == null || line.isEmpty()) return false;
3617
+
3618
+ // Find the position after leading whitespace
3619
+ int contentStart = 0;
3620
+ while (contentStart < line.length() && Character.isWhitespace(line.charAt(contentStart))) {
3621
+ contentStart++;
3622
+ }
3623
+
3624
+ // Check if there's a comment after whitespace
3625
+ return contentStart < line.length() && line.charAt(contentStart) == '#';
3626
+ }
3627
+
3628
+ public void addComment() {
3629
+ if (isSelectMode) {
3630
+ int s = Math.max(0, Math.min(selectionStart, selectionEnd));
3631
+ int e = Math.max(0, Math.max(selectionStart, selectionEnd));
3632
+ int startLine = getOffsetLine(s);
3633
+ int endLine = getOffsetLine(e == 0 ? 0 : Math.max(0, e - 1));
3634
+
3635
+ mGapBuffer.beginBatchEdit();
3636
+ for (int i = startLine; i <= endLine; i++) {
3637
+ String line = getLine(i);
3638
+ if (!isLineCommented(line) && !line.trim().isEmpty()) {
3639
+ addCommentToLine(i);
3640
+ }
3641
+ }
3642
+ mGapBuffer.endBatchEdit();
3643
+
3644
+ updateSelectionHandles();
3645
+ } else {
3646
+ String line = getLine(mCursorLine);
3647
+ if (!isLineCommented(line) && !line.trim().isEmpty()) {
3648
+ mGapBuffer.beginBatchEdit();
3649
+ addCommentToLine(mCursorLine);
3650
+ mGapBuffer.endBatchEdit();
3651
+ }
3652
+ clearSelectionMenu();
3653
+ adjustCursorPosition();
3654
+ }
3655
+ onTextChanged();
3656
+ postInvalidate();
3657
+ }
3658
+
3659
+ public void removeComment() {
3660
+ if (isSelectMode) {
3661
+ int s = Math.max(0, Math.min(selectionStart, selectionEnd));
3662
+ int e = Math.max(0, Math.max(selectionStart, selectionEnd));
3663
+ int startLine = getOffsetLine(s);
3664
+ int endLine = getOffsetLine(e == 0 ? 0 : Math.max(0, e - 1));
3665
+
3666
+ mGapBuffer.beginBatchEdit();
3667
+ for (int i = startLine; i <= endLine; i++) {
3668
+ String line = getLine(i);
3669
+ if (isLineCommented(line)) {
3670
+ removeCommentFromLine(i);
3671
+ }
3672
+ }
3673
+ mGapBuffer.endBatchEdit();
3674
+
3675
+ updateSelectionHandles();
3676
+ } else {
3677
+ String line = getLine(mCursorLine);
3678
+ if (isLineCommented(line)) {
3679
+ mGapBuffer.beginBatchEdit();
3680
+ removeCommentFromLine(mCursorLine);
3681
+ mGapBuffer.endBatchEdit();
3682
+ }
3683
+ clearSelectionMenu();
3684
+ adjustCursorPosition();
3685
+ }
3686
+ onTextChanged();
3687
+ postInvalidate();
3688
+ }
3689
+
3690
+ private void addCommentToLine(int lineNumber) {
3691
+ int lineStart = getLineStart(lineNumber);
3692
+ String line = getLine(lineNumber);
3693
+
3694
+ if (line == null || line.trim().isEmpty() || isLineCommented(line)) return;
3695
+
3696
+ int contentStart = 0;
3697
+ while (contentStart < line.length() && Character.isWhitespace(line.charAt(contentStart))) {
3698
+ contentStart++;
3699
+ }
3700
+
3701
+ int insertPos = lineStart + contentStart; // logical offset where comment marker should be
3702
+ // inserted
3703
+
3704
+ // Insert marker and capture undo
3705
+ mGapBuffer.insert(insertPos, getCommentBlock() + " ", true);
3706
+
3707
+ // If selection exists, shift selection indices forward when they are at/after insert
3708
+ if (isSelectMode) {
3709
+ // Use >= so selection that starts exactly at insert will include the inserted text
3710
+ if (selectionStart >= insertPos) selectionStart += 2;
3711
+ if (selectionEnd >= insertPos) selectionEnd += 2;
3712
+ // clamp
3713
+ selectionStart = Math.max(0, Math.min(selectionStart, mGapBuffer.length()));
3714
+ selectionEnd = Math.max(0, Math.min(selectionEnd, mGapBuffer.length()));
3715
+ } else {
3716
+ // If single-line and cursor is on the same line, advance cursor offset so cursor stays
3717
+ // after inserted marker
3718
+ int cursorOffset = getLineStart(lineNumber) + contentStart;
3719
+ // if you keep cursor as an offset, update it here; otherwise adjust mCursorLine related
3720
+ // state via adjustCursorPosition()
3721
+ // (call adjustCursorPosition() at the caller after editing)
3722
+ }
3723
+ }
3724
+
3725
+ private void removeCommentFromLine(int lineNumber) {
3726
+ int lineStart = getLineStart(lineNumber);
3727
+ String line = getLine(lineNumber);
3728
+
3729
+ if (line == null || line.isEmpty()) return;
3730
+
3731
+ int contentStart = 0;
3732
+ while (contentStart < line.length() &&
3733
+ Character.isWhitespace(line.charAt(contentStart))) {
3734
+ contentStart++;
3735
+ }
3736
+
3737
+ String block = getCommentBlock();
3738
+ int blockLen = block.length();
3739
+
3740
+ // check if line starts with comment block
3741
+ if (contentStart + blockLen <= line.length() &&
3742
+ line.startsWith(block, contentStart)) {
3743
+
3744
+ int deleteStart = lineStart + contentStart;
3745
+ int deleteEnd = deleteStart + blockLen;
3746
+
3747
+ // If there is a space after the comment, delete that too
3748
+ int nextCharIndex = contentStart + blockLen;
3749
+ if (nextCharIndex < line.length() && line.charAt(nextCharIndex) == ' ') {
3750
+ deleteEnd++; // remove the extra space
3751
+ }
3752
+
3753
+ mGapBuffer.delete(deleteStart, deleteEnd, true);
3754
+
3755
+ // update selection
3756
+ int removedCount = deleteEnd - deleteStart;
3757
+
3758
+ if (isSelectMode) {
3759
+ if (selectionStart > deleteStart) selectionStart -= removedCount;
3760
+ if (selectionEnd > deleteStart) selectionEnd -= removedCount;
3761
+
3762
+ selectionStart = Math.max(0, Math.min(selectionStart, mGapBuffer.length()));
3763
+ selectionEnd = Math.max(0, Math.min(selectionEnd, mGapBuffer.length()));
3764
+ }
3765
+ }
3766
+ }
3767
+
3768
+ // Normalize and clamp selection; returns [start, end] with start<=end
3769
+ private int[] normalizeSelection() {
3770
+ int s = Math.max(0, Math.min(selectionStart, selectionEnd));
3771
+ int e = Math.max(0, Math.max(selectionStart, selectionEnd));
3772
+ s = Math.max(0, Math.min(s, mGapBuffer.length()));
3773
+ e = Math.max(0, Math.min(e, mGapBuffer.length()));
3774
+ return new int[]{s, e};
3775
+ }
3776
+
3777
+ // Compute full-line range [startOffset, endOffset] that covers selection [s,e].
3778
+ // If e==s this returns null (degenerate).
3779
+ // endOffset is content-end (not including trailing newline) for last line.
3780
+ private int[] fullLineRangeForSelection(int s, int e) {
3781
+ if (s >= e) return null;
3782
+ int firstLine = getOffsetLine(s);
3783
+ int lastLine = getOffsetLine(Math.max(0, e - 1));
3784
+ int start = getLineStart(firstLine);
3785
+ int lastLineStart = getLineStart(lastLine);
3786
+ int end = lastLineStart + getLine(lastLine).length(); // content-end
3787
+ start = Math.max(0, Math.min(start, mGapBuffer.length()));
3788
+ end = Math.max(0, Math.min(end, mGapBuffer.length()));
3789
+ if (start > end) end = start;
3790
+ return new int[]{start, end};
3791
+ }
3792
+
3793
+ // Compute full-line range for the current cursor line (no newline included).
3794
+ private int[] fullLineRangeForCursorLine(int line) {
3795
+ int start = getLineStart(line);
3796
+ int end = start + getLine(line).length();
3797
+ start = Math.max(0, Math.min(start, mGapBuffer.length()));
3798
+ end = Math.max(0, Math.min(end, mGapBuffer.length()));
3799
+ if (start > end) end = start;
3800
+ return new int[]{start, end};
3801
+ }
3802
+
3803
+ // Helper: mark selection before, begin batch, run replace once, mark after, end batch.
3804
+ // keepSelectAfter indicates whether to set selection to [outStart, outEnd] after replace
3805
+ private void batchReplaceWithSelectionSnapshot(int replaceStart, int replaceEnd, String text,
3806
+ boolean wasSelectBefore, int selBeforeStart, int selBeforeEnd,
3807
+ boolean setSelectionAfter, int selAfterStart, int selAfterEnd) {
3808
+ mGapBuffer.markSelectionBefore(selBeforeStart, selBeforeEnd, wasSelectBefore);
3809
+ mGapBuffer.beginBatchEdit();
3810
+ mGapBuffer.replace(replaceStart, replaceEnd, text, true);
3811
+ if (setSelectionAfter) {
3812
+ mGapBuffer.markSelectionAfter(selAfterStart, selAfterEnd, true);
3813
+ } else {
3814
+ mGapBuffer.markSelectionAfter(-1, -1, false);
3815
+ }
3816
+ mGapBuffer.endBatchEdit();
3817
+ }
3818
+
3819
+ // Helper: delete a range with selection snapshot grouping
3820
+ private void batchDeleteWithSelectionSnapshot(int delStart, int delEnd,
3821
+ boolean wasSelectBefore, int selBeforeStart, int selBeforeEnd,
3822
+ boolean setSelectionAfter, int selAfterStart, int selAfterEnd) {
3823
+ mGapBuffer.markSelectionBefore(selBeforeStart, selBeforeEnd, wasSelectBefore);
3824
+ mGapBuffer.beginBatchEdit();
3825
+ mGapBuffer.delete(delStart, delEnd, true);
3826
+ if (setSelectionAfter) {
3827
+ mGapBuffer.markSelectionAfter(selAfterStart, selAfterEnd, true);
3828
+ } else {
3829
+ mGapBuffer.markSelectionAfter(-1, -1, false);
3830
+ }
3831
+ mGapBuffer.endBatchEdit();
3832
+ }
3833
+
3834
+ // Helper: copy a buffer-range to clipboard
3835
+ private void copyRangeToClipboard(int start, int end, String label) {
3836
+ start = Math.max(0, Math.min(start, mGapBuffer.length()));
3837
+ end = Math.max(0, Math.min(end, mGapBuffer.length()));
3838
+ if (start > end) end = start;
3839
+ String text = mGapBuffer.substring(start, end);
3840
+ if (text != null && !text.isEmpty()) {
3841
+ ClipData data = ClipData.newPlainText(label, text);
3842
+ mClipboard.setPrimaryClip(data);
3843
+ }
3844
+ }
3845
+
3846
+ // Helper: clear selection UI (common)
3847
+ private void clearSelection() {
3848
+ clearSelectionMenu();
3849
+ isSelectMode = false;
3850
+ selectionStart = selectionEnd = -1;
3851
+ }
3852
+
3853
+ // ===== SCALE GESTURE LISTENER CLASS =====
3854
+ class ScaleGestureListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
3855
+ @Override
3856
+ public boolean onScale(ScaleGestureDetector detector) {
3857
+ float factor = detector.getScaleFactor();
3858
+ setTextSize(mTextPaint.getTextSize() * factor);
3859
+ return true;
3860
+ }
3861
+ }
3862
+
3863
+ // ---------- Text input connection (IME) ----------
3864
+ class TextInputConnection extends BaseInputConnection {
3865
+
3866
+ public TextInputConnection(View view, boolean fullEditor) {
3867
+ super(view, fullEditor);
3868
+ }
3869
+
3870
+ @Override
3871
+ public boolean commitText(CharSequence text, int newCursorPosition) {
3872
+ long currentTime = System.currentTimeMillis();
3873
+
3874
+ if (currentTime - mLastInputTime < INPUT_DEBOUNCE_DELAY &&
3875
+ text.toString().equals(mLastCommittedText)) {
3876
+ return true;
3877
+ }
3878
+
3879
+ if (text != null && text.length() > 0) {
3880
+ mProcessingInput = true;
3881
+ mLastCommittedText = text.toString();
3882
+ mLastInputTime = currentTime;
3883
+
3884
+ // Call insert to handle the text and trigger auto-complete
3885
+ insert(text.toString());
3886
+
3887
+ postDelayed(new Runnable() {
3888
+ @Override
3889
+ public void run() {
3890
+ mProcessingInput = false;
3891
+ }
3892
+ }, INPUT_DEBOUNCE_DELAY);
3893
+ return true;
3894
+ }
3895
+ return super.commitText(text, newCursorPosition);
3896
+ }
3897
+
3898
+ @Override
3899
+ public boolean setComposingText(CharSequence text, int newCursorPosition) {
3900
+ Log.d(TAG, "setComposingText: '" + text + "', newCursor=" + newCursorPosition);
3901
+
3902
+ // For composing text, we still want to handle it but not debounce
3903
+ if (text != null && text.length() > 0) {
3904
+ mProcessingInput = true;
3905
+ insert(text.toString());
3906
+ postDelayed(new Runnable() {
3907
+ @Override
3908
+ public void run() {
3909
+ mProcessingInput = false;
3910
+ }
3911
+ }, INPUT_DEBOUNCE_DELAY);
3912
+ return true;
3913
+ }
3914
+ return super.setComposingText(text, newCursorPosition);
3915
+ }
3916
+
3917
+ @Override
3918
+ public boolean deleteSurroundingText(int beforeLength, int afterLength) {
3919
+ Log.d(TAG, "deleteSurroundingText: before=" + beforeLength + ", after=" + afterLength);
3920
+
3921
+ if (mProcessingInput) {
3922
+ Log.d(TAG, "Skipping delete - processing input");
3923
+ return true;
3924
+ }
3925
+
3926
+ if (beforeLength > 0) {
3927
+ for (int i = 0; i < beforeLength; i++) {
3928
+ delete();
3929
+ }
3930
+ return true;
3931
+ } else if (afterLength > 0) {
3932
+ for (int i = 0; i < afterLength; i++) {
3933
+ handleForwardDelete();
3934
+ }
3935
+ return true;
3936
+ }
3937
+ return super.deleteSurroundingText(beforeLength, afterLength);
3938
+ }
3939
+
3940
+ @Override
3941
+ public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
3942
+ Log.d(TAG, "deleteSurroundingTextInCodePoints: before=" + beforeLength + ", after=" + afterLength);
3943
+ return deleteSurroundingText(beforeLength, afterLength);
3944
+ }
3945
+
3946
+ @Override
3947
+ public boolean sendKeyEvent(KeyEvent event) {
3948
+ Log.d(TAG, "sendKeyEvent: " + event.getKeyCode() + ", action=" + event.getAction());
3949
+
3950
+ // Skip key events if we're already processing input to avoid duplicates
3951
+ if (mProcessingInput && event.getAction() == KeyEvent.ACTION_DOWN) {
3952
+ Log.d(TAG, "Skipping key event - processing input");
3953
+ return true;
3954
+ }
3955
+
3956
+ // Let onKeyDown handle most keys, but ensure numbers and special chars work
3957
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
3958
+ int keyCode = event.getKeyCode();
3959
+
3960
+ // Handle keys that might not be properly handled by onKeyDown
3961
+ if (keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9 ||
3962
+ keyCode >= KeyEvent.KEYCODE_NUMPAD_0 && keyCode <= KeyEvent.KEYCODE_NUMPAD_9 ||
3963
+ keyCode == KeyEvent.KEYCODE_SPACE) {
3964
+
3965
+ int unicodeChar = event.getUnicodeChar();
3966
+ if (unicodeChar != 0) {
3967
+ mProcessingInput = true;
3968
+ insert(String.valueOf((char) unicodeChar));
3969
+ postDelayed(new Runnable() {
3970
+ @Override
3971
+ public void run() {
3972
+ mProcessingInput = false;
3973
+ }
3974
+ }, INPUT_DEBOUNCE_DELAY);
3975
+ return true;
3976
+ }
3977
+ }
3978
+
3979
+ return onKeyDown(keyCode, event);
3980
+ }
3981
+ return super.sendKeyEvent(event);
3982
+ }
3983
+
3984
+ @Override
3985
+ public boolean finishComposingText() {
3986
+ Log.d(TAG, "finishComposingText");
3987
+ return true;
3988
+ }
3989
+
3990
+ @Override
3991
+ public CharSequence getTextBeforeCursor(int length, int flags) {
3992
+ try {
3993
+ int start = Math.max(0, mCursorIndex - length);
3994
+ String text = mGapBuffer.substring(start, mCursorIndex);
3995
+ Log.d(TAG, "getTextBeforeCursor: " + text);
3996
+ return text;
3997
+ } catch (Exception e) {
3998
+ Log.e(TAG, "Error in getTextBeforeCursor", e);
3999
+ return "";
4000
+ }
4001
+ }
4002
+
4003
+ @Override
4004
+ public CharSequence getTextAfterCursor(int length, int flags) {
4005
+ try {
4006
+ int end = Math.min(mGapBuffer.length(), mCursorIndex + length);
4007
+ String text = mGapBuffer.substring(mCursorIndex, end);
4008
+ Log.d(TAG, "getTextAfterCursor: " + text);
4009
+ return text;
4010
+ } catch (Exception e) {
4011
+ Log.e(TAG, "Error in getTextAfterCursor", e);
4012
+ return "";
4013
+ }
4014
+ }
4015
+
4016
+ @Override
4017
+ public int getCursorCapsMode(int reqModes) {
4018
+ // This helps with auto-capitalization
4019
+ return TextUtils.getCapsMode(mGapBuffer.toString(), mCursorIndex, reqModes);
4020
+ }
4021
+ }
4022
+ }