From e479301275f48cf99baaff42fc0e813f6bdbe16b Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Mon, 14 Sep 2026 20:28:42 +0200 Subject: [PATCH 1/2] update tests --- .../textpad/test/EditorNavigationTest.java | 449 ++++++++++++++++++ .../textpad/activities/EditorActivity.java | 130 +++-- app/src/main/res/menu/main_menu.xml | 13 +- app/src/main/res/values-ar/strings.xml | 2 - app/src/main/res/values-de/strings.xml | 2 - app/src/main/res/values-es/strings.xml | 2 - app/src/main/res/values-fr/strings.xml | 2 - app/src/main/res/values-hi/strings.xml | 2 - app/src/main/res/values-it/strings.xml | 2 - app/src/main/res/values-ja/strings.xml | 2 - app/src/main/res/values-pl/strings.xml | 2 - app/src/main/res/values-pt/strings.xml | 2 - app/src/main/res/values-ru/strings.xml | 2 - app/src/main/res/values-tr/strings.xml | 2 - app/src/main/res/values-uk/strings.xml | 2 - app/src/main/res/values-zh-rCN/strings.xml | 2 - app/src/main/res/values-zh-rTW/strings.xml | 2 - app/src/main/res/values-zh/strings.xml | 2 - app/src/main/res/values/strings.xml | 2 - 19 files changed, 541 insertions(+), 83 deletions(-) create mode 100644 app/src/androidTest/java/com/maxistar/textpad/test/EditorNavigationTest.java diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/EditorNavigationTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/EditorNavigationTest.java new file mode 100644 index 0000000..3cb91e1 --- /dev/null +++ b/app/src/androidTest/java/com/maxistar/textpad/test/EditorNavigationTest.java @@ -0,0 +1,449 @@ +package com.maxistar.textpad.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.graphics.Rect; +import android.os.SystemClock; +import android.preference.PreferenceManager; +import android.view.InputDevice; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.widget.EditText; +import android.widget.PopupMenu; +import android.widget.ScrollView; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.maxistar.textpad.R; +import com.maxistar.textpad.ServiceLocator; +import com.maxistar.textpad.activities.EditorActivity; +import com.maxistar.textpad.recovery.RecoveryRepository; +import com.maxistar.textpad.service.SettingsService; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; + +@RunWith(AndroidJUnit4.class) +public class EditorNavigationTest { + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + clearRecovery(); + setEditorPreferences(false, false); + } + + @After + public void tearDown() { + clearRecovery(); + setEditorPreferences(false, true); + } + + @Test + public void twoFingerPanRoutesAxesInStandardScrolling() { + setEditorPreferences(false, false); + + try (ActivityScenario scenario = launchWithContent(longDocument())) { + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + ScrollView scrollView = activity.findViewById(R.id.vscroll); + assertNotNull(scrollView); + resetScroll(editor, scrollView); + + performCompletedPan(editor, 600, 600, 800, 800, 500, 500, 700, 700); + + assertEquals(100, editor.getScrollX()); + assertEquals(0, editor.getScrollY()); + assertEquals(100, scrollView.getScrollY()); + }); + } + } + + @Test + public void twoFingerPanRoutesBothAxesInSimpleScrolling() { + setEditorPreferences(true, false); + + try (ActivityScenario scenario = launchWithContent(longDocument())) { + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + assertNotNull(activity.findViewById(R.id.linear_layout)); + resetScroll(editor, null); + + performCompletedPan(editor, 600, 600, 800, 800, 500, 500, 700, 700); + + assertEquals(100, editor.getScrollX()); + assertEquals(100, editor.getScrollY()); + }); + } + } + + @Test + public void pointerReleaseStopsPanBeforeRemainingPointerMoves() { + setEditorPreferences(true, false); + + try (ActivityScenario scenario = launchWithContent(longDocument())) { + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + resetScroll(editor, null); + long downTime = SystemClock.uptimeMillis(); + + dispatch(editor, event(downTime, downTime, MotionEvent.ACTION_DOWN, + point(300, 300))); + dispatch(editor, event(downTime, downTime + 10, + pointerAction(MotionEvent.ACTION_POINTER_DOWN, 1), + point(300, 300), point(500, 500))); + dispatch(editor, event(downTime, downTime + 20, MotionEvent.ACTION_MOVE, + point(300, 300), point(400, 400))); + dispatch(editor, event(downTime, downTime + 30, + pointerAction(MotionEvent.ACTION_POINTER_UP, 1), + point(300, 300), point(400, 400))); + + int scrollX = editor.getScrollX(); + int scrollY = editor.getScrollY(); + dispatch(editor, event(downTime, downTime + 40, MotionEvent.ACTION_MOVE, + point(300, 300))); + + assertEquals(scrollX, editor.getScrollX()); + assertEquals(scrollY, editor.getScrollY()); + dispatch(editor, event(downTime, downTime + 50, MotionEvent.ACTION_UP, + point(300, 300))); + }); + } + } + + @Test + public void cancelAndThirdPointerTransitionsStopPan() { + setEditorPreferences(true, false); + + try (ActivityScenario scenario = launchWithContent(longDocument())) { + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + resetScroll(editor, null); + + long cancelDownTime = SystemClock.uptimeMillis(); + startPanWithOffset(editor, cancelDownTime); + dispatch(editor, event(cancelDownTime, cancelDownTime + 30, + MotionEvent.ACTION_CANCEL, point(300, 300), point(400, 400))); + int scrollAfterCancelX = editor.getScrollX(); + int scrollAfterCancelY = editor.getScrollY(); + dispatch(editor, event(cancelDownTime, cancelDownTime + 40, + MotionEvent.ACTION_MOVE, point(300, 300))); + assertEquals(scrollAfterCancelX, editor.getScrollX()); + assertEquals(scrollAfterCancelY, editor.getScrollY()); + + resetScroll(editor, null); + long thirdPointerDownTime = SystemClock.uptimeMillis() + 100; + startPanWithOffset(editor, thirdPointerDownTime); + dispatch(editor, event(thirdPointerDownTime, thirdPointerDownTime + 30, + pointerAction(MotionEvent.ACTION_POINTER_DOWN, 2), + point(300, 300), point(400, 400), point(700, 700))); + int scrollAfterThirdPointerX = editor.getScrollX(); + int scrollAfterThirdPointerY = editor.getScrollY(); + dispatch(editor, event(thirdPointerDownTime, thirdPointerDownTime + 40, + MotionEvent.ACTION_MOVE, + point(300, 300), point(400, 400), point(700, 700))); + assertEquals(scrollAfterThirdPointerX, editor.getScrollX()); + assertEquals(scrollAfterThirdPointerY, editor.getScrollY()); + dispatch(editor, event(thirdPointerDownTime, thirdPointerDownTime + 50, + MotionEvent.ACTION_CANCEL, + point(300, 300), point(400, 400), point(700, 700))); + }); + } + } + + @Test + public void panClampsShortAndLongContentBounds() { + setEditorPreferences(true, false); + + try (ActivityScenario shortScenario = launchWithContent("short")) { + shortScenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + resetScroll(editor, null); + performCompletedPan(editor, 600, 600, 800, 800, 500, 500, 700, 700); + assertEquals(0, editor.getScrollX()); + assertEquals(0, editor.getScrollY()); + }); + } + + try (ActivityScenario longScenario = launchWithContent(longDocument())) { + longScenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + int maxX = maxHorizontalScroll(editor); + int maxY = Math.max(0, editor.getLayout().getHeight() + + editor.getTotalPaddingTop() + editor.getTotalPaddingBottom() + - editor.getHeight()); + assertTrue(maxX > 0); + assertTrue(maxY > 0); + + editor.scrollTo(maxX, maxY); + performCompletedPan(editor, 600, 600, 800, 800, 500, 500, 700, 700); + assertEquals(maxX, editor.getScrollX()); + assertEquals(maxY, editor.getScrollY()); + + performCompletedPan(editor, 100, 100, 200, 200, + maxX + 300, maxY + 300, maxX + 400, maxY + 400); + assertEquals(0, editor.getScrollX()); + assertEquals(0, editor.getScrollY()); + }); + } + } + + @Test + public void goToMenuStateAndDestinationsWorkInBothLayouts() { + verifyGoToNavigation(false); + verifyGoToNavigation(true); + } + + private void verifyGoToNavigation(boolean simpleScrolling) { + setEditorPreferences(simpleScrolling, true); + try (ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class)) { + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + Menu emptyMenu = inflateEditorMenu(activity, editor); + activity.onPrepareOptionsMenu(emptyMenu); + assertFalse(emptyMenu.findItem(R.id.menu_document_go_to).isEnabled()); + + editor.setText(longDocument()); + editor.setSelection(0); + }); + waitForIdle(); + + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + Menu menu = inflateEditorMenu(activity, editor); + activity.onPrepareOptionsMenu(menu); + MenuItem goTo = menu.findItem(R.id.menu_document_go_to); + assertTrue(goTo.isEnabled()); + assertTrue(goTo.hasSubMenu()); + assertNotNull(menu.findItem(R.id.menu_document_go_to_beginning)); + assertNotNull(menu.findItem(R.id.menu_document_go_to_end)); + + activity.onOptionsItemSelected(menu.findItem(R.id.menu_document_go_to_end)); + }); + waitForIdle(); + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + assertEquals(editor.length(), editor.getSelectionStart()); + assertCaretVisible(editor); + if (simpleScrolling) { + assertTrue(editor.getScrollY() > 0); + } else { + ScrollView scrollView = activity.findViewById(R.id.vscroll); + assertTrue(scrollView.getScrollY() > 0); + } + + Menu menu = inflateEditorMenu(activity, editor); + activity.onOptionsItemSelected(menu.findItem(R.id.menu_document_go_to_beginning)); + }); + waitForIdle(); + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + assertEquals(0, editor.getSelectionStart()); + assertCaretVisible(editor); + }); + } + } + + private ActivityScenario launchWithContent(String content) { + ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class); + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + editor.setText(content); + editor.setSelection(0); + editor.requestFocus(); + }); + waitForIdle(); + return scenario; + } + + private static Menu inflateEditorMenu(EditorActivity activity, EditText anchor) { + PopupMenu popupMenu = new PopupMenu(activity, anchor); + activity.getMenuInflater().inflate(R.menu.main_menu, popupMenu.getMenu()); + return popupMenu.getMenu(); + } + + private static void assertCaretVisible(EditText editor) { + assertNotNull(editor.getLayout()); + int line = editor.getLayout().getLineForOffset(editor.getSelectionStart()); + int[] editorLocation = new int[2]; + editor.getLocationOnScreen(editorLocation); + int caretTop = editorLocation[1] + editor.getTotalPaddingTop() + + editor.getLayout().getLineTop(line) - editor.getScrollY(); + int caretBottom = editorLocation[1] + editor.getTotalPaddingTop() + + editor.getLayout().getLineBottom(line) - editor.getScrollY(); + Rect visible = new Rect(); + assertTrue(editor.getGlobalVisibleRect(visible)); + assertTrue("Caret top must be visible", caretTop >= visible.top); + assertTrue("Caret bottom must be visible", caretBottom <= visible.bottom); + } + + private static int maxHorizontalScroll(EditText editor) { + float maxLineWidth = 0; + for (int line = 0; line < editor.getLayout().getLineCount(); line++) { + maxLineWidth = Math.max(maxLineWidth, editor.getLayout().getLineWidth(line)); + } + int contentWidth = (int) Math.ceil(maxLineWidth) + + editor.getTotalPaddingLeft() + editor.getTotalPaddingRight(); + return Math.max(0, contentWidth - editor.getWidth()); + } + + private static void resetScroll(EditText editor, ScrollView scrollView) { + editor.setSelection(0); + editor.scrollTo(0, 0); + if (scrollView != null) { + scrollView.scrollTo(0, 0); + } + } + + private static void startPanWithOffset(EditText editor, long downTime) { + dispatch(editor, event(downTime, downTime, MotionEvent.ACTION_DOWN, + point(300, 300))); + dispatch(editor, event(downTime, downTime + 10, + pointerAction(MotionEvent.ACTION_POINTER_DOWN, 1), + point(300, 300), point(500, 500))); + dispatch(editor, event(downTime, downTime + 20, MotionEvent.ACTION_MOVE, + point(300, 300), point(400, 400))); + } + + private static void performCompletedPan( + EditText editor, + float startX0, + float startY0, + float startX1, + float startY1, + float endX0, + float endY0, + float endX1, + float endY1 + ) { + long downTime = SystemClock.uptimeMillis(); + dispatch(editor, event(downTime, downTime, MotionEvent.ACTION_DOWN, + point(startX0, startY0))); + dispatch(editor, event(downTime, downTime + 10, + pointerAction(MotionEvent.ACTION_POINTER_DOWN, 1), + point(startX0, startY0), point(startX1, startY1))); + dispatch(editor, event(downTime, downTime + 20, MotionEvent.ACTION_MOVE, + point(endX0, endY0), point(endX1, endY1))); + dispatch(editor, event(downTime, downTime + 30, + pointerAction(MotionEvent.ACTION_POINTER_UP, 1), + point(endX0, endY0), point(endX1, endY1))); + dispatch(editor, event(downTime, downTime + 40, MotionEvent.ACTION_UP, + point(endX0, endY0))); + } + + private static int pointerAction(int action, int pointerIndex) { + return action | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); + } + + private static float[] point(float x, float y) { + return new float[]{x, y}; + } + + private static MotionEvent event( + long downTime, + long eventTime, + int action, + float[]... points + ) { + MotionEvent.PointerProperties[] properties = + new MotionEvent.PointerProperties[points.length]; + MotionEvent.PointerCoords[] coordinates = new MotionEvent.PointerCoords[points.length]; + for (int index = 0; index < points.length; index++) { + MotionEvent.PointerProperties pointerProperties = new MotionEvent.PointerProperties(); + pointerProperties.id = index; + pointerProperties.toolType = MotionEvent.TOOL_TYPE_FINGER; + properties[index] = pointerProperties; + + MotionEvent.PointerCoords pointerCoords = new MotionEvent.PointerCoords(); + pointerCoords.x = points[index][0]; + pointerCoords.y = points[index][1]; + pointerCoords.pressure = 1; + pointerCoords.size = 1; + coordinates[index] = pointerCoords; + } + return MotionEvent.obtain( + downTime, + eventTime, + action, + points.length, + properties, + coordinates, + 0, + 0, + 1, + 1, + 0, + 0, + InputDevice.SOURCE_TOUCHSCREEN, + 0 + ); + } + + private static void dispatch(EditText editor, MotionEvent event) { + try { + editor.dispatchTouchEvent(event); + } finally { + event.recycle(); + } + } + + private static String longDocument() { + StringBuilder document = new StringBuilder(); + for (int line = 0; line < 120; line++) { + document.append("Line ").append(line).append(' '); + for (int column = 0; column < 120; column++) { + document.append("0123456789"); + } + document.append('\n'); + } + return document.toString(); + } + + private void setEditorPreferences(boolean simpleScrolling, boolean autoWrapping) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(SettingsService.SETTING_USE_SIMPLE_SCROLLING, simpleScrolling) + .putBoolean(SettingsService.SETTING_AUTO_WRAPPING, autoWrapping) + .putBoolean(SettingsService.SETTING_OPEN_LAST_FILE, false) + .commit(); + ServiceLocator.getInstance().getSettingsService(context).reloadSettings(context); + } + + private static void waitForIdle() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + private void clearRecovery() { + context.getSharedPreferences("editor_recovery", Context.MODE_PRIVATE) + .edit() + .clear() + .commit(); + deleteRecursively(new RecoveryRepository(context).getDirectoryForTests()); + } + + private static void deleteRecursively(File file) { + if (!file.exists()) { + return; + } + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + file.delete(); + } +} diff --git a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java index 397ddf2..0549272 100644 --- a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java +++ b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java @@ -46,6 +46,7 @@ import android.view.MotionEvent; import android.view.Menu; import android.view.MenuItem; +import android.view.ViewParent; import android.view.WindowInsets; import android.view.inputmethod.EditorInfo; import android.webkit.WebView; @@ -217,9 +218,6 @@ public void onCreate(Bundle savedInstanceState) { mText = this.findViewById(R.id.editText1); mText.setBackgroundResource(android.R.color.transparent); mText.setOnTouchListener(new TwoFingerPanTouchListener()); - if (!settingsService.isAutoWrapping()) { - disableEditorAutowrapping(); - } editTextUndoRedo = new EditTextUndoRedo(mText, this); setTextWatcher(); @@ -229,6 +227,9 @@ public void onCreate(Bundle savedInstanceState) { scrollView = findViewById(R.id.vscroll); } applyPreferences(); + if (!settingsService.isAutoWrapping()) { + disableEditorAutowrapping(); + } if (savedInstanceState != null) { restoreState(savedInstanceState); @@ -865,6 +866,9 @@ public boolean onPrepareOptionsMenu(Menu menu) { MenuItem redoMenu = menu.findItem(R.id.menu_edit_redo); redoMenu.setEnabled(editTextUndoRedo.getCanRedo()); + MenuItem goToMenu = menu.findItem(R.id.menu_document_go_to); + goToMenu.setEnabled(mText.length() > 0); + updateRecentFiles(menu); if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { @@ -996,8 +1000,10 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { saveFile(); } else if (itemId == R.id.menu_document_save_as) { saveAs(); - } else if (itemId == R.id.menu_document_go_to) { - moveCaretPosition(); + } else if (itemId == R.id.menu_document_go_to_beginning) { + moveCaretPosition(0); + } else if (itemId == R.id.menu_document_go_to_end) { + moveCaretPosition(mText.length()); } else if (itemId == R.id.menu_edit_undo) { editUndo(); } else if (itemId == R.id.menu_edit_redo) { @@ -1971,50 +1977,76 @@ private class TwoFingerPanTouchListener implements View.OnTouchListener { private boolean panningActive = false; private float lastFocalX; private float lastFocalY; + private int maxScrollX; + private int maxScrollY; @Override public boolean onTouch(View view, MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: - panningActive = false; + stopPanning(view); return false; case MotionEvent.ACTION_POINTER_DOWN: if (event.getPointerCount() == 2) { - panningActive = true; - view.getParent().requestDisallowInterceptTouchEvent(true); - lastFocalX = focalX(event); - lastFocalY = focalY(event); + startPanning(view, event); + } else { + return stopPanning(view); } return panningActive; case MotionEvent.ACTION_MOVE: if (!panningActive) { return false; } + if (event.getPointerCount() != 2) { + return stopPanning(view); + } float focalX = focalX(event); float focalY = focalY(event); panContent( Math.round(focalX - lastFocalX), - Math.round(focalY - lastFocalY) + Math.round(focalY - lastFocalY), + maxScrollX, + maxScrollY ); lastFocalX = focalX; lastFocalY = focalY; return true; case MotionEvent.ACTION_POINTER_UP: - if (panningActive) { - view.getParent().requestDisallowInterceptTouchEvent(false); - } - return panningActive; + return stopPanning(view); case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: - boolean wasPanning = panningActive; - panningActive = false; - view.getParent().requestDisallowInterceptTouchEvent(false); - return wasPanning; + return stopPanning(view); default: return panningActive; } } + private void startPanning(View view, MotionEvent event) { + panningActive = true; + requestParentInterception(view, true); + lastFocalX = focalX(event); + lastFocalY = focalY(event); + Layout layout = mText.getLayout(); + maxScrollX = calculateMaxHorizontalScroll(layout); + maxScrollY = calculateMaxVerticalScroll(layout); + } + + private boolean stopPanning(View view) { + boolean wasPanning = panningActive; + panningActive = false; + if (wasPanning) { + requestParentInterception(view, false); + } + return wasPanning; + } + + private void requestParentInterception(View view, boolean disallow) { + ViewParent parent = view.getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(disallow); + } + } + private float focalX(MotionEvent event) { float sum = 0; for (int i = 0; i < event.getPointerCount(); i++) { @@ -2032,18 +2064,11 @@ private float focalY(MotionEvent event) { } } - private void panContent(int deltaX, int deltaY) { - Layout layout = mText.getLayout(); - int maxX = layout == null ? 0 : - layout.getWidth() + mText.getTotalPaddingLeft() + mText.getTotalPaddingRight() - - mText.getWidth(); - int newX = clampScroll(mText.getScrollX() - deltaX, maxX); + private void panContent(int deltaX, int deltaY, int maxScrollX, int maxScrollY) { + int newX = clampScroll(mText.getScrollX() - deltaX, maxScrollX); if (simpleScrolling()) { - int maxY = layout == null ? 0 : - layout.getHeight() + mText.getTotalPaddingTop() + mText.getTotalPaddingBottom() - - mText.getHeight(); - int newY = clampScroll(mText.getScrollY() - deltaY, maxY); + int newY = clampScroll(mText.getScrollY() - deltaY, maxScrollY); mText.scrollTo(newX, newY); return; } @@ -2054,14 +2079,31 @@ private void panContent(int deltaX, int deltaY) { mText.scrollTo(newX, mText.getScrollY()); } - private int clampScroll(int value, int max) { - if (value < 0) { + private int calculateMaxHorizontalScroll(Layout layout) { + if (layout == null) { return 0; } - if (max > 0 && value > max) { - return max; + float maxLineWidth = 0; + for (int line = 0; line < layout.getLineCount(); line++) { + maxLineWidth = Math.max(maxLineWidth, layout.getLineWidth(line)); } - return value; + int contentWidth = (int) Math.ceil(maxLineWidth) + + mText.getTotalPaddingLeft() + mText.getTotalPaddingRight(); + return Math.max(0, contentWidth - mText.getWidth()); + } + + private int calculateMaxVerticalScroll(Layout layout) { + if (layout == null) { + return 0; + } + int contentHeight = layout.getHeight() + + mText.getTotalPaddingTop() + mText.getTotalPaddingBottom(); + return Math.max(0, contentHeight - mText.getHeight()); + } + + private int clampScroll(int value, int max) { + int boundedMax = Math.max(0, max); + return Math.max(0, Math.min(value, boundedMax)); } // QueryTextListener @@ -2178,20 +2220,10 @@ public boolean onMenuItemActionCollapse(MenuItem menuItem) { } } - private void moveCaretPosition() { - if (mText.length() != 0) { - new AlertDialog.Builder(this) - .setIcon(android.R.drawable.ic_menu_directions) - .setTitle(R.string.Go_To_Title) - .setMessage(R.string.Go_To_Description) - .setPositiveButton(R.string.Go_To_End, - (dialog, which) -> { - mText.setSelection(mText.length()); - }) - .setNegativeButton(R.string.Go_To_Beginning, - (dialog, which) -> { - mText.setSelection(0); - }).show(); - } + private void moveCaretPosition(int position) { + mText.requestFocus(); + mText.setSelection(position); + View editorRoot = findViewById(R.id.editor_root); + mText.post(() -> requestFocusedCaretOnScreen(editorRoot)); } } diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index 5ab60ec..87afb36 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -89,7 +89,16 @@ android:id="@+id/menu_document_go_to" android:title="@string/Go_To" android:icon="@drawable/ic_arrow_forward" - app:iconTint="@color/colorIcon" /> + app:iconTint="@color/colorIcon"> + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 198bb9e..1ef8816 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -7,8 +7,6 @@ حفظ حفظ كـ الانتقال إلى - الانتقال إلى الموضع - إلى أين تريد نقل المؤشر؟ البداية النهاية الموقع: %s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ac9f22f..6c62e49 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -7,8 +7,6 @@ Speichern Speichern unter Gehe zu - Zu Position gehen - Wohin möchten Sie den Cursor bewegen? Anfang Ende Lage: %s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index dbf9145..a6fb78e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -7,8 +7,6 @@ Guardar Guardar como Ir a - Ir a la posición - ¿Dónde quieres mover el cursor? Principio Final Ubicación: %s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index bc6dd0b..b9dba8f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -6,8 +6,6 @@ Enregistrer Enregistrer sous Aller à - Aller à la position - Où souhaitez-vous déplacer le curseur ? Début Fin Emplacement : %s diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 44fa886..e9ff88c 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -9,8 +9,6 @@ सहेजें इस रूप में सहेजें पर जाएँ - स्थिति पर जाएँ - आप कर्सर को कहाँ ले जाना चाहते हैं? शुरुआत अंत स्थान: %s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1219f0a..0563e7e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -7,8 +7,6 @@ Salva Salva come Vai a - Vai alla posizione - Dove vuoi spostare il cursore? Inizio Fine Posizione: %s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d4f63d9..e74e747 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -8,8 +8,6 @@ 上書き保存 別名で保存 移動 - 位置に移動 - カーソルをどこに移動しますか? 先頭 末尾 パス: %s diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index ddf5d4a..3df0318 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -8,8 +8,6 @@ Zapisz Zapisz jako Przejdź do - Przejdź do pozycji - Gdzie chcesz przenieść kursor? Początek Koniec Lokalizacja: %s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 7ca4cd7..520ed44 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -7,8 +7,6 @@ Salvar Salvar como Ir para - Ir para a posição - Para onde você deseja mover o cursor? Início Fim Local: %s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5132e42..8c6f70d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -7,8 +7,6 @@ Сохранить Сохранить Как Перейти - Перейти к позиции - Куда вы хотите переместить курсор? Начало Конец Путь: %s diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index efe51a9..eb9c2f8 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -7,8 +7,6 @@ Kaydet Farklı Kaydet Git - Konuma Git - mleci nereye taşımak istiyorsunuz? Başlangıç Son Yer: %s diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 01ba67c..6c40c51 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -8,8 +8,6 @@ Зберегти Зберегти як Перейти - Перейти до позиції - Куди ви хочете перемістити курсор? Початок Кінець Розташування: %s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4baba44..bb4bdc5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -9,8 +9,6 @@ 保存 另存为 转到 - 转到位置 - 您要将光标移动到哪里? 开头 结尾 位置: %s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 654e7cd..267de3b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -8,8 +8,6 @@ 儲存 另存新檔 前往 - 前往位置 - 您要將游標移動到哪裡? 開頭 結尾 位置:%s diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 9985c29..592c448 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -9,8 +9,6 @@ 保存 另存为 转到 - 转到位置 - 您要将光标移动到哪里? 开头 结尾 位置: %s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index efa5c79..0ff1d64 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,8 +8,6 @@ Save Save As Go To - Go to Position - Where would you like to move the cursor? Beginning End Location: %s From 5be28d9d5fa8f3aaa038ee922c895b0bd3663598 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Mon, 14 Sep 2026 20:51:15 +0200 Subject: [PATCH 2/2] fix code review suggestions --- .../textpad/test/EditorNavigationTest.java | 66 +++++++++++++++ .../textpad/activities/EditorActivity.java | 83 +++++++++++++++---- 2 files changed, 132 insertions(+), 17 deletions(-) diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/EditorNavigationTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/EditorNavigationTest.java index 3cb91e1..747896d 100644 --- a/app/src/androidTest/java/com/maxistar/textpad/test/EditorNavigationTest.java +++ b/app/src/androidTest/java/com/maxistar/textpad/test/EditorNavigationTest.java @@ -124,6 +124,39 @@ public void pointerReleaseStopsPanBeforeRemainingPointerMoves() { } } + @Test + public void finalUpAfterPanDoesNotMoveCaret() { + setEditorPreferences(true, false); + + try (ActivityScenario scenario = launchWithContent(longDocument())) { + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + resetScroll(editor, null); + editor.setSelection(0); + long downTime = SystemClock.uptimeMillis(); + + dispatch(editor, event(downTime, downTime, MotionEvent.ACTION_DOWN, + point(300, 300))); + dispatch(editor, event(downTime, downTime + 10, + pointerAction(MotionEvent.ACTION_POINTER_DOWN, 1), + point(300, 300), point(500, 500))); + dispatch(editor, event(downTime, downTime + 20, MotionEvent.ACTION_MOVE, + point(250, 250), point(450, 450))); + dispatch(editor, event(downTime, downTime + 30, + pointerAction(MotionEvent.ACTION_POINTER_UP, 1), + point(250, 250), point(450, 450))); + + int selectionStart = editor.getSelectionStart(); + int selectionEnd = editor.getSelectionEnd(); + dispatch(editor, event(downTime, downTime + 40, MotionEvent.ACTION_UP, + point(450, 450))); + + assertEquals(selectionStart, editor.getSelectionStart()); + assertEquals(selectionEnd, editor.getSelectionEnd()); + }); + } + } + @Test public void cancelAndThirdPointerTransitionsStopPan() { setEditorPreferences(true, false); @@ -201,6 +234,39 @@ public void panClampsShortAndLongContentBounds() { } } + @Test + public void horizontalPanBoundsAreCachedAndInvalidatedByTextChanges() { + setEditorPreferences(true, false); + + try (ActivityScenario scenario = launchWithContent(longDocument())) { + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + resetScroll(editor, null); + + int initialCalculations = activity.getHorizontalScrollBoundsCalculationCountForTests(); + performCompletedPan(editor, 600, 600, 800, 800, 500, 500, 700, 700); + int afterFirstPan = activity.getHorizontalScrollBoundsCalculationCountForTests(); + assertEquals(initialCalculations + 1, afterFirstPan); + + resetScroll(editor, null); + performCompletedPan(editor, 600, 600, 800, 800, 500, 500, 700, 700); + assertEquals(afterFirstPan, + activity.getHorizontalScrollBoundsCalculationCountForTests()); + + editor.setText(longDocument() + "extra text invalidates horizontal bounds"); + }); + waitForIdle(); + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + resetScroll(editor, null); + int beforeInvalidatedPan = activity.getHorizontalScrollBoundsCalculationCountForTests(); + performCompletedPan(editor, 600, 600, 800, 800, 500, 500, 700, 700); + assertEquals(beforeInvalidatedPan + 1, + activity.getHorizontalScrollBoundsCalculationCountForTests()); + }); + } + } + @Test public void goToMenuStateAndDestinationsWorkInBothLayouts() { verifyGoToNavigation(false); diff --git a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java index 0549272..cd68198 100644 --- a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java +++ b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java @@ -191,6 +191,11 @@ private static final class SaveRequest { WebView mWebView; + private int cachedMaxHorizontalScroll = 0; + private int cachedHorizontalScrollEditorWidth = -1; + private boolean horizontalScrollBoundsDirty = true; + private int horizontalScrollBoundsCalculationCount = 0; + /** * Called when the activity is first created. */ @@ -218,6 +223,13 @@ public void onCreate(Bundle savedInstanceState) { mText = this.findViewById(R.id.editText1); mText.setBackgroundResource(android.R.color.transparent); mText.setOnTouchListener(new TwoFingerPanTouchListener()); + mText.addOnLayoutChangeListener((view, left, top, right, bottom, + oldLeft, oldTop, oldRight, oldBottom) -> { + if ((right - left) != (oldRight - oldLeft) + || (bottom - top) != (oldBottom - oldTop)) { + invalidateHorizontalScrollBounds(); + } + }); editTextUndoRedo = new EditTextUndoRedo(mText, this); setTextWatcher(); @@ -339,6 +351,7 @@ public void beforeTextChanged(CharSequence s, int start, int count, int after) { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { + invalidateHorizontalScrollBounds(); if (suppressRecoveryTracking) { return; } @@ -760,12 +773,14 @@ private boolean isFilenameEmpty() { } void applyPreferences() { + invalidateHorizontalScrollBounds(); applyFontFace(); applyFontSize(); applyColors(); } private void disableEditorAutowrapping() { + invalidateHorizontalScrollBounds(); mText.setHorizontallyScrolling(true); mText.setHorizontalScrollBarEnabled(true); mText.setMaxLines(Integer.MAX_VALUE); @@ -1975,6 +1990,7 @@ protected void showToast(String toast_str) { private class TwoFingerPanTouchListener implements View.OnTouchListener { private boolean panningActive = false; + private boolean panGestureOwned = false; private float lastFocalX; private float lastFocalY; private int maxScrollX; @@ -1984,21 +2000,25 @@ private class TwoFingerPanTouchListener implements View.OnTouchListener { public boolean onTouch(View view, MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: - stopPanning(view); + endPanGesture(view); return false; case MotionEvent.ACTION_POINTER_DOWN: if (event.getPointerCount() == 2) { startPanning(view, event); } else { - return stopPanning(view); + stopActivePanning(view); } - return panningActive; + return panGestureOwned; case MotionEvent.ACTION_MOVE: - if (!panningActive) { + if (!panGestureOwned) { return false; } + if (!panningActive) { + return true; + } if (event.getPointerCount() != 2) { - return stopPanning(view); + stopActivePanning(view); + return true; } float focalX = focalX(event); float focalY = focalY(event); @@ -2012,32 +2032,42 @@ public boolean onTouch(View view, MotionEvent event) { lastFocalY = focalY; return true; case MotionEvent.ACTION_POINTER_UP: - return stopPanning(view); + if (panGestureOwned) { + stopActivePanning(view); + return true; + } + return false; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: - return stopPanning(view); + return endPanGesture(view); default: - return panningActive; + return panGestureOwned; } } private void startPanning(View view, MotionEvent event) { panningActive = true; + panGestureOwned = true; requestParentInterception(view, true); lastFocalX = focalX(event); lastFocalY = focalY(event); Layout layout = mText.getLayout(); - maxScrollX = calculateMaxHorizontalScroll(layout); + maxScrollX = getMaxHorizontalScroll(layout); maxScrollY = calculateMaxVerticalScroll(layout); } - private boolean stopPanning(View view) { - boolean wasPanning = panningActive; - panningActive = false; - if (wasPanning) { + private void stopActivePanning(View view) { + if (panningActive) { requestParentInterception(view, false); } - return wasPanning; + panningActive = false; + } + + private boolean endPanGesture(View view) { + boolean shouldConsume = panGestureOwned || panningActive; + stopActivePanning(view); + panGestureOwned = false; + return shouldConsume; } private void requestParentInterception(View view, boolean disallow) { @@ -2079,17 +2109,36 @@ private void panContent(int deltaX, int deltaY, int maxScrollX, int maxScrollY) mText.scrollTo(newX, mText.getScrollY()); } - private int calculateMaxHorizontalScroll(Layout layout) { - if (layout == null) { + private int getMaxHorizontalScroll(Layout layout) { + int editorWidth = mText.getWidth(); + if (layout == null || editorWidth <= 0) { return 0; } + if (horizontalScrollBoundsDirty || cachedHorizontalScrollEditorWidth != editorWidth) { + cachedMaxHorizontalScroll = calculateMaxHorizontalScroll(layout, editorWidth); + cachedHorizontalScrollEditorWidth = editorWidth; + horizontalScrollBoundsDirty = false; + } + return cachedMaxHorizontalScroll; + } + + private void invalidateHorizontalScrollBounds() { + horizontalScrollBoundsDirty = true; + } + + public int getHorizontalScrollBoundsCalculationCountForTests() { + return horizontalScrollBoundsCalculationCount; + } + + private int calculateMaxHorizontalScroll(Layout layout, int editorWidth) { + horizontalScrollBoundsCalculationCount++; float maxLineWidth = 0; for (int line = 0; line < layout.getLineCount(); line++) { maxLineWidth = Math.max(maxLineWidth, layout.getLineWidth(line)); } int contentWidth = (int) Math.ceil(maxLineWidth) + mText.getTotalPaddingLeft() + mText.getTotalPaddingRight(); - return Math.max(0, contentWidth - mText.getWidth()); + return Math.max(0, contentWidth - editorWidth); } private int calculateMaxVerticalScroll(Layout layout) {