diff --git a/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt b/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt index 6662fe2f2b32946a151fde55971b1cb4a21b75b7..9b56e58c0bd5feac84d70ea8ddd7301acf262804 100644 --- a/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt +++ b/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt @@ -167,7 +167,6 @@ com.takisoft.preferencex:preferencex:1.1.0 commons-io:commons-io:2.16.1 de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02 de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0 -de.cketti.temp:tokenautocomplete:4.0.0-beta01-k9mail02 de.hdodenhof:circleimageview:3.1.0 io.coil-kt.coil3:coil-android:3.0.4 io.coil-kt.coil3:coil-core-android:3.0.4 diff --git a/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt b/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt index dc94ab87eee74a7b140e6c82d675c056402a81a6..7d61fb6c5c568c95907686c5e5d0e4631df2ed8e 100644 --- a/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt +++ b/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt @@ -180,7 +180,6 @@ com.takisoft.preferencex:preferencex:1.1.0 commons-io:commons-io:2.16.1 de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02 de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0 -de.cketti.temp:tokenautocomplete:4.0.0-beta01-k9mail02 de.hdodenhof:circleimageview:3.1.0 io.coil-kt.coil3:coil-android:3.0.4 io.coil-kt.coil3:coil-core-android:3.0.4 diff --git a/app-thunderbird/dependencies/fossBetaRuntimeClasspath.txt b/app-thunderbird/dependencies/fossBetaRuntimeClasspath.txt index 52b7e85e66411e8f619c1f0e1c70b9980f67edb0..4f62e3414485f66b270d456bfd0a8ab67af11407 100644 --- a/app-thunderbird/dependencies/fossBetaRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fossBetaRuntimeClasspath.txt @@ -174,7 +174,6 @@ com.takisoft.preferencex:preferencex:1.1.0 commons-io:commons-io:2.16.1 de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02 de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0 -de.cketti.temp:tokenautocomplete:4.0.0-beta01-k9mail02 de.hdodenhof:circleimageview:3.1.0 io.coil-kt.coil3:coil-android:3.0.4 io.coil-kt.coil3:coil-core-android:3.0.4 diff --git a/app-thunderbird/dependencies/fossDailyRuntimeClasspath.txt b/app-thunderbird/dependencies/fossDailyRuntimeClasspath.txt index 52b7e85e66411e8f619c1f0e1c70b9980f67edb0..4f62e3414485f66b270d456bfd0a8ab67af11407 100644 --- a/app-thunderbird/dependencies/fossDailyRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fossDailyRuntimeClasspath.txt @@ -174,7 +174,6 @@ com.takisoft.preferencex:preferencex:1.1.0 commons-io:commons-io:2.16.1 de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02 de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0 -de.cketti.temp:tokenautocomplete:4.0.0-beta01-k9mail02 de.hdodenhof:circleimageview:3.1.0 io.coil-kt.coil3:coil-android:3.0.4 io.coil-kt.coil3:coil-core-android:3.0.4 diff --git a/app-thunderbird/dependencies/fossReleaseRuntimeClasspath.txt b/app-thunderbird/dependencies/fossReleaseRuntimeClasspath.txt index 52b7e85e66411e8f619c1f0e1c70b9980f67edb0..4f62e3414485f66b270d456bfd0a8ab67af11407 100644 --- a/app-thunderbird/dependencies/fossReleaseRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fossReleaseRuntimeClasspath.txt @@ -174,7 +174,6 @@ com.takisoft.preferencex:preferencex:1.1.0 commons-io:commons-io:2.16.1 de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02 de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0 -de.cketti.temp:tokenautocomplete:4.0.0-beta01-k9mail02 de.hdodenhof:circleimageview:3.1.0 io.coil-kt.coil3:coil-android:3.0.4 io.coil-kt.coil3:coil-core-android:3.0.4 diff --git a/app-thunderbird/dependencies/fullBetaRuntimeClasspath.txt b/app-thunderbird/dependencies/fullBetaRuntimeClasspath.txt index 0b2c7b3000bf68bd4791958f59aad2bcef0e91ef..6cd4f4741d9d57a2466a95a0842b6193ae693c53 100644 --- a/app-thunderbird/dependencies/fullBetaRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fullBetaRuntimeClasspath.txt @@ -187,7 +187,6 @@ com.takisoft.preferencex:preferencex:1.1.0 commons-io:commons-io:2.16.1 de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02 de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0 -de.cketti.temp:tokenautocomplete:4.0.0-beta01-k9mail02 de.hdodenhof:circleimageview:3.1.0 io.coil-kt.coil3:coil-android:3.0.4 io.coil-kt.coil3:coil-core-android:3.0.4 diff --git a/app-thunderbird/dependencies/fullDailyRuntimeClasspath.txt b/app-thunderbird/dependencies/fullDailyRuntimeClasspath.txt index 0b2c7b3000bf68bd4791958f59aad2bcef0e91ef..6cd4f4741d9d57a2466a95a0842b6193ae693c53 100644 --- a/app-thunderbird/dependencies/fullDailyRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fullDailyRuntimeClasspath.txt @@ -187,7 +187,6 @@ com.takisoft.preferencex:preferencex:1.1.0 commons-io:commons-io:2.16.1 de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02 de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0 -de.cketti.temp:tokenautocomplete:4.0.0-beta01-k9mail02 de.hdodenhof:circleimageview:3.1.0 io.coil-kt.coil3:coil-android:3.0.4 io.coil-kt.coil3:coil-core-android:3.0.4 diff --git a/app-thunderbird/dependencies/fullReleaseRuntimeClasspath.txt b/app-thunderbird/dependencies/fullReleaseRuntimeClasspath.txt index 0b2c7b3000bf68bd4791958f59aad2bcef0e91ef..6cd4f4741d9d57a2466a95a0842b6193ae693c53 100644 --- a/app-thunderbird/dependencies/fullReleaseRuntimeClasspath.txt +++ b/app-thunderbird/dependencies/fullReleaseRuntimeClasspath.txt @@ -187,7 +187,6 @@ com.takisoft.preferencex:preferencex:1.1.0 commons-io:commons-io:2.16.1 de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02 de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0 -de.cketti.temp:tokenautocomplete:4.0.0-beta01-k9mail02 de.hdodenhof:circleimageview:3.1.0 io.coil-kt.coil3:coil-android:3.0.4 io.coil-kt.coil3:coil-core-android:3.0.4 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe16f2a6e506ce56ca4d83abefc07068c2a20ea1..e0aa752613129463fce2a6b736ecf3b32f638e6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -95,7 +95,6 @@ safeContentResolver = "1.0.0" searchPreference = "v2.3.0" spotlessPlugin = "6.25.0" timber = "5.0.1" -tokenautocomplete = "4.0.0-beta01-k9mail02" turbine = "1.2.0" xmlpull = "1.0" zxing = "3.5.3" @@ -241,7 +240,6 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr safeContentResolver = { module = "de.cketti.safecontentresolver:safe-content-resolver-v21", version.ref = "safeContentResolver" } searchPreference = { module = "com.github.ByteHamster:SearchPreference", version.ref = "searchPreference" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } -tokenautocomplete = { module = "de.cketti.temp:tokenautocomplete", version.ref = "tokenautocomplete" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } xmlpull = { module = "com.github.cketti:xmlpull-extracted-from-android", version.ref = "xmlpull" } zxing = { module = "com.google.zxing:core", version.ref = "zxing" } diff --git a/legacy/ui/legacy/build.gradle.kts b/legacy/ui/legacy/build.gradle.kts index 067030606d9451d2d823c01e3dd4a77c823c1b42..9d7e193fa82818360639d924a56878bc024b1a6f 100644 --- a/legacy/ui/legacy/build.gradle.kts +++ b/legacy/ui/legacy/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { implementation(libs.androidx.localbroadcastmanager) implementation(libs.androidx.swiperefreshlayout) implementation(libs.ckchangelog.core) - implementation(libs.tokenautocomplete) + implementation(projects.library.tokenAutoComplete) implementation(libs.safeContentResolver) implementation(libs.searchPreference) implementation(libs.fastadapter) diff --git a/library/TokenAutoComplete/README.md b/library/TokenAutoComplete/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f627d1479fb7960558415f694a5fc10de1d94351 --- /dev/null +++ b/library/TokenAutoComplete/README.md @@ -0,0 +1,9 @@ +# TokenAutoComplete + +Gmail style `MultiAutoCompleteTextView` for Android. + +--- + +Forked from https://github.com/splitwise/TokenAutoComplete (licensed under the Apache License, Version 2.0). + +Based on https://github.com/splitwise/TokenAutoComplete/commit/bb51c96b39d90d43e74b2b8cf709ec58dd633c45 diff --git a/library/TokenAutoComplete/build.gradle.kts b/library/TokenAutoComplete/build.gradle.kts new file mode 100644 index 0000000000000000000000000000000000000000..df3fd69dcee25b44b0d0cffe35739f41251e6007 --- /dev/null +++ b/library/TokenAutoComplete/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "app.k9mail.library.tokenautocomplete" +} + +dependencies { + implementation(libs.androidx.annotation) + implementation(libs.androidx.appcompat) + + testImplementation(libs.junit) +} diff --git a/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/CharacterTokenizer.java b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/CharacterTokenizer.java new file mode 100644 index 0000000000000000000000000000000000000000..24ca9c45c5d44b57b73a9922d988974af7b68559 --- /dev/null +++ b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/CharacterTokenizer.java @@ -0,0 +1,121 @@ +package com.tokenautocomplete; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Tokenizer with configurable array of characters to tokenize on. + * + * Created on 2/3/15. + * @author mgod + */ +public class CharacterTokenizer implements Tokenizer { + private ArrayList splitChar; + private String tokenTerminator; + + @SuppressWarnings("WeakerAccess") + public CharacterTokenizer(List splitChar, String tokenTerminator){ + super(); + this.splitChar = new ArrayList<>(splitChar); + this.tokenTerminator = tokenTerminator; + } + + @Override + public boolean containsTokenTerminator(CharSequence charSequence) { + for (int i = 0; i < charSequence.length(); ++i) { + if (splitChar.contains(charSequence.charAt(i))) { + return true; + } + } + return false; + } + + @Override + @NonNull + public List findTokenRanges(CharSequence charSequence, int start, int end) { + ArrayListresult = new ArrayList<>(); + + if (start == end) { + //Can't have a 0 length token + return result; + } + + int tokenStart = start; + + for (int cursor = start; cursor < end; ++cursor) { + char character = charSequence.charAt(cursor); + + //Avoid including leading whitespace, tokenStart will match the cursor as long as we're at the start + if (tokenStart == cursor && Character.isWhitespace(character)) { + tokenStart = cursor + 1; + } + + //Either this is a split character, or we contain some content and are at the end of input + if (splitChar.contains(character) || cursor == end - 1) { + boolean hasTokenContent = + //There is token content befor the current character + cursor > tokenStart || + //If the current single character is valid token content, not a split char or whitespace + (cursor == tokenStart && !splitChar.contains(character)); + if (hasTokenContent) { + //There is some token content + //Add one to range end as the end of the ranges is not inclusive + result.add(new Range(tokenStart, cursor + 1)); + } + + tokenStart = cursor + 1; + } + } + + return result; + } + + @Override + @NonNull + public CharSequence wrapTokenValue(CharSequence text) { + CharSequence wrappedText = text + tokenTerminator; + + if (text instanceof Spanned) { + SpannableString sp = new SpannableString(wrappedText); + TextUtils.copySpansFrom((Spanned) text, 0, text.length(), + Object.class, sp, 0); + return sp; + } else { + return wrappedText; + } + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @SuppressWarnings("unchecked") + public CharacterTokenizer createFromParcel(Parcel in) { + return new CharacterTokenizer(in); + } + + public CharacterTokenizer[] newArray(int size) { + return new CharacterTokenizer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @SuppressWarnings({"WeakerAccess", "unchecked"}) + CharacterTokenizer(Parcel in) { + this(in.readArrayList(Character.class.getClassLoader()), in.readString()); + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeList(splitChar); + parcel.writeString(tokenTerminator); + } +} diff --git a/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/CountSpan.java b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/CountSpan.java new file mode 100644 index 0000000000000000000000000000000000000000..241942ba06050e21dc356a108983f0abc3a8d7fd --- /dev/null +++ b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/CountSpan.java @@ -0,0 +1,44 @@ +package com.tokenautocomplete; + +import android.text.Layout; +import android.text.TextPaint; +import android.text.style.CharacterStyle; + +import java.util.Locale; + +/** + * Span that displays +[x] + * + * Created on 2/3/15. + * @author mgod + */ + +class CountSpan extends CharacterStyle { + private String countText; + + CountSpan() { + super(); + countText = ""; + } + + @Override + public void updateDrawState(TextPaint textPaint) { + //Do nothing, we are using this span as a location marker + } + + void setCount(int c) { + if (c > 0) { + countText = String.format(Locale.getDefault(), " +%d", c); + } else { + countText = ""; + } + } + + String getCountText() { + return countText; + } + + float getCountTextWidthForPaint(TextPaint paint) { + return Layout.getDesiredWidth(countText, 0, countText.length(), paint); + } +} diff --git a/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/DummySpan.java b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/DummySpan.java new file mode 100644 index 0000000000000000000000000000000000000000..b4e75a6b404c4393e51a72686629a773afd48b8c --- /dev/null +++ b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/DummySpan.java @@ -0,0 +1,23 @@ +package com.tokenautocomplete; + +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +import androidx.annotation.NonNull; + +/** + * Invisible MetricAffectingSpan that will trigger a redraw when it is being added to or removed from an Editable. + * + * @see TokenCompleteTextView#redrawTokens() + */ +class DummySpan extends MetricAffectingSpan { + static final DummySpan INSTANCE = new DummySpan(); + + private DummySpan() {} + + @Override + public void updateMeasureState(@NonNull TextPaint textPaint) {} + + @Override + public void updateDrawState(TextPaint tp) {} +} diff --git a/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.java b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..93543c37b000ac1d0ac88c2c9d50735672e61530 --- /dev/null +++ b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.java @@ -0,0 +1,144 @@ +package com.tokenautocomplete; + +import android.content.Context; +import androidx.annotation.NonNull; +import android.widget.ArrayAdapter; +import android.widget.Filter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +/** + * Simplified custom filtered ArrayAdapter + * override keepObject with your test for filtering + *

+ * Based on gist + * FilteredArrayAdapter by Tobias Schürg + *

+ * Created on 9/17/13. + * @author mgod + */ + +abstract public class FilteredArrayAdapter extends ArrayAdapter { + + private List originalObjects; + private Filter filter; + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a TextView to use when + * instantiating views. + * @param objects The objects to represent in the ListView. + */ + public FilteredArrayAdapter(Context context, int resource, T[] objects) { + this(context, resource, 0, objects); + } + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a layout to use when + * instantiating views. + * @param textViewResourceId The id of the TextView within the layout resource to be populated + * @param objects The objects to represent in the ListView. + */ + @SuppressWarnings("WeakerAccess") + public FilteredArrayAdapter(Context context, int resource, int textViewResourceId, T[] objects) { + this(context, resource, textViewResourceId, new ArrayList<>(Arrays.asList(objects))); + } + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a TextView to use when + * instantiating views. + * @param objects The objects to represent in the ListView. + */ + @SuppressWarnings("unused") + public FilteredArrayAdapter(Context context, int resource, List objects) { + this(context, resource, 0, objects); + } + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a layout to use when + * instantiating views. + * @param textViewResourceId The id of the TextView within the layout resource to be populated + * @param objects The objects to represent in the ListView. + */ + @SuppressWarnings("WeakerAccess") + public FilteredArrayAdapter(Context context, int resource, int textViewResourceId, List objects) { + super(context, resource, textViewResourceId, new ArrayList<>(objects)); + this.originalObjects = objects; + } + + @NonNull + @Override + public Filter getFilter() { + if (filter == null) + filter = new AppFilter(); + return filter; + } + + /** + * Filter method used by the adapter. Return true if the object should remain in the list + * + * @param obj object we are checking for inclusion in the adapter + * @param mask current text in the edit text we are completing against + * @return true if we should keep the item in the adapter + */ + abstract protected boolean keepObject(T obj, String mask); + + /** + * Class for filtering Adapter, relies on keepObject in FilteredArrayAdapter + * + * based on gist by Tobias Schürg + * in turn inspired by inspired by Alxandr + * (http://stackoverflow.com/a/2726348/570168) + */ + private class AppFilter extends Filter { + + @Override + protected FilterResults performFiltering(CharSequence chars) { + ArrayList sourceObjects = new ArrayList<>(originalObjects); + + FilterResults result = new FilterResults(); + if (chars != null && chars.length() > 0) { + String mask = chars.toString(); + ArrayList keptObjects = new ArrayList<>(); + + for (T object : sourceObjects) { + if (keepObject(object, mask)) + keptObjects.add(object); + } + result.count = keptObjects.size(); + result.values = keptObjects; + } else { + // add all objects + result.values = sourceObjects; + result.count = sourceObjects.size(); + } + return result; + } + + @SuppressWarnings("unchecked") + @Override + protected void publishResults(CharSequence constraint, FilterResults results) { + clear(); + if (results.count > 0) { + FilteredArrayAdapter.this.addAll((Collection)results.values); + notifyDataSetChanged(); + } else { + notifyDataSetInvalidated(); + } + } + } +} diff --git a/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/Range.java b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/Range.java new file mode 100644 index 0000000000000000000000000000000000000000..aa7446973075bc2b0e05795f8239d107b3f2a82c --- /dev/null +++ b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/Range.java @@ -0,0 +1,36 @@ +package com.tokenautocomplete; + +import java.util.Locale; + +class Range { + public final int start; + public final int end; + + Range(int start, int end) { + if (start > end) { + throw new IllegalArgumentException(String.format(Locale.ENGLISH, + "Start (%d) cannot be greater than end (%d)", start, end)); + } + this.start = start; + this.end = end; + } + + public int length() { + return end - start; + } + + @Override + public boolean equals(Object obj) { + if (null == obj || !(obj instanceof Range)) { + return false; + } + + Range other = (Range) obj; + return other.start == start && other.end == end; + } + + @Override + public String toString() { + return String.format(Locale.US, "[%d..%d]", start, end); + } +} diff --git a/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/SpanUtils.java b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/SpanUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..6924309466d8e9a4dbf470a733ee6e9407e02933 --- /dev/null +++ b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/SpanUtils.java @@ -0,0 +1,57 @@ +package com.tokenautocomplete; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.TextUtils; + +public class SpanUtils { + + private static class EllipsizeCallback implements TextUtils.EllipsizeCallback { + int start = 0; + int end = 0; + + @Override + public void ellipsized(int ellipsedStart, int ellipsedEnd) { + start = ellipsedStart; + end = ellipsedEnd; + } + } + + @Nullable + public static Spanned ellipsizeWithSpans(@Nullable CountSpan countSpan, + int tokenCount, @NonNull TextPaint paint, + @NonNull CharSequence originalText, float maxWidth) { + + float countWidth = 0; + if (countSpan != null) { + //Assume the largest possible number of items for measurement + countSpan.setCount(tokenCount); + countWidth = countSpan.getCountTextWidthForPaint(paint); + } + + EllipsizeCallback ellipsizeCallback = new EllipsizeCallback(); + CharSequence tempEllipsized = TextUtils.ellipsize(originalText, paint, maxWidth - countWidth, + TextUtils.TruncateAt.END, false, ellipsizeCallback); + SpannableStringBuilder ellipsized = new SpannableStringBuilder(tempEllipsized); + if (tempEllipsized instanceof Spanned) { + TextUtils.copySpansFrom((Spanned)tempEllipsized, 0, tempEllipsized.length(), Object.class, ellipsized, 0); + } + + if (ellipsizeCallback.start != ellipsizeCallback.end) { + + if (countSpan != null) { + int visibleCount = ellipsized.getSpans(0, ellipsized.length(), TokenCompleteTextView.TokenImageSpan.class).length; + countSpan.setCount(tokenCount - visibleCount); + ellipsized.replace(ellipsizeCallback.start, ellipsized.length(), countSpan.getCountText()); + ellipsized.setSpan(countSpan, ellipsizeCallback.start, ellipsized.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + return ellipsized; + } + //No ellipses necessary + return null; + } +} diff --git a/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java new file mode 100644 index 0000000000000000000000000000000000000000..35c7abf38fb9638c038477ea851e88759fb86ac4 --- /dev/null +++ b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java @@ -0,0 +1,1307 @@ +package com.tokenautocomplete; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.appcompat.widget.AppCompatAutoCompleteTextView; +import android.text.Editable; +import android.text.InputFilter; +import android.text.InputType; +import android.text.Layout; +import android.text.NoCopySpan; +import android.text.Selection; +import android.text.SpanWatcher; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.Log; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.ExtractedText; +import android.view.inputmethod.ExtractedTextRequest; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputConnectionWrapper; +import android.view.inputmethod.InputMethodManager; +import android.widget.Filter; +import android.widget.ListView; +import android.widget.TextView; + +import java.io.Serializable; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * GMail style auto complete view with easy token customization + * override getViewForObject to provide your token view + *
+ * Created by mgod on 9/12/13. + * + * @author mgod + */ +public abstract class TokenCompleteTextView extends AppCompatAutoCompleteTextView + implements TextView.OnEditorActionListener, ViewSpan.Layout { + //Logging + public static final String TAG = "TokenAutoComplete"; + + private Tokenizer tokenizer; + private T selectedObject; + private TokenListener listener; + private TokenSpanWatcher spanWatcher; + private TokenTextWatcher textWatcher; + private CountSpan countSpan; + private @Nullable SpannableStringBuilder hiddenContent; + private Layout lastLayout = null; + private boolean initialized = false; + private boolean performBestGuess = true; + private boolean savingState = false; + private boolean shouldFocusNext = false; + private boolean allowCollapse = true; + private boolean internalEditInProgress = false; + + private int tokenLimit = -1; + + private transient String lastCompletionText = null; + + /** + * Add the TextChangedListeners + */ + protected void addListeners() { + Editable text = getText(); + if (text != null) { + text.setSpan(spanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + addTextChangedListener(textWatcher); + } + } + + /** + * Remove the TextChangedListeners + */ + protected void removeListeners() { + Editable text = getText(); + if (text != null) { + TokenSpanWatcher[] spanWatchers = text.getSpans(0, text.length(), TokenSpanWatcher.class); + for (TokenSpanWatcher watcher : spanWatchers) { + text.removeSpan(watcher); + } + removeTextChangedListener(textWatcher); + } + } + + /** + * Initialise the variables and various listeners + */ + private void init() { + if (initialized) return; + + // Initialise variables + setTokenizer(new CharacterTokenizer(Arrays.asList(',', ';'), ",")); + Editable text = getText(); + assert null != text; + spanWatcher = new TokenSpanWatcher(); + textWatcher = new TokenTextWatcher(); + hiddenContent = null; + countSpan = new CountSpan(); + + // Initialise TextChangedListeners + addListeners(); + + setTextIsSelectable(false); + setLongClickable(false); + + //In theory, get the soft keyboard to not supply suggestions. very unreliable + setInputType(getInputType() | + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | + InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE); + setHorizontallyScrolling(false); + + // Listen to IME action keys + setOnEditorActionListener(this); + + // Initialise the text filter (listens for the split chars) + setFilters(new InputFilter[]{new InputFilter() { + @Override + public CharSequence filter(CharSequence source, int start, int end, + Spanned dest, int destinationStart, int destinationEnd) { + if (internalEditInProgress) { + return null; + } + + // Token limit check + if (tokenLimit != -1 && getObjects().size() == tokenLimit) { + return ""; + } + + //Detect split characters, remove them and complete the current token instead + if (tokenizer.containsTokenTerminator(source)) { + if (currentCompletionText().length() > 0) { + performCompletion(); + return ""; + } + } + + return null; + } + }}); + + initialized = true; + } + + public TokenCompleteTextView(Context context) { + super(context); + init(); + } + + public TokenCompleteTextView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public TokenCompleteTextView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(); + } + + @Override + protected void performFiltering(CharSequence text, int keyCode) { + Filter filter = getFilter(); + if (filter != null) { + filter.filter(currentCompletionText(), this); + } + } + + public void setTokenizer(Tokenizer t) { + tokenizer = t; + } + + /** + * Set the listener that will be notified of changes in the Token list + * + * @param l The TokenListener + */ + public void setTokenListener(TokenListener l) { + listener = l; + } + + /** + * Override if you want to prevent a token from being added. Defaults to false. + * @param token the token to check + * @return true if the token should not be added, false if it's ok to add it. + */ + public boolean shouldIgnoreToken(@SuppressWarnings("unused") T token) { + return false; + } + + /** + * Override if you want to prevent a token from being removed. Defaults to true. + * @param token the token to check + * @return false if the token should not be removed, true if it's ok to remove it. + */ + public boolean isTokenRemovable(@SuppressWarnings("unused") T token) { + return true; + } + + /** + * Get the list of Tokens + * + * @return List of tokens + */ + public List getObjects() { + ArrayListobjects = new ArrayList<>(); + Editable text = getText(); + if (hiddenContent != null) { + text = hiddenContent; + } + for (TokenImageSpan span: text.getSpans(0, text.length(), TokenImageSpan.class)) { + objects.add(span.getToken()); + } + return objects; + } + + /** + * Get the content entered in the text field, including hidden text when ellipsized + * + * @return CharSequence of the entered content + */ + public CharSequence getContentText() { + if (hiddenContent != null) { + return hiddenContent; + } else { + return getText(); + } + } + + /** + * Set whether we try to guess an entry from the autocomplete spinner or just use the + * defaultObject implementation for inline token completion. + * + * @param guess true to enable guessing + */ + public void performBestGuess(boolean guess) { + performBestGuess = guess; + } + + /** + * Set whether the view should collapse to a single line when it loses focus. + * + * @param allowCollapse true if it should collapse + */ + public void allowCollapse(boolean allowCollapse) { + this.allowCollapse = allowCollapse; + } + + /** + * Set a number of tokens limit. + * + * @param tokenLimit The number of tokens permitted. -1 value disables limit. + */ + @SuppressWarnings("unused") + public void setTokenLimit(int tokenLimit) { + this.tokenLimit = tokenLimit; + } + + /** + * A token view for the object + * + * @param object the object selected by the user from the list + * @return a view to display a token in the text field for the object + */ + abstract protected View getViewForObject(T object); + + /** + * Provides a default completion when the user hits , and there is no item in the completion + * list + * + * @param completionText the current text we are completing against + * @return a best guess for what the user meant to complete or null if you don't want a guess + */ + abstract protected T defaultObject(String completionText); + + /** + * Correctly build accessibility string for token contents + * + * This seems to be a hidden API, but there doesn't seem to be another reasonable way + * @return custom string for accessibility + */ + @SuppressWarnings("unused") + public CharSequence getTextForAccessibility() { + if (getObjects().size() == 0) { + return getText(); + } + + SpannableStringBuilder description = new SpannableStringBuilder(); + Editable text = getText(); + int selectionStart = -1; + int selectionEnd = -1; + int i; + //Need to take the existing tet buffer and + // - replace all tokens with a decent string representation of the object + // - set the selection span to the corresponding location in the new CharSequence + for (i = 0; i < text.length(); ++i) { + //See if this is where we should start the selection + int origSelectionStart = Selection.getSelectionStart(text); + if (i == origSelectionStart) { + selectionStart = description.length(); + } + int origSelectionEnd = Selection.getSelectionEnd(text); + if (i == origSelectionEnd) { + selectionEnd = description.length(); + } + + //Replace token spans + TokenImageSpan[] tokens = text.getSpans(i, i, TokenImageSpan.class); + if (tokens.length > 0) { + TokenImageSpan token = tokens[0]; + description = description.append(tokenizer.wrapTokenValue(token.getToken().toString())); + i = text.getSpanEnd(token); + continue; + } + + description = description.append(text.subSequence(i, i + 1)); + } + + int origSelectionStart = Selection.getSelectionStart(text); + if (i == origSelectionStart) { + selectionStart = description.length(); + } + int origSelectionEnd = Selection.getSelectionEnd(text); + if (i == origSelectionEnd) { + selectionEnd = description.length(); + } + + if (selectionStart >= 0 && selectionEnd >= 0) { + Selection.setSelection(description, selectionStart, selectionEnd); + } + + return description; + } + + /** + * Clear the completion text only. + */ + @SuppressWarnings("unused") + public void clearCompletionText() { + //Respect currentCompletionText in case hint is visible or if other checks are added. + if (currentCompletionText().length() == 0){ + return; + } + + Range currentRange = getCurrentCandidateTokenRange(); + internalEditInProgress = true; + getText().delete(currentRange.start, currentRange.end); + internalEditInProgress = false; + } + + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + + if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) { + CharSequence text = getTextForAccessibility(); + event.setFromIndex(Selection.getSelectionStart(text)); + event.setToIndex(Selection.getSelectionEnd(text)); + event.setItemCount(text.length()); + } + } + + private Range getCurrentCandidateTokenRange() { + Editable editable = getText(); + int cursorEndPosition = getSelectionEnd(); + int candidateStringStart = 0; + int candidateStringEnd = editable.length(); + + //We want to find the largest string that contains the selection end that is not already tokenized + TokenImageSpan[] spans = editable.getSpans(0, editable.length(), TokenImageSpan.class); + for (TokenImageSpan span : spans) { + int spanEnd = editable.getSpanEnd(span); + if (candidateStringStart < spanEnd && cursorEndPosition >= spanEnd) { + candidateStringStart = spanEnd; + } + int spanStart = editable.getSpanStart(span); + if (candidateStringEnd > spanStart && cursorEndPosition <= spanEnd) { + candidateStringEnd = spanStart; + } + } + + List tokenRanges = tokenizer.findTokenRanges(editable, candidateStringStart, candidateStringEnd); + + for (Range range: tokenRanges) { + if (range.start <= cursorEndPosition && cursorEndPosition <= range.end) { + return range; + } + } + + return new Range(cursorEndPosition, cursorEndPosition); + } + + /** + * Override if you need custom logic to provide a sting representation of a token + * @param token the token to convert + * @return the string representation of the token. Defaults to {@link Object#toString()} + */ + protected CharSequence tokenToString(T token) { + return token.toString(); + } + + protected String currentCompletionText() { + Editable editable = getText(); + Range currentRange = getCurrentCandidateTokenRange(); + + String result = TextUtils.substring(editable, currentRange.start, currentRange.end); + Log.d(TAG, "Current completion text: " + result); + return result; + } + + protected float maxTextWidth() { + return getWidth() - getPaddingLeft() - getPaddingRight(); + } + + @Override + public int getMaxViewSpanWidth() { + return (int)maxTextWidth(); + } + + public void redrawTokens() { + // There's no straight-forward way to convince the widget to redraw the text and spans. We trigger a redraw by + // making an invisible change (either adding or removing a dummy span). + + Editable text = getText(); + if (text == null) return; + + int textLength = text.length(); + DummySpan[] dummySpans = text.getSpans(0, textLength, DummySpan.class); + if (dummySpans.length > 0) { + text.removeSpan(DummySpan.INSTANCE); + } else { + text.setSpan(DummySpan.INSTANCE, 0, textLength, Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + } + + @Override + public boolean enoughToFilter() { + if (tokenizer == null) { + return false; + } + + int cursorPosition = getSelectionEnd(); + + if (cursorPosition < 0) { + return false; + } + + Range currentCandidateRange = getCurrentCandidateTokenRange(); + + //Don't allow 0 length entries to filter + return currentCandidateRange.length() >= Math.max(getThreshold(), 1); + } + + @Override + public void performCompletion() { + if ((getAdapter() == null || getListSelection() == ListView.INVALID_POSITION) && enoughToFilter()) { + Object bestGuess; + if (getAdapter() != null && getAdapter().getCount() > 0 && performBestGuess) { + bestGuess = getAdapter().getItem(0); + } else { + bestGuess = defaultObject(currentCompletionText()); + } + replaceText(convertSelectionToString(bestGuess)); + } else { + super.performCompletion(); + } + } + + @Override + public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) { + InputConnection superConn = super.onCreateInputConnection(outAttrs); + if (superConn != null) { + TokenInputConnection conn = new TokenInputConnection(superConn, true); + outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; + outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI; + return conn; + } else { + return null; + } + } + + /** + * Create a token and hide the keyboard when the user sends the DONE IME action + * Use IME_NEXT if you want to create a token and go to the next field + */ + private void handleDone() { + // Attempt to complete the current token token + performCompletion(); + + // Hide the keyboard + InputMethodManager imm = (InputMethodManager) getContext().getSystemService( + Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.hideSoftInputFromWindow(getWindowToken(), 0); + } + } + + @Override + public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { + boolean handled = super.onKeyUp(keyCode, event); + if (shouldFocusNext) { + shouldFocusNext = false; + handleDone(); + } + return handled; + } + + @Override + public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) { + boolean handled = false; + switch (keyCode) { + case KeyEvent.KEYCODE_TAB: + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_DPAD_CENTER: + if (event.hasNoModifiers()) { + shouldFocusNext = true; + handled = true; + } + break; + case KeyEvent.KEYCODE_DEL: + handled = !canDeleteSelection(1); + break; + } + + return handled || super.onKeyDown(keyCode, event); + } + + @Override + public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) { + if (action == EditorInfo.IME_ACTION_DONE) { + handleDone(); + return true; + } + return false; + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + int action = event.getActionMasked(); + Editable text = getText(); + + boolean handled = super.onTouchEvent(event); + + if (isFocused() && text != null && lastLayout != null && action == MotionEvent.ACTION_UP) { + + int offset = getOffsetForPosition(event.getX(), event.getY()); + + if (offset != -1) { + TokenImageSpan[] links = text.getSpans(offset, offset, TokenImageSpan.class); + + if (links.length > 0) { + links[0].onClick(); + handled = true; + } + } + } + + return handled; + + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + //Never let users select text + selEnd = selStart; + + Editable text = getText(); + if (text != null) { + //Make sure if we are in a span, we select the spot 1 space after the span end + TokenImageSpan[] spans = text.getSpans(selStart, selEnd, TokenImageSpan.class); + for (TokenImageSpan span : spans) { + int spanEnd = text.getSpanEnd(span); + if (selStart <= spanEnd && text.getSpanStart(span) < selStart) { + if (spanEnd == text.length()) + setSelection(spanEnd); + else + setSelection(spanEnd + 1); + return; + } + } + + } + + super.onSelectionChanged(selStart, selEnd); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + lastLayout = getLayout(); //Used for checking text positions + } + + /** + * Collapse the view by removing all the tokens not on the first line. Displays a "+x" token. + * Restores the hidden tokens when the view gains focus. + * + * @param hasFocus boolean indicating whether we have the focus or not. + */ + public void performCollapse(boolean hasFocus) { + internalEditInProgress = true; + if (!hasFocus) { + // Display +x thingy/ellipse if appropriate + final Editable text = getText(); + if (text != null && hiddenContent == null && lastLayout != null) { + + //Ellipsize copies spans, so we need to stop listening to span changes here + text.removeSpan(spanWatcher); + + Spanned ellipsized = SpanUtils.ellipsizeWithSpans(countSpan, getObjects().size(), + lastLayout.getPaint(), text, maxTextWidth()); + + if (ellipsized != null) { + hiddenContent = new SpannableStringBuilder(text); + setText(ellipsized); + TextUtils.copySpansFrom(ellipsized, 0, ellipsized.length(), + TokenImageSpan.class, getText(), 0); + TextUtils.copySpansFrom(text, 0, hiddenContent.length(), + TokenImageSpan.class, hiddenContent, 0); + hiddenContent.setSpan(spanWatcher, 0, hiddenContent.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + } else { + getText().setSpan(spanWatcher, 0, getText().length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + } + } + } else { + if (hiddenContent != null) { + setText(hiddenContent); + TextUtils.copySpansFrom(hiddenContent, 0, hiddenContent.length(), + TokenImageSpan.class, getText(), 0); + hiddenContent = null; + + post(new Runnable() { + @Override + public void run() { + setSelection(getText().length()); + } + }); + + TokenSpanWatcher[] watchers = getText().getSpans(0, getText().length(), TokenSpanWatcher.class); + if (watchers.length == 0) { + //Span watchers can get removed in setText + getText().setSpan(spanWatcher, 0, getText().length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + } + } + } + internalEditInProgress = false; + } + + @Override + public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { + super.onFocusChanged(hasFocus, direction, previous); + + // Collapse the view to a single line + if (allowCollapse) performCollapse(hasFocus); + } + + @SuppressWarnings("unchecked cast") + @Override + protected CharSequence convertSelectionToString(Object object) { + selectedObject = (T) object; + return ""; + } + + protected TokenImageSpan buildSpanForObject(T obj) { + if (obj == null) { + return null; + } + View tokenView = getViewForObject(obj); + return new TokenImageSpan(tokenView, obj); + } + + @Override + protected void replaceText(CharSequence ignore) { + clearComposingText(); + + // Don't build a token for an empty String + if (selectedObject == null || selectedObject.toString().equals("")) return; + + TokenImageSpan tokenSpan = buildSpanForObject(selectedObject); + + Editable editable = getText(); + Range candidateRange = getCurrentCandidateTokenRange(); + + String original = TextUtils.substring(editable, candidateRange.start, candidateRange.end); + + //Keep track of replacements for a bug workaround + if (original.length() > 0) { + lastCompletionText = original; + } + + if (editable != null) { + internalEditInProgress = true; + if (tokenSpan == null) { + editable.replace(candidateRange.start, candidateRange.end, ""); + } else if (shouldIgnoreToken(tokenSpan.getToken())) { + editable.replace(candidateRange.start, candidateRange.end, ""); + if (listener != null) { + listener.onTokenIgnored(tokenSpan.getToken()); + } + } else { + SpannableStringBuilder ssb = new SpannableStringBuilder(tokenizer.wrapTokenValue(tokenToString(tokenSpan.token))); + editable.replace(candidateRange.start, candidateRange.end, ssb); + editable.setSpan(tokenSpan, candidateRange.start, candidateRange.start + ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editable.insert(candidateRange.start + ssb.length(), " "); + } + internalEditInProgress = false; + } + } + + @Override + public boolean extractText(@NonNull ExtractedTextRequest request, @NonNull ExtractedText outText) { + try { + return super.extractText(request, outText); + } catch (IndexOutOfBoundsException ex) { + Log.d(TAG, "extractText hit IndexOutOfBoundsException. This may be normal.", ex); + return false; + } + } + + /** + * Append a token object to the object list. May only be called from the main thread. + * + * @param object the object to add to the displayed tokens + */ + @UiThread + public void addObjectSync(T object) { + if (object == null) return; + if (shouldIgnoreToken(object)) { + if (listener != null) { + listener.onTokenIgnored(object); + } + return; + } + if (tokenLimit != -1 && getObjects().size() == tokenLimit) return; + insertSpan(buildSpanForObject(object)); + if (getText() != null && isFocused()) setSelection(getText().length()); + } + + /** + * Append a token object to the object list. Object will be added on the main thread. + * + * @param object the object to add to the displayed tokens + */ + public void addObjectAsync(final T object) { + post(new Runnable() { + @Override + public void run() { + addObjectSync(object); + } + }); + } + + /** + * Remove an object from the token list. Will remove duplicates if present or do nothing if no + * object is present in the view. Uses {@link Object#equals(Object)} to find objects. May only + * be called from the main thread + * + * @param object object to remove, may be null or not in the view + */ + @UiThread + public void removeObjectSync(T object) { + //To make sure all the appropriate callbacks happen, we just want to piggyback on the + //existing code that handles deleting spans when the text changes + ArrayListtexts = new ArrayList<>(); + //If there is hidden content, it's important that we update it first + if (hiddenContent != null) { + texts.add(hiddenContent); + } + if (getText() != null) { + texts.add(getText()); + } + + // If the object is currently visible, remove it + for (Editable text: texts) { + TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); + for (TokenImageSpan span : spans) { + if (span.getToken().equals(object)) { + removeSpan(text, span); + } + } + } + + updateCountSpan(); + } + + /** + * Remove an object from the token list. Will remove duplicates if present or do nothing if no + * object is present in the view. Uses {@link Object#equals(Object)} to find objects. Object + * will be added on the main thread + * + * @param object object to remove, may be null or not in the view + */ + public void removeObjectAsync(final T object) { + post(new Runnable() { + @Override + public void run() { + removeObjectSync(object); + } + }); + } + + /** + * Remove all objects from the token list. Objects will be removed on the main thread. + */ + public void clearAsync() { + post(new Runnable() { + @Override + public void run() { + for (T object: getObjects()) { + removeObjectSync(object); + } + } + }); + } + + /** + * Set the count span the current number of hidden objects + */ + private void updateCountSpan() { + Editable text = getText(); + + int visibleCount = getText().getSpans(0, getText().length(), TokenImageSpan.class).length; + countSpan.setCount(getObjects().size() - visibleCount); + + SpannableStringBuilder spannedCountText = new SpannableStringBuilder(countSpan.getCountText()); + spannedCountText.setSpan(countSpan, 0, spannedCountText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + internalEditInProgress = true; + int countStart = text.getSpanStart(countSpan); + if (countStart != -1) { + //Span is in the text, replace existing text + //This will also remove the span if the count is 0 + text.replace(countStart, text.getSpanEnd(countSpan), spannedCountText); + } else { + text.append(spannedCountText); + } + + internalEditInProgress = false; + } + + /** + * Remove a span from the current EditText and fire the appropriate callback + * + * @param text Editable to remove the span from + * @param span TokenImageSpan to be removed + */ + private void removeSpan(Editable text, TokenImageSpan span) { + //We usually add whitespace after a token, so let's try to remove it as well if it's present + int end = text.getSpanEnd(span); + if (end < text.length() && text.charAt(end) == ' ') { + end += 1; + } + + internalEditInProgress = true; + text.delete(text.getSpanStart(span), end); + internalEditInProgress = false; + + if (allowCollapse && !isFocused()) { + updateCountSpan(); + } + } + + /** + * Insert a new span for an Object + * + * @param tokenSpan span to insert + */ + private void insertSpan(TokenImageSpan tokenSpan) { + CharSequence ssb = tokenizer.wrapTokenValue(tokenToString(tokenSpan.token)); + + Editable editable = getText(); + if (editable == null) return; + + // If we haven't hidden any objects yet, we can try adding it + if (hiddenContent == null) { + internalEditInProgress = true; + int offset = editable.length(); + + Range currentRange = getCurrentCandidateTokenRange(); + if (currentRange.length() > 0) { + // The user has entered some text that has not yet been tokenized. + // Find the beginning of this text and insert the new token there. + offset = currentRange.start; + } + + editable.insert(offset, ssb); + editable.insert(offset + ssb.length(), " "); + editable.setSpan(tokenSpan, offset, offset + ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + internalEditInProgress = false; + } else { + CharSequence tokenText = tokenizer.wrapTokenValue(tokenToString(tokenSpan.getToken())); + int start = hiddenContent.length(); + hiddenContent.append(tokenText); + hiddenContent.append(" "); + hiddenContent.setSpan(tokenSpan, start, start + tokenText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + updateCountSpan(); + } + } + + protected class TokenImageSpan extends ViewSpan implements NoCopySpan { + private T token; + + @SuppressWarnings("WeakerAccess") + public TokenImageSpan(View d, T token) { + super(d, TokenCompleteTextView.this); + this.token = token; + } + + @SuppressWarnings("WeakerAccess") + public T getToken() { + return this.token; + } + + @SuppressWarnings("WeakerAccess") + public void onClick() { + Editable text = getText(); + if (text == null) return; + + if (getSelectionStart() != text.getSpanEnd(this)) { + //Make sure the selection is not in the middle of the span + setSelection(text.getSpanEnd(this)); + } + } + } + + public interface TokenListener { + void onTokenAdded(T token); + void onTokenRemoved(T token); + void onTokenIgnored(T token); + } + + private class TokenSpanWatcher implements SpanWatcher { + + @SuppressWarnings("unchecked cast") + @Override + public void onSpanAdded(Spannable text, Object what, int start, int end) { + if (what instanceof TokenCompleteTextView.TokenImageSpan && !savingState) { + TokenImageSpan token = (TokenImageSpan) what; + + // If we're not focused: collapse the view if necessary + if (!isFocused() && allowCollapse) performCollapse(false); + + if (listener != null) + listener.onTokenAdded(token.getToken()); + } + } + + @SuppressWarnings("unchecked cast") + @Override + public void onSpanRemoved(Spannable text, Object what, int start, int end) { + if (what instanceof TokenCompleteTextView.TokenImageSpan && !savingState) { + TokenImageSpan token = (TokenImageSpan) what; + + if (listener != null) + listener.onTokenRemoved(token.getToken()); + } + } + + @Override + public void onSpanChanged(Spannable text, Object what, + int oldStart, int oldEnd, int newStart, int newEnd) { + } + } + + private class TokenTextWatcher implements TextWatcher { + ArrayList spansToRemove = new ArrayList<>(); + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // count > 0 means something will be deleted + if (count > 0 && getText() != null) { + Editable text = getText(); + + int end = start + count; + + TokenImageSpan[] spans = text.getSpans(start, end, TokenImageSpan.class); + + //NOTE: I'm not completely sure this won't cause problems if we get stuck in a text changed loop + //but it appears to work fine. Spans will stop getting removed if this breaks. + ArrayList spansToRemove = new ArrayList<>(); + for (TokenImageSpan token : spans) { + if (text.getSpanStart(token) < end && start < text.getSpanEnd(token)) { + spansToRemove.add(token); + } + } + this.spansToRemove = spansToRemove; + } + } + + @Override + public void afterTextChanged(Editable text) { + ArrayList spansCopy = new ArrayList<>(spansToRemove); + spansToRemove.clear(); + for (TokenImageSpan token : spansCopy) { + //Only remove it if it's still present + if (text.getSpanStart(token) != -1 && text.getSpanEnd(token) != -1) { + removeSpan(text, token); + } + + } + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + } + + protected List getSerializableObjects() { + List serializables = new ArrayList<>(); + for (Object obj : getObjects()) { + if (obj instanceof Serializable) { + serializables.add((Serializable) obj); + } else { + Log.e(TAG, "Unable to save '" + obj + "'"); + } + } + if (serializables.size() != getObjects().size()) { + String message = "You should make your objects Serializable or Parcelable or\n" + + "override getSerializableObjects and convertSerializableArrayToObjectArray"; + Log.e(TAG, message); + } + + return serializables; + } + + @SuppressWarnings("unchecked") + protected List convertSerializableObjectsToTypedObjects(List s) { + return (List) s; + } + + //Used to determine if we can use the Parcelable interface + private Class reifyParameterizedTypeClass() { + //Borrowed from http://codyaray.com/2013/01/finding-generic-type-parameters-with-guava + + //Figure out what class of objects we have + Class viewClass = getClass(); + while (!viewClass.getSuperclass().equals(TokenCompleteTextView.class)) { + viewClass = viewClass.getSuperclass(); + } + + // This operation is safe. Because viewClass is a direct sub-class, getGenericSuperclass() will + // always return the Type of this class. Because this class is parameterized, the cast is safe + ParameterizedType superclass = (ParameterizedType) viewClass.getGenericSuperclass(); + Type type = superclass.getActualTypeArguments()[0]; + return (Class)type; + } + + @Override + public Parcelable onSaveInstanceState() { + //We don't want to save the listeners as part of the parent + //onSaveInstanceState, so remove them first + removeListeners(); + + //Apparently, saving the parent state on 2.3 mutates the spannable + //prevent this mutation from triggering add or removes of token objects ~mgod + savingState = true; + Parcelable superState = super.onSaveInstanceState(); + savingState = false; + SavedState state = new SavedState(superState); + + state.allowCollapse = allowCollapse; + state.performBestGuess = performBestGuess; + Class parameterizedClass = reifyParameterizedTypeClass(); + //Our core array is Parcelable, so use that interface + if (Parcelable.class.isAssignableFrom(parameterizedClass)) { + state.parcelableClassName = parameterizedClass.getName(); + state.baseObjects = getObjects(); + } else { + //Fallback on Serializable + state.parcelableClassName = SavedState.SERIALIZABLE_PLACEHOLDER; + state.baseObjects = getSerializableObjects(); + } + state.tokenizer = tokenizer; + + //So, when the screen is locked or some other system event pauses execution, + //onSaveInstanceState gets called, but it won't restore state later because the + //activity is still in memory, so make sure we add the listeners again + //They should not be restored in onInstanceState if the app is actually killed + //as we removed them before the parent saved instance state, so our adding them in + //onRestoreInstanceState is good. + addListeners(); + + return state; + } + + @SuppressWarnings("unchecked") + @Override + public void onRestoreInstanceState(Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + + allowCollapse = ss.allowCollapse; + performBestGuess = ss.performBestGuess; + tokenizer = ss.tokenizer; + addListeners(); + + List objects; + if (SavedState.SERIALIZABLE_PLACEHOLDER.equals(ss.parcelableClassName)) { + objects = convertSerializableObjectsToTypedObjects(ss.baseObjects); + } else { + objects = (List)ss.baseObjects; + } + + //TODO: change this to keep object spans in the correct locations based on ranges. + for (T obj: objects) { + addObjectSync(obj); + } + + // Collapse the view if necessary + if (!isFocused() && allowCollapse) { + post(new Runnable() { + @Override + public void run() { + //Resize the view and display the +x if appropriate + performCollapse(isFocused()); + } + }); + } + } + + /** + * Handle saving the token state + */ + private static class SavedState extends BaseSavedState { + static final String SERIALIZABLE_PLACEHOLDER = "Serializable"; + + boolean allowCollapse; + boolean performBestGuess; + String parcelableClassName; + List baseObjects; + String tokenizerClassName; + Tokenizer tokenizer; + + @SuppressWarnings("unchecked") + SavedState(Parcel in) { + super(in); + allowCollapse = in.readInt() != 0; + performBestGuess = in.readInt() != 0; + parcelableClassName = in.readString(); + if (SERIALIZABLE_PLACEHOLDER.equals(parcelableClassName)) { + baseObjects = (ArrayList)in.readSerializable(); + } else { + try { + ClassLoader loader = Class.forName(parcelableClassName).getClassLoader(); + baseObjects = in.readArrayList(loader); + } catch (ClassNotFoundException ex) { + //This should really never happen, class had to be available to get here + throw new RuntimeException(ex); + } + } + tokenizerClassName = in.readString(); + try { + ClassLoader loader = Class.forName(tokenizerClassName).getClassLoader(); + tokenizer = in.readParcelable(loader); + } catch (ClassNotFoundException ex) { + //This should really never happen, class had to be available to get here + throw new RuntimeException(ex); + } + } + + SavedState(Parcelable superState) { + super(superState); + } + + @Override + public void writeToParcel(@NonNull Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeInt(allowCollapse ? 1 : 0); + out.writeInt(performBestGuess ? 1 : 0); + if (SERIALIZABLE_PLACEHOLDER.equals(parcelableClassName)) { + out.writeString(SERIALIZABLE_PLACEHOLDER); + out.writeSerializable((Serializable)baseObjects); + } else { + out.writeString(parcelableClassName); + out.writeList(baseObjects); + } + out.writeString(tokenizer.getClass().getCanonicalName()); + out.writeParcelable(tokenizer, 0); + } + + @Override + public String toString() { + String str = "TokenCompleteTextView.SavedState{" + + Integer.toHexString(System.identityHashCode(this)) + + " tokens=" + baseObjects; + return str + "}"; + } + + @SuppressWarnings("hiding") + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + /** + * Checks if selection can be deleted. This method is called from TokenInputConnection . + * @param beforeLength the number of characters before the current selection end to check + * @return true if there are no non-deletable pieces of the section + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public boolean canDeleteSelection(int beforeLength) { + if (getObjects().size() < 1) return true; + + // if beforeLength is 1, we either have no selection or the call is coming from OnKey Event. + // In these scenarios, getSelectionStart() will return the correct value. + + int endSelection = getSelectionEnd(); + int startSelection = beforeLength == 1 ? getSelectionStart() : endSelection - beforeLength; + + Editable text = getText(); + TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); + + // Iterate over all tokens and allow the deletion + // if there are no tokens not removable in the selection + for (TokenImageSpan span : spans) { + int startTokenSelection = text.getSpanStart(span); + int endTokenSelection = text.getSpanEnd(span); + + // moving on, no need to check this token + if (isTokenRemovable(span.token)) continue; + + if (startSelection == endSelection) { + // Delete single + if (endTokenSelection + 1 == endSelection) { + return false; + } + } else { + // Delete range + // Don't delete if a non removable token is in range + if (startSelection <= startTokenSelection + && endTokenSelection + 1 <= endSelection) { + return false; + } + } + } + return true; + } + + private class TokenInputConnection extends InputConnectionWrapper { + + TokenInputConnection(InputConnection target, boolean mutable) { + super(target, mutable); + } + + // This will fire if the soft keyboard delete key is pressed. + // The onKeyPressed method does not always do this. + @Override + public boolean deleteSurroundingText(int beforeLength, int afterLength) { + // Shouldn't be able to delete any text with tokens that are not removable + if (!canDeleteSelection(beforeLength)) return false; + + return super.deleteSurroundingText(beforeLength, afterLength); + } + + @Override + public boolean setComposingText(CharSequence text, int newCursorPosition) { + //There's an issue with some keyboards where they will try to insert the first word + //of the prefix as the composing text + CharSequence hint = getHint(); + if (hint != null && text != null) { + String firstWord = hint.toString().trim().split(" ")[0]; + if (firstWord.length() > 0 && firstWord.equals(text.toString())) { + text = ""; //It was trying to use th hint, so clear that text + } + } + + //Also, some keyboards don't correctly respect the replacement if the replacement + //is the same number of characters as the replacement span + //We need to ignore this value if it's available + if (lastCompletionText != null && text != null && + text.length() == lastCompletionText.length() + 1 && + text.toString().startsWith(lastCompletionText)) { + text = text.subSequence(text.length() - 1, text.length()); + lastCompletionText = null; + } + + return super.setComposingText(text, newCursorPosition); + } + } +} diff --git a/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/Tokenizer.java b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/Tokenizer.java new file mode 100644 index 0000000000000000000000000000000000000000..d516be72d86a0c5d3c4cfa87d329ec2638ae8510 --- /dev/null +++ b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/Tokenizer.java @@ -0,0 +1,39 @@ +package com.tokenautocomplete; + +import android.os.Parcelable; +import androidx.annotation.NonNull; + +import java.util.List; + +public interface Tokenizer extends Parcelable { + /** + * Find all ranges that can be tokenized. This system should detect possible tokens + * both with and without having had wrapTokenValue called on the token string representation + * + * @param charSequence the string to search in + * @param start where the tokenizer should start looking for tokens + * @param end where the tokenizer should stop looking for tokens + * @return all ranges of characters that are valid tokens + */ + @NonNull + List findTokenRanges(CharSequence charSequence, int start, int end); + + /** + * Return a complete string representation of the token. Often used to add commas after email + * addresses when creating tokens + * + * This value must NOT include any leading or trailing whitespace + * + * @param unwrappedTokenValue the value to wrap + * @return the token value with any expected delimiter characters + */ + @NonNull + CharSequence wrapTokenValue(CharSequence unwrappedTokenValue); + + /** + * Return true if there is a character in the charSequence that should trigger token detection + * @param charSequence source text to look at + * @return true if charSequence contains a value that should end a token + */ + boolean containsTokenTerminator(CharSequence charSequence); +} diff --git a/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/ViewSpan.java b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/ViewSpan.java new file mode 100644 index 0000000000000000000000000000000000000000..63023d21191179bcb5aa9444560d3e3fa0037b95 --- /dev/null +++ b/library/TokenAutoComplete/src/main/java/com/tokenautocomplete/ViewSpan.java @@ -0,0 +1,84 @@ +package com.tokenautocomplete; + +import android.graphics.Canvas; +import android.graphics.Paint; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.text.style.ReplacementSpan; +import android.view.View; +import android.view.ViewGroup; + +/** + * Span that holds a view it draws when rendering + * + * Created on 2/3/15. + * @author mgod + */ +public class ViewSpan extends ReplacementSpan { + protected View view; + private ViewSpan.Layout layout; + private int cachedMaxWidth = -1; + + @SuppressWarnings("WeakerAccess") + public ViewSpan(View view, ViewSpan.Layout layout) { + super(); + this.layout = layout; + this.view = view; + this.view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + } + + private void prepView() { + if (layout.getMaxViewSpanWidth() != cachedMaxWidth || view.isLayoutRequested()) { + cachedMaxWidth = layout.getMaxViewSpanWidth(); + + int spec = View.MeasureSpec.AT_MOST; + if (cachedMaxWidth == 0) { + //If the width is 0, allow the view to choose it's own content size + spec = View.MeasureSpec.UNSPECIFIED; + } + int widthSpec = View.MeasureSpec.makeMeasureSpec(cachedMaxWidth, spec); + int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + + view.measure(widthSpec, heightSpec); + view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); + } + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, @IntRange(from = 0) int start, + @IntRange(from = 0) int end, float x, int top, int y, int bottom, @NonNull Paint paint) { + prepView(); + + canvas.save(); + canvas.translate(x, top); + view.draw(canvas); + canvas.restore(); + } + + @Override + public int getSize(@NonNull Paint paint, CharSequence charSequence, @IntRange(from = 0) int start, + @IntRange(from = 0) int end, @Nullable Paint.FontMetricsInt fontMetricsInt) { + prepView(); + + if (fontMetricsInt != null) { + //We need to make sure the layout allots enough space for the view + int height = view.getMeasuredHeight(); + + int adjustedBaseline = view.getBaseline(); + //-1 means the view doesn't support baseline alignment, so align bottom to font baseline + if (adjustedBaseline == -1) { + adjustedBaseline = height; + } + fontMetricsInt.ascent = fontMetricsInt.top = -adjustedBaseline; + fontMetricsInt.descent = fontMetricsInt.bottom = height - adjustedBaseline; + } + + return view.getRight(); + } + + public interface Layout { + int getMaxViewSpanWidth(); + } +} diff --git a/library/TokenAutoComplete/src/test/java/com/tokenautocomplete/CharacterTokenizerTest.java b/library/TokenAutoComplete/src/test/java/com/tokenautocomplete/CharacterTokenizerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..5860b76762f04fc2460ec72b0b588325898fe008 --- /dev/null +++ b/library/TokenAutoComplete/src/test/java/com/tokenautocomplete/CharacterTokenizerTest.java @@ -0,0 +1,128 @@ +package com.tokenautocomplete; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.RandomAccess; + +/** + * Make sure the tokenizer finds the right boundaries + * + * Created by mgod on 8/24/17. + */ + +public class CharacterTokenizerTest { + + @Test + public void handleWhiteSpaceWithCommaTokens() { + CharacterTokenizer tokenizer = new CharacterTokenizer(Collections.singletonList(','), ","); + String text = "bears, ponies"; + + assertTrue(tokenizer.containsTokenTerminator(text)); + + assertEquals(2, tokenizer.findTokenRanges(text, 0, text.length()).size()); + + List ranges = tokenizer.findTokenRanges(text, 0, text.length()); + assertEquals(Arrays.asList(new Range(0, 6), new Range(7, 13)), ranges); + assertEquals("bears,", text.subSequence(ranges.get(0).start, ranges.get(0).end)); + assertEquals("ponies", text.subSequence(ranges.get(1).start, ranges.get(1).end)); + + ranges = tokenizer.findTokenRanges(text, 5, text.length()); + assertEquals(", ponies", text.substring(5)); + assertEquals(Collections.singletonList(new Range(7, 13)), ranges); + + ranges = tokenizer.findTokenRanges(text, 1, text.length()); + assertEquals(Arrays.asList(new Range(1, 6), new Range(7, 13)), ranges); + + assertEquals(Collections.singletonList(new Range(7, 13)), + tokenizer.findTokenRanges(text, 7, text.length())); + assertEquals(Collections.singletonList(new Range(8, 13)), + tokenizer.findTokenRanges(text, 8, text.length())); + assertEquals(Collections.singletonList(new Range(11, 13)), + tokenizer.findTokenRanges(text, 11, text.length())); + } + + @Test + public void handleWhiteSpaceWithWhitespaceTokens() { + CharacterTokenizer tokenizer = new CharacterTokenizer(Collections.singletonList(' '), ""); + String text = "bears ponies"; + + List ranges = tokenizer.findTokenRanges(text, 0, text.length()); + assertEquals(Arrays.asList(new Range(0, 6), new Range(6, 12)), ranges); + + ranges = tokenizer.findTokenRanges(text, 1, text.length()); + assertEquals(Arrays.asList(new Range(1, 6), new Range(6, 12)), ranges); + + ranges = tokenizer.findTokenRanges(text, 4, text.length()); + assertEquals(Arrays.asList(new Range(4, 6), new Range(6, 12)), ranges); + + ranges = tokenizer.findTokenRanges(text, 6, text.length()); + assertEquals(Collections.singletonList(new Range(6, 12)), ranges); + + ranges = tokenizer.findTokenRanges(text, 7, text.length()); + assertEquals(Collections.singletonList(new Range(7, 12)), ranges); + + ranges = tokenizer.findTokenRanges(text, 0, text.length() - 3); + assertEquals(Arrays.asList(new Range(0, 6), new Range(6, 9)), ranges); + } + + @Test + public void handleLotsOfWhitespace() { + CharacterTokenizer tokenizer = new CharacterTokenizer(Collections.singletonList(','), ""); + String text = "bears, ponies ,another"; + + List ranges = tokenizer.findTokenRanges(text, 0, text.length()); + assertEquals(Arrays.asList(new Range(0, 6), new Range(12, 24), new Range(24, 31)), ranges); + } + + @Test + public void handleLotsOfWhitespaceWithWhitespaceTokenizer() { + CharacterTokenizer tokenizer = new CharacterTokenizer(Collections.singletonList(' '), ""); + String text = "bears, \t ponies \n ,another"; + + List ranges = tokenizer.findTokenRanges(text, 0, text.length()); + assertEquals(Arrays.asList(new Range(0, 7), new Range(12, 19), new Range(23, 31)), ranges); + } + + @Test + public void allowsOneCharacterCandidateRangeMatches() { + CharacterTokenizer tokenizer = new CharacterTokenizer(Collections.singletonList(','), ""); + String text = "a"; + + List ranges = tokenizer.findTokenRanges(text, 0, text.length()); + assertEquals(Collections.singletonList(new Range(0,1)), ranges); + } + + @Test + public void allowsOneCharacterCandidateRangeMatchesWithWhitespace() { + CharacterTokenizer tokenizer = new CharacterTokenizer(Collections.singletonList(','), ""); + String text = " a"; + + List ranges = tokenizer.findTokenRanges(text, 0, text.length()); + assertEquals(Collections.singletonList(new Range(1,2)), ranges); + } + + @Test + public void doesntMatchWhitespaceAsCandidateTokenRange() { + CharacterTokenizer tokenizer = new CharacterTokenizer(Collections.singletonList(','), ""); + String text = "test, "; + + List ranges = tokenizer.findTokenRanges(text, 0, text.length()); + assertEquals(Collections.singletonList(new Range(0, 5)), ranges); + } + + @Test + public void matchesSingleLetterTokens() { + CharacterTokenizer tokenizer = new CharacterTokenizer(Collections.singletonList(','), ""); + String text = "t,r, a,,b"; + + List ranges = tokenizer.findTokenRanges(text, 0, text.length()); + assertEquals(Arrays.asList(new Range(0, 2), new Range(2,4), new Range(5,7), new Range(8,9)), ranges); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 3771cf6eb5c894dd6f95c056ff55e62d2a496bef..d92b315e6af425c8f314f741a6975edc481bf403 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -177,4 +177,5 @@ include( include( ":library:html-cleaner", + ":library:TokenAutoComplete", )