diff --git a/app/src/main/java/eu/faircode/email/BulletSpanEx.java b/app/src/main/java/eu/faircode/email/BulletSpanEx.java new file mode 100644 index 0000000000..07f95af863 --- /dev/null +++ b/app/src/main/java/eu/faircode/email/BulletSpanEx.java @@ -0,0 +1,68 @@ +package eu.faircode.email; + +/* + This file is part of FairEmail. + + FairEmail is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + FairEmail is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with FairEmail. If not, see . + + Copyright 2018-2021 by Marcel Bokhorst (M66B) +*/ + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.os.Build; +import android.text.Layout; +import android.text.style.BulletSpan; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +public class BulletSpanEx extends BulletSpan { + private int indentWidth; + private int level; + + public BulletSpanEx(int indentWidth, int gapWidth, int color, int level) { + super(gapWidth, color); + this.indentWidth = indentWidth; + this.level = level; + } + + @RequiresApi(api = Build.VERSION_CODES.P) + public BulletSpanEx(int indentWidth, int gapWidth, int color, int bulletRadius, int level) { + super(gapWidth, color, bulletRadius); + this.indentWidth = indentWidth; + this.level = level; + } + + int getLevel() { + return this.level; + } + + void setLevel(int level) { + this.level = level; + } + + @Override + public int getLeadingMargin(boolean first) { + // https://issuetracker.google.com/issues/36956124 + // This is called before drawLeadingMargin to justify the text + return indentWidth * level + super.getLeadingMargin(first); + } + + @Override + public void drawLeadingMargin(@NonNull Canvas canvas, @NonNull Paint paint, int x, int dir, int top, int baseline, int bottom, @NonNull CharSequence text, int start, int end, boolean first, @Nullable Layout layout) { + super.drawLeadingMargin(canvas, paint, x + indentWidth * level, dir, top, baseline, bottom, text, start, end, first, layout); + } +} diff --git a/app/src/main/java/eu/faircode/email/HtmlEx.java b/app/src/main/java/eu/faircode/email/HtmlEx.java index af06a672b3..ae8dedd18a 100644 --- a/app/src/main/java/eu/faircode/email/HtmlEx.java +++ b/app/src/main/java/eu/faircode/email/HtmlEx.java @@ -40,6 +40,9 @@ import android.text.style.TypefaceSpan; import android.text.style.URLSpan; import android.text.style.UnderlineSpan; +import java.util.ArrayList; +import java.util.List; + import static android.text.Html.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE; public class HtmlEx { @@ -211,7 +214,8 @@ public class HtmlEx { private /* static */ void withinBlockquoteIndividual(StringBuilder out, Spanned text, int start, int end) { - Boolean isInBulletList = null; + List levels = new ArrayList<>(); + int next; for (int i = start; i <= end; i = next) { next = TextUtils.indexOf(text, '\n', i, end); @@ -220,14 +224,16 @@ public class HtmlEx { } if (next == i) { - if (isInBulletList != null) { + if (levels.size() > 0) { // Current paragraph is no longer a list item; close the previously opened list - out.append(isInBulletList ? "\n" : "\n"); - isInBulletList = null; + for (int l = levels.size() - 1; l >= 0; l--) + out.append(levels.get(l) ? "\n" : "\n"); + levels.clear(); } if (i != text.length()) out.append("
\n"); } else { + int level = 0; Boolean isBulletListItem = null; ParagraphStyle[] paragraphStyles = text.getSpans(i, next, ParagraphStyle.class); for (ParagraphStyle paragraphStyle : paragraphStyles) { @@ -235,29 +241,34 @@ public class HtmlEx { if ((spanFlags & Spanned.SPAN_PARAGRAPH) == Spanned.SPAN_PARAGRAPH && paragraphStyle instanceof BulletSpan) { isBulletListItem = !(paragraphStyle instanceof eu.faircode.email.NumberSpan); + if (paragraphStyle instanceof NumberSpan) + level = ((NumberSpan) paragraphStyle).getLevel(); + else if (paragraphStyle instanceof BulletSpanEx) + level = ((BulletSpanEx) paragraphStyle).getLevel(); break; } } - if (isBulletListItem != null && isInBulletList != null && isBulletListItem != isInBulletList) { - out.append(isInBulletList ? "\n" : "\n"); - isInBulletList = null; - } + if (isBulletListItem == null) + level = -1; - if (isBulletListItem != null && isInBulletList == null) { - // Current paragraph is the first item in a list - isInBulletList = isBulletListItem; - out.append(isInBulletList ? " level + 1) { + Boolean bullet = levels.remove(levels.size() - 1); + out.append(bullet ? "\n" : "\n"); + } + if (level >= 0 && + levels.size() == level + 1 && + levels.get(level) != isBulletListItem) { + Boolean bullet = levels.remove(level); + out.append(bullet ? "\n" : "\n"); + } + while (levels.size() < level + 1) { + levels.add(isBulletListItem); + out.append(isBulletListItem ? "\n"); } - if (isInBulletList != null && isBulletListItem == null) { - // Current paragraph is no longer a list item; close the previously opened list - out.append(isInBulletList ? "\n" : "\n"); - isInBulletList = null; - } - String tagType = isBulletListItem != null ? "li" : "span"; out.append("<").append(tagType) .append(getTextDirection(text, i, next)) @@ -272,9 +283,10 @@ public class HtmlEx { if (isBulletListItem == null) out.append("
\n"); - if (next == end && isInBulletList != null) { - out.append(isInBulletList ? "\n" : "\n"); - isInBulletList = null; + if (next == end && levels.size() > 0) { + for (int l = levels.size() - 1; l >= 0; l--) + out.append(levels.get(l) ? "\n" : "\n"); + levels.clear(); } } diff --git a/app/src/main/java/eu/faircode/email/HtmlHelper.java b/app/src/main/java/eu/faircode/email/HtmlHelper.java index bd0e2af874..e3345676a6 100644 --- a/app/src/main/java/eu/faircode/email/HtmlHelper.java +++ b/app/src/main/java/eu/faircode/email/HtmlHelper.java @@ -43,7 +43,6 @@ import android.text.style.AlignmentSpan; import android.text.style.BulletSpan; import android.text.style.ForegroundColorSpan; import android.text.style.ImageSpan; -import android.text.style.LeadingMarginSpan; import android.text.style.QuoteSpan; import android.text.style.RelativeSizeSpan; import android.text.style.ReplacementSpan; @@ -1915,12 +1914,19 @@ public class HtmlHelper { for (BulletSpan span : ssb.getSpans(0, ssb.length(), BulletSpan.class)) { int start = ssb.getSpanStart(span); - ssb.insert(start, "* "); - } - - for (NumberSpan span : ssb.getSpans(0, ssb.length(), NumberSpan.class)) { - int start = ssb.getSpanStart(span); - ssb.insert(start, "- "); + if (span instanceof NumberSpan) { + ssb.insert(start, "- "); + int level = ((NumberSpan) span).getLevel(); + for (int l = 1; l <= level; l++) + ssb.insert(start, "\t"); + } else { + ssb.insert(start, "* "); + if (span instanceof BulletSpanEx) { + int level = ((BulletSpanEx) span).getLevel(); + for (int l = 1; l <= level; l++) + ssb.insert(start, "\t"); + } + } } return ssb.toString(); @@ -2372,23 +2378,32 @@ public class HtmlHelper { if (ssb.length() == 0 || ssb.charAt(ssb.length() - 1) != '\n') ssb.append("\n"); + int level = 0; + Element type = null; Element parent = element.parent(); - while (parent != null && - !"ol".equals(parent.tagName()) && - !"ul".equals(parent.tagName())) + while (parent != null) { + if ("ol".equals(parent.tagName()) || "ul".equals(parent.tagName())) { + level++; + if (type == null) + type = parent; + } parent = parent.parent(); - if (parent == null || "ul".equals(parent.tagName())) + } + if (level > 0) + level--; + + if (type == null || "ul".equals(type.tagName())) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) - setSpan(ssb, new BulletSpan(dp6, colorAccent), start, ssb.length()); + setSpan(ssb, new BulletSpanEx(dp24, dp6, colorAccent, level), start, ssb.length()); else - setSpan(ssb, new BulletSpan(dp6, colorAccent, dp3), start, ssb.length()); - else { + setSpan(ssb, new BulletSpanEx(dp24, dp6, colorAccent, dp3, level), start, ssb.length()); + } else { // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ol int index = 0; - String s = parent.attr("start"); + String s = type.attr("start"); if (!TextUtils.isEmpty(s) && TextUtils.isDigitsOnly(s)) index = Integer.parseInt(s) - 1; - for (Node child : parent.childNodes()) { + for (Node child : type.childNodes()) { if (child instanceof Element && child.nodeName().equals(element.tagName())) { index++; @@ -2397,8 +2412,9 @@ public class HtmlHelper { } } - setSpan(ssb, new NumberSpan(dp6, colorAccent, textSize, index), start, ssb.length()); + setSpan(ssb, new NumberSpan(dp24, dp6, colorAccent, textSize, level, index), start, ssb.length()); } + break; case "pre": // Signature @@ -2409,15 +2425,6 @@ public class HtmlHelper { break; case "ol": case "ul": - int llevel = 0; - Element lparent = element.parent(); - while (lparent != null) { - if ("ol".equals(lparent.tagName()) || "ul".equals(lparent.tagName())) - llevel++; - lparent = lparent.parent(); - } - if (llevel > 0) - setSpan(ssb, new LeadingMarginSpan.Standard(llevel * dp24), start, ssb.length()); break; case "meta": // Signature diff --git a/app/src/main/java/eu/faircode/email/NumberSpan.java b/app/src/main/java/eu/faircode/email/NumberSpan.java index df1a1e379d..a305503ef3 100644 --- a/app/src/main/java/eu/faircode/email/NumberSpan.java +++ b/app/src/main/java/eu/faircode/email/NumberSpan.java @@ -28,29 +28,41 @@ import android.text.TextPaint; import android.text.style.BulletSpan; public class NumberSpan extends BulletSpan { + int indentWidth; + private int level; private int index; private TextPaint tp; private String number; private int margin; - public NumberSpan(int gapWidth, int color, float textSize, int index) { + public NumberSpan(int indentWidth, int gapWidth, int color, float textSize, int level, int index) { tp = new TextPaint(); tp.setStyle(Paint.Style.FILL); tp.setColor(color); tp.setTypeface(Typeface.MONOSPACE); tp.setTextSize(textSize); + this.indentWidth = indentWidth; + this.level = level; this.index = index; number = index + "."; - margin = Math.round(tp.measureText(number) + gapWidth); + margin = Math.round(tp.measureText(number)) + gapWidth; } float getTextSize() { return tp.getTextSize(); } + int getLevel() { + return this.level; + } + + void setLevel(int level) { + this.level = level; + } + int getIndex() { return index; } @@ -59,7 +71,7 @@ public class NumberSpan extends BulletSpan { public int getLeadingMargin(boolean first) { // https://issuetracker.google.com/issues/36956124 // This is called before drawLeadingMargin to justify the text - return margin; + return indentWidth * level + margin; } @Override @@ -69,7 +81,7 @@ public class NumberSpan extends BulletSpan { float textSize = tp.getTextSize(); if (textSize > p.getTextSize()) tp.setTextSize(p.getTextSize()); - c.drawText(number, x + dir, baseline, tp); + c.drawText(number, x + indentWidth * level, baseline, tp); tp.setTextSize(textSize); } } diff --git a/app/src/main/java/eu/faircode/email/StyleHelper.java b/app/src/main/java/eu/faircode/email/StyleHelper.java index cf814e9217..207ef63e55 100644 --- a/app/src/main/java/eu/faircode/email/StyleHelper.java +++ b/app/src/main/java/eu/faircode/email/StyleHelper.java @@ -26,8 +26,6 @@ import android.graphics.Typeface; import android.os.Build; import android.text.Editable; import android.text.Layout; -import android.text.SpannableString; -import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; import android.text.style.AlignmentSpan; @@ -124,6 +122,16 @@ public class StyleHelper { smenu.add(R.id.group_style_font, i, 0, fontNames[i]); smenu.add(R.id.group_style_font, fontNames.length, 0, R.string.title_style_font_default); + int level = -1; + BulletSpan[] spans = edit.getSpans(start, end, BulletSpan.class); + for (BulletSpan span : spans) + if (span instanceof NumberSpan) + level = ((NumberSpan) span).getLevel(); + else if (span instanceof BulletSpanEx) + level = ((BulletSpanEx) span).getLevel(); + popupMenu.getMenu().findItem(R.id.menu_style_list_increase).setVisible(level >= 0); + popupMenu.getMenu().findItem(R.id.menu_style_list_decrease).setVisible(level > 0); + popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { @Override public boolean onMenuItemClick(MenuItem item) { @@ -136,7 +144,11 @@ public class StyleHelper { } else if (groupId == R.id.group_style_align) { return setAlignment(item); } else if (groupId == R.id.group_style_list) { - return setList(item); + if (item.getItemId() == R.id.menu_style_list_increase || + item.getItemId() == R.id.menu_style_list_decrease) + return setListLevel(item); + else + return setList(item); } else if (groupId == R.id.group_style_font) { return setFont(item); } else if (groupId == R.id.group_style_blockquote) { @@ -248,12 +260,38 @@ public class StyleHelper { return true; } + private boolean setListLevel(MenuItem item) { + Context context = etBody.getContext(); + int add = (item.getItemId() == R.id.menu_style_list_increase ? 1 : -1); + + boolean renum = false; + BulletSpan[] spans = edit.getSpans(start, end, BulletSpan.class); + for (BulletSpan span : spans) + if (span instanceof BulletSpanEx) { + BulletSpanEx bs = (BulletSpanEx) span; + bs.setLevel(bs.getLevel() + add); + } else if (span instanceof NumberSpan) { + renum = true; + NumberSpan ns = (NumberSpan) span; + ns.setLevel(ns.getLevel() + add); + } + + if (renum) + renumber(edit, false, context); + + etBody.setText(edit); + etBody.setSelection(start, end); + + return true; + } + private boolean setList(MenuItem item) { Context context = etBody.getContext(); int colorAccent = Helper.resolveColor(context, R.attr.colorAccent); int dp3 = Helper.dp2pixels(context, 3); int dp6 = Helper.dp2pixels(context, 6); + int dp24 = Helper.dp2pixels(context, 24); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); int message_zoom = prefs.getInt("message_zoom", 100); @@ -271,22 +309,28 @@ public class StyleHelper { int i = s; int j = s + 1; int index = 1; + boolean renum = false; while (j < e) { if (i > 0 && edit.charAt(i - 1) == '\n' && edit.charAt(j) == '\n') { Log.i("Insert " + i + "..." + (j + 1) + " size=" + e); if (item.getItemId() == R.id.menu_style_list_bullets) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) - edit.setSpan(new BulletSpan(dp6, colorAccent), i, j + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_PARAGRAPH); + edit.setSpan(new BulletSpanEx(dp24, dp6, colorAccent, 0), i, j + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_PARAGRAPH); else - edit.setSpan(new BulletSpan(dp6, colorAccent, dp3), i, j + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_PARAGRAPH); - else - edit.setSpan(new NumberSpan(dp6, colorAccent, textSize, index++), i, j + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_PARAGRAPH); + edit.setSpan(new BulletSpanEx(dp24, dp6, colorAccent, dp3, 0), i, j + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_PARAGRAPH); + else { + renum = true; + edit.setSpan(new NumberSpan(dp24, dp6, colorAccent, textSize, 0, index++), i, j + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_PARAGRAPH); + } i = j + 1; } j++; } + if (renum) + renumber(edit, false, context); + etBody.setText(edit); etBody.setSelection(s, e); @@ -472,7 +516,7 @@ public class StyleHelper { if (end == edit.length()) edit.append("\n"); // workaround Android bug - return new Pair(start, end); + return new Pair<>(start, end); } static T clone(Object span, Class type, Context context) { @@ -485,16 +529,18 @@ public class StyleHelper { } else if (NumberSpan.class.isAssignableFrom(type)) { NumberSpan n = (NumberSpan) span; int dp6 = Helper.dp2pixels(context, 6); + int dp24 = Helper.dp2pixels(context, 24); int colorAccent = Helper.resolveColor(context, R.attr.colorAccent); - return (T) new NumberSpan(dp6, colorAccent, n.getTextSize(), n.getIndex() + 1); - } else if (BulletSpan.class.isAssignableFrom(type)) { - BulletSpan b = (BulletSpan) span; + return (T) new NumberSpan(dp24, dp6, colorAccent, n.getTextSize(), n.getLevel(), n.getIndex() + 1); + } else if (BulletSpanEx.class.isAssignableFrom(type)) { + BulletSpanEx b = (BulletSpanEx) span; + int dp24 = Helper.dp2pixels(context, 24); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { int dp6 = Helper.dp2pixels(context, 6); int colorAccent = Helper.resolveColor(context, R.attr.colorAccent); - return (T) new BulletSpan(dp6, colorAccent); + return (T) new BulletSpanEx(dp24, dp6, colorAccent, b.getLevel()); } else - return (T) new BulletSpan(b.getGapWidth(), b.getColor(), b.getBulletRadius()); + return (T) new BulletSpanEx(dp24, b.getGapWidth(), b.getColor(), b.getBulletRadius(), b.getLevel()); } else throw new IllegalArgumentException(type.getName()); @@ -502,13 +548,14 @@ public class StyleHelper { static void renumber(Editable text, boolean clean, Context context) { int dp6 = Helper.dp2pixels(context, 6); + int dp24 = Helper.dp2pixels(context, 24); int colorAccent = Helper.resolveColor(context, R.attr.colorAccent); Log.i("Renumber clean=" + clean + " text=" + text); int next; - int index = 1; int pos = -1; + List levels = new ArrayList<>(); for (int i = 0; i < text.length(); i = next) { next = text.nextSpanTransition(i, text.length(), NumberSpan.class); Log.i("Bullet span next=" + next); @@ -525,16 +572,33 @@ public class StyleHelper { continue; } - if (span instanceof NumberSpan) { - if (start == pos) - index++; - else - index = 1; + int level; + if (span instanceof NumberSpan) + level = ((NumberSpan) span).getLevel(); + else if (span instanceof BulletSpanEx) + level = ((BulletSpanEx) span).getLevel(); + else + level = 0; + + if (start != pos) + levels.clear(); + while (levels.size() > level + 1) + levels.remove(levels.size() - 1); + if (levels.size() == level + 1 && !(span instanceof NumberSpan)) + levels.remove(level - 1); + while (levels.size() < level + 1) + levels.add(0); + + int index = levels.get(level) + 1; + levels.remove(level); + levels.add(level, index); + if (span instanceof NumberSpan) { NumberSpan ns = (NumberSpan) span; if (index != ns.getIndex()) { - NumberSpan clone = new NumberSpan(dp6, colorAccent, ns.getTextSize(), index); text.removeSpan(span); + // Text size needs measuring + NumberSpan clone = new NumberSpan(dp24, dp6, colorAccent, ns.getTextSize(), level, index); text.setSpan(clone, start, end, flags); } diff --git a/app/src/main/res/menu/popup_style.xml b/app/src/main/res/menu/popup_style.xml index b12cb81381..00c4597844 100644 --- a/app/src/main/res/menu/popup_style.xml +++ b/app/src/main/res/menu/popup_style.xml @@ -62,6 +62,12 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d49c1bfb57..f27c501c55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1073,6 +1073,8 @@ List Bullets Numbered + Increase indentation + Decrease indentation Font Default Block quote