diff --git a/app/src/main/java/com/termux/app/settings/properties/TermuxSharedProperties.java b/app/src/main/java/com/termux/app/settings/properties/TermuxSharedProperties.java index 8c72793f..623ab13d 100644 --- a/app/src/main/java/com/termux/app/settings/properties/TermuxSharedProperties.java +++ b/app/src/main/java/com/termux/app/settings/properties/TermuxSharedProperties.java @@ -5,8 +5,8 @@ import android.content.res.Configuration; import androidx.annotation.Nullable; -import com.termux.app.terminal.extrakeys.ExtraKeysInfo; -import com.termux.app.terminal.KeyboardShortcut; +import com.termux.app.terminal.io.extrakeys.ExtraKeysInfo; +import com.termux.app.terminal.io.KeyboardShortcut; import com.termux.app.utils.Logger; import org.json.JSONException; diff --git a/app/src/main/java/com/termux/app/terminal/io/BellHandler.java b/app/src/main/java/com/termux/app/terminal/io/BellHandler.java new file mode 100644 index 00000000..490bd2fd --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/BellHandler.java @@ -0,0 +1,63 @@ +package com.termux.app.terminal.io; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.os.Vibrator; + +public class BellHandler { + private static BellHandler instance = null; + private static final Object lock = new Object(); + + public static BellHandler getInstance(Context context) { + if (instance == null) { + synchronized (lock) { + if (instance == null) { + instance = new BellHandler((Vibrator) context.getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE)); + } + } + } + + return instance; + } + + private static final long DURATION = 50; + private static final long MIN_PAUSE = 3 * DURATION; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private long lastBell = 0; + private final Runnable bellRunnable; + + private BellHandler(final Vibrator vibrator) { + bellRunnable = new Runnable() { + @Override + public void run() { + if (vibrator != null) { + vibrator.vibrate(DURATION); + } + } + }; + } + + public synchronized void doBell() { + long now = now(); + long timeSinceLastBell = now - lastBell; + + if (timeSinceLastBell < 0) { + // there is a next bell pending; don't schedule another one + } else if (timeSinceLastBell < MIN_PAUSE) { + // there was a bell recently, scheudle the next one + handler.postDelayed(bellRunnable, MIN_PAUSE - timeSinceLastBell); + lastBell = lastBell + MIN_PAUSE; + } else { + // the last bell was long ago, do it now + bellRunnable.run(); + lastBell = now; + } + } + + private long now() { + return SystemClock.uptimeMillis(); + } +} diff --git a/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java b/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java new file mode 100644 index 00000000..c01f8994 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java @@ -0,0 +1,68 @@ +package com.termux.app.terminal.io; + +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import com.termux.app.TermuxActivity; + +/** + * Work around for fullscreen mode in Termux to fix ExtraKeysView not being visible. + * This class is derived from: + * https://stackoverflow.com/questions/7417123/android-how-to-adjust-layout-in-full-screen-mode-when-softkeyboard-is-visible + * and has some additional tweaks + * --- + * For more information, see https://issuetracker.google.com/issues/36911528 + */ +public class FullScreenWorkAround { + private final View mChildOfContent; + private int mUsableHeightPrevious; + private final ViewGroup.LayoutParams mViewGroupLayoutParams; + + private final int mNavBarHeight; + + + public static void apply(TermuxActivity activity) { + new FullScreenWorkAround(activity); + } + + private FullScreenWorkAround(TermuxActivity activity) { + ViewGroup content = activity.findViewById(android.R.id.content); + mChildOfContent = content.getChildAt(0); + mViewGroupLayoutParams = mChildOfContent.getLayoutParams(); + mNavBarHeight = activity.getNavBarHeight(); + mChildOfContent.getViewTreeObserver().addOnGlobalLayoutListener(this::possiblyResizeChildOfContent); + } + + private void possiblyResizeChildOfContent() { + int usableHeightNow = computeUsableHeight(); + if (usableHeightNow != mUsableHeightPrevious) { + int usableHeightSansKeyboard = mChildOfContent.getRootView().getHeight(); + int heightDifference = usableHeightSansKeyboard - usableHeightNow; + if (heightDifference > (usableHeightSansKeyboard / 4)) { + // keyboard probably just became visible + + // ensures that usable layout space does not extend behind the + // soft keyboard, causing the extra keys to not be visible + mViewGroupLayoutParams.height = (usableHeightSansKeyboard - heightDifference) + getNavBarHeight(); + } else { + // keyboard probably just became hidden + mViewGroupLayoutParams.height = usableHeightSansKeyboard; + } + mChildOfContent.requestLayout(); + mUsableHeightPrevious = usableHeightNow; + } + } + + private int getNavBarHeight() { + return mNavBarHeight; + } + + private int computeUsableHeight() { + Rect r = new Rect(); + mChildOfContent.getWindowVisibleDisplayFrame(r); + return (r.bottom - r.top); + } + +} + diff --git a/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java b/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java new file mode 100644 index 00000000..00a832dd --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java @@ -0,0 +1,13 @@ +package com.termux.app.terminal.io; + +public class KeyboardShortcut { + + public final int codePoint; + public final int shortcutAction; + + public KeyboardShortcut(int codePoint, int shortcutAction) { + this.codePoint = codePoint; + this.shortcutAction = shortcutAction; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeyButton.java b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeyButton.java new file mode 100644 index 00000000..2701a9ec --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeyButton.java @@ -0,0 +1,92 @@ +package com.termux.app.terminal.io.extrakeys; + +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Arrays; +import java.util.stream.Collectors; + +public class ExtraKeyButton { + + /** + * The key that will be sent to the terminal, either a control character + * defined in ExtraKeysView.keyCodesForString (LEFT, RIGHT, PGUP...) or + * some text. + */ + private final String key; + + /** + * If the key is a macro, i.e. a sequence of keys separated by space. + */ + private final boolean macro; + + /** + * The text that will be shown on the button. + */ + private final String display; + + /** + * The information of the popup (triggered by swipe up). + */ + @Nullable + private ExtraKeyButton popup = null; + + public ExtraKeyButton(ExtraKeysInfo.CharDisplayMap charDisplayMap, JSONObject config) throws JSONException { + this(charDisplayMap, config, null); + } + + public ExtraKeyButton(ExtraKeysInfo.CharDisplayMap charDisplayMap, JSONObject config, @Nullable ExtraKeyButton popup) throws JSONException { + String keyFromConfig = config.optString("key", null); + String macroFromConfig = config.optString("macro", null); + String[] keys; + if (keyFromConfig != null && macroFromConfig != null) { + throw new JSONException("Both key and macro can't be set for the same key"); + } else if (keyFromConfig != null) { + keys = new String[]{keyFromConfig}; + this.macro = false; + } else if (macroFromConfig != null) { + keys = macroFromConfig.split(" "); + this.macro = true; + } else { + throw new JSONException("All keys have to specify either key or macro"); + } + + for (int i = 0; i < keys.length; i++) { + keys[i] = ExtraKeysInfo.replaceAlias(keys[i]); + } + + this.key = TextUtils.join(" ", keys); + + String displayFromConfig = config.optString("display", null); + if (displayFromConfig != null) { + this.display = displayFromConfig; + } else { + this.display = Arrays.stream(keys) + .map(key -> charDisplayMap.get(key, key)) + .collect(Collectors.joining(" ")); + } + + this.popup = popup; + } + + public String getKey() { + return key; + } + + public boolean isMacro() { + return macro; + } + + public String getDisplay() { + return display; + } + + @Nullable + public ExtraKeyButton getPopup() { + return popup; + } +} diff --git a/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysInfo.java b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysInfo.java new file mode 100644 index 00000000..12fe30a9 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysInfo.java @@ -0,0 +1,253 @@ +package com.termux.app.terminal.io.extrakeys; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; + +public class ExtraKeysInfo { + + /** + * Matrix of buttons displayed + */ + private final ExtraKeyButton[][] buttons; + + /** + * This corresponds to one of the CharMapDisplay below + */ + private String style = "default"; + + public ExtraKeysInfo(String propertiesInfo, String style) throws JSONException { + this.style = style; + + // Convert String propertiesInfo to Array of Arrays + JSONArray arr = new JSONArray(propertiesInfo); + Object[][] matrix = new Object[arr.length()][]; + for (int i = 0; i < arr.length(); i++) { + JSONArray line = arr.getJSONArray(i); + matrix[i] = new Object[line.length()]; + for (int j = 0; j < line.length(); j++) { + matrix[i][j] = line.get(j); + } + } + + // convert matrix to buttons + this.buttons = new ExtraKeyButton[matrix.length][]; + for (int i = 0; i < matrix.length; i++) { + this.buttons[i] = new ExtraKeyButton[matrix[i].length]; + for (int j = 0; j < matrix[i].length; j++) { + Object key = matrix[i][j]; + + JSONObject jobject = normalizeKeyConfig(key); + + ExtraKeyButton button; + + if(! jobject.has("popup")) { + // no popup + button = new ExtraKeyButton(getSelectedCharMap(), jobject); + } else { + // a popup + JSONObject popupJobject = normalizeKeyConfig(jobject.get("popup")); + ExtraKeyButton popup = new ExtraKeyButton(getSelectedCharMap(), popupJobject); + button = new ExtraKeyButton(getSelectedCharMap(), jobject, popup); + } + + this.buttons[i][j] = button; + } + } + } + + /** + * "hello" -> {"key": "hello"} + */ + private static JSONObject normalizeKeyConfig(Object key) throws JSONException { + JSONObject jobject; + if(key instanceof String) { + jobject = new JSONObject(); + jobject.put("key", key); + } else if(key instanceof JSONObject) { + jobject = (JSONObject) key; + } else { + throw new JSONException("An key in the extra-key matrix must be a string or an object"); + } + return jobject; + } + + public ExtraKeyButton[][] getMatrix() { + return buttons; + } + + /** + * HashMap that implements Python dict.get(key, default) function. + * Default java.util .get(key) is then the same as .get(key, null); + */ + static class CleverMap extends HashMap { + V get(K key, V defaultValue) { + if(containsKey(key)) + return get(key); + else + return defaultValue; + } + } + + static class CharDisplayMap extends CleverMap {} + + /** + * Keys are displayed in a natural looking way, like "→" for "RIGHT" + */ + static final CharDisplayMap classicArrowsDisplay = new CharDisplayMap() {{ + // classic arrow keys (for ◀ ▶ ▲ ▼ @see arrowVariationDisplay) + put("LEFT", "←"); // U+2190 ← LEFTWARDS ARROW + put("RIGHT", "→"); // U+2192 → RIGHTWARDS ARROW + put("UP", "↑"); // U+2191 ↑ UPWARDS ARROW + put("DOWN", "↓"); // U+2193 ↓ DOWNWARDS ARROW + }}; + + static final CharDisplayMap wellKnownCharactersDisplay = new CharDisplayMap() {{ + // well known characters // https://en.wikipedia.org/wiki/{Enter_key, Tab_key, Delete_key} + put("ENTER", "↲"); // U+21B2 ↲ DOWNWARDS ARROW WITH TIP LEFTWARDS + put("TAB", "↹"); // U+21B9 ↹ LEFTWARDS ARROW TO BAR OVER RIGHTWARDS ARROW TO BAR + put("BKSP", "⌫"); // U+232B ⌫ ERASE TO THE LEFT sometimes seen and easy to understand + put("DEL", "⌦"); // U+2326 ⌦ ERASE TO THE RIGHT not well known but easy to understand + put("DRAWER", "☰"); // U+2630 ☰ TRIGRAM FOR HEAVEN not well known but easy to understand + put("KEYBOARD", "⌨"); // U+2328 ⌨ KEYBOARD not well known but easy to understand + }}; + + static final CharDisplayMap lessKnownCharactersDisplay = new CharDisplayMap() {{ + // https://en.wikipedia.org/wiki/{Home_key, End_key, Page_Up_and_Page_Down_keys} + // home key can mean "goto the beginning of line" or "goto first page" depending on context, hence the diagonal + put("HOME", "⇱"); // from IEC 9995 // U+21F1 ⇱ NORTH WEST ARROW TO CORNER + put("END", "⇲"); // from IEC 9995 // ⇲ // U+21F2 ⇲ SOUTH EAST ARROW TO CORNER + put("PGUP", "⇑"); // no ISO character exists, U+21D1 ⇑ UPWARDS DOUBLE ARROW will do the trick + put("PGDN", "⇓"); // no ISO character exists, U+21D3 ⇓ DOWNWARDS DOUBLE ARROW will do the trick + }}; + + static final CharDisplayMap arrowTriangleVariationDisplay = new CharDisplayMap() {{ + // alternative to classic arrow keys + put("LEFT", "◀"); // U+25C0 ◀ BLACK LEFT-POINTING TRIANGLE + put("RIGHT", "▶"); // U+25B6 ▶ BLACK RIGHT-POINTING TRIANGLE + put("UP", "▲"); // U+25B2 ▲ BLACK UP-POINTING TRIANGLE + put("DOWN", "▼"); // U+25BC ▼ BLACK DOWN-POINTING TRIANGLE + }}; + + static final CharDisplayMap notKnownIsoCharacters = new CharDisplayMap() {{ + // Control chars that are more clear as text // https://en.wikipedia.org/wiki/{Function_key, Alt_key, Control_key, Esc_key} + // put("FN", "FN"); // no ISO character exists + put("CTRL", "⎈"); // ISO character "U+2388 ⎈ HELM SYMBOL" is unknown to people and never printed on computers, however "U+25C7 ◇ WHITE DIAMOND" is a nice presentation, and "^" for terminal app and mac is often used + put("ALT", "⎇"); // ISO character "U+2387 ⎇ ALTERNATIVE KEY SYMBOL'" is unknown to people and only printed as the Option key "⌥" on Mac computer + put("ESC", "⎋"); // ISO character "U+238B ⎋ BROKEN CIRCLE WITH NORTHWEST ARROW" is unknown to people and not often printed on computers + }}; + + static final CharDisplayMap nicerLookingDisplay = new CharDisplayMap() {{ + // nicer looking for most cases + put("-", "―"); // U+2015 ― HORIZONTAL BAR + }}; + + /* + * Multiple maps are available to quickly change + * the style of the keys. + */ + + /** + * Some classic symbols everybody knows + */ + private static final CharDisplayMap defaultCharDisplay = new CharDisplayMap() {{ + putAll(classicArrowsDisplay); + putAll(wellKnownCharactersDisplay); + putAll(nicerLookingDisplay); + // all other characters are displayed as themselves + }}; + + /** + * Classic symbols and less known symbols + */ + private static final CharDisplayMap lotsOfArrowsCharDisplay = new CharDisplayMap() {{ + putAll(classicArrowsDisplay); + putAll(wellKnownCharactersDisplay); + putAll(lessKnownCharactersDisplay); // NEW + putAll(nicerLookingDisplay); + }}; + + /** + * Only arrows + */ + private static final CharDisplayMap arrowsOnlyCharDisplay = new CharDisplayMap() {{ + putAll(classicArrowsDisplay); + // putAll(wellKnownCharactersDisplay); // REMOVED + // putAll(lessKnownCharactersDisplay); // REMOVED + putAll(nicerLookingDisplay); + }}; + + /** + * Full Iso + */ + private static final CharDisplayMap fullIsoCharDisplay = new CharDisplayMap() {{ + putAll(classicArrowsDisplay); + putAll(wellKnownCharactersDisplay); + putAll(lessKnownCharactersDisplay); // NEW + putAll(nicerLookingDisplay); + putAll(notKnownIsoCharacters); // NEW + }}; + + /** + * Some people might call our keys differently + */ + static private final CharDisplayMap controlCharsAliases = new CharDisplayMap() {{ + put("ESCAPE", "ESC"); + put("CONTROL", "CTRL"); + put("RETURN", "ENTER"); // Technically different keys, but most applications won't see the difference + put("FUNCTION", "FN"); + // no alias for ALT + + // Directions are sometimes written as first and last letter for brevety + put("LT", "LEFT"); + put("RT", "RIGHT"); + put("DN", "DOWN"); + // put("UP", "UP"); well, "UP" is already two letters + + put("PAGEUP", "PGUP"); + put("PAGE_UP", "PGUP"); + put("PAGE UP", "PGUP"); + put("PAGE-UP", "PGUP"); + + // no alias for HOME + // no alias for END + + put("PAGEDOWN", "PGDN"); + put("PAGE_DOWN", "PGDN"); + put("PAGE-DOWN", "PGDN"); + + put("DELETE", "DEL"); + put("BACKSPACE", "BKSP"); + + // easier for writing in termux.properties + put("BACKSLASH", "\\"); + put("QUOTE", "\""); + put("APOSTROPHE", "'"); + }}; + + CharDisplayMap getSelectedCharMap() { + switch (style) { + case "arrows-only": + return arrowsOnlyCharDisplay; + case "arrows-all": + return lotsOfArrowsCharDisplay; + case "all": + return fullIsoCharDisplay; + case "none": + return new CharDisplayMap(); + default: + return defaultCharDisplay; + } + } + + /** + * Applies the 'controlCharsAliases' mapping to all the strings in *buttons* + * Modifies the array, doesn't return a new one. + */ + public static String replaceAlias(String key) { + return controlCharsAliases.get(key, key); + } +} + diff --git a/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysView.java b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysView.java new file mode 100644 index 00000000..be896ab4 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/extrakeys/ExtraKeysView.java @@ -0,0 +1,382 @@ +package com.termux.app.terminal.io.extrakeys; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.provider.Settings; +import android.util.AttributeSet; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.ScheduledExecutorService; + +import java.util.Map; +import java.util.HashMap; +import java.util.Arrays; +import java.util.stream.Collectors; + +import android.view.Gravity; +import android.view.HapticFeedbackConstants; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.GridLayout; +import android.widget.PopupWindow; + +import com.termux.R; +import com.termux.view.TerminalView; + +import androidx.drawerlayout.widget.DrawerLayout; + +/** + * A view showing extra keys (such as Escape, Ctrl, Alt) not normally available on an Android soft + * keyboard. + */ +public final class ExtraKeysView extends GridLayout { + + private static final int TEXT_COLOR = 0xFFFFFFFF; + private static final int BUTTON_COLOR = 0x00000000; + private static final int INTERESTING_COLOR = 0xFF80DEEA; + private static final int BUTTON_PRESSED_COLOR = 0xFF7F7F7F; + + public ExtraKeysView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + static final Map keyCodesForString = new HashMap() {{ + put("SPACE", KeyEvent.KEYCODE_SPACE); + put("ESC", KeyEvent.KEYCODE_ESCAPE); + put("TAB", KeyEvent.KEYCODE_TAB); + put("HOME", KeyEvent.KEYCODE_MOVE_HOME); + put("END", KeyEvent.KEYCODE_MOVE_END); + put("PGUP", KeyEvent.KEYCODE_PAGE_UP); + put("PGDN", KeyEvent.KEYCODE_PAGE_DOWN); + put("INS", KeyEvent.KEYCODE_INSERT); + put("DEL", KeyEvent.KEYCODE_FORWARD_DEL); + put("BKSP", KeyEvent.KEYCODE_DEL); + put("UP", KeyEvent.KEYCODE_DPAD_UP); + put("LEFT", KeyEvent.KEYCODE_DPAD_LEFT); + put("RIGHT", KeyEvent.KEYCODE_DPAD_RIGHT); + put("DOWN", KeyEvent.KEYCODE_DPAD_DOWN); + put("ENTER", KeyEvent.KEYCODE_ENTER); + put("F1", KeyEvent.KEYCODE_F1); + put("F2", KeyEvent.KEYCODE_F2); + put("F3", KeyEvent.KEYCODE_F3); + put("F4", KeyEvent.KEYCODE_F4); + put("F5", KeyEvent.KEYCODE_F5); + put("F6", KeyEvent.KEYCODE_F6); + put("F7", KeyEvent.KEYCODE_F7); + put("F8", KeyEvent.KEYCODE_F8); + put("F9", KeyEvent.KEYCODE_F9); + put("F10", KeyEvent.KEYCODE_F10); + put("F11", KeyEvent.KEYCODE_F11); + put("F12", KeyEvent.KEYCODE_F12); + }}; + + @SuppressLint("RtlHardcoded") + private void sendKey(View view, String keyName, boolean forceCtrlDown, boolean forceLeftAltDown) { + TerminalView terminalView = view.findViewById(R.id.terminal_view); + if ("KEYBOARD".equals(keyName)) { + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.toggleSoftInput(0, 0); + } else if ("DRAWER".equals(keyName)) { + DrawerLayout drawer = view.findViewById(R.id.drawer_layout); + drawer.openDrawer(Gravity.LEFT); + } else if (keyCodesForString.containsKey(keyName)) { + Integer keyCode = keyCodesForString.get(keyName); + if (keyCode == null) return; + int metaState = 0; + if (forceCtrlDown) { + metaState |= KeyEvent.META_CTRL_ON | KeyEvent.META_CTRL_LEFT_ON; + } + if (forceLeftAltDown) { + metaState |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON; + } + KeyEvent keyEvent = new KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState); + terminalView.onKeyDown(keyCode, keyEvent); + } else { + // not a control char + keyName.codePoints().forEach(codePoint -> { + terminalView.inputCodePoint(codePoint, forceCtrlDown, forceLeftAltDown); + }); + } + } + + private void sendKey(View view, ExtraKeyButton buttonInfo) { + if (buttonInfo.isMacro()) { + String[] keys = buttonInfo.getKey().split(" "); + boolean ctrlDown = false; + boolean altDown = false; + for (String key : keys) { + if ("CTRL".equals(key)) { + ctrlDown = true; + } else if ("ALT".equals(key)) { + altDown = true; + } else { + sendKey(view, key, ctrlDown, altDown); + ctrlDown = false; + altDown = false; + } + } + } else { + sendKey(view, buttonInfo.getKey(), false, false); + } + } + + public enum SpecialButton { + CTRL, ALT, FN + } + + private static class SpecialButtonState { + boolean isOn = false; + boolean isActive = false; + List