diff --git a/app/src/main/java/com/termux/app/TermuxActivity.java b/app/src/main/java/com/termux/app/TermuxActivity.java index 0c9f74125b..401616aeba 100644 --- a/app/src/main/java/com/termux/app/TermuxActivity.java +++ b/app/src/main/java/com/termux/app/TermuxActivity.java @@ -523,8 +523,11 @@ private void setTerminalToolbarView(Bundle savedInstanceState) { if (savedInstanceState != null) savedTextInput = savedInstanceState.getString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT); - terminalToolbarViewPager.setAdapter(new TerminalToolbarViewPager.PageAdapter(this, savedTextInput)); - terminalToolbarViewPager.addOnPageChangeListener(new TerminalToolbarViewPager.OnPageChangeListener(this, terminalToolbarViewPager)); + TerminalToolbarViewPager.PageAdapter pageAdapter = new TerminalToolbarViewPager.PageAdapter(this, savedTextInput); + TerminalToolbarViewPager.OnPageChangeListener pageChangeListener = new TerminalToolbarViewPager.OnPageChangeListener(this, terminalToolbarViewPager); + pageChangeListener.setPageAdapter(pageAdapter); + terminalToolbarViewPager.setAdapter(pageAdapter); + terminalToolbarViewPager.addOnPageChangeListener(pageChangeListener); } private void setTerminalToolbarHeight() { diff --git a/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java index a526570b09..f234bdbcd8 100644 --- a/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java +++ b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java @@ -1,6 +1,8 @@ package com.termux.app.terminal.io; +import android.view.GestureDetector; import android.view.LayoutInflater; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.EditText; @@ -20,10 +22,12 @@ public static class PageAdapter extends PagerAdapter { final TermuxActivity mActivity; String mSavedTextInput; + private final TextInputHistory mTextInputHistory; public PageAdapter(TermuxActivity activity, String savedTextInput) { this.mActivity = activity; this.mSavedTextInput = savedTextInput; + this.mTextInputHistory = new TextInputHistory(); } @Override @@ -64,17 +68,27 @@ public Object instantiateItem(@NonNull ViewGroup collection, int position) { mSavedTextInput = null; } + // Set up gesture detection for up/down swipes + setupTextInputGestureDetection(editText); + editText.setOnEditorActionListener((v, actionId, event) -> { TerminalSession session = mActivity.getCurrentSession(); if (session != null) { if (session.isRunning()) { String textToSend = editText.getText().toString(); if (textToSend.length() == 0) textToSend = "\r"; + + // Add to history before sending (ignore empty entries and carriage returns) + if (!textToSend.equals("\r") && !textToSend.trim().isEmpty()) { + mTextInputHistory.addEntry(textToSend); + } + session.write(textToSend); } else { mActivity.getTermuxTerminalSessionClient().removeFinishedSession(session); } editText.setText(""); + mTextInputHistory.resetNavigation(); // Reset navigation after submission } return true; }); @@ -88,6 +102,81 @@ public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Ob collection.removeView((View) view); } + /** + * Sets up gesture detection for the text input EditText to handle up/down swipes + * for history navigation while preserving horizontal swipes for ViewPager. + */ + private void setupTextInputGestureDetection(EditText editText) { + GestureDetector gestureDetector = new GestureDetector(mActivity, + new GestureDetector.SimpleOnGestureListener() { + + private static final int SWIPE_THRESHOLD = 100; + private static final int SWIPE_VELOCITY_THRESHOLD = 100; + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (e1 == null || e2 == null) return false; + + float diffY = e2.getY() - e1.getY(); + float diffX = e2.getX() - e1.getX(); + + // Only handle vertical swipes (up/down) + if (Math.abs(diffY) > Math.abs(diffX) && + Math.abs(diffY) > SWIPE_THRESHOLD && + Math.abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) { + + if (diffY > 0) { + // Swipe down - navigate to newer entries + navigateHistoryDown(editText); + } else { + // Swipe up - navigate to older entries + navigateHistoryUp(editText); + } + return true; + } + return false; + } + }); + + editText.setOnTouchListener((v, event) -> { + // Let the GestureDetector handle the event first + boolean handled = gestureDetector.onTouchEvent(event); + + // If it wasn't a vertical swipe gesture, let the normal touch handling proceed + // This preserves the EditText's normal text selection and cursor positioning + if (!handled) { + v.performClick(); + return false; // Let the EditText handle the touch normally + } + return true; // We handled the gesture + }); + } + + /** + * Navigates up in history (to older entries) and updates the EditText. + */ + private void navigateHistoryUp(EditText editText) { + String currentText = editText.getText().toString(); + String historyEntry = mTextInputHistory.navigateUp(currentText); + + if (historyEntry != null) { + editText.setText(historyEntry); + editText.setSelection(historyEntry.length()); // Move cursor to end + } + } + + /** + * Navigates down in history (to newer entries) and updates the EditText. + */ + private void navigateHistoryDown(EditText editText) { + String historyEntry = mTextInputHistory.navigateDown(); + + if (historyEntry != null) { + editText.setText(historyEntry); + editText.setSelection(historyEntry.length()); // Move cursor to end + } + } + } @@ -96,15 +185,27 @@ public static class OnPageChangeListener extends ViewPager.SimpleOnPageChangeLis final TermuxActivity mActivity; final ViewPager mTerminalToolbarViewPager; + private PageAdapter mPageAdapter; public OnPageChangeListener(TermuxActivity activity, ViewPager viewPager) { this.mActivity = activity; this.mTerminalToolbarViewPager = viewPager; } + /** + * Sets the PageAdapter reference so we can access the text input history. + */ + public void setPageAdapter(PageAdapter pageAdapter) { + this.mPageAdapter = pageAdapter; + } + @Override public void onPageSelected(int position) { if (position == 0) { + // Switching away from text input - reset navigation + if (mPageAdapter != null) { + mPageAdapter.mTextInputHistory.resetNavigation(); + } mActivity.getTerminalView().requestFocus(); } else { final EditText editText = mTerminalToolbarViewPager.findViewById(R.id.terminal_toolbar_text_input); diff --git a/app/src/main/java/com/termux/app/terminal/io/TextInputHistory.java b/app/src/main/java/com/termux/app/terminal/io/TextInputHistory.java new file mode 100644 index 0000000000..009fbd5a2e --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/TextInputHistory.java @@ -0,0 +1,179 @@ +package com.termux.app.terminal.io; + +import java.util.ArrayList; + +/** + * Manages command history for the terminal toolbar text input view. + * Provides functionality similar to bash command history with up/down navigation. + */ +public class TextInputHistory { + + private static final int DEFAULT_MAX_HISTORY_SIZE = 20; + + private final ArrayList history; + private final int maxHistorySize; + private int currentIndex; // -1 means not navigating history, 0+ means navigating + private String currentEdit; // Stores user's current input when navigating history + + /** + * Creates a new TextInputHistory with default maximum size. + */ + public TextInputHistory() { + this(DEFAULT_MAX_HISTORY_SIZE); + } + + /** + * Creates a new TextInputHistory with specified maximum size. + * @param maxHistorySize Maximum number of history entries to store + */ + public TextInputHistory(int maxHistorySize) { + this.maxHistorySize = Math.max(1, maxHistorySize); + this.history = new ArrayList<>(this.maxHistorySize); + this.currentIndex = -1; + this.currentEdit = ""; + } + + /** + * Adds a new entry to the history. + * Duplicate consecutive entries are not added. + * @param text The text to add to history (empty/null entries are ignored) + */ + public void addEntry(String text) { + if (text == null || text.trim().isEmpty()) { + return; + } + + // Don't add if it's the same as the last entry + if (!history.isEmpty() && history.get(history.size() - 1).equals(text)) { + return; + } + + // Add to end of history + history.add(text); + + // Remove oldest entries if we exceed max size + if (history.size() > maxHistorySize) { + history.remove(0); + } + + // Reset navigation state + resetNavigation(); + } + + /** + * Navigates up in history (to older entries). + * @param currentText The current text in the input field + * @return The previous history entry, or null if at the beginning + */ + public String navigateUp(String currentText) { + if (history.isEmpty()) { + return null; + } + + // If not currently navigating, store current text and start from the end + if (currentIndex == -1) { + currentEdit = currentText != null ? currentText : ""; + currentIndex = history.size(); + } + + // Move up (to older entries) + if (currentIndex > 0) { + currentIndex--; + return history.get(currentIndex); + } + + // Already at the oldest entry + return history.get(currentIndex); + } + + /** + * Navigates down in history (to newer entries). + * @return The next history entry, current edit, or null if at the end + */ + public String navigateDown() { + if (currentIndex == -1 || history.isEmpty()) { + return null; + } + + // Move down (to newer entries) + currentIndex++; + + // If we've gone past the newest entry, return to current edit + if (currentIndex >= history.size()) { + String result = currentEdit; + resetNavigation(); + return result; + } + + return history.get(currentIndex); + } + + /** + * Resets the navigation state to normal input mode. + */ + public void resetNavigation() { + currentIndex = -1; + currentEdit = ""; + } + + /** + * Checks if currently navigating through history. + * @return true if in navigation mode, false if in normal input mode + */ + public boolean isNavigating() { + return currentIndex != -1; + } + + /** + * Gets the current history size. + * @return Number of entries in history + */ + public int size() { + return history.size(); + } + + /** + * Checks if history is empty. + * @return true if no history entries exist + */ + public boolean isEmpty() { + return history.isEmpty(); + } + + /** + * Clears all history entries. + */ + public void clear() { + history.clear(); + resetNavigation(); + } + + /** + * Gets a copy of the current history for persistence or debugging. + * @return A new ArrayList containing all history entries + */ + public ArrayList getHistoryCopy() { + return new ArrayList<>(history); + } + + /** + * Restores history from a list (for persistence). + * @param historyEntries List of history entries to restore + */ + public void restoreHistory(ArrayList historyEntries) { + if (historyEntries == null) { + return; + } + + clear(); + + // Add entries while respecting max size + int startIndex = Math.max(0, historyEntries.size() - maxHistorySize); + for (int i = startIndex; i < historyEntries.size(); i++) { + String entry = historyEntries.get(i); + if (entry != null && !entry.trim().isEmpty()) { + history.add(entry); + } + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/termux/app/terminal/io/TextInputHistoryTest.java b/app/src/test/java/com/termux/app/terminal/io/TextInputHistoryTest.java new file mode 100644 index 0000000000..fff8f3fb28 --- /dev/null +++ b/app/src/test/java/com/termux/app/terminal/io/TextInputHistoryTest.java @@ -0,0 +1,227 @@ +package com.termux.app.terminal.io; + +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; +import java.util.ArrayList; + +public class TextInputHistoryTest { + + private TextInputHistory history; + + @Before + public void setUp() { + history = new TextInputHistory(5); // Use small size for testing + } + + @Test + public void testAddEntry() { + assertTrue(history.isEmpty()); + assertEquals(0, history.size()); + + history.addEntry("ls"); + assertEquals(1, history.size()); + assertFalse(history.isEmpty()); + + history.addEntry("cd /tmp"); + assertEquals(2, history.size()); + } + + @Test + public void testAddEntryIgnoresEmptyAndNull() { + history.addEntry(""); + history.addEntry(" "); + history.addEntry(null); + + assertEquals(0, history.size()); + assertTrue(history.isEmpty()); + } + + @Test + public void testAddEntryIgnoresDuplicates() { + history.addEntry("ls"); + history.addEntry("ls"); // Duplicate should be ignored + history.addEntry("cd"); + history.addEntry("cd"); // Duplicate should be ignored + + assertEquals(2, history.size()); + } + + @Test + public void testMaxHistorySize() { + // Add more entries than max size + for (int i = 0; i < 10; i++) { + history.addEntry("command" + i); + } + + assertEquals(5, history.size()); // Should be capped at max size + + // Verify oldest entries were removed + ArrayList historyCopy = history.getHistoryCopy(); + assertEquals("command5", historyCopy.get(0)); // Oldest should be command5 + assertEquals("command9", historyCopy.get(4)); // Newest should be command9 + } + + @Test + public void testNavigateUpFromEmpty() { + assertFalse(history.isNavigating()); + assertNull(history.navigateUp("current text")); + assertFalse(history.isNavigating()); + } + + @Test + public void testNavigateUpBasic() { + history.addEntry("first"); + history.addEntry("second"); + history.addEntry("third"); + + // Navigate up should return most recent first + assertEquals("third", history.navigateUp("current")); + assertTrue(history.isNavigating()); + + assertEquals("second", history.navigateUp("current")); + assertEquals("first", history.navigateUp("current")); + + // Should stay at oldest entry + assertEquals("first", history.navigateUp("current")); + } + + @Test + public void testNavigateDown() { + history.addEntry("first"); + history.addEntry("second"); + history.addEntry("third"); + + // Navigate up to start navigation + assertEquals("third", history.navigateUp("current")); + assertEquals("second", history.navigateUp("current")); + + // Navigate down + assertEquals("third", history.navigateDown()); + + // Navigate down past newest should return current edit and reset + assertEquals("current", history.navigateDown()); + assertFalse(history.isNavigating()); + } + + @Test + public void testNavigateDownWithoutNavigatingUp() { + history.addEntry("test"); + + assertNull(history.navigateDown()); + assertFalse(history.isNavigating()); + } + + @Test + public void testResetNavigation() { + history.addEntry("test"); + + assertEquals("test", history.navigateUp("current")); + assertTrue(history.isNavigating()); + + history.resetNavigation(); + assertFalse(history.isNavigating()); + } + + @Test + public void testNavigationPreservesCurrentEdit() { + history.addEntry("old command"); + + String currentEdit = "new command being typed"; + assertEquals("old command", history.navigateUp(currentEdit)); + + // Navigate back down should return the current edit + assertEquals(currentEdit, history.navigateDown()); + assertFalse(history.isNavigating()); + } + + @Test + public void testClear() { + history.addEntry("test1"); + history.addEntry("test2"); + assertEquals(2, history.size()); + + history.clear(); + assertEquals(0, history.size()); + assertTrue(history.isEmpty()); + assertFalse(history.isNavigating()); + } + + @Test + public void testGetHistoryCopy() { + history.addEntry("test1"); + history.addEntry("test2"); + + ArrayList copy = history.getHistoryCopy(); + assertEquals(2, copy.size()); + assertEquals("test1", copy.get(0)); + assertEquals("test2", copy.get(1)); + + // Verify it's a copy (modifications don't affect original) + copy.add("test3"); + assertEquals(2, history.size()); + } + + @Test + public void testRestoreHistory() { + ArrayList entries = new ArrayList<>(); + entries.add("restored1"); + entries.add("restored2"); + entries.add("restored3"); + + history.restoreHistory(entries); + + assertEquals(3, history.size()); + assertEquals("restored1", history.navigateUp("")); + assertEquals("restored2", history.navigateUp("")); + assertEquals("restored3", history.navigateUp("")); + } + + @Test + public void testRestoreHistoryExceedsMaxSize() { + ArrayList entries = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + entries.add("entry" + i); + } + + history.restoreHistory(entries); + + // Should only keep the most recent entries + assertEquals(5, history.size()); + ArrayList copy = history.getHistoryCopy(); + assertEquals("entry5", copy.get(0)); // Oldest kept + assertEquals("entry9", copy.get(4)); // Newest kept + } + + @Test + public void testRestoreHistoryWithNullAndEmpty() { + ArrayList entries = new ArrayList<>(); + entries.add("valid1"); + entries.add(""); + entries.add(null); + entries.add(" "); + entries.add("valid2"); + + history.restoreHistory(entries); + + assertEquals(2, history.size()); // Only valid entries should be kept + } + + @Test + public void testRestoreHistoryWithNull() { + history.addEntry("existing"); + history.restoreHistory(null); + + assertEquals(0, history.size()); // Should clear existing history + } + + @Test + public void testAddEntryResetsNavigation() { + history.addEntry("test1"); + assertEquals("test1", history.navigateUp("current")); + assertTrue(history.isNavigating()); + + history.addEntry("test2"); + assertFalse(history.isNavigating()); // Should reset navigation + } +} \ No newline at end of file