diff --git a/.gitignore b/.gitignore index 87a44e0..1f1d8a2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,13 @@ proguard/ *.iws .idea/ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.externalNativeBuild + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt new file mode 100644 index 0000000..6c7d660 --- /dev/null +++ b/app/CMakeLists.txt @@ -0,0 +1,7 @@ + +cmake_minimum_required(VERSION 3.4.1) + +add_library(neoterm + SHARED + src/main/cpp/neoterm.cpp) + diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..717e9b8 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,43 @@ +apply plugin: 'com.android.application' + +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 25 + buildToolsVersion "25.0.2" + defaultConfig { + applicationId "io.neoterm" + minSdkVersion 21 + targetSdkVersion 25 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + externalNativeBuild { + cmake { + cppFlags "-std=c++11" + } + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } +} + +dependencies { + compile fileTree(include: ['*.jar'], dir: 'libs') + androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { + exclude group: 'com.android.support', module: 'support-annotations' + }) + compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" + testCompile 'junit:junit:4.12' + compile 'com.android.support.constraint:constraint-layout:1.0.2' + compile project(':chrome-tabs') +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..0a0a5bc --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/kiva/devel/android-sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app/src/androidTest/java/io/neoterm/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/neoterm/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..b183e81 --- /dev/null +++ b/app/src/androidTest/java/io/neoterm/ExampleInstrumentedTest.kt @@ -0,0 +1,25 @@ +package io.neoterm + +import android.support.test.InstrumentationRegistry +import android.support.test.runner.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumentation test, which will execute on an Android device. + * + * @see [Testing documentation](http://d.android.com/tools/testing) + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + @Throws(Exception::class) + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getTargetContext() + assertEquals("io.neoterm", appContext.packageName) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..039d03f --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/cpp/neoterm.cpp b/app/src/main/cpp/neoterm.cpp new file mode 100755 index 0000000..e5ba2e4 --- /dev/null +++ b/app/src/main/cpp/neoterm.cpp @@ -0,0 +1,222 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define TERMUX_UNUSED(x) x __attribute__((__unused__)) +#ifdef __APPLE__ +# define LACKS_PTSNAME_R +#endif + +static int throw_runtime_exception(JNIEnv *env, char const *message) { + jclass exClass = env->FindClass("java/lang/RuntimeException"); + env->ThrowNew(exClass, message); + return -1; +} + +static int create_subprocess(JNIEnv *env, + char const *cmd, + char const *cwd, + char *const argv[], + char **envp, + int *pProcessId, + jint rows, + jint columns) { + int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC); + if (ptm < 0) return throw_runtime_exception(env, "Cannot open /dev/ptmx"); + +#ifdef LACKS_PTSNAME_R + char* devname; +#else + char devname[64]; +#endif + if (grantpt(ptm) || unlockpt(ptm) || + #ifdef LACKS_PTSNAME_R + (devname = ptsname(ptm)) == NULL + #else + ptsname_r(ptm, devname, sizeof(devname)) +#endif + ) { + return throw_runtime_exception(env, "Cannot grantpt()/unlockpt()/ptsname_r() on /dev/ptmx"); + } + + // Enable UTF-8 mode and disable flow control to prevent Ctrl+S from locking up the display. + struct termios tios; + tcgetattr(ptm, &tios); + tios.c_iflag |= IUTF8; + tios.c_iflag &= ~(IXON | IXOFF); + tcsetattr(ptm, TCSANOW, &tios); + + /** Set initial winsize. */ + struct winsize sz = {.ws_row = static_cast(rows), .ws_col = static_cast(columns)}; + ioctl(ptm, TIOCSWINSZ, &sz); + + pid_t pid = fork(); + if (pid < 0) { + return throw_runtime_exception(env, "Fork failed"); + } else if (pid > 0) { + *pProcessId = (int) pid; + return ptm; + } else { + // Clear signals which the Android java process may have blocked: + sigset_t signals_to_unblock; + sigfillset(&signals_to_unblock); + sigprocmask(SIG_UNBLOCK, &signals_to_unblock, 0); + + close(ptm); + setsid(); + + int pts = open(devname, O_RDWR); + if (pts < 0) exit(-1); + + dup2(pts, 0); + dup2(pts, 1); + dup2(pts, 2); + + DIR *self_dir = opendir("/proc/self/fd"); + if (self_dir != NULL) { + int self_dir_fd = dirfd(self_dir); + struct dirent *entry; + while ((entry = readdir(self_dir)) != NULL) { + int fd = atoi(entry->d_name); + if (fd > 2 && fd != self_dir_fd) close(fd); + } + closedir(self_dir); + } + + clearenv(); + if (envp) for (; *envp; ++envp) putenv(*envp); + + if (chdir(cwd) != 0) { + char *error_message; + // No need to free asprintf()-allocated memory since doing execvp() or exit() below. + if (asprintf(&error_message, "chdir(\"%s\")", cwd) == -1) + error_message = + const_cast("chdir()"); + perror(error_message); + fflush(stderr); + } + execvp(cmd, argv); + // Show terminal output about failing exec() call: + char *error_message; + if (asprintf(&error_message, "exec(\"%s\")", cmd) == -1) + const_cast("exec()");; + perror(error_message); + _exit(1); + } +} + +extern "C" JNIEXPORT jint JNICALL Java_io_neoterm_terminal_JNI_createSubprocess( + JNIEnv *env, + jclass TERMUX_UNUSED(clazz), + jstring cmd, + jstring cwd, + jobjectArray args, + jobjectArray envVars, + jintArray processIdArray, + jint rows, + jint columns) { + jsize size = args ? env->GetArrayLength(args) : 0; + char **argv = NULL; + if (size > 0) { + argv = (char **) malloc((size + 1) * sizeof(char *)); + if (!argv) return throw_runtime_exception(env, "Couldn't allocate argv array"); + for (int i = 0; i < size; ++i) { + jstring arg_java_string = (jstring) env->GetObjectArrayElement(args, i); + char const *arg_utf8 = env->GetStringUTFChars(arg_java_string, NULL); + if (!arg_utf8) + return throw_runtime_exception(env, "GetStringUTFChars() failed for argv"); + argv[i] = strdup(arg_utf8); + env->ReleaseStringUTFChars(arg_java_string, arg_utf8); + } + argv[size] = NULL; + } + + size = envVars ? env->GetArrayLength(envVars) : 0; + char **envp = NULL; + if (size > 0) { + envp = (char **) malloc((size + 1) * sizeof(char *)); + if (!envp) return throw_runtime_exception(env, "malloc() for envp array failed"); + for (int i = 0; i < size; ++i) { + jstring env_java_string = (jstring) env->GetObjectArrayElement(envVars, i); + char const *env_utf8 = env->GetStringUTFChars(env_java_string, 0); + if (!env_utf8) + return throw_runtime_exception(env, "GetStringUTFChars() failed for env"); + envp[i] = strdup(env_utf8); + env->ReleaseStringUTFChars(env_java_string, env_utf8); + } + envp[size] = NULL; + } + + int procId = 0; + char const *cmd_cwd = env->GetStringUTFChars(cwd, NULL); + char const *cmd_utf8 = env->GetStringUTFChars(cmd, NULL); + int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns); + env->ReleaseStringUTFChars(cmd, cmd_utf8); + env->ReleaseStringUTFChars(cmd, cmd_cwd); + + if (argv) { + for (char **tmp = argv; *tmp; ++tmp) free(*tmp); + free(argv); + } + if (envp) { + for (char **tmp = envp; *tmp; ++tmp) free(*tmp); + free(envp); + } + + int *pProcId = (int *) env->GetPrimitiveArrayCritical(processIdArray, NULL); + if (!pProcId) + return throw_runtime_exception(env, + "JNI call GetPrimitiveArrayCritical(processIdArray, &isCopy) failed"); + + *pProcId = procId; + env->ReleasePrimitiveArrayCritical(processIdArray, pProcId, 0); + + return ptm; +} + +extern "C" JNIEXPORT void JNICALL +Java_io_neoterm_terminal_JNI_setPtyWindowSize(JNIEnv *TERMUX_UNUSED(env), + jclass TERMUX_UNUSED(clazz), + jint fd, jint rows, + jint cols) { + struct winsize sz = {.ws_row = static_cast(rows), .ws_col = static_cast(cols)}; + ioctl(fd, TIOCSWINSZ, &sz); +} + +extern "C" JNIEXPORT void JNICALL +Java_io_neoterm_terminal_JNI_setPtyUTF8Mode(JNIEnv *TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), + jint fd) { + struct termios tios; + tcgetattr(fd, &tios); + if ((tios.c_iflag & IUTF8) == 0) { + tios.c_iflag |= IUTF8; + tcsetattr(fd, TCSANOW, &tios); + } +} + +extern "C" JNIEXPORT int JNICALL +Java_io_neoterm_terminal_JNI_waitFor(JNIEnv *TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), + jint pid) { + int status; + waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + return WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + return -WTERMSIG(status); + } else { + // Should never happen - waitpid(2) says "One of the first three macros will evaluate to a non-zero (true) value". + return 0; + } +} + +extern "C" JNIEXPORT void JNICALL +Java_io_neoterm_terminal_JNI_close(JNIEnv *TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), + jint fileDescriptor) { + close(fileDescriptor); +} diff --git a/app/src/main/java/io/neoterm/MainActivity.kt b/app/src/main/java/io/neoterm/MainActivity.kt new file mode 100644 index 0000000..9b0c7bc --- /dev/null +++ b/app/src/main/java/io/neoterm/MainActivity.kt @@ -0,0 +1,131 @@ +package io.neoterm + +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.os.Bundle +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.inputmethod.InputMethodManager +import io.neoterm.terminal.TerminalSession +import io.neoterm.view.ExtraKeysView +import io.neoterm.view.TerminalView +import io.neoterm.view.TerminalViewClient + +class MainActivity : Activity() { + private lateinit var extraKeysView: ExtraKeysView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + extraKeysView = findViewById(R.id.extra_keys) as ExtraKeysView + val view = findViewById(R.id.terminal_view) as TerminalView + + view.setBackgroundColor(Color.BLACK) + view.textSize = 30 + view.setTypeface(Typeface.MONOSPACE) + + val session = TerminalSession("/system/bin/sh", "/", + arrayOf("/system/bin/sh"), + arrayOf("TERM=screen", "HOME=" + filesDir), + object : TerminalSession.SessionChangedCallback { + override fun onBell(session: TerminalSession?) { + } + + override fun onClipboardText(session: TerminalSession?, text: String?) { + } + + override fun onColorsChanged(session: TerminalSession?) { + } + + override fun onSessionFinished(finishedSession: TerminalSession?) { + } + + override fun onTextChanged(changedSession: TerminalSession?) { + view.onScreenUpdated() + } + + override fun onTitleChanged(changedSession: TerminalSession?) { + } + }) + + view.setOnKeyListener(object : TerminalViewClient { + internal var mVirtualControlKeyDown: Boolean = false + internal var mVirtualFnKeyDown: Boolean = false + + override fun onScale(scale: Float): Float { + if (scale < 0.9f || scale > 1.1f) { + val increase = scale > 1f + val changedSize = (if (increase) 1 else -1) * 2 + view.textSize = view.textSize + changedSize + return 1.0f + } + return scale + } + + override fun onSingleTapUp(e: MotionEvent?) { + (getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) + .showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) + } + + override fun shouldBackButtonBeMappedToEscape(): Boolean { + return false + } + + override fun copyModeChanged(copyMode: Boolean) { + // TODO + } + + override fun onKeyDown(keyCode: Int, e: KeyEvent?, session: TerminalSession?): Boolean { + // TODO + return false + } + + override fun onKeyUp(keyCode: Int, e: KeyEvent?): Boolean { + return handleVirtualKeys(keyCode, e, false) + } + + override fun readControlKey(): Boolean { + return extraKeysView.readControlButton() || mVirtualControlKeyDown + } + + override fun readAltKey(): Boolean { + return extraKeysView.readAltButton() || mVirtualFnKeyDown + } + + override fun onCodePoint(codePoint: Int, ctrlDown: Boolean, session: TerminalSession?): Boolean { + // TODO + return false + } + + override fun onLongPress(event: MotionEvent?): Boolean { + // TODO + return false + } + + private fun handleVirtualKeys(keyCode: Int, event: KeyEvent?, down: Boolean): Boolean { + if (event == null) { + return false + } + val inputDevice = event.device + if (inputDevice != null && inputDevice.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { + return false + } else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) { + mVirtualControlKeyDown = down + return true + } else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { + mVirtualFnKeyDown = down + return true + } + return false + } + + }) + view.attachSession(session) + } + + +} diff --git a/app/src/main/java/io/neoterm/terminal/ByteQueue.java b/app/src/main/java/io/neoterm/terminal/ByteQueue.java new file mode 100755 index 0000000..b973ac2 --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/ByteQueue.java @@ -0,0 +1,108 @@ +package io.neoterm.terminal; + +/** A circular byte buffer allowing one producer and one consumer thread. */ +final class ByteQueue { + + private final byte[] mBuffer; + private int mHead; + private int mStoredBytes; + private boolean mOpen = true; + + public ByteQueue(int size) { + mBuffer = new byte[size]; + } + + public synchronized void close() { + mOpen = false; + notify(); + } + + public synchronized int read(byte[] buffer, boolean block) { + while (mStoredBytes == 0 && mOpen) { + if (block) { + try { + wait(); + } catch (InterruptedException e) { + // Ignore. + } + } else { + return 0; + } + } + if (!mOpen) return -1; + + int totalRead = 0; + int bufferLength = mBuffer.length; + boolean wasFull = bufferLength == mStoredBytes; + int length = buffer.length; + int offset = 0; + while (length > 0 && mStoredBytes > 0) { + int oneRun = Math.min(bufferLength - mHead, mStoredBytes); + int bytesToCopy = Math.min(length, oneRun); + System.arraycopy(mBuffer, mHead, buffer, offset, bytesToCopy); + mHead += bytesToCopy; + if (mHead >= bufferLength) mHead = 0; + mStoredBytes -= bytesToCopy; + length -= bytesToCopy; + offset += bytesToCopy; + totalRead += bytesToCopy; + } + if (wasFull) notify(); + return totalRead; + } + + /** + * Attempt to write the specified portion of the provided buffer to the queue. + *

+ * Returns whether the output was totally written, false if it was closed before. + */ + public boolean write(byte[] buffer, int offset, int lengthToWrite) { + if (lengthToWrite + offset > buffer.length) { + throw new IllegalArgumentException("length + offset > buffer.length"); + } else if (lengthToWrite <= 0) { + throw new IllegalArgumentException("length <= 0"); + } + + final int bufferLength = mBuffer.length; + + synchronized (this) { + while (lengthToWrite > 0) { + while (bufferLength == mStoredBytes && mOpen) { + try { + wait(); + } catch (InterruptedException e) { + // Ignore. + } + } + if (!mOpen) return false; + final boolean wasEmpty = mStoredBytes == 0; + int bytesToWriteBeforeWaiting = Math.min(lengthToWrite, bufferLength - mStoredBytes); + lengthToWrite -= bytesToWriteBeforeWaiting; + + while (bytesToWriteBeforeWaiting > 0) { + int tail = mHead + mStoredBytes; + int oneRun; + if (tail >= bufferLength) { + // Buffer: [.............] + // ________________H_______T + // => + // Buffer: [.............] + // ___________T____H + // onRun= _____----_ + tail = tail - bufferLength; + oneRun = mHead - tail; + } else { + oneRun = bufferLength - tail; + } + int bytesToCopy = Math.min(oneRun, bytesToWriteBeforeWaiting); + System.arraycopy(buffer, offset, mBuffer, tail, bytesToCopy); + offset += bytesToCopy; + bytesToWriteBeforeWaiting -= bytesToCopy; + mStoredBytes += bytesToCopy; + } + if (wasEmpty) notify(); + } + } + return true; + } +} diff --git a/app/src/main/java/io/neoterm/terminal/EmulatorDebug.java b/app/src/main/java/io/neoterm/terminal/EmulatorDebug.java new file mode 100755 index 0000000..baf1128 --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/EmulatorDebug.java @@ -0,0 +1,10 @@ +package io.neoterm.terminal; + +import android.util.Log; + +public final class EmulatorDebug { + + /** The tag to use with {@link Log}. */ + public static final String LOG_TAG = "neoterm-termux"; + +} diff --git a/app/src/main/java/io/neoterm/terminal/JNI.java b/app/src/main/java/io/neoterm/terminal/JNI.java new file mode 100755 index 0000000..4452661 --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/JNI.java @@ -0,0 +1,41 @@ +package io.neoterm.terminal; + +/** + * Native methods for creating and managing pseudoterminal subprocesses. C code is in jni/termux.c. + */ +final class JNI { + + static { + System.loadLibrary("neoterm"); + } + + /** + * Create a subprocess. Differs from {@link ProcessBuilder} in that a pseudoterminal is used to communicate with the + * subprocess. + *

+ * Callers are responsible for calling {@link #close(int)} on the returned file descriptor. + * + * @param cmd The command to execute + * @param cwd The current working directory for the executed command + * @param args An array of arguments to the command + * @param envVars An array of strings of the form "VAR=value" to be added to the environment of the process + * @param processId A one-element array to which the process ID of the started process will be written. + * @return the file descriptor resulting from opening /dev/ptmx master device. The sub process will have opened the + * slave device counterpart (/dev/pts/$N) and have it as stdint, stdout and stderr. + */ + public static native int createSubprocess(String cmd, String cwd, String[] args, String[] envVars, int[] processId, int rows, int columns); + + /** Set the window size for a given pty, which allows connected programs to learn how large their screen is. */ + public static native void setPtyWindowSize(int fd, int rows, int cols); + + /** + * Causes the calling thread to wait for the process associated with the receiver to finish executing. + * + * @return if >= 0, the exit status of the process. If < 0, the signal causing the process to stop negated. + */ + public static native int waitFor(int processId); + + /** Close a file descriptor through the close(2) system call. */ + public static native void close(int fileDescriptor); + +} diff --git a/app/src/main/java/io/neoterm/terminal/KeyHandler.java b/app/src/main/java/io/neoterm/terminal/KeyHandler.java new file mode 100755 index 0000000..290c83a --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/KeyHandler.java @@ -0,0 +1,313 @@ +package io.neoterm.terminal; + +import java.util.HashMap; +import java.util.Map; + +import static android.view.KeyEvent.KEYCODE_BACK; +import static android.view.KeyEvent.KEYCODE_BREAK; +import static android.view.KeyEvent.KEYCODE_DEL; +import static android.view.KeyEvent.KEYCODE_DPAD_CENTER; +import static android.view.KeyEvent.KEYCODE_DPAD_DOWN; +import static android.view.KeyEvent.KEYCODE_DPAD_LEFT; +import static android.view.KeyEvent.KEYCODE_DPAD_RIGHT; +import static android.view.KeyEvent.KEYCODE_DPAD_UP; +import static android.view.KeyEvent.KEYCODE_ENTER; +import static android.view.KeyEvent.KEYCODE_ESCAPE; +import static android.view.KeyEvent.KEYCODE_F1; +import static android.view.KeyEvent.KEYCODE_F10; +import static android.view.KeyEvent.KEYCODE_F11; +import static android.view.KeyEvent.KEYCODE_F12; +import static android.view.KeyEvent.KEYCODE_F2; +import static android.view.KeyEvent.KEYCODE_F3; +import static android.view.KeyEvent.KEYCODE_F4; +import static android.view.KeyEvent.KEYCODE_F5; +import static android.view.KeyEvent.KEYCODE_F6; +import static android.view.KeyEvent.KEYCODE_F7; +import static android.view.KeyEvent.KEYCODE_F8; +import static android.view.KeyEvent.KEYCODE_F9; +import static android.view.KeyEvent.KEYCODE_FORWARD_DEL; +import static android.view.KeyEvent.KEYCODE_INSERT; +import static android.view.KeyEvent.KEYCODE_MOVE_END; +import static android.view.KeyEvent.KEYCODE_MOVE_HOME; +import static android.view.KeyEvent.KEYCODE_NUMPAD_0; +import static android.view.KeyEvent.KEYCODE_NUMPAD_1; +import static android.view.KeyEvent.KEYCODE_NUMPAD_2; +import static android.view.KeyEvent.KEYCODE_NUMPAD_3; +import static android.view.KeyEvent.KEYCODE_NUMPAD_4; +import static android.view.KeyEvent.KEYCODE_NUMPAD_5; +import static android.view.KeyEvent.KEYCODE_NUMPAD_6; +import static android.view.KeyEvent.KEYCODE_NUMPAD_7; +import static android.view.KeyEvent.KEYCODE_NUMPAD_8; +import static android.view.KeyEvent.KEYCODE_NUMPAD_9; +import static android.view.KeyEvent.KEYCODE_NUMPAD_ADD; +import static android.view.KeyEvent.KEYCODE_NUMPAD_COMMA; +import static android.view.KeyEvent.KEYCODE_NUMPAD_DIVIDE; +import static android.view.KeyEvent.KEYCODE_NUMPAD_DOT; +import static android.view.KeyEvent.KEYCODE_NUMPAD_ENTER; +import static android.view.KeyEvent.KEYCODE_NUMPAD_EQUALS; +import static android.view.KeyEvent.KEYCODE_NUMPAD_MULTIPLY; +import static android.view.KeyEvent.KEYCODE_NUMPAD_SUBTRACT; +import static android.view.KeyEvent.KEYCODE_NUM_LOCK; +import static android.view.KeyEvent.KEYCODE_PAGE_DOWN; +import static android.view.KeyEvent.KEYCODE_PAGE_UP; +import static android.view.KeyEvent.KEYCODE_SPACE; +import static android.view.KeyEvent.KEYCODE_SYSRQ; +import static android.view.KeyEvent.KEYCODE_TAB; + +public final class KeyHandler { + + public static final int KEYMOD_ALT = 0x80000000; + public static final int KEYMOD_CTRL = 0x40000000; + public static final int KEYMOD_SHIFT = 0x20000000; + + private static final Map TERMCAP_TO_KEYCODE = new HashMap<>(); + + static { + // terminfo: http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html + // termcap: http://man7.org/linux/man-pages/man5/termcap.5.html + TERMCAP_TO_KEYCODE.put("%i", KEYMOD_SHIFT | KEYCODE_DPAD_RIGHT); + TERMCAP_TO_KEYCODE.put("#2", KEYMOD_SHIFT | KEYCODE_MOVE_HOME); // Shifted home + TERMCAP_TO_KEYCODE.put("#4", KEYMOD_SHIFT | KEYCODE_DPAD_LEFT); + TERMCAP_TO_KEYCODE.put("*7", KEYMOD_SHIFT | KEYCODE_MOVE_END); // Shifted end key + + TERMCAP_TO_KEYCODE.put("k1", KEYCODE_F1); + TERMCAP_TO_KEYCODE.put("k2", KEYCODE_F2); + TERMCAP_TO_KEYCODE.put("k3", KEYCODE_F3); + TERMCAP_TO_KEYCODE.put("k4", KEYCODE_F4); + TERMCAP_TO_KEYCODE.put("k5", KEYCODE_F5); + TERMCAP_TO_KEYCODE.put("k6", KEYCODE_F6); + TERMCAP_TO_KEYCODE.put("k7", KEYCODE_F7); + TERMCAP_TO_KEYCODE.put("k8", KEYCODE_F8); + TERMCAP_TO_KEYCODE.put("k9", KEYCODE_F9); + TERMCAP_TO_KEYCODE.put("k;", KEYCODE_F10); + TERMCAP_TO_KEYCODE.put("F1", KEYCODE_F11); + TERMCAP_TO_KEYCODE.put("F2", KEYCODE_F12); + TERMCAP_TO_KEYCODE.put("F3", KEYMOD_SHIFT | KEYCODE_F1); + TERMCAP_TO_KEYCODE.put("F4", KEYMOD_SHIFT | KEYCODE_F2); + TERMCAP_TO_KEYCODE.put("F5", KEYMOD_SHIFT | KEYCODE_F3); + TERMCAP_TO_KEYCODE.put("F6", KEYMOD_SHIFT | KEYCODE_F4); + TERMCAP_TO_KEYCODE.put("F7", KEYMOD_SHIFT | KEYCODE_F5); + TERMCAP_TO_KEYCODE.put("F8", KEYMOD_SHIFT | KEYCODE_F6); + TERMCAP_TO_KEYCODE.put("F9", KEYMOD_SHIFT | KEYCODE_F7); + TERMCAP_TO_KEYCODE.put("FA", KEYMOD_SHIFT | KEYCODE_F8); + TERMCAP_TO_KEYCODE.put("FB", KEYMOD_SHIFT | KEYCODE_F9); + TERMCAP_TO_KEYCODE.put("FC", KEYMOD_SHIFT | KEYCODE_F10); + TERMCAP_TO_KEYCODE.put("FD", KEYMOD_SHIFT | KEYCODE_F11); + TERMCAP_TO_KEYCODE.put("FE", KEYMOD_SHIFT | KEYCODE_F12); + + TERMCAP_TO_KEYCODE.put("kb", KEYCODE_DEL); // backspace key + + TERMCAP_TO_KEYCODE.put("kd", KEYCODE_DPAD_DOWN); // terminfo=kcud1, down-arrow key + TERMCAP_TO_KEYCODE.put("kh", KEYCODE_MOVE_HOME); + TERMCAP_TO_KEYCODE.put("kl", KEYCODE_DPAD_LEFT); + TERMCAP_TO_KEYCODE.put("kr", KEYCODE_DPAD_RIGHT); + + // K1=Upper left of keypad: + // t_K1 keypad home key + // t_K3 keypad page-up key + // t_K4 keypad end key + // t_K5 keypad page-down key + TERMCAP_TO_KEYCODE.put("K1", KEYCODE_MOVE_HOME); + TERMCAP_TO_KEYCODE.put("K3", KEYCODE_PAGE_UP); + TERMCAP_TO_KEYCODE.put("K4", KEYCODE_MOVE_END); + TERMCAP_TO_KEYCODE.put("K5", KEYCODE_PAGE_DOWN); + + TERMCAP_TO_KEYCODE.put("ku", KEYCODE_DPAD_UP); + + TERMCAP_TO_KEYCODE.put("kB", KEYMOD_SHIFT | KEYCODE_TAB); // termcap=kB, terminfo=kcbt: Back-tab_switcher + TERMCAP_TO_KEYCODE.put("kD", KEYCODE_FORWARD_DEL); // terminfo=kdch1, delete-character key + TERMCAP_TO_KEYCODE.put("kDN", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // non-standard shifted arrow down + TERMCAP_TO_KEYCODE.put("kF", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // terminfo=kind, scroll-forward key + TERMCAP_TO_KEYCODE.put("kI", KEYCODE_INSERT); + TERMCAP_TO_KEYCODE.put("kN", KEYCODE_PAGE_UP); + TERMCAP_TO_KEYCODE.put("kP", KEYCODE_PAGE_DOWN); + TERMCAP_TO_KEYCODE.put("kR", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // terminfo=kri, scroll-backward key + TERMCAP_TO_KEYCODE.put("kUP", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // non-standard shifted up + + TERMCAP_TO_KEYCODE.put("@7", KEYCODE_MOVE_END); + TERMCAP_TO_KEYCODE.put("@8", KEYCODE_NUMPAD_ENTER); + } + + static String getCodeFromTermcap(String termcap, boolean cursorKeysApplication, boolean keypadApplication) { + Integer keyCodeAndMod = TERMCAP_TO_KEYCODE.get(termcap); + if (keyCodeAndMod == null) return null; + int keyCode = keyCodeAndMod; + int keyMod = 0; + if ((keyCode & KEYMOD_SHIFT) != 0) { + keyMod |= KEYMOD_SHIFT; + keyCode &= ~KEYMOD_SHIFT; + } + if ((keyCode & KEYMOD_CTRL) != 0) { + keyMod |= KEYMOD_CTRL; + keyCode &= ~KEYMOD_CTRL; + } + if ((keyCode & KEYMOD_ALT) != 0) { + keyMod |= KEYMOD_ALT; + keyCode &= ~KEYMOD_ALT; + } + return getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication); + } + + public static String getCode(int keyCode, int keyMode, boolean cursorApp, boolean keypadApplication) { + switch (keyCode) { + case KEYCODE_DPAD_CENTER: + return "\015"; + + case KEYCODE_DPAD_UP: + return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A'); + case KEYCODE_DPAD_DOWN: + return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B'); + case KEYCODE_DPAD_RIGHT: + return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C'); + case KEYCODE_DPAD_LEFT: + return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D'); + + case KEYCODE_MOVE_HOME: + // Note that KEYCODE_HOME is handled by the system and never delivered to applications. + // On a Logitech k810 keyboard KEYCODE_MOVE_HOME is sent by FN+LeftArrow. + return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H'); + case KEYCODE_MOVE_END: + return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F'); + + // An xterm can send function keys F1 to F4 in two modes: vt100 compatible or + // not. Because Vim may not know what the xterm is sending, both types of keys + // are recognized. The same happens for the and keys. + // normal vt100 ~ + // t_k1 [11~ OP *-xterm* + // t_k2 [12~ OQ *-xterm* + // t_k3 [13~ OR *-xterm* + // t_k4 [14~ OS *-xterm* + // t_kh [7~ OH *-xterm* + // t_@7 [4~ OF *-xterm* + case KEYCODE_F1: + return (keyMode == 0) ? "\033OP" : transformForModifiers("\033[1", keyMode, 'P'); + case KEYCODE_F2: + return (keyMode == 0) ? "\033OQ" : transformForModifiers("\033[1", keyMode, 'Q'); + case KEYCODE_F3: + return (keyMode == 0) ? "\033OR" : transformForModifiers("\033[1", keyMode, 'R'); + case KEYCODE_F4: + return (keyMode == 0) ? "\033OS" : transformForModifiers("\033[1", keyMode, 'S'); + case KEYCODE_F5: + return transformForModifiers("\033[15", keyMode, '~'); + case KEYCODE_F6: + return transformForModifiers("\033[17", keyMode, '~'); + case KEYCODE_F7: + return transformForModifiers("\033[18", keyMode, '~'); + case KEYCODE_F8: + return transformForModifiers("\033[19", keyMode, '~'); + case KEYCODE_F9: + return transformForModifiers("\033[20", keyMode, '~'); + case KEYCODE_F10: + return transformForModifiers("\033[21", keyMode, '~'); + case KEYCODE_F11: + return transformForModifiers("\033[23", keyMode, '~'); + case KEYCODE_F12: + return transformForModifiers("\033[24", keyMode, '~'); + + case KEYCODE_SYSRQ: + return "\033[32~"; // Sys Request / Print + // Is this Scroll lock? case Cancel: return "\033[33~"; + case KEYCODE_BREAK: + return "\033[34~"; // Pause/Break + + case KEYCODE_ESCAPE: + case KEYCODE_BACK: + return "\033"; + + case KEYCODE_INSERT: + return transformForModifiers("\033[2", keyMode, '~'); + case KEYCODE_FORWARD_DEL: + return transformForModifiers("\033[3", keyMode, '~'); + + case KEYCODE_PAGE_UP: + return "\033[5~"; + case KEYCODE_PAGE_DOWN: + return "\033[6~"; + case KEYCODE_DEL: + String prefix = ((keyMode & KEYMOD_ALT) == 0) ? "" : "\033"; + // Just do what xterm and gnome-terminal does: + return prefix + (((keyMode & KEYMOD_CTRL) == 0) ? "\u007F" : "\u0008"); + case KEYCODE_NUM_LOCK: + return "\033OP"; + + case KEYCODE_SPACE: + // If ctrl is not down, return null so that it goes through normal input processing (which may e.g. cause a + // combining accent to be written): + return ((keyMode & KEYMOD_CTRL) == 0) ? null : "\0"; + case KEYCODE_TAB: + // This is back-tab_switcher when shifted: + return (keyMode & KEYMOD_SHIFT) == 0 ? "\011" : "\033[Z"; + case KEYCODE_ENTER: + return ((keyMode & KEYMOD_ALT) == 0) ? "\r" : "\033\r"; + + case KEYCODE_NUMPAD_ENTER: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'M') : "\n"; + case KEYCODE_NUMPAD_MULTIPLY: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'j') : "*"; + case KEYCODE_NUMPAD_ADD: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'k') : "+"; + case KEYCODE_NUMPAD_COMMA: + return ","; + case KEYCODE_NUMPAD_DOT: + return keypadApplication ? "\033On" : "."; + case KEYCODE_NUMPAD_SUBTRACT: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'm') : "-"; + case KEYCODE_NUMPAD_DIVIDE: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'o') : "/"; + case KEYCODE_NUMPAD_0: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'p') : "0"; + case KEYCODE_NUMPAD_1: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'q') : "1"; + case KEYCODE_NUMPAD_2: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'r') : "2"; + case KEYCODE_NUMPAD_3: + return keypadApplication ? transformForModifiers("\033O", keyMode, 's') : "3"; + case KEYCODE_NUMPAD_4: + return keypadApplication ? transformForModifiers("\033O", keyMode, 't') : "4"; + case KEYCODE_NUMPAD_5: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'u') : "5"; + case KEYCODE_NUMPAD_6: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'v') : "6"; + case KEYCODE_NUMPAD_7: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'w') : "7"; + case KEYCODE_NUMPAD_8: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'x') : "8"; + case KEYCODE_NUMPAD_9: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'y') : "9"; + case KEYCODE_NUMPAD_EQUALS: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'X') : "="; + } + + return null; + } + + private static String transformForModifiers(String start, int keymod, char lastChar) { + int modifier; + switch (keymod) { + case KEYMOD_SHIFT: + modifier = 2; + break; + case KEYMOD_ALT: + modifier = 3; + break; + case (KEYMOD_SHIFT | KEYMOD_ALT): + modifier = 4; + break; + case KEYMOD_CTRL: + modifier = 5; + break; + case KEYMOD_SHIFT | KEYMOD_CTRL: + modifier = 6; + break; + case KEYMOD_ALT | KEYMOD_CTRL: + modifier = 7; + break; + case KEYMOD_SHIFT | KEYMOD_ALT | KEYMOD_CTRL: + modifier = 8; + break; + default: + return start + lastChar; + } + return start + (";" + modifier) + lastChar; + } +} diff --git a/app/src/main/java/io/neoterm/terminal/TerminalBuffer.java b/app/src/main/java/io/neoterm/terminal/TerminalBuffer.java new file mode 100755 index 0000000..eaa11c7 --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/TerminalBuffer.java @@ -0,0 +1,425 @@ +package io.neoterm.terminal; + +/** + * A circular buffer of {@link TerminalRow}:s which keeps notes about what is visible on a logical screen and the scroll + * history. + *

+ * See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices. + */ +public final class TerminalBuffer { + + TerminalRow[] mLines; + /** The length of {@link #mLines}. */ + int mTotalRows; + /** The number of rows and columns visible on the screen. */ + int mScreenRows, mColumns; + /** The number of rows kept in history. */ + private int mActiveTranscriptRows = 0; + /** The index in the circular buffer where the visible screen starts. */ + private int mScreenFirstRow = 0; + + /** + * Create a transcript screen. + * + * @param columns the width of the screen in characters. + * @param totalRows the height of the entire text area, in rows of text. + * @param screenRows the height of just the screen, not including the transcript that holds lines that have scrolled off + * the top of the screen. + */ + public TerminalBuffer(int columns, int totalRows, int screenRows) { + mColumns = columns; + mTotalRows = totalRows; + mScreenRows = screenRows; + mLines = new TerminalRow[totalRows]; + + blockSet(0, 0, columns, screenRows, ' ', TextStyle.NORMAL); + } + + public String getTranscriptText() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows).trim(); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2) { + final StringBuilder builder = new StringBuilder(); + final int columns = mColumns; + + if (selY1 < -getActiveTranscriptRows()) selY1 = -getActiveTranscriptRows(); + if (selY2 >= mScreenRows) selY2 = mScreenRows - 1; + + for (int row = selY1; row <= selY2; row++) { + int x1 = (row == selY1) ? selX1 : 0; + int x2; + if (row == selY2) { + x2 = selX2 + 1; + if (x2 > columns) x2 = columns; + } else { + x2 = columns; + } + TerminalRow lineObject = mLines[externalToInternalRow(row)]; + int x1Index = lineObject.findStartOfColumn(x1); + int x2Index = (x2 < mColumns) ? lineObject.findStartOfColumn(x2) : lineObject.getSpaceUsed(); + if (x2Index == x1Index) { + // Selected the start of a wide character. + x2Index = lineObject.findStartOfColumn(x2 + 1); + } + char[] line = lineObject.mText; + int lastPrintingCharIndex = -1; + int i; + boolean rowLineWrap = getLineWrap(row); + if (rowLineWrap && x2 == columns) { + // If the line was wrapped, we shouldn't lose trailing space: + lastPrintingCharIndex = x2Index - 1; + } else { + for (i = x1Index; i < x2Index; ++i) { + char c = line[i]; + if (c != ' ') lastPrintingCharIndex = i; + } + } + if (lastPrintingCharIndex != -1) + builder.append(line, x1Index, lastPrintingCharIndex - x1Index + 1); + if (!rowLineWrap && row < selY2 && row < mScreenRows - 1) builder.append('\n'); + } + return builder.toString(); + } + + public int getActiveTranscriptRows() { + return mActiveTranscriptRows; + } + + public int getActiveRows() { + return mActiveTranscriptRows + mScreenRows; + } + + /** + * Convert a row value from the public external coordinate system to our internal private coordinate system. + * + *

+     * - External coordinate system: -mActiveTranscriptRows to mScreenRows-1, with the screen being 0..mScreenRows-1.
+     * - Internal coordinate system: the mScreenRows lines starting at mScreenFirstRow comprise the screen, while the
+     *   mActiveTranscriptRows lines ending at mScreenFirstRow-1 form the transcript (as a circular buffer).
+     *
+     * External ↔ Internal:
+     *
+     * [ ...                            ]     [ ...                                     ]
+     * [ -mActiveTranscriptRows         ]     [ mScreenFirstRow - mActiveTranscriptRows ]
+     * [ ...                            ]     [ ...                                     ]
+     * [ 0 (visible screen starts here) ]  ↔  [ mScreenFirstRow                         ]
+     * [ ...                            ]     [ ...                                     ]
+     * [ mScreenRows-1                  ]     [ mScreenFirstRow + mScreenRows-1         ]
+     * 
+ * + * @param externalRow a row in the external coordinate system. + * @return The row corresponding to the input argument in the private coordinate system. + */ + public int externalToInternalRow(int externalRow) { + if (externalRow < -mActiveTranscriptRows || externalRow > mScreenRows) + throw new IllegalArgumentException("extRow=" + externalRow + ", mScreenRows=" + mScreenRows + ", mActiveTranscriptRows=" + mActiveTranscriptRows); + final int internalRow = mScreenFirstRow + externalRow; + return (internalRow < 0) ? (mTotalRows + internalRow) : (internalRow % mTotalRows); + } + + public void setLineWrap(int row) { + mLines[externalToInternalRow(row)].mLineWrap = true; + } + + public boolean getLineWrap(int row) { + return mLines[externalToInternalRow(row)].mLineWrap; + } + + public void clearLineWrap(int row) { + mLines[externalToInternalRow(row)].mLineWrap = false; + } + + /** + * Resize the screen which this transcript backs. Currently, this only works if the number of columns does not + * change or the rows expand (that is, it only works when shrinking the number of rows). + * + * @param newColumns The number of columns the screen should have. + * @param newRows The number of rows the screen should have. + * @param cursor An int[2] containing the (column, row) cursor location. + */ + public void resize(int newColumns, int newRows, int newTotalRows, int[] cursor, long currentStyle, boolean altScreen) { + // newRows > mTotalRows should not normally happen since mTotalRows is TRANSCRIPT_ROWS (10000): + if (newColumns == mColumns && newRows <= mTotalRows) { + // Fast resize where just the rows changed. + int shiftDownOfTopRow = mScreenRows - newRows; + if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < mScreenRows) { + // Shrinking. Check if we can skip blank rows at bottom below cursor. + for (int i = mScreenRows - 1; i > 0; i--) { + if (cursor[1] >= i) break; + int r = externalToInternalRow(i); + if (mLines[r] == null || mLines[r].isBlank()) { + if (--shiftDownOfTopRow == 0) break; + } + } + } else if (shiftDownOfTopRow < 0) { + // Negative shift down = expanding. Only move screen up if there is transcript to show: + int actualShift = Math.max(shiftDownOfTopRow, -mActiveTranscriptRows); + if (shiftDownOfTopRow != actualShift) { + // The new lines revealed by the resizing are not all from the transcript. Blank the below ones. + for (int i = 0; i < actualShift - shiftDownOfTopRow; i++) + allocateFullLineIfNecessary((mScreenFirstRow + mScreenRows + i) % mTotalRows).clear(currentStyle); + shiftDownOfTopRow = actualShift; + } + } + mScreenFirstRow += shiftDownOfTopRow; + mScreenFirstRow = (mScreenFirstRow < 0) ? (mScreenFirstRow + mTotalRows) : (mScreenFirstRow % mTotalRows); + mTotalRows = newTotalRows; + mActiveTranscriptRows = altScreen ? 0 : Math.max(0, mActiveTranscriptRows + shiftDownOfTopRow); + cursor[1] -= shiftDownOfTopRow; + mScreenRows = newRows; + } else { + // Copy away old state and update new: + TerminalRow[] oldLines = mLines; + mLines = new TerminalRow[newTotalRows]; + for (int i = 0; i < newTotalRows; i++) + mLines[i] = new TerminalRow(newColumns, currentStyle); + + final int oldActiveTranscriptRows = mActiveTranscriptRows; + final int oldScreenFirstRow = mScreenFirstRow; + final int oldScreenRows = mScreenRows; + final int oldTotalRows = mTotalRows; + mTotalRows = newTotalRows; + mScreenRows = newRows; + mActiveTranscriptRows = mScreenFirstRow = 0; + mColumns = newColumns; + + int newCursorRow = -1; + int newCursorColumn = -1; + int oldCursorRow = cursor[1]; + int oldCursorColumn = cursor[0]; + boolean newCursorPlaced = false; + + int currentOutputExternalRow = 0; + int currentOutputExternalColumn = 0; + + // Loop over every character in the initial state. + // Blank lines should be skipped only if at end of transcript (just as is done in the "fast" resize), so we + // keep track how many blank lines we have skipped if we later on find a non-blank line. + int skippedBlankLines = 0; + for (int externalOldRow = -oldActiveTranscriptRows; externalOldRow < oldScreenRows; externalOldRow++) { + // Do what externalToInternalRow() does but for the old state: + int internalOldRow = oldScreenFirstRow + externalOldRow; + internalOldRow = (internalOldRow < 0) ? (oldTotalRows + internalOldRow) : (internalOldRow % oldTotalRows); + + TerminalRow oldLine = oldLines[internalOldRow]; + boolean cursorAtThisRow = externalOldRow == oldCursorRow; + // The cursor may only be on a non-null line, which we should not skip: + if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) { + skippedBlankLines++; + continue; + } else if (skippedBlankLines > 0) { + // After skipping some blank lines we encounter a non-blank line. Insert the skipped blank lines. + for (int i = 0; i < skippedBlankLines; i++) { + if (currentOutputExternalRow == mScreenRows - 1) { + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + skippedBlankLines = 0; + } + + int lastNonSpaceIndex = 0; + boolean justToCursor = false; + if (cursorAtThisRow || oldLine.mLineWrap) { + // Take the whole line, either because of cursor on it, or if line wrapping. + lastNonSpaceIndex = oldLine.getSpaceUsed(); + if (cursorAtThisRow) justToCursor = true; + } else { + for (int i = 0; i < oldLine.getSpaceUsed(); i++) + // NEWLY INTRODUCED BUG! Should not index oldLine.mStyle with char indices + if (oldLine.mText[i] != ' '/* || oldLine.mStyle[i] != currentStyle */) + lastNonSpaceIndex = i + 1; + } + + int currentOldCol = 0; + long styleAtCol = 0; + for (int i = 0; i < lastNonSpaceIndex; i++) { + // Note that looping over java character, not cells. + char c = oldLine.mText[i]; + int codePoint = (Character.isHighSurrogate(c)) ? Character.toCodePoint(c, oldLine.mText[++i]) : c; + int displayWidth = WcWidth.width(codePoint); + // Use the last style if this is a zero-width character: + if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol); + + // Line wrap as necessary: + if (currentOutputExternalColumn + displayWidth > mColumns) { + setLineWrap(currentOutputExternalRow); + if (currentOutputExternalRow == mScreenRows - 1) { + if (newCursorPlaced) newCursorRow--; + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + + int offsetDueToCombiningChar = ((displayWidth <= 0 && currentOutputExternalColumn > 0) ? 1 : 0); + int outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar; + setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol); + + if (displayWidth > 0) { + if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) { + newCursorColumn = currentOutputExternalColumn; + newCursorRow = currentOutputExternalRow; + newCursorPlaced = true; + } + currentOldCol += displayWidth; + currentOutputExternalColumn += displayWidth; + if (justToCursor && newCursorPlaced) break; + } + } + // Old row has been copied. Check if we need to insert newline if old line was not wrapping: + if (externalOldRow != (oldScreenRows - 1) && !oldLine.mLineWrap) { + if (currentOutputExternalRow == mScreenRows - 1) { + if (newCursorPlaced) newCursorRow--; + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + } + + cursor[0] = newCursorColumn; + cursor[1] = newCursorRow; + } + + // Handle cursor scrolling off screen: + if (cursor[0] < 0 || cursor[1] < 0) cursor[0] = cursor[1] = 0; + } + + /** + * Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound + * into account. + * + * @param srcInternal The first line to be copied. + * @param len The number of lines to be copied. + */ + private void blockCopyLinesDown(int srcInternal, int len) { + if (len == 0) return; + int totalRows = mTotalRows; + + int start = len - 1; + // Save away line to be overwritten: + TerminalRow lineToBeOverWritten = mLines[(srcInternal + start + 1) % totalRows]; + // Do the copy from bottom to top. + for (int i = start; i >= 0; --i) + mLines[(srcInternal + i + 1) % totalRows] = mLines[(srcInternal + i) % totalRows]; + // Put back overwritten line, now above the block: + mLines[(srcInternal) % totalRows] = lineToBeOverWritten; + } + + /** + * Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24). + * + * @param topMargin First line that is scrolled. + * @param bottomMargin One line after the last line that is scrolled. + * @param style the style for the newly exposed line. + */ + public void scrollDownOneLine(int topMargin, int bottomMargin, long style) { + if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > mScreenRows) + throw new IllegalArgumentException("topMargin=" + topMargin + ", bottomMargin=" + bottomMargin + ", mScreenRows=" + mScreenRows); + + // Copy the fixed topMargin lines one line down so that they remain on screen in same position: + blockCopyLinesDown(mScreenFirstRow, topMargin); + // Copy the fixed mScreenRows-bottomMargin lines one line down so that they remain on screen in same + // position: + blockCopyLinesDown(externalToInternalRow(bottomMargin), mScreenRows - bottomMargin); + + // Update the screen location in the ring buffer: + mScreenFirstRow = (mScreenFirstRow + 1) % mTotalRows; + // Note that the history has grown if not already full: + if (mActiveTranscriptRows < mTotalRows - mScreenRows) mActiveTranscriptRows++; + + // Blank the newly revealed line above the bottom margin: + int blankRow = externalToInternalRow(bottomMargin - 1); + if (mLines[blankRow] == null) { + mLines[blankRow] = new TerminalRow(mColumns, style); + } else { + mLines[blankRow].clear(style); + } + } + + /** + * Block copy characters from one position in the screen to another. The two positions can overlap. All characters + * of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will + * be thrown. + * + * @param sx source X coordinate + * @param sy source Y coordinate + * @param w width + * @param h height + * @param dx destination X coordinate + * @param dy destination Y coordinate + */ + public void blockCopy(int sx, int sy, int w, int h, int dx, int dy) { + if (w == 0) return; + if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows || dx < 0 || dx + w > mColumns || dy < 0 || dy + h > mScreenRows) + throw new IllegalArgumentException(); + boolean copyingUp = sy > dy; + for (int y = 0; y < h; y++) { + int y2 = copyingUp ? y : (h - (y + 1)); + TerminalRow sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2)); + allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx); + } + } + + /** + * Block set characters. All characters must be within the bounds of the screen, or else and + * InvalidParemeterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block + * of characters. + */ + public void blockSet(int sx, int sy, int w, int h, int val, long style) { + if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows) { + throw new IllegalArgumentException( + "Illegal arguments! blockSet(" + sx + ", " + sy + ", " + w + ", " + h + ", " + val + ", " + mColumns + ", " + mScreenRows + ")"); + } + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + setChar(sx + x, sy + y, val, style); + } + + public TerminalRow allocateFullLineIfNecessary(int row) { + return (mLines[row] == null) ? (mLines[row] = new TerminalRow(mColumns, 0)) : mLines[row]; + } + + public void setChar(int column, int row, int codePoint, long style) { + if (row >= mScreenRows || column >= mColumns) + throw new IllegalArgumentException("row=" + row + ", column=" + column + ", mScreenRows=" + mScreenRows + ", mColumns=" + mColumns); + row = externalToInternalRow(row); + allocateFullLineIfNecessary(row).setChar(column, codePoint, style); + } + + public long getStyleAt(int externalRow, int column) { + return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column); + } + + /** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */ + public void setOrClearEffect(int bits, boolean setOrClear, boolean reverse, boolean rectangular, int leftMargin, int rightMargin, int top, int left, + int bottom, int right) { + for (int y = top; y < bottom; y++) { + TerminalRow line = mLines[externalToInternalRow(y)]; + int startOfLine = (rectangular || y == top) ? left : leftMargin; + int endOfLine = (rectangular || y + 1 == bottom) ? right : rightMargin; + for (int x = startOfLine; x < endOfLine; x++) { + long currentStyle = line.getStyle(x); + int foreColor = TextStyle.decodeForeColor(currentStyle); + int backColor = TextStyle.decodeBackColor(currentStyle); + int effect = TextStyle.decodeEffect(currentStyle); + if (reverse) { + // Clear out the bits to reverse and add them back in reversed: + effect = (effect & ~bits) | (bits & ~effect); + } else if (setOrClear) { + effect |= bits; + } else { + effect &= ~bits; + } + line.mStyle[x] = TextStyle.encode(foreColor, backColor, effect); + } + } + } + +} diff --git a/app/src/main/java/io/neoterm/terminal/TerminalColorScheme.java b/app/src/main/java/io/neoterm/terminal/TerminalColorScheme.java new file mode 100755 index 0000000..881015a --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/TerminalColorScheme.java @@ -0,0 +1,103 @@ +package io.neoterm.terminal; + +import java.util.Map; +import java.util.Properties; + +/** + * Color scheme for a terminal with default colors, which may be overridden (and then reset) from the shell using + * Operating System Control (OSC) sequences. + * + * @see TerminalColors + */ +public final class TerminalColorScheme { + + /** http://upload.wikimedia.org/wikipedia/en/1/15/Xterm_256color_chart.svg, but with blue color brighter. */ + private static final int[] DEFAULT_COLORSCHEME = { + // 16 original colors. First 8 are dim. + 0xff000000, // black + 0xffcd0000, // dim red + 0xff00cd00, // dim green + 0xffcdcd00, // dim yellow + 0xff6495ed, // dim blue + 0xffcd00cd, // dim magenta + 0xff00cdcd, // dim cyan + 0xffe5e5e5, // dim white + // Second 8 are bright: + 0xff7f7f7f, // medium grey + 0xffff0000, // bright red + 0xff00ff00, // bright green + 0xffffff00, // bright yellow + 0xff5c5cff, // light blue + 0xffff00ff, // bright magenta + 0xff00ffff, // bright cyan + 0xffffffff, // bright white + + // 216 color cube, six shades of each color: + 0xff000000, 0xff00005f, 0xff000087, 0xff0000af, 0xff0000d7, 0xff0000ff, 0xff005f00, 0xff005f5f, 0xff005f87, 0xff005faf, 0xff005fd7, 0xff005fff, + 0xff008700, 0xff00875f, 0xff008787, 0xff0087af, 0xff0087d7, 0xff0087ff, 0xff00af00, 0xff00af5f, 0xff00af87, 0xff00afaf, 0xff00afd7, 0xff00afff, + 0xff00d700, 0xff00d75f, 0xff00d787, 0xff00d7af, 0xff00d7d7, 0xff00d7ff, 0xff00ff00, 0xff00ff5f, 0xff00ff87, 0xff00ffaf, 0xff00ffd7, 0xff00ffff, + 0xff5f0000, 0xff5f005f, 0xff5f0087, 0xff5f00af, 0xff5f00d7, 0xff5f00ff, 0xff5f5f00, 0xff5f5f5f, 0xff5f5f87, 0xff5f5faf, 0xff5f5fd7, 0xff5f5fff, + 0xff5f8700, 0xff5f875f, 0xff5f8787, 0xff5f87af, 0xff5f87d7, 0xff5f87ff, 0xff5faf00, 0xff5faf5f, 0xff5faf87, 0xff5fafaf, 0xff5fafd7, 0xff5fafff, + 0xff5fd700, 0xff5fd75f, 0xff5fd787, 0xff5fd7af, 0xff5fd7d7, 0xff5fd7ff, 0xff5fff00, 0xff5fff5f, 0xff5fff87, 0xff5fffaf, 0xff5fffd7, 0xff5fffff, + 0xff870000, 0xff87005f, 0xff870087, 0xff8700af, 0xff8700d7, 0xff8700ff, 0xff875f00, 0xff875f5f, 0xff875f87, 0xff875faf, 0xff875fd7, 0xff875fff, + 0xff878700, 0xff87875f, 0xff878787, 0xff8787af, 0xff8787d7, 0xff8787ff, 0xff87af00, 0xff87af5f, 0xff87af87, 0xff87afaf, 0xff87afd7, 0xff87afff, + 0xff87d700, 0xff87d75f, 0xff87d787, 0xff87d7af, 0xff87d7d7, 0xff87d7ff, 0xff87ff00, 0xff87ff5f, 0xff87ff87, 0xff87ffaf, 0xff87ffd7, 0xff87ffff, + 0xffaf0000, 0xffaf005f, 0xffaf0087, 0xffaf00af, 0xffaf00d7, 0xffaf00ff, 0xffaf5f00, 0xffaf5f5f, 0xffaf5f87, 0xffaf5faf, 0xffaf5fd7, 0xffaf5fff, + 0xffaf8700, 0xffaf875f, 0xffaf8787, 0xffaf87af, 0xffaf87d7, 0xffaf87ff, 0xffafaf00, 0xffafaf5f, 0xffafaf87, 0xffafafaf, 0xffafafd7, 0xffafafff, + 0xffafd700, 0xffafd75f, 0xffafd787, 0xffafd7af, 0xffafd7d7, 0xffafd7ff, 0xffafff00, 0xffafff5f, 0xffafff87, 0xffafffaf, 0xffafffd7, 0xffafffff, + 0xffd70000, 0xffd7005f, 0xffd70087, 0xffd700af, 0xffd700d7, 0xffd700ff, 0xffd75f00, 0xffd75f5f, 0xffd75f87, 0xffd75faf, 0xffd75fd7, 0xffd75fff, + 0xffd78700, 0xffd7875f, 0xffd78787, 0xffd787af, 0xffd787d7, 0xffd787ff, 0xffd7af00, 0xffd7af5f, 0xffd7af87, 0xffd7afaf, 0xffd7afd7, 0xffd7afff, + 0xffd7d700, 0xffd7d75f, 0xffd7d787, 0xffd7d7af, 0xffd7d7d7, 0xffd7d7ff, 0xffd7ff00, 0xffd7ff5f, 0xffd7ff87, 0xffd7ffaf, 0xffd7ffd7, 0xffd7ffff, + 0xffff0000, 0xffff005f, 0xffff0087, 0xffff00af, 0xffff00d7, 0xffff00ff, 0xffff5f00, 0xffff5f5f, 0xffff5f87, 0xffff5faf, 0xffff5fd7, 0xffff5fff, + 0xffff8700, 0xffff875f, 0xffff8787, 0xffff87af, 0xffff87d7, 0xffff87ff, 0xffffaf00, 0xffffaf5f, 0xffffaf87, 0xffffafaf, 0xffffafd7, 0xffffafff, + 0xffffd700, 0xffffd75f, 0xffffd787, 0xffffd7af, 0xffffd7d7, 0xffffd7ff, 0xffffff00, 0xffffff5f, 0xffffff87, 0xffffffaf, 0xffffffd7, 0xffffffff, + + // 24 grey scale ramp: + 0xff080808, 0xff121212, 0xff1c1c1c, 0xff262626, 0xff303030, 0xff3a3a3a, 0xff444444, 0xff4e4e4e, 0xff585858, 0xff626262, 0xff6c6c6c, 0xff767676, + 0xff808080, 0xff8a8a8a, 0xff949494, 0xff9e9e9e, 0xffa8a8a8, 0xffb2b2b2, 0xffbcbcbc, 0xffc6c6c6, 0xffd0d0d0, 0xffdadada, 0xffe4e4e4, 0xffeeeeee, + + // COLOR_INDEX_DEFAULT_FOREGROUND, COLOR_INDEX_DEFAULT_BACKGROUND and COLOR_INDEX_DEFAULT_CURSOR: + 0xffffffff, 0xff000000, 0xffA9AAA9}; + + public final int[] mDefaultColors = new int[TextStyle.NUM_INDEXED_COLORS]; + + public TerminalColorScheme() { + reset(); + } + + private void reset() { + System.arraycopy(DEFAULT_COLORSCHEME, 0, mDefaultColors, 0, TextStyle.NUM_INDEXED_COLORS); + } + + public void updateWith(Properties props) { + reset(); + for (Map.Entry entries : props.entrySet()) { + String key = (String) entries.getKey(); + String value = (String) entries.getValue(); + int colorIndex; + + if (key.equals("foreground")) { + colorIndex = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (key.equals("background")) { + colorIndex = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (key.equals("cursor")) { + colorIndex = TextStyle.COLOR_INDEX_CURSOR; + } else if (key.startsWith("color")) { + try { + colorIndex = Integer.parseInt(key.substring(5)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid property: '" + key + "'"); + } + } else { + throw new IllegalArgumentException("Invalid property: '" + key + "'"); + } + + int colorValue = TerminalColors.parse(value); + if (colorValue == 0) + throw new IllegalArgumentException("Property '" + key + "' has invalid color: '" + value + "'"); + + mDefaultColors[colorIndex] = colorValue; + } + } + +} diff --git a/app/src/main/java/io/neoterm/terminal/TerminalColors.java b/app/src/main/java/io/neoterm/terminal/TerminalColors.java new file mode 100755 index 0000000..af8366e --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/TerminalColors.java @@ -0,0 +1,76 @@ +package io.neoterm.terminal; + +/** Current terminal colors (if different from default). */ +public final class TerminalColors { + + /** Static data - a bit ugly but ok for now. */ + public static final TerminalColorScheme COLOR_SCHEME = new TerminalColorScheme(); + + /** + * The current terminal colors, which are normally set from the color theme, but may be set dynamically with the OSC + * 4 control sequence. + */ + public final int[] mCurrentColors = new int[TextStyle.NUM_INDEXED_COLORS]; + + /** Create a new instance with default colors from the theme. */ + public TerminalColors() { + reset(); + } + + /** Reset a particular indexed color with the default color from the color theme. */ + public void reset(int index) { + mCurrentColors[index] = COLOR_SCHEME.mDefaultColors[index]; + } + + /** Reset all indexed colors with the default color from the color theme. */ + public void reset() { + System.arraycopy(COLOR_SCHEME.mDefaultColors, 0, mCurrentColors, 0, TextStyle.NUM_INDEXED_COLORS); + } + + /** + * Parse color according to http://manpages.ubuntu.com/manpages/intrepid/man3/XQueryColor.3.html + *

+ * Highest bit is set if successful, so return value is 0xFF${R}${G}${B}. Return 0 if failed. + */ + static int parse(String c) { + try { + int skipInitial, skipBetween; + if (c.charAt(0) == '#') { + // #RGB, #RRGGBB, #RRRGGGBBB or #RRRRGGGGBBBB. Most significant bits. + skipInitial = 1; + skipBetween = 0; + } else if (c.startsWith("rgb:")) { + // rgb:// where , , := h | hh | hhh | hhhh. Scaled. + skipInitial = 4; + skipBetween = 1; + } else { + return 0; + } + int charsForColors = c.length() - skipInitial - 2 * skipBetween; + if (charsForColors % 3 != 0) return 0; // Unequal lengths. + int componentLength = charsForColors / 3; + double mult = 255 / (Math.pow(2, componentLength * 4) - 1); + + int currentPosition = skipInitial; + String rString = c.substring(currentPosition, currentPosition + componentLength); + currentPosition += componentLength + skipBetween; + String gString = c.substring(currentPosition, currentPosition + componentLength); + currentPosition += componentLength + skipBetween; + String bString = c.substring(currentPosition, currentPosition + componentLength); + + int r = (int) (Integer.parseInt(rString, 16) * mult); + int g = (int) (Integer.parseInt(gString, 16) * mult); + int b = (int) (Integer.parseInt(bString, 16) * mult); + return 0xFF << 24 | r << 16 | g << 8 | b; + } catch (NumberFormatException | IndexOutOfBoundsException e) { + return 0; + } + } + + /** Try parse a color from a text parameter and into a specified index. */ + public void tryParseColor(int intoIndex, String textParameter) { + int c = parse(textParameter); + if (c != 0) mCurrentColors[intoIndex] = c; + } + +} diff --git a/app/src/main/java/io/neoterm/terminal/TerminalEmulator.java b/app/src/main/java/io/neoterm/terminal/TerminalEmulator.java new file mode 100755 index 0000000..32b54b4 --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/TerminalEmulator.java @@ -0,0 +1,2341 @@ +package io.neoterm.terminal; + +import android.util.Base64; +import android.util.Log; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; +import java.util.Stack; + +/** + * Renders text into a screen. Contains all the terminal-specific knowledge and state. Emulates a subset of the X Window + * System xterm terminal, which in turn is an emulator for a subset of the Digital Equipment Corporation vt100 terminal. + *

+ * References: + *

    + *
  • http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
  • + *
  • http://en.wikipedia.org/wiki/ANSI_escape_code
  • + *
  • http://man.he.net/man4/console_codes
  • + *
  • http://bazaar.launchpad.net/~leonerd/libvterm/trunk/view/head:/src/state.c
  • + *
  • http://www.columbia.edu/~kermit/k95manual/iso2022.html
  • + *
  • http://www.vt100.net/docs/vt510-rm/chapter4
  • + *
  • http://en.wikipedia.org/wiki/ISO/IEC_2022 - for 7-bit and 8-bit GL GR explanation
  • + *
  • http://bjh21.me.uk/all-escapes/all-escapes.txt - extensive!
  • + *
  • http://woldlab.caltech.edu/~diane/kde4.10/workingdir/kubuntu/konsole/doc/developer/old-documents/VT100/techref. + * html - document for konsole - accessible!
  • + *
+ */ +public final class TerminalEmulator { + + /** Log unknown or unimplemented escape sequences received from the shell process. */ + private static final boolean LOG_ESCAPE_SEQUENCES = false; + + public static final int MOUSE_LEFT_BUTTON = 0; + + /** Mouse moving while having left mouse button pressed. */ + public static final int MOUSE_LEFT_BUTTON_MOVED = 32; + public static final int MOUSE_WHEELUP_BUTTON = 64; + public static final int MOUSE_WHEELDOWN_BUTTON = 65; + + public static final int CURSOR_STYLE_BLOCK = 0; + public static final int CURSOR_STYLE_UNDERLINE = 1; + public static final int CURSOR_STYLE_BAR = 2; + + /** Used for invalid data - http://en.wikipedia.org/wiki/Replacement_character#Replacement_character */ + public static final int UNICODE_REPLACEMENT_CHAR = 0xFFFD; + + /** Escape processing: Not currently in an escape sequence. */ + private static final int ESC_NONE = 0; + /** Escape processing: Have seen an ESC character - proceed to {@link #doEsc(int)} */ + private static final int ESC = 1; + /** Escape processing: Have seen ESC POUND */ + private static final int ESC_POUND = 2; + /** Escape processing: Have seen ESC and a character-set-select ( char */ + private static final int ESC_SELECT_LEFT_PAREN = 3; + /** Escape processing: Have seen ESC and a character-set-select ) char */ + private static final int ESC_SELECT_RIGHT_PAREN = 4; + /** Escape processing: "ESC [" or CSI (Control Sequence Introducer). */ + private static final int ESC_CSI = 6; + /** Escape processing: ESC [ ? */ + private static final int ESC_CSI_QUESTIONMARK = 7; + /** Escape processing: ESC [ $ */ + private static final int ESC_CSI_DOLLAR = 8; + /** Escape processing: ESC % */ + private static final int ESC_PERCENT = 9; + /** Escape processing: ESC ] (AKA OSC - Operating System Controls) */ + private static final int ESC_OSC = 10; + /** Escape processing: ESC ] (AKA OSC - Operating System Controls) ESC */ + private static final int ESC_OSC_ESC = 11; + /** Escape processing: ESC [ > */ + private static final int ESC_CSI_BIGGERTHAN = 12; + /** Escape procession: "ESC P" or Device Control String (DCS) */ + private static final int ESC_P = 13; + /** Escape processing: CSI > */ + private static final int ESC_CSI_QUESTIONMARK_ARG_DOLLAR = 14; + /** Escape processing: CSI $ARGS ' ' */ + private static final int ESC_CSI_ARGS_SPACE = 15; + /** Escape processing: CSI $ARGS '*' */ + private static final int ESC_CSI_ARGS_ASTERIX = 16; + /** Escape processing: CSI " */ + private static final int ESC_CSI_DOUBLE_QUOTE = 17; + /** Escape processing: CSI ' */ + private static final int ESC_CSI_SINGLE_QUOTE = 18; + /** Escape processing: CSI ! */ + private static final int ESC_CSI_EXCLAMATION = 19; + + /** The number of parameter arguments. This name comes from the ANSI standard for terminal escape codes. */ + private static final int MAX_ESCAPE_PARAMETERS = 16; + + /** Needs to be large enough to contain reasonable OSC 52 pastes. */ + private static final int MAX_OSC_STRING_LENGTH = 8192; + + /** DECSET 1 - application cursor keys. */ + private static final int DECSET_BIT_APPLICATION_CURSOR_KEYS = 1; + private static final int DECSET_BIT_REVERSE_VIDEO = 1 << 1; + /** + * http://www.vt100.net/docs/vt510-rm/DECOM: "When DECOM is set, the home cursor position is at the upper-left + * corner of the screen, within the margins. The starting point for line numbers depends on the current top margin + * setting. The cursor cannot move outside of the margins. When DECOM is reset, the home cursor position is at the + * upper-left corner of the screen. The starting point for line numbers is independent of the margins. The cursor + * can move outside of the margins." + */ + private static final int DECSET_BIT_ORIGIN_MODE = 1 << 2; + /** + * http://www.vt100.net/docs/vt510-rm/DECAWM: "If the DECAWM function is set, then graphic characters received when + * the cursor is at the right border of the page appear at the beginning of the next line. Any text on the page + * scrolls up if the cursor is at the end of the scrolling region. If the DECAWM function is reset, then graphic + * characters received when the cursor is at the right border of the page replace characters already on the page." + */ + private static final int DECSET_BIT_AUTOWRAP = 1 << 3; + /** DECSET 25 - if the cursor should be visible, {@link #isShowingCursor()}. */ + private static final int DECSET_BIT_SHOWING_CURSOR = 1 << 4; + private static final int DECSET_BIT_APPLICATION_KEYPAD = 1 << 5; + /** DECSET 1000 - if to report mouse press&release events. */ + private static final int DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE = 1 << 6; + /** DECSET 1002 - like 1000, but report moving mouse while pressed. */ + private static final int DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT = 1 << 7; + /** DECSET 1004 - NOT implemented. */ + private static final int DECSET_BIT_SEND_FOCUS_EVENTS = 1 << 8; + /** DECSET 1006 - SGR-like mouse protocol (the modern sane choice). */ + private static final int DECSET_BIT_MOUSE_PROTOCOL_SGR = 1 << 9; + /** DECSET 2004 - see {@link #paste(String)} */ + private static final int DECSET_BIT_BRACKETED_PASTE_MODE = 1 << 10; + /** Toggled with DECLRMM - http://www.vt100.net/docs/vt510-rm/DECLRMM */ + private static final int DECSET_BIT_LEFTRIGHT_MARGIN_MODE = 1 << 11; + /** Not really DECSET bit... - http://www.vt100.net/docs/vt510-rm/DECSACE */ + private static final int DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE = 1 << 12; + + private String mTitle; + private final Stack mTitleStack = new Stack<>(); + + /** The cursor position. Between (0,0) and (mRows-1, mColumns-1). */ + private int mCursorRow, mCursorCol; + + private int mCursorStyle = CURSOR_STYLE_BLOCK; + + /** The number of character rows and columns in the terminal screen. */ + public int mRows, mColumns; + + /** The normal screen buffer. Stores the characters that appear on the screen of the emulated terminal. */ + private final TerminalBuffer mMainBuffer; + /** + * The alternate screen buffer, exactly as large as the display and contains no additional saved lines (so that when + * the alternate screen buffer is active, you cannot scroll back to view saved lines). + *

+ * See http://www.xfree86.org/current/ctlseqs.html#The%20Alternate%20Screen%20Buffer + */ + final TerminalBuffer mAltBuffer; + /** The current screen buffer, pointing at either {@link #mMainBuffer} or {@link #mAltBuffer}. */ + private TerminalBuffer mScreen; + + /** The terminal session this emulator is bound to. */ + private final TerminalOutput mSession; + + /** Keeps track of the current argument of the current escape sequence. Ranges from 0 to MAX_ESCAPE_PARAMETERS-1. */ + private int mArgIndex; + /** Holds the arguments of the current escape sequence. */ + private final int[] mArgs = new int[MAX_ESCAPE_PARAMETERS]; + + /** Holds OSC and device control arguments, which can be strings. */ + private final StringBuilder mOSCOrDeviceControlArgs = new StringBuilder(); + + /** + * True if the current escape sequence should continue, false if the current escape sequence should be terminated. + * Used when parsing a single character. + */ + private boolean mContinueSequence; + + /** The current state of the escape sequence state machine. One of the ESC_* constants. */ + private int mEscapeState; + + private final SavedScreenState mSavedStateMain = new SavedScreenState(); + private final SavedScreenState mSavedStateAlt = new SavedScreenState(); + + /** http://www.vt100.net/docs/vt102-ug/table5-15.html */ + private boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true; + + /** + * @see TerminalEmulator#mapDecSetBitToInternalBit(int) + */ + private int mCurrentDecSetFlags, mSavedDecSetFlags; + + /** + * If insert mode (as opposed to replace mode) is active. In insert mode new characters are inserted, pushing + * existing text to the right. Characters moved past the right margin are lost. + */ + private boolean mInsertMode; + + /** An array of tab_switcher stops. mTabStop[i] is true if there is a tab_switcher stop set for column i. */ + private boolean[] mTabStop; + + /** + * Top margin of screen for scrolling ranges from 0 to mRows-2. Bottom margin ranges from mTopMargin + 2 to mRows + * (Defines the first row after the scrolling region). Left/right margin in [0, mColumns]. + */ + private int mTopMargin, mBottomMargin, mLeftMargin, mRightMargin; + + /** + * If the next character to be emitted will be automatically wrapped to the next line. Used to disambiguate the case + * where the cursor is positioned on the last column (mColumns-1). When standing there, a written character will be + * output in the last column, the cursor not moving but this flag will be set. When outputting another character + * this will move to the next line. + */ + private boolean mAboutToAutoWrap; + + /** + * Current foreground and background colors. Can either be a color index in [0,259] or a truecolor (24-bit) value. + * For a 24-bit value the top byte (0xff000000) is set. + * + * @see TextStyle + */ + int mForeColor, mBackColor; + + /** Current {@link TextStyle} effect. */ + private int mEffect; + + /** + * The number of scrolled lines since last calling {@link #clearScrollCounter()}. Used for moving selection up along + * with the scrolling text. + */ + private int mScrollCounter = 0; + + private byte mUtf8ToFollow, mUtf8Index; + private final byte[] mUtf8InputBuffer = new byte[4]; + + public final TerminalColors mColors = new TerminalColors(); + + private boolean isDecsetInternalBitSet(int bit) { + return (mCurrentDecSetFlags & bit) != 0; + } + + private void setDecsetinternalBit(int internalBit, boolean set) { + if (set) { + // The mouse modes are mutually exclusive. + if (internalBit == DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) { + setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT, false); + } else if (internalBit == DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT) { + setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE, false); + } + } + if (set) { + mCurrentDecSetFlags |= internalBit; + } else { + mCurrentDecSetFlags &= ~internalBit; + } + } + + static int mapDecSetBitToInternalBit(int decsetBit) { + switch (decsetBit) { + case 1: + return DECSET_BIT_APPLICATION_CURSOR_KEYS; + case 5: + return DECSET_BIT_REVERSE_VIDEO; + case 6: + return DECSET_BIT_ORIGIN_MODE; + case 7: + return DECSET_BIT_AUTOWRAP; + case 25: + return DECSET_BIT_SHOWING_CURSOR; + case 66: + return DECSET_BIT_APPLICATION_KEYPAD; + case 69: + return DECSET_BIT_LEFTRIGHT_MARGIN_MODE; + case 1000: + return DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE; + case 1002: + return DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT; + case 1004: + return DECSET_BIT_SEND_FOCUS_EVENTS; + case 1006: + return DECSET_BIT_MOUSE_PROTOCOL_SGR; + case 2004: + return DECSET_BIT_BRACKETED_PASTE_MODE; + default: + return -1; + // throw new IllegalArgumentException("Unsupported decset: " + decsetBit); + } + } + + public TerminalEmulator(TerminalOutput session, int columns, int rows, int transcriptRows) { + mSession = session; + mScreen = mMainBuffer = new TerminalBuffer(columns, transcriptRows, rows); + mAltBuffer = new TerminalBuffer(columns, rows, rows); + mRows = rows; + mColumns = columns; + mTabStop = new boolean[mColumns]; + reset(); + } + + public TerminalBuffer getScreen() { + return mScreen; + } + + public boolean isAlternateBufferActive() { + return mScreen == mAltBuffer; + } + + /** + * @param mouseButton one of the MOUSE_* constants of this class. + */ + public void sendMouseEvent(int mouseButton, int column, int row, boolean pressed) { + if (mouseButton == MOUSE_LEFT_BUTTON_MOVED && !isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT)) { + // Do not send tracking. + } else if (isDecsetInternalBitSet(DECSET_BIT_MOUSE_PROTOCOL_SGR)) { + mSession.write(String.format("\033[<%d;%d;%d" + (pressed ? 'M' : 'm'), mouseButton, column, row)); + } else { + mouseButton = pressed ? mouseButton : 3; // 3 for release of all buttons. + // Clip to screen, and clip to the limits of 8-bit data. + boolean out_of_bounds = column < 1 || row < 1 || column > mColumns || row > mRows || column > 255 - 32 || row > 255 - 32; + if (!out_of_bounds) { + byte[] data = {'\033', '[', 'M', (byte) (32 + mouseButton), (byte) (32 + column), (byte) (32 + row)}; + mSession.write(data, 0, data.length); + } + } + } + + public void resize(int columns, int rows) { + if (mRows == rows && mColumns == columns) { + return; + } else if (columns < 2 || rows < 2) { + throw new IllegalArgumentException("rows=" + rows + ", columns=" + columns); + } + + if (mRows != rows) { + mRows = rows; + mTopMargin = 0; + mBottomMargin = mRows; + } + if (mColumns != columns) { + int oldColumns = mColumns; + mColumns = columns; + boolean[] oldTabStop = mTabStop; + mTabStop = new boolean[mColumns]; + setDefaultTabStops(); + int toTransfer = Math.min(oldColumns, columns); + System.arraycopy(oldTabStop, 0, mTabStop, 0, toTransfer); + mLeftMargin = 0; + mRightMargin = mColumns; + } + + resizeScreen(); + } + + private void resizeScreen() { + final int[] cursor = {mCursorCol, mCursorRow}; + int newTotalRows = (mScreen == mAltBuffer) ? mRows : mMainBuffer.mTotalRows; + mScreen.resize(mColumns, mRows, newTotalRows, cursor, getStyle(), isAlternateBufferActive()); + mCursorCol = cursor[0]; + mCursorRow = cursor[1]; + } + + public int getCursorRow() { + return mCursorRow; + } + + public int getCursorCol() { + return mCursorCol; + } + + /** {@link #CURSOR_STYLE_BAR}, {@link #CURSOR_STYLE_BLOCK} or {@link #CURSOR_STYLE_UNDERLINE} */ + public int getCursorStyle() { + return mCursorStyle; + } + + public boolean isReverseVideo() { + return isDecsetInternalBitSet(DECSET_BIT_REVERSE_VIDEO); + } + + public boolean isShowingCursor() { + return isDecsetInternalBitSet(DECSET_BIT_SHOWING_CURSOR); + } + + public boolean isKeypadApplicationMode() { + return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD); + } + + public boolean isCursorKeysApplicationMode() { + return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS); + } + + /** If mouse events are being sent as escape codes to the terminal. */ + public boolean isMouseTrackingActive() { + return isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) || isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT); + } + + private void setDefaultTabStops() { + for (int i = 0; i < mColumns; i++) + mTabStop[i] = (i & 7) == 0 && i != 0; + } + + /** + * Accept bytes (typically from the pseudo-teletype) and process them. + * + * @param buffer a byte array containing the bytes to be processed + * @param length the number of bytes in the array to process + */ + public void append(byte[] buffer, int length) { + for (int i = 0; i < length; i++) + processByte(buffer[i]); + } + + private void processByte(byte byteToProcess) { + if (mUtf8ToFollow > 0) { + if ((byteToProcess & 0b11000000) == 0b10000000) { + // 10xxxxxx, a continuation byte. + mUtf8InputBuffer[mUtf8Index++] = byteToProcess; + if (--mUtf8ToFollow == 0) { + byte firstByteMask = (byte) (mUtf8Index == 2 ? 0b00011111 : (mUtf8Index == 3 ? 0b00001111 : 0b00000111)); + int codePoint = (mUtf8InputBuffer[0] & firstByteMask); + for (int i = 1; i < mUtf8Index; i++) + codePoint = ((codePoint << 6) | (mUtf8InputBuffer[i] & 0b00111111)); + if (((codePoint <= 0b1111111) && mUtf8Index > 1) || (codePoint < 0b11111111111 && mUtf8Index > 2) + || (codePoint < 0b1111111111111111 && mUtf8Index > 3)) { + // Overlong encoding. + codePoint = UNICODE_REPLACEMENT_CHAR; + } + + mUtf8Index = mUtf8ToFollow = 0; + + if (codePoint >= 0x80 && codePoint <= 0x9F) { + // Sequence decoded to a C1 control character which is the same as escape followed by + // ((code & 0x7F) + 0x40). + processCodePoint(/* escape (hexadecimal=0x1B, octal=033): */27); + processCodePoint((codePoint & 0x7F) + 0x40); + } else { + switch (Character.getType(codePoint)) { + case Character.UNASSIGNED: + case Character.SURROGATE: + codePoint = UNICODE_REPLACEMENT_CHAR; + } + processCodePoint(codePoint); + } + } + } else { + // Not a UTF-8 continuation byte so replace the entire sequence up to now with the replacement char: + mUtf8Index = mUtf8ToFollow = 0; + emitCodePoint(UNICODE_REPLACEMENT_CHAR); + // The Unicode Standard Version 6.2 – Core Specification + // (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf): + // "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first + // byte, but which does not continue with valid successor bytes (see Table 3-7), it must not consume the + // successor bytes as part of the ill-formed subsequence + // whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit + // subsequence." + processByte(byteToProcess); + } + } else { + if ((byteToProcess & 0b10000000) == 0) { // The leading bit is not set so it is a 7-bit ASCII character. + processCodePoint(byteToProcess); + return; + } else if ((byteToProcess & 0b11100000) == 0b11000000) { // 110xxxxx, a two-byte sequence. + mUtf8ToFollow = 1; + } else if ((byteToProcess & 0b11110000) == 0b11100000) { // 1110xxxx, a three-byte sequence. + mUtf8ToFollow = 2; + } else if ((byteToProcess & 0b11111000) == 0b11110000) { // 11110xxx, a four-byte sequence. + mUtf8ToFollow = 3; + } else { + // Not a valid UTF-8 sequence start, signal invalid data: + processCodePoint(UNICODE_REPLACEMENT_CHAR); + return; + } + mUtf8InputBuffer[mUtf8Index++] = byteToProcess; + } + } + + public void processCodePoint(int b) { + switch (b) { + case 0: // Null character (NUL, ^@). Do nothing. + break; + case 7: // Bell (BEL, ^G, \a). If in an OSC sequence, BEL may terminate a string; otherwise signal bell. + if (mEscapeState == ESC_OSC) + doOsc(b); + else + mSession.onBell(); + break; + case 8: // Backspace (BS, ^H). + if (mLeftMargin == mCursorCol) { + // Jump to previous line if it was auto-wrapped. + int previousRow = mCursorRow - 1; + if (previousRow >= 0 && mScreen.getLineWrap(previousRow)) { + mScreen.clearLineWrap(previousRow); + setCursorRowCol(previousRow, mRightMargin - 1); + } + } else { + setCursorCol(mCursorCol - 1); + } + break; + case 9: // Horizontal tab_switcher (HT, \t) - move to next tab_switcher stop, but not past edge of screen + // XXX: Should perhaps use color if writing to new cells. Try with + // printf "\033[41m\tXX\033[0m\n" + // The OSX Terminal.app colors the spaces from the tab_switcher red, but xterm does not. + // Note that Terminal.app only colors on new cells, in e.g. + // printf "\033[41m\t\r\033[42m\tXX\033[0m\n" + // the first cells are created with a red background, but when tabbing over + // them again with a green background they are not overwritten. + mCursorCol = nextTabStop(1); + break; + case 10: // Line feed (LF, \n). + case 11: // Vertical tab_switcher (VT, \v). + case 12: // Form feed (FF, \f). + doLinefeed(); + break; + case 13: // Carriage return (CR, \r). + setCursorCol(mLeftMargin); + break; + case 14: // Shift Out (Ctrl-N, SO) → Switch to Alternate Character Set. This invokes the G1 character set. + mUseLineDrawingUsesG0 = false; + break; + case 15: // Shift In (Ctrl-O, SI) → Switch to Standard Character Set. This invokes the G0 character set. + mUseLineDrawingUsesG0 = true; + break; + case 24: // CAN. + case 26: // SUB. + if (mEscapeState != ESC_NONE) { + // FIXME: What is this?? + mEscapeState = ESC_NONE; + emitCodePoint(127); + } + break; + case 27: // ESC + // Starts an escape sequence unless we're parsing a string + if (mEscapeState == ESC_P) { + // XXX: Ignore escape when reading device control sequence, since it may be part of string terminator. + return; + } else if (mEscapeState != ESC_OSC) { + startEscapeSequence(); + } else { + doOsc(b); + } + break; + default: + mContinueSequence = false; + switch (mEscapeState) { + case ESC_NONE: + if (b >= 32) emitCodePoint(b); + break; + case ESC: + doEsc(b); + break; + case ESC_POUND: + doEscPound(b); + break; + case ESC_SELECT_LEFT_PAREN: // Designate G0 Character Set (ISO 2022, VT100). + mUseLineDrawingG0 = (b == '0'); + break; + case ESC_SELECT_RIGHT_PAREN: // Designate G1 Character Set (ISO 2022, VT100). + mUseLineDrawingG1 = (b == '0'); + break; + case ESC_CSI: + doCsi(b); + break; + case ESC_CSI_EXCLAMATION: + if (b == 'p') { // Soft terminal reset (DECSTR, http://vt100.net/docs/vt510-rm/DECSTR). + reset(); + } else { + unknownSequence(b); + } + break; + case ESC_CSI_QUESTIONMARK: + doCsiQuestionMark(b); + break; + case ESC_CSI_BIGGERTHAN: + doCsiBiggerThan(b); + break; + case ESC_CSI_DOLLAR: + boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); + int effectiveTopMargin = originMode ? mTopMargin : 0; + int effectiveBottomMargin = originMode ? mBottomMargin : mRows; + int effectiveLeftMargin = originMode ? mLeftMargin : 0; + int effectiveRightMargin = originMode ? mRightMargin : mColumns; + switch (b) { + case 'v': // ${CSI}${SRC_TOP}${SRC_LEFT}${SRC_BOTTOM}${SRC_RIGHT}${SRC_PAGE}${DST_TOP}${DST_LEFT}${DST_PAGE}$v" + // Copy rectangular area (DECCRA - http://vt100.net/docs/vt510-rm/DECCRA): + // "If Pbs is greater than Pts, or Pls is greater than Prs, the terminal ignores DECCRA. + // The coordinates of the rectangular area are affected by the setting of origin mode (DECOM). + // DECCRA is not affected by the page margins. + // The copied text takes on the line attributes of the destination area. + // If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, then the value + // is treated as the width or height of that page. + // If the destination area is partially off the page, then DECCRA clips the off-page data. + // DECCRA does not change the active cursor position." + int topSource = Math.min(getArg(0, 1, true) - 1 + effectiveTopMargin, mRows); + int leftSource = Math.min(getArg(1, 1, true) - 1 + effectiveLeftMargin, mColumns); + // Inclusive, so do not subtract one: + int bottomSource = Math.min(Math.max(getArg(2, mRows, true) + effectiveTopMargin, topSource), mRows); + int rightSource = Math.min(Math.max(getArg(3, mColumns, true) + effectiveLeftMargin, leftSource), mColumns); + // int sourcePage = getArg(4, 1, true); + int destionationTop = Math.min(getArg(5, 1, true) - 1 + effectiveTopMargin, mRows); + int destinationLeft = Math.min(getArg(6, 1, true) - 1 + effectiveLeftMargin, mColumns); + // int destinationPage = getArg(7, 1, true); + int heightToCopy = Math.min(mRows - destionationTop, bottomSource - topSource); + int widthToCopy = Math.min(mColumns - destinationLeft, rightSource - leftSource); + mScreen.blockCopy(leftSource, topSource, widthToCopy, heightToCopy, destinationLeft, destionationTop); + break; + case '{': // ${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${" + // Selective erase rectangular area (DECSERA - http://www.vt100.net/docs/vt510-rm/DECSERA). + case 'x': // ${CSI}${CHAR};${TOP}${LEFT}${BOTTOM}${RIGHT}$x" + // Fill rectangular area (DECFRA - http://www.vt100.net/docs/vt510-rm/DECFRA). + case 'z': // ${CSI}$${TOP}${LEFT}${BOTTOM}${RIGHT}$z" + // Erase rectangular area (DECERA - http://www.vt100.net/docs/vt510-rm/DECERA). + boolean erase = b != 'x'; + boolean selective = b == '{'; + // Only DECSERA keeps visual attributes, DECERA does not: + boolean keepVisualAttributes = erase && selective; + int argIndex = 0; + int fillChar = erase ? ' ' : getArg(argIndex++, -1, true); + // "Pch can be any value from 32 to 126 or from 160 to 255. If Pch is not in this range, then the + // terminal ignores the DECFRA command": + if ((fillChar >= 32 && fillChar <= 126) || (fillChar >= 160 && fillChar <= 255)) { + // "If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, the value + // is treated as the width or height of that page." + int top = Math.min(getArg(argIndex++, 1, true) + effectiveTopMargin, effectiveBottomMargin + 1); + int left = Math.min(getArg(argIndex++, 1, true) + effectiveLeftMargin, effectiveRightMargin + 1); + int bottom = Math.min(getArg(argIndex++, mRows, true) + effectiveTopMargin, effectiveBottomMargin); + int right = Math.min(getArg(argIndex, mColumns, true) + effectiveLeftMargin, effectiveRightMargin); + long style = getStyle(); + for (int row = top - 1; row < bottom; row++) + for (int col = left - 1; col < right; col++) + if (!selective || (TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0) + mScreen.setChar(col, row, fillChar, keepVisualAttributes ? mScreen.getStyleAt(row, col) : style); + } + break; + case 'r': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$r" + // Change attributes in rectangular area (DECCARA - http://vt100.net/docs/vt510-rm/DECCARA). + case 't': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$t" + // Reverse attributes in rectangular area (DECRARA - http://www.vt100.net/docs/vt510-rm/DECRARA). + boolean reverse = b == 't'; + // FIXME: "coordinates of the rectangular area are affected by the setting of origin mode (DECOM)". + int top = Math.min(getArg(0, 1, true) - 1, effectiveBottomMargin) + effectiveTopMargin; + int left = Math.min(getArg(1, 1, true) - 1, effectiveRightMargin) + effectiveLeftMargin; + int bottom = Math.min(getArg(2, mRows, true) + 1, effectiveBottomMargin - 1) + effectiveTopMargin; + int right = Math.min(getArg(3, mColumns, true) + 1, effectiveRightMargin - 1) + effectiveLeftMargin; + if (mArgIndex >= 4) { + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 4; i <= mArgIndex; i++) { + int bits = 0; + boolean setOrClear = true; // True if setting, false if clearing. + switch (getArg(i, 0, false)) { + case 0: // Attributes off (no bold, no underline, no blink, positive image). + bits = (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE | TextStyle.CHARACTER_ATTRIBUTE_BLINK + | TextStyle.CHARACTER_ATTRIBUTE_INVERSE); + if (!reverse) setOrClear = false; + break; + case 1: // Bold. + bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + break; + case 4: // Underline. + bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + break; + case 5: // Blink. + bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; + break; + case 7: // Negative image. + bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + break; + case 22: // No bold. + bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + setOrClear = false; + break; + case 24: // No underline. + bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + setOrClear = false; + break; + case 25: // No blink. + bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; + setOrClear = false; + break; + case 27: // Positive image. + bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + setOrClear = false; + break; + } + if (reverse && !setOrClear) { + // Reverse attributes in rectangular area ignores non-(1,4,5,7) bits. + } else { + mScreen.setOrClearEffect(bits, setOrClear, reverse, isDecsetInternalBitSet(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE), + effectiveLeftMargin, effectiveRightMargin, top, left, bottom, right); + } + } + } else { + // Do nothing. + } + break; + default: + unknownSequence(b); + } + break; + case ESC_CSI_DOUBLE_QUOTE: + if (b == 'q') { + // http://www.vt100.net/docs/vt510-rm/DECSCA + int arg = getArg0(0); + if (arg == 0 || arg == 2) { + // DECSED and DECSEL can erase characters. + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_PROTECTED; + } else if (arg == 1) { + // DECSED and DECSEL cannot erase characters. + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_PROTECTED; + } else { + unknownSequence(b); + } + } else { + unknownSequence(b); + } + break; + case ESC_CSI_SINGLE_QUOTE: + if (b == '}') { // Insert Ps Column(s) (default = 1) (DECIC), VT420 and up. + int columnsAfterCursor = mRightMargin - mCursorCol; + int columnsToInsert = Math.min(getArg0(1), columnsAfterCursor); + int columnsToMove = columnsAfterCursor - columnsToInsert; + mScreen.blockCopy(mCursorCol, 0, columnsToMove, mRows, mCursorCol + columnsToInsert, 0); + blockClear(mCursorCol, 0, columnsToInsert, mRows); + } else if (b == '~') { // Delete Ps Column(s) (default = 1) (DECDC), VT420 and up. + int columnsAfterCursor = mRightMargin - mCursorCol; + int columnsToDelete = Math.min(getArg0(1), columnsAfterCursor); + int columnsToMove = columnsAfterCursor - columnsToDelete; + mScreen.blockCopy(mCursorCol + columnsToDelete, 0, columnsToMove, mRows, mCursorCol, 0); + blockClear(mCursorRow + columnsToMove, 0, columnsToDelete, mRows); + } else { + unknownSequence(b); + } + break; + case ESC_PERCENT: + break; + case ESC_OSC: + doOsc(b); + break; + case ESC_OSC_ESC: + doOscEsc(b); + break; + case ESC_P: + doDeviceControl(b); + break; + case ESC_CSI_QUESTIONMARK_ARG_DOLLAR: + if (b == 'p') { + // Request DEC private mode (DECRQM). + int mode = getArg0(0); + int value; + if (mode == 47 || mode == 1047 || mode == 1049) { + // This state is carried by mScreen pointer. + value = (mScreen == mAltBuffer) ? 1 : 2; + } else { + int internalBit = mapDecSetBitToInternalBit(mode); + if (internalBit == -1) { + value = isDecsetInternalBitSet(internalBit) ? 1 : 2; // 1=set, 2=reset. + } else { + Log.e(EmulatorDebug.LOG_TAG, "Got DECRQM for unrecognized private DEC mode=" + mode); + value = 0; // 0=not recognized, 3=permanently set, 4=permanently reset + } + } + mSession.write(String.format(Locale.US, "\033[?%d;%d$y", mode, value)); + } else { + unknownSequence(b); + } + break; + case ESC_CSI_ARGS_SPACE: + int arg = getArg0(0); + switch (b) { + case 'q': // "${CSI}${STYLE} q" - set cursor style (http://www.vt100.net/docs/vt510-rm/DECSCUSR). + switch (arg) { + case 0: // Blinking block. + case 1: // Blinking block. + case 2: // Steady block. + mCursorStyle = CURSOR_STYLE_BLOCK; + break; + case 3: // Blinking underline. + case 4: // Steady underline. + mCursorStyle = CURSOR_STYLE_UNDERLINE; + break; + case 5: // Blinking bar (xterm addition). + case 6: // Steady bar (xterm addition). + mCursorStyle = CURSOR_STYLE_BAR; + break; + } + break; + case 't': + case 'u': + // Set margin-bell volume - ignore. + break; + default: + unknownSequence(b); + } + break; + case ESC_CSI_ARGS_ASTERIX: + int attributeChangeExtent = getArg0(0); + if (b == 'x' && (attributeChangeExtent >= 0 && attributeChangeExtent <= 2)) { + // Select attribute change extent (DECSACE - http://www.vt100.net/docs/vt510-rm/DECSACE). + setDecsetinternalBit(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE, attributeChangeExtent == 2); + } else { + unknownSequence(b); + } + break; + default: + unknownSequence(b); + break; + } + if (!mContinueSequence) mEscapeState = ESC_NONE; + break; + } + } + + /** When in {@link #ESC_P} ("device control") sequence. */ + private void doDeviceControl(int b) { + switch (b) { + case (byte) '\\': // End of ESC \ string Terminator + { + String dcs = mOSCOrDeviceControlArgs.toString(); + // DCS $ q P t ST. Request Status String (DECRQSS) + if (dcs.startsWith("$q")) { + if (dcs.equals("$q\"p")) { + // DECSCL, conformance level, http://www.vt100.net/docs/vt510-rm/DECSCL: + String csiString = "64;1\"p"; + mSession.write("\033P1$r" + csiString + "\033\\"); + } else { + finishSequenceAndLogError("Unrecognized DECRQSS string: '" + dcs + "'"); + } + } else if (dcs.startsWith("+q")) { + // Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in + // hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key + // names. + // Two special features are also recognized, which are not key names: Co for termcap colors (or colors + // for terminfo colors), and TN for termcap name (or name for terminfo name). + // xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the + // corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are + // encoded in hexadecimal (2 digits per character). + // Example: + // :kr=\EOC: ks=\E[?1h\E=: ku=\EOA: le=^H:mb=\E[5m:md=\E[1m:\ + // where + // kd=down-arrow key + // kl=left-arrow key + // kr=right-arrow key + // ku=up-arrow key + // #2=key_shome, "shifted home" + // #4=key_sleft, "shift arrow left" + // %i=key_sright, "shift arrow right" + // *7=key_send, "shifted end" + // k1=F1 function key + + // Example: Request for ku is "ESC P + q 6 b 7 5 ESC \", where 6b7d=ku in hexadecimal. + // Xterm response in normal cursor mode: + // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x5B 0x41 = 27 91 65 = ESC [ A + // Xterm response in application cursor mode: + // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x4F 0x41 = 27 91 65 = ESC 0 A + + // #4 is "shift arrow left": + // *** Device Control (DCS) for '#4'- 'ESC P + q 23 34 ESC \' + // Response: <27> P 1 + r 2 3 3 4 = 1 B 5 B 3 1 3 B 3 2 4 4 <27> \ + // where 0x1B 0x5B 0x31 0x3B 0x32 0x44 = ESC [ 1 ; 2 D + // which we find in: TermKeyListener.java: KEY_MAP.put(KEYMOD_SHIFT | KEYCODE_DPAD_LEFT, "\033[1;2D"); + + // See http://h30097.www3.hp.com/docs/base_doc/DOCUMENTATION/V40G_HTML/MAN/MAN4/0178____.HTM for what to + // respond, as well as http://www.freebsd.org/cgi/man.cgi?query=termcap&sektion=5#CAPABILITIES for + // the meaning of e.g. "ku", "kd", "kr", "kl" + + for (String part : dcs.substring(2).split(";")) { + if (part.length() % 2 == 0) { + StringBuilder transBuffer = new StringBuilder(); + for (int i = 0; i < part.length(); i += 2) { + char c = (char) Long.decode("0x" + part.charAt(i) + "" + part.charAt(i + 1)).longValue(); + transBuffer.append(c); + } + String trans = transBuffer.toString(); + String responseValue; + switch (trans) { + case "Co": + case "colors": + responseValue = "256"; // Number of colors. + break; + case "TN": + case "name": + responseValue = "xterm"; + break; + default: + responseValue = KeyHandler.getCodeFromTermcap(trans, isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS), + isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD)); + break; + } + if (responseValue == null) { + switch (trans) { + case "%1": // Help key - ignore + case "&8": // Undo key - ignore. + break; + default: + Log.w(EmulatorDebug.LOG_TAG, "Unhandled termcap/terminfo name: '" + trans + "'"); + } + // Respond with invalid request: + mSession.write("\033P0+r" + part + "\033\\"); + } else { + StringBuilder hexEncoded = new StringBuilder(); + for (int j = 0; j < responseValue.length(); j++) { + hexEncoded.append(String.format("%02X", (int) responseValue.charAt(j))); + } + mSession.write("\033P1+r" + part + "=" + hexEncoded + "\033\\"); + } + } else { + Log.e(EmulatorDebug.LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part); + } + } + } else { + if (LOG_ESCAPE_SEQUENCES) + Log.e(EmulatorDebug.LOG_TAG, "Unrecognized device control string: " + dcs); + } + finishSequence(); + } + break; + default: + if (mOSCOrDeviceControlArgs.length() > MAX_OSC_STRING_LENGTH) { + // Too long. + mOSCOrDeviceControlArgs.setLength(0); + finishSequence(); + } else { + mOSCOrDeviceControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + } + } + } + + private int nextTabStop(int numTabs) { + for (int i = mCursorCol + 1; i < mColumns; i++) + if (mTabStop[i] && --numTabs == 0) return Math.min(i, mRightMargin); + return mRightMargin - 1; + } + + /** Process byte while in the {@link #ESC_CSI_QUESTIONMARK} escape state. */ + private void doCsiQuestionMark(int b) { + switch (b) { + case 'J': // Selective erase in display (DECSED) - http://www.vt100.net/docs/vt510-rm/DECSED. + case 'K': // Selective erase in line (DECSEL) - http://vt100.net/docs/vt510-rm/DECSEL. + mAboutToAutoWrap = false; + int fillChar = ' '; + int startCol = -1; + int startRow = -1; + int endCol = -1; + int endRow = -1; + boolean justRow = (b == 'K'); + switch (getArg0(0)) { + case 0: // Erase from the active position to the end, inclusive (default). + startCol = mCursorCol; + startRow = mCursorRow; + endCol = mColumns; + endRow = justRow ? (mCursorRow + 1) : mRows; + break; + case 1: // Erase from start to the active position, inclusive. + startCol = 0; + startRow = justRow ? mCursorRow : 0; + endCol = mCursorCol + 1; + endRow = mCursorRow + 1; + break; + case 2: // Erase all of the display/line. + startCol = 0; + startRow = justRow ? mCursorRow : 0; + endCol = mColumns; + endRow = justRow ? (mCursorRow + 1) : mRows; + break; + default: + unknownSequence(b); + break; + } + long style = getStyle(); + for (int row = startRow; row < endRow; row++) { + for (int col = startCol; col < endCol; col++) { + if ((TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0) + mScreen.setChar(col, row, fillChar, style); + } + } + break; + case 'h': + case 'l': + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) + doDecSetOrReset(b == 'h', mArgs[i]); + break; + case 'n': // Device Status Report (DSR, DEC-specific). + switch (getArg0(-1)) { + case 6: + // Extended Cursor Position (DECXCPR - http://www.vt100.net/docs/vt510-rm/DECXCPR). Page=1. + mSession.write(String.format(Locale.US, "\033[?%d;%d;1R", mCursorRow + 1, mCursorCol + 1)); + break; + default: + finishSequence(); + return; + } + break; + case 'r': + case 's': + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int externalBit = mArgs[i]; + int internalBit = mapDecSetBitToInternalBit(externalBit); + if (internalBit == -1) { + Log.w(EmulatorDebug.LOG_TAG, "Ignoring request to save/recall decset bit=" + externalBit); + } else { + if (b == 's') { + mSavedDecSetFlags |= internalBit; + } else { + doDecSetOrReset((mSavedDecSetFlags & internalBit) != 0, externalBit); + } + } + } + break; + case '$': + continueSequence(ESC_CSI_QUESTIONMARK_ARG_DOLLAR); + return; + default: + parseArg(b); + } + } + + public void doDecSetOrReset(boolean setting, int externalBit) { + int internalBit = mapDecSetBitToInternalBit(externalBit); + if (internalBit != -1) { + setDecsetinternalBit(internalBit, setting); + } + switch (externalBit) { + case 1: // Application Cursor Keys (DECCKM). + break; + case 3: // Set: 132 column mode (. Reset: 80 column mode. ANSI name: DECCOLM. + // We don't actually set/reset 132 cols, but we do want the side effects + // (FIXME: Should only do this if the 95 DECSET bit (DECNCSM) is set, and if changing value?): + // Sets the left, right, top and bottom scrolling margins to their default positions, which is important for + // the "reset" utility to really reset the terminal: + mLeftMargin = mTopMargin = 0; + mBottomMargin = mRows; + mRightMargin = mColumns; + // "DECCOLM resets vertical split screen mode (DECLRMM) to unavailable": + setDecsetinternalBit(DECSET_BIT_LEFTRIGHT_MARGIN_MODE, false); + // "Erases all data in page memory": + blockClear(0, 0, mColumns, mRows); + setCursorRowCol(0, 0); + break; + case 4: // DECSCLM-Scrolling Mode. Ignore. + break; + case 5: // Reverse video. No action. + break; + case 6: // Set: Origin Mode. Reset: Normal Cursor Mode. Ansi name: DECOM. + if (setting) setCursorPosition(0, 0); + break; + case 7: // Wrap-around bit, not specific action. + case 8: // Auto-repeat Keys (DECARM). Do not implement. + case 9: // X10 mouse reporting - outdated. Do not implement. + case 12: // Control cursor blinking - ignore. + case 25: // Hide/show cursor - no action needed, renderer will check with isShowingCursor(). + case 40: // Allow 80 => 132 Mode, ignore. + case 45: // TODO: Reverse wrap-around. Implement??? + case 66: // Application keypad (DECNKM). + break; + case 69: // Left and right margin mode (DECLRMM). + if (!setting) { + mLeftMargin = 0; + mRightMargin = mColumns; + } + break; + case 1000: + case 1001: + case 1002: + case 1003: + case 1004: + case 1005: // UTF-8 mouse mode, ignore. + case 1006: // SGR Mouse Mode + case 1015: + case 1034: // Interpret "meta" key, sets eighth bit. + break; + case 1048: // Set: Save cursor as in DECSC. Reset: Restore cursor as in DECRC. + if (setting) + saveCursor(); + else + restoreCursor(); + break; + case 47: + case 1047: + case 1049: { + // Set: Save cursor as in DECSC and use Alternate Screen Buffer, clearing it first. + // Reset: Use Normal Screen Buffer and restore cursor as in DECRC. + TerminalBuffer newScreen = setting ? mAltBuffer : mMainBuffer; + if (newScreen != mScreen) { + boolean resized = !(newScreen.mColumns == mColumns && newScreen.mScreenRows == mRows); + if (setting) saveCursor(); + mScreen = newScreen; + if (!setting) { + int col = mSavedStateMain.mSavedCursorCol; + int row = mSavedStateMain.mSavedCursorRow; + restoreCursor(); + if (resized) { + // Restore cursor position _not_ clipped to current screen (let resizeScreen() handle that): + mCursorCol = col; + mCursorRow = row; + } + } + // Check if buffer size needs to be updated: + if (resized) resizeScreen(); + // Clear new screen if alt buffer: + if (newScreen == mAltBuffer) + newScreen.blockSet(0, 0, mColumns, mRows, ' ', getStyle()); + } + break; + } + case 2004: + // Bracketed paste mode - setting bit is enough. + break; + default: + unknownParameter(externalBit); + break; + } + } + + private void doCsiBiggerThan(int b) { + switch (b) { + case 'c': // "${CSI}>c" or "${CSI}>c". Secondary Device Attributes (DA2). + // Originally this was used for the terminal to respond with "identification code, firmware version level, + // and hardware options" (http://vt100.net/docs/vt510-rm/DA2), with the first "41" meaning the VT420 + // terminal type. This is not used anymore, but the second version level field has been changed by xterm + // to mean it's release number ("patch numbers" listed at http://invisible-island.net/xterm/xterm.log.html), + // and some applications use it as a feature check: + // * tmux used to have a "xterm won't reach version 500 for a while so set that as the upper limit" check, + // and then check "xterm_version > 270" if rectangular area operations such as DECCRA could be used. + // * vim checks xterm version number >140 for "Request termcap/terminfo string" functionality >276 for SGR + // mouse report. + // The third number is a keyboard identifier not used nowadays. + mSession.write("\033[>41;320;0c"); + break; + case 'm': + // https://bugs.launchpad.net/gnome-terminal/+bug/96676/comments/25 + // Depending on the first number parameter, this can set one of the xterm resources + // modifyKeyboard, modifyCursorKeys, modifyFunctionKeys and modifyOtherKeys. + // http://invisible-island.net/xterm/manpage/xterm.html#RESOURCES + + // * modifyKeyboard (parameter=1): + // Normally xterm makes a special case regarding modifiers (shift, control, etc.) to handle special keyboard + // layouts (legacy and vt220). This is done to provide compatible keyboards for DEC VT220 and related + // terminals that implement user-defined keys (UDK). + // The bits of the resource value selectively enable modification of the given category when these keyboards + // are selected. The default is "0": + // (0) The legacy/vt220 keyboards interpret only the Control-modifier when constructing numbered + // function-keys. Other special keys are not modified. + // (1) allows modification of the numeric keypad + // (2) allows modification of the editing keypad + // (4) allows modification of function-keys, overrides use of Shift-modifier for UDK. + // (8) allows modification of other special keys + + // * modifyCursorKeys (parameter=2): + // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a + // parameter to the escape sequence returned by a cursor-key. The default is "2". + // - Set it to -1 to disable it. + // - Set it to 0 to use the old/obsolete behavior. + // - Set it to 1 to prefix modified sequences with CSI. + // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first. + // - Set it to 3 to mark the sequence with a ">" to hint that it is private. + + // * modifyFunctionKeys (parameter=3): + // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a + // parameter to the escape sequence returned by a (numbered) function- + // key. The default is "2". The resource values are similar to modifyCursorKeys: + // Set it to -1 to permit the user to use shift- and control-modifiers to construct function-key strings + // using the normal encoding scheme. + // - Set it to 0 to use the old/obsolete behavior. + // - Set it to 1 to prefix modified sequences with CSI. + // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first. + // - Set it to 3 to mark the sequence with a ">" to hint that it is private. + // If modifyFunctionKeys is zero, xterm uses Control- and Shift-modifiers to allow the user to construct + // numbered function-keys beyond the set provided by the keyboard: + // (Control) adds the value given by the ctrlFKeys resource. + // (Shift) adds twice the value given by the ctrlFKeys resource. + // (Control/Shift) adds three times the value given by the ctrlFKeys resource. + // + // As a special case, legacy (when oldFunctionKeys is true) or vt220 (when sunKeyboard is true) + // keyboards interpret only the Control-modifier when constructing numbered function-keys. + // This is done to provide compatible keyboards for DEC VT220 and related terminals that + // implement user-defined keys (UDK). + + // * modifyOtherKeys (parameter=4): + // Like modifyCursorKeys, tells xterm to construct an escape sequence for other keys (such as "2") when + // modified by Control-, Alt- or Meta-modifiers. This feature does not apply to function keys and + // well-defined keys such as ESC or the control keys. The default is "0". + // (0) disables this feature. + // (1) enables this feature for keys except for those with well-known behavior, e.g., Tab, Backarrow and + // some special control character cases, e.g., Control-Space to make a NUL. + // (2) enables this feature for keys including the exceptions listed. + Log.e(EmulatorDebug.LOG_TAG, "(ignored) CSI > MODIFY RESOURCE: " + getArg0(-1) + " to " + getArg1(-1)); + break; + default: + parseArg(b); + break; + } + } + + private void startEscapeSequence() { + mEscapeState = ESC; + mArgIndex = 0; + Arrays.fill(mArgs, -1); + } + + private void doLinefeed() { + int newCursorRow = mCursorRow + 1; + if (newCursorRow >= mBottomMargin) { + scrollDownOneLine(); + newCursorRow = mBottomMargin - 1; + } + setCursorRow(newCursorRow); + } + + private void continueSequence(int state) { + mEscapeState = state; + mContinueSequence = true; + } + + private void doEscPound(int b) { + switch (b) { + case '8': // Esc # 8 - DEC screen alignment test - fill screen with E's. + mScreen.blockSet(0, 0, mColumns, mRows, 'E', getStyle()); + break; + default: + unknownSequence(b); + break; + } + } + + /** Encountering a character in the {@link #ESC} state. */ + private void doEsc(int b) { + switch (b) { + case '#': + continueSequence(ESC_POUND); + break; + case '(': + continueSequence(ESC_SELECT_LEFT_PAREN); + break; + case ')': + continueSequence(ESC_SELECT_RIGHT_PAREN); + break; + case '6': // Back index (http://www.vt100.net/docs/vt510-rm/DECBI). Move left, insert blank column if start. + if (mCursorCol > mLeftMargin) { + mCursorCol--; + } else { + int rows = mBottomMargin - mTopMargin; + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin + 1, mTopMargin); + mScreen.blockSet(mLeftMargin, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0)); + } + break; + case '7': // DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC + saveCursor(); + break; + case '8': // DECRC restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC + restoreCursor(); + break; + case '9': // Forward Index (http://www.vt100.net/docs/vt510-rm/DECFI). Move right, insert blank column if end. + if (mCursorCol < mRightMargin - 1) { + mCursorCol++; + } else { + int rows = mBottomMargin - mTopMargin; + mScreen.blockCopy(mLeftMargin + 1, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin, mTopMargin); + mScreen.blockSet(mRightMargin - 1, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0)); + } + break; + case 'c': // RIS - Reset to Initial State (http://vt100.net/docs/vt510-rm/RIS). + reset(); + blockClear(0, 0, mColumns, mRows); + setCursorPosition(0, 0); + break; + case 'D': // INDEX + doLinefeed(); + break; + case 'E': // Next line (http://www.vt100.net/docs/vt510-rm/NEL). + setCursorCol(isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE) ? mLeftMargin : 0); + doLinefeed(); + break; + case 'F': // Cursor to lower-left corner of screen + setCursorRowCol(0, mBottomMargin - 1); + break; + case 'H': // Tab set + mTabStop[mCursorCol] = true; + break; + case 'M': // "${ESC}M" - reverse index (RI). + // http://www.vt100.net/docs/vt100-ug/chapter3.html: "Move the active position to the same horizontal + // position on the preceding line. If the active position is at the top margin, a scroll down is performed". + if (mCursorRow <= mTopMargin) { + mScreen.blockCopy(0, mTopMargin, mColumns, mBottomMargin - (mTopMargin + 1), 0, mTopMargin + 1); + blockClear(0, mTopMargin, mColumns); + } else { + mCursorRow--; + } + break; + case 'N': // SS2, ignore. + case '0': // SS3, ignore. + break; + case 'P': // Device control string + mOSCOrDeviceControlArgs.setLength(0); + continueSequence(ESC_P); + break; + case '[': + continueSequence(ESC_CSI); + break; + case '=': // DECKPAM + setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, true); + break; + case ']': // OSC + mOSCOrDeviceControlArgs.setLength(0); + continueSequence(ESC_OSC); + break; + case '>': // DECKPNM + setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false); + break; + default: + unknownSequence(b); + break; + } + } + + /** DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC . See {@link #restoreCursor()}. */ + private void saveCursor() { + SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt; + state.mSavedCursorRow = mCursorRow; + state.mSavedCursorCol = mCursorCol; + state.mSavedEffect = mEffect; + state.mSavedForeColor = mForeColor; + state.mSavedBackColor = mBackColor; + state.mSavedDecFlags = mCurrentDecSetFlags; + state.mUseLineDrawingG0 = mUseLineDrawingG0; + state.mUseLineDrawingG1 = mUseLineDrawingG1; + state.mUseLineDrawingUsesG0 = mUseLineDrawingUsesG0; + } + + /** DECRS restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC. See {@link #saveCursor()}. */ + private void restoreCursor() { + SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt; + setCursorRowCol(state.mSavedCursorRow, state.mSavedCursorCol); + mEffect = state.mSavedEffect; + mForeColor = state.mSavedForeColor; + mBackColor = state.mSavedBackColor; + int mask = (DECSET_BIT_AUTOWRAP | DECSET_BIT_ORIGIN_MODE); + mCurrentDecSetFlags = (mCurrentDecSetFlags & ~mask) | (state.mSavedDecFlags & mask); + mUseLineDrawingG0 = state.mUseLineDrawingG0; + mUseLineDrawingG1 = state.mUseLineDrawingG1; + mUseLineDrawingUsesG0 = state.mUseLineDrawingUsesG0; + } + + /** Following a CSI - Control Sequence Introducer, "\033[". {@link #ESC_CSI}. */ + private void doCsi(int b) { + switch (b) { + case '!': + continueSequence(ESC_CSI_EXCLAMATION); + break; + case '"': + continueSequence(ESC_CSI_DOUBLE_QUOTE); + break; + case '\'': + continueSequence(ESC_CSI_SINGLE_QUOTE); + break; + case '$': + continueSequence(ESC_CSI_DOLLAR); + break; + case '*': + continueSequence(ESC_CSI_ARGS_ASTERIX); + break; + case '@': { + // "CSI{n}@" - Insert ${n} space characters (ICH) - http://www.vt100.net/docs/vt510-rm/ICH. + mAboutToAutoWrap = false; + int columnsAfterCursor = mColumns - mCursorCol; + int spacesToInsert = Math.min(getArg0(1), columnsAfterCursor); + int charsToMove = columnsAfterCursor - spacesToInsert; + mScreen.blockCopy(mCursorCol, mCursorRow, charsToMove, 1, mCursorCol + spacesToInsert, mCursorRow); + blockClear(mCursorCol, mCursorRow, spacesToInsert); + } + break; + case 'A': // "CSI${n}A" - Cursor up (CUU) ${n} rows. + setCursorRow(Math.max(mTopMargin, mCursorRow - getArg0(1))); + break; + case 'B': // "CSI${n}B" - Cursor down (CUD) ${n} rows. + setCursorRow(Math.min(mBottomMargin - 1, mCursorRow + getArg0(1))); + break; + case 'C': // "CSI${n}C" - Cursor forward (CUF). + case 'a': // "CSI${n}a" - Horizontal position relative (HPR). From ISO-6428/ECMA-48. + setCursorCol(Math.min(mRightMargin - 1, mCursorCol + getArg0(1))); + break; + case 'D': // "CSI${n}D" - Cursor backward (CUB) ${n} columns. + setCursorCol(Math.max(mLeftMargin, mCursorCol - getArg0(1))); + break; + case 'E': // "CSI{n}E - Cursor Next Line (CNL). From ISO-6428/ECMA-48. + setCursorPosition(0, mCursorRow + getArg0(1)); + break; + case 'F': // "CSI{n}F - Cursor Previous Line (CPL). From ISO-6428/ECMA-48. + setCursorPosition(0, mCursorRow - getArg0(1)); + break; + case 'G': // "CSI${n}G" - Cursor horizontal absolute (CHA) to column ${n}. + setCursorCol(Math.min(Math.max(1, getArg0(1)), mColumns) - 1); + break; + case 'H': // "${CSI}${ROW};${COLUMN}H" - Cursor position (CUP). + case 'f': // "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). + setCursorPosition(getArg1(1) - 1, getArg0(1) - 1); + break; + case 'I': // Cursor Horizontal Forward Tabulation (CHT). Move the active position n tabs forward. + setCursorCol(nextTabStop(getArg0(1))); + break; + case 'J': // "${CSI}${0,1,2}J" - Erase in Display (ED) + // ED ignores the scrolling margins. + switch (getArg0(0)) { + case 0: // Erase from the active position to the end of the screen, inclusive (default). + blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol); + blockClear(0, mCursorRow + 1, mColumns, mRows - (mCursorRow + 1)); + break; + case 1: // Erase from start of the screen to the active position, inclusive. + blockClear(0, 0, mColumns, mCursorRow); + blockClear(0, mCursorRow, mCursorCol + 1); + break; + case 2: // Erase all of the display - all lines are erased, changed to single-width, and the cursor does not + // move.. + blockClear(0, 0, mColumns, mRows); + break; + default: + unknownSequence(b); + return; + } + mAboutToAutoWrap = false; + break; + case 'K': // "CSI{n}K" - Erase in line (EL). + switch (getArg0(0)) { + case 0: // Erase from the cursor to the end of the line, inclusive (default) + blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol); + break; + case 1: // Erase from the start of the screen to the cursor, inclusive. + blockClear(0, mCursorRow, mCursorCol + 1); + break; + case 2: // Erase all of the line. + blockClear(0, mCursorRow, mColumns); + break; + default: + unknownSequence(b); + return; + } + mAboutToAutoWrap = false; + break; + case 'L': // "${CSI}{N}L" - insert ${N} lines (IL). + { + int linesAfterCursor = mBottomMargin - mCursorRow; + int linesToInsert = Math.min(getArg0(1), linesAfterCursor); + int linesToMove = linesAfterCursor - linesToInsert; + mScreen.blockCopy(0, mCursorRow, mColumns, linesToMove, 0, mCursorRow + linesToInsert); + blockClear(0, mCursorRow, mColumns, linesToInsert); + } + break; + case 'M': // "${CSI}${N}M" - delete N lines (DL). + { + mAboutToAutoWrap = false; + int linesAfterCursor = mBottomMargin - mCursorRow; + int linesToDelete = Math.min(getArg0(1), linesAfterCursor); + int linesToMove = linesAfterCursor - linesToDelete; + mScreen.blockCopy(0, mCursorRow + linesToDelete, mColumns, linesToMove, 0, mCursorRow); + blockClear(0, mCursorRow + linesToMove, mColumns, linesToDelete); + } + break; + case 'P': // "${CSI}{N}P" - delete ${N} characters (DCH). + { + // http://www.vt100.net/docs/vt510-rm/DCH: "If ${N} is greater than the number of characters between the + // cursor and the right margin, then DCH only deletes the remaining characters. + // As characters are deleted, the remaining characters between the cursor and right margin move to the left. + // Character attributes move with the characters. The terminal adds blank spaces with no visual character + // attributes at the right margin. DCH has no effect outside the scrolling margins." + mAboutToAutoWrap = false; + int cellsAfterCursor = mColumns - mCursorCol; + int cellsToDelete = Math.min(getArg0(1), cellsAfterCursor); + int cellsToMove = cellsAfterCursor - cellsToDelete; + mScreen.blockCopy(mCursorCol + cellsToDelete, mCursorRow, cellsToMove, 1, mCursorCol, mCursorRow); + blockClear(mCursorCol + cellsToMove, mCursorRow, cellsToDelete); + } + break; + case 'S': { // "${CSI}${N}S" - scroll up ${N} lines (default = 1) (SU). + final int linesToScroll = getArg0(1); + for (int i = 0; i < linesToScroll; i++) + scrollDownOneLine(); + break; + } + case 'T': + if (mArgIndex == 0) { + // "${CSI}${N}T" - Scroll down N lines (default = 1) (SD). + // http://vt100.net/docs/vt510-rm/SD: "N is the number of lines to move the user window up in page + // memory. N new lines appear at the top of the display. N old lines disappear at the bottom of the + // display. You cannot pan past the top margin of the current page". + final int linesToScrollArg = getArg0(1); + final int linesBetweenTopAndBottomMargins = mBottomMargin - mTopMargin; + final int linesToScroll = Math.min(linesBetweenTopAndBottomMargins, linesToScrollArg); + mScreen.blockCopy(0, mTopMargin, mColumns, linesBetweenTopAndBottomMargins - linesToScroll, 0, mTopMargin + linesToScroll); + blockClear(0, mTopMargin, mColumns, linesToScroll); + } else { + // "${CSI}${func};${startx};${starty};${firstrow};${lastrow}T" - initiate highlight mouse tracking. + unimplementedSequence(b); + } + break; + case 'X': // "${CSI}${N}X" - Erase ${N:=1} character(s) (ECH). FIXME: Clears character attributes? + mAboutToAutoWrap = false; + mScreen.blockSet(mCursorCol, mCursorRow, Math.min(getArg0(1), mColumns - mCursorCol), 1, ' ', getStyle()); + break; + case 'Z': // Cursor Backward Tabulation (CBT). Move the active position n tabs backward. + int numberOfTabs = getArg0(1); + int newCol = mLeftMargin; + for (int i = mCursorCol - 1; i >= 0; i--) + if (mTabStop[i]) { + if (--numberOfTabs == 0) { + newCol = Math.max(i, mLeftMargin); + break; + } + } + mCursorCol = newCol; + break; + case '?': // Esc [ ? -- start of a private mode set + continueSequence(ESC_CSI_QUESTIONMARK); + break; + case '>': // "Esc [ >" -- + continueSequence(ESC_CSI_BIGGERTHAN); + break; + case '`': // Horizontal position absolute (HPA - http://www.vt100.net/docs/vt510-rm/HPA). + setCursorColRespectingOriginMode(getArg0(1) - 1); + break; + case 'c': // Primary Device Attributes (http://www.vt100.net/docs/vt510-rm/DA1) if argument is missing or zero. + // The important part that may still be used by some (tmux stores this value but does not currently use it) + // is the first response parameter identifying the terminal service class, where we send 64 for "vt420". + // This is followed by a list of attributes which is probably unused by applications. Send like xterm. + if (getArg0(0) == 0) mSession.write("\033[?64;1;2;6;9;15;18;21;22c"); + break; + case 'd': // ESC [ Pn d - Vert Position Absolute + setCursorRow(Math.min(Math.max(1, getArg0(1)), mRows) - 1); + break; + case 'e': // Vertical Position Relative (VPR). From ISO-6429 (ECMA-48). + setCursorPosition(mCursorCol, mCursorRow + getArg0(1)); + break; + // case 'f': "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). Grouped with case 'H'. + case 'g': // Clear tab_switcher stop + switch (getArg0(0)) { + case 0: + mTabStop[mCursorCol] = false; + break; + case 3: + for (int i = 0; i < mColumns; i++) { + mTabStop[i] = false; + } + break; + default: + // Specified to have no effect. + break; + } + break; + case 'h': // Set Mode + doSetMode(true); + break; + case 'l': // Reset Mode + doSetMode(false); + break; + case 'm': // Esc [ Pn m - character attributes. (can have up to 16 numerical arguments) + selectGraphicRendition(); + break; + case 'n': // Esc [ Pn n - ECMA-48 Status Report Commands + // sendDeviceAttributes() + switch (getArg0(0)) { + case 5: // Device status report (DSR): + // Answer is ESC [ 0 n (Terminal OK). + byte[] dsr = {(byte) 27, (byte) '[', (byte) '0', (byte) 'n'}; + mSession.write(dsr, 0, dsr.length); + break; + case 6: // Cursor position report (CPR): + // Answer is ESC [ y ; x R, where x,y is + // the cursor location. + mSession.write(String.format(Locale.US, "\033[%d;%dR", mCursorRow + 1, mCursorCol + 1)); + break; + default: + break; + } + break; + case 'r': // "CSI${top};${bottom}r" - set top and bottom Margins (DECSTBM). + { + // http://www.vt100.net/docs/vt510-rm/DECSTBM + // The top margin defaults to 1, the bottom margin defaults to mRows. + // The escape sequence numbers top 1..23, but we number top 0..22. + // The escape sequence numbers bottom 2..24, and so do we (because we use a zero based numbering + // scheme, but we store the first line below the bottom-most scrolling line. + // As a result, we adjust the top line by -1, but we leave the bottom line alone. + // Also require that top + 2 <= bottom. + mTopMargin = Math.max(0, Math.min(getArg0(1) - 1, mRows - 2)); + mBottomMargin = Math.max(mTopMargin + 2, Math.min(getArg1(mRows), mRows)); + // DECSTBM moves the cursor to column 1, line 1 of the page respecting origin mode. + setCursorPosition(0, 0); + } + break; + case 's': + if (isDecsetInternalBitSet(DECSET_BIT_LEFTRIGHT_MARGIN_MODE)) { + // Set left and right margins (DECSLRM - http://www.vt100.net/docs/vt510-rm/DECSLRM). + mLeftMargin = Math.min(getArg0(1) - 1, mColumns - 2); + mRightMargin = Math.max(mLeftMargin + 1, Math.min(getArg1(mColumns), mColumns)); + // DECSLRM moves the cursor to column 1, line 1 of the page. + setCursorPosition(0, 0); + } else { + // Save cursor (ANSI.SYS), available only when DECLRMM is disabled. + saveCursor(); + } + break; + case 't': // Window manipulation (from dtterm, as well as extensions) + switch (getArg0(0)) { + case 11: // Report xterm window state. If the xterm window is open (non-iconified), it returns CSI 1 t . + mSession.write("\033[1t"); + break; + case 13: // Report xterm window position. Result is CSI 3 ; x ; y t + mSession.write("\033[3;0;0t"); + break; + case 14: // Report xterm window in pixels. Result is CSI 4 ; height ; width t + // We just report characters time 12 here. + mSession.write(String.format(Locale.US, "\033[4;%d;%dt", mRows * 12, mColumns * 12)); + break; + case 18: // Report the size of the text area in characters. Result is CSI 8 ; height ; width t + mSession.write(String.format(Locale.US, "\033[8;%d;%dt", mRows, mColumns)); + break; + case 19: // Report the size of the screen in characters. Result is CSI 9 ; height ; width t + // We report the same size as the view, since it's the view really isn't resizable from the shell. + mSession.write(String.format(Locale.US, "\033[9;%d;%dt", mRows, mColumns)); + break; + case 20: // Report xterm windows icon label. Result is OSC L label ST. Disabled due to security concerns: + mSession.write("\033]LIconLabel\033\\"); + break; + case 21: // Report xterm windows title. Result is OSC l label ST. Disabled due to security concerns: + mSession.write("\033]l\033\\"); + break; + case 22: + // 22;0 -> Save xterm icon and window title on stack. + // 22;1 -> Save xterm icon title on stack. + // 22;2 -> Save xterm window title on stack. + mTitleStack.push(mTitle); + if (mTitleStack.size() > 20) { + // Limit size + mTitleStack.remove(0); + } + break; + case 23: // Like 22 above but restore from stack. + if (!mTitleStack.isEmpty()) setTitle(mTitleStack.pop()); + break; + default: + // Ignore window manipulation. + break; + } + break; + case 'u': // Restore cursor (ANSI.SYS). + restoreCursor(); + break; + case ' ': + continueSequence(ESC_CSI_ARGS_SPACE); + break; + default: + parseArg(b); + break; + } + } + + /** Select Graphic Rendition (SGR) - see http://en.wikipedia.org/wiki/ANSI_escape_code#graphics. */ + private void selectGraphicRendition() { + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int code = mArgs[i]; + if (code < 0) { + if (mArgIndex > 0) { + continue; + } else { + code = 0; + } + } + if (code == 0) { // reset + mForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + mEffect = 0; + } else if (code == 1) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BOLD; + } else if (code == 2) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_DIM; + } else if (code == 3) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_ITALIC; + } else if (code == 4) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else if (code == 5) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BLINK; + } else if (code == 7) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + } else if (code == 8) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE; + } else if (code == 9) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; + } else if (code == 10) { + // Exit alt charset (TERM=linux) - ignore. + } else if (code == 11) { + // Enter alt charset (TERM=linux) - ignore. + } else if (code == 22) { // Normal color or intensity, neither bright, bold nor faint. + mEffect &= ~(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_DIM); + } else if (code == 23) { // not italic, but rarely used as such; clears standout with TERM=screen + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_ITALIC; + } else if (code == 24) { // underline: none + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else if (code == 25) { // blink: none + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_BLINK; + } else if (code == 27) { // image: positive + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + } else if (code == 28) { + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE; + } else if (code == 29) { + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; + } else if (code >= 30 && code <= 37) { + mForeColor = code - 30; + } else if (code == 38 || code == 48) { + // Extended set foreground(38)/background (48) color. + // This is followed by either "2;$R;$G;$B" to set a 24-bit color or + // "5;$INDEX" to set an indexed color. + if (i + 2 > mArgIndex) continue; + int firstArg = mArgs[i + 1]; + if (firstArg == 2) { + if (i + 4 > mArgIndex) { + Log.w(EmulatorDebug.LOG_TAG, "Too few CSI" + code + ";2 RGB arguments"); + } else { + int red = mArgs[i + 2], green = mArgs[i + 3], blue = mArgs[i + 4]; + if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) { + finishSequenceAndLogError("Invalid RGB: " + red + "," + green + "," + blue); + } else { + int argbColor = 0xff000000 | (red << 16) | (green << 8) | blue; + if (code == 38) { + mForeColor = argbColor; + } else { + mBackColor = argbColor; + } + } + i += 4; // "2;P_r;P_g;P_r" + } + } else if (firstArg == 5) { + int color = mArgs[i + 2]; + i += 2; // "5;P_s" + if (color >= 0 && color < TextStyle.NUM_INDEXED_COLORS) { + if (code == 38) { + mForeColor = color; + } else { + mBackColor = color; + } + } else { + if (LOG_ESCAPE_SEQUENCES) Log.w(EmulatorDebug.LOG_TAG, "Invalid color index: " + color); + } + } else { + finishSequenceAndLogError("Invalid ISO-8613-3 SGR first argument: " + firstArg); + } + } else if (code == 39) { // Set default foreground color. + mForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (code >= 40 && code <= 47) { // Set background color. + mBackColor = code - 40; + } else if (code == 49) { // Set default background color. + mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (code >= 90 && code <= 97) { // Bright foreground colors (aixterm codes). + mForeColor = code - 90 + 8; + } else if (code >= 100 && code <= 107) { // Bright background color (aixterm codes). + mBackColor = code - 100 + 8; + } else { + if (LOG_ESCAPE_SEQUENCES) + Log.w(EmulatorDebug.LOG_TAG, String.format("SGR unknown code %d", code)); + } + } + } + + private void doOsc(int b) { + switch (b) { + case 7: // Bell. + doOscSetTextParameters("\007"); + break; + case 27: // Escape. + continueSequence(ESC_OSC_ESC); + break; + default: + collectOSCArgs(b); + break; + } + } + + private void doOscEsc(int b) { + switch (b) { + case '\\': + doOscSetTextParameters("\033\\"); + break; + default: + // The ESC character was not followed by a \, so insert the ESC and + // the current character in arg buffer. + collectOSCArgs(27); + collectOSCArgs(b); + continueSequence(ESC_OSC); + break; + } + } + + /** An Operating System Controls (OSC) Set Text Parameters. May come here from BEL or ST. */ + private void doOscSetTextParameters(String bellOrStringTerminator) { + int value = -1; + String textParameter = ""; + // Extract initial $value from initial "$value;..." string. + for (int mOSCArgTokenizerIndex = 0; mOSCArgTokenizerIndex < mOSCOrDeviceControlArgs.length(); mOSCArgTokenizerIndex++) { + char b = mOSCOrDeviceControlArgs.charAt(mOSCArgTokenizerIndex); + if (b == ';') { + textParameter = mOSCOrDeviceControlArgs.substring(mOSCArgTokenizerIndex + 1); + break; + } else if (b >= '0' && b <= '9') { + value = ((value < 0) ? 0 : value * 10) + (b - '0'); + } else { + unknownSequence(b); + return; + } + } + + switch (value) { + case 0: // Change icon name and window title to T. + case 1: // Change icon name to T. + case 2: // Change window title to T. + setTitle(textParameter); + break; + case 4: + // P s = 4 ; c ; spec → Change Color Number c to the color specified by spec. This can be a name or RGB + // specification as per XParseColor. Any number of c name pairs may be given. The color numbers correspond + // to the ANSI colors 0-7, their bright versions 8-15, and if supported, the remainder of the 88-color or + // 256-color table. + // If a "?" is given rather than a name or RGB specification, xterm replies with a control sequence of the + // same form which can be used to set the corresponding color. Because more than one pair of color number + // and specification can be given in one control sequence, xterm can make more than one reply. + int colorIndex = -1; + int parsingPairStart = -1; + for (int i = 0; ; i++) { + boolean endOfInput = i == textParameter.length(); + char b = endOfInput ? ';' : textParameter.charAt(i); + if (b == ';') { + if (parsingPairStart < 0) { + parsingPairStart = i + 1; + } else { + if (colorIndex < 0 || colorIndex > 255) { + unknownSequence(b); + return; + } else { + mColors.tryParseColor(colorIndex, textParameter.substring(parsingPairStart, i)); + mSession.onColorsChanged(); + colorIndex = -1; + parsingPairStart = -1; + } + } + } else if (parsingPairStart >= 0) { + // We have passed a color index and are now going through color spec. + } else if (parsingPairStart < 0 && (b >= '0' && b <= '9')) { + colorIndex = ((colorIndex < 0) ? 0 : colorIndex * 10) + (b - '0'); + } else { + unknownSequence(b); + return; + } + if (endOfInput) break; + } + break; + case 10: // Set foreground color. + case 11: // Set background color. + case 12: // Set cursor color. + int specialIndex = TextStyle.COLOR_INDEX_FOREGROUND + (value - 10); + int lastSemiIndex = 0; + for (int charIndex = 0; ; charIndex++) { + boolean endOfInput = charIndex == textParameter.length(); + if (endOfInput || textParameter.charAt(charIndex) == ';') { + try { + String colorSpec = textParameter.substring(lastSemiIndex, charIndex); + if ("?".equals(colorSpec)) { + // Report current color in the same format xterm and gnome-terminal does. + int rgb = mColors.mCurrentColors[specialIndex]; + int r = (65535 * ((rgb & 0x00FF0000) >> 16)) / 255; + int g = (65535 * ((rgb & 0x0000FF00) >> 8)) / 255; + int b = (65535 * ((rgb & 0x000000FF))) / 255; + mSession.write("\033]" + value + ";rgb:" + String.format(Locale.US, "%04x", r) + "/" + String.format(Locale.US, "%04x", g) + "/" + + String.format(Locale.US, "%04x", b) + bellOrStringTerminator); + } else { + mColors.tryParseColor(specialIndex, colorSpec); + mSession.onColorsChanged(); + } + specialIndex++; + if (endOfInput || (specialIndex > TextStyle.COLOR_INDEX_CURSOR) || ++charIndex >= textParameter.length()) + break; + lastSemiIndex = charIndex; + } catch (NumberFormatException e) { + // Ignore. + } + } + } + break; + case 52: // Manipulate Selection Data. Skip the optional first selection parameter(s). + int startIndex = textParameter.indexOf(";") + 1; + try { + String clipboardText = new String(Base64.decode(textParameter.substring(startIndex), 0), StandardCharsets.UTF_8); + mSession.clipboardText(clipboardText); + } catch (Exception e) { + Log.e(EmulatorDebug.LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + ""); + } + break; + case 104: + // "104;$c" → Reset Color Number $c. It is reset to the color specified by the corresponding X + // resource. Any number of c parameters may be given. These parameters correspond to the ANSI colors 0-7, + // their bright versions 8-15, and if supported, the remainder of the 88-color or 256-color table. If no + // parameters are given, the entire table will be reset. + if (textParameter.isEmpty()) { + mColors.reset(); + mSession.onColorsChanged(); + } else { + int lastIndex = 0; + for (int charIndex = 0; ; charIndex++) { + boolean endOfInput = charIndex == textParameter.length(); + if (endOfInput || textParameter.charAt(charIndex) == ';') { + try { + int colorToReset = Integer.parseInt(textParameter.substring(lastIndex, charIndex)); + mColors.reset(colorToReset); + mSession.onColorsChanged(); + if (endOfInput) break; + charIndex++; + lastIndex = charIndex; + } catch (NumberFormatException e) { + // Ignore. + } + } + } + } + break; + case 110: // Reset foreground color. + case 111: // Reset background color. + case 112: // Reset cursor color. + mColors.reset(TextStyle.COLOR_INDEX_FOREGROUND + (value - 110)); + mSession.onColorsChanged(); + break; + case 119: // Reset highlight color. + break; + default: + unknownParameter(value); + break; + } + finishSequence(); + } + + private void blockClear(int sx, int sy, int w) { + blockClear(sx, sy, w, 1); + } + + private void blockClear(int sx, int sy, int w, int h) { + mScreen.blockSet(sx, sy, w, h, ' ', getStyle()); + } + + private long getStyle() { + return TextStyle.encode(mForeColor, mBackColor, mEffect); + } + + /** "CSI P_m h" for set or "CSI P_m l" for reset ANSI mode. */ + private void doSetMode(boolean newValue) { + int modeBit = getArg0(0); + switch (modeBit) { + case 4: // Set="Insert Mode". Reset="Replace Mode". (IRM). + mInsertMode = newValue; + break; + case 20: // Normal Linefeed (LNM). + unknownParameter(modeBit); + // http://www.vt100.net/docs/vt510-rm/LNM + break; + case 34: + // Normal cursor visibility - when using TERM=screen, see + // http://www.gnu.org/software/screen/manual/html_node/Control-Sequences.html + break; + default: + unknownParameter(modeBit); + break; + } + } + + /** + * NOTE: The parameters of this function respect the {@link #DECSET_BIT_ORIGIN_MODE}. Use + * {@link #setCursorRowCol(int, int)} for absolute pos. + */ + private void setCursorPosition(int x, int y) { + boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); + int effectiveTopMargin = originMode ? mTopMargin : 0; + int effectiveBottomMargin = originMode ? mBottomMargin : mRows; + int effectiveLeftMargin = originMode ? mLeftMargin : 0; + int effectiveRightMargin = originMode ? mRightMargin : mColumns; + int newRow = Math.max(effectiveTopMargin, Math.min(effectiveTopMargin + y, effectiveBottomMargin - 1)); + int newCol = Math.max(effectiveLeftMargin, Math.min(effectiveLeftMargin + x, effectiveRightMargin - 1)); + setCursorRowCol(newRow, newCol); + } + + private void scrollDownOneLine() { + mScrollCounter++; + if (mLeftMargin != 0 || mRightMargin != mColumns) { + // Horizontal margin: Do not put anything into scroll history, just non-margin part of screen up. + mScreen.blockCopy(mLeftMargin, mTopMargin + 1, mRightMargin - mLeftMargin, mBottomMargin - mTopMargin - 1, mLeftMargin, mTopMargin); + // .. and blank bottom row between margins: + mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' ', mEffect); + } else { + mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, getStyle()); + } + } + + /** Process the next ASCII character of a parameter. */ + private void parseArg(int b) { + if (b >= '0' && b <= '9') { + if (mArgIndex < mArgs.length) { + int oldValue = mArgs[mArgIndex]; + int thisDigit = b - '0'; + int value; + if (oldValue >= 0) { + value = oldValue * 10 + thisDigit; + } else { + value = thisDigit; + } + mArgs[mArgIndex] = value; + } + continueSequence(mEscapeState); + } else if (b == ';') { + if (mArgIndex < mArgs.length) { + mArgIndex++; + } + continueSequence(mEscapeState); + } else { + unknownSequence(b); + } + } + + private int getArg0(int defaultValue) { + return getArg(0, defaultValue, true); + } + + private int getArg1(int defaultValue) { + return getArg(1, defaultValue, true); + } + + private int getArg(int index, int defaultValue, boolean treatZeroAsDefault) { + int result = mArgs[index]; + if (result < 0 || (result == 0 && treatZeroAsDefault)) { + result = defaultValue; + } + return result; + } + + private void collectOSCArgs(int b) { + if (mOSCOrDeviceControlArgs.length() < MAX_OSC_STRING_LENGTH) { + mOSCOrDeviceControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + } else { + unknownSequence(b); + } + } + + private void unimplementedSequence(int b) { + logError("Unimplemented sequence char '" + (char) b + "' (U+" + String.format("%04x", b) + ")"); + finishSequence(); + } + + private void unknownSequence(int b) { + logError("Unknown sequence char '" + (char) b + "' (numeric value=" + b + ")"); + finishSequence(); + } + + private void unknownParameter(int parameter) { + logError("Unknown parameter: " + parameter); + finishSequence(); + } + + private void logError(String errorType) { + if (LOG_ESCAPE_SEQUENCES) { + StringBuilder buf = new StringBuilder(); + buf.append(errorType); + buf.append(", escapeState="); + buf.append(mEscapeState); + boolean firstArg = true; + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int value = mArgs[i]; + if (value >= 0) { + if (firstArg) { + firstArg = false; + buf.append(", args={"); + } else { + buf.append(','); + } + buf.append(value); + } + } + if (!firstArg) buf.append('}'); + finishSequenceAndLogError(buf.toString()); + } + } + + private void finishSequenceAndLogError(String error) { + if (LOG_ESCAPE_SEQUENCES) Log.w(EmulatorDebug.LOG_TAG, error); + finishSequence(); + } + + private void finishSequence() { + mEscapeState = ESC_NONE; + } + + /** + * Send a Unicode code point to the screen. + * + * @param codePoint The code point of the character to display + */ + private void emitCodePoint(int codePoint) { + if (mUseLineDrawingUsesG0 ? mUseLineDrawingG0 : mUseLineDrawingG1) { + // http://www.vt100.net/docs/vt102-ug/table5-15.html. + switch (codePoint) { + case '_': + codePoint = ' '; // Blank. + break; + case '`': + codePoint = '◆'; // Diamond. + break; + case '0': + codePoint = '█'; // Solid block; + break; + case 'a': + codePoint = '▒'; // Checker board. + break; + case 'b': + codePoint = '␉'; // Horizontal tab_switcher. + break; + case 'c': + codePoint = '␌'; // Form feed. + break; + case 'd': + codePoint = '\r'; // Carriage return. + break; + case 'e': + codePoint = '␊'; // Linefeed. + break; + case 'f': + codePoint = '°'; // Degree. + break; + case 'g': + codePoint = '±'; // Plus-minus. + break; + case 'h': + codePoint = '\n'; // Newline. + break; + case 'i': + codePoint = '␋'; // Vertical tab_switcher. + break; + case 'j': + codePoint = '┘'; // Lower right corner. + break; + case 'k': + codePoint = '┐'; // Upper right corner. + break; + case 'l': + codePoint = '┌'; // Upper left corner. + break; + case 'm': + codePoint = '└'; // Left left corner. + break; + case 'n': + codePoint = '┼'; // Crossing lines. + break; + case 'o': + codePoint = '⎺'; // Horizontal line - scan 1. + break; + case 'p': + codePoint = '⎻'; // Horizontal line - scan 3. + break; + case 'q': + codePoint = '─'; // Horizontal line - scan 5. + break; + case 'r': + codePoint = '⎼'; // Horizontal line - scan 7. + break; + case 's': + codePoint = '⎽'; // Horizontal line - scan 9. + break; + case 't': + codePoint = '├'; // T facing rightwards. + break; + case 'u': + codePoint = '┤'; // T facing leftwards. + break; + case 'v': + codePoint = '┴'; // T facing upwards. + break; + case 'w': + codePoint = '┬'; // T facing downwards. + break; + case 'x': + codePoint = '│'; // Vertical line. + break; + case 'y': + codePoint = '≤'; // Less than or equal to. + break; + case 'z': + codePoint = '≥'; // Greater than or equal to. + break; + case '{': + codePoint = 'π'; // Pi. + break; + case '|': + codePoint = '≠'; // Not equal to. + break; + case '}': + codePoint = '£'; // UK pound. + break; + case '~': + codePoint = '·'; // Centered dot. + break; + } + } + + final boolean autoWrap = isDecsetInternalBitSet(DECSET_BIT_AUTOWRAP); + final int displayWidth = WcWidth.width(codePoint); + final boolean cursorInLastColumn = mCursorCol == mRightMargin - 1; + + if (autoWrap) { + if (cursorInLastColumn && ((mAboutToAutoWrap && displayWidth == 1) || displayWidth == 2)) { + mScreen.setLineWrap(mCursorRow); + mCursorCol = mLeftMargin; + if (mCursorRow + 1 < mBottomMargin) { + mCursorRow++; + } else { + scrollDownOneLine(); + } + } + } else if (cursorInLastColumn && displayWidth == 2) { + // The behaviour when a wide character is output with cursor in the last column when + // autowrap is disabled is not obvious - it's ignored here. + return; + } + + if (mInsertMode && displayWidth > 0) { + // Move character to right one space. + int destCol = mCursorCol + displayWidth; + if (destCol < mRightMargin) + mScreen.blockCopy(mCursorCol, mCursorRow, mRightMargin - destCol, 1, destCol, mCursorRow); + } + + int offsetDueToCombiningChar = ((displayWidth <= 0 && mCursorCol > 0 && !mAboutToAutoWrap) ? 1 : 0); + mScreen.setChar(mCursorCol - offsetDueToCombiningChar, mCursorRow, codePoint, getStyle()); + + if (autoWrap && displayWidth > 0) + mAboutToAutoWrap = (mCursorCol == mRightMargin - displayWidth); + + mCursorCol = Math.min(mCursorCol + displayWidth, mRightMargin - 1); + } + + private void setCursorRow(int row) { + mCursorRow = row; + mAboutToAutoWrap = false; + } + + private void setCursorCol(int col) { + mCursorCol = col; + mAboutToAutoWrap = false; + } + + /** Set the cursor mode, but limit it to margins if {@link #DECSET_BIT_ORIGIN_MODE} is enabled. */ + private void setCursorColRespectingOriginMode(int col) { + setCursorPosition(col, mCursorRow); + } + + /** TODO: Better name, distinguished from {@link #setCursorPosition(int, int)} by not regarding origin mode. */ + private void setCursorRowCol(int row, int col) { + mCursorRow = Math.max(0, Math.min(row, mRows - 1)); + mCursorCol = Math.max(0, Math.min(col, mColumns - 1)); + mAboutToAutoWrap = false; + } + + public int getScrollCounter() { + return mScrollCounter; + } + + public void clearScrollCounter() { + mScrollCounter = 0; + } + + /** Reset terminal state so user can interact with it regardless of present state. */ + public void reset() { + mCursorStyle = CURSOR_STYLE_BLOCK; + mArgIndex = 0; + mContinueSequence = false; + mEscapeState = ESC_NONE; + mInsertMode = false; + mTopMargin = mLeftMargin = 0; + mBottomMargin = mRows; + mRightMargin = mColumns; + mAboutToAutoWrap = false; + mForeColor = mSavedStateMain.mSavedForeColor = mSavedStateAlt.mSavedForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + mBackColor = mSavedStateMain.mSavedBackColor = mSavedStateAlt.mSavedBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + setDefaultTabStops(); + + mUseLineDrawingG0 = mUseLineDrawingG1 = false; + mUseLineDrawingUsesG0 = true; + + mSavedStateMain.mSavedCursorRow = mSavedStateMain.mSavedCursorCol = mSavedStateMain.mSavedEffect = mSavedStateMain.mSavedDecFlags = 0; + mSavedStateAlt.mSavedCursorRow = mSavedStateAlt.mSavedCursorCol = mSavedStateAlt.mSavedEffect = mSavedStateAlt.mSavedDecFlags = 0; + mCurrentDecSetFlags = 0; + // Initial wrap-around is not accurate but makes terminal more useful, especially on a small screen: + setDecsetinternalBit(DECSET_BIT_AUTOWRAP, true); + setDecsetinternalBit(DECSET_BIT_SHOWING_CURSOR, true); + mSavedDecSetFlags = mSavedStateMain.mSavedDecFlags = mSavedStateAlt.mSavedDecFlags = mCurrentDecSetFlags; + + // XXX: Should we set terminal driver back to IUTF8 with termios? + mUtf8Index = mUtf8ToFollow = 0; + + mColors.reset(); + mSession.onColorsChanged(); + } + + public String getSelectedText(int x1, int y1, int x2, int y2) { + return mScreen.getSelectedText(x1, y1, x2, y2); + } + + /** Get the terminal session's title (null if not set). */ + public String getTitle() { + return mTitle; + } + + /** Change the terminal session's title. */ + private void setTitle(String newTitle) { + String oldTitle = mTitle; + mTitle = newTitle; + if (!Objects.equals(oldTitle, newTitle)) { + mSession.titleChanged(oldTitle, newTitle); + } + } + + /** If DECSET 2004 is set, prefix paste with "\033[200~" and suffix with "\033[201~". */ + public void paste(String text) { + // First: Always remove escape key and C1 control characters [0x80,0x9F]: + text = text.replaceAll("(\u001B|[\u0080-\u009F])", ""); + // Then: Implement bracketed paste mode if enabled: + boolean bracketed = isDecsetInternalBitSet(DECSET_BIT_BRACKETED_PASTE_MODE); + if (bracketed) mSession.write("\033[200~"); + mSession.write(text); + if (bracketed) mSession.write("\033[201~"); + } + + /** http://www.vt100.net/docs/vt510-rm/DECSC */ + static final class SavedScreenState { + /** Saved state of the cursor position, Used to implement the save/restore cursor position escape sequences. */ + int mSavedCursorRow, mSavedCursorCol; + int mSavedEffect, mSavedForeColor, mSavedBackColor; + int mSavedDecFlags; + boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true; + } + + @Override + public String toString() { + return "TerminalEmulator[size=" + mScreen.mColumns + "x" + mScreen.mScreenRows + ", margins={" + mTopMargin + "," + mRightMargin + "," + mBottomMargin + + "," + mLeftMargin + "}]"; + } + +} diff --git a/app/src/main/java/io/neoterm/terminal/TerminalOutput.java b/app/src/main/java/io/neoterm/terminal/TerminalOutput.java new file mode 100755 index 0000000..8b742d5 --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/TerminalOutput.java @@ -0,0 +1,28 @@ +package io.neoterm.terminal; + +import java.nio.charset.StandardCharsets; + +/** A client which receives callbacks from events triggered by feeding input to a {@link TerminalEmulator}. */ +public abstract class TerminalOutput { + + /** Write a string using the UTF-8 encoding to the terminal client. */ + public final void write(String data) { + byte[] bytes = data.getBytes(StandardCharsets.UTF_8); + write(bytes, 0, bytes.length); + } + + /** Write bytes to the terminal client. */ + public abstract void write(byte[] data, int offset, int count); + + /** Notify the terminal client that the terminal title has changed. */ + public abstract void titleChanged(String oldTitle, String newTitle); + + /** Notify the terminal client that the terminal title has changed. */ + public abstract void clipboardText(String text); + + /** Notify the terminal client that a bell character (ASCII 7, bell, BEL, \a, ^G)) has been received. */ + public abstract void onBell(); + + public abstract void onColorsChanged(); + +} diff --git a/app/src/main/java/io/neoterm/terminal/TerminalRow.java b/app/src/main/java/io/neoterm/terminal/TerminalRow.java new file mode 100755 index 0000000..93186e6 --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/TerminalRow.java @@ -0,0 +1,232 @@ +package io.neoterm.terminal; + +import java.util.Arrays; + +/** + * A row in a terminal, composed of a fixed number of cells. + *

+ * The text in the row is stored in a char[] array, {@link #mText}, for quick access during rendering. + */ +public final class TerminalRow { + + private static final float SPARE_CAPACITY_FACTOR = 1.5f; + + /** The number of columns in this terminal row. */ + private final int mColumns; + /** The text filling this terminal row. */ + public char[] mText; + /** The number of java char:s used in {@link #mText}. */ + private short mSpaceUsed; + /** If this row has been line wrapped due to text output at the end of line. */ + boolean mLineWrap; + /** The style bits of each cell in the row. See {@link TextStyle}. */ + final long[] mStyle; + + /** Construct a blank row (containing only whitespace, ' ') with a specified style. */ + public TerminalRow(int columns, long style) { + mColumns = columns; + mText = new char[(int) (SPARE_CAPACITY_FACTOR * columns)]; + mStyle = new long[columns]; + clear(style); + } + + /** NOTE: The sourceX2 is exclusive. */ + public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int destinationX) { + final int x1 = line.findStartOfColumn(sourceX1); + final int x2 = line.findStartOfColumn(sourceX2); + boolean startingFromSecondHalfOfWideChar = (sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1)); + final char[] sourceChars = (this == line) ? Arrays.copyOf(line.mText, line.mText.length) : line.mText; + int latestNonCombiningWidth = 0; + for (int i = x1; i < x2; i++) { + char sourceChar = sourceChars[i]; + int codePoint = Character.isHighSurrogate(sourceChar) ? Character.toCodePoint(sourceChar, sourceChars[++i]) : sourceChar; + if (startingFromSecondHalfOfWideChar) { + // Just treat copying second half of wide char as copying whitespace. + codePoint = ' '; + startingFromSecondHalfOfWideChar = false; + } + int w = WcWidth.width(codePoint); + if (w > 0) { + destinationX += latestNonCombiningWidth; + sourceX1 += latestNonCombiningWidth; + latestNonCombiningWidth = w; + } + setChar(destinationX, codePoint, line.getStyle(sourceX1)); + } + } + + public int getSpaceUsed() { + return mSpaceUsed; + } + + /** Note that the column may end of second half of wide character. */ + public int findStartOfColumn(int column) { + if (column == mColumns) return getSpaceUsed(); + + int currentColumn = 0; + int currentCharIndex = 0; + while (true) { // 0<2 1 < 2 + int newCharIndex = currentCharIndex; + char c = mText[newCharIndex++]; // cci=1, cci=2 + boolean isHigh = Character.isHighSurrogate(c); + int codePoint = isHigh ? Character.toCodePoint(c, mText[newCharIndex++]) : c; + int wcwidth = WcWidth.width(codePoint); // 1, 2 + if (wcwidth > 0) { + currentColumn += wcwidth; + if (currentColumn == column) { + while (newCharIndex < mSpaceUsed) { + // Skip combining chars. + if (Character.isHighSurrogate(mText[newCharIndex])) { + if (WcWidth.width(Character.toCodePoint(mText[newCharIndex], mText[newCharIndex + 1])) <= 0) { + newCharIndex += 2; + } else { + break; + } + } else if (WcWidth.width(mText[newCharIndex]) <= 0) { + newCharIndex++; + } else { + break; + } + } + return newCharIndex; + } else if (currentColumn > column) { + // Wide column going past end. + return currentCharIndex; + } + } + currentCharIndex = newCharIndex; + } + } + + private boolean wideDisplayCharacterStartingAt(int column) { + for (int currentCharIndex = 0, currentColumn = 0; currentCharIndex < mSpaceUsed; ) { + char c = mText[currentCharIndex++]; + int codePoint = Character.isHighSurrogate(c) ? Character.toCodePoint(c, mText[currentCharIndex++]) : c; + int wcwidth = WcWidth.width(codePoint); + if (wcwidth > 0) { + if (currentColumn == column && wcwidth == 2) return true; + currentColumn += wcwidth; + if (currentColumn > column) return false; + } + } + return false; + } + + public void clear(long style) { + Arrays.fill(mText, ' '); + Arrays.fill(mStyle, style); + mSpaceUsed = (short) mColumns; + } + + // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26 + public void setChar(int columnToSet, int codePoint, long style) { + mStyle[columnToSet] = style; + + final int newCodePointDisplayWidth = WcWidth.width(codePoint); + final boolean newIsCombining = newCodePointDisplayWidth <= 0; + + boolean wasExtraColForWideChar = (columnToSet > 0) && wideDisplayCharacterStartingAt(columnToSet - 1); + + if (newIsCombining) { + // When standing at second half of wide character and inserting combining: + if (wasExtraColForWideChar) columnToSet--; + } else { + // Check if we are overwriting the second half of a wide character starting at the previous column: + if (wasExtraColForWideChar) setChar(columnToSet - 1, ' ', style); + // Check if we are overwriting the first half of a wide character starting at the next column: + boolean overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(columnToSet + 1); + if (overwritingWideCharInNextColumn) setChar(columnToSet + 1, ' ', style); + } + + char[] text = mText; + final int oldStartOfColumnIndex = findStartOfColumn(columnToSet); + final int oldCodePointDisplayWidth = WcWidth.width(text, oldStartOfColumnIndex); + + // Get the number of elements in the mText array this column uses now + int oldCharactersUsedForColumn; + if (columnToSet + oldCodePointDisplayWidth < mColumns) { + oldCharactersUsedForColumn = findStartOfColumn(columnToSet + oldCodePointDisplayWidth) - oldStartOfColumnIndex; + } else { + // Last character. + oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex; + } + + // Find how many chars this column will need + int newCharactersUsedForColumn = Character.charCount(codePoint); + if (newIsCombining) { + // Combining characters are added to the contents of the column instead of overwriting them, so that they + // modify the existing contents. + // FIXME: Put a limit of combining characters. + // FIXME: Unassigned characters also get width=0. + newCharactersUsedForColumn += oldCharactersUsedForColumn; + } + + int oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn; + int newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn; + + final int javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn; + if (javaCharDifference > 0) { + // Shift the rest of the line right. + int oldCharactersAfterColumn = mSpaceUsed - oldNextColumnIndex; + if (mSpaceUsed + javaCharDifference > text.length) { + // We need to grow the array + char[] newText = new char[text.length + mColumns]; + System.arraycopy(text, 0, newText, 0, oldStartOfColumnIndex + oldCharactersUsedForColumn); + System.arraycopy(text, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn); + mText = text = newText; + } else { + System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, oldCharactersAfterColumn); + } + } else if (javaCharDifference < 0) { + // Shift the rest of the line left. + System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - oldNextColumnIndex); + } + mSpaceUsed += javaCharDifference; + + // Store char. A combining character is stored at the end of the existing contents so that it modifies them: + //noinspection ResultOfMethodCallIgnored - since we already now how many java chars is used. + Character.toChars(codePoint, text, oldStartOfColumnIndex + (newIsCombining ? oldCharactersUsedForColumn : 0)); + + if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) { + // Replace second half of wide char with a space. Which mean that we actually add a ' ' java character. + if (mSpaceUsed + 1 > text.length) { + char[] newText = new char[text.length + mColumns]; + System.arraycopy(text, 0, newText, 0, newNextColumnIndex); + System.arraycopy(text, newNextColumnIndex, newText, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex); + mText = text = newText; + } else { + System.arraycopy(text, newNextColumnIndex, text, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex); + } + text[newNextColumnIndex] = ' '; + + ++mSpaceUsed; + } else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) { + if (columnToSet == mColumns - 1) { + throw new IllegalArgumentException("Cannot put wide character in last column"); + } else if (columnToSet == mColumns - 2) { + // Truncate the line to the second part of this wide char: + mSpaceUsed = (short) newNextColumnIndex; + } else { + // Overwrite the contents of the next column, which mean we actually remove java characters. Due to the + // check at the beginning of this method we know that we are not overwriting a wide char. + int newNextNextColumnIndex = newNextColumnIndex + (Character.isHighSurrogate(mText[newNextColumnIndex]) ? 2 : 1); + int nextLen = newNextNextColumnIndex - newNextColumnIndex; + + // Shift the array leftwards. + System.arraycopy(text, newNextNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - newNextNextColumnIndex); + mSpaceUsed -= nextLen; + } + } + } + + boolean isBlank() { + for (int charIndex = 0, charLen = getSpaceUsed(); charIndex < charLen; charIndex++) + if (mText[charIndex] != ' ') return false; + return true; + } + + public final long getStyle(int column) { + return mStyle[column]; + } + +} diff --git a/app/src/main/java/io/neoterm/terminal/TerminalSession.java b/app/src/main/java/io/neoterm/terminal/TerminalSession.java new file mode 100755 index 0000000..b59e191 --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/TerminalSession.java @@ -0,0 +1,342 @@ +package io.neoterm.terminal; + +import android.annotation.SuppressLint; +import android.os.Handler; +import android.os.Message; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; +import android.util.Log; + +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * A terminal session, consisting of a process coupled to a terminal interface. + *

+ * The subprocess will be executed by the constructor, and when the size is made known by a call to + * {@link #updateSize(int, int)} terminal emulation will begin and threads will be spawned to handle the subprocess I/O. + * All terminal emulation and callback methods will be performed on the main thread. + *

+ * The child process may be exited forcefully by using the {@link #finishIfRunning()} method. + *

+ * NOTE: The terminal session may outlive the EmulatorView, so be careful with callbacks! + */ +public final class TerminalSession extends TerminalOutput { + + /** Callback to be invoked when a {@link TerminalSession} changes. */ + public interface SessionChangedCallback { + void onTextChanged(TerminalSession changedSession); + + void onTitleChanged(TerminalSession changedSession); + + void onSessionFinished(TerminalSession finishedSession); + + void onClipboardText(TerminalSession session, String text); + + void onBell(TerminalSession session); + + void onColorsChanged(TerminalSession session); + + } + + private static FileDescriptor wrapFileDescriptor(int fileDescriptor) { + FileDescriptor result = new FileDescriptor(); + try { + Field descriptorField; + try { + descriptorField = FileDescriptor.class.getDeclaredField("descriptor"); + } catch (NoSuchFieldException e) { + // For desktop java: + descriptorField = FileDescriptor.class.getDeclaredField("fd"); + } + descriptorField.setAccessible(true); + descriptorField.set(result, fileDescriptor); + } catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) { + Log.wtf(EmulatorDebug.LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e); + System.exit(1); + } + return result; + } + + private static final int MSG_NEW_INPUT = 1; + private static final int MSG_PROCESS_EXITED = 4; + + public final String mHandle = UUID.randomUUID().toString(); + + TerminalEmulator mEmulator; + + /** + * A queue written to from a separate thread when the process outputs, and read by main thread to process by + * terminal emulator. + */ + final ByteQueue mProcessToTerminalIOQueue = new ByteQueue(4096); + /** + * A queue written to from the main thread due to user interaction, and read by another thread which forwards by + * writing to the {@link #mTerminalFileDescriptor}. + */ + final ByteQueue mTerminalToProcessIOQueue = new ByteQueue(4096); + /** Buffer to write translate code points into utf8 before writing to mTerminalToProcessIOQueue */ + private final byte[] mUtf8InputBuffer = new byte[5]; + + /** Callback which gets notified when a session finishes or changes title. */ + final SessionChangedCallback mChangeCallback; + + /** The pid of the shell process. 0 if not started and -1 if finished running. */ + int mShellPid; + + /** The exit status of the shell process. Only valid if ${@link #mShellPid} is -1. */ + int mShellExitStatus; + + /** + * The file descriptor referencing the master half of a pseudo-terminal pair, resulting from calling + * {@link JNI#createSubprocess(String, String, String[], String[], int[], int, int)}. + */ + private int mTerminalFileDescriptor; + + /** Set by the application for user identification of session, not by terminal. */ + public String mSessionName; + + @SuppressLint("HandlerLeak") + final Handler mMainThreadHandler = new Handler() { + final byte[] mReceiveBuffer = new byte[4 * 1024]; + + @Override + public void handleMessage(Message msg) { + if (msg.what == MSG_NEW_INPUT && isRunning()) { + int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false); + if (bytesRead > 0) { + mEmulator.append(mReceiveBuffer, bytesRead); + notifyScreenUpdate(); + } + } else if (msg.what == MSG_PROCESS_EXITED) { + int exitCode = (Integer) msg.obj; + cleanupResources(exitCode); + mChangeCallback.onSessionFinished(TerminalSession.this); + + String exitDescription = "\r\n[Process completed"; + if (exitCode > 0) { + // Non-zero process exit. + exitDescription += " (code " + exitCode + ")"; + } else if (exitCode < 0) { + // Negated signal. + exitDescription += " (signal " + (-exitCode) + ")"; + } + exitDescription += " - press Enter]"; + + byte[] bytesToWrite = exitDescription.getBytes(StandardCharsets.UTF_8); + mEmulator.append(bytesToWrite, bytesToWrite.length); + notifyScreenUpdate(); + } + } + }; + + private final String mShellPath; + private final String mCwd; + private final String[] mArgs; + private final String[] mEnv; + + public TerminalSession(String shellPath, String cwd, String[] args, String[] env, SessionChangedCallback changeCallback) { + mChangeCallback = changeCallback; + + this.mShellPath = shellPath; + this.mCwd = cwd; + this.mArgs = args; + this.mEnv = env; + } + + /** Inform the attached pty of the new size and reflow or initialize the emulator. */ + public void updateSize(int columns, int rows) { + if (mEmulator == null) { + initializeEmulator(columns, rows); + } else { + JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns); + mEmulator.resize(columns, rows); + } + } + + /** The terminal title as set through escape sequences or null if none set. */ + public String getTitle() { + return (mEmulator == null) ? null : mEmulator.getTitle(); + } + + /** + * Set the terminal emulator's window size and start terminal emulation. + * + * @param columns The number of columns in the terminal window. + * @param rows The number of rows in the terminal window. + */ + public void initializeEmulator(int columns, int rows) { + mEmulator = new TerminalEmulator(this, columns, rows, /* transcript= */2000); + + int[] processId = new int[1]; + mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns); + mShellPid = processId[0]; + + final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor); + + new Thread("TermSessionInputReader[pid=" + mShellPid + "]") { + @Override + public void run() { + try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) { + final byte[] buffer = new byte[4096]; + while (true) { + int read = termIn.read(buffer); + if (read == -1) return; + if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return; + mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT); + } + } catch (Exception e) { + // Ignore, just shutting down. + } + } + }.start(); + + new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") { + @Override + public void run() { + final byte[] buffer = new byte[4096]; + try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) { + while (true) { + int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true); + if (bytesToWrite == -1) return; + termOut.write(buffer, 0, bytesToWrite); + } + } catch (IOException e) { + // Ignore. + } + } + }.start(); + + new Thread("TermSessionWaiter[pid=" + mShellPid + "]") { + @Override + public void run() { + int processExitCode = JNI.waitFor(mShellPid); + mMainThreadHandler.sendMessage(mMainThreadHandler.obtainMessage(MSG_PROCESS_EXITED, processExitCode)); + } + }.start(); + + } + + /** Write data to the shell process. */ + @Override + public void write(byte[] data, int offset, int count) { + if (mShellPid > 0) mTerminalToProcessIOQueue.write(data, offset, count); + } + + /** Write the Unicode code point to the terminal encoded in UTF-8. */ + public void writeCodePoint(boolean prependEscape, int codePoint) { + if (codePoint > 1114111 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { + // 1114111 (= 2**16 + 1024**2 - 1) is the highest code point, [0xD800,0xDFFF] is the surrogate range. + throw new IllegalArgumentException("Invalid code point: " + codePoint); + } + + int bufferPosition = 0; + if (prependEscape) mUtf8InputBuffer[bufferPosition++] = 27; + + if (codePoint <= /* 7 bits */0b1111111) { + mUtf8InputBuffer[bufferPosition++] = (byte) codePoint; + } else if (codePoint <= /* 11 bits */0b11111111111) { + /* 110xxxxx leading byte with leading 5 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11000000 | (codePoint >> 6)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } else if (codePoint <= /* 16 bits */0b1111111111111111) { + /* 1110xxxx leading byte with leading 4 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11100000 | (codePoint >> 12)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } else { /* We have checked codePoint <= 1114111 above, so we have max 21 bits = 0b111111111111111111111 */ + /* 11110xxx leading byte with leading 3 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11110000 | (codePoint >> 18)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 12) & 0b111111)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111)); + /* 10xxxxxx continuation byte with following 6 bits */ + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } + write(mUtf8InputBuffer, 0, bufferPosition); + } + + public TerminalEmulator getEmulator() { + return mEmulator; + } + + /** Notify the {@link #mChangeCallback} that the screen has changed. */ + protected void notifyScreenUpdate() { + mChangeCallback.onTextChanged(this); + } + + /** Reset state for terminal emulator state. */ + public void reset() { + mEmulator.reset(); + notifyScreenUpdate(); + } + + /** Finish this terminal session by sending SIGKILL to the shell. */ + public void finishIfRunning() { + if (isRunning()) { + try { + Os.kill(mShellPid, OsConstants.SIGKILL); + } catch (ErrnoException e) { + Log.w("neoterm-termux", "Failed sending SIGKILL: " + e.getMessage()); + } + } + } + + /** Cleanup resources when the process exits. */ + void cleanupResources(int exitStatus) { + synchronized (this) { + mShellPid = -1; + mShellExitStatus = exitStatus; + } + + // Stop the reader and writer threads, and close the I/O streams + mTerminalToProcessIOQueue.close(); + mProcessToTerminalIOQueue.close(); + JNI.close(mTerminalFileDescriptor); + } + + @Override + public void titleChanged(String oldTitle, String newTitle) { + mChangeCallback.onTitleChanged(this); + } + + public synchronized boolean isRunning() { + return mShellPid != -1; + } + + /** Only valid if not {@link #isRunning()}. */ + public synchronized int getExitStatus() { + return mShellExitStatus; + } + + @Override + public void clipboardText(String text) { + mChangeCallback.onClipboardText(this, text); + } + + @Override + public void onBell() { + mChangeCallback.onBell(this); + } + + @Override + public void onColorsChanged() { + mChangeCallback.onColorsChanged(this); + } + + public int getPid() { + return mShellPid; + } + +} diff --git a/app/src/main/java/io/neoterm/terminal/TextStyle.java b/app/src/main/java/io/neoterm/terminal/TextStyle.java new file mode 100755 index 0000000..2551037 --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/TextStyle.java @@ -0,0 +1,90 @@ +package io.neoterm.terminal; + +/** + *

+ * Encodes effects, foreground and background colors into a 64 bit long, which are stored for each cell in a terminal + * row in {@link TerminalRow#mStyle}. + *

+ *

+ * The bit layout is: + *

+ * - 16 flags (11 currently used). + * - 24 for foreground color (only 9 first bits if a color index). + * - 24 for background color (only 9 first bits if a color index). + */ +public final class TextStyle { + + public final static int CHARACTER_ATTRIBUTE_BOLD = 1; + public final static int CHARACTER_ATTRIBUTE_ITALIC = 1 << 1; + public final static int CHARACTER_ATTRIBUTE_UNDERLINE = 1 << 2; + public final static int CHARACTER_ATTRIBUTE_BLINK = 1 << 3; + public final static int CHARACTER_ATTRIBUTE_INVERSE = 1 << 4; + public final static int CHARACTER_ATTRIBUTE_INVISIBLE = 1 << 5; + public final static int CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 << 6; + /** + * The selective erase control functions (DECSED and DECSEL) can only erase characters defined as erasable. + *

+ * This bit is set if DECSCA (Select Character Protection Attribute) has been used to define the characters that + * come after it as erasable from the screen. + *

+ */ + public final static int CHARACTER_ATTRIBUTE_PROTECTED = 1 << 7; + /** Dim colors. Also known as faint or half intensity. */ + public final static int CHARACTER_ATTRIBUTE_DIM = 1 << 8; + /** If true (24-bit) color is used for the cell for foreground. */ + private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 << 9; + /** If true (24-bit) color is used for the cell for foreground. */ + private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND= 1 << 10; + + public final static int COLOR_INDEX_FOREGROUND = 256; + public final static int COLOR_INDEX_BACKGROUND = 257; + public final static int COLOR_INDEX_CURSOR = 258; + + /** The 256 standard color entries and the three special (foreground, background and cursor) ones. */ + public final static int NUM_INDEXED_COLORS = 259; + + /** Normal foreground and background colors and no effects. */ + final static long NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0); + + static long encode(int foreColor, int backColor, int effect) { + long result = effect & 0b111111111; + if ((0xff000000 & foreColor) == 0xff000000) { + // 24-bit color. + result |= CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND | ((foreColor & 0x00ffffffL) << 40L); + } else { + // Indexed color. + result |= (foreColor & 0b111111111L) << 40; + } + if ((0xff000000 & backColor) == 0xff000000) { + // 24-bit color. + result |= CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND | ((backColor & 0x00ffffffL) << 16L); + } else { + // Indexed color. + result |= (backColor & 0b111111111L) << 16L; + } + + return result; + } + + public static int decodeForeColor(long style) { + if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND) == 0) { + return (int) ((style >>> 40) & 0b111111111L); + } else { + return 0xff000000 | (int) ((style >>> 40) & 0x00ffffffL); + } + + } + + public static int decodeBackColor(long style) { + if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND) == 0) { + return (int) ((style >>> 16) & 0b111111111L); + } else { + return 0xff000000 | (int) ((style >>> 16) & 0x00ffffffL); + } + } + + public static int decodeEffect(long style) { + return (int) (style & 0b11111111111); + } + +} diff --git a/app/src/main/java/io/neoterm/terminal/WcWidth.java b/app/src/main/java/io/neoterm/terminal/WcWidth.java new file mode 100755 index 0000000..bf5078b --- /dev/null +++ b/app/src/main/java/io/neoterm/terminal/WcWidth.java @@ -0,0 +1,458 @@ +package io.neoterm.terminal; + +/** + * Implementation of wcwidth(3) for Unicode 9. + * + * Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters. + */ +public final class WcWidth { + + // From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py + // t commit 0d7de112202cc8b2ebe9232ff4a5c954f19d561a (2016-07-02): + private static final int[][] ZERO_WIDTH = { + {0x0300, 0x036f}, // Combining Grave Accent ..Combining Latin Small Le + {0x0483, 0x0489}, // Combining Cyrillic Titlo..Combining Cyrillic Milli + {0x0591, 0x05bd}, // Hebrew Accent Etnahta ..Hebrew Point Meteg + {0x05bf, 0x05bf}, // Hebrew Point Rafe ..Hebrew Point Rafe + {0x05c1, 0x05c2}, // Hebrew Point Shin Dot ..Hebrew Point Sin Dot + {0x05c4, 0x05c5}, // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot + {0x05c7, 0x05c7}, // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata + {0x0610, 0x061a}, // Arabic Sign Sallallahou ..Arabic Small Kasra + {0x064b, 0x065f}, // Arabic Fathatan ..Arabic Wavy Hamza Below + {0x0670, 0x0670}, // Arabic Letter Superscrip..Arabic Letter Superscrip + {0x06d6, 0x06dc}, // Arabic Small High Ligatu..Arabic Small High Seen + {0x06df, 0x06e4}, // Arabic Small High Rounde..Arabic Small High Madda + {0x06e7, 0x06e8}, // Arabic Small High Yeh ..Arabic Small High Noon + {0x06ea, 0x06ed}, // Arabic Empty Centre Low ..Arabic Small Low Meem + {0x0711, 0x0711}, // Syriac Letter Superscrip..Syriac Letter Superscrip + {0x0730, 0x074a}, // Syriac Pthaha Above ..Syriac Barrekh + {0x07a6, 0x07b0}, // Thaana Abafili ..Thaana Sukun + {0x07eb, 0x07f3}, // Nko Combining Sh||t High..Nko Combining Double Dot + {0x0816, 0x0819}, // Samaritan Mark In ..Samaritan Mark Dagesh + {0x081b, 0x0823}, // Samaritan Mark Epentheti..Samaritan Vowel Sign A + {0x0825, 0x0827}, // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U + {0x0829, 0x082d}, // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa + {0x0859, 0x085b}, // Mandaic Affrication Mark..Mandaic Gemination Mark + {0x08d4, 0x08e1}, // (nil) .. + {0x08e3, 0x0902}, // Arabic Turned Damma Belo..Devanagari Sign Anusvara + {0x093a, 0x093a}, // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe + {0x093c, 0x093c}, // Devanagari Sign Nukta ..Devanagari Sign Nukta + {0x0941, 0x0948}, // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai + {0x094d, 0x094d}, // Devanagari Sign Virama ..Devanagari Sign Virama + {0x0951, 0x0957}, // Devanagari Stress Sign U..Devanagari Vowel Sign Uu + {0x0962, 0x0963}, // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo + {0x0981, 0x0981}, // Bengali Sign Candrabindu..Bengali Sign Candrabindu + {0x09bc, 0x09bc}, // Bengali Sign Nukta ..Bengali Sign Nukta + {0x09c1, 0x09c4}, // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal + {0x09cd, 0x09cd}, // Bengali Sign Virama ..Bengali Sign Virama + {0x09e2, 0x09e3}, // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal + {0x0a01, 0x0a02}, // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi + {0x0a3c, 0x0a3c}, // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta + {0x0a41, 0x0a42}, // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu + {0x0a47, 0x0a48}, // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai + {0x0a4b, 0x0a4d}, // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama + {0x0a51, 0x0a51}, // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat + {0x0a70, 0x0a71}, // Gurmukhi Tippi ..Gurmukhi Addak + {0x0a75, 0x0a75}, // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash + {0x0a81, 0x0a82}, // Gujarati Sign Candrabind..Gujarati Sign Anusvara + {0x0abc, 0x0abc}, // Gujarati Sign Nukta ..Gujarati Sign Nukta + {0x0ac1, 0x0ac5}, // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand + {0x0ac7, 0x0ac8}, // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai + {0x0acd, 0x0acd}, // Gujarati Sign Virama ..Gujarati Sign Virama + {0x0ae2, 0x0ae3}, // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca + {0x0b01, 0x0b01}, // ||iya Sign Candrabindu ..||iya Sign Candrabindu + {0x0b3c, 0x0b3c}, // ||iya Sign Nukta ..||iya Sign Nukta + {0x0b3f, 0x0b3f}, // ||iya Vowel Sign I ..||iya Vowel Sign I + {0x0b41, 0x0b44}, // ||iya Vowel Sign U ..||iya Vowel Sign Vocalic + {0x0b4d, 0x0b4d}, // ||iya Sign Virama ..||iya Sign Virama + {0x0b56, 0x0b56}, // ||iya Ai Length Mark ..||iya Ai Length Mark + {0x0b62, 0x0b63}, // ||iya Vowel Sign Vocalic..||iya Vowel Sign Vocalic + {0x0b82, 0x0b82}, // Tamil Sign Anusvara ..Tamil Sign Anusvara + {0x0bc0, 0x0bc0}, // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii + {0x0bcd, 0x0bcd}, // Tamil Sign Virama ..Tamil Sign Virama + {0x0c00, 0x0c00}, // Telugu Sign Combining Ca..Telugu Sign Combining Ca + {0x0c3e, 0x0c40}, // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii + {0x0c46, 0x0c48}, // Telugu Vowel Sign E ..Telugu Vowel Sign Ai + {0x0c4a, 0x0c4d}, // Telugu Vowel Sign O ..Telugu Sign Virama + {0x0c55, 0x0c56}, // Telugu Length Mark ..Telugu Ai Length Mark + {0x0c62, 0x0c63}, // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali + {0x0c81, 0x0c81}, // Kannada Sign Candrabindu..Kannada Sign Candrabindu + {0x0cbc, 0x0cbc}, // Kannada Sign Nukta ..Kannada Sign Nukta + {0x0cbf, 0x0cbf}, // Kannada Vowel Sign I ..Kannada Vowel Sign I + {0x0cc6, 0x0cc6}, // Kannada Vowel Sign E ..Kannada Vowel Sign E + {0x0ccc, 0x0ccd}, // Kannada Vowel Sign Au ..Kannada Sign Virama + {0x0ce2, 0x0ce3}, // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal + {0x0d01, 0x0d01}, // Malayalam Sign Candrabin..Malayalam Sign Candrabin + {0x0d41, 0x0d44}, // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc + {0x0d4d, 0x0d4d}, // Malayalam Sign Virama ..Malayalam Sign Virama + {0x0d62, 0x0d63}, // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc + {0x0dca, 0x0dca}, // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna + {0x0dd2, 0x0dd4}, // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti + {0x0dd6, 0x0dd6}, // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga + {0x0e31, 0x0e31}, // Thai Character Mai Han-a..Thai Character Mai Han-a + {0x0e34, 0x0e3a}, // Thai Character Sara I ..Thai Character Phinthu + {0x0e47, 0x0e4e}, // Thai Character Maitaikhu..Thai Character Yamakkan + {0x0eb1, 0x0eb1}, // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan + {0x0eb4, 0x0eb9}, // Lao Vowel Sign I ..Lao Vowel Sign Uu + {0x0ebb, 0x0ebc}, // Lao Vowel Sign Mai Kon ..Lao Semivowel Sign Lo + {0x0ec8, 0x0ecd}, // Lao Tone Mai Ek ..Lao Niggahita + {0x0f18, 0x0f19}, // Tibetan Astrological Sig..Tibetan Astrological Sig + {0x0f35, 0x0f35}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung + {0x0f37, 0x0f37}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung + {0x0f39, 0x0f39}, // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru + {0x0f71, 0x0f7e}, // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga + {0x0f80, 0x0f84}, // Tibetan Vowel Sign Rever..Tibetan Mark Halanta + {0x0f86, 0x0f87}, // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags + {0x0f8d, 0x0f97}, // Tibetan Subjoined Sign L..Tibetan Subjoined Letter + {0x0f99, 0x0fbc}, // Tibetan Subjoined Letter..Tibetan Subjoined Letter + {0x0fc6, 0x0fc6}, // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda + {0x102d, 0x1030}, // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu + {0x1032, 0x1037}, // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below + {0x1039, 0x103a}, // Myanmar Sign Virama ..Myanmar Sign Asat + {0x103d, 0x103e}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M + {0x1058, 0x1059}, // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal + {0x105e, 0x1060}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M + {0x1071, 0x1074}, // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah + {0x1082, 0x1082}, // Myanmar Consonant Sign S..Myanmar Consonant Sign S + {0x1085, 0x1086}, // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan + {0x108d, 0x108d}, // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci + {0x109d, 0x109d}, // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton + {0x135d, 0x135f}, // Ethiopic Combining Gemin..Ethiopic Combining Gemin + {0x1712, 0x1714}, // Tagalog Vowel Sign I ..Tagalog Sign Virama + {0x1732, 0x1734}, // Hanunoo Vowel Sign I ..Hanunoo Sign Pamudpod + {0x1752, 0x1753}, // Buhid Vowel Sign I ..Buhid Vowel Sign U + {0x1772, 0x1773}, // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U + {0x17b4, 0x17b5}, // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa + {0x17b7, 0x17bd}, // Khmer Vowel Sign I ..Khmer Vowel Sign Ua + {0x17c6, 0x17c6}, // Khmer Sign Nikahit ..Khmer Sign Nikahit + {0x17c9, 0x17d3}, // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat + {0x17dd, 0x17dd}, // Khmer Sign Atthacan ..Khmer Sign Atthacan + {0x180b, 0x180d}, // Mongolian Free Variation..Mongolian Free Variation + {0x1885, 0x1886}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal + {0x18a9, 0x18a9}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal + {0x1920, 0x1922}, // Limbu Vowel Sign A ..Limbu Vowel Sign U + {0x1927, 0x1928}, // Limbu Vowel Sign E ..Limbu Vowel Sign O + {0x1932, 0x1932}, // Limbu Small Letter Anusv..Limbu Small Letter Anusv + {0x1939, 0x193b}, // Limbu Sign Mukphreng ..Limbu Sign Sa-i + {0x1a17, 0x1a18}, // Buginese Vowel Sign I ..Buginese Vowel Sign U + {0x1a1b, 0x1a1b}, // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae + {0x1a56, 0x1a56}, // Tai Tham Consonant Sign ..Tai Tham Consonant Sign + {0x1a58, 0x1a5e}, // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign + {0x1a60, 0x1a60}, // Tai Tham Sign Sakot ..Tai Tham Sign Sakot + {0x1a62, 0x1a62}, // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai + {0x1a65, 0x1a6c}, // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B + {0x1a73, 0x1a7c}, // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue + {0x1a7f, 0x1a7f}, // Tai Tham Combining Crypt..Tai Tham Combining Crypt + {0x1ab0, 0x1abe}, // Combining Doubled Circum..Combining Parentheses Ov + {0x1b00, 0x1b03}, // Balinese Sign Ulu Ricem ..Balinese Sign Surang + {0x1b34, 0x1b34}, // Balinese Sign Rerekan ..Balinese Sign Rerekan + {0x1b36, 0x1b3a}, // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R + {0x1b3c, 0x1b3c}, // Balinese Vowel Sign La L..Balinese Vowel Sign La L + {0x1b42, 0x1b42}, // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe + {0x1b6b, 0x1b73}, // Balinese Musical Symbol ..Balinese Musical Symbol + {0x1b80, 0x1b81}, // Sundanese Sign Panyecek ..Sundanese Sign Panglayar + {0x1ba2, 0x1ba5}, // Sundanese Consonant Sign..Sundanese Vowel Sign Pan + {0x1ba8, 0x1ba9}, // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan + {0x1bab, 0x1bad}, // Sundanese Sign Virama ..Sundanese Consonant Sign + {0x1be6, 0x1be6}, // Batak Sign Tompi ..Batak Sign Tompi + {0x1be8, 0x1be9}, // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee + {0x1bed, 0x1bed}, // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O + {0x1bef, 0x1bf1}, // Batak Vowel Sign U F|| S..Batak Consonant Sign H + {0x1c2c, 0x1c33}, // Lepcha Vowel Sign E ..Lepcha Consonant Sign T + {0x1c36, 0x1c37}, // Lepcha Sign Ran ..Lepcha Sign Nukta + {0x1cd0, 0x1cd2}, // Vedic Tone Karshana ..Vedic Tone Prenkha + {0x1cd4, 0x1ce0}, // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash + {0x1ce2, 0x1ce8}, // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda + {0x1ced, 0x1ced}, // Vedic Sign Tiryak ..Vedic Sign Tiryak + {0x1cf4, 0x1cf4}, // Vedic Tone Candra Above ..Vedic Tone Candra Above + {0x1cf8, 0x1cf9}, // Vedic Tone Ring Above ..Vedic Tone Double Ring A + {0x1dc0, 0x1df5}, // Combining Dotted Grave A..Combining Up Tack Above + {0x1dfb, 0x1dff}, // (nil) ..Combining Right Arrowhea + {0x20d0, 0x20f0}, // Combining Left Harpoon A..Combining Asterisk Above + {0x2cef, 0x2cf1}, // Coptic Combining Ni Abov..Coptic Combining Spiritu + {0x2d7f, 0x2d7f}, // Tifinagh Consonant Joine..Tifinagh Consonant Joine + {0x2de0, 0x2dff}, // Combining Cyrillic Lette..Combining Cyrillic Lette + {0x302a, 0x302d}, // Ideographic Level Tone M..Ideographic Entering Ton + {0x3099, 0x309a}, // Combining Katakana-hirag..Combining Katakana-hirag + {0xa66f, 0xa672}, // Combining Cyrillic Vzmet..Combining Cyrillic Thous + {0xa674, 0xa67d}, // Combining Cyrillic Lette..Combining Cyrillic Payer + {0xa69e, 0xa69f}, // Combining Cyrillic Lette..Combining Cyrillic Lette + {0xa6f0, 0xa6f1}, // Bamum Combining Mark Koq..Bamum Combining Mark Tuk + {0xa802, 0xa802}, // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva + {0xa806, 0xa806}, // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant + {0xa80b, 0xa80b}, // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva + {0xa825, 0xa826}, // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign + {0xa8c4, 0xa8c5}, // Saurashtra Sign Virama .. + {0xa8e0, 0xa8f1}, // Combining Devanagari Dig..Combining Devanagari Sig + {0xa926, 0xa92d}, // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop + {0xa947, 0xa951}, // Rejang Vowel Sign I ..Rejang Consonant Sign R + {0xa980, 0xa982}, // Javanese Sign Panyangga ..Javanese Sign Layar + {0xa9b3, 0xa9b3}, // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu + {0xa9b6, 0xa9b9}, // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku + {0xa9bc, 0xa9bc}, // Javanese Vowel Sign Pepe..Javanese Vowel Sign Pepe + {0xa9e5, 0xa9e5}, // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw + {0xaa29, 0xaa2e}, // Cham Vowel Sign Aa ..Cham Vowel Sign Oe + {0xaa31, 0xaa32}, // Cham Vowel Sign Au ..Cham Vowel Sign Ue + {0xaa35, 0xaa36}, // Cham Consonant Sign La ..Cham Consonant Sign Wa + {0xaa43, 0xaa43}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina + {0xaa4c, 0xaa4c}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina + {0xaa7c, 0xaa7c}, // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T + {0xaab0, 0xaab0}, // Tai Viet Mai Kang ..Tai Viet Mai Kang + {0xaab2, 0xaab4}, // Tai Viet Vowel I ..Tai Viet Vowel U + {0xaab7, 0xaab8}, // Tai Viet Mai Khit ..Tai Viet Vowel Ia + {0xaabe, 0xaabf}, // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek + {0xaac1, 0xaac1}, // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho + {0xaaec, 0xaaed}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0xaaf6, 0xaaf6}, // Meetei Mayek Virama ..Meetei Mayek Virama + {0xabe5, 0xabe5}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0xabe8, 0xabe8}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0xabed, 0xabed}, // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek + {0xfb1e, 0xfb1e}, // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani + {0xfe00, 0xfe0f}, // Variation Select||-1 ..Variation Select||-16 + {0xfe20, 0xfe2f}, // Combining Ligature Left ..Combining Cyrillic Titlo + {0x101fd, 0x101fd}, // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi + {0x102e0, 0x102e0}, // Coptic Epact Thousands M..Coptic Epact Thousands M + {0x10376, 0x1037a}, // Combining Old Permic Let..Combining Old Permic Let + {0x10a01, 0x10a03}, // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo + {0x10a05, 0x10a06}, // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O + {0x10a0c, 0x10a0f}, // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga + {0x10a38, 0x10a3a}, // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo + {0x10a3f, 0x10a3f}, // Kharoshthi Virama ..Kharoshthi Virama + {0x10ae5, 0x10ae6}, // Manichaean Abbreviation ..Manichaean Abbreviation + {0x11001, 0x11001}, // Brahmi Sign Anusvara ..Brahmi Sign Anusvara + {0x11038, 0x11046}, // Brahmi Vowel Sign Aa ..Brahmi Virama + {0x1107f, 0x11081}, // Brahmi Number Joiner ..Kaithi Sign Anusvara + {0x110b3, 0x110b6}, // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai + {0x110b9, 0x110ba}, // Kaithi Sign Virama ..Kaithi Sign Nukta + {0x11100, 0x11102}, // Chakma Sign Candrabindu ..Chakma Sign Visarga + {0x11127, 0x1112b}, // Chakma Vowel Sign A ..Chakma Vowel Sign Uu + {0x1112d, 0x11134}, // Chakma Vowel Sign Ai ..Chakma Maayyaa + {0x11173, 0x11173}, // Mahajani Sign Nukta ..Mahajani Sign Nukta + {0x11180, 0x11181}, // Sharada Sign Candrabindu..Sharada Sign Anusvara + {0x111b6, 0x111be}, // Sharada Vowel Sign U ..Sharada Vowel Sign O + {0x111ca, 0x111cc}, // Sharada Sign Nukta ..Sharada Extra Sh||t Vowe + {0x1122f, 0x11231}, // Khojki Vowel Sign U ..Khojki Vowel Sign Ai + {0x11234, 0x11234}, // Khojki Sign Anusvara ..Khojki Sign Anusvara + {0x11236, 0x11237}, // Khojki Sign Nukta ..Khojki Sign Shadda + {0x1123e, 0x1123e}, // (nil) .. + {0x112df, 0x112df}, // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara + {0x112e3, 0x112ea}, // Khudawadi Vowel Sign U ..Khudawadi Sign Virama + {0x11300, 0x11301}, // Grantha Sign Combining A..Grantha Sign Candrabindu + {0x1133c, 0x1133c}, // Grantha Sign Nukta ..Grantha Sign Nukta + {0x11340, 0x11340}, // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii + {0x11366, 0x1136c}, // Combining Grantha Digit ..Combining Grantha Digit + {0x11370, 0x11374}, // Combining Grantha Letter..Combining Grantha Letter + {0x11438, 0x1143f}, // (nil) .. + {0x11442, 0x11444}, // (nil) .. + {0x11446, 0x11446}, // (nil) .. + {0x114b3, 0x114b8}, // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal + {0x114ba, 0x114ba}, // Tirhuta Vowel Sign Sh||t..Tirhuta Vowel Sign Sh||t + {0x114bf, 0x114c0}, // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara + {0x114c2, 0x114c3}, // Tirhuta Sign Virama ..Tirhuta Sign Nukta + {0x115b2, 0x115b5}, // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal + {0x115bc, 0x115bd}, // Siddham Sign Candrabindu..Siddham Sign Anusvara + {0x115bf, 0x115c0}, // Siddham Sign Virama ..Siddham Sign Nukta + {0x115dc, 0x115dd}, // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter + {0x11633, 0x1163a}, // Modi Vowel Sign U ..Modi Vowel Sign Ai + {0x1163d, 0x1163d}, // Modi Sign Anusvara ..Modi Sign Anusvara + {0x1163f, 0x11640}, // Modi Sign Virama ..Modi Sign Ardhacandra + {0x116ab, 0x116ab}, // Takri Sign Anusvara ..Takri Sign Anusvara + {0x116ad, 0x116ad}, // Takri Vowel Sign Aa ..Takri Vowel Sign Aa + {0x116b0, 0x116b5}, // Takri Vowel Sign U ..Takri Vowel Sign Au + {0x116b7, 0x116b7}, // Takri Sign Nukta ..Takri Sign Nukta + {0x1171d, 0x1171f}, // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi + {0x11722, 0x11725}, // Ahom Vowel Sign I ..Ahom Vowel Sign Uu + {0x11727, 0x1172b}, // Ahom Vowel Sign Aw ..Ahom Sign Killer + {0x11c30, 0x11c36}, // (nil) .. + {0x11c38, 0x11c3d}, // (nil) .. + {0x11c3f, 0x11c3f}, // (nil) .. + {0x11c92, 0x11ca7}, // (nil) .. + {0x11caa, 0x11cb0}, // (nil) .. + {0x11cb2, 0x11cb3}, // (nil) .. + {0x11cb5, 0x11cb6}, // (nil) .. + {0x16af0, 0x16af4}, // Bassa Vah Combining High..Bassa Vah Combining High + {0x16b30, 0x16b36}, // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta + {0x16f8f, 0x16f92}, // Miao Tone Right ..Miao Tone Below + {0x1bc9d, 0x1bc9e}, // Duployan Thick Letter Se..Duployan Double Mark + {0x1d167, 0x1d169}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d17b, 0x1d182}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d185, 0x1d18b}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d1aa, 0x1d1ad}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d242, 0x1d244}, // Combining Greek Musical ..Combining Greek Musical + {0x1da00, 0x1da36}, // Signwriting Head Rim ..Signwriting Air Sucking + {0x1da3b, 0x1da6c}, // Signwriting Mouth Closed..Signwriting Excitement + {0x1da75, 0x1da75}, // Signwriting Upper Body T..Signwriting Upper Body T + {0x1da84, 0x1da84}, // Signwriting Location Hea..Signwriting Location Hea + {0x1da9b, 0x1da9f}, // Signwriting Fill Modifie..Signwriting Fill Modifie + {0x1daa1, 0x1daaf}, // Signwriting Rotation Mod..Signwriting Rotation Mod + {0x1e000, 0x1e006}, // (nil) .. + {0x1e008, 0x1e018}, // (nil) .. + {0x1e01b, 0x1e021}, // (nil) .. + {0x1e023, 0x1e024}, // (nil) .. + {0x1e026, 0x1e02a}, // (nil) .. + {0x1e8d0, 0x1e8d6}, // Mende Kikakui Combining ..Mende Kikakui Combining + {0x1e944, 0x1e94a}, // (nil) .. + {0xe0100, 0xe01ef}, // Variation Select||-17 ..Variation Select||-256 + }; + + // https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py + // at commit 0d7de112202cc8b2ebe9232ff4a5c954f19d561a (2016-07-02): + private static final int[][] WIDE_EASTASIAN = { + {0x1100, 0x115f}, // Hangul Choseong Kiyeok ..Hangul Choseong Filler + {0x231a, 0x231b}, // Watch ..Hourglass + {0x2329, 0x232a}, // Left-pointing Angle Brac..Right-pointing Angle Bra + {0x23e9, 0x23ec}, // Black Right-pointing Dou..Black Down-pointing Doub + {0x23f0, 0x23f0}, // Alarm Clock ..Alarm Clock + {0x23f3, 0x23f3}, // Hourglass With Flowing S..Hourglass With Flowing S + {0x25fd, 0x25fe}, // White Medium Small Squar..Black Medium Small Squar + {0x2614, 0x2615}, // Umbrella With Rain Drops..Hot Beverage + {0x2648, 0x2653}, // Aries ..Pisces + {0x267f, 0x267f}, // Wheelchair Symbol ..Wheelchair Symbol + {0x2693, 0x2693}, // Anch|| ..Anch|| + {0x26a1, 0x26a1}, // High Voltage Sign ..High Voltage Sign + {0x26aa, 0x26ab}, // Medium White Circle ..Medium Black Circle + {0x26bd, 0x26be}, // Soccer Ball ..Baseball + {0x26c4, 0x26c5}, // Snowman Without Snow ..Sun Behind Cloud + {0x26ce, 0x26ce}, // Ophiuchus ..Ophiuchus + {0x26d4, 0x26d4}, // No Entry ..No Entry + {0x26ea, 0x26ea}, // Church ..Church + {0x26f2, 0x26f3}, // Fountain ..Flag In Hole + {0x26f5, 0x26f5}, // Sailboat ..Sailboat + {0x26fa, 0x26fa}, // Tent ..Tent + {0x26fd, 0x26fd}, // Fuel Pump ..Fuel Pump + {0x2705, 0x2705}, // White Heavy Check Mark ..White Heavy Check Mark + {0x270a, 0x270b}, // Raised Fist ..Raised Hand + {0x2728, 0x2728}, // Sparkles ..Sparkles + {0x274c, 0x274c}, // Cross Mark ..Cross Mark + {0x274e, 0x274e}, // Negative Squared Cross M..Negative Squared Cross M + {0x2753, 0x2755}, // Black Question Mark ||na..White Exclamation Mark O + {0x2757, 0x2757}, // Heavy Exclamation Mark S..Heavy Exclamation Mark S + {0x2795, 0x2797}, // Heavy Plus Sign ..Heavy Division Sign + {0x27b0, 0x27b0}, // Curly Loop ..Curly Loop + {0x27bf, 0x27bf}, // Double Curly Loop ..Double Curly Loop + {0x2b1b, 0x2b1c}, // Black Large Square ..White Large Square + {0x2b50, 0x2b50}, // White Medium Star ..White Medium Star + {0x2b55, 0x2b55}, // Heavy Large Circle ..Heavy Large Circle + {0x2e80, 0x2e99}, // Cjk Radical Repeat ..Cjk Radical Rap + {0x2e9b, 0x2ef3}, // Cjk Radical Choke ..Cjk Radical C-simplified + {0x2f00, 0x2fd5}, // Kangxi Radical One ..Kangxi Radical Flute + {0x2ff0, 0x2ffb}, // Ideographic Description ..Ideographic Description + {0x3000, 0x303e}, // Ideographic Space ..Ideographic Variation In + {0x3041, 0x3096}, // Hiragana Letter Small A ..Hiragana Letter Small Ke + {0x3099, 0x30ff}, // Combining Katakana-hirag..Katakana Digraph Koto + {0x3105, 0x312d}, // Bopomofo Letter B ..Bopomofo Letter Ih + {0x3131, 0x318e}, // Hangul Letter Kiyeok ..Hangul Letter Araeae + {0x3190, 0x31ba}, // Ideographic Annotation L..Bopomofo Letter Zy + {0x31c0, 0x31e3}, // Cjk Stroke T ..Cjk Stroke Q + {0x31f0, 0x321e}, // Katakana Letter Small Ku..Parenthesized K||ean Cha + {0x3220, 0x3247}, // Parenthesized Ideograph ..Circled Ideograph Koto + {0x3250, 0x32fe}, // Partnership Sign ..Circled Katakana Wo + {0x3300, 0x4dbf}, // Square Apaato .. + {0x4e00, 0xa48c}, // Cjk Unified Ideograph-4e..Yi Syllable Yyr + {0xa490, 0xa4c6}, // Yi Radical Qot ..Yi Radical Ke + {0xa960, 0xa97c}, // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo + {0xac00, 0xd7a3}, // Hangul Syllable Ga ..Hangul Syllable Hih + {0xf900, 0xfaff}, // Cjk Compatibility Ideogr.. + {0xfe10, 0xfe19}, // Presentation F||m F|| Ve..Presentation F||m F|| Ve + {0xfe30, 0xfe52}, // Presentation F||m F|| Ve..Small Full Stop + {0xfe54, 0xfe66}, // Small Semicolon ..Small Equals Sign + {0xfe68, 0xfe6b}, // Small Reverse Solidus ..Small Commercial At + {0xff01, 0xff60}, // Fullwidth Exclamation Ma..Fullwidth Right White Pa + {0xffe0, 0xffe6}, // Fullwidth Cent Sign ..Fullwidth Won Sign + {0x16fe0, 0x16fe0}, // (nil) .. + {0x17000, 0x187ec}, // (nil) .. + {0x18800, 0x18af2}, // (nil) .. + {0x1b000, 0x1b001}, // Katakana Letter Archaic ..Hiragana Letter Archaic + {0x1f004, 0x1f004}, // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon + {0x1f0cf, 0x1f0cf}, // Playing Card Black Joker..Playing Card Black Joker + {0x1f18e, 0x1f18e}, // Negative Squared Ab ..Negative Squared Ab + {0x1f191, 0x1f19a}, // Squared Cl ..Squared Vs + {0x1f200, 0x1f202}, // Square Hiragana Hoka ..Squared Katakana Sa + {0x1f210, 0x1f23b}, // Squared Cjk Unified Ideo.. + {0x1f240, 0x1f248}, // T||toise Shell Bracketed..T||toise Shell Bracketed + {0x1f250, 0x1f251}, // Circled Ideograph Advant..Circled Ideograph Accept + {0x1f300, 0x1f320}, // Cyclone ..Shooting Star + {0x1f32d, 0x1f335}, // Hot Dog ..Cactus + {0x1f337, 0x1f37c}, // Tulip ..Baby Bottle + {0x1f37e, 0x1f393}, // Bottle With Popping C||k..Graduation Cap + {0x1f3a0, 0x1f3ca}, // Carousel H||se ..Swimmer + {0x1f3cf, 0x1f3d3}, // Cricket Bat And Ball ..Table Tennis Paddle And + {0x1f3e0, 0x1f3f0}, // House Building ..European Castle + {0x1f3f4, 0x1f3f4}, // Waving Black Flag ..Waving Black Flag + {0x1f3f8, 0x1f43e}, // Badminton Racquet And Sh..Paw Prints + {0x1f440, 0x1f440}, // Eyes ..Eyes + {0x1f442, 0x1f4fc}, // Ear ..Videocassette + {0x1f4ff, 0x1f53d}, // Prayer Beads ..Down-pointing Small Red + {0x1f54b, 0x1f54e}, // Kaaba ..Men||ah With Nine Branch + {0x1f550, 0x1f567}, // Clock Face One Oclock ..Clock Face Twelve-thirty + {0x1f57a, 0x1f57a}, // (nil) .. + {0x1f595, 0x1f596}, // Reversed Hand With Middl..Raised Hand With Part Be + {0x1f5a4, 0x1f5a4}, // (nil) .. + {0x1f5fb, 0x1f64f}, // Mount Fuji ..Person With Folded Hands + {0x1f680, 0x1f6c5}, // Rocket ..Left Luggage + {0x1f6cc, 0x1f6cc}, // Sleeping Accommodation ..Sleeping Accommodation + {0x1f6d0, 0x1f6d2}, // Place Of W||ship .. + {0x1f6eb, 0x1f6ec}, // Airplane Departure ..Airplane Arriving + {0x1f6f4, 0x1f6f6}, // (nil) .. + {0x1f910, 0x1f91e}, // Zipper-mouth Face .. + {0x1f920, 0x1f927}, // (nil) .. + {0x1f930, 0x1f930}, // (nil) .. + {0x1f933, 0x1f93e}, // (nil) .. + {0x1f940, 0x1f94b}, // (nil) .. + {0x1f950, 0x1f95e}, // (nil) .. + {0x1f980, 0x1f991}, // Crab .. + {0x1f9c0, 0x1f9c0}, // Cheese Wedge ..Cheese Wedge + {0x20000, 0x2fffd}, // Cjk Unified Ideograph-20.. + {0x30000, 0x3fffd}, // (nil) .. + }; + + + private static boolean intable(int[][] table, int c) { + // First quick check f|| Latin1 etc. characters. + if (c < table[0][0]) return false; + + // Binary search in table. + int bot = 0; + int top = table.length - 1; // (int)(size / sizeof(struct interval) - 1); + while (top >= bot) { + int mid = (bot + top) / 2; + if (table[mid][1] < c) { + bot = mid + 1; + } else if (table[mid][0] > c) { + top = mid - 1; + } else { + return true; + } + } + return false; + } + + /** Return the terminal display width of a code point: 0, 1 || 2. */ + public static int width(int ucs) { + if (ucs == 0 || + ucs == 0x034F || + (0x200B <= ucs && ucs <= 0x200F) || + ucs == 0x2028 || + ucs == 0x2029 || + (0x202A <= ucs && ucs <= 0x202E) || + (0x2060 <= ucs && ucs <= 0x2063)) { + return 0; + } + + // C0/C1 control characters + // Termux change: Return 0 instead of -1. + if (ucs < 32 || (0x07F <= ucs && ucs < 0x0A0)) return 0; + + // combining characters with zero width + if (intable(ZERO_WIDTH, ucs)) return 0; + + return intable(WIDE_EASTASIAN, ucs) ? 2 : 1; + } + + /** The width at an index position in a java char array. */ + public static int width(char[] chars, int index) { + char c = chars[index]; + return Character.isHighSurrogate(c) ? width(Character.toCodePoint(c, chars[index + 1])) : width(c); + } + +} diff --git a/app/src/main/java/io/neoterm/view/ExtraKeysView.java b/app/src/main/java/io/neoterm/view/ExtraKeysView.java new file mode 100755 index 0000000..3324e83 --- /dev/null +++ b/app/src/main/java/io/neoterm/view/ExtraKeysView.java @@ -0,0 +1,178 @@ +package io.neoterm.view; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.HapticFeedbackConstants; +import android.view.KeyEvent; +import android.view.View; +import android.widget.Button; +import android.widget.GridLayout; +import android.widget.ToggleButton; + +import io.neoterm.R; +import io.neoterm.terminal.TerminalSession; + +/** + * 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; + + public ExtraKeysView(Context context, AttributeSet attrs) { + super(context, attrs); + + reload(); + } + + static void sendKey(View view, String keyName) { + int keyCode = 0; + String chars = null; + switch (keyName) { + case "ESC": + keyCode = KeyEvent.KEYCODE_ESCAPE; + break; + case "TAB": + keyCode = KeyEvent.KEYCODE_TAB; + break; + case "▲": + keyCode = KeyEvent.KEYCODE_DPAD_UP; + break; + case "◀": + keyCode = KeyEvent.KEYCODE_DPAD_LEFT; + break; + case "▶": + keyCode = KeyEvent.KEYCODE_DPAD_RIGHT; + break; + case "▼": + keyCode = KeyEvent.KEYCODE_DPAD_DOWN; + break; + case "―": + chars = "-"; + break; + default: + chars = keyName; + } + + if (keyCode > 0) { + view.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); + view.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode)); + } else { + TerminalView terminalView = (TerminalView) view.findViewById(R.id.terminal_view); + TerminalSession session = terminalView.getCurrentSession(); + if (session != null) session.write(chars); + } + } + + private ToggleButton controlButton; + private ToggleButton altButton; + private ToggleButton fnButton; + + public boolean readControlButton() { + if (controlButton.isPressed()) return true; + boolean result = controlButton.isChecked(); + if (result) { + controlButton.setChecked(false); + controlButton.setTextColor(TEXT_COLOR); + } + return result; + } + + public boolean readAltButton() { + if (altButton.isPressed()) return true; + boolean result = altButton.isChecked(); + if (result) { + altButton.setChecked(false); + altButton.setTextColor(TEXT_COLOR); + } + return result; + } + + public boolean readFnButton() { + if (fnButton.isPressed()) return true; + boolean result = fnButton.isChecked(); + if (result) { + fnButton.setChecked(false); + fnButton.setTextColor(TEXT_COLOR); + } + return result; + } + + void reload() { + altButton = controlButton = null; + removeAllViews(); + + String[][] buttons = { + {"ESC", "CTRL", "ALT", "TAB", "―", "/", "|"} + }; + + final int rows = buttons.length; + final int cols = buttons[0].length; + + setRowCount(rows); + setColumnCount(cols); + + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + final String buttonText = buttons[row][col]; + + Button button; + switch (buttonText) { + case "CTRL": + button = controlButton = new ToggleButton(getContext(), null, android.R.attr.buttonBarButtonStyle); + button.setClickable(true); + break; + case "ALT": + button = altButton = new ToggleButton(getContext(), null, android.R.attr.buttonBarButtonStyle); + button.setClickable(true); + break; + case "FN": + button = fnButton = new ToggleButton(getContext(), null, android.R.attr.buttonBarButtonStyle); + button.setClickable(true); + break; + default: + button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle); + break; + } + + button.setText(buttonText); + button.setTextColor(TEXT_COLOR); + + final Button finalButton = button; + button.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + finalButton.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + View root = getRootView(); + switch (buttonText) { + case "CTRL": + case "ALT": + case "FN": + ToggleButton self = (ToggleButton) finalButton; + self.setChecked(self.isChecked()); + self.setTextColor(self.isChecked() ? 0xFF80DEEA : TEXT_COLOR); + break; + default: + sendKey(root, buttonText); + break; + } + } + }); + + LayoutParams param = new LayoutParams(); + param.height = param.width = 0; + param.rightMargin = param.topMargin = 0; + param.setGravity(Gravity.LEFT); + float weight = "▲▼◀▶".contains(buttonText) ? 0.7f : 1.f; + param.columnSpec = GridLayout.spec(col, GridLayout.FILL, weight); + param.rowSpec = GridLayout.spec(row, GridLayout.FILL, 1.f); + button.setLayoutParams(param); + + addView(button); + } + } + } + +} diff --git a/app/src/main/java/io/neoterm/view/GestureAndScaleRecognizer.java b/app/src/main/java/io/neoterm/view/GestureAndScaleRecognizer.java new file mode 100755 index 0000000..0da557b --- /dev/null +++ b/app/src/main/java/io/neoterm/view/GestureAndScaleRecognizer.java @@ -0,0 +1,111 @@ +package io.neoterm.view; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; + +/** A combination of {@link GestureDetector} and {@link ScaleGestureDetector}. */ +final class GestureAndScaleRecognizer { + + public interface Listener { + boolean onSingleTapUp(MotionEvent e); + + boolean onDoubleTap(MotionEvent e); + + boolean onScroll(MotionEvent e2, float dx, float dy); + + boolean onFling(MotionEvent e, float velocityX, float velocityY); + + boolean onScale(float focusX, float focusY, float scale); + + boolean onDown(float x, float y); + + boolean onUp(MotionEvent e); + + void onLongPress(MotionEvent e); + } + + private final GestureDetector mGestureDetector; + private final ScaleGestureDetector mScaleDetector; + final Listener mListener; + boolean isAfterLongPress; + + public GestureAndScaleRecognizer(Context context, Listener listener) { + mListener = listener; + + mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) { + return mListener.onScroll(e2, dx, dy); + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + return mListener.onFling(e2, velocityX, velocityY); + } + + @Override + public boolean onDown(MotionEvent e) { + return mListener.onDown(e.getX(), e.getY()); + } + + @Override + public void onLongPress(MotionEvent e) { + mListener.onLongPress(e); + isAfterLongPress = true; + } + }, null, true /* ignoreMultitouch */); + + mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() { + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + return mListener.onSingleTapUp(e); + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + return mListener.onDoubleTap(e); + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + return true; + } + }); + + mScaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + return true; + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + return mListener.onScale(detector.getFocusX(), detector.getFocusY(), detector.getScaleFactor()); + } + }); + } + + public void onTouchEvent(MotionEvent event) { + mGestureDetector.onTouchEvent(event); + mScaleDetector.onTouchEvent(event); + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + isAfterLongPress = false; + break; + case MotionEvent.ACTION_UP: + if (!isAfterLongPress) { + // This behaviour is desired when in e.g. vim with mouse events, where we do not + // want to move the cursor when lifting finger after a long press. + mListener.onUp(event); + } + break; + } + } + + public boolean isInProgress() { + return mScaleDetector.isInProgress(); + } + +} diff --git a/app/src/main/java/io/neoterm/view/TerminalRenderer.java b/app/src/main/java/io/neoterm/view/TerminalRenderer.java new file mode 100755 index 0000000..6e9af35 --- /dev/null +++ b/app/src/main/java/io/neoterm/view/TerminalRenderer.java @@ -0,0 +1,230 @@ +package io.neoterm.view; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.Typeface; + +import io.neoterm.terminal.TerminalBuffer; +import io.neoterm.terminal.TerminalEmulator; +import io.neoterm.terminal.TerminalRow; +import io.neoterm.terminal.TextStyle; +import io.neoterm.terminal.WcWidth; + +/** + * Renderer of a {@link TerminalEmulator} into a {@link Canvas}. + *

+ * Saves font metrics, so needs to be recreated each time the typeface or font size changes. + */ +final class TerminalRenderer { + + final int mTextSize; + final Typeface mTypeface; + private final Paint mTextPaint = new Paint(); + + /** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */ + final float mFontWidth; + /** The {@link Paint#getFontSpacing()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + final int mFontLineSpacing; + /** The {@link Paint#ascent()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + private final int mFontAscent; + /** The {@link #mFontLineSpacing} + {@link #mFontAscent}. */ + final int mFontLineSpacingAndAscent; + + private final float[] asciiMeasures = new float[127]; + + public TerminalRenderer(int textSize, Typeface typeface) { + mTextSize = textSize; + mTypeface = typeface; + + mTextPaint.setTypeface(typeface); + mTextPaint.setAntiAlias(true); + mTextPaint.setTextSize(textSize); + + mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing()); + mFontAscent = (int) Math.ceil(mTextPaint.ascent()); + mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent; + mFontWidth = mTextPaint.measureText("X"); + + StringBuilder sb = new StringBuilder(" "); + for (int i = 0; i < asciiMeasures.length; i++) { + sb.setCharAt(0, (char) i); + asciiMeasures[i] = mTextPaint.measureText(sb, 0, 1); + } + } + + /** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */ + public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, + int selectionY1, int selectionY2, int selectionX1, int selectionX2) { + final boolean reverseVideo = mEmulator.isReverseVideo(); + final int endRow = topRow + mEmulator.mRows; + final int columns = mEmulator.mColumns; + final int cursorCol = mEmulator.getCursorCol(); + final int cursorRow = mEmulator.getCursorRow(); + final boolean cursorVisible = mEmulator.isShowingCursor(); + final TerminalBuffer screen = mEmulator.getScreen(); + final int[] palette = mEmulator.mColors.mCurrentColors; + final int cursorShape = mEmulator.getCursorStyle(); + + if (reverseVideo) + canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC); + + float heightOffset = mFontLineSpacingAndAscent; + for (int row = topRow; row < endRow; row++) { + heightOffset += mFontLineSpacing; + + final int cursorX = (row == cursorRow && cursorVisible) ? cursorCol : -1; + int selx1 = -1, selx2 = -1; + if (row >= selectionY1 && row <= selectionY2) { + if (row == selectionY1) selx1 = selectionX1; + selx2 = (row == selectionY2) ? selectionX2 : mEmulator.mColumns; + } + + TerminalRow lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)); + final char[] line = lineObject.mText; + final int charsUsedInLine = lineObject.getSpaceUsed(); + + long lastRunStyle = 0; + boolean lastRunInsideCursor = false; + int lastRunStartColumn = -1; + int lastRunStartIndex = 0; + boolean lastRunFontWidthMismatch = false; + int currentCharIndex = 0; + float measuredWidthForRun = 0.f; + + for (int column = 0; column < columns; ) { + final char charAtIndex = line[currentCharIndex]; + final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex); + final int charsForCodePoint = charIsHighsurrogate ? 2 : 1; + final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex; + final int codePointWcWidth = WcWidth.width(codePoint); + final boolean insideCursor = (column >= selx1 && column <= selx2) || (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1)); + final long style = lineObject.getStyle(column); + + // Check if the measured text width for this code point is not the same as that expected by wcwidth(). + // This could happen for some fonts which are not truly monospace, or for more exotic characters such as + // smileys which android font renders as wide. + // If this is detected, we draw this code point scaled to match what wcwidth() expects. + final float measuredCodePointWidth = (codePoint < asciiMeasures.length) ? asciiMeasures[codePoint] : mTextPaint.measureText(line, + currentCharIndex, charsForCodePoint); + final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01; + + if (style != lastRunStyle || insideCursor != lastRunInsideCursor || fontWidthMismatch || lastRunFontWidthMismatch) { + if (column == 0) { + // Skip first column as there is nothing to draw, just record the current style. + } else { + final int columnWidthSinceLastRun = column - lastRunStartColumn; + final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; + int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, + lastRunStartIndex, charsSinceLastRun, measuredWidthForRun, + cursorColor, cursorShape, lastRunStyle, reverseVideo); + } + measuredWidthForRun = 0.f; + lastRunStyle = style; + lastRunInsideCursor = insideCursor; + lastRunStartColumn = column; + lastRunStartIndex = currentCharIndex; + lastRunFontWidthMismatch = fontWidthMismatch; + } + measuredWidthForRun += measuredCodePointWidth; + column += codePointWcWidth; + currentCharIndex += charsForCodePoint; + while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) { + // Eat combining chars so that they are treated as part of the last non-combining code point, + // instead of e.g. being considered inside the cursor in the next run. + currentCharIndex += Character.isHighSurrogate(line[currentCharIndex]) ? 2 : 1; + } + } + + final int columnWidthSinceLastRun = columns - lastRunStartColumn; + final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; + int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun, + measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo); + } + } + + private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int startColumn, int runWidthColumns, + int startCharIndex, int runWidthChars, float mes, int cursor, int cursorStyle, + long textStyle, boolean reverseVideo) { + int foreColor = TextStyle.decodeForeColor(textStyle); + final int effect = TextStyle.decodeEffect(textStyle); + int backColor = TextStyle.decodeBackColor(textStyle); + final boolean bold = (effect & (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0; + final boolean underline = (effect & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0; + final boolean italic = (effect & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0; + final boolean strikeThrough = (effect & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0; + final boolean dim = (effect & TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0; + + if ((foreColor & 0xff000000) != 0xff000000) { + // Let bold have bright colors if applicable (one of the first 8): + if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8; + foreColor = palette[foreColor]; + } + + if ((backColor & 0xff000000) != 0xff000000) { + backColor = palette[backColor]; + } + + // Reverse video here if _one and only one_ of the reverse flags are set: + final boolean reverseVideoHere = reverseVideo ^ (effect & (TextStyle.CHARACTER_ATTRIBUTE_INVERSE)) != 0; + if (reverseVideoHere) { + int tmp = foreColor; + foreColor = backColor; + backColor = tmp; + } + + float left = startColumn * mFontWidth; + float right = left + runWidthColumns * mFontWidth; + + mes = mes / mFontWidth; + boolean savedMatrix = false; + if (Math.abs(mes - runWidthColumns) > 0.01) { + canvas.save(); + canvas.scale(runWidthColumns / mes, 1.f); + left *= mes / runWidthColumns; + right *= mes / runWidthColumns; + savedMatrix = true; + } + + if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) { + // Only draw non-default background. + mTextPaint.setColor(backColor); + canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint); + } + + if (cursor != 0) { + mTextPaint.setColor(cursor); + float cursorHeight = mFontLineSpacingAndAscent - mFontAscent; + if (cursorStyle == TerminalEmulator.CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.; + else if (cursorStyle == TerminalEmulator.CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4.; + canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint); + } + + if ((effect & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) { + if (dim) { + int red = (0xFF & (foreColor >> 16)); + int green = (0xFF & (foreColor >> 8)); + int blue = (0xFF & foreColor); + // Dim color handling used by libvte which in turn took it from xterm + // (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267): + red = red * 2 / 3; + green = green * 2 / 3; + blue = blue * 2 / 3; + foreColor = 0xFF000000 + (red << 16) + (green << 8) + blue; + } + + mTextPaint.setFakeBoldText(bold); + mTextPaint.setUnderlineText(underline); + mTextPaint.setTextSkewX(italic ? -0.35f : 0.f); + mTextPaint.setStrikeThruText(strikeThrough); + mTextPaint.setColor(foreColor); + + // The text alignment is the default Paint.Align.LEFT. + canvas.drawText(text, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, mTextPaint); + } + + if (savedMatrix) canvas.restore(); + } +} diff --git a/app/src/main/java/io/neoterm/view/TerminalView.java b/app/src/main/java/io/neoterm/view/TerminalView.java new file mode 100755 index 0000000..a7b7530 --- /dev/null +++ b/app/src/main/java/io/neoterm/view/TerminalView.java @@ -0,0 +1,919 @@ +package io.neoterm.view; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.os.Build; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.ActionMode; +import android.view.HapticFeedbackConstants; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.Scroller; + +import io.neoterm.R; +import io.neoterm.terminal.EmulatorDebug; +import io.neoterm.terminal.KeyHandler; +import io.neoterm.terminal.TerminalBuffer; +import io.neoterm.terminal.TerminalEmulator; +import io.neoterm.terminal.TerminalSession; + +/** View displaying and interacting with a {@link TerminalSession}. */ +public final class TerminalView extends View { + + /** Log view key and IME events. */ + private static final boolean LOG_KEY_EVENTS = false; + + /** The currently displayed terminal session, whose emulator is {@link #mEmulator}. */ + TerminalSession mTermSession; + /** Our terminal emulator whose session is {@link #mTermSession}. */ + TerminalEmulator mEmulator; + + TerminalRenderer mRenderer; + + TerminalViewClient mClient; + + /** The top row of text to display. Ranges from -activeTranscriptRows to 0. */ + int mTopRow; + + boolean mIsSelectingText = false, mIsDraggingLeftSelection, mInitialTextSelection; + int mSelX1 = -1, mSelX2 = -1, mSelY1 = -1, mSelY2 = -1; + float mSelectionDownX, mSelectionDownY; + private ActionMode mActionMode; + private BitmapDrawable mLeftSelectionHandle, mRightSelectionHandle; + + float mScaleFactor = 1.f; + final GestureAndScaleRecognizer mGestureRecognizer; + + /** Keep track of where mouse touch event started which we report as mouse scroll. */ + private int mMouseScrollStartX = -1, mMouseScrollStartY = -1; + /** Keep track of the time when a touch event leading to sending mouse scroll events started. */ + private long mMouseStartDownTime = -1; + + final Scroller mScroller; + + /** What was left in from scrolling movement. */ + float mScrollRemainder; + + /** If non-zero, this is the last unicode code point received if that was a combining character. */ + int mCombiningAccent; + int mTextSize; + + public TerminalView(Context context, AttributeSet attributeSet) { // NO_UCD (unused code) + super(context, attributeSet); + mGestureRecognizer = new GestureAndScaleRecognizer(context, new GestureAndScaleRecognizer.Listener() { + + boolean scrolledWithFinger; + + @Override + public boolean onUp(MotionEvent e) { + mScrollRemainder = 0.0f; + if (mEmulator != null && mEmulator.isMouseTrackingActive() && !mIsSelectingText && !scrolledWithFinger) { + // Quick event processing when mouse tracking is active - do not wait for check of double tapping + // for zooming. + sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON, true); + sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON, false); + return true; + } + scrolledWithFinger = false; + return false; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + if (mEmulator == null) return true; + if (mIsSelectingText) { + toggleSelectingText(null); + return true; + } + requestFocus(); + if (!mEmulator.isMouseTrackingActive()) { + if (!e.isFromSource(InputDevice.SOURCE_MOUSE)) { + mClient.onSingleTapUp(e); + return true; + } + } + return false; + } + + @Override + public boolean onScroll(MotionEvent e, float distanceX, float distanceY) { + if (mEmulator == null || mIsSelectingText) return true; + if (mEmulator.isMouseTrackingActive() && e.isFromSource(InputDevice.SOURCE_MOUSE)) { + // If moving with mouse pointer while pressing button, report that instead of scroll. + // This means that we never report moving with button press-events for touch input, + // since we cannot just start sending these events without a starting press event, + // which we do not do for touch input, only mouse in onTouchEvent(). + sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true); + } else { + scrolledWithFinger = true; + distanceY += mScrollRemainder; + int deltaRows = (int) (distanceY / mRenderer.mFontLineSpacing); + mScrollRemainder = distanceY - deltaRows * mRenderer.mFontLineSpacing; + doScroll(e, deltaRows); + } + return true; + } + + @Override + public boolean onScale(float focusX, float focusY, float scale) { + if (mEmulator == null || mIsSelectingText) return true; + mScaleFactor *= scale; + mScaleFactor = mClient.onScale(mScaleFactor); + return true; + } + + @Override + public boolean onFling(final MotionEvent e2, float velocityX, float velocityY) { + if (mEmulator == null || mIsSelectingText) return true; + // Do not start scrolling until last fling has been taken care of: + if (!mScroller.isFinished()) return true; + + final boolean mouseTrackingAtStartOfFling = mEmulator.isMouseTrackingActive(); + float SCALE = 0.25f; + if (mouseTrackingAtStartOfFling) { + mScroller.fling(0, 0, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.mRows / 2, mEmulator.mRows / 2); + } else { + mScroller.fling(0, mTopRow, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.getScreen().getActiveTranscriptRows(), 0); + } + + post(new Runnable() { + private int mLastY = 0; + + @Override + public void run() { + if (mouseTrackingAtStartOfFling != mEmulator.isMouseTrackingActive()) { + mScroller.abortAnimation(); + return; + } + if (mScroller.isFinished()) return; + boolean more = mScroller.computeScrollOffset(); + int newY = mScroller.getCurrY(); + int diff = mouseTrackingAtStartOfFling ? (newY - mLastY) : (newY - mTopRow); + doScroll(e2, diff); + mLastY = newY; + if (more) post(this); + } + }); + + return true; + } + + @Override + public boolean onDown(float x, float y) { + return false; + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + // Do not treat is as a single confirmed tap - it may be followed by zoom. + return false; + } + + @Override + public void onLongPress(MotionEvent e) { + if (mGestureRecognizer.isInProgress()) return; + if (mClient.onLongPress(e)) return; + if (!mIsSelectingText) { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + toggleSelectingText(e); + } + } + }); + mScroller = new Scroller(context); + } + + /** + * @param onKeyListener Listener for all kinds of key events, both hardware and IME (which makes it different from that + * available with {@link View#setOnKeyListener(OnKeyListener)}. + */ + public void setOnKeyListener(TerminalViewClient onKeyListener) { + this.mClient = onKeyListener; + } + + /** + * Attach a {@link TerminalSession} to this view. + * + * @param session The {@link TerminalSession} this view will be displaying. + */ + public boolean attachSession(TerminalSession session) { + if (session == mTermSession) return false; + mTopRow = 0; + + mTermSession = session; + mEmulator = null; + mCombiningAccent = 0; + + updateSize(); + + // Wait with enabling the scrollbar until we have a terminal to get scroll position from. + setVerticalScrollBarEnabled(true); + + return true; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + // Using InputType.NULL is the most correct input type and avoids issues with other hacks. + // + // Previous keyboard issues: + // https://github.com/termux/termux-packages/issues/25 + // https://github.com/termux/termux-app/issues/87. + // https://github.com/termux/termux-app/issues/126. + // https://github.com/termux/termux-app/issues/137 (japanese chars and TYPE_NULL). + outAttrs.inputType = InputType.TYPE_NULL; + + // Note that IME_ACTION_NONE cannot be used as that makes it impossible to input newlines using the on-screen + // keyboard on Android TV (see https://github.com/termux/termux-app/issues/221). + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN; + + return new BaseInputConnection(this, true) { + + @Override + public boolean finishComposingText() { + if (LOG_KEY_EVENTS) Log.i(EmulatorDebug.LOG_TAG, "IME: finishComposingText()"); + super.finishComposingText(); + + sendTextToTerminal(getEditable()); + getEditable().clear(); + return true; + } + + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + if (LOG_KEY_EVENTS) { + Log.i(EmulatorDebug.LOG_TAG, "IME: commitText(\"" + text + "\", " + newCursorPosition + ")"); + } + super.commitText(text, newCursorPosition); + + if (mEmulator == null) return true; + + Editable content = getEditable(); + sendTextToTerminal(content); + content.clear(); + return true; + } + + @Override + public boolean deleteSurroundingText(int leftLength, int rightLength) { + if (LOG_KEY_EVENTS) { + Log.i(EmulatorDebug.LOG_TAG, "IME: deleteSurroundingText(" + leftLength + ", " + rightLength + ")"); + } + // The stock Samsung keyboard with 'Auto check spelling' enabled sends leftLength > 1. + KeyEvent deleteKey = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); + for (int i = 0; i < leftLength; i++) sendKeyEvent(deleteKey); + return super.deleteSurroundingText(leftLength, rightLength); + } + + void sendTextToTerminal(CharSequence text) { + final int textLengthInChars = text.length(); + for (int i = 0; i < textLengthInChars; i++) { + char firstChar = text.charAt(i); + int codePoint; + if (Character.isHighSurrogate(firstChar)) { + if (++i < textLengthInChars) { + codePoint = Character.toCodePoint(firstChar, text.charAt(i)); + } else { + // At end of string, with no low surrogate following the high: + codePoint = TerminalEmulator.UNICODE_REPLACEMENT_CHAR; + } + } else { + codePoint = firstChar; + } + + boolean ctrlHeld = false; + if (codePoint <= 31 && codePoint != 27) { + if (codePoint == '\n') { + // The AOSP keyboard and descendants seems to send \n as text when the enter key is pressed, + // instead of a key event like most other keyboard apps. A terminal expects \r for the enter + // key (although when icrnl is enabled this doesn't make a difference - run 'stty -icrnl' to + // check the behaviour). + codePoint = '\r'; + } + + // E.g. penti keyboard for ctrl input. + ctrlHeld = true; + switch (codePoint) { + case 31: + codePoint = '_'; + break; + case 30: + codePoint = '^'; + break; + case 29: + codePoint = ']'; + break; + case 28: + codePoint = '\\'; + break; + default: + codePoint += 96; + break; + } + } + + inputCodePoint(codePoint, ctrlHeld, false); + } + } + + }; + } + + @Override + protected int computeVerticalScrollRange() { + return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows(); + } + + @Override + protected int computeVerticalScrollExtent() { + return mEmulator == null ? 1 : mEmulator.mRows; + } + + @Override + protected int computeVerticalScrollOffset() { + return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows() + mTopRow - mEmulator.mRows; + } + + public void onScreenUpdated() { + if (mEmulator == null) return; + + boolean skipScrolling = false; + if (mIsSelectingText) { + // Do not scroll when selecting text. + int rowsInHistory = mEmulator.getScreen().getActiveTranscriptRows(); + int rowShift = mEmulator.getScrollCounter(); + if (-mTopRow + rowShift > rowsInHistory) { + // .. unless we're hitting the end of history transcript, in which + // case we abort text selection and scroll to end. + toggleSelectingText(null); + } else { + skipScrolling = true; + mTopRow -= rowShift; + mSelY1 -= rowShift; + mSelY2 -= rowShift; + } + } + + if (!skipScrolling && mTopRow != 0) { + // Scroll down if not already there. + if (mTopRow < -3) { + // Awaken scroll bars only if scrolling a noticeable amount + // - we do not want visible scroll bars during normal typing + // of one row at a time. + awakenScrollBars(); + } + mTopRow = 0; + } + + mEmulator.clearScrollCounter(); + + invalidate(); + } + + public int getTextSize() { + return mTextSize; + } + + /** + * Sets the text size, which in turn sets the number of rows and columns. + * + * @param textSize the new font size, in density-independent pixels. + */ + public void setTextSize(int textSize) { + this.mTextSize = textSize; + mRenderer = new TerminalRenderer(textSize, mRenderer == null ? Typeface.MONOSPACE : mRenderer.mTypeface); + updateSize(); + } + + public void setTypeface(Typeface newTypeface) { + mRenderer = new TerminalRenderer(mRenderer.mTextSize, newTypeface); + updateSize(); + invalidate(); + } + + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public boolean isOpaque() { + return true; + } + + /** Send a single mouse event code to the terminal. */ + void sendMouseEventCode(MotionEvent e, int button, boolean pressed) { + int x = (int) (e.getX() / mRenderer.mFontWidth) + 1; + int y = (int) ((e.getY() - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing) + 1; + if (pressed && (button == TerminalEmulator.MOUSE_WHEELDOWN_BUTTON || button == TerminalEmulator.MOUSE_WHEELUP_BUTTON)) { + if (mMouseStartDownTime == e.getDownTime()) { + x = mMouseScrollStartX; + y = mMouseScrollStartY; + } else { + mMouseStartDownTime = e.getDownTime(); + mMouseScrollStartX = x; + mMouseScrollStartY = y; + } + } + mEmulator.sendMouseEvent(button, x, y, pressed); + } + + /** Perform a scroll, either from dragging the screen or by scrolling a mouse wheel. */ + void doScroll(MotionEvent event, int rowsDown) { + boolean up = rowsDown < 0; + int amount = Math.abs(rowsDown); + for (int i = 0; i < amount; i++) { + if (mEmulator.isMouseTrackingActive()) { + sendMouseEventCode(event, up ? TerminalEmulator.MOUSE_WHEELUP_BUTTON : TerminalEmulator.MOUSE_WHEELDOWN_BUTTON, true); + } else if (mEmulator.isAlternateBufferActive()) { + // Send up and down key events for scrolling, which is what some terminals do to make scroll work in + // e.g. less, which shifts to the alt screen without mouse handling. + handleKeyCode(up ? KeyEvent.KEYCODE_DPAD_UP : KeyEvent.KEYCODE_DPAD_DOWN, 0); + } else { + mTopRow = Math.min(0, Math.max(-(mEmulator.getScreen().getActiveTranscriptRows()), mTopRow + (up ? -1 : 1))); + if (!awakenScrollBars()) invalidate(); + } + } + } + + /** Overriding {@link View#onGenericMotionEvent(MotionEvent)}. */ + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + if (mEmulator != null && event.isFromSource(InputDevice.SOURCE_MOUSE) && event.getAction() == MotionEvent.ACTION_SCROLL) { + // Handle mouse wheel scrolling. + boolean up = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0.0f; + doScroll(event, up ? -3 : 3); + return true; + } + return false; + } + + @SuppressLint("ClickableViewAccessibility") + @Override + @TargetApi(23) + public boolean onTouchEvent(MotionEvent ev) { + if (mEmulator == null) return true; + final int action = ev.getAction(); + + if (mIsSelectingText) { + int cy = (int) (ev.getY() / mRenderer.mFontLineSpacing) + mTopRow; + int cx = (int) (ev.getX() / mRenderer.mFontWidth); + + switch (action) { + case MotionEvent.ACTION_UP: + mInitialTextSelection = false; + break; + case MotionEvent.ACTION_DOWN: + int distanceFromSel1 = Math.abs(cx - mSelX1) + Math.abs(cy - mSelY1); + int distanceFromSel2 = Math.abs(cx - mSelX2) + Math.abs(cy - mSelY2); + mIsDraggingLeftSelection = distanceFromSel1 <= distanceFromSel2; + mSelectionDownX = ev.getX(); + mSelectionDownY = ev.getY(); + break; + case MotionEvent.ACTION_MOVE: + if (mInitialTextSelection) break; + float deltaX = ev.getX() - mSelectionDownX; + float deltaY = ev.getY() - mSelectionDownY; + int deltaCols = (int) Math.ceil(deltaX / mRenderer.mFontWidth); + int deltaRows = (int) Math.ceil(deltaY / mRenderer.mFontLineSpacing); + mSelectionDownX += deltaCols * mRenderer.mFontWidth; + mSelectionDownY += deltaRows * mRenderer.mFontLineSpacing; + if (mIsDraggingLeftSelection) { + mSelX1 += deltaCols; + mSelY1 += deltaRows; + } else { + mSelX2 += deltaCols; + mSelY2 += deltaRows; + } + + mSelX1 = Math.min(mEmulator.mColumns, Math.max(0, mSelX1)); + mSelX2 = Math.min(mEmulator.mColumns, Math.max(0, mSelX2)); + + if (mSelY1 == mSelY2 && mSelX1 > mSelX2 || mSelY1 > mSelY2) { + // Switch handles. + mIsDraggingLeftSelection = !mIsDraggingLeftSelection; + int tmpX1 = mSelX1, tmpY1 = mSelY1; + mSelX1 = mSelX2; + mSelY1 = mSelY2; + mSelX2 = tmpX1; + mSelY2 = tmpY1; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + mActionMode.invalidateContentRect(); + invalidate(); + break; + default: + break; + } + mGestureRecognizer.onTouchEvent(ev); + return true; + } else if (ev.isFromSource(InputDevice.SOURCE_MOUSE)) { + if (ev.isButtonPressed(MotionEvent.BUTTON_SECONDARY)) { + if (action == MotionEvent.ACTION_DOWN) showContextMenu(); + return true; + } else if (ev.isButtonPressed(MotionEvent.BUTTON_TERTIARY)) { + ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboard.getPrimaryClip(); + if (clipData != null) { + CharSequence paste = clipData.getItemAt(0).coerceToText(getContext()); + if (!TextUtils.isEmpty(paste)) mEmulator.paste(paste.toString()); + } + } else if (mEmulator.isMouseTrackingActive()) { // BUTTON_PRIMARY. + switch (ev.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_UP: + sendMouseEventCode(ev, TerminalEmulator.MOUSE_LEFT_BUTTON, ev.getAction() == MotionEvent.ACTION_DOWN); + break; + case MotionEvent.ACTION_MOVE: + sendMouseEventCode(ev, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true); + break; + } + return true; + } + } + + mGestureRecognizer.onTouchEvent(ev); + return true; + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + if (LOG_KEY_EVENTS) + Log.i(EmulatorDebug.LOG_TAG, "onKeyPreIme(keyCode=" + keyCode + ", event=" + event + ")"); + if (keyCode == KeyEvent.KEYCODE_BACK) { + if (mIsSelectingText) { + toggleSelectingText(null); + return true; + } else if (mClient.shouldBackButtonBeMappedToEscape()) { + // Intercept back button to treat it as escape: + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + return onKeyDown(keyCode, event); + case KeyEvent.ACTION_UP: + return onKeyUp(keyCode, event); + } + } + } + return super.onKeyPreIme(keyCode, event); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (LOG_KEY_EVENTS) + Log.i(EmulatorDebug.LOG_TAG, "onKeyDown(keyCode=" + keyCode + ", isSystem()=" + event.isSystem() + ", event=" + event + ")"); + if (mEmulator == null) return true; + + if (mClient.onKeyDown(keyCode, event, mTermSession)) { + invalidate(); + return true; + } else if (event.isSystem() && (!mClient.shouldBackButtonBeMappedToEscape() || keyCode != KeyEvent.KEYCODE_BACK)) { + return super.onKeyDown(keyCode, event); + } else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && keyCode == KeyEvent.KEYCODE_UNKNOWN) { + mTermSession.write(event.getCharacters()); + return true; + } + + final int metaState = event.getMetaState(); + final boolean controlDownFromEvent = event.isCtrlPressed(); + final boolean leftAltDownFromEvent = (metaState & KeyEvent.META_ALT_LEFT_ON) != 0; + final boolean rightAltDownFromEvent = (metaState & KeyEvent.META_ALT_RIGHT_ON) != 0; + + int keyMod = 0; + if (controlDownFromEvent) keyMod |= KeyHandler.KEYMOD_CTRL; + if (event.isAltPressed()) keyMod |= KeyHandler.KEYMOD_ALT; + if (event.isShiftPressed()) keyMod |= KeyHandler.KEYMOD_SHIFT; + if (handleKeyCode(keyCode, keyMod)) { + if (LOG_KEY_EVENTS) Log.i(EmulatorDebug.LOG_TAG, "handleKeyCode() took key event"); + return true; + } + + // Clear Ctrl since we handle that ourselves: + int bitsToClear = KeyEvent.META_CTRL_MASK; + if (rightAltDownFromEvent) { + // Let right Alt/Alt Gr be used to compose characters. + } else { + // Use left alt to send to terminal (e.g. Left Alt+B to jump back a word), so remove: + bitsToClear |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON; + } + int effectiveMetaState = event.getMetaState() & ~bitsToClear; + + int result = event.getUnicodeChar(effectiveMetaState); + if (LOG_KEY_EVENTS) + Log.i(EmulatorDebug.LOG_TAG, "KeyEvent#getUnicodeChar(" + effectiveMetaState + ") returned: " + result); + if (result == 0) { + return true; + } + + int oldCombiningAccent = mCombiningAccent; + if ((result & KeyCharacterMap.COMBINING_ACCENT) != 0) { + // If entered combining accent previously, write it out: + if (mCombiningAccent != 0) + inputCodePoint(mCombiningAccent, controlDownFromEvent, leftAltDownFromEvent); + mCombiningAccent = result & KeyCharacterMap.COMBINING_ACCENT_MASK; + } else { + if (mCombiningAccent != 0) { + int combinedChar = KeyCharacterMap.getDeadChar(mCombiningAccent, result); + if (combinedChar > 0) result = combinedChar; + mCombiningAccent = 0; + } + inputCodePoint(result, controlDownFromEvent, leftAltDownFromEvent); + } + + if (mCombiningAccent != oldCombiningAccent) invalidate(); + + return true; + } + + void inputCodePoint(int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) { + if (LOG_KEY_EVENTS) { + Log.i(EmulatorDebug.LOG_TAG, "inputCodePoint(codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent=" + + leftAltDownFromEvent + ")"); + } + + final boolean controlDown = controlDownFromEvent || mClient.readControlKey(); + final boolean altDown = leftAltDownFromEvent || mClient.readAltKey(); + + if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) return; + + if (controlDown) { + if (codePoint >= 'a' && codePoint <= 'z') { + codePoint = codePoint - 'a' + 1; + } else if (codePoint >= 'A' && codePoint <= 'Z') { + codePoint = codePoint - 'A' + 1; + } else if (codePoint == ' ' || codePoint == '2') { + codePoint = 0; + } else if (codePoint == '[' || codePoint == '3') { + codePoint = 27; // ^[ (Esc) + } else if (codePoint == '\\' || codePoint == '4') { + codePoint = 28; + } else if (codePoint == ']' || codePoint == '5') { + codePoint = 29; + } else if (codePoint == '^' || codePoint == '6') { + codePoint = 30; // control-^ + } else if (codePoint == '_' || codePoint == '7' || codePoint == '/') { + // "Ctrl-/ sends 0x1f which is equivalent of Ctrl-_ since the days of VT102" + // - http://apple.stackexchange.com/questions/24261/how-do-i-send-c-that-is-control-slash-to-the-terminal + codePoint = 31; + } else if (codePoint == '8') { + codePoint = 127; // DEL + } + } + + if (codePoint > -1) { + // Work around bluetooth keyboards sending funny unicode characters instead + // of the more normal ones from ASCII that terminal programs expect - the + // desire to input the original characters should be low. + switch (codePoint) { + case 0x02DC: // SMALL TILDE. + codePoint = 0x007E; // TILDE (~). + break; + case 0x02CB: // MODIFIER LETTER GRAVE ACCENT. + codePoint = 0x0060; // GRAVE ACCENT (`). + break; + case 0x02C6: // MODIFIER LETTER CIRCUMFLEX ACCENT. + codePoint = 0x005E; // CIRCUMFLEX ACCENT (^). + break; + } + + // If left alt, send escape before the code point to make e.g. Alt+B and Alt+F work in readline: + mTermSession.writeCodePoint(altDown, codePoint); + } + } + + /** Input the specified keyCode if applicable and return if the input was consumed. */ + public boolean handleKeyCode(int keyCode, int keyMod) { + TerminalEmulator term = mTermSession.getEmulator(); + String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode()); + if (code == null) return false; + mTermSession.write(code); + return true; + } + + /** + * Called when a key is released in the view. + * + * @param keyCode The keycode of the key which was released. + * @param event A {@link KeyEvent} describing the event. + * @return Whether the event was handled. + */ + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (LOG_KEY_EVENTS) + Log.i(EmulatorDebug.LOG_TAG, "onKeyUp(keyCode=" + keyCode + ", event=" + event + ")"); + if (mEmulator == null) return true; + + if (mClient.onKeyUp(keyCode, event)) { + invalidate(); + return true; + } else if (event.isSystem()) { + // Let system key events through. + return super.onKeyUp(keyCode, event); + } + + return true; + } + + /** + * This is called during layout when the size of this view has changed. If you were just added to the view + * hierarchy, you're called with the old values of 0. + */ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + updateSize(); + } + + /** Check if the terminal size in rows and columns should be updated. */ + public void updateSize() { + int viewWidth = getWidth(); + int viewHeight = getHeight(); + if (viewWidth == 0 || viewHeight == 0 || mTermSession == null) return; + + // Set to 80 and 24 if you want to enable vttest. + int newColumns = Math.max(4, (int) (viewWidth / mRenderer.mFontWidth)); + int newRows = Math.max(4, (viewHeight - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing); + + if (mEmulator == null || (newColumns != mEmulator.mColumns || newRows != mEmulator.mRows)) { + mTermSession.updateSize(newColumns, newRows); + mEmulator = mTermSession.getEmulator(); + + mTopRow = 0; + scrollTo(0, 0); + invalidate(); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (mEmulator == null) { + canvas.drawColor(0XFF000000); + } else { + mRenderer.render(mEmulator, canvas, mTopRow, mSelY1, mSelY2, mSelX1, mSelX2); + + if (mIsSelectingText) { + final int gripHandleWidth = mLeftSelectionHandle.getIntrinsicWidth(); + final int gripHandleMargin = gripHandleWidth / 4; // See the png. + + int right = Math.round((mSelX1) * mRenderer.mFontWidth) + gripHandleMargin; + int top = (mSelY1 + 1 - mTopRow) * mRenderer.mFontLineSpacing + mRenderer.mFontLineSpacingAndAscent; + mLeftSelectionHandle.setBounds(right - gripHandleWidth, top, right, top + mLeftSelectionHandle.getIntrinsicHeight()); + mLeftSelectionHandle.draw(canvas); + + int left = Math.round((mSelX2 + 1) * mRenderer.mFontWidth) - gripHandleMargin; + top = (mSelY2 + 1 - mTopRow) * mRenderer.mFontLineSpacing + mRenderer.mFontLineSpacingAndAscent; + mRightSelectionHandle.setBounds(left, top, left + gripHandleWidth, top + mRightSelectionHandle.getIntrinsicHeight()); + mRightSelectionHandle.draw(canvas); + } + } + } + + /** Toggle text selection mode in the view. */ + @TargetApi(23) + public void toggleSelectingText(MotionEvent ev) { + mIsSelectingText = !mIsSelectingText; + mClient.copyModeChanged(mIsSelectingText); + + if (mIsSelectingText) { + if (mLeftSelectionHandle == null) { + mLeftSelectionHandle = (BitmapDrawable) getContext().getDrawable(R.drawable.text_select_handle_left_material); + mRightSelectionHandle = (BitmapDrawable) getContext().getDrawable(R.drawable.text_select_handle_right_material); + } + + int cx = (int) (ev.getX() / mRenderer.mFontWidth); + final boolean eventFromMouse = ev.isFromSource(InputDevice.SOURCE_MOUSE); + // Offset for finger: + final int SELECT_TEXT_OFFSET_Y = eventFromMouse ? 0 : -40; + int cy = (int) ((ev.getY() + SELECT_TEXT_OFFSET_Y) / mRenderer.mFontLineSpacing) + mTopRow; + + mSelX1 = mSelX2 = cx; + mSelY1 = mSelY2 = cy; + + TerminalBuffer screen = mEmulator.getScreen(); + if (!" ".equals(screen.getSelectedText(mSelX1, mSelY1, mSelX1, mSelY1))) { + // Selecting something other than whitespace. Expand to word. + while (mSelX1 > 0 && !"".equals(screen.getSelectedText(mSelX1 - 1, mSelY1, mSelX1 - 1, mSelY1))) { + mSelX1--; + } + while (mSelX2 < mEmulator.mColumns - 1 && !"".equals(screen.getSelectedText(mSelX2 + 1, mSelY1, mSelX2 + 1, mSelY1))) { + mSelX2++; + } + } + + mInitialTextSelection = true; + mIsDraggingLeftSelection = true; + mSelectionDownX = ev.getX(); + mSelectionDownY = ev.getY(); + + final ActionMode.Callback callback = new ActionMode.Callback() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + int show = MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT; + + ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + menu.add(Menu.NONE, 1, Menu.NONE, R.string.copy_text).setShowAsAction(show); + menu.add(Menu.NONE, 2, Menu.NONE, R.string.paste_text).setEnabled(clipboard.hasPrimaryClip()).setShowAsAction(show); + menu.add(Menu.NONE, 3, Menu.NONE, R.string.text_selection_more); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + switch (item.getItemId()) { + case 1: + String selectedText = mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2).trim(); + mTermSession.clipboardText(selectedText); + break; + case 2: + ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboard.getPrimaryClip(); + if (clipData != null) { + CharSequence paste = clipData.getItemAt(0).coerceToText(getContext()); + if (!TextUtils.isEmpty(paste)) mEmulator.paste(paste.toString()); + } + break; + case 3: + showContextMenu(); + break; + } + toggleSelectingText(null); + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + } + + }; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mActionMode = startActionMode(new ActionMode.Callback2() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + return callback.onCreateActionMode(mode, menu); + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return callback.onActionItemClicked(mode, item); + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + // Ignore. + } + + @Override + public void onGetContentRect(ActionMode mode, View view, Rect outRect) { + int x1 = Math.round(mSelX1 * mRenderer.mFontWidth); + int x2 = Math.round(mSelX2 * mRenderer.mFontWidth); + int y1 = Math.round((mSelY1 - mTopRow) * mRenderer.mFontLineSpacing); + int y2 = Math.round((mSelY2 + 1 - mTopRow) * mRenderer.mFontLineSpacing); + outRect.set(Math.min(x1, x2), y1, Math.max(x1, x2), y2); + } + }, ActionMode.TYPE_FLOATING); + } else { + mActionMode = startActionMode(callback); + } + + + invalidate(); + } else { + mActionMode.finish(); + mSelX1 = mSelY1 = mSelX2 = mSelY2 = -1; + invalidate(); + } + } + + public TerminalSession getCurrentSession() { + return mTermSession; + } + +} diff --git a/app/src/main/java/io/neoterm/view/TerminalViewClient.java b/app/src/main/java/io/neoterm/view/TerminalViewClient.java new file mode 100755 index 0000000..aaaca93 --- /dev/null +++ b/app/src/main/java/io/neoterm/view/TerminalViewClient.java @@ -0,0 +1,42 @@ +package io.neoterm.view; + +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; + +import io.neoterm.terminal.TerminalSession; + +/** + * Input and scale listener which may be set on a {@link TerminalView} through + * {@link TerminalView#setOnKeyListener(TerminalViewClient)}. + *

+ */ +public interface TerminalViewClient { + + /** + * Callback function on scale events according to {@link ScaleGestureDetector#getScaleFactor()}. + */ + float onScale(float scale); + + /** + * On a single tap on the terminal if terminal mouse reporting not enabled. + */ + void onSingleTapUp(MotionEvent e); + + boolean shouldBackButtonBeMappedToEscape(); + + void copyModeChanged(boolean copyMode); + + boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session); + + boolean onKeyUp(int keyCode, KeyEvent e); + + boolean readControlKey(); + + boolean readAltKey(); + + boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session); + + boolean onLongPress(MotionEvent event); + +} diff --git a/app/src/main/res/drawable-hdpi/ic_add_box_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_add_box_white_24dp.png new file mode 100755 index 0000000..50814b4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_add_box_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_add_box_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_add_box_white_24dp.png new file mode 100755 index 0000000..de65263 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_add_box_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_add_box_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_add_box_white_24dp.png new file mode 100755 index 0000000..97c73d8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_add_box_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_add_box_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_add_box_white_24dp.png new file mode 100755 index 0000000..4d054c9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_add_box_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/text_select_handle_left_mtrl_alpha.png b/app/src/main/res/drawable-xxhdpi/text_select_handle_left_mtrl_alpha.png new file mode 100755 index 0000000..39818db Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/text_select_handle_left_mtrl_alpha.png differ diff --git a/app/src/main/res/drawable-xxhdpi/text_select_handle_right_mtrl_alpha.png b/app/src/main/res/drawable-xxhdpi/text_select_handle_right_mtrl_alpha.png new file mode 100755 index 0000000..260e090 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/text_select_handle_right_mtrl_alpha.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_add_box_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_add_box_white_24dp.png new file mode 100755 index 0000000..1f300d6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_add_box_white_24dp.png differ diff --git a/app/src/main/res/drawable/text_select_handle_left_material.xml b/app/src/main/res/drawable/text_select_handle_left_material.xml new file mode 100755 index 0000000..89733d5 --- /dev/null +++ b/app/src/main/res/drawable/text_select_handle_left_material.xml @@ -0,0 +1,4 @@ + + diff --git a/app/src/main/res/drawable/text_select_handle_right_material.xml b/app/src/main/res/drawable/text_select_handle_right_material.xml new file mode 100755 index 0000000..21a2cb8 --- /dev/null +++ b/app/src/main/res/drawable/text_select_handle_right_material.xml @@ -0,0 +1,4 @@ + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..22e8d3d --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/layout/tab_main.xml b/app/src/main/res/layout/tab_main.xml new file mode 100644 index 0000000..f48ee1c --- /dev/null +++ b/app/src/main/res/layout/tab_main.xml @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/app/src/main/res/menu/tab_switcher.xml b/app/src/main/res/menu/tab_switcher.xml new file mode 100755 index 0000000..b2d491c --- /dev/null +++ b/app/src/main/res/menu/tab_switcher.xml @@ -0,0 +1,30 @@ + + + +

+ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..5507303 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..8fab6a3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..6bc7fcd Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..1eecc0e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..ec87dce Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..05ca079 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6f67f21 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8bac0f2 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..0327e13 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..bacd3e7 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..3ab3e9c --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #3F51B5 + #303F9F + #FF4081 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a1c3efc --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + Neo Term + Copy + Paste + More + + Toggle switcher + New tab + Clear all tabs + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..9785e0c --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/test/java/io/neoterm/ExampleUnitTest.kt b/app/src/test/java/io/neoterm/ExampleUnitTest.kt new file mode 100644 index 0000000..9d79b11 --- /dev/null +++ b/app/src/test/java/io/neoterm/ExampleUnitTest.kt @@ -0,0 +1,18 @@ +package io.neoterm + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see [Testing documentation](http://d.android.com/tools/testing) + */ +class ExampleUnitTest { + @Test + @Throws(Exception::class) + fun addition_isCorrect() { + assertEquals(4, (2 + 2).toLong()) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..d51b410 --- /dev/null +++ b/build.gradle @@ -0,0 +1,28 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext.kotlin_version = '1.1.2-4' + repositories { + maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' } + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.0.0-alpha1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + jcenter() + maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' } + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/chrome-tabs/.gitignore b/chrome-tabs/.gitignore new file mode 100755 index 0000000..796b96d --- /dev/null +++ b/chrome-tabs/.gitignore @@ -0,0 +1 @@ +/build diff --git a/chrome-tabs/build.gradle b/chrome-tabs/build.gradle new file mode 100755 index 0000000..1b391f0 --- /dev/null +++ b/chrome-tabs/build.gradle @@ -0,0 +1,25 @@ +apply plugin: 'com.android.library' + +android { + compileSdkVersion 25 + buildToolsVersion "25.0.2" + + defaultConfig { + minSdkVersion 21 + targetSdkVersion 25 + versionCode 1 + versionName "1.0" + } + buildTypes { + release { + minifyEnabled false + } + } +} + +dependencies { + compile 'com.github.michael-rapp:android-util:1.15.0' + compile 'com.android.support:support-annotations:25.3.0' + compile 'com.android.support:appcompat-v7:25.3.0' + testCompile 'junit:junit:4.12' +} diff --git a/chrome-tabs/gradle.properties b/chrome-tabs/gradle.properties new file mode 100755 index 0000000..f639a40 --- /dev/null +++ b/chrome-tabs/gradle.properties @@ -0,0 +1,3 @@ +POM_NAME=ChromeLikeTabSwitcher +POM_ARTIFACT_ID=chrome-like-tab-switcher +POM_PACKAGING=aar \ No newline at end of file diff --git a/chrome-tabs/src/main/AndroidManifest.xml b/chrome-tabs/src/main/AndroidManifest.xml new file mode 100755 index 0000000..b458ce8 --- /dev/null +++ b/chrome-tabs/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Animation.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Animation.java new file mode 100755 index 0000000..a77ffe8 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Animation.java @@ -0,0 +1,162 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.animation.Interpolator; + +import static de.mrapp.android.util.Condition.ensureAtLeast; + +/** + * An animation, which can be used to add or remove tabs to/from a {@link TabSwitcher}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public abstract class Animation { + + /** + * An abstract base class for all builders, which allow to configure and create instances of the + * class {@link Animation}. + * + * @param + * The type of the animations, which are created by the builder + * @param + * The type of the builder + */ + protected static abstract class Builder { + + /** + * The duration of the animations, which are created by the builder. + */ + protected long duration; + + /** + * The interpolator, which is used by the animations, which are created by the builder. + */ + protected Interpolator interpolator; + + /** + * Returns a reference to the builder. + * + * @return A reference to the builder, casted to the generic type BuilderType. The reference + * may not be null + */ + @NonNull + @SuppressWarnings("unchecked") + protected final BuilderType self() { + return (BuilderType) this; + } + + /** + * Creates a new builder, which allows to configure and create instances of the class {@link + * Animation}. + */ + public Builder() { + setDuration(-1); + setInterpolator(null); + } + + /** + * Creates and returns the animation. + * + * @return The animation, which has been created, as an instance of the generic type + * AnimationType. The animation may not be null + */ + @NonNull + public abstract AnimationType create(); + + /** + * Sets the duration of the animations, which are created by the builder. + * + * @param duration + * The duration, which should be set, in milliseconds as a {@link Long} value or -1, + * if the default duration should be used + * @return The builder, this method has be called upon, as an instance of the generic type + * BuilderType. The builder may not be null + */ + @NonNull + public final BuilderType setDuration(final long duration) { + ensureAtLeast(duration, -1, "The duration must be at least -1"); + this.duration = duration; + return self(); + } + + /** + * Sets the interpolator, which should be used by the animations, which are created by the + * builder. + * + * @param interpolator + * The interpolator, which should be set, as an instance of the type {@link + * Interpolator} or null, if the default interpolator should be used + * @return The builder, this method has be called upon, as an instance of the generic type + * BuilderType. The builder may not be null + */ + @NonNull + public final BuilderType setInterpolator(@Nullable final Interpolator interpolator) { + this.interpolator = interpolator; + return self(); + } + + } + + /** + * The duration of the animation in milliseconds. + */ + private final long duration; + + /** + * The interpolator, which is used by the animation. + */ + private final Interpolator interpolator; + + /** + * Creates a new animation. + * + * @param duration + * The duration of the animation in milliseconds as a {@link Long} value or -1, if the + * default duration should be used + * @param interpolator + * The interpolator, which should be used by the animation, as an instance of the type + * {@link Interpolator} or null, if the default interpolator should be used + */ + protected Animation(final long duration, @Nullable final Interpolator interpolator) { + ensureAtLeast(duration, -1, "The duration must be at least -1"); + this.duration = duration; + this.interpolator = interpolator; + } + + /** + * Returns the duration of the animation. + * + * @return The duration of the animation in milliseconds as a {@link Long} value or -1, if the + * default duration is used + */ + public final long getDuration() { + return duration; + } + + /** + * Returns the interpolator, which is used by the animation. + * + * @return The interpolator, which is used by the animation, as an instance of the type {@link + * Interpolator} or null, if the default interpolator is used + */ + @Nullable + public final Interpolator getInterpolator() { + return interpolator; + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Layout.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Layout.java new file mode 100755 index 0000000..fd3d341 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Layout.java @@ -0,0 +1,39 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +/** + * Contains all possible layouts of a {@link TabSwitcher}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public enum Layout { + + /** + * The layout, which is used on smartphones and phablet devices, when in portrait mode. + */ + PHONE_PORTRAIT, + + /** + * The layout, which is used on smartphones and phablet devices, when in landscape mode. + */ + PHONE_LANDSCAPE, + + /** + * The layout, which is used on tablets. + */ + TABLET + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/LayoutPolicy.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/LayoutPolicy.java new file mode 100755 index 0000000..b413623 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/LayoutPolicy.java @@ -0,0 +1,83 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +/** + * Contains all possible layout policies of a {@link TabSwitcher}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public enum LayoutPolicy { + + /** + * If the layout should automatically adapted, depending on whether the device is a smartphone + * or tablet. + */ + AUTO(0), + + /** + * If the smartphone layout should be used, regardless of the device. + */ + PHONE(1), + + /** + * If the tablet layout should be used, regardless of the device. + */ + TABLET(2); + + /** + * The value of the layout policy. + */ + private int value; + + /** + * Creates a new layout policy. + * + * @param value + * The value of the layout policy as an {@link Integer} value + */ + LayoutPolicy(final int value) { + this.value = value; + } + + /** + * Returns the value of the layout policy. + * + * @return The value of the layout policy as an {@link Integer} value + */ + public final int getValue() { + return value; + } + + /** + * Returns the layout policy, which corresponds to a specific value. + * + * @param value + * The value of the layout policy, which should be returned, as an {@link Integer} + * value + * @return The layout policy, which corresponds to the given value, as a value of the enum + * {@link LayoutPolicy} + */ + public static LayoutPolicy fromValue(final int value) { + for (LayoutPolicy layoutPolicy : values()) { + if (layoutPolicy.getValue() == value) { + return layoutPolicy; + } + } + + throw new IllegalArgumentException("Invalid enum value: " + value); + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/PeekAnimation.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/PeekAnimation.java new file mode 100755 index 0000000..ff155cf --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/PeekAnimation.java @@ -0,0 +1,140 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.animation.Interpolator; + +/** + * A peek animation, which animates the size of a tab starting at a specific position in order to + * show the tab for a short time at the end of a {@link TabSwitcher}. Peek animations can be used to + * add tabs while the tab switcher is not shown and when using the smartphone layout. They are meant + * to be used when adding a tab without selecting it and enable the user to peek at the added tab + * for a short moment. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class PeekAnimation extends Animation { + + /** + * A builder, which allows to configure and create instances of the class {@link + * PeekAnimation}. + */ + public static class Builder extends Animation.Builder { + + /** + * The horizontal position, the animations, which are created by the builder, start at. + */ + private float x; + + /** + * The vertical position, the animations, which are created by the builder, start at. + */ + private float y; + + /** + * Creates a new builder, which allows to configure and create instances of the class {@link + * PeekAnimation}. + */ + public Builder() { + setX(0); + setY(0); + } + + /** + * Sets the horizontal position, the animations, which are created by the builder, should + * start at. + * + * @param x + * The horizontal position, which should be set, in pixels as a {@link Float} value + * @return The builder, this method has be called upon, as an instance of the generic type + * BuilderType. The builder may not be null + */ + @NonNull + public final Builder setX(final float x) { + this.x = x; + return self(); + } + + /** + * Sets the vertical position, the animations, which are created by the builder, should + * start at. + * + * @param y + * The vertical position, which should be set, in pixels as a {@link Float} value + * @return The builder, this method has be called upon, as an instance of the generic type + * BuilderType. The builder may not be null + */ + @NonNull + public final Builder setY(final float y) { + this.y = y; + return self(); + } + + @NonNull + @Override + public final PeekAnimation create() { + return new PeekAnimation(duration, interpolator, x, y); + } + + } + + /** + * The horizontal position, the animation starts at. + */ + private final float x; + + /** + * The vertical position, the animation starts at. + */ + private final float y; + + /** + * Creates a new reveal animation. + * + * @param x + * The horizontal position, the animation should start at, in pixels as a {@link Float} + * value + * @param y + * The vertical position, the animation should start at, in pixels as a {@link Float} + * value + */ + private PeekAnimation(final long duration, @Nullable final Interpolator interpolator, + final float x, final float y) { + super(duration, interpolator); + this.x = x; + this.y = y; + } + + /** + * Returns the horizontal position, the animation starts at. + * + * @return The horizontal position, the animation starts at, in pixels as a {@link Float} value + */ + public final float getX() { + return x; + } + + /** + * Returns the vertical position, the animation starts at. + * + * @return The vertical position, the animation starts at, in pixels as a {@link Float} value + */ + public final float getY() { + return y; + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/RevealAnimation.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/RevealAnimation.java new file mode 100755 index 0000000..3ab7668 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/RevealAnimation.java @@ -0,0 +1,138 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.animation.Interpolator; + +/** + * A reveal animation, which animates the size of a tab starting at a specific position. Reveal + * animations can be used to add tabs to a {@link TabSwitcher} when using the smartphone layout. + * Tabs, which have been added by using a reveal animation, are selected automatically. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class RevealAnimation extends Animation { + + /** + * A builder, which allows to configure and create instances of the class {@link + * RevealAnimation}. + */ + public static class Builder extends Animation.Builder { + + /** + * The horizontal position, the animations, which are created by the builder, start at. + */ + private float x; + + /** + * The vertical position, the animations, which are created by the builder, start at. + */ + private float y; + + /** + * Creates a new builder, which allows to configure and create instances of the class {@link + * RevealAnimation}. + */ + public Builder() { + setX(0); + setY(0); + } + + /** + * Sets the horizontal position, the animations, which are created by the builder, should + * start at. + * + * @param x + * The horizontal position, which should be set, in pixels as a {@link Float} value + * @return The builder, this method has be called upon, as an instance of the generic type + * BuilderType. The builder may not be null + */ + @NonNull + public final Builder setX(final float x) { + this.x = x; + return self(); + } + + /** + * Sets the vertical position, the animations, which are created by the builder, should + * start at. + * + * @param y + * The vertical position, which should be set, in pixels as a {@link Float} value + * @return The builder, this method has be called upon, as an instance of the generic type + * BuilderType. The builder may not be null + */ + @NonNull + public final Builder setY(final float y) { + this.y = y; + return self(); + } + + @NonNull + @Override + public final RevealAnimation create() { + return new RevealAnimation(duration, interpolator, x, y); + } + + } + + /** + * The horizontal position, the animation starts at. + */ + private final float x; + + /** + * The vertical position, the animation starts at. + */ + private final float y; + + /** + * Creates a new reveal animation. + * + * @param x + * The horizontal position, the animation should start at, in pixels as a {@link Float} + * value + * @param y + * The vertical position, the animation should start at, in pixels as a {@link Float} + * value + */ + private RevealAnimation(final long duration, @Nullable final Interpolator interpolator, + final float x, final float y) { + super(duration, interpolator); + this.x = x; + this.y = y; + } + + /** + * Returns the horizontal position, the animation starts at. + * + * @return The horizontal position, the animation starts at, in pixels as a {@link Float} value + */ + public final float getX() { + return x; + } + + /** + * Returns the vertical position, the animation starts at. + * + * @return The vertical position, the animation starts at, in pixels as a {@link Float} value + */ + public final float getY() { + return y; + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/SwipeAnimation.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/SwipeAnimation.java new file mode 100755 index 0000000..5b42b00 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/SwipeAnimation.java @@ -0,0 +1,131 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.animation.Interpolator; + +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * A swipe animation, which moves tabs on the orthogonal axis, while animating their size and + * opacity at the same time. Swipe animations can be used to add or remove tabs to a {@link + * TabSwitcher} when using the smartphone layout. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class SwipeAnimation extends Animation { + + /** + * Contains all possible directions of a swipe animation. + */ + public enum SwipeDirection { + + /** + * When the tab should be swiped in/out from/to the left, respectively the top, when + * dragging horizontally. + */ + LEFT, + + /** + * When the tab should be swiped in/out from/to the right, respectively the bottom, when + * dragging horizontally. + */ + RIGHT + + } + + /** + * A builder, which allows to configure and create instances of the class {@link + * SwipeAnimation}. + */ + public static class Builder extends Animation.Builder { + + /** + * The direction of the animations, which are created by the builder. + */ + private SwipeDirection direction; + + /** + * Creates a new builder, which allows to configure and create instances of the class {@link + * SwipeAnimation}. + */ + public Builder() { + setDirection(SwipeDirection.RIGHT); + } + + /** + * Sets the direction of the animations, which are created by the builder. + * + * @param direction + * The direction, which should be set, as a value of the enum {@link + * SwipeDirection}. The direction may either be {@link SwipeDirection#LEFT} or + * {@link SwipeDirection#RIGHT} + * @return The builder, this method has be called upon, as an instance of the generic type + * BuilderType. The builder may not be null + */ + @NonNull + public final Builder setDirection(@NonNull final SwipeDirection direction) { + ensureNotNull(direction, "The direction may not be null"); + this.direction = direction; + return self(); + } + + @NonNull + @Override + public final SwipeAnimation create() { + return new SwipeAnimation(duration, interpolator, direction); + } + + } + + /** + * The direction of the swipe animation. + */ + private final SwipeDirection direction; + + /** + * Creates a new swipe animation. + * + * @param duration + * The duration of the animation in milliseconds as a {@link Long} value or -1, if the + * default duration should be used + * @param interpolator + * The interpolator, which should be used by the animation, as an instance of the type + * {@link Interpolator} or null, if the default interpolator should be used + * @param direction + * The direction of the swipe animation as a value of the enum {@link SwipeDirection}. + * The direction may not be null + */ + private SwipeAnimation(final long duration, @Nullable final Interpolator interpolator, + @NonNull final SwipeDirection direction) { + super(duration, interpolator); + ensureNotNull(direction, "The direction may not be null"); + this.direction = direction; + } + + /** + * Returns the direction of the swipe animation. + * + * @return The direction of the swipe animation as a value of the enum {@link SwipeDirection}. + * The direction may not be null + */ + @NonNull + public final SwipeDirection getDirection() { + return direction; + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Tab.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Tab.java new file mode 100755 index 0000000..2cdab39 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Tab.java @@ -0,0 +1,573 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.ColorInt; +import android.support.annotation.DrawableRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.v4.content.ContextCompat; +import android.text.TextUtils; + +import java.util.LinkedHashSet; +import java.util.Set; + +import static de.mrapp.android.util.Condition.ensureNotEmpty; +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * A tab, which can be added to a {@link TabSwitcher} widget. It has a title, as well as an optional + * icon. Furthermore, it is possible to set a custom color and to specify, whether the tab should be + * closeable, or not. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class Tab implements Parcelable { + + /** + * A creator, which allows to create instances of the class {@link Tab} from parcels. + */ + public static final Creator CREATOR = new Creator() { + + @Override + public Tab createFromParcel(final Parcel source) { + return new Tab(source); + } + + @Override + public Tab[] newArray(final int size) { + return new Tab[size]; + } + + }; + + /** + * Defines the interface, a class, which should be notified, when a tab's properties have been + * changed, must implement. + */ + public interface Callback { + + /** + * The method, which is invoked, when the tab's title has been changed. + * + * @param tab + * The observed tab as an instance of the class {@link Tab}. The tab may not be + * null + */ + void onTitleChanged(@NonNull Tab tab); + + /** + * The method, which is invoked, when the tab's icon has been changed. + * + * @param tab + * The observed tab as an instance of the class {@link Tab}. The tab may not be + * null + */ + void onIconChanged(@NonNull Tab tab); + + /** + * The method, which is invoked, when it has been changed, whether the tab is closeable, or + * not. + * + * @param tab + * The observed tab as an instance of the class {@link Tab}. The tab may not be + * null + */ + void onCloseableChanged(@NonNull Tab tab); + + /** + * The method, which is invoked, when the icon of the tab's close button has been changed. + * + * @param tab + * The observed tab as an instance of the class {@link Tab}. The tab may not be + * null + */ + void onCloseButtonIconChanged(@NonNull Tab tab); + + /** + * The method, which is invoked, when the tab's background color has been changed. + * + * @param tab + * The observed tab as an instance of the class {@link Tab}. The tab may not be + * null + */ + void onBackgroundColorChanged(@NonNull Tab tab); + + /** + * The method, which is invoked, when the text color of the tab's title has been changed. + * + * @param tab + * The observed tab as an instance of the class {@link Tab}. The tab may not be + * null + */ + void onTitleTextColorChanged(@NonNull Tab tab); + + } + + /** + * A set, which contains the callbacks, which have been registered to be notified, when the + * tab's properties have been changed. + */ + private final Set callbacks = new LinkedHashSet<>(); + + /** + * The tab's title. + */ + private CharSequence title; + + /** + * The resource id of the tab's icon. + */ + private int iconId; + + /** + * The tab's icon as a bitmap. + */ + private Bitmap iconBitmap; + + /** + * True, if the tab is closeable, false otherwise. + */ + private boolean closeable; + + /** + * The resource id of the icon of the tab's close button. + */ + private int closeButtonIconId; + + /** + * The bitmap of the icon of the tab's close button. + */ + private Bitmap closeButtonIconBitmap; + + /** + * The background color of the tab. + */ + private ColorStateList backgroundColor; + + /** + * The text color of the tab's title. + */ + private ColorStateList titleTextColor; + + /** + * Optional parameters, which are associated with the tab. + */ + private Bundle parameters; + + /** + * Notifies all callbacks, that the tab's title has been changed. + */ + private void notifyOnTitleChanged() { + for (Callback callback : callbacks) { + callback.onTitleChanged(this); + } + } + + /** + * Notifies all callbacks, that the tab's icon has been changed. + */ + private void notifyOnIconChanged() { + for (Callback callback : callbacks) { + callback.onIconChanged(this); + } + } + + /** + * Notifies all callbacks, that it has been changed, whether the tab is closeable, or not. + */ + private void notifyOnCloseableChanged() { + for (Callback callback : callbacks) { + callback.onCloseableChanged(this); + } + } + + /** + * Notifies all callbacks, that the icon of the tab's close button has been changed. + */ + private void notifyOnCloseButtonIconChanged() { + for (Callback callback : callbacks) { + callback.onCloseButtonIconChanged(this); + } + } + + /** + * Notifies all callbacks, that the background color of the tab has been changed. + */ + private void notifyOnBackgroundColorChanged() { + for (Callback callback : callbacks) { + callback.onBackgroundColorChanged(this); + } + } + + /** + * Notifies all callbacks, that the text color of the tab has been changed. + */ + private void notifyOnTitleTextColorChanged() { + for (Callback callback : callbacks) { + callback.onTitleTextColorChanged(this); + } + } + + /** + * Creates a new tab, which can be added to a {@link TabSwitcher} widget. + * + * @param source + * The parcel, the tab should be created from, as an instance of the class {@link + * Parcel}. The parcel may not be null + */ + private Tab(@NonNull final Parcel source) { + this.title = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(source); + this.iconId = source.readInt(); + this.iconBitmap = source.readParcelable(getClass().getClassLoader()); + this.closeable = source.readInt() > 0; + this.closeButtonIconId = source.readInt(); + this.closeButtonIconBitmap = source.readParcelable(getClass().getClassLoader()); + this.backgroundColor = source.readParcelable(getClass().getClassLoader()); + this.titleTextColor = source.readParcelable(getClass().getClassLoader()); + this.parameters = source.readBundle(getClass().getClassLoader()); + } + + /** + * Creates a new tab, which can be added to a {@link TabSwitcher} widget. + * + * @param title + * The tab's title as an instance of the type {@link CharSequence}. The title may not be + * neither be null, nor empty + */ + public Tab(@NonNull final CharSequence title) { + setTitle(title); + this.closeable = true; + this.closeButtonIconId = -1; + this.closeButtonIconBitmap = null; + this.iconId = -1; + this.iconBitmap = null; + this.backgroundColor = null; + this.titleTextColor = null; + this.parameters = null; + } + + /** + * Creates a new tab, which can be added to a {@link TabSwitcher} widget. + * + * @param context + * The context, which should be used, as an instance of the class {@link Context}. The + * context may not be null + * @param resourceId + * The resource id of the tab's title as an {@link Integer} value. The resource id must + * correspond to a valid string resource + */ + public Tab(@NonNull final Context context, @StringRes final int resourceId) { + this(context.getString(resourceId)); + } + + /** + * Returns the tab's title. + * + * @return The tab's title as an instance of the type {@link CharSequence}. The title may + * neither be null, nor empty + */ + @NonNull + public final CharSequence getTitle() { + return title; + } + + /** + * Sets the tab's title. + * + * @param title + * The title, which should be set, as an instance of the type {@link CharSequence}. The + * title may neither be null, nor empty + */ + public final void setTitle(@NonNull final CharSequence title) { + ensureNotNull(title, "The title may not be null"); + ensureNotEmpty(title, "The title may not be empty"); + this.title = title; + notifyOnTitleChanged(); + } + + /** + * Sets the tab's title. + * + * @param context + * The context, which should be used, as an instance of the class {@link Context}. The + * context may not be null + * @param resourceId + * The resource id of the title, which should be set, as an {@link Integer} value. The + * resource id must correspond to a valid string resource + */ + public final void setTitle(@NonNull final Context context, @StringRes final int resourceId) { + setTitle(context.getText(resourceId)); + } + + /** + * Returns the tab's icon. + * + * @param context + * The context, which should be used, as an instance of the class {@link Context}. The + * context may not be null + * @return The tab's icon as an instance of the class {@link Drawable} or null, if no custom + * icon is set + */ + @Nullable + public final Drawable getIcon(@NonNull final Context context) { + ensureNotNull(context, "The context may not be null"); + + if (iconId != -1) { + return ContextCompat.getDrawable(context, iconId); + } else { + return iconBitmap != null ? new BitmapDrawable(context.getResources(), iconBitmap) : + null; + } + } + + /** + * Sets the tab's icon. + * + * @param resourceId + * The resource id of the icon, which should be set, as an {@link Integer} value. The + * resource id must correspond to a valid drawable resource + */ + public final void setIcon(@DrawableRes final int resourceId) { + this.iconId = resourceId; + this.iconBitmap = null; + notifyOnIconChanged(); + } + + /** + * Sets the tab's icon. + * + * @param icon + * The icon, which should be set, as an instance of the class {@link Bitmap} or null, if + * no custom icon should be set + */ + public final void setIcon(@Nullable final Bitmap icon) { + this.iconId = -1; + this.iconBitmap = icon; + notifyOnIconChanged(); + } + + /** + * Returns, whether the tab is closeable, or not. + * + * @return True, if the tab is closeable, false otherwise + */ + public final boolean isCloseable() { + return closeable; + } + + /** + * Sets, whether the tab should be closeable, or not. + * + * @param closeable + * True, if the tab should be closeable, false otherwise + */ + public final void setCloseable(final boolean closeable) { + this.closeable = closeable; + notifyOnCloseableChanged(); + } + + /** + * Returns the icon of the tab's close button. + * + * @param context + * The context, which should be used to retrieve the icon, as an instance of the class + * {@link Context}. The context may not be null + * @return The icon of the tab's close button as an instance of the class {@link Drawable} or + * null, if no custom icon is set + */ + @Nullable + public final Drawable getCloseButtonIcon(@NonNull final Context context) { + ensureNotNull(context, "The context may not be null"); + + if (closeButtonIconId != -1) { + return ContextCompat.getDrawable(context, closeButtonIconId); + } else { + return closeButtonIconBitmap != null ? + new BitmapDrawable(context.getResources(), closeButtonIconBitmap) : null; + } + } + + /** + * Sets the icon of the tab's close button. + * + * @param resourceId + * The resource id of the icon, which should be set, as an {@link Integer} value. The + * resource id must correspond to a valid drawable resource + */ + public final void setCloseButtonIcon(@DrawableRes final int resourceId) { + this.closeButtonIconId = resourceId; + this.closeButtonIconBitmap = null; + notifyOnCloseButtonIconChanged(); + } + + /** + * Sets the icon of the tab's close button. + * + * @param icon + * The icon, which should be set, as an instance of the class {@link Bitmap} or null, if + * no custom icon should be set + */ + public final void setCloseButtonIcon(@Nullable final Bitmap icon) { + this.closeButtonIconId = -1; + this.closeButtonIconBitmap = icon; + notifyOnCloseButtonIconChanged(); + } + + /** + * Returns the background color of the tab. + * + * @return The background color of the tab as an instance of the class {@link ColorStateList} or + * -1, if no custom color is set + */ + @Nullable + public final ColorStateList getBackgroundColor() { + return backgroundColor; + } + + /** + * Sets the tab's background color. + * + * @param color + * The color, which should be set, as an {@link Integer} value or -1, if no custom color + * should be set + */ + public final void setBackgroundColor(@ColorInt final int color) { + setBackgroundColor(color != -1 ? ColorStateList.valueOf(color) : null); + } + + /** + * Sets the tab's background color. + * + * @param colorStateList + * The color state list, which should be set, as an instance of the class {@link + * ColorStateList} or null, if no custom color should be set + */ + public final void setBackgroundColor(@Nullable final ColorStateList colorStateList) { + this.backgroundColor = colorStateList; + notifyOnBackgroundColorChanged(); + } + + /** + * Returns the text color of the tab's title. + * + * @return The text color of the tab's title as an instance of the class {@link ColorStateList} + * or null, if no custom color is set + */ + @Nullable + public final ColorStateList getTitleTextColor() { + return titleTextColor; + } + + /** + * Sets the text color of the tab's title. + * + * @param color + * The color, which should be set, as an {@link Integer} value or -1, if no custom color + * should be set + */ + public final void setTitleTextColor(@ColorInt final int color) { + setTitleTextColor(color != -1 ? ColorStateList.valueOf(color) : null); + } + + /** + * Sets the text color of the tab's title. + * + * @param colorStateList + * The color state list, which should be set, as an instance of the class {@link + * ColorStateList} or null, if no custom color should be set + */ + public final void setTitleTextColor(@Nullable final ColorStateList colorStateList) { + this.titleTextColor = colorStateList; + notifyOnTitleTextColorChanged(); + } + + /** + * Returns a bundle, which contains the optional parameters, which are associated with the tab. + * + * @return A bundle, which contains the optional parameters, which are associated with the tab, + * as an instance of the class {@link Bundle} or null, if no parameters are associated with the + * tab + */ + @Nullable + public final Bundle getParameters() { + return parameters; + } + + /** + * Sets a bundle, which contains the optional parameters, which should be associated with the + * tab. + * + * @param parameters + * The bundle, which should be set, as an instance of the class {@link Bundle} or null, + * if no parameters should be associated with the tab + */ + public final void setParameters(@Nullable final Bundle parameters) { + this.parameters = parameters; + } + + /** + * Adds a new callback, which should be notified, when the tab's properties have been changed. + * + * @param callback + * The callback, which should be added, as an instance of the type {@link Callback}. The + * callback may not be null + */ + public final void addCallback(@NonNull final Callback callback) { + ensureNotNull(callback, "The callback may not be null"); + this.callbacks.add(callback); + } + + /** + * Removes a specific callback, which should not be notified, when the tab's properties have + * been changed, anymore. + * + * @param callback + * The callback, which should be removed, as an instance of the type {@link Callback}. + * The callback may not be null + */ + public final void removeCallback(@NonNull final Callback callback) { + ensureNotNull(callback, "The callback may not be null"); + this.callbacks.remove(callback); + } + + @Override + public final int describeContents() { + return 0; + } + + @Override + public final void writeToParcel(final Parcel parcel, final int flags) { + TextUtils.writeToParcel(title, parcel, flags); + parcel.writeInt(iconId); + parcel.writeParcelable(iconBitmap, flags); + parcel.writeInt(closeable ? 1 : 0); + parcel.writeInt(closeButtonIconId); + parcel.writeParcelable(closeButtonIconBitmap, flags); + parcel.writeParcelable(backgroundColor, flags); + parcel.writeParcelable(titleTextColor, flags); + parcel.writeBundle(parameters); + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabCloseListener.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabCloseListener.java new file mode 100755 index 0000000..b48163e --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabCloseListener.java @@ -0,0 +1,40 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +import android.support.annotation.NonNull; + +/** + * Defines the interface, a class, which should be notified, when a tab is about to be closed by + * clicking its close button, must implement. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public interface TabCloseListener { + + /** + * The method, which is invoked, when a tab is about to be closed by clicking its close button. + * + * @param tabSwitcher + * The tab switcher, the tab belongs to, as an instance of the class {@link + * TabSwitcher}. The tab switcher may not be null + * @param tab + * The tab, which is about to be closed, as an instance of the class {@link Tab}. The + * tab may not be null + * @return True, if the tab should be closed, false otherwise + */ + boolean onCloseTab(@NonNull TabSwitcher tabSwitcher, @NonNull Tab tab); + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabPreviewListener.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabPreviewListener.java new file mode 100755 index 0000000..3c4c2af --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabPreviewListener.java @@ -0,0 +1,41 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +import android.support.annotation.NonNull; + +/** + * Defines the interface, a class, which should be notified, when the preview of a tab is about to + * be loaded, must implement. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public interface TabPreviewListener { + + /** + * The method, which is invoked, when the preview of a tab is about to be loaded. + * + * @param tabSwitcher + * The tab switcher, which contains the tab, whose preview is about to be loaded, as an + * instance of the class {@link TabSwitcher}. The tab switcher may not be null + * @param tab + * The tab, whose preview is about to be loaded, as an instance of the class {@link + * Tab}. The tab may not be null + * @return True, if loading the preview should be proceeded, false otherwise. When returning + * false, the method gets invoked repeatedly until true is returned. + */ + boolean onLoadTabPreview(@NonNull TabSwitcher tabSwitcher, @NonNull Tab tab); + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcher.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcher.java new file mode 100755 index 0000000..37ea16e --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcher.java @@ -0,0 +1,1454 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.AttrRes; +import android.support.annotation.ColorInt; +import android.support.annotation.DrawableRes; +import android.support.annotation.MenuRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.annotation.StyleRes; +import android.support.v4.content.ContextCompat; +import android.support.v4.util.Pair; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.Toolbar; +import android.support.v7.widget.Toolbar.OnMenuItemClickListener; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.widget.FrameLayout; + +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.Queue; +import java.util.Set; + +import de.mrapp.android.tabswitcher.layout.AbstractTabSwitcherLayout; +import de.mrapp.android.tabswitcher.layout.AbstractTabSwitcherLayout.LayoutListenerWrapper; +import de.mrapp.android.tabswitcher.layout.TabSwitcherLayout; +import de.mrapp.android.tabswitcher.layout.phone.PhoneArithmetics; +import de.mrapp.android.tabswitcher.layout.phone.PhoneTabSwitcherLayout; +import de.mrapp.android.tabswitcher.model.Model; +import de.mrapp.android.tabswitcher.model.TabSwitcherModel; +import de.mrapp.android.tabswitcher.view.TabSwitcherButton; +import de.mrapp.android.util.DisplayUtil.DeviceType; +import de.mrapp.android.util.DisplayUtil.Orientation; +import de.mrapp.android.util.ViewUtil; +import de.mrapp.android.util.logging.LogLevel; +import de.mrapp.android.util.view.AbstractSavedState; + +import static de.mrapp.android.util.Condition.ensureNotNull; +import static de.mrapp.android.util.DisplayUtil.getDeviceType; +import static de.mrapp.android.util.DisplayUtil.getOrientation; + +/** + * A tab switcher, which allows to switch between multiple tabs. It it is designed similar to the + * tab switcher of the Google Chrome Android app. + * + * In order to specify the appearance of individual tabs, a class, which extends from the abstract + * class {@link TabSwitcherDecorator}, must be implemented and set to the tab switcher via the + * setDecorator-method. + * + * The currently selected tab is shown fullscreen by default. When displaying the switcher via the + * showSwitcher-method, an overview of all tabs is shown, allowing to select an other + * tab by clicking it. By swiping a tab or by clicking its close button, it can be removed, + * resulting in the selected tab to be altered automatically. The switcher can programmatically be + * hidden by calling the hideSwitcher-method. By calling the + * setSelectedTab-method programmatically, a tab is selected and shown fullscreen. + * + * Individual tabs are represented by instances of the class {@link Tab}. Such tabs can dynamically + * be added to the tab switcher by using the addTab-methods. In order to remove them + * afterwards, the removeTab can be used. If the switcher is currently shown, calling + * these methods results in the tabs being added or removed in an animated manner. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class TabSwitcher extends FrameLayout implements TabSwitcherLayout, Model { + + /** + * A saved state, which allows to store the state of a {@link TabSwitcher}. + */ + private static class TabSwitcherState extends AbstractSavedState { + + /** + * A creator, which allows to create instances of the class {@link TabSwitcherState}. + */ + public static Creator CREATOR = new Creator() { + + @Override + public TabSwitcherState createFromParcel(final Parcel source) { + return new TabSwitcherState(source); + } + + @Override + public TabSwitcherState[] newArray(final int size) { + return new TabSwitcherState[size]; + } + + }; + + /** + * The saved layout policy, which is used by the tab switcher. + */ + private LayoutPolicy layoutPolicy; + + /** + * The saved state of the model, which is used by the tab switcher. + */ + private Bundle modelState; + + /** + * Creates a new saved state, which allows to store the state of a {@link TabSwitcher}. + * + * @param source + * The parcel to read read from as a instance of the class {@link Parcel}. The + * parcel may not be null + */ + private TabSwitcherState(@NonNull final Parcel source) { + super(source); + layoutPolicy = (LayoutPolicy) source.readSerializable(); + modelState = source.readBundle(getClass().getClassLoader()); + } + + /** + * Creates a new saved state, which allows to store the state of a {@link TabSwitcher}. + * + * @param superState + * The state of the superclass of the view, this saved state corresponds to, as an + * instance of the type {@link Parcelable} or null if no state is available + */ + TabSwitcherState(@Nullable final Parcelable superState) { + super(superState); + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + super.writeToParcel(dest, flags); + dest.writeSerializable(layoutPolicy); + dest.writeBundle(modelState); + } + + } + + /** + * A queue, which contains all pending actions. + */ + private Queue pendingActions; + + /** + * A set, which contains the listeners, which should be notified about the tab switcher's + * events. + */ + private Set listeners; + + /** + * The layout policy, which is used by the tab switcher. + */ + private LayoutPolicy layoutPolicy; + + /** + * The model, which is used by the tab switcher. + */ + private TabSwitcherModel model; + + /** + * The layout, which is used by the tab switcher, depending on whether the device is a + * smartphone or tablet and the set layout policy. + */ + private AbstractTabSwitcherLayout layout; + + /** + * Initializes the view. + * + * @param attributeSet + * The attribute set, which should be used to initialize the view, as an instance of the + * type {@link AttributeSet} or null, if no attributes should be obtained + * @param defaultStyle + * The default style to apply to this view. If 0, no style will be applied (beyond what + * is included in the theme). This may either be an attribute resource, whose value will + * be retrieved from the current theme, or an explicit style resource + * @param defaultStyleResource + * A resource identifier of a style resource that supplies default values for the view, + * used only if the default style is 0 or can not be found in the theme. Can be 0 to not + * look for defaults + */ + private void initialize(@Nullable final AttributeSet attributeSet, + @AttrRes final int defaultStyle, + @StyleRes final int defaultStyleResource) { + pendingActions = new LinkedList<>(); + listeners = new LinkedHashSet<>(); + model = new TabSwitcherModel(this); + model.addListener(createModelListener()); + getViewTreeObserver().addOnGlobalLayoutListener( + new LayoutListenerWrapper(this, createGlobalLayoutListener(false))); + setPadding(super.getPaddingLeft(), super.getPaddingTop(), super.getPaddingRight(), + super.getPaddingBottom()); + obtainStyledAttributes(attributeSet, defaultStyle, defaultStyleResource); + } + + /** + * Initializes a specific layout. + * + * @param inflatedTabsOnly + * True, if only the tabs should be inflated, false otherwise + * @param layout + * The layout, which should be initialized, as a value of the enum {@link Layout}. The + * layout may not be null + */ + private void initializeLayout(@NonNull final Layout layout, final boolean inflatedTabsOnly) { + if (layout == Layout.TABLET) { + // TODO: Use tablet layout once implemented + PhoneArithmetics arithmetics = new PhoneArithmetics(TabSwitcher.this); + this.layout = new PhoneTabSwitcherLayout(TabSwitcher.this, model, arithmetics); + } else { + PhoneArithmetics arithmetics = new PhoneArithmetics(TabSwitcher.this); + this.layout = new PhoneTabSwitcherLayout(TabSwitcher.this, model, arithmetics); + } + + this.layout.setCallback(createLayoutCallback()); + this.model.addListener(this.layout); + this.layout.inflateLayout(inflatedTabsOnly); + final ViewGroup tabContainer = getTabContainer(); + assert tabContainer != null; + + if (ViewCompat.isLaidOut(tabContainer)) { + this.layout.onGlobalLayout(); + } else { + tabContainer.getViewTreeObserver() + .addOnGlobalLayoutListener(new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + ViewUtil.removeOnGlobalLayoutListener( + tabContainer.getViewTreeObserver(), this); + TabSwitcher.this.layout.onGlobalLayout(); + } + + }); + } + } + + /** + * Obtains all attributes from a specific attribute set. + * + * @param attributeSet + * The attribute set, the attributes should be obtained from, as an instance of the type + * {@link AttributeSet} or null, if no attributes should be obtained + * @param defaultStyle + * The default style to apply to this view. If 0, no style will be applied (beyond what + * is included in the theme). This may either be an attribute resource, whose value will + * be retrieved from the current theme, or an explicit style resource + * @param defaultStyleResource + * A resource identifier of a style resource that supplies default values for the view, + * used only if the default style is 0 or can not be found in the theme. Can be 0 to not + * look for defaults + */ + private void obtainStyledAttributes(@Nullable final AttributeSet attributeSet, + @AttrRes final int defaultStyle, + @StyleRes final int defaultStyleResource) { + TypedArray typedArray = getContext() + .obtainStyledAttributes(attributeSet, R.styleable.TabSwitcher, defaultStyle, + defaultStyleResource); + + try { + obtainLayoutPolicy(typedArray); + obtainBackground(typedArray); + obtainTabIcon(typedArray); + obtainTabBackgroundColor(typedArray); + obtainTabTitleTextColor(typedArray); + obtainTabCloseButtonIcon(typedArray); + obtainToolbarTitle(typedArray); + obtainToolbarNavigationIcon(typedArray); + obtainToolbarMenu(typedArray); + } finally { + typedArray.recycle(); + } + } + + /** + * Obtains the layout policy from a specific typed array. + * + * @param typedArray + * The typed array, the layout policy should be obtained from, as an instance of the + * class {@link TypedArray}. The typed array may not be null + */ + private void obtainLayoutPolicy(@NonNull final TypedArray typedArray) { + int defaultValue = LayoutPolicy.AUTO.getValue(); + int value = typedArray.getInt(R.styleable.TabSwitcher_layoutPolicy, defaultValue); + setLayoutPolicy(LayoutPolicy.fromValue(value)); + } + + /** + * Obtains the view's background from a specific typed array. + * + * @param typedArray + * The typed array, the background should be obtained from, as an instance of the class + * {@link TypedArray}. The typed array may not be null + */ + private void obtainBackground(@NonNull final TypedArray typedArray) { + int resourceId = typedArray.getResourceId(R.styleable.TabSwitcher_android_background, 0); + + if (resourceId != 0) { + ViewUtil.setBackground(this, ContextCompat.getDrawable(getContext(), resourceId)); + } else { + int defaultValue = + ContextCompat.getColor(getContext(), R.color.tab_switcher_background_color); + int color = + typedArray.getColor(R.styleable.TabSwitcher_android_background, defaultValue); + setBackgroundColor(color); + } + } + + /** + * Obtains the icon of a tab from a specific typed array. + * + * @param typedArray + * The typed array, the icon should be obtained from, as an instance of the class {@link + * TypedArray}. The typed array may not be null + */ + private void obtainTabIcon(@NonNull final TypedArray typedArray) { + int resourceId = typedArray.getResourceId(R.styleable.TabSwitcher_tabIcon, -1); + + if (resourceId != -1) { + setTabIcon(resourceId); + } + } + + /** + * Obtains the background color of a tab from a specific typed array. + * + * @param typedArray + * The typed array, the background color should be obtained from, as an instance of the + * class {@link TypedArray}. The typed array may not be null + */ + private void obtainTabBackgroundColor(@NonNull final TypedArray typedArray) { + ColorStateList colorStateList = + typedArray.getColorStateList(R.styleable.TabSwitcher_tabBackgroundColor); + + if (colorStateList != null) { + setTabBackgroundColor(colorStateList); + } + } + + /** + * Obtains the text color of a tab's title from a specific typed array. + * + * @param typedArray + * The typed array, the text color should be obtained from, as an instance of the class + * {@link TypedArray}. The typed array may not be null + */ + private void obtainTabTitleTextColor(@NonNull final TypedArray typedArray) { + ColorStateList colorStateList = + typedArray.getColorStateList(R.styleable.TabSwitcher_tabTitleTextColor); + + if (colorStateList != null) { + setTabTitleTextColor(colorStateList); + } + } + + /** + * Obtains the icon of a tab's close button from a specific typed array. + * + * @param typedArray + * The typed array, the icon should be obtained from, as an instance of the class {@link + * TypedArray}. The typed array may not be null + */ + private void obtainTabCloseButtonIcon(@NonNull final TypedArray typedArray) { + int resourceId = typedArray.getResourceId(R.styleable.TabSwitcher_tabCloseButtonIcon, -1); + + if (resourceId != -1) { + setTabCloseButtonIcon(resourceId); + } + } + + /** + * Obtains the title of the toolbar, which is shown, when the tab switcher is shown, from a + * specific typed array. + * + * @param typedArray + * The typed array, the title should be obtained from, as an instance of the class + * {@link TypedArray}. The typed array may not be null + */ + private void obtainToolbarTitle(@NonNull final TypedArray typedArray) { + CharSequence title = typedArray.getText(R.styleable.TabSwitcher_toolbarTitle); + + if (!TextUtils.isEmpty(title)) { + setToolbarTitle(title); + } + } + + /** + * Obtains the navigation icon of the toolbar, which is shown, when the tab switcher is shown, + * from a specific typed array. + * + * @param typedArray + * The typed array, the navigation icon should be obtained from, as an instance of the + * class {@link TypedArray}. The typed array may not be null + */ + private void obtainToolbarNavigationIcon(@NonNull final TypedArray typedArray) { + Drawable icon = typedArray.getDrawable(R.styleable.TabSwitcher_toolbarNavigationIcon); + + if (icon != null) { + setToolbarNavigationIcon(icon, null); + } + } + + /** + * Obtains the menu of the toolbar, which is shown, when the tab switcher is shown, from a + * specific typed array. + * + * @param typedArray + * The typed array, the menu should be obtained from, as an instance of the class {@link + * TypedArray}. The typed array may not be null + */ + private void obtainToolbarMenu(@NonNull final TypedArray typedArray) { + int resourceId = typedArray.getResourceId(R.styleable.TabSwitcher_toolbarMenu, -1); + + if (resourceId != -1) { + inflateToolbarMenu(resourceId, null); + } + } + + /** + * Enqueues a specific action to be executed, when no animation is running. + * + * @param action + * The action, which should be enqueued as an instance of the type {@link Runnable}. The + * action may not be null + */ + private void enqueuePendingAction(@NonNull final Runnable action) { + ensureNotNull(action, "The action may not be null"); + pendingActions.add(action); + executePendingAction(); + } + + /** + * Executes the next pending action. + */ + private void executePendingAction() { + if (!isAnimationRunning()) { + final Runnable action = pendingActions.poll(); + + if (action != null) { + new Runnable() { + + @Override + public void run() { + action.run(); + executePendingAction(); + } + + }.run(); + } + } + } + + /** + * Creates and returns a listener, which allows to observe, when the tab switcher's model is + * modified. + * + * @return The listener, which has been created, as an instance of the type {@link + * Model.Listener}. The listener may not be null + */ + @NonNull + private Model.Listener createModelListener() { + return new Model.Listener() { + + @Override + public void onLogLevelChanged(@NonNull final LogLevel logLevel) { + + } + + @Override + public void onDecoratorChanged(@NonNull final TabSwitcherDecorator decorator) { + + } + + @Override + public void onSwitcherShown() { + notifyOnSwitcherShown(); + } + + @Override + public void onSwitcherHidden() { + notifyOnSwitcherHidden(); + } + + @Override + public void onSelectionChanged(final int previousIndex, final int index, + @Nullable final Tab selectedTab, + final boolean switcherHidden) { + notifyOnSelectionChanged(index, selectedTab); + + if (switcherHidden) { + notifyOnSwitcherHidden(); + } + } + + @Override + public void onTabAdded(final int index, @NonNull final Tab tab, + final int previousSelectedTabIndex, final int selectedTabIndex, + final boolean switcherVisibilityChanged, + @NonNull final Animation animation) { + notifyOnTabAdded(index, tab, animation); + + if (previousSelectedTabIndex != selectedTabIndex) { + notifyOnSelectionChanged(selectedTabIndex, + selectedTabIndex != -1 ? getTab(selectedTabIndex) : null); + } + + if (switcherVisibilityChanged) { + notifyOnSwitcherHidden(); + } + } + + @Override + public void onAllTabsAdded(final int index, @NonNull final Tab[] tabs, + final int previousSelectedTabIndex, + final int selectedTabIndex, + @NonNull final Animation animation) { + for (Tab tab : tabs) { + notifyOnTabAdded(index, tab, animation); + } + + if (previousSelectedTabIndex != selectedTabIndex) { + notifyOnSelectionChanged(selectedTabIndex, + selectedTabIndex != -1 ? getTab(selectedTabIndex) : null); + } + } + + @Override + public void onTabRemoved(final int index, @NonNull final Tab tab, + final int previousSelectedTabIndex, final int selectedTabIndex, + @NonNull final Animation animation) { + notifyOnTabRemoved(index, tab, animation); + + if (previousSelectedTabIndex != selectedTabIndex) { + notifyOnSelectionChanged(selectedTabIndex, + selectedTabIndex != -1 ? getTab(selectedTabIndex) : null); + } + } + + @Override + public void onAllTabsRemoved(@NonNull final Tab[] tabs, + @NonNull final Animation animation) { + notifyOnAllTabsRemoved(tabs, animation); + notifyOnSelectionChanged(-1, null); + } + + @Override + public void onPaddingChanged(final int left, final int top, final int right, + final int bottom) { + + } + + @Override + public void onTabIconChanged(@Nullable final Drawable icon) { + + } + + @Override + public void onTabBackgroundColorChanged(@Nullable final ColorStateList colorStateList) { + + } + + @Override + public void onTabTitleColorChanged(@Nullable final ColorStateList colorStateList) { + + } + + @Override + public void onTabCloseButtonIconChanged(@Nullable final Drawable icon) { + + } + + @Override + public void onToolbarVisibilityChanged(final boolean visible) { + + } + + @Override + public void onToolbarTitleChanged(@Nullable final CharSequence title) { + + } + + @Override + public void onToolbarNavigationIconChanged(@Nullable final Drawable icon, + @Nullable final OnClickListener listener) { + + } + + @Override + public void onToolbarMenuInflated(@MenuRes final int resourceId, + @Nullable final OnMenuItemClickListener listener) { + + } + + }; + } + + /** + * Creates and returns a callback, which allows to observe, when all pending animations of a + * layout have been ended. + * + * @return The callback, which has been created, as an instance of the type {@link + * AbstractTabSwitcherLayout.Callback}. The callback may not be null + */ + @NonNull + private AbstractTabSwitcherLayout.Callback createLayoutCallback() { + return new AbstractTabSwitcherLayout.Callback() { + + @Override + public void onAnimationsEnded() { + executePendingAction(); + } + + }; + } + + /** + * Creates and returns a listener, which allows to inflate the view's layout once the view is + * laid out. + * + * @param inflateTabsOnly + * True, if only the tabs should be inflated, false otherwise + * @return The listener, which has been created, as an instance of the type {@link + * OnGlobalLayoutListener}. The listener may not be null + */ + @NonNull + private OnGlobalLayoutListener createGlobalLayoutListener(final boolean inflateTabsOnly) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + ensureNotNull(getDecorator(), "No decorator has been set", + IllegalStateException.class); + initializeLayout(getLayout(), inflateTabsOnly); + } + + }; + } + + /** + * Notifies all listeners, that the tab switcher has been shown. + */ + private void notifyOnSwitcherShown() { + for (TabSwitcherListener listener : listeners) { + listener.onSwitcherShown(this); + } + } + + /** + * Notifies all listeners, that the tab switcher has been hidden. + */ + private void notifyOnSwitcherHidden() { + for (TabSwitcherListener listener : listeners) { + listener.onSwitcherHidden(this); + } + } + + /** + * Notifies all listeners, that the selected tab has been changed. + * + * @param selectedTabIndex + * The index of the currently selected tab as an {@link Integer} value or -1, if no tab + * is currently selected + * @param selectedTab + * The currently selected tab as an instance of the class {@link Tab} or null, if no + * tab is currently selected + */ + private void notifyOnSelectionChanged(final int selectedTabIndex, + @Nullable final Tab selectedTab) { + for (TabSwitcherListener listener : listeners) { + listener.onSelectionChanged(this, selectedTabIndex, selectedTab); + } + } + + /** + * Notifies all listeners, that a specific tab has been added to the tab switcher. + * + * @param index + * The index of the tab, which has been added, as an {@link Integer} value + * @param tab + * The tab, which has been added, as an instance of the class {@link Tab}. The tab may + * not be null + * @param animation + * The animation, which has been used to add the tab, as an instance of the class {@link + * Animation}. The animation may not be null + */ + private void notifyOnTabAdded(final int index, @NonNull final Tab tab, + @NonNull final Animation animation) { + for (TabSwitcherListener listener : listeners) { + listener.onTabAdded(this, index, tab, animation); + } + } + + /** + * Notifies all listeners, that a specific tab has been removed from the tab switcher. + * + * @param index + * The index of the tab, which has been removed, as an {@link Integer} value + * @param tab + * The tab, which has been removed, as an instance of the class {@link Tab}. The tab may + * not be null + * @param animation + * The animation, which has been used to remove the tab, as an instance of the class + * {@link Animation}. The animation may not be null + */ + private void notifyOnTabRemoved(final int index, @NonNull final Tab tab, + @NonNull final Animation animation) { + for (TabSwitcherListener listener : listeners) { + listener.onTabRemoved(this, index, tab, animation); + } + } + + /** + * Notifies all listeners, that all tabs have been removed from the tab switcher. + * + * @param tabs + * An array, which contains the tabs, which have been removed, as an array of the type + * {@link Tab} or an empty array, if no tabs have been removed + * @param animation + * The animation, which has been used to remove the tabs, as an instance of the class + * {@link Animation}. The animation may not be null + */ + private void notifyOnAllTabsRemoved(@NonNull final Tab[] tabs, + @NonNull final Animation animation) { + for (TabSwitcherListener listener : listeners) { + listener.onAllTabsRemoved(this, tabs, animation); + } + } + + /** + * Creates a new tab switcher, which allows to switch between multiple tabs. + * + * @param context + * The context, which should be used by the view, as an instance of the class {@link + * Context}. The context may not be null + */ + public TabSwitcher(@NonNull final Context context) { + this(context, null); + } + + /** + * Creates a new tab switcher, which allows to switch between multiple tabs. + * + * @param context + * The context, which should be used by the view, as an instance of the class {@link + * Context}. The context may not be null + * @param attributeSet + * The attribute set, the view's attributes should be obtained from, as an instance of + * the type {@link AttributeSet} or null, if no attributes should be obtained + */ + public TabSwitcher(@NonNull final Context context, @Nullable final AttributeSet attributeSet) { + super(context, attributeSet); + initialize(attributeSet, 0, 0); + } + + /** + * Creates a new tab switcher, which allows to switch between multiple tabs. + * + * @param context + * The context, which should be used by the view, as an instance of the class {@link + * Context}. The context may not be null + * @param attributeSet + * The attribute set, the view's attributes should be obtained from, as an instance of + * the type {@link AttributeSet} or null, if no attributes should be obtained + * @param defaultStyle + * The default style to apply to this view. If 0, no style will be applied (beyond what + * is included in the theme). This may either be an attribute resource, whose value will + * be retrieved from the current theme, or an explicit style resource + */ + public TabSwitcher(@NonNull final Context context, @Nullable final AttributeSet attributeSet, + @AttrRes final int defaultStyle) { + super(context, attributeSet, defaultStyle); + initialize(attributeSet, defaultStyle, 0); + } + + /** + * Creates a new tab switcher, which allows to switch between multiple tabs. + * + * @param context + * The context, which should be used by the view, as an instance of the class {@link + * Context}. The context may not be null + * @param attributeSet + * The attribute set, the view's attributes should be obtained from, as an instance of + * the type {@link AttributeSet} or null, if no attributes should be obtained + * @param defaultStyle + * The default style to apply to this view. If 0, no style will be applied (beyond what + * is included in the theme). This may either be an attribute resource, whose value will + * be retrieved from the current theme, or an explicit style resource + * @param defaultStyleResource + * A resource identifier of a style resource that supplies default values for the view, + * used only if the default style is 0 or can not be found in the theme. Can be 0 to not + * look for defaults + */ + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public TabSwitcher(@NonNull final Context context, @Nullable final AttributeSet attributeSet, + @AttrRes final int defaultStyle, @StyleRes final int defaultStyleResource) { + super(context, attributeSet, defaultStyle, defaultStyleResource); + initialize(attributeSet, defaultStyle, defaultStyleResource); + } + + /** + * Setups the tab switcher to be associated with those menu items of a specific menu, which use + * a {@link TabSwitcherButton} as their action view. The icon of such menu items will + * automatically be updated, when the number of tabs, which are contained by the tab switcher, + * changes. + * + * @param tabSwitcher + * The tab switcher, which should become associated with the menu items, as an instance + * of the class {@link TabSwitcher}. The tab switcher may not be null + * @param menu + * The menu, whose menu items should become associated with the given tab switcher, as + * an instance of the type {@link Menu}. The menu may not be null + * @param listener + * The listener, which should be set to the menu items, which use a {@link + * TabSwitcherButton} as their action view, as an instance of the type {@link + * OnClickListener} or null, if no listener should be set + */ + public static void setupWithMenu(@NonNull final TabSwitcher tabSwitcher, + @NonNull final Menu menu, + @Nullable final OnClickListener listener) { + ensureNotNull(tabSwitcher, "The tab switcher may not be null"); + ensureNotNull(menu, "The menu may not be null"); + + for (int i = 0; i < menu.size(); i++) { + MenuItem menuItem = menu.getItem(i); + View view = menuItem.getActionView(); + + if (view instanceof TabSwitcherButton) { + TabSwitcherButton tabSwitcherButton = (TabSwitcherButton) view; + tabSwitcherButton.setOnClickListener(listener); + tabSwitcherButton.setCount(tabSwitcher.getCount()); + tabSwitcher.addListener(tabSwitcherButton); + } + } + } + + /** + * Adds a listener, which should be notified about the tab switcher's events. + * + * @param listener + * The listener, which should be added, as an instance of the type {@link + * TabSwitcherListener}. The listener may not be null + */ + public final void addListener(@NonNull final TabSwitcherListener listener) { + ensureNotNull(listener, "The listener may not be null"); + this.listeners.add(listener); + } + + /** + * Removes a specific listener, which should not be notified about the tab switcher's events, + * anymore. + * + * @param listener + * The listener, which should be removed, as an instance of the type {@link + * TabSwitcherListener}. The listener may not be null + */ + public final void removeListener(@NonNull final TabSwitcherListener listener) { + ensureNotNull(listener, "The listener may not be null"); + this.listeners.remove(listener); + } + + /** + * Returns the layout policy, which is used by the tab switcher. + * + * @return The layout policy, which is used by the tab switcher, as a value of the enum {@link + * LayoutPolicy}. The layout policy may either be {@link LayoutPolicy#AUTO}, {@link + * LayoutPolicy#PHONE} or {@link LayoutPolicy#TABLET} + */ + @NonNull + public final LayoutPolicy getLayoutPolicy() { + return layoutPolicy; + } + + /** + * Sets the layout policy, which should be used by the tab switcher. + * + * Changing the layout policy after the view has been laid out does not have any effect. + * + * @param layoutPolicy + * The layout policy, which should be set, as a value of the enum {@link LayoutPolicy}. + * The layout policy may either be {@link LayoutPolicy#AUTO}, {@link LayoutPolicy#PHONE} + * or {@link LayoutPolicy#TABLET} + */ + public final void setLayoutPolicy(@NonNull final LayoutPolicy layoutPolicy) { + ensureNotNull(layoutPolicy, "The layout policy may not be null"); + + if (this.layoutPolicy != layoutPolicy) { + Layout previousLayout = getLayout(); + this.layoutPolicy = layoutPolicy; + + if (layout != null) { + Layout newLayout = getLayout(); + + if (previousLayout != newLayout) { + layout.detachLayout(false); + model.removeListener(layout); + initializeLayout(newLayout, false); + } + } + } + } + + /** + * Returns the layout of the tab switcher. + * + * @return The layout of the tab switcher as a value of the enum {@link Layout}. The layout may + * either be {@link Layout#PHONE_PORTRAIT}, {@link Layout#PHONE_LANDSCAPE} or {@link + * Layout#TABLET} + */ + @NonNull + public final Layout getLayout() { + if (layoutPolicy == LayoutPolicy.TABLET || (layoutPolicy == LayoutPolicy.AUTO && + getDeviceType(getContext()) == DeviceType.TABLET)) { + return Layout.TABLET; + } else { + return getOrientation(getContext()) == Orientation.LANDSCAPE ? Layout.PHONE_LANDSCAPE : + Layout.PHONE_PORTRAIT; + } + } + + @Override + public final void addTab(@NonNull final Tab tab) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.addTab(tab); + } + + }); + } + + @Override + public final void addTab(@NonNull final Tab tab, final int index) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.addTab(tab, index); + } + + }); + } + + @Override + public final void addTab(@NonNull final Tab tab, final int index, + @NonNull final Animation animation) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.addTab(tab, index, animation); + } + + }); + } + + @Override + public final void addAllTabs(@NonNull final Collection tabs) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.addAllTabs(tabs); + } + + }); + } + + @Override + public final void addAllTabs(@NonNull final Collection tabs, final int index) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.addAllTabs(tabs, index); + } + + }); + } + + @Override + public final void addAllTabs(@NonNull final Collection tabs, final int index, + @NonNull final Animation animation) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.addAllTabs(tabs, index, animation); + } + + }); + } + + @Override + public final void addAllTabs(@NonNull final Tab[] tabs) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.addAllTabs(tabs); + } + + }); + } + + @Override + public final void addAllTabs(@NonNull final Tab[] tabs, final int index) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.addAllTabs(tabs, index); + } + + }); + } + + @Override + public final void addAllTabs(@NonNull final Tab[] tabs, final int index, + @NonNull final Animation animation) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.addAllTabs(tabs, index, animation); + } + + }); + } + + @Override + public final void removeTab(@NonNull final Tab tab) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.removeTab(tab); + } + + }); + } + + @Override + public final void removeTab(@NonNull final Tab tab, @NonNull final Animation animation) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.removeTab(tab, animation); + } + + }); + } + + @Override + public final void clear() { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.clear(); + } + + }); + } + + @Override + public final void clear(@NonNull final Animation animationType) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.clear(animationType); + } + + }); + } + + @Override + public final void selectTab(@NonNull final Tab tab) { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.selectTab(tab); + } + + }); + } + + @Nullable + @Override + public final Tab getSelectedTab() { + return model.getSelectedTab(); + } + + @Override + public final int getSelectedTabIndex() { + return model.getSelectedTabIndex(); + } + + @Override + public final Iterator iterator() { + return model.iterator(); + } + + @Override + public final boolean isEmpty() { + return model.isEmpty(); + } + + @Override + public final int getCount() { + return model.getCount(); + } + + @NonNull + @Override + public final Tab getTab(final int index) { + return model.getTab(index); + } + + @Override + public final int indexOf(@NonNull final Tab tab) { + return model.indexOf(tab); + } + + @Override + public final boolean isSwitcherShown() { + return model.isSwitcherShown(); + } + + @Override + public final void showSwitcher() { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.showSwitcher(); + } + + }); + } + + @Override + public final void hideSwitcher() { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.hideSwitcher(); + } + + }); + } + + @Override + public final void toggleSwitcherVisibility() { + enqueuePendingAction(new Runnable() { + + @Override + public void run() { + model.toggleSwitcherVisibility(); + } + + }); + } + + @Override + public final void setDecorator(@NonNull final TabSwitcherDecorator decorator) { + model.setDecorator(decorator); + } + + @Override + public final TabSwitcherDecorator getDecorator() { + return model.getDecorator(); + } + + @NonNull + @Override + public final LogLevel getLogLevel() { + return model.getLogLevel(); + } + + @Override + public final void setLogLevel(@NonNull final LogLevel logLevel) { + model.setLogLevel(logLevel); + } + + @Override + public final void setPadding(final int left, final int top, final int right, final int bottom) { + model.setPadding(left, top, right, bottom); + } + + @Override + public final int getPaddingLeft() { + return model.getPaddingLeft(); + } + + @Override + public final int getPaddingTop() { + return model.getPaddingTop(); + } + + @Override + public final int getPaddingRight() { + return model.getPaddingRight(); + } + + @Override + public final int getPaddingBottom() { + return model.getPaddingBottom(); + } + + @Override + public final int getPaddingStart() { + return model.getPaddingStart(); + } + + @Override + public final int getPaddingEnd() { + return model.getPaddingEnd(); + } + + @Nullable + @Override + public final Drawable getTabIcon() { + return model.getTabIcon(); + } + + @Override + public final void setTabIcon(@DrawableRes final int resourceId) { + model.setTabIcon(resourceId); + } + + @Override + public final void setTabIcon(@Nullable final Bitmap icon) { + model.setTabIcon(icon); + } + + @Nullable + @Override + public final ColorStateList getTabBackgroundColor() { + return model.getTabBackgroundColor(); + } + + @Override + public final void setTabBackgroundColor(@ColorInt final int color) { + model.setTabBackgroundColor(color); + } + + @Override + public final void setTabBackgroundColor(@Nullable final ColorStateList colorStateList) { + model.setTabBackgroundColor(colorStateList); + } + + @Nullable + @Override + public final ColorStateList getTabTitleTextColor() { + return model.getTabTitleTextColor(); + } + + @Override + public final void setTabTitleTextColor(@ColorInt final int color) { + model.setTabTitleTextColor(color); + } + + @Override + public final void setTabTitleTextColor(@Nullable final ColorStateList colorStateList) { + model.setTabTitleTextColor(colorStateList); + } + + @Nullable + @Override + public final Drawable getTabCloseButtonIcon() { + return model.getTabCloseButtonIcon(); + } + + @Override + public final void setTabCloseButtonIcon(@DrawableRes final int resourceId) { + model.setTabCloseButtonIcon(resourceId); + } + + @Override + public final void setTabCloseButtonIcon(@Nullable final Bitmap icon) { + model.setTabCloseButtonIcon(icon); + } + + @Override + public final boolean areToolbarsShown() { + return model.areToolbarsShown(); + } + + @Override + public final void showToolbars(final boolean show) { + model.showToolbars(show); + } + + @Nullable + @Override + public final CharSequence getToolbarTitle() { + Toolbar[] toolbars = getToolbars(); + return toolbars != null ? toolbars[0].getTitle() : model.getToolbarTitle(); + } + + @Override + public final void setToolbarTitle(@StringRes final int resourceId) { + model.setToolbarTitle(resourceId); + } + + @Override + public final void setToolbarTitle(@Nullable final CharSequence title) { + model.setToolbarTitle(title); + } + + @Nullable + @Override + public final Drawable getToolbarNavigationIcon() { + Toolbar[] toolbars = getToolbars(); + return toolbars != null ? toolbars[0].getNavigationIcon() : + model.getToolbarNavigationIcon(); + } + + @Override + public final void setToolbarNavigationIcon(@Nullable final Drawable icon, + @Nullable final OnClickListener listener) { + model.setToolbarNavigationIcon(icon, listener); + } + + @Override + public final void setToolbarNavigationIcon(@DrawableRes final int resourceId, + @Nullable final OnClickListener listener) { + model.setToolbarNavigationIcon(resourceId, listener); + } + + @Override + public final void inflateToolbarMenu(@MenuRes final int resourceId, + @Nullable final OnMenuItemClickListener listener) { + model.inflateToolbarMenu(resourceId, listener); + } + + @Override + public final void addCloseTabListener(@NonNull final TabCloseListener listener) { + model.addCloseTabListener(listener); + } + + @Override + public final void removeCloseTabListener(@NonNull final TabCloseListener listener) { + model.removeCloseTabListener(listener); + } + + @Override + public final void addTabPreviewListener(@NonNull final TabPreviewListener listener) { + model.addTabPreviewListener(listener); + } + + @Override + public final void removeTabPreviewListener(@NonNull final TabPreviewListener listener) { + model.removeTabPreviewListener(listener); + } + + @Override + public final boolean isAnimationRunning() { + return layout != null && layout.isAnimationRunning(); + } + + @Nullable + @Override + public final ViewGroup getTabContainer() { + return layout != null ? layout.getTabContainer() : null; + } + + @Override + public final Toolbar[] getToolbars() { + return layout != null ? layout.getToolbars() : null; + } + + @Nullable + @Override + public final Menu getToolbarMenu() { + return layout != null ? layout.getToolbarMenu() : null; + } + + @Override + public final boolean onTouchEvent(final MotionEvent event) { + return (layout != null && layout.handleTouchEvent(event)) || super.onTouchEvent(event); + } + + @Override + public final Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + TabSwitcherState savedState = new TabSwitcherState(superState); + savedState.layoutPolicy = layoutPolicy; + savedState.modelState = new Bundle(); + Pair pair = layout.detachLayout(true); + + if (pair != null) { + savedState.modelState + .putInt(TabSwitcherModel.FIRST_VISIBLE_TAB_INDEX_EXTRA, pair.first); + savedState.modelState + .putFloat(TabSwitcherModel.FIRST_VISIBLE_TAB_POSITION_EXTRA, pair.second); + model.setFirstVisibleTabIndex(pair.first); + model.setFirstVisibleTabPosition(pair.second); + } else { + model.setFirstVisibleTabPosition(-1); + model.setFirstVisibleTabIndex(-1); + } + + model.removeListener(layout); + layout = null; + executePendingAction(); + getViewTreeObserver().addOnGlobalLayoutListener( + new LayoutListenerWrapper(this, createGlobalLayoutListener(true))); + model.saveInstanceState(savedState.modelState); + return savedState; + } + + @Override + public final void onRestoreInstanceState(final Parcelable state) { + if (state instanceof TabSwitcherState) { + TabSwitcherState savedState = (TabSwitcherState) state; + this.layoutPolicy = savedState.layoutPolicy; + model.restoreInstanceState(savedState.modelState); + super.onRestoreInstanceState(savedState.getSuperState()); + } else { + super.onRestoreInstanceState(state); + } + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherDecorator.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherDecorator.java new file mode 100755 index 0000000..8038a5a --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherDecorator.java @@ -0,0 +1,243 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +import android.content.Context; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import de.mrapp.android.util.view.AbstractViewHolderAdapter; + +/** + * An abstract base class for all decorators, which are responsible for inflating views, which + * should be used to visualize the tabs of a {@link TabSwitcher}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public abstract class TabSwitcherDecorator extends AbstractViewHolderAdapter { + + /** + * The name of the extra, which is used to store the state of a view hierarchy within a bundle. + */ + private static final String VIEW_HIERARCHY_STATE_EXTRA = + TabSwitcherDecorator.class.getName() + "::ViewHierarchyState"; + + /** + * The method which is invoked, when a view, which is used to visualize a tab, should be + * inflated. + * + * @param inflater + * The inflater, which should be used to inflate the view, as an instance of the class + * {@link LayoutInflater}. The inflater may not be null + * @param parent + * The parent view of the view, which should be inflated, as an instance of the class + * {@link ViewGroup} or null, if no parent view is available + * @param viewType + * The view type of the tab, which should be visualized, as an {@link Integer} value + * @return The view, which has been inflated, as an instance of the class {@link View}. The view + * may not be null + */ + @NonNull + public abstract View onInflateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup parent, final int viewType); + + /** + * The method which is invoked, when the view, which is used to visualize a tab, should be + * shown, respectively when it should be refreshed. The purpose of this method is to customize + * the appearance of the view, which is used to visualize the corresponding tab, depending on + * its state and whether the tab switcher is currently shown, or not. + * + * @param context + * The context, the tab switcher belongs to, as an instance of the class {@link + * Context}. The context may not be null + * @param tabSwitcher + * The tab switcher, whose tabs are visualized by the decorator, as an instance of the + * type {@link TabSwitcher}. The tab switcher may not be null + * @param view + * The view, which is used to visualize the tab, as an instance of the class {@link + * View}. The view may not be null + * @param tab + * The tab, which should be visualized, as an instance of the class {@link Tab}. The tab + * may not be null + * @param index + * The index of the tab, which should be visualized, as an {@link Integer} value + * @param viewType + * The view type of the tab, which should be visualized, as an {@link Integer} value + * @param savedInstanceState + * The bundle, which has previously been used to save the state of the view as an + * instance of the class {@link Bundle} or null, if no saved state is available + */ + public abstract void onShowTab(@NonNull final Context context, + @NonNull final TabSwitcher tabSwitcher, @NonNull final View view, + @NonNull final Tab tab, final int index, final int viewType, + @Nullable final Bundle savedInstanceState); + + /** + * The method, which is invoked, when the view, which is used to visualize a tab, is removed. + * The purpose of this method is to save the current state of the tab in a bundle. + * + * @param view + * The view, which is used to visualize the tab, as an instance of the class {@link + * View} + * @param tab + * The tab, whose state should be saved, as an instance of the class {@link Tab}. The + * tab may not be null + * @param index + * The index of the tab, whose state should be saved, as an {@link Integer} value + * @param viewType + * The view type of the tab, whose state should be saved, as an {@link Integer} value + * @param outState + * The bundle, the state of the tab should be saved to, as an instance of the class + * {@link Bundle}. The bundle may not be null + */ + public void onSaveInstanceState(@NonNull final View view, @NonNull final Tab tab, + final int index, final int viewType, + @NonNull final Bundle outState) { + + } + + /** + * Returns the view type, which corresponds to a specific tab. For each layout, which is + * inflated by the onInflateView-method, a distinct view type must be + * returned. + * + * @param tab + * The tab, whose view type should be returned, as an instance of the class {@link Tab}. + * The tab may not be null + * @param index + * The index of the tab, whose view type should be returned, as an {@link Integer} + * value + * @return The view type, which corresponds to the given tab, as an {@link Integer} value + */ + public int getViewType(@NonNull final Tab tab, final int index) { + return 0; + } + + /** + * Returns the number of view types, which are used by the decorator. + * + * @return The number of view types, which are used by the decorator, as an {@link Integer} + * value. The number of view types must correspond to the number of distinct values, which are + * returned by the getViewType-method + */ + public int getViewTypeCount() { + return 1; + } + + /** + * The method, which is invoked by a {@link TabSwitcher} to inflate the view, which should be + * used to visualize a specific tab. + * + * @param inflater + * The inflater, which should be used to inflate the view, as an instance of the class + * {@link LayoutInflater}. The inflater may not be null + * @param parent + * The parent view of the view, which should be inflated, as an instance of the class + * {@link ViewGroup} or null, if no parent view is available + * @param tab + * The tab, which should be visualized, as an instance of the class {@link Tab}. The tab + * may not be null + * @param index + * The index of the tab, which should be visualized, as an {@link Integer} value + * @return The view, which has been inflated, as an instance of the class {@link View}. The view + * may not be null + */ + @NonNull + public final View inflateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup parent, @NonNull final Tab tab, + final int index) { + int viewType = getViewType(tab, index); + return onInflateView(inflater, parent, viewType); + } + + /** + * The method, which is invoked by a {@link TabSwitcher} to apply the decorator. It initializes + * the view holder pattern, which is provided by the decorator and then delegates the method + * call to the decorator's custom implementation of the method onShowTab(...):void. + * + * @param context + * The context, the tab switcher belongs to, as an instance of the class {@link + * Context}. The context may not be null + * @param tabSwitcher + * The tab switcher, whose tabs are visualized by the decorator, as an instance of the + * class {@link TabSwitcher}. The tab switcher may not be null + * @param view + * The view, which is used to visualize the tab, as an instance of the class {@link + * View}. The view may not be null + * @param tab + * The tab, which should be visualized, as an instance of the class {@link Tab}. The tab + * may not be null + * @param index + * The index of the tab, which should be visualized, as an {@link Integer} value + * @param savedInstanceState + * The bundle, which has previously been used to save the state of the view as an + * instance of the class {@link Bundle} or null, if no saved state is available + */ + public final void applyDecorator(@NonNull final Context context, + @NonNull final TabSwitcher tabSwitcher, + @NonNull final View view, @NonNull final Tab tab, + final int index, @Nullable final Bundle savedInstanceState) { + setCurrentParentView(view); + int viewType = getViewType(tab, index); + + if (savedInstanceState != null) { + SparseArray viewStates = + savedInstanceState.getSparseParcelableArray(VIEW_HIERARCHY_STATE_EXTRA); + + if (viewStates != null) { + view.restoreHierarchyState(viewStates); + } + } + + onShowTab(context, tabSwitcher, view, tab, index, viewType, savedInstanceState); + } + + /** + * The method, which is invoked by a {@link TabSwitcher} to save the current state of a tab. It + * initializes the view holder pattern, which is provided by the decorator and then delegates + * the method call to the decorator's custom implementation of the method + * onSaveInstanceState(...):void. + * + * @param view + * The view, which is used to visualize the tab, as an instance of the class {@link + * View} + * @param tab + * The tab, whose state should be saved, as an instance of the class {@link Tab}. The + * tab may not be null + * @param index + * The index of the tab, whose state should be saved, as an {@link Integer} value + * @return The bundle, which has been used to save the state, as an instance of the class {@link + * Bundle}. The bundle may not be null + */ + @NonNull + public final Bundle saveInstanceState(@NonNull final View view, @NonNull final Tab tab, + final int index) { + setCurrentParentView(view); + int viewType = getViewType(tab, index); + Bundle outState = new Bundle(); + SparseArray viewStates = new SparseArray<>(); + view.saveHierarchyState(viewStates); + outState.putSparseParcelableArray(VIEW_HIERARCHY_STATE_EXTRA, viewStates); + onSaveInstanceState(view, tab, index, viewType, outState); + return outState; + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherListener.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherListener.java new file mode 100755 index 0000000..00a11c3 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherListener.java @@ -0,0 +1,114 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +/** + * Defines the interface, a class, which should be notified about a tab switcher's events, must + * implement. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public interface TabSwitcherListener { + + /** + * The method, which is invoked, when the tab switcher has been shown. + * + * @param tabSwitcher + * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab + * switcher may not be null + */ + void onSwitcherShown(@NonNull TabSwitcher tabSwitcher); + + /** + * The method, which is invoked, when the tab switcher has been hidden. + * + * @param tabSwitcher + * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab + * switcher may not be null + */ + void onSwitcherHidden(@NonNull TabSwitcher tabSwitcher); + + /** + * The method, which is invoked, when the currently selected tab has been changed. + * + * @param tabSwitcher + * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab + * switcher may not be null + * @param selectedTabIndex + * The index of the currently selected tab as an {@link Integer} value or -1, if the tab + * switcher does not contain any tabs + * @param selectedTab + * The currently selected tab as an instance of the class {@link Tab} or null, if the + * tab switcher does not contain any tabs + */ + void onSelectionChanged(@NonNull TabSwitcher tabSwitcher, int selectedTabIndex, + @Nullable Tab selectedTab); + + /** + * The method, which is invoked, when a tab has been added to the tab switcher. + * + * @param tabSwitcher + * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab + * switcher may not be null + * @param index + * The index of the tab, which has been added, as an {@link Integer} value + * @param tab + * The tab, which has been added, as an instance of the class {@link Tab}. The tab may + * not be null + * @param animation + * The animation, which has been used to add the tab, as an instance of the class {@link + * Animation}. The animation may not be null + */ + void onTabAdded(@NonNull TabSwitcher tabSwitcher, int index, @NonNull Tab tab, + @NonNull Animation animation); + + /** + * The method, which is invoked, when a tab has been removed from the tab switcher. + * + * @param tabSwitcher + * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab + * switcher may not be null + * @param index + * The index of the tab, which has been removed, as an {@link Integer} value + * @param tab + * The tab, which has been removed, as an instance of the class {@link Tab}. The tab may + * not be null + * @param animation + * The animation, which has been used to remove the tab, as an instance of the class + * {@link Animation}. The animation may not be null + */ + void onTabRemoved(@NonNull TabSwitcher tabSwitcher, int index, @NonNull Tab tab, + @NonNull Animation animation); + + /** + * The method, which is invoked, when all tabs have been removed from the tab switcher. + * + * @param tabSwitcher + * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab + * switcher may not be null + * @param tabs + * An array, which contains the tabs, which have been removed, as an array of the type + * {@link Tab} or an empty array, if no tabs have been removed + * @param animation + * The animation, which has been used to remove the tabs, as an instance of the class + * {@link Animation}. The animation may not be null + */ + void onAllTabsRemoved(@NonNull TabSwitcher tabSwitcher, @NonNull Tab[] tabs, + @NonNull Animation animation); + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/drawable/TabSwitcherDrawable.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/drawable/TabSwitcherDrawable.java new file mode 100755 index 0000000..cc8f87c --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/drawable/TabSwitcherDrawable.java @@ -0,0 +1,211 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.drawable; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.PixelFormat; +import android.graphics.PorterDuff; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; + +import de.mrapp.android.tabswitcher.Animation; +import de.mrapp.android.tabswitcher.R; +import de.mrapp.android.tabswitcher.Tab; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.TabSwitcherListener; +import de.mrapp.android.util.ThemeUtil; + +import static de.mrapp.android.util.Condition.ensureAtLeast; +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * A drawable, which allows to display the number of tabs, which are currently contained by a {@link + * TabSwitcher}. It must be registered at a {@link TabSwitcher} instance in order to keep the + * displayed label up to date. It therefore implements the interface {@link TabSwitcherListener}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class TabSwitcherDrawable extends Drawable implements TabSwitcherListener { + + /** + * The size of the drawable in pixels. + */ + private final int size; + + /** + * The default text size of the displayed label in pixels. + */ + private final int textSizeNormal; + + /** + * The text size of the displayed label, which is used when displaying a value greater than 99, + * in pixels. + */ + private final int textSizeSmall; + + /** + * The drawable, which is shown as the background. + */ + private final Drawable background; + + /** + * The paint, which is used to draw the drawable's label. + */ + private final Paint paint; + + /** + * The currently displayed label. + */ + private String label; + + /** + * Creates a new drawable, which allows to display the number of tabs, which are currently + * contained by a {@link TabSwitcher}. + * + * @param context + * The context, which should be used by the drawable, as an instance of the class {@link + * Context}. The context may not be null + */ + public TabSwitcherDrawable(@NonNull final Context context) { + ensureNotNull(context, "The context may not be null"); + Resources resources = context.getResources(); + size = resources.getDimensionPixelSize(R.dimen.tab_switcher_drawable_size); + textSizeNormal = + resources.getDimensionPixelSize(R.dimen.tab_switcher_drawable_font_size_normal); + textSizeSmall = + resources.getDimensionPixelSize(R.dimen.tab_switcher_drawable_font_size_small); + background = + ContextCompat.getDrawable(context, R.drawable.tab_switcher_drawable_background) + .mutate(); + paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setColor(Color.WHITE); + paint.setTextAlign(Align.CENTER); + paint.setTextSize(textSizeNormal); + paint.setTypeface(Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)); + label = Integer.toString(0); + int tint = ThemeUtil.getColor(context, android.R.attr.textColorPrimary); + setColorFilter(tint, PorterDuff.Mode.MULTIPLY); + } + + /** + * Updates the drawable to display a specific value. + * + * @param count + * The value, which should be displayed, as an {@link Integer} value. The value must be + * at least 0 + */ + public final void setCount(final int count) { + ensureAtLeast(count, 0, "The count must be at least 0"); + label = Integer.toString(count); + + if (label.length() > 2) { + label = "99+"; + paint.setTextSize(textSizeSmall); + } else { + paint.setTextSize(textSizeNormal); + } + + invalidateSelf(); + } + + @Override + public final void draw(@NonNull final Canvas canvas) { + int width = canvas.getWidth(); + int height = canvas.getHeight(); + int intrinsicWidth = background.getIntrinsicWidth(); + int intrinsicHeight = background.getIntrinsicHeight(); + int left = (width / 2) - (intrinsicWidth / 2); + int top = (height / 2) - (intrinsicHeight / 2); + background.getIntrinsicWidth(); + background.setBounds(left, top, left + intrinsicWidth, top + intrinsicHeight); + background.draw(canvas); + float x = width / 2f; + float y = (height / 2f) - ((paint.descent() + paint.ascent()) / 2f); + canvas.drawText(label, x, y, paint); + } + + @Override + public final int getIntrinsicWidth() { + return size; + } + + @Override + public final int getIntrinsicHeight() { + return size; + } + + @Override + public final void setAlpha(final int alpha) { + background.setAlpha(alpha); + paint.setAlpha(alpha); + } + + @Override + public final void setColorFilter(@Nullable final ColorFilter colorFilter) { + background.setColorFilter(colorFilter); + paint.setColorFilter(colorFilter); + } + + @Override + public final int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public final void onSwitcherShown(@NonNull final TabSwitcher tabSwitcher) { + + } + + @Override + public final void onSwitcherHidden(@NonNull final TabSwitcher tabSwitcher) { + + } + + @Override + public final void onSelectionChanged(@NonNull final TabSwitcher tabSwitcher, + final int selectedTabIndex, + @Nullable final Tab selectedTab) { + + } + + @Override + public final void onTabAdded(@NonNull final TabSwitcher tabSwitcher, final int index, + @NonNull final Tab tab, @NonNull final Animation animation) { + setCount(tabSwitcher.getCount()); + } + + @Override + public final void onTabRemoved(@NonNull final TabSwitcher tabSwitcher, final int index, + @NonNull final Tab tab, @NonNull final Animation animation) { + setCount(tabSwitcher.getCount()); + } + + @Override + public final void onAllTabsRemoved(@NonNull final TabSwitcher tabSwitcher, + @NonNull final Tab[] tab, + @NonNull final Animation animation) { + setCount(tabSwitcher.getCount()); + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/AbstractTabItemIterator.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/AbstractTabItemIterator.java new file mode 100755 index 0000000..800d75f --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/AbstractTabItemIterator.java @@ -0,0 +1,232 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.iterator; + +import android.support.annotation.NonNull; + +import de.mrapp.android.tabswitcher.model.TabItem; + +import static de.mrapp.android.util.Condition.ensureAtLeast; + +/** + * An abstract base class for all iterators, which allow to iterate items of the type {@link + * TabItem}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public abstract class AbstractTabItemIterator implements java.util.Iterator { + + /** + * An abstract base class of all builders, which allows to configure and create instances of the + * class {@link AbstractTabItemIterator}. + */ + public static abstract class AbstractBuilder, ProductType extends AbstractTabItemIterator> { + + /** + * True, if the tabs should be iterated in reverse order, false otherwise. + */ + protected boolean reverse; + + /** + * The index of the first tab, which should be iterated. + */ + protected int start; + + /** + * Returns a reference to the builder itself. It is implicitly cast to the generic type + * BuilderType. + * + * @return The builder as an instance of the generic type BuilderType + */ + @SuppressWarnings("unchecked") + private BuilderType self() { + return (BuilderType) this; + } + + /** + * Creates a new builder, which allows to configure and create instances of the class {@link + * AbstractTabItemIterator}. + */ + protected AbstractBuilder() { + reverse(false); + start(-1); + } + + /** + * Creates the iterator, which has been configured by using the builder. + * + * @return The iterator, which has been created, as an instance of the class {@link + * TabItemIterator}. The iterator may not be null + */ + @NonNull + public abstract ProductType create(); + + /** + * Sets, whether the tabs should be iterated in reverse order, or not. + * + * @param reverse + * True, if the tabs should be iterated in reverse order, false otherwise + * @return The builder, this method has been called upon, as an instance of the generic type + * BuilderType. The builder may not be null + */ + @NonNull + public BuilderType reverse(final boolean reverse) { + this.reverse = reverse; + return self(); + } + + /** + * Sets the index of the first tab, which should be iterated. + * + * @param start + * The index, which should be set, as an {@link Integer} value or -1, if all tabs + * should be iterated Builder}. The builder may not be null + * @return The builder, this method has been called upon, as an instance of the generic type + * BuilderType. The builder may not be null + */ + @NonNull + public BuilderType start(final int start) { + ensureAtLeast(start, -1, "The start must be at least -1"); + this.start = start; + return self(); + } + + } + + /** + * True, if the tabs should be iterated in reverse order, false otherwise. + */ + private boolean reverse; + + /** + * The index of the next tab. + */ + private int index; + + /** + * The current tab item. + */ + private TabItem current; + + /** + * The previous tab item. + */ + private TabItem previous; + + /** + * The first tab item. + */ + private TabItem first; + + /** + * The method, which is invoked on subclasses in order to retrieve the total number of available + * items. + * + * @return The total number of available items as an {@link Integer} value + */ + public abstract int getCount(); + + /** + * The method, which is invoked on subclasses in order to retrieve the item, which corresponds + * to a specific index. + * + * @param index + * The index of the item, which should be returned, as an {@link Integer} value + * @return The item, which corresponds to the given index, as an instance of the class {@link + * TabItem}. The tab item may not be null + */ + @NonNull + public abstract TabItem getItem(final int index); + + /** + * Initializes the iterator. + * + * @param reverse + * True, if the tabs should be iterated in reverse order, false otherwise + * @param start + * The index of the first tab, which should be iterated, as an {@link Integer} value or + * -1, if all tabs should be iterated + */ + protected final void initialize(final boolean reverse, final int start) { + ensureAtLeast(start, -1, "The start must be at least -1"); + this.reverse = reverse; + this.previous = null; + this.index = start != -1 ? start : (reverse ? getCount() - 1 : 0); + int previousIndex = reverse ? this.index + 1 : this.index - 1; + + if (previousIndex >= 0 && previousIndex < getCount()) { + this.current = getItem(previousIndex); + } else { + this.current = null; + } + } + + /** + * Returns the tab item, which corresponds to the first tab. + * + * @return The tab item, which corresponds to the first tab, as an instance of the class {@link + * TabItem} or null, if no tabs are available + */ + public final TabItem first() { + return first; + } + + /** + * Returns the tab item, which corresponds to the previous tab. + * + * @return The tab item, which corresponds to the previous tab, as an instance of the class + * {@link TabItem} or null, if no previous tab is available + */ + public final TabItem previous() { + return previous; + } + + /** + * Returns the tab item, which corresponds to the next tab. + * + * @return The tab item, which corresponds to the next tab, as an instance of the class {@link + * TabItem} or null, if no next tab is available + */ + public final TabItem peek() { + return index >= 0 && index < getCount() ? getItem(index) : null; + } + + @Override + public final boolean hasNext() { + if (reverse) { + return index >= 0; + } else { + return getCount() - index >= 1; + } + } + + @Override + public final TabItem next() { + if (hasNext()) { + previous = current; + + if (first == null) { + first = current; + } + + current = getItem(index); + index += reverse ? -1 : 1; + return current; + } + + return null; + } + +} diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/ArrayTabItemIterator.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/ArrayTabItemIterator.java new file mode 100755 index 0000000..713f18b --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/ArrayTabItemIterator.java @@ -0,0 +1,128 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.iterator; + +import android.support.annotation.NonNull; + +import de.mrapp.android.tabswitcher.Tab; +import de.mrapp.android.tabswitcher.model.TabItem; +import de.mrapp.android.util.view.AttachedViewRecycler; + +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * An iterator, which allows to iterate the tab items, which correspond to the tabs, which are + * contained by an array. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class ArrayTabItemIterator extends AbstractTabItemIterator { + + /** + * A builder, which allows to configure an create instances of the class {@link + * ArrayTabItemIterator}. + */ + public static class Builder extends AbstractBuilder { + + /** + * The view recycler, which allows to inflate the views, which are used to visualize the + * tabs, which are iterated by the iterator, which is created by the builder. + */ + private final AttachedViewRecycler viewRecycler; + + /** + * The array, which contains the tabs, which are iterated by the iterator, which is created + * by the builder. + */ + private final Tab[] array; + + /** + * Creates a new builder, which allows to configure and create instances of the class {@link + * ArrayTabItemIterator}. + * + * @param viewRecycler + * The view recycler, which allows to inflate the views, which are used to visualize + * the tabs, which should be iterated by the iterator, as an instance of the class + * AttachedViewRecycler. The view recycler may not be null + * @param array + * The array, which contains the tabs, which should be iterated by the iterator, as + * an array of the type {@link Tab}. The array may not be null + */ + public Builder(@NonNull final AttachedViewRecycler viewRecycler, + @NonNull final Tab[] array) { + ensureNotNull(viewRecycler, "The view recycler may not be null"); + ensureNotNull(array, "The array may not be null"); + this.viewRecycler = viewRecycler; + this.array = array; + } + + @NonNull + @Override + public ArrayTabItemIterator create() { + return new ArrayTabItemIterator(viewRecycler, array, reverse, start); + } + + } + + /** + * The view recycler, which allows to inflate the views, which are used to visualize the + * iterated tabs. + */ + private final AttachedViewRecycler viewRecycler; + + /** + * The array, which contains the tabs, which are iterated by the iterator. + */ + private final Tab[] array; + + /** + * Creates a new iterator, which allows to iterate the tab items, whcih correspond to the tabs, + * which are contained by an array. + * + * @param viewRecycler + * The view recycler, which allows to inflate the views, which are used to visualize the + * iterated tabs, as an instance of the class AttachedViewRecycler. The view recycler + * may not be null + * @param array + * The array, which contains the tabs, which should be iterated by the iterator, as an + * array of the type {@link Tab}. The array may not be null + * @param reverse + * True, if the tabs should be iterated in reverse order, false otherwise + * @param start + * The index of the first tab, which should be iterated, as an {@link Integer} value or + * -1, if all tabs should be iterated + */ + private ArrayTabItemIterator(@NonNull final AttachedViewRecycler viewRecycler, + @NonNull final Tab[] array, final boolean reverse, + final int start) { + ensureNotNull(viewRecycler, "The view recycler may not be null"); + ensureNotNull(array, "The array may not be null"); + this.viewRecycler = viewRecycler; + this.array = array; + initialize(reverse, start); + } + + @Override + public final int getCount() { + return array.length; + } + + @NonNull + @Override + public final TabItem getItem(final int index) { + return TabItem.create(viewRecycler, index, array[index]); + } + +} diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/TabItemIterator.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/TabItemIterator.java new file mode 100755 index 0000000..a8b5e89 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/TabItemIterator.java @@ -0,0 +1,130 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.iterator; + +import android.support.annotation.NonNull; + +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.model.Model; +import de.mrapp.android.tabswitcher.model.TabItem; +import de.mrapp.android.util.view.AttachedViewRecycler; + +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * An iterator, which allows to iterate the tab items, which correspond to the tabs of a {@link + * TabSwitcher}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class TabItemIterator extends AbstractTabItemIterator { + + /** + * A builder, which allows to configure and create instances of the class {@link + * TabItemIterator}. + */ + public static class Builder extends AbstractBuilder { + + /** + * The model, which belongs to the tab switcher, whose tabs should be iterated by the + * iterator, which is created by the builder. + */ + private final Model model; + + /** + * The view recycler, which allows to inflate the views, which are used to visualize the + * tabs, which are iterated by the iterator, which is created by the builder. + */ + private final AttachedViewRecycler viewRecycler; + + /** + * Creates a new builder, which allows to configure and create instances of the class {@link + * TabItemIterator}. + * + * @param model + * The model, which belongs to the tab switcher, whose tabs should be iterated by + * the iterator, which is created by the builder, as an instance of the type {@link + * Model}. The model may not be null + * @param viewRecycler + * The view recycler, which allows to inflate the views, which are used to visualize + * the tabs, which are iterated by the iterator, which is created by the builder, as + * an instance of the class AttachedViewRecycler. The view recycler may not be null + */ + public Builder(@NonNull final Model model, + @NonNull final AttachedViewRecycler viewRecycler) { + ensureNotNull(model, "The model may not be null"); + ensureNotNull(viewRecycler, "The view recycler may not be null"); + this.model = model; + this.viewRecycler = viewRecycler; + } + + @NonNull + @Override + public TabItemIterator create() { + return new TabItemIterator(model, viewRecycler, reverse, start); + } + + } + + /** + * The model, which belongs to the tab switcher, whose tabs are iterated. + */ + private final Model model; + + /** + * The view recycler, which allows to inflated the views, which are used to visualize the + * iterated tabs. + */ + private final AttachedViewRecycler viewRecycler; + + /** + * Creates a new iterator, which allows to iterate the tab items, which correspond to the tabs + * of a {@link TabSwitcher}. + * + * @param model + * The model, which belongs to the tab switcher, whose tabs should be iterated, as an + * instance of the type {@link Model}. The model may not be null + * @param viewRecycler + * The view recycler, which allows to inflate the views, which are used to visualize the + * iterated tabs, as an instance of the class AttachedViewRecycler. The view recycler + * may not be null + * @param reverse + * True, if the tabs should be iterated in reverse order, false otherwise + * @param start + * The index of the first tab, which should be iterated, as an {@link Integer} value or + * -1, if all tabs should be iterated + */ + private TabItemIterator(@NonNull final Model model, + @NonNull final AttachedViewRecycler viewRecycler, + final boolean reverse, final int start) { + ensureNotNull(model, "The model may not be null"); + ensureNotNull(viewRecycler, "The view recycler may not be null"); + this.model = model; + this.viewRecycler = viewRecycler; + initialize(reverse, start); + } + + @Override + public final int getCount() { + return model.getCount(); + } + + @NonNull + @Override + public final TabItem getItem(final int index) { + return TabItem.create(model, viewRecycler, index); + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractDragHandler.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractDragHandler.java new file mode 100755 index 0000000..296e9cf --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractDragHandler.java @@ -0,0 +1,778 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout; + +import android.content.res.Resources; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.ViewConfiguration; + +import de.mrapp.android.tabswitcher.R; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.layout.Arithmetics.Axis; +import de.mrapp.android.tabswitcher.model.TabItem; +import de.mrapp.android.util.gesture.DragHelper; + +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * An abstract base class for all drag handlers, which allow to calculate the position and state of + * tabs on touch events. + * + * @param + * The type of the drag handler's callback + * @author Michael Rapp + * @since 0.1.0 + */ +public abstract class AbstractDragHandler { + + /** + * Contains all possible states of dragging gestures, which can be performed on a {@link + * TabSwitcher}. + */ + public enum DragState { + + /** + * When no dragging gesture is being performed. + */ + NONE, + + /** + * When the tabs are dragged towards the start. + */ + DRAG_TO_START, + + /** + * When the tabs are dragged towards the end. + */ + DRAG_TO_END, + + /** + * When an overshoot at the start is being performed. + */ + OVERSHOOT_START, + + /** + * When an overshoot at the end is being performed. + */ + OVERSHOOT_END, + + /** + * When a tab is swiped. + */ + SWIPE + + } + + /** + * Defines the interface, a class, which should be notified about the events of a drag handler, + * must implement. + */ + public interface Callback { + + /** + * The method, which is invoked in order to calculate the positions of all tabs, depending + * on the current drag distance. + * + * @param dragState + * The current drag state as a value of the enum {@link DragState}. The drag state + * must either be {@link DragState#DRAG_TO_END} or {@link DragState#DRAG_TO_START} + * @param dragDistance + * The current drag distance in pixels as a {@link Float} value + * @return A drag state, which specifies whether the tabs are overshooting, or not. If the + * tabs are overshooting, the drag state must be {@link DragState#OVERSHOOT_START} or {@link + * DragState#OVERSHOOT_END}, null otherwise + */ + @Nullable + DragState onDrag(@NonNull DragState dragState, float dragDistance); + + /** + * The method, which is invoked, when a tab has been clicked. + * + * @param tabItem + * The tab item, which corresponds to the tab, which has been clicked, as an + * instance of the class {@link TabItem}. The tab item may not be null + */ + void onClick(@NonNull TabItem tabItem); + + /** + * The method, which is invoked, when a fling has been triggered. + * + * @param distance + * The distance of the fling in pixels as a {@link Float} value + * @param duration + * The duration of the fling in milliseconds as a {@link Long} value + */ + void onFling(float distance, long duration); + + /** + * The method, which is invoked, when a fling has been cancelled. + */ + void onCancelFling(); + + /** + * The method, which is invoked, when an overshoot at the start should be reverted. + */ + void onRevertStartOvershoot(); + + /** + * The method, which is invoked, when an overshoot at the end should be reverted. + */ + void onRevertEndOvershoot(); + + /** + * The method, which is invoked, when a tab is swiped. + * + * @param tabItem + * The tab item, which corresponds to the swiped tab, as an instance of the class + * {@link TabItem}. The tab item may not be null + * @param distance + * The distance, the tab is swiped by, in pixels as a {@link Float} value + */ + void onSwipe(@NonNull TabItem tabItem, float distance); + + /** + * The method, which is invoked, when swiping a tab ended. + * + * @param tabItem + * The tab item, which corresponds to the swiped tab, as an instance of the class + * {@link TabItem}. The tab item may not be null + * @param remove + * True, if the tab should be removed, false otherwise + * @param velocity + * The velocity of the swipe gesture in pixels per second as a {@link Float} value + */ + void onSwipeEnded(@NonNull TabItem tabItem, boolean remove, float velocity); + + } + + /** + * The tab switcher, whose tabs' positions and states are calculated by the drag handler. + */ + private final TabSwitcher tabSwitcher; + + /** + * The arithmetics, which are used to calculate the positions, size and rotation of tabs. + */ + private final Arithmetics arithmetics; + + /** + * True, if tabs can be swiped on the orthogonal axis, false otherwise. + */ + private final boolean swipeEnabled; + + /** + * The drag helper, which is used to recognize drag gestures on the dragging axis. + */ + private final DragHelper dragHelper; + + /** + * The drag helper, which is used to recognize swipe gestures on the orthogonal axis. + */ + private final DragHelper swipeDragHelper; + + /** + * The minimum velocity, which must be reached by a drag gesture to start a fling animation. + */ + private final float minFlingVelocity; + + /** + * The velocity, which may be reached by a drag gesture at maximum to start a fling animation. + */ + private final float maxFlingVelocity; + + /** + * The velocity, which must be reached by a drag gesture in order to start a swipe animation. + */ + private final float minSwipeVelocity; + + /** + * The threshold, which must be reached until tabs are dragged, in pixels. + */ + private int dragThreshold; + + /** + * The velocity tracker, which is used to measure the velocity of dragging gestures. + */ + private VelocityTracker velocityTracker; + + /** + * The id of the pointer, which has been used to start the current drag gesture. + */ + private int pointerId; + + /** + * The currently swiped tab item. + */ + private TabItem swipedTabItem; + + /** + * The state of the currently performed drag gesture. + */ + private DragState dragState; + + /** + * The distance of the current drag gesture in pixels. + */ + private float dragDistance; + + /** + * The drag distance at which the start overshoot begins. + */ + private float startOvershootThreshold; + + /** + * The drag distance at which the end overshoot begins. + */ + private float endOvershootThreshold; + + /** + * The callback, which is notified about the drag handler's events. + */ + private CallbackType callback; + + /** + * Resets the drag handler to its previous state, when a drag gesture has ended. + * + * @param dragThreshold + * The drag threshold, which should be used to recognize drag gestures, in pixels as an + * {@link Integer} value + */ + private void resetDragging(final int dragThreshold) { + if (this.velocityTracker != null) { + this.velocityTracker.recycle(); + this.velocityTracker = null; + } + + this.pointerId = -1; + this.dragState = DragState.NONE; + this.swipedTabItem = null; + this.dragDistance = 0; + this.startOvershootThreshold = -Float.MAX_VALUE; + this.endOvershootThreshold = Float.MAX_VALUE; + this.dragThreshold = dragThreshold; + this.dragHelper.reset(dragThreshold); + this.swipeDragHelper.reset(); + } + + /** + * Handles, when a drag gesture has been started. + * + * @param event + * The motion event, which started the drag gesture, as an instance of the class {@link + * MotionEvent}. The motion event may not be null + */ + private void handleDown(@NonNull final MotionEvent event) { + pointerId = event.getPointerId(0); + + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain(); + } else { + velocityTracker.clear(); + } + + velocityTracker.addMovement(event); + } + + /** + * Handles a click. + * + * @param event + * The motion event, which triggered the click, as an instance of the class {@link + * MotionEvent}. The motion event may not be null + */ + private void handleClick(@NonNull final MotionEvent event) { + TabItem tabItem = getFocusedTab(arithmetics.getPosition(Axis.DRAGGING_AXIS, event)); + + if (tabItem != null) { + notifyOnClick(tabItem); + } + } + + /** + * Handles a fling gesture. + * + * @param event + * The motion event, which triggered the fling gesture, as an instance of the class + * {@link MotionEvent}. The motion event may not be null + * @param dragState + * The current drag state, which determines the fling direction, as a value of the enum + * {@link DragState}. The drag state may not be null + */ + private void handleFling(@NonNull final MotionEvent event, @NonNull final DragState dragState) { + int pointerId = event.getPointerId(0); + velocityTracker.computeCurrentVelocity(1000, maxFlingVelocity); + float flingVelocity = Math.abs(velocityTracker.getYVelocity(pointerId)); + + if (flingVelocity > minFlingVelocity) { + float flingDistance = 0.25f * flingVelocity; + + if (dragState == DragState.DRAG_TO_START) { + flingDistance = -1 * flingDistance; + } + + long duration = Math.round(Math.abs(flingDistance) / flingVelocity * 1000); + notifyOnFling(flingDistance, duration); + } + } + + /** + * Handles, when the tabs are overshooting. + */ + private void handleOvershoot() { + if (!dragHelper.isReset()) { + dragHelper.reset(0); + dragDistance = 0; + } + } + + /** + * Notifies the callback in order to calculate the positions of all tabs, depending on the + * current drag distance. + * + * @param dragState + * The current drag state as a value of the enum {@link DragState}. The drag state must + * either be {@link DragState#DRAG_TO_END} or {@link DragState#DRAG_TO_START} + * @param dragDistance + * The current drag distance in pixels as a {@link Float} value + * @return A drag state, which specifies whether the tabs are overshooting, or not. If the tabs + * are overshooting, the drag state must be {@link DragState#OVERSHOOT_START} or {@link + * DragState#OVERSHOOT_END}, null otherwise + */ + private DragState notifyOnDrag(@NonNull final DragState dragState, final float dragDistance) { + if (callback != null) { + return callback.onDrag(dragState, dragDistance); + } + + return null; + } + + /** + * Notifies the callback, that a tab has been clicked. + * + * @param tabItem + * The tab item, which corresponds to the tab, which has been clicked, as an instance of + * the class {@link TabItem}. The tab item may not be null + */ + private void notifyOnClick(@NonNull final TabItem tabItem) { + if (callback != null) { + callback.onClick(tabItem); + } + } + + /** + * Notifies the callback, that a fling has been triggered. + * + * @param distance + * The distance of the fling in pixels as a {@link Float} value + * @param duration + * The duration of the fling in milliseconds as a {@link Long} value + */ + private void notifyOnFling(final float distance, final long duration) { + if (callback != null) { + callback.onFling(distance, duration); + } + } + + /** + * Notifies the callback, that a fling has been cancelled. + */ + private void notifyOnCancelFling() { + if (callback != null) { + callback.onCancelFling(); + } + } + + /** + * Notifies the callback, that an overshoot at the start should be reverted. + */ + private void notifyOnRevertStartOvershoot() { + if (callback != null) { + callback.onRevertStartOvershoot(); + } + } + + /** + * Notifies the callback, that an overshoot at the end should be reverted. + */ + private void notifyOnRevertEndOvershoot() { + if (callback != null) { + callback.onRevertEndOvershoot(); + } + } + + /** + * Notifies the callback, that a tab is swiped. + * + * @param tabItem + * The tab item, which corresponds to the swiped tab, as an instance of the class {@link + * TabItem}. The tab item may not be null + * @param distance + * The distance, the tab is swiped by, in pixels as a {@link Float} value + */ + private void notifyOnSwipe(@NonNull final TabItem tabItem, final float distance) { + if (callback != null) { + callback.onSwipe(tabItem, distance); + } + } + + /** + * Notifies the callback, that swiping a tab ended. + * + * @param tabItem + * The tab item, which corresponds to the swiped tab, as an instance of the class {@link + * TabItem}. The tab item may not be null + * @param remove + * True, if the tab should be removed, false otherwise + * @param velocity + * The velocity of the swipe gesture in pixels per second as a {@link Float} value + */ + private void notifyOnSwipeEnded(@NonNull final TabItem tabItem, final boolean remove, + final float velocity) { + if (callback != null) { + callback.onSwipeEnded(tabItem, remove, velocity); + } + } + + /** + * Returns the tab switcher, whose tabs' positions and states are calculated by the drag + * handler. + * + * @return The tab switcher, whose tabs' positions and states are calculated by the drag + * handler, as an instance of the class {@link TabSwitcher}. The tab switcher may not be null + */ + @NonNull + protected TabSwitcher getTabSwitcher() { + return tabSwitcher; + } + + /** + * Returns the arithmetics, which are used to calculate the positions, size and rotation of + * tabs. + * + * @return The arithmetics, which are used to calculate the positions, size and rotation of + * tabs, as an instance of the type {@link Arithmetics}. The arithmetics may not be null + */ + @NonNull + protected Arithmetics getArithmetics() { + return arithmetics; + } + + /** + * Returns the callback, which should be notified about the drag handler's events. + * + * @return The callback, which should be notified about the drag handler's events, as an + * instance of the generic type CallbackType or null, if no callback should be notified + */ + @Nullable + protected CallbackType getCallback() { + return callback; + } + + /** + * Creates a new drag handler, which allows to calculate the position and state of tabs on touch + * events. + * + * @param tabSwitcher + * The tab switcher, whose tabs' positions and states should be calculated by the drag + * handler, as an instance of the class {@link TabSwitcher}. The tab switcher may not be + * null + * @param arithmetics + * The arithmetics, which should be used to calculate the position, size and rotation of + * tabs, as an instance of the type {@link Arithmetics}. The arithmetics may not be + * null + * @param swipeEnabled + * True, if tabs can be swiped on the orthogonal axis, false otherwise + */ + public AbstractDragHandler(@NonNull final TabSwitcher tabSwitcher, + @NonNull final Arithmetics arithmetics, final boolean swipeEnabled) { + ensureNotNull(tabSwitcher, "The tab switcher may not be null"); + ensureNotNull(arithmetics, "The arithmetics may not be null"); + this.tabSwitcher = tabSwitcher; + this.arithmetics = arithmetics; + this.swipeEnabled = swipeEnabled; + this.dragHelper = new DragHelper(0); + Resources resources = tabSwitcher.getResources(); + this.swipeDragHelper = + new DragHelper(resources.getDimensionPixelSize(R.dimen.swipe_threshold)); + this.callback = null; + ViewConfiguration configuration = ViewConfiguration.get(tabSwitcher.getContext()); + this.minFlingVelocity = configuration.getScaledMinimumFlingVelocity(); + this.maxFlingVelocity = configuration.getScaledMaximumFlingVelocity(); + this.minSwipeVelocity = resources.getDimensionPixelSize(R.dimen.min_swipe_velocity); + resetDragging(resources.getDimensionPixelSize(R.dimen.drag_threshold)); + } + + /** + * The method, which is invoked on implementing subclasses in order to retrieve the tab item, + * which corresponds to the tab, which is focused when clicking/dragging at a specific position. + * + * @param position + * The position on the dragging axis in pixels as a {@link Float} value + * @return The tab item, which corresponds to the focused tab, as an instance of the class + * {@link TabItem} or null, if no tab is focused + */ + protected abstract TabItem getFocusedTab(final float position); + + /** + * The method, which is invoked on implementing subclasses, when the tabs are overshooting at + * the start. + * + * @param dragPosition + * The position of the pointer on the dragging axis in pixels as a {@link Float} value + * @param overshootThreshold + * The position on the dragging axis, an overshoot at the start currently starts at, in + * pixels as a {@link Float} value + * @return The updated position on the dragging axis, an overshoot at the start starts at, in + * pixels as a {@link Float} value + */ + protected float onOvershootStart(final float dragPosition, final float overshootThreshold) { + return overshootThreshold; + } + + /** + * The method, which is invoked on implementing subclasses, when the tabs are overshooting at + * the end. + * + * @param dragPosition + * The position of the pointer on the dragging axis in pixels as a {@link Float} value + * @param overshootThreshold + * The position on the dragging axis, an overshoot at the end currently starts at, in + * pixels as a {@link Float} value + * @return The updated position on the dragging axis, an overshoot at the end starts at, in + * pixels as a {@link Float} value + */ + protected float onOvershootEnd(final float dragPosition, final float overshootThreshold) { + return overshootThreshold; + } + + /** + * The method, which is invoked on implementing subclasses, when an overshoot has been reverted. + */ + protected void onOvershootReverted() { + + } + + /** + * The method, which invoked on implementing subclasses, when the drag handler has been reset. + */ + protected void onReset() { + + } + + /** + * Returns, whether the threshold of a swiped tab item, which causes the corresponding tab to be + * removed, has been reached, or not. + * + * @param swipedTabItem + * The swiped tab item as an instance of the class {@link TabItem}. The tab item may not + * be null + * @return True, if the threshold has been reached, false otherwise + */ + protected boolean isSwipeThresholdReached(@NonNull final TabItem swipedTabItem) { + return false; + } + + /** + * Sets the callback, which should be notified about the drag handler's events. + * + * @param callback + * The callback, which should be set, as an instance of the generic type CallbackType or + * null, if no callback should be notified + */ + public final void setCallback(@Nullable final CallbackType callback) { + this.callback = callback; + } + + /** + * Handles a touch event. + * + * @param event + * The event, which should be handled, as an instance of the class {@link MotionEvent}. + * The event may be not null + * @return True, if the event has been handled, false otherwise + */ + public final boolean handleTouchEvent(@NonNull final MotionEvent event) { + ensureNotNull(event, "The motion event may not be null"); + + if (tabSwitcher.isSwitcherShown() && !tabSwitcher.isEmpty()) { + notifyOnCancelFling(); + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + handleDown(event); + return true; + case MotionEvent.ACTION_MOVE: + if (!tabSwitcher.isAnimationRunning() && event.getPointerId(0) == pointerId) { + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain(); + } + + velocityTracker.addMovement(event); + handleDrag(arithmetics.getPosition(Axis.DRAGGING_AXIS, event), + arithmetics.getPosition(Axis.ORTHOGONAL_AXIS, event)); + } else { + handleRelease(null, dragThreshold); + handleDown(event); + } + + return true; + case MotionEvent.ACTION_UP: + if (!tabSwitcher.isAnimationRunning() && event.getPointerId(0) == pointerId) { + handleRelease(event, dragThreshold); + } + + return true; + default: + break; + } + } + + return false; + } + + /** + * Handles drag gestures. + * + * @param dragPosition + * The position of the pointer on the dragging axis in pixels as a {@link Float} value + * @param orthogonalPosition + * The position of the pointer of the orthogonal axis in pixels as a {@link Float} + * value + * @return True, if any tabs have been moved, false otherwise + */ + public final boolean handleDrag(final float dragPosition, final float orthogonalPosition) { + if (dragPosition <= startOvershootThreshold) { + handleOvershoot(); + dragState = DragState.OVERSHOOT_START; + startOvershootThreshold = onOvershootStart(dragPosition, startOvershootThreshold); + } else if (dragPosition >= endOvershootThreshold) { + handleOvershoot(); + dragState = DragState.OVERSHOOT_END; + endOvershootThreshold = onOvershootEnd(dragPosition, endOvershootThreshold); + } else { + onOvershootReverted(); + float previousDistance = dragHelper.isReset() ? 0 : dragHelper.getDragDistance(); + dragHelper.update(dragPosition); + + if (swipeEnabled) { + swipeDragHelper.update(orthogonalPosition); + + if (dragState == DragState.NONE && swipeDragHelper.hasThresholdBeenReached()) { + TabItem tabItem = getFocusedTab(dragHelper.getDragStartPosition()); + + if (tabItem != null) { + dragState = DragState.SWIPE; + swipedTabItem = tabItem; + } + } + } + + if (dragState != DragState.SWIPE && dragHelper.hasThresholdBeenReached()) { + if (dragState == DragState.OVERSHOOT_START) { + dragState = DragState.DRAG_TO_END; + } else if (dragState == DragState.OVERSHOOT_END) { + dragState = DragState.DRAG_TO_START; + } else { + float dragDistance = dragHelper.getDragDistance(); + + if (dragDistance == 0) { + dragState = DragState.NONE; + } else { + dragState = previousDistance - dragDistance < 0 ? DragState.DRAG_TO_END : + DragState.DRAG_TO_START; + } + } + } + + if (dragState == DragState.SWIPE) { + notifyOnSwipe(swipedTabItem, swipeDragHelper.getDragDistance()); + } else if (dragState != DragState.NONE) { + float currentDragDistance = dragHelper.getDragDistance(); + float distance = currentDragDistance - dragDistance; + dragDistance = currentDragDistance; + DragState overshoot = notifyOnDrag(dragState, distance); + + if (overshoot == DragState.OVERSHOOT_END && (dragState == DragState.DRAG_TO_END || + dragState == DragState.OVERSHOOT_END)) { + endOvershootThreshold = dragPosition; + dragState = DragState.OVERSHOOT_END; + } else if (overshoot == DragState.OVERSHOOT_START && + (dragState == DragState.DRAG_TO_START || + dragState == DragState.OVERSHOOT_START)) { + startOvershootThreshold = dragPosition; + dragState = DragState.OVERSHOOT_START; + } + + return true; + } + } + + return false; + } + + /** + * Handles, when a drag gesture has been ended. + * + * @param event + * The motion event, which ended the drag gesture, as an instance of the class {@link + * MotionEvent} or null, if no fling animation should be triggered + * @param dragThreshold + * The drag threshold, which should be used to recognize drag gestures, in pixels as an + * {@link Integer} value + */ + public final void handleRelease(@Nullable final MotionEvent event, final int dragThreshold) { + if (dragState == DragState.SWIPE) { + float swipeVelocity = 0; + + if (event != null && velocityTracker != null) { + int pointerId = event.getPointerId(0); + velocityTracker.computeCurrentVelocity(1000, maxFlingVelocity); + swipeVelocity = Math.abs(velocityTracker.getXVelocity(pointerId)); + } + + boolean remove = swipedTabItem.getTab().isCloseable() && + (swipeVelocity >= minSwipeVelocity || isSwipeThresholdReached(swipedTabItem)); + notifyOnSwipeEnded(swipedTabItem, remove, + swipeVelocity >= minSwipeVelocity ? swipeVelocity : 0); + } else if (dragState == DragState.DRAG_TO_START || dragState == DragState.DRAG_TO_END) { + if (event != null && velocityTracker != null && dragHelper.hasThresholdBeenReached()) { + handleFling(event, dragState); + } + } else if (dragState == DragState.OVERSHOOT_END) { + notifyOnRevertEndOvershoot(); + } else if (dragState == DragState.OVERSHOOT_START) { + notifyOnRevertStartOvershoot(); + } else if (event != null) { + handleClick(event); + } + + resetDragging(dragThreshold); + } + + /** + * Resets the drag handler to its initial state. + * + * @param dragThreshold + * The drag threshold, which should be used to recognize drag gestures, in pixels as an + * {@link Integer} value + */ + public final void reset(final int dragThreshold) { + resetDragging(dragThreshold); + onReset(); + } + +} diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabSwitcherLayout.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabSwitcherLayout.java new file mode 100755 index 0000000..c831fa0 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabSwitcherLayout.java @@ -0,0 +1,616 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout; + +import android.animation.Animator; +import android.animation.Animator.AnimatorListener; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.support.annotation.CallSuper; +import android.support.annotation.MenuRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.util.Pair; +import android.support.v7.widget.Toolbar; +import android.support.v7.widget.Toolbar.OnMenuItemClickListener; +import android.view.Menu; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.animation.Animation; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Transformation; + +import de.mrapp.android.tabswitcher.R; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.TabSwitcherDecorator; +import de.mrapp.android.tabswitcher.model.Model; +import de.mrapp.android.tabswitcher.model.TabItem; +import de.mrapp.android.tabswitcher.model.TabSwitcherModel; +import de.mrapp.android.util.ViewUtil; +import de.mrapp.android.util.logging.Logger; + +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * An abstract base class for all layouts, which implement the functionality of a {@link + * TabSwitcher}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public abstract class AbstractTabSwitcherLayout + implements TabSwitcherLayout, OnGlobalLayoutListener, Model.Listener, + AbstractDragHandler.Callback { + + /** + * Defines the interface, a class, which should be notified about the events of a tab switcher + * layout, must implement. + */ + public interface Callback { + + /* + * The method, which is invoked, when all animations have been ended. + */ + void onAnimationsEnded(); + + } + + /** + * A layout listener, which unregisters itself from the observed view, when invoked. The + * listener allows to encapsulate another listener, which is notified, when the listener is + * invoked. + */ + public static class LayoutListenerWrapper implements OnGlobalLayoutListener { + + /** + * The observed view. + */ + private final View view; + + /** + * The encapsulated listener. + */ + private final OnGlobalLayoutListener listener; + + /** + * Creates a new layout listener, which unregisters itself from the observed view, when + * invoked. + * + * @param view + * The observed view as an instance of the class {@link View}. The view may not be + * null + * @param listener + * The listener, which should be encapsulated, as an instance of the type {@link + * OnGlobalLayoutListener} or null, if no listener should be encapsulated + */ + public LayoutListenerWrapper(@NonNull final View view, + @Nullable final OnGlobalLayoutListener listener) { + ensureNotNull(view, "The view may not be null"); + this.view = view; + this.listener = listener; + } + + @Override + public void onGlobalLayout() { + ViewUtil.removeOnGlobalLayoutListener(view.getViewTreeObserver(), this); + + if (listener != null) { + listener.onGlobalLayout(); + } + } + + } + + /** + * A animation listener, which increases the number of running animations, when the observed + * animation is started, and decreases the number of accordingly, when the animation is + * finished. The listener allows to encapsulate another animation listener, which is notified + * when the animation has been started, canceled or ended. + */ + protected class AnimationListenerWrapper extends AnimatorListenerAdapter { + + /** + * The encapsulated listener. + */ + private final AnimatorListener listener; + + /** + * Decreases the number of running animations and executes the next pending action, if no + * running animations remain. + */ + private void endAnimation() { + if (--runningAnimations == 0) { + notifyOnAnimationsEnded(); + } + } + + /** + * Creates a new animation listener, which increases the number of running animations, when + * the observed animation is started, and decreases the number of accordingly, when the + * animation is finished. + * + * @param listener + * The listener, which should be encapsulated, as an instance of the type {@link + * AnimatorListener} or null, if no listener should be encapsulated + */ + public AnimationListenerWrapper(@Nullable final AnimatorListener listener) { + this.listener = listener; + } + + @Override + public void onAnimationStart(final Animator animation) { + super.onAnimationStart(animation); + runningAnimations++; + + if (listener != null) { + listener.onAnimationStart(animation); + } + } + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + + if (listener != null) { + listener.onAnimationEnd(animation); + } + + endAnimation(); + } + + @Override + public void onAnimationCancel(final Animator animation) { + super.onAnimationCancel(animation); + + if (listener != null) { + listener.onAnimationCancel(animation); + } + + endAnimation(); + } + + } + + /** + * An animation, which allows to fling the tabs. + */ + private class FlingAnimation extends android.view.animation.Animation { + + /** + * The distance, the tabs should be moved. + */ + private final float distance; + + /** + * Creates a new fling animation. + * + * @param distance + * The distance, the tabs should be moved, in pixels as a {@link Float} value + */ + FlingAnimation(final float distance) { + this.distance = distance; + } + + @Override + protected void applyTransformation(final float interpolatedTime, final Transformation t) { + if (flingAnimation != null) { + dragHandler.handleDrag(distance * interpolatedTime, 0); + } + } + + } + + /** + * The tab switcher, the layout belongs to. + */ + private final TabSwitcher tabSwitcher; + + /** + * The model of the tab switcher, the layout belongs to. + */ + private final TabSwitcherModel model; + + /** + * The arithmetics, which are used by the layout. + */ + private final Arithmetics arithmetics; + + /** + * The threshold, which must be reached until tabs are dragged, in pixels. + */ + private final int dragThreshold; + + /** + * The logger, which is used for logging. + */ + private final Logger logger; + + /** + * The callback, which is notified about the layout's events. + */ + private Callback callback; + + /** + * The number of animations, which are currently running. + */ + private int runningAnimations; + + /** + * The animation, which is used to fling the tabs. + */ + private android.view.animation.Animation flingAnimation; + + /** + * The drag handler, which is used by the layout. + */ + private AbstractDragHandler dragHandler; + + /** + * Adapts the visibility of the toolbars, which are shown, when the tab switcher is shown. + */ + private void adaptToolbarVisibility() { + Toolbar[] toolbars = getToolbars(); + + if (toolbars != null) { + for (Toolbar toolbar : toolbars) { + toolbar.setVisibility( + getModel().areToolbarsShown() ? View.VISIBLE : View.INVISIBLE); + } + } + } + + /** + * Adapts the title of the toolbar, which is shown, when the tab switcher is shown. + */ + private void adaptToolbarTitle() { + Toolbar[] toolbars = getToolbars(); + + if (toolbars != null) { + toolbars[0].setTitle(getModel().getToolbarTitle()); + } + } + + /** + * Adapts the navigation icon of the toolbar, which is shown, when the tab switcher is shown. + */ + private void adaptToolbarNavigationIcon() { + Toolbar[] toolbars = getToolbars(); + + if (toolbars != null) { + Toolbar toolbar = toolbars[0]; + toolbar.setNavigationIcon(getModel().getToolbarNavigationIcon()); + toolbar.setNavigationOnClickListener(getModel().getToolbarNavigationIconListener()); + } + } + + /** + * Inflates the menu of the toolbar, which is shown, when the tab switcher is shown. + */ + private void inflateToolbarMenu() { + Toolbar[] toolbars = getToolbars(); + int menuId = getModel().getToolbarMenuId(); + + if (toolbars != null && menuId != -1) { + Toolbar toolbar = toolbars.length > 1 ? toolbars[1] : toolbars[0]; + toolbar.inflateMenu(menuId); + toolbar.setOnMenuItemClickListener(getModel().getToolbarMenuItemListener()); + } + } + + /** + * Creates and returns an animation listener, which allows to handle, when a fling animation + * ended. + * + * @return The listener, which has been created, as an instance of the class {@link + * Animation.AnimationListener}. The listener may not be null + */ + @NonNull + private Animation.AnimationListener createFlingAnimationListener() { + return new Animation.AnimationListener() { + + @Override + public void onAnimationStart(final android.view.animation.Animation animation) { + + } + + @Override + public void onAnimationEnd(final android.view.animation.Animation animation) { + dragHandler.handleRelease(null, dragThreshold); + flingAnimation = null; + notifyOnAnimationsEnded(); + } + + @Override + public void onAnimationRepeat(final android.view.animation.Animation animation) { + + } + + }; + } + + /** + * Notifies the callback, that all animations have been ended. + */ + private void notifyOnAnimationsEnded() { + if (callback != null) { + callback.onAnimationsEnded(); + } + } + + /** + * Returns the tab switcher, the layout belongs to. + * + * @return The tab switcher, the layout belongs to, as an instance of the class {@link + * TabSwitcher}. The tab switcher may not be null + */ + @NonNull + protected final TabSwitcher getTabSwitcher() { + return tabSwitcher; + } + + /** + * Returns the model of the tab switcher, the layout belongs to. + * + * @return The model of the tab switcher, the layout belongs to, as an instance of the class + * {@link TabSwitcherModel}. The model may not be null + */ + @NonNull + protected final TabSwitcherModel getModel() { + return model; + } + + /** + * Returns the arithmetics, which are used by the layout. + * + * @return The arithmetics, which are used by the layout, as an instance of the type {@link + * Arithmetics}. The arithmetics may not be null + */ + @NonNull + protected final Arithmetics getArithmetics() { + return arithmetics; + } + + /** + * Returns the threshold, which must be reached until tabs are dragged. + * + * @return The threshold, which must be reached until tabs are dragged, in pixels as an {@link + * Integer} value + */ + protected final int getDragThreshold() { + return dragThreshold; + } + + /** + * Returns the logger, which is used for logging. + * + * @return The logger, which is used for logging, as an instance of the class Logger. The logger + * may not be null + */ + @NonNull + protected final Logger getLogger() { + return logger; + } + + /** + * Returns the context, which is used by the layout. + * + * @return The context, which is used by the layout, as an instance of the class {@link + * Context}. The context may not be null + */ + @NonNull + protected final Context getContext() { + return tabSwitcher.getContext(); + } + + /** + * Creates a new layout, which implements the functionality of a {@link TabSwitcher}. + * + * @param tabSwitcher + * The tab switcher, the layout belongs to, as an instance of the class {@link + * TabSwitcher}. The tab switcher may not be null + * @param model + * The model of the tab switcher, the layout belongs to, as an instance of the class + * {@link TabSwitcherModel}. The model may not be null + * @param arithmetics + * The arithmetics, which should be used by the layout, as an instance of the type + * {@link Arithmetics}. The arithmetics may not be null + */ + public AbstractTabSwitcherLayout(@NonNull final TabSwitcher tabSwitcher, + @NonNull final TabSwitcherModel model, + @NonNull final Arithmetics arithmetics) { + ensureNotNull(tabSwitcher, "The tab switcher may not be null"); + ensureNotNull(model, "The model may not be null"); + ensureNotNull(arithmetics, "The arithmetics may not be null"); + this.tabSwitcher = tabSwitcher; + this.model = model; + this.arithmetics = arithmetics; + this.dragThreshold = + getTabSwitcher().getResources().getDimensionPixelSize(R.dimen.drag_threshold); + this.logger = new Logger(model.getLogLevel()); + this.callback = null; + this.runningAnimations = 0; + this.flingAnimation = null; + this.dragHandler = null; + } + + /** + * The method, which is invoked on implementing subclasses in order to inflate the layout. + * + * @param tabsOnly + * True, if only the tabs should be inflated, false otherwise + * @return The drag handler, which is used by the layout, as an instance of the class {@link + * AbstractDragHandler} or null, if no drag handler is used + */ + @Nullable + protected abstract AbstractDragHandler onInflateLayout(final boolean tabsOnly); + + /** + * The method, which is invoked on implementing subclasses in order to detach the layout. + * + * @param tabsOnly + * True, if only the tabs should be detached, false otherwise + * @return A pair, which contains the index of the first visible tab, as well as its current + * position, as an instance of the class Pair or null, if the tab switcher is not shown + */ + @Nullable + protected abstract Pair onDetachLayout(final boolean tabsOnly); + + /** + * Handles a touch event. + * + * @param event + * The touch event as an instance of the class {@link MotionEvent}. The touch event may + * not be null + * @return True, if the event has been handled, false otherwise + */ + public abstract boolean handleTouchEvent(@NonNull final MotionEvent event); + + /** + * Inflates the layout. + * + * @param tabsOnly + * True, if only the tabs should be inflated, false otherwise + */ + public final void inflateLayout(final boolean tabsOnly) { + dragHandler = onInflateLayout(tabsOnly); + + if (!tabsOnly) { + adaptToolbarVisibility(); + adaptToolbarTitle(); + adaptToolbarNavigationIcon(); + inflateToolbarMenu(); + } + } + + /** + * Detaches the layout. + * + * @param tabsOnly + * True, if only the tabs should be detached, false otherwise + * @return A pair, which contains the index of the first visible tab, as well as its current + * position, as an instance of the class Pair or null, if the tab switcher is not shown + */ + @Nullable + public final Pair detachLayout(final boolean tabsOnly) { + return onDetachLayout(tabsOnly); + } + + /** + * Sets the callback, which should be notified about the layout's events. + * + * @param callback + * The callback, which should be set, as an instance of the type {@link Callback} or + * null, if no callback should be notified + */ + public final void setCallback(@Nullable final Callback callback) { + this.callback = callback; + } + + @Override + public final boolean isAnimationRunning() { + return runningAnimations > 0 || flingAnimation != null; + } + + @Nullable + @Override + public final Menu getToolbarMenu() { + Toolbar[] toolbars = getToolbars(); + + if (toolbars != null) { + Toolbar toolbar = toolbars.length > 1 ? toolbars[1] : toolbars[0]; + return toolbar.getMenu(); + } + + return null; + } + + @CallSuper + @Override + public void onDecoratorChanged(@NonNull final TabSwitcherDecorator decorator) { + detachLayout(true); + onGlobalLayout(); + } + + @Override + public final void onToolbarVisibilityChanged(final boolean visible) { + adaptToolbarVisibility(); + } + + @Override + public final void onToolbarTitleChanged(@Nullable final CharSequence title) { + adaptToolbarTitle(); + } + + @Override + public final void onToolbarNavigationIconChanged(@Nullable final Drawable icon, + @Nullable final OnClickListener listener) { + adaptToolbarNavigationIcon(); + } + + @Override + public final void onToolbarMenuInflated(@MenuRes final int resourceId, + @Nullable final OnMenuItemClickListener listener) { + inflateToolbarMenu(); + } + + @Override + public final void onFling(final float distance, final long duration) { + if (dragHandler != null) { + flingAnimation = new FlingAnimation(distance); + flingAnimation.setFillAfter(true); + flingAnimation.setAnimationListener(createFlingAnimationListener()); + flingAnimation.setDuration(duration); + flingAnimation.setInterpolator(new DecelerateInterpolator()); + getTabSwitcher().startAnimation(flingAnimation); + logger.logVerbose(getClass(), + "Started fling animation using a distance of " + distance + + " pixels and a duration of " + duration + " milliseconds"); + } + } + + @Override + public final void onCancelFling() { + if (flingAnimation != null) { + flingAnimation.cancel(); + flingAnimation = null; + dragHandler.handleRelease(null, dragThreshold); + logger.logVerbose(getClass(), "Canceled fling animation"); + } + } + + @Override + public void onRevertStartOvershoot() { + + } + + @Override + public void onRevertEndOvershoot() { + + } + + @Override + public void onSwipe(@NonNull final TabItem tabItem, final float distance) { + + } + + @Override + public void onSwipeEnded(@NonNull final TabItem tabItem, final boolean remove, + final float velocity) { + + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabViewHolder.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabViewHolder.java new file mode 100755 index 0000000..ef14bc9 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabViewHolder.java @@ -0,0 +1,40 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout; + +import android.widget.ImageButton; +import android.widget.TextView; + +import de.mrapp.android.tabswitcher.TabSwitcher; + +/** + * An abstract base class for all view holders, which allow to store references to the views, a tab + * of a {@link TabSwitcher} consists of. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public abstract class AbstractTabViewHolder { + + /** + * The text view, which is used to display the title of a tab. + */ + public TextView titleTextView; + + /** + * The close button of a tab. + */ + public ImageButton closeButton; + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/Arithmetics.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/Arithmetics.java new file mode 100755 index 0000000..da12f87 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/Arithmetics.java @@ -0,0 +1,274 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package de.mrapp.android.tabswitcher.layout; + +import android.support.annotation.NonNull; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewPropertyAnimator; + +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.layout.AbstractDragHandler.DragState; + +/** + * Defines the interface, a class, which provides methods, which allow to calculate the position, + * size and rotation of a {@link TabSwitcher}'s children, must implement. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public interface Arithmetics { + + /** + * Contains all axes on which the tabs of a {@link TabSwitcher} can be moved. + */ + enum Axis { + + /** + * The axis on which a tab is moved when dragging it. + */ + DRAGGING_AXIS, + + /** + * The axis on which a tab is moved, when it is added to or removed from the switcher. + */ + ORTHOGONAL_AXIS, + + /** + * The horizontal axis. + */ + X_AXIS, + + /** + * The vertical axis. + */ + Y_AXIS + + } + + /** + * Returns the position of a motion event on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param event + * The motion event, whose position should be returned, as an instance of the class + * {@link MotionEvent}. The motion event may not be null + * @return The position of the given motion event on the given axis as a {@link Float} value + */ + float getPosition(@NonNull Axis axis, @NonNull MotionEvent event); + + /** + * Returns the position of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose position should be returned, as an instance of the class {@link + * View}. The view may not be null + * @return The position of the given view on the given axis as a {@link Float} value + */ + float getPosition(@NonNull Axis axis, @NonNull View view); + + /** + * Sets the position of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose position should be set, as an instance of the class {@link View}. The + * view may not be null + * @param position + * The position, which should be set, as a {@link Float} value + */ + void setPosition(@NonNull Axis axis, @NonNull View view, float position); + + /** + * Animates the position of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param animator + * The animator, which should be used to animate the position, as an instance of the + * class {@link ViewPropertyAnimator}. The animator may not be null + * @param view + * The view, whose position should be animated, as an instance of the class {@link + * View}. The view may not be null + * @param position + * The position, which should be set by the animation, as a {@link Float} value + * @param includePadding + * True, if the view's padding should be taken into account, false otherwise + */ + void animatePosition(@NonNull Axis axis, @NonNull ViewPropertyAnimator animator, + @NonNull View view, float position, boolean includePadding); + + /** + * Returns the padding of a view on a specific axis and using a specific gravity. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param gravity + * The gravity as an {@link Integer} value. The gravity must be + * Gravity.START or Gravity.END + * @param view + * The view, whose padding should be returned, as an instance of the class {@link View}. + * The view may not be null + * @return The padding of the given view on the given axis and using the given gravity as an + * {@link Integer} value + */ + int getPadding(@NonNull Axis axis, int gravity, @NonNull View view); + + /** + * Returns the scale of a view, depending on its margin. + * + * @param view + * The view, whose scale should be returned, as an instance of the class {@link View}. + * The view may not be null + * @param includePadding + * True, if the view's padding should be taken into account as well, false otherwise + * @return The scale of the given view as a {@link Float} value + */ + float getScale(@NonNull final View view, final boolean includePadding); + + /** + * Sets the scale of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose scale should be set, as an instance of the class {@link View}. The + * view may not be null + * @param scale + * The scale, which should be set, as a {@link Float} value + */ + void setScale(@NonNull Axis axis, @NonNull View view, float scale); + + /** + * Animates the scale of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param animator + * The animator, which should be used to animate the scale, as an instance of the class + * {@link ViewPropertyAnimator}. The animator may not be null + * @param scale + * The scale, which should be set by the animation, as a {@link Float} value + */ + void animateScale(@NonNull Axis axis, @NonNull ViewPropertyAnimator animator, float scale); + + /** + * Returns the size of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose size should be returned, as an instance of the class {@link View}. + * The view may not be null + * @return The size of the given view on the given axis as a {@link Float} value + */ + float getSize(@NonNull Axis axis, @NonNull View view); + + /** + * Returns the size of the container, which contains the tab switcher's tabs, on a specific + * axis. By default, the padding and the size of the toolbars are included. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @return The size of the container, which contains the tab switcher's tabs, on the given axis + * as a {@link Float} value + */ + float getTabContainerSize(@NonNull Axis axis); + + /** + * Returns the size of the container, which contains the tab switcher's tabs, on a specific + * axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param includePadding + * True, if the padding and the size of the toolbars should be included, false + * otherwise + * @return The size of the container, which contains the tab switcher's tabs, on the given axis + * as a {@link Float} value + */ + float getTabContainerSize(@NonNull Axis axis, boolean includePadding); + + /** + * Returns the pivot of a view on a specific axis, depending on the current drag state. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose pivot should be returned, as an instance of the class {@link View}. + * The view may not be null + * @param dragState + * The current drag state as a value of the enum {@link DragState}. The drag state may + * not be null + * @return The pivot of the given view on the given axis as a {@link Float} value + */ + float getPivot(@NonNull Axis axis, @NonNull View view, @NonNull DragState dragState); + + /** + * Sets the pivot of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose pivot should be set, as an instance of the class {@link View}. The + * view may not be null + * @param pivot + * The pivot, which should be set, as a {@link Float} value + */ + void setPivot(@NonNull Axis axis, @NonNull View view, float pivot); + + /** + * Returns the rotation of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose rotation should be returned, as an instance of the class {@link + * View}. The view may not be null + * @return The rotation of the given view on the given axis as a {@link Float} value + */ + float getRotation(@NonNull Axis axis, @NonNull View view); + + /** + * Sets the rotation of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose rotation should be set, as an instance of the class {@link View}. The + * view may not be null + * @param angle + * The rotation, which should be set, as a {@link Float} value + */ + void setRotation(@NonNull Axis axis, @NonNull View view, float angle); + + /** + * Animates the rotation of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param animator + * The animator, should be used to animate the rotation, as an instance of the class + * {@link ViewPropertyAnimator}. The animator may not be null + * @param angle + * The rotation, which should be set by the animation, as a {@link Float} value + */ + void animateRotation(@NonNull Axis axis, @NonNull ViewPropertyAnimator animator, float angle); + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/ChildRecyclerAdapter.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/ChildRecyclerAdapter.java new file mode 100755 index 0000000..9a6b3f5 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/ChildRecyclerAdapter.java @@ -0,0 +1,137 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout; + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import de.mrapp.android.tabswitcher.Tab; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.TabSwitcherDecorator; +import de.mrapp.android.tabswitcher.model.Restorable; +import de.mrapp.android.util.view.AbstractViewRecycler; + +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * A view recycler adapter, which allows to inflate the views, which are used to visualize the child + * views of the tabs of a {@link TabSwitcher}, by encapsulating a {@link TabSwitcherDecorator}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class ChildRecyclerAdapter extends AbstractViewRecycler.Adapter + implements Restorable { + + /** + * The name of the extra, which is used to store the saved instance states of previously removed + * child views within a bundle. + */ + private static final String SAVED_INSTANCE_STATES_EXTRA = + ChildRecyclerAdapter.class.getName() + "::SavedInstanceStates"; + + /** + * The tab switcher, which contains the tabs, the child views, which are inflated by the + * adapter, correspond to. + */ + private final TabSwitcher tabSwitcher; + + /** + * The decorator, which is used to inflate the child views. + */ + private final TabSwitcherDecorator decorator; + + /** + * A sparse array, which manages the saved instance states of previously removed child views. + */ + private SparseArray savedInstanceStates; + + /** + * Creates a new view recycler adapter, which allows to inflate the views, which are used to + * visualize the child views of the tabs of a {@link TabSwitcher}, by encapsulating a {@link + * TabSwitcherDecorator}. + * + * @param tabSwitcher + * The tab switcher, which contains the tabs, the child views, which are inflated by the + * adapter, correspond to, as an instance of the class {@link TabSwitcher}. The tab + * switcher may not be null + * @param decorator + * The decorator, which should be used to inflate the child views, as an instance of the + * class {@link TabSwitcherDecorator}. The decorator may not be null + */ + public ChildRecyclerAdapter(@NonNull final TabSwitcher tabSwitcher, + @NonNull final TabSwitcherDecorator decorator) { + ensureNotNull(tabSwitcher, "The tab switcher may not be null"); + ensureNotNull(decorator, "The decorator may not be null"); + this.tabSwitcher = tabSwitcher; + this.decorator = decorator; + this.savedInstanceStates = new SparseArray<>(); + } + + @NonNull + @Override + public final View onInflateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup parent, @NonNull final Tab item, + final int viewType, @NonNull final Void... params) { + int index = tabSwitcher.indexOf(item); + return decorator.inflateView(inflater, parent, item, index); + } + + @Override + public final void onShowView(@NonNull final Context context, @NonNull final View view, + @NonNull final Tab item, final boolean inflated, + @NonNull final Void... params) { + int index = tabSwitcher.indexOf(item); + Bundle savedInstanceState = savedInstanceStates.get(item.hashCode()); + decorator.applyDecorator(context, tabSwitcher, view, item, index, savedInstanceState); + } + + @Override + public final void onRemoveView(@NonNull final View view, @NonNull final Tab item) { + int index = tabSwitcher.indexOf(item); + Bundle outState = decorator.saveInstanceState(view, item, index); + savedInstanceStates.put(item.hashCode(), outState); + } + + @Override + public final int getViewTypeCount() { + return decorator.getViewTypeCount(); + } + + @Override + public final int getViewType(@NonNull final Tab item) { + int index = tabSwitcher.indexOf(item); + return decorator.getViewType(item, index); + } + + @Override + public final void saveInstanceState(@NonNull final Bundle outState) { + outState.putSparseParcelableArray(SAVED_INSTANCE_STATES_EXTRA, savedInstanceStates); + } + + @Override + public final void restoreInstanceState(@Nullable final Bundle savedInstanceState) { + if (savedInstanceState != null) { + savedInstanceStates = + savedInstanceState.getSparseParcelableArray(SAVED_INSTANCE_STATES_EXTRA); + } + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/TabSwitcherLayout.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/TabSwitcherLayout.java new file mode 100755 index 0000000..ac33396 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/TabSwitcherLayout.java @@ -0,0 +1,70 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout; + +import android.support.annotation.Nullable; +import android.support.v7.widget.Toolbar; +import android.view.Menu; +import android.view.ViewGroup; + +import de.mrapp.android.tabswitcher.TabSwitcher; + +/** + * Defines the interface, a layout, which implements the functionality of a {@link TabSwitcher}, + * must implement. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public interface TabSwitcherLayout { + + /** + * Returns, whether an animation is currently running, or not. + * + * @return True, if an animation is currently running, false otherwise + */ + boolean isAnimationRunning(); + + /** + * Returns the view group, which contains the tab switcher's tabs. + * + * @return The view group, which contains the tab switcher's tabs, as an instance of the class + * {@link ViewGroup} or null, if the view has not been laid out yet + */ + @Nullable + ViewGroup getTabContainer(); + + /** + * Returns the toolbars, which are shown, when the tab switcher is shown. When using the + * smartphone layout, only one toolbar is shown. When using the tablet layout, a primary and + * secondary toolbar is shown. In such case, the first index of the returned array corresponds + * to the primary toolbar. + * + * @return An array, which contains the toolbars, which are shown, when the tab switcher is + * shown, as an array of the type Toolbar or null, if the view has not been laid out yet + */ + @Nullable + Toolbar[] getToolbars(); + + /** + * Returns the menu of the toolbar, which is shown, when the tab switcher is shown. When using + * the tablet layout, the menu corresponds to the secondary toolbar. + * + * @return The menu of the toolbar as an instance of the type {@link Menu} or null, if the view + * has not been laid out yet + */ + @Nullable + Menu getToolbarMenu(); + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneArithmetics.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneArithmetics.java new file mode 100755 index 0000000..a7ea2ad --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneArithmetics.java @@ -0,0 +1,439 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout.phone; + +import android.content.res.Resources; +import android.support.annotation.NonNull; +import android.support.v7.widget.Toolbar; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewPropertyAnimator; +import android.widget.FrameLayout; + +import de.mrapp.android.tabswitcher.Layout; +import de.mrapp.android.tabswitcher.R; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.layout.AbstractDragHandler.DragState; +import de.mrapp.android.tabswitcher.layout.Arithmetics; + +import static de.mrapp.android.util.Condition.ensureNotNull; +import static de.mrapp.android.util.Condition.ensureTrue; + +/** + * Provides methods, which allow to calculate the position, size and rotation of a {@link + * TabSwitcher}'s children, when using the smartphone layout. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class PhoneArithmetics implements Arithmetics { + + /** + * The tab switcher, the arithmetics are calculated for. + */ + private final TabSwitcher tabSwitcher; + + /** + * The height of a tab's title container in pixels. + */ + private final int tabTitleContainerHeight; + + /** + * The inset of tabs in pixels. + */ + private final int tabInset; + + /** + * The number of tabs, which are contained by a stack. + */ + private final int stackedTabCount; + + /** + * The space between tabs, which are part of a stack, in pixels. + */ + private final float stackedTabSpacing; + + /** + * The pivot when overshooting at the end. + */ + private final float endOvershootPivot; + + /** + * Modifies a specific axis depending on the orientation of the tab switcher. + * + * @param axis + * The original axis as a value of the enum {@link Axis}. The axis may not be null + * @return The orientation invariant axis as a value of the enum {@link Axis}. The orientation + * invariant axis may not be null + */ + @NonNull + private Axis getOrientationInvariantAxis(@NonNull final Axis axis) { + if (axis == Axis.Y_AXIS) { + return Axis.DRAGGING_AXIS; + } else if (axis == Axis.X_AXIS) { + return Axis.ORTHOGONAL_AXIS; + } else if (tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE) { + return axis == Axis.DRAGGING_AXIS ? Axis.ORTHOGONAL_AXIS : Axis.DRAGGING_AXIS; + } else { + return axis; + } + } + + /** + * Returns the default pivot of a view on a specific axis. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose pivot should be returned, as an instance of the class {@link View}. + * The view may not be null + * @return The pivot of the given view on the given axis as a {@link Float} value + */ + private float getDefaultPivot(@NonNull final Axis axis, @NonNull final View view) { + if (axis == Axis.DRAGGING_AXIS || axis == Axis.Y_AXIS) { + return tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? getSize(axis, view) / 2f : 0; + } else { + return tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? 0 : getSize(axis, view) / 2f; + } + } + + /** + * Returns the pivot of a view on a specific axis, when it is swiped. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose pivot should be returned, as an instance of the class {@link View}. + * The view may not be null + * @return The pivot of the given view on the given axis as a {@link Float} value + */ + private float getPivotWhenSwiping(@NonNull final Axis axis, @NonNull final View view) { + if (axis == Axis.DRAGGING_AXIS || axis == Axis.Y_AXIS) { + return endOvershootPivot; + } else { + return getDefaultPivot(axis, view); + } + } + + /** + * Returns the pivot of a view on a specific axis, when overshooting at the start. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose pivot should be returned, as an instance of the class {@link View}. + * The view may not be null + * @return The pivot of the given view on the given axis as a {@link Float} value + */ + private float getPivotWhenOvershootingAtStart(@NonNull final Axis axis, + @NonNull final View view) { + return getSize(axis, view) / 2f; + } + + /** + * Returns the pivot of a view on a specific axis, when overshooting at the end. + * + * @param axis + * The axis as a value of the enum {@link Axis}. The axis may not be null + * @param view + * The view, whose pivot should be returned, as an instance of the class {@link View}. + * The view may not be null + * @return The pivot of the given view on the given axis as a {@link Float} value + */ + private float getPivotWhenOvershootingAtEnd(@NonNull final Axis axis, + @NonNull final View view) { + if (axis == Axis.DRAGGING_AXIS || axis == Axis.Y_AXIS) { + return tabSwitcher.getCount() > 1 ? endOvershootPivot : getSize(axis, view) / 2f; + } else { + return getSize(axis, view) / 2f; + } + } + + /** + * Creates a new class, which provides methods, which allow to calculate the position, size and + * rotation of a {@link TabSwitcher}'s children. + * + * @param tabSwitcher + * The tab switcher, the arithmetics should be calculated for, as an instance of the + * class {@link TabSwitcher}. The tab switcher may not be null + */ + public PhoneArithmetics(@NonNull final TabSwitcher tabSwitcher) { + ensureNotNull(tabSwitcher, "The tab switcher may not be null"); + this.tabSwitcher = tabSwitcher; + Resources resources = tabSwitcher.getResources(); + this.tabTitleContainerHeight = + resources.getDimensionPixelSize(R.dimen.tab_title_container_height); + this.tabInset = resources.getDimensionPixelSize(R.dimen.tab_inset); + this.stackedTabCount = resources.getInteger(R.integer.stacked_tab_count); + this.stackedTabSpacing = resources.getDimensionPixelSize(R.dimen.stacked_tab_spacing); + this.endOvershootPivot = resources.getDimensionPixelSize(R.dimen.end_overshoot_pivot); + } + + @Override + public final float getPosition(@NonNull final Axis axis, @NonNull final MotionEvent event) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(event, "The motion event may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + return event.getY(); + } else { + return event.getX(); + } + } + + @Override + public final float getPosition(@NonNull final Axis axis, @NonNull final View view) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(view, "The view may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + Toolbar[] toolbars = tabSwitcher.getToolbars(); + return view.getY() - (tabSwitcher.areToolbarsShown() && tabSwitcher.isSwitcherShown() && + toolbars != null ? toolbars[0].getHeight() - tabInset : 0) - + getPadding(axis, Gravity.START, tabSwitcher); + } else { + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) view.getLayoutParams(); + return view.getX() - layoutParams.leftMargin - tabSwitcher.getPaddingLeft() / 2f + + tabSwitcher.getPaddingRight() / 2f + + (tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE && + tabSwitcher.isSwitcherShown() ? + stackedTabCount * stackedTabSpacing / 2f : 0); + } + } + + @Override + public final void setPosition(@NonNull final Axis axis, @NonNull final View view, + final float position) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(view, "The view may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + Toolbar[] toolbars = tabSwitcher.getToolbars(); + view.setY((tabSwitcher.areToolbarsShown() && tabSwitcher.isSwitcherShown() && + toolbars != null ? toolbars[0].getHeight() - tabInset : 0) + + getPadding(axis, Gravity.START, tabSwitcher) + position); + } else { + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) view.getLayoutParams(); + view.setX(position + layoutParams.leftMargin + tabSwitcher.getPaddingLeft() / 2f - + tabSwitcher.getPaddingRight() / 2f - + (tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE && + tabSwitcher.isSwitcherShown() ? + stackedTabCount * stackedTabSpacing / 2f : 0)); + } + } + + @Override + public final void animatePosition(@NonNull final Axis axis, + @NonNull final ViewPropertyAnimator animator, + @NonNull final View view, final float position, + final boolean includePadding) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(animator, "The animator may not be null"); + ensureNotNull(view, "The view may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + Toolbar[] toolbars = tabSwitcher.getToolbars(); + animator.y((tabSwitcher.areToolbarsShown() && tabSwitcher.isSwitcherShown() && + toolbars != null ? toolbars[0].getHeight() - tabInset : 0) + + (includePadding ? getPadding(axis, Gravity.START, tabSwitcher) : 0) + position); + } else { + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) view.getLayoutParams(); + animator.x(position + layoutParams.leftMargin + (includePadding ? + tabSwitcher.getPaddingLeft() / 2f - tabSwitcher.getPaddingRight() / 2f : 0) - + (tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE && + tabSwitcher.isSwitcherShown() ? + stackedTabCount * stackedTabSpacing / 2f : 0)); + } + } + + @Override + public final int getPadding(@NonNull final Axis axis, final int gravity, + @NonNull final View view) { + ensureNotNull(axis, "The axis may not be null"); + ensureTrue(gravity == Gravity.START || gravity == Gravity.END, "Invalid gravity"); + ensureNotNull(view, "The view may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + return gravity == Gravity.START ? view.getPaddingTop() : view.getPaddingBottom(); + } else { + return gravity == Gravity.START ? view.getPaddingLeft() : view.getPaddingRight(); + } + } + + @Override + public final float getScale(@NonNull final View view, final boolean includePadding) { + ensureNotNull(view, "The view may not be null"); + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams(); + float width = view.getWidth(); + float targetWidth = width + layoutParams.leftMargin + layoutParams.rightMargin - + (includePadding ? tabSwitcher.getPaddingLeft() + tabSwitcher.getPaddingRight() : + 0) - (tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? + stackedTabCount * stackedTabSpacing : 0); + return targetWidth / width; + } + + @Override + public final void setScale(@NonNull final Axis axis, @NonNull final View view, + final float scale) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(view, "The view may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + view.setScaleY(scale); + } else { + view.setScaleX(scale); + } + } + + @Override + public final void animateScale(@NonNull final Axis axis, + @NonNull final ViewPropertyAnimator animator, + final float scale) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(animator, "The animator may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + animator.scaleY(scale); + } else { + animator.scaleX(scale); + } + } + + @Override + public final float getSize(@NonNull final Axis axis, @NonNull final View view) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(view, "The view may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + return view.getHeight() * getScale(view, false); + } else { + return view.getWidth() * getScale(view, false); + } + } + + @Override + public final float getTabContainerSize(@NonNull final Axis axis) { + return getTabContainerSize(axis, true); + } + + @Override + public final float getTabContainerSize(@NonNull final Axis axis, final boolean includePadding) { + ensureNotNull(axis, "The axis may not be null"); + ViewGroup tabContainer = tabSwitcher.getTabContainer(); + assert tabContainer != null; + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) tabContainer.getLayoutParams(); + int padding = !includePadding ? (getPadding(axis, Gravity.START, tabSwitcher) + + getPadding(axis, Gravity.END, tabSwitcher)) : 0; + Toolbar[] toolbars = tabSwitcher.getToolbars(); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + int toolbarSize = + !includePadding && tabSwitcher.areToolbarsShown() && toolbars != null ? + toolbars[0].getHeight() - tabInset : 0; + return tabContainer.getHeight() - layoutParams.topMargin - layoutParams.bottomMargin - + padding - toolbarSize; + } else { + return tabContainer.getWidth() - layoutParams.leftMargin - layoutParams.rightMargin - + padding; + } + } + + @Override + public final float getPivot(@NonNull final Axis axis, @NonNull final View view, + @NonNull final DragState dragState) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(view, "The view may not be null"); + ensureNotNull(dragState, "The drag state may not be null"); + + if (dragState == DragState.SWIPE) { + return getPivotWhenSwiping(axis, view); + } else if (dragState == DragState.OVERSHOOT_START) { + return getPivotWhenOvershootingAtStart(axis, view); + } else if (dragState == DragState.OVERSHOOT_END) { + return getPivotWhenOvershootingAtEnd(axis, view); + } else { + return getDefaultPivot(axis, view); + } + } + + @Override + public final void setPivot(@NonNull final Axis axis, @NonNull final View view, + final float pivot) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(view, "The view may not be null"); + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams(); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + float newPivot = pivot - layoutParams.topMargin - tabTitleContainerHeight; + view.setTranslationY(view.getTranslationY() + + (view.getPivotY() - newPivot) * (1 - view.getScaleY())); + view.setPivotY(newPivot); + } else { + float newPivot = pivot - layoutParams.leftMargin; + view.setTranslationX(view.getTranslationX() + + (view.getPivotX() - newPivot) * (1 - view.getScaleX())); + view.setPivotX(newPivot); + } + } + + @Override + public final float getRotation(@NonNull final Axis axis, @NonNull final View view) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(view, "The view may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + return view.getRotationY(); + } else { + return view.getRotationX(); + } + } + + @Override + public final void setRotation(@NonNull final Axis axis, @NonNull final View view, + final float angle) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(view, "The view may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + view.setRotationY( + tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? -1 * angle : angle); + } else { + view.setRotationX( + tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? -1 * angle : angle); + } + } + + @Override + public final void animateRotation(@NonNull final Axis axis, + @NonNull final ViewPropertyAnimator animator, + final float angle) { + ensureNotNull(axis, "The axis may not be null"); + ensureNotNull(animator, "The animator may not be null"); + + if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) { + animator.rotationY( + tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? -1 * angle : angle); + } else { + animator.rotationX( + tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? -1 * angle : angle); + } + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneDragHandler.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneDragHandler.java new file mode 100755 index 0000000..e4532d6 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneDragHandler.java @@ -0,0 +1,288 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout.phone; + +import android.content.res.Resources; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.Toolbar; +import android.view.Gravity; +import android.view.View; + +import de.mrapp.android.tabswitcher.Layout; +import de.mrapp.android.tabswitcher.R; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.iterator.AbstractTabItemIterator; +import de.mrapp.android.tabswitcher.iterator.TabItemIterator; +import de.mrapp.android.tabswitcher.layout.AbstractDragHandler; +import de.mrapp.android.tabswitcher.layout.Arithmetics; +import de.mrapp.android.tabswitcher.layout.Arithmetics.Axis; +import de.mrapp.android.tabswitcher.model.State; +import de.mrapp.android.tabswitcher.model.TabItem; +import de.mrapp.android.util.gesture.DragHelper; +import de.mrapp.android.util.view.AttachedViewRecycler; + +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * A drag handler, which allows to calculate the position and state of tabs on touch events, when + * using the smartphone layout. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class PhoneDragHandler extends AbstractDragHandler { + + /** + * Defines the interface, a class, which should be notified about the events of a drag handler, + * must implement. + */ + public interface Callback extends AbstractDragHandler.Callback { + + /** + * The method, which is invoked, when tabs are overshooting at the start. + * + * @param position + * The position of the first tab in pixels as a {@link Float} value + */ + void onStartOvershoot(float position); + + /** + * The method, which is invoked, when the tabs should be tilted when overshooting at the + * start. + * + * @param angle + * The angle, the tabs should be tilted by, in degrees as a {@link Float} value + */ + void onTiltOnStartOvershoot(float angle); + + /** + * The method, which is invoked, when the tabs should be tilted when overshooting at the + * end. + * + * @param angle + * The angle, the tabs should be tilted by, in degrees as a {@link Float} value + */ + void onTiltOnEndOvershoot(float angle); + + } + + /** + * The view recycler, which allows to inflate the views, which are used to visualize the tabs, + * whose positions and states are calculated by the drag handler. + */ + private final AttachedViewRecycler viewRecycler; + + /** + * The drag helper, which is used to recognize drag gestures when overshooting. + */ + private final DragHelper overshootDragHelper; + + /** + * The maximum overshoot distance in pixels. + */ + private final int maxOvershootDistance; + + /** + * The maximum angle, tabs can be rotated by, when overshooting at the start, in degrees. + */ + private final float maxStartOvershootAngle; + + /** + * The maximum angle, tabs can be rotated by, when overshooting at the end, in degrees. + */ + private final float maxEndOvershootAngle; + + /** + * The number of tabs, which are contained by a stack. + */ + private final int stackedTabCount; + + /** + * The inset of tabs in pixels. + */ + private final int tabInset; + + /** + * Notifies the callback, that tabs are overshooting at the start. + * + * @param position + * The position of the first tab in pixels as a {@link Float} value + */ + private void notifyOnStartOvershoot(final float position) { + if (getCallback() != null) { + getCallback().onStartOvershoot(position); + } + } + + /** + * Notifies the callback, that the tabs should be tilted when overshooting at the start. + * + * @param angle + * The angle, the tabs should be tilted by, in degrees as a {@link Float} value + */ + private void notifyOnTiltOnStartOvershoot(final float angle) { + if (getCallback() != null) { + getCallback().onTiltOnStartOvershoot(angle); + } + } + + /** + * Notifies the callback, that the tabs should be titled when overshooting at the end. + * + * @param angle + * The angle, the tabs should be tilted by, in degrees as a {@link Float} value + */ + private void notifyOnTiltOnEndOvershoot(final float angle) { + if (getCallback() != null) { + getCallback().onTiltOnEndOvershoot(angle); + } + } + + /** + * Creates a new drag handler, which allows to calculate the position and state of tabs on touch + * events, when using the smartphone layout. + * + * @param tabSwitcher + * The tab switcher, whose tabs' positions and states should be calculated by the drag + * handler, as an instance of the class {@link TabSwitcher}. The tab switcher may not be + * null + * @param arithmetics + * The arithmetics, which should be used to calculate the position, size and rotation of + * tabs, as an instance of the type {@link Arithmetics}. The arithmetics may not be + * null + * @param viewRecycler + * The view recycler, which allows to inflate the views, which are used to visualize the + * tabs, whose positions and states should be calculated by the tab switcher, as an + * instance of the class AttachedViewRecycler. The view recycler may not be null + */ + public PhoneDragHandler(@NonNull final TabSwitcher tabSwitcher, + @NonNull final Arithmetics arithmetics, + @NonNull final AttachedViewRecycler viewRecycler) { + super(tabSwitcher, arithmetics, true); + ensureNotNull(viewRecycler, "The view recycler may not be null"); + this.viewRecycler = viewRecycler; + this.overshootDragHelper = new DragHelper(0); + Resources resources = tabSwitcher.getResources(); + this.tabInset = resources.getDimensionPixelSize(R.dimen.tab_inset); + this.stackedTabCount = resources.getInteger(R.integer.stacked_tab_count); + this.maxOvershootDistance = resources.getDimensionPixelSize(R.dimen.max_overshoot_distance); + this.maxStartOvershootAngle = resources.getInteger(R.integer.max_start_overshoot_angle); + this.maxEndOvershootAngle = resources.getInteger(R.integer.max_end_overshoot_angle); + } + + @Override + @Nullable + protected final TabItem getFocusedTab(final float position) { + AbstractTabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.getTag().getState() == State.FLOATING || + tabItem.getTag().getState() == State.STACKED_START_ATOP) { + View view = tabItem.getView(); + Toolbar[] toolbars = getTabSwitcher().getToolbars(); + float toolbarHeight = getTabSwitcher().getLayout() != Layout.PHONE_LANDSCAPE && + getTabSwitcher().areToolbarsShown() && toolbars != null ? + toolbars[0].getHeight() - tabInset : 0; + float viewPosition = + getArithmetics().getPosition(Axis.DRAGGING_AXIS, view) + toolbarHeight + + getArithmetics().getPadding(Axis.DRAGGING_AXIS, Gravity.START, + getTabSwitcher()); + + if (viewPosition <= position) { + return tabItem; + } + } + } + + return null; + } + + @Override + protected final float onOvershootStart(final float dragPosition, + final float overshootThreshold) { + float result = overshootThreshold; + overshootDragHelper.update(dragPosition); + float overshootDistance = overshootDragHelper.getDragDistance(); + + if (overshootDistance < 0) { + float absOvershootDistance = Math.abs(overshootDistance); + float startOvershootDistance = + getTabSwitcher().getCount() >= stackedTabCount ? maxOvershootDistance : + (getTabSwitcher().getCount() > 1 ? (float) maxOvershootDistance / + (float) getTabSwitcher().getCount() : 0); + + if (absOvershootDistance <= startOvershootDistance) { + float ratio = + Math.max(0, Math.min(1, absOvershootDistance / startOvershootDistance)); + AbstractTabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create(); + TabItem tabItem = iterator.getItem(0); + float currentPosition = tabItem.getTag().getPosition(); + float position = currentPosition - (currentPosition * ratio); + notifyOnStartOvershoot(position); + } else { + float ratio = + (absOvershootDistance - startOvershootDistance) / maxOvershootDistance; + + if (ratio >= 1) { + overshootDragHelper.setMinDragDistance(overshootDistance); + result = dragPosition + maxOvershootDistance + startOvershootDistance; + } + + notifyOnTiltOnStartOvershoot( + Math.max(0, Math.min(1, ratio)) * maxStartOvershootAngle); + } + } + + return result; + } + + @Override + protected final float onOvershootEnd(final float dragPosition, final float overshootThreshold) { + float result = overshootThreshold; + overshootDragHelper.update(dragPosition); + float overshootDistance = overshootDragHelper.getDragDistance(); + float ratio = overshootDistance / maxOvershootDistance; + + if (ratio >= 1) { + overshootDragHelper.setMaxDragDistance(overshootDistance); + result = dragPosition - maxOvershootDistance; + } + + notifyOnTiltOnEndOvershoot(Math.max(0, Math.min(1, ratio)) * + -(getTabSwitcher().getCount() > 1 ? maxEndOvershootAngle : maxStartOvershootAngle)); + return result; + } + + @Override + protected final void onOvershootReverted() { + overshootDragHelper.reset(); + } + + @Override + protected final void onReset() { + overshootDragHelper.reset(); + } + + @Override + protected final boolean isSwipeThresholdReached(@NonNull final TabItem swipedTabItem) { + View view = swipedTabItem.getView(); + return Math.abs(getArithmetics().getPosition(Axis.ORTHOGONAL_AXIS, view)) > + getArithmetics().getTabContainerSize(Axis.ORTHOGONAL_AXIS) / 6f; + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneRecyclerAdapter.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneRecyclerAdapter.java new file mode 100755 index 0000000..7829166 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneRecyclerAdapter.java @@ -0,0 +1,836 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout.phone; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.PorterDuff; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.support.annotation.MenuRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +import android.support.v4.util.Pair; +import android.support.v7.widget.Toolbar.OnMenuItemClickListener; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.FrameLayout.LayoutParams; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import de.mrapp.android.tabswitcher.Animation; +import de.mrapp.android.tabswitcher.R; +import de.mrapp.android.tabswitcher.Tab; +import de.mrapp.android.tabswitcher.TabCloseListener; +import de.mrapp.android.tabswitcher.TabPreviewListener; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.TabSwitcherDecorator; +import de.mrapp.android.tabswitcher.iterator.AbstractTabItemIterator; +import de.mrapp.android.tabswitcher.iterator.TabItemIterator; +import de.mrapp.android.tabswitcher.model.Model; +import de.mrapp.android.tabswitcher.model.TabItem; +import de.mrapp.android.tabswitcher.model.TabSwitcherModel; +import de.mrapp.android.util.ViewUtil; +import de.mrapp.android.util.logging.LogLevel; +import de.mrapp.android.util.multithreading.AbstractDataBinder; +import de.mrapp.android.util.view.AbstractViewRecycler; +import de.mrapp.android.util.view.AttachedViewRecycler; +import de.mrapp.android.util.view.ViewRecycler; + +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * A view recycler adapter, which allows to inflate the views, which are used to visualize the tabs + * of a {@link TabSwitcher}, when using the smartphone layout. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class PhoneRecyclerAdapter extends AbstractViewRecycler.Adapter + implements Tab.Callback, Model.Listener, + AbstractDataBinder.Listener { + + /** + * The tab switcher, the tabs belong to. + */ + private final TabSwitcher tabSwitcher; + + /** + * The model, which belongs to the tab switcher. + */ + private final TabSwitcherModel model; + + /** + * The view recycler, which allows to inflate the child views of tabs. + */ + private final ViewRecycler childViewRecycler; + + /** + * The data binder, which allows to render previews of tabs. + */ + private final AbstractDataBinder dataBinder; + + /** + * The inset of tabs in pixels. + */ + private final int tabInset; + + /** + * The width of the border, which is shown around the preview of tabs, in pixels. + */ + private final int tabBorderWidth; + + /** + * The height of the view group, which contains a tab's title and close button, in pixels. + */ + private final int tabTitleContainerHeight; + + /** + * The default background color of tabs. + */ + private final int tabBackgroundColor; + + /** + * The default text color of a tab's title. + */ + private final int tabTitleTextColor; + + /** + * The view recycler, the adapter is bound to. + */ + private AttachedViewRecycler viewRecycler; + + /** + * Inflates the child view of a tab and adds it to the view hierarchy. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose child view should be inflated, as + * an instance of the class {@link TabItem}. The tab item may not be null + */ + private void addChildView(@NonNull final TabItem tabItem) { + PhoneTabViewHolder viewHolder = tabItem.getViewHolder(); + View view = viewHolder.child; + Tab tab = tabItem.getTab(); + + if (view == null) { + ViewGroup parent = viewHolder.childContainer; + Pair pair = childViewRecycler.inflate(tab, parent); + view = pair.first; + LayoutParams layoutParams = + new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + layoutParams.setMargins(model.getPaddingLeft(), model.getPaddingTop(), + model.getPaddingRight(), model.getPaddingBottom()); + parent.addView(view, 0, layoutParams); + viewHolder.child = view; + } else { + childViewRecycler.getAdapter().onShowView(model.getContext(), view, tab, false); + } + + viewHolder.previewImageView.setVisibility(View.GONE); + viewHolder.previewImageView.setImageBitmap(null); + viewHolder.borderView.setVisibility(View.GONE); + } + + /** + * Renders and displays the child view of a tab. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose preview should be rendered, as an + * instance of the class {@link TabItem}. The tab item may not be null + */ + private void renderChildView(@NonNull final TabItem tabItem) { + Tab tab = tabItem.getTab(); + PhoneTabViewHolder viewHolder = tabItem.getViewHolder(); + viewHolder.borderView.setVisibility(View.VISIBLE); + + if (viewHolder.child != null) { + childViewRecycler.getAdapter().onRemoveView(viewHolder.child, tab); + dataBinder.load(tab, viewHolder.previewImageView, false, tabItem); + removeChildView(viewHolder, tab); + } else { + dataBinder.load(tab, viewHolder.previewImageView, tabItem); + } + } + + /** + * Removes the child of a tab from its parent. + * + * @param viewHolder + * The view holder, which stores references to the tab's views, as an instance of the + * class {@link PhoneTabViewHolder}. The view holder may not be null + * @param tab + * The tab, whose child should be removed, as an instance of the class {@link Tab}. The + * tab may not be null + */ + private void removeChildView(@NonNull final PhoneTabViewHolder viewHolder, + @NonNull final Tab tab) { + if (viewHolder.childContainer.getChildCount() > 2) { + viewHolder.childContainer.removeViewAt(0); + } + + viewHolder.child = null; + childViewRecycler.remove(tab); + } + + /** + * Adapts the log level. + */ + private void adaptLogLevel() { + dataBinder.setLogLevel(model.getLogLevel()); + } + + /** + * Adapts the title of a tab. + * + * @param viewHolder + * The view holder, which stores references to the tab's views, as an instance of the + * class {@link PhoneTabViewHolder}. The view holder may not be null + * @param tab + * The tab, whose title should be adapted, as an instance of the class {@link Tab}. The + * tab may not be null + */ + private void adaptTitle(@NonNull final PhoneTabViewHolder viewHolder, @NonNull final Tab tab) { + viewHolder.titleTextView.setText(tab.getTitle()); + } + + /** + * Adapts the icon of a tab. + * + * @param viewHolder + * The view holder, which stores references to the tab's views, as an instance of the + * class {@link PhoneTabViewHolder}. The view holder may not be null + * @param tab + * The icon, whose icon should be adapted, as an instance of the class {@link Tab}. The + * tab may not be null + */ + private void adaptIcon(@NonNull final PhoneTabViewHolder viewHolder, @NonNull final Tab tab) { + Drawable icon = tab.getIcon(model.getContext()); + viewHolder.titleTextView + .setCompoundDrawablesWithIntrinsicBounds(icon != null ? icon : model.getTabIcon(), + null, null, null); + } + + /** + * Adapts the visibility of a tab's close button. + * + * @param viewHolder + * The view holder, which stores references to the tab's views, as an instance of the + * class {@link PhoneTabViewHolder}. The view holder may not be null + * @param tab + * The icon, whose close button should be adapted, as an instance of the class {@link + * Tab}. The tab may not be null + */ + private void adaptCloseButton(@NonNull final PhoneTabViewHolder viewHolder, + @NonNull final Tab tab) { + viewHolder.closeButton.setVisibility(tab.isCloseable() ? View.VISIBLE : View.GONE); + viewHolder.closeButton.setOnClickListener( + tab.isCloseable() ? createCloseButtonClickListener(viewHolder.closeButton, tab) : + null); + } + + /** + * Adapts the icon of a tab's close button. + * + * @param viewHolder + * The view holder, which stores references to the tab's views, as an instance of the + * class {@link PhoneTabViewHolder}. The view holder may not be null + * @param tab + * The icon, whose icon hould be adapted, as an instance of the class {@link Tab}. The + * tab may not be null + */ + private void adaptCloseButtonIcon(@NonNull final PhoneTabViewHolder viewHolder, + @NonNull final Tab tab) { + Drawable icon = tab.getCloseButtonIcon(model.getContext()); + + if (icon == null) { + icon = model.getTabCloseButtonIcon(); + } + + if (icon != null) { + viewHolder.closeButton.setImageDrawable(icon); + } else { + viewHolder.closeButton.setImageResource(R.drawable.ic_close_tab_18dp); + } + } + + /** + * Creates and returns a listener, which allows to close a specific tab, when its close button + * is clicked. + * + * @param closeButton + * The tab's close button as an instance of the class {@link ImageButton}. The button + * may not be null + * @param tab + * The tab, which should be closed, as an instance of the class {@link Tab}. The tab may + * not be null + * @return The listener, which has been created, as an instance of the class {@link + * OnClickListener}. The listener may not be null + */ + @NonNull + private OnClickListener createCloseButtonClickListener(@NonNull final ImageButton closeButton, + @NonNull final Tab tab) { + return new OnClickListener() { + + @Override + public void onClick(final View v) { + if (notifyOnCloseTab(tab)) { + closeButton.setOnClickListener(null); + tabSwitcher.removeTab(tab); + } + } + + }; + } + + /** + * Notifies all listeners, that a tab is about to be closed by clicking its close button. + * + * @param tab + * The tab, which is about to be closed, as an instance of the class {@link Tab}. The + * tab may not be null + * @return True, if the tab should be closed, false otherwise + */ + private boolean notifyOnCloseTab(@NonNull final Tab tab) { + boolean result = true; + + for (TabCloseListener listener : model.getTabCloseListeners()) { + result &= listener.onCloseTab(tabSwitcher, tab); + } + + return result; + } + + /** + * Adapts the background color of a tab. + * + * @param view + * The view, which is used to visualize the tab, as an instance of the class {@link + * View}. The view may not be null + * @param viewHolder + * The view holder, which stores references to the tab's views, as an instance of the + * class {@link PhoneTabViewHolder}. The view holder may not be null + * @param tab + * The tab, whose background color should be adapted, as an instance of the class {@link + * Tab}. The tab may not be null + */ + private void adaptBackgroundColor(@NonNull final View view, + @NonNull final PhoneTabViewHolder viewHolder, + @NonNull final Tab tab) { + ColorStateList colorStateList = + tab.getBackgroundColor() != null ? tab.getBackgroundColor() : + model.getTabBackgroundColor(); + int color = tabBackgroundColor; + + if (colorStateList != null) { + int[] stateSet = + model.getSelectedTab() == tab ? new int[]{android.R.attr.state_selected} : + new int[]{}; + color = colorStateList.getColorForState(stateSet, colorStateList.getDefaultColor()); + } + + Drawable background = view.getBackground(); + background.setColorFilter(color, PorterDuff.Mode.MULTIPLY); + Drawable border = viewHolder.borderView.getBackground(); + border.setColorFilter(color, PorterDuff.Mode.MULTIPLY); + } + + /** + * Adapts the text color of a tab's title. + * + * @param viewHolder + * The view holder, which stores references to the tab's views, as an instance of the + * class {@link PhoneTabViewHolder}. The view holder may not be null + * @param tab + * The tab, whose text color should be adapted, as an instance of the class {@link Tab}. + * The tab may not be null + */ + private void adaptTitleTextColor(@NonNull final PhoneTabViewHolder viewHolder, + @NonNull final Tab tab) { + ColorStateList colorStateList = tab.getTitleTextColor() != null ? tab.getTitleTextColor() : + model.getTabTitleTextColor(); + + if (colorStateList != null) { + viewHolder.titleTextView.setTextColor(colorStateList); + } else { + viewHolder.titleTextView.setTextColor(tabTitleTextColor); + } + } + + /** + * Adapts the selection state of a tab's views. + * + * @param viewHolder + * The view holder, which stores references to the tab's views, as an instance of the + * class {@link PhoneTabViewHolder}. The view holder may not be null + * @param tab + * The tab, whose selection state should be adapted, as an instance of the class {@link + * Tab}. The tab may not be null + */ + private void adaptSelectionState(@NonNull final PhoneTabViewHolder viewHolder, + @NonNull final Tab tab) { + boolean selected = model.getSelectedTab() == tab; + viewHolder.titleTextView.setSelected(selected); + viewHolder.closeButton.setSelected(selected); + } + + /** + * Adapts the appearance of all currently inflated tabs, depending on whether they are currently + * selected, or not. + */ + private void adaptAllSelectionStates() { + AbstractTabItemIterator iterator = + new TabItemIterator.Builder(model, viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.isInflated()) { + Tab tab = tabItem.getTab(); + PhoneTabViewHolder viewHolder = tabItem.getViewHolder(); + adaptSelectionState(viewHolder, tab); + adaptBackgroundColor(tabItem.getView(), viewHolder, tab); + } + } + } + + /** + * Adapts the padding of a tab. + * + * @param viewHolder + * The view holder, which stores references to the tab's views, as an instance of the + * class {@link PhoneTabViewHolder}. The view holder may not be null + */ + private void adaptPadding(@NonNull final PhoneTabViewHolder viewHolder) { + if (viewHolder.child != null) { + LayoutParams childLayoutParams = (LayoutParams) viewHolder.child.getLayoutParams(); + childLayoutParams.setMargins(model.getPaddingLeft(), model.getPaddingTop(), + model.getPaddingRight(), model.getPaddingBottom()); + } + + LayoutParams previewLayoutParams = + (LayoutParams) viewHolder.previewImageView.getLayoutParams(); + previewLayoutParams + .setMargins(model.getPaddingLeft(), model.getPaddingTop(), model.getPaddingRight(), + model.getPaddingBottom()); + } + + /** + * Returns the tab item, which corresponds to a specific tab. + * + * @param tab + * The tab, whose tab item should be returned, as an instance of the class {@link Tab}. + * The tab may not be null + * @return The tab item, which corresponds to the given tab, as an instance of the class {@link + * TabItem} or null, if no view, which visualizes the tab, is currently inflated + */ + @Nullable + private TabItem getTabItem(@NonNull final Tab tab) { + ensureNotNull(viewRecycler, "No view recycler has been set", IllegalStateException.class); + int index = model.indexOf(tab); + + if (index != -1) { + TabItem tabItem = TabItem.create(model, viewRecycler, index); + + if (tabItem.isInflated()) { + return tabItem; + } + } + + return null; + } + + /** + * Creates a new view recycler adapter, which allows to inflate the views, which are used to + * visualize the tabs of a {@link TabSwitcher}. + * + * @param tabSwitcher + * The tab switcher as an instance of the class {@link TabSwitcher}. The tab switcher + * may not be null + * @param model + * The model, which belongs to the tab switcher, as an instance of the class {@link + * TabSwitcherModel}. The model may not be null + * @param childViewRecycler + * The view recycler, which allows to inflate the child views of tabs, as an instance of + * the class ViewRecycler. The view recycler may not be null + */ + public PhoneRecyclerAdapter(@NonNull final TabSwitcher tabSwitcher, + @NonNull final TabSwitcherModel model, + @NonNull final ViewRecycler childViewRecycler) { + ensureNotNull(tabSwitcher, "The tab switcher may not be null"); + ensureNotNull(model, "The model may not be null"); + ensureNotNull(childViewRecycler, "The child view recycler may not be null"); + this.tabSwitcher = tabSwitcher; + this.model = model; + this.childViewRecycler = childViewRecycler; + this.dataBinder = new PreviewDataBinder(tabSwitcher, childViewRecycler); + this.dataBinder.addListener(this); + Resources resources = tabSwitcher.getResources(); + this.tabInset = resources.getDimensionPixelSize(R.dimen.tab_inset); + this.tabBorderWidth = resources.getDimensionPixelSize(R.dimen.tab_border_width); + this.tabTitleContainerHeight = + resources.getDimensionPixelSize(R.dimen.tab_title_container_height); + this.tabBackgroundColor = + ContextCompat.getColor(tabSwitcher.getContext(), R.color.tab_background_color); + this.tabTitleTextColor = + ContextCompat.getColor(tabSwitcher.getContext(), R.color.tab_title_text_color); + this.viewRecycler = null; + adaptLogLevel(); + } + + /** + * Sets the view recycler, which allows to inflate the views, which are used to visualize tabs. + * + * @param viewRecycler + * The view recycler, which should be set, as an instance of the class + * AttachedViewRecycler. The view recycler may not be null + */ + public final void setViewRecycler( + @NonNull final AttachedViewRecycler viewRecycler) { + ensureNotNull(viewRecycler, "The view recycler may not be null"); + this.viewRecycler = viewRecycler; + } + + /** + * Removes all previously rendered previews from the cache. + */ + public final void clearCachedPreviews() { + dataBinder.clearCache(); + } + + @NonNull + @Override + public final View onInflateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup parent, + @NonNull final TabItem tabItem, final int viewType, + @NonNull final Integer... params) { + PhoneTabViewHolder viewHolder = new PhoneTabViewHolder(); + View view = inflater.inflate(R.layout.phone_tab, tabSwitcher.getTabContainer(), false); + Drawable backgroundDrawable = + ContextCompat.getDrawable(model.getContext(), R.drawable.phone_tab_background); + ViewUtil.setBackground(view, backgroundDrawable); + int padding = tabInset + tabBorderWidth; + view.setPadding(padding, tabInset, padding, padding); + viewHolder.titleContainer = (ViewGroup) view.findViewById(R.id.tab_title_container); + viewHolder.titleTextView = (TextView) view.findViewById(R.id.tab_title_text_view); + viewHolder.closeButton = (ImageButton) view.findViewById(R.id.close_tab_button); + viewHolder.childContainer = (ViewGroup) view.findViewById(R.id.child_container); + viewHolder.previewImageView = (ImageView) view.findViewById(R.id.preview_image_view); + adaptPadding(viewHolder); + viewHolder.borderView = view.findViewById(R.id.border_view); + Drawable borderDrawable = + ContextCompat.getDrawable(model.getContext(), R.drawable.phone_tab_border); + ViewUtil.setBackground(viewHolder.borderView, borderDrawable); + view.setTag(R.id.tag_view_holder, viewHolder); + tabItem.setView(view); + tabItem.setViewHolder(viewHolder); + view.setTag(R.id.tag_properties, tabItem.getTag()); + return view; + } + + @Override + public final void onShowView(@NonNull final Context context, @NonNull final View view, + @NonNull final TabItem tabItem, final boolean inflated, + @NonNull final Integer... params) { + PhoneTabViewHolder viewHolder = (PhoneTabViewHolder) view.getTag(R.id.tag_view_holder); + + if (!tabItem.isInflated()) { + tabItem.setView(view); + tabItem.setViewHolder(viewHolder); + view.setTag(R.id.tag_properties, tabItem.getTag()); + } + + LayoutParams layoutParams = + new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + int borderMargin = -(tabInset + tabBorderWidth); + int bottomMargin = params.length > 0 && params[0] != -1 ? params[0] : borderMargin; + layoutParams.leftMargin = borderMargin; + layoutParams.topMargin = -(tabInset + tabTitleContainerHeight); + layoutParams.rightMargin = borderMargin; + layoutParams.bottomMargin = bottomMargin; + view.setLayoutParams(layoutParams); + Tab tab = tabItem.getTab(); + tab.addCallback(this); + adaptTitle(viewHolder, tab); + adaptIcon(viewHolder, tab); + adaptCloseButton(viewHolder, tab); + adaptCloseButtonIcon(viewHolder, tab); + adaptBackgroundColor(view, viewHolder, tab); + adaptTitleTextColor(viewHolder, tab); + adaptSelectionState(viewHolder, tab); + + if (!model.isSwitcherShown()) { + if (tab == model.getSelectedTab()) { + addChildView(tabItem); + } + } else { + renderChildView(tabItem); + } + } + + @Override + public final void onRemoveView(@NonNull final View view, @NonNull final TabItem tabItem) { + PhoneTabViewHolder viewHolder = (PhoneTabViewHolder) view.getTag(R.id.tag_view_holder); + Tab tab = tabItem.getTab(); + tab.removeCallback(this); + removeChildView(viewHolder, tab); + + if (!dataBinder.isCached(tab)) { + Drawable drawable = viewHolder.previewImageView.getDrawable(); + viewHolder.previewImageView.setImageBitmap(null); + + if (drawable instanceof BitmapDrawable) { + Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap(); + + if (bitmap != null && !bitmap.isRecycled()) { + bitmap.recycle(); + } + } + } else { + viewHolder.previewImageView.setImageBitmap(null); + } + + view.setTag(R.id.tag_properties, null); + } + + @Override + public final void onTitleChanged(@NonNull final Tab tab) { + TabItem tabItem = getTabItem(tab); + + if (tabItem != null) { + adaptTitle(tabItem.getViewHolder(), tabItem.getTab()); + } + } + + @Override + public final void onIconChanged(@NonNull final Tab tab) { + TabItem tabItem = getTabItem(tab); + + if (tabItem != null) { + adaptIcon(tabItem.getViewHolder(), tabItem.getTab()); + } + } + + @Override + public final void onCloseableChanged(@NonNull final Tab tab) { + TabItem tabItem = getTabItem(tab); + + if (tabItem != null) { + adaptCloseButton(tabItem.getViewHolder(), tabItem.getTab()); + } + } + + @Override + public final void onCloseButtonIconChanged(@NonNull final Tab tab) { + TabItem tabItem = getTabItem(tab); + + if (tabItem != null) { + adaptCloseButtonIcon(tabItem.getViewHolder(), tabItem.getTab()); + } + } + + @Override + public final void onBackgroundColorChanged(@NonNull final Tab tab) { + TabItem tabItem = getTabItem(tab); + + if (tabItem != null) { + adaptBackgroundColor(tabItem.getView(), tabItem.getViewHolder(), tabItem.getTab()); + } + } + + @Override + public final void onTitleTextColorChanged(@NonNull final Tab tab) { + TabItem tabItem = getTabItem(tab); + + if (tabItem != null) { + adaptTitleTextColor(tabItem.getViewHolder(), tabItem.getTab()); + } + } + + @Override + public final void onLogLevelChanged(@NonNull final LogLevel logLevel) { + adaptLogLevel(); + } + + @Override + public final void onDecoratorChanged(@NonNull final TabSwitcherDecorator decorator) { + + } + + @Override + public final void onSwitcherShown() { + + } + + @Override + public final void onSwitcherHidden() { + + } + + @Override + public final void onSelectionChanged(final int previousIndex, final int index, + @Nullable final Tab selectedTab, + final boolean switcherHidden) { + adaptAllSelectionStates(); + } + + @Override + public final void onTabAdded(final int index, @NonNull final Tab tab, + final int previousSelectedTabIndex, final int selectedTabIndex, + final boolean switcherVisibilityChanged, + @NonNull final Animation animation) { + if (previousSelectedTabIndex != selectedTabIndex) { + adaptAllSelectionStates(); + } + } + + @Override + public final void onAllTabsAdded(final int index, @NonNull final Tab[] tabs, + final int previousSelectedTabIndex, final int selectedTabIndex, + @NonNull final Animation animation) { + if (previousSelectedTabIndex != selectedTabIndex) { + adaptAllSelectionStates(); + } + } + + @Override + public final void onTabRemoved(final int index, @NonNull final Tab tab, + final int previousSelectedTabIndex, final int selectedTabIndex, + @NonNull final Animation animation) { + if (previousSelectedTabIndex != selectedTabIndex) { + adaptAllSelectionStates(); + } + } + + @Override + public final void onAllTabsRemoved(@NonNull final Tab[] tabs, + @NonNull final Animation animation) { + + } + + @Override + public final void onPaddingChanged(final int left, final int top, final int right, + final int bottom) { + TabItemIterator iterator = new TabItemIterator.Builder(model, viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.isInflated()) { + adaptPadding(tabItem.getViewHolder()); + } + } + } + + @Override + public final void onTabIconChanged(@Nullable final Drawable icon) { + TabItemIterator iterator = new TabItemIterator.Builder(model, viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.isInflated()) { + adaptIcon(tabItem.getViewHolder(), tabItem.getTab()); + } + } + } + + @Override + public final void onTabBackgroundColorChanged(@Nullable final ColorStateList colorStateList) { + TabItemIterator iterator = new TabItemIterator.Builder(model, viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.isInflated()) { + adaptBackgroundColor(tabItem.getView(), tabItem.getViewHolder(), tabItem.getTab()); + } + } + } + + @Override + public final void onTabTitleColorChanged(@Nullable final ColorStateList colorStateList) { + TabItemIterator iterator = new TabItemIterator.Builder(model, viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.isInflated()) { + adaptTitleTextColor(tabItem.getViewHolder(), tabItem.getTab()); + } + } + } + + @Override + public final void onTabCloseButtonIconChanged(@Nullable final Drawable icon) { + TabItemIterator iterator = new TabItemIterator.Builder(model, viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.isInflated()) { + adaptCloseButtonIcon(tabItem.getViewHolder(), tabItem.getTab()); + } + } + } + + @Override + public final void onToolbarVisibilityChanged(final boolean visible) { + + } + + @Override + public final void onToolbarTitleChanged(@Nullable final CharSequence title) { + + } + + @Override + public final void onToolbarNavigationIconChanged(@Nullable final Drawable icon, + @Nullable final OnClickListener listener) { + + } + + @Override + public final void onToolbarMenuInflated(@MenuRes final int resourceId, + @Nullable final OnMenuItemClickListener listener) { + + } + + @Override + public final boolean onLoadData( + @NonNull final AbstractDataBinder dataBinder, + @NonNull final Tab key, @NonNull final TabItem... params) { + boolean result = true; + + for (TabPreviewListener listener : model.getTabPreviewListeners()) { + result &= listener.onLoadTabPreview(tabSwitcher, key); + } + + return result; + } + + @Override + public final void onFinished( + @NonNull final AbstractDataBinder dataBinder, + @NonNull final Tab key, @Nullable final Bitmap data, @NonNull final ImageView view, + @NonNull final TabItem... params) { + + } + + @Override + public final void onCanceled( + @NonNull final AbstractDataBinder dataBinder) { + + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabSwitcherLayout.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabSwitcherLayout.java new file mode 100755 index 0000000..b685765 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabSwitcherLayout.java @@ -0,0 +1,3705 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout.phone; + +import android.animation.Animator; +import android.animation.Animator.AnimatorListener; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.util.Pair; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.Toolbar; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewPropertyAnimator; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.FrameLayout; + +import java.util.Collections; + +import de.mrapp.android.tabswitcher.Animation; +import de.mrapp.android.tabswitcher.Layout; +import de.mrapp.android.tabswitcher.PeekAnimation; +import de.mrapp.android.tabswitcher.R; +import de.mrapp.android.tabswitcher.RevealAnimation; +import de.mrapp.android.tabswitcher.SwipeAnimation; +import de.mrapp.android.tabswitcher.SwipeAnimation.SwipeDirection; +import de.mrapp.android.tabswitcher.Tab; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.TabSwitcherDecorator; +import de.mrapp.android.tabswitcher.iterator.AbstractTabItemIterator; +import de.mrapp.android.tabswitcher.iterator.ArrayTabItemIterator; +import de.mrapp.android.tabswitcher.iterator.TabItemIterator; +import de.mrapp.android.tabswitcher.layout.AbstractDragHandler; +import de.mrapp.android.tabswitcher.layout.AbstractDragHandler.DragState; +import de.mrapp.android.tabswitcher.layout.AbstractTabSwitcherLayout; +import de.mrapp.android.tabswitcher.layout.Arithmetics.Axis; +import de.mrapp.android.tabswitcher.model.State; +import de.mrapp.android.tabswitcher.model.TabItem; +import de.mrapp.android.tabswitcher.model.TabSwitcherModel; +import de.mrapp.android.tabswitcher.model.Tag; +import de.mrapp.android.util.logging.LogLevel; +import de.mrapp.android.util.view.AttachedViewRecycler; +import de.mrapp.android.util.view.ViewRecycler; + +import static de.mrapp.android.util.Condition.ensureEqual; +import static de.mrapp.android.util.Condition.ensureGreater; +import static de.mrapp.android.util.Condition.ensureNotNull; +import static de.mrapp.android.util.Condition.ensureTrue; + +/** + * A layout, which implements the functionality of a {@link TabSwitcher} on smartphones. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class PhoneTabSwitcherLayout extends AbstractTabSwitcherLayout + implements PhoneDragHandler.Callback { + + /** + * An iterator, which allows to iterate the tab items, which correspond to the tabs of a {@link + * TabSwitcher}. When a tab item is referenced for the first time, its initial position and + * state is calculated and the tab item is stored in a backing array. When the tab item is + * iterated again, it is retrieved from the backing array. + */ + private class InitialTabItemIterator extends AbstractTabItemIterator { + + /** + * The backing array, which is used to store tab items, once their initial position and + * state has been calculated. + */ + private final TabItem[] array; + + /** + * Calculates the initial position and state of a specific tab item. + * + * @param tabItem + * The tab item, whose position and state should be calculated, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @param predecessor + * The predecessor of the given tab item as an instance of the class {@link TabItem} + * or null, if the tab item does not have a predecessor + */ + private void calculateAndClipStartPosition(@NonNull final TabItem tabItem, + @Nullable final TabItem predecessor) { + float position = calculateStartPosition(tabItem); + Pair pair = + clipTabPosition(getModel().getCount(), tabItem.getIndex(), position, + predecessor); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + } + + /** + * Calculates and returns the initial position of a specific tab item. + * + * @param tabItem + * The tab item, whose position should be calculated, as an instance of the class + * {@link TabItem}. The tab item may not be null + * @return The position, which has been calculated, as a {@link Float} value + */ + private float calculateStartPosition(@NonNull final TabItem tabItem) { + if (tabItem.getIndex() == 0) { + return getCount() > stackedTabCount ? stackedTabCount * stackedTabSpacing : + (getCount() - 1) * stackedTabSpacing; + + } else { + return -1; + } + } + + /** + * Creates a new iterator, which allows to iterate the tab items, which corresponds to the + * tabs of a {@link TabSwitcher}. + * + * @param array + * The backing array, which should be used to store tab items, once their initial + * position and state has been calculated, as an array of the type {@link TabItem}. + * The array may not be null and the array's length must be equal to the number of + * tabs, which are contained by the given tab switcher + * @param reverse + * True, if the tabs should be iterated in reverse order, false otherwise + * @param start + * The index of the first tab, which should be iterated, as an {@link Integer} value + * or -1, if all tabs should be iterated + */ + private InitialTabItemIterator(@NonNull final TabItem[] array, final boolean reverse, + final int start) { + ensureNotNull(array, "The array may not be null"); + ensureEqual(array.length, getModel().getCount(), + "The array's length must be " + getModel().getCount()); + this.array = array; + initialize(reverse, start); + } + + @Override + public final int getCount() { + return array.length; + } + + @NonNull + @Override + public final TabItem getItem(final int index) { + TabItem tabItem = array[index]; + + if (tabItem == null) { + tabItem = TabItem.create(getModel(), viewRecycler, index); + calculateAndClipStartPosition(tabItem, index > 0 ? getItem(index - 1) : null); + array[index] = tabItem; + } + + return tabItem; + } + + } + + /** + * A layout listener, which encapsulates another listener, which is notified, when the listener + * has been invoked a specific number of times. + */ + private class CompoundLayoutListener implements OnGlobalLayoutListener { + + /** + * The number of times, the listener must still be invoked, until the encapsulated listener + * is notified. + */ + private int count; + + /** + * The encapsulated listener; + */ + private final OnGlobalLayoutListener listener; + + /** + * Creates a new layout listener, which encapsulates another listener, which is notified, + * when the listener has been invoked a specific number of times. + * + * @param count + * The number of times, the listener should be invoked until the encapsulated + * listener is notified, as an {@link Integer} value. The count must be greater than + * 0 + * @param listener + * The encapsulated listener, which should be notified, when the listener has been + * notified the given number of times, as an instance of the type {@link + * OnGlobalLayoutListener} or null, if no listener should be notified + */ + CompoundLayoutListener(final int count, @Nullable final OnGlobalLayoutListener listener) { + ensureGreater(count, 0, "The count must be greater than 0"); + this.count = count; + this.listener = listener; + } + + @Override + public void onGlobalLayout() { + if (--count == 0) { + if (listener != null) { + listener.onGlobalLayout(); + } + } + } + + } + + /** + * The ratio, which specifies the maximum space between the currently selected tab and its + * predecessor in relation to the default space. + */ + private static final float SELECTED_TAB_SPACING_RATIO = 1.5f; + + /** + * The ratio, which specifies the minimum space between two neighboring tabs in relation to the + * maximum space. + */ + private static final float MIN_TAB_SPACING_RATIO = 0.375f; + + /** + * The inset of tabs in pixels. + */ + private final int tabInset; + + /** + * The width of the border, which is drawn around the preview of tabs. + */ + private final int tabBorderWidth; + + /** + * The height of a tab's title container in pixels. + */ + private final int tabTitleContainerHeight; + + /** + * The number of tabs, which are contained by a stack. + */ + private final int stackedTabCount; + + /** + * The space between tabs, which are part of a stack, in pixels. + */ + private final int stackedTabSpacing; + + /** + * The maximum camera distance, when tilting a tab, in pixels. + */ + private final int maxCameraDistance; + + /** + * The alpha of a tab, when it is swiped. + */ + private final float swipedTabAlpha; + + /** + * The scale of a tab, when it is swiped. + */ + private final float swipedTabScale; + + /** + * The duration of the animation, which is used to show the switcher. + */ + private final long showSwitcherAnimationDuration; + + /** + * The duration of the animation, which is used to hide the switcher. + */ + private final long hideSwitcherAnimationDuration; + + /** + * The duration of the animation, which is used to show or hide the toolbar. + */ + private final long toolbarVisibilityAnimationDuration; + + /** + * The delay of the animation, which is used to show or hide the toolbar. + */ + private final long toolbarVisibilityAnimationDelay; + + /** + * The duration of the animation, which is used to swipe tabs. + */ + private final long swipeAnimationDuration; + + /** + * The delay of the animation, which is used to remove all tabs. + */ + private final long clearAnimationDelay; + + /** + * The duration of the animation, which is used to relocate tabs. + */ + private final long relocateAnimationDuration; + + /** + * The delay of the animation, which is used to relocate tabs. + */ + private final long relocateAnimationDelay; + + /** + * The duration of the animation, which is used to revert overshoots. + */ + private final long revertOvershootAnimationDuration; + + /** + * The duration of a reveal animation. + */ + private final long revealAnimationDuration; + + /** + * The duration of a peek animation. + */ + private final long peekAnimationDuration; + + /** + * The maximum angle, tabs can be rotated by, when overshooting at the start, in degrees. + */ + private final float maxStartOvershootAngle; + + /** + * The maximum angle, tabs can be rotated by, when overshooting at the end, in degrees. + */ + private final float maxEndOvershootAngle; + + /** + * The drag handler, which is used by the layout. + */ + private PhoneDragHandler dragHandler; + + /** + * The view recycler, which allows to recycler the child views of tabs. + */ + private ViewRecycler childViewRecycler; + + /** + * The adapter, which allows to inflate the views, which are used to visualize tabs. + */ + private PhoneRecyclerAdapter recyclerAdapter; + + /** + * The view recycler, which allows to recycle the views, which are used to visualize tabs. + */ + private AttachedViewRecycler viewRecycler; + + /** + * The view group, which contains the tab switcher's tabs. + */ + private ViewGroup tabContainer; + + /** + * The toolbar, which is shown, when the tab switcher is shown. + */ + private Toolbar toolbar; + + /** + * The bottom margin of a view, which visualizes a tab. + */ + private int tabViewBottomMargin; + + /** + * The animation, which is used to show or hide the toolbar. + */ + private ViewPropertyAnimator toolbarAnimation; + + /** + * The index of the first visible tab. + */ + private int firstVisibleIndex; + + /** + * Adapts the log level. + */ + private void adaptLogLevel() { + viewRecycler.setLogLevel(getModel().getLogLevel()); + childViewRecycler.setLogLevel(getModel().getLogLevel()); + } + + /** + * Adapts the decorator. + */ + private void adaptDecorator() { + childViewRecycler.setAdapter(getModel().getChildRecyclerAdapter()); + recyclerAdapter.clearCachedPreviews(); + } + + /** + * Adapts the margin of the toolbar, which is shown, when the tab switcher is shown. + */ + private void adaptToolbarMargin() { + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) toolbar.getLayoutParams(); + layoutParams.setMargins(getModel().getPaddingLeft(), getModel().getPaddingTop(), + getModel().getPaddingRight(), 0); + } + + /** + * Calculates the positions of all tabs, when dragging towards the start. + * + * @param dragDistance + * The current drag distance in pixels as a {@link Float} value + */ + private void calculatePositionsWhenDraggingToEnd(final float dragDistance) { + firstVisibleIndex = -1; + AbstractTabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler) + .start(Math.max(0, firstVisibleIndex)).create(); + TabItem tabItem; + boolean abort = false; + + while ((tabItem = iterator.next()) != null && !abort) { + if (getTabSwitcher().getCount() - tabItem.getIndex() > 1) { + abort = calculatePositionWhenDraggingToEnd(dragDistance, tabItem, + iterator.previous()); + + if (firstVisibleIndex == -1 && tabItem.getTag().getState() == State.FLOATING) { + firstVisibleIndex = tabItem.getIndex(); + } + } else { + Pair pair = + clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(), + tabItem.getTag().getPosition(), iterator.previous()); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + } + + inflateOrRemoveView(tabItem); + } + } + + /** + * Calculates the position of a specific tab, when dragging towards the end. + * + * @param dragDistance + * The current drag distance in pixels as a {@link Float} value + * @param tabItem + * The tab item, which corresponds to the tab, whose position should be calculated, as + * an instance of the class {@link TabItem}. The tab item may not be null + * @param predecessor + * The predecessor of the given tab item as an instance of the class {@link TabItem} or + * null, if the tab item does not have a predecessor + * @return True, if calculating the position of subsequent tabs can be omitted, false otherwise + */ + private boolean calculatePositionWhenDraggingToEnd(final float dragDistance, + @NonNull final TabItem tabItem, + @Nullable final TabItem predecessor) { + if (predecessor == null || predecessor.getTag().getState() != State.FLOATING) { + if ((tabItem.getTag().getState() == State.STACKED_START_ATOP && + tabItem.getIndex() == 0) || tabItem.getTag().getState() == State.FLOATING) { + float currentPosition = tabItem.getTag().getPosition(); + float thresholdPosition = calculateEndPosition(tabItem.getIndex()); + float newPosition = Math.min(currentPosition + dragDistance, thresholdPosition); + Pair pair = + clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(), + newPosition, predecessor); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + } else if (tabItem.getTag().getState() == State.STACKED_START_ATOP) { + return true; + } + } else { + float thresholdPosition = calculateEndPosition(tabItem.getIndex()); + float newPosition = + Math.min(calculateNonLinearPosition(tabItem, predecessor), thresholdPosition); + Pair pair = + clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(), newPosition, + predecessor); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + } + + return false; + } + + /** + * Calculates the positions of all tabs, when dragging towards the end. + * + * @param dragDistance + * The current drag distance in pixels as a {@link Float} value + */ + private void calculatePositionsWhenDraggingToStart(final float dragDistance) { + AbstractTabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler) + .start(Math.max(0, firstVisibleIndex)).create(); + TabItem tabItem; + boolean abort = false; + + while ((tabItem = iterator.next()) != null && !abort) { + if (getTabSwitcher().getCount() - tabItem.getIndex() > 1) { + abort = calculatePositionWhenDraggingToStart(dragDistance, tabItem, + iterator.previous()); + } else { + Pair pair = + clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(), + tabItem.getTag().getPosition(), iterator.previous()); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + } + + inflateOrRemoveView(tabItem); + } + + if (firstVisibleIndex > 0) { + int start = firstVisibleIndex - 1; + iterator = new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).reverse(true) + .start(start).create(); + + while ((tabItem = iterator.next()) != null) { + TabItem predecessor = iterator.previous(); + float predecessorPosition = predecessor.getTag().getPosition(); + + if (tabItem.getIndex() < start) { + Pair pair = + clipTabPosition(getTabSwitcher().getCount(), predecessor.getIndex(), + predecessorPosition, tabItem); + predecessor.getTag().setPosition(pair.first); + predecessor.getTag().setState(pair.second); + inflateOrRemoveView(predecessor); + + if (predecessor.getTag().getState() == State.FLOATING) { + firstVisibleIndex = predecessor.getIndex(); + } else { + break; + } + } + + float newPosition = predecessorPosition + + calculateMaxTabSpacing(getTabSwitcher().getCount(), predecessor); + tabItem.getTag().setPosition(newPosition); + + if (!iterator.hasNext()) { + Pair pair = + clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(), + newPosition, (TabItem) null); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + inflateOrRemoveView(tabItem); + + if (tabItem.getTag().getState() == State.FLOATING) { + firstVisibleIndex = tabItem.getIndex(); + } + } + } + } + } + + /** + * Calculates the position of a specific tab, when dragging towards the start. + * + * @param dragDistance + * The current drag distance in pixels as a {@link Float} value + * @param tabItem + * The tab item, which corresponds to the tab, whose position should be calculated, as + * an instance of the class {@link TabItem}. The tab item may not be null + * @param predecessor + * The predecessor of the given tab item as an instance of the class {@link TabItem} or + * null, if the tab item does not have a predecessor + * @return True, if calculating the position of subsequent tabs can be omitted, false otherwise + */ + private boolean calculatePositionWhenDraggingToStart(final float dragDistance, + @NonNull final TabItem tabItem, + @Nullable final TabItem predecessor) { + if (predecessor == null || predecessor.getTag().getState() != State.FLOATING || + predecessor.getTag().getPosition() > + calculateAttachedPosition(getTabSwitcher().getCount())) { + if (tabItem.getTag().getState() == State.FLOATING) { + float currentPosition = tabItem.getTag().getPosition(); + float newPosition = currentPosition + dragDistance; + Pair pair = + clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(), + newPosition, predecessor); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + } else if (tabItem.getTag().getState() == State.STACKED_START_ATOP) { + float currentPosition = tabItem.getTag().getPosition(); + Pair pair = + clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(), + currentPosition, predecessor); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + return true; + } else if (tabItem.getTag().getState() == State.HIDDEN || + tabItem.getTag().getState() == State.STACKED_START) { + return true; + } + } else { + float newPosition = calculateNonLinearPosition(tabItem, predecessor); + Pair pair = + clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(), newPosition, + predecessor); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + } + + return false; + } + + /** + * Calculates the non-linear position of a tab in relation to the position of its predecessor. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose non-linear position should be + * calculated, as an instance of the class {@link TabItem}. The tab item may not be + * null + * @param predecessor + * The predecessor as an instance of the class {@link TabItem}. The predecessor may not + * be null + * @return The position, which has been calculated, as a {@link Float} value + */ + private float calculateNonLinearPosition(@NonNull final TabItem tabItem, + @NonNull final TabItem predecessor) { + float predecessorPosition = predecessor.getTag().getPosition(); + float maxTabSpacing = calculateMaxTabSpacing(getTabSwitcher().getCount(), tabItem); + return calculateNonLinearPosition(predecessorPosition, maxTabSpacing); + } + + /** + * Calculates the non-linear position of a tab in relation to the position of its predecessor. + * + * @param predecessorPosition + * The position of the predecessor in pixels as a {@link Float} value + * @param maxTabSpacing + * The maximum space between two neighboring tabs in pixels as a {@link Float} value + * @return The position, which has been calculated, as a {@link Float} value + */ + private float calculateNonLinearPosition(final float predecessorPosition, + final float maxTabSpacing) { + float ratio = Math.min(1, + predecessorPosition / calculateAttachedPosition(getTabSwitcher().getCount())); + float minTabSpacing = calculateMinTabSpacing(getTabSwitcher().getCount()); + return predecessorPosition - minTabSpacing - (ratio * (maxTabSpacing - minTabSpacing)); + } + + /** + * Calculates and returns the position of a specific tab, when located at the end. + * + * @param index + * The index of the tab, whose position should be calculated, as an {@link Integer} + * value + * @return The position, which has been calculated, as a {@link Float} value + */ + private float calculateEndPosition(final int index) { + float defaultMaxTabSpacing = calculateMaxTabSpacing(getTabSwitcher().getCount(), null); + int selectedTabIndex = getTabSwitcher().getSelectedTabIndex(); + + if (selectedTabIndex > index) { + AbstractTabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create(); + TabItem selectedTabItem = iterator.getItem(selectedTabIndex); + float selectedTabSpacing = + calculateMaxTabSpacing(getTabSwitcher().getCount(), selectedTabItem); + return (getTabSwitcher().getCount() - 2 - index) * defaultMaxTabSpacing + + selectedTabSpacing; + } + + return (getTabSwitcher().getCount() - 1 - index) * defaultMaxTabSpacing; + } + + /** + * Calculates and returns the position of a tab, when it is swiped. + * + * @return The position, which has been calculated, in pixels as an {@link Float} value + */ + private float calculateSwipePosition() { + return getArithmetics().getTabContainerSize(Axis.ORTHOGONAL_AXIS, true); + } + + /** + * Calculates and returns the maximum space between a specific tab and its predecessor. The + * maximum space is greater for the currently selected tab. + * + * @param count + * The total number of tabs, which are contained by the tabs switcher, as an {@link + * Integer} value + * @param tabItem + * The tab item, which corresponds to the tab, the maximum space should be returned for, + * as an instance of the class {@link TabItem} or null, if the default maximum space + * should be returned + * @return The maximum space between the given tab and its predecessor in pixels as a {@link + * Float} value + */ + private float calculateMaxTabSpacing(final int count, @Nullable final TabItem tabItem) { + float totalSpace = getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS, false); + float maxTabSpacing; + + if (count <= 2) { + maxTabSpacing = totalSpace * 0.66f; + } else if (count == 3) { + maxTabSpacing = totalSpace * 0.33f; + } else if (count == 4) { + maxTabSpacing = totalSpace * 0.3f; + } else { + maxTabSpacing = totalSpace * 0.25f; + } + + return count > 4 && tabItem != null && + tabItem.getTab() == getTabSwitcher().getSelectedTab() ? + maxTabSpacing * SELECTED_TAB_SPACING_RATIO : maxTabSpacing; + } + + /** + * Calculates and returns the minimum space between two neighboring tabs. + * + * @param count + * The total number of tabs, which are contained by the tabs switcher, as an {@link + * Integer} value + * @return The minimum space between two neighboring tabs in pixels as a {@link Float} value + */ + private float calculateMinTabSpacing(final int count) { + return calculateMaxTabSpacing(count, null) * MIN_TAB_SPACING_RATIO; + } + + /** + * Calculates and returns the position on the dragging axis, where the distance between a tab + * and its predecessor should have reached the maximum. + * + * @param count + * The total number of tabs, which are contained by the tabs switcher, as an {@link + * Integer} value + * @return The position, which has been calculated, in pixels as an {@link Float} value + */ + private float calculateAttachedPosition(final int count) { + float totalSpace = getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS, false); + float attachedPosition; + + if (count == 3) { + attachedPosition = totalSpace * 0.66f; + } else if (count == 4) { + attachedPosition = totalSpace * 0.6f; + } else { + attachedPosition = totalSpace * 0.5f; + } + + return attachedPosition; + } + + /** + * Clips the position of a specific tab. + * + * @param count + * The total number of tabs, which are currently contained by the tab switcher, as an + * {@link Integer} value + * @param index + * The index of the tab, whose position should be clipped, as an {@link Integer} value + * @param position + * The position, which should be clipped, in pixels as a {@link Float} value + * @param predecessor + * The predecessor of the given tab item as an instance of the class {@link TabItem} or + * null, if the tab item does not have a predecessor + * @return A pair, which contains the position and state of the tab item, as an instance of the + * class {@link Pair}. The pair may not be null + */ + @NonNull + private Pair clipTabPosition(final int count, final int index, + final float position, + @Nullable final TabItem predecessor) { + return clipTabPosition(count, index, position, + predecessor != null ? predecessor.getTag().getState() : null); + } + + /** + * Clips the position of a specific tab. + * + * @param count + * The total number of tabs, which are currently contained by the tab switcher, as an + * {@link Integer} value + * @param index + * The index of the tab, whose position should be clipped, as an {@link Integer} value + * @param position + * The position, which should be clipped, in pixels as a {@link Float} value + * @param predecessorState + * The state of the predecessor of the given tab item as a value of the enum {@link + * State} or null, if the tab item does not have a predecessor + * @return A pair, which contains the position and state of the tab item, as an instance of the + * class {@link Pair}. The pair may not be null + */ + private Pair clipTabPosition(final int count, final int index, + final float position, + @Nullable final State predecessorState) { + Pair startPair = + calculatePositionAndStateWhenStackedAtStart(count, index, predecessorState); + float startPosition = startPair.first; + + if (position <= startPosition) { + State state = startPair.second; + return Pair.create(startPosition, state); + } else { + Pair endPair = calculatePositionAndStateWhenStackedAtEnd(index); + float endPosition = endPair.first; + + if (position >= endPosition) { + State state = endPair.second; + return Pair.create(endPosition, state); + } else { + State state = State.FLOATING; + return Pair.create(position, state); + } + } + } + + /** + * Calculates and returns the position and state of a specific tab, when stacked at the start. + * + * @param count + * The total number of tabs, which are currently contained by the tab switcher, as an + * {@link Integer} value + * @param index + * The index of the tab, whose position and state should be returned, as an {@link + * Integer} value + * @param predecessor + * The predecessor of the given tab item as an instance of the class {@link TabItem} or + * null, if the tab item does not have a predecessor + * @return A pair, which contains the position and state of the given tab item, when stacked at + * the start, as an instance of the class {@link Pair}. The pair may not be null + */ + @NonNull + private Pair calculatePositionAndStateWhenStackedAtStart(final int count, + final int index, + @Nullable final TabItem predecessor) { + return calculatePositionAndStateWhenStackedAtStart(count, index, + predecessor != null ? predecessor.getTag().getState() : null); + } + + /** + * Calculates and returns the position and state of a specific tab, when stacked at the start. + * + * @param count + * The total number of tabs, which are currently contained by the tab switcher, as an + * {@link Integer} value + * @param index + * The index of the tab, whose position and state should be returned, as an {@link + * Integer} value + * @param predecessorState + * The state of the predecessor of the given tab item as a value of the enum {@link + * State} or null, if the tab item does not have a predecessor + * @return A pair, which contains the position and state of the given tab item, when stacked at + * the start, as an instance of the class {@link Pair}. The pair may not be null + */ + @NonNull + private Pair calculatePositionAndStateWhenStackedAtStart(final int count, + final int index, + @Nullable final State predecessorState) { + if ((count - index) <= stackedTabCount) { + float position = stackedTabSpacing * (count - (index + 1)); + return Pair.create(position, + (predecessorState == null || predecessorState == State.FLOATING) ? + State.STACKED_START_ATOP : State.STACKED_START); + } else { + float position = stackedTabSpacing * stackedTabCount; + return Pair.create(position, + (predecessorState == null || predecessorState == State.FLOATING) ? + State.STACKED_START_ATOP : State.HIDDEN); + } + } + + /** + * Calculates and returns the position and state of a specific tab, when stacked at the end. + * + * @param index + * The index of the tab, whose position and state should be returned, as an {@link + * Integer} value + * @return A pair, which contains the position and state of the given tab item, when stacked at + * the end, as an instance of the class {@link Pair}. The pair may not be null + */ + @NonNull + private Pair calculatePositionAndStateWhenStackedAtEnd(final int index) { + float size = getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS, false); + + if (index < stackedTabCount) { + float position = size - tabInset - (stackedTabSpacing * (index + 1)); + return Pair.create(position, State.STACKED_END); + } else { + float position = size - tabInset - (stackedTabSpacing * stackedTabCount); + return Pair.create(position, State.HIDDEN); + } + } + + /** + * The method, which is invoked on implementing subclasses in order to retrieve, whether the + * tabs are overshooting at the start. + * + * @return True, if the tabs are overshooting at the start, false otherwise + */ + private boolean isOvershootingAtStart() { + if (getTabSwitcher().getCount() <= 1) { + return true; + } else { + AbstractTabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create(); + TabItem tabItem = iterator.getItem(0); + return tabItem.getTag().getState() == State.STACKED_START_ATOP; + } + } + + /** + * The method, which is invoked on implementing subclasses in order to retrieve, whether the + * tabs are overshooting at the end. + * + * @param iterator + * An iterator, which allows to iterate the tabs, which are contained by the tab + * switcher, as an instance of the class {@link AbstractTabItemIterator}. The iterator + * may not be null + * @return True, if the tabs are overshooting at the end, false otherwise + */ + private boolean isOvershootingAtEnd(@NonNull final AbstractTabItemIterator iterator) { + if (getTabSwitcher().getCount() <= 1) { + return true; + } else { + TabItem lastTabItem = iterator.getItem(getTabSwitcher().getCount() - 1); + TabItem predecessor = iterator.getItem(getTabSwitcher().getCount() - 2); + return Math.round(predecessor.getTag().getPosition()) >= + Math.round(calculateMaxTabSpacing(getTabSwitcher().getCount(), lastTabItem)); + } + } + + /** + * Calculates and returns the bottom margin of a view, which visualizes a tab. + * + * @param view + * The view, whose bottom margin should be calculated, as an instance of the class + * {@link View}. The view may not be null + * @return The bottom margin, which has been calculated, in pixels as an {@link Integer} value + */ + private int calculateBottomMargin(@NonNull final View view) { + float tabHeight = (view.getHeight() - 2 * tabInset) * getArithmetics().getScale(view, true); + float containerHeight = getArithmetics().getTabContainerSize(Axis.Y_AXIS, false); + int stackHeight = getTabSwitcher().getLayout() == Layout.PHONE_LANDSCAPE ? 0 : + stackedTabCount * stackedTabSpacing; + return Math.round(tabHeight + tabInset + stackHeight - containerHeight); + } + + /** + * Animates the bottom margin of a specific view. + * + * @param view + * The view, whose bottom margin should be animated, as an instance of the class {@link + * View}. The view may not be null + * @param margin + * The bottom margin, which should be set by the animation, as an {@link Integer} value + * @param duration + * The duration of the animation in milliseconds as a {@link Long} value + * @param delay + * The delay of the animation in milliseconds as a {@link Long} value + */ + private void animateBottomMargin(@NonNull final View view, final int margin, + final long duration, final long delay) { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams(); + final int initialMargin = layoutParams.bottomMargin; + ValueAnimator animation = ValueAnimator.ofInt(margin - initialMargin); + animation.setDuration(duration); + animation.addListener(new AnimationListenerWrapper(null)); + animation.setInterpolator(new AccelerateDecelerateInterpolator()); + animation.setStartDelay(delay); + animation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + + @Override + public void onAnimationUpdate(ValueAnimator animation) { + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) view.getLayoutParams(); + layoutParams.bottomMargin = initialMargin + (int) animation.getAnimatedValue(); + view.setLayoutParams(layoutParams); + } + + }); + + animation.start(); + } + + /** + * Animates the visibility of the toolbar, which is shown, when the tab switcher is shown. + * + * @param visible + * True, if the toolbar should become visible, false otherwise + * @param delay + * The delay of the animation in milliseconds as a {@link Long} value + */ + private void animateToolbarVisibility(final boolean visible, final long delay) { + if (toolbarAnimation != null) { + toolbarAnimation.cancel(); + } + + float targetAlpha = visible ? 1 : 0; + + if (toolbar.getAlpha() != targetAlpha) { + toolbarAnimation = toolbar.animate(); + toolbarAnimation.setInterpolator(new AccelerateDecelerateInterpolator()); + toolbarAnimation.setDuration(toolbarVisibilityAnimationDuration); + toolbarAnimation.setStartDelay(delay); + toolbarAnimation.alpha(targetAlpha); + toolbarAnimation.start(); + } + } + + /** + * Shows the tab switcher in an animated manner. + */ + private void animateShowSwitcher() { + TabItem[] tabItems = calculateInitialTabItems(-1, -1); + AbstractTabItemIterator iterator = new InitialTabItemIterator(tabItems, false, 0); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.getTab() == getModel().getSelectedTab() || tabItem.isVisible()) { + viewRecycler.inflate(tabItem); + View view = tabItem.getView(); + + if (!ViewCompat.isLaidOut(view)) { + view.getViewTreeObserver().addOnGlobalLayoutListener( + new LayoutListenerWrapper(view, + createShowSwitcherLayoutListener(tabItem))); + } else { + animateShowSwitcher(tabItem, createUpdateViewAnimationListener(tabItem)); + } + } + } + + animateToolbarVisibility(getModel().areToolbarsShown(), toolbarVisibilityAnimationDelay); + } + + /** + * Calculates and returns the tab items, which correspond to the tabs, when the tab switcher is + * shown initially. + * + * @param firstVisibleTabIndex + * The index of the first visible tab as an {@link Integer} value or -1, if the index is + * unknown + * @param firstVisibleTabPosition + * The position of the first visible tab in pixels as a {@link Float} value or -1, if + * the position is unknown + * @return An array, which contains the tab items, as an array of the type {@link TabItem}. The + * array may not be null + */ + @NonNull + private TabItem[] calculateInitialTabItems(final int firstVisibleTabIndex, + final float firstVisibleTabPosition) { + dragHandler.reset(getDragThreshold()); + firstVisibleIndex = -1; + TabItem[] tabItems = new TabItem[getModel().getCount()]; + + if (!getModel().isEmpty()) { + int selectedTabIndex = getModel().getSelectedTabIndex(); + float attachedPosition = calculateAttachedPosition(getModel().getCount()); + int referenceIndex = firstVisibleTabIndex != -1 && firstVisibleTabPosition != -1 ? + firstVisibleTabIndex : selectedTabIndex; + float referencePosition = firstVisibleTabIndex != -1 && firstVisibleTabPosition != -1 ? + firstVisibleTabPosition : attachedPosition; + referencePosition = Math.min(calculateEndPosition(referenceIndex), referencePosition); + AbstractTabItemIterator iterator = + new InitialTabItemIterator(tabItems, false, referenceIndex); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + TabItem predecessor = iterator.previous(); + float position; + + if (tabItem.getIndex() == getModel().getCount() - 1) { + position = 0; + } else if (tabItem.getIndex() == referenceIndex) { + position = referencePosition; + } else { + position = calculateNonLinearPosition(tabItem, predecessor); + } + + Pair pair = + clipTabPosition(getModel().getCount(), tabItem.getIndex(), position, + predecessor); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + + if (firstVisibleIndex == -1 && pair.second != State.STACKED_END && + pair.second != State.HIDDEN) { + firstVisibleIndex = tabItem.getIndex(); + } + + if (pair.second == State.STACKED_START || pair.second == State.STACKED_START_ATOP) { + break; + } + } + + boolean overshooting = + referenceIndex == getModel().getCount() - 1 || isOvershootingAtEnd(iterator); + iterator = new InitialTabItemIterator(tabItems, true, referenceIndex - 1); + float minTabSpacing = calculateMinTabSpacing(getModel().getCount()); + float defaultTabSpacing = calculateMaxTabSpacing(getModel().getCount(), null); + TabItem selectedTabItem = + TabItem.create(getTabSwitcher(), viewRecycler, selectedTabIndex); + float maxTabSpacing = calculateMaxTabSpacing(getModel().getCount(), selectedTabItem); + TabItem currentReferenceTabItem = iterator.getItem(referenceIndex); + + while ((tabItem = iterator.next()) != null && + (overshooting || tabItem.getIndex() < referenceIndex)) { + float currentTabSpacing = + calculateMaxTabSpacing(getModel().getCount(), currentReferenceTabItem); + TabItem predecessor = iterator.peek(); + Pair pair; + + if (overshooting) { + float position; + + if (referenceIndex > tabItem.getIndex()) { + position = maxTabSpacing + + ((getModel().getCount() - 1 - tabItem.getIndex() - 1) * + defaultTabSpacing); + } else { + position = (getModel().getCount() - 1 - tabItem.getIndex()) * + defaultTabSpacing; + } + + pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), position, + predecessor); + } else if (referencePosition >= attachedPosition - currentTabSpacing) { + float position; + + if (selectedTabIndex > tabItem.getIndex() && + selectedTabIndex <= referenceIndex) { + position = referencePosition + maxTabSpacing + + ((referenceIndex - tabItem.getIndex() - 1) * defaultTabSpacing); + } else { + position = referencePosition + + ((referenceIndex - tabItem.getIndex()) * defaultTabSpacing); + } + + pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), position, + predecessor); + } else { + TabItem successor = iterator.previous(); + float successorPosition = successor.getTag().getPosition(); + float position = (attachedPosition * (successorPosition + minTabSpacing)) / + (minTabSpacing + attachedPosition - currentTabSpacing); + pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), position, + predecessor); + + if (pair.first >= attachedPosition - currentTabSpacing) { + currentReferenceTabItem = tabItem; + referencePosition = pair.first; + referenceIndex = tabItem.getIndex(); + } + } + + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + + if ((firstVisibleIndex == -1 || firstVisibleIndex > tabItem.getIndex()) && + pair.second == State.FLOATING) { + firstVisibleIndex = tabItem.getIndex(); + } + } + } + + dragHandler.setCallback(this); + return tabItems; + } + + /** + * Adds all tabs, which are contained by an array, to the tab switcher. + * + * @param index + * The index, the first tab should be added at, as an {@link Integer} value + * @param tabs + * The array, which contains the tabs, which should be added, as an array of the type + * {@link Tab}. The array may not be null + * @param animation + * The animation, which should be used to add the tabs, as an instance of the class + * {@link Animation}. The animation may not be null + */ + private void addAllTabs(final int index, @NonNull final Tab[] tabs, + @NonNull final Animation animation) { + if (tabs.length > 0) { + if (getModel().isSwitcherShown()) { + SwipeAnimation swipeAnimation = + animation instanceof SwipeAnimation ? (SwipeAnimation) animation : + new SwipeAnimation.Builder().create(); + TabItem[] tabItems = new TabItem[tabs.length]; + OnGlobalLayoutListener compoundListener = new CompoundLayoutListener(tabs.length, + createSwipeLayoutListener(tabItems, swipeAnimation)); + + for (int i = 0; i < tabs.length; i++) { + Tab tab = tabs[i]; + TabItem tabItem = new TabItem(index + i, tab); + tabItems[i] = tabItem; + inflateView(tabItem, compoundListener); + } + } else if (!getModel().isSwitcherShown()) { + toolbar.setAlpha(0); + + if (getModel().getSelectedTab() == tabs[0]) { + TabItem tabItem = TabItem.create(getTabSwitcher(), viewRecycler, index); + inflateView(tabItem, createAddSelectedTabLayoutListener(tabItem)); + } + } + } + } + + /** + * Animates the position and size of a specific tab item in order to show the tab switcher. + * + * @param tabItem + * The tab item, which should be animated, as an instance of the class {@link TabItem}. + * The tab item may not be null + * @param listener + * The listener, which should be notified about the animation's progress, as an instance + * of the type {@link AnimatorListener} or null, if no listener should be notified + */ + private void animateShowSwitcher(@NonNull final TabItem tabItem, + @Nullable final AnimatorListener listener) { + animateShowSwitcher(tabItem, showSwitcherAnimationDuration, + new AccelerateDecelerateInterpolator(), listener); + } + + /** + * Animates the position and size of a specific tab in order to show the tab switcher. + * + * @param tabItem + * The tab item, which should be animated, as an instance of the class {@link TabItem}. + * The tab item may not be null + * @param duration + * The duration of the animation in milliseconds as a {@link Long} value + * @param interpolator + * The interpolator, which should be used by the animation, as an instance of the type + * {@link Interpolator}. The interpolator may not be null + * @param listener + * The listener, which should be notified about the animation's progress, as an instance + * of the type {@link AnimatorListener} or null, if no listener should be notified + */ + private void animateShowSwitcher(@NonNull final TabItem tabItem, final long duration, + @NonNull final Interpolator interpolator, + @Nullable final AnimatorListener listener) { + View view = tabItem.getView(); + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams(); + view.setX(layoutParams.leftMargin); + view.setY(layoutParams.topMargin); + getArithmetics().setScale(Axis.DRAGGING_AXIS, view, 1); + getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, 1); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, + getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE)); + float scale = getArithmetics().getScale(view, true); + int selectedTabIndex = getModel().getSelectedTabIndex(); + + if (tabItem.getIndex() < selectedTabIndex) { + getArithmetics().setPosition(Axis.DRAGGING_AXIS, view, + getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS)); + } else if (tabItem.getIndex() > selectedTabIndex) { + getArithmetics().setPosition(Axis.DRAGGING_AXIS, view, + getTabSwitcher().getLayout() == Layout.PHONE_LANDSCAPE ? 0 : + layoutParams.topMargin); + } + + if (tabViewBottomMargin == -1) { + tabViewBottomMargin = calculateBottomMargin(view); + } + + animateBottomMargin(view, tabViewBottomMargin, duration, 0); + ViewPropertyAnimator animation = view.animate(); + animation.setDuration(duration); + animation.setInterpolator(interpolator); + animation.setListener(new AnimationListenerWrapper(listener)); + getArithmetics().animateScale(Axis.DRAGGING_AXIS, animation, scale); + getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animation, scale); + getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view, + tabItem.getTag().getPosition(), true); + getArithmetics().animatePosition(Axis.ORTHOGONAL_AXIS, animation, view, 0, true); + animation.setStartDelay(0); + animation.start(); + } + + /** + * Hides the tab switcher in an animated manner. + */ + private void animateHideSwitcher() { + dragHandler.setCallback(null); + TabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.isInflated()) { + animateHideSwitcher(tabItem, + tabItem.getIndex() == getModel().getSelectedTabIndex() ? + createHideSwitcherAnimationListener() : null); + } else if (tabItem.getTab() == getModel().getSelectedTab()) { + inflateAndUpdateView(tabItem, createHideSwitcherLayoutListener(tabItem)); + } + } + + animateToolbarVisibility(getModel().areToolbarsShown() && getModel().isEmpty(), 0); + } + + /** + * Animates the position and size of a specific tab item in order to hide the tab switcher. + * + * @param tabItem + * The tab item, which should be animated, as an instance of the class {@link TabItem}. + * The tab item may not be null + * @param listener + * The listener, which should be notified about the animation's progress, as an instance + * of the type {@link AnimatorListener} or null, if no listener should be notified + */ + private void animateHideSwitcher(@NonNull final TabItem tabItem, + @Nullable final AnimatorListener listener) { + animateHideSwitcher(tabItem, hideSwitcherAnimationDuration, + new AccelerateDecelerateInterpolator(), 0, listener); + } + + /** + * Animates the position and size of a specific tab item in order to hide the tab switcher. + * + * @param tabItem + * The tab item, which should be animated, as an instance of the class {@link TabItem}. + * The tab item may not be null + * @param duration + * The duration of the animation in milliseconds as a {@link Long} value + * @param interpolator + * The interpolator, which should be used by the animation, as an instance of the class + * {@link Interpolator}. The interpolator may not be null + * @param delay + * The delay of the animation in milliseconds as a {@link Long} value + * @param listener + * The listener, which should be notified about the animation's progress, as an instance + * of the type {@link AnimatorListener} or null, if no listener should be notified + */ + private void animateHideSwitcher(@NonNull final TabItem tabItem, final long duration, + @NonNull final Interpolator interpolator, final long delay, + @Nullable final AnimatorListener listener) { + View view = tabItem.getView(); + animateBottomMargin(view, -(tabInset + tabBorderWidth), duration, delay); + ViewPropertyAnimator animation = view.animate(); + animation.setDuration(duration); + animation.setInterpolator(interpolator); + animation.setListener(new AnimationListenerWrapper(listener)); + getArithmetics().animateScale(Axis.DRAGGING_AXIS, animation, 1); + getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animation, 1); + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams(); + getArithmetics().animatePosition(Axis.ORTHOGONAL_AXIS, animation, view, + getTabSwitcher().getLayout() == Layout.PHONE_LANDSCAPE ? layoutParams.topMargin : 0, + false); + int selectedTabIndex = getModel().getSelectedTabIndex(); + + if (tabItem.getIndex() < selectedTabIndex) { + getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view, + getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS), false); + } else if (tabItem.getIndex() > selectedTabIndex) { + getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view, + getTabSwitcher().getLayout() == Layout.PHONE_LANDSCAPE ? 0 : + layoutParams.topMargin, false); + } else { + getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view, + getTabSwitcher().getLayout() == Layout.PHONE_LANDSCAPE ? 0 : + layoutParams.topMargin, false); + } + + animation.setStartDelay(delay); + animation.start(); + } + + /** + * Animates the position, size and alpha of a specific tab item in order to swipe it + * orthogonally. + * + * @param tabItem + * The tab item, which should be animated, as an instance of the class {@link TabItem}. + * The tab item may not be null + * @param remove + * True, if the tab should be removed after the animation has finished, false otherwise + * @param delay + * The delay after which the animation should be started in milliseconds as a {@link + * Long} value + * @param swipeAnimation + * The animation, which should be used, as an instance of the class {@link + * SwipeAnimation}. The animation may not be null + * @param listener + * The listener, which should be notified about the progress of the animation, as an + * instance of the type {@link AnimatorListener} or null, if no listener should be + * notified + */ + private void animateSwipe(@NonNull final TabItem tabItem, final boolean remove, + final long delay, @NonNull final SwipeAnimation swipeAnimation, + @Nullable final AnimatorListener listener) { + View view = tabItem.getView(); + float currentScale = getArithmetics().getScale(view, true); + float swipePosition = calculateSwipePosition(); + float targetPosition = remove ? + (swipeAnimation.getDirection() == SwipeDirection.LEFT ? -1 * swipePosition : + swipePosition) : 0; + float currentPosition = getArithmetics().getPosition(Axis.ORTHOGONAL_AXIS, view); + float distance = Math.abs(targetPosition - currentPosition); + long animationDuration = swipeAnimation.getDuration() != -1 ? swipeAnimation.getDuration() : + Math.round(swipeAnimationDuration * (distance / swipePosition)); + ViewPropertyAnimator animation = view.animate(); + animation.setInterpolator( + swipeAnimation.getInterpolator() != null ? swipeAnimation.getInterpolator() : + new AccelerateDecelerateInterpolator()); + animation.setListener(new AnimationListenerWrapper(listener)); + animation.setDuration(animationDuration); + getArithmetics() + .animatePosition(Axis.ORTHOGONAL_AXIS, animation, view, targetPosition, true); + getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animation, + remove ? swipedTabScale * currentScale : currentScale); + getArithmetics().animateScale(Axis.DRAGGING_AXIS, animation, + remove ? swipedTabScale * currentScale : currentScale); + animation.alpha(remove ? swipedTabAlpha : 1); + animation.setStartDelay(delay); + animation.start(); + } + + /** + * Animates the removal of a specific tab item. + * + * @param removedTabItem + * The tab item, which should be animated, as an instance of the class {@link TabItem}. + * The tab item may not be null + * @param swipeAnimation + * The animation, which should be used, as an instance of the class {@link + * SwipeAnimation}. The animation may not be null + */ + private void animateRemove(@NonNull final TabItem removedTabItem, + @NonNull final SwipeAnimation swipeAnimation) { + View view = removedTabItem.getView(); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.SWIPE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, + getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.SWIPE)); + animateSwipe(removedTabItem, true, 0, swipeAnimation, + createRemoveAnimationListener(removedTabItem)); + } + + /** + * Animates the position of a specific tab item in order to relocate it. + * + * @param tabItem + * The tab item, which should be animated, as an instance of the class {@link TabItem}. + * The tab item may not be null + * @param position + * The position, the tab should be relocated to, in pixels as a {@link Float} value + * @param tag + * The tag, which should be applied to the given tab item, as an instance of the class + * {@link Tag} or null, if no tag should be applied + * @param delay + * The delay of the relocate animation in milliseconds as a {@link Long} value + * @param listener + * The listener, which should be notified about the progress of the relocate animation, + * as an instance of the type {@link AnimatorListener} or null, if no listener should be + * notified + */ + private void animateRelocate(@NonNull final TabItem tabItem, final float position, + @Nullable final Tag tag, final long delay, + @Nullable final AnimatorListener listener) { + if (tag != null) { + tabItem.getView().setTag(R.id.tag_properties, tag); + tabItem.setTag(tag); + } + + View view = tabItem.getView(); + ViewPropertyAnimator animation = view.animate(); + animation.setListener(new AnimationListenerWrapper(listener)); + animation.setInterpolator(new AccelerateDecelerateInterpolator()); + animation.setDuration(relocateAnimationDuration); + getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view, position, true); + animation.setStartDelay(delay); + animation.start(); + } + + /** + * Animates reverting an overshoot at the start. + */ + private void animateRevertStartOvershoot() { + boolean tilted = animateTilt(new AccelerateInterpolator(), maxStartOvershootAngle, + createRevertStartOvershootAnimationListener()); + + if (!tilted) { + animateRevertStartOvershoot(new AccelerateDecelerateInterpolator()); + } + } + + /** + * Animates reverting an overshoot at the start using a specific interpolator. + * + * @param interpolator + * The interpolator, which should be used by the animation, as an instance of the type + * {@link Interpolator}. The interpolator may not be null + */ + private void animateRevertStartOvershoot(@NonNull final Interpolator interpolator) { + TabItem tabItem = TabItem.create(getTabSwitcher(), viewRecycler, 0); + View view = tabItem.getView(); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, + getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE)); + float position = getArithmetics().getPosition(Axis.DRAGGING_AXIS, view); + float targetPosition = tabItem.getTag().getPosition(); + final float startPosition = getArithmetics().getPosition(Axis.DRAGGING_AXIS, view); + ValueAnimator animation = ValueAnimator.ofFloat(targetPosition - position); + animation.setDuration(Math.round(revertOvershootAnimationDuration * Math.abs( + (targetPosition - position) / (float) (stackedTabCount * stackedTabSpacing)))); + animation.addListener(new AnimationListenerWrapper(null)); + animation.setInterpolator(interpolator); + animation.setStartDelay(0); + animation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + + @Override + public void onAnimationUpdate(final ValueAnimator animation) { + TabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.getIndex() == 0) { + View view = tabItem.getView(); + getArithmetics().setPosition(Axis.DRAGGING_AXIS, view, + startPosition + (float) animation.getAnimatedValue()); + } else if (tabItem.isInflated()) { + View firstView = iterator.first().getView(); + View view = tabItem.getView(); + view.setVisibility( + getArithmetics().getPosition(Axis.DRAGGING_AXIS, firstView) <= + getArithmetics().getPosition(Axis.DRAGGING_AXIS, view) ? + View.INVISIBLE : View.VISIBLE); + } + } + } + + }); + + animation.start(); + } + + /** + * Animates reverting an overshoot at the end. + */ + private void animateRevertEndOvershoot() { + animateTilt(new AccelerateDecelerateInterpolator(), maxEndOvershootAngle, null); + } + + /** + * Animates to rotation of all tabs to be reset to normal. + * + * @param interpolator + * The interpolator, which should be used by the animation, as an instance of the type + * {@link Interpolator}. The interpolator may not be null + * @param maxAngle + * The angle, the tabs may be rotated by at maximum, in degrees as a {@link Float} + * value + * @param listener + * The listener, which should be notified about the animation's progress, as an instance + * of the type {@link AnimatorListener} or null, if no listener should be notified + * @return True, if at least one tab was animated, false otherwise + */ + private boolean animateTilt(@NonNull final Interpolator interpolator, final float maxAngle, + @Nullable final AnimatorListener listener) { + TabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).reverse(true).create(); + TabItem tabItem; + boolean result = false; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.isInflated()) { + View view = tabItem.getView(); + + if (getArithmetics().getRotation(Axis.ORTHOGONAL_AXIS, view) != 0) { + ViewPropertyAnimator animation = view.animate(); + animation.setListener(new AnimationListenerWrapper( + createRevertOvershootAnimationListener(view, + !result ? listener : null))); + animation.setDuration(Math.round(revertOvershootAnimationDuration * + (Math.abs(getArithmetics().getRotation(Axis.ORTHOGONAL_AXIS, view)) / + maxAngle))); + animation.setInterpolator(interpolator); + getArithmetics().animateRotation(Axis.ORTHOGONAL_AXIS, animation, 0); + animation.setStartDelay(0); + animation.start(); + result = true; + } + } + } + + return result; + } + + /** + * Starts a reveal animation to add a specific tab. + * + * @param tabItem + * The tab item, which corresponds to the tab, which should be added, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @param revealAnimation + * The reveal animation, which should be started, as an instance of the class {@link + * RevealAnimation}. The reveal animation may not be null + */ + private void animateReveal(@NonNull final TabItem tabItem, + @NonNull final RevealAnimation revealAnimation) { + tabViewBottomMargin = -1; + recyclerAdapter.clearCachedPreviews(); + dragHandler.setCallback(null); + View view = tabItem.getView(); + ViewPropertyAnimator animation = view.animate(); + animation.setInterpolator( + revealAnimation.getInterpolator() != null ? revealAnimation.getInterpolator() : + new AccelerateDecelerateInterpolator()); + animation.setListener(new AnimationListenerWrapper(createHideSwitcherAnimationListener())); + animation.setStartDelay(0); + animation.setDuration(revealAnimation.getDuration() != -1 ? revealAnimation.getDuration() : + revealAnimationDuration); + getArithmetics().animateScale(Axis.DRAGGING_AXIS, animation, 1); + getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animation, 1); + animation.start(); + animateToolbarVisibility(getModel().areToolbarsShown() && getModel().isEmpty(), 0); + } + + /** + * Starts a peek animation to add a specific tab. + * + * @param tabItem + * The tab item, which corresponds to the tab, which should be added, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @param duration + * The duration of the animation in milliseconds as a {@link Long} value + * @param interpolator + * The interpolator, which should be used by the animation, as an instance of the type + * {@link Interpolator}. The interpolator may not be null + * @param peekPosition + * The position on the dragging axis, the tab should be moved to, in pixels as a {@link + * Float} value + * @param peekAnimation + * The peek animation, which has been used to add the tab, as an instance of the class + * {@link PeekAnimation}. The peek animation may not be null + */ + private void animatePeek(@NonNull final TabItem tabItem, final long duration, + @NonNull final Interpolator interpolator, final float peekPosition, + @NonNull final PeekAnimation peekAnimation) { + PhoneTabViewHolder viewHolder = tabItem.getViewHolder(); + viewHolder.closeButton.setVisibility(View.GONE); + View view = tabItem.getView(); + float x = peekAnimation.getX(); + float y = peekAnimation.getY() + tabTitleContainerHeight; + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams(); + view.setAlpha(1f); + getArithmetics().setPivot(Axis.X_AXIS, view, x); + getArithmetics().setPivot(Axis.Y_AXIS, view, y); + view.setX(layoutParams.leftMargin); + view.setY(layoutParams.topMargin); + getArithmetics().setScale(Axis.DRAGGING_AXIS, view, 0); + getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, 0); + ViewPropertyAnimator animation = view.animate(); + animation.setInterpolator(interpolator); + animation.setListener( + new AnimationListenerWrapper(createPeekAnimationListener(tabItem, peekAnimation))); + animation.setStartDelay(0); + animation.setDuration(duration); + getArithmetics().animateScale(Axis.DRAGGING_AXIS, animation, 1); + getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animation, 1); + getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view, peekPosition, true); + animation.start(); + int selectedTabIndex = getModel().getSelectedTabIndex(); + TabItem selectedTabItem = TabItem.create(getModel(), viewRecycler, selectedTabIndex); + viewRecycler.inflate(selectedTabItem); + selectedTabItem.getTag().setPosition(0); + PhoneTabViewHolder selectedTabViewHolder = selectedTabItem.getViewHolder(); + selectedTabViewHolder.closeButton.setVisibility(View.GONE); + animateShowSwitcher(selectedTabItem, duration, interpolator, + createZoomOutAnimationListener(selectedTabItem, peekAnimation)); + } + + /** + * Creates and returns a layout listener, which allows to animate the position and size of a tab + * in order to show the tab switcher, once its view has been inflated. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose view should be animated, as an + * instance of the class {@link TabItem}. The tab item may not be null + * @return The listener, which has been created, as an instance of the type {@link + * OnGlobalLayoutListener}. The listener may not be null + */ + @NonNull + private OnGlobalLayoutListener createShowSwitcherLayoutListener( + @NonNull final TabItem tabItem) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + animateShowSwitcher(tabItem, createUpdateViewAnimationListener(tabItem)); + } + + }; + } + + /** + * Creates and returns a layout listener, which allows to animate the position and size of a tab + * in order to hide the tab switcher, once its view has been inflated. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose view should be animated, as an + * instance of the class {@link TabItem}. The tab item may not be null + * @return The listener, which has been created, as an instance of the type {@link + * OnGlobalLayoutListener}. The listener may not be null + */ + @NonNull + private OnGlobalLayoutListener createHideSwitcherLayoutListener( + @NonNull final TabItem tabItem) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + animateHideSwitcher(tabItem, + tabItem.getIndex() == getModel().getSelectedTabIndex() ? + createHideSwitcherAnimationListener() : null); + } + + }; + } + + /** + * Creates and returns a layout listener, which allows to remove a tab, once its view has been + * inflated. + * + * @param tabItem + * The tab item, which corresponds to the tab, which should be removed, as an instance + * of the class {@link TabItem}. The tab item may not be null + * @param swipeAnimation + * The animation, which should be used, as an instance of the class {@link + * SwipeAnimation}. The animation may not be null + * @return The listener, which has been created, as an instance of the type {@link + * OnGlobalLayoutListener}. The listener may not be null + */ + @NonNull + private OnGlobalLayoutListener createRemoveLayoutListener(@NonNull final TabItem tabItem, + @NonNull final SwipeAnimation swipeAnimation) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + animateRemove(tabItem, swipeAnimation); + } + + }; + } + + /** + * Creates and returns a layout listener, which allows to relocate a tab, once its view has been + * inflated. + * + * @param tabItem + * The tab item, which corresponds to the tab, which should be relocated, as an instance + * of the class {@link TabItem}. The tab item may not be null + * @param position + * The position, the tab should be relocated to, in pixels as a {@link Float} value + * @param tag + * The tag, which should be applied to the given tab item, as an instance of the class + * {@link Tag} or null, if no tag should be applied + * @param delay + * The delay of the relocate animation in milliseconds as a {@link Long} value + * @param listener + * The listener, which should be notified about the progress of the relocate animation, + * as an instance of the type {@link AnimatorListener} or null, if no listener should be + * notified + * @return The listener, which has been created, as an instance of the class {@link + * OnGlobalLayoutListener}. The listener may not be null + */ + @NonNull + private OnGlobalLayoutListener createRelocateLayoutListener(@NonNull final TabItem tabItem, + final float position, + @Nullable final Tag tag, + final long delay, + @Nullable final AnimatorListener listener) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + animateRelocate(tabItem, position, tag, delay, listener); + } + + }; + } + + /** + * Creates and returns a layout listener, which allows to show a tab as the currently selected + * one, once it view has been inflated. + * + * @param tabItem + * The tab item, which corresponds to the tab, which has been added, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @return The listener, which has been created, as an instance of the type {@link + * OnGlobalLayoutListener}. The listener may not be null + */ + @NonNull + private OnGlobalLayoutListener createAddSelectedTabLayoutListener( + @NonNull final TabItem tabItem) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + View view = tabItem.getView(); + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) view.getLayoutParams(); + view.setAlpha(1f); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, + getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE)); + view.setX(layoutParams.leftMargin); + view.setY(layoutParams.topMargin); + getArithmetics().setScale(Axis.DRAGGING_AXIS, view, 1); + getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, 1); + } + + }; + } + + /** + * Creates and returns a layout listener, which allows to start a reveal animation to add a tab, + * once its view has been inflated. + * + * @param tabItem + * The tab item, which corresponds to the tab, which should be added, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @param revealAnimation + * The reveal animation, which should be started, as an instance of the class {@link + * RevealAnimation}. The reveal animation may not be null + * @return The listener, which has been created, as an instance of the type {@link + * OnGlobalLayoutListener}. The listener may not be null + */ + @NonNull + private OnGlobalLayoutListener createRevealLayoutListener(@NonNull final TabItem tabItem, + @NonNull final RevealAnimation revealAnimation) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + View view = tabItem.getView(); + float x = revealAnimation.getX(); + float y = revealAnimation.getY() + tabTitleContainerHeight; + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) view.getLayoutParams(); + view.setAlpha(1f); + getArithmetics().setPivot(Axis.X_AXIS, view, x); + getArithmetics().setPivot(Axis.Y_AXIS, view, y); + view.setX(layoutParams.leftMargin); + view.setY(layoutParams.topMargin); + getArithmetics().setScale(Axis.DRAGGING_AXIS, view, 0); + getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, 0); + animateReveal(tabItem, revealAnimation); + } + + }; + } + + /** + * Creates and returns a layout listener, which allows to start a peek animation to add a tab, + * once its view has been inflated. + * + * @param tabItem + * The tab item, which corresponds to the tab, which should be added, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @param peekAnimation + * The peek animation, which should be started, as an instance of the class {@link + * PeekAnimation}. The peek animation may not be null + * @return The listener, which has been created, as an instance of the type {@link + * OnGlobalLayoutListener}. The listener may not be null + */ + private OnGlobalLayoutListener createPeekLayoutListener(@NonNull final TabItem tabItem, + @NonNull final PeekAnimation peekAnimation) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + long totalDuration = + peekAnimation.getDuration() != -1 ? peekAnimation.getDuration() : + peekAnimationDuration; + long duration = totalDuration / 3; + Interpolator interpolator = + peekAnimation.getInterpolator() != null ? peekAnimation.getInterpolator() : + new AccelerateDecelerateInterpolator(); + float peekPosition = + getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS, false) * 0.66f; + animatePeek(tabItem, duration, interpolator, peekPosition, peekAnimation); + } + + }; + } + + /** + * Creates and returns a layout listener, which allows to start a swipe animations to add + * several tabs, once their views have been inflated. + * + * @param addedTabItems + * An array, which contains the tab items, which correspond to the tabs, which should be + * added, as an array of the type {@link TabItem}. The array may not be null + * @param swipeAnimation + * The swipe animation, which should be started, as an instance of the class {@link + * SwipeAnimation}. The swipe animation may not be null + * @return The listener, which has been created, as an instance of the type {@link + * OnGlobalLayoutListener}. The listener may not be null + */ + @NonNull + private OnGlobalLayoutListener createSwipeLayoutListener(@NonNull final TabItem[] addedTabItems, + @NonNull final SwipeAnimation swipeAnimation) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + int count = getModel().getCount(); + float previousAttachedPosition = + calculateAttachedPosition(count - addedTabItems.length); + float attachedPosition = calculateAttachedPosition(count); + TabItem[] tabItems; + + if (count - addedTabItems.length == 0) { + tabItems = calculateInitialTabItems(-1, -1); + } else { + TabItem firstAddedTabItem = addedTabItems[0]; + int index = firstAddedTabItem.getIndex(); + boolean isReferencingPredecessor = index > 0; + int referenceIndex = isReferencingPredecessor ? index - 1 : + (index + addedTabItems.length - 1 < count - 1 ? + index + addedTabItems.length : -1); + TabItem referenceTabItem = referenceIndex != -1 ? + TabItem.create(getTabSwitcher(), viewRecycler, referenceIndex) : null; + State state = + referenceTabItem != null ? referenceTabItem.getTag().getState() : null; + + if (state == null || state == State.STACKED_START) { + tabItems = relocateWhenAddingStackedTabs(true, addedTabItems); + } else if (state == State.STACKED_END) { + tabItems = relocateWhenAddingStackedTabs(false, addedTabItems); + } else if (state == State.FLOATING || + (state == State.STACKED_START_ATOP && (index > 0 || count <= 2))) { + tabItems = relocateWhenAddingFloatingTabs(addedTabItems, referenceTabItem, + isReferencingPredecessor, attachedPosition, + attachedPosition != previousAttachedPosition); + } else { + tabItems = relocateWhenAddingHiddenTabs(addedTabItems, referenceTabItem); + } + } + + Tag previousTag = null; + + for (TabItem tabItem : tabItems) { + Tag tag = tabItem.getTag(); + + if (previousTag == null || tag.getPosition() != previousTag.getPosition()) { + createBottomMarginLayoutListener(tabItem).onGlobalLayout(); + View view = tabItem.getView(); + view.setTag(R.id.tag_properties, tag); + view.setAlpha(swipedTabAlpha); + float swipePosition = calculateSwipePosition(); + float scale = getArithmetics().getScale(view, true); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, getArithmetics() + .getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, getArithmetics() + .getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE)); + getArithmetics().setPosition(Axis.DRAGGING_AXIS, view, tag.getPosition()); + getArithmetics().setPosition(Axis.ORTHOGONAL_AXIS, view, + swipeAnimation.getDirection() == SwipeDirection.LEFT ? + -1 * swipePosition : swipePosition); + getArithmetics().setScale(Axis.DRAGGING_AXIS, view, scale); + getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, scale); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, getArithmetics() + .getPivot(Axis.DRAGGING_AXIS, view, DragState.SWIPE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, getArithmetics() + .getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.SWIPE)); + getArithmetics().setScale(Axis.DRAGGING_AXIS, view, swipedTabScale * scale); + getArithmetics() + .setScale(Axis.ORTHOGONAL_AXIS, view, swipedTabScale * scale); + animateSwipe(tabItem, false, 0, swipeAnimation, + createSwipeAnimationListener(tabItem)); + } else { + viewRecycler.remove(tabItem); + } + + previousTag = tag; + } + } + + }; + } + + /** + * Creates and returns a layout listener, which allows to adapt the bottom margin of a tab, once + * its view has been inflated. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose view should be adapted, as an + * instance of the class {@link TabItem}. The tab item may not be null + * @return The layout listener, which has been created, as an instance of the type {@link + * OnGlobalLayoutListener}. The layout listener may not be null + */ + private OnGlobalLayoutListener createBottomMarginLayoutListener( + @NonNull final TabItem tabItem) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + View view = tabItem.getView(); + + if (tabViewBottomMargin == -1) { + tabViewBottomMargin = calculateBottomMargin(view); + } + + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) view.getLayoutParams(); + layoutParams.bottomMargin = tabViewBottomMargin; + view.setLayoutParams(layoutParams); + } + + }; + } + + /** + * Creates and returns a layout listener, which allows to adapt the size and position of a tab, + * once its view has been inflated. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose view should be adapted, as an + * instance of the class {@link TabItem}. The tab item may not be null + * @param layoutListener + * The layout lister, which should be notified, when the created listener is invoked, as + * an instance of the type {@link OnGlobalLayoutListener} or null, if no listener should + * be notified + * @return The layout listener, which has been created, as an instance of the type {@link + * OnGlobalLayoutListener}. The layout listener may not be null + */ + @NonNull + private OnGlobalLayoutListener createInflateViewLayoutListener(@NonNull final TabItem tabItem, + @Nullable final OnGlobalLayoutListener layoutListener) { + return new OnGlobalLayoutListener() { + + @Override + public void onGlobalLayout() { + adaptViewSize(tabItem); + updateView(tabItem); + + if (layoutListener != null) { + layoutListener.onGlobalLayout(); + } + } + + }; + } + + /** + * Creates and returns an animation listener, which allows to update the view, which is used to + * visualize a specific tab, when an animation has been finished. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose view should be updated, as an + * instance of the class {@link TabItem}. The tab item may not be null + * @return The animation listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createUpdateViewAnimationListener(@NonNull final TabItem tabItem) { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + inflateOrRemoveView(tabItem); + } + + }; + } + + /** + * Creates and returns an animation listener, which allows to inflate or remove the views, which + * are used to visualize tabs, when an animation, which is used to hide the tab switcher, + * has been finished. + * + * @return The animation listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createHideSwitcherAnimationListener() { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + AbstractTabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.getTab() == getModel().getSelectedTab()) { + Pair pair = viewRecycler.inflate(tabItem); + View view = pair.first; + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) view.getLayoutParams(); + view.setAlpha(1f); + getArithmetics().setScale(Axis.DRAGGING_AXIS, view, 1); + getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, 1); + view.setX(layoutParams.leftMargin); + view.setY(layoutParams.topMargin); + } else { + viewRecycler.remove(tabItem); + } + } + + viewRecycler.clearCache(); + recyclerAdapter.clearCachedPreviews(); + tabViewBottomMargin = -1; + } + + }; + } + + /** + * Creates and returns an animation listener, which allows to remove all tabs, when the + * animation, which is used to swipe all tabs, has been finished. + * + * @return The animation listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createClearAnimationListener() { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + viewRecycler.removeAll(); + animateToolbarVisibility(getModel().areToolbarsShown(), 0); + } + + }; + } + + /** + * Creates and returns a listener, which allows to handle, when a tab has been swiped, but was + * not removed. + * + * @param tabItem + * The tab item, which corresponds to the tab, which has been swiped, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @return The listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createSwipeAnimationListener(@NonNull final TabItem tabItem) { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + inflateOrRemoveView(tabItem); + View view = tabItem.getView(); + adaptStackOnSwipeAborted(tabItem, tabItem.getIndex() + 1); + tabItem.getTag().setClosing(false); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE)); + animateToolbarVisibility(true, 0); + } + + }; + } + + /** + * Creates and returns a listener, which allows to relocate all previous tabs, when a tab has + * been removed. + * + * @param removedTabItem + * The tab item, which corresponds to the tab, which has been removed, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @return The listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createRemoveAnimationListener(@NonNull final TabItem removedTabItem) { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationStart(final Animator animation) { + super.onAnimationStart(animation); + + if (getModel().isEmpty()) { + animateToolbarVisibility(getModel().areToolbarsShown(), 0); + } + + float previousAttachedPosition = + calculateAttachedPosition(getModel().getCount() + 1); + float attachedPosition = calculateAttachedPosition(getModel().getCount()); + State state = removedTabItem.getTag().getState(); + + if (state == State.STACKED_END) { + relocateWhenRemovingStackedTab(removedTabItem, false); + } else if (state == State.STACKED_START) { + relocateWhenRemovingStackedTab(removedTabItem, true); + } else if (state == State.FLOATING || state == State.STACKED_START_ATOP) { + relocateWhenRemovingFloatingTab(removedTabItem, attachedPosition, + previousAttachedPosition != attachedPosition); + } + } + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + viewRecycler.remove(removedTabItem); + } + + }; + } + + /** + * Creates and returns an animation listener, which allows to update or remove the view, which + * is used to visualize a tab, when the animation, which has been used to relocate it, has been + * ended. + * + * @param tabItem + * The tab item, which corresponds to the tab, which has been relocated, as an instance + * of the class {@link TabItem}. The tab item may not be null + * @return The listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createRelocateAnimationListener(@NonNull final TabItem tabItem) { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationStart(final Animator animation) { + super.onAnimationStart(animation); + tabItem.getView().setVisibility(View.VISIBLE); + } + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + + if (tabItem.getTag().getState() == State.STACKED_START_ATOP) { + adaptStackOnSwipeAborted(tabItem, tabItem.getIndex() + 1); + } + + if (tabItem.isVisible()) { + updateView(tabItem); + } else { + viewRecycler.remove(tabItem); + } + } + + }; + } + + /** + * Creates and returns an animation listener, which allows to adapt the pivot of a specific + * view, when an animation, which reverted an overshoot, has been ended. + * + * @param view + * The view, whose pivot should be adapted, as an instance of the class {@link View}. + * The view may not be null + * @param listener + * The listener, which should be notified about the animation's progress, as an instance + * of the type {@link AnimatorListener} or null, if no listener should be notified + * @return The listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createRevertOvershootAnimationListener(@NonNull final View view, + @Nullable final AnimatorListener listener) { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE)); + + if (listener != null) { + listener.onAnimationEnd(animation); + } + } + + }; + } + + /** + * Creates and returns an animation listener, which allows to revert an overshoot at the start, + * when an animation has been ended. + * + * @return The listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createRevertStartOvershootAnimationListener() { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + animateRevertStartOvershoot(new DecelerateInterpolator()); + } + + }; + } + + /** + * Creates and returns an animation listener, which allows to hide a tab, which has been added + * by using a peek animation, when the animation has been ended. + * + * @param tabItem + * The tab item, which corresponds to the tab, which has been added by using the peek + * animation, as an instance of the class {@link TabItem}. The tab item may not be null + * @param peekAnimation + * The peek animation as an instance of the class {@link PeekAnimation}. The peek + * animation may not be null + * @return The listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createPeekAnimationListener(@NonNull final TabItem tabItem, + @NonNull final PeekAnimation peekAnimation) { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + long totalDuration = + peekAnimation.getDuration() != -1 ? peekAnimation.getDuration() : + peekAnimationDuration; + long duration = totalDuration / 3; + Interpolator interpolator = + peekAnimation.getInterpolator() != null ? peekAnimation.getInterpolator() : + new AccelerateDecelerateInterpolator(); + View view = tabItem.getView(); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, tabTitleContainerHeight); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, + getArithmetics().getSize(Axis.ORTHOGONAL_AXIS, view) / 2f); + ViewPropertyAnimator animator = view.animate(); + animator.setDuration(duration); + animator.setStartDelay(duration); + animator.setInterpolator(interpolator); + animator.setListener( + new AnimationListenerWrapper(createRevertPeekAnimationListener(tabItem))); + animator.alpha(0); + getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animator, view, + getArithmetics().getPosition(Axis.DRAGGING_AXIS, view) * 1.5f, false); + getArithmetics().animateScale(Axis.DRAGGING_AXIS, animator, 0); + getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animator, 0); + animator.start(); + } + + }; + } + + /** + * Creates and returns an animation listener, which allows to remove the view of a tab, which + * has been added by using a peek animation, when the animation, which reverts the peek + * animation, has been ended. + * + * @param tabItem + * The tab item, which corresponds to the tab, which has been added by using the peek + * animation, as an instance of the class {@link TabItem}. The tab item may not be null + * @return The listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createRevertPeekAnimationListener(@NonNull final TabItem tabItem) { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + viewRecycler.remove(tabItem); + } + + }; + } + + /** + * Creates and returns an animation listener, which allows to zoom in the currently selected + * tab, when a peek animation has been ended. + * + * @param selectedTabItem + * The tab item, which corresponds to the currently selected tab, as an instance of the + * class {@link TabItem}. The tab item may not be null + * @param peekAnimation + * The peek animation as an instance of the class {@link PeekAnimation}. The peek + * animation may not be null + * @return The listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + @NonNull + private AnimatorListener createZoomOutAnimationListener(@NonNull final TabItem selectedTabItem, + @NonNull final PeekAnimation peekAnimation) { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + getModel().removeListener(PhoneTabSwitcherLayout.this); + getModel().hideSwitcher(); + long totalDuration = + peekAnimation.getDuration() != -1 ? peekAnimation.getDuration() : + peekAnimationDuration; + long duration = totalDuration / 3; + Interpolator interpolator = + peekAnimation.getInterpolator() != null ? peekAnimation.getInterpolator() : + new AccelerateDecelerateInterpolator(); + animateHideSwitcher(selectedTabItem, duration, interpolator, duration, + createZoomInAnimationListener(selectedTabItem)); + } + + }; + } + + /** + * Creates and returns an animation listener, which allows to restore the original state of a + * tab, when an animation, which zooms in the tab, has been ended. + * + * @param tabItem + * The tab item, which corresponds to the tab, which has been zoomed in, as an instance + * of the class {@link TabItem}. The tab item may not be null + * @return The listener, which has been created, as an instance of the type {@link + * AnimatorListener}. The listener may not be null + */ + private AnimatorListener createZoomInAnimationListener(@NonNull final TabItem tabItem) { + return new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(final Animator animation) { + super.onAnimationEnd(animation); + getModel().addListener(PhoneTabSwitcherLayout.this); + viewRecycler.inflate(tabItem); + viewRecycler.clearCache(); + recyclerAdapter.clearCachedPreviews(); + tabViewBottomMargin = -1; + } + + }; + } + + /** + * Adapts the stack, which is located at the start, when swiping a tab. + * + * @param swipedTabItem + * The tab item, which corresponds to the swiped tab, as an instance of the class {@link + * TabItem}. The tab item may not be null + * @param successorIndex + * The index of the tab, which is located after the swiped tab, as an {@link Integer} + * value + * @param count + * The number of tabs, which are contained by the tab switcher, excluding the swiped + * tab, as an {@link Integer} value + */ + private void adaptStackOnSwipe(@NonNull final TabItem swipedTabItem, final int successorIndex, + final int count) { + if (swipedTabItem.getTag().getState() == State.STACKED_START_ATOP && + successorIndex < getModel().getCount()) { + TabItem tabItem = TabItem.create(getTabSwitcher(), viewRecycler, successorIndex); + State state = tabItem.getTag().getState(); + + if (state == State.HIDDEN || state == State.STACKED_START) { + Pair pair = + calculatePositionAndStateWhenStackedAtStart(count, swipedTabItem.getIndex(), + (TabItem) null); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + inflateOrRemoveView(tabItem); + } + } + } + + /** + * Adapts the stack, which located at the start, when swiping a tab has been aborted. + * + * @param swipedTabItem + * The tab item, which corresponds to the swiped tab, as an instance of the class {@link + * TabItem}. The tab item may not be null + * @param successorIndex + * The index of the the tab, which is located after the swiped tab, as an {@link + * Integer} value + */ + private void adaptStackOnSwipeAborted(@NonNull final TabItem swipedTabItem, + final int successorIndex) { + if (swipedTabItem.getTag().getState() == State.STACKED_START_ATOP && + successorIndex < getModel().getCount()) { + TabItem tabItem = TabItem.create(getTabSwitcher(), viewRecycler, successorIndex); + + if (tabItem.getTag().getState() == State.STACKED_START_ATOP) { + Pair pair = + calculatePositionAndStateWhenStackedAtStart(getTabSwitcher().getCount(), + tabItem.getIndex(), swipedTabItem); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + inflateOrRemoveView(tabItem); + } + } + } + + /** + * Inflates or removes the view, which is used to visualize a specific tab, depending on the + * tab's current state. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose view should be inflated or removed, + * as an instance of the class {@link TabItem}. The tab item may not be null + */ + private void inflateOrRemoveView(@NonNull final TabItem tabItem) { + if (tabItem.isInflated() && !tabItem.isVisible()) { + viewRecycler.remove(tabItem); + } else if (tabItem.isVisible()) { + if (!tabItem.isInflated()) { + inflateAndUpdateView(tabItem, null); + } else { + updateView(tabItem); + } + } + } + + /** + * Inflates and updates the view, which is used to visualize a specific tab. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose view should be inflated, as an + * instance of the class {@link TabItem}. The tab item may not be null + * @param listener + * The layout listener, which should be notified, when the view has been inflated, as an + * instance of the type {@link OnGlobalLayoutListener} or null, if no listener should be + * notified + */ + private void inflateAndUpdateView(@NonNull final TabItem tabItem, + @Nullable final OnGlobalLayoutListener listener) { + inflateView(tabItem, createInflateViewLayoutListener(tabItem, listener), + tabViewBottomMargin); + } + + /** + * Inflates the view, which is used to visualize a specific tab. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose view should be inflated, as an + * instance of the class {@link TabItem}. The tab item may not be null + * @param listener + * The layout listener, which should be notified, when the view has been inflated, as an + * instance of the type {@link OnGlobalLayoutListener} or null, if no listener should be + * notified + * @param params + * An array, which contains optional parameters, which should be passed to the view + * recycler, which is used to inflate the view, as an array of the type {@link Integer}. + * The array may not be null + */ + private void inflateView(@NonNull final TabItem tabItem, + @Nullable final OnGlobalLayoutListener listener, + @NonNull final Integer... params) { + Pair pair = viewRecycler.inflate(tabItem, params); + + if (listener != null) { + boolean inflated = pair.second; + + if (inflated) { + View view = pair.first; + view.getViewTreeObserver() + .addOnGlobalLayoutListener(new LayoutListenerWrapper(view, listener)); + } else { + listener.onGlobalLayout(); + } + } + } + + /** + * Adapts the size of the view, which is used to visualize a specific tab. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose view should be adapted, as an + * instance of the class {@link TabItem}. The tab item may not be null + */ + private void adaptViewSize(@NonNull final TabItem tabItem) { + View view = tabItem.getView(); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, + getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE)); + float scale = getArithmetics().getScale(view, true); + getArithmetics().setScale(Axis.DRAGGING_AXIS, view, scale); + getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, scale); + } + + /** + * Updates the view, which is used to visualize a specific tab. + * + * @param tabItem + * The tab item, which corresponds to the tab, whose view should be updated, as an + * instance of the class {@link TabItem}. The tab item may not be null + */ + private void updateView(@NonNull final TabItem tabItem) { + float position = tabItem.getTag().getPosition(); + View view = tabItem.getView(); + view.setAlpha(1f); + view.setVisibility(View.VISIBLE); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, + getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE)); + getArithmetics().setPosition(Axis.DRAGGING_AXIS, view, position); + getArithmetics().setPosition(Axis.ORTHOGONAL_AXIS, view, 0); + getArithmetics().setRotation(Axis.ORTHOGONAL_AXIS, view, 0); + } + + /** + * Relocates all previous tabs, when a floating tab has been removed from the tab switcher. + * + * @param removedTabItem + * The tab item, which corresponds to the tab, which has been removed, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @param attachedPositionChanged + * True, if removing the tab caused the attached position to be changed, false + * otherwise + */ + private void relocateWhenRemovingFloatingTab(@NonNull final TabItem removedTabItem, + final float attachedPosition, + boolean attachedPositionChanged) { + AbstractTabItemIterator iterator; + TabItem tabItem; + float defaultTabSpacing = calculateMaxTabSpacing(getModel().getCount(), null); + float minTabSpacing = calculateMinTabSpacing(getModel().getCount()); + int referenceIndex = removedTabItem.getIndex(); + TabItem currentReferenceTabItem = removedTabItem; + float referencePosition = removedTabItem.getTag().getPosition(); + + if (attachedPositionChanged && getModel().getCount() > 0) { + int neighboringIndex = + removedTabItem.getIndex() > 0 ? referenceIndex - 1 : referenceIndex; + referencePosition += Math.abs( + TabItem.create(getTabSwitcher(), viewRecycler, neighboringIndex).getTag() + .getPosition() - referencePosition) / 2f; + } + + referencePosition = + Math.min(calculateEndPosition(removedTabItem.getIndex() - 1), referencePosition); + float initialReferencePosition = referencePosition; + + if (removedTabItem.getIndex() > 0) { + int selectedTabIndex = getModel().getSelectedTabIndex(); + TabItem selectedTabItem = + TabItem.create(getTabSwitcher(), viewRecycler, selectedTabIndex); + float maxTabSpacing = calculateMaxTabSpacing(getModel().getCount(), selectedTabItem); + iterator = new TabItemIterator.Builder(getTabSwitcher(), viewRecycler) + .start(removedTabItem.getIndex() - 1).reverse(true).create(); + + while ((tabItem = iterator.next()) != null) { + TabItem predecessor = iterator.peek(); + float currentTabSpacing = + calculateMaxTabSpacing(getModel().getCount(), currentReferenceTabItem); + Pair pair; + + if (tabItem.getIndex() == removedTabItem.getIndex() - 1) { + pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), + referencePosition, predecessor); + currentReferenceTabItem = tabItem; + referencePosition = pair.first; + referenceIndex = tabItem.getIndex(); + } else if (referencePosition >= attachedPosition - currentTabSpacing) { + float position; + + if (selectedTabIndex > tabItem.getIndex() && + selectedTabIndex <= referenceIndex) { + position = referencePosition + maxTabSpacing + + ((referenceIndex - tabItem.getIndex() - 1) * defaultTabSpacing); + } else { + position = referencePosition + + ((referenceIndex - tabItem.getIndex()) * defaultTabSpacing); + } + + pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), position, + predecessor); + } else { + TabItem successor = iterator.previous(); + float successorPosition = successor.getTag().getPosition(); + float position = (attachedPosition * (successorPosition + minTabSpacing)) / + (minTabSpacing + attachedPosition - currentTabSpacing); + pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), position, + predecessor); + + if (pair.first >= attachedPosition - currentTabSpacing) { + currentReferenceTabItem = tabItem; + referencePosition = pair.first; + referenceIndex = tabItem.getIndex(); + } + } + + Tag tag = tabItem.getTag().clone(); + tag.setPosition(pair.first); + tag.setState(pair.second); + + if (tag.getState() != State.HIDDEN) { + long startDelay = Math.abs(removedTabItem.getIndex() - tabItem.getIndex()) * + relocateAnimationDelay; + + if (!tabItem.isInflated()) { + Pair pair2 = + calculatePositionAndStateWhenStackedAtEnd(tabItem.getIndex()); + tabItem.getTag().setPosition(pair2.first); + tabItem.getTag().setState(pair2.second); + } + + relocate(tabItem, tag.getPosition(), tag, startDelay); + } else { + break; + } + } + } + + if (attachedPositionChanged && getModel().getCount() > 2 && + removedTabItem.getTag().getState() != State.STACKED_START_ATOP) { + iterator = new TabItemIterator.Builder(getTabSwitcher(), viewRecycler) + .start(removedTabItem.getIndex()).create(); + float previousPosition = initialReferencePosition; + Tag previousTag = removedTabItem.getTag(); + + while ((tabItem = iterator.next()) != null && + tabItem.getIndex() < getModel().getCount() - 1) { + float position = calculateNonLinearPosition(previousPosition, + calculateMaxTabSpacing(getModel().getCount(), tabItem)); + Pair pair = + clipTabPosition(getModel().getCount(), tabItem.getIndex(), position, + previousTag.getState()); + Tag tag = tabItem.getTag().clone(); + tag.setPosition(pair.first); + tag.setState(pair.second); + long startDelay = (Math.abs(removedTabItem.getIndex() - tabItem.getIndex()) + 1) * + relocateAnimationDelay; + + if (!tabItem.isInflated()) { + Pair pair2 = + calculatePositionAndStateWhenStackedAtStart(getModel().getCount(), + tabItem.getIndex(), iterator.previous()); + tabItem.getTag().setPosition(pair2.first); + tabItem.getTag().setState(pair2.second); + } + + relocate(tabItem, tag.getPosition(), tag, startDelay); + previousPosition = pair.first; + previousTag = tag; + + if (pair.second == State.HIDDEN || pair.second == State.STACKED_START) { + break; + } + } + } + } + + /** + * Relocates all neighboring tabs, when a stacked tab has been removed from the tab switcher. + * + * @param removedTabItem + * The tab item, which corresponds to the tab, which has been removed, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @param start + * True, if the removed tab was part of the stack, which is located at the start, false, + * if it was part of the stack, which is located at the end + */ + private void relocateWhenRemovingStackedTab(@NonNull final TabItem removedTabItem, + final boolean start) { + int startIndex = removedTabItem.getIndex() + (start ? -1 : 0); + TabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).reverse(start) + .start(startIndex).create(); + TabItem tabItem; + float previousProjectedPosition = removedTabItem.getTag().getPosition(); + + while ((tabItem = iterator.next()) != null && + (tabItem.getTag().getState() == State.HIDDEN || + tabItem.getTag().getState() == State.STACKED_START || + tabItem.getTag().getState() == State.STACKED_START_ATOP || + tabItem.getTag().getState() == State.STACKED_END)) { + float projectedPosition = tabItem.getTag().getPosition(); + + if (tabItem.getTag().getState() == State.HIDDEN) { + TabItem previous = iterator.previous(); + tabItem.getTag().setState(previous.getTag().getState()); + + if (tabItem.isVisible()) { + Pair pair = start ? + calculatePositionAndStateWhenStackedAtStart(getTabSwitcher().getCount(), + tabItem.getIndex(), tabItem) : + calculatePositionAndStateWhenStackedAtEnd(tabItem.getIndex()); + tabItem.getTag().setPosition(pair.first); + tabItem.getTag().setState(pair.second); + inflateAndUpdateView(tabItem, null); + } + + break; + } else { + tabItem.getTag().setPosition(previousProjectedPosition); + long startDelay = + (Math.abs(startIndex - tabItem.getIndex()) + 1) * relocateAnimationDelay; + animateRelocate(tabItem, previousProjectedPosition, null, startDelay, + createRelocateAnimationListener(tabItem)); + } + + previousProjectedPosition = projectedPosition; + } + } + + /** + * Relocates all previous tabs, when floating tabs have been added to the tab switcher. + * + * @param addedTabItems + * An array, which contains the tab items, which correspond to the tabs, which have been + * added, as an array of the type {@link TabItem}. The array may not be null + * @param referenceTabItem + * The tab item, which corresponds to the tab, which is used as a reference, as an + * instance of the class {@link TabItem}. The tab item may not be null + * @param isReferencingPredecessor + * True, if the tab, which is used as a reference, is the predecessor of the added tab, + * false if it is the successor + * @param attachedPosition + * The current attached position in pixels as a {@link Float} value + * @param attachedPositionChanged + * True, if adding the tab caused the attached position to be changed, false otherwise + * @return An array, which contains the tab items, which correspond to the tabs, which have been + * added, as an array of the type {@link TabItem}. The array may not be null + */ + @NonNull + private TabItem[] relocateWhenAddingFloatingTabs(@NonNull final TabItem[] addedTabItems, + @NonNull final TabItem referenceTabItem, + final boolean isReferencingPredecessor, + final float attachedPosition, + final boolean attachedPositionChanged) { + int count = getTabSwitcher().getCount(); + TabItem firstAddedTabItem = addedTabItems[0]; + TabItem lastAddedTabItem = addedTabItems[addedTabItems.length - 1]; + + float referencePosition = referenceTabItem.getTag().getPosition(); + + if (isReferencingPredecessor && attachedPositionChanged && + lastAddedTabItem.getIndex() < count - 1) { + int neighboringIndex = lastAddedTabItem.getIndex() + 1; + referencePosition -= Math.abs(referencePosition - + TabItem.create(getTabSwitcher(), viewRecycler, neighboringIndex).getTag() + .getPosition()) / 2f; + } + + float initialReferencePosition = referencePosition; + int selectedTabIndex = getModel().getSelectedTabIndex(); + TabItem selectedTabItem = TabItem.create(getTabSwitcher(), viewRecycler, selectedTabIndex); + float defaultTabSpacing = calculateMaxTabSpacing(count, null); + float maxTabSpacing = calculateMaxTabSpacing(count, selectedTabItem); + float minTabSpacing = calculateMinTabSpacing(count); + TabItem currentReferenceTabItem = referenceTabItem; + int referenceIndex = referenceTabItem.getIndex(); + + AbstractTabItemIterator.AbstractBuilder builder = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler); + + for (TabItem addedTabItem : addedTabItems) { + int iterationReferenceIndex = referenceIndex; + float iterationReferencePosition = referencePosition; + TabItem iterationReferenceTabItem = currentReferenceTabItem; + AbstractTabItemIterator iterator = + builder.start(addedTabItem.getIndex()).reverse(true).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + TabItem predecessor = iterator.peek(); + Pair pair; + float currentTabSpacing = calculateMaxTabSpacing(count, iterationReferenceTabItem); + + if (isReferencingPredecessor && tabItem.getIndex() == addedTabItem.getIndex()) { + State predecessorState = + predecessor != null ? predecessor.getTag().getState() : null; + pair = clipTabPosition(count, tabItem.getIndex(), iterationReferencePosition, + predecessorState == State.STACKED_START_ATOP ? State.FLOATING : + predecessorState); + currentReferenceTabItem = iterationReferenceTabItem = tabItem; + initialReferencePosition = + referencePosition = iterationReferencePosition = pair.first; + referenceIndex = iterationReferenceIndex = tabItem.getIndex(); + } else if (iterationReferencePosition >= attachedPosition - currentTabSpacing) { + float position; + + if (selectedTabIndex > tabItem.getIndex() && + selectedTabIndex <= iterationReferenceIndex) { + position = iterationReferencePosition + maxTabSpacing + + ((iterationReferenceIndex - tabItem.getIndex() - 1) * + defaultTabSpacing); + } else { + position = iterationReferencePosition + + ((iterationReferenceIndex - tabItem.getIndex()) * + defaultTabSpacing); + } + + pair = clipTabPosition(count, tabItem.getIndex(), position, predecessor); + } else { + TabItem successor = iterator.previous(); + float successorPosition = successor.getTag().getPosition(); + float position = (attachedPosition * (successorPosition + minTabSpacing)) / + (minTabSpacing + attachedPosition - currentTabSpacing); + pair = clipTabPosition(count, tabItem.getIndex(), position, predecessor); + + if (pair.first >= attachedPosition - currentTabSpacing) { + iterationReferenceTabItem = tabItem; + iterationReferencePosition = pair.first; + iterationReferenceIndex = tabItem.getIndex(); + } + } + + if (tabItem.getIndex() >= firstAddedTabItem.getIndex() && + tabItem.getIndex() <= lastAddedTabItem.getIndex()) { + if (!isReferencingPredecessor && attachedPositionChanged && count > 3) { + TabItem successor = iterator.previous(); + float successorPosition = successor.getTag().getPosition(); + float position = pair.first - Math.abs(pair.first - successorPosition) / 2f; + pair = clipTabPosition(count, tabItem.getIndex(), position, predecessor); + initialReferencePosition = pair.first; + } + + Tag tag = addedTabItems[tabItem.getIndex() - firstAddedTabItem.getIndex()] + .getTag(); + tag.setPosition(pair.first); + tag.setState(pair.second); + } else { + Tag tag = tabItem.getTag().clone(); + tag.setPosition(pair.first); + tag.setState(pair.second); + + if (!tabItem.isInflated()) { + Pair pair2 = + calculatePositionAndStateWhenStackedAtEnd(tabItem.getIndex()); + tabItem.getTag().setPosition(pair2.first); + tabItem.getTag().setState(pair2.second); + } + + relocate(tabItem, tag.getPosition(), tag, 0); + } + + if (pair.second == State.HIDDEN || pair.second == State.STACKED_END) { + firstVisibleIndex++; + break; + } + } + } + + if (attachedPositionChanged && count > 3) { + AbstractTabItemIterator iterator = + builder.start(lastAddedTabItem.getIndex() + 1).reverse(false).create(); + TabItem tabItem; + float previousPosition = initialReferencePosition; + Tag previousTag = lastAddedTabItem.getTag(); + + while ((tabItem = iterator.next()) != null && tabItem.getIndex() < count - 1) { + float position = calculateNonLinearPosition(previousPosition, + calculateMaxTabSpacing(count, tabItem)); + Pair pair = clipTabPosition(count, tabItem.getIndex(), position, + previousTag.getState()); + Tag tag = tabItem.getTag().clone(); + tag.setPosition(pair.first); + tag.setState(pair.second); + + if (!tabItem.isInflated()) { + Pair pair2 = + calculatePositionAndStateWhenStackedAtStart(count, tabItem.getIndex(), + iterator.previous()); + tabItem.getTag().setPosition(pair2.first); + tabItem.getTag().setState(pair2.second); + } + + relocate(tabItem, tag.getPosition(), tag, 0); + previousPosition = pair.first; + previousTag = tag; + + if (pair.second == State.HIDDEN || pair.second == State.STACKED_START) { + break; + } + } + } + + return addedTabItems; + } + + /** + * Relocates all neighboring tabs, when stacked tabs have been added to the tab switcher. + * + * @param start + * True, if the added tab was part of the stack, which is located at the start, false, + * if it was part of the stack, which is located at the end + * @param addedTabItems + * An array, which contains the tab items, which correspond to the tabs, which have been + * added, as an array of the type {@link TabItem}. The array may not be null + * @return An array, which contains the tab items, which correspond to the tabs, which have been + * added, as an array of the type {@link TabItem}. The array may not be null + */ + @NonNull + private TabItem[] relocateWhenAddingStackedTabs(final boolean start, + @NonNull final TabItem[] addedTabItems) { + if (!start) { + firstVisibleIndex += addedTabItems.length; + } + + int count = getTabSwitcher().getCount(); + TabItem firstAddedTabItem = addedTabItems[0]; + TabItem lastAddedTabItem = addedTabItems[addedTabItems.length - 1]; + AbstractTabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler) + .start(start ? lastAddedTabItem.getIndex() : firstAddedTabItem.getIndex()) + .reverse(start).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null && + (tabItem.getTag().getState() == State.STACKED_START || + tabItem.getTag().getState() == State.STACKED_START_ATOP || + tabItem.getTag().getState() == State.STACKED_END || + tabItem.getTag().getState() == State.HIDDEN)) { + TabItem predecessor = start ? iterator.peek() : iterator.previous(); + Pair pair = start ? + calculatePositionAndStateWhenStackedAtStart(count, tabItem.getIndex(), + predecessor) : + calculatePositionAndStateWhenStackedAtEnd(tabItem.getIndex()); + + if (start && predecessor != null && predecessor.getTag().getState() == State.FLOATING) { + float predecessorPosition = predecessor.getTag().getPosition(); + float distance = predecessorPosition - pair.first; + + if (distance > calculateMinTabSpacing(count)) { + float position = calculateNonLinearPosition(tabItem, predecessor); + pair = clipTabPosition(count, tabItem.getIndex(), position, predecessor); + } + } + + if (tabItem.getIndex() >= firstAddedTabItem.getIndex() && + tabItem.getIndex() <= lastAddedTabItem.getIndex()) { + Tag tag = addedTabItems[tabItem.getIndex() - firstAddedTabItem.getIndex()].getTag(); + tag.setPosition(pair.first); + tag.setState(pair.second); + } else if (tabItem.isInflated()) { + Tag tag = tabItem.getTag().clone(); + tag.setPosition(pair.first); + tag.setState(pair.second); + animateRelocate(tabItem, tag.getPosition(), tag, 0, + createRelocateAnimationListener(tabItem)); + } else { + break; + } + } + + return addedTabItems; + } + + /** + * Calculates the position and state of hidden tabs, which have been added to the tab switcher. + * + * @param addedTabItems + * An array, which contains the tab items, which correspond to the tabs, which have been + * added, as an array of the type {@link TabItem}. The array may not be null + * @param referenceTabItem + * The tab item, which corresponds to the tab, which is used as a reference, as an + * instance of the class {@link TabItem}. The tab item may not be null + * @return An array, which contains the tab items, which correspond to the tabs, which have been + * added, as an array of the type {@link TabItem}. The array may not be null + */ + @NonNull + private TabItem[] relocateWhenAddingHiddenTabs(@NonNull final TabItem[] addedTabItems, + @NonNull final TabItem referenceTabItem) { + boolean stackedAtStart = isStackedAtStart(referenceTabItem.getIndex()); + + for (TabItem tabItem : addedTabItems) { + Pair pair; + + if (stackedAtStart) { + TabItem predecessor = tabItem.getIndex() > 0 ? + TabItem.create(getTabSwitcher(), viewRecycler, tabItem.getIndex() - 1) : + null; + pair = calculatePositionAndStateWhenStackedAtStart(getModel().getCount(), + tabItem.getIndex(), predecessor); + } else { + pair = calculatePositionAndStateWhenStackedAtEnd(tabItem.getIndex()); + } + + Tag tag = tabItem.getTag(); + tag.setPosition(pair.first); + tag.setState(pair.second); + } + + return addedTabItems; + } + + /** + * Relocates a specific tab. If its view is now yet inflated, it is inflated first. + * + * @param tabItem + * The tab item, which corresponds to the tab, which should be relocated, as an instance + * of the class {@link TabItem}. The tab item may not be null + * @param relocatePosition + * The position, the tab should be moved to, in pixels as an {@link Float} value + * @param tag + * The tag, which should be applied to the tab, once it has been relocated, as an + * instance of the class {@link Tag} or null, if no tag should be applied + * @param startDelay + * The start delay of the relocate animation in milliseconds as a {@link Long} value + */ + private void relocate(@NonNull final TabItem tabItem, final float relocatePosition, + @Nullable final Tag tag, final long startDelay) { + if (tabItem.isInflated()) { + animateRelocate(tabItem, relocatePosition, tag, startDelay, + createRelocateAnimationListener(tabItem)); + } else { + inflateAndUpdateView(tabItem, + createRelocateLayoutListener(tabItem, relocatePosition, tag, startDelay, + createRelocateAnimationListener(tabItem))); + tabItem.getView().setVisibility(View.INVISIBLE); + } + } + + /** + * Swipes a specific tab. + * + * @param tabItem + * The tab item, which corresponds to the tab, which should be swiped, as an instance of + * the class {@link TabItem}. The tab item may not be null + * @param distance + * The distance, the tab should be swiped by, in pixels as a {@link Float} value + */ + private void swipe(@NonNull final TabItem tabItem, final float distance) { + View view = tabItem.getView(); + + if (!tabItem.getTag().isClosing()) { + adaptStackOnSwipe(tabItem, tabItem.getIndex() + 1, getModel().getCount() - 1); + } + + tabItem.getTag().setClosing(true); + float dragDistance = distance; + + if (!tabItem.getTab().isCloseable()) { + dragDistance = (float) Math.pow(Math.abs(distance), 0.75); + dragDistance = distance < 0 ? dragDistance * -1 : dragDistance; + } + + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.SWIPE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, + getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.SWIPE)); + float scale = getArithmetics().getScale(view, true); + float ratio = 1 - (Math.abs(dragDistance) / calculateSwipePosition()); + float scaledClosedTabScale = swipedTabScale * scale; + float targetScale = scaledClosedTabScale + ratio * (scale - scaledClosedTabScale); + getArithmetics().setScale(Axis.DRAGGING_AXIS, view, targetScale); + getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, targetScale); + view.setAlpha(swipedTabAlpha + ratio * (1 - swipedTabAlpha)); + getArithmetics().setPosition(Axis.ORTHOGONAL_AXIS, view, dragDistance); + } + + /** + * Moves the first tab to overlap the other tabs, when overshooting at the start. + * + * @param position + * The position of the first tab in pixels as a {@link Float} value + */ + private void startOvershoot(final float position) { + TabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.getIndex() == 0) { + View view = tabItem.getView(); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, + getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, + getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE)); + getArithmetics().setPosition(Axis.DRAGGING_AXIS, view, position); + } else if (tabItem.isInflated()) { + View firstView = iterator.first().getView(); + View view = tabItem.getView(); + view.setVisibility(getArithmetics().getPosition(Axis.DRAGGING_AXIS, firstView) <= + getArithmetics().getPosition(Axis.DRAGGING_AXIS, view) ? View.INVISIBLE : + View.VISIBLE); + } + } + } + + /** + * Tilts the tabs, when overshooting at the start. + * + * @param angle + * The angle, the tabs should be rotated by, in degrees as a {@link Float} value + */ + private void tiltOnStartOvershoot(final float angle) { + TabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + View view = tabItem.getView(); + + if (tabItem.getIndex() == 0) { + view.setCameraDistance(maxCameraDistance); + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, getArithmetics() + .getPivot(Axis.DRAGGING_AXIS, view, DragState.OVERSHOOT_START)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, getArithmetics() + .getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.OVERSHOOT_START)); + getArithmetics().setRotation(Axis.ORTHOGONAL_AXIS, view, angle); + } else if (tabItem.isInflated()) { + tabItem.getView().setVisibility(View.INVISIBLE); + } + } + } + + /** + * Tilts the tabs, when overshooting at the end. + * + * @param angle + * The angle, the tabs should be rotated by, in degrees as a {@link Float} value + */ + private void tiltOnEndOvershoot(final float angle) { + float minCameraDistance = maxCameraDistance / 2f; + int firstVisibleIndex = -1; + TabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.isInflated()) { + View view = tabItem.getView(); + + if (!iterator.hasNext()) { + view.setCameraDistance(maxCameraDistance); + } else if (firstVisibleIndex == -1) { + view.setCameraDistance(minCameraDistance); + + if (tabItem.getTag().getState() == State.FLOATING) { + firstVisibleIndex = tabItem.getIndex(); + } + } else { + int diff = tabItem.getIndex() - firstVisibleIndex; + float ratio = + (float) diff / (float) (getModel().getCount() - firstVisibleIndex); + view.setCameraDistance( + minCameraDistance + (maxCameraDistance - minCameraDistance) * ratio); + } + + getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, getArithmetics() + .getPivot(Axis.DRAGGING_AXIS, view, DragState.OVERSHOOT_END)); + getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, getArithmetics() + .getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.OVERSHOOT_END)); + getArithmetics().setRotation(Axis.ORTHOGONAL_AXIS, view, angle); + } + } + } + + /** + * Returns, whether a hidden tab at a specific index, is part of the stack, which is located at + * the start, or not. + * + * @param index + * The index of the hidden tab, as an {@link Integer} value + * @return True, if the hidden tab is part of the stack, which is located at the start, false + * otherwise + */ + private boolean isStackedAtStart(final int index) { + boolean start = true; + AbstractTabItemIterator iterator = + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).start(index + 1) + .create(); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + State state = tabItem.getTag().getState(); + + if (state == State.STACKED_START) { + start = true; + break; + } else if (state == State.FLOATING) { + start = false; + break; + } + } + + return start; + } + + /** + * Creates a new layout, which implements the functionality of a {@link TabSwitcher} on + * smartphones. + * + * @param tabSwitcher + * The tab switcher, the layout belongs to, as an instance of the class {@link + * TabSwitcher}. The tab switcher may not be null + * @param model + * The model of the tab switcher, the layout belongs to, as an instance of the class + * {@link TabSwitcherModel}. The model may not be null + * @param arithmetics + * The arithmetics, which should be used by the layout, as an instance of the class + * {@link PhoneArithmetics}. The arithmetics may not be null + */ + public PhoneTabSwitcherLayout(@NonNull final TabSwitcher tabSwitcher, + @NonNull final TabSwitcherModel model, + @NonNull final PhoneArithmetics arithmetics) { + super(tabSwitcher, model, arithmetics); + Resources resources = tabSwitcher.getResources(); + tabInset = resources.getDimensionPixelSize(R.dimen.tab_inset); + tabBorderWidth = resources.getDimensionPixelSize(R.dimen.tab_border_width); + tabTitleContainerHeight = + resources.getDimensionPixelSize(R.dimen.tab_title_container_height); + stackedTabCount = resources.getInteger(R.integer.stacked_tab_count); + stackedTabSpacing = resources.getDimensionPixelSize(R.dimen.stacked_tab_spacing); + maxCameraDistance = resources.getDimensionPixelSize(R.dimen.max_camera_distance); + TypedValue typedValue = new TypedValue(); + resources.getValue(R.dimen.swiped_tab_scale, typedValue, true); + swipedTabScale = typedValue.getFloat(); + resources.getValue(R.dimen.swiped_tab_alpha, typedValue, true); + swipedTabAlpha = typedValue.getFloat(); + showSwitcherAnimationDuration = + resources.getInteger(R.integer.show_switcher_animation_duration); + hideSwitcherAnimationDuration = + resources.getInteger(R.integer.hide_switcher_animation_duration); + toolbarVisibilityAnimationDuration = + resources.getInteger(R.integer.toolbar_visibility_animation_duration); + toolbarVisibilityAnimationDelay = + resources.getInteger(R.integer.toolbar_visibility_animation_delay); + swipeAnimationDuration = resources.getInteger(R.integer.swipe_animation_duration); + clearAnimationDelay = resources.getInteger(R.integer.clear_animation_delay); + relocateAnimationDuration = resources.getInteger(R.integer.relocate_animation_duration); + relocateAnimationDelay = resources.getInteger(R.integer.relocate_animation_delay); + revertOvershootAnimationDuration = + resources.getInteger(R.integer.revert_overshoot_animation_duration); + revealAnimationDuration = resources.getInteger(R.integer.reveal_animation_duration); + peekAnimationDuration = resources.getInteger(R.integer.peek_animation_duration); + maxStartOvershootAngle = resources.getInteger(R.integer.max_start_overshoot_angle); + maxEndOvershootAngle = resources.getInteger(R.integer.max_end_overshoot_angle); + tabViewBottomMargin = -1; + toolbarAnimation = null; + } + + @NonNull + @Override + protected final AbstractDragHandler onInflateLayout(final boolean tabsOnly) { + LayoutInflater inflater = LayoutInflater.from(getContext()); + + if (tabsOnly) { + toolbar = (Toolbar) getTabSwitcher().findViewById(R.id.primary_toolbar); + } else { + toolbar = (Toolbar) inflater.inflate(R.layout.phone_toolbar, getTabSwitcher(), false); + toolbar.setVisibility(getModel().areToolbarsShown() ? View.VISIBLE : View.INVISIBLE); + getTabSwitcher().addView(toolbar); + } + + tabContainer = new FrameLayout(getContext()); + getTabSwitcher().addView(tabContainer, FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT); + childViewRecycler = new ViewRecycler<>(inflater); + recyclerAdapter = new PhoneRecyclerAdapter(getTabSwitcher(), getModel(), childViewRecycler); + getModel().addListener(recyclerAdapter); + viewRecycler = new AttachedViewRecycler<>(tabContainer, inflater, + Collections.reverseOrder(new TabItem.Comparator(getTabSwitcher()))); + viewRecycler.setAdapter(recyclerAdapter); + recyclerAdapter.setViewRecycler(viewRecycler); + dragHandler = new PhoneDragHandler(getTabSwitcher(), getArithmetics(), viewRecycler); + adaptLogLevel(); + adaptDecorator(); + adaptToolbarMargin(); + return dragHandler; + } + + @Nullable + @Override + protected final Pair onDetachLayout(final boolean tabsOnly) { + Pair result = null; + + if (getModel().isSwitcherShown() && firstVisibleIndex != -1) { + TabItem tabItem = TabItem.create(getModel(), viewRecycler, firstVisibleIndex); + Tag tag = tabItem.getTag(); + + if (tag.getState() != State.HIDDEN) { + float firstVisibleTabPosition = tabItem.getTag().getPosition(); + result = Pair.create(firstVisibleIndex, firstVisibleTabPosition); + } + } + + childViewRecycler.removeAll(); + childViewRecycler.clearCache(); + viewRecycler.removeAll(); + viewRecycler.clearCache(); + recyclerAdapter.clearCachedPreviews(); + + if (!tabsOnly) { + getModel().removeListener(recyclerAdapter); + getTabSwitcher().removeView(toolbar); + getTabSwitcher().removeView(tabContainer); + } + + return result; + } + + @Override + public final boolean handleTouchEvent(@NonNull final MotionEvent event) { + return dragHandler.handleTouchEvent(event); + } + + @Nullable + @Override + public final ViewGroup getTabContainer() { + return tabContainer; + } + + @Nullable + @Override + public final Toolbar[] getToolbars() { + return new Toolbar[]{toolbar}; + } + + @Override + public final void onLogLevelChanged(@NonNull final LogLevel logLevel) { + adaptLogLevel(); + } + + @Override + public final void onDecoratorChanged(@NonNull final TabSwitcherDecorator decorator) { + adaptDecorator(); + super.onDecoratorChanged(decorator); + } + + @Override + public final void onSwitcherShown() { + getLogger().logInfo(getClass(), "Showed tab switcher"); + animateShowSwitcher(); + } + + @Override + public final void onSwitcherHidden() { + getLogger().logInfo(getClass(), "Hid tab switcher"); + animateHideSwitcher(); + } + + @Override + public final void onSelectionChanged(final int previousIndex, final int index, + @Nullable final Tab selectedTab, + final boolean switcherHidden) { + getLogger().logInfo(getClass(), "Selected tab at index " + index); + + if (switcherHidden) { + animateHideSwitcher(); + } else { + viewRecycler.remove(TabItem.create(getTabSwitcher(), viewRecycler, previousIndex)); + viewRecycler.inflate(TabItem.create(getTabSwitcher(), viewRecycler, index)); + } + } + + @Override + public final void onTabAdded(final int index, @NonNull final Tab tab, + final int previousSelectedTabIndex, final int selectedTabIndex, + final boolean switcherVisibilityChanged, + @NonNull final Animation animation) { + getLogger().logInfo(getClass(), + "Added tab at index " + index + " using a " + animation.getClass().getSimpleName()); + + if (animation instanceof PeekAnimation && !getModel().isEmpty()) { + ensureTrue(switcherVisibilityChanged, animation.getClass().getSimpleName() + + " not supported when the tab switcher is shown"); + PeekAnimation peekAnimation = (PeekAnimation) animation; + TabItem tabItem = new TabItem(0, tab); + inflateView(tabItem, createPeekLayoutListener(tabItem, peekAnimation)); + } else if (animation instanceof RevealAnimation && switcherVisibilityChanged) { + TabItem tabItem = new TabItem(0, tab); + RevealAnimation revealAnimation = (RevealAnimation) animation; + inflateView(tabItem, createRevealLayoutListener(tabItem, revealAnimation)); + } else { + addAllTabs(index, new Tab[]{tab}, animation); + } + } + + @Override + public final void onAllTabsAdded(final int index, @NonNull final Tab[] tabs, + final int previousSelectedTabIndex, final int selectedTabIndex, + @NonNull final Animation animation) { + ensureTrue(animation instanceof SwipeAnimation, + animation.getClass().getSimpleName() + " not supported for adding multiple tabs"); + getLogger().logInfo(getClass(), + "Added " + tabs.length + " tabs at index " + index + " using a " + + animation.getClass().getSimpleName()); + addAllTabs(index, tabs, animation); + } + + @Override + public final void onTabRemoved(final int index, @NonNull final Tab tab, + final int previousSelectedTabIndex, final int selectedTabIndex, + @NonNull final Animation animation) { + ensureTrue(animation instanceof SwipeAnimation, + animation.getClass().getSimpleName() + " not supported for removing tabs"); + getLogger().logInfo(getClass(), "Removed tab at index " + index + " using a " + + animation.getClass().getSimpleName()); + TabItem removedTabItem = TabItem.create(viewRecycler, index, tab); + + if (!getModel().isSwitcherShown()) { + viewRecycler.remove(removedTabItem); + + if (getModel().isEmpty()) { + toolbar.setAlpha(getModel().areToolbarsShown() ? 1 : 0); + } else if (selectedTabIndex != previousSelectedTabIndex) { + viewRecycler + .inflate(TabItem.create(getTabSwitcher(), viewRecycler, selectedTabIndex)); + } + } else { + adaptStackOnSwipe(removedTabItem, removedTabItem.getIndex(), getModel().getCount()); + removedTabItem.getTag().setClosing(true); + SwipeAnimation swipeAnimation = + animation instanceof SwipeAnimation ? (SwipeAnimation) animation : + new SwipeAnimation.Builder().create(); + + if (removedTabItem.isInflated()) { + animateRemove(removedTabItem, swipeAnimation); + } else { + boolean start = isStackedAtStart(index); + TabItem predecessor = TabItem.create(getTabSwitcher(), viewRecycler, index - 1); + Pair pair = start ? + calculatePositionAndStateWhenStackedAtStart(getModel().getCount(), index, + predecessor) : calculatePositionAndStateWhenStackedAtEnd(index); + removedTabItem.getTag().setPosition(pair.first); + removedTabItem.getTag().setState(pair.second); + inflateAndUpdateView(removedTabItem, + createRemoveLayoutListener(removedTabItem, swipeAnimation)); + } + } + } + + @Override + public final void onAllTabsRemoved(@NonNull final Tab[] tabs, + @NonNull final Animation animation) { + ensureTrue(animation instanceof SwipeAnimation, + animation.getClass().getSimpleName() + " not supported for removing tabs "); + getLogger().logInfo(getClass(), + "Removed all tabs using a " + animation.getClass().getSimpleName()); + + if (!getModel().isSwitcherShown()) { + viewRecycler.removeAll(); + toolbar.setAlpha(getModel().areToolbarsShown() ? 1 : 0); + } else { + SwipeAnimation swipeAnimation = + animation instanceof SwipeAnimation ? (SwipeAnimation) animation : + new SwipeAnimation.Builder().create(); + AbstractTabItemIterator iterator = + new ArrayTabItemIterator.Builder(viewRecycler, tabs).reverse(true).create(); + TabItem tabItem; + int startDelay = 0; + + while ((tabItem = iterator.next()) != null) { + TabItem previous = iterator.previous(); + + if (tabItem.getTag().getState() == State.FLOATING || + (previous != null && previous.getTag().getState() == State.FLOATING)) { + startDelay += clearAnimationDelay; + } + + if (tabItem.isInflated()) { + animateSwipe(tabItem, true, startDelay, swipeAnimation, + !iterator.hasNext() ? createClearAnimationListener() : null); + } + } + } + } + + @Override + public final void onPaddingChanged(final int left, final int top, final int right, + final int bottom) { + adaptToolbarMargin(); + } + + @Override + public final void onTabIconChanged(@Nullable final Drawable icon) { + + } + + @Override + public final void onTabBackgroundColorChanged(@Nullable final ColorStateList colorStateList) { + + } + + @Override + public final void onTabTitleColorChanged(@Nullable final ColorStateList colorStateList) { + + } + + @Override + public final void onTabCloseButtonIconChanged(@Nullable final Drawable icon) { + + } + + @Override + public final void onGlobalLayout() { + if (getModel().isSwitcherShown()) { + TabItem[] tabItems = calculateInitialTabItems(getModel().getFirstVisibleTabIndex(), + getModel().getFirstVisibleTabPosition()); + AbstractTabItemIterator iterator = new InitialTabItemIterator(tabItems, false, 0); + TabItem tabItem; + + while ((tabItem = iterator.next()) != null) { + if (tabItem.isVisible()) { + inflateAndUpdateView(tabItem, createBottomMarginLayoutListener(tabItem)); + } + } + + toolbar.setAlpha(getModel().areToolbarsShown() ? 1 : 0); + } else if (getModel().getSelectedTab() != null) { + TabItem tabItem = TabItem.create(getTabSwitcher(), viewRecycler, + getModel().getSelectedTabIndex()); + viewRecycler.inflate(tabItem); + } + } + + @Nullable + @Override + public final DragState onDrag(@NonNull final DragState dragState, final float dragDistance) { + if (dragDistance != 0) { + if (dragState == DragState.DRAG_TO_END) { + calculatePositionsWhenDraggingToEnd(dragDistance); + } else { + calculatePositionsWhenDraggingToStart(dragDistance); + } + } + + DragState overshoot = isOvershootingAtEnd( + new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create()) ? + DragState.OVERSHOOT_END : + (isOvershootingAtStart() ? DragState.OVERSHOOT_START : null); + getLogger().logVerbose(getClass(), + "Dragging using a distance of " + dragDistance + " pixels. Drag state is " + + dragState + ", overshoot is " + overshoot); + return overshoot; + } + + @Override + public final void onClick(@NonNull final TabItem tabItem) { + getModel().selectTab(tabItem.getTab()); + getLogger().logVerbose(getClass(), "Clicked tab at index " + tabItem.getIndex()); + } + + @Override + public final void onRevertStartOvershoot() { + animateRevertStartOvershoot(); + getLogger().logVerbose(getClass(), "Reverting overshoot at the start"); + } + + @Override + public final void onRevertEndOvershoot() { + animateRevertEndOvershoot(); + getLogger().logVerbose(getClass(), "Reverting overshoot at the end"); + } + + public final void onStartOvershoot(final float position) { + startOvershoot(position); + getLogger().logVerbose(getClass(), + "Overshooting at the start using a position of " + position + " pixels"); + } + + @Override + public final void onTiltOnStartOvershoot(final float angle) { + tiltOnStartOvershoot(angle); + getLogger().logVerbose(getClass(), + "Tilting on start overshoot using an angle of " + angle + " degrees"); + } + + @Override + public final void onTiltOnEndOvershoot(final float angle) { + tiltOnEndOvershoot(angle); + getLogger().logVerbose(getClass(), + "Tilting on end overshoot using an angle of " + angle + " degrees"); + } + + @Override + public final void onSwipe(@NonNull final TabItem tabItem, final float distance) { + swipe(tabItem, distance); + getLogger().logVerbose(getClass(), + "Swiping tab at index " + tabItem.getIndex() + ". Current swipe distance is " + + distance + " pixels"); + } + + @Override + public final void onSwipeEnded(@NonNull final TabItem tabItem, final boolean remove, + final float velocity) { + if (remove) { + View view = tabItem.getView(); + SwipeDirection direction = + getArithmetics().getPosition(Axis.ORTHOGONAL_AXIS, view) < 0 ? + SwipeDirection.LEFT : SwipeDirection.RIGHT; + long animationDuration = + velocity > 0 ? Math.round((calculateSwipePosition() / velocity) * 1000) : -1; + Animation animation = new SwipeAnimation.Builder().setDirection(direction) + .setDuration(animationDuration).create(); + getModel().removeTab(tabItem.getTab(), animation); + } else { + animateSwipe(tabItem, false, 0, new SwipeAnimation.Builder().create(), + createSwipeAnimationListener(tabItem)); + } + + getLogger().logVerbose(getClass(), + "Ended swiping tab at index " + tabItem.getIndex() + ". Tab will " + + (remove ? "" : "not ") + "be removed"); + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabViewHolder.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabViewHolder.java new file mode 100755 index 0000000..f655077 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabViewHolder.java @@ -0,0 +1,57 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout.phone; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.layout.AbstractTabViewHolder; + +/** + * A view holder, which allows to store references to the views, a tab of a {@link TabSwitcher} + * consists of, when using the smartphone layout. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class PhoneTabViewHolder extends AbstractTabViewHolder { + + /** + * The view group, which contains the title and close button of a tab. + */ + public ViewGroup titleContainer; + + /** + * The view group, which contains the child view of a tab. + */ + public ViewGroup childContainer; + + /** + * The child view, which contains the tab's content. + */ + public View child; + + /** + * The image view, which is used to display the preview of a tab. + */ + public ImageView previewImageView; + + /** + * The view, which is used to display a border around the preview of a tab. + */ + public View borderView; + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PreviewDataBinder.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PreviewDataBinder.java new file mode 100755 index 0000000..cecd364 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PreviewDataBinder.java @@ -0,0 +1,119 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.layout.phone; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.util.LruCache; +import android.support.v4.util.Pair; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.ViewGroup; +import android.widget.ImageView; + +import de.mrapp.android.tabswitcher.Tab; +import de.mrapp.android.tabswitcher.model.TabItem; +import de.mrapp.android.util.multithreading.AbstractDataBinder; +import de.mrapp.android.util.view.ViewRecycler; + +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * A data binder, which allows to asynchronously render preview images of tabs and display them + * afterwards. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class PreviewDataBinder extends AbstractDataBinder { + + /** + * The parent view of the tab switcher, the tabs belong to. + */ + private final ViewGroup parent; + + /** + * The view recycler, which is used to inflate child views. + */ + private final ViewRecycler childViewRecycler; + + /** + * Creates a new data binder, which allows to asynchronously render preview images of tabs and + * display them afterwards. + * + * @param parent + * The parent view of the tab switcher, the tabs belong to, as an instance of the class + * {@link ViewGroup}. The parent may not be null + * @param childViewRecycler + * The view recycler, which should be used to inflate child views, as an instance of the + * class ViewRecycler. The view recycler may not be null + */ + public PreviewDataBinder(@NonNull final ViewGroup parent, + @NonNull final ViewRecycler childViewRecycler) { + super(parent.getContext(), new LruCache(7)); + ensureNotNull(parent, "The parent may not be null"); + ensureNotNull(childViewRecycler, "The child view recycler may not be null"); + this.parent = parent; + this.childViewRecycler = childViewRecycler; + } + + @Override + protected final void onPreExecute(@NonNull final ImageView view, + @NonNull final TabItem... params) { + TabItem tabItem = params[0]; + PhoneTabViewHolder viewHolder = tabItem.getViewHolder(); + View child = viewHolder.child; + Tab tab = tabItem.getTab(); + + if (child == null) { + Pair pair = childViewRecycler.inflate(tab, viewHolder.childContainer); + child = pair.first; + } else { + childViewRecycler.getAdapter().onShowView(getContext(), child, tab, false); + } + + viewHolder.child = child; + } + + @Nullable + @Override + protected final Bitmap doInBackground(@NonNull final Tab key, + @NonNull final TabItem... params) { + TabItem tabItem = params[0]; + PhoneTabViewHolder viewHolder = tabItem.getViewHolder(); + View child = viewHolder.child; + viewHolder.child = null; + int width = parent.getWidth(); + int height = parent.getHeight(); + child.measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); + child.layout(0, 0, child.getMeasuredWidth(), child.getMeasuredHeight()); + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + child.draw(canvas); + return bitmap; + } + + @Override + protected final void onPostExecute(@NonNull final ImageView view, @Nullable final Bitmap data, + @NonNull final TabItem... params) { + view.setImageBitmap(data); + view.setVisibility(data != null ? View.VISIBLE : View.GONE); + TabItem tabItem = params[0]; + childViewRecycler.remove(tabItem.getTab()); + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Model.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Model.java new file mode 100755 index 0000000..0fa6250 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Model.java @@ -0,0 +1,915 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.model; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.support.annotation.ColorInt; +import android.support.annotation.DrawableRes; +import android.support.annotation.MenuRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.v7.widget.Toolbar.OnMenuItemClickListener; +import android.view.View.OnClickListener; + +import java.util.Collection; +import java.util.NoSuchElementException; + +import de.mrapp.android.tabswitcher.Animation; +import de.mrapp.android.tabswitcher.SwipeAnimation; +import de.mrapp.android.tabswitcher.SwipeAnimation.SwipeDirection; +import de.mrapp.android.tabswitcher.Tab; +import de.mrapp.android.tabswitcher.TabCloseListener; +import de.mrapp.android.tabswitcher.TabPreviewListener; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.TabSwitcherDecorator; +import de.mrapp.android.util.logging.LogLevel; + +/** + * Defines the interface, a class, which implements the model of a {@link TabSwitcher} must + * implement. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public interface Model extends Iterable { + + /** + * Defines the interface, a class, which should be notified about the model's events, must + * implement. + */ + interface Listener { + + /** + * The method, which is invoked, when the log level has been changed. + * + * @param logLevel + * The log level, which has been set, as a value of the enum LogLevel. The log level + * may not be null + */ + void onLogLevelChanged(@NonNull LogLevel logLevel); + + /** + * The method, which is invoked, when the decorator has been changed. + * + * @param decorator + * The decorator, which has been set, as an instance of the class {@link + * TabSwitcherDecorator}. The decorator may not be null + */ + void onDecoratorChanged(@NonNull TabSwitcherDecorator decorator); + + /** + * The method, which is invoked, when the tab switcher has been shown. + */ + void onSwitcherShown(); + + /** + * The method, which is invoked, when the tab switcher has been hidden. + */ + void onSwitcherHidden(); + + /** + * The method, which is invoked, when the currently selected tab has been changed. + * + * @param previousIndex + * The index of the previously selected tab as an {@link Integer} value or -1, if no + * tab was previously selected + * @param index + * The index of the currently selected tab as an {@link Integer} value or -1, if the + * tab switcher does not contain any tabs + * @param selectedTab + * The currently selected tab as an instance of the class {@link Tab} or null, if + * the tab switcher does not contain any tabs + * @param switcherHidden + * True, if selecting the tab caused the tab switcher to be hidden, false otherwise + */ + void onSelectionChanged(int previousIndex, int index, @Nullable Tab selectedTab, + boolean switcherHidden); + + /** + * The method, which is invoked, when a tab has been added to the model. + * + * @param index + * The index of the tab, which has been added, as an {@link Integer} value + * @param tab + * The tab, which has been added, as an instance of the class {@link Tab}. The tab + * may not be null + * @param previousSelectedTabIndex + * The index of the previously selected tab as an {@link Integer} value or -1, if no + * tab was selected + * @param selectedTabIndex + * The index of the currently selected tab as an {@link Integer} value or -1, if the + * tab switcher does not contain any tabs + * @param switcherVisibilityChanged + * True, if adding the tab caused the visibility of the tab switcher to be changed, + * false otherwise + * @param animation + * The animation, which has been used to add the tab, as an instance of the class + * {@link Animation}. The animation may not be null + */ + void onTabAdded(int index, @NonNull Tab tab, int previousSelectedTabIndex, + int selectedTabIndex, boolean switcherVisibilityChanged, + @NonNull Animation animation); + + /** + * The method, which is invoked, when multiple tabs have been added to the model. + * + * @param index + * The index of the first tab, which has been added, as an {@link Integer} value + * @param tabs + * An array, which contains the tabs, which have been added, as an array of the type + * {@link Tab} or an empty array, if no tabs have been added + * @param previousSelectedTabIndex + * The index of the previously selected tab as an {@link Integer} value or -1, if no + * tab was selected + * @param selectedTabIndex + * The index of the currently selected tab as an {@link Integer} value or -1, if the + * tab switcher does not contain any tabs + * @param animation + * The animation, which has been used to add the tabs, as an instance of the class + * {@link Animation}. The animation may not be null + */ + void onAllTabsAdded(int index, @NonNull Tab[] tabs, int previousSelectedTabIndex, + int selectedTabIndex, @NonNull Animation animation); + + /** + * The method, which is invoked, when a tab has been removed from the model. + * + * @param index + * The index of the tab, which has been removed, as an {@link Integer} value + * @param tab + * The tab, which has been removed, as an instance of the class {@link Tab}. The tab + * may not be null + * @param previousSelectedTabIndex + * The index of the previously selected tab as an {@link Integer} value or -1, if no + * tab was selected + * @param selectedTabIndex + * The index of the currently selected tab as an {@link Integer} value or -1, if the + * tab switcher does not contain any tabs + * @param animation + * The animation, which has been used to remove the tab, as an instance of the class + * {@link Animation}. The animation may not be null + */ + void onTabRemoved(int index, @NonNull Tab tab, int previousSelectedTabIndex, + int selectedTabIndex, @NonNull Animation animation); + + /** + * The method, which is invoked, when all tabs have been removed from the tab switcher. + * + * @param tabs + * An array, which contains the tabs, which have been removed, as an array of the + * type {@link Tab} or an empty array, if no tabs have been removed + * @param animation + * The animation, which has been used to remove the tabs, as an instance of the + * class {@link Animation}. The animation may not be null + */ + void onAllTabsRemoved(@NonNull Tab[] tabs, @NonNull Animation animation); + + /** + * The method, which is invoked, when the padding has been changed. + * + * @param left + * The left padding, which has been set, in pixels as an {@link Integer} value + * @param top + * The top padding, which has been set, in pixels as an {@link Integer} value + * @param right + * The right padding, which has been set, in pixels as an {@link Integer} value + * @param bottom + * The bottom padding, which has been set, in pixels as an {@link Integer} value + */ + void onPaddingChanged(int left, int top, int right, int bottom); + + /** + * The method, which is invoked, when the default icon of a tab has been changed. + * + * @param icon + * The icon, which has been set, as an instance of the class {@link Drawable} or + * null, if no icon is set + */ + void onTabIconChanged(@Nullable Drawable icon); + + /** + * The method, which is invoked, when the background color of a tab has been changed. + * + * @param colorStateList + * The color state list, which has been set, as an instance of the class {@link + * ColorStateList} or null, if the default color should be used + */ + void onTabBackgroundColorChanged(@Nullable ColorStateList colorStateList); + + /** + * The method, which is invoked, when the text color of a tab's title has been changed. + * + * @param colorStateList + * The color state list, which has been set, as an instance of the class {@link + * ColorStateList} or null, if the default color should be used + */ + void onTabTitleColorChanged(@Nullable ColorStateList colorStateList); + + /** + * The method, which is invoked, when the icon of a tab's close button has been changed. + * + * @param icon + * The icon, which has been set, as an instance of the class {@link Drawable} or + * null, if the default icon should be used + */ + void onTabCloseButtonIconChanged(@Nullable Drawable icon); + + /** + * The method, which is invoked, when it has been changed, whether the toolbars should be + * shown, when the tab switcher is shown, or not. + * + * @param visible + * True, if the toolbars should be shown, when the tab switcher is shown, false + * otherwise + */ + void onToolbarVisibilityChanged(boolean visible); + + /** + * The method, which is invoked, when the title of the toolbar, which is shown, when the tab + * switcher is shown, has been changed. + * + * @param title + * The title, which has been set, as an instance of the type {@link CharSequence} or + * null, if no title is set + */ + void onToolbarTitleChanged(@Nullable CharSequence title); + + /** + * The method, which is invoked, when the navigation icon of the toolbar, which is shown, + * when the tab switcher is shown, has been changed. + * + * @param icon + * The navigation icon, which has been set, as an instance of the class {@link + * Drawable} or null, if no navigation icon is set + * @param listener + * The listener, which should be notified, when the navigation item has been + * clicked, as an instance of the type {@link OnClickListener} or null, if no + * listener should be notified + */ + void onToolbarNavigationIconChanged(@Nullable Drawable icon, + @Nullable OnClickListener listener); + + /** + * The method, which is invoked, when the menu of the toolbar, which is shown, when the tab + * switcher is shown, has been inflated. + * + * @param resourceId + * The resource id of the menu, which has been inflated, as an {@link Integer} + * value. The resource id must correspond to a valid menu resource + * @param listener + * The listener, which has been registered to be notified, when an item of the menu + * has been clicked, as an instance of the type OnMenuItemClickListener or null, if + * no listener should be notified + */ + void onToolbarMenuInflated(@MenuRes int resourceId, + @Nullable OnMenuItemClickListener listener); + + } + + /** + * Returns the context, which is used by the tab switcher. + * + * @return The context, which is used by the tab switcher, as an instance of the class {@link + * Context}. The context may not be null + */ + @NonNull + Context getContext(); + + /** + * Sets the decorator, which allows to inflate the views, which correspond to the tabs of the + * tab switcher. + * + * @param decorator + * The decorator, which should be set, as an instance of the class {@link + * TabSwitcherDecorator}. The decorator may not be null + */ + void setDecorator(@NonNull TabSwitcherDecorator decorator); + + /** + * Returns the decorator, which allows to inflate the views, which correspond to the tabs of the + * tab switcher. + * + * @return The decorator as an instance of the class {@link TabSwitcherDecorator} or null, if no + * decorator has been set + */ + TabSwitcherDecorator getDecorator(); + + /** + * Returns the log level, which is used for logging. + * + * @return The log level, which is used for logging, as a value of the enum LogLevel. The log + * level may not be null + */ + @NonNull + LogLevel getLogLevel(); + + /** + * Sets the log level, which should be used for logging. + * + * @param logLevel + * The log level, which should be set, as a value of the enum LogLevel. The log level + * may not be null + */ + void setLogLevel(@NonNull LogLevel logLevel); + + /** + * Returns, whether the tab switcher is empty, or not. + * + * @return True, if the tab switcher is empty, false otherwise + */ + boolean isEmpty(); + + /** + * Returns the number of tabs, which are contained by the tab switcher. + * + * @return The number of tabs, which are contained by the tab switcher, as an {@link Integer} + * value + */ + int getCount(); + + /** + * Returns the tab at a specific index. + * + * @param index + * The index of the tab, which should be returned, as an {@link Integer} value. The + * index must be at least 0 and at maximum getCount() - 1, otherwise a + * {@link IndexOutOfBoundsException} will be thrown + * @return The tab, which corresponds to the given index, as an instance of the class {@link + * Tab}. The tab may not be null + */ + @NonNull + Tab getTab(int index); + + /** + * Returns the index of a specific tab. + * + * @param tab + * The tab, whose index should be returned, as an instance of the class {@link Tab}. The + * tab may not be null + * @return The index of the given tab as an {@link Integer} value or -1, if the given tab is not + * contained by the tab switcher + */ + int indexOf(@NonNull Tab tab); + + /** + * Adds a new tab to the tab switcher. By default, the tab is added at the end. If the switcher + * is currently shown, the tab is added by using an animation. By default, a {@link + * SwipeAnimation} with direction {@link SwipeDirection#RIGHT} is used. If + * an animation is currently running, the tab will be added once all previously started + * animations have been finished. + * + * @param tab + * The tab, which should be added, as an instance of the class {@link Tab}. The tab may + * not be null + */ + void addTab(@NonNull Tab tab); + + /** + * Adds a new tab to the tab switcher at a specific index. If the switcher is currently shown, + * the tab is added by using an animation. By default, a {@link SwipeAnimation} with + * direction {@link SwipeDirection#RIGHT} is used. If an animation is currently + * running, the tab will be added once all previously started animations have been finished. + * + * @param tab + * The tab, which should be added, as an instance of the class {@link Tab}. The tab may + * not be null + * @param index + * The index, the tab should be added at, as an {@link Integer} value. The index must be + * at least 0 and at maximum getCount(), otherwise an {@link + * IndexOutOfBoundsException} will be thrown + */ + void addTab(@NonNull Tab tab, int index); + + /** + * Adds a new tab to the tab switcher at a specific index. If the switcher is currently shown, + * the tab is added by using a specific animation. If an animation is currently + * running, the tab will be added once all previously started animations have been finished. + * + * @param tab + * The tab, which should be added, as an instance of the class {@link Tab}. The tab may + * not be null + * @param index + * The index, the tab should be added at, as an {@link Integer} value. The index must be + * at least 0 and at maximum getCount(), otherwise an {@link + * IndexOutOfBoundsException} will be thrown + * @param animation + * The animation, which should be used to add the tab, as an instance of the class + * {@link Animation}. The animation may not be null + */ + void addTab(@NonNull Tab tab, int index, @NonNull Animation animation); + + /** + * Adds all tabs, which are contained by a collection, to the tab switcher. By default, the tabs + * are added at the end. If the switcher is currently shown, the tabs are added by using an + * animation. By default, a {@link SwipeAnimation} with direction {@link + * SwipeDirection#RIGHT} is used. If an animation is currently running, the tabs will + * be added once all previously started animations have been finished. + * + * @param tabs + * A collection, which contains the tabs, which should be added, as an instance of the + * type {@link Collection} or an empty collection, if no tabs should be added + */ + void addAllTabs(@NonNull Collection tabs); + + /** + * Adds all tabs, which are contained by a collection, to the tab switcher, starting at a + * specific index. If the switcher is currently shown, the tabs are added by using an animation. + * By default, a {@link SwipeAnimation} with direction {@link + * SwipeDirection#RIGHT} is used. If an animation is currently running, the tabs will + * be added once all previously started animations have been finished. + * + * @param tabs + * A collection, which contains the tabs, which should be added, as an instance of the + * type {@link Collection} or an empty collection, if no tabs should be added + * @param index + * The index, the first tab should be started at, as an {@link Integer} value. The index + * must be at least 0 and at maximum getCount(), otherwise an {@link + * IndexOutOfBoundsException} will be thrown + */ + void addAllTabs(@NonNull Collection tabs, int index); + + /** + * Adds all tabs, which are contained by a collection, to the tab switcher, starting at a + * specific index. If the switcher is currently shown, the tabs are added by using a specific + * animation. If an animation is currently running, the tabs will be added once all previously + * started animations have been finished. + * + * @param tabs + * A collection, which contains the tabs, which should be added, as an instance of the + * type {@link Collection} or an empty collection, if no tabs should be added + * @param index + * The index, the first tab should be started at, as an {@link Integer} value. The index + * must be at least 0 and at maximum getCount(), otherwise an {@link + * IndexOutOfBoundsException} will be thrown + * @param animation + * The animation, which should be used to add the tabs, as an instance of the class + * {@link Animation}. The animation may not be null + */ + void addAllTabs(@NonNull Collection tabs, int index, + @NonNull Animation animation); + + /** + * Adds all tabs, which are contained by an array, to the tab switcher. By default, the tabs are + * added at the end. If the switcher is currently shown, the tabs are added by using an + * animation. By default, a {@link SwipeAnimation} with direction {@link + * SwipeDirection#RIGHT} is used. If an animation is currently running, the tabs will + * be added once all previously started animations have been finished. + * + * @param tabs + * An array, which contains the tabs, which should be added, as an array of the type + * {@link Tab} or an empty array, if no tabs should be added + */ + void addAllTabs(@NonNull Tab[] tabs); + + /** + * Adds all tabs, which are contained by an array, to the tab switcher, starting at a specific + * index. If the switcher is currently shown, the tabs are added by using an animation. By + * default, a {@link SwipeAnimation} with direction {@link + * SwipeDirection#RIGHT} is used. If an animation is currently running, the tabs will + * be added once all previously started animations have been finished. + * + * @param tabs + * An array, which contains the tabs, which should be added, as an array of the type + * {@link Tab} or an empty array, if no tabs should be added + * @param index + * The index, the first tab should be started at, as an {@link Integer} value. The index + * must be at least 0 and at maximum getCount(), otherwise an {@link + * IndexOutOfBoundsException} will be thrown + */ + void addAllTabs(@NonNull Tab[] tabs, int index); + + /** + * Adds all tabs, which are contained by an array, to the tab switcher, starting at a + * specific index. If the switcher is currently shown, the tabs are added by using a specific + * animation. If an animation is currently running, the tabs will be added once all previously + * started animations have been finished. + * + * @param tabs + * An array, which contains the tabs, which should be added, as an array of the type + * {@link Tab} or an empty array, if no tabs should be added + * @param index + * The index, the first tab should be started at, as an {@link Integer} value. The index + * must be at least 0 and at maximum getCount(), otherwise an {@link + * IndexOutOfBoundsException} will be thrown + * @param animation + * The animation, which should be used to add the tabs, as an instance of the class + * {@link Animation}. The animation may not be null + */ + void addAllTabs(@NonNull Tab[] tabs, int index, @NonNull Animation animation); + + /** + * Removes a specific tab from the tab switcher. If the switcher is currently shown, the tab is + * removed by using an animation. By default, a {@link SwipeAnimation} with direction + * {@link SwipeDirection#RIGHT} is used. If an animation is currently running, the tab + * will be removed once all previously started animations have been finished. + * + * @param tab + * The tab, which should be removed, as an instance of the class {@link Tab}. The tab + * may not be null + */ + void removeTab(@NonNull Tab tab); + + /** + * Removes a specific tab from the tab switcher. If the switcher is currently shown, the tab is + * removed by using a specific animation. If an animation is currently running, the + * tab will be removed once all previously started animations have been finished. + * + * @param tab + * The tab, which should be removed, as an instance of the class {@link Tab}. The tab + * may not be null + * @param animation + * The animation, which should be used to remove the tabs, as an instance of the class + * {@link Animation}. The animation may not be null + */ + void removeTab(@NonNull Tab tab, @NonNull Animation animation); + + /** + * Removes all tabs from the tab switcher. If the switcher is currently shown, the tabs are + * removed by using an animation. By default, a {@link SwipeAnimation} with direction + * {@link SwipeDirection#RIGHT} is used. If an animation is currently running, the + * tabs will be removed once all previously started animations have been finished. + */ + void clear(); + + /** + * Removes all tabs from the tab switcher. If the switcher is currently shown, the tabs are + * removed by using a specific animation. If an animation is currently running, the + * tabs will be removed once all previously started animations have been finished. + * + * @param animation + * The animation, which should be used to remove the tabs, as an instance of the class + * {@link Animation}. The animation may not be null + */ + void clear(@NonNull Animation animation); + + /** + * Returns, whether the tab switcher is currently shown. + * + * @return True, if the tab switcher is currently shown, false otherwise + */ + boolean isSwitcherShown(); + + /** + * Shows the tab switcher by using an animation, if it is not already shown. + */ + void showSwitcher(); + + /** + * Hides the tab switcher by using an animation, if it is currently shown. + */ + void hideSwitcher(); + + /** + * Toggles the visibility of the tab switcher by using an animation, i.e. if the switcher is + * currently shown, it is hidden, otherwise it is shown. + */ + void toggleSwitcherVisibility(); + + /** + * Returns the currently selected tab. + * + * @return The currently selected tab as an instance of the class {@link Tab} or null, if no tab + * is currently selected + */ + @Nullable + Tab getSelectedTab(); + + /** + * Returns the index of the currently selected tab. + * + * @return The index of the currently selected tab as an {@link Integer} value or -1, if no tab + * is currently selected + */ + int getSelectedTabIndex(); + + /** + * Selects a specific tab. + * + * @param tab + * The tab, which should be selected, as an instance of the class {@link Tab}. The tab + * may not be null. If the tab is not contained by the tab switcher, a {@link + * NoSuchElementException} will be thrown + */ + void selectTab(@NonNull Tab tab); + + /** + * Sets the padding of the tab switcher. + * + * @param left + * The left padding, which should be set, in pixels as an {@link Integer} value + * @param top + * The top padding, which should be set, in pixels as an {@link Integer} value + * @param right + * The right padding, which should be set, in pixels as an {@link Integer} value + * @param bottom + * The bottom padding, which should be set, in pixels as an {@link Integer} value + */ + void setPadding(int left, int top, int right, int bottom); + + /** + * Returns the left padding of the tab switcher. + * + * @return The left padding of the tab switcher in pixels as an {@link Integer} value + */ + int getPaddingLeft(); + + /** + * Returns the top padding of the tab switcher. + * + * @return The top padding of the tab switcher in pixels as an {@link Integer} value + */ + int getPaddingTop(); + + /** + * Returns the right padding of the tab switcher. + * + * @return The right padding of the tab switcher in pixels as an {@link Integer} value + */ + int getPaddingRight(); + + /** + * Returns the bottom padding of the tab switcher. + * + * @return The bottom padding of the tab switcher in pixels as an {@link Integer} value + */ + int getPaddingBottom(); + + /** + * Returns the start padding of the tab switcher. This corresponds to the right padding, if a + * right-to-left layout is used, or to the left padding otherwise. + * + * @return The start padding of the tab switcher in pixels as an {@link Integer} value + */ + int getPaddingStart(); + + /** + * Returns the end padding of the tab switcher. This corresponds ot the left padding, if a + * right-to-left layout is used, or to the right padding otherwise. + * + * @return The end padding of the tab switcher in pixels as an {@link Integer} value + */ + int getPaddingEnd(); + + /** + * Returns the default icon of a tab. + * + * @return The default icon of a tab as an instance of the class {@link Drawable} or null, if no + * icon is set + */ + @Nullable + Drawable getTabIcon(); + + /** + * Sets the default icon of a tab. + * + * @param resourceId + * The resource id of the icon, which should be set, as an {@link Integer} value. The + * resource id must correspond to a valid drawable resource + */ + void setTabIcon(@DrawableRes int resourceId); + + /** + * Sets the default icon of a tab. + * + * @param icon + * The icon, which should be set, as an instance of the class {@link Bitmap} or null, if + * no icon should be set + */ + void setTabIcon(@Nullable Bitmap icon); + + /** + * Returns the default background color of a tab. + * + * @return The default background color of a tab as an instance of the class {@link + * ColorStateList} or null, if the default color is used + */ + @Nullable + ColorStateList getTabBackgroundColor(); + + /** + * Sets the default background color of a tab. + * + * @param color + * The color, which should be set, as an {@link Integer} value or -1, if the default + * color should be used + */ + void setTabBackgroundColor(@ColorInt int color); + + /** + * Sets the default background color of a tab. + * + * @param colorStateList + * The color, which should be set, as an instance of the class {@link ColorStateList} or + * null, if the default color should be used + */ + void setTabBackgroundColor(@Nullable ColorStateList colorStateList); + + /** + * Returns the default text color of a tab's title. + * + * @return The default text color of a tab's title as an instance of the class {@link + * ColorStateList} or null, if the default color is used + */ + @Nullable + ColorStateList getTabTitleTextColor(); + + /** + * Sets the default text color of a tab's title. + * + * @param color + * The color, which should be set, as an {@link Integer} value or -1, if the default + * color should be used + */ + void setTabTitleTextColor(@ColorInt int color); + + /** + * Sets the default text color of a tab's title. + * + * @param colorStateList + * The color state list, which should be set, as an instance of the class {@link + * ColorStateList} or null, if the default color should be used + */ + void setTabTitleTextColor(@Nullable ColorStateList colorStateList); + + /** + * Returns the default icon of a tab's close button. + * + * @return The default icon of a tab's close button as an instance of the class {@link Drawable} + * or null, if the default icon is used + */ + @Nullable + Drawable getTabCloseButtonIcon(); + + /** + * Sets the default icon of a tab's close button. + * + * @param resourceId + * The resource id of the icon, which should be set, as an {@link Integer} value. The + * resource id must correspond to a valid drawable resource + */ + void setTabCloseButtonIcon(@DrawableRes int resourceId); + + /** + * Sets the default icon of a tab's close button. + * + * @param icon + * The icon, which should be set, as an instance of the class {@link Bitmap} or null, if + * the default icon should be used + */ + void setTabCloseButtonIcon(@Nullable final Bitmap icon); + + /** + * Returns, whether the toolbars are shown, when the tab switcher is shown, or not. When using + * the tablet layout, the toolbars are always shown. + * + * @return True, if the toolbars are shown, false otherwise + */ + boolean areToolbarsShown(); + + /** + * Sets, whether the toolbars should be shown, when the tab switcher is shown, or not. This + * method does not have any effect when using the tablet layout. + * + * @param show + * True, if the toolbars should be shown, false otherwise + */ + void showToolbars(boolean show); + + /** + * Returns the title of the toolbar, which is shown, when the tab switcher is shown. When using + * the tablet layout, the title corresponds to the primary toolbar. + * + * @return The title of the toolbar, which is shown, when the tab switcher is shown, as an + * instance of the type {@link CharSequence} or null, if no title is set + */ + @Nullable + CharSequence getToolbarTitle(); + + /** + * Sets the title of the toolbar, which is shown, when the tab switcher is shown. When using the + * tablet layout, the title is set to the primary toolbar. + * + * @param resourceId + * The resource id of the title, which should be set, as an {@link Integer} value. The + * resource id must correspond to a valid string resource + */ + void setToolbarTitle(@StringRes int resourceId); + + /** + * Sets the title of the toolbar, which is shown, when the tab switcher is shown. When using the + * tablet layout, the title is set to the primary toolbar. + * + * @param title + * The title, which should be set, as an instance of the type {@link CharSequence} or + * null, if no title should be set + */ + void setToolbarTitle(@Nullable CharSequence title); + + /** + * Returns the navigation icon of the toolbar, which is shown, when the tab switcher is shown. + * When using the tablet layout, the icon corresponds to the primary toolbar. + * + * @return The icon of the toolbar, which is shown, when the tab switcher is shown, as an + * instance of the class {@link Drawable} or null, if no icon is set + */ + @Nullable + Drawable getToolbarNavigationIcon(); + + /** + * Sets the navigation icon of the toolbar, which is shown, when the tab switcher is shown. When + * using the tablet layout, the icon is set to the primary toolbar. + * + * @param resourceId + * The resource id of the icon, which should be set, as an {@link Integer} value. The + * resource id must correspond to a valid drawable resource + * @param listener + * The listener, which should be notified, when the navigation item has been clicked, as + * an instance of the type {@link OnClickListener} or null, if no listener should be + * notified + */ + void setToolbarNavigationIcon(@DrawableRes int resourceId, @Nullable OnClickListener listener); + + /** + * Sets the navigation icon of the toolbar, which is shown, when the tab switcher is shown. When + * using the tablet layout, the icon is set to the primary toolbar. + * + * @param icon + * The icon, which should be set, as an instance of the class {@link Drawable} or null, + * if no icon should be set + * @param listener + * The listener, which should be notified, when the navigation item has been clicked, as + * an instance of the type {@link OnClickListener} or null, if no listener should be + * notified + */ + void setToolbarNavigationIcon(@Nullable Drawable icon, @Nullable OnClickListener listener); + + /** + * Inflates the menu of the toolbar, which is shown, when the tab switcher is shown. When using + * the tablet layout, the menu is inflated into the secondary toolbar. + * + * @param resourceId + * The resource id of the menu, which should be inflated, as an {@link Integer} value. + * The resource id must correspond to a valid menu resource + * @param listener + * The listener, which should be notified, when an menu item has been clicked, as an + * instance of the type OnMenuItemClickListener or null, if no listener should be + * notified + */ + void inflateToolbarMenu(@MenuRes int resourceId, @Nullable OnMenuItemClickListener listener); + + /** + * Adds a new listener, which should be notified, when a tab is about to be closed by clicking + * its close button. + * + * @param listener + * The listener, which should be added, as an instance of the type {@link + * TabCloseListener}. The listener may not be null + */ + void addCloseTabListener(@NonNull TabCloseListener listener); + + /** + * Removes a specific listener, which should not be notified, when a tab is about to be closed + * by clicking its close button, anymore. + * + * @param listener + * The listener, which should be removed, as an instance of the type {@link + * TabCloseListener}. The listener may not be null + */ + void removeCloseTabListener(@NonNull TabCloseListener listener); + + /** + * Adds a new listener, which should be notified, when the preview of a tab is about to be + * loaded. Previews are only loaded when using the smartphone layout. + * + * @param listener + * The listener, which should be added, as an instance of the type {@link + * TabPreviewListener}. The listener may not be null + */ + void addTabPreviewListener(@NonNull TabPreviewListener listener); + + /** + * Removes a specific listener, which should not be notified, when the preview of a tab is about + * to be loaded. + * + * @param listener + * The listener, which should be removed, as an instance of the type {@link + * TabPreviewListener}. The listener may not be null + */ + void removeTabPreviewListener(@NonNull TabPreviewListener listener); + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Restorable.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Restorable.java new file mode 100755 index 0000000..39f8f69 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Restorable.java @@ -0,0 +1,46 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.model; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +/** + * Defines the interface, a class, whose state should be stored and restored, must implement. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public interface Restorable { + + /** + * Saves the current state. + * + * @param outState + * The bundle, which should be used to store the saved state, as an instance of the + * class {@link Bundle}. The bundle may not be null + */ + void saveInstanceState(@NonNull Bundle outState); + + /** + * Restores a previously saved state. + * + * @param savedInstanceState + * The saved state as an instance of the class {@link Bundle} or null, if no saved state + * is available + */ + void restoreInstanceState(@Nullable Bundle savedInstanceState); + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/State.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/State.java new file mode 100755 index 0000000..c652ee6 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/State.java @@ -0,0 +1,49 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.model; + +/** + * Contains all possible states of a tab, while the switcher is shown. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public enum State { + + /** + * When the tab is part of the stack, which is located at the start of the switcher. + */ + STACKED_START, + + /** + * When the tab is displayed atop of the stack, which is located at the start of the switcher. + */ + STACKED_START_ATOP, + + /** + * When the tab is floating and freely movable. + */ + FLOATING, + + /** + * When the tab is part of the stack, which is located at the end of the switcher. + */ + STACKED_END, + + /** + * When the tab is currently not visible, i.e. if no view is inflated to visualize it. + */ + HIDDEN + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabItem.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabItem.java new file mode 100755 index 0000000..c676e38 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabItem.java @@ -0,0 +1,305 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.model; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.View; + +import de.mrapp.android.tabswitcher.R; +import de.mrapp.android.tabswitcher.Tab; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.layout.phone.PhoneTabViewHolder; +import de.mrapp.android.util.view.AttachedViewRecycler; + +import static de.mrapp.android.util.Condition.ensureAtLeast; +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * An item, which contains information about a tab of a {@link TabSwitcher}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class TabItem { + + /** + * A comparator, which allows to compare two instances of the class {@link TabItem}. + */ + public static class Comparator implements java.util.Comparator { + + /** + * The tab switcher, the tab items, which are compared by the comparator, belong to. + */ + private final TabSwitcher tabSwitcher; + + /** + * Creates a new comparator, which allows to compare two instances of the class {@link + * TabItem}. + * + * @param tabSwitcher + * The tab switcher, the tab items, which should be compared by the comparator, + * belong to, as a instance of the class {@link TabSwitcher}. The tab switcher may + * not be null + */ + public Comparator(@NonNull final TabSwitcher tabSwitcher) { + ensureNotNull(tabSwitcher, "The tab switcher may not be null"); + this.tabSwitcher = tabSwitcher; + } + + @Override + public int compare(final TabItem o1, final TabItem o2) { + Tab tab1 = o1.getTab(); + Tab tab2 = o2.getTab(); + int index1 = tabSwitcher.indexOf(tab1); + int index2 = tabSwitcher.indexOf(tab2); + + if (index2 == -1) { + index2 = o2.getIndex(); + } + + if (index1 == -1 || index2 == -1) { + throw new RuntimeException("Tab not contained by tab switcher"); + } + + return index1 < index2 ? -1 : 1; + } + + } + + /** + * The index of the tab. + */ + private final int index; + + /** + * The tab. + */ + private final Tab tab; + + /** + * The view, which is used to visualize the tab. + */ + private View view; + + /** + * The view holder, which stores references the views, which belong to the tab. + */ + private PhoneTabViewHolder viewHolder; + + /** + * The tag, which is associated with the tab. + */ + private Tag tag; + + /** + * Creates a new item, which contains information about a tab of a {@link TabSwitcher}. By + * default, the item is neither associated with a view, nor with a view holder. + * + * @param index + * The index of the tab as an {@link Integer} value. The index must be at least 0 + * @param tab + * The tab as an instance of the class {@link Tab}. The tab may not be null + */ + public TabItem(final int index, @NonNull final Tab tab) { + ensureAtLeast(index, 0, "The index must be at least 0"); + ensureNotNull(tab, "The tab may not be null"); + this.index = index; + this.tab = tab; + this.view = null; + this.viewHolder = null; + this.tag = new Tag(); + } + + /** + * Creates a new item, which contains information about a tab of a tab switcher. By + * default, the item is neither associated with a view, nor with a view holder. + * + * @param model + * The model, the tab belongs to, as an instance of the type {@link Model}. The model + * may not be null + * @param viewRecycler + * The view recycler, which is used to reuse the views, which are used to visualize + * tabs, as an instance of the class AttachedViewRecycler. The view recycler may not be + * null + * @param index + * The index of the tab as an {@link Integer} value. The index must be at least 0 + * @return The item, which has been created, as an instance of the class {@link TabItem}. The + * item may not be null + */ + @NonNull + public static TabItem create(@NonNull final Model model, + @NonNull final AttachedViewRecycler viewRecycler, + final int index) { + Tab tab = model.getTab(index); + return create(viewRecycler, index, tab); + } + + /** + * Creates a new item, which contains information about a specific tab. By default, the item is + * neither associated with a view, nor with a view holder. + * + * @param viewRecycler + * The view recycler, which is used to reuse the views, which are used to visualize + * tabs, as an instance of the class AttachedViewRecycler. The view recycler may not be + * null + * @param index + * The index of the tab as an {@link Integer} value. The index must be at least 0 + * @param tab + * The tab as an instance of the class {@link Tab}. The tab may not be null + * @return The item, which has been created, as an instance of the class {@link TabItem}. The + * item may not be null + */ + @NonNull + public static TabItem create(@NonNull final AttachedViewRecycler viewRecycler, + final int index, @NonNull final Tab tab) { + TabItem tabItem = new TabItem(index, tab); + View view = viewRecycler.getView(tabItem); + + if (view != null) { + tabItem.setView(view); + tabItem.setViewHolder((PhoneTabViewHolder) view.getTag(R.id.tag_view_holder)); + Tag tag = (Tag) view.getTag(R.id.tag_properties); + + if (tag != null) { + tabItem.setTag(tag); + } + } + + return tabItem; + } + + /** + * Returns the index of the tab. + * + * @return The index of the tab as an {@link Integer} value. The index must be at least 0 + */ + public final int getIndex() { + return index; + } + + /** + * Returns the tab. + * + * @return The tab as an instance of the class {@link Tab}. The tab may not be null + */ + @NonNull + public final Tab getTab() { + return tab; + } + + /** + * Returns the view, which is used to visualize the tab. + * + * @return The view, which is used to visualize the tab, as an instance of the class {@link + * View} or null, if no such view is currently inflated + */ + public final View getView() { + return view; + } + + /** + * Sets the view, which is used to visualize the tab. + * + * @param view + * The view, which should be set, as an instance of the class {@link View} or null, if + * no view should be set + */ + public final void setView(@Nullable final View view) { + this.view = view; + } + + /** + * Returns the view holder, which stores references to the views, which belong to the tab. + * + * @return The view holder as an instance of the class {@link PhoneTabViewHolder} or null, if no + * view is is currently inflated to visualize the tab + */ + public final PhoneTabViewHolder getViewHolder() { + return viewHolder; + } + + /** + * Sets the view holder, which stores references to the views, which belong to the tab. + * + * @param viewHolder + * The view holder, which should be set, as an instance of the class {@link + * PhoneTabViewHolder} or null, if no view holder should be set + */ + public final void setViewHolder(@Nullable final PhoneTabViewHolder viewHolder) { + this.viewHolder = viewHolder; + } + + /** + * Returns the tag, which is associated with the tab. + * + * @return The tag as an instance of the class {@link Tag}. The tag may not be null + */ + @NonNull + public final Tag getTag() { + return tag; + } + + /** + * Sets the tag, which is associated with the tab. + * + * @param tag + * The tag, which should be set, as an instance of the class {@link Tag}. The tag may + * not be null + */ + public final void setTag(@NonNull final Tag tag) { + ensureNotNull(tag, "The tag may not be null"); + this.tag = tag; + } + + /** + * Returns, whether a view, which is used to visualize the tab, is currently inflated, or not. + * + * @return True, if a view, which is used to visualize the tab, is currently inflated, false + * otherwise + */ + public final boolean isInflated() { + return view != null && viewHolder != null; + } + + /** + * Returns, whether the tab is currently visible, or not. + * + * @return True, if the tab is currently visible, false otherwise + */ + public final boolean isVisible() { + return tag.getState() != State.HIDDEN || tag.isClosing(); + } + + @Override + public final String toString() { + return "TabItem [index = " + index + "]"; + } + + @Override + public final int hashCode() { + return tab.hashCode(); + } + + @Override + public final boolean equals(final Object obj) { + if (obj == null) + return false; + if (obj.getClass() != getClass()) + return false; + TabItem other = (TabItem) obj; + return tab.equals(other.tab); + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabSwitcherModel.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabSwitcherModel.java new file mode 100755 index 0000000..d8bf9c2 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabSwitcherModel.java @@ -0,0 +1,1298 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.model; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.ColorInt; +import android.support.annotation.DrawableRes; +import android.support.annotation.MenuRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.Toolbar.OnMenuItemClickListener; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.NoSuchElementException; +import java.util.Set; + +import de.mrapp.android.tabswitcher.Animation; +import de.mrapp.android.tabswitcher.PeekAnimation; +import de.mrapp.android.tabswitcher.RevealAnimation; +import de.mrapp.android.tabswitcher.SwipeAnimation; +import de.mrapp.android.tabswitcher.Tab; +import de.mrapp.android.tabswitcher.TabCloseListener; +import de.mrapp.android.tabswitcher.TabPreviewListener; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.TabSwitcherDecorator; +import de.mrapp.android.tabswitcher.layout.ChildRecyclerAdapter; +import de.mrapp.android.util.logging.LogLevel; + +import static de.mrapp.android.util.Condition.ensureNotEqual; +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * The model of a {@link TabSwitcher}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class TabSwitcherModel implements Model, Restorable { + + /** + * The name of the extra, which is used to store the index of the first visible tab within a + * bundle. + */ + public static final String FIRST_VISIBLE_TAB_INDEX_EXTRA = + TabSwitcherModel.class.getName() + "::FirstVisibleIndex"; + + /** + * The name of the extra, which is used to store the position of the first visible tab within a + * bundle. + */ + public static final String FIRST_VISIBLE_TAB_POSITION_EXTRA = + TabSwitcherModel.class.getName() + "::FirstVisiblePosition"; + + /** + * The name of the extra, which is used to store the log level within a bundle. + */ + private static final String LOG_LEVEL_EXTRA = TabSwitcherModel.class.getName() + "::LogLevel"; + + /** + * The name of the extra, which is used to store the tabs within a bundle. + */ + private static final String TABS_EXTRA = TabSwitcherModel.class.getName() + "::Tabs"; + + /** + * The name of the extra, which is used to store, whether the tab switcher is shown, or not, + * within a bundle. + */ + private static final String SWITCHER_SHOWN_EXTRA = + TabSwitcherModel.class.getName() + "::SwitcherShown"; + + /** + * The name of the extra, which is used to store the selected tab within a bundle. + */ + private static final String SELECTED_TAB_EXTRA = + TabSwitcherModel.class.getName() + "::SelectedTab"; + + /** + * The name of the extra, which is used to store the padding within a bundle. + */ + private static final String PADDING_EXTRA = TabSwitcherModel.class.getName() + "::Padding"; + + /** + * The name of the extra, which is used to store the resource id of a tab's icon within a + * bundle. + */ + private static final String TAB_ICON_ID_EXTRA = + TabSwitcherModel.class.getName() + "::TabIconId"; + + /** + * The name of the extra, which is used to store the bitmap of a tab's icon within a bundle. + */ + private static final String TAB_ICON_BITMAP_EXTRA = + TabSwitcherModel.class.getName() + "::TabIconBitmap"; + + /** + * The name of the extra, which is used to store the background color of a tab within a bundle. + */ + private static final String TAB_BACKGROUND_COLOR_EXTRA = + TabSwitcherModel.class.getName() + "::TabBackgroundColor"; + + /** + * The name of the extra, which is used to store the text color of a tab's title within a + * bundle. + */ + private static final String TAB_TITLE_TEXT_COLOR_EXTRA = + TabSwitcherModel.class.getName() + "::TabTitleTextColor"; + + /** + * The name of the extra, which is used to store the resource id of a tab's icon within a + * bundle. + */ + private static final String TAB_CLOSE_BUTTON_ICON_ID_EXTRA = + TabSwitcherModel.class.getName() + "::TabCloseButtonIconId"; + + /** + * The name of the extra, which is used to store the bitmap of a tab's icon within a bundle. + */ + private static final String TAB_CLOSE_BUTTON_ICON_BITMAP_EXTRA = + TabSwitcher.class.getName() + "::TabCloseButtonIconBitmap"; + + /** + * The name of the extra, which is used to store, whether the toolbars are shown, or not, within + * a bundle. + */ + private static final String SHOW_TOOLBARS_EXTRA = + TabSwitcher.class.getName() + "::ShowToolbars"; + + /** + * The name of the extra, which is used to store the title of the toolbar within a bundle. + */ + private static final String TOOLBAR_TITLE_EXTRA = + TabSwitcher.class.getName() + "::ToolbarTitle"; + + /** + * The tab switcher, the model belongs to. + */ + private final TabSwitcher tabSwitcher; + + /** + * A set, which contains the listeners, which are notified about the model's events. + */ + private final Set listeners; + + /** + * The index of the first visible tab. + */ + private int firstVisibleTabIndex; + + /** + * The position of the first visible tab. + */ + private float firstVisibleTabPosition; + + /** + * The log level, which is used for logging. + */ + private LogLevel logLevel; + + /** + * A list, which contains the tabs, which are contained by the tab switcher. + */ + private ArrayList tabs; + + /** + * True, if the tab switcher is currently shown, false otherwise. + */ + private boolean switcherShown; + + /** + * The currently selected tab. + */ + private Tab selectedTab; + + /** + * The decorator, which allows to inflate the views, which correspond to the tab switcher's + * tabs. + */ + private TabSwitcherDecorator decorator; + + /** + * The adapter, which allows to inflate the child views of tabs. + */ + private ChildRecyclerAdapter childRecyclerAdapter; + + /** + * An array, which contains the left, top, right and bottom padding of the tab switcher. + */ + private int[] padding; + + /** + * The resource id of a tab's icon. + */ + private int tabIconId; + + /** + * The bitmap of a tab's icon. + */ + private Bitmap tabIconBitmap; + + /** + * The background color of a tab; + */ + private ColorStateList tabBackgroundColor; + + /** + * The text color of a tab's title. + */ + private ColorStateList tabTitleTextColor; + + /** + * The resource id of the icon of a tab's close button. + */ + private int tabCloseButtonIconId; + + /** + * The bitmap of the icon of a tab's close button. + */ + private Bitmap tabCloseButtonIconBitmap; + + /** + * True, if the toolbars should be shown, when the tab switcher is shown, false otherwise. + */ + private boolean showToolbars; + + /** + * The title of the toolbar, which is shown, when the tab switcher is shown. + */ + private CharSequence toolbarTitle; + + /** + * The navigation icon of the toolbar, which is shown, when the tab switcher is shown. + */ + private Drawable toolbarNavigationIcon; + + /** + * The listener, which is notified, when the navigation icon of the toolbar, which is shown, + * when the tab switcher is shown, has been clicked. + */ + private OnClickListener toolbarNavigationIconListener; + + /** + * The resource id of the menu of the toolbar, which is shown, when the tab switcher is shown. + */ + private int toolbarMenuId; + + /** + * The listener, which is notified, when an item of the menu of the toolbar, which is shown, + * when the tab switcher is shown, is clicked. + */ + private OnMenuItemClickListener toolbarMenuItemListener; + + /** + * A set, which contains the listeners, which should be notified, when a tab is about to be + * closed by clicking its close button. + */ + private final Set tabCloseListeners; + + /** + * A set, which contains the listeners, which should be notified, when the previews of tabs are + * about to be loaded. + */ + private final Set tabPreviewListeners; + + /** + * Returns the index of a specific tab or throws a {@link NoSuchElementException}, if the model + * does not contain the given tab. + * + * @param tab + * The tab, whose index should be returned, as an instance of the class {@link Tab}. The + * tab may not be null + * @return The index of the given tab as an {@link Integer} value + */ + private int indexOfOrThrowException(@NonNull final Tab tab) { + int index = indexOf(tab); + ensureNotEqual(index, -1, "No such tab: " + tab, NoSuchElementException.class); + return index; + } + + /** + * Sets, whether the tab switcher is currently shown, or not. + * + * @param shown + * True, if the tab switcher is currently shown, false otherwise + * @return True, if the visibility of the tab switcher has been changed, false otherwise + */ + private boolean setSwitcherShown(final boolean shown) { + if (switcherShown != shown) { + switcherShown = shown; + return true; + } + + return false; + } + + /** + * Notifies the listeners, that the log level has been changed. + * + * @param logLevel + * The log level, which has been set, as a value of the enum {@link LogLevel}. The log + * level may not be null + */ + private void notifyOnLogLevelChanged(@NonNull final LogLevel logLevel) { + for (Listener listener : listeners) { + listener.onLogLevelChanged(logLevel); + } + } + + /** + * Notifies the listeners, that the decorator has been changed. + * + * @param decorator + * The decorator, which has been set, as an instance of the class {@link + * TabSwitcherDecorator}. The decorator may not be null + */ + private void notifyOnDecoratorChanged(@NonNull final TabSwitcherDecorator decorator) { + for (Listener listener : listeners) { + listener.onDecoratorChanged(decorator); + } + } + + /** + * Notifies the listeners, that the tab switcher has been shown. + */ + private void notifyOnSwitcherShown() { + for (Listener listener : listeners) { + listener.onSwitcherShown(); + } + } + + /** + * Notifies the listeners, that the tab switcher has been shown. + */ + private void notifyOnSwitcherHidden() { + for (Listener listener : listeners) { + listener.onSwitcherHidden(); + } + } + + /** + * Notifies the listeners, that the currently selected tab has been changed. + * + * @param previousIndex + * The index of the previously selected tab as an {@link Integer} value or -1, if no tab + * was selected + * @param index + * The index of the tab, which has been selected, as an {@link Integer} value or -1, if + * no tab has been selected + * @param tab + * The tab, which has been selected, as an instance of the class {@link Tab} or null, if + * no tab has been selected + * @param switcherHidden + * True, if selecting the tab caused the tab switcher to be hidden, false otherwise + */ + private void notifyOnSelectionChanged(final int previousIndex, final int index, + @Nullable final Tab tab, final boolean switcherHidden) { + for (Listener listener : listeners) { + listener.onSelectionChanged(previousIndex, index, tab, switcherHidden); + } + } + + /** + * Notifies the listeners, that a specific tab has been added to the model. + * + * @param index + * The index, the tab has been added at, as an {@link Integer} value + * @param tab + * The tab, which has been added, as an instance of the class {@link Tab}. The tab may + * not be null + * @param previousSelectedTabIndex + * The index of the previously selected tab as an {@link Integer} value or -1, if no tab + * was selected + * @param selectedTabIndex + * The index of the currently selected tab as an {@link Integer} value or -1, if the tab + * switcher does not contain any tabs + * @param switcherVisibilityChanged + * True, if adding the tab caused the visibility of the tab switcher to be changed, + * false otherwise + * @param animation + * The animation, which has been used to add the tab, as an instance of the class {@link + * Animation}. The animation may not be null + */ + private void notifyOnTabAdded(final int index, @NonNull final Tab tab, + final int previousSelectedTabIndex, final int selectedTabIndex, + final boolean switcherVisibilityChanged, + @NonNull final Animation animation) { + for (Listener listener : listeners) { + listener.onTabAdded(index, tab, previousSelectedTabIndex, selectedTabIndex, + switcherVisibilityChanged, animation); + } + } + + /** + * Notifies the listeners, that multiple tabs have been added to the model. + * + * @param index + * The index of the tab, which has been added, as an {@link Integer} value + * @param tabs + * An array, which contains the tabs, which have been added, as an array of the type + * {@link Tab}. The array may not be null + * @param previousSelectedTabIndex + * The index of the previously selected tab as an {@link Integer} value or -1, if no tab + * was selected + * @param selectedTabIndex + * The index of the currently selected tab as an {@link Integer} value or -1, if the tab + * switcher does not contain any tabs + * @param animation + * The animation, which has been used to add the tabs, as an instance of the class + * {@link Animation}. The animation may not be null + */ + private void notifyOnAllTabsAdded(final int index, @NonNull final Tab[] tabs, + final int previousSelectedTabIndex, + final int selectedTabIndex, + @NonNull final Animation animation) { + for (Listener listener : listeners) { + listener.onAllTabsAdded(index, tabs, previousSelectedTabIndex, selectedTabIndex, + animation); + } + } + + /** + * Notifies the listeners, that a tab has been removed from the model. + * + * @param index + * The index of the tab, which has been removed, as an {@link Integer} value + * @param tab + * The tab, which has been removed, as an instance of the class {@link Tab}. The tab may + * not be null + * @param previousSelectedTabIndex + * The index of the previously selected tab as an {@link Integer} value or -1, if no tab + * was selected + * @param selectedTabIndex + * The index of the currently selected tab as an {@link Integer} value or -1, if the tab + * switcher does not contain any tabs + * @param animation + * The animation, which has been used to remove the tab, as an instance of the class + * {@link Animation}. The animation may not be null + */ + private void notifyOnTabRemoved(final int index, @NonNull final Tab tab, + final int previousSelectedTabIndex, final int selectedTabIndex, + @NonNull final Animation animation) { + for (Listener listener : listeners) { + listener.onTabRemoved(index, tab, previousSelectedTabIndex, selectedTabIndex, + animation); + } + } + + /** + * Notifies the listeners, that all tabs have been removed. + * + * @param tabs + * An array, which contains the tabs, which have been removed, as an array of the type + * {@link Tab} or an empty array, if no tabs have been removed + * @param animation + * The animation, which has been used to remove the tabs, as an instance of the class + * {@link Animation}. The animation may not be null + */ + private void notifyOnAllTabsRemoved(@NonNull final Tab[] tabs, + @NonNull final Animation animation) { + for (Listener listener : listeners) { + listener.onAllTabsRemoved(tabs, animation); + } + } + + /** + * Notifies the listeners, that the padding has been changed. + * + * @param left + * The left padding, which has been set, in pixels as an {@link Integer} value + * @param top + * The top padding, which has been set, in pixels as an {@link Integer} value + * @param right + * The right padding, which has been set, in pixels as an {@link Integer} value + * @param bottom + * The bottom padding, which has been set, in pixels as an {@link Integer} value + */ + private void notifyOnPaddingChanged(final int left, final int top, final int right, + final int bottom) { + for (Listener listener : listeners) { + listener.onPaddingChanged(left, top, right, bottom); + } + } + + /** + * Notifies the listeners, that the default icon of a tab has been changed. + * + * @param icon + * The icon, which has been set, as an instance of the class {@link Drawable} or null, + * if no icon is set + */ + private void notifyOnTabIconChanged(@Nullable final Drawable icon) { + for (Listener listener : listeners) { + listener.onTabIconChanged(icon); + } + } + + /** + * Notifies the listeners, that the default background color of a tab has been changed. + * + * @param colorStateList + * The color state list, which has been set, as an instance of the class {@link + * ColorStateList} or null, if the default color should be used + */ + private void notifyOnTabBackgroundColorChanged(@Nullable final ColorStateList colorStateList) { + for (Listener listener : listeners) { + listener.onTabBackgroundColorChanged(colorStateList); + } + } + + /** + * Notifies the listeners, that the default text color of a tab's title has been changed. + * + * @param colorStateList + * The color state list, which has been set, as an instance of the class {@link + * ColorStateList} or null, if the default color should be used + */ + private void notifyOnTabTitleColorChanged(@Nullable final ColorStateList colorStateList) { + for (Listener listener : listeners) { + listener.onTabTitleColorChanged(colorStateList); + } + } + + /** + * Notifies the listeners, that the icon of a tab's close button has been changed. + * + * @param icon + * The icon, which has been set, as an instance of the class {@link Drawable} or null, + * if the default icon should be used + */ + private void notifyOnTabCloseButtonIconChanged(@Nullable final Drawable icon) { + for (Listener listener : listeners) { + listener.onTabCloseButtonIconChanged(icon); + } + } + + /** + * Notifies the listeners, that it has been changed, whether the toolbars should be shown, when + * the tab switcher is shown, or not. + * + * @param visible + * True, if the toolbars should be shown, when the tab switcher is shown, false + * otherwise + */ + private void notifyOnToolbarVisibilityChanged(final boolean visible) { + for (Listener listener : listeners) { + listener.onToolbarVisibilityChanged(visible); + } + } + + /** + * Notifies the listeners, that the title of the toolbar, which is shown, when the tab switcher + * is shown, has been changed. + * + * @param title + * The title, which has been set, as an instance of the type {@link CharSequence} or + * null, if no title is set + */ + private void notifyOnToolbarTitleChanged(@Nullable final CharSequence title) { + for (Listener listener : listeners) { + listener.onToolbarTitleChanged(title); + } + } + + /** + * Notifies the listeners, that the menu of the toolbar, which is shown, when the tab switcher + * is shown, has been inflated. + * + * @param resourceId + * The resource id of the menu, which has been inflated, as an {@link Integer} value. + * The resource id must correspond to a valid menu resource + * @param menuItemClickListener + * The listener, which has been registered to be notified, when an item of the menu has + * been clicked, as an instance of the type OnMenuItemClickListener or null, if no + * listener should be notified + */ + private void notifyOnToolbarMenuInflated(@MenuRes final int resourceId, + @Nullable final OnMenuItemClickListener menuItemClickListener) { + for (Listener listener : listeners) { + listener.onToolbarMenuInflated(resourceId, menuItemClickListener); + } + } + + /** + * Notifies the listeners, that the navigation icon of the toolbar, which is shown, when the tab + * switcher is shown, has been changed. + * + * @param icon + * The navigation icon, which has been set, as an instance of the class {@link Drawable} + * or null, if no navigation icon is set + * @param clickListener + * The listener, which should be notified, when the navigation item has been clicked, as + * an instance of the type {@link OnClickListener} or null, if no listener should be + * notified + */ + private void notifyOnToolbarNavigationIconChanged(@Nullable final Drawable icon, + @Nullable final OnClickListener clickListener) { + for (Listener listener : listeners) { + listener.onToolbarNavigationIconChanged(icon, clickListener); + } + } + + /** + * Creates a new model of a {@link TabSwitcher}. + * + * @param tabSwitcher + * The tab switcher, the model belongs to, as an instance of the class {@link + * ViewGroup}. The parent may not be null + */ + public TabSwitcherModel(@NonNull final TabSwitcher tabSwitcher) { + ensureNotNull(tabSwitcher, "The tab switcher may not be null"); + this.tabSwitcher = tabSwitcher; + this.listeners = new LinkedHashSet<>(); + this.firstVisibleTabIndex = -1; + this.firstVisibleTabPosition = -1; + this.logLevel = LogLevel.INFO; + this.tabs = new ArrayList<>(); + this.switcherShown = false; + this.selectedTab = null; + this.decorator = null; + this.childRecyclerAdapter = null; + this.padding = new int[]{0, 0, 0, 0}; + this.tabIconId = -1; + this.tabIconBitmap = null; + this.tabBackgroundColor = null; + this.tabTitleTextColor = null; + this.tabCloseButtonIconId = -1; + this.tabCloseButtonIconBitmap = null; + this.showToolbars = false; + this.toolbarTitle = null; + this.toolbarNavigationIcon = null; + this.toolbarNavigationIconListener = null; + this.toolbarMenuId = -1; + this.toolbarMenuItemListener = null; + this.tabCloseListeners = new LinkedHashSet<>(); + this.tabPreviewListeners = new LinkedHashSet<>(); + } + + /** + * Adds a new listener, which should be notified about the model's events. + * + * @param listener + * The listener, which should be added, as an instance of the type {@link Listener}. The + * listener may not be null + */ + public final void addListener(@NonNull final Listener listener) { + ensureNotNull(listener, "The listener may not be null"); + listeners.add(listener); + } + + /** + * Removes a specific listener, which should not be notified about the model's events, anymore. + * + * @param listener + * The listener, which should be removed, as an instance of the type {@link Listener}. + * The listener may not be null + */ + public final void removeListener(@NonNull final Listener listener) { + ensureNotNull(listener, "The listener may not be null"); + listeners.remove(listener); + } + + /** + * Returns the index of the first visible tab. + * + * @return The index of the first visible tab as an {@link Integer} value or -1, if the index is + * unknown + */ + public final int getFirstVisibleTabIndex() { + return firstVisibleTabIndex; + } + + /** + * Sets the index of the first visible tab. + * + * @param firstVisibleTabIndex + * The index of the first visible tab, which should be set, as an {@link Integer} value + * or -1, if the index is unknown + */ + public final void setFirstVisibleTabIndex(final int firstVisibleTabIndex) { + this.firstVisibleTabIndex = firstVisibleTabIndex; + } + + /** + * Returns the position of the first visible tab. + * + * @return The position of the first visible tab as a {@link Float} value or -1, if the position + * is unknown + */ + public final float getFirstVisibleTabPosition() { + return firstVisibleTabPosition; + } + + /** + * Sets the position of the first visible tab. + * + * @param firstVisibleTabPosition + * The position of the first visible tab, which should be set, as a {@link Float} value + * or -1, if the position is unknown + */ + public final void setFirstVisibleTabPosition(final float firstVisibleTabPosition) { + this.firstVisibleTabPosition = firstVisibleTabPosition; + } + + /** + * Returns the listener, which is notified, when the navigation icon of the toolbar, which is + * shown, when the tab switcher is shown, has been clicked. + * + * @return The listener, which is notified, when the navigation icon of the toolbar, which is + * shown, when the tab switcher is shown, has been clicked as an instance of the type {@link + * OnClickListener} or null, if no listener should be notified + */ + @Nullable + public final OnClickListener getToolbarNavigationIconListener() { + return toolbarNavigationIconListener; + } + + /** + * Returns the resource id of the menu of the toolbar, which is shown, when the tab switcher is + * shown. + * + * @return The resource id of the menu of the toolbar, which is shown, when the tab switcher is + * shown, as an {@link Integer} value. The resource id must correspond to a valid menu resource + */ + @MenuRes + public final int getToolbarMenuId() { + return toolbarMenuId; + } + + /** + * Returns the listener, which is notified, when an item of the menu of the toolbar, which is + * shown, when the tab switcher is shown, has been clicked. + * + * @return The listener, which is notified, when an item of the menu of the toolbar, which is + * shown, when the tab switcher is shown, has been clicked as an instance of the type + * OnMenuItemClickListener or null, if no listener should be notified + */ + @Nullable + public final OnMenuItemClickListener getToolbarMenuItemListener() { + return toolbarMenuItemListener; + } + + /** + * Returns the listeners, which should be notified, when a tab is about to be closed by clicking + * its close button. + * + * @return A set, which contains the listeners, which should be notified, when a tab is about to + * be closed by clicking its close button, as an instance of the type {@link Set} or an empty + * set, if no listeners should be notified + */ + @NonNull + public final Set getTabCloseListeners() { + return tabCloseListeners; + } + + /** + * Returns the listeners, which should be notified, when the previews of tabs are about to be + * loaded. + * + * @return A set, which contains the listeners, which should be notified, when the previews of + * tabs are about to be loaded, as an instance of the type {@link Set} or an empty set, if no + * listeners should be notified + */ + @NonNull + public final Set getTabPreviewListeners() { + return tabPreviewListeners; + } + + /** + * Returns the adapter, which allows to inflate the child views of tabs. + * + * @return The adapter, which allows to inflate the child views of tabs, as an instance of the + * class {@link ChildRecyclerAdapter} + */ + public final ChildRecyclerAdapter getChildRecyclerAdapter() { + return childRecyclerAdapter; + } + + @NonNull + @Override + public final Context getContext() { + return tabSwitcher.getContext(); + } + + @Override + public final void setDecorator(@NonNull final TabSwitcherDecorator decorator) { + ensureNotNull(decorator, "The decorator may not be null"); + this.decorator = decorator; + this.childRecyclerAdapter = new ChildRecyclerAdapter(tabSwitcher, decorator); + notifyOnDecoratorChanged(decorator); + } + + @Override + public final TabSwitcherDecorator getDecorator() { + return decorator; + } + + @NonNull + @Override + public final LogLevel getLogLevel() { + return logLevel; + } + + @Override + public final void setLogLevel(@NonNull final LogLevel logLevel) { + ensureNotNull(logLevel, "The log level may not be null"); + this.logLevel = logLevel; + notifyOnLogLevelChanged(logLevel); + } + + @Override + public final boolean isEmpty() { + return tabs.isEmpty(); + } + + @Override + public final int getCount() { + return tabs.size(); + } + + @NonNull + @Override + public final Tab getTab(final int index) { + return tabs.get(index); + } + + @Override + public final int indexOf(@NonNull final Tab tab) { + ensureNotNull(tab, "The tab may not be null"); + return tabs.indexOf(tab); + } + + @Override + public final void addTab(@NonNull Tab tab) { + addTab(tab, getCount()); + } + + @Override + public final void addTab(@NonNull final Tab tab, final int index) { + addTab(tab, index, new SwipeAnimation.Builder().create()); + } + + @Override + public final void addTab(@NonNull final Tab tab, final int index, + @NonNull final Animation animation) { + ensureNotNull(tab, "The tab may not be null"); + ensureNotNull(animation, "The animation may not be null"); + tabs.add(index, tab); + int previousSelectedTabIndex = getSelectedTabIndex(); + int selectedTabIndex = previousSelectedTabIndex; + boolean switcherVisibilityChanged = false; + + if (previousSelectedTabIndex == -1) { + selectedTab = tab; + selectedTabIndex = index; + } + + if (animation instanceof RevealAnimation) { + selectedTab = tab; + selectedTabIndex = index; + switcherVisibilityChanged = setSwitcherShown(false); + } + + if (animation instanceof PeekAnimation) { + switcherVisibilityChanged = setSwitcherShown(true); + } + + notifyOnTabAdded(index, tab, previousSelectedTabIndex, selectedTabIndex, + switcherVisibilityChanged, animation); + } + + @Override + public final void addAllTabs(@NonNull final Collection tabs) { + addAllTabs(tabs, getCount()); + } + + @Override + public final void addAllTabs(@NonNull final Collection tabs, final int index) { + addAllTabs(tabs, index, new SwipeAnimation.Builder().create()); + } + + @Override + public final void addAllTabs(@NonNull final Collection tabs, final int index, + @NonNull final Animation animation) { + ensureNotNull(tabs, "The collection may not be null"); + Tab[] array = new Tab[tabs.size()]; + tabs.toArray(array); + addAllTabs(array, index, animation); + } + + @Override + public final void addAllTabs(@NonNull final Tab[] tabs) { + addAllTabs(tabs, getCount()); + } + + @Override + public final void addAllTabs(@NonNull final Tab[] tabs, final int index) { + addAllTabs(tabs, index, new SwipeAnimation.Builder().create()); + } + + @Override + public final void addAllTabs(@NonNull final Tab[] tabs, final int index, + @NonNull final Animation animation) { + ensureNotNull(tabs, "The array may not be null"); + ensureNotNull(animation, "The animation may not be null"); + + if (tabs.length > 0) { + int previousSelectedTabIndex = getSelectedTabIndex(); + int selectedTabIndex = previousSelectedTabIndex; + + for (int i = 0; i < tabs.length; i++) { + Tab tab = tabs[i]; + this.tabs.add(index + i, tab); + } + + if (previousSelectedTabIndex == -1) { + selectedTabIndex = 0; + selectedTab = tabs[selectedTabIndex]; + } + + notifyOnAllTabsAdded(index, tabs, previousSelectedTabIndex, selectedTabIndex, + animation); + } + } + + @Override + public final void removeTab(@NonNull final Tab tab) { + removeTab(tab, new SwipeAnimation.Builder().create()); + } + + @Override + public final void removeTab(@NonNull final Tab tab, @NonNull final Animation animation) { + ensureNotNull(tab, "The tab may not be null"); + ensureNotNull(animation, "The animation may not be null"); + int index = indexOfOrThrowException(tab); + int previousSelectedTabIndex = getSelectedTabIndex(); + int selectedTabIndex = previousSelectedTabIndex; + tabs.remove(index); + + if (isEmpty()) { + selectedTabIndex = -1; + selectedTab = null; + } else if (index == previousSelectedTabIndex) { + if (index > 0) { + selectedTabIndex = index - 1; + } + + selectedTab = getTab(selectedTabIndex); + } + + notifyOnTabRemoved(index, tab, previousSelectedTabIndex, selectedTabIndex, animation); + + } + + @Override + public final void clear() { + clear(new SwipeAnimation.Builder().create()); + } + + @Override + public final void clear(@NonNull final Animation animation) { + ensureNotNull(animation, "The animation may not be null"); + Tab[] result = new Tab[tabs.size()]; + tabs.toArray(result); + tabs.clear(); + notifyOnAllTabsRemoved(result, animation); + selectedTab = null; + } + + @Override + public final boolean isSwitcherShown() { + return switcherShown; + } + + @Override + public final void showSwitcher() { + setSwitcherShown(true); + notifyOnSwitcherShown(); + } + + @Override + public final void hideSwitcher() { + setSwitcherShown(false); + notifyOnSwitcherHidden(); + } + + @Override + public final void toggleSwitcherVisibility() { + if (isSwitcherShown()) { + hideSwitcher(); + } else { + showSwitcher(); + } + } + + @Nullable + @Override + public final Tab getSelectedTab() { + return selectedTab; + } + + @Override + public final int getSelectedTabIndex() { + return selectedTab != null ? indexOf(selectedTab) : -1; + } + + @Override + public final void selectTab(@NonNull final Tab tab) { + ensureNotNull(tab, "The tab may not be null"); + int previousIndex = getSelectedTabIndex(); + int index = indexOfOrThrowException(tab); + selectedTab = tab; + boolean switcherHidden = setSwitcherShown(false); + notifyOnSelectionChanged(previousIndex, index, tab, switcherHidden); + } + + @Override + public final Iterator iterator() { + return tabs.iterator(); + } + + @Override + public final void setPadding(final int left, final int top, final int right, final int bottom) { + padding = new int[]{left, top, right, bottom}; + notifyOnPaddingChanged(left, top, right, bottom); + } + + @Override + public final int getPaddingLeft() { + return padding[0]; + } + + @Override + public final int getPaddingTop() { + return padding[1]; + } + + @Override + public final int getPaddingRight() { + return padding[2]; + } + + @Override + public final int getPaddingBottom() { + return padding[3]; + } + + @Override + public final int getPaddingStart() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + return tabSwitcher.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL ? + getPaddingRight() : getPaddingLeft(); + } + + return getPaddingLeft(); + } + + @Override + public final int getPaddingEnd() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + return tabSwitcher.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL ? + getPaddingLeft() : getPaddingRight(); + } + + return getPaddingRight(); + } + + @Nullable + @Override + public final Drawable getTabIcon() { + if (tabIconId != -1) { + return ContextCompat.getDrawable(getContext(), tabIconId); + } else { + return tabIconBitmap != null ? + new BitmapDrawable(getContext().getResources(), tabIconBitmap) : null; + } + } + + @Override + public final void setTabIcon(@DrawableRes final int resourceId) { + this.tabIconId = resourceId; + this.tabIconBitmap = null; + notifyOnTabIconChanged(getTabIcon()); + } + + @Override + public final void setTabIcon(@Nullable final Bitmap icon) { + this.tabIconId = -1; + this.tabIconBitmap = icon; + notifyOnTabIconChanged(getTabIcon()); + } + + @Nullable + @Override + public final ColorStateList getTabBackgroundColor() { + return tabBackgroundColor; + } + + @Override + public final void setTabBackgroundColor(@ColorInt final int color) { + setTabBackgroundColor(color != -1 ? ColorStateList.valueOf(color) : null); + } + + @Override + public final void setTabBackgroundColor(@Nullable final ColorStateList colorStateList) { + this.tabBackgroundColor = colorStateList; + notifyOnTabBackgroundColorChanged(colorStateList); + } + + @Nullable + @Override + public final ColorStateList getTabTitleTextColor() { + return tabTitleTextColor; + } + + @Override + public final void setTabTitleTextColor(@ColorInt final int color) { + setTabTitleTextColor(color != -1 ? ColorStateList.valueOf(color) : null); + } + + @Override + public final void setTabTitleTextColor(@Nullable final ColorStateList colorStateList) { + this.tabTitleTextColor = colorStateList; + notifyOnTabTitleColorChanged(colorStateList); + } + + @Nullable + @Override + public final Drawable getTabCloseButtonIcon() { + if (tabCloseButtonIconId != -1) { + return ContextCompat.getDrawable(getContext(), tabCloseButtonIconId); + } else { + return tabCloseButtonIconBitmap != null ? + new BitmapDrawable(getContext().getResources(), tabCloseButtonIconBitmap) : + null; + } + } + + @Override + public final void setTabCloseButtonIcon(@DrawableRes final int resourceId) { + tabCloseButtonIconId = resourceId; + tabCloseButtonIconBitmap = null; + notifyOnTabCloseButtonIconChanged(getTabCloseButtonIcon()); + } + + @Override + public final void setTabCloseButtonIcon(@Nullable final Bitmap icon) { + tabCloseButtonIconId = -1; + tabCloseButtonIconBitmap = icon; + notifyOnTabCloseButtonIconChanged(getTabCloseButtonIcon()); + } + + @Override + public final boolean areToolbarsShown() { + return showToolbars; + } + + @Override + public final void showToolbars(final boolean show) { + this.showToolbars = show; + notifyOnToolbarVisibilityChanged(show); + } + + @Nullable + @Override + public final CharSequence getToolbarTitle() { + return toolbarTitle; + } + + @Override + public void setToolbarTitle(@StringRes final int resourceId) { + setToolbarTitle(getContext().getText(resourceId)); + } + + @Override + public final void setToolbarTitle(@Nullable final CharSequence title) { + this.toolbarTitle = title; + notifyOnToolbarTitleChanged(title); + } + + @Nullable + @Override + public final Drawable getToolbarNavigationIcon() { + return toolbarNavigationIcon; + } + + @Override + public final void setToolbarNavigationIcon(@DrawableRes final int resourceId, + @Nullable final OnClickListener listener) { + setToolbarNavigationIcon(ContextCompat.getDrawable(getContext(), resourceId), listener); + } + + @Override + public final void setToolbarNavigationIcon(@Nullable final Drawable icon, + @Nullable final OnClickListener listener) { + this.toolbarNavigationIcon = icon; + this.toolbarNavigationIconListener = listener; + notifyOnToolbarNavigationIconChanged(icon, listener); + } + + @Override + public final void inflateToolbarMenu(@MenuRes final int resourceId, + @Nullable final OnMenuItemClickListener listener) { + this.toolbarMenuId = resourceId; + this.toolbarMenuItemListener = listener; + notifyOnToolbarMenuInflated(resourceId, listener); + } + + @Override + public final void addCloseTabListener(@NonNull final TabCloseListener listener) { + ensureNotNull(listener, "The listener may not be null"); + tabCloseListeners.add(listener); + } + + @Override + public final void removeCloseTabListener(@NonNull final TabCloseListener listener) { + ensureNotNull(listener, "The listener may not be null"); + tabCloseListeners.remove(listener); + } + + @Override + public final void addTabPreviewListener(@NonNull final TabPreviewListener listener) { + ensureNotNull(listener, "The listener may not be null"); + tabPreviewListeners.add(listener); + } + + @Override + public final void removeTabPreviewListener(@NonNull final TabPreviewListener listener) { + ensureNotNull(listener, "The listener may not be null"); + tabPreviewListeners.remove(listener); + } + + @Override + public final void saveInstanceState(@NonNull final Bundle outState) { + outState.putSerializable(LOG_LEVEL_EXTRA, logLevel); + outState.putParcelableArrayList(TABS_EXTRA, tabs); + outState.putBoolean(SWITCHER_SHOWN_EXTRA, switcherShown); + outState.putParcelable(SELECTED_TAB_EXTRA, selectedTab); + outState.putIntArray(PADDING_EXTRA, padding); + outState.putInt(TAB_ICON_ID_EXTRA, tabIconId); + outState.putParcelable(TAB_ICON_BITMAP_EXTRA, tabIconBitmap); + outState.putParcelable(TAB_BACKGROUND_COLOR_EXTRA, tabBackgroundColor); + outState.putParcelable(TAB_TITLE_TEXT_COLOR_EXTRA, tabTitleTextColor); + outState.putInt(TAB_CLOSE_BUTTON_ICON_ID_EXTRA, tabCloseButtonIconId); + outState.putParcelable(TAB_CLOSE_BUTTON_ICON_BITMAP_EXTRA, tabCloseButtonIconBitmap); + outState.putBoolean(SHOW_TOOLBARS_EXTRA, showToolbars); + outState.putCharSequence(TOOLBAR_TITLE_EXTRA, toolbarTitle); + childRecyclerAdapter.saveInstanceState(outState); + } + + @Override + public final void restoreInstanceState(@Nullable final Bundle savedInstanceState) { + if (savedInstanceState != null) { + firstVisibleTabIndex = savedInstanceState.getInt(FIRST_VISIBLE_TAB_INDEX_EXTRA, -1); + firstVisibleTabPosition = + savedInstanceState.getFloat(FIRST_VISIBLE_TAB_POSITION_EXTRA, -1); + logLevel = (LogLevel) savedInstanceState.getSerializable(LOG_LEVEL_EXTRA); + tabs = savedInstanceState.getParcelableArrayList(TABS_EXTRA); + switcherShown = savedInstanceState.getBoolean(SWITCHER_SHOWN_EXTRA); + selectedTab = savedInstanceState.getParcelable(SELECTED_TAB_EXTRA); + padding = savedInstanceState.getIntArray(PADDING_EXTRA); + tabIconId = savedInstanceState.getInt(TAB_ICON_ID_EXTRA); + tabIconBitmap = savedInstanceState.getParcelable(TAB_ICON_BITMAP_EXTRA); + tabBackgroundColor = savedInstanceState.getParcelable(TAB_BACKGROUND_COLOR_EXTRA); + tabTitleTextColor = savedInstanceState.getParcelable(TAB_TITLE_TEXT_COLOR_EXTRA); + tabCloseButtonIconId = savedInstanceState.getInt(TAB_CLOSE_BUTTON_ICON_ID_EXTRA); + tabCloseButtonIconBitmap = + savedInstanceState.getParcelable(TAB_CLOSE_BUTTON_ICON_BITMAP_EXTRA); + showToolbars = savedInstanceState.getBoolean(SHOW_TOOLBARS_EXTRA); + toolbarTitle = savedInstanceState.getCharSequence(TOOLBAR_TITLE_EXTRA); + childRecyclerAdapter.restoreInstanceState(savedInstanceState); + } + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Tag.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Tag.java new file mode 100755 index 0000000..5ee6081 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Tag.java @@ -0,0 +1,131 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.model; + +import android.support.annotation.NonNull; + +import de.mrapp.android.tabswitcher.TabSwitcher; + +import static de.mrapp.android.util.Condition.ensureNotNull; + +/** + * A tag, which allows to store the properties of the tabs of a {@link TabSwitcher}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class Tag implements Cloneable { + + /** + * The position of the tab on the dragging axis. + */ + private float position; + + /** + * The state of the tab. + */ + private State state; + + /** + * True, if the tab is currently being closed, false otherwise + */ + private boolean closing; + + /** + * Creates a new tag, which allows to store the properties of the tabs of a {@link + * TabSwitcher}. + */ + public Tag() { + setPosition(Float.NaN); + setState(State.HIDDEN); + setClosing(false); + } + + /** + * Returns the position of the tab on the dragging axis. + * + * @return The position of the tab as a {@link Float} value + */ + public final float getPosition() { + return position; + } + + /** + * Sets the position of the tab on the dragging axis. + * + * @param position + * The position, which should be set, as a {@link Float} value + */ + public final void setPosition(final float position) { + this.position = position; + } + + /** + * Returns the state of the tab. + * + * @return The state of the tab as a value of the enum {@link State}. The state may not be null + */ + @NonNull + public final State getState() { + return state; + } + + /** + * Sets the state of the tab. + * + * @param state + * The state, which should be set, as a value of the enum {@link State}. The state may + * not be null + */ + public final void setState(@NonNull final State state) { + ensureNotNull(state, "The state may not be null"); + this.state = state; + } + + /** + * Returns, whether the tab is currently being closed, or not. + * + * @return True, if the tab is currently being closed, false otherwise + */ + public final boolean isClosing() { + return closing; + } + + /** + * Sets, whether the tab is currently being closed, or not. + * + * @param closing + * True, if the tab is currently being closed, false otherwise + */ + public final void setClosing(final boolean closing) { + this.closing = closing; + } + + @Override + public final Tag clone() { + Tag clone; + + try { + clone = (Tag) super.clone(); + } catch (ClassCastException | CloneNotSupportedException e) { + clone = new Tag(); + } + + clone.position = position; + clone.state = state; + clone.closing = closing; + return clone; + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/view/TabSwitcherButton.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/view/TabSwitcherButton.java new file mode 100755 index 0000000..7f9c7f7 --- /dev/null +++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/view/TabSwitcherButton.java @@ -0,0 +1,159 @@ +/* + * Copyright 2016 - 2017 Michael Rapp + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package de.mrapp.android.tabswitcher.view; + +import android.content.Context; +import android.support.annotation.AttrRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.AppCompatImageButton; +import android.util.AttributeSet; + +import de.mrapp.android.tabswitcher.Animation; +import de.mrapp.android.tabswitcher.R; +import de.mrapp.android.tabswitcher.Tab; +import de.mrapp.android.tabswitcher.TabSwitcher; +import de.mrapp.android.tabswitcher.TabSwitcherListener; +import de.mrapp.android.tabswitcher.drawable.TabSwitcherDrawable; +import de.mrapp.android.util.ThemeUtil; +import de.mrapp.android.util.ViewUtil; + +/** + * An image button, which allows to display the number of tabs, which are currently contained by a + * {@link TabSwitcher} by using a {@link TabSwitcherDrawable}. It must be registered at a {@link + * TabSwitcher} instance in order to keep the displayed count up to date. It therefore implements + * the interface {@link TabSwitcherListener}. + * + * @author Michael Rapp + * @since 0.1.0 + */ +public class TabSwitcherButton extends AppCompatImageButton implements TabSwitcherListener { + + /** + * The drawable, which is used by the image button. + */ + private TabSwitcherDrawable drawable; + + /** + * Initializes the view. + */ + private void initialize() { + drawable = new TabSwitcherDrawable(getContext()); + setImageDrawable(drawable); + ViewUtil.setBackground(this, + ThemeUtil.getDrawable(getContext(), R.attr.selectableItemBackgroundBorderless)); + setContentDescription(null); + setClickable(true); + setFocusable(true); + } + + /** + * Creates a new image button, which allows to display the number of tabs, which are currently + * contained by a {@link TabSwitcher}. + * + * @param context + * The context, which should be used by the view, as an instance of the class {@link + * Context}. The context may not be null + */ + public TabSwitcherButton(@NonNull final Context context) { + this(context, null); + } + + /** + * Creates a new image button, which allows to display the number of tabs, which are currently + * contained by a {@link TabSwitcher}. + * + * @param context + * The context, which should be used by the view, as an instance of the class {@link + * Context}. The context may not be null + * @param attributeSet + * The attribute set, the view's attributes should be obtained from, as an instance of + * the type {@link AttributeSet} or null, if no attributes should be obtained + */ + public TabSwitcherButton(@NonNull final Context context, + @Nullable final AttributeSet attributeSet) { + super(context, attributeSet); + initialize(); + } + + /** + * Creates a new image button, which allows to display the number of tabs, which are currently + * contained by a {@link TabSwitcher}. + * + * @param context + * The context, which should be used by the view, as an instance of the class {@link + * Context}. The context may not be null + * @param attributeSet + * The attribute set, the view's attributes should be obtained from, as an instance of + * the type {@link AttributeSet} or null, if no attributes should be obtained + * @param defaultStyle + * The default style to apply to this view. If 0, no style will be applied (beyond what + * is included in the theme). This may either be an attribute resource, whose value will + * be retrieved from the current theme, or an explicit style resource + */ + public TabSwitcherButton(@NonNull final Context context, + @Nullable final AttributeSet attributeSet, + @AttrRes final int defaultStyle) { + super(context, attributeSet, defaultStyle); + initialize(); + } + + /** + * Updates the image button to display a specific value. + * + * @param count + * The value, which should be displayed, as an {@link Integer} value. The value must be + * at least 0 + */ + public final void setCount(final int count) { + drawable.setCount(count); + } + + @Override + public final void onSwitcherShown(@NonNull final TabSwitcher tabSwitcher) { + drawable.onSwitcherShown(tabSwitcher); + } + + @Override + public final void onSwitcherHidden(@NonNull final TabSwitcher tabSwitcher) { + drawable.onSwitcherHidden(tabSwitcher); + } + + @Override + public final void onSelectionChanged(@NonNull final TabSwitcher tabSwitcher, + final int selectedTabIndex, + @Nullable final Tab selectedTab) { + drawable.onSelectionChanged(tabSwitcher, selectedTabIndex, selectedTab); + } + + @Override + public final void onTabAdded(@NonNull final TabSwitcher tabSwitcher, final int index, + @NonNull final Tab tab, @NonNull final Animation animation) { + drawable.onTabAdded(tabSwitcher, index, tab, animation); + } + + @Override + public final void onTabRemoved(@NonNull final TabSwitcher tabSwitcher, final int index, + @NonNull final Tab tab, @NonNull final Animation animation) { + drawable.onTabRemoved(tabSwitcher, index, tab, animation); + } + + @Override + public final void onAllTabsRemoved(@NonNull final TabSwitcher tabSwitcher, + @NonNull final Tab[] tabs, + @NonNull final Animation animation) { + drawable.onAllTabsRemoved(tabSwitcher, tabs, animation); + } + +} \ No newline at end of file diff --git a/chrome-tabs/src/main/res/drawable-hdpi/phone_close_tab_icon.png b/chrome-tabs/src/main/res/drawable-hdpi/phone_close_tab_icon.png new file mode 100755 index 0000000..b3faed4 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-hdpi/phone_close_tab_icon.png differ diff --git a/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_background.9.png b/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_background.9.png new file mode 100755 index 0000000..2f4bf05 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_background.9.png differ diff --git a/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_border.9.png b/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_border.9.png new file mode 100755 index 0000000..a85f0f1 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_border.9.png differ diff --git a/chrome-tabs/src/main/res/drawable-hdpi/tab_switcher_drawable_background.png b/chrome-tabs/src/main/res/drawable-hdpi/tab_switcher_drawable_background.png new file mode 100755 index 0000000..335deae Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-hdpi/tab_switcher_drawable_background.png differ diff --git a/chrome-tabs/src/main/res/drawable-mdpi/ic_close_tab_18dp.png b/chrome-tabs/src/main/res/drawable-mdpi/ic_close_tab_18dp.png new file mode 100755 index 0000000..70bbbf3 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-mdpi/ic_close_tab_18dp.png differ diff --git a/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_background.9.png b/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_background.9.png new file mode 100755 index 0000000..ffc021c Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_background.9.png differ diff --git a/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_border.9.png b/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_border.9.png new file mode 100755 index 0000000..12b73ab Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_border.9.png differ diff --git a/chrome-tabs/src/main/res/drawable-mdpi/tab_switcher_drawable_background.png b/chrome-tabs/src/main/res/drawable-mdpi/tab_switcher_drawable_background.png new file mode 100755 index 0000000..7a6dcd8 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-mdpi/tab_switcher_drawable_background.png differ diff --git a/chrome-tabs/src/main/res/drawable-xhdpi/ic_close_tab_18dp.png b/chrome-tabs/src/main/res/drawable-xhdpi/ic_close_tab_18dp.png new file mode 100755 index 0000000..f7a2aa1 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xhdpi/ic_close_tab_18dp.png differ diff --git a/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_background.9.png b/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_background.9.png new file mode 100755 index 0000000..20bc872 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_background.9.png differ diff --git a/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_border.9.png b/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_border.9.png new file mode 100755 index 0000000..9190ab9 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_border.9.png differ diff --git a/chrome-tabs/src/main/res/drawable-xhdpi/tab_switcher_drawable_background.png b/chrome-tabs/src/main/res/drawable-xhdpi/tab_switcher_drawable_background.png new file mode 100755 index 0000000..a345c5e Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xhdpi/tab_switcher_drawable_background.png differ diff --git a/chrome-tabs/src/main/res/drawable-xxhdpi/ic_close_tab_18dp.png b/chrome-tabs/src/main/res/drawable-xxhdpi/ic_close_tab_18dp.png new file mode 100755 index 0000000..05a5741 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxhdpi/ic_close_tab_18dp.png differ diff --git a/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_background.9.png b/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_background.9.png new file mode 100755 index 0000000..33cfd60 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_background.9.png differ diff --git a/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_border.9.png b/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_border.9.png new file mode 100755 index 0000000..bfc0df7 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_border.9.png differ diff --git a/chrome-tabs/src/main/res/drawable-xxhdpi/tab_switcher_drawable_background.png b/chrome-tabs/src/main/res/drawable-xxhdpi/tab_switcher_drawable_background.png new file mode 100755 index 0000000..ca78d44 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxhdpi/tab_switcher_drawable_background.png differ diff --git a/chrome-tabs/src/main/res/drawable-xxxhdpi/ic_close_tab_18dp.png b/chrome-tabs/src/main/res/drawable-xxxhdpi/ic_close_tab_18dp.png new file mode 100755 index 0000000..9567930 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxxhdpi/ic_close_tab_18dp.png differ diff --git a/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_background.9.png b/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_background.9.png new file mode 100755 index 0000000..3c94a8b Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_background.9.png differ diff --git a/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_border.9.png b/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_border.9.png new file mode 100755 index 0000000..2ac893b Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_border.9.png differ diff --git a/chrome-tabs/src/main/res/drawable-xxxhdpi/tab_switcher_drawable_background.png b/chrome-tabs/src/main/res/drawable-xxxhdpi/tab_switcher_drawable_background.png new file mode 100755 index 0000000..f435a65 Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxxhdpi/tab_switcher_drawable_background.png differ diff --git a/chrome-tabs/src/main/res/layout-land/phone_tab.xml b/chrome-tabs/src/main/res/layout-land/phone_tab.xml new file mode 100755 index 0000000..fab0950 --- /dev/null +++ b/chrome-tabs/src/main/res/layout-land/phone_tab.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/chrome-tabs/src/main/res/layout/phone_tab.xml b/chrome-tabs/src/main/res/layout/phone_tab.xml new file mode 100755 index 0000000..0b90831 --- /dev/null +++ b/chrome-tabs/src/main/res/layout/phone_tab.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/chrome-tabs/src/main/res/layout/phone_toolbar.xml b/chrome-tabs/src/main/res/layout/phone_toolbar.xml new file mode 100755 index 0000000..507b1d7 --- /dev/null +++ b/chrome-tabs/src/main/res/layout/phone_toolbar.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/chrome-tabs/src/main/res/layout/tab_switcher_menu_item.xml b/chrome-tabs/src/main/res/layout/tab_switcher_menu_item.xml new file mode 100755 index 0000000..c400afc --- /dev/null +++ b/chrome-tabs/src/main/res/layout/tab_switcher_menu_item.xml @@ -0,0 +1,19 @@ + + + + \ No newline at end of file diff --git a/chrome-tabs/src/main/res/values-v21/styles.xml b/chrome-tabs/src/main/res/values-v21/styles.xml new file mode 100755 index 0000000..4a80ff8 --- /dev/null +++ b/chrome-tabs/src/main/res/values-v21/styles.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/chrome-tabs/src/main/res/values/attrs.xml b/chrome-tabs/src/main/res/values/attrs.xml new file mode 100755 index 0000000..2d201ad --- /dev/null +++ b/chrome-tabs/src/main/res/values/attrs.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/chrome-tabs/src/main/res/values/colors.xml b/chrome-tabs/src/main/res/values/colors.xml new file mode 100755 index 0000000..0f65732 --- /dev/null +++ b/chrome-tabs/src/main/res/values/colors.xml @@ -0,0 +1,22 @@ + + + + + + #ff14181c + #fff2f2f2 + #a8000000 + + \ No newline at end of file diff --git a/chrome-tabs/src/main/res/values/dimens.xml b/chrome-tabs/src/main/res/values/dimens.xml new file mode 100755 index 0000000..5bdafdf --- /dev/null +++ b/chrome-tabs/src/main/res/values/dimens.xml @@ -0,0 +1,36 @@ + + + + + + 16dp + 4dp + 48dp + 12dp + 8dp + 128dp + 4dp + 48dp + 1280dp + 1024dp + 8dp + 24dp + 32dp + 10sp + 7sp + 0.5 + 0 + + \ No newline at end of file diff --git a/chrome-tabs/src/main/res/values/ids.xml b/chrome-tabs/src/main/res/values/ids.xml new file mode 100755 index 0000000..f6013c6 --- /dev/null +++ b/chrome-tabs/src/main/res/values/ids.xml @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/chrome-tabs/src/main/res/values/integers.xml b/chrome-tabs/src/main/res/values/integers.xml new file mode 100755 index 0000000..f9b9577 --- /dev/null +++ b/chrome-tabs/src/main/res/values/integers.xml @@ -0,0 +1,33 @@ + + + + + + 3 + 2 + 3 + @android:integer/config_shortAnimTime + @android:integer/config_shortAnimTime + @android:integer/config_mediumAnimTime + @android:integer/config_shortAnimTime + @android:integer/config_longAnimTime + @android:integer/config_shortAnimTime + @android:integer/config_mediumAnimTime + @android:integer/config_shortAnimTime + @android:integer/config_shortAnimTime + @android:integer/config_shortAnimTime + 1200 + + \ No newline at end of file diff --git a/chrome-tabs/src/main/res/values/styles.xml b/chrome-tabs/src/main/res/values/styles.xml new file mode 100755 index 0000000..f9337b9 --- /dev/null +++ b/chrome-tabs/src/main/res/values/styles.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..aac7c9b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,17 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4f7af6d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Jun 11 16:46:54 CST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.0-milestone-1-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..aec9973 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..db5559a --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':app', ':chrome-tabs'