From acff62db44030da1260906add34713ed331c9420 Mon Sep 17 00:00:00 2001 From: zt515 Date: Sun, 11 Jun 2017 19:24:09 +0800 Subject: [PATCH] Feature: Terminal session and extra keys --- .gitignore | 10 + app/.gitignore | 1 + app/CMakeLists.txt | 7 + app/build.gradle | 43 + app/proguard-rules.pro | 25 + .../io/neoterm/ExampleInstrumentedTest.kt | 25 + app/src/main/AndroidManifest.xml | 25 + app/src/main/cpp/neoterm.cpp | 222 + app/src/main/java/io/neoterm/MainActivity.kt | 131 + .../java/io/neoterm/terminal/ByteQueue.java | 108 + .../io/neoterm/terminal/EmulatorDebug.java | 10 + .../main/java/io/neoterm/terminal/JNI.java | 41 + .../java/io/neoterm/terminal/KeyHandler.java | 313 ++ .../io/neoterm/terminal/TerminalBuffer.java | 425 ++ .../neoterm/terminal/TerminalColorScheme.java | 103 + .../io/neoterm/terminal/TerminalColors.java | 76 + .../io/neoterm/terminal/TerminalEmulator.java | 2341 +++++++++++ .../io/neoterm/terminal/TerminalOutput.java | 28 + .../java/io/neoterm/terminal/TerminalRow.java | 232 ++ .../io/neoterm/terminal/TerminalSession.java | 342 ++ .../java/io/neoterm/terminal/TextStyle.java | 90 + .../java/io/neoterm/terminal/WcWidth.java | 458 ++ .../java/io/neoterm/view/ExtraKeysView.java | 178 + .../view/GestureAndScaleRecognizer.java | 111 + .../io/neoterm/view/TerminalRenderer.java | 230 + .../java/io/neoterm/view/TerminalView.java | 919 ++++ .../io/neoterm/view/TerminalViewClient.java | 42 + .../drawable-hdpi/ic_add_box_white_24dp.png | Bin 0 -> 280 bytes .../drawable-mdpi/ic_add_box_white_24dp.png | Bin 0 -> 210 bytes .../drawable-xhdpi/ic_add_box_white_24dp.png | Bin 0 -> 257 bytes .../drawable-xxhdpi/ic_add_box_white_24dp.png | Bin 0 -> 324 bytes .../text_select_handle_left_mtrl_alpha.png | Bin 0 -> 2032 bytes .../text_select_handle_right_mtrl_alpha.png | Bin 0 -> 15200 bytes .../ic_add_box_white_24dp.png | Bin 0 -> 486 bytes .../text_select_handle_left_material.xml | 4 + .../text_select_handle_right_material.xml | 4 + app/src/main/res/layout/activity_main.xml | 27 + app/src/main/res/layout/tab_main.xml | 14 + app/src/main/res/menu/tab_switcher.xml | 30 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3358 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5084 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2386 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3179 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4648 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7381 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7008 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 11545 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9442 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 16109 bytes app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/strings.xml | 10 + app/src/main/res/values/styles.xml | 8 + .../test/java/io/neoterm/ExampleUnitTest.kt | 18 + build.gradle | 28 + chrome-tabs/.gitignore | 1 + chrome-tabs/build.gradle | 25 + chrome-tabs/gradle.properties | 3 + chrome-tabs/src/main/AndroidManifest.xml | 16 + .../mrapp/android/tabswitcher/Animation.java | 162 + .../de/mrapp/android/tabswitcher/Layout.java | 39 + .../android/tabswitcher/LayoutPolicy.java | 83 + .../android/tabswitcher/PeekAnimation.java | 140 + .../android/tabswitcher/RevealAnimation.java | 138 + .../android/tabswitcher/SwipeAnimation.java | 131 + .../de/mrapp/android/tabswitcher/Tab.java | 573 +++ .../android/tabswitcher/TabCloseListener.java | 40 + .../tabswitcher/TabPreviewListener.java | 41 + .../android/tabswitcher/TabSwitcher.java | 1454 +++++++ .../tabswitcher/TabSwitcherDecorator.java | 243 ++ .../tabswitcher/TabSwitcherListener.java | 114 + .../drawable/TabSwitcherDrawable.java | 211 + .../iterator/AbstractTabItemIterator.java | 232 ++ .../iterator/ArrayTabItemIterator.java | 128 + .../tabswitcher/iterator/TabItemIterator.java | 130 + .../layout/AbstractDragHandler.java | 778 ++++ .../layout/AbstractTabSwitcherLayout.java | 616 +++ .../layout/AbstractTabViewHolder.java | 40 + .../tabswitcher/layout/Arithmetics.java | 274 ++ .../layout/ChildRecyclerAdapter.java | 137 + .../tabswitcher/layout/TabSwitcherLayout.java | 70 + .../layout/phone/PhoneArithmetics.java | 439 ++ .../layout/phone/PhoneDragHandler.java | 288 ++ .../layout/phone/PhoneRecyclerAdapter.java | 836 ++++ .../layout/phone/PhoneTabSwitcherLayout.java | 3705 +++++++++++++++++ .../layout/phone/PhoneTabViewHolder.java | 57 + .../layout/phone/PreviewDataBinder.java | 119 + .../android/tabswitcher/model/Model.java | 915 ++++ .../android/tabswitcher/model/Restorable.java | 46 + .../android/tabswitcher/model/State.java | 49 + .../android/tabswitcher/model/TabItem.java | 305 ++ .../tabswitcher/model/TabSwitcherModel.java | 1298 ++++++ .../mrapp/android/tabswitcher/model/Tag.java | 131 + .../tabswitcher/view/TabSwitcherButton.java | 159 + .../drawable-hdpi/phone_close_tab_icon.png | Bin 0 -> 395 bytes .../drawable-hdpi/phone_tab_background.9.png | Bin 0 -> 2409 bytes .../res/drawable-hdpi/phone_tab_border.9.png | Bin 0 -> 465 bytes .../tab_switcher_drawable_background.png | Bin 0 -> 250 bytes .../res/drawable-mdpi/ic_close_tab_18dp.png | Bin 0 -> 292 bytes .../drawable-mdpi/phone_tab_background.9.png | Bin 0 -> 1055 bytes .../res/drawable-mdpi/phone_tab_border.9.png | Bin 0 -> 315 bytes .../tab_switcher_drawable_background.png | Bin 0 -> 202 bytes .../res/drawable-xhdpi/ic_close_tab_18dp.png | Bin 0 -> 467 bytes .../drawable-xhdpi/phone_tab_background.9.png | Bin 0 -> 3516 bytes .../res/drawable-xhdpi/phone_tab_border.9.png | Bin 0 -> 651 bytes .../tab_switcher_drawable_background.png | Bin 0 -> 286 bytes .../res/drawable-xxhdpi/ic_close_tab_18dp.png | Bin 0 -> 597 bytes .../phone_tab_background.9.png | Bin 0 -> 5969 bytes .../drawable-xxhdpi/phone_tab_border.9.png | Bin 0 -> 966 bytes .../tab_switcher_drawable_background.png | Bin 0 -> 378 bytes .../drawable-xxxhdpi/ic_close_tab_18dp.png | Bin 0 -> 763 bytes .../phone_tab_background.9.png | Bin 0 -> 8467 bytes .../drawable-xxxhdpi/phone_tab_border.9.png | Bin 0 -> 1352 bytes .../tab_switcher_drawable_background.png | Bin 0 -> 473 bytes .../src/main/res/layout-land/phone_tab.xml | 71 + chrome-tabs/src/main/res/layout/phone_tab.xml | 71 + .../src/main/res/layout/phone_toolbar.xml | 25 + .../res/layout/tab_switcher_menu_item.xml | 19 + .../src/main/res/values-v21/styles.xml | 26 + chrome-tabs/src/main/res/values/attrs.xml | 34 + chrome-tabs/src/main/res/values/colors.xml | 22 + chrome-tabs/src/main/res/values/dimens.xml | 36 + chrome-tabs/src/main/res/values/ids.xml | 21 + chrome-tabs/src/main/res/values/integers.xml | 33 + chrome-tabs/src/main/res/values/styles.xml | 26 + gradle.properties | 17 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 160 + gradlew.bat | 90 + settings.gradle | 1 + 130 files changed, 21509 insertions(+) create mode 100644 app/.gitignore create mode 100644 app/CMakeLists.txt create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/io/neoterm/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100755 app/src/main/cpp/neoterm.cpp create mode 100644 app/src/main/java/io/neoterm/MainActivity.kt create mode 100755 app/src/main/java/io/neoterm/terminal/ByteQueue.java create mode 100755 app/src/main/java/io/neoterm/terminal/EmulatorDebug.java create mode 100755 app/src/main/java/io/neoterm/terminal/JNI.java create mode 100755 app/src/main/java/io/neoterm/terminal/KeyHandler.java create mode 100755 app/src/main/java/io/neoterm/terminal/TerminalBuffer.java create mode 100755 app/src/main/java/io/neoterm/terminal/TerminalColorScheme.java create mode 100755 app/src/main/java/io/neoterm/terminal/TerminalColors.java create mode 100755 app/src/main/java/io/neoterm/terminal/TerminalEmulator.java create mode 100755 app/src/main/java/io/neoterm/terminal/TerminalOutput.java create mode 100755 app/src/main/java/io/neoterm/terminal/TerminalRow.java create mode 100755 app/src/main/java/io/neoterm/terminal/TerminalSession.java create mode 100755 app/src/main/java/io/neoterm/terminal/TextStyle.java create mode 100755 app/src/main/java/io/neoterm/terminal/WcWidth.java create mode 100755 app/src/main/java/io/neoterm/view/ExtraKeysView.java create mode 100755 app/src/main/java/io/neoterm/view/GestureAndScaleRecognizer.java create mode 100755 app/src/main/java/io/neoterm/view/TerminalRenderer.java create mode 100755 app/src/main/java/io/neoterm/view/TerminalView.java create mode 100755 app/src/main/java/io/neoterm/view/TerminalViewClient.java create mode 100755 app/src/main/res/drawable-hdpi/ic_add_box_white_24dp.png create mode 100755 app/src/main/res/drawable-mdpi/ic_add_box_white_24dp.png create mode 100755 app/src/main/res/drawable-xhdpi/ic_add_box_white_24dp.png create mode 100755 app/src/main/res/drawable-xxhdpi/ic_add_box_white_24dp.png create mode 100755 app/src/main/res/drawable-xxhdpi/text_select_handle_left_mtrl_alpha.png create mode 100755 app/src/main/res/drawable-xxhdpi/text_select_handle_right_mtrl_alpha.png create mode 100755 app/src/main/res/drawable-xxxhdpi/ic_add_box_white_24dp.png create mode 100755 app/src/main/res/drawable/text_select_handle_left_material.xml create mode 100755 app/src/main/res/drawable/text_select_handle_right_material.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/tab_main.xml create mode 100755 app/src/main/res/menu/tab_switcher.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/test/java/io/neoterm/ExampleUnitTest.kt create mode 100644 build.gradle create mode 100755 chrome-tabs/.gitignore create mode 100755 chrome-tabs/build.gradle create mode 100755 chrome-tabs/gradle.properties create mode 100755 chrome-tabs/src/main/AndroidManifest.xml create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Animation.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Layout.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/LayoutPolicy.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/PeekAnimation.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/RevealAnimation.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/SwipeAnimation.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Tab.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabCloseListener.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabPreviewListener.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcher.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherDecorator.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherListener.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/drawable/TabSwitcherDrawable.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/AbstractTabItemIterator.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/ArrayTabItemIterator.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/TabItemIterator.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractDragHandler.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabSwitcherLayout.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabViewHolder.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/Arithmetics.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/ChildRecyclerAdapter.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/TabSwitcherLayout.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneArithmetics.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneDragHandler.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneRecyclerAdapter.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabSwitcherLayout.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabViewHolder.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PreviewDataBinder.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Model.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Restorable.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/State.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabItem.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabSwitcherModel.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Tag.java create mode 100755 chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/view/TabSwitcherButton.java create mode 100755 chrome-tabs/src/main/res/drawable-hdpi/phone_close_tab_icon.png create mode 100755 chrome-tabs/src/main/res/drawable-hdpi/phone_tab_background.9.png create mode 100755 chrome-tabs/src/main/res/drawable-hdpi/phone_tab_border.9.png create mode 100755 chrome-tabs/src/main/res/drawable-hdpi/tab_switcher_drawable_background.png create mode 100755 chrome-tabs/src/main/res/drawable-mdpi/ic_close_tab_18dp.png create mode 100755 chrome-tabs/src/main/res/drawable-mdpi/phone_tab_background.9.png create mode 100755 chrome-tabs/src/main/res/drawable-mdpi/phone_tab_border.9.png create mode 100755 chrome-tabs/src/main/res/drawable-mdpi/tab_switcher_drawable_background.png create mode 100755 chrome-tabs/src/main/res/drawable-xhdpi/ic_close_tab_18dp.png create mode 100755 chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_background.9.png create mode 100755 chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_border.9.png create mode 100755 chrome-tabs/src/main/res/drawable-xhdpi/tab_switcher_drawable_background.png create mode 100755 chrome-tabs/src/main/res/drawable-xxhdpi/ic_close_tab_18dp.png create mode 100755 chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_background.9.png create mode 100755 chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_border.9.png create mode 100755 chrome-tabs/src/main/res/drawable-xxhdpi/tab_switcher_drawable_background.png create mode 100755 chrome-tabs/src/main/res/drawable-xxxhdpi/ic_close_tab_18dp.png create mode 100755 chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_background.9.png create mode 100755 chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_border.9.png create mode 100755 chrome-tabs/src/main/res/drawable-xxxhdpi/tab_switcher_drawable_background.png create mode 100755 chrome-tabs/src/main/res/layout-land/phone_tab.xml create mode 100755 chrome-tabs/src/main/res/layout/phone_tab.xml create mode 100755 chrome-tabs/src/main/res/layout/phone_toolbar.xml create mode 100755 chrome-tabs/src/main/res/layout/tab_switcher_menu_item.xml create mode 100755 chrome-tabs/src/main/res/values-v21/styles.xml create mode 100755 chrome-tabs/src/main/res/values/attrs.xml create mode 100755 chrome-tabs/src/main/res/values/colors.xml create mode 100755 chrome-tabs/src/main/res/values/dimens.xml create mode 100755 chrome-tabs/src/main/res/values/ids.xml create mode 100755 chrome-tabs/src/main/res/values/integers.xml create mode 100755 chrome-tabs/src/main/res/values/styles.xml create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle 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 0000000000000000000000000000000000000000..50814b496e8d3d6bfdaabb374d0b98f02d20d160 GIT binary patch literal 280 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k0wldT1B8K;Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZK3>dAqwX{BQ3+vmeOgEbxddW?O=~G=WkM1 z<>}%WVsSe8$9V^-lm)Zf4*b{u|Co9Gzu$|q4y>19iCdJVz!nhBthaID0>mdKI;Vst0J@x15dZ)H literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d054c94730d5934e6f06fc49f26084cab9c5df6 GIT binary patch literal 324 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY0wn)GsXhaw6p}rHd>I(3)EF2VS{N990fib~ zFff!FFfhDIU|_JC!N4G1FlSew4N!t9$=lt9;eUJonf*W>XMsm#F#`j)FbFd;%$g$s z6x`$K;uw-~@9j-Tu0sYQEf3fDoL}&zL-m!eulA)^zEdvUt!UJXn_$EA@AJ2Z$}J8? zjtkdvHZU+UUD43cdbw9j_I4Xe0yybfZE@l5x0$2@h)#xxe4?KgQu&X J%Q~loCIF~!Zt?&C literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..39818db88613816bb4d17a85de435af254937567 GIT binary patch literal 2032 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 PyaQ{ksJ0B68@K)!N!r*i literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1f300d61985fbeb0e4199af0e013ca32738384ec GIT binary patch literal 486 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD0wg^q?%xcg6p}rHd>I(3)EF2VS{N990fib~ zFff!FFfhDIU|_JC!N4G1FlSew4N!t9$=lt9;eUJonf*W>XMsm#F#`j)FbFd;%$g&? zz`z*i>EaktaqI1EN54Z3BF8@F_gpOE@xSnep>2uw9uAq~t$`CO1Y#9FGD#is55)uy&AP+mWo21@3dLS4>Lp-Ewnj%et5recn<@dx`T7OU2ENuZCpKoZZ;D zI84~&oc_XFq1)Gd_Fi$ZQ`up$lK6w%%`y>QAUhnmAMe<_FE<5ZA((8q_lEtD{rvnX z0wAu;mC}Uh`_J})IZ#!9@ACgC-S5B#;vO)6a5;32uib;gq6u^PD~ecW9OpYCZ>#rp z)?f3W>eG{gR^Q>#F)jZ5`~Hzb;h#jOIPptaanvvDj#b;Z<)TM + 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 0000000000000000000000000000000000000000..550730310e5f66436525e196a5884166c4a3fb48 GIT binary patch literal 3358 zcmV+(4dL>MP)vrBz!Y6;-NQiYkRlI1O>)9Jb^2 zy{C7*_u6akIx&fXAVCl}X@NpSIh3>%=poLw9E~%MUf=(lne}>iCbrkHv8^5HAJ5MF zX1@1+`yFq-na!d_sV%jow$zr|=J`gnxawrxl0= zYu|b-L^mGq*R{ijGJ1Ag8+GW};TwF^vK4VmRl(rWvanZLKj_o7!-g_?c3c~E=-J^LeAKqvs32&6l0&>KKIREG zF^KV&0qSnIEqFkO_qvk+bW#8+beZg!CNUGBqIemO1Zey50n)NTRqWsm z$NF{cn}&L6D^JsUcGyrx&yH(XDG;;--{7OBooByl2E4ZQfJX*&a_WE#e!CsH1g1-T z8}WhzKRj`iv}{n#aF}j;o)a z?`%p2P;?BkFZ3>!*;MCAehf+hN&!j%N&!j%nu~zsX};8Dv5Vyd&KclHysDI`JpTBt zoEi+Cd4s&Kze+oJs@6Ie+QQdHE~96MZHrvLrtaWrJoMI|sqd}V=teAy#M$8+eAGNY z?2$IJEx+|p@_A6D*KWs}3zik|ZN&=#AQy=q<8K`5d-FBAaNz<;qoefryMLu^Tu#{H z&PM>qbF{7v!G9DIY1n%6%XkhTMA+ic}Qerr^< z&z__&&W{L4=NVGu%Yw}m}VE_YoKd9^7a|=1)z@q zo;ydsdg8>G4|8%1Ql0ZzDBEwNu9r{IIq7_~B@Hs9H5@p~tm5)Hy$<@(qeEnT>3Qdi|ph1gwpGu7IQ1MkI+2 zKK~@Ga(9V3=MkVT%5-*6ncq&n(=Ur4Wk{pk_0EgG6M=~rT8wJvo0~*(g%%T;Q2D+)` z)YJ6g=bv42K<|I{DH&gQTGWBMQ0FWT((*K4=CRoE*ac4(Fq^~+Yc-gP$BucZYyfi8 z`hGVxGMvvxMlLxVRk$|lKwYR))D0gLf8ZN@)bhP7;*+*wt*LDqdC)qq)sAOVc>IDX zH(prK!kSI=SOoG?1^;dMto1D7UAJ9_M?h$pS)IoP%?54ZUuLR`lz~hAGq4Dg1$NUp}BC3Lu}AhX3-` zMFYhR>~rtEL#N;Wm$*%sEFhG{IXG9n3D(o`GjE729+|cJ$Y;j#ph-{joL5v0`ssri zNC>B#K(Y^pkc-NBvbgow0BsqXk!gMkiQ;frBxyh8yKZT8^h*Z>AAZ6KVhN9jH4lbp z<6vN1@_307#VK|%o;mJ);!)kPVKxOHe)IUll;!Fo1A{1FP+6GSzPvHWFYe&6CUap9 z0D`atG7b>#4dWrr3ZPJr0?14)JQaU_?%a5dD2m-5rx8U{v5!RcMa+JzTKLu@0T7m9 zVj;~cAdi_c9c`4>@Bm$1UrSf-yI=fW-B3sQ&GmH6{#|s<0oK9avv$)phge*8ZOZ}5 zZ#zV3je97qsZQ46y|SA3Q9;*X%Ij*P6)pQ^wH}}qZO}oX5?dSPS&Wq4afs488z|k> zL>X2i`_ire@+g2TV^Sf_b_>el+#|!*!h!Fhs}IOPa+)~EmV+`71qvj9uyPqrK}Ta0 zNSYCJ@Oh+msc?2>|)cQgLXSdCzbX6?01{u|O?UVue~o_t@;R&{mI?O6~2UHIzC!sLatxWzH@t>oL&{0Viz@ zIH=rhW?87*!}3~LHd^PiQBBxK*Y|rwed~h`Squa|z?U7If?R*Jmns+(R0%~Cac;Mr zbEJ~#l+SR`8ie?^BA>g+CJweL=%fN3;|drKmRAcQhEwRb(e)8ORWqO>1|$@O9E7)5 z;m1v!2N}2w_Tk$&^*g0aoZRLm_X1TCw;CFPv*-rtosDuALH97p-2>xA5rZh<))Cu9 zGVHB#H(_DXzZi@1eCr^(4c4ArUGfqucWJbeB6J}JDC9Md%~&i0w`{eGt@OnSU5vlT z%!WO)aI<=mXY=TK8P?_mI0BH%L?zrZWHVkeR50h;00fZ6d49QgJDAJs$xRLT35y*;#ngWbS6 z%!ZJg3_L^K%F}esP)Hmp! zB^iTktuy~y4VD;$V-i3ihOv&j*?2&f1VF{1=>f`UtaB#~=sL$Ny~K$Z9z3Rmsayu+ z(#>-!6+qf~j#Z2`x`5O?XgYWE*I8TJX6-Fk0m-Zb$TBrR3WSRSQd>~{E_>2|GWON} zOaKa6rTFLGGx8d+`s&PN0Y#DoBo@uA5^j3Gf6v{&6cdu|7ne`BELM0A8L!&v+40x~&!!S=ST?~l&S}IJ9%!+z3U}bDw(V380qC*dav9G0^eY?U1`}OT>`TX4dyLIikdu#a`wc!=J`19_aitbaiGn~)o zW^HOadix(|l{&oKQT~-z= z8^0Ex=gL-MS+;1|vTukOr@a*xUVcSh+7;hgxqRt2GnRd4Oc{!n>1Zz4#~(ushy^jt o#D52}c!4OjrMA?T+UCXfe{|G<&J1Nqa{vGU07*qoM6N<$f)>nc*Z=?k literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8fab6a3a5b8342cacf009fd1a5481eb3c7faa5ac GIT binary patch literal 5084 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 Ef@vV1$N&HU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1eecc0e7d5b0129aff2236d3c4ed2592543a8fe0 GIT binary patch literal 3179 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 R`1$|<002ovPDHLkV1k6j8%Y2F literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ec87dcebee2b6e52f988e50e087a7dbcfc4321a4 GIT binary patch literal 4648 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..05ca079ca743343d5b2d6037aa42047efa865269 GIT binary patch literal 7381 zcmV;`94h09P)U=_s%3yLVN{D^>21B6i0GbMd8lSw8CN$4GR zQBigkcUKXA1Oa82q@!!@zgqZ00w)|x>_}jww)BP98uB4g?!W9}yW$NJQ zio*E86$NYK_Ei^6-K9SgtuOl5;EJLz23HhbM|_X?k;nJ!v&zEDl!nsMHFPcIL3vT0 z=AzTR=eDMW-L)R(*fEQWkFM3ZkE_wl8&jiEjH}U}9$TaR4gq7UHL&pTE=btFgZ(cB z@tBsbF?`15^#PZ+W**(k+z_Zt_dfGNN6Ehe^Ygp=m#Pv1N>!zZs|X0tDox<8Ro0_L z*Nv~%)&*!4b@ZM=qiiq%|2d_hv~&&kUTKjhf3H%yH!8 zq_W6BP=H2xOH8;qS}A}zm>_gd;eIFpP?@M~R7Rn^*7~Qr<|QURT$|$>Sel!J)^?LD z{^9-ARB7feY>|E&1uzkV%0OjFz%ubI;wF`q%51GJcZJonP>+8FXjStAN|oo0u0~Op zu&=tFtWGMSw^0BPS^$-aHwaz`<$3?o+yz2CSe@E@YY;}N`0^>u?S~a9GZ1fdGmyZw zfVL$7-vUJL3HY*?QC$puLz^AYi8`$SZSFKI{1;-Y7gx9~3t)svYq(5xvR2nN;_r}ezJTjP^c~s zg3$#@!Cb7wu*zb|>8J0mfC)9FlG9UKx<+bxDhriKYI>?G)tOpCKw;)Vr^Smq{W{T+ zCRx$Z}YLaLbjlotX((a``Kn$uY)j6U(zb-(d09kH^Mu?7X?>K>1*k7$!D|EQz z7yrOp&u>fs)+gZm2LBQ{_{z5=-quzi^!9-zim5`VNdi#k^jj5x>Mle^{zcg!OWQc> zwnRokFYl9^M$-$I#j){kT>x%?#!Yj}a?`%>NL)40v5^r?Kyi~$?E*@aI?noy1C#p# z2-6jcI{y;v4|29uB3JE2ygA1D1B$cfi55>9?+aii0C7V7Vqq5eZQJmeV_E^sq?cgozw?XZ`BgVZ%A|GohER@Sgp_5Q^<;FGo? zg@vXq7J$Dnpdi&3GrrC;FHtny7oZ6Nh$H;oPnCMZYwfCFer(XOrM{o6Kh2i)OBMhJ zE7SK>KFf&7;sz-t@a2kg$)#pN{Q^uYddj>g(&_ z7UExD{TH79%R$5U*76l&#fihP1oLProd8Np*Vs>IEZ?fPkHYzz*P#x}M!5X#O*E37 zqVk!RaV|ffMRm89H*rOr&8$#=Jt6UxAmVaybH)bzrm1!@y4-cNdm%<&&_K=98fYew zCbMWh#qhbFP+&mPskfk^p}_#E6S1icCRbK~Fc9vPxL#hiB zbLN|qa<<3ZDQ=SIi3H#8mzO!mTHN0f0fIG3(4GAVe!6wbLWKPnKZ9{-N%lk#1PH|c zqsj|l45t4*ww}qm${6T>`i#B{Y)SzBK9iB)xBvZx1^M6o@-w8Jeh&gm6s;1#U&DLo`k8TB+#Z z@_bl&d_SapcnT`coq@l6c?nKm`wyJG{uO+New#r5(`U!=J3XdJMFPo%MB%m8$~6*Q z2$64ImS5`XAPesksa3IZ2b9QLEP!!zEC-*|H!G0S4o#1a`6ebQ)8s{+mMt5c&Y-g18nym7GqHSu%ZEy#NR zT^L@XWMwiDzyU5u%;7Lq`e`S3_pUbGkP!U@rfm)!TU~O)Q39}5P|URg8Q%)1vO}

HWg8C^o)QF79Mg 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 Hu0mjf20Ao4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6f67f21bac1024e18e1584913e5bcd5e1395456a GIT binary patch literal 7008 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 LNkvXXu0mjfne+ST literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0327e13fa7927a4a05b16efa6db900c7b5591043 GIT binary patch literal 9442 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

Be^f zfRdLN6alsV)L|yGBJVmqC~k(FK*38<#T=R1Oynm+YqR(y^(|TCcEq~4fXG-qgMlKv$7!Xnz#e*&>wVzIO}hMS`O#lzNacDdh8uzBwQq)L48r0 z#r;|IK^ORlw)-mtt{e_h%++1fllfQYYX*-e2XS{_+TZum4WohfScXGv|qXPQ> zq_!gwR7q&9rSx!Gdb`QaW8t63d=jA-WFDewzvyJ!rQ>noWb^7vJ?X|RFaF@wE-pPh z3Ou!SY2GjRnE>_t#>7UwX?O0p7djz0q0mrC&~TU|Lf6K7!=RdNC&wh;MV0hdm~koB zJED?U+USq#(oJ(?s)W+D@sAzX6RE!qLr%+<5CJtn6u6er*^q$(oZWw3513^MC0VOC z%%|W7g}SuX9@}m<`iAZVEeRIKf9C(n<}4Nv-z@&ZBdOq3F42A&TEgMM!(0>Kb;2e* zcysxmT07|LuWpK<7JQR*4m@}gK+18(f{5W2B2eaK0Rz1E4BN24s4v-X@wBXp#a=Ir z=Ac0PdH%2!NhHkVRGNaqK8EkRDZv1GZVcURk2kSl3v*+o?Hx+ z;<$SmGdxv}-!BOpFc+o-?HTuj_LXP_b%qWxAKZK>tooDSI9;cgzB~njj@ulA}%Zb6a`i+tI?#xY!bH5Z1O9OpZ-cJmB z7_2Swq%g%e1Xa!Ytf|_iG9;#Jl`O5m<1qtkY|-dzrKd|x?Ddu@Vq%*J$9Zpkr#XfOim3a4mhe5>ie0?Ytz}E;@63Y;T_@H zDAvflL}A3~vIE8JLDNcc;r{-gEvi5g6eKbG2=v*={*?J=d$C~8$@=an^#ZE=y+B$Y zQO)K}<^3kYN{{*dSsEz+bcs{xK&JPosTF-n4@w|y!_FUj{r%cwrlu(M`=9-LB|13V zsir{BW_Hmj9IHfaHO7`4}hH-YSznD7c z>o(A^y|6V9G+80$%oOZa_ItmYGeT4oNIk3e?h=;4rMUf*C`qht_UEMPTcoV0`t2SR zm+w?SZz?2OVg6l(aBCUJJz%wKjBZ8$kk|QvCj;Y;3-kiB*Y^p-X|?@1(b%Sr$JHc5r&lJ0oH2d2 z-ba?=9porCR}Jdq1EhHibg}#&5Lc*q+T(YY8pLu_U-#h6z;lY<_+s`H>jyTrIqYYj`uOWA$}3+orj|4 zBcXp>v9Nv~uRTLB9Z1)@`S|W6V*Z^O#2>SQZg_7Td2n;=X;5w*T~nsQ;iR6f_|Ciy zc8h{@J|_i(RFc4>KgX^EV{oW->xr8IM_72{K99g+euwI;paV*q*D)QGU*cU}I7wN`T$ zb4Vz<03@wpx10LXJ8hf^rfNt|edkubmS?$uMNM`jv2G?}TFK`3 zO>2k+Jz9)mB4|^vFhP0oEfa-?XPj~`v36XVO z4LDV8uf_p*(ZC>-grBz7>PCjdSPd>#Ym)!uVByw+K|_axOMX@WwUHru6cm?{6aM~s ziRm)yVPa`PFG@k*tmhpcI>SV?4~|0S+M&Y@*eWmSIgw*Q*E=Q`{Wr}VndkBSA198* zbvc{%YaJ%Xsn%nIPh${Ks{vJpzO$CS569+P*^6HcelE+{8b=?us8WYCEZ@9s%{dW& zRE(-Py6RiAr?DmbVRwp$aD)diH|~$ z<&QobV|JQ|j#zY* zlS;eH>}lHxaQngGmI^;9nVSV91IGz_UCvtGh?U2fg+!d0yPa97U4-6zRIj_~d0Vz~ zI&Y;rb!9sDNkQx`yV9OiAA1~>_xl3VYGU})H!B=k@S-e#VcoAhPz^FByU|;i=)0uG zLb&yZUs>n^N2_z=KMGg9O0ON{kC(%qGQ=Mb87Z*2z`mpxXVyf#c6c%|1o6xy;0T$Kzg?Ee6y5{Tyj literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b3faed4b166c7d650bd668f04b1feda785b9cef8 GIT binary patch literal 395 zcmeAS@N?(olHy`uVBq!ia0vp^(jd&i1|)m0d4nJ zh#UrCM$rOaC!nBYiEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$QVa}?9G)(Y zAs(G?uiARCI102qbUz#ZSoQ|<434H3sy2;{6)eGl${%^EHCe^Y3{4Dz0?dt!XXMG0 zJzpNh^W2xM?N#0H+3S)dB!4O|pCP(#=F-x&Z@zS#E9gu z{th2@j?M&ah0`XJQw}L5oix(k^LWX}yuQ?u=5^Qdle<1|Ef?ovv!9i->|e{zzGpYS zSGO-b*ndLnYl8Xg#FcV?PRO*Siso(EUVicc|EXUtGnEpj>nLf7m6bkD446`Rw070} m2MG)6n(P11@9p{WiTPV)(BG$Q+$(?q$KdJe=d#Wzp$Pzp&74R8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2f4bf055087e9eacb7e8a92f1a15f13ba2cf5b81 GIT binary patch literal 2409 zcma);XH-+!9>o(N1OfrU1f)bFMFDBjq8K5Glz`F#3W!pJ5YYt2P#nrYcnks$CL#<) zBfTn3M5+u*l_f$#lo}PV1f>@#QYJ9V=UZ?2;eEL0u6y@B`}}V?zjNa4Y%HJhIdFuQ6|Bx;jRaatF~m<)UYsFm!mG(A0DhUecLRpBilb= zBRmrFB-gmA2}pZ^ctt$SjIK&El~Cqg!mxRfwsi8q=@kk;D$RJEr!s4K%6988kfFOJPN& zujD+$3~N$|m;{_#34OgJzr~5v%1Zx;N;)eds1aKxZ5Q~IZ7r$D zz(DBF^84!pdM3aDW@vHk{)qAu=gvm@5X%D893iPTCCVNNpaQpjKF6*s2_ozIzX`R1 z2yheFUqk8far{Ny5>Uevwzk(tIH*lasyvh#L70ZH| zNYu64c3fM~ZN^z#SRyIaNaWY{O#-?(h6Za6(%2h=u4iyG^%n4dZB&eiP^GC(O^s zb(0lbW|5;oM2`8_AgaAN>IM})=$HUfW|%h?T6?;`wtO%3@UB(iYa3hJoyPk5wbjMN zMb~sIQ!Ur$Yl*}yrcd%r1?ljBh7n6sYle~bJA_b4DsUZ%nC>G#dd#7is{CYe^x8FvIP|K@bk)nMXV)rXY^0dC$dnD34wc@CrY}D(a*8}f zH0c`c)VeP!#?opGC5v%3U^#Vl@i$is+OaG-SEGsW_xC<2e0hBMjd$8p?1ek2)uMg|G*4)9ZpF`lrCo$vPlC-XlA@CN+f@_!_i$O*F^hMsOd zYV65-S|hBq9QSeoHVfXDC@swDj(K#m;+ou_H39SV9%E!vT)ks5ArO8n}sEt+oC53D@*UDkd%<$%^+!s-$=i4SV<+aT*!OXEC zS~M{muz5X2j$w`N3enQiYN=xB7@3&tfCv0Q3`eDx7WPg%xiP7oes}dp@3b6=$hImj zE>>_M9f;W8T({LrTHeCf)ml>14;td_QFX#|o-7-ckShOTLTPPHO^xk?p!$>-JHf+~ z5{tJ`Nf3$Ak;<{53J`(q*ppRSP&sDs?*X4WBr220jvUWaW9l(c>D6~kqx@tL>;S_o zg8@~!HvI&s@^9)NdL3H%#6qmY;o9?IeNtq}+12a_bVkGA;Og2XuD~{C$D{Yd0T+Z~ zQv|Ug=IY7r#-!;lTwLYmI&i>`i=3M2y_fdO<;0_Ro-Yf1TZVj{7JFkj_N-3gnVi6) z6h-0nPZ3JBGjzT1@}%7QoW*G%so#;MS;2kk*x^|jZ>BXF{X%n7B@+DE!>fGqFSPT| zAP(oZXI>=*TIUa89{Q>%6!Do)X+(~?v@V4sb{cv#*My~ZVELgkZn?Y#=g}<}5gEf< zBm_Vcx#U8%yBXDeFNqe#_E%rm`B0@`JD!-)R-Q|XJr8yM?=`Aq-jTi_ubYhnHwfLF z$S|*tW{VeziUk=lU?q}L7doAm0KgY*B2;&O8~AN^nan>$F-Eboxrx|)AmEg`PGK*V zkh3UQM$))2uI>qQ?PI>3`9#p|e}Oy`dxQdDsioQoA?T{`R<3(Z_9w<6VCn(eq1X^NCOTKOk z1Br=`VT+ew1DJo!Vs9KNY{zc6RB=O3X+ROmpI7&YvPY_a_AYxkkWjv|h72NY$hNGQ Yv{@#}`_!+SXXUWW=Kjz5g` zy|Hn6!uF|U8-r&U2tQmtvHsfcLoy!hb9-7p)I4BMt-A0%Q#$nO=WBlp=Y24LrMt7Fo@*1+_s-rAoj|(w~B_3)_gLX`dvT$&*aljWaOmm7)@Q4FO6@#a%pUXO@ GgeCymslj6a literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..335deae2d272be69f81d17259ccd4f52c0fb08fa GIT binary patch literal 250 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k0wldT1B8K;Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZK3>dAqwX{BQ3+vmeOgEbxddW?TZ)5S5w~}U&Kt&5ZT^vI! zdY@j}&3ni}#O0#09cQh;Zhmf;9CkfErWFkhdOaCUQXC2!bk97B<$TDYc4uz6>HEX+ zn$m|SKWxx?xH9TIcaGGIbu*HCSzkTae}v0@PLRS5mjqTb-4toDvq~So>*~DU`_#}w zCvf?$={Yl-4yZ8x^4NGSW83Aehc}-+7-AUkuvX3d?1sel>ir2BZc87&ZMkjZ?)0F$ gx#!@q4}ZGkf_mj#{%|gl13H$$)78&qol`;+05&XZ+5i9m literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ffc021c490677672156c823b4f1dd46063b48252 GIT binary patch literal 1055 zcmeAS@N?(olHy`uVBq!ia0vp^Ng&L@1|(N6W#eODV1DN5;uunK>+Rf&eo~GiZO3;t zFljAllh~~i>ZHLdu5wJ{-i#{|&HUkSoi*PFzLhR`{e|c6_L)A@w|d{btM_~Ull}Am z*A%Cve|sde^pmue6VI_rv%4Gxc$ySA*c=5~g0d~c^&Ix3J-XYa;TiF3EyE4&xbi1! zJ7VIy6_&CcVogxr!!mW@sV>bAqCdXK7@TIhc-m=0dHqSRUu_w|A);4Zz6#_%bxF)# zfAF^2o-iGQ*NUQ(I5@U**>E3F6U*dz(=lt&v<&fEr*|^R>sH)uye_qe-C@s!R~{@a z+=>hvdRb?=Zg*v0{jFABU(ZcIBU z?-Cp6e?`1jgx6($(JDrpFArC8_uJXLS7wjq>#+!3IDI9Xw3G1e!#b}dx@EiE771Tm z`NF3}PwIKd^A81F{>l|^4sY(4F_D!hT;+6pZJW@!!?RcyHqSLW;-v2~eUbJ>@r%6~ zO1sXkRQr&yr>1Q?cZKYs>f=p;&V62ntDN+sd2efYZkDdPnpOJc$B!SmX0vUx*T#Q& zb-eqd)m*>#z2yf!Oq~2yb(NAB$N80Q%u}9SaJazA`?0S7l!cq$n^hksF$5mETgh$C zCjb0N=bJuxS60P)^9y{wPMmD}B;&B zT>NgG2)tVLV(+)P7Yg_Df7^KFDDR^nahnf}@`~!;K8E-Sxp4E(mh<}5rSQ!Bi13pq z0+y?e{z!OjA^*zg-}X0;rrtb$_tz|iE2c#k-Sf&OuKpBXx1scobTGrAKs`4#r{%?@n^n*94XHLl`rb2Il&+&>Z zjsirXiP{3Q6gYrM4T0Vxv600w*$Kf@QXEpQiL~Z6{bQM!HCw^#92fhWkt&Wgey_*>GOO{+}xNbLZQB;t<_|&F~|LcM>JY~y@H}x-9 z4*j6^mY<)$d_|k`)S$`VPHbeo{PN2=6P@cvC8Iy6%-g@rcAG-Z1b1gw{!a$Z`dwxx z7B3W%d9rbZ)~l|?xpyv_l}>$-A-!G8hMle5_V1}iUd4@x{&}GVe?slv;1fG9a3uP`ulrL=masJe!|x!_#|Z*Q hvKlUgEDN${{CF;F<^zQ&D`3WF@O1TaS?83{1OVUJ!Po!* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..12b73aba3d8ef5cea58192d61318d6f4cb5f59d0 GIT binary patch literal 315 zcmeAS@N?(olHy`uVBq!ia0vp^P9V&|1|%PcFuY)3U}W-iaSW-r_4bY-Uz3AC>%+`1 z|F*_;PKy&P3-38w#4yKm${NliY)vQsOx`o^d*B4$8KO7KkIj7Z!^Fz+dAirjb)9~b zwiM^zT`r!$q1aL!8p>X1bS>0+{+8-l3O>h$ojL`RbG$kf{M;4SIWRD?2*8LVvjpat zHZU-8D8PsgU4=aP4neR$$9;isS6@J7k_xv}b0c&DS!*~iG9sA;6v<%_^y+v}sJd(2 z>&3cfyf>=+-_Tm|NNddK&zoZGbT-~9O}KDqutrk?iFz?N^XZ?VkWf8kK@ a6V?aot=eoZH}(Mog~8L+&t;ucLK6UK%W(7n literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7a6dcd829d867a3cd95a9e6b27e125fa78dd2db3 GIT binary patch literal 202 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_*1g=CK)Uj~LMH3o);76yi2K%s^g z3=E|P3=FRl7#OT(FffQ0%-I!a1C(G&@^*J&_}|`tWO=~G=WkM1 z19iCdJ#z*ytgc)CAJ;;O*|sW?HO pd-?&UKZH*R>i_$F$=Y9-mr*=)=ldL+2p^zn44$rjF6*2UngAXrJhuP< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f7a2aa1b2f96fcff8cb420feaaeb90b88f7e6224 GIT binary patch literal 467 zcmV;^0WAKBP)UqK~z|U?Uy}n!axv4UjU^;DVzg%)WP~epoSntlv8kAzc#`1bSdY+k`PllqRqW__Xd&TGo_Dq18G++eg z1DK{Zb_FmKUkVQZ_c3^^v0X3Z_&Ct8FnIu$fFdl$? z28@d_2fnj-*IUK9+o1=0Wj002ov JPDHLkV1ft>x2OOB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..20bc872e213a693cb59d9f300bc572bb09e86aab GIT binary patch literal 3516 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9190ab917ab3201ddfb0e85ec44672dedb246b3c GIT binary patch literal 651 zcmeAS@N?(olHy`uVBq!ia0vp^O(4v{1|++f1^zNHFa>$KIEGZ*dV9;!@3wl<*FE$5lyZQoN&j-gA^_Qh8TxtvV9x4cj#Lg!P~j{_+s zR_>*-<|e+zS4wSj%%qFmgspq;9_#C$eek$`?S1~-+jhP3RUY!c<~_Te?i(5!f4Ro+ z{p>BPcYm#_{d#T5_t@p%?;p=gKAw5K{l-si4tI`q-HUcaywK^syG&MJokv~Z(W)5& z7OQM#860_+&hhb%hyJ^}EI&i0JxzUM^Y-6!9>skwOP(J;_3=sQuXh`~cKe%5@|JSkC*j+ z%f^7~rSYLx_bxv9&Mji;-T!SDOnmQf{9dG?C*vYF?WBHfoQmE)#cz{LWXw*p82Si?Yf({`^LvYJpKX;+k;f?DJUAfNn1un{e_E^?| zfssW3LVPe-`XV=^(|*~v&$I6woRWI+@pje|XMA@tSc=$Io8Bqb{&<~<<+=!yZ`r?P WMR8RH248?KWbkzLb6Mw<&;$UiH(>Gr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..05a5741a9cf29067d6694f51ca05c4d86d000873 GIT binary patch literal 597 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bfc0df7e0f21e3ea5364e5e7df6ee561d2b6e310 GIT binary patch literal 966 zcmeAS@N?(olHy`uVBq!ia0vp^hd`Kv4M<8qlr>~vVD9yFaSW-r_4c--pR}P!L*fpv zh2hB)qZP_TZFyYpGVb+O3J{1?-91V2y8b41_C;P(PbnGSSC4dZ+)!}8|9EFr(V=wj z|Ct|O{tsf$*?xP=&74bFTfI&{HL|s{i@FxLcKPz;cWGx^$!|^_%8NT6MAyB%^38D5?hl!E zn(JQAIezAStAP09XMP3BOvhxL=Y70gmEPgLM6qQ@;B=0@Sc za}ejlb7qFDKTRrDl(#CjJS%SUjy~ezw4-Zg|4n(PjCbtD(MLq4Y!53he+-mmZs!Kd z`n7aheRp!;fFcIP1_1{S1r`A&4#pM+r-9-&XtqvhX%Ik1SR8a_6^bm13aDss zc!RTbLcGt@qce4rxRS1&mjAz9VA|I(3)EF2VS{N990fib~ zFff!FFfhDIU|_JC!N4G1FlSew4N!t9$=lt9;eUJonf*W>XMsm#F#`j)FbFd;%$g$s z6#V4r;uw-~@9iyHu0sw2Z4dR8*p5g);mJ~+=eVb7Zv@NU$#KW#J2jbeuGlDf+av5| z8hdy7C0>=!2Ah}6JnQpc)RvKnh2uhf6Ps3Ph_3eso6?JZKc}BMSkARO>eY%h99Pfj zT$ww08Q$(5hw(@LlcKUAho7?WaOt<88FWQhbW?9;ba!ELWdL_~cP?peYja~^ zaAhuUa%Y?FJQ@H10(eP8K~#90?VDe2(?Af1Kk|n&1Rf{_q&}evxCc+UNTJ+<_9UDm z6c9=yBqSsbz!{p><-uObMDZrxot?45e3BI@UfI^CS=;-Y1RTe49LI4S$8j9zfEim~ zKnt;Jkvh;Ecnf?1DxqVhG6CKJPk|q~2a^yqZ?+lm99u~f;0$;JoB$8Wl$0wd$O%|V zYRd^^OUe&TcDZ~@$=oFHe4Rlry5B*msxU=@Yb=QU_VDB4d`#=dLlAD98cbn6#pYPK%I!c#>K=;z4$ zOt&u7rd1n@pgl#Bi5#%&MD&qlCI`|J#s3DY3fkvACt%twu@KZpPQXl(wxAR_0TW4@ zf~M4+j#$9H1pd(dE;Iv>|2 z((jQmzp1aeH#t-67jOf--1y!iLyk?iPtBG@?oE1Al4ZcYO;VN|ka^4pNfn0OhlaMK t1(|ZBImkb=IF92uj^j9v<2cUX_zN~z0Lpb@<=y}Q002ovPDHLkV1k3GJnR4f literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3c94a8b51dcefd86b068e0f8bc81aa8203b5f969 GIT binary patch literal 8467 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2ac893ba7f159ad97fa929621b07876638c7952a GIT binary patch literal 1352 zcmeAS@N?(olHy`uVBq!ia0y~yU}OSe4mO~OsQ=9X1_oAsPZ!6Kid%2*c;*Q^iZndT zdElg@YaBa1;p>G+p=nEbPK)r zFW+_eqTv78ulV`c|LQ0A|E!7Ybz7WeAQ5smZ~LwtJ64=Kf4NmJeQjt-6|G^z*O4ny}Sb7BW^Y$y~E;yu9%?p6g)I&Od<@ zviDuMZleD3(&NZq6?5$F86Uf|F7kcan-?dm(#zNJB^|GqYFgoH%rw{U`RAukpEBEf zHXgp5w_SdjZJufJgm}p}Eek5GC!G4U?P}KD!Z`&ylG-nufBAm)c1yOi!Cb%D+nMBL z6{jfg+xXmCj-R>AX@b3M$8E-Nrht1>9qVQbSl6qvd|b2fV{xNw(4pyDh#PQt$ch6o&+?`zqZrd#*T>0Fxqvq5z3h z3xfcY0|$d5!;oh@c;CK2h(zDx4XzLOXEqCPFmf~sFgP)AFeY3(|IJwL=9LRK`7Qr^C(v={rM~@ZzVE&MI@j4ig6ETVldwCh>wT#`Do~(8Rwr@ayp!0Flr!BYNw*OqGFV>yQv1-Zv_s@UHx3vIVCg!0ApmcP5=M^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f435a65147dad732371f98fddd6184d3584a594f GIT binary patch literal 473 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD0wg^q?%xcg6p}rHd>I(3)EF2VS{N990fib~ zFff!FFfhDIU|_JC!N4G1FlSew4N!t9$=lt9;eUJonf*W>XMsm#F#`j)FbFd;%$g&? zz`z*j>EaktaqI1EL%+iYBF8@Jb2wJ<_z65^SP``Bo6=JYo)rPBDy59YQCW|IWP_^r^1yd>{L6gTHUfRDf21K|?&tm02%+9j|>9{<2CswED64 zzSIQn*V}7voH-TJxeVj`=VCfisuc)I$ztaD0e0svrtrepvB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..13372aef5e24af05341d49695ee84e5f9b594659 GIT binary patch literal 53636 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ⅅ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 H-+%o-(+5aQ literal 0 HcmV?d00001 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'