diff --git a/.travis.yml b/.travis.yml index 93df6e80..bd4791e7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ android: - platform-tools - tools - - build-tools-27.0.3 + - build-tools-28.0.3 - android-27 branches: diff --git a/README.md b/README.md index 7545d2b4..be6976a9 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ [![markwon-syntax-highlight](https://img.shields.io/maven-central/v/ru.noties/markwon-syntax-highlight.svg?label=markwon-syntax-highlight)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-syntax-highlight%22) [![markwon-view](https://img.shields.io/maven-central/v/ru.noties/markwon-view.svg?label=markwon-view)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-view%22) +[![Build Status](https://travis-ci.org/noties/Markwon.svg?branch=master)](https://travis-ci.org/noties/Markwon) + **Markwon** is a markdown library for Android. It parses markdown following [commonmark-spec] with the help of amazing [commonmark-java] library and renders result as _Android-native_ Spannables. **No HTML** @@ -91,6 +93,14 @@ Please visit [documentation] web-site for reference [documentation]: https://noties.github.io/Markwon +--- + +## Applications using Markwon + +* [Partiko](https://partiko.app) +* [FairNote Notepad](https://play.google.com/store/apps/details?id=com.rgiskard.fairnote) + + --- # Demo @@ -277,12 +287,6 @@ ___ Underscores (`_`) ---- - -## Applications using Markwon - -* [FairNote Notepad](https://play.google.com/store/apps/details?id=com.rgiskard.fairnote) - ## License diff --git a/app/build.gradle b/app/build.gradle index a8154b4a..0869e5e5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -36,7 +36,6 @@ dependencies { implementation it['okhttp'] implementation it['prism4j'] implementation it['debug'] - implementation it['better-link-movement'] implementation it['dagger'] } diff --git a/app/src/main/java/ru/noties/markwon/MainActivity.java b/app/src/main/java/ru/noties/markwon/MainActivity.java index 19882a74..3bf49109 100644 --- a/app/src/main/java/ru/noties/markwon/MainActivity.java +++ b/app/src/main/java/ru/noties/markwon/MainActivity.java @@ -11,7 +11,6 @@ import android.widget.TextView; import javax.inject.Inject; -import me.saket.bettermovementmethod.BetterLinkMovementMethod; import ru.noties.debug.Debug; public class MainActivity extends Activity { @@ -71,7 +70,7 @@ public class MainActivity extends Activity { @Override public void onMarkdownReady(CharSequence markdown) { - Markwon.setText(textView, markdown, BetterLinkMovementMethod.getInstance()); + Markwon.setText(textView, markdown); gifProcessor.process(textView); diff --git a/build.gradle b/build.gradle index a99710f4..6ba06545 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,10 @@ buildscript { repositories { - jcenter() google() + jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.0.1' + classpath 'com.android.tools.build:gradle:3.2.1' classpath 'com.github.ben-manes:gradle-versions-plugin:0.20.0' } } @@ -14,8 +14,8 @@ allprojects { if (project.hasProperty('LOCAL_MAVEN_URL')) { maven { url LOCAL_MAVEN_URL } } - jcenter() google() + jcenter() } version = VERSION_NAME group = GROUP @@ -26,7 +26,7 @@ task clean(type: Delete) { } task wrapper(type: Wrapper) { - gradleVersion '4.8.1' + gradleVersion '4.10.2' distributionType 'all' } @@ -40,8 +40,9 @@ if (hasProperty('local')) { ext { + // NB, updating build-tools or compile-sdk will require updating Travis config (.travis.yml) config = [ - 'build-tools' : '27.0.3', + 'build-tools' : '28.0.3', 'compile-sdk' : 27, 'target-sdk' : 27, 'min-sdk' : 16, @@ -49,7 +50,7 @@ ext { ] final def supportVersion = '27.1.1' - final def commonMarkVersion = '0.11.0' + final def commonMarkVersion = '0.12.1' final def daggerVersion = '2.10' deps = [ @@ -63,7 +64,6 @@ ext { 'okhttp' : 'com.squareup.okhttp3:okhttp:3.9.0', 'prism4j' : 'ru.noties:prism4j:1.1.0', 'debug' : 'ru.noties:debug:3.0.0@jar', - 'better-link-movement' : 'me.saket:better-link-movement-method:2.2.0', 'dagger' : "com.google.dagger:dagger:$daggerVersion" ] diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 1e42c485..60524932 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -82,6 +82,9 @@ textView.setMovementMethod(LinkMovementMethod.getInstance()); Markwon.unscheduleDrawables(textView); Markwon.unscheduleTableRows(textView); +// @since 2.0.1 we must measure ordered list items _before_ they are rendered +OrderedListItemSpan.measure(view, text); + textView.setText(text); Markwon.scheduleDrawables(textView); diff --git a/gradle.properties b/gradle.properties index 7f10785b..7ef25526 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ org.gradle.configureondemand=true android.enableBuildCache=true android.buildCacheDir=build/pre-dex-cache -VERSION_NAME=2.0.0 +VERSION_NAME=2.0.1-SNAPSHOT GROUP=ru.noties POM_DESCRIPTION=Markwon diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a5fe1cb9..758de960 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bb5158de..d76b502e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.8.1-all.zip diff --git a/markwon-image-loader/src/main/java/ru/noties/markwon/il/DataUriSchemeHandler.java b/markwon-image-loader/src/main/java/ru/noties/markwon/il/DataUriSchemeHandler.java index 73f415af..c70ea863 100644 --- a/markwon-image-loader/src/main/java/ru/noties/markwon/il/DataUriSchemeHandler.java +++ b/markwon-image-loader/src/main/java/ru/noties/markwon/il/DataUriSchemeHandler.java @@ -3,7 +3,6 @@ package ru.noties.markwon.il; import android.net.Uri; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.text.TextUtils; import java.io.ByteArrayInputStream; import java.util.Collection; @@ -19,7 +18,7 @@ public class DataUriSchemeHandler extends SchemeHandler { return new DataUriSchemeHandler(DataUriParser.create(), DataUriDecoder.create()); } - private static final String START = "data://"; + private static final String START = "data:"; private final DataUriParser uriParser; private final DataUriDecoder uriDecoder; @@ -38,7 +37,12 @@ public class DataUriSchemeHandler extends SchemeHandler { return null; } - final String part = raw.substring(START.length()); + String part = raw.substring(START.length()); + + // this part is added to support `data://` with which this functionality was released + if (part.startsWith("//")) { + part = part.substring(2); + } final DataUri dataUri = uriParser.parse(part); if (dataUri == null) { diff --git a/markwon-image-loader/src/test/java/ru/noties/markwon/il/DataUriSchemeHandlerTest.java b/markwon-image-loader/src/test/java/ru/noties/markwon/il/DataUriSchemeHandlerTest.java index 5274c5fb..1473744a 100644 --- a/markwon-image-loader/src/test/java/ru/noties/markwon/il/DataUriSchemeHandlerTest.java +++ b/markwon-image-loader/src/test/java/ru/noties/markwon/il/DataUriSchemeHandlerTest.java @@ -71,6 +71,33 @@ public class DataUriSchemeHandlerTest { } } + @Test + public void correct_real() { + + final class Item { + + final String contentType; + final String data; + + Item(String contentType, String data) { + this.contentType = contentType; + this.data = data; + } + } + + final Map expected = new HashMap() {{ + put("data:text/plain;,123", new Item("text/plain", "123")); + put("data:image/svg+xml;base64,MTIz", new Item("image/svg+xml", "123")); + }}; + + for (Map.Entry entry : expected.entrySet()) { + final ImageItem item = handler.handle(entry.getKey(), Uri.parse(entry.getKey())); + assertNotNull(entry.getKey(), item); + assertEquals(entry.getKey(), entry.getValue().contentType, item.contentType()); + assertEquals(entry.getKey(), entry.getValue().data, readStream(item.inputStream())); + } + } + @NonNull private static String readStream(@NonNull InputStream stream) { try { diff --git a/markwon/src/main/java/ru/noties/markwon/Markwon.java b/markwon/src/main/java/ru/noties/markwon/Markwon.java index 01273b78..6ecb9ca8 100644 --- a/markwon/src/main/java/ru/noties/markwon/Markwon.java +++ b/markwon/src/main/java/ru/noties/markwon/Markwon.java @@ -15,6 +15,7 @@ import org.commonmark.parser.Parser; import java.util.Arrays; import ru.noties.markwon.renderer.SpannableRenderer; +import ru.noties.markwon.spans.OrderedListItemSpan; import ru.noties.markwon.tasklist.TaskListExtension; @SuppressWarnings({"WeakerAccess", "unused"}) @@ -100,6 +101,12 @@ public abstract class Markwon { unscheduleDrawables(view); unscheduleTableRows(view); + // @since 2.0.1 we must measure ordered-list-item-spans before applying text to a TextView. + // if markdown has a lot of ordered list items (or text size is relatively big, or block-margin + // is relatively small) then this list won't be rendered properly: it will take correct + // layout (width and margin) but will be clipped if margin is not _consistent_ between calls. + OrderedListItemSpan.measure(view, text); + // update movement method (for links to be clickable) view.setMovementMethod(movementMethod); view.setText(text); diff --git a/markwon/src/main/java/ru/noties/markwon/SpannableBuilder.java b/markwon/src/main/java/ru/noties/markwon/SpannableBuilder.java index 9e3ec713..07e2bb85 100644 --- a/markwon/src/main/java/ru/noties/markwon/SpannableBuilder.java +++ b/markwon/src/main/java/ru/noties/markwon/SpannableBuilder.java @@ -2,12 +2,16 @@ package ru.noties.markwon; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; import android.text.SpannableStringBuilder; import android.text.Spanned; import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; import java.util.Deque; import java.util.Iterator; +import java.util.List; /** * This class is used to _revert_ order of applied spans. Original SpannableStringBuilder @@ -44,7 +48,9 @@ public class SpannableBuilder implements Appendable, CharSequence { } } - private static boolean isPositionValid(int length, int start, int end) { + // @since 2.0.1 package-private visibility for testing + @VisibleForTesting + static boolean isPositionValid(int length, int start, int end) { return end > start && start >= 0 && end <= length; @@ -157,7 +163,93 @@ public class SpannableBuilder implements Appendable, CharSequence { */ @Override public CharSequence subSequence(int start, int end) { - return builder.subSequence(start, end); + + final CharSequence out; + + // @since 2.0.1 we copy spans to resulting subSequence + final List spans = getSpans(start, end); + if (spans.isEmpty()) { + out = builder.subSequence(start, end); + } else { + + // we should not be SpannableStringBuilderReversed here + final SpannableStringBuilder builder = new SpannableStringBuilder(this.builder.subSequence(start, end)); + + final int length = builder.length(); + + int s; + int e; + + for (Span span : spans) { + + // we should limit start/end to resulting subSequence length + // + // for example, originally it was 5-7 and range 5-7 requested + // span should have 0-2 + // + // if a span was fully including resulting subSequence it's start and + // end must be within 0..length bounds + s = Math.max(0, span.start - start); + e = Math.max(length, s + (span.end - span.start)); + + builder.setSpan( + span.what, + s, + e, + span.flags + ); + } + out = builder; + } + + return out; + } + + /** + * This method will return all {@link Span} spans that overlap specified range, + * so if for example a 1..9 range is specified some spans might have 0..6 or 0..10 start/end ranges. + * NB spans are returned in reversed order (no in order that we store them internally) + * + * @since 2.0.1 + */ + @NonNull + public List getSpans(int start, int end) { + + final int length = length(); + + if (!isPositionValid(length, start, end)) { + // we might as well throw here + return Collections.emptyList(); + } + + // all requested + if (start == 0 + && length == end) { + // but also copy (do not allow external modification) + final List list = new ArrayList<>(spans); + Collections.reverse(list); + return Collections.unmodifiableList(list); + } + + final List list = new ArrayList<>(0); + + final Iterator iterator = spans.descendingIterator(); + Span span; + + while (iterator.hasNext()) { + span = iterator.next(); + // we must execute 2 checks: if overlap with specified range or fully include it + // if span.start is >= range.start -> check if it's before range.end + // if span.end is <= end -> check if it's after range.start + if ( + (span.start >= start && span.start < end) + || (span.end <= end && span.end > start) + || (span.start < start && span.end > end)) { + list.add(span); + } + } + + return Collections.unmodifiableList(list); } public char lastChar() { @@ -173,7 +265,7 @@ public class SpannableBuilder implements Appendable, CharSequence { final int end = length(); // as we do not expose builder and do no apply spans to it, we are safe to NOT to convert to String - final SpannableStringBuilderImpl impl = new SpannableStringBuilderImpl(builder.subSequence(start, end)); + final SpannableStringBuilderReversed impl = new SpannableStringBuilderReversed(builder.subSequence(start, end)); final Iterator iterator = spans.iterator(); @@ -206,7 +298,7 @@ public class SpannableBuilder implements Appendable, CharSequence { /** * Simple method to create a SpannableStringBuilder, which is created anyway. Unlike {@link #text()} * method which returns the same SpannableStringBuilder there is no need to cast the resulting - * CharSequence + * CharSequence and makes the thing more explicit * * @since 2.0.0 */ @@ -222,13 +314,15 @@ public class SpannableBuilder implements Appendable, CharSequence { // as we do not expose builder and do no apply spans to it, we are safe to NOT to convert to String - final SpannableStringBuilderImpl impl = new SpannableStringBuilderImpl(builder); + final SpannableStringBuilderReversed reversed = new SpannableStringBuilderReversed(builder); + // NB, as e are using Deque -> iteration will be started with last element + // so, spans will be appearing in the for loop in reverse order for (Span span : spans) { - impl.setSpan(span.what, span.start, span.end, span.flags); + reversed.setSpan(span.what, span.start, span.end, span.flags); } - return impl; + return reversed; } private void copySpans(final int index, @Nullable CharSequence cs) { @@ -239,7 +333,7 @@ public class SpannableBuilder implements Appendable, CharSequence { if (cs instanceof Spanned) { final Spanned spanned = (Spanned) cs; - final boolean reverse = spanned instanceof SpannedReversed; + final boolean reversed = spanned instanceof SpannableStringBuilderReversed; final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class); final int length = spans != null @@ -247,7 +341,7 @@ public class SpannableBuilder implements Appendable, CharSequence { : 0; if (length > 0) { - if (reverse) { + if (reversed) { Object o; for (int i = length - 1; i >= 0; i--) { o = spans[i]; @@ -274,12 +368,15 @@ public class SpannableBuilder implements Appendable, CharSequence { } } - static class Span { + /** + * @since 2.0.1 made public in order to be returned from `getSpans` method, initially added in 1.0.1 + */ + public static class Span { - final Object what; - int start; - int end; - final int flags; + public final Object what; + public int start; + public int end; + public final int flags; Span(@NonNull Object what, int start, int end, int flags) { this.what = what; @@ -288,4 +385,13 @@ public class SpannableBuilder implements Appendable, CharSequence { this.flags = flags; } } + + /** + * @since 2.0.1 made inner class of {@link SpannableBuilder}, initially added in 1.0.1 + */ + static class SpannableStringBuilderReversed extends SpannableStringBuilder { + SpannableStringBuilderReversed(CharSequence text) { + super(text); + } + } } diff --git a/markwon/src/main/java/ru/noties/markwon/SpannableConfiguration.java b/markwon/src/main/java/ru/noties/markwon/SpannableConfiguration.java index cb2a34bc..5df9d316 100644 --- a/markwon/src/main/java/ru/noties/markwon/SpannableConfiguration.java +++ b/markwon/src/main/java/ru/noties/markwon/SpannableConfiguration.java @@ -51,6 +51,14 @@ public class SpannableConfiguration { this.htmlAllowNonClosedTags = builder.htmlAllowNonClosedTags; } + /** + * Returns a new builder based on this configuration + */ + @NonNull + public Builder newBuilder(@NonNull Context context) { + return new Builder(context, this); + } + @NonNull public SpannableTheme theme() { return theme; @@ -138,6 +146,21 @@ public class SpannableConfiguration { this.context = context; } + Builder(@NonNull Context context, @NonNull SpannableConfiguration configuration) { + this(context); + this.theme = configuration.theme; + this.asyncDrawableLoader = configuration.asyncDrawableLoader; + this.syntaxHighlight = configuration.syntaxHighlight; + this.linkResolver = configuration.linkResolver; + this.urlProcessor = configuration.urlProcessor; + this.imageSizeResolver = configuration.imageSizeResolver; + this.factory = configuration.factory; + this.softBreakAddsNewLine = configuration.softBreakAddsNewLine; + this.htmlParser = configuration.htmlParser; + this.htmlRenderer = configuration.htmlRenderer; + this.htmlAllowNonClosedTags = configuration.htmlAllowNonClosedTags; + } + @NonNull public Builder theme(@NonNull SpannableTheme theme) { this.theme = theme; diff --git a/markwon/src/main/java/ru/noties/markwon/SpannableStringBuilderImpl.java b/markwon/src/main/java/ru/noties/markwon/SpannableStringBuilderImpl.java deleted file mode 100644 index 7b29440b..00000000 --- a/markwon/src/main/java/ru/noties/markwon/SpannableStringBuilderImpl.java +++ /dev/null @@ -1,13 +0,0 @@ -package ru.noties.markwon; - -import android.text.SpannableStringBuilder; - -/** - * @since 1.0.1 - */ -class SpannableStringBuilderImpl extends SpannableStringBuilder implements SpannedReversed { - - SpannableStringBuilderImpl(CharSequence text) { - super(text); - } -} diff --git a/markwon/src/main/java/ru/noties/markwon/SpannedReversed.java b/markwon/src/main/java/ru/noties/markwon/SpannedReversed.java deleted file mode 100644 index 3fd7f566..00000000 --- a/markwon/src/main/java/ru/noties/markwon/SpannedReversed.java +++ /dev/null @@ -1,9 +0,0 @@ -package ru.noties.markwon; - -import android.text.Spanned; - -/** - * @since 1.0.1 - */ -interface SpannedReversed extends Spanned { -} diff --git a/markwon/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java b/markwon/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java index 967784e5..81e2d36e 100644 --- a/markwon/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java +++ b/markwon/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java @@ -56,7 +56,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { private final SpannableTheme theme; private final SpannableFactory factory; - private int blockQuoteIndent; + private int blockIndent; private int listLevel; private List pendingTableRow; @@ -105,25 +105,20 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { public void visit(BlockQuote blockQuote) { newLine(); - if (blockQuoteIndent != 0) { - builder.append('\n'); - } final int length = builder.length(); - blockQuoteIndent += 1; + blockIndent += 1; visitChildren(blockQuote); setSpan(length, factory.blockQuote(theme)); - blockQuoteIndent -= 1; + blockIndent -= 1; if (hasNext(blockQuote)) { newLine(); - if (blockQuoteIndent == 0) { - builder.append('\n'); - } + forceNewLine(); } } @@ -180,7 +175,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { if (hasNext(node)) { newLine(); - builder.append('\n'); + forceNewLine(); } } @@ -202,9 +197,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { if (hasNext(node)) { newLine(); - if (listLevel == 0 && blockQuoteIndent == 0) { - builder.append('\n'); - } + forceNewLine(); } } @@ -213,7 +206,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { final int length = builder.length(); - blockQuoteIndent += 1; + blockIndent += 1; listLevel += 1; final Node parent = listItem.getParent(); @@ -236,7 +229,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { setSpan(length, factory.bulletListItem(theme, listLevel - 1)); } - blockQuoteIndent -= 1; + blockIndent -= 1; listLevel -= 1; if (hasNext(listItem)) { @@ -256,7 +249,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { if (hasNext(thematicBreak)) { newLine(); - builder.append('\n'); + forceNewLine(); } } @@ -272,7 +265,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { if (hasNext(heading)) { newLine(); // after heading we add another line anyway (no additional checks) - builder.append('\n'); + forceNewLine(); } } @@ -298,13 +291,14 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { public void visit(CustomBlock customBlock) { if (customBlock instanceof TaskListBlock) { - blockQuoteIndent += 1; + + blockIndent += 1; visitChildren(customBlock); - blockQuoteIndent -= 1; + blockIndent -= 1; if (hasNext(customBlock)) { newLine(); - builder.append('\n'); + forceNewLine(); } } else { @@ -329,17 +323,17 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { final int length = builder.length(); - blockQuoteIndent += listItem.indent(); + blockIndent += listItem.indent(); visitChildren(customNode); - setSpan(length, factory.taskListItem(theme, blockQuoteIndent, listItem.done())); + setSpan(length, factory.taskListItem(theme, blockIndent, listItem.done())); if (hasNext(customNode)) { newLine(); } - blockQuoteIndent -= listItem.indent(); + blockIndent -= listItem.indent(); } else if (!handleTableNodes(customNode)) { super.visit(customNode); @@ -358,7 +352,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { if (hasNext(node)) { newLine(); - builder.append('\n'); + forceNewLine(); } } else if (node instanceof TableRow || node instanceof TableHead) { @@ -445,9 +439,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { if (hasNext(paragraph) && !inTightList) { newLine(); - if (blockQuoteIndent == 0) { - builder.append('\n'); - } + forceNewLine(); } } @@ -518,6 +510,10 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { } } + private void forceNewLine() { + builder.append('\n'); + } + private boolean isInTightList(Paragraph paragraph) { final Node parent = paragraph.getParent(); if (parent != null) { diff --git a/markwon/src/main/java/ru/noties/markwon/spans/AsyncDrawable.java b/markwon/src/main/java/ru/noties/markwon/spans/AsyncDrawable.java index 588b8a04..dbd0fe0b 100644 --- a/markwon/src/main/java/ru/noties/markwon/spans/AsyncDrawable.java +++ b/markwon/src/main/java/ru/noties/markwon/spans/AsyncDrawable.java @@ -33,6 +33,9 @@ public class AsyncDrawable extends Drawable { private int canvasWidth; private float textSize; + // @since 2.0.1 for use-cases when image is loaded faster than span is drawn and knows canvas width + private boolean waitingForDimensions; + /** * @since 1.0.1 */ @@ -98,6 +101,19 @@ public class AsyncDrawable extends Drawable { this.result = result; this.result.setCallback(callback); + initBounds(); + } + + private void initBounds() { + + if (canvasWidth == 0) { + // we still have no bounds - wait for them + waitingForDimensions = true; + return; + } + + waitingForDimensions = false; + final Rect bounds = resolveBounds(); result.setBounds(bounds); setBounds(bounds); @@ -112,6 +128,10 @@ public class AsyncDrawable extends Drawable { public void initWithKnownDimensions(int width, float textSize) { this.canvasWidth = width; this.textSize = textSize; + + if (waitingForDimensions) { + initBounds(); + } } @Override diff --git a/markwon/src/main/java/ru/noties/markwon/spans/OrderedListItemSpan.java b/markwon/src/main/java/ru/noties/markwon/spans/OrderedListItemSpan.java index 29f68e9e..1db29e1a 100644 --- a/markwon/src/main/java/ru/noties/markwon/spans/OrderedListItemSpan.java +++ b/markwon/src/main/java/ru/noties/markwon/spans/OrderedListItemSpan.java @@ -4,10 +4,44 @@ import android.graphics.Canvas; import android.graphics.Paint; import android.support.annotation.NonNull; import android.text.Layout; +import android.text.Spanned; +import android.text.TextPaint; import android.text.style.LeadingMarginSpan; +import android.widget.TextView; public class OrderedListItemSpan implements LeadingMarginSpan { + /** + * Process supplied `text` argument and supply TextView paint to all OrderedListItemSpans + * in order for them to measure number. + *

+ * NB, this method must be called before setting text to a TextView (`TextView#setText` + * internally can trigger new Layout creation which will ask for leading margins right away) + * + * @param textView to which markdown will be applied + * @param text parsed markdown to process + * @since 2.0.1 + */ + public static void measure(@NonNull TextView textView, @NonNull CharSequence text) { + + if (!(text instanceof Spanned)) { + // nothing to do here + return; + } + + final OrderedListItemSpan[] spans = ((Spanned) text).getSpans( + 0, + text.length(), + OrderedListItemSpan.class); + + if (spans != null) { + final TextPaint paint = textView.getPaint(); + for (OrderedListItemSpan span : spans) { + span.margin = (int) (paint.measureText(span.number) + .5F); + } + } + } + private final SpannableTheme theme; private final String number; private final Paint paint = ObjectsPool.paint(); @@ -27,8 +61,8 @@ public class OrderedListItemSpan implements LeadingMarginSpan { @Override public int getLeadingMargin(boolean first) { - // @since 1.0.3 - return margin > 0 ? margin : theme.getBlockMargin(); + // @since 2.0.1 we return maximum value of both (now we should measure number before) + return Math.max(margin, theme.getBlockMargin()); } @Override @@ -44,11 +78,16 @@ public class OrderedListItemSpan implements LeadingMarginSpan { theme.applyListItemStyle(paint); - final int numberWidth = (int) (p.measureText(number) + .5F); + // if we could force usage of #measure method then we might want skip this measuring here + // but this won't hold against new values that a TextView can receive (new text size for + // example...) + final int numberWidth = (int) (paint.measureText(number) + .5F); // @since 1.0.3 int width = theme.getBlockMargin(); if (numberWidth > width) { + // let's keep this logic here in case a user decided not to call #measure and is fine + // with current implementation width = numberWidth; margin = numberWidth; } else { diff --git a/markwon/src/main/java/ru/noties/markwon/spans/SpannableTheme.java b/markwon/src/main/java/ru/noties/markwon/spans/SpannableTheme.java index e82d0d8b..a3ba8c55 100644 --- a/markwon/src/main/java/ru/noties/markwon/spans/SpannableTheme.java +++ b/markwon/src/main/java/ru/noties/markwon/spans/SpannableTheme.java @@ -12,6 +12,7 @@ import android.support.annotation.FloatRange; import android.support.annotation.IntRange; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.annotation.Px; import android.support.annotation.Size; import android.text.TextPaint; import android.util.TypedValue; @@ -600,13 +601,13 @@ public class SpannableTheme { } @NonNull - public Builder blockMargin(@Dimension int blockMargin) { + public Builder blockMargin(@Px int blockMargin) { this.blockMargin = blockMargin; return this; } @NonNull - public Builder blockQuoteWidth(@Dimension int blockQuoteWidth) { + public Builder blockQuoteWidth(@Px int blockQuoteWidth) { this.blockQuoteWidth = blockQuoteWidth; return this; } @@ -625,13 +626,13 @@ public class SpannableTheme { } @NonNull - public Builder bulletListItemStrokeWidth(@Dimension int bulletListItemStrokeWidth) { + public Builder bulletListItemStrokeWidth(@Px int bulletListItemStrokeWidth) { this.bulletListItemStrokeWidth = bulletListItemStrokeWidth; return this; } @NonNull - public Builder bulletWidth(@Dimension int bulletWidth) { + public Builder bulletWidth(@Px int bulletWidth) { this.bulletWidth = bulletWidth; return this; } @@ -668,7 +669,7 @@ public class SpannableTheme { } @NonNull - public Builder codeMultilineMargin(@Dimension int codeMultilineMargin) { + public Builder codeMultilineMargin(@Px int codeMultilineMargin) { this.codeMultilineMargin = codeMultilineMargin; return this; } @@ -680,13 +681,13 @@ public class SpannableTheme { } @NonNull - public Builder codeTextSize(@Dimension int codeTextSize) { + public Builder codeTextSize(@Px int codeTextSize) { this.codeTextSize = codeTextSize; return this; } @NonNull - public Builder headingBreakHeight(@Dimension int headingBreakHeight) { + public Builder headingBreakHeight(@Px int headingBreakHeight) { this.headingBreakHeight = headingBreakHeight; return this; } @@ -733,13 +734,13 @@ public class SpannableTheme { } @NonNull - public Builder thematicBreakHeight(@Dimension int thematicBreakHeight) { + public Builder thematicBreakHeight(@Px int thematicBreakHeight) { this.thematicBreakHeight = thematicBreakHeight; return this; } @NonNull - public Builder tableCellPadding(@Dimension int tableCellPadding) { + public Builder tableCellPadding(@Px int tableCellPadding) { this.tableCellPadding = tableCellPadding; return this; } @@ -751,7 +752,7 @@ public class SpannableTheme { } @NonNull - public Builder tableBorderWidth(@Dimension int tableBorderWidth) { + public Builder tableBorderWidth(@Px int tableBorderWidth) { this.tableBorderWidth = tableBorderWidth; return this; } @@ -775,7 +776,7 @@ public class SpannableTheme { * @since 1.1.1 */ @NonNull - public Builder tableHeaderRowBackgroundColor(int tableHeaderRowBackgroundColor) { + public Builder tableHeaderRowBackgroundColor(@ColorInt int tableHeaderRowBackgroundColor) { this.tableHeaderRowBackgroundColor = tableHeaderRowBackgroundColor; return this; } diff --git a/markwon/src/main/java/ru/noties/markwon/spans/TaskListSpan.java b/markwon/src/main/java/ru/noties/markwon/spans/TaskListSpan.java index 172b952c..25bc6a41 100644 --- a/markwon/src/main/java/ru/noties/markwon/spans/TaskListSpan.java +++ b/markwon/src/main/java/ru/noties/markwon/spans/TaskListSpan.java @@ -18,7 +18,9 @@ public class TaskListSpan implements LeadingMarginSpan { private final SpannableTheme theme; private final int blockIndent; - private final boolean isDone; + + // @since 2.0.1 field is NOT final (to allow mutation) + private boolean isDone; public TaskListSpan(@NonNull SpannableTheme theme, int blockIndent, boolean isDone) { this.theme = theme; @@ -26,6 +28,23 @@ public class TaskListSpan implements LeadingMarginSpan { this.isDone = isDone; } + /** + * @since 2.0.1 + */ + public boolean isDone() { + return isDone; + } + + /** + * Update {@link #isDone} property of this span. Please note that this is merely a visual change + * which is not changing underlying text in any means. + * + * @since 2.0.1 + */ + public void setDone(boolean isDone) { + this.isDone = isDone; + } + @Override public int getLeadingMargin(boolean first) { return theme.getBlockMargin() * blockIndent; diff --git a/markwon/src/test/java/ru/noties/markwon/SpannableBuilderTest.java b/markwon/src/test/java/ru/noties/markwon/SpannableBuilderTest.java new file mode 100644 index 00000000..a1daf218 --- /dev/null +++ b/markwon/src/test/java/ru/noties/markwon/SpannableBuilderTest.java @@ -0,0 +1,359 @@ +package ru.noties.markwon; + +import android.support.annotation.NonNull; +import android.text.SpannableStringBuilder; +import android.text.Spanned; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Arrays; +import java.util.List; + +import ix.Ix; +import ix.IxFunction; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static ru.noties.markwon.SpannableBuilder.isPositionValid; +import static ru.noties.markwon.SpannableBuilder.setSpans; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class SpannableBuilderTest { + + private SpannableBuilder builder; + + @Before + public void before() { + builder = new SpannableBuilder(); + } + + @Test + public void position_invalid() { + + final Position[] positions = { + Position.of(0, 0, 0), + Position.of(-1, -1, -1), + Position.of(0, -1, 1), + Position.of(1, 1, 1), + Position.of(0, 0, 10), + Position.of(10, 10, 0), + Position.of(10, 5, 2), + Position.of(5, 1, 1) + }; + + for (Position position : positions) { + assertFalse(position.toString(), isPositionValid(position.length, position.start, position.end)); + } + } + + @Test + public void position_valid() { + + final Position[] positions = { + Position.of(1, 0, 1), + Position.of(2, 0, 1), + Position.of(2, 1, 2), + Position.of(10, 0, 10), + Position.of(7, 6, 7) + }; + + for (Position position : positions) { + assertTrue(position.toString(), isPositionValid(position.length, position.start, position.end)); + } + } + + @Test + public void get_spans() { + + // all spans that overlap with specified range or spans that include it fully -> should be returned + + final int length = 10; + + for (int i = 0; i < length; i++) { + builder.append(String.valueOf(i)); + } + + for (int start = 0, end = length - 1; start < end; start++, end--) { + builder.setSpan("" + start + "-" + end, start, end); + } + + // all (simple check that spans that take range greater that supplied range are also returned) + final List all = Arrays.asList("0-9", "1-8", "2-7", "3-6", "4-5"); + for (int start = 0, end = length - 1; start < end; start++, end--) { + assertEquals( + "" + start + "-" + end, + all, + getSpans(start, end) + ); + } + + assertEquals( + "1-3", + Arrays.asList("0-9", "1-8", "2-7"), + getSpans(1, 3) + ); + + assertEquals( + "1-10", + all, + getSpans(1, 10) + ); + + assertEquals( + "5-10", + Arrays.asList("0-9", "1-8", "2-7", "3-6"), + getSpans(5, 10) + ); + + assertEquals( + "7-10", + Arrays.asList("0-9", "1-8"), + getSpans(7, 10) + ); + } + + @Test + public void get_spans_out_of_range() { + + // let's test that if span.start >= range.start -> it will be less than range.end + // if span.end <= end -> it will be greater than range.start + + for (int i = 0; i < 10; i++) { + builder.append(String.valueOf(i)); + builder.setSpan("" + i + "-" + (i + 1), i, i + 1); + } + + assertEquals(10, getSpans(0, 10).size()); + + // so + // 0-1 + // 1-2 + // 2-3 + // etc + + //noinspection ArraysAsListWithZeroOrOneArgument + assertEquals( + "0-1", + Arrays.asList("0-1"), + getSpans(0, 1) + ); + + assertEquals( + "1-5", + Arrays.asList("1-2", "2-3", "3-4", "4-5"), + getSpans(1, 5) + ); + } + + @NonNull + private List getSpans(int start, int end) { + return Ix.from(builder.getSpans(start, end)) + .map(new IxFunction() { + @Override + public String apply(SpannableBuilder.Span span) { + return (String) span.what; + } + }) + .toList(); + } + + @Test + public void set_spans_position_invalid() { + // if supplied position is invalid, no spans should be added + + builder.append('0'); + + assertTrue(builder.getSpans(0, builder.length()).isEmpty()); + + setSpans(builder, new Object(), -1, -1); + + assertTrue(builder.getSpans(0, builder.length()).isEmpty()); + } + + @Test + public void set_spans_single() { + // single span as `spans` argument correctly added + + builder.append('0'); + + assertTrue(builder.getSpans(0, builder.length()).isEmpty()); + + final Object span = new Object(); + setSpans(builder, span, 0, 1); + + final List spans = builder.getSpans(0, builder.length()); + assertEquals(1, spans.size()); + assertEquals(span, spans.get(0).what); + } + + @Test + public void set_spans_array_detected() { + // if supplied `spans` argument is an array -> it should be expanded + + builder.append('0'); + + assertTrue(builder.getSpans(0, builder.length()).isEmpty()); + + final Object[] spans = { + new Object(), + new Object(), + new Object() + }; + + setSpans(builder, spans, 0, 1); + + final List actual = builder.getSpans(0, builder.length()); + assertEquals(spans.length, actual.size()); + + for (int i = 0, length = spans.length; i < length; i++) { + assertEquals(spans[i], actual.get(i).what); + } + } + + @Test + public void set_spans_array_of_arrays() { + // if array of arrays is supplied -> it won't be expanded to single elements + + builder.append('0'); + + assertTrue(builder.getSpans(0, builder.length()).isEmpty()); + + final Object[] spans = { + new Object[]{ + new Object(), new Object() + }, + new Object[]{ + new Object(), new Object(), new Object() + } + }; + + setSpans(builder, spans, 0, 1); + + final List actual = builder.getSpans(0, builder.length()); + assertEquals(2, actual.size()); + + for (int i = 0, length = spans.length; i < length; i++) { + assertEquals(spans[i], actual.get(i).what); + } + } + + @Test + public void set_spans_null() { + // if `spans` argument is null, then nothing will be added + + builder.append('0'); + + assertTrue(builder.getSpans(0, builder.length()).isEmpty()); + + setSpans(builder, null, 0, builder.length()); + + assertTrue(builder.getSpans(0, builder.length()).isEmpty()); + } + + @Test + public void spans_reversed() { + // resulting SpannableStringBuilder should have spans reversed + + final Object[] spans = { + 0, + 1, + 2 + }; + + for (Object span : spans) { + builder.append(span.toString(), span); + } + + final SpannableStringBuilder spannableStringBuilder = builder.spannableStringBuilder(); + final Object[] actual = spannableStringBuilder.getSpans(0, builder.length(), Object.class); + + for (int start = 0, length = spans.length, end = length - 1; start < length; start++, end--) { + assertEquals(spans[start], actual[end]); + } + } + + @Test + public void append_spanned_normal() { + // #append is called with regular Spanned content -> spans should be added in reverse + + final SpannableStringBuilder ssb = new SpannableStringBuilder(); + for (int i = 0; i < 3; i++) { + ssb.append(String.valueOf(i)); + ssb.setSpan(i, i, i + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + assertTrue(builder.getSpans(0, builder.length()).isEmpty()); + + builder.append(ssb); + + assertEquals("012", builder.toString()); + + // this one would return normal order as spans are reversed here +// final List spans = builder.getSpans(0, builder.length()); + + final SpannableStringBuilder spannableStringBuilder = builder.spannableStringBuilder(); + final Object[] spans = spannableStringBuilder.getSpans(0, builder.length(), Object.class); + assertEquals(3, spans.length); + + for (int i = 0, length = spans.length; i < length; i++) { + assertEquals(length - 1 - i, spans[i]); + } + } + + @Test + public void append_spanned_reversed() { + // #append is called with reversed spanned content -> spans should be added as-are + + final SpannableBuilder spannableBuilder = new SpannableBuilder(); + for (int i = 0; i < 3; i++) { + spannableBuilder.append(String.valueOf(i), i); + } + + assertTrue(builder.getSpans(0, builder.length()).isEmpty()); + + builder.append(spannableBuilder.spannableStringBuilder()); + + final SpannableStringBuilder spannableStringBuilder = builder.spannableStringBuilder(); + final Object[] spans = spannableStringBuilder.getSpans(0, spannableStringBuilder.length(), Object.class); + assertEquals(3, spans.length); + + for (int i = 0, length = spans.length; i < length; i++) { + // in the end order should be as we expect in order to properly render it + // (no matter if reversed is used or not) + assertEquals(length - 1 - i, spans[i]); + } + } + + private static class Position { + + @NonNull + static Position of(int length, int start, int end) { + return new Position(length, start, end); + } + + final int length; + final int start; + final int end; + + private Position(int length, int start, int end) { + this.length = length; + this.start = start; + this.end = end; + } + + @Override + public String toString() { + return "Position{" + + "length=" + length + + ", start=" + start + + ", end=" + end + + '}'; + } + } +} \ No newline at end of file diff --git a/markwon/src/test/java/ru/noties/markwon/renderer/SpannableConfigurationTest.java b/markwon/src/test/java/ru/noties/markwon/renderer/SpannableConfigurationTest.java new file mode 100644 index 00000000..daa70332 --- /dev/null +++ b/markwon/src/test/java/ru/noties/markwon/renderer/SpannableConfigurationTest.java @@ -0,0 +1,52 @@ +package ru.noties.markwon.renderer; + +import org.junit.Test; + +import ru.noties.markwon.SpannableConfiguration; +import ru.noties.markwon.SpannableFactory; +import ru.noties.markwon.SyntaxHighlight; +import ru.noties.markwon.UrlProcessor; +import ru.noties.markwon.html.api.MarkwonHtmlParser; +import ru.noties.markwon.renderer.html2.MarkwonHtmlRenderer; +import ru.noties.markwon.spans.AsyncDrawable; +import ru.noties.markwon.spans.LinkSpan; +import ru.noties.markwon.spans.SpannableTheme; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +public class SpannableConfigurationTest { + + @Test + public void testNewBuilder() { + final SpannableConfiguration configuration = SpannableConfiguration + .builder(null) + .theme(mock(SpannableTheme.class)) + .asyncDrawableLoader(mock(AsyncDrawable.Loader.class)) + .syntaxHighlight(mock(SyntaxHighlight.class)) + .linkResolver(mock(LinkSpan.Resolver.class)) + .urlProcessor(mock(UrlProcessor.class)) + .imageSizeResolver(mock(ImageSizeResolver.class)) + .factory(mock(SpannableFactory.class)) + .softBreakAddsNewLine(true) + .htmlParser(mock(MarkwonHtmlParser.class)) + .htmlRenderer(mock(MarkwonHtmlRenderer.class)) + .htmlAllowNonClosedTags(true) + .build(); + + final SpannableConfiguration newConfiguration = configuration + .newBuilder(null) + .build(); + + assertEquals(configuration.theme(), newConfiguration.theme()); + assertEquals(configuration.asyncDrawableLoader(), newConfiguration.asyncDrawableLoader()); + assertEquals(configuration.syntaxHighlight(), newConfiguration.syntaxHighlight()); + assertEquals(configuration.linkResolver(), newConfiguration.linkResolver()); + assertEquals(configuration.urlProcessor(), newConfiguration.urlProcessor()); + assertEquals(configuration.imageSizeResolver(), newConfiguration.imageSizeResolver()); + assertEquals(configuration.factory(), newConfiguration.factory()); + assertEquals(configuration.softBreakAddsNewLine(), newConfiguration.softBreakAddsNewLine()); + assertEquals(configuration.htmlParser(), newConfiguration.htmlParser()); + assertEquals(configuration.htmlRenderer(), newConfiguration.htmlRenderer()); + assertEquals(configuration.htmlAllowNonClosedTags(), newConfiguration.htmlAllowNonClosedTags()); + } +} diff --git a/markwon/src/test/java/ru/noties/markwon/renderer/SyntaxHighlightTest.java b/markwon/src/test/java/ru/noties/markwon/renderer/SyntaxHighlightTest.java new file mode 100644 index 00000000..1f5d88d4 --- /dev/null +++ b/markwon/src/test/java/ru/noties/markwon/renderer/SyntaxHighlightTest.java @@ -0,0 +1,111 @@ +package ru.noties.markwon.renderer; + +import android.content.Context; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; + +import org.commonmark.node.FencedCodeBlock; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import ru.noties.markwon.SpannableBuilder; +import ru.noties.markwon.SpannableConfiguration; +import ru.noties.markwon.SpannableFactory; +import ru.noties.markwon.SyntaxHighlight; +import ru.noties.markwon.spans.SpannableTheme; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE, sdk = { + Build.VERSION_CODES.JELLY_BEAN, + Build.VERSION_CODES.M, + Build.VERSION_CODES.O +}) +public class SyntaxHighlightTest { + + // codeSpan must be before actual highlight spans (true reverse of builder) + + // if we go with path of reversing spans inside SpannableBuilder (which + // might extend SpannableStringBuilder like https://github.com/noties/Markwon/pull/71) + // then on M (23) codeSpan will always be _before_ actual highlight and thus + // no highlight will be present + // note that bad behaviour is present on M (emulator/device/robolectric) + // other SDKs are added to validate that they do not fail + @Test + public void test() { + + class Highlight { + } + + final Object codeSpan = new Object(); + + final SyntaxHighlight highlight = new SyntaxHighlight() { + @NonNull + @Override + public CharSequence highlight(@Nullable String info, @NonNull String code) { + final SpannableStringBuilder builder = new SpannableStringBuilder(code); + for (int i = 0, length = code.length(); i < length; i++) { + builder.setSpan(new Highlight(), i, i + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + return builder; + } + }; + + final SpannableFactory factory = mock(SpannableFactory.class); + when(factory.code(any(SpannableTheme.class), anyBoolean())).thenReturn(codeSpan); + + final SpannableConfiguration configuration = SpannableConfiguration.builder(mock(Context.class)) + .syntaxHighlight(highlight) + .factory(factory) + .theme(mock(SpannableTheme.class)) + .build(); + + final SpannableBuilder builder = new SpannableBuilder(); + + append(builder, "# Header 1\n", new Object()); + append(builder, "## Header 2\n", new Object()); + append(builder, "### Header 3\n", new Object()); + + final int start = builder.length(); + + final SpannableMarkdownVisitor visitor = new SpannableMarkdownVisitor(configuration, builder); + final FencedCodeBlock fencedCodeBlock = new FencedCodeBlock(); + fencedCodeBlock.setLiteral("{code}"); + + visitor.visit(fencedCodeBlock); + + final int end = builder.length(); + + append(builder, "### Footer 3\n", new Object()); + append(builder, "## Footer 2\n", new Object()); + append(builder, "# Footer 1\n", new Object()); + + final Object[] spans = builder.spannableStringBuilder().getSpans(start, end, Object.class); + + // each character + code span + final int length = fencedCodeBlock.getLiteral().length() + 1; + assertEquals(length, spans.length); + assertEquals(codeSpan, spans[0]); + + for (int i = 1; i < length; i++) { + assertTrue(spans[i] instanceof Highlight); + } + } + + private static void append(@NonNull SpannableBuilder builder, @NonNull String text, @NonNull Object span) { + final int start = builder.length(); + builder.append(text); + builder.setSpan(span, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } +} \ No newline at end of file diff --git a/markwon/src/test/java/ru/noties/markwon/spans/AsyncDrawableTest.java b/markwon/src/test/java/ru/noties/markwon/spans/AsyncDrawableTest.java new file mode 100644 index 00000000..873cc404 --- /dev/null +++ b/markwon/src/test/java/ru/noties/markwon/spans/AsyncDrawableTest.java @@ -0,0 +1,107 @@ +package ru.noties.markwon.spans; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import ru.noties.markwon.renderer.ImageSize; +import ru.noties.markwon.renderer.ImageSizeResolver; +import ru.noties.markwon.renderer.ImageSizeResolverDef; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class AsyncDrawableTest { + + private ImageSizeResolver imageSizeResolver; + + @Before + public void before() { + imageSizeResolver = new ImageSizeResolverDef(); + } + + @Test + public void no_dimensions_await() { + // when drawable have no known dimensions yet, it will await for them + + final AsyncDrawable drawable = new AsyncDrawable("", + mock(AsyncDrawable.Loader.class), + imageSizeResolver, + new ImageSize(new ImageSize.Dimension(100.F, "%"), null)); + + final Drawable result = new AbstractDrawable(); + result.setBounds(0, 0, 0, 0); + + assertFalse(drawable.hasResult()); + drawable.setResult(result); + assertTrue(drawable.hasResult()); + + assertTrue(result.getBounds().isEmpty()); + + drawable.initWithKnownDimensions(100, 1); + assertEquals( + new Rect(0, 0, 100, 0), + result.getBounds() + ); + } + + @Test + public void previous_result_detached() { + // when result is present it will be detached (setCallback(null)) + + final AsyncDrawable drawable = new AsyncDrawable("", + mock(AsyncDrawable.Loader.class), + imageSizeResolver, + null); + + drawable.setCallback2(mock(Drawable.Callback.class)); + drawable.initWithKnownDimensions(100, 1); + + final Drawable result1 = new AbstractDrawable(); + final Drawable result2 = new AbstractDrawable(); + + drawable.setResult(result1); + assertNotNull(result1.getCallback()); + drawable.setResult(result2); + assertNull(result1.getCallback()); + assertNotNull(result2.getCallback()); + } + + private static class AbstractDrawable extends Drawable { + + @Override + public void draw(@NonNull Canvas canvas) { + + } + + @Override + public void setAlpha(int alpha) { + + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter) { + + } + + @Override + public int getOpacity() { + return 0; + } + } +} \ No newline at end of file