diff --git a/mastodon/src/main/java/com/hootsuite/nachos/ChipConfiguration.java b/mastodon/src/main/java/com/hootsuite/nachos/ChipConfiguration.java
new file mode 100644
index 000000000..0b8fb3b7a
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/ChipConfiguration.java
@@ -0,0 +1,78 @@
+package com.hootsuite.nachos;
+
+import android.content.res.ColorStateList;
+
+public class ChipConfiguration {
+
+ private final int mChipHorizontalSpacing;
+ private final ColorStateList mChipBackground;
+ private final int mChipCornerRadius;
+ private final int mChipTextColor;
+ private final int mChipTextSize;
+ private final int mChipHeight;
+ private final int mChipVerticalSpacing;
+ private final int mMaxAvailableWidth;
+
+ /**
+ * Creates a new ChipConfiguration. You can pass in {@code -1} or {@code null} for any of the parameters to indicate that parameter should be
+ * ignored.
+ *
+ * @param chipHorizontalSpacing the amount of horizontal space (in pixels) to put between consecutive chips
+ * @param chipBackground the {@link ColorStateList} to set as the background of the chips
+ * @param chipCornerRadius the corner radius of the chip background, in pixels
+ * @param chipTextColor the color to set as the text color of the chips
+ * @param chipTextSize the font size (in pixels) to use for the text of the chips
+ * @param chipHeight the height (in pixels) of each chip
+ * @param chipVerticalSpacing the amount of vertical space (in pixels) to put between chips on consecutive lines
+ * @param maxAvailableWidth the maximum available with for a chip (the width of a full line of text in the text view)
+ */
+ ChipConfiguration(int chipHorizontalSpacing,
+ ColorStateList chipBackground,
+ int chipCornerRadius,
+ int chipTextColor,
+ int chipTextSize,
+ int chipHeight,
+ int chipVerticalSpacing,
+ int maxAvailableWidth) {
+ mChipHorizontalSpacing = chipHorizontalSpacing;
+ mChipBackground = chipBackground;
+ mChipCornerRadius = chipCornerRadius;
+ mChipTextColor = chipTextColor;
+ mChipTextSize = chipTextSize;
+ mChipHeight = chipHeight;
+ mChipVerticalSpacing = chipVerticalSpacing;
+ mMaxAvailableWidth = maxAvailableWidth;
+ }
+
+ public int getChipHorizontalSpacing() {
+ return mChipHorizontalSpacing;
+ }
+
+ public ColorStateList getChipBackground() {
+ return mChipBackground;
+ }
+
+ public int getChipCornerRadius() {
+ return mChipCornerRadius;
+ }
+
+ public int getChipTextColor() {
+ return mChipTextColor;
+ }
+
+ public int getChipTextSize() {
+ return mChipTextSize;
+ }
+
+ public int getChipHeight() {
+ return mChipHeight;
+ }
+
+ public int getChipVerticalSpacing() {
+ return mChipVerticalSpacing;
+ }
+
+ public int getMaxAvailableWidth() {
+ return mMaxAvailableWidth;
+ }
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java b/mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java
new file mode 100644
index 000000000..f91ebf81d
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java
@@ -0,0 +1,1132 @@
+package com.hootsuite.nachos;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.text.Editable;
+import android.text.Layout;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.Pair;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import android.widget.Adapter;
+import android.widget.AdapterView;
+import android.widget.AutoCompleteTextView;
+import android.widget.ListAdapter;
+import android.widget.MultiAutoCompleteTextView;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.ColorRes;
+import androidx.annotation.DimenRes;
+import androidx.annotation.Dimension;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.hootsuite.nachos.chip.Chip;
+import com.hootsuite.nachos.chip.ChipInfo;
+import com.hootsuite.nachos.chip.ChipSpan;
+import com.hootsuite.nachos.chip.ChipSpanChipCreator;
+import com.hootsuite.nachos.terminator.ChipTerminatorHandler;
+import com.hootsuite.nachos.terminator.DefaultChipTerminatorHandler;
+import com.hootsuite.nachos.tokenizer.ChipTokenizer;
+import com.hootsuite.nachos.tokenizer.SpanChipTokenizer;
+import com.hootsuite.nachos.validator.ChipifyingNachoValidator;
+import com.hootsuite.nachos.validator.IllegalCharacterIdentifier;
+import com.hootsuite.nachos.validator.NachoValidator;
+
+import org.joinmastodon.android.R;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * An editable TextView extending {@link MultiAutoCompleteTextView} that supports "chipifying" pieces of text and displaying suggestions for segments of the text.
+ *
The ChipTokenizer
+ * To customize chipifying with this class you can provide a custom {@link ChipTokenizer} by calling {@link #setChipTokenizer(ChipTokenizer)}.
+ * By default the {@link SpanChipTokenizer} is used.
+ *
Chip Terminators
+ * To set which characters trigger the creation of a chip, call {@link #addChipTerminator(char, int)} or {@link #setChipTerminators(Map)}.
+ * For example if tapping enter should cause all unchipped text to become chipped, call
+ * {@code chipSuggestionTextView.addChipTerminator('\n', ChipTerminatorHandler.BEHAVIOR_CHIPIFY_ALL);}
+ * To completely customize how chips are created when text is entered in this text view you can provide a custom {@link ChipTerminatorHandler}
+ * through {@link #setChipTerminatorHandler(ChipTerminatorHandler)}
+ *
Illegal Characters
+ * To prevent a character from being typed you can call {@link #setIllegalCharacterIdentifier(IllegalCharacterIdentifier)}} to identify characters
+ * that should be considered illegal.
+ *
Suggestions
+ * To provide suggestions you must provide an {@link android.widget.Adapter} by calling {@link #setAdapter(ListAdapter)}
+ *
UI Customization
+ * This view defines six custom attributes (all of which are optional):
+ *
+ *
chipHorizontalSpacing - the horizontal space between chips
+ *
chipBackground - the background color of the chip
+ *
chipCornerRadius - the corner radius of the chip background
+ *
chipTextColor - the color of the chip text
+ *
chipTextSize - the font size of the chip text
+ *
chipHeight - the height of a single chip
+ *
chipVerticalSpacing - the vertical space between chips on consecutive lines
+ *
+ *
Note: chipVerticalSpacing is only used if a chipHeight is also set
+ *
+ *
+ *
+ * The values of these attributes will be passed to the ChipTokenizer through {@link ChipTokenizer#applyConfiguration(Editable, ChipConfiguration)}
+ *
Validation
+ * This class can perform validation when certain events occur (such as losing focus). When the validation occurs is decided by
+ * {@link AutoCompleteTextView}. To perform validation, set a {@link NachoValidator}:
+ *
+ * Note: The NachoValidator will be ignored if a ChipTokenizer is not set. To perform validation without a ChipTokenizer you can use
+ * {@link AutoCompleteTextView}'s built-in {@link AutoCompleteTextView.Validator Validator} through {@link #setValidator(Validator)}
+ *
Editing Chips
+ * This class also supports editing chips on touch. To enable this behavior call {@link #enableEditChipOnTouch(boolean, boolean)}. To disable this
+ * behavior you can call {@link #disableEditChipOnTouch()}
+ *
Example Setup:
+ * A standard setup for this class could look something like the following:
+ *
+ *
+ * @see SpanChipTokenizer
+ * @see DefaultChipTerminatorHandler
+ * @see ChipifyingNachoValidator
+ */
+public class NachoTextView extends MultiAutoCompleteTextView implements TextWatcher, AdapterView.OnItemClickListener {
+
+ // UI Attributes
+ private int mChipHorizontalSpacing = -1;
+ private ColorStateList mChipBackground = null;
+ private int mChipCornerRadius = -1;
+ private int mChipTextColor = Color.TRANSPARENT;
+ private int mChipTextSize = -1;
+ private int mChipHeight = -1;
+ private int mChipVerticalSpacing = -1;
+
+ private int mDefaultPaddingTop = 0;
+ private int mDefaultPaddingBottom = 0;
+ /**
+ * Flag to keep track of the padding state so we only update the padding when necessary
+ */
+ private boolean mUsingDefaultPadding = true;
+
+ // Touch events
+ @Nullable
+ private OnChipClickListener mOnChipClickListener;
+ private GestureDetector singleTapDetector;
+ private boolean mEditChipOnTouchEnabled;
+ private boolean mMoveChipToEndOnEdit;
+ private boolean mChipifyUnterminatedTokensOnEdit;
+
+ // Text entry
+ @Nullable
+ private ChipTokenizer mChipTokenizer;
+ @Nullable
+ private ChipTerminatorHandler mChipTerminatorHandler;
+ @Nullable
+ private NachoValidator mNachoValidator;
+ @Nullable
+ private IllegalCharacterIdentifier illegalCharacterIdentifier;
+
+ @Nullable
+ private OnChipRemoveListener mOnChipRemoveListener;
+ private List mChipsToRemove = new ArrayList<>();
+ private boolean mIgnoreTextChangedEvents;
+ private int mTextChangedStart;
+ private int mTextChangedEnd;
+ private boolean mIsPasteEvent;
+
+ // Measurement
+ private boolean mMeasured;
+
+ // Layout
+ private boolean mLayoutComplete;
+
+ public NachoTextView(Context context) {
+ super(context);
+ init(null);
+ }
+
+ public NachoTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs);
+ }
+
+ public NachoTextView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs);
+ }
+
+ private void init(@Nullable AttributeSet attrs) {
+ Context context = getContext();
+
+ if (attrs != null) {
+ TypedArray attributes = context.getTheme().obtainStyledAttributes(
+ attrs,
+ R.styleable.NachoTextView,
+ 0,
+ R.style.DefaultChipSuggestionTextView);
+
+ try {
+ mChipHorizontalSpacing = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipHorizontalSpacing, -1);
+ mChipBackground = attributes.getColorStateList(R.styleable.NachoTextView_chipBackground);
+ mChipCornerRadius = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipCornerRadius, -1);
+ mChipTextColor = attributes.getColor(R.styleable.NachoTextView_chipTextColor, Color.TRANSPARENT);
+ mChipTextSize = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipTextSize, -1);
+ mChipHeight = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipHeight, -1);
+ mChipVerticalSpacing = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipVerticalSpacing, -1);
+ } finally {
+ attributes.recycle();
+ }
+ }
+
+ mDefaultPaddingTop = getPaddingTop();
+ mDefaultPaddingBottom = getPaddingBottom();
+
+ singleTapDetector = new GestureDetector(getContext(), new SingleTapListener());
+
+ setImeOptions(EditorInfo.IME_FLAG_NO_FULLSCREEN);
+ addTextChangedListener(this);
+ setChipTokenizer(new SpanChipTokenizer<>(context, new ChipSpanChipCreator(), ChipSpan.class));
+ setChipTerminatorHandler(new DefaultChipTerminatorHandler());
+ setOnItemClickListener(this);
+
+ updatePadding();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ if (!mMeasured && getWidth() > 0) {
+ // Refresh the tokenizer for width changes
+ invalidateChips();
+ mMeasured = true;
+ }
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+
+ if (!mLayoutComplete) {
+ invalidateChips();
+ mLayoutComplete = true;
+ }
+ }
+
+ /**
+ * Updates the padding based on whether or not any chips are present to avoid the view from changing heights when chips are inserted/deleted.
+ * Extra padding is added when there are no chips. When there are chips the padding is reverted to its defaults. This only affects top and bottom
+ * padding because the chips only affect the height of the view.
+ */
+ private void updatePadding() {
+ if (mChipHeight != -1) {
+ boolean chipsArePresent = !getAllChips().isEmpty();
+ if (!chipsArePresent && mUsingDefaultPadding) {
+ mUsingDefaultPadding = false;
+ Paint paint = getPaint();
+ Paint.FontMetricsInt fm = paint.getFontMetricsInt();
+ int textHeight = fm.descent - fm.ascent;
+ // Calculate how tall the view should be if there were chips
+ int newTextHeight = mChipHeight + (mChipVerticalSpacing != -1 ? mChipVerticalSpacing : 0);
+ // We need to add half our missing height above and below the text by increasing top and bottom padding
+ int paddingAdjustment = (newTextHeight - textHeight) / 2;
+ super.setPadding(getPaddingLeft(), mDefaultPaddingTop + paddingAdjustment, getPaddingRight(), mDefaultPaddingBottom + paddingAdjustment);
+ } else if (chipsArePresent && !mUsingDefaultPadding) {
+ // If there are chips we can revert to default padding
+ mUsingDefaultPadding = true;
+ super.setPadding(getPaddingLeft(), mDefaultPaddingTop, getPaddingRight(), mDefaultPaddingBottom);
+ }
+ }
+ }
+
+ /**
+ * Sets the padding on this View. The left and right padding will be handled as they normally would in a TextView. The top and bottom padding passed
+ * here will be the padding that is used when there are one or more chips in the text view. When there are no chips present, the padding will be
+ * increased to make sure the overall height of the text view stays the same, since chips take up more vertical space than plain text.
+ *
+ * @param left the left padding in pixels
+ * @param top the top padding in pixels
+ * @param right the right padding in pixels
+ * @param bottom the bottom padding in pixels
+ */
+ @Override
+ public void setPadding(int left, int top, int right, int bottom) {
+ // Call the super method so that left and right padding are updated
+ // top and bottom padding will be handled in updatePadding()
+ super.setPadding(left, top, right, bottom);
+ mDefaultPaddingTop = top;
+ mDefaultPaddingBottom = bottom;
+ updatePadding();
+ }
+
+ public int getChipHorizontalSpacing() {
+ return mChipHorizontalSpacing;
+ }
+
+ public void setChipHorizontalSpacing(@DimenRes int chipHorizontalSpacingResId) {
+ mChipHorizontalSpacing = getContext().getResources().getDimensionPixelSize(chipHorizontalSpacingResId);
+ invalidateChips();
+ }
+
+ public ColorStateList getChipBackground() {
+ return mChipBackground;
+ }
+
+ public void setChipBackgroundResource(@ColorRes int chipBackgroundResId) {
+ setChipBackground(getContext().getColorStateList(chipBackgroundResId));
+ }
+
+ public void setChipBackground(ColorStateList chipBackground) {
+ mChipBackground = chipBackground;
+ invalidateChips();
+ }
+
+ /**
+ * @return The chip background corner radius value, in pixels.
+ */
+ @Dimension
+ public int getChipCornerRadius() {
+ return mChipCornerRadius;
+ }
+
+ /**
+ * Sets the chip background corner radius.
+ *
+ * @param chipCornerRadiusResId The dimension resource with the corner radius value.
+ */
+ public void setChipCornerRadiusResource(@DimenRes int chipCornerRadiusResId) {
+ setChipCornerRadius(getContext().getResources().getDimensionPixelSize(chipCornerRadiusResId));
+ }
+
+ /**
+ * Sets the chip background corner radius.
+ *
+ * @param chipCornerRadius The corner radius value, in pixels.
+ */
+ public void setChipCornerRadius(@Dimension int chipCornerRadius) {
+ mChipCornerRadius = chipCornerRadius;
+ invalidateChips();
+ }
+
+
+ public int getChipTextColor() {
+ return mChipTextColor;
+ }
+
+ public void setChipTextColorResource(@ColorRes int chipTextColorResId) {
+ setChipTextColor(getContext().getColor(chipTextColorResId));
+ }
+
+ public void setChipTextColor(@ColorInt int chipTextColor) {
+ mChipTextColor = chipTextColor;
+ invalidateChips();
+ }
+
+ public int getChipTextSize() {
+ return mChipTextSize;
+ }
+
+ public void setChipTextSize(@DimenRes int chipTextSizeResId) {
+ mChipTextSize = getContext().getResources().getDimensionPixelSize(chipTextSizeResId);
+ invalidateChips();
+ }
+
+ public int getChipHeight() {
+ return mChipHeight;
+ }
+
+ public void setChipHeight(@DimenRes int chipHeightResId) {
+ mChipHeight = getContext().getResources().getDimensionPixelSize(chipHeightResId);
+ invalidateChips();
+ }
+
+ public int getChipVerticalSpacing() {
+ return mChipVerticalSpacing;
+ }
+
+ public void setChipVerticalSpacing(@DimenRes int chipVerticalSpacingResId) {
+ mChipVerticalSpacing = getContext().getResources().getDimensionPixelSize(chipVerticalSpacingResId);
+ invalidateChips();
+ }
+
+ @Nullable
+ public ChipTokenizer getChipTokenizer() {
+ return mChipTokenizer;
+ }
+
+ /**
+ * Sets the {@link ChipTokenizer} to be used by this ChipSuggestionTextView.
+ * Note that a Tokenizer set here will override any Tokenizer set by {@link #setTokenizer(Tokenizer)}
+ *
+ * @param chipTokenizer the {@link ChipTokenizer} to set
+ */
+ public void setChipTokenizer(@Nullable ChipTokenizer chipTokenizer) {
+ mChipTokenizer = chipTokenizer;
+ if (mChipTokenizer != null) {
+ setTokenizer(new ChipTokenizerWrapper(mChipTokenizer));
+ } else {
+ setTokenizer(null);
+ }
+ invalidateChips();
+ }
+
+ public void setOnChipClickListener(@Nullable OnChipClickListener onChipClickListener) {
+ mOnChipClickListener = onChipClickListener;
+ }
+
+ public void setOnChipRemoveListener(@Nullable OnChipRemoveListener onChipRemoveListener) {
+ mOnChipRemoveListener = onChipRemoveListener;
+ }
+
+ public void setChipTerminatorHandler(@Nullable ChipTerminatorHandler chipTerminatorHandler) {
+ mChipTerminatorHandler = chipTerminatorHandler;
+ }
+
+ public void setNachoValidator(@Nullable NachoValidator nachoValidator) {
+ mNachoValidator = nachoValidator;
+ }
+
+ /**
+ * @see ChipTerminatorHandler#setChipTerminators(Map)
+ */
+ public void setChipTerminators(@Nullable Map chipTerminators) {
+ if (mChipTerminatorHandler != null) {
+ mChipTerminatorHandler.setChipTerminators(chipTerminators);
+ }
+ }
+
+ /**
+ * @see ChipTerminatorHandler#addChipTerminator(char, int)
+ */
+ public void addChipTerminator(char character, int behavior) {
+ if (mChipTerminatorHandler != null) {
+ mChipTerminatorHandler.addChipTerminator(character, behavior);
+ }
+ }
+
+ /**
+ * @see ChipTerminatorHandler#setPasteBehavior(int)
+ */
+ public void setPasteBehavior(int pasteBehavior) {
+ if (mChipTerminatorHandler != null) {
+ mChipTerminatorHandler.setPasteBehavior(pasteBehavior);
+ }
+ }
+
+ /**
+ * Sets the {@link IllegalCharacterIdentifier} that will identify characters that should
+ * not show up in the field when typed (i.e. they will be deleted as soon as they are entered).
+ * If a character is listed as both a chip terminator character and an illegal character,
+ * it will be treated as an illegal character.
+ *
+ * @param illegalCharacterIdentifier the identifier to use
+ */
+ public void setIllegalCharacterIdentifier(@Nullable IllegalCharacterIdentifier illegalCharacterIdentifier) {
+ this.illegalCharacterIdentifier = illegalCharacterIdentifier;
+ }
+
+ /**
+ * Applies any updated configuration parameters to any existing chips and all future chips in the text view.
+ *
+ * @see ChipTokenizer#applyConfiguration(Editable, ChipConfiguration)
+ */
+ public void invalidateChips() {
+ beginUnwatchedTextChange();
+
+ if (mChipTokenizer != null) {
+ Editable text = getText();
+ int availableWidth = getWidth() - getCompoundPaddingLeft() - getCompoundPaddingRight();
+ ChipConfiguration configuration = new ChipConfiguration(
+ mChipHorizontalSpacing,
+ mChipBackground,
+ mChipCornerRadius,
+ mChipTextColor,
+ mChipTextSize,
+ mChipHeight,
+ mChipVerticalSpacing,
+ availableWidth);
+
+ mChipTokenizer.applyConfiguration(text, configuration);
+ }
+
+ endUnwatchedTextChange();
+ }
+
+ /**
+ * Enables editing chips on touch events. When a touch event occurs, the touched chip will be put in editing mode. To later disable this behavior
+ * call {@link #disableEditChipOnTouch()}.
+ *
+ * Note: If an {@link OnChipClickListener} is set it's behavior will override the behavior described here if it's
+ * {@link OnChipClickListener#onChipClick(Chip, MotionEvent)} method returns true. If that method returns false, the touched chip will be put
+ * in editing mode as expected.
+ *
+ *
+ * @param moveChipToEnd if true, the chip will also be moved to the end of the text when it is put in editing mode
+ * @param chipifyUnterminatedTokens if true, all unterminated tokens will be chipified before the touched chip is put in editing mode
+ * @see #disableEditChipOnTouch()
+ */
+ public void enableEditChipOnTouch(boolean moveChipToEnd, boolean chipifyUnterminatedTokens) {
+ mEditChipOnTouchEnabled = true;
+ mMoveChipToEndOnEdit = moveChipToEnd;
+ mChipifyUnterminatedTokensOnEdit = chipifyUnterminatedTokens;
+ }
+
+ /**
+ * Disables editing chips on touch events. To re-enable this behavior call {@link #enableEditChipOnTouch(boolean, boolean)}.
+ *
+ * @see #enableEditChipOnTouch(boolean, boolean)
+ */
+ public void disableEditChipOnTouch() {
+ mEditChipOnTouchEnabled = false;
+ }
+
+ /**
+ * Puts the provided Chip in editing mode (i.e. reverts it to an unchipified token whose text can be edited).
+ *
+ * @param chip the chip to edit
+ * @param moveChipToEnd if true, the chip will also be moved to the end of the text
+ */
+ public void setEditingChip(Chip chip, boolean moveChipToEnd) {
+ if (mChipTokenizer == null) {
+ return;
+ }
+
+ beginUnwatchedTextChange();
+
+ Editable text = getText();
+ if (moveChipToEnd) {
+ // Move the chip text to the end of the text
+ text.append(chip.getText());
+ // Delete the existing chip
+ mChipTokenizer.deleteChipAndPadding(chip, text);
+ // Move the cursor to the end of the text
+ setSelection(text.length());
+ } else {
+ int chipStart = mChipTokenizer.findChipStart(chip, text);
+ mChipTokenizer.revertChipToToken(chip, text);
+ setSelection(mChipTokenizer.findTokenEnd(text, chipStart));
+ }
+
+ endUnwatchedTextChange();
+ }
+
+ @Override
+ public boolean onTouchEvent(@NonNull MotionEvent event) {
+ boolean wasHandled = false;
+ clearChipStates();
+ Chip touchedChip = findTouchedChip(event);
+ if (touchedChip != null && isFocused() && singleTapDetector.onTouchEvent(event)) {
+ touchedChip.setState(View.PRESSED_SELECTED_STATE_SET);
+ if (onChipClicked(touchedChip)) {
+ wasHandled = true;
+ }
+ if (mOnChipClickListener != null) {
+ mOnChipClickListener.onChipClick(touchedChip, event);
+ }
+ }
+
+ // Getting NullPointerException inside Editor.updateFloatingToolbarVisibility (Editor.java:1520)
+ // primarily seen in Samsung Nougat devices.
+ boolean superOnTouch = false;
+ try {
+ superOnTouch = super.onTouchEvent(event);
+ } catch (NullPointerException e) {
+ Log.w("Nacho", String.format("Error during touch event of type [%d]", event.getAction()), e);
+ // can't handle or reproduce, but will monitor the error
+ }
+
+ return wasHandled || superOnTouch;
+ }
+
+ @Nullable
+ private Chip findTouchedChip(MotionEvent event) {
+ if (mChipTokenizer == null) {
+ return null;
+ }
+
+ Editable text = getText();
+ int offset = getOffsetForPosition(event.getX(), event.getY());
+ List chips = getAllChips();
+ for (Chip chip : chips) {
+ int chipStart = mChipTokenizer.findChipStart(chip, text);
+ int chipEnd = mChipTokenizer.findChipEnd(chip, text); // This is actually the index of the character just past the end of the chip
+ // When a touch event occurs getOffsetForPosition will either return the index of the first character of the span or the index of the
+ // character one past the end of the span
+ // This matches up perfectly with chipStart and chipEnd so we can just directly compare them...
+ if (chipStart <= offset && offset <= chipEnd) {
+ float startX = getXForIndex(chipStart);
+ float endX = getXForIndex(chipEnd - 1);
+ float eventX = event.getX();
+ // ... however, when comparing the x coordinate we need to use (chipEnd - 1) because chipEnd will give us the x coordinate of the
+ // beginning of the next span since that is actually what chipEnd holds. We want the x coordinate of the end of the current span so
+ // we use (chipEnd - 1)
+ if (startX <= eventX && eventX <= endX) {
+ return chip;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Implement this method to handle chip clicked events.
+ *
+ * @param chip the chip that was clicked
+ * @return true if the event was handled, otherwise false
+ */
+ public boolean onChipClicked(Chip chip) {
+ boolean wasHandled = false;
+ if (mEditChipOnTouchEnabled) {
+ if (mChipifyUnterminatedTokensOnEdit) {
+ chipifyAllUnterminatedTokens();
+ }
+ setEditingChip(chip, mMoveChipToEndOnEdit);
+ wasHandled = true;
+ }
+ return wasHandled;
+ }
+
+ private float getXForIndex(int index) {
+ Layout layout = getLayout();
+ return layout.getPrimaryHorizontal(index);
+ }
+
+ private void clearChipStates() {
+ for (Chip chip : getAllChips()) {
+ chip.setState(View.EMPTY_STATE_SET);
+ }
+ }
+
+ @Override
+ public boolean onTextContextMenuItem(int id) {
+ int start = getSelectionStart();
+ int end = getSelectionEnd();
+ switch (id) {
+ case android.R.id.cut:
+ try {
+ setClipboardData(ClipData.newPlainText(null, getTextWithPlainTextSpans(start, end)));
+ } catch (StringIndexOutOfBoundsException e) {
+ throw new StringIndexOutOfBoundsException(
+ String.format(
+ "%s \nError cutting text index [%s, %s] for text [%s] and substring [%s]",
+ e.getMessage(),
+ start,
+ end,
+ getText().toString(),
+ getText().subSequence(start, end)));
+ }
+ getText().delete(getSelectionStart(), getSelectionEnd());
+ return true;
+ case android.R.id.copy:
+ try {
+ setClipboardData(ClipData.newPlainText(null, getTextWithPlainTextSpans(start, end)));
+ } catch (StringIndexOutOfBoundsException e) {
+ throw new StringIndexOutOfBoundsException(
+ String.format(
+ "%s \nError copying text index [%s, %s] for text [%s] and substring [%s]",
+ e.getMessage(),
+ start,
+ end,
+ getText().toString(),
+ getText().subSequence(start, end)));
+ }
+ return true;
+ case android.R.id.paste:
+ mIsPasteEvent = true;
+ boolean returnValue = super.onTextContextMenuItem(id);
+ mIsPasteEvent = false;
+ return returnValue;
+ default:
+ return super.onTextContextMenuItem(id);
+ }
+ }
+
+ private void setClipboardData(ClipData clip) {
+ ClipboardManager clipboard = (ClipboardManager) getContext().
+ getSystemService(Context.CLIPBOARD_SERVICE);
+ clipboard.setPrimaryClip(clip);
+ }
+
+ /**
+ * If a {@link android.widget.AutoCompleteTextView.Validator Validator} was set, this method will validate the entire text.
+ * (Overrides the superclass method which only validates the current token)
+ */
+ @Override
+ public void performValidation() {
+ if (mNachoValidator == null || mChipTokenizer == null) {
+ super.performValidation();
+ return;
+ }
+
+ CharSequence text = getText();
+ if (!TextUtils.isEmpty(text) && !mNachoValidator.isValid(mChipTokenizer, text)) {
+ setRawText(mNachoValidator.fixText(mChipTokenizer, text));
+ }
+ }
+
+ /**
+ * From the point this method is called to when {@link #endUnwatchedTextChange()} is called, all TextChanged events will be ignored
+ */
+ private void beginUnwatchedTextChange() {
+ mIgnoreTextChangedEvents = true;
+ }
+
+ /**
+ * After this method is called TextChanged events will resume being handled.
+ * This method also calls {@link #updatePadding()} in case the unwatched changed created/destroyed chips
+ */
+ private void endUnwatchedTextChange() {
+ updatePadding();
+ mIgnoreTextChangedEvents = false;
+ }
+
+ /**
+ * Sets the contents of this text view without performing any processing (nothing will be chipified, no characters will be removed etc.)
+ *
+ * @param text the text to set
+ */
+ private void setRawText(CharSequence text) {
+ beginUnwatchedTextChange();
+ super.setText(text);
+ endUnwatchedTextChange();
+ }
+
+ /**
+ * Sets the contents of this text view to contain the provided list of strings. The text view will be cleared then each string in the list will
+ * be chipified and appended to the text.
+ *
+ * @param chipValues the list of strings to chipify and set as the contents of the text view or null to clear the text view
+ */
+ public void setText(@Nullable List chipValues) {
+ if (mChipTokenizer == null) {
+ return;
+ }
+ beginUnwatchedTextChange();
+
+ Editable text = getText();
+ text.clear();
+
+ if (chipValues != null) {
+ for (String chipValue : chipValues) {
+ CharSequence chippedText = mChipTokenizer.terminateToken(chipValue, null);
+ text.append(chippedText);
+ }
+ }
+ setSelection(text.length());
+
+ endUnwatchedTextChange();
+ }
+
+ public void setTextWithChips(@Nullable List chips) {
+ if (mChipTokenizer == null) {
+ return;
+ }
+ beginUnwatchedTextChange();
+
+ Editable text = getText();
+ text.clear();
+
+ if (chips != null) {
+ for (ChipInfo chipInfo : chips) {
+ CharSequence chippedText = mChipTokenizer.terminateToken(chipInfo.getText(), chipInfo.getData());
+ text.append(chippedText);
+ }
+ }
+ setSelection(text.length());
+ endUnwatchedTextChange();
+ }
+
+ @Override
+ public void onItemClick(AdapterView> adapterView, View view, int position, long id) {
+ if (mChipTokenizer == null) {
+ return;
+ }
+ Adapter adapter = getAdapter();
+ if (adapter == null) {
+ return;
+ }
+ beginUnwatchedTextChange();
+
+ Object data = getDataForSuggestion(adapter, position);
+ CharSequence text = getFilter().convertResultToString(adapter.getItem(position));
+
+ clearComposingText();
+ int end = getSelectionEnd();
+ Editable editable = getText();
+ int start = mChipTokenizer.findTokenStart(editable, end);
+
+ // guard against java.lang.StringIndexOutOfBoundsException
+ start = Math.min(Math.max(0, start), editable.length());
+ end = Math.min(Math.max(0, end), editable.length());
+ if (end < start) {
+ end = start;
+ }
+
+ editable.replace(start, end, mChipTokenizer.terminateToken(text, data));
+
+ endUnwatchedTextChange();
+ }
+
+ /**
+ * Returns a object that will be associated with a chip that is about to be created for the item at {@code position} in {@code adapter} because that
+ * item was just tapped.
+ *
+ * @param adapter the adapter supplying the suggestions
+ * @param position the position of the suggestion that was tapped
+ * @return the data object
+ */
+ protected Object getDataForSuggestion(@NonNull Adapter adapter, int position) {
+ return adapter.getItem(position);
+ }
+
+ /**
+ * If there is a ChipTokenizer set, this method will do nothing. Instead we wait until the OnItemClickListener is triggered to actually perform
+ * the text replacement so we can also associate the suggestion data with it.
+ *
+ * If there is no ChipTokenizer set, we call through to the super method.
+ *
+ * @param text the text to be chipified
+ */
+ @Override
+ protected void replaceText(CharSequence text) {
+ // If we have a ChipTokenizer, this will be handled by our OnItemClickListener so we can do nothing here.
+ // If we don't have a ChipTokenizer, we'll use the default behavior
+ if (mChipTokenizer == null) {
+ super.replaceText(text);
+ }
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ if (mIgnoreTextChangedEvents) {
+ return;
+ }
+
+ mTextChangedStart = start;
+ mTextChangedEnd = start + after;
+
+ // Check for backspace
+ if (mChipTokenizer != null) {
+ if (count > 0 && after < count) {
+ int end = start + count;
+ Editable message = getText();
+ Chip[] chips = mChipTokenizer.findAllChips(start, end, message);
+
+ for (Chip chip : chips) {
+ int spanStart = mChipTokenizer.findChipStart(chip, message);
+ int spanEnd = mChipTokenizer.findChipEnd(chip, message);
+ if ((spanStart < end) && (spanEnd > start)) {
+ // Add to remove list
+ mChipsToRemove.add(chip);
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onTextChanged(@NonNull CharSequence textChanged, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(Editable message) {
+ if (mIgnoreTextChangedEvents) {
+ return;
+ }
+
+ // Avoid triggering text changed events from changes we make in this method
+ beginUnwatchedTextChange();
+
+ // Handle backspace key
+ if (mChipTokenizer != null) {
+ Iterator iterator = mChipsToRemove.iterator();
+ while (iterator.hasNext()) {
+ Chip chip = iterator.next();
+ iterator.remove();
+ mChipTokenizer.deleteChip(chip, message);
+ if (mOnChipRemoveListener != null) {
+ mOnChipRemoveListener.onChipRemove(chip);
+ }
+ }
+ }
+
+ // Handle an illegal or chip terminator character
+ if (message.length() >= mTextChangedEnd && message.length() >= mTextChangedStart) {
+ handleTextChanged(mTextChangedStart, mTextChangedEnd);
+ }
+
+ endUnwatchedTextChange();
+ }
+
+ private void handleTextChanged(int start, int end) {
+ if (start == end) {
+ // If start and end are the same there was text deleted, so this type of event can be ignored
+ return;
+ }
+
+ // First remove any illegal characters
+ Editable text = getText();
+ CharSequence subText = text.subSequence(start, end);
+ CharSequence withoutIllegalCharacters = removeIllegalCharacters(subText);
+
+ // Check if illegal characters were found
+ if (withoutIllegalCharacters.length() < subText.length()) {
+ text.replace(start, end, withoutIllegalCharacters);
+ end = start + withoutIllegalCharacters.length();
+ clearComposingText();
+ }
+
+ if (start == end) {
+ // If start and end are the same here, it means only illegal characters were inserted so there's nothing left to do
+ return;
+ }
+
+ // Then handle chip terminator characters
+ if (mChipTokenizer != null && mChipTerminatorHandler != null) {
+ int newSelectionIndex = mChipTerminatorHandler.findAndHandleChipTerminators(mChipTokenizer, getText(), start, end, mIsPasteEvent);
+ if (newSelectionIndex > 0) {
+ setSelection(newSelectionIndex);
+ }
+ }
+ }
+
+ private CharSequence removeIllegalCharacters(CharSequence text) {
+ StringBuilder newText = new StringBuilder();
+
+ for (int i = 0; i < text.length(); i++) {
+ char theChar = text.charAt(i);
+ if (!isIllegalCharacter(theChar)) {
+ newText.append(theChar);
+ }
+ }
+
+ return newText;
+ }
+
+ private boolean isIllegalCharacter(char character) {
+ if (illegalCharacterIdentifier != null) {
+ return illegalCharacterIdentifier.isCharacterIllegal(character);
+ }
+ return false;
+ }
+
+ /**
+ * Chipifies all existing plain text in the field
+ */
+ public void chipifyAllUnterminatedTokens() {
+ beginUnwatchedTextChange();
+ chipifyAllUnterminatedTokens(getText());
+ endUnwatchedTextChange();
+ }
+
+ private void chipifyAllUnterminatedTokens(Editable text) {
+ if (mChipTokenizer != null) {
+ mChipTokenizer.terminateAllTokens(text);
+ }
+ }
+
+ /**
+ * Replaces the text from start (inclusive) to end (exclusive) with a chip
+ * containing the same text
+ *
+ * @param start the index of the first character to replace
+ * @param end one more than the index of the last character to replace
+ */
+ public void chipify(int start, int end) {
+ beginUnwatchedTextChange();
+ chipify(start, end, getText(), null);
+ endUnwatchedTextChange();
+ }
+
+ private void chipify(int start, int end, Editable text, Object data) {
+ if (mChipTokenizer != null) {
+ CharSequence textToChip = text.subSequence(start, end);
+ CharSequence chippedText = mChipTokenizer.terminateToken(textToChip, data);
+ text.replace(start, end, chippedText);
+ }
+ }
+
+ private CharSequence getTextWithPlainTextSpans(int start, int end) {
+ Editable editable = getText();
+ String selectedText = editable.subSequence(start, end).toString();
+
+ if (mChipTokenizer != null) {
+ List chips = Arrays.asList(mChipTokenizer.findAllChips(start, end, editable));
+ Collections.reverse(chips);
+ for (Chip chip : chips) {
+ String chipText = chip.getText().toString();
+ int chipStart = mChipTokenizer.findChipStart(chip, editable) - start;
+ int chipEnd = mChipTokenizer.findChipEnd(chip, editable) - start;
+ selectedText = selectedText.substring(0, chipStart) + chipText + selectedText.substring(chipEnd, selectedText.length());
+ }
+ }
+ return selectedText;
+ }
+
+ /**
+ * @return all of the chips currently in the text view - this does not include any unchipped text
+ */
+ @NonNull
+ public List getAllChips() {
+ Editable text = getText();
+ return mChipTokenizer != null ? Arrays.asList(mChipTokenizer.findAllChips(0, text.length(), text)) : new ArrayList();
+ }
+
+ /**
+ * Returns a List of the string values of all the chips in the text (obtained through {@link Chip#getText()}).
+ * This does not include the text of any unterminated tokens.
+ *
+ * @return the List of chip values
+ */
+ @NonNull
+ public List getChipValues() {
+ List chipValues = new ArrayList<>();
+
+ List chips = getAllChips();
+ for (Chip chip : chips) {
+ chipValues.add(chip.getText().toString());
+ }
+
+ return chipValues;
+ }
+
+ /**
+ * Returns a List of the string values of all the tokens (unchipped text) in the text
+ * (obtained through {@link ChipTokenizer#findAllTokens(CharSequence)}). This does not include any chipped text.
+ *
+ * @return the List of token values
+ */
+ @NonNull
+ public List getTokenValues() {
+ List tokenValues = new ArrayList<>();
+
+ if (mChipTokenizer != null) {
+ Editable text = getText();
+ List> unterminatedTokenIndexes = mChipTokenizer.findAllTokens(text);
+ for (Pair indexes : unterminatedTokenIndexes) {
+ String tokenValue = text.subSequence(indexes.first, indexes.second).toString();
+ tokenValues.add(tokenValue);
+ }
+ }
+
+ return tokenValues;
+ }
+
+ /**
+ * Returns a combination of the chip values and token values in the text.
+ *
+ * @return the List of all chip and token values
+ * @see #getChipValues()
+ * @see #getTokenValues()
+ */
+ @NonNull
+ public List getChipAndTokenValues() {
+ List chipAndTokenValues = new ArrayList<>();
+ chipAndTokenValues.addAll(getChipValues());
+ chipAndTokenValues.addAll(getTokenValues());
+ return chipAndTokenValues;
+ }
+
+ @Override
+ public String toString() {
+ try {
+ return getTextWithPlainTextSpans(0, getText().length()).toString();
+ } catch (ClassCastException ex) { // Exception is thrown by cast in getText() on some LG devices
+ return super.toString();
+ } catch (StringIndexOutOfBoundsException e) {
+ throw new StringIndexOutOfBoundsException(String.format("%s \nError converting toString() [%s]", e.getMessage(), getText().toString()));
+ }
+ }
+
+ private class ChipTokenizerWrapper implements Tokenizer {
+
+ @NonNull
+ private ChipTokenizer mChipTokenizer;
+
+ public ChipTokenizerWrapper(@NonNull ChipTokenizer chipTokenizer) {
+ mChipTokenizer = chipTokenizer;
+ }
+
+ @Override
+ public int findTokenStart(CharSequence text, int cursor) {
+ return mChipTokenizer.findTokenStart(text, cursor);
+ }
+
+ @Override
+ public int findTokenEnd(CharSequence text, int cursor) {
+ return mChipTokenizer.findTokenEnd(text, cursor);
+ }
+
+ @Override
+ public CharSequence terminateToken(CharSequence text) {
+ return mChipTokenizer.terminateToken(text, null);
+ }
+ }
+
+ public interface OnChipClickListener {
+
+ /**
+ * Called when a chip in this TextView is touched. This callback is triggered by the {@link MotionEvent#ACTION_UP} event.
+ *
+ * @param chip the {@link Chip} that was touched
+ * @param event the {@link MotionEvent} that caused the touch
+ */
+ void onChipClick(Chip chip, MotionEvent event);
+ }
+
+ public interface OnChipRemoveListener {
+
+ /**
+ * Called when a chip in this TextView is removed
+ *
+ * @param chip the {@link Chip} that was removed
+ */
+ void onChipRemove(Chip chip);
+ }
+
+ private class SingleTapListener extends GestureDetector.SimpleOnGestureListener {
+
+ /**
+ * @param e the {@link MotionEvent} passed to the GestureDetector
+ * @return true if singleTapUp (click) was detected
+ */
+ @Override
+ public boolean onSingleTapUp(MotionEvent e) {
+ return true;
+ }
+ }
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java
new file mode 100644
index 000000000..f07707cd3
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java
@@ -0,0 +1,29 @@
+package com.hootsuite.nachos.chip;
+
+import androidx.annotation.Nullable;
+
+public interface Chip {
+
+ /**
+ * @return the text represented by this Chip
+ */
+ CharSequence getText();
+
+ /**
+ * @return the data associated with this Chip or null if no data is associated with it
+ */
+ @Nullable
+ Object getData();
+
+ /**
+ * @return the width of the Chip or -1 if the Chip hasn't been given the chance to calculate its width
+ */
+ int getWidth();
+
+ /**
+ * Sets the UI state.
+ *
+ * @param stateSet one of the state constants in {@link android.view.View}
+ */
+ void setState(int[] stateSet);
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipCreator.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipCreator.java
new file mode 100644
index 000000000..c38f5cff1
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipCreator.java
@@ -0,0 +1,44 @@
+package com.hootsuite.nachos.chip;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.hootsuite.nachos.ChipConfiguration;
+
+/**
+ * Interface to allow the creation and configuration of chips
+ *
+ * @param The type of {@link Chip} that the implementation will create/configure
+ */
+public interface ChipCreator {
+
+ /**
+ * Creates a chip from the given context and text. Use this method when creating a brand new chip from a piece of text.
+ *
+ * @param context the {@link Context} to use to initialize the chip
+ * @param text the text the Chip should represent
+ * @param data the data to associate with the Chip, or null to associate no data
+ * @return the created chip
+ */
+ C createChip(@NonNull Context context, @NonNull CharSequence text, @Nullable Object data);
+
+ /**
+ * Creates a chip from the given context and existing chip. Use this method when recreating a chip from an existing one.
+ *
+ * @param context the {@link Context} to use to initialize the chip
+ * @param existingChip the chip that the created chip should be based on
+ * @return the created chip
+ */
+ C createChip(@NonNull Context context, @NonNull C existingChip);
+
+ /**
+ * Applies the given {@link ChipConfiguration} to the given {@link Chip}. Use this method to customize the appearance/behavior of a chip before
+ * adding it to the text.
+ *
+ * @param chip the chip to configure
+ * @param chipConfiguration the configuration to apply to the chip
+ */
+ void configureChip(@NonNull C chip, @NonNull ChipConfiguration chipConfiguration);
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipInfo.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipInfo.java
new file mode 100644
index 000000000..69b8ea4c4
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipInfo.java
@@ -0,0 +1,20 @@
+package com.hootsuite.nachos.chip;
+
+public class ChipInfo {
+
+ private final CharSequence mText;
+ private final Object mData;
+
+ public ChipInfo(CharSequence text, Object data) {
+ this.mText = text;
+ this.mData = data;
+ }
+
+ public CharSequence getText() {
+ return mText;
+ }
+
+ public Object getData() {
+ return mData;
+ }
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java
new file mode 100644
index 000000000..e3cc57922
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java
@@ -0,0 +1,510 @@
+package com.hootsuite.nachos.chip;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.text.style.ImageSpan;
+
+import androidx.annotation.Dimension;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.joinmastodon.android.R;
+
+/**
+ * A Span that displays text and an optional icon inside of a material design chip. The chip's dimensions, colors etc. can be extensively customized
+ * through the various setter methods available in this class.
+ * The basic structure of the chip is the following:
+ * For chips with the icon on right:
+ *
+ */
+public class ChipSpan extends ImageSpan implements Chip {
+
+ private static final float SCALE_PERCENT_OF_CHIP_HEIGHT = 0.70f;
+ private static final boolean ICON_ON_LEFT_DEFAULT = true;
+
+ private int[] mStateSet = new int[]{};
+
+ private String mEllipsis;
+
+ private ColorStateList mDefaultBackgroundColor;
+ private ColorStateList mBackgroundColor;
+ private int mTextColor;
+ private int mCornerRadius = -1;
+ private int mIconBackgroundColor;
+
+ private int mTextSize = -1;
+ private int mPaddingEdgePx;
+ private int mPaddingBetweenImagePx;
+ private int mLeftMarginPx;
+ private int mRightMarginPx;
+ private int mMaxAvailableWidth = -1;
+
+ private CharSequence mText;
+ private String mTextToDraw;
+
+ private Drawable mIcon;
+ private boolean mShowIconOnLeft = ICON_ON_LEFT_DEFAULT;
+
+ private int mChipVerticalSpacing = 0;
+ private int mChipHeight = -1;
+ private int mChipWidth = -1;
+ private int mIconWidth;
+
+ private int mCachedSize = -1;
+
+ private Object mData;
+
+ /**
+ * Constructs a new ChipSpan.
+ *
+ * @param context a {@link Context} that will be used to retrieve default configurations from resource files
+ * @param text the text for the ChipSpan to display
+ * @param icon an optional icon (can be {@code null}) for the ChipSpan to display
+ */
+ public ChipSpan(@NonNull Context context, @NonNull CharSequence text, @Nullable Drawable icon, Object data) {
+ super(icon);
+ mIcon = icon;
+ mText = text;
+ mTextToDraw = mText.toString();
+
+ mEllipsis = context.getString(R.string.chip_ellipsis);
+
+ mDefaultBackgroundColor = context.getColorStateList(R.color.chip_material_background);
+ mBackgroundColor = mDefaultBackgroundColor;
+
+ mTextColor = context.getColor(R.color.chip_default_text_color);
+ mIconBackgroundColor = context.getColor(R.color.chip_default_icon_background_color);
+
+ Resources resources = context.getResources();
+ mPaddingEdgePx = resources.getDimensionPixelSize(R.dimen.chip_default_padding_edge);
+ mPaddingBetweenImagePx = resources.getDimensionPixelSize(R.dimen.chip_default_padding_between_image);
+ mLeftMarginPx = resources.getDimensionPixelSize(R.dimen.chip_default_left_margin);
+ mRightMarginPx = resources.getDimensionPixelSize(R.dimen.chip_default_right_margin);
+
+ mData = data;
+ }
+
+ /**
+ * Copy constructor to recreate a ChipSpan from an existing one
+ *
+ * @param context a {@link Context} that will be used to retrieve default configurations from resource files
+ * @param chipSpan the ChipSpan to copy
+ */
+ public ChipSpan(@NonNull Context context, @NonNull ChipSpan chipSpan) {
+ this(context, chipSpan.getText(), chipSpan.getDrawable(), chipSpan.getData());
+
+ mDefaultBackgroundColor = chipSpan.mDefaultBackgroundColor;
+ mTextColor = chipSpan.mTextColor;
+ mIconBackgroundColor = chipSpan.mIconBackgroundColor;
+ mCornerRadius = chipSpan.mCornerRadius;
+
+ mTextSize = chipSpan.mTextSize;
+ mPaddingEdgePx = chipSpan.mPaddingEdgePx;
+ mPaddingBetweenImagePx = chipSpan.mPaddingBetweenImagePx;
+ mLeftMarginPx = chipSpan.mLeftMarginPx;
+ mRightMarginPx = chipSpan.mRightMarginPx;
+ mMaxAvailableWidth = chipSpan.mMaxAvailableWidth;
+
+ mShowIconOnLeft = chipSpan.mShowIconOnLeft;
+
+ mChipVerticalSpacing = chipSpan.mChipVerticalSpacing;
+ mChipHeight = chipSpan.mChipHeight;
+
+ mStateSet = chipSpan.mStateSet;
+ }
+
+ @Override
+ public Object getData() {
+ return mData;
+ }
+
+ /**
+ * Sets the height of the chip. This height should not include any extra spacing (for extra vertical spacing call {@link #setChipVerticalSpacing(int)}).
+ * The background of the chip will fill the full height provided here. If this method is never called, the chip will have the height of one full line
+ * of text by default. If {@code -1} is passed here, the chip will revert to this default behavior.
+ *
+ * @param chipHeight the height to set in pixels
+ */
+ public void setChipHeight(int chipHeight) {
+ mChipHeight = chipHeight;
+ }
+
+ /**
+ * Sets the vertical spacing to include in between chips. Half of the value set here will be placed as empty space above the chip and half the value
+ * will be placed as empty space below the chip. Therefore chips on consecutive lines will have the full value as vertical space in between them.
+ * This spacing is achieved by adjusting the font metrics used by the text view containing these chips; however it does not come into effect until
+ * at least one chip is created. Note that vertical spacing is dependent on having a fixed chip height (set in {@link #setChipHeight(int)}). If a
+ * height is not specified in that method, the value set here will be ignored.
+ *
+ * @param chipVerticalSpacing the vertical spacing to set in pixels
+ */
+ public void setChipVerticalSpacing(int chipVerticalSpacing) {
+ mChipVerticalSpacing = chipVerticalSpacing;
+ }
+
+ /**
+ * Sets the font size for the chip's text. If this method is never called, the chip text will have the same font size as the text in the TextView
+ * containing this chip by default. If {@code -1} is passed here, the chip will revert to this default behavior.
+ *
+ * @param size the font size to set in pixels
+ */
+ public void setTextSize(int size) {
+ mTextSize = size;
+ invalidateCachedSize();
+ }
+
+ /**
+ * Sets the color for the chip's text.
+ *
+ * @param color the color to set (as a hexadecimal number in the form 0xAARRGGBB)
+ */
+ public void setTextColor(int color) {
+ mTextColor = color;
+ }
+
+ /**
+ * Sets where the icon (if an icon was provided in the constructor) will appear.
+ *
+ * @param showIconOnLeft if true, the icon will appear on the left, otherwise the icon will appear on the right
+ */
+ public void setShowIconOnLeft(boolean showIconOnLeft) {
+ this.mShowIconOnLeft = showIconOnLeft;
+ invalidateCachedSize();
+ }
+
+ /**
+ * Sets the left margin. This margin will appear as empty space (it will not share the chip's background color) to the left of the chip.
+ *
+ * @param leftMarginPx the left margin to set in pixels
+ */
+ public void setLeftMargin(int leftMarginPx) {
+ mLeftMarginPx = leftMarginPx;
+ invalidateCachedSize();
+ }
+
+ /**
+ * Sets the right margin. This margin will appear as empty space (it will not share the chip's background color) to the right of the chip.
+ *
+ * @param rightMarginPx the right margin to set in pixels
+ */
+ public void setRightMargin(int rightMarginPx) {
+ this.mRightMarginPx = rightMarginPx;
+ invalidateCachedSize();
+ }
+
+ /**
+ * Sets the background color. To configure which color in the {@link ColorStateList} is shown you can call {@link #setState(int[])}.
+ * Passing {@code null} here will cause the chip to revert to it's default background.
+ *
+ * @param backgroundColor a {@link ColorStateList} containing backgrounds for different states.
+ * @see #setState(int[])
+ */
+ public void setBackgroundColor(@Nullable ColorStateList backgroundColor) {
+ mBackgroundColor = backgroundColor != null ? backgroundColor : mDefaultBackgroundColor;
+ }
+
+ /**
+ * Sets the chip background corner radius.
+ *
+ * @param cornerRadius The corner radius value, in pixels.
+ */
+ public void setCornerRadius(@Dimension int cornerRadius) {
+ mCornerRadius = cornerRadius;
+ }
+
+ /**
+ * Sets the icon background color. This is the color of the circle that gets drawn behind the icon passed to the
+ * {@link #ChipSpan(Context, CharSequence, Drawable, Object)} constructor}
+ *
+ * @param iconBackgroundColor the icon background color to set (as a hexadecimal number in the form 0xAARRGGBB)
+ */
+ public void setIconBackgroundColor(int iconBackgroundColor) {
+ mIconBackgroundColor = iconBackgroundColor;
+ }
+
+ public void setMaxAvailableWidth(int maxAvailableWidth) {
+ mMaxAvailableWidth = maxAvailableWidth;
+ invalidateCachedSize();
+ }
+
+ /**
+ * Sets the UI state. This state will be reflected in the background color drawn for the chip.
+ *
+ * @param stateSet one of the state constants in {@link android.view.View}
+ * @see #setBackgroundColor(ColorStateList)
+ */
+ @Override
+ public void setState(int[] stateSet) {
+ this.mStateSet = stateSet != null ? stateSet : new int[]{};
+ }
+
+ @Override
+ public CharSequence getText() {
+ return mText;
+ }
+
+ @Override
+ public int getWidth() {
+ // If we haven't actually calculated a chip width yet just return -1, otherwise return the chip width + margins
+ return mChipWidth != -1 ? (mLeftMarginPx + mChipWidth + mRightMarginPx) : -1;
+ }
+
+ @Override
+ public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
+ boolean usingFontMetrics = (fm != null);
+
+ // Adjust the font metrics regardless of whether or not there is a cached size so that the text view can maintain its height
+ if (usingFontMetrics) {
+ adjustFontMetrics(paint, fm);
+ }
+
+ if (mCachedSize == -1 && usingFontMetrics) {
+ mIconWidth = (mIcon != null) ? calculateChipHeight(fm.top, fm.bottom) : 0;
+
+ int actualWidth = calculateActualWidth(paint);
+ mCachedSize = actualWidth;
+
+ if (mMaxAvailableWidth != -1) {
+ int maxAvailableWidthMinusMargins = mMaxAvailableWidth - mLeftMarginPx - mRightMarginPx;
+ if (actualWidth > maxAvailableWidthMinusMargins) {
+ mTextToDraw = mText + mEllipsis;
+
+ while ((calculateActualWidth(paint) > maxAvailableWidthMinusMargins) && mTextToDraw.length() > 0) {
+ int lastCharacterIndex = mTextToDraw.length() - mEllipsis.length() - 1;
+ if (lastCharacterIndex < 0) {
+ break;
+ }
+ mTextToDraw = mTextToDraw.substring(0, lastCharacterIndex) + mEllipsis;
+ }
+
+ // Avoid a negative width
+ mChipWidth = Math.max(0, maxAvailableWidthMinusMargins);
+ mCachedSize = mMaxAvailableWidth;
+ }
+ }
+ }
+
+ return mCachedSize;
+ }
+
+ private int calculateActualWidth(Paint paint) {
+ // Only change the text size if a text size was set
+ if (mTextSize != -1) {
+ paint.setTextSize(mTextSize);
+ }
+
+ int totalPadding = mPaddingEdgePx;
+
+ // Find text width
+ Rect bounds = new Rect();
+ paint.getTextBounds(mTextToDraw, 0, mTextToDraw.length(), bounds);
+ int textWidth = bounds.width();
+
+ if (mIcon != null) {
+ totalPadding += mPaddingBetweenImagePx;
+ } else {
+ totalPadding += mPaddingEdgePx;
+ }
+
+ mChipWidth = totalPadding + textWidth + mIconWidth;
+ return getWidth();
+ }
+
+ public void invalidateCachedSize() {
+ mCachedSize = -1;
+ }
+
+ /**
+ * Adjusts the provided font metrics to make it seem like the font takes up {@code mChipHeight + mChipVerticalSpacing} pixels in height.
+ * This effectively ensures that the TextView will have a height equal to {@code mChipHeight + mChipVerticalSpacing} + whatever padding it has set.
+ * In {@link #draw(Canvas, CharSequence, int, int, float, int, int, int, Paint)} the chip itself is drawn to that it is vertically centered with
+ * {@code mChipVerticalSpacing / 2} pixels of space above and below it
+ *
+ * @param paint the paint whose font metrics should be adjusted
+ * @param fm the font metrics object to populate through {@link Paint#getFontMetricsInt(Paint.FontMetricsInt)}
+ */
+ private void adjustFontMetrics(Paint paint, Paint.FontMetricsInt fm) {
+ // Only actually adjust font metrics if we have a chip height set
+ if (mChipHeight != -1) {
+ paint.getFontMetricsInt(fm);
+ int textHeight = fm.descent - fm.ascent;
+ // Break up the vertical spacing in half because half will go above the chip, half will go below the chip
+ int halfSpacing = mChipVerticalSpacing / 2;
+
+ // Given that the text is centered vertically within the chip, the amount of space above or below the text (inbetween the text and chip)
+ // is half their difference in height:
+ int spaceBetweenChipAndText = (mChipHeight - textHeight) / 2;
+
+ int textTop = fm.top;
+ int chipTop = fm.top - spaceBetweenChipAndText;
+
+ int textBottom = fm.bottom;
+ int chipBottom = fm.bottom + spaceBetweenChipAndText;
+
+ // The text may have been taller to begin with so we take the most negative coordinate (highest up) to be the top of the content
+ int topOfContent = Math.min(textTop, chipTop);
+ // Same as above but we want the largest positive coordinate (lowest down) to be the bottom of the content
+ int bottomOfContent = Math.max(textBottom, chipBottom);
+
+ // Shift the top up by halfSpacing and the bottom down by halfSpacing
+ int topOfContentWithSpacing = topOfContent - halfSpacing;
+ int bottomOfContentWithSpacing = bottomOfContent + halfSpacing;
+
+ // Change the font metrics so that the TextView thinks the font takes up the vertical space of a chip + spacing
+ fm.ascent = topOfContentWithSpacing;
+ fm.descent = bottomOfContentWithSpacing;
+ fm.top = topOfContentWithSpacing;
+ fm.bottom = bottomOfContentWithSpacing;
+ }
+ }
+
+ private int calculateChipHeight(int top, int bottom) {
+ // If a chip height was set we can return that, otherwise calculate it from top and bottom
+ return mChipHeight != -1 ? mChipHeight : bottom - top;
+ }
+
+ @Override
+ public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
+ // Shift everything mLeftMarginPx to the left to create an empty space on the left (creating the margin)
+ x += mLeftMarginPx;
+ if (mChipHeight != -1) {
+ // If we set a chip height, adjust to vertically center chip in the line
+ // Adding (bottom - top) / 2 shifts the chip down so the top of it will be centered vertically
+ // Subtracting (mChipHeight / 2) shifts the chip back up so that the center of it will be centered vertically (as desired)
+ top += ((bottom - top) / 2) - (mChipHeight / 2);
+ bottom = top + mChipHeight;
+ }
+
+ // Perform actual drawing
+ drawBackground(canvas, x, top, bottom, paint);
+ drawText(canvas, x, top, bottom, paint, mTextToDraw);
+ if (mIcon != null) {
+ drawIcon(canvas, x, top, bottom, paint);
+ }
+ }
+
+ private void drawBackground(Canvas canvas, float x, int top, int bottom, Paint paint) {
+ int backgroundColor = mBackgroundColor.getColorForState(mStateSet, mBackgroundColor.getDefaultColor());
+ paint.setColor(backgroundColor);
+ int height = calculateChipHeight(top, bottom);
+ RectF rect = new RectF(x, top, x + mChipWidth, bottom);
+ int cornerRadius = (mCornerRadius != -1) ? mCornerRadius : height / 2;
+ canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint);
+ paint.setColor(mTextColor);
+ }
+
+ private void drawText(Canvas canvas, float x, int top, int bottom, Paint paint, CharSequence text) {
+ if (mTextSize != -1) {
+ paint.setTextSize(mTextSize);
+ }
+ int height = calculateChipHeight(top, bottom);
+ Paint.FontMetrics fm = paint.getFontMetrics();
+
+ // The top value provided here is the y coordinate for the very top of the chip
+ // The y coordinate we are calculating is where the baseline of the text will be drawn
+ // Our objective is to have the midpoint between the top and baseline of the text be in line with the vertical center of the chip
+ // First we add height / 2 which will put the baseline at the vertical center of the chip
+ // Then we add half the height of the text which will lower baseline so that the midpoint is at the vertical center of the chip as desired
+ float adjustedY = top + ((height / 2) + ((-fm.top - fm.bottom) / 2));
+
+ // The x coordinate provided here is the left-most edge of the chip
+ // If there is no icon or the icon is on the right, then the text will start at the left-most edge, but indented with the edge padding, so we
+ // add mPaddingEdgePx
+ // If there is an icon and it's on the left, the text will start at the left-most edge, but indented by the combined width of the icon and
+ // the padding between the icon and text, so we add (mIconWidth + mPaddingBetweenImagePx)
+ float adjustedX = x + ((mIcon == null || !mShowIconOnLeft) ? mPaddingEdgePx : (mIconWidth + mPaddingBetweenImagePx));
+
+ canvas.drawText(text, 0, text.length(), adjustedX, adjustedY, paint);
+ }
+
+ private void drawIcon(Canvas canvas, float x, int top, int bottom, Paint paint) {
+ drawIconBackground(canvas, x, top, bottom, paint);
+ drawIconBitmap(canvas, x, top, bottom, paint);
+ }
+
+ private void drawIconBackground(Canvas canvas, float x, int top, int bottom, Paint paint) {
+ int height = calculateChipHeight(top, bottom);
+
+ paint.setColor(mIconBackgroundColor);
+
+ // Since it's a circle the diameter is equal to the height, so the radius == diameter / 2 == height / 2
+ int radius = height / 2;
+ // The coordinates that get passed to drawCircle are for the center of the circle
+ // x is the left edge of the chip, (x + mChipWidth) is the right edge of the chip
+ // So the center of the circle is one radius distance from either the left or right edge (depending on which side the icon is being drawn on)
+ float circleX = mShowIconOnLeft ? (x + radius) : (x + mChipWidth - radius);
+ // The y coordinate is always just one radius distance from the top
+ canvas.drawCircle(circleX, top + radius, radius, paint);
+
+ paint.setColor(mTextColor);
+ }
+
+ private void drawIconBitmap(Canvas canvas, float x, int top, int bottom, Paint paint) {
+ int height = calculateChipHeight(top, bottom);
+
+ // Create a scaled down version of the bitmap to fit within the circle (whose diameter == height)
+ Bitmap iconBitmap = Bitmap.createBitmap(mIcon.getIntrinsicWidth(), mIcon.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+ Bitmap scaledIconBitMap = scaleDown(iconBitmap, (float) height * SCALE_PERCENT_OF_CHIP_HEIGHT, true);
+ iconBitmap.recycle();
+ Canvas bitmapCanvas = new Canvas(scaledIconBitMap);
+ mIcon.setBounds(0, 0, bitmapCanvas.getWidth(), bitmapCanvas.getHeight());
+ mIcon.draw(bitmapCanvas);
+
+ // We are drawing a square icon inside of a circle
+ // The coordinates we pass to canvas.drawBitmap have to be for the top-left corner of the bitmap
+ // The bitmap should be inset by half of (circle width - bitmap width)
+ // Since it's a circle, the circle's width is equal to it's height which is equal to the chip height
+ float xInsetWithinCircle = (height - bitmapCanvas.getWidth()) / 2;
+
+ // The icon x coordinate is going to be insetWithinCircle pixels away from the left edge of the circle
+ // If the icon is on the left, the left edge of the circle is just x
+ // If the icon is on the right, the left edge of the circle is x + mChipWidth - height
+ float iconX = mShowIconOnLeft ? (x + xInsetWithinCircle) : (x + mChipWidth - height + xInsetWithinCircle);
+
+ // The y coordinate works the same way (only it's always from the top edge)
+ float yInsetWithinCircle = (height - bitmapCanvas.getHeight()) / 2;
+ float iconY = top + yInsetWithinCircle;
+
+ canvas.drawBitmap(scaledIconBitMap, iconX, iconY, paint);
+ }
+
+ private Bitmap scaleDown(Bitmap realImage, float maxImageSize, boolean filter) {
+ float ratio = Math.min(maxImageSize / realImage.getWidth(), maxImageSize / realImage.getHeight());
+ int width = Math.round(ratio * realImage.getWidth());
+ int height = Math.round(ratio * realImage.getHeight());
+ return Bitmap.createScaledBitmap(realImage, width, height, filter);
+ }
+
+ @Override
+ public String toString() {
+ return mText.toString();
+ }
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpanChipCreator.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpanChipCreator.java
new file mode 100644
index 000000000..8e0278f0c
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpanChipCreator.java
@@ -0,0 +1,60 @@
+package com.hootsuite.nachos.chip;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+
+import androidx.annotation.NonNull;
+
+import com.hootsuite.nachos.ChipConfiguration;
+
+public class ChipSpanChipCreator implements ChipCreator {
+
+ @Override
+ public ChipSpan createChip(@NonNull Context context, @NonNull CharSequence text, Object data) {
+ return new ChipSpan(context, text, null, data);
+ }
+
+ @Override
+ public ChipSpan createChip(@NonNull Context context, @NonNull ChipSpan existingChip) {
+ return new ChipSpan(context, existingChip);
+ }
+
+ @Override
+ public void configureChip(@NonNull ChipSpan chip, @NonNull ChipConfiguration chipConfiguration) {
+ int chipHorizontalSpacing = chipConfiguration.getChipHorizontalSpacing();
+ ColorStateList chipBackground = chipConfiguration.getChipBackground();
+ int chipCornerRadius = chipConfiguration.getChipCornerRadius();
+ int chipTextColor = chipConfiguration.getChipTextColor();
+ int chipTextSize = chipConfiguration.getChipTextSize();
+ int chipHeight = chipConfiguration.getChipHeight();
+ int chipVerticalSpacing = chipConfiguration.getChipVerticalSpacing();
+ int maxAvailableWidth = chipConfiguration.getMaxAvailableWidth();
+
+ if (chipHorizontalSpacing != -1) {
+ chip.setLeftMargin(chipHorizontalSpacing / 2);
+ chip.setRightMargin(chipHorizontalSpacing / 2);
+ }
+ if (chipBackground != null) {
+ chip.setBackgroundColor(chipBackground);
+ }
+ if (chipCornerRadius != -1) {
+ chip.setCornerRadius(chipCornerRadius);
+ }
+ if (chipTextColor != Color.TRANSPARENT) {
+ chip.setTextColor(chipTextColor);
+ }
+ if (chipTextSize != -1) {
+ chip.setTextSize(chipTextSize);
+ }
+ if (chipHeight != -1) {
+ chip.setChipHeight(chipHeight);
+ }
+ if (chipVerticalSpacing != -1) {
+ chip.setChipVerticalSpacing(chipVerticalSpacing);
+ }
+ if (maxAvailableWidth != -1) {
+ chip.setMaxAvailableWidth(maxAvailableWidth);
+ }
+ }
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/terminator/ChipTerminatorHandler.java b/mastodon/src/main/java/com/hootsuite/nachos/terminator/ChipTerminatorHandler.java
new file mode 100644
index 000000000..f97d430ea
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/terminator/ChipTerminatorHandler.java
@@ -0,0 +1,95 @@
+package com.hootsuite.nachos.terminator;
+
+import android.text.Editable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.hootsuite.nachos.tokenizer.ChipTokenizer;
+
+import java.util.Map;
+
+/**
+ * This interface is used to handle the management of characters that should trigger the creation of chips in a text view.
+ *
+ * @see ChipTokenizer
+ */
+public interface ChipTerminatorHandler {
+
+ /**
+ * When a chip terminator character is encountered in newly inserted text, all tokens in the whole text view will be chipified
+ */
+ int BEHAVIOR_CHIPIFY_ALL = 0;
+
+ /**
+ * When a chip terminator character is encountered in newly inserted text, only the current token (that in which the chip terminator character
+ * was found) will be chipified. This token may extend beyond where the chip terminator character was located.
+ */
+ int BEHAVIOR_CHIPIFY_CURRENT_TOKEN = 1;
+
+ /**
+ * When a chip terminator character is encountered in newly inserted text, only the text from the previous chip up until the chip terminator
+ * character will be chipified. This may not be an entire token.
+ */
+ int BEHAVIOR_CHIPIFY_TO_TERMINATOR = 2;
+
+ /**
+ * Constant for use with {@link #setPasteBehavior(int)}. Use this if a paste should behave the same as a standard text input (the chip temrinators
+ * will all behave according to their pre-determined behavior set through {@link #addChipTerminator(char, int)} or {@link #setChipTerminators(Map)}).
+ */
+ int PASTE_BEHAVIOR_USE_DEFAULT = -1;
+
+ /**
+ * Sets all the characters that will be marked as chip terminators. This will replace any previously set chip terminators.
+ *
+ * @param chipTerminators a map of characters to be marked as chip terminators to behaviors that describe how to respond to the characters, or null
+ * to remove all chip terminators
+ */
+ void setChipTerminators(@Nullable Map chipTerminators);
+
+ /**
+ * Adds a character as a chip terminator. When the provided character is encountered in entered text, the nearby text will be chipified according
+ * to the behavior provided here.
+ * {@code behavior} Must be one of:
+ *
+ *
{@link #BEHAVIOR_CHIPIFY_ALL}
+ *
{@link #BEHAVIOR_CHIPIFY_CURRENT_TOKEN}
+ *
{@link #BEHAVIOR_CHIPIFY_TO_TERMINATOR}
+ *
+ *
+ * @param character the character to mark as a chip terminator
+ * @param behavior the behavior describing how to respond to the chip terminator
+ */
+ void addChipTerminator(char character, int behavior);
+
+ /**
+ * Customizes the way paste events are handled.
+ * If one of:
+ *
+ *
{@link #BEHAVIOR_CHIPIFY_ALL}
+ *
{@link #BEHAVIOR_CHIPIFY_CURRENT_TOKEN}
+ *
{@link #BEHAVIOR_CHIPIFY_TO_TERMINATOR}
+ *
+ * is passed, all chip terminators will be handled with that behavior when a paste event occurs.
+ * If {@link #PASTE_BEHAVIOR_USE_DEFAULT} is passed, whatever behavior is configured for a particular chip terminator
+ * (through {@link #setChipTerminators(Map)} or {@link #addChipTerminator(char, int)} will be used for that chip terminator
+ *
+ * @param pasteBehavior the behavior to use on a paste event
+ */
+ void setPasteBehavior(int pasteBehavior);
+
+ /**
+ * Parses the provided text looking for characters marked as chip terminators through {@link #addChipTerminator(char, int)} and {@link #setChipTerminators(Map)}.
+ * The provided {@link Editable} will be modified if chip terminators are encountered.
+ *
+ * @param tokenizer the {@link ChipTokenizer} to use to identify and chipify tokens in the text
+ * @param text the text in which to search for chip terminators tokens to be chipped
+ * @param start the index at which to begin looking for chip terminators (inclusive)
+ * @param end the index at which to end looking for chip terminators (exclusive)
+ * @param isPasteEvent true if this handling is for a paste event in which case the behavior set in {@link #setPasteBehavior(int)} will be used,
+ * otherwise false
+ * @return an non-negative integer indicating the index where the cursor (selection) should be placed once the handling is complete,
+ * or a negative integer indicating that the cursor should not be moved.
+ */
+ int findAndHandleChipTerminators(@NonNull ChipTokenizer tokenizer, @NonNull Editable text, int start, int end, boolean isPasteEvent);
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/terminator/DefaultChipTerminatorHandler.java b/mastodon/src/main/java/com/hootsuite/nachos/terminator/DefaultChipTerminatorHandler.java
new file mode 100644
index 000000000..75cb9b0dc
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/terminator/DefaultChipTerminatorHandler.java
@@ -0,0 +1,115 @@
+package com.hootsuite.nachos.terminator;
+
+import android.text.Editable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.hootsuite.nachos.tokenizer.ChipTokenizer;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class DefaultChipTerminatorHandler implements ChipTerminatorHandler {
+
+ @Nullable
+ private Map mChipTerminators;
+ private int mPasteBehavior = BEHAVIOR_CHIPIFY_TO_TERMINATOR;
+
+ @Override
+ public void setChipTerminators(@Nullable Map chipTerminators) {
+ mChipTerminators = chipTerminators;
+ }
+
+ @Override
+ public void addChipTerminator(char character, int behavior) {
+ if (mChipTerminators == null) {
+ mChipTerminators = new HashMap<>();
+ }
+
+ mChipTerminators.put(character, behavior);
+ }
+
+ @Override
+ public void setPasteBehavior(int pasteBehavior) {
+ mPasteBehavior = pasteBehavior;
+ }
+
+ @Override
+ public int findAndHandleChipTerminators(@NonNull ChipTokenizer tokenizer, @NonNull Editable text, int start, int end, boolean isPasteEvent) {
+ // If we don't have a tokenizer or any chip terminators, there's nothing to look for
+ if (mChipTerminators == null) {
+ return -1;
+ }
+
+ TextIterator textIterator = new TextIterator(text, start, end);
+ int selectionIndex = -1;
+
+ characterLoop:
+ while (textIterator.hasNextCharacter()) {
+ char theChar = textIterator.nextCharacter();
+ if (isChipTerminator(theChar)) {
+ int behavior = (isPasteEvent && mPasteBehavior != PASTE_BEHAVIOR_USE_DEFAULT) ? mPasteBehavior : mChipTerminators.get(theChar);
+ int newSelection = -1;
+ switch (behavior) {
+ case BEHAVIOR_CHIPIFY_ALL:
+ selectionIndex = handleChipifyAll(textIterator, tokenizer);
+ break characterLoop;
+ case BEHAVIOR_CHIPIFY_CURRENT_TOKEN:
+ newSelection = handleChipifyCurrentToken(textIterator, tokenizer);
+ break;
+ case BEHAVIOR_CHIPIFY_TO_TERMINATOR:
+ newSelection = handleChipifyToTerminator(textIterator, tokenizer);
+ break;
+ }
+
+ if (newSelection != -1) {
+ selectionIndex = newSelection;
+ }
+ }
+ }
+
+ return selectionIndex;
+ }
+
+ private int handleChipifyAll(TextIterator textIterator, ChipTokenizer tokenizer) {
+ textIterator.deleteCharacter(true);
+ tokenizer.terminateAllTokens(textIterator.getText());
+ return textIterator.totalLength();
+ }
+
+ private int handleChipifyCurrentToken(TextIterator textIterator, ChipTokenizer tokenizer) {
+ textIterator.deleteCharacter(true);
+ Editable text = textIterator.getText();
+ int index = textIterator.getIndex();
+ int tokenStart = tokenizer.findTokenStart(text, index);
+ int tokenEnd = tokenizer.findTokenEnd(text, index);
+ if (tokenStart < tokenEnd) {
+ CharSequence chippedText = tokenizer.terminateToken(text.subSequence(tokenStart, tokenEnd), null);
+ textIterator.replace(tokenStart, tokenEnd, chippedText);
+ return tokenStart + chippedText.length();
+ }
+ return -1;
+ }
+
+ private int handleChipifyToTerminator(TextIterator textIterator, ChipTokenizer tokenizer) {
+ Editable text = textIterator.getText();
+ int index = textIterator.getIndex();
+ if (index > 0) {
+ int tokenStart = tokenizer.findTokenStart(text, index);
+ if (tokenStart < index) {
+ CharSequence chippedText = tokenizer.terminateToken(text.subSequence(tokenStart, index), null);
+ textIterator.replace(tokenStart, index + 1, chippedText);
+ } else {
+ textIterator.deleteCharacter(false);
+ }
+ } else {
+ textIterator.deleteCharacter(false);
+ }
+ return -1;
+ }
+
+ private boolean isChipTerminator(char character) {
+ return mChipTerminators != null && mChipTerminators.keySet().contains(character);
+ }
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/terminator/TextIterator.java b/mastodon/src/main/java/com/hootsuite/nachos/terminator/TextIterator.java
new file mode 100644
index 000000000..7c25d4c9f
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/terminator/TextIterator.java
@@ -0,0 +1,63 @@
+package com.hootsuite.nachos.terminator;
+
+import android.text.Editable;
+
+public class TextIterator {
+
+ private Editable mText;
+ private int mStart;
+ private int mEnd;
+
+ private int mIndex;
+
+ public TextIterator(Editable text, int start, int end) {
+ mText = text;
+ mStart = start;
+ mEnd = end;
+
+ mIndex = mStart - 1; // Subtract 1 so that the first call to nextCharacter() will return the first character
+ }
+
+ public int totalLength() {
+ return mText.length();
+ }
+
+ public int windowLength() {
+ return mEnd - mStart;
+ }
+
+ public Editable getText() {
+ return mText;
+ }
+
+ public int getIndex() {
+ return mIndex;
+ }
+
+ public boolean hasNextCharacter() {
+ return (mIndex + 1) < mEnd;
+ }
+
+ public char nextCharacter() {
+ mIndex++;
+ return mText.charAt(mIndex);
+ }
+
+ public void deleteCharacter(boolean maintainIndex) {
+ mText.replace(mIndex, mIndex + 1, "");
+ if (!maintainIndex) {
+ mIndex--;
+ }
+ mEnd--;
+ }
+
+ public void replace(int replaceStart, int replaceEnd, CharSequence chippedText) {
+ mText.replace(replaceStart, replaceEnd, chippedText);
+
+ // Update indexes
+ int newLength = chippedText.length();
+ int oldLength = replaceEnd - replaceStart;
+ mIndex = replaceStart + newLength - 1;
+ mEnd += newLength - oldLength;
+ }
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/BaseChipTokenizer.java b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/BaseChipTokenizer.java
new file mode 100644
index 000000000..ba946b5b0
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/BaseChipTokenizer.java
@@ -0,0 +1,89 @@
+package com.hootsuite.nachos.tokenizer;
+
+import android.text.Editable;
+import android.text.Spanned;
+import android.util.Pair;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.hootsuite.nachos.ChipConfiguration;
+import com.hootsuite.nachos.chip.Chip;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Base implementation of the {@link ChipTokenizer} interface that performs no actions and returns default values.
+ * This class allows for the easy creation of a ChipTokenizer that only implements some of the methods of the interface.
+ */
+public abstract class BaseChipTokenizer implements ChipTokenizer {
+
+ @Override
+ public void applyConfiguration(Editable text, ChipConfiguration chipConfiguration) {
+ // Do nothing
+ }
+
+ @Override
+ public int findTokenStart(CharSequence charSequence, int i) {
+ // Do nothing
+ return 0;
+ }
+
+ @Override
+ public int findTokenEnd(CharSequence charSequence, int i) {
+ // Do nothing
+ return 0;
+ }
+
+ @NonNull
+ @Override
+ public List> findAllTokens(CharSequence text) {
+ // Do nothing
+ return new ArrayList<>();
+ }
+
+ @Override
+ public CharSequence terminateToken(CharSequence charSequence, @Nullable Object data) {
+ // Do nothing
+ return charSequence;
+ }
+
+ @Override
+ public void terminateAllTokens(Editable text) {
+ // Do nothing
+ }
+
+ @Override
+ public int findChipStart(Chip chip, Spanned text) {
+ // Do nothing
+ return 0;
+ }
+
+ @Override
+ public int findChipEnd(Chip chip, Spanned text) {
+ // Do nothing
+ return 0;
+ }
+
+ @NonNull
+ @Override
+ public Chip[] findAllChips(int start, int end, Spanned text) {
+ return new Chip[]{};
+ }
+
+ @Override
+ public void revertChipToToken(Chip chip, Editable text) {
+ // Do nothing
+ }
+
+ @Override
+ public void deleteChip(Chip chip, Editable text) {
+ // Do nothing
+ }
+
+ @Override
+ public void deleteChipAndPadding(Chip chip, Editable text) {
+ // Do nothing
+ }
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/ChipTokenizer.java b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/ChipTokenizer.java
new file mode 100644
index 000000000..32d8f0614
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/ChipTokenizer.java
@@ -0,0 +1,134 @@
+package com.hootsuite.nachos.tokenizer;
+
+import android.text.Editable;
+import android.text.Spanned;
+import android.util.Pair;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.hootsuite.nachos.ChipConfiguration;
+import com.hootsuite.nachos.chip.Chip;
+
+import java.util.List;
+
+/**
+ * An extension of {@link android.widget.MultiAutoCompleteTextView.Tokenizer Tokenizer} that provides extra support
+ * for chipification.
+ *
+ * In the context of this interface, a token is considered to be plain (non-chipped) text. Once a token is terminated it becomes or contains a chip.
+ *
+ *
+ * The CharSequences passed to the ChipTokenizer methods may contain both chipped text
+ * and plain text so the tokenizer must have some method of distinguishing between the two (e.g. using a delimeter character.
+ * The {@link #terminateToken(CharSequence, Object)} method is where a chip can be formed and returned to replace the plain text.
+ * Whatever class the implementation deems to represent a chip, must implement the {@link Chip} interface.
+ *
+ *
+ * @see SpanChipTokenizer
+ */
+public interface ChipTokenizer {
+
+ /**
+ * Configures this ChipTokenizer to produce chips with the provided attributes. For each of these attributes, {@code -1} or {@code null} may be
+ * passed to indicate that the attribute may be ignored.
+ *
+ * This will also apply the provided {@link ChipConfiguration} to any existing chips in the provided text.
+ *
+ *
+ * @param text the text in which to search for existing chips to apply the configuration to
+ * @param chipConfiguration a {@link ChipConfiguration} containing customizations for the chips produced by this class
+ */
+ void applyConfiguration(Editable text, ChipConfiguration chipConfiguration);
+
+ /**
+ * Returns the start of the token that ends at offset
+ * cursor within text.
+ */
+ int findTokenStart(CharSequence text, int cursor);
+
+ /**
+ * Returns the end of the token (minus trailing punctuation)
+ * that begins at offset cursor within text.
+ */
+ int findTokenEnd(CharSequence text, int cursor);
+
+ /**
+ * Searches through {@code text} for any tokens.
+ *
+ * @param text the text in which to search for un-terminated tokens
+ * @return a list of {@link Pair}s of the form (startIndex, endIndex) containing the locations of all
+ * unterminated tokens
+ */
+ @NonNull
+ List> findAllTokens(CharSequence text);
+
+ /**
+ * Returns text, modified, if necessary, to ensure that
+ * it ends with a token terminator (for example a space or comma).
+ */
+ CharSequence terminateToken(CharSequence text, @Nullable Object data);
+
+ /**
+ * Terminates (converts from token into chip) all unterminated tokens in the provided text.
+ * This method CAN alter the provided text.
+ *
+ * @param text the text in which to terminate all tokens
+ */
+ void terminateAllTokens(Editable text);
+
+ /**
+ * Finds the index of the first character in {@code text} that is a part of {@code chip}
+ *
+ * @param chip the chip whose start should be found
+ * @param text the text in which to search for the start of {@code chip}
+ * @return the start index of the chip
+ */
+ int findChipStart(Chip chip, Spanned text);
+
+ /**
+ * Finds the index of the character after the last character in {@code text} that is a part of {@code chip}
+ *
+ * @param chip the chip whose end should be found
+ * @param text the text in which to search for the end of {@code chip}
+ * @return the end index of the chip
+ */
+ int findChipEnd(Chip chip, Spanned text);
+
+ /**
+ * Searches through {@code text} for any chips
+ *
+ * @param start index to start looking for terminated tokens (inclusive)
+ * @param end index to end looking for terminated tokens (exclusive)
+ * @param text the text in which to search for terminated tokens
+ * @return a list of objects implementing the {@link Chip} interface to represent the terminated tokens
+ */
+ @NonNull
+ Chip[] findAllChips(int start, int end, Spanned text);
+
+ /**
+ * Effectively does the opposite of {@link #terminateToken(CharSequence, Object)} by reverting the provided chip back into a token.
+ * This method CAN alter the provided text.
+ *
+ * @param chip the chip to revert into a token
+ * @param text the text in which the chip resides
+ */
+ void revertChipToToken(Chip chip, Editable text);
+
+ /**
+ * Removes a chip and any text it encompasses from {@code text}. This method CAN alter the provided text.
+ *
+ * @param chip the chip to remove
+ * @param text the text to remove the chip from
+ */
+ void deleteChip(Chip chip, Editable text);
+
+ /**
+ * Removes a chip, any text it encompasses AND any padding text (such as spaces) that may have been inserted when the chip was created in
+ * {@link #terminateToken(CharSequence, Object)} or after. This method CAN alter the provided text.
+ *
+ * @param chip the chip to remove
+ * @param text the text to remove the chip and padding from
+ */
+ void deleteChipAndPadding(Chip chip, Editable text);
+}
diff --git a/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/SpanChipTokenizer.java b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/SpanChipTokenizer.java
new file mode 100644
index 000000000..511bdb9bb
--- /dev/null
+++ b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/SpanChipTokenizer.java
@@ -0,0 +1,246 @@
+package com.hootsuite.nachos.tokenizer;
+
+import android.content.Context;
+import android.text.Editable;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.util.Pair;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.hootsuite.nachos.ChipConfiguration;
+import com.hootsuite.nachos.chip.Chip;
+import com.hootsuite.nachos.chip.ChipCreator;
+import com.hootsuite.nachos.chip.ChipSpan;
+
+import java.lang.reflect.Array;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * A default implementation of {@link ChipTokenizer}.
+ * This implementation does the following:
+ *
+ *
Surrounds each token with a space and the Unit Separator ASCII control character (31) - See the diagram below
+ *
+ *
The spaces are included so that android keyboards can distinguish the chips as different words and provide accurate
+ * autocorrect suggestions
+ *
+ *
+ *
Replaces each token with a {@link ChipSpan} containing the same text, once the token terminates
+ *
Uses the values passed to {@link #applyConfiguration(Editable, ChipConfiguration)} to configure any ChipSpans that get created
+ *
+ * Each terminated token will therefore look like the following (this is what will be returned from {@link #terminateToken(CharSequence, Object)}):
+ *