1/*
2 * Copyright (c) 2017, Daniel Gultsch All rights reserved.
3 *
4 * Redistribution and use in source and binary forms, with or without modification,
5 * are permitted provided that the following conditions are met:
6 *
7 * 1. Redistributions of source code must retain the above copyright notice, this
8 * list of conditions and the following disclaimer.
9 *
10 * 2. Redistributions in binary form must reproduce the above copyright notice,
11 * this list of conditions and the following disclaimer in the documentation and/or
12 * other materials provided with the distribution.
13 *
14 * 3. Neither the name of the copyright holder nor the names of its contributors
15 * may be used to endorse or promote products derived from this software without
16 * specific prior written permission.
17 *
18 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
22 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 */
29
30package eu.siacs.conversations.utils;
31
32import android.graphics.Color;
33import android.graphics.Typeface;
34import android.text.Editable;
35import android.text.ParcelableSpan;
36import android.text.Spannable;
37import android.text.SpannableString;
38import android.text.Spanned;
39import android.text.TextWatcher;
40import android.text.style.BackgroundColorSpan;
41import android.text.style.ForegroundColorSpan;
42import android.text.style.StrikethroughSpan;
43import android.text.style.StyleSpan;
44import android.text.style.TypefaceSpan;
45import android.widget.EditText;
46import android.widget.TextView;
47import androidx.annotation.ColorInt;
48import com.google.android.material.color.MaterialColors;
49import eu.siacs.conversations.ui.text.QuoteSpan;
50import java.util.ArrayList;
51import java.util.Arrays;
52import java.util.List;
53
54public class StylingHelper {
55
56 private static final List<? extends Class<? extends ParcelableSpan>> SPAN_CLASSES =
57 Arrays.asList(
58 StyleSpan.class,
59 StrikethroughSpan.class,
60 TypefaceSpan.class,
61 ForegroundColorSpan.class);
62
63 public static void clear(final Editable editable) {
64 final int end = editable.length() - 1;
65 for (Class<? extends ParcelableSpan> clazz : SPAN_CLASSES) {
66 for (ParcelableSpan span : editable.getSpans(0, end, clazz)) {
67 editable.removeSpan(span);
68 }
69 }
70 }
71
72 public static void format(
73 final Spannable editable, int start, int end, @ColorInt int textColor) {
74 for (ImStyleParser.Style style : ImStyleParser.parse(editable, start, end)) {
75 final int keywordLength = style.getKeyword().length();
76 editable.setSpan(
77 createSpanForStyle(style),
78 style.getStart() + keywordLength,
79 style.getEnd() - keywordLength + 1,
80 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
81 makeKeywordOpaque(
82 editable, style.getStart(), style.getStart() + keywordLength, textColor);
83 makeKeywordOpaque(
84 editable, style.getEnd() - keywordLength + 1, style.getEnd() + 1, textColor);
85 }
86 }
87
88 public static void format(final Spannable editable, @ColorInt final int textColor) {
89 format(editable, 0, editable.length() - 1, textColor);
90 }
91
92 public static void highlight(
93 final TextView view, final Editable editable, final List<String> needles) {
94 for (final String needle : needles) {
95 if (!FtsUtils.isKeyword(needle)) {
96 highlight(view, editable, needle);
97 }
98 }
99 }
100
101 public static List<String> filterHighlightedWords(List<String> terms) {
102 List<String> words = new ArrayList<>();
103 for (String term : terms) {
104 if (!FtsUtils.isKeyword(term)) {
105 StringBuilder builder = new StringBuilder();
106 for (int codepoint, i = 0; i < term.length(); i += Character.charCount(codepoint)) {
107 codepoint = term.codePointAt(i);
108 if (Character.isLetterOrDigit(codepoint)) {
109 builder.append(Character.toChars(codepoint));
110 } else if (builder.length() > 0) {
111 words.add(builder.toString());
112 builder.delete(0, builder.length());
113 }
114 }
115 if (builder.length() > 0) {
116 words.add(builder.toString());
117 }
118 }
119 }
120 return words;
121 }
122
123 private static void highlight(
124 final TextView view, final Spannable editable, final String needle) {
125 final int length = needle.length();
126 String string = editable.toString();
127 int start = indexOfIgnoreCase(string, needle, 0);
128 while (start != -1) {
129 int end = start + length;
130 editable.setSpan(
131 new BackgroundColorSpan(
132 MaterialColors.getColor(
133 view, com.google.android.material.R.attr.colorPrimaryFixedDim)),
134 start,
135 end,
136 SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE);
137 editable.setSpan(
138 new ForegroundColorSpan(
139 MaterialColors.getColor(
140 view, com.google.android.material.R.attr.colorOnPrimaryFixed)),
141 start,
142 end,
143 SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE);
144 start = indexOfIgnoreCase(string, needle, start + length);
145 }
146 }
147
148 static CharSequence subSequence(CharSequence charSequence, int start, int end) {
149 if (start == 0 && charSequence.length() + 1 == end) {
150 return charSequence;
151 }
152 if (charSequence instanceof Spannable spannable) {
153 Spannable sub = (Spannable) spannable.subSequence(start, end);
154 for (Class<? extends ParcelableSpan> clazz : SPAN_CLASSES) {
155 ParcelableSpan[] spannables = spannable.getSpans(start, end, clazz);
156 for (ParcelableSpan parcelableSpan : spannables) {
157 int beginSpan = spannable.getSpanStart(parcelableSpan);
158 int endSpan = spannable.getSpanEnd(parcelableSpan);
159 if (beginSpan >= start && endSpan <= end) {
160 continue;
161 }
162 sub.setSpan(
163 clone(parcelableSpan),
164 Math.max(beginSpan - start, 0),
165 Math.min(sub.length() - 1, endSpan),
166 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
167 }
168 }
169 return sub;
170 } else {
171 return charSequence.subSequence(start, end);
172 }
173 }
174
175 private static ParcelableSpan clone(ParcelableSpan span) {
176 if (span instanceof ForegroundColorSpan) {
177 return new ForegroundColorSpan(((ForegroundColorSpan) span).getForegroundColor());
178 } else if (span instanceof TypefaceSpan) {
179 return new TypefaceSpan(((TypefaceSpan) span).getFamily());
180 } else if (span instanceof StyleSpan) {
181 return new StyleSpan(((StyleSpan) span).getStyle());
182 } else if (span instanceof StrikethroughSpan) {
183 return new StrikethroughSpan();
184 } else {
185 throw new AssertionError("Unknown Span");
186 }
187 }
188
189 private static ParcelableSpan createSpanForStyle(final ImStyleParser.Style style) {
190 return switch (style.getKeyword()) {
191 case "*" -> new StyleSpan(Typeface.BOLD);
192 case "_" -> new StyleSpan(Typeface.ITALIC);
193 case "~" -> new StrikethroughSpan();
194 case "`", "```" -> new TypefaceSpan("monospace");
195 default -> throw new AssertionError("Unknown Style");
196 };
197 }
198
199 private static void makeKeywordOpaque(
200 final Spannable editable, int start, int end, @ColorInt int fallbackTextColor) {
201 QuoteSpan[] quoteSpans = editable.getSpans(start, end, QuoteSpan.class);
202 @ColorInt
203 int textColor = quoteSpans.length > 0 ? quoteSpans[0].getColor() : fallbackTextColor;
204 @ColorInt int keywordColor = transformColor(textColor);
205 editable.setSpan(
206 new ForegroundColorSpan(keywordColor),
207 start,
208 end,
209 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
210 }
211
212 private static @ColorInt int transformColor(@ColorInt int c) {
213 return Color.argb(
214 Math.round(Color.alpha(c) * 0.45f), Color.red(c), Color.green(c), Color.blue(c));
215 }
216
217 private static int indexOfIgnoreCase(
218 final String haystack, final String needle, final int start) {
219 if (haystack == null || needle == null) {
220 return -1;
221 }
222 final int endLimit = haystack.length() - needle.length() + 1;
223 if (start > endLimit) {
224 return -1;
225 }
226 if (needle.length() == 0) {
227 return start;
228 }
229 for (int i = start; i < endLimit; i++) {
230 if (haystack.regionMatches(true, i, needle, 0, needle.length())) {
231 return i;
232 }
233 }
234 return -1;
235 }
236
237 public static class MessageEditorStyler implements TextWatcher {
238
239 private final EditText mEditText;
240
241 public MessageEditorStyler(EditText editText) {
242 this.mEditText = editText;
243 }
244
245 @Override
246 public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {}
247
248 @Override
249 public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {}
250
251 @Override
252 public void afterTextChanged(Editable editable) {
253 clear(editable);
254 format(editable, mEditText.getCurrentTextColor());
255 }
256 }
257}