1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.text.style;
18 
19 import android.annotation.IntRange;
20 import android.annotation.NonNull;
21 import android.graphics.Typeface;
22 import android.text.Spannable;
23 import android.text.Spanned;
24 import android.util.LongArray;
25 
26 import java.util.ArrayList;
27 import java.util.Arrays;
28 import java.util.List;
29 
30 /**
31  * @hide
32  */
33 public class SpanUtils {
SpanUtils()34     private SpanUtils() {}  // Do not instantiate
35 
36     /**
37      * Toggle the bold state of the given range.
38      *
39      * If there is at least one character is not bold in the given range, make the entire region to
40      * be bold. If all characters of the given range is already bolded, this method removes bold
41      * style from the given selection.
42      *
43      * @param spannable a spannable string
44      * @param min minimum inclusive index of the selection.
45      * @param max maximum exclusive index of the selection.
46      * @return true if the selected region is toggled.
47      */
toggleBold(@onNull Spannable spannable, @IntRange(from = 0) int min, @IntRange(from = 0) int max)48     public static boolean toggleBold(@NonNull Spannable spannable,
49             @IntRange(from = 0) int min, @IntRange(from = 0) int max) {
50 
51         if (min == max) {
52             return false;
53         }
54 
55         final StyleSpan[] boldSpans = spannable.getSpans(min, max, StyleSpan.class);
56         final ArrayList<StyleSpan> filteredBoldSpans = new ArrayList<>();
57         for (StyleSpan span : boldSpans) {
58             if ((span.getStyle() & Typeface.BOLD) == Typeface.BOLD) {
59                 filteredBoldSpans.add(span);
60             }
61         }
62 
63         if (!isCovered(spannable, filteredBoldSpans, min, max)) {
64             // At least one character doesn't have bold style. Making given region bold.
65             spannable.setSpan(
66                     new StyleSpan(Typeface.BOLD), min, max, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
67             return true;
68         }
69 
70         // Span covers the entire selection. Removing spans from tha region.
71         for (int si = 0; si < filteredBoldSpans.size(); ++si) {
72             final StyleSpan span = filteredBoldSpans.get(si);
73             final int start = spannable.getSpanStart(span);
74             final int end = spannable.getSpanEnd(span);
75             final int flag = spannable.getSpanFlags(span);
76 
77             // If BOLD_ITALIC style is attached, need to set ITALIC span to the subtracted range.
78             final boolean needItalicSpan = (span.getStyle() & Typeface.ITALIC) == Typeface.ITALIC;
79 
80             if (start < min) {
81                 if (end > max) {
82                     // selection: ------------|===================|----------------
83                     //      span:     <-------------------------------->
84                     //    result:     <------->                   <---->
85                     spannable.setSpan(span, start, min, flag);
86                     spannable.setSpan(new StyleSpan(span.getStyle()), max, end, flag);
87                     if (needItalicSpan) {
88                         spannable.setSpan(new StyleSpan(Typeface.ITALIC), min, max, flag);
89                     }
90                 } else {
91                     // selection: ------------|===================|----------------
92                     //      span:     <----------->
93                     //    result:     <------->
94                     spannable.setSpan(span, start, min, flag);
95                     if (needItalicSpan) {
96                         spannable.setSpan(new StyleSpan(Typeface.ITALIC), min, end, flag);
97                     }
98                 }
99             } else {
100                 if (end > max) {
101                     // selection: ------------|===================|----------------
102                     //      span:                     <------------------------>
103                     //    result:                                 <------------>
104                     spannable.setSpan(span, max, end, flag);
105                     if (needItalicSpan) {
106                         spannable.setSpan(new StyleSpan(Typeface.ITALIC), max, end, flag);
107                     }
108                 } else {
109                     // selection: ------------|===================|----------------
110                     //      span:                 <----------->
111                     //    result:
112                     spannable.removeSpan(span);
113                     if (needItalicSpan) {
114                         spannable.setSpan(new StyleSpan(Typeface.ITALIC), start, end, flag);
115                     }
116                 }
117             }
118         }
119         return true;
120     }
121 
122     /**
123      * Toggle the italic state of the given range.
124      *
125      * If there is at least one character is not italic in the given range, make the entire region
126      * to be italic. If all characters of the given range is already italic, this method removes
127      * italic style from the given selection.
128      *
129      * @param spannable a spannable string
130      * @param min minimum inclusive index of the selection.
131      * @param max maximum exclusive index of the selection.
132      * @return true if the selected region is toggled.
133      */
toggleItalic(@onNull Spannable spannable, @IntRange(from = 0) int min, @IntRange(from = 0) int max)134     public static boolean toggleItalic(@NonNull Spannable spannable,
135             @IntRange(from = 0) int min, @IntRange(from = 0) int max) {
136 
137         if (min == max) {
138             return false;
139         }
140 
141         final StyleSpan[] boldSpans = spannable.getSpans(min, max, StyleSpan.class);
142         final ArrayList<StyleSpan> filteredBoldSpans = new ArrayList<>();
143         for (StyleSpan span : boldSpans) {
144             if ((span.getStyle() & Typeface.ITALIC) == Typeface.ITALIC) {
145                 filteredBoldSpans.add(span);
146             }
147         }
148 
149         if (!isCovered(spannable, filteredBoldSpans, min, max)) {
150             // At least one character doesn't have italic style. Making given region italic.
151             spannable.setSpan(
152                     new StyleSpan(Typeface.ITALIC), min, max, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
153             return true;
154         }
155 
156         // Span covers the entire selection. Removing spans from tha region.
157         for (int si = 0; si < filteredBoldSpans.size(); ++si) {
158             final StyleSpan span = filteredBoldSpans.get(si);
159             final int start = spannable.getSpanStart(span);
160             final int end = spannable.getSpanEnd(span);
161             final int flag = spannable.getSpanFlags(span);
162 
163             // If BOLD_ITALIC style is attached, need to set BOLD span to the subtracted range.
164             final boolean needBoldSpan = (span.getStyle() & Typeface.BOLD) == Typeface.BOLD;
165 
166             if (start < min) {
167                 if (end > max) {
168                     // selection: ------------|===================|----------------
169                     //      span:     <-------------------------------->
170                     //    result:     <------->                   <---->
171                     spannable.setSpan(span, start, min, flag);
172                     spannable.setSpan(new StyleSpan(span.getStyle()), max, end, flag);
173                     if (needBoldSpan) {
174                         spannable.setSpan(new StyleSpan(Typeface.BOLD), min, max, flag);
175                     }
176                 } else {
177                     // selection: ------------|===================|----------------
178                     //      span:     <----------->
179                     //    result:     <------->
180                     spannable.setSpan(span, start, min, flag);
181                     if (needBoldSpan) {
182                         spannable.setSpan(new StyleSpan(Typeface.BOLD), min, end, flag);
183                     }
184                 }
185             } else {
186                 if (end > max) {
187                     // selection: ------------|===================|----------------
188                     //      span:                     <------------------------>
189                     //    result:                                 <------------>
190                     spannable.setSpan(span, max, end, flag);
191                     if (needBoldSpan) {
192                         spannable.setSpan(new StyleSpan(Typeface.BOLD), max, end, flag);
193                     }
194                 } else {
195                     // selection: ------------|===================|----------------
196                     //      span:                 <----------->
197                     //    result:
198                     spannable.removeSpan(span);
199                     if (needBoldSpan) {
200                         spannable.setSpan(new StyleSpan(Typeface.BOLD), start, end, flag);
201                     }
202                 }
203             }
204         }
205         return true;
206     }
207 
208     /**
209      * Toggle the underline state of the given range.
210      *
211      * If there is at least one character is not underlined in the given range, make the entire
212      * region to underlined. If all characters of the given range is already underlined, this
213      * method removes underline from the given selection.
214      *
215      * @param spannable a spannable string
216      * @param min minimum inclusive index of the selection.
217      * @param max maximum exclusive index of the selection.
218      * @return true if the selected region is toggled.
219      */
toggleUnderline(@onNull Spannable spannable, @IntRange(from = 0) int min, @IntRange(from = 0) int max)220     public static boolean toggleUnderline(@NonNull Spannable spannable,
221             @IntRange(from = 0) int min, @IntRange(from = 0) int max) {
222 
223         if (min == max) {
224             return false;
225         }
226 
227         final List<UnderlineSpan> spans =
228                 Arrays.asList(spannable.getSpans(min, max, UnderlineSpan.class));
229 
230         if (!isCovered(spannable, spans, min, max)) {
231             // At least one character doesn't have underline style. Making given region underline.
232             spannable.setSpan(new UnderlineSpan(), min, max, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
233             return true;
234         }
235         // Span covers the entire selection. Removing spans from tha region.
236         for (int si = 0; si < spans.size(); ++si) {
237             final UnderlineSpan span = spans.get(si);
238             final int start = spannable.getSpanStart(span);
239             final int end = spannable.getSpanEnd(span);
240             final int flag = spannable.getSpanFlags(span);
241 
242             if (start < min) {
243                 if (end > max) {
244                     // selection: ------------|===================|----------------
245                     //      span:     <-------------------------------->
246                     //    result:     <------->                   <---->
247                     spannable.setSpan(span, start, min, flag);
248                     spannable.setSpan(new UnderlineSpan(), max, end, flag);
249                 } else {
250                     // selection: ------------|===================|----------------
251                     //      span:     <----------->
252                     //    result:     <------->
253                     spannable.setSpan(span, start, min, flag);
254                 }
255             } else {
256                 if (end > max) {
257                     // selection: ------------|===================|----------------
258                     //      span:                     <------------------------>
259                     //    result:                                 <------------>
260                     spannable.setSpan(span, max, end, flag);
261                 } else {
262                     // selection: ------------|===================|----------------
263                     //      span:                 <----------->
264                     //    result:
265                     spannable.removeSpan(span);
266                 }
267             }
268         }
269         return true;
270     }
271 
pack(int from, int to)272     private static long pack(int from, int to) {
273         return ((long) from) << 32 | (long) to;
274     }
275 
min(long packed)276     private static int min(long packed) {
277         return (int) (packed >> 32);
278     }
279 
max(long packed)280     private static int max(long packed) {
281         return (int) (packed & 0xFFFFFFFFL);
282     }
283 
hasIntersection(int aMin, int aMax, int bMin, int bMax)284     private static boolean hasIntersection(int aMin, int aMax, int bMin, int bMax) {
285         return aMin < bMax && bMin < aMax;
286     }
287 
intersection(int aMin, int aMax, int bMin, int bMax)288     private static long intersection(int aMin, int aMax, int bMin, int bMax) {
289         return pack(Math.max(aMin, bMin), Math.min(aMax, bMax));
290     }
291 
isCovered(@onNull Spannable spannable, @NonNull List<T> spans, @IntRange(from = 0) int min, @IntRange(from = 0) int max)292     private static <T> boolean isCovered(@NonNull Spannable spannable, @NonNull List<T> spans,
293             @IntRange(from = 0) int min, @IntRange(from = 0) int max) {
294 
295         if (min == max) {
296             return false;
297         }
298 
299         LongArray uncoveredRanges = new LongArray();
300         LongArray nextUncoveredRanges = new LongArray();
301 
302         uncoveredRanges.add(pack(min, max));
303 
304         for (int si = 0; si < spans.size(); ++si) {
305             final T span = spans.get(si);
306             final int start = spannable.getSpanStart(span);
307             final int end = spannable.getSpanEnd(span);
308 
309             for (int i = 0; i < uncoveredRanges.size(); ++i) {
310                 final long packed = uncoveredRanges.get(i);
311                 final int uncoveredStart = min(packed);
312                 final int uncoveredEnd = max(packed);
313 
314                 if (!hasIntersection(start, end, uncoveredStart, uncoveredEnd)) {
315                     // This span doesn't affect this uncovered range. Try next span.
316                     nextUncoveredRanges.add(packed);
317                 } else {
318                     // This span has an intersection with uncovered range. Update the uncovered
319                     // range.
320                     long intersectionPack = intersection(start, end, uncoveredStart, uncoveredEnd);
321                     int intersectStart = min(intersectionPack);
322                     int intersectEnd = max(intersectionPack);
323 
324                     // Uncovered Range           : ----------|=======================|-------------
325                     //    Intersection           :                 <---------->
326                     // Remaining uncovered ranges: ----------|=====|----------|======|-------------
327                     if (uncoveredStart != intersectStart) {
328                         // There is still uncovered area on the left.
329                         nextUncoveredRanges.add(pack(uncoveredStart, intersectStart));
330                     }
331                     if (intersectEnd != uncoveredEnd) {
332                         // There is still uncovered area on the right.
333                         nextUncoveredRanges.add(pack(intersectEnd, uncoveredEnd));
334                     }
335                 }
336             }
337 
338             if (nextUncoveredRanges.size() == 0) {
339                 return true;
340             }
341 
342             // Swap the uncoveredRanges and nextUncoveredRanges and clear the next one.
343             final LongArray tmp = nextUncoveredRanges;
344             nextUncoveredRanges = uncoveredRanges;
345             uncoveredRanges = tmp;
346             nextUncoveredRanges.clear();
347         }
348 
349         return false;
350     }
351 }
352